Merge remote-tracking branch 'origin' into go-gh-term

This commit is contained in:
Mislav Marohnić 2022-10-31 15:33:24 +01:00
commit 38d465b1da
No known key found for this signature in database
7 changed files with 1134 additions and 4 deletions

View file

@ -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

View file

@ -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
}

View file

@ -66,6 +66,7 @@ func mainRun() exitCode {
stderr := cmdFactory.IOStreams.ErrOut
if !cmdFactory.IOStreams.ColorEnabled() {
surveyCore.DisableColor = true
ansi.DisableColors(true)
} else {
// override survey's poor choice of color
surveyCore.TemplateFuncsWithColor["color"] = func(style string) string {

View file

@ -0,0 +1,286 @@
package develop
import (
ctx "context"
"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/tableprinter"
"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/spf13/cobra"
)
type DevelopOptions struct {
HttpClient func() (*http.Client, error)
GitClient *git.Client
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,
GitClient: f.GitClient,
Config: f.Config,
BaseRepo: f.BaseRepo,
Remotes: f.Remotes,
}
cmd := &cobra.Command{
Use: "develop [flags] {<number> | <url>}",
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 := tableprinter.New(io)
for _, branch := range branches {
table.AddField(branch.BranchName, tableprinter.WithColor(cs.ColorFromString("cyan")))
if io.CanPrompt() {
table.AddField(branch.Url())
}
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 opts.GitClient.HasLocalBranch(ctx.Background(), checkoutBranch) {
if err := opts.GitClient.CheckoutBranch(ctx.Background(), checkoutBranch); err != nil {
return err
}
} else {
gitFetch, err := opts.GitClient.Command(ctx.Background(), "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 = gitFetch.Run()
if err != nil {
return err
}
if err := opts.GitClient.CheckoutNewBranch(ctx.Background(), baseRemote.Name, checkoutBranch); err != nil {
return err
}
}
if err := opts.GitClient.Pull(ctx.Background(), 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
}

View file

@ -0,0 +1,606 @@
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
}
opts.GitClient = &git.Client{GitPath: "some/path/git"}
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())
}
})
}
}

View file

@ -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))

View file

@ -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
}