diff --git a/api/client.go b/api/client.go index 1bf861f9a..61b5d844b 100644 --- a/api/client.go +++ b/api/client.go @@ -19,7 +19,7 @@ const ( authorization = "Authorization" cacheTTL = "X-GH-CACHE-TTL" graphqlFeatures = "GraphQL-Features" - mergeQueue = "merge_queue" + features = "merge_queue" userAgent = "User-Agent" ) @@ -55,7 +55,7 @@ func (err HTTPError) ScopesSuggestion() string { // GraphQLError will be returned, but the data will also be parsed into the receiver. func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error { opts := clientOptions(hostname, c.http.Transport) - opts.Headers[graphqlFeatures] = mergeQueue + opts.Headers[graphqlFeatures] = features gqlClient, err := gh.GQLClient(&opts) if err != nil { return err @@ -67,7 +67,7 @@ func (c Client) GraphQL(hostname string, query string, variables map[string]inte // GraphQLError will be returned, but the data will also be parsed into the receiver. func (c Client) Mutate(hostname, name string, mutation interface{}, variables map[string]interface{}) error { opts := clientOptions(hostname, c.http.Transport) - opts.Headers[graphqlFeatures] = mergeQueue + opts.Headers[graphqlFeatures] = features gqlClient, err := gh.GQLClient(&opts) if err != nil { return err @@ -79,7 +79,7 @@ func (c Client) Mutate(hostname, name string, mutation interface{}, variables ma // GraphQLError will be returned, but the data will also be parsed into the receiver. func (c Client) Query(hostname, name string, query interface{}, variables map[string]interface{}) error { opts := clientOptions(hostname, c.http.Transport) - opts.Headers[graphqlFeatures] = mergeQueue + opts.Headers[graphqlFeatures] = features gqlClient, err := gh.GQLClient(&opts) if err != nil { return err diff --git a/api/queries_branch_issue_reference.go b/api/queries_branch_issue_reference.go new file mode 100644 index 000000000..5393b484d --- /dev/null +++ b/api/queries_branch_issue_reference.go @@ -0,0 +1,219 @@ +package api + +import ( + "fmt" + + "github.com/cli/cli/v2/internal/ghrepo" +) + +type LinkedBranch struct { + ID string + BranchName string + RepoUrl string +} + +// method to return url of linked branch, adds the branch name to the end of the repo url +func (b *LinkedBranch) Url() string { + return fmt.Sprintf("%s/tree/%s", b.RepoUrl, b.BranchName) +} + +func nameParam(params map[string]interface{}) string { + if params["name"] != "" { + return "name: $name," + } + return "" +} + +func nameArg(params map[string]interface{}) string { + if params["name"] != "" { + return "$name: String, " + } + + return "" +} + +func CreateBranchIssueReference(client *Client, repo *Repository, params map[string]interface{}) (*LinkedBranch, error) { + query := fmt.Sprintf(` + mutation CreateLinkedBranch($issueId: ID!, $oid: GitObjectID!, %[1]s$repositoryId: ID) { + createLinkedBranch(input: { + issueId: $issueId, + %[2]s + oid: $oid, + repositoryId: $repositoryId + }) { + linkedBranch { + id + ref { + name + } + } + } + }`, nameArg(params), nameParam(params)) + + inputParams := map[string]interface{}{ + "repositoryId": repo.ID, + } + for key, val := range params { + switch key { + case "issueId", "name", "oid": + inputParams[key] = val + } + } + + result := struct { + CreateLinkedBranch struct { + LinkedBranch struct { + ID string + Ref struct { + Name string + } + } + } + }{} + + err := client.GraphQL(repo.RepoHost(), query, inputParams, &result) + if err != nil { + return nil, err + } + + ref := LinkedBranch{ + ID: result.CreateLinkedBranch.LinkedBranch.ID, + BranchName: result.CreateLinkedBranch.LinkedBranch.Ref.Name, + } + return &ref, nil + +} + +func ListLinkedBranches(client *Client, repo ghrepo.Interface, issueNumber int) ([]LinkedBranch, error) { + query := ` + query BranchIssueReferenceListLinkedBranches($repositoryName: String!, $repositoryOwner: String!, $issueNumber: Int!) { + repository(name: $repositoryName, owner: $repositoryOwner) { + issue(number: $issueNumber) { + linkedBranches(first: 30) { + edges { + node { + ref { + name + repository { + url + } + } + } + } + } + } + } + } + ` + variables := map[string]interface{}{ + "repositoryName": repo.RepoName(), + "repositoryOwner": repo.RepoOwner(), + "issueNumber": issueNumber, + } + + result := struct { + Repository struct { + Issue struct { + LinkedBranches struct { + Edges []struct { + Node struct { + Ref struct { + Name string + Repository struct { + NameWithOwner string + Url string + } + } + } + } + } + } + } + }{} + + err := client.GraphQL(repo.RepoHost(), query, variables, &result) + var branchNames []LinkedBranch + if err != nil { + return branchNames, err + } + + for _, edge := range result.Repository.Issue.LinkedBranches.Edges { + branch := LinkedBranch{ + BranchName: edge.Node.Ref.Name, + RepoUrl: edge.Node.Ref.Repository.Url, + } + + branchNames = append(branchNames, branch) + } + + return branchNames, nil + +} + +// introspects the schema to see if we expose the LinkedBranch type +func CheckLinkedBranchFeature(client *Client, host string) (err error) { + var featureDetection struct { + Name struct { + Fields []struct { + Name string + } + } `graphql:"LinkedBranch: __type(name: \"LinkedBranch\")"` + } + + err = client.Query(host, "LinkedBranch_fields", &featureDetection, nil) + + if err != nil { + return err + } + + if len(featureDetection.Name.Fields) == 0 { + return fmt.Errorf("the `gh issue develop` command is not currently available") + } + return nil +} + +// This fetches the oids for the repo's default branch (`main`, etc) and the name the user might have provided in one shot. +func FindBaseOid(client *Client, repo *Repository, ref string) (string, string, error) { + query := ` + query BranchIssueReferenceFindBaseOid($repositoryName: String!, $repositoryOwner: String!, $ref: String!) { + repository(name: $repositoryName, owner: $repositoryOwner) { + defaultBranchRef { + target { + oid + } + } + ref(qualifiedName: $ref) { + target { + oid + } + } + } + }` + + variables := map[string]interface{}{ + "repositoryName": repo.Name, + "repositoryOwner": repo.RepoOwner(), + "ref": ref, + } + + result := struct { + Repository struct { + DefaultBranchRef struct { + Target struct { + Oid string + } + } + Ref struct { + Target struct { + Oid string + } + } + } + }{} + + err := client.GraphQL(repo.RepoHost(), query, variables, &result) + if err != nil { + return "", "", err + } + return result.Repository.Ref.Target.Oid, result.Repository.DefaultBranchRef.Target.Oid, nil +} diff --git a/pkg/cmd/issue/develop/develop.go b/pkg/cmd/issue/develop/develop.go new file mode 100644 index 000000000..eeda7bd19 --- /dev/null +++ b/pkg/cmd/issue/develop/develop.go @@ -0,0 +1,284 @@ +package develop + +import ( + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/run" + "github.com/cli/cli/v2/pkg/cmd/issue/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/utils" + "github.com/spf13/cobra" +) + +type DevelopOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Remotes func() (context.Remotes, error) + + IssueRepoSelector string + IssueSelector string + Name string + BaseBranch string + Checkout bool + List bool +} + +func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra.Command { + opts := &DevelopOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + BaseRepo: f.BaseRepo, + Remotes: f.Remotes, + } + + cmd := &cobra.Command{ + Use: "develop [flags] { | }", + Short: "Manage linked branches for an issue", + Example: heredoc.Doc(` + $ gh issue develop --list 123 # list branches for issue 123 + $ gh issue develop --list --issue-repo "github/cli" 123 # list branches for issue 123 in repo "github/cli" + $ gh issue develop --list https://github.com/github/cli/issues/123 # list branches for issue 123 in repo "github/cli" + $ gh issue develop 123 --name "my-branch" --base my-feature # create a branch for issue 123 based on the my-feature branch + $ gh issue develop 123 --checkout # fetch and checkout the branch for issue 123 after creating it + `), + Args: cmdutil.ExactArgs(1, "issue number or url is required"), + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + opts.IssueSelector = args[0] + if opts.List { + return developRunList(opts) + } + return developRunCreate(opts) + }, + } + fl := cmd.Flags() + fl.StringVarP(&opts.BaseBranch, "base", "b", "", "Name of the base branch you want to make your new branch from") + fl.BoolVarP(&opts.Checkout, "checkout", "c", false, "Checkout the branch after creating it") + fl.StringVarP(&opts.IssueRepoSelector, "issue-repo", "i", "", "Name or URL of the issue's repository") + fl.BoolVarP(&opts.List, "list", "l", false, "List linked branches for the issue") + fl.StringVarP(&opts.Name, "name", "n", "", "Name of the branch to create") + return cmd +} + +func developRunCreate(opts *DevelopOptions) (err error) { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + opts.IO.StartProgressIndicator() + err = api.CheckLinkedBranchFeature(apiClient, baseRepo.RepoHost()) + if err != nil { + return err + } + + repo, err := api.GitHubRepo(apiClient, baseRepo) + if err != nil { + return err + } + + issueNumber, issueRepo, err := issueMetadata(opts.IssueSelector, opts.IssueRepoSelector, baseRepo) + if err != nil { + return err + } + + // The mutation requires the issue id, not just its number + issue, _, err := shared.IssueFromArgWithFields(httpClient, func() (ghrepo.Interface, error) { return issueRepo, nil }, fmt.Sprint(issueNumber), []string{"id"}) + if err != nil { + return err + } + + // The mutation takes an oid instead of a branch name as it's a more stable reference + oid, default_branch_oid, err := api.FindBaseOid(apiClient, repo, opts.BaseBranch) + if err != nil { + return err + } + + if oid == "" { + oid = default_branch_oid + } + + // get the oid of the branch from the base repo + params := map[string]interface{}{ + "issueId": issue.ID, + "name": opts.Name, + "oid": oid, + "repositoryId": repo.ID, + } + + ref, err := api.CreateBranchIssueReference(apiClient, repo, params) + opts.IO.StopProgressIndicator() + if ref != nil { + baseRepo.RepoHost() + fmt.Fprintf(opts.IO.Out, "%s/%s/%s/tree/%s\n", baseRepo.RepoHost(), baseRepo.RepoOwner(), baseRepo.RepoName(), ref.BranchName) + + if opts.Checkout { + return checkoutBranch(opts, baseRepo, ref.BranchName) + } + } + if err != nil { + return err + } + return +} + +// If the issue is in the base repo, we can use the issue number directly. Otherwise, we need to use the issue's url or the IssueRepoSelector argument. +// If the repo from the URL doesn't match the IssueRepoSelector argument, we error. +func issueMetadata(issueSelector string, issueRepoSelector string, baseRepo ghrepo.Interface) (issueNumber int, issueFlagRepo ghrepo.Interface, err error) { + var targetRepo ghrepo.Interface + if issueRepoSelector != "" { + issueFlagRepo, err = ghrepo.FromFullNameWithHost(issueRepoSelector, baseRepo.RepoHost()) + if err != nil { + return 0, nil, err + } + } + + if issueFlagRepo != nil { + targetRepo = issueFlagRepo + } + + issueNumber, issueArgRepo, err := shared.IssueNumberAndRepoFromArg(issueSelector) + if err != nil { + return 0, nil, err + } + + if issueArgRepo != nil { + targetRepo = issueArgRepo + + if issueFlagRepo != nil { + differentOwner := (issueFlagRepo.RepoOwner() != issueArgRepo.RepoOwner()) + differentName := (issueFlagRepo.RepoName() != issueArgRepo.RepoName()) + if differentOwner || differentName { + return 0, nil, fmt.Errorf("issue repo in url %s/%s does not match the repo from --issue-repo %s/%s", issueArgRepo.RepoOwner(), issueArgRepo.RepoName(), issueFlagRepo.RepoOwner(), issueFlagRepo.RepoName()) + } + } + } + + if issueFlagRepo == nil && issueArgRepo == nil { + targetRepo = baseRepo + } + + if targetRepo == nil { + return 0, nil, fmt.Errorf("could not determine issue repo") + } + + return issueNumber, targetRepo, nil +} + +func printLinkedBranches(io *iostreams.IOStreams, branches []api.LinkedBranch) { + + cs := io.ColorScheme() + table := utils.NewTablePrinter(io) + + for _, branch := range branches { + table.AddField(branch.BranchName, nil, cs.ColorFromString("cyan")) + if table.IsTTY() { + table.AddField(branch.Url(), nil, nil) + } + table.EndRow() + } + + _ = table.Render() +} + +func developRunList(opts *DevelopOptions) (err error) { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + apiClient := api.NewClientFromHTTP(httpClient) + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + opts.IO.StartProgressIndicator() + + err = api.CheckLinkedBranchFeature(apiClient, baseRepo.RepoHost()) + if err != nil { + return err + } + issueNumber, issueRepo, err := issueMetadata(opts.IssueSelector, opts.IssueRepoSelector, baseRepo) + if err != nil { + return err + } + + branches, err := api.ListLinkedBranches(apiClient, issueRepo, issueNumber) + if err != nil { + return err + } + + opts.IO.StopProgressIndicator() + if len(branches) == 0 { + return cmdutil.NewNoResultsError(fmt.Sprintf("no linked branches found for %s/%s#%d", issueRepo.RepoOwner(), issueRepo.RepoName(), issueNumber)) + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "\nShowing linked branches for %s/%s#%d\n\n", issueRepo.RepoOwner(), issueRepo.RepoName(), issueNumber) + } + + printLinkedBranches(opts.IO, branches) + + return nil + +} + +func checkoutBranch(opts *DevelopOptions, baseRepo ghrepo.Interface, checkoutBranch string) (err error) { + + remotes, err := opts.Remotes() + if err != nil { + return err + } + + baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()) + if err != nil { + return err + } + + if git.HasLocalBranch(checkoutBranch) { + if err := git.CheckoutBranch(checkoutBranch); err != nil { + return err + } + } else { + gitFetch, err := git.GitCommand("fetch", "origin", fmt.Sprintf("+refs/heads/%[1]s:refs/remotes/origin/%[1]s", checkoutBranch)) + + if err != nil { + return err + } + + gitFetch.Stdout = opts.IO.Out + gitFetch.Stderr = opts.IO.ErrOut + err = run.PrepareCmd(gitFetch).Run() + if err != nil { + return err + } + if err := git.CheckoutNewBranch(baseRemote.Name, checkoutBranch); err != nil { + return err + } + } + + if err := git.Pull(baseRemote.Name, checkoutBranch); err != nil { + _, _ = fmt.Fprintf(opts.IO.ErrOut, "%s warning: not possible to fast-forward to: %q\n", opts.IO.ColorScheme().WarningIcon(), checkoutBranch) + } + + return nil +} diff --git a/pkg/cmd/issue/develop/develop_test.go b/pkg/cmd/issue/develop/develop_test.go new file mode 100644 index 000000000..20d34cce0 --- /dev/null +++ b/pkg/cmd/issue/develop/develop_test.go @@ -0,0 +1,604 @@ +package develop + +import ( + "errors" + "net/http" + "testing" + + "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/run" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/prompt" + "github.com/cli/cli/v2/test" + "github.com/stretchr/testify/assert" +) + +func Test_developRun(t *testing.T) { + featureEnabledPayload := `{ + "data": { + "LinkedBranch": { + "fields": [ + { + "name": "id" + }, + { + "name": "ref" + } + ] + } + } + }` + + featureDisabledPayload := `{ "data": { "LinkedBranch": null } }` + + tests := []struct { + name string + setup func(*DevelopOptions, *testing.T) func() + cmdStubs func(*run.CommandStubber) + runStubs func(*run.CommandStubber) + remotes map[string]string + askStubs func(*prompt.AskStubber) // TODO eventually migrate to PrompterMock + httpStubs func(*httpmock.Registry, *testing.T) + expectedOut string + expectedErrOut string + expectedBrowse string + wantErr string + tty bool + }{ + {name: "list branches for an issue", + setup: func(opts *DevelopOptions, t *testing.T) func() { + opts.IssueSelector = "42" + opts.List = true + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "issue": { + "linkedBranches": { + "edges": [ + { + "node": { + "ref": { + "name": "foo" + } + } + }, + { + "node": { + "ref": { + "name": "bar" + } + } + } + ] + } + } + } + } + } + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(42), inputs["issueNumber"]) + assert.Equal(t, "OWNER", inputs["repositoryOwner"]) + assert.Equal(t, "REPO", inputs["repositoryName"]) + })) + }, + expectedOut: "foo\nbar\n", + }, + {name: "list branches for an issue in tty", + setup: func(opts *DevelopOptions, t *testing.T) func() { + opts.IssueSelector = "42" + opts.List = true + return func() {} + }, + tty: true, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "issue": { + "linkedBranches": { + "edges": [ + { + "node": { + "ref": { + "name": "foo", + "repository": { + "url": "http://github.localhost/OWNER/REPO" + } + } + } + }, + { + "node": { + "ref": { + "name": "bar", + "repository": { + "url": "http://github.localhost/OWNER/OTHER-REPO" + } + } + } + } + ] + } + } + } + } + } + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(42), inputs["issueNumber"]) + assert.Equal(t, "OWNER", inputs["repositoryOwner"]) + assert.Equal(t, "REPO", inputs["repositoryName"]) + })) + }, + expectedOut: "\nShowing linked branches for OWNER/REPO#42\n\nfoo http://github.localhost/OWNER/REPO/tree/foo\nbar http://github.localhost/OWNER/OTHER-REPO/tree/bar\n", + }, + {name: "list branches for an issue providing an issue url", + setup: func(opts *DevelopOptions, t *testing.T) func() { + opts.IssueSelector = "https://github.com/cli/test-repo/issues/42" + opts.List = true + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "issue": { + "linkedBranches": { + "edges": [ + { + "node": { + "ref": { + "name": "foo" + } + } + }, + { + "node": { + "ref": { + "name": "bar" + } + } + } + ] + } + } + } + } + } + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(42), inputs["issueNumber"]) + assert.Equal(t, "cli", inputs["repositoryOwner"]) + assert.Equal(t, "test-repo", inputs["repositoryName"]) + })) + }, + expectedOut: "foo\nbar\n", + }, + {name: "list branches for an issue providing an issue repo", + setup: func(opts *DevelopOptions, t *testing.T) func() { + opts.IssueSelector = "42" + opts.IssueRepoSelector = "cli/test-repo" + opts.List = true + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "issue": { + "linkedBranches": { + "edges": [ + { + "node": { + "ref": { + "name": "foo" + } + } + }, + { + "node": { + "ref": { + "name": "bar" + } + } + } + ] + } + } + } + } + } + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(42), inputs["issueNumber"]) + assert.Equal(t, "cli", inputs["repositoryOwner"]) + assert.Equal(t, "test-repo", inputs["repositoryName"]) + })) + }, + expectedOut: "foo\nbar\n", + }, + {name: "list branches for an issue providing an issue url and specifying the same repo works", + setup: func(opts *DevelopOptions, t *testing.T) func() { + opts.IssueSelector = "https://github.com/cli/test-repo/issues/42" + opts.IssueRepoSelector = "cli/test-repo" + opts.List = true + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "issue": { + "linkedBranches": { + "edges": [ + { + "node": { + "ref": { + "name": "foo" + } + } + }, + { + "node": { + "ref": { + "name": "bar" + } + } + } + ] + } + } + } + } + } + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(42), inputs["issueNumber"]) + assert.Equal(t, "cli", inputs["repositoryOwner"]) + assert.Equal(t, "test-repo", inputs["repositoryName"]) + })) + }, + expectedOut: "foo\nbar\n", + }, + {name: "list branches for an issue providing an issue url and specifying a different repo returns an error", + setup: func(opts *DevelopOptions, t *testing.T) func() { + opts.IssueSelector = "https://github.com/cli/test-repo/issues/42" + opts.IssueRepoSelector = "cli/other" + opts.List = true + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + }, + wantErr: "issue repo in url cli/test-repo does not match the repo from --issue-repo cli/other", + }, + {name: "returns an error when the feature isn't enabled in the GraphQL API", + setup: func(opts *DevelopOptions, t *testing.T) func() { + opts.IssueSelector = "https://github.com/cli/test-repo/issues/42" + opts.List = true + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.StringResponse(featureDisabledPayload), + ) + }, + wantErr: "the `gh issue develop` command is not currently available", + }, + {name: "develop new branch with a name provided", + setup: func(opts *DevelopOptions, t *testing.T) func() { + opts.Name = "my-branch" + opts.BaseBranch = "main" + opts.IssueSelector = "123" + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled": true, "issue":{"id": "yar", "number":123, "title":"my issue"} }}}`)) + reg.Register( + httpmock.GraphQL(`query BranchIssueReferenceFindBaseOid\b`), + httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"123"}}}}}`)) + + reg.Register( + httpmock.GraphQL(`(?s)mutation CreateLinkedBranch\b.*issueId: \$issueId,\s+name: \$name,\s+oid: \$oid,`), + httpmock.GraphQLQuery(`{ "data": { "createLinkedBranch": { "linkedBranch": {"id": "2", "ref": {"name": "my-branch"} } } } }`, + func(query string, inputs map[string]interface{}) { + assert.Equal(t, "REPOID", inputs["repositoryId"]) + assert.Equal(t, "my-branch", inputs["name"]) + assert.Equal(t, "yar", inputs["issueId"]) + }), + ) + + }, + expectedOut: "github.com/OWNER/REPO/tree/my-branch\n", + }, + {name: "develop new branch without a name provided omits the param from the mutation", + setup: func(opts *DevelopOptions, t *testing.T) func() { + opts.Name = "" + opts.BaseBranch = "main" + opts.IssueSelector = "123" + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled": true, "issue":{"id": "yar", "number":123, "title":"my issue"} }}}`)) + reg.Register( + httpmock.GraphQL(`query BranchIssueReferenceFindBaseOid\b`), + httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"123"}}}}}`)) + + reg.Register( + httpmock.GraphQL(`(?s)mutation CreateLinkedBranch\b.*\$oid: GitObjectID!, \$repositoryId:.*issueId: \$issueId,\s+oid: \$oid,`), + httpmock.GraphQLQuery(`{ "data": { "createLinkedBranch": { "linkedBranch": {"id": "2", "ref": {"name": "my-issue-1"} } } } }`, + func(query string, inputs map[string]interface{}) { + assert.Equal(t, "REPOID", inputs["repositoryId"]) + assert.Equal(t, "", inputs["name"]) + assert.Equal(t, "yar", inputs["issueId"]) + }), + ) + + }, + expectedOut: "github.com/OWNER/REPO/tree/my-issue-1\n", + }, + {name: "develop providing an issue url and specifying a different repo returns an error", + setup: func(opts *DevelopOptions, t *testing.T) func() { + opts.IssueSelector = "https://github.com/cli/test-repo/issues/42" + opts.IssueRepoSelector = "cli/other" + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`), + ) + }, + wantErr: "issue repo in url cli/test-repo does not match the repo from --issue-repo cli/other", + }, + {name: "develop new branch with checkout when the branch exists locally", + setup: func(opts *DevelopOptions, t *testing.T) func() { + opts.Name = "my-branch" + opts.BaseBranch = "main" + opts.IssueSelector = "123" + opts.Checkout = true + return func() {} + }, + remotes: map[string]string{ + "origin": "OWNER/REPO", + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled": true, "issue":{"id": "yar", "number":123, "title":"my issue"} }}}`)) + reg.Register( + httpmock.GraphQL(`query BranchIssueReferenceFindBaseOid\b`), + httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"123"}}}}}`)) + + reg.Register( + httpmock.GraphQL(`mutation CreateLinkedBranch\b`), + httpmock.GraphQLQuery(`{ "data": { "createLinkedBranch": { "linkedBranch": {"id": "2", "ref": {"name": "my-branch"} } } } }`, + func(query string, inputs map[string]interface{}) { + assert.Equal(t, "REPOID", inputs["repositoryId"]) + assert.Equal(t, "my-branch", inputs["name"]) + assert.Equal(t, "yar", inputs["issueId"]) + }), + ) + + }, + runStubs: func(cs *run.CommandStubber) { + 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 new branch with checkout when the branch does not exist locally", + setup: func(opts *DevelopOptions, t *testing.T) func() { + opts.Name = "my-branch" + opts.BaseBranch = "main" + opts.IssueSelector = "123" + opts.Checkout = true + return func() {} + }, + remotes: map[string]string{ + "origin": "OWNER/REPO", + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranch_fields\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled": true, "issue":{"id": "yar", "number":123, "title":"my issue"} }}}`)) + reg.Register( + httpmock.GraphQL(`query BranchIssueReferenceFindBaseOid\b`), + httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"123"}}}}}`)) + + reg.Register( + httpmock.GraphQL(`mutation CreateLinkedBranch\b`), + httpmock.GraphQLQuery(`{ "data": { "createLinkedBranch": { "linkedBranch": {"id": "2", "ref": {"name": "my-branch"} } } } }`, + func(query string, inputs map[string]interface{}) { + assert.Equal(t, "REPOID", inputs["repositoryId"]) + assert.Equal(t, "my-branch", inputs["name"]) + assert.Equal(t, "yar", inputs["issueId"]) + }), + ) + + }, + runStubs: func(cs *run.CommandStubber) { + cs.Register(`git rev-parse --verify refs/heads/my-branch`, 1, "") + cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "") + cs.Register(`git checkout -b my-branch --track origin/my-branch`, 0, "") + cs.Register(`git pull --ff-only origin my-branch`, 0, "") + }, + expectedOut: "github.com/OWNER/REPO/tree/my-branch\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg, t) + } + + opts := DevelopOptions{} + + ios, _, stdout, stderr := iostreams.Test() + + ios.SetStdoutTTY(tt.tty) + ios.SetStdinTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + opts.IO = ios + + opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + + opts.Remotes = func() (context.Remotes, error) { + if len(tt.remotes) == 0 { + return nil, errors.New("no remotes") + } + var remotes context.Remotes + for name, repo := range tt.remotes { + r, err := ghrepo.FromFullName(repo) + if err != nil { + return remotes, err + } + remotes = append(remotes, &context.Remote{ + Remote: &git.Remote{Name: name}, + Repo: r, + }) + } + return remotes, nil + } + + cmdStubs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + if tt.runStubs != nil { + tt.runStubs(cmdStubs) + } + + cleanSetup := func() {} + if tt.setup != nil { + cleanSetup = tt.setup(&opts, t) + } + defer cleanSetup() + + var err error + if opts.List { + err = developRunList(&opts) + } else { + + err = developRunCreate(&opts) + } + output := &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + } + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedOut, output.String()) + assert.Equal(t, tt.expectedErrOut, output.Stderr()) + } + }) + } +} diff --git a/pkg/cmd/issue/issue.go b/pkg/cmd/issue/issue.go index ae6d57923..adff7dac3 100644 --- a/pkg/cmd/issue/issue.go +++ b/pkg/cmd/issue/issue.go @@ -6,6 +6,7 @@ import ( cmdComment "github.com/cli/cli/v2/pkg/cmd/issue/comment" cmdCreate "github.com/cli/cli/v2/pkg/cmd/issue/create" cmdDelete "github.com/cli/cli/v2/pkg/cmd/issue/delete" + cmdDevelop "github.com/cli/cli/v2/pkg/cmd/issue/develop" cmdEdit "github.com/cli/cli/v2/pkg/cmd/issue/edit" cmdList "github.com/cli/cli/v2/pkg/cmd/issue/list" cmdPin "github.com/cli/cli/v2/pkg/cmd/issue/pin" @@ -50,6 +51,7 @@ func NewCmdIssue(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) cmd.AddCommand(cmdEdit.NewCmdEdit(f, nil)) cmd.AddCommand(cmdTransfer.NewCmdTransfer(f, nil)) + cmd.AddCommand(cmdDevelop.NewCmdDevelop(f, nil)) cmd.AddCommand(cmdPin.NewCmdPin(f, nil)) cmd.AddCommand(cmdUnpin.NewCmdUnpin(f, nil)) diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index 552820cc3..3c38a3027 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -63,6 +63,22 @@ func issueMetadataFromURL(s string) (int, ghrepo.Interface) { return issueNumber, repo } +// Returns the issue number and repo if the issue URL is provided. +// If only the issue number is provided, returns the number and nil repo. +func IssueNumberAndRepoFromArg(arg string) (int, ghrepo.Interface, error) { + issueNumber, baseRepo := issueMetadataFromURL(arg) + + if issueNumber == 0 { + var err error + issueNumber, err = strconv.Atoi(strings.TrimPrefix(arg, "#")) + if err != nil { + return 0, nil, fmt.Errorf("invalid issue format: %q", arg) + } + } + + return issueNumber, baseRepo, nil +} + type PartialLoadError struct { error }