From 350a1d642830ed79e7326c6130248457d716b66a Mon Sep 17 00:00:00 2001 From: scarf Date: Thu, 11 Sep 2025 01:28:55 +0900 Subject: [PATCH 01/92] build: customizable install `prefix` --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f823f6e938e..5529113d443 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,7 @@ endif ## Install/uninstall tasks are here for use on *nix platform. On Windows, there is no equivalent. DESTDIR := -prefix := /usr/local +prefix ?= /usr/local bindir := ${prefix}/bin datadir := ${prefix}/share mandir := ${datadir}/man From 3f0044fd94ace79714ee93df89b5efbd5b8bf242 Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Fri, 26 Dec 2025 14:43:52 +0800 Subject: [PATCH 02/92] fix: error when --remote flag used with repo argument When a repository argument is provided to `gh repo fork`, the command operates independently of the current local repository. Using --remote in this context is incompatible because there's no local repository to add the remote to. This change returns an explicit error when these flags are combined, providing clear feedback instead of silently ignoring the --remote flag. Fixes #2722 Signed-off-by: majiayu000 <1835304752@qq.com> --- pkg/cmd/repo/fork/fork.go | 4 ++++ pkg/cmd/repo/fork/fork_test.go | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index b620291d6af..3ebb02413bc 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -113,6 +113,10 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman opts.Rename = true // Any existing 'origin' will be renamed to upstream } + if opts.Repository != "" && cmd.Flags().Changed("remote") { + return cmdutil.FlagErrorf("the `--remote` flag is unsupported when a repository argument is provided") + } + if promptOk { // We can prompt for these if they were not specified. opts.PromptClone = !cmd.Flags().Changed("clone") diff --git a/pkg/cmd/repo/fork/fork_test.go b/pkg/cmd/repo/fork/fork_test.go index 1f0b9cef1e3..edf5f2763b9 100644 --- a/pkg/cmd/repo/fork/fork_test.go +++ b/pkg/cmd/repo/fork/fork_test.go @@ -144,6 +144,12 @@ func TestNewCmdFork(t *testing.T) { Rename: false, }, }, + { + name: "remote with repo argument", + cli: "foo/bar --remote", + wantErr: true, + errMsg: "the `--remote` flag is unsupported when a repository argument is provided", + }, } for _, tt := range tests { From 6f739036b8aa23936a89a7e69a4fb2f7acf647af Mon Sep 17 00:00:00 2001 From: elijahthis Date: Sun, 1 Feb 2026 23:12:01 +0100 Subject: [PATCH 03/92] fix: clarify scope error while creating issues for projects --- api/client.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/api/client.go b/api/client.go index e6ff59c592b..b83e9b70739 100644 --- a/api/client.go +++ b/api/client.go @@ -10,6 +10,7 @@ import ( "regexp" "strings" + "github.com/cli/cli/v2/pkg/set" ghAPI "github.com/cli/go-gh/v2/pkg/api" ghauth "github.com/cli/go-gh/v2/pkg/auth" ) @@ -178,6 +179,10 @@ func handleResponse(err error) error { var gqlErr *ghAPI.GraphQLError if errors.As(err, &gqlErr) { + scopeErr := GenerateScopeErrorForGQL(gqlErr) + if scopeErr != nil { + return scopeErr + } return GraphQLError{ GraphQLError: gqlErr, } @@ -186,6 +191,40 @@ func handleResponse(err error) error { return err } +func GenerateScopeErrorForGQL(gqlErr *ghAPI.GraphQLError) error { + missing := set.NewStringSet() + for _, e := range gqlErr.Errors { + if e.Type != "INSUFFICIENT_SCOPES" { + continue + } + missing.AddValues(requiredScopesFromServerMessage(e.Message)) + } + if missing.Len() > 0 { + s := missing.ToSlice() + // TODO: this duplicates parts of generateScopesSuggestion + return fmt.Errorf( + "error: your authentication token is missing required scopes %v\n"+ + "To request it, run: gh auth refresh -s %s", + s, + strings.Join(s, ",")) + } + return nil +} + +var scopesRE = regexp.MustCompile(`one of the following scopes: \[(.+?)]`) + +func requiredScopesFromServerMessage(msg string) []string { + m := scopesRE.FindStringSubmatch(msg) + if m == nil { + return nil + } + var scopes []string + for _, mm := range strings.Split(m[1], ",") { + scopes = append(scopes, strings.Trim(mm, "' ")) + } + return scopes +} + // ScopesSuggestion is an error messaging utility that prints the suggestion to request additional OAuth // scopes in case a server response indicates that there are missing scopes. func ScopesSuggestion(resp *http.Response) string { From 71564fd4a15df4687e5d85962922067d69755b79 Mon Sep 17 00:00:00 2001 From: elijahthis Date: Sun, 1 Feb 2026 23:26:03 +0100 Subject: [PATCH 04/92] test: TestGenerateScopeErrorForGQL and TestRequiredScopesFromServerMessage --- api/client_test.go | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/api/client_test.go b/api/client_test.go index 1701a17a967..d961b05dce8 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/go-gh/v2/pkg/api" "github.com/stretchr/testify/assert" ) @@ -255,3 +256,79 @@ func TestHTTPHeaders(t *testing.T) { } assert.Equal(t, "", stderr.String()) } + +func TestGenerateScopeErrorForGQL(t *testing.T) { + tests := []struct { + name string + gqlError *api.GraphQLError + wantErr bool + expected string + }{ + { + name: "missing scope", + gqlError: &api.GraphQLError{ + Errors: []api.GraphQLErrorItem{ + { + Type: "INSUFFICIENT_SCOPES", + Message: "The 'addProjectV2ItemById' field requires one of the following scopes: ['project']", + }, + }, + }, + wantErr: true, + expected: "error: your authentication token is missing required scopes [project]\n" + + "To request it, run: gh auth refresh -s project", + }, + + { + name: "ignore non-scope errors", + gqlError: &api.GraphQLError{ + Errors: []api.GraphQLErrorItem{ + { + Type: "NOT_FOUND", + Message: "Could not resolve to a Repository", + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := GenerateScopeErrorForGQL(tt.gqlError) + if tt.wantErr { + assert.NotNil(t, err) + assert.Equal(t, tt.expected, err.Error()) + } else { + assert.Nil(t, err) + } + }) + } +} + +func TestRequiredScopesFromServerMessage(t *testing.T) { + tests := []struct { + msg string + expected []string + }{ + { + msg: "requires one of the following scopes: ['project']", + expected: []string{"project"}, + }, + { + msg: "requires one of the following scopes: ['repo', 'read:org']", + expected: []string{"repo", "read:org"}, + }, + { + msg: "no match here", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.msg, func(t *testing.T) { + output := requiredScopesFromServerMessage(tt.msg) + assert.Equal(t, tt.expected, output) + }) + } +} From fc8c0f1110171c3e4dffc77b3e8b7d4ed70785ba Mon Sep 17 00:00:00 2001 From: elijahthis Date: Tue, 3 Feb 2026 23:59:15 +0100 Subject: [PATCH 05/92] trigger rerun From 9904f7d1b9070bc22a1bccca0aa2c7ed150f2ec0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:50:56 -0700 Subject: [PATCH 06/92] gh pr create: CCR and multiselectwithsearch --- api/queries_pr.go | 170 +++++++- api/queries_pr_test.go | 167 ++++++++ pkg/cmd/issue/create/create.go | 2 +- pkg/cmd/pr/create/create.go | 28 +- pkg/cmd/pr/create/create_test.go | 672 ++++++++++++++++++------------- pkg/cmd/pr/shared/params.go | 42 +- pkg/cmd/pr/shared/state.go | 1 + pkg/cmd/pr/shared/survey.go | 16 +- pkg/cmd/pr/shared/survey_test.go | 8 +- 9 files changed, 793 insertions(+), 313 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index bb5438fb3c9..632e7dd7522 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -615,29 +615,41 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter } } - // reviewers are requested in yet another additional mutation - reviewParams := make(map[string]interface{}) - if ids, ok := params["userReviewerIds"]; ok && !isBlank(ids) { - reviewParams["userIds"] = ids - } - if ids, ok := params["teamReviewerIds"]; ok && !isBlank(ids) { - reviewParams["teamIds"] = ids - } + // Request reviewers using either login-based (github.com) or ID-based (GHES) mutation + userLogins, hasUserLogins := params["userReviewerLogins"].([]string) + teamSlugs, hasTeamSlugs := params["teamReviewerSlugs"].([]string) - //TODO: How much work to extract this into own method and use for create and edit? - if len(reviewParams) > 0 { - reviewQuery := ` + if hasUserLogins || hasTeamSlugs { + // Use login-based mutation (RequestReviewsByLogin) for github.com + err := RequestReviewsByLogin(client, repo, pr.ID, userLogins, nil, teamSlugs, true) + if err != nil { + return pr, err + } + } else { + // Use ID-based mutation (requestReviews) for GHES compatibility + reviewParams := make(map[string]interface{}) + if ids, ok := params["userReviewerIds"]; ok && !isBlank(ids) { + reviewParams["userIds"] = ids + } + if ids, ok := params["teamReviewerIds"]; ok && !isBlank(ids) { + reviewParams["teamIds"] = ids + } + + //TODO: How much work to extract this into own method and use for create and edit? + if len(reviewParams) > 0 { + reviewQuery := ` mutation PullRequestCreateRequestReviews($input: RequestReviewsInput!) { requestReviews(input: $input) { clientMutationId } }` - reviewParams["pullRequestId"] = pr.ID - reviewParams["union"] = true - variables := map[string]interface{}{ - "input": reviewParams, - } - err := client.GraphQL(repo.RepoHost(), reviewQuery, variables, &result) - if err != nil { - return pr, err + reviewParams["pullRequestId"] = pr.ID + reviewParams["union"] = true + variables := map[string]interface{}{ + "input": reviewParams, + } + err := client.GraphQL(repo.RepoHost(), reviewQuery, variables, &result) + if err != nil { + return pr, err + } } } @@ -1109,6 +1121,126 @@ func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, return candidates, moreResults, nil } +// SuggestedReviewerActorsForRepo fetches potential reviewers for a repository. +// Unlike SuggestedReviewerActors, this doesn't require an existing PR - used for gh pr create. +// It combines results from two sources using a cascading quota system: +// - repository collaborators (base quota: 5) +// - organization teams (base quota: 5 + unfilled from collaborators) +// +// This ensures we show up to 10 total candidates, with each source filling any +// unfilled quota from the previous source. Results are deduplicated. +// Returns the candidates, a MoreResults count, and an error. +func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query string) ([]ReviewerCandidate, int, error) { + type responseData struct { + Repository struct { + // Check for Copilot availability by looking at any open PR's suggested reviewers + PullRequests struct { + Nodes []struct { + SuggestedActors struct { + Nodes []struct { + Reviewer struct { + TypeName string `graphql:"__typename"` + Bot struct { + Login string + } `graphql:"... on Bot"` + } + } + } `graphql:"suggestedReviewerActors(first: 10)"` + } + } `graphql:"pullRequests(first: 1, states: [OPEN])"` + Collaborators struct { + Nodes []struct { + Login string + Name string + } + } `graphql:"collaborators(first: 10, query: $query)"` + CollaboratorsTotalCount struct { + TotalCount int + } `graphql:"collaboratorsTotalCount: collaborators(first: 0)"` + } `graphql:"repository(owner: $owner, name: $name)"` + Organization struct { + Teams struct { + Nodes []struct { + Slug string + } + } `graphql:"teams(first: 10, query: $query)"` + TeamsTotalCount struct { + TotalCount int + } `graphql:"teamsTotalCount: teams(first: 0)"` + } `graphql:"organization(login: $owner)"` + } + + variables := map[string]interface{}{ + "query": githubv4.String(query), + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + } + + var result responseData + err := client.Query(repo.RepoHost(), "SuggestedReviewerActorsForRepo", &result, variables) + // Handle the case where the owner is not an organization - the query still returns + // partial data (repository), so we can continue processing. + if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) { + return nil, 0, err + } + + // Build candidates using cascading quota logic + seen := make(map[string]bool) + var candidates []ReviewerCandidate + const baseQuota = 5 + + // Check for Copilot availability from open PR's suggested reviewers + for _, pr := range result.Repository.PullRequests.Nodes { + for _, actor := range pr.SuggestedActors.Nodes { + if actor.Reviewer.TypeName == "Bot" && actor.Reviewer.Bot.Login == CopilotReviewerLogin { + candidates = append(candidates, NewReviewerBot(CopilotReviewerLogin)) + seen[CopilotReviewerLogin] = true + break + } + } + } + + // Collaborators + collaboratorsAdded := 0 + for _, c := range result.Repository.Collaborators.Nodes { + if collaboratorsAdded >= baseQuota { + break + } + if c.Login == "" { + continue + } + if !seen[c.Login] { + seen[c.Login] = true + candidates = append(candidates, NewReviewerUser(c.Login, c.Name)) + collaboratorsAdded++ + } + } + + // Teams: quota = base + unfilled from collaborators + teamsQuota := baseQuota + (baseQuota - collaboratorsAdded) + teamsAdded := 0 + ownerName := repo.RepoOwner() + for _, t := range result.Organization.Teams.Nodes { + if teamsAdded >= teamsQuota { + break + } + if t.Slug == "" { + continue + } + teamLogin := fmt.Sprintf("%s/%s", ownerName, t.Slug) + if !seen[teamLogin] { + seen[teamLogin] = true + candidates = append(candidates, NewReviewerTeam(ownerName, t.Slug)) + teamsAdded++ + } + } + + // MoreResults uses unfiltered total counts (teams will be 0 for personal repos) + moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Organization.TeamsTotalCount.TotalCount + + return candidates, moreResults, nil +} + func UpdatePullRequestBranch(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestBranchInput) error { var mutation struct { UpdatePullRequestBranch struct { diff --git a/api/queries_pr_test.go b/api/queries_pr_test.go index 69dc505ca70..4ee312b5d8b 100644 --- a/api/queries_pr_test.go +++ b/api/queries_pr_test.go @@ -362,3 +362,170 @@ func TestSuggestedReviewerActors(t *testing.T) { }) } } + +// mockReviewerResponseForRepo generates a GraphQL response for SuggestedReviewerActorsForRepo tests. +// It creates collaborators (c1, c2...) and teams (team1, team2...). +func mockReviewerResponseForRepo(collabs, teams, totalCollabs, totalTeams int) string { + return mockReviewerResponseForRepoWithCopilot(collabs, teams, totalCollabs, totalTeams, false) +} + +// mockReviewerResponseForRepoWithCopilot generates a GraphQL response for SuggestedReviewerActorsForRepo tests. +// If copilotAvailable is true, includes Copilot in the first open PR's suggested reviewers. +func mockReviewerResponseForRepoWithCopilot(collabs, teams, totalCollabs, totalTeams int, copilotAvailable bool) string { + var collabNodes, teamNodes []string + + for i := 1; i <= collabs; i++ { + collabNodes = append(collabNodes, + fmt.Sprintf(`{"login": "c%d", "name": "C%d"}`, i, i)) + } + for i := 1; i <= teams; i++ { + teamNodes = append(teamNodes, + fmt.Sprintf(`{"slug": "team%d"}`, i)) + } + + pullRequestsJSON := `"pullRequests": {"nodes": []}` + if copilotAvailable { + pullRequestsJSON = `"pullRequests": {"nodes": [{"suggestedReviewerActors": {"nodes": [{"reviewer": {"__typename": "Bot", "login": "copilot-pull-request-reviewer"}}]}}]}` + } + + return fmt.Sprintf(`{ + "data": { + "repository": { + %s, + "collaborators": {"nodes": [%s]}, + "collaboratorsTotalCount": {"totalCount": %d} + }, + "organization": { + "teams": {"nodes": [%s]}, + "teamsTotalCount": {"totalCount": %d} + } + } + }`, pullRequestsJSON, strings.Join(collabNodes, ","), totalCollabs, + strings.Join(teamNodes, ","), totalTeams) +} + +func TestSuggestedReviewerActorsForRepo(t *testing.T) { + tests := []struct { + name string + httpStubs func(*httpmock.Registry) + expectedCount int + expectedLogins []string + expectedMore int + expectError bool + }{ + { + name: "both sources plentiful - 5 each from cascading quota", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), + httpmock.StringResponse(mockReviewerResponseForRepo(6, 6, 20, 10))) + }, + expectedCount: 10, + expectedLogins: []string{"c1", "c2", "c3", "c4", "c5", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5"}, + expectedMore: 30, + }, + { + name: "few collaborators - teams fill gap", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), + httpmock.StringResponse(mockReviewerResponseForRepo(2, 10, 2, 15))) + }, + expectedCount: 10, + expectedLogins: []string{"c1", "c2", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5", "OWNER/team6", "OWNER/team7", "OWNER/team8"}, + expectedMore: 17, + }, + { + name: "no collaborators - teams only", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), + httpmock.StringResponse(mockReviewerResponseForRepo(0, 10, 0, 20))) + }, + expectedCount: 10, + expectedLogins: []string{"OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5", "OWNER/team6", "OWNER/team7", "OWNER/team8", "OWNER/team9", "OWNER/team10"}, + expectedMore: 20, + }, + { + name: "personal repo - no organization teams", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), + httpmock.StringResponse(`{ + "data": { + "repository": { + "pullRequests": {"nodes": []}, + "collaborators": {"nodes": [{"login": "c1", "name": "C1"}]}, + "collaboratorsTotalCount": {"totalCount": 3} + }, + "organization": null + }, + "errors": [{"message": "Could not resolve to an Organization with the login of 'OWNER'."}] + }`)) + }, + expectedCount: 1, + expectedLogins: []string{"c1"}, + expectedMore: 3, + }, + { + name: "empty repo", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), + httpmock.StringResponse(mockReviewerResponseForRepo(0, 0, 0, 0))) + }, + expectedCount: 0, + expectedLogins: []string{}, + expectedMore: 0, + }, + { + name: "copilot available - prepended to candidates", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), + httpmock.StringResponse(mockReviewerResponseForRepoWithCopilot(3, 2, 5, 5, true))) + }, + expectedCount: 6, + expectedLogins: []string{"copilot-pull-request-reviewer", "c1", "c2", "c3", "OWNER/team1", "OWNER/team2"}, + expectedMore: 10, + }, + { + name: "copilot not available - not included", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), + httpmock.StringResponse(mockReviewerResponseForRepoWithCopilot(3, 2, 5, 5, false))) + }, + expectedCount: 5, + expectedLogins: []string{"c1", "c2", "c3", "OWNER/team1", "OWNER/team2"}, + expectedMore: 10, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + + client := newTestClient(reg) + repo, _ := ghrepo.FromFullName("OWNER/REPO") + + candidates, moreResults, err := SuggestedReviewerActorsForRepo(client, repo, "") + if tt.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.expectedCount, len(candidates), "candidate count mismatch") + assert.Equal(t, tt.expectedMore, moreResults, "moreResults mismatch") + + logins := make([]string, len(candidates)) + for i, c := range candidates { + logins[i] = c.Login() + } + assert.Equal(t, tt.expectedLogins, logins) + }) + } +} diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index bc38c52b356..e3f2bd5e934 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -312,7 +312,7 @@ func createRun(opts *CreateOptions) (err error) { Repo: baseRepo, State: &tb, } - err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support) + err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support, nil) if err != nil { return } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 5b5d45f9c0a..d29240fe5fd 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -21,6 +21,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -397,11 +398,36 @@ func createRun(opts *CreateOptions) error { client := ctx.Client + // Detect ActorIsAssignable feature to determine if we can use search-based + // reviewer selection (github.com) or need to use traditional ID-based selection (GHES) + issueFeatures, _ := opts.Detector.IssueFeatures() + var reviewerSearchFunc func(string) prompter.MultiSelectSearchResult + if issueFeatures.ActorIsAssignable { + // Create search function for reviewer selection using login-based API + reviewerSearchFunc = func(query string) prompter.MultiSelectSearchResult { + candidates, moreResults, err := api.SuggestedReviewerActorsForRepo(client, ctx.PRRefs.BaseRepo(), query) + if err != nil { + return prompter.MultiSelectSearchResult{Err: err} + } + keys := make([]string, len(candidates)) + labels := make([]string, len(candidates)) + for i, c := range candidates { + keys[i] = c.Login() + labels[i] = c.DisplayName() + } + return prompter.MultiSelectSearchResult{Keys: keys, Labels: labels, MoreResults: moreResults} + } + } + state, err := NewIssueState(*ctx, *opts) if err != nil { return err } + if issueFeatures.ActorIsAssignable { + state.ActorReviewers = true + } + var openURL string if opts.WebMode { @@ -568,7 +594,7 @@ func createRun(opts *CreateOptions) error { Repo: ctx.PRRefs.BaseRepo(), State: state, } - err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, projectsV1Support) + err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, projectsV1Support, reviewerSearchFunc) if err != nil { return err } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 5ccffd7a8a8..254b545289c 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -434,92 +434,6 @@ func Test_createRun(t *testing.T) { }, expectedErrOut: "", }, - { - name: "dry-run-nontty-with-all-opts", - tty: false, - setup: func(opts *CreateOptions, t *testing.T) func() { - opts.TitleProvided = true - opts.BodyProvided = true - opts.Title = "TITLE" - opts.Body = "BODY" - opts.BaseBranch = "trunk" - opts.HeadBranch = "feature" - opts.Assignees = []string{"monalisa"} - opts.Labels = []string{"bug", "todo"} - opts.Projects = []string{"roadmap"} - opts.Reviewers = []string{"hubot", "monalisa", "/core", "/robots"} - opts.Milestone = "big one.oh" - opts.DryRun = true - return func() {} - }, - httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) - reg.Register( - httpmock.GraphQL(`query RepositoryAssignableUsers\b`), - httpmock.StringResponse(` - { "data": { "repository": { "assignableUsers": { - "nodes": [ - { "login": "hubot", "id": "HUBOTID", "name": "" }, - { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - reg.Register( - httpmock.GraphQL(`query RepositoryLabelList\b`), - httpmock.StringResponse(` - { "data": { "repository": { "labels": { - "nodes": [ - { "name": "TODO", "id": "TODOID" }, - { "name": "bug", "id": "BUGID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - reg.Register( - httpmock.GraphQL(`query RepositoryMilestoneList\b`), - httpmock.StringResponse(` - { "data": { "repository": { "milestones": { - "nodes": [ - { "title": "GA", "id": "GAID" }, - { "title": "Big One.oh", "id": "BIGONEID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - reg.Register( - httpmock.GraphQL(`query OrganizationTeamList\b`), - httpmock.StringResponse(` - { "data": { "organization": { "teams": { - "nodes": [ - { "slug": "core", "id": "COREID" }, - { "slug": "robots", "id": "ROBOTID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - mockRetrieveProjects(t, reg) - }, - expectedOutputs: []string{ - "Would have created a Pull Request with:", - `title: TITLE`, - `draft: false`, - `base: trunk`, - `head: feature`, - `labels: bug, todo`, - `reviewers: hubot, monalisa, /core, /robots`, - `assignees: monalisa`, - `milestones: big one.oh`, - `projects: roadmap`, - `maintainerCanModify: false`, - `body:`, - `BODY`, - ``, - }, - expectedErrOut: "", - }, { name: "dry-run-tty-with-default-base", tty: true, @@ -549,98 +463,6 @@ func Test_createRun(t *testing.T) { Dry Running pull request for feature into master in OWNER/REPO - `), - }, - { - name: "dry-run-tty-with-all-opts", - tty: true, - setup: func(opts *CreateOptions, t *testing.T) func() { - opts.TitleProvided = true - opts.BodyProvided = true - opts.Title = "TITLE" - opts.Body = "BODY" - opts.BaseBranch = "trunk" - opts.HeadBranch = "feature" - opts.Assignees = []string{"monalisa"} - opts.Labels = []string{"bug", "todo"} - opts.Projects = []string{"roadmap"} - opts.Reviewers = []string{"hubot", "monalisa", "/core", "/robots"} - opts.Milestone = "big one.oh" - opts.DryRun = true - return func() {} - }, - httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) - reg.Register( - httpmock.GraphQL(`query RepositoryAssignableUsers\b`), - httpmock.StringResponse(` - { "data": { "repository": { "assignableUsers": { - "nodes": [ - { "login": "hubot", "id": "HUBOTID", "name": "" }, - { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - reg.Register( - httpmock.GraphQL(`query RepositoryLabelList\b`), - httpmock.StringResponse(` - { "data": { "repository": { "labels": { - "nodes": [ - { "name": "TODO", "id": "TODOID" }, - { "name": "bug", "id": "BUGID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - reg.Register( - httpmock.GraphQL(`query RepositoryMilestoneList\b`), - httpmock.StringResponse(` - { "data": { "repository": { "milestones": { - "nodes": [ - { "title": "GA", "id": "GAID" }, - { "title": "Big One.oh", "id": "BIGONEID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - reg.Register( - httpmock.GraphQL(`query OrganizationTeamList\b`), - httpmock.StringResponse(` - { "data": { "organization": { "teams": { - "nodes": [ - { "slug": "core", "id": "COREID" }, - { "slug": "robots", "id": "ROBOTID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - mockRetrieveProjects(t, reg) - }, - expectedOutputs: []string{ - `Would have created a Pull Request with:`, - `Title: TITLE`, - `Draft: false`, - `Base: trunk`, - `Head: feature`, - `Labels: bug, todo`, - `Reviewers: hubot, monalisa, /core, /robots`, - `Assignees: monalisa`, - `Milestones: big one.oh`, - `Projects: roadmap`, - `MaintainerCanModify: false`, - `Body:`, - ``, - ` BODY `, - ``, - ``, - }, - expectedErrOut: heredoc.Doc(` - - Dry Running pull request for feature into trunk in OWNER/REPO - `), }, { @@ -1092,9 +914,6 @@ func Test_createRun(t *testing.T) { return func() {} }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) reg.Register( httpmock.GraphQL(`query RepositoryAssignableUsers\b`), httpmock.StringResponse(` @@ -1128,17 +947,6 @@ func Test_createRun(t *testing.T) { "pageInfo": { "hasNextPage": false } } } } } `)) - reg.Register( - httpmock.GraphQL(`query OrganizationTeamList\b`), - httpmock.StringResponse(` - { "data": { "organization": { "teams": { - "nodes": [ - { "slug": "core", "id": "COREID" }, - { "slug": "robots", "id": "ROBOTID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) mockRetrieveProjects(t, reg) reg.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), @@ -1171,15 +979,15 @@ func Test_createRun(t *testing.T) { assert.Equal(t, "BIGONEID", inputs["milestoneId"]) })) reg.Register( - httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`), + httpmock.GraphQL(`mutation RequestReviewsByLogin\b`), httpmock.GraphQLMutation(` - { "data": { "requestReviews": { + { "data": { "requestReviewsByLogin": { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) - assert.Equal(t, []interface{}{"HUBOTID", "MONAID"}, inputs["userIds"]) - assert.Equal(t, []interface{}{"COREID", "ROBOTID"}, inputs["teamIds"]) + assert.Equal(t, []interface{}{"hubot", "monalisa"}, inputs["userLogins"]) + assert.Equal(t, []interface{}{"core", "robots"}, inputs["teamSlugs"]) assert.Equal(t, true, inputs["union"]) })) }, @@ -1680,7 +1488,7 @@ func Test_createRun(t *testing.T) { expectedOut: "https://github.com/OWNER/REPO/pull/12\n", }, { - name: "fetch org teams non-interactively if reviewer contains any team", + name: "request reviewers by login", setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true opts.BodyProvided = true @@ -1700,78 +1508,399 @@ func Test_createRun(t *testing.T) { } } } }`, func(input map[string]interface{}) {})) reg.Register( - httpmock.GraphQL(`query RepositoryAssignableUsers\b`), - httpmock.StringResponse(` - { "data": { "repository": { "assignableUsers": { - "nodes": [ - { "login": "hubot", "id": "HUBOTID" }, - { "login": "MonaLisa", "id": "MONAID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(` - { "data": { "viewer": { "login": "monalisa" } } } - `)) - reg.Register( - httpmock.GraphQL(`query OrganizationTeamList\b`), - httpmock.StringResponse(` - { "data": { "organization": { "teams": { - "nodes": [ - { "slug": "core", "id": "COREID" }, - { "slug": "robots", "id": "ROBOTID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - reg.Register( - httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`), + httpmock.GraphQL(`mutation RequestReviewsByLogin\b`), httpmock.GraphQLMutation(` - { "data": { "requestReviews": { + { "data": { "requestReviewsByLogin": { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) - assert.Equal(t, []interface{}{"HUBOTID", "MONAID"}, inputs["userIds"]) - assert.Equal(t, []interface{}{"COREID", "ROBOTID"}, inputs["teamIds"]) + assert.Equal(t, []interface{}{"hubot", "monalisa"}, inputs["userLogins"]) + assert.Equal(t, []interface{}{"core", "robots"}, inputs["teamSlugs"]) assert.Equal(t, true, inputs["union"]) })) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "", }, - { - name: "do not fetch org teams non-interactively if reviewer does not contain any team", - setup: func(opts *CreateOptions, t *testing.T) func() { - opts.TitleProvided = true - opts.BodyProvided = true - opts.Title = "my title" - opts.Body = "my body" - opts.Reviewers = []string{"hubot", "monalisa"} - opts.HeadBranch = "feature" - return func() {} - }, - httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`mutation PullRequestCreate\b`), - httpmock.GraphQLMutation(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12", - "id": "NEWPULLID" - } } } }`, - func(input map[string]interface{}) {})) - reg.Register( - httpmock.GraphQL(`query RepositoryAssignableUsers\b`), - httpmock.StringResponse(` - { "data": { "repository": { "assignableUsers": { - "nodes": [ - { "login": "hubot", "id": "HUBOTID" }, - { "login": "MonaLisa", "id": "MONAID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + branch := "feature" + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "master") + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg, t) + } + + pm := &prompter.PrompterMock{} + + if tt.promptStubs != nil { + tt.promptStubs(pm) + } + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + if !tt.customBranchConfig { + cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") + } + + if tt.cmdStubs != nil { + tt.cmdStubs(cs) + } + + opts := CreateOptions{} + opts.Prompter = pm + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + ios.SetStdinTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + + browser := &browser.Stub{} + opts.IO = ios + opts.Browser = browser + opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + opts.Config = func() (gh.Config, error) { + return config.NewBlankConfig(), nil + } + opts.Remotes = func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{ + Name: "origin", + Resolved: "base", + }, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + } + opts.Branch = func() (string, error) { + return branch, nil + } + opts.Finder = shared.NewMockFinder(branch, nil, nil) + opts.GitClient = &git.Client{ + GhPath: "some/path/gh", + GitPath: "some/path/git", + } + cleanSetup := func() {} + if tt.setup != nil { + cleanSetup = tt.setup(&opts, t) + } + defer cleanSetup() + + // All tests in this function use github.com behavior + opts.Detector = &fd.EnabledDetectorMock{} + + if opts.HeadBranch == "" { + cs.Register(`git status --porcelain`, 0, "") + } + + err := createRun(&opts) + output := &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + BrowsedURL: browser.BrowsedURL(), + } + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + if tt.expectedOut != "" { + assert.Equal(t, tt.expectedOut, output.String()) + } + if len(tt.expectedOutputs) > 0 { + assert.Equal(t, tt.expectedOutputs, strings.Split(output.String(), "\n")) + } + assert.Equal(t, tt.expectedErrOut, output.Stderr()) + assert.Equal(t, tt.expectedBrowse, output.BrowsedURL) + } + }) + } +} + +func Test_createRun_GHES(t *testing.T) { + tests := []struct { + name string + setup func(*CreateOptions, *testing.T) func() + cmdStubs func(*run.CommandStubber) + promptStubs func(*prompter.PrompterMock) + httpStubs func(*httpmock.Registry, *testing.T) + expectedOutputs []string + expectedOut string + expectedErrOut string + tty bool + customBranchConfig bool + }{ + { + name: "dry-run-nontty-with-all-opts", + tty: false, + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "TITLE" + opts.Body = "BODY" + opts.BaseBranch = "trunk" + opts.HeadBranch = "feature" + opts.Assignees = []string{"monalisa"} + opts.Labels = []string{"bug", "todo"} + opts.Reviewers = []string{"hubot", "monalisa", "/core", "/robots"} + opts.Milestone = "big one.oh" + opts.DryRun = true + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) + reg.Register( + httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + httpmock.StringResponse(` + { "data": { "repository": { "assignableUsers": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID", "name": "" }, + { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query RepositoryLabelList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "labels": { + "nodes": [ + { "name": "TODO", "id": "TODOID" }, + { "name": "bug", "id": "BUGID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query RepositoryMilestoneList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "milestones": { + "nodes": [ + { "title": "GA", "id": "GAID" }, + { "title": "Big One.oh", "id": "BIGONEID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationTeamList\b`), + httpmock.StringResponse(` + { "data": { "organization": { "teams": { + "nodes": [ + { "slug": "core", "id": "COREID" }, + { "slug": "robots", "id": "ROBOTID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + }, + expectedOutputs: []string{ + "Would have created a Pull Request with:", + `title: TITLE`, + `draft: false`, + `base: trunk`, + `head: feature`, + `labels: bug, todo`, + `reviewers: hubot, monalisa, /core, /robots`, + `assignees: monalisa`, + `milestones: big one.oh`, + `maintainerCanModify: false`, + `body:`, + `BODY`, + ``, + }, + expectedErrOut: "", + }, + { + name: "dry-run-tty-with-all-opts", + tty: true, + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "TITLE" + opts.Body = "BODY" + opts.BaseBranch = "trunk" + opts.HeadBranch = "feature" + opts.Assignees = []string{"monalisa"} + opts.Labels = []string{"bug", "todo"} + opts.Reviewers = []string{"hubot", "monalisa", "/core", "/robots"} + opts.Milestone = "big one.oh" + opts.DryRun = true + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) + reg.Register( + httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + httpmock.StringResponse(` + { "data": { "repository": { "assignableUsers": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID", "name": "" }, + { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query RepositoryLabelList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "labels": { + "nodes": [ + { "name": "TODO", "id": "TODOID" }, + { "name": "bug", "id": "BUGID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query RepositoryMilestoneList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "milestones": { + "nodes": [ + { "title": "GA", "id": "GAID" }, + { "title": "Big One.oh", "id": "BIGONEID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationTeamList\b`), + httpmock.StringResponse(` + { "data": { "organization": { "teams": { + "nodes": [ + { "slug": "core", "id": "COREID" }, + { "slug": "robots", "id": "ROBOTID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + }, + expectedOutputs: []string{ + `Would have created a Pull Request with:`, + `Title: TITLE`, + `Draft: false`, + `Base: trunk`, + `Head: feature`, + `Labels: bug, todo`, + `Reviewers: hubot, monalisa, /core, /robots`, + `Assignees: monalisa`, + `Milestones: big one.oh`, + `MaintainerCanModify: false`, + `Body:`, + ``, + ` BODY `, + ``, + ``, + }, + expectedErrOut: heredoc.Doc(` + + Dry Running pull request for feature into trunk in OWNER/REPO + + `), + }, + { + name: "fetch org teams non-interactively if reviewer contains any team", + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "my title" + opts.Body = "my body" + opts.Reviewers = []string{"hubot", "monalisa", "org/core", "org/robots"} + opts.HeadBranch = "feature" + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12", + "id": "NEWPULLID" + } } } }`, + func(input map[string]interface{}) {})) + reg.Register( + httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + httpmock.StringResponse(` + { "data": { "repository": { "assignableUsers": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID" }, + { "login": "MonaLisa", "id": "MONAID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(` + { "data": { "viewer": { "login": "monalisa" } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationTeamList\b`), + httpmock.StringResponse(` + { "data": { "organization": { "teams": { + "nodes": [ + { "slug": "core", "id": "COREID" }, + { "slug": "robots", "id": "ROBOTID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`), + httpmock.GraphQLMutation(` + { "data": { "requestReviews": { + "clientMutationId": "" + } } } + `, func(inputs map[string]interface{}) { + assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) + assert.Equal(t, []interface{}{"HUBOTID", "MONAID"}, inputs["userIds"]) + assert.Equal(t, []interface{}{"COREID", "ROBOTID"}, inputs["teamIds"]) + assert.Equal(t, true, inputs["union"]) + })) + }, + expectedOut: "https://github.com/OWNER/REPO/pull/12\n", + expectedErrOut: "", + }, + { + name: "do not fetch org teams non-interactively if reviewer does not contain any team", + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "my title" + opts.Body = "my body" + opts.Reviewers = []string{"hubot", "monalisa"} + opts.HeadBranch = "feature" + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12", + "id": "NEWPULLID" + } } } }`, + func(input map[string]interface{}) {})) + reg.Register( + httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + httpmock.StringResponse(` + { "data": { "repository": { "assignableUsers": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID" }, + { "login": "MonaLisa", "id": "MONAID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } `)) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), @@ -1951,11 +2080,10 @@ func Test_createRun(t *testing.T) { } opts := CreateOptions{} - opts.Detector = &fd.EnabledDetectorMock{} + opts.Detector = &fd.DisabledDetectorMock{} opts.Prompter = pm ios, _, stdout, stderr := iostreams.Test() - // TODO do i need to bother with this ios.SetStdoutTTY(tt.tty) ios.SetStdinTTY(tt.tty) ios.SetStderrTTY(tt.tty) @@ -1999,23 +2127,17 @@ func Test_createRun(t *testing.T) { err := createRun(&opts) output := &test.CmdOut{ - OutBuf: stdout, - ErrBuf: stderr, - BrowsedURL: browser.BrowsedURL(), + OutBuf: stdout, + ErrBuf: stderr, } - if tt.wantErr != "" { - assert.EqualError(t, err, tt.wantErr) - } else { - assert.NoError(t, err) - if tt.expectedOut != "" { - assert.Equal(t, tt.expectedOut, output.String()) - } - if len(tt.expectedOutputs) > 0 { - assert.Equal(t, tt.expectedOutputs, strings.Split(output.String(), "\n")) - } - assert.Equal(t, tt.expectedErrOut, output.Stderr()) - assert.Equal(t, tt.expectedBrowse, output.BrowsedURL) + assert.NoError(t, err) + if tt.expectedOut != "" { + assert.Equal(t, tt.expectedOut, output.String()) + } + if len(tt.expectedOutputs) > 0 { + assert.Equal(t, tt.expectedOutputs, strings.Split(output.String(), "\n")) } + assert.Equal(t, tt.expectedErrOut, output.Stderr()) }) } } diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 784b68cf9bc..70990f2bfa7 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -61,11 +61,14 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par return nil } + // When ActorReviewers is true, we use login-based mutation and don't need to resolve reviewer IDs. + needReviewerIDs := len(tb.Reviewers) > 0 && !tb.ActorReviewers + // Retrieve minimal information needed to resolve metadata if this was not previously cached from additional metadata survey. if tb.MetadataResult == nil { input := api.RepoMetadataInput{ - Reviewers: len(tb.Reviewers) > 0, - TeamReviewers: len(tb.Reviewers) > 0 && slices.ContainsFunc(tb.Reviewers, func(r string) bool { + Reviewers: needReviewerIDs, + TeamReviewers: needReviewerIDs && slices.ContainsFunc(tb.Reviewers, func(r string) bool { return strings.ContainsRune(r, '/') }), Assignees: len(tb.Assignees) > 0, @@ -124,17 +127,34 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par } } - userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers) - if err != nil { - return fmt.Errorf("could not request reviewer: %w", err) - } - params["userReviewerIds"] = userReviewerIDs + // When ActorReviewers is true (github.com), pass logins directly for use with + // RequestReviewsByLogin mutation. Otherwise, resolve to IDs for GHES compatibility. + if tb.ActorReviewers { + params["userReviewerLogins"] = userReviewers + // Extract team slugs from org/slug format + teamSlugs := make([]string, len(teamReviewers)) + for i, t := range teamReviewers { + parts := strings.SplitN(t, "/", 2) + if len(parts) == 2 { + teamSlugs[i] = parts[1] + } else { + teamSlugs[i] = t + } + } + params["teamReviewerSlugs"] = teamSlugs + } else { + userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers) + if err != nil { + return fmt.Errorf("could not request reviewer: %w", err) + } + params["userReviewerIds"] = userReviewerIDs - teamReviewerIDs, err := tb.MetadataResult.TeamsToIDs(teamReviewers) - if err != nil { - return fmt.Errorf("could not request reviewer: %w", err) + teamReviewerIDs, err := tb.MetadataResult.TeamsToIDs(teamReviewers) + if err != nil { + return fmt.Errorf("could not request reviewer: %w", err) + } + params["teamReviewerIds"] = teamReviewerIDs } - params["teamReviewerIds"] = teamReviewerIDs return nil } diff --git a/pkg/cmd/pr/shared/state.go b/pkg/cmd/pr/shared/state.go index b9f2c029372..0e5c31cdd0a 100644 --- a/pkg/cmd/pr/shared/state.go +++ b/pkg/cmd/pr/shared/state.go @@ -20,6 +20,7 @@ type IssueMetadataState struct { Draft bool ActorAssignees bool + ActorReviewers bool Body string Title string diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index e350671b947..7bcf3a4a7b1 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -154,7 +154,7 @@ type RepoMetadataFetcher interface { RepoMetadataFetch(api.RepoMetadataInput) (*api.RepoMetadataResult, error) } -func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState, projectsV1Support gh.ProjectsV1Support) error { +func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState, projectsV1Support gh.ProjectsV1Support, reviewerSearchFunc func(string) prompter.MultiSelectSearchResult) error { isChosen := func(m string) bool { for _, c := range state.Metadata { if m == c { @@ -254,7 +254,19 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface }{} if isChosen("Reviewers") { - if len(reviewers) > 0 { + if reviewerSearchFunc != nil { + // Use search-based selection (github.com with ActorIsAssignable) + selectedReviewers, err := p.MultiSelectWithSearch( + "Reviewers", + "Search reviewers", + state.Reviewers, + []string{}, + reviewerSearchFunc) + if err != nil { + return err + } + values.Reviewers = selectedReviewers + } else if len(reviewers) > 0 { selected, err := p.MultiSelect("Reviewers", state.Reviewers, reviewers) if err != nil { return err diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 7097d0761d4..23ba96ceff7 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -71,7 +71,7 @@ func TestMetadataSurvey_selectAll(t *testing.T) { Assignees: []string{"hubot"}, Type: PRMetadata, } - err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported) + err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported, nil) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) @@ -117,7 +117,7 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { Assignees: []string{"hubot"}, } - err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported) + err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported, nil) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) @@ -146,7 +146,7 @@ func TestMetadataSurveyProjectV1Deprecation(t *testing.T) { return []int{0}, nil }) - err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Supported) + err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Supported, nil) require.ErrorContains(t, err, "expected test error") require.True(t, fetcher.projectsV1Requested, "expected projectsV1 to be requested") @@ -167,7 +167,7 @@ func TestMetadataSurveyProjectV1Deprecation(t *testing.T) { return []int{0}, nil }) - err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Unsupported) + err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Unsupported, nil) require.ErrorContains(t, err, "expected test error") require.False(t, fetcher.projectsV1Requested, "expected projectsV1 not to be requested") From 04bf86a72c568faf503cc2a88a453be3a921329c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:04:20 -0700 Subject: [PATCH 07/92] Address PR review comments Address PR review comments: code consistency and DRY improvements - Add botTypeName const for consistency with teamTypeName - Create extractTeamSlugs helper using strings.SplitN to simplify team slug extraction logic - Replace duplicate code in AddPullRequestReviews and RemovePullRequestReviews with extractTeamSlugs helper - Fix ClientMutationId naming with explicit graphql tag for consistency with other mutations in the codebase --- api/queries_pr.go | 47 +++++++++++++++++++++-------------------------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 632e7dd7522..6ee7f0c0018 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -317,6 +317,9 @@ type RequestedReviewer struct { } `json:"organization"` } +const teamTypeName = "Team" +const botTypeName = "Bot" + func (r RequestedReviewer) LoginOrSlug() string { if r.TypeName == teamTypeName { return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug) @@ -331,7 +334,7 @@ func (r RequestedReviewer) DisplayName() string { if r.TypeName == teamTypeName { return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug) } - if r.TypeName == "Bot" && r.Login == CopilotReviewerLogin { + if r.TypeName == botTypeName && r.Login == CopilotReviewerLogin { return "Copilot (AI)" } if r.Name != "" { @@ -340,8 +343,6 @@ func (r RequestedReviewer) DisplayName() string { return r.Login } -const teamTypeName = "Team" - func (r ReviewRequests) Logins() []string { logins := make([]string, len(r.Nodes)) for i, r := range r.Nodes { @@ -669,6 +670,20 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter return pr, nil } +// extractTeamSlugs extracts just the slug portion from team identifiers. +// Team identifiers can be in "org/slug" format; this returns just the slug. +func extractTeamSlugs(teams []string) []string { + slugs := make([]string, 0, len(teams)) + for _, t := range teams { + if t == "" { + continue + } + s := strings.SplitN(t, "/", 2) + slugs = append(slugs, s[len(s)-1]) + } + return slugs +} + // AddPullRequestReviews adds the given user and team reviewers to a pull request using the REST API. // Team identifiers can be in "org/slug" format. func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { @@ -681,16 +696,6 @@ func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users = []string{} } - // Extract just the slug from org/slug format - teamSlugs := make([]string, 0, len(teams)) - for _, t := range teams { - if idx := strings.Index(t, "/"); idx >= 0 { - teamSlugs = append(teamSlugs, t[idx+1:]) - } else if t != "" { - teamSlugs = append(teamSlugs, t) - } - } - path := fmt.Sprintf( "repos/%s/%s/pulls/%d/requested_reviewers", url.PathEscape(repo.RepoOwner()), @@ -702,7 +707,7 @@ func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, TeamReviewers []string `json:"team_reviewers"` }{ Reviewers: users, - TeamReviewers: teamSlugs, + TeamReviewers: extractTeamSlugs(teams), } buf := &bytes.Buffer{} if err := json.NewEncoder(buf).Encode(body); err != nil { @@ -724,16 +729,6 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in users = []string{} } - // Extract just the slug from org/slug format - teamSlugs := make([]string, 0, len(teams)) - for _, t := range teams { - if idx := strings.Index(t, "/"); idx >= 0 { - teamSlugs = append(teamSlugs, t[idx+1:]) - } else if t != "" { - teamSlugs = append(teamSlugs, t) - } - } - path := fmt.Sprintf( "repos/%s/%s/pulls/%d/requested_reviewers", url.PathEscape(repo.RepoOwner()), @@ -745,7 +740,7 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in TeamReviewers []string `json:"team_reviewers"` }{ Reviewers: users, - TeamReviewers: teamSlugs, + TeamReviewers: extractTeamSlugs(teams), } buf := &bytes.Buffer{} if err := json.NewEncoder(buf).Encode(body); err != nil { @@ -770,7 +765,7 @@ func RequestReviewsByLogin(client *Client, repo ghrepo.Interface, prID string, u var mutation struct { RequestReviewsByLogin struct { - ClientMutationID string + ClientMutationId string `graphql:"clientMutationId"` } `graphql:"requestReviewsByLogin(input: $input)"` } From 78aaa877183b82f5aec3a3a89878b6468330696a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:18:49 -0700 Subject: [PATCH 08/92] Add toGitHubV4Strings helper to reduce code duplication --- api/queries_pr.go | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 6ee7f0c0018..ccde369355e 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -684,6 +684,16 @@ func extractTeamSlugs(teams []string) []string { return slugs } +// toGitHubV4Strings converts a string slice to a githubv4.String slice, +// optionally appending a suffix to each element. +func toGitHubV4Strings(strs []string, suffix string) []githubv4.String { + result := make([]githubv4.String, len(strs)) + for i, s := range strs { + result[i] = githubv4.String(s + suffix) + } + return result +} + // AddPullRequestReviews adds the given user and team reviewers to a pull request using the REST API. // Team identifiers can be in "org/slug" format. func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { @@ -782,23 +792,14 @@ func RequestReviewsByLogin(client *Client, repo ghrepo.Interface, prID string, u Union: githubv4.Boolean(union), } - userLoginValues := make([]githubv4.String, len(userLogins)) - for i, l := range userLogins { - userLoginValues[i] = githubv4.String(l) - } + userLoginValues := toGitHubV4Strings(userLogins, "") input.UserLogins = &userLoginValues - botLoginValues := make([]githubv4.String, len(botLogins)) - for i, l := range botLogins { - // Bot logins require the [bot] suffix for the mutation - botLoginValues[i] = githubv4.String(l + "[bot]") - } + // Bot logins require the [bot] suffix for the mutation + botLoginValues := toGitHubV4Strings(botLogins, "[bot]") input.BotLogins = &botLoginValues - teamSlugValues := make([]githubv4.String, len(teamSlugs)) - for i, s := range teamSlugs { - teamSlugValues[i] = githubv4.String(s) - } + teamSlugValues := toGitHubV4Strings(teamSlugs, "") input.TeamSlugs = &teamSlugValues variables := map[string]interface{}{ From b32b7eab3911caf9ee4a90e71d89ce7842a7d37c Mon Sep 17 00:00:00 2001 From: vishnuvv27 Date: Mon, 9 Feb 2026 15:31:55 +0530 Subject: [PATCH 09/92] Fix redundant API call in gh issue view --comments (#12606) --- .../view/fixtures/issueView_previewSingleComment.json | 8 ++++++-- pkg/cmd/issue/view/http.go | 11 +++++------ pkg/cmd/issue/view/view.go | 2 -- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json b/pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json index be099c14b1f..8959acec67d 100644 --- a/pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json +++ b/pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json @@ -138,10 +138,14 @@ ] } ], - "totalCount": 6 + "totalCount": 6, + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOjg5" + } }, "url": "https://github.com/OWNER/REPO/issues/123" } } } -} +} \ No newline at end of file diff --git a/pkg/cmd/issue/view/http.go b/pkg/cmd/issue/view/http.go index 4adc71802dc..2982fbbe3a2 100644 --- a/pkg/cmd/issue/view/http.go +++ b/pkg/cmd/issue/view/http.go @@ -20,14 +20,13 @@ func preloadIssueComments(client *http.Client, repo ghrepo.Interface, issue *api } `graphql:"node(id: $id)"` } + if !issue.Comments.PageInfo.HasNextPage { + return nil + } + variables := map[string]interface{}{ "id": githubv4.ID(issue.ID), - "endCursor": (*githubv4.String)(nil), - } - if issue.Comments.PageInfo.HasNextPage { - variables["endCursor"] = githubv4.String(issue.Comments.PageInfo.EndCursor) - } else { - issue.Comments.Nodes = issue.Comments.Nodes[0:0] + "endCursor": githubv4.String(issue.Comments.PageInfo.EndCursor), } gql := api.NewClientFromHTTP(client) diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 41c01ef40d9..e41ad6acffe 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -144,8 +144,6 @@ func viewRun(opts *ViewOptions) error { } if lookupFields.Contains("comments") { - // FIXME: this re-fetches the comments connection even though the initial set of 100 were - // fetched in the previous request. err := preloadIssueComments(httpClient, baseRepo, issue) if err != nil { return err From b38f6772e5741b754ff88e8dd464293eadd083c4 Mon Sep 17 00:00:00 2001 From: gunadhya <6939749+gunadhya@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:31:22 +0530 Subject: [PATCH 10/92] Fix issue develop repeated invocation with named branches --- pkg/cmd/issue/develop/develop.go | 98 +++++++++++-- pkg/cmd/issue/develop/develop_test.go | 189 ++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/issue/develop/develop.go b/pkg/cmd/issue/develop/develop.go index 04bf14ebe56..812194cf026 100644 --- a/pkg/cmd/issue/develop/develop.go +++ b/pkg/cmd/issue/develop/develop.go @@ -4,6 +4,8 @@ import ( ctx "context" "fmt" "net/http" + "net/url" + "strings" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" @@ -174,7 +176,6 @@ func developRun(opts *DevelopOptions) error { func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error { branchRepo := issueRepo - var repoID string if opts.BranchRepo != "" { var err error branchRepo, err = ghrepo.FromFullName(opts.BranchRepo) @@ -183,24 +184,66 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr } } - opts.IO.StartProgressIndicator() - repoID, branchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch) - opts.IO.StopProgressIndicator() - if err != nil { - return err + branchName := "" + reusedExisting := false + if opts.Name != "" { + opts.IO.StartProgressIndicator() + branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + branchName = findExistingLinkedBranchName(branches, branchRepo, opts.Name) + reusedExisting = branchName != "" } - opts.IO.StartProgressIndicator() - branchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name) - opts.IO.StopProgressIndicator() - if err != nil { - return err + repoID := "" + branchID := "" + baseValidated := false + if opts.BaseBranch != "" { + opts.IO.StartProgressIndicator() + foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + repoID = foundRepoID + branchID = foundBranchID + baseValidated = true + } + + if branchName == "" { + if !baseValidated { + opts.IO.StartProgressIndicator() + foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + repoID = foundRepoID + branchID = foundBranchID + } + + opts.IO.StartProgressIndicator() + createdBranchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + branchName = createdBranchName + } + + if branchName == "" { + return fmt.Errorf("failed to create linked branch: API returned empty branch name") + } + + if reusedExisting && opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Using existing linked branch %q\n", branchName) } // Remember which branch to target when creating a PR. if opts.BaseBranch != "" { - err = opts.GitClient.SetBranchConfig(ctx.Background(), branchName, git.MergeBaseConfig, opts.BaseBranch) - if err != nil { + if err := opts.GitClient.SetBranchConfig(ctx.Background(), branchName, git.MergeBaseConfig, opts.BaseBranch); err != nil { return err } } @@ -210,6 +253,35 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr return checkoutBranch(opts, branchRepo, branchName) } +func findExistingLinkedBranchName(branches []api.LinkedBranch, branchRepo ghrepo.Interface, branchName string) string { + for _, branch := range branches { + if branch.BranchName != branchName { + continue + } + linkedRepo, err := linkedBranchRepoFromURL(branch.URL) + if err != nil { + continue + } + if ghrepo.IsSame(linkedRepo, branchRepo) { + return branch.BranchName + } + } + return "" +} + +func linkedBranchRepoFromURL(branchURL string) (ghrepo.Interface, error) { + u, err := url.Parse(branchURL) + if err != nil { + return nil, err + } + pathParts := strings.SplitN(strings.Trim(u.Path, "/"), "/", 3) + if len(pathParts) < 2 { + return nil, fmt.Errorf("invalid linked branch URL: %q", branchURL) + } + u.Path = "/" + strings.Join(pathParts[0:2], "/") + return ghrepo.FromURL(u) +} + func developRunList(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error { opts.IO.StartProgressIndicator() branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number) diff --git a/pkg/cmd/issue/develop/develop_test.go b/pkg/cmd/issue/develop/develop_test.go index 2485c8cc4cf..fe984df79dd 100644 --- a/pkg/cmd/issue/develop/develop_test.go +++ b/pkg/cmd/issue/develop/develop_test.go @@ -353,6 +353,16 @@ func TestDevelopRun(t *testing.T) { reg.Register( httpmock.GraphQL(`query FindRepoBranchID\b`), httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`)) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) reg.Register( httpmock.GraphQL(`mutation CreateLinkedBranch\b`), httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`, @@ -370,6 +380,165 @@ func TestDevelopRun(t *testing.T) { }, expectedOut: "github.com/OWNER/REPO/tree/my-branch\n", }, + { + name: "develop existing linked branch with name and checkout", + opts: &DevelopOptions{ + Name: "my-branch", + BaseBranch: "main", + IssueNumber: 123, + Checkout: true, + }, + remotes: map[string]string{ + "origin": "OWNER/REPO", + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`), + ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"my-branch","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`)) + }, + runStubs: func(cs *run.CommandStubber) { + cs.Register(`git config branch\.my-branch\.gh-merge-base main`, 0, "") + cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "") + cs.Register(`git rev-parse --verify refs/heads/my-branch`, 0, "") + cs.Register(`git checkout my-branch`, 0, "") + cs.Register(`git pull --ff-only origin my-branch`, 0, "") + }, + expectedOut: "github.com/OWNER/REPO/tree/my-branch\n", + }, + { + name: "develop existing linked branch with name in tty shows reuse message", + opts: &DevelopOptions{ + Name: "my-branch", + BaseBranch: "main", + IssueNumber: 123, + }, + tty: true, + remotes: map[string]string{ + "origin": "OWNER/REPO", + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`), + ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"my-branch","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`)) + }, + runStubs: func(cs *run.CommandStubber) { + cs.Register(`git config branch\.my-branch\.gh-merge-base main`, 0, "") + cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "") + }, + expectedOut: "github.com/OWNER/REPO/tree/my-branch\n", + expectedErrOut: "Using existing linked branch \"my-branch\"\n", + }, + { + name: "develop existing linked branch with invalid base branch returns an error", + opts: &DevelopOptions{ + Name: "my-branch", + BaseBranch: "does-not-exist-branch", + IssueNumber: 123, + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`), + ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"my-branch","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":null}}}`), + ) + }, + wantErr: `could not find branch "does-not-exist-branch" in OWNER/REPO`, + }, + { + name: "develop with empty linked branch name response returns an error", + opts: &DevelopOptions{ + Name: "my-branch", + BaseBranch: "main", + IssueNumber: 123, + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`), + ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`)) + reg.Register( + httpmock.GraphQL(`mutation CreateLinkedBranch\b`), + httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":""}}}}}`, + func(inputs map[string]interface{}) { + assert.Equal(t, "REPOID", inputs["repositoryId"]) + assert.Equal(t, "SOMEID", inputs["issueId"]) + assert.Equal(t, "OID", inputs["oid"]) + assert.Equal(t, "my-branch", inputs["name"]) + }), + ) + }, + wantErr: "failed to create linked branch: API returned empty branch name", + }, { name: "develop new branch outside of local git repo", opts: &DevelopOptions{ @@ -426,6 +595,16 @@ func TestDevelopRun(t *testing.T) { httpmock.GraphQL(`query FindRepoBranchID\b`), httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`), ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) reg.Register( httpmock.GraphQL(`mutation CreateLinkedBranch\b`), httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`, @@ -468,6 +647,16 @@ func TestDevelopRun(t *testing.T) { httpmock.GraphQL(`query FindRepoBranchID\b`), httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`), ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) reg.Register( httpmock.GraphQL(`mutation CreateLinkedBranch\b`), httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`, From 620261fea4ebdfa4eb5b8edaa62e8cfc5668274f Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:53:30 -0700 Subject: [PATCH 11/92] Remove redundant comments --- pkg/cmd/pr/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index d29240fe5fd..caf79c550c7 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -399,7 +399,7 @@ func createRun(opts *CreateOptions) error { client := ctx.Client // Detect ActorIsAssignable feature to determine if we can use search-based - // reviewer selection (github.com) or need to use traditional ID-based selection (GHES) + // reviewer selection (github.com) or need to use legacy ID-based selection (GHES) issueFeatures, _ := opts.Detector.IssueFeatures() var reviewerSearchFunc func(string) prompter.MultiSelectSearchResult if issueFeatures.ActorIsAssignable { From dd9ab7152b8a628eb43cf36e49d3398b7fe96215 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:54:03 -0700 Subject: [PATCH 12/92] Don't swallow error from FD Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/cmd/pr/create/create.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index caf79c550c7..bd6c79f9cc3 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -400,7 +400,10 @@ func createRun(opts *CreateOptions) error { // Detect ActorIsAssignable feature to determine if we can use search-based // reviewer selection (github.com) or need to use legacy ID-based selection (GHES) - issueFeatures, _ := opts.Detector.IssueFeatures() + issueFeatures, err := opts.Detector.IssueFeatures() + if err != nil { + return err + } var reviewerSearchFunc func(string) prompter.MultiSelectSearchResult if issueFeatures.ActorIsAssignable { // Create search function for reviewer selection using login-based API From 7373de3e707b59889cb186b617d3158e33db5c2d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:58:23 -0700 Subject: [PATCH 13/92] Remove redundant comment --- pkg/cmd/pr/create/create.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index bd6c79f9cc3..631e6943073 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -406,7 +406,6 @@ func createRun(opts *CreateOptions) error { } var reviewerSearchFunc func(string) prompter.MultiSelectSearchResult if issueFeatures.ActorIsAssignable { - // Create search function for reviewer selection using login-based API reviewerSearchFunc = func(query string) prompter.MultiSelectSearchResult { candidates, moreResults, err := api.SuggestedReviewerActorsForRepo(client, ctx.PRRefs.BaseRepo(), query) if err != nil { From cf08f4d51e55cb5ced9812ba51d0303f395183dd Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:08:12 -0700 Subject: [PATCH 14/92] Apply suggestion from @BagToad --- pkg/cmd/pr/shared/survey.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 7bcf3a4a7b1..ae84a6ef4fb 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -255,7 +255,6 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface if isChosen("Reviewers") { if reviewerSearchFunc != nil { - // Use search-based selection (github.com with ActorIsAssignable) selectedReviewers, err := p.MultiSelectWithSearch( "Reviewers", "Search reviewers", From db167d31164bc3c18d39a45f2261f79eb047a03a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:15:14 -0700 Subject: [PATCH 15/92] Preserve org/slug format for team reviewer slugs --- pkg/cmd/pr/shared/params.go | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 70990f2bfa7..c6c5661c8e0 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -131,17 +131,7 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par // RequestReviewsByLogin mutation. Otherwise, resolve to IDs for GHES compatibility. if tb.ActorReviewers { params["userReviewerLogins"] = userReviewers - // Extract team slugs from org/slug format - teamSlugs := make([]string, len(teamReviewers)) - for i, t := range teamReviewers { - parts := strings.SplitN(t, "/", 2) - if len(parts) == 2 { - teamSlugs[i] = parts[1] - } else { - teamSlugs[i] = t - } - } - params["teamReviewerSlugs"] = teamSlugs + params["teamReviewerSlugs"] = teamReviewers } else { userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers) if err != nil { From 1209b24e69fe07a8ae4f291325d20a5c790c6b76 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:16:03 -0700 Subject: [PATCH 16/92] Partition bot reviewers separately for RequestReviewsByLogin --- pkg/cmd/pr/shared/params.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index c6c5661c8e0..90e1e6f897d 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -118,10 +118,13 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par } var userReviewers []string + var botReviewers []string var teamReviewers []string for _, r := range tb.Reviewers { if strings.ContainsRune(r, '/') { teamReviewers = append(teamReviewers, r) + } else if r == api.CopilotReviewerLogin { + botReviewers = append(botReviewers, r) } else { userReviewers = append(userReviewers, r) } @@ -131,6 +134,9 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par // RequestReviewsByLogin mutation. Otherwise, resolve to IDs for GHES compatibility. if tb.ActorReviewers { params["userReviewerLogins"] = userReviewers + if len(botReviewers) > 0 { + params["botReviewerLogins"] = botReviewers + } params["teamReviewerSlugs"] = teamReviewers } else { userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers) From ad64d10bf4f59c41581b905f5479f9b561b0ef9a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:16:23 -0700 Subject: [PATCH 17/92] Wire bot reviewer logins through CreatePullRequest --- api/queries_pr.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index ccde369355e..39036127744 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -618,11 +618,12 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter // Request reviewers using either login-based (github.com) or ID-based (GHES) mutation userLogins, hasUserLogins := params["userReviewerLogins"].([]string) + botLogins, _ := params["botReviewerLogins"].([]string) teamSlugs, hasTeamSlugs := params["teamReviewerSlugs"].([]string) if hasUserLogins || hasTeamSlugs { // Use login-based mutation (RequestReviewsByLogin) for github.com - err := RequestReviewsByLogin(client, repo, pr.ID, userLogins, nil, teamSlugs, true) + err := RequestReviewsByLogin(client, repo, pr.ID, userLogins, botLogins, teamSlugs, true) if err != nil { return pr, err } From 38661646eef035f08e04e1c4e12777bedf10cddd Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:18:07 -0700 Subject: [PATCH 18/92] Update test assertions to expect org/slug team format --- pkg/cmd/pr/create/create_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 254b545289c..2adf1007b93 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -987,7 +987,7 @@ func Test_createRun(t *testing.T) { `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) assert.Equal(t, []interface{}{"hubot", "monalisa"}, inputs["userLogins"]) - assert.Equal(t, []interface{}{"core", "robots"}, inputs["teamSlugs"]) + assert.Equal(t, []interface{}{"/core", "/robots"}, inputs["teamSlugs"]) assert.Equal(t, true, inputs["union"]) })) }, @@ -1516,7 +1516,7 @@ func Test_createRun(t *testing.T) { `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) assert.Equal(t, []interface{}{"hubot", "monalisa"}, inputs["userLogins"]) - assert.Equal(t, []interface{}{"core", "robots"}, inputs["teamSlugs"]) + assert.Equal(t, []interface{}{"org/core", "org/robots"}, inputs["teamSlugs"]) assert.Equal(t, true, inputs["union"]) })) }, From e361335e5c1f67154216c80bfaeccdce29966321 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:18:39 -0700 Subject: [PATCH 19/92] Skip reviewer metadata fetch when using search-based selection --- pkg/cmd/pr/shared/survey.go | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index ae84a6ef4fb..6c88a74af45 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -180,10 +180,13 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface state.Metadata = append(state.Metadata, extraFieldsOptions[i]) } - // Retrieve and process data for survey prompts based on the extra fields selected + // Retrieve and process data for survey prompts based on the extra fields selected. + // When search-based reviewer selection is available, skip the expensive assignable-users + // and teams fetch since reviewers are found dynamically via the search function. + useReviewerSearch := reviewerSearchFunc != nil metadataInput := api.RepoMetadataInput{ - Reviewers: isChosen("Reviewers"), - TeamReviewers: isChosen("Reviewers"), + Reviewers: isChosen("Reviewers") && !useReviewerSearch, + TeamReviewers: isChosen("Reviewers") && !useReviewerSearch, Assignees: isChosen("Assignees"), ActorAssignees: isChosen("Assignees") && state.ActorAssignees, Labels: isChosen("Labels"), @@ -197,13 +200,15 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface } var reviewers []string - for _, u := range metadataResult.AssignableUsers { - if u.Login() != metadataResult.CurrentLogin { - reviewers = append(reviewers, u.DisplayName()) + if !useReviewerSearch { + for _, u := range metadataResult.AssignableUsers { + if u.Login() != metadataResult.CurrentLogin { + reviewers = append(reviewers, u.DisplayName()) + } + } + for _, t := range metadataResult.Teams { + reviewers = append(reviewers, fmt.Sprintf("%s/%s", baseRepo.RepoOwner(), t.Slug)) } - } - for _, t := range metadataResult.Teams { - reviewers = append(reviewers, fmt.Sprintf("%s/%s", baseRepo.RepoOwner(), t.Slug)) } // Populate the list of selectable assignees and their default selections. From a8655bcda9261f462f6bc37f97b157fd1893c7c3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:55:57 -0700 Subject: [PATCH 20/92] Include bot logins in login-based reviewer mutation guard --- api/queries_pr.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 39036127744..eb2eb549491 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -618,10 +618,10 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter // Request reviewers using either login-based (github.com) or ID-based (GHES) mutation userLogins, hasUserLogins := params["userReviewerLogins"].([]string) - botLogins, _ := params["botReviewerLogins"].([]string) + botLogins, hasBotLogins := params["botReviewerLogins"].([]string) teamSlugs, hasTeamSlugs := params["teamReviewerSlugs"].([]string) - if hasUserLogins || hasTeamSlugs { + if hasUserLogins || hasBotLogins || hasTeamSlugs { // Use login-based mutation (RequestReviewsByLogin) for github.com err := RequestReviewsByLogin(client, repo, pr.ID, userLogins, botLogins, teamSlugs, true) if err != nil { From 1cb776384ecd1c2a935b86096fe58ad31b9facfb Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:33:13 -0700 Subject: [PATCH 21/92] Normalize /slug team shorthand to org/slug and fix docs --- api/queries_pr.go | 4 ++-- pkg/cmd/pr/create/create_test.go | 2 +- pkg/cmd/pr/shared/params.go | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index eb2eb549491..2ee1d5e5f14 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -764,8 +764,8 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in // RequestReviewsByLogin sets requested reviewers on a pull request using the GraphQL mutation. // This mutation replaces existing reviewers with the provided set unless union is true. // Only available on github.com, not GHES. -// Bot logins should include the [bot] suffix (e.g., "copilot-pull-request-reviewer[bot]"). -// Team slugs should be in the format "org/team-slug". +// Bot logins should be passed without the [bot] suffix; it is appended automatically. +// Team slugs must be in the format "org/team-slug". // When union is false (replace mode), passing empty slices will remove all reviewers. func RequestReviewsByLogin(client *Client, repo ghrepo.Interface, prID string, userLogins, botLogins, teamSlugs []string, union bool) error { // In union mode (additive), nothing to do if all lists are empty. diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 2adf1007b93..a5e30cfcdb3 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -987,7 +987,7 @@ func Test_createRun(t *testing.T) { `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) assert.Equal(t, []interface{}{"hubot", "monalisa"}, inputs["userLogins"]) - assert.Equal(t, []interface{}{"/core", "/robots"}, inputs["teamSlugs"]) + assert.Equal(t, []interface{}{"OWNER/core", "OWNER/robots"}, inputs["teamSlugs"]) assert.Equal(t, true, inputs["union"]) })) }, diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 90e1e6f897d..06f599f0d6f 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -122,6 +122,10 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par var teamReviewers []string for _, r := range tb.Reviewers { if strings.ContainsRune(r, '/') { + // Normalize /slug shorthand to org/slug using the repo owner + if strings.HasPrefix(r, "/") { + r = baseRepo.RepoOwner() + r + } teamReviewers = append(teamReviewers, r) } else if r == api.CopilotReviewerLogin { botReviewers = append(botReviewers, r) From 1d730951d2a13b3b959d939a977a3f128d883d3e Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:33:13 -0700 Subject: [PATCH 22/92] Use org/slug format in test fixtures and remove /slug normalization --- pkg/cmd/pr/create/create_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index a5e30cfcdb3..428aca650ed 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -909,7 +909,7 @@ func Test_createRun(t *testing.T) { opts.Assignees = []string{"monalisa"} opts.Labels = []string{"bug", "todo"} opts.Projects = []string{"roadmap"} - opts.Reviewers = []string{"hubot", "monalisa", "/core", "/robots"} + opts.Reviewers = []string{"hubot", "monalisa", "OWNER/core", "OWNER/robots"} opts.Milestone = "big one.oh" return func() {} }, @@ -1648,7 +1648,7 @@ func Test_createRun_GHES(t *testing.T) { opts.HeadBranch = "feature" opts.Assignees = []string{"monalisa"} opts.Labels = []string{"bug", "todo"} - opts.Reviewers = []string{"hubot", "monalisa", "/core", "/robots"} + opts.Reviewers = []string{"hubot", "monalisa", "OWNER/core", "OWNER/robots"} opts.Milestone = "big one.oh" opts.DryRun = true return func() {} @@ -1709,7 +1709,7 @@ func Test_createRun_GHES(t *testing.T) { `base: trunk`, `head: feature`, `labels: bug, todo`, - `reviewers: hubot, monalisa, /core, /robots`, + `reviewers: hubot, monalisa, OWNER/core, OWNER/robots`, `assignees: monalisa`, `milestones: big one.oh`, `maintainerCanModify: false`, @@ -1731,7 +1731,7 @@ func Test_createRun_GHES(t *testing.T) { opts.HeadBranch = "feature" opts.Assignees = []string{"monalisa"} opts.Labels = []string{"bug", "todo"} - opts.Reviewers = []string{"hubot", "monalisa", "/core", "/robots"} + opts.Reviewers = []string{"hubot", "monalisa", "OWNER/core", "OWNER/robots"} opts.Milestone = "big one.oh" opts.DryRun = true return func() {} @@ -1792,7 +1792,7 @@ func Test_createRun_GHES(t *testing.T) { `Base: trunk`, `Head: feature`, `Labels: bug, todo`, - `Reviewers: hubot, monalisa, /core, /robots`, + `Reviewers: hubot, monalisa, OWNER/core, OWNER/robots`, `Assignees: monalisa`, `Milestones: big one.oh`, `MaintainerCanModify: false`, From 72a6e9f3a7c533139a475c482b0e179574b2ff58 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:40:59 -0700 Subject: [PATCH 23/92] Move PR review queries from queries_pr.go to queries_pr_review.go Consolidate all review-related types, methods, and functions into queries_pr_review.go for better code organization. The file is now ordered by logical sections: review data types, review status, review requests, reviewer candidates, API operations, and helpers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_pr.go | 568 ----------------------------------- api/queries_pr_review.go | 624 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 597 insertions(+), 595 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 2ee1d5e5f14..f1d38397131 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -1,12 +1,9 @@ package api import ( - "bytes" - "encoding/json" "fmt" "net/http" "net/url" - "strings" "time" "github.com/cli/cli/v2/internal/ghrepo" @@ -301,65 +298,6 @@ type PullRequestFile struct { Deletions int `json:"deletions"` } -type ReviewRequests struct { - Nodes []struct { - RequestedReviewer RequestedReviewer - } -} - -type RequestedReviewer struct { - TypeName string `json:"__typename"` - Login string `json:"login"` - Name string `json:"name"` - Slug string `json:"slug"` - Organization struct { - Login string `json:"login"` - } `json:"organization"` -} - -const teamTypeName = "Team" -const botTypeName = "Bot" - -func (r RequestedReviewer) LoginOrSlug() string { - if r.TypeName == teamTypeName { - return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug) - } - return r.Login -} - -// DisplayName returns a user-friendly name for the reviewer. -// For Copilot bot, returns "Copilot (AI)". For teams, returns "org/slug". -// For users, returns "login (Name)" if name is available, otherwise just login. -func (r RequestedReviewer) DisplayName() string { - if r.TypeName == teamTypeName { - return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug) - } - if r.TypeName == botTypeName && r.Login == CopilotReviewerLogin { - return "Copilot (AI)" - } - if r.Name != "" { - return fmt.Sprintf("%s (%s)", r.Login, r.Name) - } - return r.Login -} - -func (r ReviewRequests) Logins() []string { - logins := make([]string, len(r.Nodes)) - for i, r := range r.Nodes { - logins[i] = r.RequestedReviewer.LoginOrSlug() - } - return logins -} - -// DisplayNames returns user-friendly display names for all requested reviewers. -func (r ReviewRequests) DisplayNames() []string { - names := make([]string, len(r.Nodes)) - for i, r := range r.Nodes { - names[i] = r.RequestedReviewer.DisplayName() - } - return names -} - func (pr PullRequest) HeadLabel() string { if pr.IsCrossRepository { return fmt.Sprintf("%s:%s", pr.HeadRepositoryOwner.Login, pr.HeadRefName) @@ -383,25 +321,6 @@ func (pr PullRequest) IsOpen() bool { return pr.State == "OPEN" } -type PullRequestReviewStatus struct { - ChangesRequested bool - Approved bool - ReviewRequired bool -} - -func (pr *PullRequest) ReviewStatus() PullRequestReviewStatus { - var status PullRequestReviewStatus - switch pr.ReviewDecision { - case "CHANGES_REQUESTED": - status.ChangesRequested = true - case "APPROVED": - status.Approved = true - case "REVIEW_REQUIRED": - status.ReviewRequired = true - } - return status -} - type PullRequestChecksStatus struct { Pending int Failing int @@ -541,18 +460,6 @@ func parseCheckStatusFromCheckConclusionState(state CheckConclusionState) checkS } } -func (pr *PullRequest) DisplayableReviews() PullRequestReviews { - published := []PullRequestReview{} - for _, prr := range pr.Reviews.Nodes { - //Dont display pending reviews - //Dont display commenting reviews without top level comment body - if prr.State != "PENDING" && !(prr.State == "COMMENTED" && prr.Body == "") { - published = append(published, prr) - } - } - return PullRequestReviews{Nodes: published, TotalCount: len(published)} -} - // CreatePullRequest creates a pull request in a GitHub repository func CreatePullRequest(client *Client, repo *Repository, params map[string]interface{}) (*PullRequest, error) { query := ` @@ -671,145 +578,6 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter return pr, nil } -// extractTeamSlugs extracts just the slug portion from team identifiers. -// Team identifiers can be in "org/slug" format; this returns just the slug. -func extractTeamSlugs(teams []string) []string { - slugs := make([]string, 0, len(teams)) - for _, t := range teams { - if t == "" { - continue - } - s := strings.SplitN(t, "/", 2) - slugs = append(slugs, s[len(s)-1]) - } - return slugs -} - -// toGitHubV4Strings converts a string slice to a githubv4.String slice, -// optionally appending a suffix to each element. -func toGitHubV4Strings(strs []string, suffix string) []githubv4.String { - result := make([]githubv4.String, len(strs)) - for i, s := range strs { - result[i] = githubv4.String(s + suffix) - } - return result -} - -// AddPullRequestReviews adds the given user and team reviewers to a pull request using the REST API. -// Team identifiers can be in "org/slug" format. -func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { - if len(users) == 0 && len(teams) == 0 { - return nil - } - - // The API requires empty arrays instead of null values - if users == nil { - users = []string{} - } - - path := fmt.Sprintf( - "repos/%s/%s/pulls/%d/requested_reviewers", - url.PathEscape(repo.RepoOwner()), - url.PathEscape(repo.RepoName()), - prNumber, - ) - body := struct { - Reviewers []string `json:"reviewers"` - TeamReviewers []string `json:"team_reviewers"` - }{ - Reviewers: users, - TeamReviewers: extractTeamSlugs(teams), - } - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(body); err != nil { - return err - } - // The endpoint responds with the updated pull request object; we don't need it here. - return client.REST(repo.RepoHost(), "POST", path, buf, nil) -} - -// RemovePullRequestReviews removes requested reviewers from a pull request using the REST API. -// Team identifiers can be in "org/slug" format. -func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { - if len(users) == 0 && len(teams) == 0 { - return nil - } - - // The API requires empty arrays instead of null values - if users == nil { - users = []string{} - } - - path := fmt.Sprintf( - "repos/%s/%s/pulls/%d/requested_reviewers", - url.PathEscape(repo.RepoOwner()), - url.PathEscape(repo.RepoName()), - prNumber, - ) - body := struct { - Reviewers []string `json:"reviewers"` - TeamReviewers []string `json:"team_reviewers"` - }{ - Reviewers: users, - TeamReviewers: extractTeamSlugs(teams), - } - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(body); err != nil { - return err - } - // The endpoint responds with the updated pull request object; we don't need it here. - return client.REST(repo.RepoHost(), "DELETE", path, buf, nil) -} - -// RequestReviewsByLogin sets requested reviewers on a pull request using the GraphQL mutation. -// This mutation replaces existing reviewers with the provided set unless union is true. -// Only available on github.com, not GHES. -// Bot logins should be passed without the [bot] suffix; it is appended automatically. -// Team slugs must be in the format "org/team-slug". -// When union is false (replace mode), passing empty slices will remove all reviewers. -func RequestReviewsByLogin(client *Client, repo ghrepo.Interface, prID string, userLogins, botLogins, teamSlugs []string, union bool) error { - // In union mode (additive), nothing to do if all lists are empty. - // In replace mode, we may still need to call the mutation to clear reviewers. - if union && len(userLogins) == 0 && len(botLogins) == 0 && len(teamSlugs) == 0 { - return nil - } - - var mutation struct { - RequestReviewsByLogin struct { - ClientMutationId string `graphql:"clientMutationId"` - } `graphql:"requestReviewsByLogin(input: $input)"` - } - - type RequestReviewsByLoginInput struct { - PullRequestID githubv4.ID `json:"pullRequestId"` - UserLogins *[]githubv4.String `json:"userLogins,omitempty"` - BotLogins *[]githubv4.String `json:"botLogins,omitempty"` - TeamSlugs *[]githubv4.String `json:"teamSlugs,omitempty"` - Union githubv4.Boolean `json:"union"` - } - - input := RequestReviewsByLoginInput{ - PullRequestID: githubv4.ID(prID), - Union: githubv4.Boolean(union), - } - - userLoginValues := toGitHubV4Strings(userLogins, "") - input.UserLogins = &userLoginValues - - // Bot logins require the [bot] suffix for the mutation - botLoginValues := toGitHubV4Strings(botLogins, "[bot]") - input.BotLogins = &botLoginValues - - teamSlugValues := toGitHubV4Strings(teamSlugs, "") - input.TeamSlugs = &teamSlugValues - - variables := map[string]interface{}{ - "input": input, - } - - return client.Mutate(repo.RepoHost(), "RequestReviewsByLogin", &mutation, variables) -} - // SuggestedAssignableActors fetches up to 10 suggested actors for a specific assignable // (Issue or PullRequest) node ID. `assignableID` is the GraphQL node ID for the Issue/PR. // Returns the actors, the total count of available assignees in the repo, and an error. @@ -902,342 +670,6 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable return actors, availableAssigneesCount, nil } -// ReviewerCandidate represents a potential reviewer for a pull request. -// This can be a User, Bot, or Team. -type ReviewerCandidate interface { - DisplayName() string - Login() string - - sealedReviewerCandidate() -} - -// ReviewerUser is a user who can review a pull request. -type ReviewerUser struct { - AssignableUser -} - -func NewReviewerUser(login, name string) ReviewerUser { - return ReviewerUser{ - AssignableUser: NewAssignableUser("", login, name), - } -} - -func (r ReviewerUser) sealedReviewerCandidate() {} - -// ReviewerBot is a bot who can review a pull request. -type ReviewerBot struct { - AssignableBot -} - -func NewReviewerBot(login string) ReviewerBot { - return ReviewerBot{ - AssignableBot: NewAssignableBot("", login), - } -} - -func (b ReviewerBot) DisplayName() string { - if b.login == CopilotReviewerLogin { - return fmt.Sprintf("%s (AI)", CopilotActorName) - } - return b.Login() -} - -func (r ReviewerBot) sealedReviewerCandidate() {} - -// ReviewerTeam is a team that can review a pull request. -type ReviewerTeam struct { - org string - teamSlug string -} - -// NewReviewerTeam creates a new ReviewerTeam. -func NewReviewerTeam(orgName, teamSlug string) ReviewerTeam { - return ReviewerTeam{org: orgName, teamSlug: teamSlug} -} - -func (r ReviewerTeam) DisplayName() string { - return fmt.Sprintf("%s/%s", r.org, r.teamSlug) -} - -func (r ReviewerTeam) Login() string { - return fmt.Sprintf("%s/%s", r.org, r.teamSlug) -} - -func (r ReviewerTeam) Slug() string { - return r.teamSlug -} - -func (r ReviewerTeam) sealedReviewerCandidate() {} - -// SuggestedReviewerActors fetches suggested reviewers for a pull request. -// It combines results from three sources using a cascading quota system: -// - suggestedReviewerActors - suggested based on PR activity (base quota: 5) -// - repository collaborators - all collaborators (base quota: 5 + unfilled from suggestions) -// - organization teams - all teams for org repos (base quota: 5 + unfilled from collaborators) -// -// This ensures we show up to 15 total candidates, with each source filling any -// unfilled quota from the previous source. Results are deduplicated. -// Returns the candidates, a MoreResults count, and an error. -func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, query string) ([]ReviewerCandidate, int, error) { - // Fetch 10 from each source to allow cascading quota to fill from available results. - // Use a single query that includes organization.teams - if the owner is not an org, - // we'll get a "Could not resolve to an Organization" error which we handle gracefully. - // We also fetch unfiltered total counts via aliases for the "X more" display. - type responseData struct { - Node struct { - PullRequest struct { - SuggestedActors struct { - Nodes []struct { - IsAuthor bool - IsCommenter bool - Reviewer struct { - TypeName string `graphql:"__typename"` - User struct { - Login string - Name string - } `graphql:"... on User"` - Bot struct { - Login string - } `graphql:"... on Bot"` - } - } - } `graphql:"suggestedReviewerActors(first: 10, query: $query)"` - } `graphql:"... on PullRequest"` - } `graphql:"node(id: $id)"` - Repository struct { - Collaborators struct { - Nodes []struct { - Login string - Name string - } - } `graphql:"collaborators(first: 10, query: $query)"` - CollaboratorsTotalCount struct { - TotalCount int - } `graphql:"collaboratorsTotalCount: collaborators(first: 0)"` - } `graphql:"repository(owner: $owner, name: $name)"` - Organization struct { - Teams struct { - Nodes []struct { - Slug string - } - } `graphql:"teams(first: 10, query: $query)"` - TeamsTotalCount struct { - TotalCount int - } `graphql:"teamsTotalCount: teams(first: 0)"` - } `graphql:"organization(login: $owner)"` - } - - variables := map[string]interface{}{ - "id": githubv4.ID(prID), - "query": githubv4.String(query), - "owner": githubv4.String(repo.RepoOwner()), - "name": githubv4.String(repo.RepoName()), - } - - var result responseData - err := client.Query(repo.RepoHost(), "SuggestedReviewerActors", &result, variables) - // Handle the case where the owner is not an organization - the query still returns - // partial data (repository, node), so we can continue processing. - if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) { - return nil, 0, err - } - - // Build candidates using cascading quota logic: - // Each source has a base quota of 5, plus any unfilled quota from previous sources. - // This ensures we show up to 15 total candidates, filling gaps when earlier sources have fewer. - seen := make(map[string]bool) - var candidates []ReviewerCandidate - const baseQuota = 5 - - // Suggested reviewers (excluding author) - suggestionsAdded := 0 - for _, n := range result.Node.PullRequest.SuggestedActors.Nodes { - if suggestionsAdded >= baseQuota { - break - } - if n.IsAuthor { - continue - } - var candidate ReviewerCandidate - var login string - if n.Reviewer.TypeName == "User" && n.Reviewer.User.Login != "" { - login = n.Reviewer.User.Login - candidate = NewReviewerUser(login, n.Reviewer.User.Name) - } else if n.Reviewer.TypeName == "Bot" && n.Reviewer.Bot.Login != "" { - login = n.Reviewer.Bot.Login - candidate = NewReviewerBot(login) - } else { - continue - } - if !seen[login] { - seen[login] = true - candidates = append(candidates, candidate) - suggestionsAdded++ - } - } - - // Collaborators: quota = base + unfilled from suggestions - collaboratorsQuota := baseQuota + (baseQuota - suggestionsAdded) - collaboratorsAdded := 0 - for _, c := range result.Repository.Collaborators.Nodes { - if collaboratorsAdded >= collaboratorsQuota { - break - } - if c.Login == "" { - continue - } - if !seen[c.Login] { - seen[c.Login] = true - candidates = append(candidates, NewReviewerUser(c.Login, c.Name)) - collaboratorsAdded++ - } - } - - // Teams: quota = base + unfilled from collaborators - teamsQuota := baseQuota + (collaboratorsQuota - collaboratorsAdded) - teamsAdded := 0 - ownerName := repo.RepoOwner() - for _, t := range result.Organization.Teams.Nodes { - if teamsAdded >= teamsQuota { - break - } - if t.Slug == "" { - continue - } - teamLogin := fmt.Sprintf("%s/%s", ownerName, t.Slug) - if !seen[teamLogin] { - seen[teamLogin] = true - candidates = append(candidates, NewReviewerTeam(ownerName, t.Slug)) - teamsAdded++ - } - } - - // MoreResults uses unfiltered total counts (teams will be 0 for personal repos) - moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Organization.TeamsTotalCount.TotalCount - - return candidates, moreResults, nil -} - -// SuggestedReviewerActorsForRepo fetches potential reviewers for a repository. -// Unlike SuggestedReviewerActors, this doesn't require an existing PR - used for gh pr create. -// It combines results from two sources using a cascading quota system: -// - repository collaborators (base quota: 5) -// - organization teams (base quota: 5 + unfilled from collaborators) -// -// This ensures we show up to 10 total candidates, with each source filling any -// unfilled quota from the previous source. Results are deduplicated. -// Returns the candidates, a MoreResults count, and an error. -func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query string) ([]ReviewerCandidate, int, error) { - type responseData struct { - Repository struct { - // Check for Copilot availability by looking at any open PR's suggested reviewers - PullRequests struct { - Nodes []struct { - SuggestedActors struct { - Nodes []struct { - Reviewer struct { - TypeName string `graphql:"__typename"` - Bot struct { - Login string - } `graphql:"... on Bot"` - } - } - } `graphql:"suggestedReviewerActors(first: 10)"` - } - } `graphql:"pullRequests(first: 1, states: [OPEN])"` - Collaborators struct { - Nodes []struct { - Login string - Name string - } - } `graphql:"collaborators(first: 10, query: $query)"` - CollaboratorsTotalCount struct { - TotalCount int - } `graphql:"collaboratorsTotalCount: collaborators(first: 0)"` - } `graphql:"repository(owner: $owner, name: $name)"` - Organization struct { - Teams struct { - Nodes []struct { - Slug string - } - } `graphql:"teams(first: 10, query: $query)"` - TeamsTotalCount struct { - TotalCount int - } `graphql:"teamsTotalCount: teams(first: 0)"` - } `graphql:"organization(login: $owner)"` - } - - variables := map[string]interface{}{ - "query": githubv4.String(query), - "owner": githubv4.String(repo.RepoOwner()), - "name": githubv4.String(repo.RepoName()), - } - - var result responseData - err := client.Query(repo.RepoHost(), "SuggestedReviewerActorsForRepo", &result, variables) - // Handle the case where the owner is not an organization - the query still returns - // partial data (repository), so we can continue processing. - if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) { - return nil, 0, err - } - - // Build candidates using cascading quota logic - seen := make(map[string]bool) - var candidates []ReviewerCandidate - const baseQuota = 5 - - // Check for Copilot availability from open PR's suggested reviewers - for _, pr := range result.Repository.PullRequests.Nodes { - for _, actor := range pr.SuggestedActors.Nodes { - if actor.Reviewer.TypeName == "Bot" && actor.Reviewer.Bot.Login == CopilotReviewerLogin { - candidates = append(candidates, NewReviewerBot(CopilotReviewerLogin)) - seen[CopilotReviewerLogin] = true - break - } - } - } - - // Collaborators - collaboratorsAdded := 0 - for _, c := range result.Repository.Collaborators.Nodes { - if collaboratorsAdded >= baseQuota { - break - } - if c.Login == "" { - continue - } - if !seen[c.Login] { - seen[c.Login] = true - candidates = append(candidates, NewReviewerUser(c.Login, c.Name)) - collaboratorsAdded++ - } - } - - // Teams: quota = base + unfilled from collaborators - teamsQuota := baseQuota + (baseQuota - collaboratorsAdded) - teamsAdded := 0 - ownerName := repo.RepoOwner() - for _, t := range result.Organization.Teams.Nodes { - if teamsAdded >= teamsQuota { - break - } - if t.Slug == "" { - continue - } - teamLogin := fmt.Sprintf("%s/%s", ownerName, t.Slug) - if !seen[teamLogin] { - seen[teamLogin] = true - candidates = append(candidates, NewReviewerTeam(ownerName, t.Slug)) - teamsAdded++ - } - } - - // MoreResults uses unfiltered total counts (teams will be 0 for personal repos) - moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Organization.TeamsTotalCount.TotalCount - - return candidates, moreResults, nil -} - func UpdatePullRequestBranch(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestBranchInput) error { var mutation struct { UpdatePullRequestBranch struct { diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index d5565b54b23..b233ee46ff1 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -1,6 +1,11 @@ package api import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "strings" "time" "github.com/cli/cli/v2/internal/ghrepo" @@ -42,33 +47,6 @@ type PullRequestReview struct { Commit Commit `json:"commit"` } -func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error { - var mutation struct { - AddPullRequestReview struct { - ClientMutationID string - } `graphql:"addPullRequestReview(input:$input)"` - } - - state := githubv4.PullRequestReviewEventComment - switch input.State { - case ReviewApprove: - state = githubv4.PullRequestReviewEventApprove - case ReviewRequestChanges: - state = githubv4.PullRequestReviewEventRequestChanges - } - - body := githubv4.String(input.Body) - variables := map[string]interface{}{ - "input": githubv4.AddPullRequestReviewInput{ - PullRequestID: pr.ID, - Event: &state, - Body: &body, - }, - } - - return client.Mutate(repo.RepoHost(), "PullRequestReviewAdd", &mutation, variables) -} - func (prr PullRequestReview) Identifier() string { return prr.ID } @@ -115,3 +93,595 @@ func (prr PullRequestReview) Reactions() ReactionGroups { func (prr PullRequestReview) Status() string { return prr.State } + +type PullRequestReviewStatus struct { + ChangesRequested bool + Approved bool + ReviewRequired bool +} + +func (pr *PullRequest) ReviewStatus() PullRequestReviewStatus { + var status PullRequestReviewStatus + switch pr.ReviewDecision { + case "CHANGES_REQUESTED": + status.ChangesRequested = true + case "APPROVED": + status.Approved = true + case "REVIEW_REQUIRED": + status.ReviewRequired = true + } + return status +} + +func (pr *PullRequest) DisplayableReviews() PullRequestReviews { + published := []PullRequestReview{} + for _, prr := range pr.Reviews.Nodes { + //Dont display pending reviews + //Dont display commenting reviews without top level comment body + if prr.State != "PENDING" && !(prr.State == "COMMENTED" && prr.Body == "") { + published = append(published, prr) + } + } + return PullRequestReviews{Nodes: published, TotalCount: len(published)} +} + +type ReviewRequests struct { + Nodes []struct { + RequestedReviewer RequestedReviewer + } +} + +type RequestedReviewer struct { + TypeName string `json:"__typename"` + Login string `json:"login"` + Name string `json:"name"` + Slug string `json:"slug"` + Organization struct { + Login string `json:"login"` + } `json:"organization"` +} + +const teamTypeName = "Team" +const botTypeName = "Bot" + +func (r RequestedReviewer) LoginOrSlug() string { + if r.TypeName == teamTypeName { + return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug) + } + return r.Login +} + +// DisplayName returns a user-friendly name for the reviewer. +// For Copilot bot, returns "Copilot (AI)". For teams, returns "org/slug". +// For users, returns "login (Name)" if name is available, otherwise just login. +func (r RequestedReviewer) DisplayName() string { + if r.TypeName == teamTypeName { + return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug) + } + if r.TypeName == botTypeName && r.Login == CopilotReviewerLogin { + return "Copilot (AI)" + } + if r.Name != "" { + return fmt.Sprintf("%s (%s)", r.Login, r.Name) + } + return r.Login +} + +func (r ReviewRequests) Logins() []string { + logins := make([]string, len(r.Nodes)) + for i, r := range r.Nodes { + logins[i] = r.RequestedReviewer.LoginOrSlug() + } + return logins +} + +// DisplayNames returns user-friendly display names for all requested reviewers. +func (r ReviewRequests) DisplayNames() []string { + names := make([]string, len(r.Nodes)) + for i, r := range r.Nodes { + names[i] = r.RequestedReviewer.DisplayName() + } + return names +} + +// ReviewerCandidate represents a potential reviewer for a pull request. +// This can be a User, Bot, or Team. +type ReviewerCandidate interface { + DisplayName() string + Login() string + + sealedReviewerCandidate() +} + +// ReviewerUser is a user who can review a pull request. +type ReviewerUser struct { + AssignableUser +} + +func NewReviewerUser(login, name string) ReviewerUser { + return ReviewerUser{ + AssignableUser: NewAssignableUser("", login, name), + } +} + +func (r ReviewerUser) sealedReviewerCandidate() {} + +// ReviewerBot is a bot who can review a pull request. +type ReviewerBot struct { + AssignableBot +} + +func NewReviewerBot(login string) ReviewerBot { + return ReviewerBot{ + AssignableBot: NewAssignableBot("", login), + } +} + +func (b ReviewerBot) DisplayName() string { + if b.login == CopilotReviewerLogin { + return fmt.Sprintf("%s (AI)", CopilotActorName) + } + return b.Login() +} + +func (r ReviewerBot) sealedReviewerCandidate() {} + +// ReviewerTeam is a team that can review a pull request. +type ReviewerTeam struct { + org string + teamSlug string +} + +// NewReviewerTeam creates a new ReviewerTeam. +func NewReviewerTeam(orgName, teamSlug string) ReviewerTeam { + return ReviewerTeam{org: orgName, teamSlug: teamSlug} +} + +func (r ReviewerTeam) DisplayName() string { + return fmt.Sprintf("%s/%s", r.org, r.teamSlug) +} + +func (r ReviewerTeam) Login() string { + return fmt.Sprintf("%s/%s", r.org, r.teamSlug) +} + +func (r ReviewerTeam) Slug() string { + return r.teamSlug +} + +func (r ReviewerTeam) sealedReviewerCandidate() {} + +func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error { + var mutation struct { + AddPullRequestReview struct { + ClientMutationID string + } `graphql:"addPullRequestReview(input:$input)"` + } + + state := githubv4.PullRequestReviewEventComment + switch input.State { + case ReviewApprove: + state = githubv4.PullRequestReviewEventApprove + case ReviewRequestChanges: + state = githubv4.PullRequestReviewEventRequestChanges + } + + body := githubv4.String(input.Body) + variables := map[string]interface{}{ + "input": githubv4.AddPullRequestReviewInput{ + PullRequestID: pr.ID, + Event: &state, + Body: &body, + }, + } + + return client.Mutate(repo.RepoHost(), "PullRequestReviewAdd", &mutation, variables) +} + +// AddPullRequestReviews adds the given user and team reviewers to a pull request using the REST API. +// Team identifiers can be in "org/slug" format. +func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { + if len(users) == 0 && len(teams) == 0 { + return nil + } + + // The API requires empty arrays instead of null values + if users == nil { + users = []string{} + } + + path := fmt.Sprintf( + "repos/%s/%s/pulls/%d/requested_reviewers", + url.PathEscape(repo.RepoOwner()), + url.PathEscape(repo.RepoName()), + prNumber, + ) + body := struct { + Reviewers []string `json:"reviewers"` + TeamReviewers []string `json:"team_reviewers"` + }{ + Reviewers: users, + TeamReviewers: extractTeamSlugs(teams), + } + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(body); err != nil { + return err + } + // The endpoint responds with the updated pull request object; we don't need it here. + return client.REST(repo.RepoHost(), "POST", path, buf, nil) +} + +// RemovePullRequestReviews removes requested reviewers from a pull request using the REST API. +// Team identifiers can be in "org/slug" format. +func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { + if len(users) == 0 && len(teams) == 0 { + return nil + } + + // The API requires empty arrays instead of null values + if users == nil { + users = []string{} + } + + path := fmt.Sprintf( + "repos/%s/%s/pulls/%d/requested_reviewers", + url.PathEscape(repo.RepoOwner()), + url.PathEscape(repo.RepoName()), + prNumber, + ) + body := struct { + Reviewers []string `json:"reviewers"` + TeamReviewers []string `json:"team_reviewers"` + }{ + Reviewers: users, + TeamReviewers: extractTeamSlugs(teams), + } + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(body); err != nil { + return err + } + // The endpoint responds with the updated pull request object; we don't need it here. + return client.REST(repo.RepoHost(), "DELETE", path, buf, nil) +} + +// RequestReviewsByLogin sets requested reviewers on a pull request using the GraphQL mutation. +// This mutation replaces existing reviewers with the provided set unless union is true. +// Only available on github.com, not GHES. +// Bot logins should be passed without the [bot] suffix; it is appended automatically. +// Team slugs must be in the format "org/team-slug". +// When union is false (replace mode), passing empty slices will remove all reviewers. +func RequestReviewsByLogin(client *Client, repo ghrepo.Interface, prID string, userLogins, botLogins, teamSlugs []string, union bool) error { + // In union mode (additive), nothing to do if all lists are empty. + // In replace mode, we may still need to call the mutation to clear reviewers. + if union && len(userLogins) == 0 && len(botLogins) == 0 && len(teamSlugs) == 0 { + return nil + } + + var mutation struct { + RequestReviewsByLogin struct { + ClientMutationId string `graphql:"clientMutationId"` + } `graphql:"requestReviewsByLogin(input: $input)"` + } + + type RequestReviewsByLoginInput struct { + PullRequestID githubv4.ID `json:"pullRequestId"` + UserLogins *[]githubv4.String `json:"userLogins,omitempty"` + BotLogins *[]githubv4.String `json:"botLogins,omitempty"` + TeamSlugs *[]githubv4.String `json:"teamSlugs,omitempty"` + Union githubv4.Boolean `json:"union"` + } + + input := RequestReviewsByLoginInput{ + PullRequestID: githubv4.ID(prID), + Union: githubv4.Boolean(union), + } + + userLoginValues := toGitHubV4Strings(userLogins, "") + input.UserLogins = &userLoginValues + + // Bot logins require the [bot] suffix for the mutation + botLoginValues := toGitHubV4Strings(botLogins, "[bot]") + input.BotLogins = &botLoginValues + + teamSlugValues := toGitHubV4Strings(teamSlugs, "") + input.TeamSlugs = &teamSlugValues + + variables := map[string]interface{}{ + "input": input, + } + + return client.Mutate(repo.RepoHost(), "RequestReviewsByLogin", &mutation, variables) +} + +// SuggestedReviewerActors fetches suggested reviewers for a pull request. +// It combines results from three sources using a cascading quota system: +// - suggestedReviewerActors - suggested based on PR activity (base quota: 5) +// - repository collaborators - all collaborators (base quota: 5 + unfilled from suggestions) +// - organization teams - all teams for org repos (base quota: 5 + unfilled from collaborators) +// +// This ensures we show up to 15 total candidates, with each source filling any +// unfilled quota from the previous source. Results are deduplicated. +// Returns the candidates, a MoreResults count, and an error. +func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, query string) ([]ReviewerCandidate, int, error) { + // Fetch 10 from each source to allow cascading quota to fill from available results. + // Use a single query that includes organization.teams - if the owner is not an org, + // we'll get a "Could not resolve to an Organization" error which we handle gracefully. + // We also fetch unfiltered total counts via aliases for the "X more" display. + type responseData struct { + Node struct { + PullRequest struct { + SuggestedActors struct { + Nodes []struct { + IsAuthor bool + IsCommenter bool + Reviewer struct { + TypeName string `graphql:"__typename"` + User struct { + Login string + Name string + } `graphql:"... on User"` + Bot struct { + Login string + } `graphql:"... on Bot"` + } + } + } `graphql:"suggestedReviewerActors(first: 10, query: $query)"` + } `graphql:"... on PullRequest"` + } `graphql:"node(id: $id)"` + Repository struct { + Collaborators struct { + Nodes []struct { + Login string + Name string + } + } `graphql:"collaborators(first: 10, query: $query)"` + CollaboratorsTotalCount struct { + TotalCount int + } `graphql:"collaboratorsTotalCount: collaborators(first: 0)"` + } `graphql:"repository(owner: $owner, name: $name)"` + Organization struct { + Teams struct { + Nodes []struct { + Slug string + } + } `graphql:"teams(first: 10, query: $query)"` + TeamsTotalCount struct { + TotalCount int + } `graphql:"teamsTotalCount: teams(first: 0)"` + } `graphql:"organization(login: $owner)"` + } + + variables := map[string]interface{}{ + "id": githubv4.ID(prID), + "query": githubv4.String(query), + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + } + + var result responseData + err := client.Query(repo.RepoHost(), "SuggestedReviewerActors", &result, variables) + // Handle the case where the owner is not an organization - the query still returns + // partial data (repository, node), so we can continue processing. + if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) { + return nil, 0, err + } + + // Build candidates using cascading quota logic: + // Each source has a base quota of 5, plus any unfilled quota from previous sources. + // This ensures we show up to 15 total candidates, filling gaps when earlier sources have fewer. + seen := make(map[string]bool) + var candidates []ReviewerCandidate + const baseQuota = 5 + + // Suggested reviewers (excluding author) + suggestionsAdded := 0 + for _, n := range result.Node.PullRequest.SuggestedActors.Nodes { + if suggestionsAdded >= baseQuota { + break + } + if n.IsAuthor { + continue + } + var candidate ReviewerCandidate + var login string + if n.Reviewer.TypeName == "User" && n.Reviewer.User.Login != "" { + login = n.Reviewer.User.Login + candidate = NewReviewerUser(login, n.Reviewer.User.Name) + } else if n.Reviewer.TypeName == "Bot" && n.Reviewer.Bot.Login != "" { + login = n.Reviewer.Bot.Login + candidate = NewReviewerBot(login) + } else { + continue + } + if !seen[login] { + seen[login] = true + candidates = append(candidates, candidate) + suggestionsAdded++ + } + } + + // Collaborators: quota = base + unfilled from suggestions + collaboratorsQuota := baseQuota + (baseQuota - suggestionsAdded) + collaboratorsAdded := 0 + for _, c := range result.Repository.Collaborators.Nodes { + if collaboratorsAdded >= collaboratorsQuota { + break + } + if c.Login == "" { + continue + } + if !seen[c.Login] { + seen[c.Login] = true + candidates = append(candidates, NewReviewerUser(c.Login, c.Name)) + collaboratorsAdded++ + } + } + + // Teams: quota = base + unfilled from collaborators + teamsQuota := baseQuota + (collaboratorsQuota - collaboratorsAdded) + teamsAdded := 0 + ownerName := repo.RepoOwner() + for _, t := range result.Organization.Teams.Nodes { + if teamsAdded >= teamsQuota { + break + } + if t.Slug == "" { + continue + } + teamLogin := fmt.Sprintf("%s/%s", ownerName, t.Slug) + if !seen[teamLogin] { + seen[teamLogin] = true + candidates = append(candidates, NewReviewerTeam(ownerName, t.Slug)) + teamsAdded++ + } + } + + // MoreResults uses unfiltered total counts (teams will be 0 for personal repos) + moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Organization.TeamsTotalCount.TotalCount + + return candidates, moreResults, nil +} + +// SuggestedReviewerActorsForRepo fetches potential reviewers for a repository. +// Unlike SuggestedReviewerActors, this doesn't require an existing PR - used for gh pr create. +// It combines results from two sources using a cascading quota system: +// - repository collaborators (base quota: 5) +// - organization teams (base quota: 5 + unfilled from collaborators) +// +// This ensures we show up to 10 total candidates, with each source filling any +// unfilled quota from the previous source. Results are deduplicated. +// Returns the candidates, a MoreResults count, and an error. +func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query string) ([]ReviewerCandidate, int, error) { + type responseData struct { + Repository struct { + // Check for Copilot availability by looking at any open PR's suggested reviewers + PullRequests struct { + Nodes []struct { + SuggestedActors struct { + Nodes []struct { + Reviewer struct { + TypeName string `graphql:"__typename"` + Bot struct { + Login string + } `graphql:"... on Bot"` + } + } + } `graphql:"suggestedReviewerActors(first: 10)"` + } + } `graphql:"pullRequests(first: 1, states: [OPEN])"` + Collaborators struct { + Nodes []struct { + Login string + Name string + } + } `graphql:"collaborators(first: 10, query: $query)"` + CollaboratorsTotalCount struct { + TotalCount int + } `graphql:"collaboratorsTotalCount: collaborators(first: 0)"` + } `graphql:"repository(owner: $owner, name: $name)"` + Organization struct { + Teams struct { + Nodes []struct { + Slug string + } + } `graphql:"teams(first: 10, query: $query)"` + TeamsTotalCount struct { + TotalCount int + } `graphql:"teamsTotalCount: teams(first: 0)"` + } `graphql:"organization(login: $owner)"` + } + + variables := map[string]interface{}{ + "query": githubv4.String(query), + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + } + + var result responseData + err := client.Query(repo.RepoHost(), "SuggestedReviewerActorsForRepo", &result, variables) + // Handle the case where the owner is not an organization - the query still returns + // partial data (repository), so we can continue processing. + if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) { + return nil, 0, err + } + + // Build candidates using cascading quota logic + seen := make(map[string]bool) + var candidates []ReviewerCandidate + const baseQuota = 5 + + // Check for Copilot availability from open PR's suggested reviewers + for _, pr := range result.Repository.PullRequests.Nodes { + for _, actor := range pr.SuggestedActors.Nodes { + if actor.Reviewer.TypeName == "Bot" && actor.Reviewer.Bot.Login == CopilotReviewerLogin { + candidates = append(candidates, NewReviewerBot(CopilotReviewerLogin)) + seen[CopilotReviewerLogin] = true + break + } + } + } + + // Collaborators + collaboratorsAdded := 0 + for _, c := range result.Repository.Collaborators.Nodes { + if collaboratorsAdded >= baseQuota { + break + } + if c.Login == "" { + continue + } + if !seen[c.Login] { + seen[c.Login] = true + candidates = append(candidates, NewReviewerUser(c.Login, c.Name)) + collaboratorsAdded++ + } + } + + // Teams: quota = base + unfilled from collaborators + teamsQuota := baseQuota + (baseQuota - collaboratorsAdded) + teamsAdded := 0 + ownerName := repo.RepoOwner() + for _, t := range result.Organization.Teams.Nodes { + if teamsAdded >= teamsQuota { + break + } + if t.Slug == "" { + continue + } + teamLogin := fmt.Sprintf("%s/%s", ownerName, t.Slug) + if !seen[teamLogin] { + seen[teamLogin] = true + candidates = append(candidates, NewReviewerTeam(ownerName, t.Slug)) + teamsAdded++ + } + } + + // MoreResults uses unfiltered total counts (teams will be 0 for personal repos) + moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Organization.TeamsTotalCount.TotalCount + + return candidates, moreResults, nil +} + +// extractTeamSlugs extracts just the slug portion from team identifiers. +// Team identifiers can be in "org/slug" format; this returns just the slug. +func extractTeamSlugs(teams []string) []string { + slugs := make([]string, 0, len(teams)) + for _, t := range teams { + if t == "" { + continue + } + s := strings.SplitN(t, "/", 2) + slugs = append(slugs, s[len(s)-1]) + } + return slugs +} + +// toGitHubV4Strings converts a string slice to a githubv4.String slice, +// optionally appending a suffix to each element. +func toGitHubV4Strings(strs []string, suffix string) []githubv4.String { + result := make([]githubv4.String, len(strs)) + for i, s := range strs { + result[i] = githubv4.String(s + suffix) + } + return result +} From ceb904413dd8f7478bdc2a7dc1ab0acfd4ab2608 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:45:00 -0700 Subject: [PATCH 24/92] Clarify ReviewerCandidate relationship to AssignableActor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_pr_review.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index b233ee46ff1..e4deb7124be 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -185,7 +185,10 @@ func (r ReviewRequests) DisplayNames() []string { } // ReviewerCandidate represents a potential reviewer for a pull request. -// This can be a User, Bot, or Team. +// This can be a User, Bot, or Team. It mirrors AssignableActor but adds +// team support (teams can review but not be assigned) and drops the ID method. +// ReviewerUser and ReviewerBot are thin wrappers around AssignableUser and +// AssignableBot that satisfy this interface. type ReviewerCandidate interface { DisplayName() string Login() string From fa95f3a21b2bd525e834786594054e513fdb9eeb Mon Sep 17 00:00:00 2001 From: 4RH1T3CT0R7 Date: Sat, 14 Feb 2026 20:31:02 +0300 Subject: [PATCH 25/92] Add --no-upstream flag to gh repo clone When cloning a forked repository, `gh repo clone` automatically adds the parent repo as an `upstream` remote and sets it as the default repository. This can be problematic when the user lacks access to the parent repo, the upstream fetch is expensive for large repos, or the user simply doesn't want the upstream remote. Add a `--no-upstream` flag that skips adding the upstream remote when cloning a fork. When used, origin (the fork) is set as the default repository instead. The flag is mutually exclusive with `--upstream-remote-name`. For non-fork repos the flag is a harmless no-op. Closes #8274 --- pkg/cmd/repo/clone/clone.go | 62 +++++++++++++++---------- pkg/cmd/repo/clone/clone_test.go | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 24 deletions(-) diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index 1466cd96a0d..7c538214484 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -27,6 +27,7 @@ type CloneOptions struct { GitArgs []string Repository string UpstreamName string + NoUpstream bool } func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Command { @@ -60,6 +61,8 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm the remote after the owner of the parent repository. If the repository is a fork, its parent repository will be set as the default remote repository. + + To skip adding the upstream remote entirely, use %[1]s--no-upstream%[1]s. `, "`"), Example: heredoc.Doc(` # Clone a repository from a specific org @@ -77,6 +80,9 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm # Clone a repository with additional git clone flags $ gh repo clone cli/cli -- --depth=1 + + # Clone a fork without adding an upstream remote + $ gh repo clone myfork --no-upstream `), RunE: func(cmd *cobra.Command, args []string) error { opts.Repository = args[0] @@ -91,6 +97,8 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm } cmd.Flags().StringVarP(&opts.UpstreamName, "upstream-remote-name", "u", "upstream", "Upstream remote name when cloning a fork") + cmd.Flags().BoolVar(&opts.NoUpstream, "no-upstream", false, "Do not add an upstream remote when cloning a fork") + cmd.MarkFlagsMutuallyExclusive("upstream-remote-name", "no-upstream") cmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { if err == pflag.ErrHelp { return err @@ -187,37 +195,43 @@ func cloneRun(opts *CloneOptions) error { // If the repo is a fork, add the parent as an upstream remote and set the parent as the default repo. if canonicalRepo.Parent != nil { - protocol := cfg.GitProtocol(canonicalRepo.Parent.RepoHost()).Value - upstreamURL := ghrepo.FormatRemoteURL(canonicalRepo.Parent, protocol) - - upstreamName := opts.UpstreamName - if opts.UpstreamName == "@owner" { - upstreamName = canonicalRepo.Parent.RepoOwner() - } - gc := gitClient.Copy() gc.RepoDir = cloneDir - if _, err := gc.AddRemote(ctx, upstreamName, upstreamURL, []string{canonicalRepo.Parent.DefaultBranchRef.Name}); err != nil { - return err - } + if opts.NoUpstream { + if err := gc.SetRemoteResolution(ctx, "origin", "base"); err != nil { + return err + } + } else { + protocol := cfg.GitProtocol(canonicalRepo.Parent.RepoHost()).Value + upstreamURL := ghrepo.FormatRemoteURL(canonicalRepo.Parent, protocol) - if err := gc.Fetch(ctx, upstreamName, ""); err != nil { - return err - } + upstreamName := opts.UpstreamName + if opts.UpstreamName == "@owner" { + upstreamName = canonicalRepo.Parent.RepoOwner() + } - if err := gc.SetRemoteBranches(ctx, upstreamName, `*`); err != nil { - return err - } + if _, err := gc.AddRemote(ctx, upstreamName, upstreamURL, []string{canonicalRepo.Parent.DefaultBranchRef.Name}); err != nil { + return err + } - if err = gc.SetRemoteResolution(ctx, upstreamName, "base"); err != nil { - return err - } + if err := gc.Fetch(ctx, upstreamName, ""); err != nil { + return err + } - connectedToTerminal := opts.IO.IsStdoutTTY() - if connectedToTerminal { - cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(canonicalRepo.Parent))) + if err := gc.SetRemoteBranches(ctx, upstreamName, `*`); err != nil { + return err + } + + if err := gc.SetRemoteResolution(ctx, upstreamName, "base"); err != nil { + return err + } + + connectedToTerminal := opts.IO.IsStdoutTTY() + if connectedToTerminal { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(canonicalRepo.Parent))) + } } } return nil diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go index 471ed05ddc2..bab2bd670bd 100644 --- a/pkg/cmd/repo/clone/clone_test.go +++ b/pkg/cmd/repo/clone/clone_test.go @@ -54,6 +54,20 @@ func TestNewCmdClone(t *testing.T) { GitArgs: []string{"--depth", "1", "--recurse-submodules"}, }, }, + { + name: "no-upstream flag", + args: "OWNER/REPO --no-upstream", + wantOpts: CloneOptions{ + Repository: "OWNER/REPO", + GitArgs: []string{}, + NoUpstream: true, + }, + }, + { + name: "no-upstream with upstream-remote-name", + args: "OWNER/REPO --no-upstream --upstream-remote-name test", + wantErr: "if any flags in the group [upstream-remote-name no-upstream] are set none of the others can be; [no-upstream upstream-remote-name] were all set", + }, { name: "unknown argument", args: "OWNER/REPO --depth 1", @@ -92,6 +106,7 @@ func TestNewCmdClone(t *testing.T) { assert.Equal(t, tt.wantOpts.Repository, opts.Repository) assert.Equal(t, tt.wantOpts.GitArgs, opts.GitArgs) + assert.Equal(t, tt.wantOpts.NoUpstream, opts.NoUpstream) }) } } @@ -344,6 +359,70 @@ func Test_RepoClone_withoutUsername(t *testing.T) { assert.Equal(t, "", output.Stderr()) } +func Test_RepoClone_hasParent_noUpstream(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "name": "REPO", + "owner": { + "login": "OWNER" + }, + "parent": { + "name": "ORIG", + "owner": { + "login": "hubot" + }, + "defaultBranchRef": { + "name": "trunk" + } + } + } } } + `)) + + httpClient := &http.Client{Transport: reg} + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git clone https://github.com/OWNER/REPO.git`, 0, "") + cs.Register(`git -C REPO config --add remote.origin.gh-resolved base`, 0, "") + + _, err := runCloneCommand(httpClient, "OWNER/REPO --no-upstream") + if err != nil { + t.Fatalf("error running command `repo clone`: %v", err) + } +} + +func Test_RepoClone_noParent_noUpstream(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "name": "REPO", + "owner": { + "login": "OWNER" + } + } } } + `)) + + httpClient := &http.Client{Transport: reg} + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git clone https://github.com/OWNER/REPO.git`, 0, "") + + _, err := runCloneCommand(httpClient, "OWNER/REPO --no-upstream") + if err != nil { + t.Fatalf("error running command `repo clone`: %v", err) + } +} + func TestSimplifyURL(t *testing.T) { tests := []struct { name string From 48951aca01fd5bfeaf0c07747f01acd2b1140d71 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:52:01 -0700 Subject: [PATCH 26/92] Fix invalid ANSI SGR escape code in JSON and diff colorization Replace `1;38` with `1;37` (bold white) in the delimiter/header color constant. SGR parameter 38 is the extended foreground color prefix and requires sub-parameters (e.g. `38;5;n` or `38;2;r;g;b`), so using it bare produces an invalid escape sequence. Most terminals silently ignore the malformed parameter, masking the bug. Fixes cli/cli#12683 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/pr/diff/diff.go | 2 +- pkg/cmd/pr/diff/diff_test.go | 4 ++-- pkg/jsoncolor/jsoncolor.go | 2 +- pkg/jsoncolor/jsoncolor_test.go | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index acf17462c5e..6d37bf1e5b8 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -190,7 +190,7 @@ func fetchDiff(httpClient *http.Client, baseRepo ghrepo.Interface, prNumber int, const lineBufferSize = 4096 var ( - colorHeader = []byte("\x1b[1;38m") + colorHeader = []byte("\x1b[1;37m") colorAddition = []byte("\x1b[32m") colorRemoval = []byte("\x1b[31m") colorReset = []byte("\x1b[m") diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go index 28a83bfc42a..ceb93a18790 100644 --- a/pkg/cmd/pr/diff/diff_test.go +++ b/pkg/cmd/pr/diff/diff_test.go @@ -179,7 +179,7 @@ func Test_diffRun(t *testing.T) { Patch: false, }, wantFields: []string{"number"}, - wantStdout: fmt.Sprintf(testDiff, "\x1b[m", "\x1b[1;38m", "\x1b[32m", "\x1b[31m"), + wantStdout: fmt.Sprintf(testDiff, "\x1b[m", "\x1b[1;37m", "\x1b[32m", "\x1b[31m"), httpStubs: func(reg *httpmock.Registry) { stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", "")) }, @@ -313,7 +313,7 @@ func Test_colorDiffLines(t *testing.T) { "%[4]s+foo%[2]s\n%[5]s-b%[1]sr%[2]s\n%[3]s+++ baz%[2]s\n", strings.Repeat("a", 2*lineBufferSize), "\x1b[m", - "\x1b[1;38m", + "\x1b[1;37m", "\x1b[32m", "\x1b[31m", ), diff --git a/pkg/jsoncolor/jsoncolor.go b/pkg/jsoncolor/jsoncolor.go index 8e20a11611a..b9ff9525362 100644 --- a/pkg/jsoncolor/jsoncolor.go +++ b/pkg/jsoncolor/jsoncolor.go @@ -9,7 +9,7 @@ import ( ) const ( - colorDelim = "1;38" // bright white + colorDelim = "1;37" // bold white colorKey = "1;34" // bright blue colorNull = "36" // cyan colorString = "32" // green diff --git a/pkg/jsoncolor/jsoncolor_test.go b/pkg/jsoncolor/jsoncolor_test.go index 9e2eda34314..d2a22b90bc0 100644 --- a/pkg/jsoncolor/jsoncolor_test.go +++ b/pkg/jsoncolor/jsoncolor_test.go @@ -34,7 +34,7 @@ func TestWrite(t *testing.T) { r: bytes.NewBufferString(`{}`), indent: "", }, - wantW: "\x1b[1;38m{\x1b[m\x1b[1;38m}\x1b[m\n", + wantW: "\x1b[1;37m{\x1b[m\x1b[1;37m}\x1b[m\n", wantErr: false, }, { @@ -43,9 +43,9 @@ func TestWrite(t *testing.T) { r: bytes.NewBufferString(`{"hash":{"a":1,"b":2},"array":[3,4]}`), indent: "\t", }, - wantW: "\x1b[1;38m{\x1b[m\n\t\x1b[1;34m\"hash\"\x1b[m\x1b[1;38m:\x1b[m " + - "\x1b[1;38m{\x1b[m\n\t\t\x1b[1;34m\"a\"\x1b[m\x1b[1;38m:\x1b[m 1\x1b[1;38m,\x1b[m\n\t\t\x1b[1;34m\"b\"\x1b[m\x1b[1;38m:\x1b[m 2\n\t\x1b[1;38m}\x1b[m\x1b[1;38m,\x1b[m" + - "\n\t\x1b[1;34m\"array\"\x1b[m\x1b[1;38m:\x1b[m \x1b[1;38m[\x1b[m\n\t\t3\x1b[1;38m,\x1b[m\n\t\t4\n\t\x1b[1;38m]\x1b[m\n\x1b[1;38m}\x1b[m\n", + wantW: "\x1b[1;37m{\x1b[m\n\t\x1b[1;34m\"hash\"\x1b[m\x1b[1;37m:\x1b[m " + + "\x1b[1;37m{\x1b[m\n\t\t\x1b[1;34m\"a\"\x1b[m\x1b[1;37m:\x1b[m 1\x1b[1;37m,\x1b[m\n\t\t\x1b[1;34m\"b\"\x1b[m\x1b[1;37m:\x1b[m 2\n\t\x1b[1;37m}\x1b[m\x1b[1;37m,\x1b[m" + + "\n\t\x1b[1;34m\"array\"\x1b[m\x1b[1;37m:\x1b[m \x1b[1;37m[\x1b[m\n\t\t3\x1b[1;37m,\x1b[m\n\t\t4\n\t\x1b[1;37m]\x1b[m\n\x1b[1;37m}\x1b[m\n", wantErr: false, }, { @@ -63,7 +63,7 @@ func TestWrite(t *testing.T) { r: bytes.NewBufferString(`{{`), indent: "", }, - wantW: "\x1b[1;38m{\x1b[m\n", + wantW: "\x1b[1;37m{\x1b[m\n", wantErr: true, }, } From 07e802d616dc7a6d35275a1372af621dee0e2e47 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 24 Feb 2026 15:22:52 +0000 Subject: [PATCH 27/92] fix(script/licenses): generate licenses in separate dirs Signed-off-by: Babak K. Shandiz --- script/licenses | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/licenses b/script/licenses index ecdcbcdf4b8..01f87897ea3 100755 --- a/script/licenses +++ b/script/licenses @@ -75,8 +75,8 @@ if [ "$1" = "--check" ]; then echo "License generation verified for all platforms." elif [ $# -eq 2 ]; then - generate_licenses "$1" "$2" "internal/licenses/embed" - echo "Licenses written to internal/licenses/embed/" + generate_licenses "$1" "$2" "internal/licenses/embed/${1}-${2}" + echo "Licenses written to internal/licenses/embed/${1}-${2}" else echo "Usage: $0 " echo " $0 --check" From ad5ebc6ad976b68d2cb0b56783c60168a13a6dc6 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 24 Feb 2026 15:23:18 +0000 Subject: [PATCH 28/92] chore(script/licenses): fix indentation Signed-off-by: Babak K. Shandiz --- script/licenses | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/licenses b/script/licenses index 01f87897ea3..1c9debf6136 100755 --- a/script/licenses +++ b/script/licenses @@ -3,7 +3,7 @@ # Generate third-party license information for embedding in the binary. # # Usage: -# ./script/licenses Generate licenses for a single platform +# ./script/licenses Generate licenses for a single platform # ./script/licenses --check Verify generation works for all release platforms # # The single-platform mode is called by goreleaser pre-build hooks to generate From 6057bfadbfcf4570031a3c2ee972184f917cc935 Mon Sep 17 00:00:00 2001 From: itchyny Date: Wed, 25 Feb 2026 19:43:45 +0900 Subject: [PATCH 29/92] Use pre-compiled regexp for matching Content-Type --- pkg/cmd/api/api.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 53c07f3f033..8e4b2edd45d 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -456,6 +456,8 @@ func apiRun(opts *ApiOptions) error { return tmpl.Flush() } +var jsonContentTypeRE = regexp.MustCompile(`[/+]json(;|$)`) + func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersWriter io.Writer, template *template.Template, isFirstPage, isLastPage bool) (endCursor string, err error) { if opts.ShowResponseHeaders { fmt.Fprintln(headersWriter, resp.Proto, resp.Status) @@ -469,7 +471,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW var responseBody io.Reader = resp.Body defer resp.Body.Close() - isJSON, _ := regexp.MatchString(`[/+]json(;|$)`, resp.Header.Get("Content-Type")) + isJSON := jsonContentTypeRE.MatchString(resp.Header.Get("Content-Type")) var serverError string if isJSON && (opts.RequestPath == "graphql" || resp.StatusCode >= 400) { From 8fe4ddd7ce7eeb09f5642e4ccd90f3ae0313ee3e Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 25 Feb 2026 14:58:56 +0000 Subject: [PATCH 30/92] chore(gitignore): ignore generated license files Signed-off-by: Babak K. Shandiz --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index a4b73ac7a50..b82a00c7274 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ # Windows resource files /cmd/gh/*.syso +# Third-party licenses +/internal/licenses/embed/*/* +!/internal/licenses/embed/*/PLACEHOLDER + # VS Code .vscode From c9543e748919e9f1955e731d1b4436e4753dfb7f Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 25 Feb 2026 14:59:52 +0000 Subject: [PATCH 31/92] fix(licenses): implement VCS-friendly embedding Signed-off-by: Babak K. Shandiz --- .../licenses/embed/darwin-amd64/PLACEHOLDER | 0 .../licenses/embed/darwin-arm64/PLACEHOLDER | 0 internal/licenses/embed/linux-386/PLACEHOLDER | 0 .../licenses/embed/linux-amd64/PLACEHOLDER | 0 internal/licenses/embed/linux-arm/PLACEHOLDER | 0 .../licenses/embed/linux-arm64/PLACEHOLDER | 0 internal/licenses/embed/report.txt | 1 - .../licenses/embed/third-party/PLACEHOLDER | 1 - .../licenses/embed/windows-386/PLACEHOLDER | 0 .../licenses/embed/windows-amd64/PLACEHOLDER | 0 .../licenses/embed/windows-arm64/PLACEHOLDER | 0 internal/licenses/embed_darwin_amd64.go | 8 + internal/licenses/embed_darwin_arm64.go | 8 + internal/licenses/embed_default.go | 15 ++ internal/licenses/embed_linux_386.go | 8 + internal/licenses/embed_linux_amd64.go | 8 + internal/licenses/embed_linux_arm.go | 8 + internal/licenses/embed_linux_arm64.go | 8 + internal/licenses/embed_windows_386.go | 8 + internal/licenses/embed_windows_amd64.go | 8 + internal/licenses/embed_windows_arm64.go | 8 + internal/licenses/licenses.go | 49 +++-- internal/licenses/licenses_test.go | 205 ++++++++++++------ 23 files changed, 252 insertions(+), 91 deletions(-) create mode 100644 internal/licenses/embed/darwin-amd64/PLACEHOLDER create mode 100644 internal/licenses/embed/darwin-arm64/PLACEHOLDER create mode 100644 internal/licenses/embed/linux-386/PLACEHOLDER create mode 100644 internal/licenses/embed/linux-amd64/PLACEHOLDER create mode 100644 internal/licenses/embed/linux-arm/PLACEHOLDER create mode 100644 internal/licenses/embed/linux-arm64/PLACEHOLDER delete mode 100644 internal/licenses/embed/report.txt delete mode 100644 internal/licenses/embed/third-party/PLACEHOLDER create mode 100644 internal/licenses/embed/windows-386/PLACEHOLDER create mode 100644 internal/licenses/embed/windows-amd64/PLACEHOLDER create mode 100644 internal/licenses/embed/windows-arm64/PLACEHOLDER create mode 100644 internal/licenses/embed_darwin_amd64.go create mode 100644 internal/licenses/embed_darwin_arm64.go create mode 100644 internal/licenses/embed_default.go create mode 100644 internal/licenses/embed_linux_386.go create mode 100644 internal/licenses/embed_linux_amd64.go create mode 100644 internal/licenses/embed_linux_arm.go create mode 100644 internal/licenses/embed_linux_arm64.go create mode 100644 internal/licenses/embed_windows_386.go create mode 100644 internal/licenses/embed_windows_amd64.go create mode 100644 internal/licenses/embed_windows_arm64.go diff --git a/internal/licenses/embed/darwin-amd64/PLACEHOLDER b/internal/licenses/embed/darwin-amd64/PLACEHOLDER new file mode 100644 index 00000000000..e69de29bb2d diff --git a/internal/licenses/embed/darwin-arm64/PLACEHOLDER b/internal/licenses/embed/darwin-arm64/PLACEHOLDER new file mode 100644 index 00000000000..e69de29bb2d diff --git a/internal/licenses/embed/linux-386/PLACEHOLDER b/internal/licenses/embed/linux-386/PLACEHOLDER new file mode 100644 index 00000000000..e69de29bb2d diff --git a/internal/licenses/embed/linux-amd64/PLACEHOLDER b/internal/licenses/embed/linux-amd64/PLACEHOLDER new file mode 100644 index 00000000000..e69de29bb2d diff --git a/internal/licenses/embed/linux-arm/PLACEHOLDER b/internal/licenses/embed/linux-arm/PLACEHOLDER new file mode 100644 index 00000000000..e69de29bb2d diff --git a/internal/licenses/embed/linux-arm64/PLACEHOLDER b/internal/licenses/embed/linux-arm64/PLACEHOLDER new file mode 100644 index 00000000000..e69de29bb2d diff --git a/internal/licenses/embed/report.txt b/internal/licenses/embed/report.txt deleted file mode 100644 index 002dfeeec8a..00000000000 --- a/internal/licenses/embed/report.txt +++ /dev/null @@ -1 +0,0 @@ -License information is only available in official release builds. diff --git a/internal/licenses/embed/third-party/PLACEHOLDER b/internal/licenses/embed/third-party/PLACEHOLDER deleted file mode 100644 index 48cdce85287..00000000000 --- a/internal/licenses/embed/third-party/PLACEHOLDER +++ /dev/null @@ -1 +0,0 @@ -placeholder diff --git a/internal/licenses/embed/windows-386/PLACEHOLDER b/internal/licenses/embed/windows-386/PLACEHOLDER new file mode 100644 index 00000000000..e69de29bb2d diff --git a/internal/licenses/embed/windows-amd64/PLACEHOLDER b/internal/licenses/embed/windows-amd64/PLACEHOLDER new file mode 100644 index 00000000000..e69de29bb2d diff --git a/internal/licenses/embed/windows-arm64/PLACEHOLDER b/internal/licenses/embed/windows-arm64/PLACEHOLDER new file mode 100644 index 00000000000..e69de29bb2d diff --git a/internal/licenses/embed_darwin_amd64.go b/internal/licenses/embed_darwin_amd64.go new file mode 100644 index 00000000000..9da7398c61e --- /dev/null +++ b/internal/licenses/embed_darwin_amd64.go @@ -0,0 +1,8 @@ +package licenses + +import "embed" + +const rootDir = "embed/darwin-amd64" + +//go:embed all:embed/darwin-amd64 +var embedFS embed.FS diff --git a/internal/licenses/embed_darwin_arm64.go b/internal/licenses/embed_darwin_arm64.go new file mode 100644 index 00000000000..844a51ab948 --- /dev/null +++ b/internal/licenses/embed_darwin_arm64.go @@ -0,0 +1,8 @@ +package licenses + +import "embed" + +const rootDir = "embed/darwin-arm64" + +//go:embed all:embed/darwin-arm64 +var embedFS embed.FS diff --git a/internal/licenses/embed_default.go b/internal/licenses/embed_default.go new file mode 100644 index 00000000000..387f4285fd8 --- /dev/null +++ b/internal/licenses/embed_default.go @@ -0,0 +1,15 @@ +// This file is necessary to allow building on platforms that we do not have +// official release builds for. Without this, `go build` or `go install` calls +// would fail due to undefined symbols that are expected to be included in the +// build. + +//go:build !(darwin && (amd64 || arm64)) && !(linux && (386 || amd64 || arm || arm64)) && !(windows && (386 || amd64 || arm64)) + +package licenses + +import "embed" + +const rootDir = "" + +// embedFS is left empty to indicate there's no embedded content. +var embedFS embed.FS diff --git a/internal/licenses/embed_linux_386.go b/internal/licenses/embed_linux_386.go new file mode 100644 index 00000000000..f6f34313ee9 --- /dev/null +++ b/internal/licenses/embed_linux_386.go @@ -0,0 +1,8 @@ +package licenses + +import "embed" + +const rootDir = "embed/linux-386" + +//go:embed all:embed/linux-386 +var embedFS embed.FS diff --git a/internal/licenses/embed_linux_amd64.go b/internal/licenses/embed_linux_amd64.go new file mode 100644 index 00000000000..8c944d61377 --- /dev/null +++ b/internal/licenses/embed_linux_amd64.go @@ -0,0 +1,8 @@ +package licenses + +import "embed" + +const rootDir = "embed/linux-amd64" + +//go:embed all:embed/linux-amd64 +var embedFS embed.FS diff --git a/internal/licenses/embed_linux_arm.go b/internal/licenses/embed_linux_arm.go new file mode 100644 index 00000000000..61ba21d7d94 --- /dev/null +++ b/internal/licenses/embed_linux_arm.go @@ -0,0 +1,8 @@ +package licenses + +import "embed" + +const rootDir = "embed/linux-arm" + +//go:embed all:embed/linux-arm +var embedFS embed.FS diff --git a/internal/licenses/embed_linux_arm64.go b/internal/licenses/embed_linux_arm64.go new file mode 100644 index 00000000000..99013dc98ad --- /dev/null +++ b/internal/licenses/embed_linux_arm64.go @@ -0,0 +1,8 @@ +package licenses + +import "embed" + +const rootDir = "embed/linux-arm64" + +//go:embed all:embed/linux-arm64 +var embedFS embed.FS diff --git a/internal/licenses/embed_windows_386.go b/internal/licenses/embed_windows_386.go new file mode 100644 index 00000000000..1976ab9f13c --- /dev/null +++ b/internal/licenses/embed_windows_386.go @@ -0,0 +1,8 @@ +package licenses + +import "embed" + +const rootDir = "embed/windows-386" + +//go:embed all:embed/windows-386 +var embedFS embed.FS diff --git a/internal/licenses/embed_windows_amd64.go b/internal/licenses/embed_windows_amd64.go new file mode 100644 index 00000000000..3e9fb0b5d60 --- /dev/null +++ b/internal/licenses/embed_windows_amd64.go @@ -0,0 +1,8 @@ +package licenses + +import "embed" + +const rootDir = "embed/windows-amd64" + +//go:embed all:embed/windows-amd64 +var embedFS embed.FS diff --git a/internal/licenses/embed_windows_arm64.go b/internal/licenses/embed_windows_arm64.go new file mode 100644 index 00000000000..4afd13825ab --- /dev/null +++ b/internal/licenses/embed_windows_arm64.go @@ -0,0 +1,8 @@ +package licenses + +import "embed" + +const rootDir = "embed/windows-arm64" + +//go:embed all:embed/windows-arm64 +var embedFS embed.FS diff --git a/internal/licenses/licenses.go b/internal/licenses/licenses.go index 09c8058f773..1499a0722fc 100644 --- a/internal/licenses/licenses.go +++ b/internal/licenses/licenses.go @@ -1,28 +1,31 @@ package licenses import ( - "embed" "fmt" "io/fs" - "path/filepath" + "path" "sort" "strings" ) -//go:embed embed/report.txt -var report string - -//go:embed all:embed/third-party -var thirdParty embed.FS - +// Content returns the full license report, including the main report and all +// third-party licenses. func Content() string { - return content(report, thirdParty, "embed/third-party") + return content(embedFS, rootDir) } -func content(report string, thirdPartyFS fs.ReadFileFS, root string) string { +func content(embedFS fs.ReadFileFS, rootDir string) string { var b strings.Builder - b.WriteString(report) + reportPath := path.Join(rootDir, "report.txt") + thirdPartyPath := path.Join(rootDir, "third-party") + + report, err := fs.ReadFile(embedFS, reportPath) + if err != nil { + return "License information is only available in official release builds.\n" + } + + b.Write(report) b.WriteString("\n") // Walk the third-party directory and output each license/notice file @@ -32,8 +35,13 @@ func content(report string, thirdPartyFS fs.ReadFileFS, root string) string { files []string } + thirdPartyFS, err := fs.Sub(embedFS, thirdPartyPath) + if err != nil { + return b.String() + } + modules := map[string]*moduleFiles{} - fs.WalkDir(thirdPartyFS, root, func(filePath string, d fs.DirEntry, err error) error { + fs.WalkDir(thirdPartyFS, ".", func(filePath string, d fs.DirEntry, err error) error { if err != nil { return fmt.Errorf("failed to read embedded file %s: %w", filePath, err) } @@ -42,18 +50,11 @@ func content(report string, thirdPartyFS fs.ReadFileFS, root string) string { return nil } - name := d.Name() - if name == "PLACEHOLDER" { - return nil - } - - // Module path is the directory relative to root - dir := filepath.Dir(filepath.FromSlash(filePath)) - rel, _ := filepath.Rel(filepath.FromSlash(root), dir) - if _, ok := modules[rel]; !ok { - modules[rel] = &moduleFiles{path: rel} + dir := path.Dir(filePath) + if _, ok := modules[dir]; !ok { + modules[dir] = &moduleFiles{path: dir} } - modules[rel].files = append(modules[rel].files, filePath) + modules[dir].files = append(modules[dir].files, filePath) return nil }) @@ -71,7 +72,7 @@ func content(report string, thirdPartyFS fs.ReadFileFS, root string) string { b.WriteString("================================================================================\n\n") for _, filePath := range mod.files { - data, err := thirdPartyFS.ReadFile(filePath) + data, err := fs.ReadFile(thirdPartyFS, filePath) if err != nil { continue } diff --git a/internal/licenses/licenses_test.go b/internal/licenses/licenses_test.go index b3b81d666b8..befb03e5fb5 100644 --- a/internal/licenses/licenses_test.go +++ b/internal/licenses/licenses_test.go @@ -1,85 +1,160 @@ package licenses import ( - "path/filepath" - "strings" + "io/fs" "testing" "testing/fstest" + "github.com/MakeNowJust/heredoc" "github.com/stretchr/testify/require" ) -func TestContent_reportOnly(t *testing.T) { - report := "dep1 (v1.0.0) - MIT - https://example.com\n" - fsys := fstest.MapFS{ - "third-party/PLACEHOLDER": &fstest.MapFile{Data: []byte("placeholder")}, - } - - actualContent := content(report, fsys, "third-party") - - require.True(t, strings.HasPrefix(actualContent, report), "expected output to start with report") - require.NotContains(t, actualContent, "PLACEHOLDER") - require.NotContains(t, actualContent, "====") +func TestContent(t *testing.T) { + // This test is to ensure that we don't accidentally commit actual license + // files in the repo. The embedded content is only included in release builds, + // so in a normal test build we should get a default message. + require.Equal(t, "License information is only available in official release builds.\n", Content()) } -func TestContent_singleModule(t *testing.T) { - report := "example.com/mod (v1.0.0) - MIT - https://example.com\n" - fsys := fstest.MapFS{ - "third-party/example.com/mod/LICENSE": &fstest.MapFile{ - Data: []byte("MIT License\n\nCopyright (c) 2024"), +func TestContent_tableTests(t *testing.T) { + tests := []struct { + name string + fsys fstest.MapFS + expected string + }{ + { + name: "report only", + fsys: fstest.MapFS{ + "embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there. + "embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("dep1 (v1.0.0) - MIT - https://example.com\n")}, + }, + expected: heredoc.Doc(` + dep1 (v1.0.0) - MIT - https://example.com + + `), }, - } - - actualContent := content(report, fsys, "third-party") - - require.Contains(t, actualContent, filepath.FromSlash("example.com/mod")) - require.Contains(t, actualContent, "MIT License") -} - -func TestContent_multipleModulesSortedAlphabetically(t *testing.T) { - report := "header\n" - fsys := fstest.MapFS{ - "third-party/github.com/zzz/pkg/LICENSE": &fstest.MapFile{ - Data: []byte("ZZZ License"), + { + name: "empty third-party dir", + fsys: fstest.MapFS{ + "embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there. + "embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("dep1 (v1.0.0) - MIT - https://example.com\n")}, + "embed/os-arch/third-party": &fstest.MapFile{Data: []byte{}, Mode: fs.ModeDir}, + }, + expected: heredoc.Doc(` + dep1 (v1.0.0) - MIT - https://example.com + + `), }, - "third-party/github.com/aaa/pkg/LICENSE": &fstest.MapFile{ - Data: []byte("AAA License"), + { + name: "unknown file at root ignored", + fsys: fstest.MapFS{ + "embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there. + "embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("dep1 (v1.0.0) - MIT - https://example.com\n")}, + "embed/os-arch/unknown": &fstest.MapFile{ + Data: []byte("MIT License\n\nCopyright (c) 2024"), + }, + }, + expected: heredoc.Doc(` + dep1 (v1.0.0) - MIT - https://example.com + + `), }, - } - - actualContent := content(report, fsys, "third-party") - - aIdx := strings.Index(actualContent, filepath.FromSlash("github.com/aaa/pkg")) - zIdx := strings.Index(actualContent, filepath.FromSlash("github.com/zzz/pkg")) - require.NotEqual(t, -1, aIdx, "expected aaa module in output") - require.NotEqual(t, -1, zIdx, "expected zzz module in output") - require.Less(t, aIdx, zIdx, "expected modules to be sorted alphabetically") -} - -func TestContent_licenseAndNoticeFiles(t *testing.T) { - report := "header\n" - fsys := fstest.MapFS{ - "third-party/example.com/mod/LICENSE": &fstest.MapFile{ - Data: []byte("Apache License 2.0"), + { + name: "unknown directory at root ignored", + fsys: fstest.MapFS{ + "embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there. + "embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("dep1 (v1.0.0) - MIT - https://example.com\n")}, + "embed/os-arch/unknown/example.com/mod/LICENSE": &fstest.MapFile{ + Data: []byte("MIT License\n\nCopyright (c) 2024"), + }, + }, + expected: heredoc.Doc(` + dep1 (v1.0.0) - MIT - https://example.com + + `), + }, + { + name: "single module", + fsys: fstest.MapFS{ + "embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there. + "embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("example.com/mod (v1.0.0) - MIT - https://example.com\n")}, + "embed/os-arch/third-party/example.com/mod/LICENSE": &fstest.MapFile{ + Data: []byte("MIT License\n\nCopyright (c) 2024"), + }, + }, + expected: heredoc.Doc(` + example.com/mod (v1.0.0) - MIT - https://example.com + + ================================================================================ + example.com/mod + ================================================================================ + + MIT License + + Copyright (c) 2024 + + `), + }, + { + name: "multiple modules sorted alphabetically", + fsys: fstest.MapFS{ + "embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there. + "embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("example.com/mod (v1.0.0) - MIT - https://example.com\n")}, + "embed/os-arch/third-party/github.com/zzz/pkg/LICENSE": &fstest.MapFile{ + Data: []byte("ZZZ License"), + }, + "embed/os-arch/third-party/github.com/aaa/pkg/LICENSE": &fstest.MapFile{ + Data: []byte("AAA License"), + }, + }, + expected: heredoc.Doc(` + example.com/mod (v1.0.0) - MIT - https://example.com + + ================================================================================ + github.com/aaa/pkg + ================================================================================ + + AAA License + + ================================================================================ + github.com/zzz/pkg + ================================================================================ + + ZZZ License + + `), }, - "third-party/example.com/mod/NOTICE": &fstest.MapFile{ - Data: []byte("Copyright 2024 Example Corp"), + { + name: "license and notice files", + fsys: fstest.MapFS{ + "embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there. + "embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("example.com/mod (v1.0.0) - MIT - https://example.com\n")}, + "embed/os-arch/third-party/example.com/mod/LICENSE": &fstest.MapFile{ + Data: []byte("Apache License 2.0"), + }, + "embed/os-arch/third-party/example.com/mod/NOTICE": &fstest.MapFile{ + Data: []byte("Copyright 2024 Example Corp"), + }, + }, + expected: heredoc.Doc(` + example.com/mod (v1.0.0) - MIT - https://example.com + + ================================================================================ + example.com/mod + ================================================================================ + + Apache License 2.0 + + Copyright 2024 Example Corp + + `), }, } - actualContent := content(report, fsys, "third-party") - - require.Contains(t, actualContent, "Apache License 2.0") - require.Contains(t, actualContent, "Copyright 2024 Example Corp") -} - -func TestContent_emptyThirdPartyDir(t *testing.T) { - report := "header\n" - fsys := fstest.MapFS{ - "third-party/empty": &fstest.MapFile{Data: []byte("")}, + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := content(tt.fsys, "embed/os-arch") + require.Equal(t, tt.expected, got) + }) } - - actualContent := content(report, fsys, "third-party") - - require.True(t, strings.HasPrefix(actualContent, "header\n"), "expected output to start with report header") } From 34c3b3c809cf4a81428b4acbb5c2a582091a2b29 Mon Sep 17 00:00:00 2001 From: Simon Taranto Date: Wed, 25 Feb 2026 10:22:42 -0500 Subject: [PATCH 32/92] Add databaseId to assignees GraphQL fragment The assignees query fragment only requested id, login, and name but the GitHubUser struct includes a DatabaseID field. Since the field was never requested from the API, it always defaulted to Go's zero value (0) in JSON output. This adds databaseId to the fragment so the actual value is returned. Also adds ExportData test cases for assignees on both Issue and PullRequest to verify databaseId round-trips correctly through JSON serialization. --- api/export_pr_test.go | 52 +++++++++++++++++++++++++++++++++++++++ api/query_builder.go | 2 +- api/query_builder_test.go | 4 +-- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/api/export_pr_test.go b/api/export_pr_test.go index 1f310693e68..ec7b002498c 100644 --- a/api/export_pr_test.go +++ b/api/export_pr_test.go @@ -107,6 +107,32 @@ func TestIssue_ExportData(t *testing.T) { } `), }, + { + name: "assignees", + fields: []string{"assignees"}, + inputJSON: heredoc.Doc(` + { "assignees": { "nodes": [ + { + "id": "MDQ6VXNlcjE=", + "login": "monalisa", + "name": "Mona Lisa", + "databaseId": 1234 + } + ] } } + `), + outputJSON: heredoc.Doc(` + { + "assignees": [ + { + "id": "MDQ6VXNlcjE=", + "login": "monalisa", + "name": "Mona Lisa", + "databaseId": 1234 + } + ] + } + `), + }, { name: "linked pull requests", fields: []string{"closedByPullRequestsReferences"}, @@ -316,6 +342,32 @@ func TestPullRequest_ExportData(t *testing.T) { } `), }, + { + name: "assignees", + fields: []string{"assignees"}, + inputJSON: heredoc.Doc(` + { "assignees": { "nodes": [ + { + "id": "MDQ6VXNlcjE=", + "login": "monalisa", + "name": "Mona Lisa", + "databaseId": 1234 + } + ] } } + `), + outputJSON: heredoc.Doc(` + { + "assignees": [ + { + "id": "MDQ6VXNlcjE=", + "login": "monalisa", + "name": "Mona Lisa", + "databaseId": 1234 + } + ] + } + `), + }, { name: "linked issues", fields: []string{"closingIssuesReferences"}, diff --git a/api/query_builder.go b/api/query_builder.go index 766c2b4aa1b..cb80b595f0c 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -388,7 +388,7 @@ func IssueGraphQL(fields []string) string { case "headRepository": q = append(q, `headRepository{id,name}`) case "assignees": - q = append(q, `assignees(first:100){nodes{id,login,name},totalCount}`) + q = append(q, `assignees(first:100){nodes{id,login,name,databaseId},totalCount}`) case "assignedActors": q = append(q, assignedActors) case "labels": diff --git a/api/query_builder_test.go b/api/query_builder_test.go index e39b0826958..f854cd36ac2 100644 --- a/api/query_builder_test.go +++ b/api/query_builder_test.go @@ -21,7 +21,7 @@ func TestPullRequestGraphQL(t *testing.T) { { name: "fields with nested structures", fields: []string{"author", "assignees"}, - want: "author{login,...on User{id,name}},assignees(first:100){nodes{id,login,name},totalCount}", + want: "author{login,...on User{id,name}},assignees(first:100){nodes{id,login,name,databaseId},totalCount}", }, { name: "compressed query", @@ -67,7 +67,7 @@ func TestIssueGraphQL(t *testing.T) { { name: "fields with nested structures", fields: []string{"author", "assignees"}, - want: "author{login,...on User{id,name}},assignees(first:100){nodes{id,login,name},totalCount}", + want: "author{login,...on User{id,name}},assignees(first:100){nodes{id,login,name,databaseId},totalCount}", }, { name: "compressed query", From d21544c085f1661bb9cbe197517f8c26f7a259fb Mon Sep 17 00:00:00 2001 From: ManManavadaria Date: Thu, 26 Feb 2026 16:23:37 +0530 Subject: [PATCH 33/92] fix(project/item-edit): preserve title/body when editing draft issue with partial flags --- pkg/cmd/project/item-edit/item_edit.go | 72 ++++++-- pkg/cmd/project/item-edit/item_edit_test.go | 193 ++++++++++++++++++-- pkg/cmd/project/shared/queries/queries.go | 5 + 3 files changed, 244 insertions(+), 26 deletions(-) diff --git a/pkg/cmd/project/item-edit/item_edit.go b/pkg/cmd/project/item-edit/item_edit.go index 43aff835ab5..6eb44caf4e3 100644 --- a/pkg/cmd/project/item-edit/item_edit.go +++ b/pkg/cmd/project/item-edit/item_edit.go @@ -16,9 +16,11 @@ import ( type editItemOpts struct { // updateDraftIssue - title string - body string - itemID string + title string + titleChanged bool + body string + bodyChanged bool + itemID string // updateItem fieldID string projectID string @@ -45,6 +47,12 @@ type EditProjectDraftIssue struct { } `graphql:"updateProjectV2DraftIssue(input:$input)"` } +type DraftIssueQuery struct { + DraftIssueNode struct { + DraftIssue queries.DraftIssue `graphql:"... on DraftIssue"` + } `graphql:"node(id: $id)"` +} + type UpdateProjectV2FieldValue struct { Update struct { Item queries.ProjectItem `graphql:"projectV2Item"` @@ -78,6 +86,8 @@ func NewCmdEditItem(f *cmdutil.Factory, runF func(config editItemConfig) error) `), RunE: func(cmd *cobra.Command, args []string) error { opts.numberChanged = cmd.Flags().Changed("number") + opts.titleChanged = cmd.Flags().Changed("title") + opts.bodyChanged = cmd.Flags().Changed("body") if err := cmdutil.MutuallyExclusive( "only one of `--text`, `--number`, `--date`, `--single-select-option-id` or `--iteration-id` may be used", opts.text != "", @@ -143,7 +153,7 @@ func runEditItem(config editItemConfig) error { } // update draft issue - if config.opts.title != "" || config.opts.body != "" { + if config.opts.titleChanged || config.opts.bodyChanged { return updateDraftIssue(config) } @@ -158,13 +168,41 @@ func runEditItem(config editItemConfig) error { return cmdutil.SilentError } -func buildEditDraftIssue(config editItemConfig) (*EditProjectDraftIssue, map[string]interface{}) { +func fetchDraftIssueByID(config editItemConfig, draftIssueID string) (*queries.DraftIssue, error) { + var query DraftIssueQuery + variables := map[string]interface{}{ + "id": githubv4.ID(draftIssueID), + } + + err := config.client.Query("DraftIssueByID", &query, variables) + if err != nil { + return nil, err + } + + return &query.DraftIssueNode.DraftIssue, nil +} + +func buildEditDraftIssue(config editItemConfig, currentDraftIssue *queries.DraftIssue) (*EditProjectDraftIssue, map[string]interface{}) { + input := githubv4.UpdateProjectV2DraftIssueInput{ + DraftIssueID: githubv4.ID(config.opts.itemID), + } + + if config.opts.titleChanged { + input.Title = githubv4.NewString(githubv4.String(config.opts.title)) + } else if currentDraftIssue != nil { + // Preserve existing if title is not provided + input.Title = githubv4.NewString(githubv4.String(currentDraftIssue.Title)) + } + + if config.opts.bodyChanged { + input.Body = githubv4.NewString(githubv4.String(config.opts.body)) + } else if currentDraftIssue != nil { + // Preserve existing if body is not provided + input.Body = githubv4.NewString(githubv4.String(currentDraftIssue.Body)) + } + return &EditProjectDraftIssue{}, map[string]interface{}{ - "input": githubv4.UpdateProjectV2DraftIssueInput{ - Body: githubv4.NewString(githubv4.String(config.opts.body)), - DraftIssueID: githubv4.ID(config.opts.itemID), - Title: githubv4.NewString(githubv4.String(config.opts.title)), - }, + "input": input, } } @@ -250,9 +288,19 @@ func updateDraftIssue(config editItemConfig) error { return cmdutil.FlagErrorf("ID must be the ID of the draft issue content which is prefixed with `DI_`") } - query, variables := buildEditDraftIssue(config) + // Fetch current draft issue to preserve fields that aren't being updated + var currentDraftIssue *queries.DraftIssue + var err error + if !config.opts.titleChanged || !config.opts.bodyChanged { + currentDraftIssue, err = fetchDraftIssueByID(config, config.opts.itemID) + if err != nil { + return err + } + } + + query, variables := buildEditDraftIssue(config, currentDraftIssue) - err := config.client.Mutate("EditDraftIssueItem", query, variables) + err = config.client.Mutate("EditDraftIssueItem", query, variables) if err != nil { return err } diff --git a/pkg/cmd/project/item-edit/item_edit_test.go b/pkg/cmd/project/item-edit/item_edit_test.go index 916bb5899e6..64852bad555 100644 --- a/pkg/cmd/project/item-edit/item_edit_test.go +++ b/pkg/cmd/project/item-edit/item_edit_test.go @@ -129,6 +129,15 @@ func TestNewCmdeditItem(t *testing.T) { }, wantsExporter: true, }, + { + name: "draft issue body only", + cli: "--id 123 --body foobar", + wants: editItemOpts{ + itemID: "123", + body: "foobar", + bodyChanged: true, + }, + }, } t.Setenv("GH_TOKEN", "auth-token") @@ -170,6 +179,9 @@ func TestNewCmdeditItem(t *testing.T) { assert.Equal(t, tt.wants.singleSelectOptionID, gotOpts.singleSelectOptionID) assert.Equal(t, tt.wants.iterationID, gotOpts.iterationID) assert.Equal(t, tt.wants.clear, gotOpts.clear) + assert.Equal(t, tt.wants.titleChanged, gotOpts.titleChanged) + assert.Equal(t, tt.wants.bodyChanged, gotOpts.bodyChanged) + assert.Equal(t, tt.wants.body, gotOpts.body) }) } } @@ -202,9 +214,11 @@ func TestRunItemEdit_Draft(t *testing.T) { config := editItemConfig{ io: ios, opts: editItemOpts{ - title: "a title", - body: "a new body", - itemID: "DI_item_id", + title: "a title", + titleChanged: true, + body: "a new body", + bodyChanged: true, + itemID: "DI_item_id", }, client: client, } @@ -217,6 +231,154 @@ func TestRunItemEdit_Draft(t *testing.T) { stdout.String()) } +func TestRunItemEdit_DraftTitleOnly(t *testing.T) { + defer gock.Off() + + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"query DraftIssueByID.*","variables":{"id":"DI_item_id"}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "id": "DI_item_id", + "title": "existing title", + "body": "existing body", + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation EditDraftIssueItem.*","variables":{"input":{"draftIssueId":"DI_item_id","title":"new title","body":"existing body"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2DraftIssue": map[string]interface{}{ + "draftIssue": map[string]interface{}{ + "title": "new title", + "body": "existing body", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + title: "new title", + titleChanged: true, + bodyChanged: false, + itemID: "DI_item_id", + }, + client: client, + } + + err := runEditItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Edited draft issue \"new title\"\n", + stdout.String()) +} + +func TestRunItemEdit_DraftBodyOnly(t *testing.T) { + defer gock.Off() + + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"query DraftIssueByID.*","variables":{"id":"DI_item_id"}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "id": "DI_item_id", + "title": "existing title", + "body": "existing body", + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation EditDraftIssueItem.*","variables":{"input":{"draftIssueId":"DI_item_id","title":"existing title","body":"new body"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2DraftIssue": map[string]interface{}{ + "draftIssue": map[string]interface{}{ + "title": "existing title", + "body": "new body", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + titleChanged: false, + body: "new body", + bodyChanged: true, + itemID: "DI_item_id", + }, + client: client, + } + + err := runEditItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Edited draft issue \"existing title\"\n", + stdout.String()) +} + +func TestRunItemEdit_DraftFetchError(t *testing.T) { + defer gock.Off() + + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"query DraftIssueByID.*","variables":{"id":"DI_item_id"}}`). + Reply(200). + JSON(map[string]interface{}{ + "errors": []map[string]interface{}{ + { + "type": "NOT_FOUND", + "message": "Could not resolve to a node with the global id of 'DI_item_id' (node)", + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, _, _ := iostreams.Test() + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + title: "new title", + titleChanged: true, + bodyChanged: false, + itemID: "DI_item_id", + }, + client: client, + } + + err := runEditItem(config) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Could not resolve to a node") +} + func TestRunItemEdit_Text(t *testing.T) { defer gock.Off() // gock.Observe(gock.DumpRequest) @@ -232,10 +394,9 @@ func TestRunItemEdit_Text(t *testing.T) { "projectV2Item": map[string]interface{}{ "ID": "item_id", "content": map[string]interface{}{ - "__typename": "Issue", - "body": "body", - "title": "title", - "number": 1, + "body": "body", + "title": "title", + "number": 1, "repository": map[string]interface{}{ "nameWithOwner": "my-repo", }, @@ -544,9 +705,11 @@ func TestRunItemEdit_InvalidID(t *testing.T) { client := queries.NewTestClient() config := editItemConfig{ opts: editItemOpts{ - title: "a title", - body: "a new body", - itemID: "item_id", + title: "a title", + titleChanged: true, + body: "a new body", + bodyChanged: true, + itemID: "item_id", }, client: client, } @@ -630,10 +793,12 @@ func TestRunItemEdit_JSON(t *testing.T) { config := editItemConfig{ io: ios, opts: editItemOpts{ - title: "a title", - body: "a new body", - itemID: "DI_item_id", - exporter: cmdutil.NewJSONExporter(), + title: "a title", + titleChanged: true, + body: "a new body", + bodyChanged: true, + itemID: "DI_item_id", + exporter: cmdutil.NewJSONExporter(), }, client: client, } diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go index d56d611079c..9a3bd490902 100644 --- a/pkg/cmd/project/shared/queries/queries.go +++ b/pkg/cmd/project/shared/queries/queries.go @@ -103,6 +103,11 @@ func (c *Client) Mutate(operationName string, query interface{}, variables map[s return handleError(err) } +func (c *Client) Query(operationName string, query interface{}, variables map[string]interface{}) error { + err := c.apiClient.Query(operationName, query, variables) + return handleError(err) +} + // PageInfo is a PageInfo GraphQL object https://docs.github.com/en/graphql/reference/objects#pageinfo. type PageInfo struct { EndCursor githubv4.String From 0d9e6af3da7e880fe045fd4cb3575cbd98f3964f Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sat, 28 Feb 2026 09:20:13 -0600 Subject: [PATCH 34/92] Add --json support to `gh agent-task list` Add --json, --jq, and --template flags to `gh agent-task list`, consistent with the pattern used by `gh pr list --json`, `gh issue list --json`, etc. This implements the ExportData interface on capi.Session and defines SessionFields for the available JSON fields: id, name, status, repository, createdAt, updatedAt, pullRequestNumber, pullRequestUrl. Closes https://github.com/cli/cli/issues/12805 (partial) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/agent-task/capi/sessions.go | 52 ++++++++++++++++++++++++++++ pkg/cmd/agent-task/list/list.go | 7 ++++ pkg/cmd/agent-task/list/list_test.go | 34 ++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 8e3969d69f8..c1f7fd84675 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -102,6 +102,58 @@ type SessionError struct { Message string } +// SessionFields defines the available fields for JSON export of a Session. +var SessionFields = []string{ + "id", + "name", + "status", + "repository", + "createdAt", + "updatedAt", + "pullRequestNumber", + "pullRequestUrl", +} + +// ExportData implements the exportable interface for JSON output. +func (s *Session) ExportData(fields []string) map[string]interface{} { + data := make(map[string]interface{}, len(fields)) + for _, f := range fields { + switch f { + case "id": + data[f] = s.ID + case "name": + data[f] = s.Name + case "status": + data[f] = s.State + case "repository": + if s.PullRequest != nil && s.PullRequest.Repository != nil { + data[f] = s.PullRequest.Repository.NameWithOwner + } else { + data[f] = nil + } + case "createdAt": + data[f] = s.CreatedAt + case "updatedAt": + data[f] = s.LastUpdatedAt + case "pullRequestNumber": + if s.PullRequest != nil { + data[f] = s.PullRequest.Number + } else { + data[f] = nil + } + case "pullRequestUrl": + if s.PullRequest != nil { + data[f] = s.PullRequest.URL + } else { + data[f] = nil + } + default: + data[f] = nil + } + } + return data +} + type resource struct { ID string `json:"id"` UserID uint64 `json:"user_id"` diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 211dc07ea24..913a22295a3 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -25,6 +25,7 @@ type ListOptions struct { CapiClient func() (capi.CapiClient, error) Web bool Browser browser.Browser + Exporter cmdutil.Exporter } // NewCmdList creates the list command @@ -54,6 +55,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum number of agent tasks to fetch") cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open agent tasks in the browser") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, capi.SessionFields) + return cmd } @@ -91,6 +94,10 @@ func listRun(opts *ListOptions) error { return cmdutil.NewNoResultsError("no agent tasks found") } + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, sessions) + } + if err := opts.IO.StartPager(); err == nil { defer opts.IO.StopPager() } else { diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index 74765028397..3e146c93478 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -99,6 +99,7 @@ func Test_listRun(t *testing.T) { capiStubs func(*testing.T, *capi.CapiClientMock) limit int web bool + jsonFields []string wantOut string wantErr error wantStderr string @@ -286,6 +287,33 @@ func Test_listRun(t *testing.T) { wantStderr: "Opening https://github.com/copilot/agents in your browser.\n", wantBrowserURL: "https://github.com/copilot/agents", }, + { + name: "json output", + tty: false, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + return []*capi.Session{ + { + ID: "abc-123", + Name: "s1", + State: "completed", + CreatedAt: sampleDate, + LastUpdatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + }, nil + } + }, + jsonFields: []string{"id", "name", "status", "repository", "pullRequestNumber", "pullRequestUrl"}, + wantOut: "[{\"id\":\"abc-123\",\"name\":\"s1\",\"pullRequestNumber\":101,\"pullRequestUrl\":\"https://github.com/OWNER/REPO/pull/101\",\"repository\":\"OWNER/REPO\",\"status\":\"completed\"}]\n", + }, } for _, tt := range tests { @@ -316,6 +344,12 @@ func Test_listRun(t *testing.T) { }, } + if tt.jsonFields != nil { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields(tt.jsonFields) + opts.Exporter = exporter + } + err := listRun(opts) if tt.wantErr != nil { assert.Error(t, err) From 7241b42ecff71488673a12dda99a3d1dcf0f23f3 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sat, 28 Feb 2026 09:22:28 -0600 Subject: [PATCH 35/92] Add --json support to `gh agent-task view` Add --json, --jq, and --template flags to `gh agent-task view`, consistent with the pattern used by `gh pr view --json`, `gh issue view --json`, etc. This reuses the same ExportData interface and SessionFields defined for list, applying them to the single-session view output. Closes https://github.com/cli/cli/issues/12805 (partial) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/agent-task/capi/sessions.go | 52 ++++++++++++++++++++++++++++ pkg/cmd/agent-task/view/view.go | 7 ++++ pkg/cmd/agent-task/view/view_test.go | 42 ++++++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 8e3969d69f8..c1f7fd84675 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -102,6 +102,58 @@ type SessionError struct { Message string } +// SessionFields defines the available fields for JSON export of a Session. +var SessionFields = []string{ + "id", + "name", + "status", + "repository", + "createdAt", + "updatedAt", + "pullRequestNumber", + "pullRequestUrl", +} + +// ExportData implements the exportable interface for JSON output. +func (s *Session) ExportData(fields []string) map[string]interface{} { + data := make(map[string]interface{}, len(fields)) + for _, f := range fields { + switch f { + case "id": + data[f] = s.ID + case "name": + data[f] = s.Name + case "status": + data[f] = s.State + case "repository": + if s.PullRequest != nil && s.PullRequest.Repository != nil { + data[f] = s.PullRequest.Repository.NameWithOwner + } else { + data[f] = nil + } + case "createdAt": + data[f] = s.CreatedAt + case "updatedAt": + data[f] = s.LastUpdatedAt + case "pullRequestNumber": + if s.PullRequest != nil { + data[f] = s.PullRequest.Number + } else { + data[f] = nil + } + case "pullRequestUrl": + if s.PullRequest != nil { + data[f] = s.PullRequest.URL + } else { + data[f] = nil + } + default: + data[f] = nil + } + } + return data +} + type resource struct { ID string `json:"id"` UserID uint64 `json:"user_id"` diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 38c1e0e1295..137efdb6409 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -37,6 +37,7 @@ type ViewOptions struct { Finder prShared.PRFinder Prompter prompter.Prompter Browser browser.Browser + Exporter cmdutil.Exporter LogRenderer func() shared.LogRenderer Sleep func(d time.Duration) @@ -125,6 +126,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd.Flags().BoolVar(&opts.Log, "log", false, "Show agent session logs") cmd.Flags().BoolVar(&opts.Follow, "follow", false, "Follow agent session logs") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, capi.SessionFields) + return cmd } @@ -289,6 +292,10 @@ func viewRun(opts *ViewOptions) error { return printLogs(opts, capiClient, session.ID) } + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, session) + } + printSession(opts, session) return nil } diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index 68cc377e303..335569d3a87 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -168,6 +168,7 @@ func Test_viewRun(t *testing.T) { promptStubs func(*testing.T, *prompter.MockPrompter) capiStubs func(*testing.T, *capi.CapiClientMock) logRendererStubs func(*testing.T, *shared.LogRendererMock) + jsonFields []string wantOut string wantErr error wantStderr string @@ -1209,6 +1210,41 @@ func Test_viewRun(t *testing.T) { (rendered:) `), }, + { + name: "json output (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + return &capi.Session{ + ID: "some-session-id", + Name: "Fix login bug", + State: "completed", + CreatedAt: sampleDate, + LastUpdatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 42, + URL: "https://github.com/OWNER/REPO/pull/42", + Title: "Fix login bug", + State: "OPEN", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "testuser", + }, + }, nil + } + }, + wantOut: "{\"id\":\"some-session-id\",\"name\":\"Fix login bug\",\"pullRequestNumber\":42,\"pullRequestUrl\":\"https://github.com/OWNER/REPO/pull/42\",\"repository\":\"OWNER/REPO\",\"status\":\"completed\"}\n", + jsonFields: []string{"id", "name", "status", "repository", "pullRequestNumber", "pullRequestUrl"}, + }, } for _, tt := range tests { @@ -1244,6 +1280,12 @@ func Test_viewRun(t *testing.T) { return logRenderer } + if tt.jsonFields != nil { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields(tt.jsonFields) + opts.Exporter = exporter + } + err := viewRun(&opts) if tt.wantErr != nil { assert.Error(t, err) From 01c83acfe837660712b3fd5227176cac2275760a Mon Sep 17 00:00:00 2001 From: Takeshi Date: Sat, 28 Feb 2026 20:01:38 -0500 Subject: [PATCH 36/92] Add --duplicate-of flag and duplicate reason to gh issue close Support closing issues as duplicates via --reason duplicate and --duplicate-of flags. The --duplicate-of flag accepts an issue number or URL, validates it references a different issue (not a PR), and passes the duplicate issue ID to the closeIssue mutation. Feature detection checks whether the GHES instance supports the DUPLICATE enum value in IssueClosedStateReason before using it. --- .../featuredetection/feature_detection.go | 39 ++- .../feature_detection_test.go | 51 +++- pkg/cmd/issue/close/close.go | 72 +++++- pkg/cmd/issue/close/close_test.go | 228 ++++++++++++++++++ 4 files changed, 368 insertions(+), 22 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 7a200e20c84..b162e4c2cf3 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -23,13 +23,15 @@ type Detector interface { } type IssueFeatures struct { - StateReason bool - ActorIsAssignable bool + StateReason bool + StateReasonDuplicate bool + ActorIsAssignable bool } var allIssueFeatures = IssueFeatures{ - StateReason: true, - ActorIsAssignable: true, + StateReason: true, + StateReasonDuplicate: true, + ActorIsAssignable: true, } type PullRequestFeatures struct { @@ -138,8 +140,9 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { } features := IssueFeatures{ - StateReason: false, - ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES + StateReason: false, + StateReasonDuplicate: false, + ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES } var featureDetection struct { @@ -162,6 +165,30 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { } } + if !features.StateReason { + return features, nil + } + + var issueClosedStateReasonFeatureDetection struct { + IssueClosedStateReason struct { + EnumValues []struct { + Name string + } `graphql:"enumValues(includeDeprecated: true)"` + } `graphql:"IssueClosedStateReason: __type(name: \"IssueClosedStateReason\")"` + } + + err = gql.Query(d.host, "IssueClosedStateReason_enumValues", &issueClosedStateReasonFeatureDetection, nil) + if err != nil { + return features, err + } + + for _, enumValue := range issueClosedStateReasonFeatureDetection.IssueClosedStateReason.EnumValues { + if enumValue.Name == "DUPLICATE" { + features.StateReasonDuplicate = true + break + } + } + return features, nil } diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index 032f5cda03c..c1d7b3b4a70 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -23,8 +23,9 @@ func TestIssueFeatures(t *testing.T) { name: "github.com", hostname: "github.com", wantFeatures: IssueFeatures{ - StateReason: true, - ActorIsAssignable: true, + StateReason: true, + StateReasonDuplicate: true, + ActorIsAssignable: true, }, wantErr: false, }, @@ -32,8 +33,9 @@ func TestIssueFeatures(t *testing.T) { name: "ghec data residency (ghe.com)", hostname: "stampname.ghe.com", wantFeatures: IssueFeatures{ - StateReason: true, - ActorIsAssignable: true, + StateReason: true, + StateReasonDuplicate: true, + ActorIsAssignable: true, }, wantErr: false, }, @@ -44,13 +46,14 @@ func TestIssueFeatures(t *testing.T) { `query Issue_fields\b`: `{"data": {}}`, }, wantFeatures: IssueFeatures{ - StateReason: false, - ActorIsAssignable: false, + StateReason: false, + StateReasonDuplicate: false, + ActorIsAssignable: false, }, wantErr: false, }, { - name: "GHE has state reason field", + name: "GHE has state reason field without duplicate enum", hostname: "git.my.org", queryResponse: map[string]string{ `query Issue_fields\b`: heredoc.Doc(` @@ -58,9 +61,41 @@ func TestIssueFeatures(t *testing.T) { {"name": "stateReason"} ] } } } `), + `query IssueClosedStateReason_enumValues\b`: heredoc.Doc(` + { "data": { "IssueClosedStateReason": { "enumValues": [ + {"name": "COMPLETED"}, + {"name": "NOT_PLANNED"} + ] } } } + `), + }, + wantFeatures: IssueFeatures{ + StateReason: true, + StateReasonDuplicate: false, + ActorIsAssignable: false, + }, + wantErr: false, + }, + { + name: "GHE has duplicate state reason enum value", + hostname: "git.my.org", + queryResponse: map[string]string{ + `query Issue_fields\b`: heredoc.Doc(` + { "data": { "Issue": { "fields": [ + {"name": "stateReason"} + ] } } } + `), + `query IssueClosedStateReason_enumValues\b`: heredoc.Doc(` + { "data": { "IssueClosedStateReason": { "enumValues": [ + {"name": "COMPLETED"}, + {"name": "NOT_PLANNED"}, + {"name": "DUPLICATE"} + ] } } } + `), }, wantFeatures: IssueFeatures{ - StateReason: true, + StateReason: true, + StateReasonDuplicate: true, + ActorIsAssignable: false, }, wantErr: false, }, diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go index c61d1d917fe..19510bf89a9 100644 --- a/pkg/cmd/issue/close/close.go +++ b/pkg/cmd/issue/close/close.go @@ -24,6 +24,7 @@ type CloseOptions struct { IssueNumber int Comment string Reason string + DuplicateOf string Detector fd.Detector } @@ -55,6 +56,13 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm } opts.IssueNumber = issueNumber + if opts.DuplicateOf != "" { + if opts.Reason == "" { + opts.Reason = "duplicate" + } else if opts.Reason != "duplicate" { + return cmdutil.FlagErrorf("`--duplicate-of` can only be used with `--reason duplicate`") + } + } if runF != nil { return runF(opts) @@ -64,13 +72,22 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm } cmd.Flags().StringVarP(&opts.Comment, "comment", "c", "", "Leave a closing comment") - cmdutil.StringEnumFlag(cmd, &opts.Reason, "reason", "r", "", []string{"completed", "not planned"}, "Reason for closing") + cmdutil.StringEnumFlag(cmd, &opts.Reason, "reason", "r", "", []string{"completed", "not planned", "duplicate"}, "Reason for closing") + cmd.Flags().StringVar(&opts.DuplicateOf, "duplicate-of", "", "Mark as duplicate of another issue by number or URL") return cmd } func closeRun(opts *CloseOptions) error { cs := opts.IO.ColorScheme() + closeReason := opts.Reason + if opts.DuplicateOf != "" { + if closeReason == "" { + closeReason = "duplicate" + } else if closeReason != "duplicate" { + return cmdutil.FlagErrorf("`--duplicate-of` can only be used with `--reason duplicate`") + } + } httpClient, err := opts.HttpClient() if err != nil { @@ -92,6 +109,32 @@ func closeRun(opts *CloseOptions) error { return nil } + var duplicateIssueID string + if opts.DuplicateOf != "" { + if issue.IsPullRequest() { + return cmdutil.FlagErrorf("`--duplicate-of` is only supported for issues") + } + duplicateIssueNumber, duplicateRepo, err := shared.ParseIssueFromArg(opts.DuplicateOf) + if err != nil { + return cmdutil.FlagErrorf("invalid value for `--duplicate-of`: %v", err) + } + duplicateIssueRepo := baseRepo + if parsedRepo, present := duplicateRepo.Value(); present { + duplicateIssueRepo = parsedRepo + } + if ghrepo.IsSame(baseRepo, duplicateIssueRepo) && issue.Number == duplicateIssueNumber { + return cmdutil.FlagErrorf("`--duplicate-of` cannot reference the current issue") + } + duplicateIssue, err := shared.FindIssueOrPR(httpClient, duplicateIssueRepo, duplicateIssueNumber, []string{"id"}) + if err != nil { + return err + } + if duplicateIssue.IsPullRequest() { + return cmdutil.FlagErrorf("`--duplicate-of` must reference an issue") + } + duplicateIssueID = duplicateIssue.ID + } + if opts.Comment != "" { commentOpts := &prShared.CommentableOptions{ Body: opts.Comment, @@ -108,7 +151,7 @@ func closeRun(opts *CloseOptions) error { } } - err = apiClose(httpClient, baseRepo, issue, opts.Detector, opts.Reason) + err = apiClose(httpClient, baseRepo, issue, opts.Detector, closeReason, duplicateIssueID) if err != nil { return err } @@ -118,12 +161,12 @@ func closeRun(opts *CloseOptions) error { return nil } -func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, detector fd.Detector, reason string) error { +func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, detector fd.Detector, reason string, duplicateIssueID string) error { if issue.IsPullRequest() { return api.PullRequestClose(httpClient, repo, issue.ID) } - if reason != "" { + if reason != "" || duplicateIssueID != "" { if detector == nil { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) detector = fd.NewDetector(cachedClient, repo.RepoHost()) @@ -135,6 +178,15 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, // TODO stateReasonCleanup if !features.StateReason { // If StateReason is not supported silently close issue without setting StateReason. + if duplicateIssueID != "" { + return fmt.Errorf("closing as duplicate is not supported on %s", repo.RepoHost()) + } + reason = "" + } else if reason == "duplicate" && !features.StateReasonDuplicate { + if duplicateIssueID != "" { + return fmt.Errorf("closing as duplicate is not supported on %s", repo.RepoHost()) + } + // If DUPLICATE is not supported silently close issue without setting StateReason. reason = "" } } @@ -144,6 +196,8 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, // If no reason is specified do not set it. case "not planned": reason = "NOT_PLANNED" + case "duplicate": + reason = "DUPLICATE" default: reason = "COMPLETED" } @@ -158,8 +212,9 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, variables := map[string]interface{}{ "input": CloseIssueInput{ - IssueID: issue.ID, - StateReason: reason, + IssueID: issue.ID, + StateReason: reason, + DuplicateIssueID: duplicateIssueID, }, } @@ -168,6 +223,7 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, } type CloseIssueInput struct { - IssueID string `json:"issueId"` - StateReason string `json:"stateReason,omitempty"` + IssueID string `json:"issueId"` + StateReason string `json:"stateReason,omitempty"` + DuplicateIssueID string `json:"duplicateIssueId,omitempty"` } diff --git a/pkg/cmd/issue/close/close_test.go b/pkg/cmd/issue/close/close_test.go index 04c39cd8da0..ddab7121007 100644 --- a/pkg/cmd/issue/close/close_test.go +++ b/pkg/cmd/issue/close/close_test.go @@ -16,6 +16,15 @@ import ( "github.com/stretchr/testify/require" ) +type issueFeaturesDetectorMock struct { + fd.EnabledDetectorMock + issueFeatures fd.IssueFeatures +} + +func (md *issueFeaturesDetectorMock) IssueFeatures() (fd.IssueFeatures, error) { + return md.issueFeatures, nil +} + func TestNewCmdClose(t *testing.T) { // Test shared parsing of issue number / URL. argparsetest.TestArgParsing(t, NewCmdClose) @@ -44,6 +53,29 @@ func TestNewCmdClose(t *testing.T) { Reason: "not planned", }, }, + { + name: "reason duplicate", + input: "123 --reason duplicate", + output: CloseOptions{ + IssueNumber: 123, + Reason: "duplicate", + }, + }, + { + name: "duplicate of sets duplicate reason", + input: "123 --duplicate-of 456", + output: CloseOptions{ + IssueNumber: 123, + Reason: "duplicate", + DuplicateOf: "456", + }, + }, + { + name: "duplicate of with invalid reason", + input: "123 --reason completed --duplicate-of 456", + wantErr: true, + errMsg: "`--duplicate-of` can only be used with `--reason duplicate`", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -74,6 +106,7 @@ func TestNewCmdClose(t *testing.T) { assert.Equal(t, tt.output.IssueNumber, gotOpts.IssueNumber) assert.Equal(t, tt.output.Comment, gotOpts.Comment) assert.Equal(t, tt.output.Reason, gotOpts.Reason) + assert.Equal(t, tt.output.DuplicateOf, gotOpts.DuplicateOf) if tt.expectedBaseRepo != nil { baseRepo, err := gotOpts.BaseRepo() require.NoError(t, err) @@ -184,6 +217,201 @@ func TestCloseRun(t *testing.T) { }, wantStderr: "โœ“ Closed issue OWNER/REPO#13 (The title of the issue)\n", }, + { + name: "close issue with duplicate reason", + opts: &CloseOptions{ + IssueNumber: 13, + Reason: "duplicate", + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`mutation IssueClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, 2, len(inputs)) + assert.Equal(t, "THE-ID", inputs["issueId"]) + assert.Equal(t, "DUPLICATE", inputs["stateReason"]) + }), + ) + }, + wantStderr: "โœ“ Closed issue OWNER/REPO#13 (The title of the issue)\n", + }, + { + name: "close issue as duplicate of another issue", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "99", + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "DUPLICATE-ID", "number": 99} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`mutation IssueClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, 3, len(inputs)) + assert.Equal(t, "THE-ID", inputs["issueId"]) + assert.Equal(t, "DUPLICATE", inputs["stateReason"]) + assert.Equal(t, "DUPLICATE-ID", inputs["duplicateIssueId"]) + }), + ) + }, + wantStderr: "โœ“ Closed issue OWNER/REPO#13 (The title of the issue)\n", + }, + { + name: "close issue with duplicate reason when duplicate is not supported", + opts: &CloseOptions{ + IssueNumber: 13, + Reason: "duplicate", + Detector: &issueFeaturesDetectorMock{ + issueFeatures: fd.IssueFeatures{ + StateReason: true, + StateReasonDuplicate: false, + }, + }, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`mutation IssueClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, 1, len(inputs)) + assert.Equal(t, "THE-ID", inputs["issueId"]) + }), + ) + }, + wantStderr: "โœ“ Closed issue OWNER/REPO#13 (The title of the issue)\n", + }, + { + name: "close issue as duplicate when duplicate is not supported", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "99", + Detector: &issueFeaturesDetectorMock{ + issueFeatures: fd.IssueFeatures{ + StateReason: true, + StateReasonDuplicate: false, + }, + }, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "DUPLICATE-ID", "number": 99} + } } }`), + ) + }, + wantErr: true, + errMsg: "closing as duplicate is not supported on github.com", + }, + { + name: "duplicate of cannot point to same issue", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "13", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + }, + wantErr: true, + errMsg: "`--duplicate-of` cannot reference the current issue", + }, + { + name: "duplicate of must reference an issue", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "99", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "__typename": "PullRequest", "id": "PULL-ID", "number": 99} + } } }`), + ) + }, + wantErr: true, + errMsg: "`--duplicate-of` must reference an issue", + }, + { + name: "duplicate of with invalid format", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "not-an-issue", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + }, + wantErr: true, + errMsg: "invalid value for `--duplicate-of`: invalid issue format: \"not-an-issue\"", + }, { name: "close issue with reason when reason is not supported", opts: &CloseOptions{ From de61b2b65dde6166720bbf68fc2abb9dc5efedee Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Tue, 10 Feb 2026 16:57:25 +0530 Subject: [PATCH 37/92] feat(pr): add changeType field to files JSON output Add the changeType field from the PullRequestChangedFile GraphQL type to the PullRequestFile struct. This exposes the file status (added, modified, deleted, renamed, copied, changed) in gh pr list --json files and gh pr view --json files output. Closes #11385 --- api/queries_pr.go | 7 ++++--- api/query_builder.go | 3 ++- api/query_builder_test.go | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 1bc5ddb55d7..c38018a68fe 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -296,9 +296,10 @@ type PullRequestCommitCommit struct { } type PullRequestFile struct { - Path string `json:"path"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` + Path string `json:"path"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + ChangeType string `json:"changeType"` } type ReviewRequests struct { diff --git a/api/query_builder.go b/api/query_builder.go index cb80b595f0c..c3e1e9ba3a9 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -148,7 +148,8 @@ var prFiles = shortenQuery(` nodes { additions, deletions, - path + path, + changeType } } `) diff --git a/api/query_builder_test.go b/api/query_builder_test.go index f854cd36ac2..f0b5789a3e6 100644 --- a/api/query_builder_test.go +++ b/api/query_builder_test.go @@ -26,7 +26,7 @@ func TestPullRequestGraphQL(t *testing.T) { { name: "compressed query", fields: []string{"files"}, - want: "files(first: 100) {nodes {additions,deletions,path}}", + want: "files(first: 100) {nodes {additions,deletions,path,changeType}}", }, { name: "invalid fields", @@ -72,7 +72,7 @@ func TestIssueGraphQL(t *testing.T) { { name: "compressed query", fields: []string{"files"}, - want: "files(first: 100) {nodes {additions,deletions,path}}", + want: "files(first: 100) {nodes {additions,deletions,path,changeType}}", }, { name: "projectItems", From 250d5a850ade0508f1cfdbdef63fbdde67ae29f0 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sun, 1 Mar 2026 08:57:53 -0600 Subject: [PATCH 38/92] Fix gofmt alignment in view_test.go Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/agent-task/view/view_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index 335569d3a87..f2abe4d5282 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -1242,7 +1242,7 @@ func Test_viewRun(t *testing.T) { }, nil } }, - wantOut: "{\"id\":\"some-session-id\",\"name\":\"Fix login bug\",\"pullRequestNumber\":42,\"pullRequestUrl\":\"https://github.com/OWNER/REPO/pull/42\",\"repository\":\"OWNER/REPO\",\"status\":\"completed\"}\n", + wantOut: "{\"id\":\"some-session-id\",\"name\":\"Fix login bug\",\"pullRequestNumber\":42,\"pullRequestUrl\":\"https://github.com/OWNER/REPO/pull/42\",\"repository\":\"OWNER/REPO\",\"status\":\"completed\"}\n", jsonFields: []string{"id", "name", "status", "repository", "pullRequestNumber", "pullRequestUrl"}, }, } From c5c107aa8b2f4d33f53dcc3071401e795924bbc4 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sun, 1 Mar 2026 09:08:18 -0600 Subject: [PATCH 39/92] Polish --json support for agent-task list - Fix empty results returning error instead of [] when --json is used - Rename 'status' field to 'state' for consistency with struct and UI - Add missing JSON fields: completedAt, user, pullRequestTitle, pullRequestState - Add test for empty results with --json - Add test for nil PullRequest with --json Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/agent-task/capi/sessions.go | 32 +++++++++++++++++++++-- pkg/cmd/agent-task/list/list.go | 2 +- pkg/cmd/agent-task/list/list_test.go | 39 ++++++++++++++++++++++++++-- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index c1f7fd84675..3a74ef3d9e4 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -106,12 +106,16 @@ type SessionError struct { var SessionFields = []string{ "id", "name", - "status", + "state", "repository", + "user", "createdAt", "updatedAt", + "completedAt", "pullRequestNumber", "pullRequestUrl", + "pullRequestTitle", + "pullRequestState", } // ExportData implements the exportable interface for JSON output. @@ -123,7 +127,7 @@ func (s *Session) ExportData(fields []string) map[string]interface{} { data[f] = s.ID case "name": data[f] = s.Name - case "status": + case "state": data[f] = s.State case "repository": if s.PullRequest != nil && s.PullRequest.Repository != nil { @@ -131,10 +135,22 @@ func (s *Session) ExportData(fields []string) map[string]interface{} { } else { data[f] = nil } + case "user": + if s.User != nil { + data[f] = s.User.Login + } else { + data[f] = nil + } case "createdAt": data[f] = s.CreatedAt case "updatedAt": data[f] = s.LastUpdatedAt + case "completedAt": + if s.CompletedAt.IsZero() { + data[f] = nil + } else { + data[f] = s.CompletedAt + } case "pullRequestNumber": if s.PullRequest != nil { data[f] = s.PullRequest.Number @@ -147,6 +163,18 @@ func (s *Session) ExportData(fields []string) map[string]interface{} { } else { data[f] = nil } + case "pullRequestTitle": + if s.PullRequest != nil { + data[f] = s.PullRequest.Title + } else { + data[f] = nil + } + case "pullRequestState": + if s.PullRequest != nil { + data[f] = s.PullRequest.State + } else { + data[f] = nil + } default: data[f] = nil } diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 913a22295a3..559389b5c79 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -90,7 +90,7 @@ func listRun(opts *ListOptions) error { opts.IO.StopProgressIndicator() - if len(sessions) == 0 { + if len(sessions) == 0 && opts.Exporter == nil { return cmdutil.NewNoResultsError("no agent tasks found") } diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index 3e146c93478..d46240b5933 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -299,9 +299,13 @@ func Test_listRun(t *testing.T) { State: "completed", CreatedAt: sampleDate, LastUpdatedAt: sampleDate, + CompletedAt: sampleDate, ResourceType: "pull", + User: &api.GitHubUser{Login: "monalisa"}, PullRequest: &api.PullRequest{ Number: 101, + Title: "Fix login bug", + State: "MERGED", URL: "https://github.com/OWNER/REPO/pull/101", Repository: &api.PRRepository{ NameWithOwner: "OWNER/REPO", @@ -311,8 +315,39 @@ func Test_listRun(t *testing.T) { }, nil } }, - jsonFields: []string{"id", "name", "status", "repository", "pullRequestNumber", "pullRequestUrl"}, - wantOut: "[{\"id\":\"abc-123\",\"name\":\"s1\",\"pullRequestNumber\":101,\"pullRequestUrl\":\"https://github.com/OWNER/REPO/pull/101\",\"repository\":\"OWNER/REPO\",\"status\":\"completed\"}]\n", + jsonFields: []string{"id", "name", "state", "repository", "user", "pullRequestNumber", "pullRequestUrl", "pullRequestTitle", "pullRequestState"}, + wantOut: "[{\"id\":\"abc-123\",\"name\":\"s1\",\"pullRequestNumber\":101,\"pullRequestState\":\"MERGED\",\"pullRequestTitle\":\"Fix login bug\",\"pullRequestUrl\":\"https://github.com/OWNER/REPO/pull/101\",\"repository\":\"OWNER/REPO\",\"state\":\"completed\",\"user\":\"monalisa\"}]\n", + }, + { + name: "json output with no sessions returns empty array", + tty: false, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + return nil, nil + } + }, + jsonFields: []string{"id", "name", "state"}, + wantOut: "[]\n", + }, + { + name: "json output with nil pull request", + tty: false, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + return []*capi.Session{ + { + ID: "abc-456", + Name: "s2", + State: "in_progress", + CreatedAt: sampleDate, + LastUpdatedAt: sampleDate, + ResourceType: "pull", + }, + }, nil + } + }, + jsonFields: []string{"id", "name", "state", "repository", "user", "pullRequestNumber", "pullRequestUrl", "pullRequestTitle", "pullRequestState"}, + wantOut: "[{\"id\":\"abc-456\",\"name\":\"s2\",\"pullRequestNumber\":null,\"pullRequestState\":null,\"pullRequestTitle\":null,\"pullRequestUrl\":null,\"repository\":null,\"state\":\"in_progress\",\"user\":null}]\n", }, } From ceb8cf2561e7caf1ea272d62e75a1bf7c531e157 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sun, 1 Mar 2026 09:09:08 -0600 Subject: [PATCH 40/92] Polish --json support for agent-task view - Rename 'status' field to 'state' for consistency with struct and UI - Add missing JSON fields: completedAt, user, pullRequestTitle, pullRequestState - Add test for nil PullRequest with --json - Expand existing JSON test to cover new fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/agent-task/capi/sessions.go | 32 ++++++++++++++++++++++++++-- pkg/cmd/agent-task/view/view_test.go | 28 +++++++++++++++++++++--- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index c1f7fd84675..3a74ef3d9e4 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -106,12 +106,16 @@ type SessionError struct { var SessionFields = []string{ "id", "name", - "status", + "state", "repository", + "user", "createdAt", "updatedAt", + "completedAt", "pullRequestNumber", "pullRequestUrl", + "pullRequestTitle", + "pullRequestState", } // ExportData implements the exportable interface for JSON output. @@ -123,7 +127,7 @@ func (s *Session) ExportData(fields []string) map[string]interface{} { data[f] = s.ID case "name": data[f] = s.Name - case "status": + case "state": data[f] = s.State case "repository": if s.PullRequest != nil && s.PullRequest.Repository != nil { @@ -131,10 +135,22 @@ func (s *Session) ExportData(fields []string) map[string]interface{} { } else { data[f] = nil } + case "user": + if s.User != nil { + data[f] = s.User.Login + } else { + data[f] = nil + } case "createdAt": data[f] = s.CreatedAt case "updatedAt": data[f] = s.LastUpdatedAt + case "completedAt": + if s.CompletedAt.IsZero() { + data[f] = nil + } else { + data[f] = s.CompletedAt + } case "pullRequestNumber": if s.PullRequest != nil { data[f] = s.PullRequest.Number @@ -147,6 +163,18 @@ func (s *Session) ExportData(fields []string) map[string]interface{} { } else { data[f] = nil } + case "pullRequestTitle": + if s.PullRequest != nil { + data[f] = s.PullRequest.Title + } else { + data[f] = nil + } + case "pullRequestState": + if s.PullRequest != nil { + data[f] = s.PullRequest.State + } else { + data[f] = nil + } default: data[f] = nil } diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index f2abe4d5282..34036cfa518 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -1231,7 +1231,7 @@ func Test_viewRun(t *testing.T) { Number: 42, URL: "https://github.com/OWNER/REPO/pull/42", Title: "Fix login bug", - State: "OPEN", + State: "MERGED", Repository: &api.PRRepository{ NameWithOwner: "OWNER/REPO", }, @@ -1242,8 +1242,30 @@ func Test_viewRun(t *testing.T) { }, nil } }, - wantOut: "{\"id\":\"some-session-id\",\"name\":\"Fix login bug\",\"pullRequestNumber\":42,\"pullRequestUrl\":\"https://github.com/OWNER/REPO/pull/42\",\"repository\":\"OWNER/REPO\",\"status\":\"completed\"}\n", - jsonFields: []string{"id", "name", "status", "repository", "pullRequestNumber", "pullRequestUrl"}, + wantOut: "{\"id\":\"some-session-id\",\"name\":\"Fix login bug\",\"pullRequestNumber\":42,\"pullRequestState\":\"MERGED\",\"pullRequestTitle\":\"Fix login bug\",\"pullRequestUrl\":\"https://github.com/OWNER/REPO/pull/42\",\"repository\":\"OWNER/REPO\",\"state\":\"completed\",\"user\":\"testuser\"}\n", + jsonFields: []string{"id", "name", "state", "repository", "user", "pullRequestNumber", "pullRequestUrl", "pullRequestTitle", "pullRequestState"}, + }, + { + name: "json output with nil pull request", + tty: false, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + return &capi.Session{ + ID: "some-session-id", + Name: "New task", + State: "in_progress", + CreatedAt: sampleDate, + LastUpdatedAt: sampleDate, + ResourceType: "pull", + }, nil + } + }, + wantOut: "{\"id\":\"some-session-id\",\"name\":\"New task\",\"pullRequestNumber\":null,\"pullRequestUrl\":null,\"repository\":null,\"state\":\"in_progress\",\"user\":null}\n", + jsonFields: []string{"id", "name", "state", "repository", "user", "pullRequestNumber", "pullRequestUrl"}, }, } From 0d05a8acca4b4e5d45ee428cb2be4690ffbc6ab7 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sun, 1 Mar 2026 10:18:21 -0600 Subject: [PATCH 41/92] Address Copilot review feedback - Prioritize --json output over --log/--follow so JSON is not silently ignored - Emit null for zero createdAt/updatedAt values, consistent with completedAt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/agent-task/capi/sessions.go | 12 ++++++++++-- pkg/cmd/agent-task/view/view.go | 8 ++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 3a74ef3d9e4..4b457d799bb 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -142,9 +142,17 @@ func (s *Session) ExportData(fields []string) map[string]interface{} { data[f] = nil } case "createdAt": - data[f] = s.CreatedAt + if s.CreatedAt.IsZero() { + data[f] = nil + } else { + data[f] = s.CreatedAt + } case "updatedAt": - data[f] = s.LastUpdatedAt + if s.LastUpdatedAt.IsZero() { + data[f] = nil + } else { + data[f] = s.LastUpdatedAt + } case "completedAt": if s.CompletedAt.IsZero() { data[f] = nil diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 137efdb6409..854faa73def 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -288,14 +288,14 @@ func viewRun(opts *ViewOptions) error { opts.IO.StopProgressIndicator() } - if opts.Log { - return printLogs(opts, capiClient, session.ID) - } - if opts.Exporter != nil { return opts.Exporter.Write(opts.IO, session) } + if opts.Log { + return printLogs(opts, capiClient, session.ID) + } + printSession(opts, session) return nil } From d908af348499a5eca6da1806881e04b02da2940c Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Sun, 1 Mar 2026 10:18:37 -0600 Subject: [PATCH 42/92] Emit null for zero createdAt/updatedAt values Consistent with completedAt handling. Addresses Copilot review feedback from companion PR #12807. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/agent-task/capi/sessions.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 3a74ef3d9e4..4b457d799bb 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -142,9 +142,17 @@ func (s *Session) ExportData(fields []string) map[string]interface{} { data[f] = nil } case "createdAt": - data[f] = s.CreatedAt + if s.CreatedAt.IsZero() { + data[f] = nil + } else { + data[f] = s.CreatedAt + } case "updatedAt": - data[f] = s.LastUpdatedAt + if s.LastUpdatedAt.IsZero() { + data[f] = nil + } else { + data[f] = s.LastUpdatedAt + } case "completedAt": if s.CompletedAt.IsZero() { data[f] = nil From ee8014a4d9b18290992393d87f8325f13e7bdd21 Mon Sep 17 00:00:00 2001 From: gunadhya <6939749+gunadhya@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:01:57 +0530 Subject: [PATCH 43/92] Simplify progress indicators in issue develop --- pkg/cmd/issue/develop/develop.go | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/issue/develop/develop.go b/pkg/cmd/issue/develop/develop.go index 812194cf026..90c52dff6e4 100644 --- a/pkg/cmd/issue/develop/develop.go +++ b/pkg/cmd/issue/develop/develop.go @@ -152,21 +152,22 @@ func developRun(opts *DevelopOptions) error { return err } - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Fetching issue #%d", opts.IssueNumber)) + defer opts.IO.StopProgressIndicator() + issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number"}) - opts.IO.StopProgressIndicator() if err != nil { return err } apiClient := api.NewClientFromHTTP(httpClient) - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel("Checking linked branch support") err = api.CheckLinkedBranchFeature(apiClient, baseRepo.RepoHost()) - opts.IO.StopProgressIndicator() if err != nil { return err } + opts.IO.StopProgressIndicator() if opts.List { return developRunList(opts, apiClient, baseRepo, issue) @@ -184,12 +185,14 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr } } + opts.IO.StartProgressIndicatorWithLabel("Preparing linked branch") + defer opts.IO.StopProgressIndicator() + branchName := "" reusedExisting := false if opts.Name != "" { - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel("Checking existing linked branches") branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number) - opts.IO.StopProgressIndicator() if err != nil { return err } @@ -201,9 +204,8 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr branchID := "" baseValidated := false if opts.BaseBranch != "" { - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Validating base branch %q", opts.BaseBranch)) foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch) - opts.IO.StopProgressIndicator() if err != nil { return err } @@ -214,9 +216,8 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr if branchName == "" { if !baseValidated { - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel("Resolving base branch") foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch) - opts.IO.StopProgressIndicator() if err != nil { return err } @@ -224,9 +225,8 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr branchID = foundBranchID } - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel("Creating linked branch") createdBranchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name) - opts.IO.StopProgressIndicator() if err != nil { return err } @@ -237,6 +237,8 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr return fmt.Errorf("failed to create linked branch: API returned empty branch name") } + opts.IO.StopProgressIndicator() + if reusedExisting && opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.ErrOut, "Using existing linked branch %q\n", branchName) } @@ -283,12 +285,14 @@ func linkedBranchRepoFromURL(branchURL string) (ghrepo.Interface, error) { } func developRunList(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error { - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel("Fetching linked branches") + defer opts.IO.StopProgressIndicator() + branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number) - opts.IO.StopProgressIndicator() if err != nil { return err } + opts.IO.StopProgressIndicator() if len(branches) == 0 { return cmdutil.NewNoResultsError(fmt.Sprintf("no linked branches found for %s#%d", ghrepo.FullName(issueRepo), issue.Number)) From 351df5b9d1f760985395de86558429574b7881ec Mon Sep 17 00:00:00 2001 From: Devraj Mehta Date: Mon, 2 Mar 2026 00:28:17 -0500 Subject: [PATCH 44/92] Set COPILOT_GH env var when launching Copilot CLI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/copilot/copilot.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/copilot/copilot.go b/pkg/cmd/copilot/copilot.go index 1195accc488..4ab840709f1 100644 --- a/pkg/cmd/copilot/copilot.go +++ b/pkg/cmd/copilot/copilot.go @@ -170,6 +170,7 @@ func runCopilot(opts *CopilotOptions) error { externalCmd.Stdin = opts.IO.In externalCmd.Stdout = opts.IO.Out externalCmd.Stderr = opts.IO.ErrOut + externalCmd.Env = append(os.Environ(), "COPILOT_GH=true") if err := externalCmd.Run(); err != nil { if exitErr, ok := err.(*exec.ExitError); ok { From cc15e7e16dc00dffc65b11cda3f19c118fd718eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:02:25 +0000 Subject: [PATCH 45/92] chore(deps): bump actions/upload-artifact from 6 to 7 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/deployment.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 0470a6644c1..22059f8d8cc 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -70,7 +70,7 @@ jobs: run: | go run ./cmd/gen-docs --website --doc-path dist/manual tar -czvf dist/manual.tar.gz -C dist -- manual - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: linux if-no-files-found: error @@ -150,7 +150,7 @@ jobs: run: | shopt -s failglob script/pkgmacos "$TAG_NAME" - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: macos if-no-files-found: error @@ -263,7 +263,7 @@ jobs: Get-ChildItem -Path .\dist -Filter *.msi | ForEach-Object { .\script\sign.ps1 $_.FullName } - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: windows if-no-files-found: error From 6842f5bdcbf15e49792cdc1a722b0fb5e0cf71ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:02:32 +0000 Subject: [PATCH 46/92] chore(deps): bump actions/download-artifact from 7 to 8 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/deployment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 0470a6644c1..fe69ac8b667 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -281,7 +281,7 @@ jobs: - name: Checkout cli/cli uses: actions/checkout@v6 - name: Merge built artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 - name: Checkout documentation site uses: actions/checkout@v6 with: From ab399f09e12eab7a8717fa4ddb2e32dfc09c6e65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:02:34 +0000 Subject: [PATCH 47/92] chore(deps): bump actions/attest-build-provenance from 3.2.0 to 4.1.0 Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 3.2.0 to 4.1.0. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/96278af6caaf10aea03fd8d33a09a777ca52d62f...a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/deployment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 0470a6644c1..03814e9cf7d 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -334,7 +334,7 @@ jobs: rpmsign --addsign dist/*.rpm - name: Attest release artifacts if: inputs.environment == 'production' - uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-path: "dist/gh_*" create-storage-record: false # (default: true) From 8c72314f6c41756b9740c9cc730f224a79254027 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:02:39 +0000 Subject: [PATCH 48/92] chore(deps): bump google.golang.org/grpc from 1.78.0 to 1.79.1 Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.78.0 to 1.79.1. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.78.0...v1.79.1) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.79.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 9 +++++---- go.sum | 26 ++++++++++++++------------ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 68cf9d6ff39..27506ee42ee 100644 --- a/go.mod +++ b/go.mod @@ -54,7 +54,7 @@ require ( golang.org/x/sync v0.19.0 golang.org/x/term v0.40.0 golang.org/x/text v0.34.0 - google.golang.org/grpc v1.78.0 + google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 @@ -72,6 +72,7 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/catppuccin/go v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect @@ -173,9 +174,9 @@ require ( github.com/yuin/goldmark-emoji v1.0.6 // indirect go.mongodb.org/mongo-driver v1.17.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.38.0 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect diff --git a/go.sum b/go.sum index e9460e53b28..ba2d9a2af2a 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -548,16 +550,16 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.step.sm/crypto v0.74.0 h1:/APBEv45yYR4qQFg47HA8w1nesIGcxh44pGyQNw6JRA= go.step.sm/crypto v0.74.0/go.mod h1:UoXqCAJjjRgzPte0Llaqen7O9P7XjPmgjgTHQGkKCDk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -638,8 +640,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1: google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 0f53ed545c1a159a65105b1db43b9850c4f61a36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:02:45 +0000 Subject: [PATCH 49/92] chore(deps): bump github.com/gabriel-vasile/mimetype Bumps [github.com/gabriel-vasile/mimetype](https://github.com/gabriel-vasile/mimetype) from 1.4.11 to 1.4.13. - [Release notes](https://github.com/gabriel-vasile/mimetype/releases) - [Commits](https://github.com/gabriel-vasile/mimetype/compare/v1.4.11...v1.4.13) --- updated-dependencies: - dependency-name: github.com/gabriel-vasile/mimetype dependency-version: 1.4.13 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 68cf9d6ff39..a6a9daca938 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/creack/pty v1.1.24 github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea github.com/distribution/reference v0.6.0 - github.com/gabriel-vasile/mimetype v1.4.11 + github.com/gabriel-vasile/mimetype v1.4.13 github.com/gdamore/tcell/v2 v2.13.8 github.com/golang/snappy v1.0.0 github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum index e9460e53b28..10f643f9313 100644 --- a/go.sum +++ b/go.sum @@ -193,8 +193,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= -github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= From b1df464f5225e6a5dc289fc651f6fac3b1291bde Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:03:04 +0000 Subject: [PATCH 50/92] chore(deps): bump goreleaser/goreleaser-action from 6.4.0 to 7.0.0 Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 6.4.0 to 7.0.0. - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/e435ccd777264be153ace6237001ef4d979d3a7a...ec59f474b9834571250b370d4735c50f8e2d1e29) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/deployment.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 0470a6644c1..9d845f798d3 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -50,7 +50,7 @@ jobs: with: go-version-file: 'go.mod' - name: Install GoReleaser - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. @@ -111,7 +111,7 @@ jobs: security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$keychain_password" "$keychain" rm "$RUNNER_TEMP/cert.p12" - name: Install GoReleaser - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. @@ -173,7 +173,7 @@ jobs: with: go-version-file: 'go.mod' - name: Install GoReleaser - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. From 5194256928d00e514328e66793bbcded8901c598 Mon Sep 17 00:00:00 2001 From: Louis Shawn Date: Fri, 6 Feb 2026 10:45:28 +0800 Subject: [PATCH 51/92] fix(issue list): reject pull request-only search qualifiers --- pkg/cmd/issue/list/http.go | 7 +++++++ pkg/cmd/issue/list/http_test.go | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/pkg/cmd/issue/list/http.go b/pkg/cmd/issue/list/http.go index 49e316db6b6..0657637bfb5 100644 --- a/pkg/cmd/issue/list/http.go +++ b/pkg/cmd/issue/list/http.go @@ -2,6 +2,7 @@ package list import ( "fmt" + "regexp" "github.com/cli/cli/v2/api" fd "github.com/cli/cli/v2/internal/featuredetection" @@ -9,6 +10,8 @@ import ( prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" ) +var pullRequestSearchQualifierRE = regexp.MustCompile(`(?i)\b(?:is|type):(?:pr|pull-?request)\b`) + func listIssues(client *api.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) { var states []string switch filters.State { @@ -114,6 +117,10 @@ loop: } func searchIssues(client *api.Client, detector fd.Detector, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) { + if pullRequestSearchQualifierRE.MatchString(filters.Search) { + return nil, fmt.Errorf("cannot use pull request search qualifiers with `gh issue list`; use `gh pr list` instead") + } + // TODO advancedIssueSearchCleanup // We won't need feature detection when GHES 3.17 support ends, since // the advanced issue search is the only available search backend for diff --git a/pkg/cmd/issue/list/http_test.go b/pkg/cmd/issue/list/http_test.go index 747bd0a4be3..52d35570265 100644 --- a/pkg/cmd/issue/list/http_test.go +++ b/pkg/cmd/issue/list/http_test.go @@ -214,3 +214,22 @@ func TestSearchIssuesAndAdvancedSearch(t *testing.T) { }) } } + +func TestSearchIssues_rejectsPullRequestQualifiers(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + httpClient := &http.Client{Transport: reg} + client := api.NewClientFromHTTP(httpClient) + + _, err := searchIssues( + client, + fd.AdvancedIssueSearchSupportedAsOnlyBackend(), + ghrepo.New("OWNER", "REPO"), + prShared.FilterOptions{Search: "is:pr"}, + 30, + ) + + assert.EqualError(t, err, "cannot use pull request search qualifiers with `gh issue list`; use `gh pr list` instead") + assert.Len(t, reg.Requests, 0) +} From b601b307a4d2db5c77e369f7ec01dcca2ab89271 Mon Sep 17 00:00:00 2001 From: Louis Shawn Date: Tue, 3 Mar 2026 10:13:57 +0800 Subject: [PATCH 52/92] test(issue list): cover additional PR search qualifier variants --- pkg/cmd/issue/list/http_test.go | 60 ++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/issue/list/http_test.go b/pkg/cmd/issue/list/http_test.go index 52d35570265..d313da90b6a 100644 --- a/pkg/cmd/issue/list/http_test.go +++ b/pkg/cmd/issue/list/http_test.go @@ -216,20 +216,54 @@ func TestSearchIssuesAndAdvancedSearch(t *testing.T) { } func TestSearchIssues_rejectsPullRequestQualifiers(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) + tests := []struct { + name string + search string + }{ + { + name: "is:pr", + search: "is:pr", + }, + { + name: "type:pr", + search: "type:pr", + }, + { + name: "type:pull-request", + search: "type:pull-request", + }, + { + name: "type:pullrequest", + search: "type:pullrequest", + }, + { + name: "case-insensitive is:PR", + search: "is:PR", + }, + { + name: "case-insensitive TYPE:Pull-Request", + search: "TYPE:Pull-Request", + }, + } - httpClient := &http.Client{Transport: reg} - client := api.NewClientFromHTTP(httpClient) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) - _, err := searchIssues( - client, - fd.AdvancedIssueSearchSupportedAsOnlyBackend(), - ghrepo.New("OWNER", "REPO"), - prShared.FilterOptions{Search: "is:pr"}, - 30, - ) + httpClient := &http.Client{Transport: reg} + client := api.NewClientFromHTTP(httpClient) - assert.EqualError(t, err, "cannot use pull request search qualifiers with `gh issue list`; use `gh pr list` instead") - assert.Len(t, reg.Requests, 0) + _, err := searchIssues( + client, + fd.AdvancedIssueSearchSupportedAsOnlyBackend(), + ghrepo.New("OWNER", "REPO"), + prShared.FilterOptions{Search: tt.search}, + 30, + ) + + assert.EqualError(t, err, "cannot use pull request search qualifiers with `gh issue list`; use `gh pr list` instead") + assert.Len(t, reg.Requests, 0) + }) + } } From 6045a593a35b9eb9913e89269fc326851145ec9a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:51:27 -0700 Subject: [PATCH 53/92] Reword `--no-upstream` help doc --- pkg/cmd/repo/clone/clone.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index 7c538214484..b29b25038b6 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -61,8 +61,7 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm the remote after the owner of the parent repository. If the repository is a fork, its parent repository will be set as the default remote repository. - - To skip adding the upstream remote entirely, use %[1]s--no-upstream%[1]s. + To skip this behavior, use %[1]s--no-upstream%[1]s. `, "`"), Example: heredoc.Doc(` # Clone a repository from a specific org From 74ff94c3e4d14e8a9dd1cf1d138eb17617e8b95e Mon Sep 17 00:00:00 2001 From: Mason McElvain <52104630+masonmcelvain@users.noreply.github.com> Date: Sat, 9 Aug 2025 08:19:51 -0600 Subject: [PATCH 54/92] feat(browse): add blame flag --- pkg/cmd/browse/browse.go | 16 +++++++ pkg/cmd/browse/browse_test.go | 79 +++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go index a85b8ab7de1..d057139565a 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -44,6 +44,7 @@ type BrowseOptions struct { SettingsFlag bool WikiFlag bool ActionsFlag bool + BlameFlag bool NoBrowserFlag bool HasRepoOverride bool } @@ -91,6 +92,9 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co # Open main.go at line 312 $ gh browse main.go:312 + # Open blame view for main.go at line 312 + $ gh browse main.go:312 --blame + # Open main.go with the repository at head of bug-fix branch $ gh browse main.go --branch bug-fix @@ -141,6 +145,10 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co return err } + if opts.BlameFlag && opts.SelectorArg == "" { + return cmdutil.FlagErrorf("`--blame` requires a file path argument") + } + if (isNumber(opts.SelectorArg) || isCommit(opts.SelectorArg)) && (opts.Branch != "" || opts.Commit != "") { return cmdutil.FlagErrorf("%q is an invalid argument when using `--branch` or `--commit`", opts.SelectorArg) } @@ -163,6 +171,7 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Open repository wiki") cmd.Flags().BoolVarP(&opts.ActionsFlag, "actions", "a", false, "Open repository actions") cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Open repository settings") + cmd.Flags().BoolVar(&opts.BlameFlag, "blame", false, "Open blame view for a file") cmd.Flags().BoolVarP(&opts.NoBrowserFlag, "no-browser", "n", false, "Print destination URL instead of opening the browser") cmd.Flags().StringVarP(&opts.Commit, "commit", "c", "", "Select another commit by passing in the commit SHA, default is the last commit") cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Select another branch by passing in the branch name") @@ -272,9 +281,16 @@ func parseSection(baseRepo ghrepo.Interface, opts *BrowseOptions) (string, error } else { rangeFragment = fmt.Sprintf("L%d", rangeStart) } + if opts.BlameFlag { + return fmt.Sprintf("blame/%s/%s#%s", escapePath(ref), escapePath(filePath), rangeFragment), nil + } return fmt.Sprintf("blob/%s/%s?plain=1#%s", escapePath(ref), escapePath(filePath), rangeFragment), nil } + if opts.BlameFlag { + return fmt.Sprintf("blame/%s/%s", escapePath(ref), escapePath(filePath)), nil + } + return strings.TrimSuffix(fmt.Sprintf("tree/%s/%s", escapePath(ref), escapePath(filePath)), "/"), nil } diff --git a/pkg/cmd/browse/browse_test.go b/pkg/cmd/browse/browse_test.go index 6d4476f3a33..c321fbdbb57 100644 --- a/pkg/cmd/browse/browse_test.go +++ b/pkg/cmd/browse/browse_test.go @@ -207,6 +207,29 @@ func TestNewCmdBrowse(t *testing.T) { cli: "de07febc26e19000f8c9e821207f3bc34a3c8038 --commit=12a4", wantsErr: true, }, + { + name: "blame flag", + cli: "main.go --blame", + wants: BrowseOptions{ + BlameFlag: true, + SelectorArg: "main.go", + }, + wantsErr: false, + }, + { + name: "blame flag without file argument", + cli: "--blame", + wantsErr: true, + }, + { + name: "blame flag with line number", + cli: "main.go:312 --blame", + wants: BrowseOptions{ + BlameFlag: true, + SelectorArg: "main.go:312", + }, + wantsErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -239,6 +262,7 @@ func TestNewCmdBrowse(t *testing.T) { assert.Equal(t, tt.wants.SettingsFlag, opts.SettingsFlag) assert.Equal(t, tt.wants.ActionsFlag, opts.ActionsFlag) assert.Equal(t, tt.wants.Commit, opts.Commit) + assert.Equal(t, tt.wants.BlameFlag, opts.BlameFlag) }) } } @@ -595,6 +619,61 @@ func Test_runBrowse(t *testing.T) { expectedURL: "https://github.com/bchadwic/test/tree/trunk/77507cd94ccafcf568f8560cfecde965fcfa63e7.txt", wantsErr: false, }, + { + name: "file with blame flag", + opts: BrowseOptions{ + SelectorArg: "path/to/file.txt", + BlameFlag: true, + }, + baseRepo: ghrepo.New("owner", "repo"), + defaultBranch: "main", + expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt", + wantsErr: false, + }, + { + name: "file with blame flag and line number", + opts: BrowseOptions{ + SelectorArg: "path/to/file.txt:42", + BlameFlag: true, + }, + baseRepo: ghrepo.New("owner", "repo"), + defaultBranch: "main", + expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt#L42", + wantsErr: false, + }, + { + name: "file with blame flag and line range", + opts: BrowseOptions{ + SelectorArg: "path/to/file.txt:10-20", + BlameFlag: true, + }, + baseRepo: ghrepo.New("owner", "repo"), + defaultBranch: "main", + expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt#L10-L20", + wantsErr: false, + }, + { + name: "file with blame flag and branch", + opts: BrowseOptions{ + SelectorArg: "main.go:100", + BlameFlag: true, + Branch: "feature-branch", + }, + baseRepo: ghrepo.New("owner", "repo"), + expectedURL: "https://github.com/owner/repo/blame/feature-branch/main.go#L100", + wantsErr: false, + }, + { + name: "file with blame flag and commit", + opts: BrowseOptions{ + SelectorArg: "src/app.js:50", + BlameFlag: true, + Commit: "abc123", + }, + baseRepo: ghrepo.New("owner", "repo"), + expectedURL: "https://github.com/owner/repo/blame/abc123/src/app.js#L50", + wantsErr: false, + }, } for _, tt := range tests { From 22658208533c56ec894f0a258611651933cea8c1 Mon Sep 17 00:00:00 2001 From: Takeshi Date: Tue, 3 Mar 2026 21:51:18 -0500 Subject: [PATCH 55/92] Combine issue feature detection into a single GraphQL query Merge the Issue_fields and IssueClosedStateReason_enumValues introspection queries into one call to avoid an extra API round-trip on GHES. --- .../featuredetection/feature_detection.go | 32 +++++++------------ .../feature_detection_test.go | 10 ++---- 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index b162e4c2cf3..e40c134bc7b 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -151,6 +151,11 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { Name string } `graphql:"fields(includeDeprecated: true)"` } `graphql:"Issue: __type(name: \"Issue\")"` + IssueClosedStateReason struct { + EnumValues []struct { + Name string + } `graphql:"enumValues(includeDeprecated: true)"` + } `graphql:"IssueClosedStateReason: __type(name: \"IssueClosedStateReason\")"` } gql := api.NewClientFromHTTP(d.httpClient) @@ -165,27 +170,12 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { } } - if !features.StateReason { - return features, nil - } - - var issueClosedStateReasonFeatureDetection struct { - IssueClosedStateReason struct { - EnumValues []struct { - Name string - } `graphql:"enumValues(includeDeprecated: true)"` - } `graphql:"IssueClosedStateReason: __type(name: \"IssueClosedStateReason\")"` - } - - err = gql.Query(d.host, "IssueClosedStateReason_enumValues", &issueClosedStateReasonFeatureDetection, nil) - if err != nil { - return features, err - } - - for _, enumValue := range issueClosedStateReasonFeatureDetection.IssueClosedStateReason.EnumValues { - if enumValue.Name == "DUPLICATE" { - features.StateReasonDuplicate = true - break + if features.StateReason { + for _, enumValue := range featureDetection.IssueClosedStateReason.EnumValues { + if enumValue.Name == "DUPLICATE" { + features.StateReasonDuplicate = true + break + } } } diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index c1d7b3b4a70..7417cc7d8e8 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -59,10 +59,7 @@ func TestIssueFeatures(t *testing.T) { `query Issue_fields\b`: heredoc.Doc(` { "data": { "Issue": { "fields": [ {"name": "stateReason"} - ] } } } - `), - `query IssueClosedStateReason_enumValues\b`: heredoc.Doc(` - { "data": { "IssueClosedStateReason": { "enumValues": [ + ] }, "IssueClosedStateReason": { "enumValues": [ {"name": "COMPLETED"}, {"name": "NOT_PLANNED"} ] } } } @@ -82,10 +79,7 @@ func TestIssueFeatures(t *testing.T) { `query Issue_fields\b`: heredoc.Doc(` { "data": { "Issue": { "fields": [ {"name": "stateReason"} - ] } } } - `), - `query IssueClosedStateReason_enumValues\b`: heredoc.Doc(` - { "data": { "IssueClosedStateReason": { "enumValues": [ + ] }, "IssueClosedStateReason": { "enumValues": [ {"name": "COMPLETED"}, {"name": "NOT_PLANNED"}, {"name": "DUPLICATE"} From 38c997567a2e2be51aca6a82a03aab64ff16f7d7 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 4 Mar 2026 05:01:52 -0700 Subject: [PATCH 56/92] Fix incorrect integer conversion from int to uint16 in port forwarder (#12831) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/codespaces/portforwarder/port_forwarder.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/codespaces/portforwarder/port_forwarder.go b/internal/codespaces/portforwarder/port_forwarder.go index 7f696c1a497..b24f8f637f5 100644 --- a/internal/codespaces/portforwarder/port_forwarder.go +++ b/internal/codespaces/portforwarder/port_forwarder.go @@ -264,7 +264,11 @@ func (fwd *CodespacesPortForwarder) UpdatePortVisibility(ctx context.Context, re } // Delete the existing tunnel port to update - err = fwd.connection.TunnelManager.DeleteTunnelPort(ctx, fwd.connection.Tunnel, uint16(remotePort), fwd.connection.Options) + port, err := convertIntToUint16(remotePort) + if err != nil { + return fmt.Errorf("error converting port: %w", err) + } + err = fwd.connection.TunnelManager.DeleteTunnelPort(ctx, fwd.connection.Tunnel, port, fwd.connection.Options) if err != nil { return fmt.Errorf("error deleting tunnel port: %w", err) } From d594c5e91870e11e0d44d67677b48cacc142e170 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:53:27 -0700 Subject: [PATCH 57/92] docs: add examples to gh issue close help text (#12830) Add examples for closing issues, closing with a comment, closing as duplicate, and closing with a reason. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Babak K. Shandiz --- pkg/cmd/issue/close/close.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go index 19510bf89a9..4c1c7d382bf 100644 --- a/pkg/cmd/issue/close/close.go +++ b/pkg/cmd/issue/close/close.go @@ -5,6 +5,7 @@ import ( "net/http" "time" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" @@ -38,7 +39,20 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm cmd := &cobra.Command{ Use: "close { | }", Short: "Close issue", - Args: cobra.ExactArgs(1), + Example: heredoc.Doc(` + # Close issue + $ gh issue close 123 + + # Close issue and add a closing comment + $ gh issue close 123 --comment "Closing this issue" + + # Close issue as a duplicate of issue #456 + $ gh issue close 123 --duplicate-of 456 + + # Close issue as not planned + $ gh issue close 123 --reason "not planned" + `), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0]) if err != nil { From 6b56a239704caf59a14e82a9d8124509c2d77cbe Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:24:05 -0700 Subject: [PATCH 58/92] Remove unnecessary StateReasonDuplicate feature detection The DUPLICATE enum variant for IssueClosedStateReason was added in GHES 3.16, which is older than the earliest supported GHES version. The feature detection check is therefore unnecessary. Addresses: https://github.com/cli/cli/pull/12811#issuecomment-3997044372 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../featuredetection/feature_detection.go | 29 ++------ .../feature_detection_test.go | 45 ++--------- pkg/cmd/issue/close/close.go | 6 -- pkg/cmd/issue/close/close_test.go | 74 ------------------- 4 files changed, 14 insertions(+), 140 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index e40c134bc7b..7a200e20c84 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -23,15 +23,13 @@ type Detector interface { } type IssueFeatures struct { - StateReason bool - StateReasonDuplicate bool - ActorIsAssignable bool + StateReason bool + ActorIsAssignable bool } var allIssueFeatures = IssueFeatures{ - StateReason: true, - StateReasonDuplicate: true, - ActorIsAssignable: true, + StateReason: true, + ActorIsAssignable: true, } type PullRequestFeatures struct { @@ -140,9 +138,8 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { } features := IssueFeatures{ - StateReason: false, - StateReasonDuplicate: false, - ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES + StateReason: false, + ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES } var featureDetection struct { @@ -151,11 +148,6 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { Name string } `graphql:"fields(includeDeprecated: true)"` } `graphql:"Issue: __type(name: \"Issue\")"` - IssueClosedStateReason struct { - EnumValues []struct { - Name string - } `graphql:"enumValues(includeDeprecated: true)"` - } `graphql:"IssueClosedStateReason: __type(name: \"IssueClosedStateReason\")"` } gql := api.NewClientFromHTTP(d.httpClient) @@ -170,15 +162,6 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { } } - if features.StateReason { - for _, enumValue := range featureDetection.IssueClosedStateReason.EnumValues { - if enumValue.Name == "DUPLICATE" { - features.StateReasonDuplicate = true - break - } - } - } - return features, nil } diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index 7417cc7d8e8..032f5cda03c 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -23,9 +23,8 @@ func TestIssueFeatures(t *testing.T) { name: "github.com", hostname: "github.com", wantFeatures: IssueFeatures{ - StateReason: true, - StateReasonDuplicate: true, - ActorIsAssignable: true, + StateReason: true, + ActorIsAssignable: true, }, wantErr: false, }, @@ -33,9 +32,8 @@ func TestIssueFeatures(t *testing.T) { name: "ghec data residency (ghe.com)", hostname: "stampname.ghe.com", wantFeatures: IssueFeatures{ - StateReason: true, - StateReasonDuplicate: true, - ActorIsAssignable: true, + StateReason: true, + ActorIsAssignable: true, }, wantErr: false, }, @@ -46,50 +44,23 @@ func TestIssueFeatures(t *testing.T) { `query Issue_fields\b`: `{"data": {}}`, }, wantFeatures: IssueFeatures{ - StateReason: false, - StateReasonDuplicate: false, - ActorIsAssignable: false, + StateReason: false, + ActorIsAssignable: false, }, wantErr: false, }, { - name: "GHE has state reason field without duplicate enum", + name: "GHE has state reason field", hostname: "git.my.org", queryResponse: map[string]string{ `query Issue_fields\b`: heredoc.Doc(` { "data": { "Issue": { "fields": [ {"name": "stateReason"} - ] }, "IssueClosedStateReason": { "enumValues": [ - {"name": "COMPLETED"}, - {"name": "NOT_PLANNED"} ] } } } `), }, wantFeatures: IssueFeatures{ - StateReason: true, - StateReasonDuplicate: false, - ActorIsAssignable: false, - }, - wantErr: false, - }, - { - name: "GHE has duplicate state reason enum value", - hostname: "git.my.org", - queryResponse: map[string]string{ - `query Issue_fields\b`: heredoc.Doc(` - { "data": { "Issue": { "fields": [ - {"name": "stateReason"} - ] }, "IssueClosedStateReason": { "enumValues": [ - {"name": "COMPLETED"}, - {"name": "NOT_PLANNED"}, - {"name": "DUPLICATE"} - ] } } } - `), - }, - wantFeatures: IssueFeatures{ - StateReason: true, - StateReasonDuplicate: true, - ActorIsAssignable: false, + StateReason: true, }, wantErr: false, }, diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go index 4c1c7d382bf..ab7e30688e6 100644 --- a/pkg/cmd/issue/close/close.go +++ b/pkg/cmd/issue/close/close.go @@ -196,12 +196,6 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, return fmt.Errorf("closing as duplicate is not supported on %s", repo.RepoHost()) } reason = "" - } else if reason == "duplicate" && !features.StateReasonDuplicate { - if duplicateIssueID != "" { - return fmt.Errorf("closing as duplicate is not supported on %s", repo.RepoHost()) - } - // If DUPLICATE is not supported silently close issue without setting StateReason. - reason = "" } } diff --git a/pkg/cmd/issue/close/close_test.go b/pkg/cmd/issue/close/close_test.go index ddab7121007..300fe5fc54b 100644 --- a/pkg/cmd/issue/close/close_test.go +++ b/pkg/cmd/issue/close/close_test.go @@ -16,15 +16,6 @@ import ( "github.com/stretchr/testify/require" ) -type issueFeaturesDetectorMock struct { - fd.EnabledDetectorMock - issueFeatures fd.IssueFeatures -} - -func (md *issueFeaturesDetectorMock) IssueFeatures() (fd.IssueFeatures, error) { - return md.issueFeatures, nil -} - func TestNewCmdClose(t *testing.T) { // Test shared parsing of issue number / URL. argparsetest.TestArgParsing(t, NewCmdClose) @@ -282,71 +273,6 @@ func TestCloseRun(t *testing.T) { }, wantStderr: "โœ“ Closed issue OWNER/REPO#13 (The title of the issue)\n", }, - { - name: "close issue with duplicate reason when duplicate is not supported", - opts: &CloseOptions{ - IssueNumber: 13, - Reason: "duplicate", - Detector: &issueFeaturesDetectorMock{ - issueFeatures: fd.IssueFeatures{ - StateReason: true, - StateReasonDuplicate: false, - }, - }, - }, - httpStubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} - } } }`), - ) - reg.Register( - httpmock.GraphQL(`mutation IssueClose\b`), - httpmock.GraphQLMutation(`{"id": "THE-ID"}`, - func(inputs map[string]interface{}) { - assert.Equal(t, 1, len(inputs)) - assert.Equal(t, "THE-ID", inputs["issueId"]) - }), - ) - }, - wantStderr: "โœ“ Closed issue OWNER/REPO#13 (The title of the issue)\n", - }, - { - name: "close issue as duplicate when duplicate is not supported", - opts: &CloseOptions{ - IssueNumber: 13, - DuplicateOf: "99", - Detector: &issueFeaturesDetectorMock{ - issueFeatures: fd.IssueFeatures{ - StateReason: true, - StateReasonDuplicate: false, - }, - }, - }, - httpStubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} - } } }`), - ) - reg.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "id": "DUPLICATE-ID", "number": 99} - } } }`), - ) - }, - wantErr: true, - errMsg: "closing as duplicate is not supported on github.com", - }, { name: "duplicate of cannot point to same issue", opts: &CloseOptions{ From 4f2304d4e5d69e9981b55020212594a642f3d71f Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:31:30 -0700 Subject: [PATCH 59/92] Remove StateReason feature detection for issue close The stateReason field was added in GHES ~3.4, which is far older than the earliest supported GHES version (3.14). The feature detection and conditional inclusion of stateReason is therefore unnecessary. This removes: - StateReason field from IssueFeatures struct - GHES introspection query in IssueFeatures() (only ActorIsAssignable remains, which is always false on GHES) - Conditional stateReason field inclusion in issue list - Feature detection guard in issue close - Feature detection guard in FindIssueOrPR Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../featuredetection/feature_detection.go | 29 ++--------------- .../feature_detection_test.go | 23 +------------- pkg/cmd/issue/close/close.go | 27 ++-------------- pkg/cmd/issue/close/close_test.go | 31 ------------------- pkg/cmd/issue/list/list.go | 10 +----- pkg/cmd/issue/shared/lookup.go | 14 --------- 6 files changed, 6 insertions(+), 128 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 7a200e20c84..9af4c5aeca5 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -23,12 +23,10 @@ type Detector interface { } type IssueFeatures struct { - StateReason bool ActorIsAssignable bool } var allIssueFeatures = IssueFeatures{ - StateReason: true, ActorIsAssignable: true, } @@ -137,32 +135,9 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { return allIssueFeatures, nil } - features := IssueFeatures{ - StateReason: false, + return IssueFeatures{ ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES - } - - var featureDetection struct { - Issue struct { - Fields []struct { - Name string - } `graphql:"fields(includeDeprecated: true)"` - } `graphql:"Issue: __type(name: \"Issue\")"` - } - - gql := api.NewClientFromHTTP(d.httpClient) - err := gql.Query(d.host, "Issue_fields", &featureDetection, nil) - if err != nil { - return features, err - } - - for _, field := range featureDetection.Issue.Fields { - if field.Name == "stateReason" { - features.StateReason = true - } - } - - return features, nil + }, nil } func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) { diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index 032f5cda03c..82132ab83fd 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -23,7 +23,6 @@ func TestIssueFeatures(t *testing.T) { name: "github.com", hostname: "github.com", wantFeatures: IssueFeatures{ - StateReason: true, ActorIsAssignable: true, }, wantErr: false, @@ -32,38 +31,18 @@ func TestIssueFeatures(t *testing.T) { name: "ghec data residency (ghe.com)", hostname: "stampname.ghe.com", wantFeatures: IssueFeatures{ - StateReason: true, ActorIsAssignable: true, }, wantErr: false, }, { - name: "GHE empty response", + name: "GHE", hostname: "git.my.org", - queryResponse: map[string]string{ - `query Issue_fields\b`: `{"data": {}}`, - }, wantFeatures: IssueFeatures{ - StateReason: false, ActorIsAssignable: false, }, wantErr: false, }, - { - name: "GHE has state reason field", - hostname: "git.my.org", - queryResponse: map[string]string{ - `query Issue_fields\b`: heredoc.Doc(` - { "data": { "Issue": { "fields": [ - {"name": "stateReason"} - ] } } } - `), - }, - wantFeatures: IssueFeatures{ - StateReason: true, - }, - wantErr: false, - }, } for _, tt := range tests { diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go index ab7e30688e6..d9b02b6a22f 100644 --- a/pkg/cmd/issue/close/close.go +++ b/pkg/cmd/issue/close/close.go @@ -3,11 +3,9 @@ package close import ( "fmt" "net/http" - "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -26,8 +24,6 @@ type CloseOptions struct { Comment string Reason string DuplicateOf string - - Detector fd.Detector } func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Command { @@ -165,7 +161,7 @@ func closeRun(opts *CloseOptions) error { } } - err = apiClose(httpClient, baseRepo, issue, opts.Detector, closeReason, duplicateIssueID) + err = apiClose(httpClient, baseRepo, issue, closeReason, duplicateIssueID) if err != nil { return err } @@ -175,30 +171,11 @@ func closeRun(opts *CloseOptions) error { return nil } -func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, detector fd.Detector, reason string, duplicateIssueID string) error { +func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, reason string, duplicateIssueID string) error { if issue.IsPullRequest() { return api.PullRequestClose(httpClient, repo, issue.ID) } - if reason != "" || duplicateIssueID != "" { - if detector == nil { - cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - detector = fd.NewDetector(cachedClient, repo.RepoHost()) - } - features, err := detector.IssueFeatures() - if err != nil { - return err - } - // TODO stateReasonCleanup - if !features.StateReason { - // If StateReason is not supported silently close issue without setting StateReason. - if duplicateIssueID != "" { - return fmt.Errorf("closing as duplicate is not supported on %s", repo.RepoHost()) - } - reason = "" - } - } - switch reason { case "": // If no reason is specified do not set it. diff --git a/pkg/cmd/issue/close/close_test.go b/pkg/cmd/issue/close/close_test.go index 300fe5fc54b..e7dfa162751 100644 --- a/pkg/cmd/issue/close/close_test.go +++ b/pkg/cmd/issue/close/close_test.go @@ -5,7 +5,6 @@ import ( "net/http" "testing" - fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/issue/argparsetest" "github.com/cli/cli/v2/pkg/cmdutil" @@ -185,7 +184,6 @@ func TestCloseRun(t *testing.T) { opts: &CloseOptions{ IssueNumber: 13, Reason: "not planned", - Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -213,7 +211,6 @@ func TestCloseRun(t *testing.T) { opts: &CloseOptions{ IssueNumber: 13, Reason: "duplicate", - Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -241,7 +238,6 @@ func TestCloseRun(t *testing.T) { opts: &CloseOptions{ IssueNumber: 13, DuplicateOf: "99", - Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -338,33 +334,6 @@ func TestCloseRun(t *testing.T) { wantErr: true, errMsg: "invalid value for `--duplicate-of`: invalid issue format: \"not-an-issue\"", }, - { - name: "close issue with reason when reason is not supported", - opts: &CloseOptions{ - IssueNumber: 13, - Reason: "not planned", - Detector: &fd.DisabledDetectorMock{}, - }, - httpStubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} - } } }`), - ) - reg.Register( - httpmock.GraphQL(`mutation IssueClose\b`), - httpmock.GraphQLMutation(`{"id": "THE-ID"}`, - func(inputs map[string]interface{}) { - assert.Equal(t, 1, len(inputs)) - assert.Equal(t, "THE-ID", inputs["issueId"]) - }), - ) - }, - wantStderr: "โœ“ Closed issue OWNER/REPO#13 (The title of the issue)\n", - }, { name: "issue already closed", opts: &CloseOptions{ diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index f5a0a442926..d58357ac475 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -147,15 +147,7 @@ func listRun(opts *ListOptions) error { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) } - features, err := opts.Detector.IssueFeatures() - if err != nil { - return err - } - fields := defaultFields - // TODO stateReasonCleanup - if features.StateReason { - fields = append(defaultFields, "stateReason") - } + fields := append(defaultFields, "stateReason") filterOptions := prShared.FilterOptions{ Entity: "issue", diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index 2975ad48481..63efd61f72e 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -8,10 +8,8 @@ import ( "regexp" "strconv" "strings" - "time" "github.com/cli/cli/v2/api" - fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" o "github.com/cli/cli/v2/pkg/option" "github.com/cli/cli/v2/pkg/set" @@ -138,18 +136,6 @@ func FindIssuesOrPRs(httpClient *http.Client, repo ghrepo.Interface, issueNumber func FindIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.Issue, error) { fieldSet := set.NewStringSet() fieldSet.AddValues(fields) - if fieldSet.Contains("stateReason") { - cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - detector := fd.NewDetector(cachedClient, repo.RepoHost()) - features, err := detector.IssueFeatures() - if err != nil { - return nil, err - } - // TODO stateReasonCleanup - if !features.StateReason { - fieldSet.Remove("stateReason") - } - } var getProjectItems bool if fieldSet.Contains("projectItems") { From 80faf6f24c1944e6644d8c94dac6ea5dfa19f328 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 03:22:41 +0000 Subject: [PATCH 60/92] chore(deps): bump github.com/docker/cli Bumps [github.com/docker/cli](https://github.com/docker/cli) from 29.0.3+incompatible to 29.2.0+incompatible. - [Commits](https://github.com/docker/cli/compare/v29.0.3...v29.2.0) --- updated-dependencies: - dependency-name: github.com/docker/cli dependency-version: 29.2.0+incompatible dependency-type: indirect ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5e6d0724708..a9c86a672d0 100644 --- a/go.mod +++ b/go.mod @@ -89,7 +89,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/docker/cli v29.0.3+incompatible // indirect + github.com/docker/cli v29.2.0+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect diff --git a/go.sum b/go.sum index 576d84c65b8..e4c39bd3a92 100644 --- a/go.sum +++ b/go.sum @@ -179,8 +179,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v29.0.3+incompatible h1:8J+PZIcF2xLd6h5sHPsp5pvvJA+Sr2wGQxHkRl53a1E= -github.com/docker/cli v29.0.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= +github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= From ff8873da07e291dcbe7043e953a23ad3329ccf18 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:28:54 +0100 Subject: [PATCH 61/92] Fix extension install error message showing raw struct instead of owner/repo (#12836) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: williammartin <1611510+williammartin@users.noreply.github.com> --- pkg/cmd/extension/manager.go | 2 +- pkg/cmd/extension/manager_test.go | 33 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index b7f1f1c0c07..de758f5a3d2 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -268,7 +268,7 @@ func (m *Manager) Install(repo ghrepo.Interface, target string) error { return err } if !hs { - return fmt.Errorf("extension is not installable: no usable release artifact or script found in %s", repo) + return fmt.Errorf("extension is not installable: no usable release artifact or script found in %s", ghrepo.FullName(repo)) } return m.installGit(repo, target) diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index e933f0bdfab..1e8f8248324 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -867,6 +867,39 @@ func TestManager_Install_git(t *testing.T) { assert.NoDirExistsf(t, extensionUpdatePath, "update directory should be removed") } +func TestManager_Install_not_installable(t *testing.T) { + dataDir := t.TempDir() + updateDir := t.TempDir() + + reg := httpmock.Registry{} + defer reg.Verify(t) + client := http.Client{Transport: ®} + + ios, _, _, _ := iostreams.Test() + + m := newTestManager(dataDir, updateDir, &client, nil, ios) + + reg.Register( + httpmock.REST("GET", "repos/owner/gh-some-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Assets: []releaseAsset{ + { + Name: "not-a-binary", + APIURL: "https://example.com/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/owner/gh-some-ext/contents/gh-some-ext"), + httpmock.StatusStringResponse(404, "not found")) + + repo := ghrepo.New("owner", "gh-some-ext") + + err := m.Install(repo, "") + assert.EqualError(t, err, "extension is not installable: no usable release artifact or script found in owner/gh-some-ext") +} + func TestManager_Install_git_pinned(t *testing.T) { dataDir := t.TempDir() updateDir := t.TempDir() From fdd638808636072216604e0167c872011229d44a Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Thu, 5 Mar 2026 14:02:15 +0530 Subject: [PATCH 62/92] refactor: deduplicate scope error handling between api/client.go and project queries Consolidate duplicated INSUFFICIENT_SCOPES error handling into a single implementation. The project queries package now calls api.GenerateScopeErrorForGQL instead of reimplementing the same logic. Removes duplicated requiredScopesFromServerMessage, scopesRE, and the associated test (already covered by api/client_test.go). Fixes #12823 --- api/client.go | 1 - pkg/cmd/project/shared/queries/queries.go | 37 ++----------------- .../project/shared/queries/queries_test.go | 32 ---------------- 3 files changed, 4 insertions(+), 66 deletions(-) diff --git a/api/client.go b/api/client.go index 895f2969272..2eb3f3ff20d 100644 --- a/api/client.go +++ b/api/client.go @@ -203,7 +203,6 @@ func GenerateScopeErrorForGQL(gqlErr *ghAPI.GraphQLError) error { } if missing.Len() > 0 { s := missing.ToSlice() - // TODO: this duplicates parts of generateScopesSuggestion return fmt.Errorf( "error: your authentication token is missing required scopes %v\n"+ "To request it, run: gh auth refresh -s %s", diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go index 9a3bd490902..1de42a4bd56 100644 --- a/pkg/cmd/project/shared/queries/queries.go +++ b/pkg/cmd/project/shared/queries/queries.go @@ -5,13 +5,11 @@ import ( "fmt" "net/http" "net/url" - "regexp" "strings" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/set" "github.com/shurcooL/githubv4" ) @@ -1664,42 +1662,15 @@ func (c *Client) UnlinkProjectFromTeam(projectID string, teamID string) error { } func handleError(err error) error { - var gerr api.GraphQLError - if errors.As(err, &gerr) { - missing := set.NewStringSet() - for _, e := range gerr.Errors { - if e.Type != "INSUFFICIENT_SCOPES" { - continue - } - missing.AddValues(requiredScopesFromServerMessage(e.Message)) - } - if missing.Len() > 0 { - s := missing.ToSlice() - // TODO: this duplicates parts of generateScopesSuggestion - return fmt.Errorf( - "error: your authentication token is missing required scopes %v\n"+ - "To request it, run: gh auth refresh -s %s", - s, - strings.Join(s, ",")) + var gqlErr api.GraphQLError + if errors.As(err, &gqlErr) { + if scopeErr := api.GenerateScopeErrorForGQL(gqlErr.GraphQLError); scopeErr != nil { + return scopeErr } } return err } -var scopesRE = regexp.MustCompile(`one of the following scopes: \[(.+?)]`) - -func requiredScopesFromServerMessage(msg string) []string { - m := scopesRE.FindStringSubmatch(msg) - if m == nil { - return nil - } - var scopes []string - for _, mm := range strings.Split(m[1], ",") { - scopes = append(scopes, strings.Trim(mm, "' ")) - } - return scopes -} - func projectFieldValueData(v FieldValueNodes) interface{} { switch v.Type { case "ProjectV2ItemFieldDateValue": diff --git a/pkg/cmd/project/shared/queries/queries_test.go b/pkg/cmd/project/shared/queries/queries_test.go index cc4850d8620..dea5d13bb8f 100644 --- a/pkg/cmd/project/shared/queries/queries_test.go +++ b/pkg/cmd/project/shared/queries/queries_test.go @@ -3,7 +3,6 @@ package queries import ( "io" "net/http" - "reflect" "strings" "testing" @@ -564,37 +563,6 @@ func TestProjectFields_NoLimit(t *testing.T) { assert.Len(t, project.Fields.Nodes, 3) } -func Test_requiredScopesFromServerMessage(t *testing.T) { - tests := []struct { - name string - msg string - want []string - }{ - { - name: "no scopes", - msg: "SERVER OOPSIE", - want: []string(nil), - }, - { - name: "one scope", - msg: "Your token has not been granted the required scopes to execute this query. The 'dataType' field requires one of the following scopes: ['read:project'], but your token has only been granted the: ['codespace', repo'] scopes. Please modify your token's scopes at: https://github.com/settings/tokens.", - want: []string{"read:project"}, - }, - { - name: "multiple scopes", - msg: "Your token has not been granted the required scopes to execute this query. The 'dataType' field requires one of the following scopes: ['read:project', 'read:discussion', 'codespace'], but your token has only been granted the: [repo'] scopes. Please modify your token's scopes at: https://github.com/settings/tokens.", - want: []string{"read:project", "read:discussion", "codespace"}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := requiredScopesFromServerMessage(tt.msg); !reflect.DeepEqual(got, tt.want) { - t.Errorf("requiredScopesFromServerMessage() = %v, want %v", got, tt.want) - } - }) - } -} - func TestNewProject_nonTTY(t *testing.T) { client := NewTestClient() _, err := client.NewProject(false, &Owner{}, 0, false) From 6341588f90d2b90c1900afab6fac15ea02be7b24 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:56:31 -0700 Subject: [PATCH 63/92] Add TODO requestReviewsByLoginCleanup in CreatePullRequest Add a cleanup TODO comment above the GHES feature detection branch in CreatePullRequest so we can track removing the ID-based reviewer request path once GHES supports requestReviewsByLogin. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_pr.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index f1d38397131..85680f6e2ca 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -523,7 +523,9 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter } } - // Request reviewers using either login-based (github.com) or ID-based (GHES) mutation + // TODO requestReviewsByLoginCleanup + // Request reviewers using either login-based (github.com) or ID-based (GHES) mutation. + // The ID-based path can be removed once GHES supports requestReviewsByLogin. userLogins, hasUserLogins := params["userReviewerLogins"].([]string) botLogins, hasBotLogins := params["botReviewerLogins"].([]string) teamSlugs, hasTeamSlugs := params["teamReviewerSlugs"].([]string) From 8f62e8116df4243019a41681ec54c66dda2e8f2e Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:56:47 -0700 Subject: [PATCH 64/92] Label Copilot detection in SuggestedReviewerActorsForRepo as a hack Mark the piggyback-on-open-PR technique for detecting Copilot reviewer availability as a HACK, since there is no repo-level API to check Copilot eligibility without a PR context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_pr_review.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index e4deb7124be..b25b199d1a0 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -557,7 +557,9 @@ func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query string) ([]ReviewerCandidate, int, error) { type responseData struct { Repository struct { - // Check for Copilot availability by looking at any open PR's suggested reviewers + // HACK: There's no repo-level API to check Copilot reviewer eligibility, + // so we piggyback on an open PR's suggestedReviewerActors to detect + // whether Copilot is available as a reviewer for this repository. PullRequests struct { Nodes []struct { SuggestedActors struct { From dd7e44ee0ac308c1fb967d8a971f809f7d94985c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:57:28 -0700 Subject: [PATCH 65/92] Check state.ActorReviewers in MetadataSurvey reviewer search gate Gate search-based reviewer selection on both state.ActorReviewers and the search function being available, consistent with the ActorAssignees pattern used for assignees. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/pr/shared/survey.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 6c88a74af45..0f7ed1a43ea 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -183,7 +183,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface // Retrieve and process data for survey prompts based on the extra fields selected. // When search-based reviewer selection is available, skip the expensive assignable-users // and teams fetch since reviewers are found dynamically via the search function. - useReviewerSearch := reviewerSearchFunc != nil + useReviewerSearch := state.ActorReviewers && reviewerSearchFunc != nil metadataInput := api.RepoMetadataInput{ Reviewers: isChosen("Reviewers") && !useReviewerSearch, TeamReviewers: isChosen("Reviewers") && !useReviewerSearch, From 37776cf2e661666f2fdbf6c905362d2513826d74 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:57:45 -0700 Subject: [PATCH 66/92] Add TODO requestReviewsByLoginCleanup on static reviewer MultiSelect Mark the legacy static MultiSelect reviewer path for cleanup once GHES supports requestReviewsByLogin and search-based selection can be used universally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/pr/shared/survey.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 0f7ed1a43ea..cc66bbe5c4d 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -271,6 +271,9 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface } values.Reviewers = selectedReviewers } else if len(reviewers) > 0 { + // TODO requestReviewsByLoginCleanup + // The static MultiSelect path can be removed once GHES supports + // requestReviewsByLogin and search-based selection is always used. selected, err := p.MultiSelect("Reviewers", state.Reviewers, reviewers) if err != nil { return err From 07138b6edf216c1a2cc45a2b3c7cd69d7f4ed09d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:58:08 -0700 Subject: [PATCH 67/92] Remove /slug team reviewer shorthand normalization Team reviewers must be provided as fully qualified org/teamname. Remove the /slug shorthand that auto-prefixed the repo owner, as this format is not supported. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/pr/shared/params.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 06f599f0d6f..90e1e6f897d 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -122,10 +122,6 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par var teamReviewers []string for _, r := range tb.Reviewers { if strings.ContainsRune(r, '/') { - // Normalize /slug shorthand to org/slug using the repo owner - if strings.HasPrefix(r, "/") { - r = baseRepo.RepoOwner() + r - } teamReviewers = append(teamReviewers, r) } else if r == api.CopilotReviewerLogin { botReviewers = append(botReviewers, r) From 49f1bd88004d02f73e8a7d468673decca6904be4 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:58:23 -0700 Subject: [PATCH 68/92] Add TODO requestReviewsByLoginCleanup on GHES ID-based reviewer path Mark the GHES ID-resolution branch in AddMetadataToIssueParams for cleanup once GHES supports requestReviewsByLogin. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/pr/shared/params.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 90e1e6f897d..d5c168e5f7f 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -130,8 +130,10 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par } } + // TODO requestReviewsByLoginCleanup // When ActorReviewers is true (github.com), pass logins directly for use with - // RequestReviewsByLogin mutation. Otherwise, resolve to IDs for GHES compatibility. + // RequestReviewsByLogin mutation. The ID-based else branch can be removed once + // GHES supports requestReviewsByLogin. if tb.ActorReviewers { params["userReviewerLogins"] = userReviewers if len(botReviewers) > 0 { From 08c7a4c207f480c6fa5bbbfcdec202f0a43971f5 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:13:53 -0700 Subject: [PATCH 69/92] Replace @copilot with Copilot reviewer login in gh pr create Wire CopilotReviewerReplacer into NewIssueState so that `gh pr create --reviewer @copilot` correctly resolves to the copilot-pull-request-reviewer bot login, matching the behavior already implemented in gh pr edit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/pr/create/create.go | 5 ++++- pkg/cmd/pr/create/create_test.go | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 631e6943073..1f2ce190aac 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -680,9 +680,12 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata return nil, err } + copilotReplacer := shared.NewCopilotReviewerReplacer() + reviewers := copilotReplacer.ReplaceSlice(opts.Reviewers) + state := &shared.IssueMetadataState{ Type: shared.PRMetadata, - Reviewers: opts.Reviewers, + Reviewers: reviewers, Assignees: assignees, Labels: opts.Labels, ProjectTitles: opts.Projects, diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 428aca650ed..d7c7fff8798 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1523,6 +1523,42 @@ func Test_createRun(t *testing.T) { expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "", }, + { + name: "@copilot reviewer resolves to bot login", + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "my title" + opts.Body = "my body" + opts.Reviewers = []string{"hubot", "@copilot"} + opts.HeadBranch = "feature" + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12", + "id": "NEWPULLID" + } } } }`, + func(input map[string]interface{}) {})) + reg.Register( + httpmock.GraphQL(`mutation RequestReviewsByLogin\b`), + httpmock.GraphQLMutation(` + { "data": { "requestReviewsByLogin": { + "clientMutationId": "" + } } } + `, func(inputs map[string]interface{}) { + assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) + assert.Equal(t, []interface{}{"hubot"}, inputs["userLogins"]) + assert.Equal(t, []interface{}{"copilot-pull-request-reviewer[bot]"}, inputs["botLogins"]) + assert.Equal(t, true, inputs["union"]) + })) + }, + expectedOut: "https://github.com/OWNER/REPO/pull/12\n", + expectedErrOut: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 90bfa624c2c4b66053188f6d9382d7f413be258e Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:36:48 -0700 Subject: [PATCH 70/92] Exclude current user from suggested reviewers in gh pr create Query the viewer login in SuggestedReviewerActorsForRepo and pre-seed the seen map so the current user is filtered out of collaborator results. You cannot review your own PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_pr_review.go | 10 +++++++++- api/queries_pr_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index b25b199d1a0..f6ef2a74e39 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -556,6 +556,9 @@ func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, // Returns the candidates, a MoreResults count, and an error. func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query string) ([]ReviewerCandidate, int, error) { type responseData struct { + Viewer struct { + Login string + } Repository struct { // HACK: There's no repo-level API to check Copilot reviewer eligibility, // so we piggyback on an open PR's suggestedReviewerActors to detect @@ -610,8 +613,13 @@ func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query return nil, 0, err } - // Build candidates using cascading quota logic + // Build candidates using cascading quota logic. + // Pre-seed seen with the current user to exclude them from results + // since you cannot review your own PR. seen := make(map[string]bool) + if result.Viewer.Login != "" { + seen[result.Viewer.Login] = true + } var candidates []ReviewerCandidate const baseQuota = 5 diff --git a/api/queries_pr_test.go b/api/queries_pr_test.go index 4ee312b5d8b..7b7727f3e7e 100644 --- a/api/queries_pr_test.go +++ b/api/queries_pr_test.go @@ -390,6 +390,7 @@ func mockReviewerResponseForRepoWithCopilot(collabs, teams, totalCollabs, totalT return fmt.Sprintf(`{ "data": { + "viewer": {"login": "testuser"}, "repository": { %s, "collaborators": {"nodes": [%s]}, @@ -500,6 +501,36 @@ func TestSuggestedReviewerActorsForRepo(t *testing.T) { expectedLogins: []string{"c1", "c2", "c3", "OWNER/team1", "OWNER/team2"}, expectedMore: 10, }, + { + name: "viewer excluded from collaborators", + httpStubs: func(reg *httpmock.Registry) { + // c1 matches the viewer login "testuser" won't be in this fixture, + // but we can craft a response where the viewer login matches a collaborator. + reg.Register( + httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), + httpmock.StringResponse(`{ + "data": { + "viewer": {"login": "c2"}, + "repository": { + "pullRequests": {"nodes": []}, + "collaborators": {"nodes": [ + {"login": "c1", "name": "C1"}, + {"login": "c2", "name": "C2"}, + {"login": "c3", "name": "C3"} + ]}, + "collaboratorsTotalCount": {"totalCount": 3} + }, + "organization": { + "teams": {"nodes": []}, + "teamsTotalCount": {"totalCount": 0} + } + } + }`)) + }, + expectedCount: 2, + expectedLogins: []string{"c1", "c3"}, + expectedMore: 3, + }, } for _, tt := range tests { From 1bba50b3e01212bc7663ceceeb50c455e9fc94b4 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:44:25 -0700 Subject: [PATCH 71/92] Fix duplicate reviewers in gh pr edit by passing logins as defaults Use DefaultLogins instead of Default display names when calling MultiSelectWithSearch for reviewers. The dedup logic in the prompter compares keys (logins) against defaults, so passing display names like 'mxie (Melissa Xie)' prevented deduplication against search result keys like 'mxie'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/pr/shared/editable.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 31b253d9b6d..1b7c42be3b6 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -317,7 +317,7 @@ func EditFieldsSurvey(p EditPrompter, editable *Editable, editorCommand string) editable.Reviewers.Value, err = p.MultiSelectWithSearch( "Reviewers", "Search reviewers", - editable.Reviewers.Default, + editable.Reviewers.DefaultLogins, // No persistent options - teams are included in search results []string{}, editable.ReviewerSearchFunc) From 24fb7657cd9fd0460ac57ed33a767227073bac95 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:47:24 -0700 Subject: [PATCH 72/92] Exclude PR author from reviewer candidates in SuggestedReviewerActors Add author { login } to the SuggestedReviewerActors GraphQL query and pre-seed the seen map with the author login so they are excluded from all sources (suggestions, collaborators, teams). Previously the author was only skipped via the isAuthor flag in the suggestions loop but could still appear as a collaborator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_pr_review.go | 7 +++++++ api/queries_pr_test.go | 38 +++++++++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index f6ef2a74e39..3d0132cbddb 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -413,6 +413,9 @@ func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, type responseData struct { Node struct { PullRequest struct { + Author struct { + Login string + } SuggestedActors struct { Nodes []struct { IsAuthor bool @@ -472,7 +475,11 @@ func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, // Build candidates using cascading quota logic: // Each source has a base quota of 5, plus any unfilled quota from previous sources. // This ensures we show up to 15 total candidates, filling gaps when earlier sources have fewer. + // Pre-seed seen with the PR author since you cannot review your own PR. seen := make(map[string]bool) + if authorLogin := result.Node.PullRequest.Author.Login; authorLogin != "" { + seen[authorLogin] = true + } var candidates []ReviewerCandidate const baseQuota = 5 diff --git a/api/queries_pr_test.go b/api/queries_pr_test.go index 7b7727f3e7e..8fff120bbac 100644 --- a/api/queries_pr_test.go +++ b/api/queries_pr_test.go @@ -160,7 +160,7 @@ func mockReviewerResponse(suggestions, collabs, teams, totalCollabs, totalTeams return fmt.Sprintf(`{ "data": { - "node": {"suggestedReviewerActors": {"nodes": [%s]}}, + "node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [%s]}}, "repository": { "collaborators": {"nodes": [%s]}, "collaboratorsTotalCount": {"totalCount": %d} @@ -235,7 +235,7 @@ func TestSuggestedReviewerActors(t *testing.T) { httpmock.GraphQL(`query SuggestedReviewerActors\b`), httpmock.StringResponse(`{ "data": { - "node": {"suggestedReviewerActors": {"nodes": [ + "node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [ {"isAuthor": true, "reviewer": {"__typename": "User", "login": "author", "name": "Author"}}, {"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}}, {"isAuthor": false, "reviewer": {"__typename": "User", "login": "s2", "name": "S2"}} @@ -255,6 +255,34 @@ func TestSuggestedReviewerActors(t *testing.T) { expectedLogins: []string{"s1", "s2", "c1", "OWNER/team1"}, expectedMore: 8, }, + { + name: "author excluded from collaborators", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query SuggestedReviewerActors\b`), + httpmock.StringResponse(`{ + "data": { + "node": {"author": {"login": "theauthor"}, "suggestedReviewerActors": {"nodes": [ + {"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}} + ]}}, + "repository": { + "collaborators": {"nodes": [ + {"login": "theauthor", "name": "The Author"}, + {"login": "c1", "name": "C1"} + ]}, + "collaboratorsTotalCount": {"totalCount": 5} + }, + "organization": { + "teams": {"nodes": []}, + "teamsTotalCount": {"totalCount": 0} + } + } + }`)) + }, + expectedCount: 2, + expectedLogins: []string{"s1", "c1"}, + expectedMore: 5, + }, { name: "deduplication across sources", httpStubs: func(reg *httpmock.Registry) { @@ -263,7 +291,7 @@ func TestSuggestedReviewerActors(t *testing.T) { httpmock.GraphQL(`query SuggestedReviewerActors\b`), httpmock.StringResponse(`{ "data": { - "node": {"suggestedReviewerActors": {"nodes": [ + "node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [ {"isAuthor": false, "reviewer": {"__typename": "User", "login": "shareduser", "name": "Shared"}} ]}}, "repository": { @@ -291,7 +319,7 @@ func TestSuggestedReviewerActors(t *testing.T) { httpmock.GraphQL(`query SuggestedReviewerActors\b`), httpmock.StringResponse(`{ "data": { - "node": {"suggestedReviewerActors": {"nodes": [ + "node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [ {"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}} ]}}, "repository": { @@ -314,7 +342,7 @@ func TestSuggestedReviewerActors(t *testing.T) { httpmock.GraphQL(`query SuggestedReviewerActors\b`), httpmock.StringResponse(`{ "data": { - "node": {"suggestedReviewerActors": {"nodes": [ + "node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [ {"isAuthor": false, "reviewer": {"__typename": "Bot", "login": "copilot-pull-request-reviewer"}}, {"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}} ]}}, From c39a51e73b51eb6d619504eff091e8ebae653e58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:03:21 +0000 Subject: [PATCH 73/92] chore(deps): bump google.golang.org/grpc from 1.79.1 to 1.79.2 Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.79.1 to 1.79.2. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.79.1...v1.79.2) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.79.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a9c86a672d0..723c8037d72 100644 --- a/go.mod +++ b/go.mod @@ -54,7 +54,7 @@ require ( golang.org/x/sync v0.19.0 golang.org/x/term v0.40.0 golang.org/x/text v0.34.0 - google.golang.org/grpc v1.79.1 + google.golang.org/grpc v1.79.2 google.golang.org/protobuf v1.36.11 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index e4c39bd3a92..e1ff3129b49 100644 --- a/go.sum +++ b/go.sum @@ -640,8 +640,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1: google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 7382b86c1a5e983bcc1792f2145c659cadc738a0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:47:47 -0700 Subject: [PATCH 74/92] Fetch org teams via repository.owner inline fragment Replace the top-level organization(login: $owner) query with repository.owner { ... on Organization { teams } }. This uses GraphQL inline fragments to conditionally fetch team data only when the repo owner is an Organization, eliminating the need to handle 'Could not resolve to an Organization' errors for personal repos. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_pr_review.go | 66 +++++++++++++++++++++------------------- api/queries_pr_test.go | 58 +++++++++++------------------------ 2 files changed, 52 insertions(+), 72 deletions(-) diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index 3d0132cbddb..a6fa34f9e88 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -407,8 +407,8 @@ func RequestReviewsByLogin(client *Client, repo ghrepo.Interface, prID string, u // Returns the candidates, a MoreResults count, and an error. func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, query string) ([]ReviewerCandidate, int, error) { // Fetch 10 from each source to allow cascading quota to fill from available results. - // Use a single query that includes organization.teams - if the owner is not an org, - // we'll get a "Could not resolve to an Organization" error which we handle gracefully. + // Organization teams are fetched via repository.owner inline fragment, which + // gracefully returns empty data for personal (User-owned) repos. // We also fetch unfiltered total counts via aliases for the "X more" display. type responseData struct { Node struct { @@ -435,6 +435,19 @@ func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, } `graphql:"... on PullRequest"` } `graphql:"node(id: $id)"` Repository struct { + Owner struct { + TypeName string `graphql:"__typename"` + Organization struct { + Teams struct { + Nodes []struct { + Slug string + } + } `graphql:"teams(first: 10, query: $query)"` + TeamsTotalCount struct { + TotalCount int + } `graphql:"teamsTotalCount: teams(first: 0)"` + } `graphql:"... on Organization"` + } Collaborators struct { Nodes []struct { Login string @@ -445,16 +458,6 @@ func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, TotalCount int } `graphql:"collaboratorsTotalCount: collaborators(first: 0)"` } `graphql:"repository(owner: $owner, name: $name)"` - Organization struct { - Teams struct { - Nodes []struct { - Slug string - } - } `graphql:"teams(first: 10, query: $query)"` - TeamsTotalCount struct { - TotalCount int - } `graphql:"teamsTotalCount: teams(first: 0)"` - } `graphql:"organization(login: $owner)"` } variables := map[string]interface{}{ @@ -466,9 +469,7 @@ func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, var result responseData err := client.Query(repo.RepoHost(), "SuggestedReviewerActors", &result, variables) - // Handle the case where the owner is not an organization - the query still returns - // partial data (repository, node), so we can continue processing. - if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) { + if err != nil { return nil, 0, err } @@ -531,7 +532,7 @@ func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, teamsQuota := baseQuota + (collaboratorsQuota - collaboratorsAdded) teamsAdded := 0 ownerName := repo.RepoOwner() - for _, t := range result.Organization.Teams.Nodes { + for _, t := range result.Repository.Owner.Organization.Teams.Nodes { if teamsAdded >= teamsQuota { break } @@ -547,7 +548,7 @@ func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, } // MoreResults uses unfiltered total counts (teams will be 0 for personal repos) - moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Organization.TeamsTotalCount.TotalCount + moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Repository.Owner.Organization.TeamsTotalCount.TotalCount return candidates, moreResults, nil } @@ -584,6 +585,19 @@ func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query } `graphql:"suggestedReviewerActors(first: 10)"` } } `graphql:"pullRequests(first: 1, states: [OPEN])"` + Owner struct { + TypeName string `graphql:"__typename"` + Organization struct { + Teams struct { + Nodes []struct { + Slug string + } + } `graphql:"teams(first: 10, query: $query)"` + TeamsTotalCount struct { + TotalCount int + } `graphql:"teamsTotalCount: teams(first: 0)"` + } `graphql:"... on Organization"` + } Collaborators struct { Nodes []struct { Login string @@ -594,16 +608,6 @@ func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query TotalCount int } `graphql:"collaboratorsTotalCount: collaborators(first: 0)"` } `graphql:"repository(owner: $owner, name: $name)"` - Organization struct { - Teams struct { - Nodes []struct { - Slug string - } - } `graphql:"teams(first: 10, query: $query)"` - TeamsTotalCount struct { - TotalCount int - } `graphql:"teamsTotalCount: teams(first: 0)"` - } `graphql:"organization(login: $owner)"` } variables := map[string]interface{}{ @@ -614,9 +618,7 @@ func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query var result responseData err := client.Query(repo.RepoHost(), "SuggestedReviewerActorsForRepo", &result, variables) - // Handle the case where the owner is not an organization - the query still returns - // partial data (repository), so we can continue processing. - if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) { + if err != nil { return nil, 0, err } @@ -661,7 +663,7 @@ func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query teamsQuota := baseQuota + (baseQuota - collaboratorsAdded) teamsAdded := 0 ownerName := repo.RepoOwner() - for _, t := range result.Organization.Teams.Nodes { + for _, t := range result.Repository.Owner.Organization.Teams.Nodes { if teamsAdded >= teamsQuota { break } @@ -677,7 +679,7 @@ func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query } // MoreResults uses unfiltered total counts (teams will be 0 for personal repos) - moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Organization.TeamsTotalCount.TotalCount + moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Repository.Owner.Organization.TeamsTotalCount.TotalCount return candidates, moreResults, nil } diff --git a/api/queries_pr_test.go b/api/queries_pr_test.go index 8fff120bbac..633b9a8c35f 100644 --- a/api/queries_pr_test.go +++ b/api/queries_pr_test.go @@ -162,16 +162,14 @@ func mockReviewerResponse(suggestions, collabs, teams, totalCollabs, totalTeams "data": { "node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [%s]}}, "repository": { + "owner": {"__typename": "Organization", "teams": {"nodes": [%s]}, "teamsTotalCount": {"totalCount": %d}}, "collaborators": {"nodes": [%s]}, "collaboratorsTotalCount": {"totalCount": %d} - }, - "organization": { - "teams": {"nodes": [%s]}, - "teamsTotalCount": {"totalCount": %d} } } - }`, strings.Join(suggestionNodes, ","), strings.Join(collabNodes, ","), totalCollabs, - strings.Join(teamNodes, ","), totalTeams) + }`, strings.Join(suggestionNodes, ","), + strings.Join(teamNodes, ","), totalTeams, + strings.Join(collabNodes, ","), totalCollabs) } func TestSuggestedReviewerActors(t *testing.T) { @@ -241,12 +239,9 @@ func TestSuggestedReviewerActors(t *testing.T) { {"isAuthor": false, "reviewer": {"__typename": "User", "login": "s2", "name": "S2"}} ]}}, "repository": { + "owner": {"__typename": "Organization", "teams": {"nodes": [{"slug": "team1"}]}, "teamsTotalCount": {"totalCount": 3}}, "collaborators": {"nodes": [{"login": "c1", "name": "C1"}]}, "collaboratorsTotalCount": {"totalCount": 5} - }, - "organization": { - "teams": {"nodes": [{"slug": "team1"}]}, - "teamsTotalCount": {"totalCount": 3} } } }`)) @@ -271,10 +266,6 @@ func TestSuggestedReviewerActors(t *testing.T) { {"login": "c1", "name": "C1"} ]}, "collaboratorsTotalCount": {"totalCount": 5} - }, - "organization": { - "teams": {"nodes": []}, - "teamsTotalCount": {"totalCount": 0} } } }`)) @@ -295,15 +286,12 @@ func TestSuggestedReviewerActors(t *testing.T) { {"isAuthor": false, "reviewer": {"__typename": "User", "login": "shareduser", "name": "Shared"}} ]}}, "repository": { + "owner": {"__typename": "Organization", "teams": {"nodes": [{"slug": "team1"}]}, "teamsTotalCount": {"totalCount": 5}}, "collaborators": {"nodes": [ {"login": "shareduser", "name": "Shared"}, {"login": "c1", "name": "C1"} ]}, "collaboratorsTotalCount": {"totalCount": 10} - }, - "organization": { - "teams": {"nodes": [{"slug": "team1"}]}, - "teamsTotalCount": {"totalCount": 5} } } }`)) @@ -323,12 +311,11 @@ func TestSuggestedReviewerActors(t *testing.T) { {"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}} ]}}, "repository": { + "owner": {"__typename": "User"}, "collaborators": {"nodes": [{"login": "c1", "name": "C1"}]}, "collaboratorsTotalCount": {"totalCount": 3} - }, - "organization": null - }, - "errors": [{"message": "Could not resolve to an Organization with the login of 'OWNER'."}] + } + } }`)) }, expectedCount: 2, @@ -347,12 +334,9 @@ func TestSuggestedReviewerActors(t *testing.T) { {"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}} ]}}, "repository": { + "owner": {"__typename": "Organization", "teams": {"nodes": []}, "teamsTotalCount": {"totalCount": 0}}, "collaborators": {"nodes": []}, "collaboratorsTotalCount": {"totalCount": 5} - }, - "organization": { - "teams": {"nodes": []}, - "teamsTotalCount": {"totalCount": 0} } } }`)) @@ -421,16 +405,14 @@ func mockReviewerResponseForRepoWithCopilot(collabs, teams, totalCollabs, totalT "viewer": {"login": "testuser"}, "repository": { %s, + "owner": {"__typename": "Organization", "teams": {"nodes": [%s]}, "teamsTotalCount": {"totalCount": %d}}, "collaborators": {"nodes": [%s]}, "collaboratorsTotalCount": {"totalCount": %d} - }, - "organization": { - "teams": {"nodes": [%s]}, - "teamsTotalCount": {"totalCount": %d} } } - }`, pullRequestsJSON, strings.Join(collabNodes, ","), totalCollabs, - strings.Join(teamNodes, ","), totalTeams) + }`, pullRequestsJSON, + strings.Join(teamNodes, ","), totalTeams, + strings.Join(collabNodes, ","), totalCollabs) } func TestSuggestedReviewerActorsForRepo(t *testing.T) { @@ -484,12 +466,11 @@ func TestSuggestedReviewerActorsForRepo(t *testing.T) { "data": { "repository": { "pullRequests": {"nodes": []}, + "owner": {"__typename": "User"}, "collaborators": {"nodes": [{"login": "c1", "name": "C1"}]}, "collaboratorsTotalCount": {"totalCount": 3} - }, - "organization": null - }, - "errors": [{"message": "Could not resolve to an Organization with the login of 'OWNER'."}] + } + } }`)) }, expectedCount: 1, @@ -541,16 +522,13 @@ func TestSuggestedReviewerActorsForRepo(t *testing.T) { "viewer": {"login": "c2"}, "repository": { "pullRequests": {"nodes": []}, + "owner": {"__typename": "Organization", "teams": {"nodes": []}, "teamsTotalCount": {"totalCount": 0}}, "collaborators": {"nodes": [ {"login": "c1", "name": "C1"}, {"login": "c2", "name": "C2"}, {"login": "c3", "name": "C3"} ]}, "collaboratorsTotalCount": {"totalCount": 3} - }, - "organization": { - "teams": {"nodes": []}, - "teamsTotalCount": {"totalCount": 0} } } }`)) From 18536dc2b1aafe154974b73f549a0c9baf8f27a9 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Tue, 10 Feb 2026 15:44:34 +0530 Subject: [PATCH 75/92] feat(pr diff): add --exclude flag to filter files from diff output Add a new --exclude (-e) flag to gh pr diff that allows users to exclude files matching glob patterns from the diff output. This is useful for filtering out auto-generated files, vendor directories, or other noise when reviewing pull requests. Supports standard glob patterns and can be specified multiple times. Patterns match against both the full path and basename. Closes #8739 --- pkg/cmd/pr/diff/diff.go | 73 +++++++++++++++ pkg/cmd/pr/diff/diff_test.go | 173 +++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index acf17462c5e..14b1b95cc55 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -2,10 +2,12 @@ package diff import ( "bufio" + "bytes" "errors" "fmt" "io" "net/http" + "path/filepath" "regexp" "strings" "unicode" @@ -36,6 +38,7 @@ type DiffOptions struct { Patch bool NameOnly bool BrowserMode bool + Exclude []string } func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Command { @@ -92,6 +95,7 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman cmd.Flags().BoolVar(&opts.Patch, "patch", false, "Display diff in patch format") cmd.Flags().BoolVar(&opts.NameOnly, "name-only", false, "Display only names of changed files") cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open the pull request diff in the browser") + cmd.Flags().StringSliceVarP(&opts.Exclude, "exclude", "e", nil, "Exclude files matching glob `patterns` from the diff") return cmd } @@ -135,6 +139,13 @@ func diffRun(opts *DiffOptions) error { defer diffReadCloser.Close() var diff io.Reader = diffReadCloser + if len(opts.Exclude) > 0 { + filtered, err := filterDiff(diff, opts.Exclude) + if err != nil { + return err + } + diff = filtered + } if opts.IO.IsStdoutTTY() { diff = sanitizedReader(diff) } @@ -357,3 +368,65 @@ func (t sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err e func isPrint(r rune) bool { return r == '\n' || r == '\r' || r == '\t' || unicode.IsPrint(r) } + +var diffHeaderRegexp = regexp.MustCompile(`diff\s--git.*\s(["]?)b/(.*)`) + +// filterDiff reads a unified diff and returns a new reader with file entries +// matching any of the exclude patterns removed. +func filterDiff(r io.Reader, patterns []string) (io.Reader, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, err + } + + var result bytes.Buffer + for _, section := range splitDiffSections(string(data)) { + name := extractFileName([]byte(section)) + if name != "" && matchesAny(name, patterns) { + continue + } + result.WriteString(section) + } + return &result, nil +} + +// splitDiffSections splits a unified diff string into per-file sections. +// Each section starts with "diff --git" and includes all content up to (but +// not including) the next "diff --git" line. +func splitDiffSections(diff string) []string { + marker := "\ndiff --git " + var sections []string + for { + idx := strings.Index(diff, marker) + if idx == -1 { + if len(diff) > 0 { + sections = append(sections, diff) + } + break + } + sections = append(sections, diff[:idx+1]) // include the trailing \n + diff = diff[idx+1:] // next section starts at "diff --git" + } + return sections +} + +func extractFileName(section []byte) string { + m := diffHeaderRegexp.FindSubmatch(section) + if m == nil { + return "" + } + return strings.TrimSpace(string(m[1]) + string(m[2])) +} + +func matchesAny(name string, patterns []string) bool { + for _, p := range patterns { + if matched, _ := filepath.Match(p, name); matched { + return true + } + // Also match against the basename so "*.yml" matches "dir/file.yml" + if matched, _ := filepath.Match(p, filepath.Base(name)); matched { + return true + } + } + return false +} diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go index 28a83bfc42a..856c858dc75 100644 --- a/pkg/cmd/pr/diff/diff_test.go +++ b/pkg/cmd/pr/diff/diff_test.go @@ -87,6 +87,26 @@ func Test_NewCmdDiff(t *testing.T) { isTTY: true, wantErr: "argument required when using the `--repo` flag", }, + { + name: "exclude single pattern", + args: "--exclude '*.yml'", + isTTY: true, + want: DiffOptions{ + SelectorArg: "", + UseColor: true, + Exclude: []string{"*.yml"}, + }, + }, + { + name: "exclude multiple patterns", + args: "--exclude '*.yml' --exclude Makefile", + isTTY: true, + want: DiffOptions{ + SelectorArg: "", + UseColor: true, + Exclude: []string{"*.yml", "Makefile"}, + }, + }, { name: "invalid --color argument", args: "--color doublerainbow", @@ -142,6 +162,7 @@ func Test_NewCmdDiff(t *testing.T) { assert.Equal(t, tt.want.SelectorArg, opts.SelectorArg) assert.Equal(t, tt.want.UseColor, opts.UseColor) assert.Equal(t, tt.want.BrowserMode, opts.BrowserMode) + assert.Equal(t, tt.want.Exclude, opts.Exclude) }) } } @@ -211,6 +232,48 @@ func Test_diffRun(t *testing.T) { stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", "")) }, }, + { + name: "exclude yml files", + opts: DiffOptions{ + SelectorArg: "123", + UseColor: false, + Exclude: []string{"*.yml"}, + }, + wantFields: []string{"number"}, + wantStdout: `diff --git a/Makefile b/Makefile +index f2b4805c..3d7bd0f9 100644 +--- a/Makefile ++++ b/Makefile +@@ -22,8 +22,8 @@ test: + go test ./... + .PHONY: test + +-site: +- git clone https://github.com/github/cli.github.com.git "$@" ++site: bin/gh ++ bin/gh repo clone github/cli.github.com "$@" + + site-docs: site + git -C site pull +`, + httpStubs: func(reg *httpmock.Registry) { + stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", "")) + }, + }, + { + name: "name only with exclude", + opts: DiffOptions{ + SelectorArg: "123", + UseColor: false, + NameOnly: true, + Exclude: []string{"*.yml"}, + }, + wantFields: []string{"number"}, + wantStdout: "Makefile\n", + httpStubs: func(reg *httpmock.Registry) { + stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", "")) + }, + }, { name: "web mode", opts: DiffOptions{ @@ -394,6 +457,116 @@ func stubDiffRequest(reg *httpmock.Registry, accept, diff string) { }) } +func Test_filterDiff(t *testing.T) { + rawDiff := fmt.Sprintf(testDiff, "", "", "", "") + + tests := []struct { + name string + patterns []string + want string + }{ + { + name: "exclude yml files", + patterns: []string{"*.yml"}, + want: `diff --git a/Makefile b/Makefile +index f2b4805c..3d7bd0f9 100644 +--- a/Makefile ++++ b/Makefile +@@ -22,8 +22,8 @@ test: + go test ./... + .PHONY: test + +-site: +- git clone https://github.com/github/cli.github.com.git "$@" ++site: bin/gh ++ bin/gh repo clone github/cli.github.com "$@" + + site-docs: site + git -C site pull +`, + }, + { + name: "exclude Makefile", + patterns: []string{"Makefile"}, + want: `diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml +index 73974448..b7fc0154 100644 +--- a/.github/workflows/releases.yml ++++ b/.github/workflows/releases.yml +@@ -44,6 +44,11 @@ jobs: + token: ${{secrets.SITE_GITHUB_TOKEN}} + - name: Publish documentation site + if: "!contains(github.ref, '-')" # skip prereleases ++ env: ++ GIT_COMMITTER_NAME: cli automation ++ GIT_AUTHOR_NAME: cli automation ++ GIT_COMMITTER_EMAIL: noreply@github.com ++ GIT_AUTHOR_EMAIL: noreply@github.com + run: make site-publish + - name: Move project cards + if: "!contains(github.ref, '-')" # skip prereleases +`, + }, + { + name: "exclude all files", + patterns: []string{"*.yml", "Makefile"}, + want: "", + }, + { + name: "no matches", + patterns: []string{"*.go"}, + want: rawDiff, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader, err := filterDiff(strings.NewReader(rawDiff), tt.patterns) + require.NoError(t, err) + got, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, tt.want, string(got)) + }) + } +} + +func Test_matchesAny(t *testing.T) { + tests := []struct { + name string + filename string + patterns []string + want bool + }{ + { + name: "exact match", + filename: "Makefile", + patterns: []string{"Makefile"}, + want: true, + }, + { + name: "glob extension", + filename: ".github/workflows/releases.yml", + patterns: []string{"*.yml"}, + want: true, + }, + { + name: "no match", + filename: "main.go", + patterns: []string{"*.yml"}, + want: false, + }, + { + name: "directory glob", + filename: ".github/workflows/releases.yml", + patterns: []string{".github/*/*"}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, matchesAny(tt.filename, tt.patterns)) + }) + } +} + func Test_sanitizedReader(t *testing.T) { input := strings.NewReader("\t hello \x1B[m world! ฤƒัฃ๐” ีฎแป\r\n") expected := "\t hello \\u{1b}[m world! ฤƒัฃ๐” ีฎแป\r\n" From 8bd48afa87a6a62af32d798e06ff1e19ee8b58e4 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Thu, 5 Mar 2026 03:17:10 +0530 Subject: [PATCH 76/92] fix: share diffHeaderRegexp between changedFilesNames and extractFileName, fix gofmt --- pkg/cmd/pr/diff/diff.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index 14b1b95cc55..3dea0c4bc92 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -303,8 +303,7 @@ func changedFilesNames(w io.Writer, r io.Reader) error { // `"`` + hello-\360\237\230\200-world" // // Where I'm using the `` to indicate a string to avoid confusion with the " character. - pattern := regexp.MustCompile(`(?:^|\n)diff\s--git.*\s(["]?)b/(.*)`) - matches := pattern.FindAllStringSubmatch(string(diff), -1) + matches := diffHeaderRegexp.FindAllStringSubmatch(string(diff), -1) for _, val := range matches { name := strings.TrimSpace(val[1] + val[2]) @@ -369,7 +368,7 @@ func isPrint(r rune) bool { return r == '\n' || r == '\r' || r == '\t' || unicode.IsPrint(r) } -var diffHeaderRegexp = regexp.MustCompile(`diff\s--git.*\s(["]?)b/(.*)`) +var diffHeaderRegexp = regexp.MustCompile(`(?:^|\n)diff\s--git.*\s(["]?)b/(.*)`) // filterDiff reads a unified diff and returns a new reader with file entries // matching any of the exclude patterns removed. @@ -405,7 +404,7 @@ func splitDiffSections(diff string) []string { break } sections = append(sections, diff[:idx+1]) // include the trailing \n - diff = diff[idx+1:] // next section starts at "diff --git" + diff = diff[idx+1:] // next section starts at "diff --git" } return sections } From d7f1d8e89d10ef92d306a8d9d3c199940ce0e1c6 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Fri, 6 Mar 2026 13:38:18 +0530 Subject: [PATCH 77/92] fix: address review feedback for --exclude flag - add usage examples to --help output - use path.Match instead of filepath.Match for OS-consistent behavior - rename patterns to excludePatterns for clarity - simplify splitDiffSections using strings.Split --- pkg/cmd/pr/diff/diff.go | 53 ++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index 3dea0c4bc92..bd8c3b4a94d 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -7,7 +7,7 @@ import ( "fmt" "io" "net/http" - "path/filepath" + "path" "regexp" "strings" "unicode" @@ -60,7 +60,24 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman is selected. With %[1]s--web%[1]s flag, open the pull request diff in a web browser instead. + + Use %[1]s--exclude%[1]s to filter out files matching a glob pattern. The pattern + uses forward slashes as path separators on all platforms. You can repeat + the flag to exclude multiple patterns. `, "`"), + Example: heredoc.Doc(` + # See diff for current branch + $ gh pr diff + + # See diff for a specific PR + $ gh pr diff 123 + + # Exclude files from diff output + $ gh pr diff --exclude '*.yml' --exclude 'generated/*' + + # Exclude matching files by name + $ gh pr diff --name-only --exclude '*.generated.*' + `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { opts.Finder = shared.NewFinder(f) @@ -368,11 +385,11 @@ func isPrint(r rune) bool { return r == '\n' || r == '\r' || r == '\t' || unicode.IsPrint(r) } -var diffHeaderRegexp = regexp.MustCompile(`(?:^|\n)diff\s--git.*\s(["]?)b/(.*)`) +var diffHeaderRegexp = regexp.MustCompile(`(?:^|\n)diff\s--git.*\s("?)b/(.*)`) // filterDiff reads a unified diff and returns a new reader with file entries // matching any of the exclude patterns removed. -func filterDiff(r io.Reader, patterns []string) (io.Reader, error) { +func filterDiff(r io.Reader, excludePatterns []string) (io.Reader, error) { data, err := io.ReadAll(r) if err != nil { return nil, err @@ -381,7 +398,7 @@ func filterDiff(r io.Reader, patterns []string) (io.Reader, error) { var result bytes.Buffer for _, section := range splitDiffSections(string(data)) { name := extractFileName([]byte(section)) - if name != "" && matchesAny(name, patterns) { + if name != "" && matchesAny(name, excludePatterns) { continue } result.WriteString(section) @@ -394,17 +411,19 @@ func filterDiff(r io.Reader, patterns []string) (io.Reader, error) { // not including) the next "diff --git" line. func splitDiffSections(diff string) []string { marker := "\ndiff --git " - var sections []string - for { - idx := strings.Index(diff, marker) - if idx == -1 { - if len(diff) > 0 { - sections = append(sections, diff) + parts := strings.Split(diff, marker) + if len(parts) == 1 { + return []string{diff} + } + sections := make([]string, 0, len(parts)) + for i, p := range parts { + if i == 0 { + if len(p) > 0 { + sections = append(sections, p+"\n") } - break + } else { + sections = append(sections, "diff --git "+p) } - sections = append(sections, diff[:idx+1]) // include the trailing \n - diff = diff[idx+1:] // next section starts at "diff --git" } return sections } @@ -417,13 +436,13 @@ func extractFileName(section []byte) string { return strings.TrimSpace(string(m[1]) + string(m[2])) } -func matchesAny(name string, patterns []string) bool { - for _, p := range patterns { - if matched, _ := filepath.Match(p, name); matched { +func matchesAny(name string, excludePatterns []string) bool { + for _, p := range excludePatterns { + if matched, _ := path.Match(p, name); matched { return true } // Also match against the basename so "*.yml" matches "dir/file.yml" - if matched, _ := filepath.Match(p, filepath.Base(name)); matched { + if matched, _ := path.Match(p, path.Base(name)); matched { return true } } From a331ef7f8fe99d73f4adfdf1f02ae46aa532eaca Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Fri, 6 Mar 2026 13:41:40 +0530 Subject: [PATCH 78/92] fix: align example indentation with codebase convention --- pkg/cmd/pr/diff/diff.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index bd8c3b4a94d..c292bce6f4a 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -66,18 +66,18 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman the flag to exclude multiple patterns. `, "`"), Example: heredoc.Doc(` - # See diff for current branch - $ gh pr diff + # See diff for current branch + $ gh pr diff - # See diff for a specific PR - $ gh pr diff 123 + # See diff for a specific PR + $ gh pr diff 123 - # Exclude files from diff output - $ gh pr diff --exclude '*.yml' --exclude 'generated/*' + # Exclude files from diff output + $ gh pr diff --exclude '*.yml' --exclude 'generated/*' - # Exclude matching files by name - $ gh pr diff --name-only --exclude '*.generated.*' - `), + # Exclude matching files by name + $ gh pr diff --name-only --exclude '*.generated.*' + `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { opts.Finder = shared.NewFinder(f) From 04bc0d27f4414a5e3d9224e606203b6ce600be3d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:58:57 -0700 Subject: [PATCH 79/92] Show friendly Copilot (AI) name in gh pr view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CopilotDisplayName helper that translates known Copilot bot logins (copilot-pull-request-reviewer, copilot-swe-agent) to the friendly 'Copilot (AI)' display name. Applied to: - PullRequestReview.AuthorLogin() โ€” review comment author - Comment.AuthorLogin() โ€” PR/issue comment author - parseReviewers() in pr view โ€” reviewer list display This ensures gh pr view shows 'Copilot (AI)' instead of the raw 'copilot-pull-request-reviewer' login for both the reviewer status line and any review comments left by Copilot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_comments.go | 2 +- api/queries_pr_review.go | 12 +++++------- api/queries_repo.go | 15 +++++++++++---- api/queries_repo_test.go | 20 ++++++++++++++++++++ pkg/cmd/pr/view/view.go | 4 ++-- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/api/queries_comments.go b/api/queries_comments.go index 8af17fd2ae6..b0450c0681c 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -129,7 +129,7 @@ func (c Comment) Identifier() string { } func (c Comment) AuthorLogin() string { - return c.Author.Login + return copilotDisplayName(c.Author.Login) } func (c Comment) Association() string { diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index a6fa34f9e88..7f23f8b5e40 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -52,7 +52,7 @@ func (prr PullRequestReview) Identifier() string { } func (prr PullRequestReview) AuthorLogin() string { - return prr.Author.Login + return copilotDisplayName(prr.Author.Login) } func (prr PullRequestReview) Association() string { @@ -158,8 +158,9 @@ func (r RequestedReviewer) DisplayName() string { if r.TypeName == teamTypeName { return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug) } - if r.TypeName == botTypeName && r.Login == CopilotReviewerLogin { - return "Copilot (AI)" + displayName := copilotDisplayName(r.Login) + if displayName != r.Login { + return displayName } if r.Name != "" { return fmt.Sprintf("%s (%s)", r.Login, r.Name) @@ -221,10 +222,7 @@ func NewReviewerBot(login string) ReviewerBot { } func (b ReviewerBot) DisplayName() string { - if b.login == CopilotReviewerLogin { - return fmt.Sprintf("%s (AI)", CopilotActorName) - } - return b.Login() + return copilotDisplayName(b.login) } func (r ReviewerBot) sealedReviewerCandidate() {} diff --git a/api/queries_repo.go b/api/queries_repo.go index d8ffa191dfc..31dbf75f18c 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -1087,6 +1087,16 @@ const CopilotAssigneeLogin = "copilot-swe-agent" const CopilotReviewerLogin = "copilot-pull-request-reviewer" const CopilotActorName = "Copilot" +// copilotDisplayName returns "Copilot (AI)" if the login is a known Copilot bot login, +// otherwise returns the login unchanged. Use this to translate raw bot logins into +// user-friendly display names in command output. +func copilotDisplayName(login string) string { + if login == CopilotReviewerLogin || login == CopilotAssigneeLogin { + return fmt.Sprintf("%s (AI)", CopilotActorName) + } + return login +} + type AssignableActor interface { DisplayName() string ID() string @@ -1145,10 +1155,7 @@ func NewAssignableBot(id, login string) AssignableBot { } func (b AssignableBot) DisplayName() string { - if b.login == CopilotAssigneeLogin { - return fmt.Sprintf("%s (AI)", CopilotActorName) - } - return b.Login() + return copilotDisplayName(b.login) } func (b AssignableBot) ID() string { diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index ad0b8e8572a..928a9e885a4 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -563,6 +563,26 @@ func TestDisplayName(t *testing.T) { } } +func TestCopilotDisplayName(t *testing.T) { + tests := []struct { + login string + want string + }{ + {login: "copilot-pull-request-reviewer", want: "Copilot (AI)"}, + {login: "copilot-swe-agent", want: "Copilot (AI)"}, + {login: "octocat", want: "octocat"}, + {login: "", want: ""}, + } + for _, tt := range tests { + t.Run(tt.login, func(t *testing.T) { + got := copilotDisplayName(tt.login) + if got != tt.want { + t.Errorf("copilotDisplayName(%q) = %q, want %q", tt.login, got, tt.want) + } + }) + } +} + func TestRepoExists(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index 564cce9132e..e6ae63a10e9 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -351,7 +351,7 @@ func parseReviewers(pr api.PullRequest) []*reviewerState { for _, review := range pr.Reviews.Nodes { if review.Author.Login != pr.Author.Login { - name := review.Author.Login + name := review.AuthorLogin() if name == "" { name = ghostName } @@ -364,7 +364,7 @@ func parseReviewers(pr api.PullRequest) []*reviewerState { // Overwrite reviewer's state if a review request for the same reviewer exists. for _, reviewRequest := range pr.ReviewRequests.Nodes { - name := reviewRequest.RequestedReviewer.LoginOrSlug() + name := reviewRequest.RequestedReviewer.DisplayName() reviewerStates[name] = &reviewerState{ Name: name, State: requestedReviewState, From 7198d270b4e39f7991a7b7894d9bfb69d2744a50 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:31:38 -0700 Subject: [PATCH 80/92] Add generic actorDisplayName for all actor display names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace copilotDisplayName with actorDisplayName(typeName, login, name) which handles all actor types: known bots get friendly names (e.g. Copilot โ†’ 'Copilot (AI)'), regular bots return login, users with names return 'login (Name)', others return login. All DisplayName() methods on Author, CommentAuthor, GitHubUser, AssignableUser, AssignableBot, RequestedReviewer, and ReviewerBot now delegate to actorDisplayName with their available fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_comments.go | 2 +- api/queries_issue.go | 10 ++++++++++ api/queries_pr_review.go | 18 +++++------------- api/queries_repo.go | 32 +++++++++++++++++++++----------- api/queries_repo_test.go | 28 ++++++++++++++++++---------- pkg/cmd/pr/view/view.go | 6 +++--- 6 files changed, 58 insertions(+), 38 deletions(-) diff --git a/api/queries_comments.go b/api/queries_comments.go index b0450c0681c..bb1e9b2871c 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -129,7 +129,7 @@ func (c Comment) Identifier() string { } func (c Comment) AuthorLogin() string { - return copilotDisplayName(c.Author.Login) + return c.Author.DisplayName() } func (c Comment) Association() string { diff --git a/api/queries_issue.go b/api/queries_issue.go index 1a8e082ad8f..d545ef59f85 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -234,6 +234,11 @@ type Author struct { Login string } +// DisplayName returns a user-friendly name via actorDisplayName. +func (a Author) DisplayName() string { + return actorDisplayName("", a.Login, a.Name) +} + func (author Author) MarshalJSON() ([]byte, error) { if author.ID == "" { return json.Marshal(map[string]interface{}{ @@ -260,6 +265,11 @@ type CommentAuthor struct { // } `graphql:"... on User"` } +// DisplayName returns a user-friendly name via actorDisplayName. +func (a CommentAuthor) DisplayName() string { + return actorDisplayName("", a.Login, "") +} + // IssueCreate creates an issue in a GitHub repository func IssueCreate(client *Client, repo *Repository, params map[string]interface{}) (*Issue, error) { query := ` diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index 7f23f8b5e40..48e7582566e 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -52,7 +52,7 @@ func (prr PullRequestReview) Identifier() string { } func (prr PullRequestReview) AuthorLogin() string { - return copilotDisplayName(prr.Author.Login) + return prr.Author.DisplayName() } func (prr PullRequestReview) Association() string { @@ -151,21 +151,13 @@ func (r RequestedReviewer) LoginOrSlug() string { return r.Login } -// DisplayName returns a user-friendly name for the reviewer. -// For Copilot bot, returns "Copilot (AI)". For teams, returns "org/slug". -// For users, returns "login (Name)" if name is available, otherwise just login. +// DisplayName returns a user-friendly name for the reviewer via actorDisplayName. +// Teams are handled separately as "org/slug". func (r RequestedReviewer) DisplayName() string { if r.TypeName == teamTypeName { return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug) } - displayName := copilotDisplayName(r.Login) - if displayName != r.Login { - return displayName - } - if r.Name != "" { - return fmt.Sprintf("%s (%s)", r.Login, r.Name) - } - return r.Login + return actorDisplayName(r.TypeName, r.Login, r.Name) } func (r ReviewRequests) Logins() []string { @@ -222,7 +214,7 @@ func NewReviewerBot(login string) ReviewerBot { } func (b ReviewerBot) DisplayName() string { - return copilotDisplayName(b.login) + return actorDisplayName("Bot", b.login, "") } func (r ReviewerBot) sealedReviewerCandidate() {} diff --git a/api/queries_repo.go b/api/queries_repo.go index 31dbf75f18c..d4077eea9ca 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -147,6 +147,11 @@ type GitHubUser struct { DatabaseID int64 `json:"databaseId"` } +// DisplayName returns a user-friendly name via actorDisplayName. +func (u GitHubUser) DisplayName() string { + return actorDisplayName("", u.Login, u.Name) +} + // Actor is a superset of User and Bot, among others. // At the time of writing, some of these fields // are not directly supported by the Actor type and @@ -1087,13 +1092,21 @@ const CopilotAssigneeLogin = "copilot-swe-agent" const CopilotReviewerLogin = "copilot-pull-request-reviewer" const CopilotActorName = "Copilot" -// copilotDisplayName returns "Copilot (AI)" if the login is a known Copilot bot login, -// otherwise returns the login unchanged. Use this to translate raw bot logins into -// user-friendly display names in command output. -func copilotDisplayName(login string) string { - if login == CopilotReviewerLogin || login == CopilotAssigneeLogin { +// actorDisplayName returns a user-friendly display name for any actor. +// It handles bots (e.g. Copilot โ†’ "Copilot (AI)"), users with names +// ("login (Name)"), and falls back to just login. Empty typeName is +// treated as a possible bot or user โ€” the login is checked against +// known bot logins first. +func actorDisplayName(typeName, login, name string) string { + if login == CopilotReviewerLogin || login == CopilotAssigneeLogin || login == CopilotActorName { return fmt.Sprintf("%s (AI)", CopilotActorName) } + if typeName == botTypeName { + return login + } + if name != "" { + return fmt.Sprintf("%s (%s)", login, name) + } return login } @@ -1120,12 +1133,9 @@ func NewAssignableUser(id, login, name string) AssignableUser { } } -// DisplayName returns a formatted string that uses Login and Name to be displayed e.g. 'Login (Name)' or 'Login' +// DisplayName returns a user-friendly name via actorDisplayName. func (u AssignableUser) DisplayName() string { - if u.name != "" { - return fmt.Sprintf("%s (%s)", u.login, u.name) - } - return u.login + return actorDisplayName("User", u.login, u.name) } func (u AssignableUser) ID() string { @@ -1155,7 +1165,7 @@ func NewAssignableBot(id, login string) AssignableBot { } func (b AssignableBot) DisplayName() string { - return copilotDisplayName(b.login) + return actorDisplayName("Bot", b.login, "") } func (b AssignableBot) ID() string { diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 928a9e885a4..a45c26f8ca1 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -563,21 +563,29 @@ func TestDisplayName(t *testing.T) { } } -func TestCopilotDisplayName(t *testing.T) { +func TestActorDisplayName(t *testing.T) { tests := []struct { - login string - want string + name string + typeName string + login string + actName string + want string }{ - {login: "copilot-pull-request-reviewer", want: "Copilot (AI)"}, - {login: "copilot-swe-agent", want: "Copilot (AI)"}, - {login: "octocat", want: "octocat"}, - {login: "", want: ""}, + {name: "copilot reviewer", typeName: "Bot", login: "copilot-pull-request-reviewer", want: "Copilot (AI)"}, + {name: "copilot assignee", typeName: "Bot", login: "copilot-swe-agent", want: "Copilot (AI)"}, + {name: "copilot without typename", typeName: "", login: "copilot-pull-request-reviewer", want: "Copilot (AI)"}, + {name: "copilot actor name login", typeName: "", login: "Copilot", want: "Copilot (AI)"}, + {name: "regular bot", typeName: "Bot", login: "dependabot", want: "dependabot"}, + {name: "user with name", typeName: "User", login: "octocat", actName: "Mona Lisa", want: "octocat (Mona Lisa)"}, + {name: "user without name", typeName: "User", login: "octocat", want: "octocat"}, + {name: "unknown type with name", typeName: "", login: "octocat", actName: "Mona Lisa", want: "octocat (Mona Lisa)"}, + {name: "empty login", typeName: "", login: "", want: ""}, } for _, tt := range tests { - t.Run(tt.login, func(t *testing.T) { - got := copilotDisplayName(tt.login) + t.Run(tt.name, func(t *testing.T) { + got := actorDisplayName(tt.typeName, tt.login, tt.actName) if got != tt.want { - t.Errorf("copilotDisplayName(%q) = %q, want %q", tt.login, got, tt.want) + t.Errorf("actorDisplayName(%q, %q, %q) = %q, want %q", tt.typeName, tt.login, tt.actName, got, tt.want) } }) } diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index e6ae63a10e9..6e6859bc035 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -149,7 +149,7 @@ func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { fmt.Fprintf(out, "title:\t%s\n", pr.Title) fmt.Fprintf(out, "state:\t%s\n", prStateWithDraft(pr)) - fmt.Fprintf(out, "author:\t%s\n", pr.Author.Login) + fmt.Fprintf(out, "author:\t%s\n", pr.Author.DisplayName()) fmt.Fprintf(out, "labels:\t%s\n", labels) fmt.Fprintf(out, "assignees:\t%s\n", assignees) fmt.Fprintf(out, "reviewers:\t%s\n", reviewers) @@ -188,7 +188,7 @@ func printHumanPrPreview(opts *ViewOptions, baseRepo ghrepo.Interface, pr *api.P fmt.Fprintf(out, "%s โ€ข %s wants to merge %s into %s from %s โ€ข %s\n", shared.StateTitleWithColor(cs, *pr), - pr.Author.Login, + pr.Author.DisplayName(), text.Pluralize(pr.Commits.TotalCount, "commit"), pr.BaseRefName, pr.HeadRefName, @@ -406,7 +406,7 @@ func prAssigneeList(pr api.PullRequest) string { AssigneeNames := make([]string, 0, len(pr.Assignees.Nodes)) for _, assignee := range pr.Assignees.Nodes { - AssigneeNames = append(AssigneeNames, assignee.Login) + AssigneeNames = append(AssigneeNames, assignee.DisplayName()) } list := strings.Join(AssigneeNames, ", ") From 3651c289ed15a4307df51e03f386ad77afa9a2b8 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:01:06 -0700 Subject: [PATCH 81/92] Show friendly display names in gh issue view Apply DisplayName() to author and assignee display in issue view, consistent with the pr view changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/issue/view/view.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index e41ad6acffe..5add5a71b1e 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -197,7 +197,7 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error { // processing many issues with head and grep. fmt.Fprintf(out, "title:\t%s\n", issue.Title) fmt.Fprintf(out, "state:\t%s\n", issue.State) - fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login) + fmt.Fprintf(out, "author:\t%s\n", issue.Author.DisplayName()) fmt.Fprintf(out, "labels:\t%s\n", labels) fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount) fmt.Fprintf(out, "assignees:\t%s\n", assignees) @@ -222,7 +222,7 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue fmt.Fprintf(out, "%s โ€ข %s opened %s โ€ข %s\n", issueStateTitleWithColor(cs, issue), - issue.Author.Login, + issue.Author.DisplayName(), text.FuzzyAgo(opts.Now(), issue.CreatedAt), text.Pluralize(issue.Comments.TotalCount, "comment"), ) @@ -298,7 +298,7 @@ func issueAssigneeList(issue api.Issue) string { AssigneeNames := make([]string, 0, len(issue.Assignees.Nodes)) for _, assignee := range issue.Assignees.Nodes { - AssigneeNames = append(AssigneeNames, assignee.Login) + AssigneeNames = append(AssigneeNames, assignee.DisplayName()) } list := strings.Join(AssigneeNames, ", ") From e047fa6b0e9435cc8382867fc808a7ae9ccc05c1 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:36:46 -0700 Subject: [PATCH 82/92] Address review comments: use actorDisplayName for Copilot author display - Add actorDisplayName call in CommentAuthor.DisplayName for consistency - Use require.Equal in TestActorDisplayName instead of manual comparisons - Simplify user type name constant usage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_pr_review.go | 1 + api/queries_repo.go | 4 ++-- api/queries_repo_test.go | 5 +---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index 48e7582566e..b0a602bf4c9 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -143,6 +143,7 @@ type RequestedReviewer struct { const teamTypeName = "Team" const botTypeName = "Bot" +const userTypeName = "User" func (r RequestedReviewer) LoginOrSlug() string { if r.TypeName == teamTypeName { diff --git a/api/queries_repo.go b/api/queries_repo.go index d4077eea9ca..d358255d8b9 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -1135,7 +1135,7 @@ func NewAssignableUser(id, login, name string) AssignableUser { // DisplayName returns a user-friendly name via actorDisplayName. func (u AssignableUser) DisplayName() string { - return actorDisplayName("User", u.login, u.name) + return actorDisplayName(userTypeName, u.login, u.name) } func (u AssignableUser) ID() string { @@ -1165,7 +1165,7 @@ func NewAssignableBot(id, login string) AssignableBot { } func (b AssignableBot) DisplayName() string { - return actorDisplayName("Bot", b.login, "") + return actorDisplayName(botTypeName, b.login, "") } func (b AssignableBot) ID() string { diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index a45c26f8ca1..ae00a98b217 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -583,10 +583,7 @@ func TestActorDisplayName(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := actorDisplayName(tt.typeName, tt.login, tt.actName) - if got != tt.want { - t.Errorf("actorDisplayName(%q, %q, %q) = %q, want %q", tt.typeName, tt.login, tt.actName, got, tt.want) - } + require.Equal(t, tt.want, actorDisplayName(tt.typeName, tt.login, tt.actName)) }) } } From 1524ea21ae0ec9f116ba59132f71a987b4315750 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:21:21 -0700 Subject: [PATCH 83/92] Bump Go from 1.25.7 to 1.26.1 to fix stdlib vulnerabilities Fixes 5 Go standard library vulnerabilities found by govulncheck: - GO-2026-4603: html/template URL escaping - GO-2026-4602: os FileInfo root escape - GO-2026-4601: net/url IPv6 parsing - GO-2026-4600: crypto/x509 malformed cert panic - GO-2026-4599: crypto/x509 email constraint enforcement Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index a9c86a672d0..02c6c220251 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/cli/cli/v2 -go 1.25.7 +go 1.26.1 require ( github.com/AlecAivazis/survey/v2 v2.3.7 From b18358b7547d13cd8fdae813e864a66d077af0fb Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:24:41 -0700 Subject: [PATCH 84/92] Bump golangci-lint from v2.6.0 to v2.11.0 for Go 1.26 support golangci-lint v2.6.0 was built with Go 1.25 and cannot lint code targeting Go 1.26.1. Go 1.26 support was added in golangci-lint v2.9.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2298acf8e7e..2f95d5e0ba1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -48,7 +48,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: - version: v2.6.0 + version: v2.11.0 # Verify that license generation succeeds for all release platforms (GOOS/GOARCH). # This catches issues like new dependencies with unrecognized licenses before release time. From 7fa453e4675e4b419b9e6f718e90b18398cd9902 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Sat, 7 Mar 2026 09:58:06 -0700 Subject: [PATCH 85/92] Update Go version requirement to 1.26+ --- .github/CONTRIBUTING.md | 2 +- .github/workflows/lint.yml | 2 +- docs/install_source.md | 2 +- docs/source.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4ff7fbca27a..4cc5df46a41 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -24,7 +24,7 @@ We accept pull requests for issues labelled `help wanted`. We encourage issues a ## Building the project Prerequisites: -- Go 1.25+ +- Go 1.26+ Build with: * Unix-like systems: `make` diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2f95d5e0ba1..d55a944c854 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -77,7 +77,7 @@ jobs: # `govulncheck` exits unsuccessfully if vulnerabilities are found, providing results in stdout. # See https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck#hdr-Exit_codes for more information on exit codes. # - # On go1.25, To make `-mode binary` work we need to make sure the binary is built with `go build -buildvcs=false` + # On go1.25+, To make `-mode binary` work we need to make sure the binary is built with `go build -buildvcs=false` # Since our builds do not use `-buildvcs=false`, we run in source mode here instead. - name: Check Go vulnerabilities run: | diff --git a/docs/install_source.md b/docs/install_source.md index 2f74cb21264..b2ef2219968 100644 --- a/docs/install_source.md +++ b/docs/install_source.md @@ -1,6 +1,6 @@ # Installation from source -1. Verify that you have Go 1.25+ installed +1. Verify that you have Go 1.26+ installed ```sh $ go version diff --git a/docs/source.md b/docs/source.md index 8486a3dbe35..4f9506774b8 100644 --- a/docs/source.md +++ b/docs/source.md @@ -1,6 +1,6 @@ # Installation from source -1. Verify that you have Go 1.25+ installed +1. Verify that you have Go 1.26+ installed ```sh $ go version From ea83ca0ca90a20682edb4c5d3c66d2defab22072 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Mon, 9 Mar 2026 19:26:29 +0530 Subject: [PATCH 86/92] refactor: change extractFileName param from []byte to string --- pkg/cmd/pr/diff/diff.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index c292bce6f4a..93e39bcef03 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -397,7 +397,7 @@ func filterDiff(r io.Reader, excludePatterns []string) (io.Reader, error) { var result bytes.Buffer for _, section := range splitDiffSections(string(data)) { - name := extractFileName([]byte(section)) + name := extractFileName(section) if name != "" && matchesAny(name, excludePatterns) { continue } @@ -428,12 +428,12 @@ func splitDiffSections(diff string) []string { return sections } -func extractFileName(section []byte) string { - m := diffHeaderRegexp.FindSubmatch(section) +func extractFileName(section string) string { + m := diffHeaderRegexp.FindStringSubmatch(section) if m == nil { return "" } - return strings.TrimSpace(string(m[1]) + string(m[2])) + return strings.TrimSpace(m[1] + m[2]) } func matchesAny(name string, excludePatterns []string) bool { From 1a7f2aa0c08c381d1fc27d23eb7dab831045e12f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:59:56 +0000 Subject: [PATCH 87/92] chore(deps): bump golang.org/x/sync from 0.19.0 to 0.20.0 Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.19.0 to 0.20.0. - [Commits](https://github.com/golang/sync/compare/v0.19.0...v0.20.0) --- updated-dependencies: - dependency-name: golang.org/x/sync dependency-version: 0.20.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5ad5bf5ab00..a3a55c3cd68 100644 --- a/go.mod +++ b/go.mod @@ -51,7 +51,7 @@ require ( github.com/yuin/goldmark v1.7.16 github.com/zalando/go-keyring v0.2.6 golang.org/x/crypto v0.48.0 - golang.org/x/sync v0.19.0 + golang.org/x/sync v0.20.0 golang.org/x/term v0.40.0 golang.org/x/text v0.34.0 google.golang.org/grpc v1.79.2 diff --git a/go.sum b/go.sum index e1ff3129b49..af66f223f17 100644 --- a/go.sum +++ b/go.sum @@ -593,8 +593,8 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= From 4ae0c5851be9f36b53d36e6e6ff49954ddee3a86 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Thu, 5 Mar 2026 16:36:59 +0530 Subject: [PATCH 88/92] feat(repo): add --squash-merge-commit-message flag to gh repo edit Add a single --squash-merge-commit-message flag that maps to both squash_merge_commit_title and squash_merge_commit_message API fields. Supported values: - default: COMMIT_OR_PR_TITLE + COMMIT_MESSAGES - pr-title: PR_TITLE + BLANK - pr-title-commits: PR_TITLE + COMMIT_MESSAGES - pr-title-description: PR_TITLE + PR_BODY The flag requires --enable-squash-merge to be set alongside it. In interactive mode, the squash merge commit message prompt appears when squash merging is selected. Closes #10092 --- pkg/cmd/repo/edit/edit.go | 118 +++++++++++++++++++---- pkg/cmd/repo/edit/edit_test.go | 165 ++++++++++++++++++++++++++++++++- 2 files changed, 264 insertions(+), 19 deletions(-) diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index d94f7a1d467..3f75915ef36 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -35,6 +35,11 @@ const ( allowSquashMerge = "Allow Squash Merging" allowRebaseMerge = "Allow Rebase Merging" + squashMsgDefault = "default" + squashMsgPRTitle = "pr-title" + squashMsgPRTitleCommits = "pr-title-commits" + squashMsgPRTitleDescription = "pr-title-description" + optionAllowForking = "Allow Forking" optionDefaultBranchName = "Default Branch Name" optionDescription = "Description" @@ -69,24 +74,27 @@ type EditRepositoryInput struct { enableAdvancedSecurity *bool enableSecretScanning *bool enableSecretScanningPushProtection *bool - - AllowForking *bool `json:"allow_forking,omitempty"` - AllowUpdateBranch *bool `json:"allow_update_branch,omitempty"` - DefaultBranch *string `json:"default_branch,omitempty"` - DeleteBranchOnMerge *bool `json:"delete_branch_on_merge,omitempty"` - Description *string `json:"description,omitempty"` - EnableAutoMerge *bool `json:"allow_auto_merge,omitempty"` - EnableIssues *bool `json:"has_issues,omitempty"` - EnableMergeCommit *bool `json:"allow_merge_commit,omitempty"` - EnableProjects *bool `json:"has_projects,omitempty"` - EnableDiscussions *bool `json:"has_discussions,omitempty"` - EnableRebaseMerge *bool `json:"allow_rebase_merge,omitempty"` - EnableSquashMerge *bool `json:"allow_squash_merge,omitempty"` - EnableWiki *bool `json:"has_wiki,omitempty"` - Homepage *string `json:"homepage,omitempty"` - IsTemplate *bool `json:"is_template,omitempty"` - SecurityAndAnalysis *SecurityAndAnalysisInput `json:"security_and_analysis,omitempty"` - Visibility *string `json:"visibility,omitempty"` + squashMergeCommitMsg *string + + AllowForking *bool `json:"allow_forking,omitempty"` + AllowUpdateBranch *bool `json:"allow_update_branch,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` + DeleteBranchOnMerge *bool `json:"delete_branch_on_merge,omitempty"` + Description *string `json:"description,omitempty"` + EnableAutoMerge *bool `json:"allow_auto_merge,omitempty"` + EnableIssues *bool `json:"has_issues,omitempty"` + EnableMergeCommit *bool `json:"allow_merge_commit,omitempty"` + EnableProjects *bool `json:"has_projects,omitempty"` + EnableDiscussions *bool `json:"has_discussions,omitempty"` + EnableRebaseMerge *bool `json:"allow_rebase_merge,omitempty"` + EnableSquashMerge *bool `json:"allow_squash_merge,omitempty"` + EnableWiki *bool `json:"has_wiki,omitempty"` + Homepage *string `json:"homepage,omitempty"` + IsTemplate *bool `json:"is_template,omitempty"` + SecurityAndAnalysis *SecurityAndAnalysisInput `json:"security_and_analysis,omitempty"` + SquashMergeCommitTitle *string `json:"squash_merge_commit_title,omitempty"` + SquashMergeCommitMessage *string `json:"squash_merge_commit_message,omitempty"` + Visibility *string `json:"visibility,omitempty"` } func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobra.Command { @@ -120,6 +128,14 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobr When the %[1]s--visibility%[1]s flag is used, %[1]s--accept-visibility-change-consequences%[1]s flag is required. For information on all the potential consequences, see . + + When the %[1]s--enable-squash-merge%[1]s flag is used, %[1]s--squash-merge-commit-message%[1]s + can be used to change the default squash merge commit message behavior: + + - %[1]sdefault%[1]s: uses commit title and message for 1 commit, or pull request title and list of commits for 2 or more + - %[1]spr-title%[1]s: uses pull request title + - %[1]spr-title-commits%[1]s: uses pull request title and list of commits + - %[1]spr-title-description%[1]s: uses pull request title and description `, "`"), Args: cobra.MaximumNArgs(1), Example: heredoc.Doc(` @@ -162,6 +178,16 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobr return cmdutil.FlagErrorf("use of --visibility flag requires --accept-visibility-change-consequences flag") } + if opts.Edits.squashMergeCommitMsg != nil { + if err := validateSquashMergeCommitMsg(*opts.Edits.squashMergeCommitMsg); err != nil { + return err + } + if opts.Edits.EnableSquashMerge == nil { + return cmdutil.FlagErrorf("--squash-merge-commit-message requires --enable-squash-merge") + } + transformSquashMergeOpts(&opts.Edits) + } + if hasSecurityEdits(opts.Edits) { opts.Edits.SecurityAndAnalysis = transformSecurityAndAnalysisOpts(opts) } @@ -192,6 +218,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobr cmdutil.NilBoolFlag(cmd, &opts.Edits.DeleteBranchOnMerge, "delete-branch-on-merge", "", "Delete head branch when pull requests are merged") cmdutil.NilBoolFlag(cmd, &opts.Edits.AllowForking, "allow-forking", "", "Allow forking of an organization repository") cmdutil.NilBoolFlag(cmd, &opts.Edits.AllowUpdateBranch, "allow-update-branch", "", "Allow a pull request head branch that is behind its base branch to be updated") + cmdutil.NilStringFlag(cmd, &opts.Edits.squashMergeCommitMsg, "squash-merge-commit-message", "", "The default value for a squash merge commit message: {default|pr-title|pr-title-commits|pr-title-description}") cmd.Flags().StringSliceVar(&opts.AddTopics, "add-topic", nil, "Add repository topic") cmd.Flags().StringSliceVar(&opts.RemoveTopics, "remove-topic", nil, "Remove repository topic") cmd.Flags().BoolVar(&opts.AcceptVisibilityChangeConsequences, "accept-visibility-change-consequences", false, "Accept the consequences of changing the repository visibility") @@ -474,6 +501,25 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { return fmt.Errorf("you need to allow at least one merge strategy") } + if enableSquashMerge { + squashMsgOptions := []string{ + squashMsgDefault, + squashMsgPRTitle, + squashMsgPRTitleCommits, + squashMsgPRTitleDescription, + } + idx, err := p.Select( + "Default squash merge commit message", + squashMsgDefault, + squashMsgOptions) + if err != nil { + return err + } + selected := squashMsgOptions[idx] + opts.Edits.squashMergeCommitMsg = &selected + transformSquashMergeOpts(&opts.Edits) + } + opts.Edits.EnableAutoMerge = &r.AutoMergeAllowed c, err := p.Confirm("Enable Auto Merge?", r.AutoMergeAllowed) if err != nil { @@ -634,3 +680,39 @@ func transformSecurityAndAnalysisOpts(opts *EditOptions) *SecurityAndAnalysisInp } return securityOptions } + +var validSquashMsgValues = []string{squashMsgDefault, squashMsgPRTitle, squashMsgPRTitleCommits, squashMsgPRTitleDescription} + +func validateSquashMergeCommitMsg(value string) error { + for _, v := range validSquashMsgValues { + if value == v { + return nil + } + } + return cmdutil.FlagErrorf("invalid value for --squash-merge-commit-message: %q. Valid values are: %s", value, strings.Join(validSquashMsgValues, ", ")) +} + +// transformSquashMergeOpts maps the user-facing squash merge commit message option +// to the two API fields: squash_merge_commit_title and squash_merge_commit_message. +func transformSquashMergeOpts(edits *EditRepositoryInput) { + if edits.squashMergeCommitMsg == nil { + return + } + var title, message string + switch *edits.squashMergeCommitMsg { + case squashMsgDefault: + title = "COMMIT_OR_PR_TITLE" + message = "COMMIT_MESSAGES" + case squashMsgPRTitle: + title = "PR_TITLE" + message = "BLANK" + case squashMsgPRTitleCommits: + title = "PR_TITLE" + message = "COMMIT_MESSAGES" + case squashMsgPRTitleDescription: + title = "PR_TITLE" + message = "PR_BODY" + } + edits.SquashMergeCommitTitle = &title + edits.SquashMergeCommitMessage = &message +} diff --git a/pkg/cmd/repo/edit/edit_test.go b/pkg/cmd/repo/edit/edit_test.go index 868e300facd..683bc3f3f6c 100644 --- a/pkg/cmd/repo/edit/edit_test.go +++ b/pkg/cmd/repo/edit/edit_test.go @@ -91,6 +91,29 @@ func TestNewCmdEdit(t *testing.T) { }, }, }, + { + name: "squash merge commit message with enable-squash-merge", + args: "--enable-squash-merge --squash-merge-commit-message pr-title", + wantOpts: EditOptions{ + Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), + Edits: EditRepositoryInput{ + squashMergeCommitMsg: sp("pr-title"), + EnableSquashMerge: bp(true), + SquashMergeCommitTitle: sp("PR_TITLE"), + SquashMergeCommitMessage: sp("BLANK"), + }, + }, + }, + { + name: "squash merge commit message without enable-squash-merge", + args: "--squash-merge-commit-message default", + wantErr: "--squash-merge-commit-message requires --enable-squash-merge", + }, + { + name: "squash merge commit message with invalid value", + args: "--enable-squash-merge --squash-merge-commit-message blah", + wantErr: `invalid value for --squash-merge-commit-message: "blah". Valid values are: default, pr-title, pr-title-commits, pr-title-description`, + }, } for _, tt := range tests { @@ -235,6 +258,26 @@ func Test_editRun(t *testing.T) { })) }, }, + { + name: "set squash merge commit message to pr-title-description", + opts: EditOptions{ + Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), + Edits: EditRepositoryInput{ + EnableSquashMerge: bp(true), + SquashMergeCommitTitle: sp("PR_TITLE"), + SquashMergeCommitMessage: sp("PR_BODY"), + }, + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO"), + httpmock.RESTPayload(200, `{}`, func(payload map[string]interface{}) { + assert.Equal(t, true, payload["allow_squash_merge"]) + assert.Equal(t, "PR_TITLE", payload["squash_merge_commit_title"]) + assert.Equal(t, "PR_BODY", payload["squash_merge_commit_message"]) + })) + }, + }, { name: "does not have sufficient permissions for security edits", opts: EditOptions{ @@ -633,7 +676,7 @@ func Test_editRun_interactive(t *testing.T) { }, }, { - name: "updates repo merge options", + name: "updates repo merge options without squash", opts: EditOptions{ Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), InteractiveMode: true, @@ -691,6 +734,72 @@ func Test_editRun_interactive(t *testing.T) { })) }, }, + { + name: "updates repo merge options with squash and commit message", + opts: EditOptions{ + Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), + InteractiveMode: true, + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterMultiSelect("What do you want to edit?", nil, editList, + func(_ string, _, opts []string) ([]int, error) { + return []int{4}, nil + }) + pm.RegisterMultiSelect("Allowed merge strategies", nil, + []string{allowMergeCommits, allowSquashMerge, allowRebaseMerge}, + func(_ string, _, opts []string) ([]int, error) { + return []int{1}, nil + }) + pm.RegisterSelect("Default squash merge commit message", + []string{"default", "pr-title", "pr-title-commits", "pr-title-description"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "pr-title-description") + }) + pm.RegisterConfirm("Enable Auto Merge?", func(_ string, _ bool) (bool, error) { + return false, nil + }) + pm.RegisterConfirm("Automatically delete head branches after merging?", func(_ string, _ bool) (bool, error) { + return false, nil + }) + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { + "data": { + "repository": { + "description": "old description", + "homePageUrl": "https://url.com", + "defaultBranchRef": { + "name": "main" + }, + "isInOrganization": false, + "squashMergeAllowed": false, + "rebaseMergeAllowed": false, + "mergeCommitAllowed": true, + "deleteBranchOnMerge": false, + "repositoryTopics": { + "nodes": [{ + "topic": { + "name": "x" + } + }] + } + } + } + }`)) + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO"), + httpmock.RESTPayload(200, `{}`, func(payload map[string]interface{}) { + assert.Equal(t, false, payload["allow_merge_commit"]) + assert.Equal(t, true, payload["allow_squash_merge"]) + assert.Equal(t, false, payload["allow_rebase_merge"]) + assert.Equal(t, "PR_TITLE", payload["squash_merge_commit_title"]) + assert.Equal(t, "PR_BODY", payload["squash_merge_commit_message"]) + })) + }, + }, } for _, tt := range tests { @@ -818,6 +927,60 @@ func Test_transformSecurityAndAnalysisOpts(t *testing.T) { } } +func Test_transformSquashMergeOpts(t *testing.T) { + tests := []struct { + name string + input string + wantTitle string + wantMessage string + }{ + { + name: "default", + input: "default", + wantTitle: "COMMIT_OR_PR_TITLE", + wantMessage: "COMMIT_MESSAGES", + }, + { + name: "pr-title", + input: "pr-title", + wantTitle: "PR_TITLE", + wantMessage: "BLANK", + }, + { + name: "pr-title-commits", + input: "pr-title-commits", + wantTitle: "PR_TITLE", + wantMessage: "COMMIT_MESSAGES", + }, + { + name: "pr-title-description", + input: "pr-title-description", + wantTitle: "PR_TITLE", + wantMessage: "PR_BODY", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + edits := &EditRepositoryInput{ + squashMergeCommitMsg: sp(tt.input), + } + transformSquashMergeOpts(edits) + assert.Equal(t, tt.wantTitle, *edits.SquashMergeCommitTitle) + assert.Equal(t, tt.wantMessage, *edits.SquashMergeCommitMessage) + }) + } +} + +func Test_validateSquashMergeCommitMsg(t *testing.T) { + assert.NoError(t, validateSquashMergeCommitMsg("default")) + assert.NoError(t, validateSquashMergeCommitMsg("pr-title")) + assert.NoError(t, validateSquashMergeCommitMsg("pr-title-commits")) + assert.NoError(t, validateSquashMergeCommitMsg("pr-title-description")) + assert.Error(t, validateSquashMergeCommitMsg("blah")) + assert.Error(t, validateSquashMergeCommitMsg("")) +} + func sp(v string) *string { return &v } From 3baf83a3390db58aad8f62a661e2bd98373f8a67 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Thu, 5 Mar 2026 16:52:38 +0530 Subject: [PATCH 89/92] fix: gofmt alignment in test struct literals --- pkg/cmd/repo/edit/edit_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/repo/edit/edit_test.go b/pkg/cmd/repo/edit/edit_test.go index 683bc3f3f6c..bad2210d891 100644 --- a/pkg/cmd/repo/edit/edit_test.go +++ b/pkg/cmd/repo/edit/edit_test.go @@ -105,13 +105,13 @@ func TestNewCmdEdit(t *testing.T) { }, }, { - name: "squash merge commit message without enable-squash-merge", - args: "--squash-merge-commit-message default", + name: "squash merge commit message without enable-squash-merge", + args: "--squash-merge-commit-message default", wantErr: "--squash-merge-commit-message requires --enable-squash-merge", }, { - name: "squash merge commit message with invalid value", - args: "--enable-squash-merge --squash-merge-commit-message blah", + name: "squash merge commit message with invalid value", + args: "--enable-squash-merge --squash-merge-commit-message blah", wantErr: `invalid value for --squash-merge-commit-message: "blah". Valid values are: default, pr-title, pr-title-commits, pr-title-description`, }, } From 198487e1666f8800ca6fbf5e351320acd1a50a3f Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Tue, 10 Mar 2026 18:27:21 +0530 Subject: [PATCH 90/92] fix: address review feedback on squash merge commit message - reorder if checks: validate --enable-squash-merge is set before checking the value, and error when --enable-squash-merge=false - use validSquashMsgValues directly in interactive prompt instead of duplicating the slice - use slices.Contains in validateSquashMergeCommitMsg - interpolate const values in Long description instead of hardcoding - add default clause in transformSquashMergeOpts to avoid mutating title/message on unknown input - move optionDiscussions to end of const block with TODO comment - add test for unknown input and --enable-squash-merge=false case --- pkg/cmd/repo/edit/edit.go | 39 +++++++++++++++++----------------- pkg/cmd/repo/edit/edit_test.go | 14 ++++++++++++ 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index 3f75915ef36..aff7a5fe188 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" "time" @@ -47,11 +48,13 @@ const ( optionIssues = "Issues" optionMergeOptions = "Merge Options" optionProjects = "Projects" - optionDiscussions = "Discussions" optionTemplateRepo = "Template Repository" optionTopics = "Topics" optionVisibility = "Visibility" optionWikis = "Wikis" + + // TODO: GitHub Enterprise Server does not support has_discussions yet + // optionDiscussions = "Discussions" ) type EditOptions struct { @@ -132,11 +135,11 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobr When the %[1]s--enable-squash-merge%[1]s flag is used, %[1]s--squash-merge-commit-message%[1]s can be used to change the default squash merge commit message behavior: - - %[1]sdefault%[1]s: uses commit title and message for 1 commit, or pull request title and list of commits for 2 or more - - %[1]spr-title%[1]s: uses pull request title - - %[1]spr-title-commits%[1]s: uses pull request title and list of commits - - %[1]spr-title-description%[1]s: uses pull request title and description - `, "`"), + - %[1]s%[2]s%[1]s: uses commit title and message for 1 commit, or pull request title and list of commits for 2 or more + - %[1]s%[3]s%[1]s: uses pull request title + - %[1]s%[4]s%[1]s: uses pull request title and list of commits + - %[1]s%[5]s%[1]s: uses pull request title and description + `, "`", squashMsgDefault, squashMsgPRTitle, squashMsgPRTitleCommits, squashMsgPRTitleDescription), Args: cobra.MaximumNArgs(1), Example: heredoc.Doc(` # Enable issues and wiki @@ -179,12 +182,15 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobr } if opts.Edits.squashMergeCommitMsg != nil { - if err := validateSquashMergeCommitMsg(*opts.Edits.squashMergeCommitMsg); err != nil { - return err - } if opts.Edits.EnableSquashMerge == nil { return cmdutil.FlagErrorf("--squash-merge-commit-message requires --enable-squash-merge") } + if !*opts.Edits.EnableSquashMerge { + return cmdutil.FlagErrorf("--squash-merge-commit-message cannot be used when --enable-squash-merge=false") + } + if err := validateSquashMergeCommitMsg(*opts.Edits.squashMergeCommitMsg); err != nil { + return err + } transformSquashMergeOpts(&opts.Edits) } @@ -502,12 +508,7 @@ func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { } if enableSquashMerge { - squashMsgOptions := []string{ - squashMsgDefault, - squashMsgPRTitle, - squashMsgPRTitleCommits, - squashMsgPRTitleDescription, - } + squashMsgOptions := validSquashMsgValues idx, err := p.Select( "Default squash merge commit message", squashMsgDefault, @@ -684,10 +685,8 @@ func transformSecurityAndAnalysisOpts(opts *EditOptions) *SecurityAndAnalysisInp var validSquashMsgValues = []string{squashMsgDefault, squashMsgPRTitle, squashMsgPRTitleCommits, squashMsgPRTitleDescription} func validateSquashMergeCommitMsg(value string) error { - for _, v := range validSquashMsgValues { - if value == v { - return nil - } + if slices.Contains(validSquashMsgValues, value) { + return nil } return cmdutil.FlagErrorf("invalid value for --squash-merge-commit-message: %q. Valid values are: %s", value, strings.Join(validSquashMsgValues, ", ")) } @@ -712,6 +711,8 @@ func transformSquashMergeOpts(edits *EditRepositoryInput) { case squashMsgPRTitleDescription: title = "PR_TITLE" message = "PR_BODY" + default: + return } edits.SquashMergeCommitTitle = &title edits.SquashMergeCommitMessage = &message diff --git a/pkg/cmd/repo/edit/edit_test.go b/pkg/cmd/repo/edit/edit_test.go index bad2210d891..d4b297a1902 100644 --- a/pkg/cmd/repo/edit/edit_test.go +++ b/pkg/cmd/repo/edit/edit_test.go @@ -114,6 +114,11 @@ func TestNewCmdEdit(t *testing.T) { args: "--enable-squash-merge --squash-merge-commit-message blah", wantErr: `invalid value for --squash-merge-commit-message: "blah". Valid values are: default, pr-title, pr-title-commits, pr-title-description`, }, + { + name: "squash merge commit message with enable-squash-merge=false", + args: "--enable-squash-merge=false --squash-merge-commit-message default", + wantErr: "--squash-merge-commit-message cannot be used when --enable-squash-merge=false", + }, } for _, tt := range tests { @@ -972,6 +977,15 @@ func Test_transformSquashMergeOpts(t *testing.T) { } } +func Test_transformSquashMergeOpts_unknownInput(t *testing.T) { + edits := &EditRepositoryInput{ + squashMergeCommitMsg: sp("unknown-value"), + } + transformSquashMergeOpts(edits) + assert.Nil(t, edits.SquashMergeCommitTitle) + assert.Nil(t, edits.SquashMergeCommitMessage) +} + func Test_validateSquashMergeCommitMsg(t *testing.T) { assert.NoError(t, validateSquashMergeCommitMsg("default")) assert.NoError(t, validateSquashMergeCommitMsg("pr-title")) From 089e461087f5cdbcf1585c45e464f1b974edaedf Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:30:57 -0400 Subject: [PATCH 91/92] Add pitch surfacing workflow (monthly + manual dispatch) --- .github/workflows/triage-scheduled-tasks.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/triage-scheduled-tasks.yml b/.github/workflows/triage-scheduled-tasks.yml index 8dd0793b209..0da0ef2c8eb 100644 --- a/.github/workflows/triage-scheduled-tasks.yml +++ b/.github/workflows/triage-scheduled-tasks.yml @@ -6,6 +6,7 @@ on: schedule: - cron: '5 * * * *' # Hourly โ€” no-response close - cron: '0 3 * * *' # Daily at 3 AM UTC โ€” stale issues + - cron: '0 14 1 * *' # Monthly on the 1st at 2 PM UTC โ€” pitch surfacing jobs: no-response: @@ -24,3 +25,9 @@ jobs: exempt_issue_labels: 'keep' permissions: issues: write + + pitch-surface: + if: github.event.schedule == '0 14 1 * *' || github.event_name == 'workflow_dispatch' + uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/pitch-surface-top-issues.yml@main + permissions: + issues: write From c9afc3c0895ae0769efb08d4e259b239ffdf1fd6 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:44:55 -0400 Subject: [PATCH 92/92] fix: add if guard to no-response job to prevent running on workflow_dispatch Prevents no-response from accidentally closing issues when manually dispatching the workflow for pitch surfacing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/triage-scheduled-tasks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/triage-scheduled-tasks.yml b/.github/workflows/triage-scheduled-tasks.yml index 0da0ef2c8eb..bfa2eb222fc 100644 --- a/.github/workflows/triage-scheduled-tasks.yml +++ b/.github/workflows/triage-scheduled-tasks.yml @@ -10,6 +10,7 @@ on: jobs: no-response: + if: github.event_name == 'issue_comment' || github.event.schedule == '5 * * * *' uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-no-response-close.yml@main permissions: issues: write