issues

Github, Jira, Gitlab issues from Plan 9's acme
Log | Files | Refs | README | LICENSE

commit e5c958cc6d18f09283167faa7a74152ab9062b81
parent e02b948b80baf2ff1c0c7bd7f96e97fb6cb5f849
Author: Russ Cox <rsc@golang.org>
Date:   Wed, 21 Sep 2022 00:11:41 -0400

github: new package with basic GitHub API

Diffstat:
Aclient.go | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agithub.go | 9+++++++++
Aissue.go | 411+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anetrc.go | 37+++++++++++++++++++++++++++++++++++++
Aproject.go | 479+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 1182 insertions(+), 0 deletions(-)

diff --git a/client.go b/client.go @@ -0,0 +1,246 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package github provides idiomatic Go APIs for accessing basic GitHub issue operations. +// +// The entire GitHub API can be accessed by using the [Client] with GraphQL schema from +// [rsc.io/github/schema]. +package github + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "strings" + "time" + + "rsc.io/github/schema" +) + +// A Client is an authenticated client for accessing the GitHub GraphQL API. +// Client provides convenient methods for common operations. +// To build others, see the [GraphQLQuery] and [GraphQLMutation] methods. +type Client struct { + user string + passwd string +} + +// Dial returns a Client authenticating as user. +// Authentication credentials are loaded from $HOME/.netrc +// using the 'api.github.com' entry, which should contain a +// GitHub personal access token. +// If user is the empty string, Dial uses the first line in .netrc +// listed for api.github.com. +// +// For example, $HOME/.netrc might contain: +// +// machine api.github.com login ken password ghp_123456789abcdef123456789abcdef12345 +func Dial(user string) (*Client, error) { + user, passwd, err := netrcAuth("api.github.com", user) + if err != nil { + return nil, err + } + return &Client{user: user, passwd: passwd}, nil +} + +// A Vars is a binding of GraphQL variables to JSON-able values (usually strings). +type Vars map[string]any + +// GraphQLQuery runs a single query with the bound variables. +// For example, to look up a repository ID: +// +// func repoID(org, name string) (string, error) { +// graphql := ` +// query($Org: String!, $Repo: String!) { +// repository(owner: $Org, name: $Repo) { +// id +// } +// } +// ` +// vars := Vars{"Org": org, "Repo": repo} +// q, err := c.GraphQLQuery(graphql, vars) +// if err != nil { +// return "", err +// } +// return string(q.Repository.Id), nil +// } +// +// (This is roughly the implementation of the [Client.Repo] method.) +func (c *Client) GraphQLQuery(query string, vars Vars) (*schema.Query, error) { + var reply schema.Query + if err := c.graphQL(query, vars, &reply); err != nil { + return nil, err + } + return &reply, nil +} + +// GraphQLMutation runs a single mutation with the bound variables. +// For example, to edit an issue comment: +// +// func editComment(commentID, body string) error { +// graphql := ` +// mutation($Comment: ID!, $Body: String!) { +// updateIssueComment(input: {id: $Comment, body: $Body}) { +// clientMutationId +// } +// } +// ` +// _, err := c.GraphQLMutation(graphql, Vars{"Comment": commentID, "Body": body}) +// return err +// } +// +// (This is roughly the implementation of the [Client.EditIssueComment] method.) +func (c *Client) GraphQLMutation(query string, vars Vars) (*schema.Mutation, error) { + var reply schema.Mutation + if err := c.graphQL(query, vars, &reply); err != nil { + return nil, err + } + return &reply, nil +} + +func (c *Client) graphQL(query string, vars Vars, reply any) error { + js, err := json.Marshal(struct { + Query string `json:"query"` + Variables any `json:"variables"` + }{ + Query: query, + Variables: vars, + }) + if err != nil { + return err + } + +Retry: + method := "POST" + body := bytes.NewReader(js) + if query == "schema" && vars == nil { + method = "GET" + js = nil + } + req, err := http.NewRequest(method, "https://api.github.com/graphql", body) + if err != nil { + return err + } + if c.user != "" { + req.SetBasicAuth(c.user, c.passwd) + } + + previews := []string{ + "application/vnd.github.inertia-preview+json", // projects + "application/vnd.github.starfox-preview+json", // projects events + "application/vnd.github.elektra-preview+json", // pinned issues + } + req.Header.Set("Accept", strings.Join(previews, ",")) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading body: %v", err) + } + if resp.StatusCode != 200 { + err := fmt.Errorf("%s\n%s", resp.Status, data) + // TODO(rsc): Could do better here, but this works reasonably well. + // If we're over quota, it could be a while. + if strings.Contains(err.Error(), "wait a few minutes") { + log.Printf("github: %v", err) + time.Sleep(10 * time.Minute) + goto Retry + } + return err + } + + jsreply := struct { + Data any + Errors []struct { + Message string + } + }{ + Data: reply, + } + + err = json.Unmarshal(data, &jsreply) + if err != nil { + return fmt.Errorf("parsing reply: %v", err) + } + + if len(jsreply.Errors) > 0 { + if strings.Contains(jsreply.Errors[0].Message, "rate limit exceeded") { + log.Printf("github: %s", jsreply.Errors[0].Message) + time.Sleep(10 * time.Minute) + goto Retry + } + if strings.Contains(jsreply.Errors[0].Message, "submitted too quickly") { + log.Printf("github: %s", jsreply.Errors[0].Message) + time.Sleep(5 * time.Second) + goto Retry + } + for i, line := range strings.Split(query, "\n") { + log.Print(i+1, line) + } + return fmt.Errorf("graphql error: %s", jsreply.Errors[0].Message) + } + + return nil +} + +func collect[Schema, Out any](c *Client, graphql string, vars Vars, transform func(Schema) Out, + page func(*schema.Query) pager[Schema]) ([]Out, error) { + var cursor string + var list []Out + for { + if cursor != "" { + vars["Cursor"] = cursor + } + q, err := c.GraphQLQuery(graphql, vars) + if err != nil { + return list, err + } + p := page(q) + if p == nil { + break + } + list = append(list, apply(transform, p.GetNodes())...) + info := p.GetPageInfo() + cursor = info.EndCursor + if cursor == "" || !info.HasNextPage { + break + } + } + return list, nil +} + +type pager[T any] interface { + GetPageInfo() *schema.PageInfo + GetNodes() []T +} + +func apply[In, Out any](f func(In) Out, x []In) []Out { + var out []Out + for _, in := range x { + out = append(out, f(in)) + } + return out +} + +func toTime(s schema.DateTime) time.Time { + t, err := time.ParseInLocation(time.RFC3339Nano, string(s), time.UTC) + if err != nil { + return time.Time{} + } + return t +} + +func toDate(s schema.Date) time.Time { + t, err := time.ParseInLocation("2006-01-02", string(s), time.UTC) + if err != nil { + return time.Time{} + } + return t +} diff --git a/github.go b/github.go @@ -0,0 +1,9 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package github provides idiomatic Go APIs for accessing basic GitHub issue operations. +// +// The entire GitHub API can be accessed by using the [Client] with GraphQL schema from +// [rsc.io/github/schema]. +package github diff --git a/issue.go b/issue.go @@ -0,0 +1,411 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "fmt" + "time" + + "rsc.io/github/schema" +) + +const issueFields = ` + number + title + id + closed + closedAt + createdAt + lastEditedAt + milestone { id number title } + repository { name owner { __typename login } } + body + labels(first: 100) { + nodes { + name + description + id + repository { name owner { __typename login } } + } + } +` + +func (c *Client) SearchLabels(org, repo, query string) ([]*Label, error) { + graphql := ` + query($Org: String!, $Repo: String!, $Query: String, $Cursor: String) { + repository(owner: $Org, name: $Repo) { + labels(first: 100, query: $Query, after: $Cursor) { + pageInfo { + hasNextPage + endCursor + } + totalCount + nodes { + name + description + id + repository { name owner { __typename login } } + } + } + } + } + ` + + vars := Vars{"Org": org, "Repo": repo} + if query != "" { + vars["Query"] = query + } + return collect(c, graphql, vars, toLabel, + func(q *schema.Query) pager[*schema.Label] { return q.Repository.Labels }, + ) +} + +func (c *Client) SearchMilestones(org, repo, query string) ([]*Milestone, error) { + graphql := ` + query($Org: String!, $Repo: String!, $Query: String, $Cursor: String) { + repository(owner: $Org, name: $Repo) { + milestones(first: 100, query: $Query, after: $Cursor) { + pageInfo { + hasNextPage + endCursor + } + totalCount + nodes { + id + number + title + } + } + } + } + ` + + vars := Vars{"Org": org, "Repo": repo} + if query != "" { + vars["Query"] = query + } + return collect(c, graphql, vars, toMilestone, + func(q *schema.Query) pager[*schema.Milestone] { return q.Repository.Milestones }, + ) +} + +func (c *Client) IssueComments(issue *Issue) ([]*IssueComment, error) { + graphql := ` + query($Org: String!, $Repo: String!, $Number: Int!, $Cursor: String) { + repository(owner: $Org, name: $Repo) { + issue(number: $Number) { + comments(first: 100, after: $Cursor) { + pageInfo { + hasNextPage + endCursor + } + totalCount + nodes { + author { __typename login } + id + body + createdAt + publishedAt + updatedAt + } + } + } + } + } + ` + + vars := Vars{"Org": issue.Owner, "Repo": issue.Repo, "Number": issue.Number} + return collect(c, graphql, vars, toIssueComment, + func(q *schema.Query) pager[*schema.IssueComment] { return q.Repository.Issue.Comments }, + ) +} + +func (c *Client) AddIssueComment(issue *Issue, text string) error { + graphql := ` + mutation($ID: ID!, $Text: String!) { + addComment(input: {subjectId: $ID, body: $Text}) { + clientMutationId + } + } + ` + _, err := c.GraphQLMutation(graphql, Vars{"ID": issue.ID, "Text": text}) + return err +} + +func (c *Client) CloseIssue(issue *Issue) error { + graphql := ` + mutation($ID: ID!) { + closeIssue(input: {issueId: $ID}) { + clientMutationId + } + } + ` + _, err := c.GraphQLMutation(graphql, Vars{"ID": issue.ID}) + return err +} + +func (c *Client) ReopenIssue(issue *Issue) error { + graphql := ` + mutation($ID: ID!) { + reopenIssue(input: {issueId: $ID}) { + clientMutationId + } + } + ` + _, err := c.GraphQLMutation(graphql, Vars{"ID": issue.ID}) + return err +} + +func (c *Client) AddIssueLabels(issue *Issue, labels ...*Label) error { + var labelIDs []string + for _, lab := range labels { + labelIDs = append(labelIDs, lab.ID) + } + graphql := ` + mutation($Issue: ID!, $Labels: [ID!]!) { + addLabelsToLabelable(input: {labelableId: $Issue, labelIds: $Labels}) { + clientMutationId + } + } + ` + _, err := c.GraphQLMutation(graphql, Vars{"Issue": issue.ID, "Labels": labelIDs}) + return err +} + +func (c *Client) RemoveIssueLabels(issue *Issue, labels ...*Label) error { + var labelIDs []string + for _, lab := range labels { + labelIDs = append(labelIDs, lab.ID) + } + graphql := ` + mutation($Issue: ID!, $Labels: [ID!]!) { + removeLabelsFromLabelable(input: {labelableId: $Issue, labelIds: $Labels}) { + clientMutationId + } + } + ` + _, err := c.GraphQLMutation(graphql, Vars{"Issue": issue.ID, "Labels": labelIDs}) + return err +} + +func (c *Client) CreateIssue(repo *Repo, title, body string, extra ...any) (*Issue, error) { + var labelIDs []string + var projectIDs []string + for _, x := range extra { + switch x := x.(type) { + default: + return nil, fmt.Errorf("cannot create issue with extra of type %T", x) + case *Label: + labelIDs = append(labelIDs, x.ID) + case *Project: + projectIDs = append(projectIDs, x.ID) + } + } + graphql := ` + mutation($Repo: ID!, $Title: String!, $Body: String!, $Labels: [ID!]!) { + createIssue(input: {repositoryId: $Repo, title: $Title, body: $Body, labelIds: $Labels}) { + clientMutationId + issue { + ` + issueFields + ` + } + } + } + ` + m, err := c.GraphQLMutation(graphql, Vars{"Repo": repo.ID, "Title": title, "Body": body, "Labels": labelIDs, "Projects": projectIDs}) + if err != nil { + return nil, err + } + issue := toIssue(m.CreateIssue.Issue) + for _, id := range projectIDs { + graphql := ` + mutation($Project: ID!, $Issue: ID!) { + addProjectV2ItemById(input: {projectId: $Project, contentId: $Issue}) { + clientMutationId + } + } + ` + _, err := c.GraphQLMutation(graphql, Vars{"Project": id, "Issue": string(m.CreateIssue.Issue.Id)}) + if err != nil { + return issue, err + } + } + return issue, nil +} + +func (c *Client) RetitleIssue(issue *Issue, title string) error { + graphql := ` + mutation($Issue: ID!, $Title: String!) { + updateIssue(input: {id: $Issue, title: $Title}) { + clientMutationId + } + } + ` + _, err := c.GraphQLMutation(graphql, Vars{"Issue": issue.ID, "Title": title}) + return err +} + +func (c *Client) EditIssueComment(comment *IssueComment, body string) error { + graphql := ` + mutation($Comment: ID!, $Body: String!) { + updateIssueComment(input: {id: $Comment, body: $Body}) { + clientMutationId + } + } + ` + _, err := c.GraphQLMutation(graphql, Vars{"Comment": comment.ID, "Body": body}) + return err +} + +func (c *Client) RemilestoneIssue(issue *Issue, milestone *Milestone) error { + graphql := ` + mutation($Issue: ID!, $Milestone: ID!) { + updateIssue(input: {id: $Issue, milestoneId: $Milestone}) { + clientMutationId + } + } + ` + _, err := c.GraphQLMutation(graphql, Vars{"Issue": issue.ID, "Milestone": milestone.ID}) + return err +} + +func (c *Client) SetProjectItemFieldOption(project *Project, item *ProjectItem, field *ProjectField, option *ProjectFieldOption) error { + graphql := ` + mutation($Project: ID!, $Item: ID!, $Field: ID!, $Option: String!) { + updateProjectV2ItemFieldValue(input: {projectId: $Project, itemId: $Item, fieldId: $Field, value: {singleSelectOptionId: $Option}}) { + clientMutationId + } + } + ` + _, err := c.GraphQLMutation(graphql, Vars{"Project": project.ID, "Item": item.ID, "Field": field.ID, "Option": option.ID}) + return err +} + +func (c *Client) DeleteProjectItem(project *Project, item *ProjectItem) error { + graphql := ` + mutation($Project: ID!, $Item: ID!) { + deleteProjectV2Item(input: {projectId: $Project, itemId: $Item}) { + clientMutationId + } + } + ` + _, err := c.GraphQLMutation(graphql, Vars{"Project": project.ID, "Item": item.ID}) + return err +} + +type Label struct { + Name string + Description string + ID string + Owner string + Repo string +} + +func toLabel(s *schema.Label) *Label { + return &Label{ + Name: s.Name, + Description: s.Description, + ID: string(s.Id), + Owner: s.Repository.Owner.Interface.(interface{ GetLogin() string }).GetLogin(), + Repo: s.Repository.Name, + } +} + +type Milestone struct { + Title string + ID string +} + +func toMilestone(s *schema.Milestone) *Milestone { + if s == nil { + return nil + } + return &Milestone{ + Title: s.Title, + ID: string(s.Id), + } +} + +type Issue struct { + ID string + Title string + Number int + Closed bool + ClosedAt time.Time + CreatedAt time.Time + LastEditedAt time.Time + Labels []*Label + Milestone *Milestone + Owner string + Repo string + Body string +} + +func toIssue(s *schema.Issue) *Issue { + return &Issue{ + ID: string(s.Id), + Title: s.Title, + Number: s.Number, + Closed: s.Closed, + ClosedAt: toTime(s.ClosedAt), + CreatedAt: toTime(s.CreatedAt), + LastEditedAt: toTime(s.LastEditedAt), + Owner: s.Repository.Owner.Interface.(interface{ GetLogin() string }).GetLogin(), + Repo: s.Repository.Name, + Milestone: toMilestone(s.Milestone), + Labels: apply(toLabel, s.Labels.Nodes), + Body: s.Body, + } +} + +func (i *Issue) LabelByName(name string) *Label { + for _, lab := range i.Labels { + if lab.Name == name { + return lab + } + } + return nil +} + +type IssueComment struct { + ID string + Author string + Body string + CreatedAt time.Time + PublishedAt time.Time + UpdatedAt time.Time +} + +func toIssueComment(s *schema.IssueComment) *IssueComment { + return &IssueComment{ + Author: s.Author.Interface.GetLogin(), + Body: s.Body, + CreatedAt: toTime(s.CreatedAt), + ID: string(s.Id), + PublishedAt: toTime(s.PublishedAt), + UpdatedAt: toTime(s.UpdatedAt), + } +} + +type Repo struct { + Owner string + Repo string + ID string +} + +func (c *Client) Repo(org, repo string) (*Repo, error) { + graphql := ` + query($Org: String!, $Repo: String!) { + repository(owner: $Org, name: $Repo) { + id + } + } + ` + vars := Vars{"Org": org, "Repo": repo} + q, err := c.GraphQLQuery(graphql, vars) + if err != nil { + return nil, err + } + return &Repo{org, repo, string(q.Repository.Id)}, nil +} diff --git a/netrc.go b/netrc.go @@ -0,0 +1,37 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "runtime" + "strings" +) + +func netrcAuth(host, user string) (string, string, error) { + netrc := ".netrc" + if runtime.GOOS == "windows" { + netrc = "_netrc" + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", "", err + } + data, _ := ioutil.ReadFile(filepath.Join(homeDir, netrc)) + for _, line := range strings.Split(string(data), "\n") { + if i := strings.Index(line, "#"); i >= 0 { + line = line[:i] + } + f := strings.Fields(line) + if len(f) >= 6 && f[0] == "machine" && f[1] == host && f[2] == "login" && f[4] == "password" && (user == "" || f[3] == user) { + return f[3], f[5], nil + } + } + return "", "", fmt.Errorf("cannot find netrc entry for %s", host) +} diff --git a/project.go b/project.go @@ -0,0 +1,479 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "fmt" + "time" + + "rsc.io/github/schema" +) + +func (c *Client) Projects(org, query string) ([]*Project, error) { + commonField := ` + createdAt + dataType + id + name + updatedAt + ` + graphql := ` + query($Org: String!, $Query: String, $Cursor: String) { + organization(login: $Org) { + projectsV2(first: 100, query: $Query, after: $Cursor) { + pageInfo { + hasNextPage + endCursor + } + totalCount + nodes { + closed + closedAt + createdAt + updatedAt + id + number + title + url + fields(first: 100) { + pageInfo { + hasNextPage + endCursor + } + totalCount + nodes { + __typename + ... on ProjectV2Field { + ` + commonField + ` + } + ... on ProjectV2IterationField { + ` + commonField + ` + configuration { + completedIterations { + duration + id + startDate + title + titleHTML + } + iterations { + duration + id + startDate + title + titleHTML + } + duration + startDay + } + } + ... on ProjectV2SingleSelectField { + ` + commonField + ` + options { + id + name + nameHTML + } + } + } + } + } + } + } + } + ` + + vars := Vars{"Org": org} + if query != "" { + vars["Query"] = query + } + return collect(c, graphql, vars, + toProject(org), + func(q *schema.Query) pager[*schema.ProjectV2] { return q.Organization.ProjectsV2 }, + ) +} + +func (c *Client) ProjectItems(p *Project) ([]*ProjectItem, error) { + graphql := ` + query($Org: String!, $ProjectNumber: Int!, $Cursor: String) { + organization(login: $Org) { + projectV2(number: $ProjectNumber) { + items(first: 100, after: $Cursor) { + pageInfo { + hasNextPage + endCursor + } + totalCount + nodes { + databaseId + fieldValues(first: 100) { + pageInfo { + hasNextPage + endCursor + } + totalCount + nodes { + __typename + ... on ProjectV2ItemFieldDateValue { + createdAt databaseId id updatedAt + date + field { __typename ... on ProjectV2Field { databaseId id name } } + } + ... on ProjectV2ItemFieldIterationValue { + createdAt databaseId id updatedAt + field { __typename ... on ProjectV2IterationField { databaseId id name } } + } + ... on ProjectV2ItemFieldLabelValue { + field { __typename ... on ProjectV2Field { databaseId id name } } + } + ... on ProjectV2ItemFieldMilestoneValue { + field { __typename ... on ProjectV2Field { databaseId id name } } + } + ... on ProjectV2ItemFieldNumberValue { + createdAt databaseId id updatedAt + number + field { __typename ... on ProjectV2Field { databaseId id name } } + } + ... on ProjectV2ItemFieldPullRequestValue { + field { __typename ... on ProjectV2Field { databaseId id name } } + } + ... on ProjectV2ItemFieldRepositoryValue { + field { __typename ... on ProjectV2Field { databaseId id name } } + } + ... on ProjectV2ItemFieldReviewerValue { + field { __typename ... on ProjectV2Field { databaseId id name } } + } + ... on ProjectV2ItemFieldSingleSelectValue { + createdAt databaseId id updatedAt + name nameHTML optionId + field { __typename ... on ProjectV2SingleSelectField { databaseId id name } } + } + ... on ProjectV2ItemFieldTextValue { + createdAt databaseId id updatedAt + text + field { __typename ... on ProjectV2Field { databaseId id name } } + } + ... on ProjectV2ItemFieldUserValue { + field { __typename ... on ProjectV2Field { databaseId id name } } + } + } + } + id + isArchived + type + updatedAt + createdAt + content { + __typename + ... on Issue { + ` + issueFields + ` + } + } + } + } + } + } + } + ` + + vars := Vars{"Org": p.Org, "ProjectNumber": p.Number} + return collect(c, graphql, vars, + p.toProjectItem, + func(q *schema.Query) pager[*schema.ProjectV2Item] { return q.Organization.ProjectV2.Items }, + ) +} + +type Project struct { + ID string + Closed bool + ClosedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time + Fields []*ProjectField + Number int + Title string + URL string + Org string +} + +func (p *Project) FieldByName(name string) *ProjectField { + for _, f := range p.Fields { + if f.Name == name { + return f + } + } + return nil +} + +func toProject(org string) func(*schema.ProjectV2) *Project { + return func(s *schema.ProjectV2) *Project { + // TODO: Check p.Fields.PageInfo.HasNextPage. + return &Project{ + ID: string(s.Id), + Closed: s.Closed, + ClosedAt: toTime(s.ClosedAt), + CreatedAt: toTime(s.CreatedAt), + UpdatedAt: toTime(s.UpdatedAt), + Fields: apply(toProjectField, s.Fields.Nodes), + Number: s.Number, + Title: s.Title, + URL: string(s.Url), + Org: org, + } + } +} + +type ProjectField struct { + Kind string // "field", "iteration", "select" + CreatedAt time.Time + UpdatedAt time.Time + DataType schema.ProjectV2FieldType // TODO + DatabaseID int + ID schema.ID + Name string + Iterations *ProjectIterations + Options []*ProjectFieldOption +} + +func (f *ProjectField) OptionByName(name string) *ProjectFieldOption { + for _, o := range f.Options { + if o.Name == name { + return o + } + } + return nil +} + +func toProjectField(su schema.ProjectV2FieldConfiguration) *ProjectField { + s, _ := su.Interface.(schema.ProjectV2FieldCommon_Interface) + f := &ProjectField{ + CreatedAt: toTime(s.GetCreatedAt()), + UpdatedAt: toTime(s.GetUpdatedAt()), + DatabaseID: s.GetDatabaseId(), + ID: s.GetId(), + Name: s.GetName(), + } + switch s := s.(type) { + case *schema.ProjectV2Field: + f.Kind = "field" + case *schema.ProjectV2IterationField: + f.Kind = "iteration" + f.Iterations = toProjectIterations(s.Configuration) + case *schema.ProjectV2SingleSelectField: + f.Kind = "select" + f.Options = apply(toProjectFieldOption, s.Options) + } + return f +} + +type ProjectIterations struct { + Completed []*ProjectIteration + Active []*ProjectIteration + Days int + StartDay time.Weekday +} + +func toProjectIterations(s *schema.ProjectV2IterationFieldConfiguration) *ProjectIterations { + return &ProjectIterations{ + Completed: apply(toProjectIteration, s.CompletedIterations), + Active: apply(toProjectIteration, s.Iterations), + StartDay: time.Weekday(s.StartDay), + Days: s.Duration, + } +} + +type ProjectIteration struct { + Days int + ID string + Start time.Time + Title string + TitleHTML string +} + +func toProjectIteration(s *schema.ProjectV2IterationFieldIteration) *ProjectIteration { + return &ProjectIteration{ + Days: s.Duration, + ID: s.Id, + Start: toDate(s.StartDate), + Title: s.Title, + TitleHTML: s.TitleHTML, + } +} + +type ProjectFieldOption struct { + ID string + Name string + NameHTML string +} + +func (o *ProjectFieldOption) String() string { + return fmt.Sprintf("%+v", *o) +} + +func toProjectFieldOption(s *schema.ProjectV2SingleSelectFieldOption) *ProjectFieldOption { + return &ProjectFieldOption{ + ID: s.Id, + Name: s.Name, + NameHTML: s.NameHTML, + } +} + +type ProjectItem struct { + CreatedAt time.Time + DatabaseID int + ID schema.ID + IsArchived bool + Type schema.ProjectV2ItemType + UpdatedAt time.Time + Fields []*ProjectFieldValue + Issue *Issue +} + +func (it *ProjectItem) FieldByName(name string) *ProjectFieldValue { + for _, f := range it.Fields { + if f.Field == name { + return f + } + } + return nil +} + +func (p *Project) toProjectItem(s *schema.ProjectV2Item) *ProjectItem { + // TODO: Check p.Fields.PageInfo.HasNextPage. + it := &ProjectItem{ + CreatedAt: toTime(s.CreatedAt), + DatabaseID: s.DatabaseId, + ID: s.Id, + IsArchived: s.IsArchived, + Type: s.Type, + UpdatedAt: toTime(s.UpdatedAt), + Fields: apply(p.toProjectFieldValue, s.FieldValues.Nodes), + // TODO Issue + } + if si, ok := s.Content.Interface.(*schema.Issue); ok { + it.Issue = toIssue(si) + } + return it +} + +type ProjectFieldValue struct { + CreatedAt time.Time + UpdatedAt time.Time + Kind string + ID string + DatabaseID int + Field string + Option *ProjectFieldOption + Date time.Time + Text string +} + +func (v *ProjectFieldValue) String() string { + switch v.Kind { + case "date": + return fmt.Sprintf("%s:%v", v.Field, v.Date.Format("2006-01-02")) + case "text": + return fmt.Sprintf("%s:%q", v.Field, v.Text) + case "select": + return fmt.Sprintf("%s:%q", v.Field, v.Option) + } + return fmt.Sprintf("%s:???", v.Field) +} + +func (p *Project) optionByID(id string) *ProjectFieldOption { + for _, f := range p.Fields { + for _, o := range f.Options { + if o.ID == id { + return o + } + } + } + return nil +} + +func (p *Project) toProjectFieldValue(s schema.ProjectV2ItemFieldValue) *ProjectFieldValue { + switch sv := s.Interface.(type) { + case *schema.ProjectV2ItemFieldDateValue: + return &ProjectFieldValue{ + Kind: "date", + CreatedAt: toTime(sv.CreatedAt), + DatabaseID: sv.DatabaseId, + Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), + ID: string(sv.Id), + UpdatedAt: toTime(sv.UpdatedAt), + Date: toDate(sv.Date), + } + case *schema.ProjectV2ItemFieldIterationValue: + return &ProjectFieldValue{ + Kind: "iteration", + CreatedAt: toTime(sv.CreatedAt), + DatabaseID: sv.DatabaseId, + Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), + ID: string(sv.Id), + UpdatedAt: toTime(sv.UpdatedAt), + } + case *schema.ProjectV2ItemFieldLabelValue: + return &ProjectFieldValue{ + Kind: "label", + Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), + } + case *schema.ProjectV2ItemFieldMilestoneValue: + return &ProjectFieldValue{ + Kind: "milestone", + Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), + } + case *schema.ProjectV2ItemFieldNumberValue: + return &ProjectFieldValue{ + Kind: "number", + CreatedAt: toTime(sv.CreatedAt), + DatabaseID: sv.DatabaseId, + Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), + ID: string(sv.Id), + UpdatedAt: toTime(sv.UpdatedAt), + } + case *schema.ProjectV2ItemFieldPullRequestValue: + return &ProjectFieldValue{ + Kind: "pr", + Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), + } + case *schema.ProjectV2ItemFieldRepositoryValue: + return &ProjectFieldValue{ + Kind: "repo", + Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), + } + case *schema.ProjectV2ItemFieldReviewerValue: + return &ProjectFieldValue{ + Kind: "reviewer", + Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), + } + case *schema.ProjectV2ItemFieldSingleSelectValue: + return &ProjectFieldValue{ + Kind: "select", + CreatedAt: toTime(sv.CreatedAt), + DatabaseID: sv.DatabaseId, + Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), + ID: string(sv.Id), + UpdatedAt: toTime(sv.UpdatedAt), + + Option: p.optionByID(sv.OptionId), + } + case *schema.ProjectV2ItemFieldTextValue: + return &ProjectFieldValue{ + Kind: "text", + CreatedAt: toTime(sv.CreatedAt), + DatabaseID: sv.DatabaseId, + Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), + ID: string(sv.Id), + UpdatedAt: toTime(sv.UpdatedAt), + Text: sv.Text, + } + case *schema.ProjectV2ItemFieldUserValue: + return &ProjectFieldValue{ + Kind: "user", + Field: sv.Field.Interface.(schema.ProjectV2FieldCommon_Interface).GetName(), + } + } + return &ProjectFieldValue{} +}