From 7af33d090a31fc85cc62d01c339fa330ce6efb01 Mon Sep 17 00:00:00 2001 From: Mateus Marquezini Date: Mon, 20 Nov 2023 18:45:12 -0300 Subject: [PATCH 1/9] added a new error handling when the display name flag exceeds 48 characters #8356 --- pkg/cmd/codespace/create.go | 6 +++++- pkg/cmd/codespace/create_test.go | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go index fa484173d..d8b24d016 100644 --- a/pkg/cmd/codespace/create.go +++ b/pkg/cmd/codespace/create.go @@ -110,7 +110,7 @@ func newCreateCmd(app *App) *cobra.Command { createCmd.Flags().DurationVar(&opts.idleTimeout, "idle-timeout", 0, "allowed inactivity before codespace is stopped, e.g. \"10m\", \"1h\"") createCmd.Flags().Var(&opts.retentionPeriod, "retention-period", "allowed time after shutting down before the codespace is automatically deleted (maximum 30 days), e.g. \"1h\", \"72h\"") createCmd.Flags().StringVar(&opts.devContainerPath, "devcontainer-path", "", "path to the devcontainer.json file to use when creating codespace") - createCmd.Flags().StringVarP(&opts.displayName, "display-name", "d", "", "display name for the codespace") + createCmd.Flags().StringVarP(&opts.displayName, "display-name", "d", "", "display name for the codespace (48 characters or less)") return createCmd } @@ -282,6 +282,10 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { } } + if len(opts.displayName) > 48 { // 48 is the max length of the display name in the API + return fmt.Errorf("error creating codespace: display name should contains 48 characters max or less") + } + createParams := &api.CreateCodespaceParams{ RepositoryID: repository.ID, Branch: branch, diff --git a/pkg/cmd/codespace/create_test.go b/pkg/cmd/codespace/create_test.go index ae85fc34a..bbb8d84d0 100644 --- a/pkg/cmd/codespace/create_test.go +++ b/pkg/cmd/codespace/create_test.go @@ -184,6 +184,25 @@ func TestApp_Create(t *testing.T) { wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n", wantErr: fmt.Errorf("error getting machine type: there is no such machine for the repository: %s\nAvailable machines: %v", "MEGA", []string{"GIGA", "TERA"}), }, + { + name: "create codespace with display name more than 48 characters results in error", + fields: fields{ + apiClient: apiCreateDefaults(&apiClientMock{ + CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) { + return &api.Codespace{ + Name: "monalisa-dotfiles-abcd1234", + }, nil + }, + }), + }, + opts: createOptions{ + repo: "monalisa/dotfiles", + machine: "GIGA", + displayName: "this-is-very-long-display-name-with-49-characters", + }, + wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n", + wantErr: fmt.Errorf("error creating codespace: display name should contains 48 characters max or less"), + }, { name: "create codespace with devcontainer path results in selecting the correct machine type", fields: fields{ From b96a8e3c0168a3ee300c25d91879d32dc30169b6 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 27 Nov 2023 10:40:09 +0100 Subject: [PATCH 2/9] Add PR Finder test to cover projectItems --- pkg/cmd/pr/shared/finder_test.go | 63 ++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 23138ed2d..bfd82a2f2 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/require" ) func TestFind(t *testing.T) { @@ -409,6 +410,68 @@ func TestFind(t *testing.T) { wantPR: 13, wantRepo: "https://github.com/OWNER/REPO", }, + { + name: "inclduing project items", + args: args{ + selector: "", + fields: []string{"projectItems"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + }, + branchFn: func() (string, error) { + return "blueberries", nil + }, + branchConfig: func(branch string) (c git.BranchConfig) { + c.MergeRef = "refs/pull/13/head" + return + }, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequest":{"number":13} + }}}`)) + + r.Register( + httpmock.GraphQL(`query PullRequestProjectItems\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "pullRequest": { + "projectItems": { + "nodes": [ + { + "id": "PVTI_lADOB-vozM4AVk16zgK6U50", + "project": { + "id": "PVT_kwDOB-vozM4AVk16", + "title": "Test Project" + }, + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "MQ" + } + } + } + } + } + }`, + func(query string, inputs map[string]interface{}) { + require.Equal(t, float64(13), inputs["number"]) + require.Equal(t, "OWNER", inputs["owner"]) + require.Equal(t, "REPO", inputs["name"]) + }), + ) + }, + wantPR: 13, + wantRepo: "https://github.com/OWNER/REPO", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 65d63e1bc9aafbd1e98f8bb905521576f4918961 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 27 Nov 2023 10:53:19 +0100 Subject: [PATCH 3/9] Add test for ProjectV2Item query Status column --- api/queries_projects_v2_test.go | 68 +++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/api/queries_projects_v2_test.go b/api/queries_projects_v2_test.go index bf6a618ba..30d782bef 100644 --- a/api/queries_projects_v2_test.go +++ b/api/queries_projects_v2_test.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestUpdateProjectV2Items(t *testing.T) { @@ -185,6 +186,67 @@ func TestProjectsV2ItemsForPullRequest(t *testing.T) { }, expectError: true, }, + { + name: "retrieves project items that have status columns", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query PullRequestProjectItems\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "pullRequest": { + "projectItems": { + "nodes": [ + { + "id": "PVTI_lADOB-vozM4AVk16zgK6U50", + "project": { + "id": "PVT_kwDOB-vozM4AVk16", + "title": "Test Project" + }, + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "MQ" + } + } + } + } + } + }`, + func(query string, inputs map[string]interface{}) { + require.Equal(t, float64(1), inputs["number"]) + require.Equal(t, "OWNER", inputs["owner"]) + require.Equal(t, "REPO", inputs["name"]) + }), + ) + }, + expectItems: ProjectItems{ + Nodes: []*ProjectV2Item{ + { + ID: "PVTI_lADOB-vozM4AVk16zgK6U50", + Project: struct { + ID string `json:"id"` + Title string `json:"title"` + }{ + ID: "PVT_kwDOB-vozM4AVk16", + Title: "Test Project", + }, + Status: struct { + OptionID string `json:"optionId" graphql:"... on ProjectV2ItemFieldSingleSelectValue{optionId}"` + Name string `json:"name" graphql:"... on ProjectV2ItemFieldSingleSelectValue{name}"` + }{ + OptionID: "47fc9ee4", + Name: "In Progress", + }, + }, + }, + }, + }, } for _, tt := range tests { @@ -199,11 +261,11 @@ func TestProjectsV2ItemsForPullRequest(t *testing.T) { pr := &PullRequest{Number: 1} err := ProjectsV2ItemsForPullRequest(client, repo, pr) if tt.expectError { - assert.Error(t, err) + require.Error(t, err) } else { - assert.NoError(t, err) + require.NoError(t, err) } - assert.Equal(t, tt.expectItems, pr.ProjectItems) + require.Equal(t, tt.expectItems, pr.ProjectItems) }) } } From e843349a13fe28b89d758191e49baffe6e875a35 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 27 Nov 2023 12:25:51 +0000 Subject: [PATCH 4/9] Extract Project Status into fragment to fix unmarshal error --- api/export_pr.go | 4 +- api/export_pr_test.go | 169 ++++++++++++++++++++------------ api/queries_issue.go | 12 ++- api/queries_projects_v2_test.go | 14 +-- 4 files changed, 123 insertions(+), 76 deletions(-) diff --git a/api/export_pr.go b/api/export_pr.go index bb3310811..8c17ea510 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -23,7 +23,7 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} { items := make([]map[string]interface{}, 0, len(issue.ProjectItems.Nodes)) for _, n := range issue.ProjectItems.Nodes { items = append(items, map[string]interface{}{ - "status": n.Status, + "status": n.Status.StatusFragment, "title": n.Project.Title, }) } @@ -109,7 +109,7 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} { items := make([]map[string]interface{}, 0, len(pr.ProjectItems.Nodes)) for _, n := range pr.ProjectItems.Nodes { items = append(items, map[string]interface{}{ - "status": n.Status, + "status": n.Status.StatusFragment, "title": n.Project.Title, }) } diff --git a/api/export_pr_test.go b/api/export_pr_test.go index b7f4dcddb..50e346dee 100644 --- a/api/export_pr_test.go +++ b/api/export_pr_test.go @@ -75,38 +75,6 @@ func TestIssue_ExportData(t *testing.T) { } `), }, - { - name: "project items", - fields: []string{"projectItems"}, - inputJSON: heredoc.Doc(` - { "projectItems": { "nodes": [ - { - "id": "PVTI_id", - "project": { - "id": "PVT_id", - "title": "Some Project" - }, - "status": { - "name": "Todo", - "optionId": "abc123" - } - } - ] } } - `), - outputJSON: heredoc.Doc(` - { - "projectItems": [ - { - "status": { - "optionId": "abc123", - "name": "Todo" - }, - "title": "Some Project" - } - ] - } - `), - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -213,38 +181,6 @@ func TestPullRequest_ExportData(t *testing.T) { } `), }, - { - name: "project items", - fields: []string{"projectItems"}, - inputJSON: heredoc.Doc(` - { "projectItems": { "nodes": [ - { - "id": "PVTPR_id", - "project": { - "id": "PVT_id", - "title": "Some Project" - }, - "status": { - "name": "Todo", - "optionId": "abc123" - } - } - ] } } - `), - outputJSON: heredoc.Doc(` - { - "projectItems": [ - { - "status": { - "optionId": "abc123", - "name": "Todo" - }, - "title": "Some Project" - } - ] - } - `), - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -269,3 +205,108 @@ func TestPullRequest_ExportData(t *testing.T) { }) } } + +// The following tests exist separately from the table driven tests +// above because in the implementation code, the GraphQL request is +// performed by shurcool, and it handles the JSON decoding into the +// response struct, which is slightly different than the stdlib JSON +// decoder used by the rest of the requests when Finding an Issue or PR. +func TestIssueExportProjectItems(t *testing.T) { + issue := Issue{ + ProjectItems: ProjectItems{ + Nodes: []*ProjectV2Item{ + { + ID: "PVTI_lADOB-vozM4AVk16zgK6U50", + Project: struct { + ID string `json:"id"` + Title string `json:"title"` + }{ + ID: "PVT_kwDOB-vozM4AVk16", + Title: "Test Project", + }, + Status: Status{ + StatusFragment: struct { + OptionID string `json:"optionId"` + Name string `json:"name"` + }{ + OptionID: "47fc9ee4", + Name: "In Progress", + }, + }, + }, + }, + }, + } + + expectedExportedJSON := heredoc.Doc(` + { + "projectItems": [ + { + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + }, + "title": "Test Project" + } + ] + } + `) + + exported := issue.ExportData([]string{"projectItems"}) + + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + enc.SetIndent("", "\t") + require.NoError(t, enc.Encode(exported)) + require.Equal(t, expectedExportedJSON, buf.String()) +} + +func TestPRExportProjectItems(t *testing.T) { + pr := PullRequest{ + ProjectItems: ProjectItems{ + Nodes: []*ProjectV2Item{ + { + ID: "PVTI_lADOB-vozM4AVk16zgK6U50", + Project: struct { + ID string `json:"id"` + Title string `json:"title"` + }{ + ID: "PVT_kwDOB-vozM4AVk16", + Title: "Test Project", + }, + Status: Status{ + StatusFragment: struct { + OptionID string `json:"optionId"` + Name string `json:"name"` + }{ + OptionID: "47fc9ee4", + Name: "In Progress", + }, + }, + }, + }, + }, + } + + expectedExportedJSON := heredoc.Doc(` + { + "projectItems": [ + { + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + }, + "title": "Test Project" + } + ] + } + `) + + exported := pr.ExportData([]string{"projectItems"}) + + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + enc.SetIndent("", "\t") + require.NoError(t, enc.Encode(exported)) + require.Equal(t, expectedExportedJSON, buf.String()) +} diff --git a/api/queries_issue.go b/api/queries_issue.go index fdef7783f..1871294e7 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -106,10 +106,14 @@ type ProjectV2Item struct { ID string `json:"id"` Title string `json:"title"` } - Status struct { - OptionID string `json:"optionId" graphql:"... on ProjectV2ItemFieldSingleSelectValue{optionId}"` - Name string `json:"name" graphql:"... on ProjectV2ItemFieldSingleSelectValue{name}"` - } `graphql:"status:fieldValueByName(name: \"Status\")"` + Status Status `graphql:"status:fieldValueByName(name: \"Status\")"` +} + +type Status struct { + StatusFragment struct { + OptionID string `json:"optionId"` + Name string `json:"name"` + } `graphql:"... on ProjectV2ItemFieldSingleSelectValue"` } func (p ProjectCards) ProjectNames() []string { diff --git a/api/queries_projects_v2_test.go b/api/queries_projects_v2_test.go index 30d782bef..8cacad921 100644 --- a/api/queries_projects_v2_test.go +++ b/api/queries_projects_v2_test.go @@ -236,12 +236,14 @@ func TestProjectsV2ItemsForPullRequest(t *testing.T) { ID: "PVT_kwDOB-vozM4AVk16", Title: "Test Project", }, - Status: struct { - OptionID string `json:"optionId" graphql:"... on ProjectV2ItemFieldSingleSelectValue{optionId}"` - Name string `json:"name" graphql:"... on ProjectV2ItemFieldSingleSelectValue{name}"` - }{ - OptionID: "47fc9ee4", - Name: "In Progress", + Status: Status{ + StatusFragment: struct { + OptionID string `json:"optionId"` + Name string `json:"name"` + }{ + OptionID: "47fc9ee4", + Name: "In Progress", + }, }, }, }, From 7aeccdb7a08525bca2ee4d27c150d156f8e13e21 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 27 Nov 2023 15:18:13 +0100 Subject: [PATCH 5/9] Update pkg/cmd/pr/shared/finder_test.go Co-authored-by: Andy Feller --- pkg/cmd/pr/shared/finder_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index bfd82a2f2..6df8d3000 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -411,7 +411,7 @@ func TestFind(t *testing.T) { wantRepo: "https://github.com/OWNER/REPO", }, { - name: "inclduing project items", + name: "including project items", args: args{ selector: "", fields: []string{"projectItems"}, From e775bc64b8cf4314d9e42e832e1c48e2d0cb8a02 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 27 Nov 2023 16:59:28 +0100 Subject: [PATCH 6/9] Fix PR and Issue list Separate the GQL and JSON decoded types that we use for queries. --- api/export_pr.go | 4 +- api/export_pr_test.go | 169 ++++++++++++-------------------- api/queries_issue.go | 20 ++-- api/queries_projects_v2.go | 67 ++++++++++++- api/queries_projects_v2_test.go | 16 +-- pkg/cmd/issue/list/list_test.go | 150 ++++++++++++++++++++++++++++ pkg/cmd/pr/list/list_test.go | 146 +++++++++++++++++++++++++++ 7 files changed, 439 insertions(+), 133 deletions(-) diff --git a/api/export_pr.go b/api/export_pr.go index 8c17ea510..bb3310811 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -23,7 +23,7 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} { items := make([]map[string]interface{}, 0, len(issue.ProjectItems.Nodes)) for _, n := range issue.ProjectItems.Nodes { items = append(items, map[string]interface{}{ - "status": n.Status.StatusFragment, + "status": n.Status, "title": n.Project.Title, }) } @@ -109,7 +109,7 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} { items := make([]map[string]interface{}, 0, len(pr.ProjectItems.Nodes)) for _, n := range pr.ProjectItems.Nodes { items = append(items, map[string]interface{}{ - "status": n.Status.StatusFragment, + "status": n.Status, "title": n.Project.Title, }) } diff --git a/api/export_pr_test.go b/api/export_pr_test.go index 50e346dee..b7f4dcddb 100644 --- a/api/export_pr_test.go +++ b/api/export_pr_test.go @@ -75,6 +75,38 @@ func TestIssue_ExportData(t *testing.T) { } `), }, + { + name: "project items", + fields: []string{"projectItems"}, + inputJSON: heredoc.Doc(` + { "projectItems": { "nodes": [ + { + "id": "PVTI_id", + "project": { + "id": "PVT_id", + "title": "Some Project" + }, + "status": { + "name": "Todo", + "optionId": "abc123" + } + } + ] } } + `), + outputJSON: heredoc.Doc(` + { + "projectItems": [ + { + "status": { + "optionId": "abc123", + "name": "Todo" + }, + "title": "Some Project" + } + ] + } + `), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -181,6 +213,38 @@ func TestPullRequest_ExportData(t *testing.T) { } `), }, + { + name: "project items", + fields: []string{"projectItems"}, + inputJSON: heredoc.Doc(` + { "projectItems": { "nodes": [ + { + "id": "PVTPR_id", + "project": { + "id": "PVT_id", + "title": "Some Project" + }, + "status": { + "name": "Todo", + "optionId": "abc123" + } + } + ] } } + `), + outputJSON: heredoc.Doc(` + { + "projectItems": [ + { + "status": { + "optionId": "abc123", + "name": "Todo" + }, + "title": "Some Project" + } + ] + } + `), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -205,108 +269,3 @@ func TestPullRequest_ExportData(t *testing.T) { }) } } - -// The following tests exist separately from the table driven tests -// above because in the implementation code, the GraphQL request is -// performed by shurcool, and it handles the JSON decoding into the -// response struct, which is slightly different than the stdlib JSON -// decoder used by the rest of the requests when Finding an Issue or PR. -func TestIssueExportProjectItems(t *testing.T) { - issue := Issue{ - ProjectItems: ProjectItems{ - Nodes: []*ProjectV2Item{ - { - ID: "PVTI_lADOB-vozM4AVk16zgK6U50", - Project: struct { - ID string `json:"id"` - Title string `json:"title"` - }{ - ID: "PVT_kwDOB-vozM4AVk16", - Title: "Test Project", - }, - Status: Status{ - StatusFragment: struct { - OptionID string `json:"optionId"` - Name string `json:"name"` - }{ - OptionID: "47fc9ee4", - Name: "In Progress", - }, - }, - }, - }, - }, - } - - expectedExportedJSON := heredoc.Doc(` - { - "projectItems": [ - { - "status": { - "optionId": "47fc9ee4", - "name": "In Progress" - }, - "title": "Test Project" - } - ] - } - `) - - exported := issue.ExportData([]string{"projectItems"}) - - buf := bytes.Buffer{} - enc := json.NewEncoder(&buf) - enc.SetIndent("", "\t") - require.NoError(t, enc.Encode(exported)) - require.Equal(t, expectedExportedJSON, buf.String()) -} - -func TestPRExportProjectItems(t *testing.T) { - pr := PullRequest{ - ProjectItems: ProjectItems{ - Nodes: []*ProjectV2Item{ - { - ID: "PVTI_lADOB-vozM4AVk16zgK6U50", - Project: struct { - ID string `json:"id"` - Title string `json:"title"` - }{ - ID: "PVT_kwDOB-vozM4AVk16", - Title: "Test Project", - }, - Status: Status{ - StatusFragment: struct { - OptionID string `json:"optionId"` - Name string `json:"name"` - }{ - OptionID: "47fc9ee4", - Name: "In Progress", - }, - }, - }, - }, - }, - } - - expectedExportedJSON := heredoc.Doc(` - { - "projectItems": [ - { - "status": { - "optionId": "47fc9ee4", - "name": "In Progress" - }, - "title": "Test Project" - } - ] - } - `) - - exported := pr.ExportData([]string{"projectItems"}) - - buf := bytes.Buffer{} - enc := json.NewEncoder(&buf) - enc.SetIndent("", "\t") - require.NoError(t, enc.Encode(exported)) - require.Equal(t, expectedExportedJSON, buf.String()) -} diff --git a/api/queries_issue.go b/api/queries_issue.go index 1871294e7..62531e84e 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -102,18 +102,18 @@ type ProjectInfo struct { type ProjectV2Item struct { ID string `json:"id"` - Project struct { - ID string `json:"id"` - Title string `json:"title"` - } - Status Status `graphql:"status:fieldValueByName(name: \"Status\")"` + Project ProjectV2ItemProject + Status ProjectV2ItemStatus } -type Status struct { - StatusFragment struct { - OptionID string `json:"optionId"` - Name string `json:"name"` - } `graphql:"... on ProjectV2ItemFieldSingleSelectValue"` +type ProjectV2ItemProject struct { + ID string `json:"id"` + Title string `json:"title"` +} + +type ProjectV2ItemStatus struct { + OptionID string `json:"optionId"` + Name string `json:"name"` } func (p ProjectCards) ProjectNames() []string { diff --git a/api/queries_projects_v2.go b/api/queries_projects_v2.go index a602653af..958be6616 100644 --- a/api/queries_projects_v2.go +++ b/api/queries_projects_v2.go @@ -61,11 +61,27 @@ func UpdateProjectV2Items(client *Client, repo ghrepo.Interface, addProjectItems // ProjectsV2ItemsForIssue fetches all ProjectItems for an issue. func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue) error { + type projectV2ItemStatus struct { + StatusFragment struct { + OptionID string `json:"optionId"` + Name string `json:"name"` + } `graphql:"... on ProjectV2ItemFieldSingleSelectValue"` + } + + type projectV2Item struct { + ID string `json:"id"` + Project struct { + ID string `json:"id"` + Title string `json:"title"` + } + Status projectV2ItemStatus `graphql:"status:fieldValueByName(name: \"Status\")"` + } + type response struct { Repository struct { Issue struct { ProjectItems struct { - Nodes []*ProjectV2Item + Nodes []*projectV2Item PageInfo struct { HasNextPage bool EndCursor string @@ -87,7 +103,20 @@ func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue if err != nil { return err } - items.Nodes = append(items.Nodes, query.Repository.Issue.ProjectItems.Nodes...) + for _, projectItemNode := range query.Repository.Issue.ProjectItems.Nodes { + items.Nodes = append(items.Nodes, &ProjectV2Item{ + ID: projectItemNode.ID, + Project: ProjectV2ItemProject{ + ID: projectItemNode.Project.ID, + Title: projectItemNode.Project.Title, + }, + Status: ProjectV2ItemStatus{ + OptionID: projectItemNode.Status.StatusFragment.OptionID, + Name: projectItemNode.Status.StatusFragment.Name, + }, + }) + } + if !query.Repository.Issue.ProjectItems.PageInfo.HasNextPage { break } @@ -99,11 +128,27 @@ func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue // ProjectsV2ItemsForPullRequest fetches all ProjectItems for a pull request. func ProjectsV2ItemsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) error { + type projectV2ItemStatus struct { + StatusFragment struct { + OptionID string `json:"optionId"` + Name string `json:"name"` + } `graphql:"... on ProjectV2ItemFieldSingleSelectValue"` + } + + type projectV2Item struct { + ID string `json:"id"` + Project struct { + ID string `json:"id"` + Title string `json:"title"` + } + Status projectV2ItemStatus `graphql:"status:fieldValueByName(name: \"Status\")"` + } + type response struct { Repository struct { PullRequest struct { ProjectItems struct { - Nodes []*ProjectV2Item + Nodes []*projectV2Item PageInfo struct { HasNextPage bool EndCursor string @@ -125,7 +170,21 @@ func ProjectsV2ItemsForPullRequest(client *Client, repo ghrepo.Interface, pr *Pu if err != nil { return err } - items.Nodes = append(items.Nodes, query.Repository.PullRequest.ProjectItems.Nodes...) + + for _, projectItemNode := range query.Repository.PullRequest.ProjectItems.Nodes { + items.Nodes = append(items.Nodes, &ProjectV2Item{ + ID: projectItemNode.ID, + Project: ProjectV2ItemProject{ + ID: projectItemNode.Project.ID, + Title: projectItemNode.Project.Title, + }, + Status: ProjectV2ItemStatus{ + OptionID: projectItemNode.Status.StatusFragment.OptionID, + Name: projectItemNode.Status.StatusFragment.Name, + }, + }) + } + if !query.Repository.PullRequest.ProjectItems.PageInfo.HasNextPage { break } diff --git a/api/queries_projects_v2_test.go b/api/queries_projects_v2_test.go index 8cacad921..fae49c926 100644 --- a/api/queries_projects_v2_test.go +++ b/api/queries_projects_v2_test.go @@ -229,21 +229,13 @@ func TestProjectsV2ItemsForPullRequest(t *testing.T) { Nodes: []*ProjectV2Item{ { ID: "PVTI_lADOB-vozM4AVk16zgK6U50", - Project: struct { - ID string `json:"id"` - Title string `json:"title"` - }{ + Project: ProjectV2ItemProject{ ID: "PVT_kwDOB-vozM4AVk16", Title: "Test Project", }, - Status: Status{ - StatusFragment: struct { - OptionID string `json:"optionId"` - Name string `json:"name"` - }{ - OptionID: "47fc9ee4", - Name: "In Progress", - }, + Status: ProjectV2ItemStatus{ + OptionID: "47fc9ee4", + Name: "In Progress", }, }, }, diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index e816500c2..5bed620c1 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -19,6 +20,7 @@ import ( "github.com/cli/cli/v2/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { @@ -456,3 +458,151 @@ func Test_issueList(t *testing.T) { }) } } + +func TestIssueList_withProjectItems(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "hasIssuesEnabled": true, + "issues": { + "totalCount": 1, + "nodes": [ + { + "projectItems": { + "nodes": [ + { + "id": "PVTI_lAHOAA3WC84AW6WNzgJ8rnQ", + "project": { + "id": "PVT_kwHOAA3WC84AW6WN", + "title": "Test Public Project" + }, + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + } + } + ], + "totalCount": 1 + } + } + ] + } + } + } + }`, func(_ string, params map[string]interface{}) { + require.Equal(t, map[string]interface{}{ + "owner": "OWNER", + "repo": "REPO", + "limit": float64(30), + "states": []interface{}{"OPEN"}, + }, params) + })) + + client := &http.Client{Transport: reg} + issuesAndTotalCount, err := issueList( + client, + ghrepo.New("OWNER", "REPO"), + prShared.FilterOptions{ + Entity: "issue", + }, + 30, + ) + + require.NoError(t, err) + require.Len(t, issuesAndTotalCount.Issues, 1) + require.Len(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes, 1) + + require.Equal(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes[0].ID, "PVTI_lAHOAA3WC84AW6WNzgJ8rnQ") + + expectedProject := api.ProjectV2ItemProject{ + ID: "PVT_kwHOAA3WC84AW6WN", + Title: "Test Public Project", + } + require.Equal(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes[0].Project, expectedProject) + + expectedStatus := api.ProjectV2ItemStatus{ + OptionID: "47fc9ee4", + Name: "In Progress", + } + require.Equal(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes[0].Status, expectedStatus) +} + +func TestIssueList_Search_withProjectItems(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query IssueSearch\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "hasIssuesEnabled": true + }, + "search": { + "issueCount": 1, + "nodes": [ + { + "projectItems": { + "nodes": [ + { + "id": "PVTI_lAHOAA3WC84AW6WNzgJ8rl0", + "project": { + "id": "PVT_kwHOAA3WC84AW6WN", + "title": "Test Public Project" + }, + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + } + } + ], + "totalCount": 1 + } + } + ] + } + } + }`, func(_ string, params map[string]interface{}) { + require.Equal(t, map[string]interface{}{ + "owner": "OWNER", + "repo": "REPO", + "type": "ISSUE", + "limit": float64(30), + "query": "just used to force the search API branch repo:OWNER/REPO type:issue", + }, params) + })) + + client := &http.Client{Transport: reg} + issuesAndTotalCount, err := issueList( + client, + ghrepo.New("OWNER", "REPO"), + prShared.FilterOptions{ + Entity: "issue", + Search: "just used to force the search API branch", + }, + 30, + ) + + require.NoError(t, err) + require.Len(t, issuesAndTotalCount.Issues, 1) + require.Len(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes, 1) + + require.Equal(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes[0].ID, "PVTI_lAHOAA3WC84AW6WNzgJ8rl0") + + expectedProject := api.ProjectV2ItemProject{ + ID: "PVT_kwHOAA3WC84AW6WN", + Title: "Test Public Project", + } + require.Equal(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes[0].Project, expectedProject) + + expectedStatus := api.ProjectV2ItemStatus{ + OptionID: "47fc9ee4", + Name: "In Progress", + } + require.Equal(t, issuesAndTotalCount.Issues[0].ProjectItems.Nodes[0].Status, expectedStatus) +} diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go index 61a8e3698..869db9c38 100644 --- a/pkg/cmd/pr/list/list_test.go +++ b/pkg/cmd/pr/list/list_test.go @@ -9,15 +9,18 @@ import ( "time" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" + prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { @@ -320,3 +323,146 @@ func TestPRList_web(t *testing.T) { }) } } + +func TestPRList_withProjectItems(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query PullRequestList\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "pullRequests": { + "totalCount": 1, + "nodes": [ + { + "projectItems": { + "nodes": [ + { + "id": "PVTI_lAHOAA3WC84AW6WNzgJ8rnQ", + "project": { + "id": "PVT_kwHOAA3WC84AW6WN", + "title": "Test Public Project" + }, + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + } + } + ], + "totalCount": 1 + } + } + ] + } + } + } + }`, func(_ string, params map[string]interface{}) { + require.Equal(t, map[string]interface{}{ + "owner": "OWNER", + "repo": "REPO", + "limit": float64(30), + "state": []interface{}{"OPEN"}, + }, params) + })) + + client := &http.Client{Transport: reg} + prsAndTotalCount, err := listPullRequests( + client, + ghrepo.New("OWNER", "REPO"), + prShared.FilterOptions{ + Entity: "pr", + State: "open", + }, + 30, + ) + + require.NoError(t, err) + require.Len(t, prsAndTotalCount.PullRequests, 1) + require.Len(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes, 1) + + require.Equal(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes[0].ID, "PVTI_lAHOAA3WC84AW6WNzgJ8rnQ") + + expectedProject := api.ProjectV2ItemProject{ + ID: "PVT_kwHOAA3WC84AW6WN", + Title: "Test Public Project", + } + require.Equal(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes[0].Project, expectedProject) + + expectedStatus := api.ProjectV2ItemStatus{ + OptionID: "47fc9ee4", + Name: "In Progress", + } + require.Equal(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes[0].Status, expectedStatus) +} + +func TestPRList_Search_withProjectItems(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query PullRequestSearch\b`), + httpmock.GraphQLQuery(`{ + "data": { + "search": { + "issueCount": 1, + "nodes": [ + { + "projectItems": { + "nodes": [ + { + "id": "PVTI_lAHOAA3WC84AW6WNzgJ8rl0", + "project": { + "id": "PVT_kwHOAA3WC84AW6WN", + "title": "Test Public Project" + }, + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + } + } + ], + "totalCount": 1 + } + } + ] + } + } + }`, func(_ string, params map[string]interface{}) { + require.Equal(t, map[string]interface{}{ + "limit": float64(30), + "q": "just used to force the search API branch repo:OWNER/REPO state:open type:pr", + }, params) + })) + + client := &http.Client{Transport: reg} + prsAndTotalCount, err := listPullRequests( + client, + ghrepo.New("OWNER", "REPO"), + prShared.FilterOptions{ + Entity: "pr", + State: "open", + Search: "just used to force the search API branch", + }, + 30, + ) + + require.NoError(t, err) + require.Len(t, prsAndTotalCount.PullRequests, 1) + require.Len(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes, 1) + + require.Equal(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes[0].ID, "PVTI_lAHOAA3WC84AW6WNzgJ8rl0") + + expectedProject := api.ProjectV2ItemProject{ + ID: "PVT_kwHOAA3WC84AW6WN", + Title: "Test Public Project", + } + require.Equal(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes[0].Project, expectedProject) + + expectedStatus := api.ProjectV2ItemStatus{ + OptionID: "47fc9ee4", + Name: "In Progress", + } + require.Equal(t, prsAndTotalCount.PullRequests[0].ProjectItems.Nodes[0].Status, expectedStatus) +} From 7a32b8b5f30c0e14ed8b7babc147e6fb172283d5 Mon Sep 17 00:00:00 2001 From: Mateus Marquezini Date: Mon, 27 Nov 2023 17:17:24 -0300 Subject: [PATCH 7/9] some code improvements after code review --- pkg/cmd/codespace/create.go | 10 +++++++--- pkg/cmd/codespace/create_test.go | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go index d8b24d016..821faa10b 100644 --- a/pkg/cmd/codespace/create.go +++ b/pkg/cmd/codespace/create.go @@ -24,6 +24,10 @@ const ( permissionsPollingTimeout = 1 * time.Minute ) +const ( + displayNameMaxLength = 48 // 48 is the max length of the display name in the API +) + var ( DEFAULT_DEVCONTAINER_DEFINITIONS = []string{".devcontainer.json", ".devcontainer/devcontainer.json"} ) @@ -110,7 +114,7 @@ func newCreateCmd(app *App) *cobra.Command { createCmd.Flags().DurationVar(&opts.idleTimeout, "idle-timeout", 0, "allowed inactivity before codespace is stopped, e.g. \"10m\", \"1h\"") createCmd.Flags().Var(&opts.retentionPeriod, "retention-period", "allowed time after shutting down before the codespace is automatically deleted (maximum 30 days), e.g. \"1h\", \"72h\"") createCmd.Flags().StringVar(&opts.devContainerPath, "devcontainer-path", "", "path to the devcontainer.json file to use when creating codespace") - createCmd.Flags().StringVarP(&opts.displayName, "display-name", "d", "", "display name for the codespace (48 characters or less)") + createCmd.Flags().StringVarP(&opts.displayName, "display-name", "d", "", fmt.Sprintf("display name for the codespace (%d characters or less)", displayNameMaxLength)) return createCmd } @@ -282,8 +286,8 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { } } - if len(opts.displayName) > 48 { // 48 is the max length of the display name in the API - return fmt.Errorf("error creating codespace: display name should contains 48 characters max or less") + if len(opts.displayName) > displayNameMaxLength { + return fmt.Errorf("error creating codespace: display name should contain a maximum of %d characters", displayNameMaxLength) } createParams := &api.CreateCodespaceParams{ diff --git a/pkg/cmd/codespace/create_test.go b/pkg/cmd/codespace/create_test.go index bbb8d84d0..78039db0d 100644 --- a/pkg/cmd/codespace/create_test.go +++ b/pkg/cmd/codespace/create_test.go @@ -201,7 +201,7 @@ func TestApp_Create(t *testing.T) { displayName: "this-is-very-long-display-name-with-49-characters", }, wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n", - wantErr: fmt.Errorf("error creating codespace: display name should contains 48 characters max or less"), + wantErr: fmt.Errorf("error creating codespace: display name should contain a maximum of %d characters", displayNameMaxLength), }, { name: "create codespace with devcontainer path results in selecting the correct machine type", From 2f31607096b39b4954fb5a211166b2b9e627fcb6 Mon Sep 17 00:00:00 2001 From: Mika <77445020+tal66@users.noreply.github.com> Date: Wed, 29 Nov 2023 08:54:55 +0000 Subject: [PATCH 8/9] Add timeout error in `gh auth status` (#8337) --- pkg/cmd/auth/status/status.go | 20 +++++++++++++------- pkg/cmd/auth/status/status_test.go | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index a064aabc2..831dfc503 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -3,6 +3,7 @@ package status import ( "errors" "fmt" + "net" "net/http" "path/filepath" "strings" @@ -107,13 +108,18 @@ func statusRun(opts *StatusOptions) error { scopesHeader, err := shared.GetScopes(httpClient, hostname, token) if err != nil { - addMsg("%s %s: authentication failed", cs.Red("X"), hostname) - addMsg("- The %s token in %s is invalid.", cs.Bold(hostname), tokenSource) - if tokenIsWriteable { - addMsg("- To re-authenticate, run: %s %s", - cs.Bold("gh auth login -h"), cs.Bold(hostname)) - addMsg("- To forget about this host, run: %s %s", - cs.Bold("gh auth logout -h"), cs.Bold(hostname)) + var networkError net.Error + if errors.As(err, &networkError) && networkError.Timeout() { + addMsg("%s %s: timeout trying to connect to host", cs.Red("X"), hostname) + } else { + addMsg("%s %s: authentication failed", cs.Red("X"), hostname) + addMsg("- The %s token in %s is invalid.", cs.Bold(hostname), tokenSource) + if tokenIsWriteable { + addMsg("- To re-authenticate, run: %s %s", + cs.Bold("gh auth login -h"), cs.Bold(hostname)) + addMsg("- To forget about this host, run: %s %s", + cs.Bold("gh auth logout -h"), cs.Bold(hostname)) + } } failed = true continue diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 07cbe0be8..dfebddda3 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -2,6 +2,7 @@ package status import ( "bytes" + "context" "net/http" "path/filepath" "strings" @@ -84,6 +85,26 @@ func Test_statusRun(t *testing.T) { wantOut string wantErrOut string }{ + { + name: "timeout error", + opts: &StatusOptions{ + Hostname: "joel.miller", + }, + cfgStubs: func(c *config.ConfigMock) { + c.Set("joel.miller", "oauth_token", "abc123") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "api/v3/"), func(req *http.Request) (*http.Response, error) { + // timeout error + return nil, context.DeadlineExceeded + }) + }, + wantErr: "SilentError", + wantErrOut: heredoc.Doc(` + joel.miller + X joel.miller: timeout trying to connect to host + `), + }, { name: "hostname set", opts: &StatusOptions{ From 3bb62d4724b324bb5d6f0395aca93c9b9834cab8 Mon Sep 17 00:00:00 2001 From: nelsonchen304 <63536132+nelsonchen304@users.noreply.github.com> Date: Wed, 29 Nov 2023 17:29:26 +0800 Subject: [PATCH 9/9] Actions: filter to workflow runs for a specific commit (#8350) --- pkg/cmd/run/list/list.go | 3 +++ pkg/cmd/run/list/list_test.go | 18 ++++++++++++++++++ pkg/cmd/run/shared/shared.go | 4 ++++ 3 files changed, 25 insertions(+) diff --git a/pkg/cmd/run/list/list.go b/pkg/cmd/run/list/list.go index 7776b3521..0913babf3 100644 --- a/pkg/cmd/run/list/list.go +++ b/pkg/cmd/run/list/list.go @@ -34,6 +34,7 @@ type ListOptions struct { Status string Event string Created string + Commit string now time.Time } @@ -77,6 +78,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.Actor, "user", "u", "", "Filter runs by user who triggered the run") cmd.Flags().StringVarP(&opts.Event, "event", "e", "", "Filter runs by which `event` triggered the run") cmd.Flags().StringVarP(&opts.Created, "created", "", "", "Filter runs by the `date` it was created") + cmd.Flags().StringVarP(&opts.Commit, "commit", "c", "", "Filter runs by the `SHA` of the commit") cmdutil.StringEnumFlag(cmd, &opts.Status, "status", "s", "", shared.AllStatuses, "Filter runs by status") cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.RunFields) @@ -103,6 +105,7 @@ func listRun(opts *ListOptions) error { Status: opts.Status, Event: opts.Event, Created: opts.Created, + Commit: opts.Commit, } opts.IO.StartProgressIndicator() diff --git a/pkg/cmd/run/list/list_test.go b/pkg/cmd/run/list/list_test.go index 31680928f..1c3062804 100644 --- a/pkg/cmd/run/list/list_test.go +++ b/pkg/cmd/run/list/list_test.go @@ -479,6 +479,24 @@ func TestListRun(t *testing.T) { wantErr: true, wantErrMsg: "no runs found", }, + { + name: "commit filter applied", + opts: &ListOptions{ + Limit: defaultLimit, + Commit: "1234567890123456789012345678901234567890", + }, + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{ + "head_sha": []string{"1234567890123456789012345678901234567890"}, + }), + httpmock.JSONResponse(shared.RunsPayload{}), + ) + }, + wantErr: true, + wantErrMsg: "no runs found", + }, } for _, tt := range tests { diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index 516e70b50..20846d60f 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -307,6 +307,7 @@ type FilterOptions struct { Status string Event string Created string + Commit string } // GetRunsWithFilter fetches 50 runs from the API and filters them in-memory @@ -358,6 +359,9 @@ func GetRuns(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, lim if opts.Created != "" { path += fmt.Sprintf("&created=%s", url.QueryEscape(opts.Created)) } + if opts.Commit != "" { + path += fmt.Sprintf("&head_sha=%s", url.QueryEscape(opts.Commit)) + } } var result *RunsPayload