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] 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 04bf14ebe..812194cf0 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 2485c8cc4..fe984df79 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"}}}}}`,