diff --git a/api/queries_issue.go b/api/queries_issue.go index fdef7783f..62531e84e 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -102,14 +102,18 @@ type ProjectInfo struct { type ProjectV2Item struct { ID string `json:"id"` - Project 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\")"` + Project ProjectV2ItemProject + Status ProjectV2ItemStatus +} + +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 bf6a618ba..fae49c926 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,61 @@ 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: ProjectV2ItemProject{ + ID: "PVT_kwDOB-vozM4AVk16", + Title: "Test Project", + }, + Status: ProjectV2ItemStatus{ + OptionID: "47fc9ee4", + Name: "In Progress", + }, + }, + }, + }, + }, } for _, tt := range tests { @@ -199,11 +255,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) }) } } 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{ diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go index fa484173d..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") + createCmd.Flags().StringVarP(&opts.displayName, "display-name", "d", "", fmt.Sprintf("display name for the codespace (%d characters or less)", displayNameMaxLength)) return createCmd } @@ -282,6 +286,10 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { } } + if len(opts.displayName) > displayNameMaxLength { + return fmt.Errorf("error creating codespace: display name should contain a maximum of %d characters", displayNameMaxLength) + } + 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..78039db0d 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 contain a maximum of %d characters", displayNameMaxLength), + }, { name: "create codespace with devcontainer path results in selecting the correct machine type", fields: fields{ 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) +} diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 23138ed2d..6df8d3000 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: "including 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) { 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