diff --git a/Makefile b/Makefile index a809188c425..4efdbfbed1b 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 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", diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 7a200e20c84..e40c134bc7b 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 { @@ -148,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) @@ -162,6 +170,15 @@ 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 032f5cda03c..7417cc7d8e8 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,23 +46,50 @@ 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(` { "data": { "Issue": { "fields": [ {"name": "stateReason"} + ] }, "IssueClosedStateReason": { "enumValues": [ + {"name": "COMPLETED"}, + {"name": "NOT_PLANNED"} ] } } } `), }, wantFeatures: IssueFeatures{ - StateReason: true, + 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, }, wantErr: false, }, 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 { 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{ diff --git a/pkg/cmd/issue/develop/develop.go b/pkg/cmd/issue/develop/develop.go index 04bf14ebe56..90c52dff6e4 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" @@ -150,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) @@ -174,7 +177,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 +185,67 @@ 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 + opts.IO.StartProgressIndicatorWithLabel("Preparing linked branch") + defer opts.IO.StopProgressIndicator() + + branchName := "" + reusedExisting := false + if opts.Name != "" { + opts.IO.StartProgressIndicatorWithLabel("Checking existing linked branches") + branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number) + if err != nil { + return err + } + branchName = findExistingLinkedBranchName(branches, branchRepo, opts.Name) + reusedExisting = branchName != "" + } + + repoID := "" + branchID := "" + baseValidated := false + if opts.BaseBranch != "" { + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Validating base branch %q", opts.BaseBranch)) + foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch) + if err != nil { + return err + } + repoID = foundRepoID + branchID = foundBranchID + baseValidated = true + } + + if branchName == "" { + if !baseValidated { + opts.IO.StartProgressIndicatorWithLabel("Resolving base branch") + foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch) + if err != nil { + return err + } + repoID = foundRepoID + branchID = foundBranchID + } + + opts.IO.StartProgressIndicatorWithLabel("Creating linked branch") + createdBranchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name) + if err != nil { + return err + } + branchName = createdBranchName + } + + if branchName == "" { + return fmt.Errorf("failed to create linked branch: API returned empty branch name") } - opts.IO.StartProgressIndicator() - branchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name) opts.IO.StopProgressIndicator() - if err != nil { - return err + + 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,13 +255,44 @@ 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() + 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)) 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"}}}}}`, diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index 1466cd96a0d..b29b25038b6 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,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 this behavior, use %[1]s--no-upstream%[1]s. `, "`"), Example: heredoc.Doc(` # Clone a repository from a specific org @@ -77,6 +79,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 +96,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 +194,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