diff --git a/README.md b/README.md index 3e00ac6..4392297 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,23 @@ $ pr label [owner]/[repo] -l 'state == `"open"`' --action "replace" --label "foo `--action "remove"` removes the label specified for the PR that matched the rule. `--action "replace"` replaces all labels on PR that match the rule with the specified label. +### Assignee + +Append/Remove/Replace assignees to PRs that match the rule. + +```bash +$ pr assignees [owner]/[repo] -l 'state == `"open"`' --action "append" --assignee "foo" +... +$ pr assignees [owner]/[repo] -l 'state == `"open"`' --action "remove" --assignee "foo" +... +$ pr assignees [owner]/[repo] -l 'state == `"open"`' --action "replace" --assignee "foo" +... +``` + +`--action "append"` appends the specified label to the PR that matches the rule. +`--action "remove"` removes the label specified for the PR that matched the rule. +`--action "replace"` replaces all labels on PR that match the rule with the specified label. + ### Check When the PR CLI is run on the CI, the rule status is displayed separately from the CI. diff --git a/cmd/assignee.go b/cmd/assignee.go new file mode 100644 index 0000000..d051661 --- /dev/null +++ b/cmd/assignee.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/k-kinzal/pr/pkg/pr" + "github.com/spf13/cobra" + "golang.org/x/xerrors" +) + +func AssigneeRun(cmd *cobra.Command, args []string) error { + assigneeOption.Action = pr.AssigneeAction(assigneeAction) + + pulls, err := pr.Assignee(owner, repo, assigneeOption) + if err != nil { + if _, ok := err.(*pr.NoMatchError); ok { + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stdout, "[]") + if exitCode { + os.Exit(127) + } + return nil + } + return err + } + + out, err := json.Marshal(pulls) + if err != nil { + return xerrors.Errorf("merge: %s", err) + } + fmt.Fprintln(os.Stdout, string(out)) + + return nil +} + +var ( + assigneeOption *pr.AssigneeOption + assigneeCmd = &cobra.Command{ + Use: "assignee owner/repo", + Short: "Manipulate assignees that match a rule", + RunE: AssigneeRun, + SilenceErrors: true, + SilenceUsage: true, + } + assigneeAction string +) + +func setAssigneeFrags(cmd *cobra.Command) *pr.AssigneeOption { + opt := &pr.AssigneeOption{} + cmd.Flags().StringArrayVarP(&opt.Assignees, "assignee", "", nil, "Manipulate assignee") + cmd.Flags().StringVar(&assigneeAction, "action", "append", "Manipulation of `append`, `remove`, `replace` for assignees") + return opt +} + +func init() { + assigneeOption = setAssigneeFrags(assigneeCmd) + assigneeOption.ListOption = setListFrags(assigneeCmd) + rootCmd.AddCommand(assigneeCmd) +} diff --git a/pkg/api/assignee.go b/pkg/api/assignee.go new file mode 100644 index 0000000..d860aa9 --- /dev/null +++ b/pkg/api/assignee.go @@ -0,0 +1,108 @@ +package api + +import ( + "context" + "fmt" + + "golang.org/x/sync/errgroup" +) + +type AssigneeOption struct { + Assignees []string +} + +func (c *Client) AddAssignees(ctx context.Context, pulls []*PullRequest, opt *AssigneeOption) ([]*PullRequest, error) { + eg, ctx := errgroup.WithContext(ctx) + + for _, pull := range pulls { + eg.Go(func(pull *PullRequest) func() error { + return func() error { + issue, _, err := c.github.Issues.AddAssignees(ctx, pull.Owner, pull.Repo, int(pull.Number), opt.Assignees) + if err != nil { + return err + } + + users := make([]*User, len(issue.Assignees)) + for i, assignee := range issue.Assignees { + users[i] = newUser(assignee) + } + pull.Assignees = users + pull.UpdatedAt = newTimestamp(issue.UpdatedAt) + + return nil + } + }(pull)) + } + if err := eg.Wait(); err != nil { + return nil, err + } + + return pulls, nil +} + +func (c *Client) RemoveAssignees(ctx context.Context, pulls []*PullRequest, opt *AssigneeOption) ([]*PullRequest, error) { + eg, ctx := errgroup.WithContext(ctx) + + for _, pull := range pulls { + eg.Go(func(pull *PullRequest) func() error { + return func() error { + issue, _, err := c.github.Issues.RemoveAssignees(ctx, pull.Owner, pull.Repo, int(pull.Number), opt.Assignees) + if err != nil { + return err + } + + users := make([]*User, len(issue.Assignees)) + for i, assignee := range issue.Assignees { + users[i] = newUser(assignee) + } + pull.Assignees = users + pull.UpdatedAt = newTimestamp(issue.UpdatedAt) + + return nil + } + }(pull)) + } + if err := eg.Wait(); err != nil { + return nil, err + } + + return pulls, nil +} + +func (c *Client) ReplaceAssignees(ctx context.Context, pulls []*PullRequest, opt *AssigneeOption) ([]*PullRequest, error) { + eg, ctx := errgroup.WithContext(ctx) + + for _, pull := range pulls { + eg.Go(func(pull *PullRequest) func() error { + return func() error { + assignees := make([]string, len(pull.Assignees)) + for i, a := range pull.Assignees { + assignees[i] = a.Login + } + _, _, err := c.github.Issues.RemoveAssignees(ctx, pull.Owner, pull.Repo, int(pull.Number), assignees) + if err != nil { + return err + } + issue, resp, err := c.github.Issues.AddAssignees(ctx, pull.Owner, pull.Repo, int(pull.Number), opt.Assignees) + if err != nil { + return err + } + fmt.Println(resp) + + users := make([]*User, len(issue.Assignees)) + for i, assignee := range issue.Assignees { + users[i] = newUser(assignee) + } + pull.Assignees = users + pull.UpdatedAt = newTimestamp(issue.UpdatedAt) + + return nil + } + }(pull)) + } + if err := eg.Wait(); err != nil { + return nil, err + } + + return pulls, nil +} diff --git a/pkg/api/assignee_test.go b/pkg/api/assignee_test.go new file mode 100644 index 0000000..a28a5d1 --- /dev/null +++ b/pkg/api/assignee_test.go @@ -0,0 +1,334 @@ +package api_test + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "testing" + "time" + + "github.com/google/go-github/v28/github" + + "github.com/jarcoal/httpmock" + "github.com/k-kinzal/pr/pkg/api" + "github.com/k-kinzal/pr/test/gen" +) + +func TestClient_AddAssignees(t *testing.T) { + gen.Reset() + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("POST", "=~^https://api.github.com/repos/octocat/Hello-World/issues/\\d+/assignees", func(request *http.Request) (response *http.Response, e error) { + var req struct { + Assignees []string `json:"assignees,omitempty"` + } + if err := json.NewDecoder(request.Body).Decode(&req); err != nil { + return nil, err + } + + issue, err := gen.Issue() + if err != nil { + return nil, err + } + issue.Assignees = make([]*github.User, len(issue.Assignees)) + for i, name := range req.Assignees { + issue.Assignees[i] = &github.User{Login: &name} + } + + resp, err := httpmock.NewJsonResponse(200, issue) + if err != nil { + return nil, err + } + resp.Header.Add("X-RateLimit-Limit", "5000") + resp.Header.Add("X-RateLimit-Remaining", "4999") + resp.Header.Add("X-RateLimit-Reset", fmt.Sprint(time.Now().Unix())) + resp.Request = request + + return resp, nil + }) + + pulls := []*api.PullRequest{ + { + Id: 1, + Number: 1, + State: "open", + Head: &api.PullRequestBranch{ + Sha: "6dcb09b5b57875f334f61aebed695e2e4193db5e", + }, + Owner: "octocat", + Repo: "Hello-World", + }, + { + Id: 2, + Number: 2, + State: "open", + Head: &api.PullRequestBranch{ + Sha: "6dcb09b5b57875f334f61aebed695e2e4193db5e", + }, + Owner: "octocat", + Repo: "Hello-World", + }, + { + Id: 3, + Number: 3, + State: "open", + Head: &api.PullRequestBranch{ + Sha: "6dcb09b5b57875f334f61aebed695e2e4193db5e", + }, + Owner: "octocat", + Repo: "Hello-World", + }, + } + + ctx := context.Background() + client := api.NewClient(ctx, &api.Options{ + Token: "xxxx", + RateLimit: math.MaxInt32, + }) + opt := &api.AssigneeOption{ + Assignees: []string{"user1"}, + } + pulls, err := client.AddAssignees(ctx, pulls, opt) + if err != nil { + t.Fatal(err) + } + for i, pull := range pulls { + if len(pull.Assignees) != 1 { + t.Fatalf("len(pulls[%d].assignees): expect `1`, but actual `%d`", i, len(pull.Assignees)) + } + if pull.Assignees[0].Login != "user1" { + t.Fatalf("pulls[%d].assignees[0].name: expect `user1`, but actual `%s`", i, pull.Assignees[0].Login) + } + } + + info := httpmock.GetCallCountInfo() + if info["POST =~^https://api.github.com/repos/octocat/Hello-World/issues/\\d+/assignees"] != 3 { + t.Fatalf("expect `3`, but actual `%d`: %#v", info["POST =~^https://api.github.com/repos/octocat/Hello-World/issues/\\d+/assignees"], info) + } +} + +func TestClient_RemoveAssignees(t *testing.T) { + gen.Reset() + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("DELETE", "=~^https://api.github.com/repos/octocat/Hello-World/issues/\\d+/assignees", func(request *http.Request) (response *http.Response, e error) { + resp, err := httpmock.NewJsonResponse(200, nil) + if err != nil { + return nil, err + } + resp.Header.Add("X-RateLimit-Limit", "5000") + resp.Header.Add("X-RateLimit-Remaining", "4999") + resp.Header.Add("X-RateLimit-Reset", fmt.Sprint(time.Now().Unix())) + resp.Request = request + + return resp, nil + }) + + pulls := []*api.PullRequest{ + { + Id: 1, + Number: 1, + State: "open", + Head: &api.PullRequestBranch{ + Sha: "6dcb09b5b57875f334f61aebed695e2e4193db5e", + }, + Owner: "octocat", + Repo: "Hello-World", + Assignees: []*api.User{ + { + Login: "user1", + }, + }, + }, + { + Id: 2, + Number: 2, + State: "open", + Head: &api.PullRequestBranch{ + Sha: "6dcb09b5b57875f334f61aebed695e2e4193db5e", + }, + Owner: "octocat", + Repo: "Hello-World", + Assignees: []*api.User{ + { + Login: "user1", + }, + }, + }, + { + Id: 3, + Number: 3, + State: "open", + Head: &api.PullRequestBranch{ + Sha: "6dcb09b5b57875f334f61aebed695e2e4193db5e", + }, + Owner: "octocat", + Repo: "Hello-World", + Assignees: []*api.User{ + { + Login: "user1", + }, + }, + }, + } + + ctx := context.Background() + client := api.NewClient(ctx, &api.Options{ + Token: "xxxx", + RateLimit: math.MaxInt32, + }) + opt := &api.AssigneeOption{ + Assignees: []string{"user1"}, + } + pulls, err := client.RemoveAssignees(ctx, pulls, opt) + if err != nil { + t.Fatal(err) + } + for i, pull := range pulls { + if len(pull.Assignees) != 0 { + t.Fatalf("len(pulls[%d].labels): expect `0`, but actual `%d`", i, len(pull.Labels)) + } + } + + info := httpmock.GetCallCountInfo() + if info["DELETE =~^https://api.github.com/repos/octocat/Hello-World/issues/\\d+/assignees"] != 3 { + t.Fatalf("expect `3`, but actual `%d`: %#v", info["DELETE =~^https://api.github.com/repos/octocat/Hello-World/issues/\\d+/assignees"], info) + } +} + +func TestClient_ReplaceAssignees(t *testing.T) { + gen.Reset() + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("POST", "=~^https://api.github.com/repos/octocat/Hello-World/issues/\\d+/assignees", func(request *http.Request) (response *http.Response, e error) { + var req struct { + Assignees []string `json:"assignees,omitempty"` + } + if err := json.NewDecoder(request.Body).Decode(&req); err != nil { + return nil, err + } + + issue, err := gen.Issue() + if err != nil { + return nil, err + } + issue.Assignees = make([]*github.User, len(issue.Assignees)) + for i, name := range req.Assignees { + issue.Assignees[i] = &github.User{Login: &name} + } + + resp, err := httpmock.NewJsonResponse(200, issue) + if err != nil { + return nil, err + } + resp.Header.Add("X-RateLimit-Limit", "5000") + resp.Header.Add("X-RateLimit-Remaining", "4999") + resp.Header.Add("X-RateLimit-Reset", fmt.Sprint(time.Now().Unix())) + resp.Request = request + + return resp, nil + }) + httpmock.RegisterResponder("DELETE", "=~^https://api.github.com/repos/octocat/Hello-World/issues/\\d+/assignees", func(request *http.Request) (response *http.Response, e error) { + resp, err := httpmock.NewJsonResponse(200, nil) + if err != nil { + return nil, err + } + resp.Header.Add("X-RateLimit-Limit", "5000") + resp.Header.Add("X-RateLimit-Remaining", "4999") + resp.Header.Add("X-RateLimit-Reset", fmt.Sprint(time.Now().Unix())) + resp.Request = request + + return resp, nil + }) + + pulls := []*api.PullRequest{ + { + Id: 1, + Number: 1, + State: "open", + Head: &api.PullRequestBranch{ + Sha: "6dcb09b5b57875f334f61aebed695e2e4193db5e", + }, + Owner: "octocat", + Repo: "Hello-World", + Assignees: []*api.User{ + { + Login: "user1", + }, + { + Login: "user2", + }, + }, + }, + { + Id: 2, + Number: 2, + State: "open", + Head: &api.PullRequestBranch{ + Sha: "6dcb09b5b57875f334f61aebed695e2e4193db5e", + }, + Owner: "octocat", + Repo: "Hello-World", + Assignees: []*api.User{ + { + Login: "user1", + }, + { + Login: "user2", + }, + }, + }, + { + Id: 3, + Number: 3, + State: "open", + Head: &api.PullRequestBranch{ + Sha: "6dcb09b5b57875f334f61aebed695e2e4193db5e", + }, + Owner: "octocat", + Repo: "Hello-World", + Assignees: []*api.User{ + { + Login: "user1", + }, + { + Login: "user2", + }, + }, + }, + } + + ctx := context.Background() + client := api.NewClient(ctx, &api.Options{ + Token: "xxxx", + RateLimit: math.MaxInt32, + }) + opt := &api.AssigneeOption{ + Assignees: []string{"user3"}, + } + pulls, err := client.ReplaceAssignees(ctx, pulls, opt) + if err != nil { + t.Fatal(err) + } + for i, pull := range pulls { + if len(pull.Assignees) != 1 { + t.Fatalf("len(pulls[%d].labels): expect `1`, but actual `%d`", i, len(pull.Assignees)) + } + if pull.Assignees[0].Login != "user3" { + t.Fatalf("pulls[%d].labels[0].name: expect `user3`, but actual `%s`", i, pull.Assignees[0].Login) + } + } + + info := httpmock.GetCallCountInfo() + if info["POST =~^https://api.github.com/repos/octocat/Hello-World/issues/\\d+/assignees"] != 3 { + t.Fatalf("expect `3`, but actual `%d`: %#v", info["POST =~^https://api.github.com/repos/octocat/Hello-World/issues/\\d+/assignees"], info) + } + if info["DELETE =~^https://api.github.com/repos/octocat/Hello-World/issues/\\d+/assignees"] != 3 { + t.Fatalf("expect `3`, but actual `%d`: %#v", info["DELETE =~^https://api.github.com/repos/octocat/Hello-World/issues/\\d+/assignees"], info) + } +} diff --git a/pkg/pr/assignee.go b/pkg/pr/assignee.go new file mode 100644 index 0000000..2464c1f --- /dev/null +++ b/pkg/pr/assignee.go @@ -0,0 +1,84 @@ +package pr + +import ( + "context" + "fmt" + "math/rand" + "time" + + "github.com/k-kinzal/pr/pkg/api" +) + +type AssigneeAction string + +const ( + AssigneeActionAppend AssigneeAction = "append" + AssigneeActionRemove AssigneeAction = "remove" + AssigneeActionReplace AssigneeAction = "replace" +) + +type AssigneeFunc func(assignees []string) []string + +func RandomizeAssignee(assignees []string) []string { + rand.Seed(time.Now().UnixNano()) + return []string{assignees[rand.Intn(len(assignees)-1)]} +} + +type AssigneeOption struct { + Assignees []string + Action AssigneeAction + FuncList []AssigneeFunc + *ListOption +} + +func Assignee(owner string, repo string, opt *AssigneeOption) ([]*api.PullRequest, error) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + clientOption := &api.Options{ + Token: token, + RateLimit: opt.Rate, + } + client := api.NewClient(ctx, clientOption) + + pullOption := api.PullsOption{ + EnableComments: opt.EnableComments, + EnableReviews: opt.EnableReviews, + EnableCommits: opt.EnableCommits, + EnableStatuses: opt.EnableStatuses, + EnableChecks: opt.EnableChecks, + Rules: api.NewPullRequestRules(opt.Rules, opt.Limit), + } + pulls, err := client.GetPulls(ctx, owner, repo, pullOption) + if err != nil { + return nil, err + } + if len(pulls) == 0 { + return nil, &NoMatchError{pullOption.Rules} + } + + assignees := opt.Assignees + for _, fn := range opt.FuncList { + assignees = fn(assignees) + } + + switch opt.Action { + case AssigneeActionAppend: + option := &api.AssigneeOption{ + Assignees: assignees, + } + return client.AddAssignees(ctx, pulls, option) + case AssigneeActionRemove: + option := &api.AssigneeOption{ + Assignees: assignees, + } + return client.RemoveAssignees(ctx, pulls, option) + case AssigneeActionReplace: + option := &api.AssigneeOption{ + Assignees: assignees, + } + return client.ReplaceAssignees(ctx, pulls, option) + } + + return nil, fmt.Errorf("action is expected to be `append`, `remove`, `all`, but was actually %s", opt.Action) +} diff --git a/test/gen/issue.go b/test/gen/issue.go new file mode 100644 index 0000000..8a65a36 --- /dev/null +++ b/test/gen/issue.go @@ -0,0 +1,37 @@ +package gen + +import ( + "encoding/json" + "sync" + + "github.com/google/go-github/v28/github" +) + +// See: https://developer.github.com/v3/issues/#list-issues +var issueText = `{"id":1,"node_id":"MDU6SXNzdWUx","url":"https://api.github.com/repos/octocat/Hello-World/issues/1347","repository_url":"https://api.github.com/repos/octocat/Hello-World","labels_url":"https://api.github.com/repos/octocat/Hello-World/issues/1347/labels{/name}","comments_url":"https://api.github.com/repos/octocat/Hello-World/issues/1347/comments","events_url":"https://api.github.com/repos/octocat/Hello-World/issues/1347/events","html_url":"https://github.com/octocat/Hello-World/issues/1347","number":1347,"state":"open","title":"Found a bug","body":"I'm having a problem with this.","user":{"login":"octocat","id":1,"node_id":"MDQ6VXNlcjE=","avatar_url":"https://github.com/images/error/octocat_happy.gif","gravatar_id":"","url":"https://api.github.com/users/octocat","html_url":"https://github.com/octocat","followers_url":"https://api.github.com/users/octocat/followers","following_url":"https://api.github.com/users/octocat/following{/other_user}","gists_url":"https://api.github.com/users/octocat/gists{/gist_id}","starred_url":"https://api.github.com/users/octocat/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/octocat/subscriptions","organizations_url":"https://api.github.com/users/octocat/orgs","repos_url":"https://api.github.com/users/octocat/repos","events_url":"https://api.github.com/users/octocat/events{/privacy}","received_events_url":"https://api.github.com/users/octocat/received_events","type":"User","site_admin":false},"labels":[{"id":208045946,"node_id":"MDU6TGFiZWwyMDgwNDU5NDY=","url":"https://api.github.com/repos/octocat/Hello-World/labels/bug","name":"bug","description":"Something isn't working","color":"f29513","default":true}],"assignee":{"login":"octocat","id":1,"node_id":"MDQ6VXNlcjE=","avatar_url":"https://github.com/images/error/octocat_happy.gif","gravatar_id":"","url":"https://api.github.com/users/octocat","html_url":"https://github.com/octocat","followers_url":"https://api.github.com/users/octocat/followers","following_url":"https://api.github.com/users/octocat/following{/other_user}","gists_url":"https://api.github.com/users/octocat/gists{/gist_id}","starred_url":"https://api.github.com/users/octocat/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/octocat/subscriptions","organizations_url":"https://api.github.com/users/octocat/orgs","repos_url":"https://api.github.com/users/octocat/repos","events_url":"https://api.github.com/users/octocat/events{/privacy}","received_events_url":"https://api.github.com/users/octocat/received_events","type":"User","site_admin":false},"assignees":[{"login":"octocat","id":1,"node_id":"MDQ6VXNlcjE=","avatar_url":"https://github.com/images/error/octocat_happy.gif","gravatar_id":"","url":"https://api.github.com/users/octocat","html_url":"https://github.com/octocat","followers_url":"https://api.github.com/users/octocat/followers","following_url":"https://api.github.com/users/octocat/following{/other_user}","gists_url":"https://api.github.com/users/octocat/gists{/gist_id}","starred_url":"https://api.github.com/users/octocat/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/octocat/subscriptions","organizations_url":"https://api.github.com/users/octocat/orgs","repos_url":"https://api.github.com/users/octocat/repos","events_url":"https://api.github.com/users/octocat/events{/privacy}","received_events_url":"https://api.github.com/users/octocat/received_events","type":"User","site_admin":false}],"milestone":{"url":"https://api.github.com/repos/octocat/Hello-World/milestones/1","html_url":"https://github.com/octocat/Hello-World/milestones/v1.0","labels_url":"https://api.github.com/repos/octocat/Hello-World/milestones/1/labels","id":1002604,"node_id":"MDk6TWlsZXN0b25lMTAwMjYwNA==","number":1,"state":"open","title":"v1.0","description":"Tracking milestone for version 1.0","creator":{"login":"octocat","id":1,"node_id":"MDQ6VXNlcjE=","avatar_url":"https://github.com/images/error/octocat_happy.gif","gravatar_id":"","url":"https://api.github.com/users/octocat","html_url":"https://github.com/octocat","followers_url":"https://api.github.com/users/octocat/followers","following_url":"https://api.github.com/users/octocat/following{/other_user}","gists_url":"https://api.github.com/users/octocat/gists{/gist_id}","starred_url":"https://api.github.com/users/octocat/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/octocat/subscriptions","organizations_url":"https://api.github.com/users/octocat/orgs","repos_url":"https://api.github.com/users/octocat/repos","events_url":"https://api.github.com/users/octocat/events{/privacy}","received_events_url":"https://api.github.com/users/octocat/received_events","type":"User","site_admin":false},"open_issues":4,"closed_issues":8,"created_at":"2011-04-10T20:09:31Z","updated_at":"2014-03-03T18:58:10Z","closed_at":"2013-02-12T13:22:01Z","due_on":"2012-10-09T23:39:01Z"},"locked":true,"active_lock_reason":"too heated","comments":0,"pull_request":{"url":"https://api.github.com/repos/octocat/Hello-World/pulls/1347","html_url":"https://github.com/octocat/Hello-World/pull/1347","diff_url":"https://github.com/octocat/Hello-World/pull/1347.diff","patch_url":"https://github.com/octocat/Hello-World/pull/1347.patch"},"closed_at":null,"created_at":"2011-04-22T13:33:48Z","updated_at":"2011-04-22T13:33:48Z","repository":{"id":1296269,"node_id":"MDEwOlJlcG9zaXRvcnkxMjk2MjY5","name":"Hello-World","full_name":"octocat/Hello-World","owner":{"login":"octocat","id":1,"node_id":"MDQ6VXNlcjE=","avatar_url":"https://github.com/images/error/octocat_happy.gif","gravatar_id":"","url":"https://api.github.com/users/octocat","html_url":"https://github.com/octocat","followers_url":"https://api.github.com/users/octocat/followers","following_url":"https://api.github.com/users/octocat/following{/other_user}","gists_url":"https://api.github.com/users/octocat/gists{/gist_id}","starred_url":"https://api.github.com/users/octocat/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/octocat/subscriptions","organizations_url":"https://api.github.com/users/octocat/orgs","repos_url":"https://api.github.com/users/octocat/repos","events_url":"https://api.github.com/users/octocat/events{/privacy}","received_events_url":"https://api.github.com/users/octocat/received_events","type":"User","site_admin":false},"private":false,"html_url":"https://github.com/octocat/Hello-World","description":"This your first repo!","fork":false,"url":"https://api.github.com/repos/octocat/Hello-World","archive_url":"http://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}","assignees_url":"http://api.github.com/repos/octocat/Hello-World/assignees{/user}","blobs_url":"http://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}","branches_url":"http://api.github.com/repos/octocat/Hello-World/branches{/branch}","collaborators_url":"http://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}","comments_url":"http://api.github.com/repos/octocat/Hello-World/comments{/number}","commits_url":"http://api.github.com/repos/octocat/Hello-World/commits{/sha}","compare_url":"http://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}","contents_url":"http://api.github.com/repos/octocat/Hello-World/contents/{+path}","contributors_url":"http://api.github.com/repos/octocat/Hello-World/contributors","deployments_url":"http://api.github.com/repos/octocat/Hello-World/deployments","downloads_url":"http://api.github.com/repos/octocat/Hello-World/downloads","events_url":"http://api.github.com/repos/octocat/Hello-World/events","forks_url":"http://api.github.com/repos/octocat/Hello-World/forks","git_commits_url":"http://api.github.com/repos/octocat/Hello-World/git/commits{/sha}","git_refs_url":"http://api.github.com/repos/octocat/Hello-World/git/refs{/sha}","git_tags_url":"http://api.github.com/repos/octocat/Hello-World/git/tags{/sha}","git_url":"git:github.com/octocat/Hello-World.git","issue_comment_url":"http://api.github.com/repos/octocat/Hello-World/issues/comments{/number}","issue_events_url":"http://api.github.com/repos/octocat/Hello-World/issues/events{/number}","issues_url":"http://api.github.com/repos/octocat/Hello-World/issues{/number}","keys_url":"http://api.github.com/repos/octocat/Hello-World/keys{/key_id}","labels_url":"http://api.github.com/repos/octocat/Hello-World/labels{/name}","languages_url":"http://api.github.com/repos/octocat/Hello-World/languages","merges_url":"http://api.github.com/repos/octocat/Hello-World/merges","milestones_url":"http://api.github.com/repos/octocat/Hello-World/milestones{/number}","notifications_url":"http://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}","pulls_url":"http://api.github.com/repos/octocat/Hello-World/pulls{/number}","releases_url":"http://api.github.com/repos/octocat/Hello-World/releases{/id}","ssh_url":"git@github.com:octocat/Hello-World.git","stargazers_url":"http://api.github.com/repos/octocat/Hello-World/stargazers","statuses_url":"http://api.github.com/repos/octocat/Hello-World/statuses/{sha}","subscribers_url":"http://api.github.com/repos/octocat/Hello-World/subscribers","subscription_url":"http://api.github.com/repos/octocat/Hello-World/subscription","tags_url":"http://api.github.com/repos/octocat/Hello-World/tags","teams_url":"http://api.github.com/repos/octocat/Hello-World/teams","trees_url":"http://api.github.com/repos/octocat/Hello-World/git/trees{/sha}","clone_url":"https://github.com/octocat/Hello-World.git","mirror_url":"git:git.example.com/octocat/Hello-World","hooks_url":"http://api.github.com/repos/octocat/Hello-World/hooks","svn_url":"https://svn.github.com/octocat/Hello-World","homepage":"https://github.com","language":null,"forks_count":9,"stargazers_count":80,"watchers_count":80,"size":108,"default_branch":"master","open_issues_count":0,"is_template":true,"topics":["octocat","atom","electron","api"],"has_issues":true,"has_projects":true,"has_wiki":true,"has_pages":false,"has_downloads":true,"archived":false,"disabled":false,"visibility":"public","pushed_at":"2011-01-26T19:06:43Z","created_at":"2011-01-26T19:01:12Z","updated_at":"2011-01-26T19:14:43Z","permissions":{"admin":false,"push":false,"pull":true},"allow_rebase_merge":true,"template_repository":null,"temp_clone_token":"ABTLWHOULUVAXGTRYU7OC2876QJ2O","allow_squash_merge":true,"allow_merge_commit":true,"subscribers_count":42,"network_count":0}}` +var issue *github.Issue +var issueOnce sync.Once + +func Issue() (*github.Issue, error) { + var err error + issueOnce.Do(func() { + err = json.Unmarshal([]byte(issueText), &issue) + }) + if err != nil { + return nil, err + } + s := *issue + return &s, nil +} + +func Issues(length int) ([]*github.Issue, error) { + values := make([]*github.Issue, length) + for i := 0; i < length; i++ { + v, err := Issue() + if err != nil { + return nil, err + } + values[i] = v + } + return values, nil +}