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:
| A | client.go | | | 246 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | github.go | | | 9 | +++++++++ |
| A | issue.go | | | 411 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | netrc.go | | | 37 | +++++++++++++++++++++++++++++++++++++ |
| A | project.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{}
+}