From 289469565281dd8834621e303f23ec5aaaf00d9e Mon Sep 17 00:00:00 2001 From: Wing-Kam Wong Date: Sun, 26 Mar 2023 14:11:03 +0800 Subject: [PATCH 01/65] feat: add attempt flag to run view --- pkg/cmd/run/view/view.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 895823ade..43c7b351c 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -74,6 +74,7 @@ type ViewOptions struct { Log bool LogFailed bool Web bool + Attempt uint64 Prompt bool Exporter cmdutil.Exporter @@ -93,7 +94,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "view []", Short: "View a summary of a workflow run", - Args: cobra.MaximumNArgs(1), + Args: cobra.MaximumNArgs(2), Example: heredoc.Doc(` # Interactively select a run to view, optionally selecting a single job $ gh run view @@ -101,6 +102,9 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman # View a specific run $ gh run view 12345 + # View a specific run with specific attempt number + $ gh run view 12345 --attempt 3 + # View a specific job within a run $ gh run view --job 456789 @@ -153,6 +157,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd.Flags().BoolVar(&opts.Log, "log", false, "View full log for either a run or specific job") cmd.Flags().BoolVar(&opts.LogFailed, "log-failed", false, "View the log for any failed steps in a run or specific job") cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open run in the browser") + cmd.Flags().Uint64VarP(&opts.Attempt, "attempt", "a", 0, "The attempt number of the workflow run") cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.SingleRunFields) return cmd @@ -172,6 +177,7 @@ func runView(opts *ViewOptions) error { jobID := opts.JobID runID := opts.RunID + attempt := opts.Attempt var selectedJob *shared.Job var run *shared.Run var jobs []shared.Job @@ -206,7 +212,7 @@ func runView(opts *ViewOptions) error { } opts.IO.StartProgressIndicator() - run, err = shared.GetRun(client, repo, runID) + run, err = shared.GetRun(client, repo, runID, attempt) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get run: %w", err) From bd0f535dea139d2586744396e806f65ff5d2c52c Mon Sep 17 00:00:00 2001 From: Wing-Kam Wong Date: Sun, 26 Mar 2023 14:11:19 +0800 Subject: [PATCH 02/65] feat: pass 0 to fetch latest attempt --- pkg/cmd/run/cancel/cancel.go | 2 +- pkg/cmd/run/rerun/rerun.go | 2 +- pkg/cmd/run/watch/watch.go | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go index 39891986c..ca4521b1d 100644 --- a/pkg/cmd/run/cancel/cancel.go +++ b/pkg/cmd/run/cancel/cancel.go @@ -95,7 +95,7 @@ func runCancel(opts *CancelOptions) error { } } } else { - run, err = shared.GetRun(client, repo, runID) + run, err = shared.GetRun(client, repo, runID, 0) if err != nil { var httpErr api.HTTPError if errors.As(err, &httpErr) { diff --git a/pkg/cmd/run/rerun/rerun.go b/pkg/cmd/run/rerun/rerun.go index f439a2304..2522e632c 100644 --- a/pkg/cmd/run/rerun/rerun.go +++ b/pkg/cmd/run/rerun/rerun.go @@ -139,7 +139,7 @@ func runRerun(opts *RerunOptions) error { } } else { opts.IO.StartProgressIndicator() - run, err := shared.GetRun(client, repo, runID) + run, err := shared.GetRun(client, repo, runID, 0) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get run: %w", err) diff --git a/pkg/cmd/run/watch/watch.go b/pkg/cmd/run/watch/watch.go index d56c42405..0d31f8299 100644 --- a/pkg/cmd/run/watch/watch.go +++ b/pkg/cmd/run/watch/watch.go @@ -114,7 +114,7 @@ func watchRun(opts *WatchOptions) error { } } } else { - run, err = shared.GetRun(client, repo, runID) + run, err = shared.GetRun(client, repo, runID, 0) if err != nil { return fmt.Errorf("failed to get run: %w", err) } @@ -201,7 +201,7 @@ func renderRun(out io.Writer, opts WatchOptions, client *api.Client, repo ghrepo var err error - run, err = shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID)) + run, err = shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID), 0) if err != nil { return nil, fmt.Errorf("failed to get run: %w", err) } From 9a0f7916c3387d983e9b23a83c98da48efe40a21 Mon Sep 17 00:00:00 2001 From: Wing-Kam Wong Date: Sun, 26 Mar 2023 14:11:42 +0800 Subject: [PATCH 03/65] feat: update GetRun path based on attempt --- pkg/cmd/run/shared/shared.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index a1a71c4f9..cfee75705 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -449,10 +449,15 @@ func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) { return fmt.Sprintf("%d", runs[selected].ID), nil } -func GetRun(client *api.Client, repo ghrepo.Interface, runID string) (*Run, error) { +func GetRun(client *api.Client, repo ghrepo.Interface, runID string, attempt uint64) (*Run, error) { var result Run + var path string - path := fmt.Sprintf("repos/%s/actions/runs/%s?exclude_pull_requests=true", ghrepo.FullName(repo), runID) + if attempt == 0 { + path = fmt.Sprintf("repos/%s/actions/runs/%s?exclude_pull_requests=true", ghrepo.FullName(repo), runID) + } else { + path = fmt.Sprintf("repos/%s/actions/runs/%s/attempts/%d?exclude_pull_requests=true", ghrepo.FullName(repo), runID, attempt) + } err := client.REST(repo.RepoHost(), "GET", path, nil, &result) if err != nil { From 343e7300168ed4c008b2b952cba741e37d24a6fd Mon Sep 17 00:00:00 2001 From: Wing-Kam Wong Date: Sun, 26 Mar 2023 14:38:05 +0800 Subject: [PATCH 04/65] feat: pass attempt to RenderRunHeader --- pkg/cmd/run/view/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 43c7b351c..10ed4919f 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -324,7 +324,7 @@ func runView(opts *ViewOptions) error { out := opts.IO.Out fmt.Fprintln(out) - fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber)) + fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber, attempt)) fmt.Fprintln(out) if len(jobs) == 0 && run.Conclusion == shared.Failure || run.Conclusion == shared.StartupFailure { From baaa5c3184753ecbd0d15ad75572e322ca72e535 Mon Sep 17 00:00:00 2001 From: Wing-Kam Wong Date: Sun, 26 Mar 2023 14:38:24 +0800 Subject: [PATCH 05/65] feat: add attemptLabel to RenderRunHeader --- pkg/cmd/run/shared/presentation.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/run/shared/presentation.go b/pkg/cmd/run/shared/presentation.go index 900bd07a1..21ee15c25 100644 --- a/pkg/cmd/run/shared/presentation.go +++ b/pkg/cmd/run/shared/presentation.go @@ -7,14 +7,21 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" ) -func RenderRunHeader(cs *iostreams.ColorScheme, run Run, ago, prNumber string) string { +func RenderRunHeader(cs *iostreams.ColorScheme, run Run, ago, prNumber string, attempt uint64) string { title := fmt.Sprintf("%s %s%s", cs.Bold(run.HeadBranch), run.WorkflowName(), prNumber) symbol, symbolColor := Symbol(cs, run.Status, run.Conclusion) id := cs.Cyanf("%d", run.ID) + var attemptLabel string + if attempt == 0 { + attemptLabel = "Latest Attempt" + } else { + attemptLabel = fmt.Sprintf("Attempt #%d", attempt) + } + header := "" - header += fmt.Sprintf("%s %s · %s\n", symbolColor(symbol), title, id) + header += fmt.Sprintf("%s %s · %s (%s)\n", symbolColor(symbol), title, id, attemptLabel) header += fmt.Sprintf("Triggered via %s %s", run.Event, ago) return header From 6c663427425390ab79e60d682d5437993ecf1901 Mon Sep 17 00:00:00 2001 From: Wing-Kam Wong Date: Sun, 26 Mar 2023 14:38:53 +0800 Subject: [PATCH 06/65] feat: pass latest attempt to RenderRunHeader for watch --- pkg/cmd/run/watch/watch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/run/watch/watch.go b/pkg/cmd/run/watch/watch.go index 0d31f8299..05b207693 100644 --- a/pkg/cmd/run/watch/watch.go +++ b/pkg/cmd/run/watch/watch.go @@ -236,7 +236,7 @@ func renderRun(out io.Writer, opts WatchOptions, client *api.Client, repo ghrepo return nil, fmt.Errorf("failed to get annotations: %w", annotationErr) } - fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber)) + fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber, 0)) fmt.Fprintln(out) if len(jobs) == 0 { From 3796b6d6e8641170daed89a2acf71678100d8b95 Mon Sep 17 00:00:00 2001 From: Wing-Kam Wong Date: Sun, 26 Mar 2023 16:10:42 +0800 Subject: [PATCH 07/65] feat: append attempt number in returned run url --- pkg/cmd/run/shared/shared.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index cfee75705..90aae6d9d 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -464,6 +464,10 @@ func GetRun(client *api.Client, repo ghrepo.Interface, runID string, attempt uin return nil, err } + if attempt > 0 { + result.URL += fmt.Sprintf("/attempts/%d", attempt) + } + // Set name to workflow name workflow, err := workflowShared.GetWorkflow(client, repo, result.WorkflowID) if err != nil { From 897a2aa53f3d65fd15974c7f652a02f656240693 Mon Sep 17 00:00:00 2001 From: Wing-Kam Wong Date: Sun, 26 Mar 2023 16:20:57 +0800 Subject: [PATCH 08/65] refactor: don't show latest attempt --- pkg/cmd/run/shared/presentation.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/run/shared/presentation.go b/pkg/cmd/run/shared/presentation.go index 21ee15c25..2ec149729 100644 --- a/pkg/cmd/run/shared/presentation.go +++ b/pkg/cmd/run/shared/presentation.go @@ -13,15 +13,13 @@ func RenderRunHeader(cs *iostreams.ColorScheme, run Run, ago, prNumber string, a symbol, symbolColor := Symbol(cs, run.Status, run.Conclusion) id := cs.Cyanf("%d", run.ID) - var attemptLabel string - if attempt == 0 { - attemptLabel = "Latest Attempt" - } else { - attemptLabel = fmt.Sprintf("Attempt #%d", attempt) + attemptLabel := "" + if attempt > 0 { + attemptLabel = fmt.Sprintf(" (Attempt #%d)", attempt) } header := "" - header += fmt.Sprintf("%s %s · %s (%s)\n", symbolColor(symbol), title, id, attemptLabel) + header += fmt.Sprintf("%s %s · %s%s\n", symbolColor(symbol), title, id, attemptLabel) header += fmt.Sprintf("Triggered via %s %s", run.Event, ago) return header From a4c77ffd92489bda7c1505f9055cb230f25f6691 Mon Sep 17 00:00:00 2001 From: Wing-Kam Wong Date: Sun, 26 Mar 2023 19:12:21 +0800 Subject: [PATCH 09/65] feat: include attempt in getRunLog --- pkg/cmd/run/view/view.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 10ed4919f..9470b0001 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -277,7 +277,7 @@ func runView(opts *ViewOptions) error { } opts.IO.StartProgressIndicator() - runLogZip, err := getRunLog(opts.RunLogCache, httpClient, repo, run) + runLogZip, err := getRunLog(opts.RunLogCache, httpClient, repo, run, attempt) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get run log: %w", err) @@ -431,13 +431,19 @@ func getLog(httpClient *http.Client, logURL string) (io.ReadCloser, error) { return resp.Body, nil } -func getRunLog(cache runLogCache, httpClient *http.Client, repo ghrepo.Interface, run *shared.Run) (*zip.ReadCloser, error) { +func getRunLog(cache runLogCache, httpClient *http.Client, repo ghrepo.Interface, run *shared.Run, attempt uint64) (*zip.ReadCloser, error) { filename := fmt.Sprintf("run-log-%d-%d.zip", run.ID, run.StartedTime().Unix()) filepath := filepath.Join(os.TempDir(), "gh-cli-cache", filename) if !cache.Exists(filepath) { // Run log does not exist in cache so retrieve and store it - logURL := fmt.Sprintf("%srepos/%s/actions/runs/%d/logs", - ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), run.ID) + var logURL string + if attempt == 0 { + logURL = fmt.Sprintf("%srepos/%s/actions/runs/%d/logs", + ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), run.ID) + } else { + logURL = fmt.Sprintf("%srepos/%s/actions/runs/%d/attempts/%d/logs", + ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), run.ID, attempt) + } resp, err := getLog(httpClient, logURL) if err != nil { From 0fa168b80bcf9dc8cd99c9e410c12cc016f21ece Mon Sep 17 00:00:00 2001 From: Wing-Kam Wong Date: Sun, 26 Mar 2023 19:12:40 +0800 Subject: [PATCH 10/65] feat: add tests for attempt flag --- pkg/cmd/run/view/view_test.go | 249 ++++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index ee1fca6ee..99a9ef30e 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -117,6 +117,14 @@ func TestNewCmdView(t *testing.T) { JobID: "4567", }, }, + { + name: "run id with attempt", + cli: "1234 --attempt 2", + wants: ViewOptions{ + RunID: "1234", + Attempt: 2, + }, + }, } for _, tt := range tests { @@ -154,6 +162,7 @@ func TestNewCmdView(t *testing.T) { assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt) assert.Equal(t, tt.wants.ExitStatus, gotOpts.ExitStatus) assert.Equal(t, tt.wants.Verbose, gotOpts.Verbose) + assert.Equal(t, tt.wants.Attempt, gotOpts.Attempt) }) } } @@ -213,6 +222,50 @@ func TestViewRun(t *testing.T) { }, wantOut: "\n✓ trunk CI #2898 · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about the job, try: gh run view --job=10\nView this run on GitHub: https://github.com/runs/3\n", }, + { + name: "associate with PR with attempt", + tty: true, + opts: &ViewOptions{ + RunID: "3", + Attempt: 3, + Prompt: false, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/artifacts"), + httpmock.StringResponse(`{}`)) + reg.Register( + httpmock.GraphQL(`query PullRequestForRun`), + httpmock.StringResponse(`{"data": { + "repository": { + "pullRequests": { + "nodes": [ + {"number": 2898, + "headRepository": { + "owner": { + "login": "OWNER" + }, + "name": "REPO"}} + ]}}}}`)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"), + httpmock.JSONResponse([]shared.Annotation{})) + }, + wantOut: "\n✓ trunk CI #2898 · 3 (Attempt #3)\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about the job, try: gh run view --job=10\nView this run on GitHub: https://github.com/runs/3/attempts/3\n", + }, { name: "exit status, failed run", opts: &ViewOptions{ @@ -291,6 +344,52 @@ func TestViewRun(t *testing.T) { View this run on GitHub: https://github.com/runs/3 `), }, + { + name: "with artifacts and attempt", + opts: &ViewOptions{ + RunID: "3", + Attempt: 3, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/artifacts"), + httpmock.JSONResponse(map[string][]shared.Artifact{ + "artifacts": { + shared.Artifact{Name: "artifact-1", Expired: false}, + shared.Artifact{Name: "artifact-2", Expired: true}, + shared.Artifact{Name: "artifact-3", Expired: false}, + }, + })) + reg.Register( + httpmock.GraphQL(`query PullRequestForRun`), + httpmock.StringResponse(``)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{})) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: heredoc.Doc(` + + ✓ trunk CI · 3 (Attempt #3) + Triggered via push about 59 minutes ago + + JOBS + + + ARTIFACTS + artifact-1 + artifact-2 (expired) + artifact-3 + + For more information about a job, try: gh run view --job= + View this run on GitHub: https://github.com/runs/3/attempts/3 + `), + }, { name: "exit status, successful run", opts: &ViewOptions{ @@ -323,6 +422,39 @@ func TestViewRun(t *testing.T) { }, wantOut: "\n✓ trunk CI · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about the job, try: gh run view --job=10\nView this run on GitHub: https://github.com/runs/3\n", }, + { + name: "exit status, successful run, with attempt", + opts: &ViewOptions{ + RunID: "3", + Attempt: 3, + ExitStatus: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/artifacts"), + httpmock.StringResponse(`{}`)) + reg.Register( + httpmock.GraphQL(`query PullRequestForRun`), + httpmock.StringResponse(``)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"), + httpmock.JSONResponse([]shared.Annotation{})) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: "\n✓ trunk CI · 3 (Attempt #3)\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about the job, try: gh run view --job=10\nView this run on GitHub: https://github.com/runs/3/attempts/3\n", + }, { name: "verbose", tty: true, @@ -455,6 +587,53 @@ func TestViewRun(t *testing.T) { }, wantOut: coolJobRunLogOutput, }, + { + name: "interactive with log and attempt", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Attempt: 3, + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + shared.FailedJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + askStubs: func(as *prompt.AskStubber) { + //nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt + as.StubOne(2) + //nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt + as.StubOne(1) + }, + wantOut: coolJobRunLogOutput, + }, { name: "noninteractive with log", opts: &ViewOptions{ @@ -477,6 +656,29 @@ func TestViewRun(t *testing.T) { }, wantOut: coolJobRunLogOutput, }, + { + name: "noninteractive with log and attempt", + opts: &ViewOptions{ + JobID: "10", + Attempt: 3, + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10"), + httpmock.JSONResponse(shared.SuccessfulJob)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: coolJobRunLogOutput, + }, { name: "interactive with run log", tty: true, @@ -597,6 +799,53 @@ func TestViewRun(t *testing.T) { }, wantOut: quuxTheBarfLogOutput, }, + { + name: "interactive with log-failed with attempt", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Attempt: 3, + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/attempts/3"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + shared.FailedJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/attempts/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + askStubs: func(as *prompt.AskStubber) { + //nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt + as.StubOne(4) + //nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt + as.StubOne(2) + }, + wantOut: quuxTheBarfLogOutput, + }, { name: "noninteractive with log-failed", opts: &ViewOptions{ From 5a2c24d3632bed5c2a34e0ceedcbe639eddd74b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Mon, 27 Mar 2023 09:18:09 +0800 Subject: [PATCH 11/65] docs: revise GH_CONFIG_DIR content in help_topic.go (#7230) --- pkg/cmd/root/help_topic.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index 42892d76d..4a4d19a51 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -79,8 +79,11 @@ var HelpTopics = map[string]map[string]string{ checks for new releases once every 24 hours and displays an upgrade notice on standard error if a newer version was found. - GH_CONFIG_DIR: the directory where gh will store configuration files. Default: - "$XDG_CONFIG_HOME/gh" or "$HOME/.config/gh". + GH_CONFIG_DIR: the directory where gh will store configuration files. If not specified, + the default value will be one of the following paths (in order of precedence): + - "$XDG_CONFIG_HOME/gh" (if $XDG_CONFIG_HOME is set), + - "$AppData/GitHub CLI" (on Windows if $AppData is set), or + - "$HOME/.config/gh". GH_PROMPT_DISABLED: set to any value to disable interactive prompting in the terminal. From 185ebbe69dcf213356e8b8d75f3c1c08fcd933a4 Mon Sep 17 00:00:00 2001 From: Benjamin Levesque <14175665+benjlevesque@users.noreply.github.com> Date: Mon, 27 Mar 2023 03:36:30 +0200 Subject: [PATCH 12/65] Add a `--fail-fast` option to `pr checks --watch` (#7203) --- pkg/cmd/pr/checks/checks.go | 10 ++++++++++ pkg/cmd/pr/checks/checks_test.go | 33 +++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go index 65df87b15..fb629d497 100644 --- a/pkg/cmd/pr/checks/checks.go +++ b/pkg/cmd/pr/checks/checks.go @@ -30,6 +30,7 @@ type ChecksOptions struct { WebMode bool Interval time.Duration Watch bool + FailFast bool Required bool } @@ -59,6 +60,10 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co return cmdutil.FlagErrorf("argument required when using the `--repo` flag") } + if opts.FailFast && !opts.Watch { + return cmdutil.FlagErrorf("cannot use `--fail-fast` flag without `--watch` flag") + } + intervalChanged := cmd.Flags().Changed("interval") if !opts.Watch && intervalChanged { return cmdutil.FlagErrorf("cannot use `--interval` flag without `--watch` flag") @@ -86,6 +91,7 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to show details about checks") cmd.Flags().BoolVarP(&opts.Watch, "watch", "", false, "Watch checks until they finish") + cmd.Flags().BoolVarP(&opts.FailFast, "fail-fast", "", false, "Exit watch mode on first check failure") cmd.Flags().IntVarP(&interval, "interval", "i", 10, "Refresh interval in seconds when using `--watch` flag") cmd.Flags().BoolVar(&opts.Required, "required", false, "Only show checks that are required") @@ -171,6 +177,10 @@ func checksRun(opts *ChecksOptions) error { break } + if opts.FailFast && counts.Failed > 0 { + break + } + time.Sleep(opts.Interval) checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required) diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go index 3e5c8394a..186c1d324 100644 --- a/pkg/cmd/pr/checks/checks_test.go +++ b/pkg/cmd/pr/checks/checks_test.go @@ -62,6 +62,20 @@ func TestNewCmdChecks(t *testing.T) { cli: "--interval 5", wantsError: "cannot use `--interval` flag without `--watch` flag", }, + { + name: "watch with fail-fast flag", + cli: "--watch --fail-fast", + wants: ChecksOptions{ + Watch: true, + FailFast: true, + Interval: time.Duration(10000000000), + }, + }, + { + name: "fail-fast flag without watch flag", + cli: "--fail-fast", + wantsError: "cannot use `--fail-fast` flag without `--watch` flag", + }, { name: "required flag", cli: "--required", @@ -102,6 +116,7 @@ func TestNewCmdChecks(t *testing.T) { assert.Equal(t, tt.wants.Watch, gotOpts.Watch) assert.Equal(t, tt.wants.Interval, gotOpts.Interval) assert.Equal(t, tt.wants.Required, gotOpts.Required) + assert.Equal(t, tt.wants.FailFast, gotOpts.FailFast) }) } } @@ -111,6 +126,7 @@ func Test_checksRun(t *testing.T) { name string tty bool watch bool + failFast bool required bool httpStubs func(*httpmock.Registry) wantOut string @@ -189,6 +205,20 @@ func Test_checksRun(t *testing.T) { wantOut: "\x1b[?1049hAll checks were successful\n0 failing, 3 successful, 0 skipped, and 0 pending checks\n\n✓ awesome tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n\x1b[?1049lAll checks were successful\n0 failing, 3 successful, 0 skipped, and 0 pending checks\n\n✓ awesome tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n", wantErr: "", }, + { + name: "watch some failing with fail fast", + tty: true, + watch: true, + failFast: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query PullRequestStatusChecks\b`), + httpmock.FileResponse("./fixtures/someFailing.json"), + ) + }, + wantOut: "\x1b[?1049h\x1b[0;0H\x1b[JRefreshing checks status every 0 seconds. Press Ctrl+C to quit.\n\nSome checks were not successful\n1 failing, 1 successful, 0 skipped, and 1 pending checks\n\nX sad tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n* slow tests 1m26s sweet link\n\x1b[?1049lSome checks were not successful\n1 failing, 1 successful, 0 skipped, and 1 pending checks\n\nX sad tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n* slow tests 1m26s sweet link\n", + wantErr: "SilentError", + }, { name: "with statuses", tty: true, @@ -316,6 +346,7 @@ func Test_checksRun(t *testing.T) { SelectorArg: "123", Finder: shared.NewMockFinder("123", response, ghrepo.New("OWNER", "REPO")), Watch: tt.watch, + FailFast: tt.failFast, Required: tt.required, } @@ -381,7 +412,7 @@ func TestChecksRun_web(t *testing.T) { } } -func TestEliminateDupulicates(t *testing.T) { +func TestEliminateDuplicates(t *testing.T) { tests := []struct { name string checkContexts []api.CheckContext From c6a693c459c22b66a2914ba2794e24d22faa8015 Mon Sep 17 00:00:00 2001 From: Federico Guerinoni <41150432+guerinoni@users.noreply.github.com> Date: Mon, 27 Mar 2023 10:24:12 +0200 Subject: [PATCH 13/65] Add `--template` flag for issue create and pr create (#7185) Signed-off-by: Federico Guerinoni Co-authored-by: Alessio Cosenza --- pkg/cmd/issue/create/create.go | 21 +++++- pkg/cmd/issue/create/create_test.go | 33 +++++++++ pkg/cmd/pr/create/create.go | 20 +++++- pkg/cmd/pr/create/create_test.go | 39 ++++++++++ pkg/cmd/pr/shared/templates.go | 19 +++++ pkg/cmd/pr/shared/templates_test.go | 108 ++++++++++++++++++++++++++++ 6 files changed, 234 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 9e14760f5..2414c1807 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -1,6 +1,7 @@ package create import ( + "errors" "fmt" "net/http" @@ -39,6 +40,7 @@ type CreateOptions struct { Labels []string Projects []string Milestone string + Template string } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -91,6 +93,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return cmdutil.FlagErrorf("`--recover` only supported when running interactively") } + if opts.Template != "" && bodyProvided { + return errors.New("`--template` is not supported when using `--body` or `--body-file`") + } + opts.Interactive = !(titleProvided && bodyProvided) if opts.Interactive && !opts.IO.CanPrompt() { @@ -113,6 +119,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the issue to projects by `name`") cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`") cmd.Flags().StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") + cmd.Flags().StringVarP(&opts.Template, "template", "T", "", "Template `name` to use as starting body text") return cmd } @@ -216,9 +223,17 @@ func createRun(opts *CreateOptions) (err error) { if opts.RecoverFile == "" { var template shared.Template - template, err = tpl.Choose() - if err != nil { - return + + if opts.Template != "" { + template, err = tpl.Select(opts.Template) + if err != nil { + return + } + } else { + template, err = tpl.Choose() + if err != nil { + return + } } if template != nil { diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 1b498eebc..dcc4c7618 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -92,6 +92,38 @@ func TestNewCmdCreate(t *testing.T) { Interactive: false, }, }, + { + name: "template from name tty", + tty: true, + cli: `-t mytitle --template "bug report"`, + wantsErr: false, + wantsOpts: CreateOptions{ + Title: "mytitle", + Body: "", + RecoverFile: "", + WebMode: false, + Template: "bug report", + Interactive: true, + }, + }, + { + name: "template from name non-tty", + tty: false, + cli: `-t mytitle --template "bug report"`, + wantsErr: true, + }, + { + name: "template and body", + tty: false, + cli: `-t mytitle --template "bug report" --body "issue body"`, + wantsErr: true, + }, + { + name: "template and body file", + tty: false, + cli: `-t mytitle --template "bug report" --body-file "body_file.md"`, + wantsErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -134,6 +166,7 @@ func TestNewCmdCreate(t *testing.T) { assert.Equal(t, tt.wantsOpts.RecoverFile, opts.RecoverFile) assert.Equal(t, tt.wantsOpts.WebMode, opts.WebMode) assert.Equal(t, tt.wantsOpts.Interactive, opts.Interactive) + assert.Equal(t, tt.wantsOpts.Template, opts.Template) }) } } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index d8f58638e..c427f4d68 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -60,6 +60,7 @@ type CreateOptions struct { Milestone string MaintainerCanModify bool + Template string } type CreateContext struct { @@ -155,6 +156,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts.BodyProvided = true } + if opts.Template != "" && opts.BodyProvided { + return errors.New("`--template` is not supported when using `--body` or `--body-file`") + } + if !opts.IO.CanPrompt() && !opts.WebMode && !opts.Autofill && (!opts.TitleProvided || !opts.BodyProvided) { return cmdutil.FlagErrorf("must provide `--title` and `--body` (or `--fill`) when not running interactively") } @@ -182,6 +187,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co fl.StringVarP(&opts.Milestone, "milestone", "m", "", "Add the pull request to a milestone by `name`") fl.Bool("no-maintainer-edit", false, "Disable maintainer's ability to modify pull request") fl.StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") + fl.StringVarP(&opts.Template, "template", "T", "", "Template `file` to use as starting body text") _ = cmd.RegisterFlagCompletionFunc("reviewer", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { results, err := requestableReviewersForCompletion(opts) @@ -295,9 +301,17 @@ func createRun(opts *CreateOptions) (err error) { if opts.RecoverFile == "" { tpl := shared.NewTemplateManager(client.HTTP(), ctx.BaseRepo, opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true) var template shared.Template - template, err = tpl.Choose() - if err != nil { - return + + if opts.Template != "" { + template, err = tpl.Select(opts.Template) + if err != nil { + return + } + } else { + template, err = tpl.Choose() + if err != nil { + return + } } if template != nil { diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 06100c04d..84b5cf6fa 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -130,6 +130,44 @@ func TestNewCmdCreate(t *testing.T) { MaintainerCanModify: true, }, }, + { + name: "template from file name tty", + tty: true, + cli: "-t mytitle --template bug_fix.md", + wantsErr: false, + wantsOpts: CreateOptions{ + Title: "mytitle", + TitleProvided: true, + Body: "", + BodyProvided: false, + Autofill: false, + RecoverFile: "", + WebMode: false, + IsDraft: false, + BaseBranch: "", + HeadBranch: "", + MaintainerCanModify: true, + Template: "bug_fix.md", + }, + }, + { + name: "template from file name non-tty", + tty: false, + cli: "-t mytitle --template bug_fix.md", + wantsErr: true, + }, + { + name: "template and body", + tty: false, + cli: `-t mytitle --template bug_fix.md --body "pr body"`, + wantsErr: true, + }, + { + name: "template and body file", + tty: false, + cli: "-t mytitle --template bug_fix.md --body-file body_file.md", + wantsErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -178,6 +216,7 @@ func TestNewCmdCreate(t *testing.T) { assert.Equal(t, tt.wantsOpts.MaintainerCanModify, opts.MaintainerCanModify) assert.Equal(t, tt.wantsOpts.BaseBranch, opts.BaseBranch) assert.Equal(t, tt.wantsOpts.HeadBranch, opts.HeadBranch) + assert.Equal(t, tt.wantsOpts.Template, opts.Template) }) } } diff --git a/pkg/cmd/pr/shared/templates.go b/pkg/cmd/pr/shared/templates.go index 998952246..f46a9f1d4 100644 --- a/pkg/cmd/pr/shared/templates.go +++ b/pkg/cmd/pr/shared/templates.go @@ -2,6 +2,7 @@ package shared import ( "context" + "errors" "fmt" "net/http" "time" @@ -199,6 +200,24 @@ func (m *templateManager) Choose() (Template, error) { return m.templates[selectedOption], nil } +func (m *templateManager) Select(name string) (Template, error) { + if err := m.memoizedFetch(); err != nil { + return nil, err + } + + if len(m.templates) == 0 { + return nil, errors.New("no templates found") + } + + for _, t := range m.templates { + if t.Name() == name { + return t, nil + } + } + + return nil, fmt.Errorf("template %q not found", name) +} + func (m *templateManager) memoizedFetch() error { if m.didFetch { return m.fetchError diff --git a/pkg/cmd/pr/shared/templates_test.go b/pkg/cmd/pr/shared/templates_test.go index a962bb187..319d9daa5 100644 --- a/pkg/cmd/pr/shared/templates_test.go +++ b/pkg/cmd/pr/shared/templates_test.go @@ -113,3 +113,111 @@ func TestTemplateManager_hasAPI_PullRequest(t *testing.T) { assert.Equal(t, "", tpl.NameForSubmit()) assert.Equal(t, "I fixed a problem", string(tpl.Body())) } + +func TestTemplateManagerSelect(t *testing.T) { + tests := []struct { + name string + isPR bool + templateName string + wantTemplate Template + wantErr bool + errMsg string + httpStubs func(*httpmock.Registry) + }{ + { + name: "no templates found", + templateName: "Bug report", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueTemplates\b`), + httpmock.StringResponse(`{"data":{"repository":{"issueTemplates":[]}}}`), + ) + }, + wantErr: true, + errMsg: "no templates found", + }, + { + name: "no matching templates found", + templateName: "Unknown report", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueTemplates\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTemplates": [ + { "name": "Bug report", "body": "I found a problem" }, + { "name": "Feature request", "body": "I need a feature" } + ] } } }`), + ) + }, + wantErr: true, + errMsg: `template "Unknown report" not found`, + }, + { + name: "matching issue template found", + templateName: "Bug report", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueTemplates\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTemplates": [ + { "name": "Bug report", "body": "I found a problem" }, + { "name": "Feature request", "body": "I need a feature" } + ] } } }`), + ) + }, + wantTemplate: &issueTemplate{ + Gname: "Bug report", + Gbody: "I found a problem", + }, + }, + { + name: "matching pull request template found", + isPR: true, + templateName: "feature.md", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query PullRequestTemplates\b`), + httpmock.StringResponse(` + { "data": { "repository": { "PullRequestTemplates": [ + { "filename": "bug.md", "body": "I fixed a problem" }, + { "filename": "feature.md", "body": "I made a feature" } + ] } } }`), + ) + }, + wantTemplate: &pullRequestTemplate{ + Gname: "feature.md", + Gbody: "I made a feature", + }, + }, + } + + 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) + } + + m := templateManager{ + repo: ghrepo.NewWithHost("OWNER", "REPO", "example.com"), + allowFS: false, + isPR: tt.isPR, + httpClient: &http.Client{Transport: reg}, + detector: &fd.EnabledDetectorMock{}, + } + + tmpl, err := m.Select(tt.templateName) + + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + } else { + assert.NoError(t, err) + } + if tt.wantTemplate != nil { + assert.Equal(t, tt.wantTemplate.Name(), tmpl.Name()) + assert.Equal(t, tt.wantTemplate.Body(), tmpl.Body()) + } + }) + } +} From cc554135e826a1acfe49202c162d457377d946fe Mon Sep 17 00:00:00 2001 From: Wing-Kam Wong Date: Tue, 28 Mar 2023 21:16:04 +0800 Subject: [PATCH 14/65] fix: change MaximumNArgs from 2 to 1 in run view.go --- pkg/cmd/run/view/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 9470b0001..b276cce77 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -94,7 +94,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "view []", Short: "View a summary of a workflow run", - Args: cobra.MaximumNArgs(2), + Args: cobra.MaximumNArgs(1), Example: heredoc.Doc(` # Interactively select a run to view, optionally selecting a single job $ gh run view From 48ed914d585e43ece728125d902d0606aefcae0c Mon Sep 17 00:00:00 2001 From: Wing-Kam Wong Date: Tue, 28 Mar 2023 21:21:11 +0800 Subject: [PATCH 15/65] refactor: set the default case first --- pkg/cmd/run/shared/shared.go | 7 +++---- pkg/cmd/run/view/view.go | 9 ++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index 90aae6d9d..87bbb907c 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -451,11 +451,10 @@ func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) { func GetRun(client *api.Client, repo ghrepo.Interface, runID string, attempt uint64) (*Run, error) { var result Run - var path string - if attempt == 0 { - path = fmt.Sprintf("repos/%s/actions/runs/%s?exclude_pull_requests=true", ghrepo.FullName(repo), runID) - } else { + path := fmt.Sprintf("repos/%s/actions/runs/%s?exclude_pull_requests=true", ghrepo.FullName(repo), runID) + + if attempt > 0 { path = fmt.Sprintf("repos/%s/actions/runs/%s/attempts/%d?exclude_pull_requests=true", ghrepo.FullName(repo), runID, attempt) } diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index b276cce77..34c5fcbc7 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -436,11 +436,10 @@ func getRunLog(cache runLogCache, httpClient *http.Client, repo ghrepo.Interface filepath := filepath.Join(os.TempDir(), "gh-cli-cache", filename) if !cache.Exists(filepath) { // Run log does not exist in cache so retrieve and store it - var logURL string - if attempt == 0 { - logURL = fmt.Sprintf("%srepos/%s/actions/runs/%d/logs", - ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), run.ID) - } else { + logURL := fmt.Sprintf("%srepos/%s/actions/runs/%d/logs", + ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), run.ID) + + if attempt > 0 { logURL = fmt.Sprintf("%srepos/%s/actions/runs/%d/attempts/%d/logs", ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), run.ID, attempt) } From 0dd8261d2b12d2e0f11c484fcc28f4053e35e1f3 Mon Sep 17 00:00:00 2001 From: Wing-Kam Wong Date: Tue, 28 Mar 2023 21:25:42 +0800 Subject: [PATCH 16/65] refactor: use net/url JoinPath --- pkg/cmd/run/shared/shared.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index 87bbb907c..db347eee5 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -464,7 +464,10 @@ func GetRun(client *api.Client, repo ghrepo.Interface, runID string, attempt uin } if attempt > 0 { - result.URL += fmt.Sprintf("/attempts/%d", attempt) + result.URL, err = url.JoinPath(result.URL, fmt.Sprintf("/attempts/%d", attempt)) + if err != nil { + return nil, err + } } // Set name to workflow name From 1f0e53319e7c06401f0a3b5b8a5fbaa493edd459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=C9=A8=D5=BC=C9=A2=D3=84=D5=A1=D6=85=D5=BC=C9=A2?= Date: Wed, 29 Mar 2023 01:48:38 +0800 Subject: [PATCH 17/65] fix: throw error for non-existing org / repo with non-zero status in repo list (#7240) --- pkg/cmd/repo/list/list.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index f9c0ca8ac..e5b031794 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -154,6 +154,10 @@ func listRun(opts *ListOptions) error { return err } + if opts.Owner != "" && listResult.Owner == "" && !listResult.FromSearch { + return fmt.Errorf("the owner handle %q was not recognized as either a GitHub user or an organization", opts.Owner) + } + if err := opts.IO.StartPager(); err != nil { fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) } From 3b2397811400a6bc5d98f9e66cd95d34c377bb0e Mon Sep 17 00:00:00 2001 From: Marco Carvalho Date: Tue, 28 Mar 2023 13:43:31 -0500 Subject: [PATCH 18/65] repo sync: clearer error message with actionable hint (#7110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mislav Marohnić --- pkg/cmd/repo/sync/sync.go | 2 +- pkg/cmd/repo/sync/sync_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/repo/sync/sync.go b/pkg/cmd/repo/sync/sync.go index eebec4389..1f734b543 100644 --- a/pkg/cmd/repo/sync/sync.go +++ b/pkg/cmd/repo/sync/sync.go @@ -256,7 +256,7 @@ func executeLocalRepoSync(srcRepo ghrepo.Interface, remote string, opts *SyncOpt } if currentBranch == branch { if isDirty, err := git.IsDirty(); err == nil && isDirty { - return fmt.Errorf("can't sync because there are local changes; please stash them before trying again") + return fmt.Errorf("refusing to sync due to uncommitted/untracked local changes\ntip: use `git stash --all` before retrying the sync and run `git stash pop` afterwards") } else if err != nil { return err } diff --git a/pkg/cmd/repo/sync/sync_test.go b/pkg/cmd/repo/sync/sync_test.go index 9e7e5b5b1..90a216607 100644 --- a/pkg/cmd/repo/sync/sync_test.go +++ b/pkg/cmd/repo/sync/sync_test.go @@ -229,7 +229,7 @@ func Test_SyncRun(t *testing.T) { mgc.On("IsDirty").Return(true, nil).Once() }, wantErr: true, - errMsg: "can't sync because there are local changes; please stash them before trying again", + errMsg: "refusing to sync due to uncommitted/untracked local changes\ntip: use `git stash --all` before retrying the sync and run `git stash pop` afterwards", }, { name: "sync local repo with parent - existing branch, non-current", From 944df28f1606427e0414f5187ff3a56d2d4b8c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 29 Mar 2023 11:32:19 +0200 Subject: [PATCH 19/65] repo list: add test for invalid owner error --- pkg/cmd/repo/list/list_test.go | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/pkg/cmd/repo/list/list_test.go b/pkg/cmd/repo/list/list_test.go index e6fb918d0..3a02cbb22 100644 --- a/pkg/cmd/repo/list/list_test.go +++ b/pkg/cmd/repo/list/list_test.go @@ -452,3 +452,40 @@ func TestRepoList_noVisibilityField(t *testing.T) { assert.Equal(t, "", stderr.String()) assert.Equal(t, "", stdout.String()) } + +func TestRepoList_invalidOwner(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + ios.SetStderrTTY(false) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query RepositoryList\b`), + httpmock.StringResponse(`{ "data": { "repositoryOwner": null } }`), + ) + + opts := ListOptions{ + Owner: "nonexist", + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + Now: func() time.Time { + t, _ := time.Parse(time.RFC822, "19 Feb 21 15:00 UTC") + return t + }, + Limit: 30, + Detector: &fd.DisabledDetectorMock{}, + } + + err := listRun(&opts) + assert.EqualError(t, err, `the owner handle "nonexist" was not recognized as either a GitHub user or an organization`) + assert.Equal(t, "", stderr.String()) + assert.Equal(t, "", stdout.String()) +} From 852bc0035474026cc95e842d0708ba251403429a Mon Sep 17 00:00:00 2001 From: Alex Petrov Date: Wed, 29 Mar 2023 18:39:11 -0400 Subject: [PATCH 20/65] feat: allow filtering workflow runs by statuses --- pkg/cmd/run/list/list.go | 3 +++ pkg/cmd/run/list/list_test.go | 25 +++++++++++++++++++++++++ pkg/cmd/run/shared/shared.go | 21 +++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/pkg/cmd/run/list/list.go b/pkg/cmd/run/list/list.go index 2b2b78109..4b085a1a1 100644 --- a/pkg/cmd/run/list/list.go +++ b/pkg/cmd/run/list/list.go @@ -31,6 +31,7 @@ type ListOptions struct { WorkflowSelector string Branch string Actor string + Status string now time.Time } @@ -67,6 +68,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.WorkflowSelector, "workflow", "w", "", "Filter runs by workflow") cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Filter runs by branch") cmd.Flags().StringVarP(&opts.Actor, "user", "u", "", "Filter runs by user who triggered the run") + cmdutil.StringEnumFlag(cmd, &opts.Status, "status", "s", "", shared.AllStatuses, "Filter runs by status") cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.RunFields) _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "branch") @@ -89,6 +91,7 @@ func listRun(opts *ListOptions) error { filters := &shared.FilterOptions{ Branch: opts.Branch, Actor: opts.Actor, + Status: opts.Status, } opts.IO.StartProgressIndicator() diff --git a/pkg/cmd/run/list/list_test.go b/pkg/cmd/run/list/list_test.go index aa7fe993c..96a9ab912 100644 --- a/pkg/cmd/run/list/list_test.go +++ b/pkg/cmd/run/list/list_test.go @@ -69,6 +69,14 @@ func TestNewCmdList(t *testing.T) { Actor: "bak1an", }, }, + { + name: "status", + cli: "--status completed", + wants: ListOptions{ + Limit: defaultLimit, + Status: "completed", + }, + }, } for _, tt := range tests { @@ -394,6 +402,23 @@ func TestListRun(t *testing.T) { }, wantErr: true, }, + { + name: "status filter applied", + opts: &ListOptions{ + Limit: defaultLimit, + Status: "queued", + }, + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{ + "status": []string{"queued"}, + }), + httpmock.JSONResponse(shared.RunsPayload{}), + ) + }, + wantErr: true, + }, } for _, tt := range tests { diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index db347eee5..a165f94a3 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -44,6 +44,23 @@ type Status string type Conclusion string type Level string +var AllStatuses = []string{ + "queued", + "completed", + "in_progress", + "requested", + "waiting", + "action_required", + "cancelled", + "failure", + "neutral", + "skipped", + "stale", + "startup_failure", + "success", + "timed_out", +} + var RunFields = []string{ "name", "displayTitle", @@ -284,6 +301,7 @@ type FilterOptions struct { WorkflowID int64 // avoid loading workflow name separately and use the provided one WorkflowName string + Status string } // GetRunsWithFilter fetches 50 runs from the API and filters them in-memory @@ -326,6 +344,9 @@ func GetRuns(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, lim if opts.Actor != "" { path += fmt.Sprintf("&actor=%s", url.QueryEscape(opts.Actor)) } + if opts.Status != "" { + path += fmt.Sprintf("&status=%s", url.QueryEscape(opts.Status)) + } } var result *RunsPayload From 1fb12a98ea8afcf4dc7a1bf1b4b3a2da8b2d3b4a Mon Sep 17 00:00:00 2001 From: nate smith Date: Wed, 29 Mar 2023 06:25:35 -0700 Subject: [PATCH 21/65] improve test resiliency --- pkg/cmd/run/list/list_test.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/run/list/list_test.go b/pkg/cmd/run/list/list_test.go index 96a9ab912..d33af7167 100644 --- a/pkg/cmd/run/list/list_test.go +++ b/pkg/cmd/run/list/list_test.go @@ -123,6 +123,7 @@ func TestListRun(t *testing.T) { wantErr bool wantOut string wantErrOut string + wantErrMsg string stubs func(*httpmock.Registry) isTTY bool }{ @@ -343,7 +344,8 @@ func TestListRun(t *testing.T) { httpmock.JSONResponse(shared.RunsPayload{}), ) }, - wantErr: true, + wantErr: true, + wantErrMsg: "no runs found", }, { name: "workflow selector", @@ -384,7 +386,8 @@ func TestListRun(t *testing.T) { httpmock.JSONResponse(shared.RunsPayload{}), ) }, - wantErr: true, + wantErr: true, + wantErrMsg: "no runs found", }, { name: "actor filter applied", @@ -400,7 +403,8 @@ func TestListRun(t *testing.T) { httpmock.JSONResponse(shared.RunsPayload{}), ) }, - wantErr: true, + wantErr: true, + wantErrMsg: "no runs found", }, { name: "status filter applied", @@ -417,7 +421,8 @@ func TestListRun(t *testing.T) { httpmock.JSONResponse(shared.RunsPayload{}), ) }, - wantErr: true, + wantErr: true, + wantErrMsg: "no runs found", }, } @@ -441,6 +446,7 @@ func TestListRun(t *testing.T) { err := listRun(tt.opts) if tt.wantErr { assert.Error(t, err) + assert.Equal(t, tt.wantErrMsg, err.Error()) } else { assert.NoError(t, err) } From 36436cb8b8afd1adbe77dc2ae70fd2560d75715c Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Mon, 13 Mar 2023 15:15:38 -0700 Subject: [PATCH 22/65] Retry fetching repo from template Fixes #7055 --- pkg/cmd/repo/create/create.go | 53 +++++++++------- pkg/cmd/repo/create/create_test.go | 98 ++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 22 deletions(-) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 6d6eeef30..105c7f130 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -9,8 +9,10 @@ import ( "path/filepath" "sort" "strings" + "time" "github.com/MakeNowJust/heredoc" + "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" @@ -21,6 +23,10 @@ import ( "github.com/spf13/cobra" ) +type errWithExitCode interface { + ExitCode() int +} + type iprompter interface { Input(string, string) (string, error) Select(string, string, []string) (int, error) @@ -33,6 +39,7 @@ type CreateOptions struct { Config func() (config.Config, error) IO *iostreams.IOStreams Prompter iprompter + BackOff backoff.BackOff Name string Description string @@ -328,7 +335,6 @@ func createFromScratch(opts *CreateOptions) error { InitReadme: opts.AddReadme, } - var templateRepoMainBranch string if opts.Template != "" { var templateRepo ghrepo.Interface apiClient := api.NewClientFromHTTP(httpClient) @@ -352,7 +358,6 @@ func createFromScratch(opts *CreateOptions) error { } input.TemplateRepositoryID = repo.ID - templateRepoMainBranch = repo.DefaultBranchRef.Name } repo, err := repoCreate(httpClient, repoToCreate.RepoHost(), input) @@ -387,17 +392,12 @@ func createFromScratch(opts *CreateOptions) error { remoteURL := ghrepo.FormatRemoteURL(repo, protocol) - if opts.LicenseTemplate == "" && opts.GitIgnoreTemplate == "" { + if opts.LicenseTemplate == "" && opts.GitIgnoreTemplate == "" && opts.Template == "" { // cloning empty repository or template - checkoutBranch := "" - if opts.Template != "" { - // use the template's default branch - checkoutBranch = templateRepoMainBranch - } - if err := localInit(opts.GitClient, remoteURL, repo.RepoName(), checkoutBranch); err != nil { + if err := localInit(opts.GitClient, remoteURL, repo.RepoName()); err != nil { return err } - } else if _, err := opts.GitClient.Clone(context.Background(), remoteURL, []string{}); err != nil { + } else if err := cloneWithRetry(opts, remoteURL); err != nil { return err } } @@ -563,6 +563,25 @@ func createFromLocal(opts *CreateOptions) error { return nil } +func cloneWithRetry(opts *CreateOptions, remoteURL string) error { + // Allow injecting alternative BackOff in tests. + if opts.BackOff == nil { + opts.BackOff = backoff.NewConstantBackOff(3 * time.Second) + } + + ctx := context.Background() + return backoff.Retry(func() error { + _, err := opts.GitClient.Clone(ctx, remoteURL, []string{}) + + var execError errWithExitCode + if errors.As(err, &execError) && execError.ExitCode() == 128 { + return err + } + + return backoff.Permanent(err) + }, backoff.WithContext(backoff.WithMaxRetries(opts.BackOff, 3), ctx)) +} + func sourceInit(gitClient *git.Client, io *iostreams.IOStreams, remoteURL, baseRemote string) error { cs := io.ColorScheme() remoteAdd, err := gitClient.Command(context.Background(), "remote", "add", baseRemote, remoteURL) @@ -620,7 +639,7 @@ func isLocalRepo(gitClient *git.Client) (bool, error) { } // clone the checkout branch to specified path -func localInit(gitClient *git.Client, remoteURL, path, checkoutBranch string) error { +func localInit(gitClient *git.Client, remoteURL, path string) error { ctx := context.Background() gitInit, err := gitClient.Command(ctx, "init", path) if err != nil { @@ -644,17 +663,7 @@ func localInit(gitClient *git.Client, remoteURL, path, checkoutBranch string) er return err } - if checkoutBranch == "" { - return nil - } - - refspec := fmt.Sprintf("+refs/heads/%[1]s:refs/remotes/origin/%[1]s", checkoutBranch) - err = gc.Fetch(ctx, "origin", refspec) - if err != nil { - return err - } - - return gc.CheckoutBranch(ctx, checkoutBranch) + return nil } func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompter) (string, error) { diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 4ba48990b..d773b06f5 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -119,6 +119,18 @@ func TestNewCmdCreate(t *testing.T) { IncludeAllBranches: true, }, }, + { + name: "template with .gitignore", + cli: "template-repo --template mytemplate --gitignore ../.gitignore --public", + wantsErr: true, + errMsg: ".gitignore and license templates are not added when template is provided", + }, + { + name: "template with license", + cli: "template-repo --template mytemplate --license ../.license --public", + wantsErr: true, + errMsg: ".gitignore and license templates are not added when template is provided", + }, } for _, tt := range tests { @@ -557,6 +569,92 @@ func Test_createRun(t *testing.T) { }, wantStdout: "https://github.com/OWNER/REPO\n", }, + { + name: "noninteractive clone from scratch", + opts: &CreateOptions{ + Interactive: false, + Name: "REPO", + Visibility: "PRIVATE", + Clone: true, + }, + tty: false, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation RepositoryCreate\b`), + httpmock.StringResponse(` + { + "data": { + "createRepository": { + "repository": { + "id": "REPOID", + "name": "REPO", + "owner": {"login":"OWNER"}, + "url": "https://github.com/OWNER/REPO" + } + } + } + }`)) + }, + execStubs: func(cs *run.CommandStubber) { + cs.Register(`git init REPO`, 0, "") + cs.Register(`git -C REPO remote add origin https://github.com/OWNER/REPO`, 0, "") + }, + wantStdout: "https://github.com/OWNER/REPO\n", + }, + { + name: "noninteractive create from template", + opts: &CreateOptions{ + Interactive: false, + Name: "REPO", + Visibility: "PRIVATE", + Clone: true, + Template: "mytemplate", + }, + tty: false, + httpStubs: func(reg *httpmock.Registry) { + // Test resolving repo owner from repo name only. + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"OWNER"}}}`)) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "id": "REPOID" + } + } + }`, func(s string, m map[string]interface{}) { + assert.Equal(t, "OWNER", m["owner"]) + assert.Equal(t, "mytemplate", m["name"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"id":"OWNERID"}}}`)) + reg.Register( + httpmock.GraphQL(`mutation CloneTemplateRepository\b`), + httpmock.GraphQLMutation(` + { + "data": { + "cloneTemplateRepository": { + "repository": { + "id": "REPOID", + "name": "REPO", + "owner": {"login":"OWNER"}, + "url": "https://github.com/OWNER/REPO" + } + } + } + }`, func(m map[string]interface{}) { + assert.Equal(t, "REPOID", m["repositoryId"]) + })) + }, + execStubs: func(cs *run.CommandStubber) { + cs.Register(`git clone https://github.com/OWNER/REPO`, 0, "") + }, + wantStdout: "https://github.com/OWNER/REPO\n", + }, } for _, tt := range tests { prompterMock := &prompter.PrompterMock{} From 3085c39a730782dcc4292e76ba24b0449167152b Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Wed, 29 Mar 2023 07:43:21 -0700 Subject: [PATCH 23/65] Specify branch to force exit code --- pkg/cmd/repo/create/create.go | 18 +++++++++++++++--- pkg/cmd/repo/create/create_test.go | 13 ++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 105c7f130..0aae16aac 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -1,9 +1,11 @@ package create import ( + "bytes" "context" "errors" "fmt" + "io" "net/http" "os/exec" "path/filepath" @@ -335,6 +337,7 @@ func createFromScratch(opts *CreateOptions) error { InitReadme: opts.AddReadme, } + var templateRepoMainBranch string if opts.Template != "" { var templateRepo ghrepo.Interface apiClient := api.NewClientFromHTTP(httpClient) @@ -358,6 +361,7 @@ func createFromScratch(opts *CreateOptions) error { } input.TemplateRepositoryID = repo.ID + templateRepoMainBranch = repo.DefaultBranchRef.Name } repo, err := repoCreate(httpClient, repoToCreate.RepoHost(), input) @@ -397,7 +401,7 @@ func createFromScratch(opts *CreateOptions) error { if err := localInit(opts.GitClient, remoteURL, repo.RepoName()); err != nil { return err } - } else if err := cloneWithRetry(opts, remoteURL); err != nil { + } else if err := cloneWithRetry(opts, remoteURL, templateRepoMainBranch); err != nil { return err } } @@ -563,19 +567,27 @@ func createFromLocal(opts *CreateOptions) error { return nil } -func cloneWithRetry(opts *CreateOptions, remoteURL string) error { +func cloneWithRetry(opts *CreateOptions, remoteURL, branch string) error { // Allow injecting alternative BackOff in tests. if opts.BackOff == nil { opts.BackOff = backoff.NewConstantBackOff(3 * time.Second) } + var args []string + if branch != "" { + args = append(args, "--branch", branch) + } + ctx := context.Background() return backoff.Retry(func() error { - _, err := opts.GitClient.Clone(ctx, remoteURL, []string{}) + stderr := &bytes.Buffer{} + _, err := opts.GitClient.Clone(ctx, remoteURL, args, git.WithStderr(stderr)) var execError errWithExitCode if errors.As(err, &execError) && execError.ExitCode() == 128 { return err + } else { + io.Copy(opts.IO.ErrOut, stderr) } return backoff.Permanent(err) diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index d773b06f5..5b5fd72bb 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -6,6 +6,7 @@ import ( "net/http" "testing" + "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/prompter" @@ -602,13 +603,14 @@ func Test_createRun(t *testing.T) { wantStdout: "https://github.com/OWNER/REPO\n", }, { - name: "noninteractive create from template", + name: "noninteractive create from template with retry", opts: &CreateOptions{ Interactive: false, Name: "REPO", Visibility: "PRIVATE", Clone: true, Template: "mytemplate", + BackOff: &backoff.ZeroBackOff{}, }, tty: false, httpStubs: func(reg *httpmock.Registry) { @@ -621,7 +623,10 @@ func Test_createRun(t *testing.T) { httpmock.GraphQLQuery(`{ "data": { "repository": { - "id": "REPOID" + "id": "REPOID", + "defaultBranchRef": { + "name": "main" + } } } }`, func(s string, m map[string]interface{}) { @@ -651,7 +656,9 @@ func Test_createRun(t *testing.T) { })) }, execStubs: func(cs *run.CommandStubber) { - cs.Register(`git clone https://github.com/OWNER/REPO`, 0, "") + // fatal: Remote branch main not found in upstream origin + cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 128, "") + cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 0, "") }, wantStdout: "https://github.com/OWNER/REPO\n", }, From d104926ce218787d0e8547b326a0557b32e85a83 Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Wed, 29 Mar 2023 08:18:05 -0700 Subject: [PATCH 24/65] Resolve lint error --- pkg/cmd/repo/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 0aae16aac..640fee632 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -587,7 +587,7 @@ func cloneWithRetry(opts *CreateOptions, remoteURL, branch string) error { if errors.As(err, &execError) && execError.ExitCode() == 128 { return err } else { - io.Copy(opts.IO.ErrOut, stderr) + _, _ = io.Copy(opts.IO.ErrOut, stderr) } return backoff.Permanent(err) From 82662685e3cf3e93fd0c5c81b6680c6a06491f1d Mon Sep 17 00:00:00 2001 From: Benjamin Chadwick <73488828+bchadwic@users.noreply.github.com> Date: Wed, 29 Mar 2023 09:09:44 -0700 Subject: [PATCH 25/65] Autocomplete branch flags (#6031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mislav Marohnić --- git/client.go | 29 +++++++++++++++++++++++++++++ pkg/cmd/browse/browse.go | 2 ++ pkg/cmd/pr/create/create.go | 2 ++ pkg/cmd/pr/edit/edit.go | 2 ++ pkg/cmd/pr/list/list.go | 3 ++- pkg/cmd/release/create/create.go | 2 ++ pkg/cmd/release/edit/edit.go | 2 ++ pkg/cmd/repo/view/view.go | 2 ++ pkg/cmd/run/list/list.go | 2 ++ pkg/cmd/search/prs/prs.go | 2 ++ pkg/cmd/workflow/run/run.go | 2 ++ pkg/cmd/workflow/view/view.go | 2 ++ pkg/cmdutil/flags.go | 21 +++++++++++++++++++++ 13 files changed, 72 insertions(+), 1 deletion(-) diff --git a/git/client.go b/git/client.go index 43c94fef1..a7d59899e 100644 --- a/git/client.go +++ b/git/client.go @@ -354,6 +354,22 @@ func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool { return err == nil } +func (c *Client) TrackingBranchNames(ctx context.Context, prefix string) []string { + args := []string{"branch", "-r", "--format", "%(refname:strip=3)"} + if prefix != "" { + args = append(args, "--list", fmt.Sprintf("*/%s*", escapeGlob(prefix))) + } + cmd, err := c.Command(ctx, args...) + if err != nil { + return nil + } + output, err := cmd.Output() + if err != nil { + return nil + } + return strings.Split(string(output), "\n") +} + // ToplevelDir returns the top-level directory path of the current repository. func (c *Client) ToplevelDir(ctx context.Context) (string, error) { out, err := c.revParse(ctx, "--show-toplevel") @@ -623,3 +639,16 @@ func populateResolvedRemotes(remotes RemoteSet, resolved []string) { } } } + +var globReplacer = strings.NewReplacer( + "*", `\*`, + "?", `\?`, + "[", `\[`, + "]", `\]`, + "{", `\{`, + "}", `\}`, +) + +func escapeGlob(p string) string { + return globReplacer.Replace(p) +} diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go index 0d8239754..160d72aa2 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -144,6 +144,8 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co 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") + _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "branch") + // Preserve backwards compatibility for when commit flag used to be a boolean flag. cmd.Flags().Lookup("commit").NoOptDefVal = emptyCommitFlag diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index c427f4d68..4fc61f362 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -189,6 +189,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co fl.StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") fl.StringVarP(&opts.Template, "template", "T", "", "Template `file` to use as starting body text") + _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head") + _ = cmd.RegisterFlagCompletionFunc("reviewer", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { results, err := requestableReviewersForCompletion(opts) if err != nil { diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 216d0d6d2..915eba657 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -148,6 +148,8 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the pull request from projects by `name`") cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the pull request belongs to by `name`") + _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base") + for _, flagName := range []string{"add-reviewer", "remove-reviewer"} { _ = cmd.RegisterFlagCompletionFunc(flagName, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { baseRepo, err := f.BaseRepo() diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index a56ac0e5d..cea7c2b19 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -109,9 +109,10 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Filter by assignee") cmd.Flags().StringVarP(&opts.Search, "search", "S", "", "Search pull requests with `query`") cmdutil.NilBoolFlag(cmd, &opts.Draft, "draft", "d", "Filter by draft state") - cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.PullRequestFields) + _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head") + return cmd } diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index e667f65ef..dea14307f 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -177,6 +177,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmdutil.NilBoolFlag(cmd, &opts.IsLatest, "latest", "", "Mark this release as \"Latest\" (default: automatic based on date and version)") cmd.Flags().BoolVarP(&opts.VerifyTag, "verify-tag", "", false, "Abort in case the git tag doesn't already exist in the remote repository") + _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "target") + return cmd } diff --git a/pkg/cmd/release/edit/edit.go b/pkg/cmd/release/edit/edit.go index 89d365a9a..cac9c0e48 100644 --- a/pkg/cmd/release/edit/edit.go +++ b/pkg/cmd/release/edit/edit.go @@ -82,6 +82,8 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman cmd.Flags().StringVar(&opts.TagName, "tag", "", "The name of the tag") cmd.Flags().StringVarP(¬esFile, "notes-file", "F", "", "Read release notes from `file` (use \"-\" to read from standard input)") + _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "target") + return cmd } diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go index 722301397..3351d9cd9 100644 --- a/pkg/cmd/repo/view/view.go +++ b/pkg/cmd/repo/view/view.go @@ -68,6 +68,8 @@ With '--branch', view a specific branch of the repository.`, cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "View a specific branch of the repository") cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.RepositoryFields) + _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "branch") + return cmd } diff --git a/pkg/cmd/run/list/list.go b/pkg/cmd/run/list/list.go index 0fe685110..2b2b78109 100644 --- a/pkg/cmd/run/list/list.go +++ b/pkg/cmd/run/list/list.go @@ -69,6 +69,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.Actor, "user", "u", "", "Filter runs by user who triggered the run") cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.RunFields) + _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "branch") + return cmd } diff --git a/pkg/cmd/search/prs/prs.go b/pkg/cmd/search/prs/prs.go index 2fd4961ff..2ef37b2ef 100644 --- a/pkg/cmd/search/prs/prs.go +++ b/pkg/cmd/search/prs/prs.go @@ -184,5 +184,7 @@ func NewCmdPrs(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobr cmd.Flags().StringVar(&opts.Query.Qualifiers.ReviewedBy, "reviewed-by", "", "Filter on `user` who reviewed") cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.Status, "checks", "", "", []string{"pending", "success", "failure"}, "Filter based on status of the checks") + _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head") + return cmd } diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index ca703027a..564b41985 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -128,6 +128,8 @@ func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format") cmd.Flags().BoolVar(&opts.JSON, "json", false, "Read workflow inputs as JSON via STDIN") + _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "ref") + return cmd } diff --git a/pkg/cmd/workflow/view/view.go b/pkg/cmd/workflow/view/view.go index 52a364be8..f45024828 100644 --- a/pkg/cmd/workflow/view/view.go +++ b/pkg/cmd/workflow/view/view.go @@ -85,6 +85,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd.Flags().BoolVarP(&opts.YAML, "yaml", "y", false, "View the workflow yaml file") cmd.Flags().StringVarP(&opts.Ref, "ref", "r", "", "The branch or tag name which contains the version of the workflow file you'd like to view") + _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "ref") + return cmd } diff --git a/pkg/cmdutil/flags.go b/pkg/cmdutil/flags.go index 2a73a79de..c0064099c 100644 --- a/pkg/cmdutil/flags.go +++ b/pkg/cmdutil/flags.go @@ -1,6 +1,7 @@ package cmdutil import ( + "context" "fmt" "strconv" "strings" @@ -44,6 +45,26 @@ func StringSliceEnumFlag(cmd *cobra.Command, p *[]string, name, shorthand string return f } +type gitClient interface { + TrackingBranchNames(context.Context, string) []string +} + +// RegisterBranchCompletionFlags suggests and autocompletes known remote git branches for flags passed +func RegisterBranchCompletionFlags(gitc gitClient, cmd *cobra.Command, flags ...string) error { + for _, flag := range flags { + err := cmd.RegisterFlagCompletionFunc(flag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if repoFlag := cmd.Flag("repo"); repoFlag != nil && repoFlag.Changed { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return gitc.TrackingBranchNames(context.TODO(), toComplete), cobra.ShellCompDirectiveNoFileComp + }) + if err != nil { + return err + } + } + return nil +} + func formatValuesForUsageDocs(values []string) string { return fmt.Sprintf("{%s}", strings.Join(values, "|")) } From 66706f0880813d4bbf1411840c0bdd9c64d15002 Mon Sep 17 00:00:00 2001 From: SonicGDX <114670430+SonicGDX@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:36:40 +0100 Subject: [PATCH 26/65] Fix typo in README.md Signed-off-by: SonicGDX<114670430+SonicGDX@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c95722466..38c44c788 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ For more information, see [Linux & BSD installation](./docs/install_linux.md). | `winget install --id GitHub.cli` | `winget upgrade --id GitHub.cli` | > **Note** -> The Windows installer modifes your PATH. When using Windows Terminal, you will need to **open a new window** for the changes to take affect. (Simply opening a new tab will _not_ be sufficient.) +> The Windows installer modifies your PATH. When using Windows Terminal, you will need to **open a new window** for the changes to take affect. (Simply opening a new tab will _not_ be sufficient.) #### scoop From f21f1ca4ef62ee72e13353be557daec813985f44 Mon Sep 17 00:00:00 2001 From: vaindil Date: Wed, 29 Mar 2023 17:23:22 -0400 Subject: [PATCH 27/65] check SSH key existence before uploading --- pkg/cmd/auth/shared/login_flow.go | 6 +- pkg/cmd/auth/shared/login_flow_test.go | 5 +- pkg/cmd/ssh-key/add/add.go | 8 +- pkg/cmd/ssh-key/add/add_test.go | 107 ++++++++++++++---- pkg/cmd/ssh-key/add/http.go | 34 +++++- pkg/cmd/ssh-key/list/list.go | 3 +- .../{list/http.go => shared/user_keys.go} | 4 +- 7 files changed, 130 insertions(+), 37 deletions(-) rename pkg/cmd/ssh-key/{list/http.go => shared/user_keys.go} (91%) diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 837ff0d64..4f2cb9e0c 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -200,7 +200,7 @@ func Login(opts *LoginOptions) error { } if keyToUpload != "" { - err := sshKeyUpload(httpClient, hostname, keyToUpload, keyTitle) + err := sshKeyUpload(httpClient, hostname, keyToUpload, keyTitle, opts.IO) if err != nil { return err } @@ -223,14 +223,14 @@ func scopesSentence(scopes []string, isEnterprise bool) string { return strings.Join(quoted, ", ") } -func sshKeyUpload(httpClient *http.Client, hostname, keyFile string, title string) error { +func sshKeyUpload(httpClient *http.Client, hostname, keyFile string, title string, ios *iostreams.IOStreams) error { f, err := os.Open(keyFile) if err != nil { return err } defer f.Close() - return add.SSHKeyUpload(httpClient, hostname, f, title) + return add.SSHKeyUpload(httpClient, hostname, f, title, false, ios) } func getCurrentLogin(httpClient httpClient, hostname, authToken string) (string, error) { diff --git a/pkg/cmd/auth/shared/login_flow_test.go b/pkg/cmd/auth/shared/login_flow_test.go index f01ba46ff..92ae75915 100644 --- a/pkg/cmd/auth/shared/login_flow_test.go +++ b/pkg/cmd/auth/shared/login_flow_test.go @@ -38,6 +38,9 @@ func TestLogin_ssh(t *testing.T) { tr.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{ "login": "monalisa" }}}`)) + tr.Register( + httpmock.REST("GET", "api/v3/user/keys"), + httpmock.StringResponse(`[]`)) tr.Register( httpmock.REST("POST", "api/v3/user/keys"), httpmock.StringResponse(`{}`)) @@ -78,7 +81,7 @@ func TestLogin_ssh(t *testing.T) { } assert.Equal(t, expected, args) // simulate that the public key file has been generated - _ = os.WriteFile(keyFile+".pub", []byte("PUBKEY"), 0600) + _ = os.WriteFile(keyFile+".pub", []byte("PUBKEY asdf"), 0600) }) cfg := tinyConfig{} diff --git a/pkg/cmd/ssh-key/add/add.go b/pkg/cmd/ssh-key/add/add.go index e3f5088d2..df977b291 100644 --- a/pkg/cmd/ssh-key/add/add.go +++ b/pkg/cmd/ssh-key/add/add.go @@ -1,7 +1,6 @@ package add import ( - "fmt" "io" "net/http" "os" @@ -79,14 +78,11 @@ func runAdd(opts *AddOptions) error { hostname, _ := cfg.Authentication().DefaultHost() - err = SSHKeyUpload(httpClient, hostname, keyReader, opts.Title) + err = SSHKeyUpload(httpClient, hostname, keyReader, opts.Title, true, opts.IO) if err != nil { return err } - if opts.IO.IsStdoutTTY() { - cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.ErrOut, "%s Public key added to your account\n", cs.SuccessIcon()) - } + // SSHKeyUpload prints the success message. return nil } diff --git a/pkg/cmd/ssh-key/add/add_test.go b/pkg/cmd/ssh-key/add/add_test.go index 565528ddd..8497dc991 100644 --- a/pkg/cmd/ssh-key/add/add_test.go +++ b/pkg/cmd/ssh-key/add/add_test.go @@ -11,33 +11,94 @@ import ( ) func Test_runAdd(t *testing.T) { - ios, stdin, stdout, stderr := iostreams.Test() - ios.SetStdinTTY(false) - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) + tests := []struct { + name string + stdin string + opts AddOptions + httpStubs func(*httpmock.Registry) + wantStdout string + wantStderr string + wantErrMsg string + }{ + { + name: "valid key format, not already in use", + stdin: "ssh-ed25519 asdf", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse("[]")) + reg.Register( + httpmock.REST("POST", "user/keys"), + httpmock.RESTPayload(200, ``, func(payload map[string]interface{}) { + assert.Contains(t, payload, "key") + assert.Empty(t, payload["title"]) + })) + }, + wantStdout: "", + wantStderr: "✓ Public key added to your account\n", + wantErrMsg: "", + opts: AddOptions{KeyFile: "-"}, + }, + { + name: "valid key format, already in use", + stdin: "ssh-ed25519 asdf title", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(`[ + { + "id": 1, + "key": "ssh-ed25519 asdf", + "title": "anything" + } + ]`)) + }, + wantStdout: "", + wantStderr: "✓ Public key already exists on your account\n", + wantErrMsg: "", + opts: AddOptions{KeyFile: "-"}, + }, + { + name: "invalid key format", + stdin: "ssh-ed25519", + wantStdout: "", + wantStderr: "X Error: provided key is not in a valid format\n", + wantErrMsg: "SilentError", + opts: AddOptions{KeyFile: "-"}, + }, + } - stdin.WriteString("PUBKEY") + for _, tt := range tests { + ios, stdin, stdout, stderr := iostreams.Test() + ios.SetStdinTTY(false) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) - tr := httpmock.Registry{} - defer tr.Verify(t) + stdin.WriteString(tt.stdin) - tr.Register( - httpmock.REST("POST", "user/keys"), - httpmock.StringResponse(`{}`)) + reg := &httpmock.Registry{} - err := runAdd(&AddOptions{ - IO: ios, - Config: func() (config.Config, error) { + tt.opts.IO = ios + tt.opts.HTTPClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + tt.opts.Config = func() (config.Config, error) { return config.NewBlankConfig(), nil - }, - HTTPClient: func() (*http.Client, error) { - return &http.Client{Transport: &tr}, nil - }, - KeyFile: "-", - Title: "my sacred key", - }) - assert.NoError(t, err) + } - assert.Equal(t, "", stdout.String()) - assert.Equal(t, "✓ Public key added to your account\n", stderr.String()) + t.Run(tt.name, func(t *testing.T) { + defer reg.Verify(t) + err := runAdd(&tt.opts) + if tt.wantErrMsg != "" { + assert.Equal(t, tt.wantErrMsg, err.Error()) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } } diff --git a/pkg/cmd/ssh-key/add/http.go b/pkg/cmd/ssh-key/add/http.go index 1b8b7b245..9a603393d 100644 --- a/pkg/cmd/ssh-key/add/http.go +++ b/pkg/cmd/ssh-key/add/http.go @@ -3,21 +3,50 @@ package add import ( "bytes" "encoding/json" + "fmt" "io" "net/http" + "strings" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/pkg/cmd/ssh-key/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" ) -func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) error { +func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string, printSuccessMsgs bool, ios *iostreams.IOStreams) error { url := ghinstance.RESTPrefix(hostname) + "user/keys" + cs := ios.ColorScheme() keyBytes, err := io.ReadAll(keyFile) if err != nil { return err } + userKey := string(keyBytes) + splitKey := strings.Fields(userKey) + if len(splitKey) < 2 { + fmt.Fprintf(ios.ErrOut, "%s Error: provided key is not in a valid format\n", cs.FailureIcon()) + return cmdutil.SilentError + } + + userKey = splitKey[0] + " " + splitKey[1] + + keys, err := shared.UserKeys(httpClient, hostname, "") + if err != nil { + return err + } + + for _, k := range keys { + if k.Key == userKey { + if printSuccessMsgs && ios.IsStdoutTTY() { + fmt.Fprintf(ios.ErrOut, "%s Public key already exists on your account\n", cs.SuccessIcon()) + } + return nil + } + } + payload := map[string]string{ "title": title, "key": string(keyBytes), @@ -48,5 +77,8 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t return err } + if printSuccessMsgs { + fmt.Fprintf(ios.ErrOut, "%s Public key added to your account\n", cs.SuccessIcon()) + } return nil } diff --git a/pkg/cmd/ssh-key/list/list.go b/pkg/cmd/ssh-key/list/list.go index d057b198c..0c0cbcd0b 100644 --- a/pkg/cmd/ssh-key/list/list.go +++ b/pkg/cmd/ssh-key/list/list.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/ssh-key/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/utils" @@ -55,7 +56,7 @@ func listRun(opts *ListOptions) error { host, _ := cfg.Authentication().DefaultHost() - sshKeys, err := userKeys(apiClient, host, "") + sshKeys, err := shared.UserKeys(apiClient, host, "") if err != nil { return err } diff --git a/pkg/cmd/ssh-key/list/http.go b/pkg/cmd/ssh-key/shared/user_keys.go similarity index 91% rename from pkg/cmd/ssh-key/list/http.go rename to pkg/cmd/ssh-key/shared/user_keys.go index 7275615e0..57f309d19 100644 --- a/pkg/cmd/ssh-key/list/http.go +++ b/pkg/cmd/ssh-key/shared/user_keys.go @@ -1,4 +1,4 @@ -package list +package shared import ( "encoding/json" @@ -18,7 +18,7 @@ type sshKey struct { CreatedAt time.Time `json:"created_at"` } -func userKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) { +func UserKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) { resource := "user/keys" if userHandle != "" { resource = fmt.Sprintf("users/%s/keys", userHandle) From a4b9d41e79b0e188bda76c213c168737a51e8b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Thu, 30 Mar 2023 11:30:12 +0200 Subject: [PATCH 28/65] Improve Amazon Linux install instructions --- docs/install_linux.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/install_linux.md b/docs/install_linux.md index 26ffeda90..0825c3305 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -59,6 +59,7 @@ sudo dnf update gh Install using our package repository for immediate access to latest releases: ```bash +type -p yum-config-manager >/dev/null || sudo yum install yum-utils sudo yum-config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo sudo yum install gh ``` From 409e1555e652fe0b339f9f64d97b231fa7e40bc0 Mon Sep 17 00:00:00 2001 From: Phillipp Bertram Date: Thu, 30 Mar 2023 17:02:04 +0200 Subject: [PATCH 29/65] feat(run): add new delete command --- pkg/cmd/run/delete/delete.go | 148 ++++++++++++++++++++++ pkg/cmd/run/delete/delete_test.go | 196 ++++++++++++++++++++++++++++++ pkg/cmd/run/run.go | 2 + pkg/cmd/run/shared/shared.go | 23 ++++ 4 files changed, 369 insertions(+) create mode 100644 pkg/cmd/run/delete/delete.go create mode 100644 pkg/cmd/run/delete/delete_test.go diff --git a/pkg/cmd/run/delete/delete.go b/pkg/cmd/run/delete/delete.go new file mode 100644 index 000000000..ade0b9d65 --- /dev/null +++ b/pkg/cmd/run/delete/delete.go @@ -0,0 +1,148 @@ +package delete + +import ( + "errors" + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmd/run/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +const ( + defaultLimit = 20 +) + +type DeleteOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Prompter prompter.Prompter + Prompt bool + RunID string +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Prompter: f.Prompter, + } + + cmd := &cobra.Command{ + Use: "delete []", + Short: "Delete a workflow run", + Example: heredoc.Doc(` + # Interactively select a run to delete, optionally selecting a single job + $ gh run delete + + # Delete a specific run + $ gh run delete 12345 + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.RunID = args[0] + } else if !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("run ID required when not running interactively") + } else { + opts.Prompt = true + } + + if runF != nil { + return runF(opts) + } + + return runDelete(opts) + }, + } + + return cmd +} + +func runDelete(opts *DeleteOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("failed to create http client: %w", err) + } + client := api.NewClientFromHTTP(httpClient) + + cs := opts.IO.ColorScheme() + + repo, err := opts.BaseRepo() + if err != nil { + return fmt.Errorf("failed to determine base repo: %w", err) + } + + runID := opts.RunID + var run *shared.Run + + if opts.Prompt { + payload, err := shared.GetRuns(client, repo, nil, defaultLimit) + if err != nil { + return fmt.Errorf("failed to get runs: %w", err) + } + + runs := payload.WorkflowRuns + if len(runs) == 0 { + return fmt.Errorf("found no runs to delete") + } + + runID, err = shared.SelectRun(opts.Prompter, cs, runs) + if err != nil { + return err + } + + for _, r := range runs { + if fmt.Sprintf("%d", r.ID) == runID { + run = &r + break + } + } + } else { + run, err = shared.GetRun(client, repo, runID, 0) + if err != nil { + var httpErr api.HTTPError + if errors.As(err, &httpErr) { + if httpErr.StatusCode == http.StatusNotFound { + err = fmt.Errorf("could not find any workflow run with ID %s", opts.RunID) + } + } + return err + } + } + + err = deleteWorkflowRun(client, repo, fmt.Sprintf("%d", run.ID)) + if err != nil { + var httpErr api.HTTPError + if errors.As(err, &httpErr) { + if httpErr.StatusCode == http.StatusConflict { + err = fmt.Errorf("cannot delete a workflow run that is completed") + } + } + + return err + } + + fmt.Fprintf(opts.IO.Out, "%s Request to delete workflow submitted.\n", cs.SuccessIcon()) + + return nil +} + +func deleteWorkflowRun(client *api.Client, repo ghrepo.Interface, runID string) error { + path := fmt.Sprintf("repos/%s/actions/runs/%s", ghrepo.FullName(repo), runID) + err := client.REST(repo.RepoHost(), "DELETE", path, nil, nil) + if err != nil { + return err + } + return nil +} diff --git a/pkg/cmd/run/delete/delete_test.go b/pkg/cmd/run/delete/delete_test.go new file mode 100644 index 000000000..0033cb9e3 --- /dev/null +++ b/pkg/cmd/run/delete/delete_test.go @@ -0,0 +1,196 @@ +package delete + +import ( + "bytes" + "fmt" + "io" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmd/run/shared" + workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdDelete(t *testing.T) { + tests := []struct { + name string + cli string + tty bool + wants DeleteOptions + wantsErr bool + prompterStubs func(*prompter.PrompterMock) + }{ + { + name: "blank tty", + tty: true, + wants: DeleteOptions{ + Prompt: true, + }, + }, + { + name: "blank nontty", + wantsErr: true, + }, + { + name: "with arg", + cli: "1234", + wants: DeleteOptions{ + RunID: "1234", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *DeleteOptions + cmd := NewCmdDelete(f, func(opts *DeleteOptions) error { + gotOpts = opts + return nil + }) + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.wants.RunID, gotOpts.RunID) + }) + } +} + +func TestRunDelete(t *testing.T) { + tests := []struct { + name string + opts *DeleteOptions + httpStubs func(*httpmock.Registry) + prompterStubs func(*prompter.PrompterMock) + wantErr bool + wantOut string + errMsg string + }{ + { + name: "delete run", + opts: &DeleteOptions{ + RunID: "1234", + }, + wantErr: false, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("DELETE", fmt.Sprintf("repos/OWNER/REPO/actions/runs/%d", shared.SuccessfulRun.ID)), + httpmock.StatusStringResponse(204, "")) + }, + wantOut: "✓ Request to delete workflow submitted.\n", + }, + { + name: "not found", + opts: &DeleteOptions{ + RunID: "1234", + }, + wantErr: true, + errMsg: "could not find any workflow run with ID 1234", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.StatusStringResponse(404, "")) + }, + }, + { + name: "prompt", + opts: &DeleteOptions{ + Prompt: true, + }, + prompterStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021") + } + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + TotalCount: 0, + WorkflowRuns: []shared.Run{shared.SuccessfulRun}, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse( + workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{shared.TestWorkflow}, + }, + )) + reg.Register( + httpmock.REST("DELETE", fmt.Sprintf("repos/OWNER/REPO/actions/runs/%d", shared.SuccessfulRun.ID)), + httpmock.StatusStringResponse(204, "")) + }, + wantOut: "✓ Request to delete workflow submitted.\n", + }, + } + + for _, tt := range tests { + + reg := &httpmock.Registry{} + tt.httpStubs(reg) + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + tt.opts.IO = ios + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + pm := &prompter.PrompterMock{} + if tt.prompterStubs != nil { + tt.prompterStubs(pm) + } + tt.opts.Prompter = pm + + t.Run(tt.name, func(t *testing.T) { + err := runDelete(tt.opts) + if tt.wantErr { + assert.Error(t, err) + if tt.errMsg != "" { + assert.Equal(t, tt.errMsg, err.Error()) + } + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/run/run.go b/pkg/cmd/run/run.go index f65400595..d62f03328 100644 --- a/pkg/cmd/run/run.go +++ b/pkg/cmd/run/run.go @@ -2,6 +2,7 @@ package run import ( cmdCancel "github.com/cli/cli/v2/pkg/cmd/run/cancel" + cmdDelete "github.com/cli/cli/v2/pkg/cmd/run/delete" cmdDownload "github.com/cli/cli/v2/pkg/cmd/run/download" cmdList "github.com/cli/cli/v2/pkg/cmd/run/list" cmdRerun "github.com/cli/cli/v2/pkg/cmd/run/rerun" @@ -26,6 +27,7 @@ func NewCmdRun(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdDownload.NewCmdDownload(f, nil)) cmd.AddCommand(cmdWatch.NewCmdWatch(f, nil)) cmd.AddCommand(cmdCancel.NewCmdCancel(f, nil)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) return cmd } diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index a165f94a3..aba3ca492 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -12,6 +12,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/prompt" @@ -441,6 +442,28 @@ func GetJob(client *api.Client, repo ghrepo.Interface, jobID string) (*Job, erro return &result, nil } +func SelectRun(p prompter.Prompter, cs *iostreams.ColorScheme, runs []Run) (string, error) { + now := time.Now() + + candidates := []string{} + + for _, run := range runs { + symbol, _ := Symbol(cs, run.Status, run.Conclusion) + candidates = append(candidates, + // TODO truncate commit message, long ones look terrible + fmt.Sprintf("%s %s, %s (%s) %s", symbol, run.Title(), run.WorkflowName(), run.HeadBranch, preciseAgo(now, run.StartedTime()))) + } + + selected, err := p.Select("Select a workflow run", "", candidates) + + if err != nil { + return "", err + } + + return fmt.Sprintf("%d", runs[selected].ID), nil +} + +// Deprecated: use SelectRun func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) { var selected int now := time.Now() From 7b3a83157db9fcfc370039a3054307e1b9d2562d Mon Sep 17 00:00:00 2001 From: Phillipp Bertram Date: Thu, 30 Mar 2023 18:06:02 +0200 Subject: [PATCH 30/65] cleanup --- pkg/cmd/run/delete/delete.go | 7 +++---- pkg/cmd/run/delete/delete_test.go | 10 +++++++--- pkg/cmd/run/shared/shared.go | 1 + 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/run/delete/delete.go b/pkg/cmd/run/delete/delete.go index ade0b9d65..b785b5f0f 100644 --- a/pkg/cmd/run/delete/delete.go +++ b/pkg/cmd/run/delete/delete.go @@ -16,7 +16,7 @@ import ( ) const ( - defaultLimit = 20 + defaultRunGetLimit = 10 ) type DeleteOptions struct { @@ -39,7 +39,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co Use: "delete []", Short: "Delete a workflow run", Example: heredoc.Doc(` - # Interactively select a run to delete, optionally selecting a single job + # Interactively select a run to delete $ gh run delete # Delete a specific run @@ -87,7 +87,7 @@ func runDelete(opts *DeleteOptions) error { var run *shared.Run if opts.Prompt { - payload, err := shared.GetRuns(client, repo, nil, defaultLimit) + payload, err := shared.GetRuns(client, repo, nil, defaultRunGetLimit) if err != nil { return fmt.Errorf("failed to get runs: %w", err) } @@ -134,7 +134,6 @@ func runDelete(opts *DeleteOptions) error { } fmt.Fprintf(opts.IO.Out, "%s Request to delete workflow submitted.\n", cs.SuccessIcon()) - return nil } diff --git a/pkg/cmd/run/delete/delete_test.go b/pkg/cmd/run/delete/delete_test.go index 0033cb9e3..3ded01182 100644 --- a/pkg/cmd/run/delete/delete_test.go +++ b/pkg/cmd/run/delete/delete_test.go @@ -158,27 +158,31 @@ func TestRunDelete(t *testing.T) { } for _, tt := range tests { + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + // mock http reg := &httpmock.Registry{} tt.httpStubs(reg) tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + // mock IO ios, _, stdout, _ := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStdinTTY(true) tt.opts.IO = ios - tt.opts.BaseRepo = func() (ghrepo.Interface, error) { - return ghrepo.FromFullName("OWNER/REPO") - } + // mock prompter pm := &prompter.PrompterMock{} if tt.prompterStubs != nil { tt.prompterStubs(pm) } tt.opts.Prompter = pm + // execute test t.Run(tt.name, func(t *testing.T) { err := runDelete(tt.opts) if tt.wantErr { diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index aba3ca492..92f166ed9 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -442,6 +442,7 @@ func GetJob(client *api.Client, repo ghrepo.Interface, jobID string) (*Job, erro return &result, nil } +// SelectRun prompts the user to select a run from a list of runs by using the recommended prompter interface func SelectRun(p prompter.Prompter, cs *iostreams.ColorScheme, runs []Run) (string, error) { now := time.Now() From 32901084bd4953a7fd2128b54cc8f47f82b03cf6 Mon Sep 17 00:00:00 2001 From: Phillipp Bertram Date: Thu, 30 Mar 2023 18:12:25 +0200 Subject: [PATCH 31/65] lint fixes --- pkg/cmd/run/shared/shared.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index 92f166ed9..94a5590a3 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -464,7 +464,7 @@ func SelectRun(p prompter.Prompter, cs *iostreams.ColorScheme, runs []Run) (stri return fmt.Sprintf("%d", runs[selected].ID), nil } -// Deprecated: use SelectRun +// TODO: this should be deprecated in favor of SelectRun func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) { var selected int now := time.Now() From 0e5e7de9af4616a672a4b19acdee4aa0d13b5c67 Mon Sep 17 00:00:00 2001 From: vaindil Date: Thu, 30 Mar 2023 16:36:56 -0400 Subject: [PATCH 32/65] don't parse key bytes twice --- pkg/cmd/ssh-key/add/http.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/ssh-key/add/http.go b/pkg/cmd/ssh-key/add/http.go index 9a603393d..85e4a4dc0 100644 --- a/pkg/cmd/ssh-key/add/http.go +++ b/pkg/cmd/ssh-key/add/http.go @@ -24,14 +24,14 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t return err } - userKey := string(keyBytes) - splitKey := strings.Fields(userKey) + fullUserKey := string(keyBytes) + splitKey := strings.Fields(fullUserKey) if len(splitKey) < 2 { fmt.Fprintf(ios.ErrOut, "%s Error: provided key is not in a valid format\n", cs.FailureIcon()) return cmdutil.SilentError } - userKey = splitKey[0] + " " + splitKey[1] + keyToCompare := splitKey[0] + " " + splitKey[1] keys, err := shared.UserKeys(httpClient, hostname, "") if err != nil { @@ -39,8 +39,8 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t } for _, k := range keys { - if k.Key == userKey { - if printSuccessMsgs && ios.IsStdoutTTY() { + if k.Key == keyToCompare { + if printSuccessMsgs && ios.IsStderrTTY() { fmt.Fprintf(ios.ErrOut, "%s Public key already exists on your account\n", cs.SuccessIcon()) } return nil @@ -49,7 +49,7 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t payload := map[string]string{ "title": title, - "key": string(keyBytes), + "key": fullUserKey, } payloadBytes, err := json.Marshal(payload) @@ -77,7 +77,7 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t return err } - if printSuccessMsgs { + if printSuccessMsgs && ios.IsStderrTTY() { fmt.Fprintf(ios.ErrOut, "%s Public key added to your account\n", cs.SuccessIcon()) } return nil From 7006d3f0a59abe23546757d3dd571be181ac045e Mon Sep 17 00:00:00 2001 From: vaindil Date: Fri, 31 Mar 2023 13:47:25 -0400 Subject: [PATCH 33/65] move SSH key errors out of upload function --- pkg/cmd/auth/shared/login_flow.go | 15 ++++++++----- pkg/cmd/ssh-key/add/add.go | 12 +++++++++-- pkg/cmd/ssh-key/add/add_test.go | 4 ++-- pkg/cmd/ssh-key/add/http.go | 35 ++++++++++++------------------- 4 files changed, 35 insertions(+), 31 deletions(-) diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 4f2cb9e0c..7c2ff1639 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -200,11 +200,16 @@ func Login(opts *LoginOptions) error { } if keyToUpload != "" { - err := sshKeyUpload(httpClient, hostname, keyToUpload, keyTitle, opts.IO) + uploaded, err := sshKeyUpload(httpClient, hostname, keyToUpload, keyTitle) if err != nil { return err } - fmt.Fprintf(opts.IO.ErrOut, "%s Uploaded the SSH key to your GitHub account: %s\n", cs.SuccessIcon(), cs.Bold(keyToUpload)) + + if uploaded { + fmt.Fprintf(opts.IO.ErrOut, "%s Uploaded the SSH key to your GitHub account: %s\n", cs.SuccessIcon(), cs.Bold(keyToUpload)) + } else { + fmt.Fprintf(opts.IO.ErrOut, "%s SSH key already existed on your GitHub account: %s\n", cs.SuccessIcon(), cs.Bold(keyToUpload)) + } } fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", cs.SuccessIcon(), cs.Bold(username)) @@ -223,14 +228,14 @@ func scopesSentence(scopes []string, isEnterprise bool) string { return strings.Join(quoted, ", ") } -func sshKeyUpload(httpClient *http.Client, hostname, keyFile string, title string, ios *iostreams.IOStreams) error { +func sshKeyUpload(httpClient *http.Client, hostname, keyFile string, title string) (bool, error) { f, err := os.Open(keyFile) if err != nil { - return err + return false, err } defer f.Close() - return add.SSHKeyUpload(httpClient, hostname, f, title, false, ios) + return add.SSHKeyUpload(httpClient, hostname, f, title) } func getCurrentLogin(httpClient httpClient, hostname, authToken string) (string, error) { diff --git a/pkg/cmd/ssh-key/add/add.go b/pkg/cmd/ssh-key/add/add.go index df977b291..84d39d1e7 100644 --- a/pkg/cmd/ssh-key/add/add.go +++ b/pkg/cmd/ssh-key/add/add.go @@ -1,6 +1,7 @@ package add import ( + "fmt" "io" "net/http" "os" @@ -78,11 +79,18 @@ func runAdd(opts *AddOptions) error { hostname, _ := cfg.Authentication().DefaultHost() - err = SSHKeyUpload(httpClient, hostname, keyReader, opts.Title, true, opts.IO) + uploaded, err := SSHKeyUpload(httpClient, hostname, keyReader, opts.Title) if err != nil { return err } - // SSHKeyUpload prints the success message. + cs := opts.IO.ColorScheme() + + if uploaded { + fmt.Fprintf(opts.IO.ErrOut, "%s Public key added to your account\n", cs.SuccessIcon()) + } else { + fmt.Fprintf(opts.IO.ErrOut, "%s Public key already exists on your account\n", cs.SuccessIcon()) + } + return nil } diff --git a/pkg/cmd/ssh-key/add/add_test.go b/pkg/cmd/ssh-key/add/add_test.go index 8497dc991..d49aaea90 100644 --- a/pkg/cmd/ssh-key/add/add_test.go +++ b/pkg/cmd/ssh-key/add/add_test.go @@ -62,8 +62,8 @@ func Test_runAdd(t *testing.T) { name: "invalid key format", stdin: "ssh-ed25519", wantStdout: "", - wantStderr: "X Error: provided key is not in a valid format\n", - wantErrMsg: "SilentError", + wantStderr: "", + wantErrMsg: "provided key is not in a valid format", opts: AddOptions{KeyFile: "-"}, }, } diff --git a/pkg/cmd/ssh-key/add/http.go b/pkg/cmd/ssh-key/add/http.go index 85e4a4dc0..e716425e0 100644 --- a/pkg/cmd/ssh-key/add/http.go +++ b/pkg/cmd/ssh-key/add/http.go @@ -3,7 +3,7 @@ package add import ( "bytes" "encoding/json" - "fmt" + "errors" "io" "net/http" "strings" @@ -11,39 +11,33 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/cmd/ssh-key/shared" - "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/iostreams" ) -func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string, printSuccessMsgs bool, ios *iostreams.IOStreams) error { +// Uploads the provided SSH key. Returns true if the key was uploaded, false if it was not. +func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) (bool, error) { url := ghinstance.RESTPrefix(hostname) + "user/keys" - cs := ios.ColorScheme() keyBytes, err := io.ReadAll(keyFile) if err != nil { - return err + return false, err } fullUserKey := string(keyBytes) splitKey := strings.Fields(fullUserKey) if len(splitKey) < 2 { - fmt.Fprintf(ios.ErrOut, "%s Error: provided key is not in a valid format\n", cs.FailureIcon()) - return cmdutil.SilentError + return false, errors.New("provided key is not in a valid format") } keyToCompare := splitKey[0] + " " + splitKey[1] keys, err := shared.UserKeys(httpClient, hostname, "") if err != nil { - return err + return false, err } for _, k := range keys { if k.Key == keyToCompare { - if printSuccessMsgs && ios.IsStderrTTY() { - fmt.Fprintf(ios.ErrOut, "%s Public key already exists on your account\n", cs.SuccessIcon()) - } - return nil + return false, nil } } @@ -54,31 +48,28 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t payloadBytes, err := json.Marshal(payload) if err != nil { - return err + return false, err } req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) if err != nil { - return err + return false, err } resp, err := httpClient.Do(req) if err != nil { - return err + return false, err } defer resp.Body.Close() if resp.StatusCode > 299 { - return api.HandleHTTPError(resp) + return false, api.HandleHTTPError(resp) } _, err = io.Copy(io.Discard, resp.Body) if err != nil { - return err + return false, err } - if printSuccessMsgs && ios.IsStderrTTY() { - fmt.Fprintf(ios.ErrOut, "%s Public key added to your account\n", cs.SuccessIcon()) - } - return nil + return true, nil } From 37970f4863b872a7559c731c46ee467cbfbb9414 Mon Sep 17 00:00:00 2001 From: Josh Kraft Date: Wed, 29 Mar 2023 16:11:34 -0600 Subject: [PATCH 34/65] feat: add gh org list --- pkg/cmd/org/list/http.go | 87 +++++++++++++ pkg/cmd/org/list/http_test.go | 83 +++++++++++++ pkg/cmd/org/list/list.go | 94 ++++++++++++++ pkg/cmd/org/list/list_test.go | 227 ++++++++++++++++++++++++++++++++++ pkg/cmd/org/org.go | 24 ++++ pkg/cmd/root/root.go | 2 + 6 files changed, 517 insertions(+) create mode 100644 pkg/cmd/org/list/http.go create mode 100644 pkg/cmd/org/list/http_test.go create mode 100644 pkg/cmd/org/list/list.go create mode 100644 pkg/cmd/org/list/list_test.go create mode 100644 pkg/cmd/org/org.go diff --git a/pkg/cmd/org/list/http.go b/pkg/cmd/org/list/http.go new file mode 100644 index 000000000..37f19f39c --- /dev/null +++ b/pkg/cmd/org/list/http.go @@ -0,0 +1,87 @@ +package list + +import ( + "net/http" + + "github.com/cli/cli/v2/api" +) + +type Organization struct { + Login string +} + +func listOrgs(httpClient *http.Client, hostname string, limit int) (*[]Organization, error) { + type response struct { + User struct { + Login string + Organizations struct { + Nodes []Organization + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"organizations(first: $limit, after: $endCursor)"` + } + } + + query := `query OrganizationList($user: String!, $limit: Int!, $endCursor: String) { + user(login: $user) { + login + organizations(first: $limit, after: $endCursor) { + nodes { + login + } + pageInfo { + hasNextPage + endCursor + } + } + } + }` + + client := api.NewClientFromHTTP(httpClient) + + user, err := api.CurrentLoginName(client, hostname) + if err != nil { + return nil, err + } + + orgs := []Organization{} + pageLimit := min(limit, 100) + variables := map[string]interface{}{ + "user": user, + } + +loop: + for { + variables["limit"] = pageLimit + var data response + err := client.GraphQL(hostname, query, variables, &data) + if err != nil { + return nil, err + } + + for _, org := range data.User.Organizations.Nodes { + orgs = append(orgs, org) + if len(orgs) == limit { + break loop + } + } + + if data.User.Organizations.PageInfo.HasNextPage { + variables["endCursor"] = data.User.Organizations.PageInfo.EndCursor + pageLimit = min(pageLimit, limit-len(orgs)) + } else { + break + } + } + + return &orgs, nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/cmd/org/list/http_test.go b/pkg/cmd/org/list/http_test.go new file mode 100644 index 000000000..168fffdd7 --- /dev/null +++ b/pkg/cmd/org/list/http_test.go @@ -0,0 +1,83 @@ +package list + +import ( + "net/http" + "reflect" + "testing" + + "github.com/cli/cli/v2/pkg/httpmock" +) + +func Test_listOrgs(t *testing.T) { + type args struct { + limit int + } + + tests := []struct { + name string + args args + httpStub func(*testing.T, *httpmock.Registry) + wantErr bool + }{ + { + name: "default", + args: args{ + limit: 30, + }, + httpStub: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`)) + reg.Register( + httpmock.GraphQL(`query OrganizationList\b`), + httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) { + want := map[string]interface{}{ + "user": "octocat", + "limit": float64(30), + } + if !reflect.DeepEqual(vars, want) { + t.Errorf("got GraphQL variables %#v, want %#v", vars, want) + } + })) + }, + }, + { + name: "with limit", + args: args{ + limit: 1, + }, + httpStub: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`)) + r.Register( + httpmock.GraphQL(`query OrganizationList\b`), + httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) { + want := map[string]interface{}{ + "user": "octocat", + "limit": float64(1), + } + if !reflect.DeepEqual(vars, want) { + t.Errorf("got GraphQL variables %#v, want %#v", vars, want) + } + })) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStub != nil { + tt.httpStub(t, reg) + } + httpClient := &http.Client{Transport: reg} + + _, err := listOrgs(httpClient, "octocat", tt.args.limit) + if (err != nil) != tt.wantErr { + t.Errorf("listOrgs() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} diff --git a/pkg/cmd/org/list/list.go b/pkg/cmd/org/list/list.go new file mode 100644 index 000000000..6006cf237 --- /dev/null +++ b/pkg/cmd/org/list/list.go @@ -0,0 +1,94 @@ +package list + +import ( + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type ListOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + HttpClient func() (*http.Client, error) + + Limit int +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := ListOptions{ + IO: f.IOStreams, + Config: f.Config, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Args: cobra.MaximumNArgs(1), + Short: "List organizations for the authenticated user.", + Example: heredoc.Doc(` + # List the first 30 organizations + $ gh org list + + # List more organizations + $ gh org list --limit 100 + `), + RunE: func(cmd *cobra.Command, args []string) error { + if opts.Limit < 1 { + return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit) + } + + if runF != nil { + return runF(&opts) + } + return listRun(&opts) + }, + } + + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of organizations to list") + + return cmd +} + +func listRun(opts *ListOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + host, _ := cfg.Authentication().DefaultHost() + + listResult, err := listOrgs(httpClient, host, opts.Limit) + if err != nil { + return err + } + + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) + } + defer opts.IO.StopPager() + + table := tableprinter.New(opts.IO) + table.HeaderRow("Name") + for _, org := range *listResult { + table.AddField(org.Login) + table.EndRow() + } + + err = table.Render() + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/org/list/list_test.go b/pkg/cmd/org/list/list_test.go new file mode 100644 index 000000000..e8aa98cfc --- /dev/null +++ b/pkg/cmd/org/list/list_test.go @@ -0,0 +1,227 @@ +package list + +import ( + "bytes" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wants ListOptions + wantsErr string + }{ + { + name: "no arguments", + cli: "", + wants: ListOptions{ + Limit: 30, + }, + }, + { + name: "with limit", + cli: "-L 101", + wants: ListOptions{ + Limit: 101, + }, + }, + { + name: "too many arguments", + cli: "123 456", + wantsErr: "accepts at most 1 arg(s), received 2", + }, + { + name: "invalid limit", + cli: "-L 0", + wantsErr: "invalid limit: 0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantsErr != "" { + assert.EqualError(t, err, tt.wantsErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.wants.Limit, gotOpts.Limit) + }) + } +} + +func TestListRun(t *testing.T) { + tests := []struct { + name string + opts ListOptions + isTTY bool + wantOut []string + }{ + { + name: "no organizations found", + opts: ListOptions{HttpClient: func() (*http.Client, error) { + r := &httpmock.Registry{} + + r.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`)) + + r.Register( + httpmock.GraphQL(`query OrganizationList\b`), + httpmock.StringResponse(` + { "data": { "user": { + "organizations": { "nodes": [] } + } } }`, + ), + ) + + return &http.Client{Transport: r}, nil + }}, + isTTY: true, + wantOut: []string{ + "NAME", + }, + }, + { + name: "default behavior", + opts: ListOptions{HttpClient: func() (*http.Client, error) { + r := &httpmock.Registry{} + + r.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`)) + + r.Register( + httpmock.GraphQL(`query OrganizationList\b`), + httpmock.StringResponse(` + { "data": { "user": { "organizations": { "nodes": [ + { + "login": "github" + }, + { + "login": "cli" + } + ] } } } }`, + ), + ) + + return &http.Client{Transport: r}, nil + }}, + isTTY: true, + wantOut: []string{ + "NAME", + "github", + "cli", + }, + }, + { + name: "with limit", + opts: ListOptions{Limit: 1, HttpClient: func() (*http.Client, error) { + r := &httpmock.Registry{} + + r.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`)) + + r.Register( + httpmock.GraphQL(`query OrganizationList\b`), + httpmock.StringResponse(` + { "data": { "user": { "organizations": { "nodes": [ + { + "login": "github" + }, + { + "login": "cli" + } + ] } } } }`, + ), + ) + + return &http.Client{Transport: r}, nil + }}, + isTTY: true, + wantOut: []string{ + "NAME", + "github", + }, + }, + { + name: "nontty output", + opts: ListOptions{HttpClient: func() (*http.Client, error) { + r := &httpmock.Registry{} + + r.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`)) + + r.Register( + httpmock.GraphQL(`query OrganizationList\b`), + httpmock.StringResponse(` + { "data": { "user": { "organizations": { "nodes": [ + { + "login": "github" + }, + { + "login": "cli" + } + ] } } } }`, + ), + ) + return &http.Client{Transport: r}, nil + }}, + isTTY: false, + wantOut: []string{ + "github", + "cli", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + + tt.opts.IO = ios + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + + err := listRun(&tt.opts) + assert.NoError(t, err) + + expected := fmt.Sprintf("%s\n", strings.Join(tt.wantOut, "\n")) + assert.Equal(t, expected, stdout.String()) + }) + } +} diff --git a/pkg/cmd/org/org.go b/pkg/cmd/org/org.go new file mode 100644 index 000000000..9c44cf48a --- /dev/null +++ b/pkg/cmd/org/org.go @@ -0,0 +1,24 @@ +package org + +import ( + "github.com/MakeNowJust/heredoc" + orgListCmd "github.com/cli/cli/v2/pkg/cmd/org/list" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdOrg(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "org ", + Short: "Manage organizations", + Long: "Work with Github organizations.", + Example: heredoc.Doc(` + $ gh org list + `), + GroupID: "core", + } + + cmdutil.AddGroup(cmd, "General commands", orgListCmd.NewCmdList(f, nil)) + + return cmd +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 5db63a672..78b436fe1 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -22,6 +22,7 @@ import ( gpgKeyCmd "github.com/cli/cli/v2/pkg/cmd/gpg-key" issueCmd "github.com/cli/cli/v2/pkg/cmd/issue" labelCmd "github.com/cli/cli/v2/pkg/cmd/label" + orgCmd "github.com/cli/cli/v2/pkg/cmd/org" prCmd "github.com/cli/cli/v2/pkg/cmd/pr" releaseCmd "github.com/cli/cli/v2/pkg/cmd/release" repoCmd "github.com/cli/cli/v2/pkg/cmd/repo" @@ -115,6 +116,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.AddCommand(browseCmd.NewCmdBrowse(&repoResolvingCmdFactory, nil)) cmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory)) + cmd.AddCommand(orgCmd.NewCmdOrg(&repoResolvingCmdFactory)) cmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory)) cmd.AddCommand(releaseCmd.NewCmdRelease(&repoResolvingCmdFactory)) cmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory)) From 18f2484080dc81a92166c9127d6d70b3beeaf52a Mon Sep 17 00:00:00 2001 From: Kenichi Kocha <64531971+kkocha@users.noreply.github.com> Date: Mon, 3 Apr 2023 23:40:37 +0900 Subject: [PATCH 35/65] docs: add commit SHA arg to gh browse help (#7267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mislav Marohnić --- pkg/cmd/browse/browse.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go index 160d72aa2..6a9456eb7 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -59,7 +59,7 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co cmd := &cobra.Command{ Long: "Open the GitHub repository in the web browser.", Short: "Open the repository in the browser", - Use: "browse [ | ]", + Use: "browse [ | | ]", Args: cobra.MaximumNArgs(1), Example: heredoc.Doc(` $ gh browse @@ -87,7 +87,8 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co "help:arguments": heredoc.Doc(` A browser location can be specified using arguments in the following format: - by number for issue or pull request, e.g. "123"; or - - by path for opening folders and files, e.g. "cmd/gh/main.go" + - by path for opening folders and files, e.g. "cmd/gh/main.go"; or + - by commit SHA `), "help:environment": heredoc.Doc(` To configure a web browser other than the default, use the BROWSER environment variable. From 6261bc697188321d43d18d915832255830175b45 Mon Sep 17 00:00:00 2001 From: Kenichi Kocha <64531971+kkocha@users.noreply.github.com> Date: Tue, 4 Apr 2023 10:36:09 +0900 Subject: [PATCH 36/65] Additional help doc and example for auth setup-git (#7243) --- pkg/cmd/auth/setupgit/setupgit.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go index a041ebfc1..a3c991f4f 100644 --- a/pkg/cmd/auth/setupgit/setupgit.go +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/pkg/cmd/auth/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -29,8 +30,26 @@ func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobr } cmd := &cobra.Command{ - Short: "Configure git to use GitHub CLI as a credential helper", Use: "setup-git", + Short: "Setup git with GitHub CLI", + Long: heredoc.Docf(` + This command configures git to use GitHub CLI as a credential helper. + For more information on git credential helpers please reference: + https://git-scm.com/docs/gitcredentials. + + By default, GitHub CLI will be set as the credential helper for all authenticated hosts. + If there is no authenticated hosts the command fails with an error. + + Alternatively, use the %[1]s--hostname%[1]s flag to specify a single host to be configured. + If the host is not authenticated with, the command fails with an error. + `, "`"), + Example: heredoc.Doc(` + # Configure git to use GitHub CLI as the credential helper for all authenticated hosts + $ gh auth setup-git + + # Configure git to use GitHub CLI as the credential helper for enterprise.internal host + $ gh auth setup-git --hostname enterprise.internal + `), RunE: func(cmd *cobra.Command, args []string) error { opts.gitConfigure = &shared.GitCredentialFlow{ Executable: f.Executable(), From 126341205500b7f69ecef223f661a30b3fa7df03 Mon Sep 17 00:00:00 2001 From: Kenichi Kocha <64531971+kkocha@users.noreply.github.com> Date: Tue, 4 Apr 2023 11:54:40 +0900 Subject: [PATCH 37/65] fix: make number arg, commit arg, and flags mutually exclusive (#7268) * make number, commit, and flags mutually exclusive * break condition into arg own check --- pkg/cmd/browse/browse.go | 4 ++++ pkg/cmd/browse/browse_test.go | 22 +++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go index 6a9456eb7..42664d77c 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -125,6 +125,10 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co return err } + 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) + } + if cmd.Flags().Changed("repo") { opts.GitClient = &remoteGitClient{opts.BaseRepo, opts.HttpClient} } diff --git a/pkg/cmd/browse/browse_test.go b/pkg/cmd/browse/browse_test.go index 440970f29..4995e5cdf 100644 --- a/pkg/cmd/browse/browse_test.go +++ b/pkg/cmd/browse/browse_test.go @@ -162,7 +162,27 @@ func TestNewCmdBrowse(t *testing.T) { }, { name: "passed both branch and commit flags", - cli: "main.go --branch main --comit=12a4", + cli: "main.go --branch main --commit=12a4", + wantsErr: true, + }, + { + name: "passed both number arg and branch flag", + cli: "1 --branch trunk", + wantsErr: true, + }, + { + name: "passed both number arg and commit flag", + cli: "1 --commit=12a4", + wantsErr: true, + }, + { + name: "passed both commit SHA arg and branch flag", + cli: "de07febc26e19000f8c9e821207f3bc34a3c8038 --branch trunk", + wantsErr: true, + }, + { + name: "passed both commit SHA arg and commit flag", + cli: "de07febc26e19000f8c9e821207f3bc34a3c8038 --commit=12a4", wantsErr: true, }, } From 5d56dfbf423ce95f73d65b741d2a6578cddca087 Mon Sep 17 00:00:00 2001 From: nate smith Date: Tue, 4 Apr 2023 11:13:43 -0700 Subject: [PATCH 38/65] add --insecure-storage and make secure storage the default --- pkg/cmd/auth/login/login.go | 29 ++++++------ pkg/cmd/auth/login/login_test.go | 76 ++++++++++++++++++++------------ 2 files changed, 63 insertions(+), 42 deletions(-) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 3eda42ae0..258853164 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -30,12 +30,12 @@ type LoginOptions struct { Interactive bool - Hostname string - Scopes []string - Token string - Web bool - GitProtocol string - SecureStorage bool + Hostname string + Scopes []string + Token string + Web bool + GitProtocol string + InsecureStorage bool } func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { @@ -124,7 +124,13 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm cmd.Flags().BoolVar(&tokenStdin, "with-token", false, "Read token from standard input") cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a browser to authenticate") cmdutil.StringEnumFlag(cmd, &opts.GitProtocol, "git-protocol", "p", "", []string{"ssh", "https"}, "The protocol to use for git operations") - cmd.Flags().BoolVarP(&opts.SecureStorage, "secure-storage", "", false, "Save authentication credentials in secure credential store") + + // secure storage became the default on 2023/4/04; this flag is left as a no-op for backwards compatibility + var secureStorage bool + cmd.Flags().BoolVar(&secureStorage, "secure-storage", false, "Save authentication credentials in secure credential store") + _ = cmd.Flags().MarkHidden("secure-storage") + + cmd.Flags().BoolVar(&opts.InsecureStorage, "insecure-storage", false, "Save authentication credentials in plain text instead of credential store") return cmd } @@ -136,11 +142,6 @@ func loginRun(opts *LoginOptions) error { } authCfg := cfg.Authentication() - if opts.SecureStorage { - cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.ErrOut, "%s Using secure storage could break installed extensions\n", cs.WarningIcon()) - } - hostname := opts.Hostname if opts.Interactive && hostname == "" { var err error @@ -166,7 +167,7 @@ func loginRun(opts *LoginOptions) error { return fmt.Errorf("error validating token: %w", err) } // Adding a user key ensures that a nonempty host section gets written to the config file. - return authCfg.Login(hostname, "x-access-token", opts.Token, opts.GitProtocol, opts.SecureStorage) + return authCfg.Login(hostname, "x-access-token", opts.Token, opts.GitProtocol, !opts.InsecureStorage) } existingToken, _ := authCfg.Token(hostname) @@ -195,7 +196,7 @@ func loginRun(opts *LoginOptions) error { Prompter: opts.Prompter, GitClient: opts.GitClient, Browser: opts.Browser, - SecureStorage: opts.SecureStorage, + SecureStorage: !opts.InsecureStorage, }) } diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 9f95cc9ee..72d796a39 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -178,16 +178,31 @@ func Test_NewCmdLogin(t *testing.T) { stdinTTY: true, cli: "--secure-storage", wants: LoginOptions{ - Interactive: true, - SecureStorage: true, + Interactive: true, }, }, { name: "nontty secure-storage", cli: "--secure-storage", wants: LoginOptions{ - Hostname: "github.com", - SecureStorage: true, + Hostname: "github.com", + }, + }, + { + name: "tty insecure-storage", + stdinTTY: true, + cli: "--insecure-storage", + wants: LoginOptions{ + Interactive: true, + InsecureStorage: true, + }, + }, + { + name: "nontty insecure-storage", + cli: "--insecure-storage", + wants: LoginOptions{ + Hostname: "github.com", + InsecureStorage: true, }, }, } @@ -251,10 +266,11 @@ func Test_loginRun_nontty(t *testing.T) { wantSecureToken string }{ { - name: "with token", + name: "insecure with token", opts: &LoginOptions{ - Hostname: "github.com", - Token: "abc123", + Hostname: "github.com", + Token: "abc123", + InsecureStorage: true, }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) @@ -262,11 +278,12 @@ func Test_loginRun_nontty(t *testing.T) { wantHosts: "github.com:\n oauth_token: abc123\n user: x-access-token\n", }, { - name: "with token and https git-protocol", + name: "insecure with token and https git-protocol", opts: &LoginOptions{ - Hostname: "github.com", - Token: "abc123", - GitProtocol: "https", + Hostname: "github.com", + Token: "abc123", + GitProtocol: "https", + InsecureStorage: true, }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) @@ -276,8 +293,9 @@ func Test_loginRun_nontty(t *testing.T) { { name: "with token and non-default host", opts: &LoginOptions{ - Hostname: "albert.wesker", - Token: "abc123", + Hostname: "albert.wesker", + Token: "abc123", + InsecureStorage: true, }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) @@ -309,8 +327,9 @@ func Test_loginRun_nontty(t *testing.T) { { name: "has admin scope", opts: &LoginOptions{ - Hostname: "github.com", - Token: "abc456", + Hostname: "github.com", + Token: "abc456", + InsecureStorage: true, }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,admin:org")) @@ -358,16 +377,14 @@ func Test_loginRun_nontty(t *testing.T) { { name: "with token and secure storage", opts: &LoginOptions{ - Hostname: "github.com", - Token: "abc123", - SecureStorage: true, + Hostname: "github.com", + Token: "abc123", }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) }, wantHosts: "github.com:\n user: x-access-token\n", wantSecureToken: "abc123", - wantStderr: "! Using secure storage could break installed extensions\n", }, } @@ -463,8 +480,9 @@ func Test_loginRun_Survey(t *testing.T) { { name: "hostname set", opts: &LoginOptions{ - Hostname: "rebecca.chambers", - Interactive: true, + Hostname: "rebecca.chambers", + Interactive: true, + InsecureStorage: true, }, wantHosts: heredoc.Doc(` rebecca.chambers: @@ -504,7 +522,8 @@ func Test_loginRun_Survey(t *testing.T) { git_protocol: https `), opts: &LoginOptions{ - Interactive: true, + Interactive: true, + InsecureStorage: true, }, prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { @@ -543,7 +562,8 @@ func Test_loginRun_Survey(t *testing.T) { git_protocol: https `), opts: &LoginOptions{ - Interactive: true, + Interactive: true, + InsecureStorage: true, }, prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { @@ -573,7 +593,8 @@ func Test_loginRun_Survey(t *testing.T) { git_protocol: ssh `), opts: &LoginOptions{ - Interactive: true, + Interactive: true, + InsecureStorage: true, }, prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { @@ -593,9 +614,8 @@ func Test_loginRun_Survey(t *testing.T) { { name: "secure storage", opts: &LoginOptions{ - Hostname: "github.com", - Interactive: true, - SecureStorage: true, + Hostname: "github.com", + Interactive: true, }, prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { @@ -617,7 +637,7 @@ func Test_loginRun_Survey(t *testing.T) { user: jillv git_protocol: https `), - wantErrOut: regexp.MustCompile("! Using secure storage could break installed extensions"), + wantErrOut: regexp.MustCompile("Logged in as jillv"), wantSecureToken: "def456", }, } From d9db81db4ff35d38fb8abf3fd27c46de532c198f Mon Sep 17 00:00:00 2001 From: nate smith Date: Tue, 4 Apr 2023 11:13:53 -0700 Subject: [PATCH 39/65] fix apparent typo --- pkg/cmd/auth/token/token.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/auth/token/token.go b/pkg/cmd/auth/token/token.go index 5ae555bb6..fee8dc638 100644 --- a/pkg/cmd/auth/token/token.go +++ b/pkg/cmd/auth/token/token.go @@ -38,7 +38,7 @@ func NewCmdToken(f *cmdutil.Factory, runF func(*TokenOptions) error) *cobra.Comm cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance authenticated with") cmd.Flags().BoolVarP(&opts.SecureStorage, "secure-storage", "", false, "Search only secure credential store for authentication token") - _ = cmd.Flags().MarkHidden("secure-storeage") + _ = cmd.Flags().MarkHidden("secure-storage") return cmd } From 75dc9c0c2274cb32b28488c93e24c135fbf4d9d0 Mon Sep 17 00:00:00 2001 From: nate smith Date: Tue, 4 Apr 2023 15:22:42 -0700 Subject: [PATCH 40/65] extend secure storage default to auth refresh --- pkg/cmd/auth/refresh/refresh.go | 22 ++++---- pkg/cmd/auth/refresh/refresh_test.go | 81 ++++++++++++++-------------- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index ab13e0552..fc068440c 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -28,8 +28,8 @@ type RefreshOptions struct { Scopes []string AuthFlow func(*config.AuthConfig, *iostreams.IOStreams, string, []string, bool, bool) error - Interactive bool - SecureStorage bool + Interactive bool + InsecureStorage bool } func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command { @@ -90,7 +90,12 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The GitHub host to use for authentication") cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes for gh to have") - cmd.Flags().BoolVarP(&opts.SecureStorage, "secure-storage", "", false, "Save authentication credentials in secure credential store") + // secure storage became the default on 2023/4/04; this flag is left as a no-op for backwards compatibility + var secureStorage bool + cmd.Flags().BoolVar(&secureStorage, "secure-storage", false, "Save authentication credentials in secure credential store") + _ = cmd.Flags().MarkHidden("secure-storage") + + cmd.Flags().BoolVarP(&opts.InsecureStorage, "insecure-storage", "", false, "Save authentication credentials in plain text instead of credential store") return cmd } @@ -139,7 +144,7 @@ func refreshRun(opts *RefreshOptions) error { } var additionalScopes []string - if oldToken, source := authCfg.Token(hostname); oldToken != "" { + if oldToken, _ := authCfg.Token(hostname); oldToken != "" { if oldScopes, err := shared.GetScopes(opts.HttpClient, hostname, oldToken); err == nil { for _, s := range strings.Split(oldScopes, ",") { s = strings.TrimSpace(s) @@ -148,13 +153,6 @@ func refreshRun(opts *RefreshOptions) error { } } } - - // If previous token was stored in secure storage assume - // user wants to continue storing it there even if not - // explicitly stated with the secure storage flag. - if source == "keyring" { - opts.SecureStorage = true - } } credentialFlow := &shared.GitCredentialFlow{ @@ -170,7 +168,7 @@ func refreshRun(opts *RefreshOptions) error { additionalScopes = append(additionalScopes, credentialFlow.Scopes()...) } - if err := opts.AuthFlow(authCfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...), opts.Interactive, opts.SecureStorage); err != nil { + if err := opts.AuthFlow(authCfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...), opts.Interactive, !opts.InsecureStorage); err != nil { return err } diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index 7d14cdaa1..8743f1835 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -86,11 +86,17 @@ func Test_NewCmdRefresh(t *testing.T) { }, }, { - name: "secure storage", + name: "secure storage", + tty: true, + cli: "--secure-storage", + wants: RefreshOptions{}, + }, + { + name: "insecure storage", tty: true, - cli: "--secure-storage", + cli: "--insecure-storage", wants: RefreshOptions{ - SecureStorage: true, + InsecureStorage: true, }, }, } @@ -178,8 +184,9 @@ func Test_refreshRun(t *testing.T) { Hostname: "obed.morton", }, wantAuthArgs: authArgs{ - hostname: "obed.morton", - scopes: nil, + hostname: "obed.morton", + scopes: nil, + secureStorage: true, }, }, { @@ -191,8 +198,9 @@ func Test_refreshRun(t *testing.T) { Hostname: "", }, wantAuthArgs: authArgs{ - hostname: "github.com", - scopes: nil, + hostname: "github.com", + scopes: nil, + secureStorage: true, }, }, { @@ -210,8 +218,9 @@ func Test_refreshRun(t *testing.T) { } }, wantAuthArgs: authArgs{ - hostname: "github.com", - scopes: nil, + hostname: "github.com", + scopes: nil, + secureStorage: true, }, }, { @@ -223,12 +232,13 @@ func Test_refreshRun(t *testing.T) { Scopes: []string{"repo:invite", "public_key:read"}, }, wantAuthArgs: authArgs{ - hostname: "github.com", - scopes: []string{"repo:invite", "public_key:read"}, + hostname: "github.com", + scopes: []string{"repo:invite", "public_key:read"}, + secureStorage: true, }, }, { - name: "scopes provided", + name: "more scopes provided", cfgHosts: []string{ "github.com", }, @@ -237,37 +247,16 @@ func Test_refreshRun(t *testing.T) { Scopes: []string{"repo:invite", "public_key:read"}, }, wantAuthArgs: authArgs{ - hostname: "github.com", - scopes: []string{"repo:invite", "public_key:read", "delete_repo", "codespace"}, - }, - }, - { - name: "explicit secure storage", - cfgHosts: []string{ - "obed.morton", - }, - opts: &RefreshOptions{ - Hostname: "obed.morton", - SecureStorage: true, - }, - wantAuthArgs: authArgs{ - hostname: "obed.morton", - scopes: nil, + hostname: "github.com", + scopes: []string{"repo:invite", "public_key:read", "delete_repo", "codespace"}, secureStorage: true, }, }, { - name: "implicit secure storage", - config: func() config.Config { - cfg := config.NewFromString("") - authCfg := cfg.Authentication() - authCfg.SetHosts([]string{"obed.morton"}) - authCfg.SetToken("abc123", "keyring") - cfg.AuthenticationFunc = func() *config.AuthConfig { - return authCfg - } - return cfg - }(), + name: "secure storage", + cfgHosts: []string{ + "obed.morton", + }, opts: &RefreshOptions{ Hostname: "obed.morton", }, @@ -277,6 +266,20 @@ func Test_refreshRun(t *testing.T) { secureStorage: true, }, }, + { + name: "insecure storage", + cfgHosts: []string{ + "obed.morton", + }, + opts: &RefreshOptions{ + Hostname: "obed.morton", + InsecureStorage: true, + }, + wantAuthArgs: authArgs{ + hostname: "obed.morton", + scopes: nil, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 0d8b5ab9d7663346b071beb5473ba6f69449e225 Mon Sep 17 00:00:00 2001 From: Kousik Mitra Date: Tue, 4 Apr 2023 02:12:27 +0530 Subject: [PATCH 41/65] Add option to add ssh singing key --- pkg/cmd/ssh-key/add/add.go | 13 +++- pkg/cmd/ssh-key/add/add_test.go | 46 +++++++++++++++ pkg/cmd/ssh-key/add/http.go | 92 ++++++++++++++++++++++------- pkg/cmd/ssh-key/shared/user_keys.go | 32 ++++++++++ 4 files changed, 162 insertions(+), 21 deletions(-) diff --git a/pkg/cmd/ssh-key/add/add.go b/pkg/cmd/ssh-key/add/add.go index 84d39d1e7..553c35985 100644 --- a/pkg/cmd/ssh-key/add/add.go +++ b/pkg/cmd/ssh-key/add/add.go @@ -7,6 +7,7 @@ import ( "os" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/cmd/ssh-key/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -19,6 +20,7 @@ type AddOptions struct { KeyFile string Title string + Type string } func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command { @@ -49,6 +51,8 @@ func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command }, } + typeEnums := []string{shared.AuthenticationKey, shared.SigningKey} + cmdutil.StringEnumFlag(cmd, &opts.Type, "type", "", shared.AuthenticationKey, typeEnums, "Type of the ssh key") cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Title for the new key") return cmd } @@ -79,7 +83,14 @@ func runAdd(opts *AddOptions) error { hostname, _ := cfg.Authentication().DefaultHost() - uploaded, err := SSHKeyUpload(httpClient, hostname, keyReader, opts.Title) + var uploaded bool + + if opts.Type == shared.SigningKey { + uploaded, err = SSHSigningKeyUpload(httpClient, hostname, keyReader, opts.Title) + } else { + uploaded, err = SSHKeyUpload(httpClient, hostname, keyReader, opts.Title) + } + if err != nil { return err } diff --git a/pkg/cmd/ssh-key/add/add_test.go b/pkg/cmd/ssh-key/add/add_test.go index d49aaea90..43222a6c4 100644 --- a/pkg/cmd/ssh-key/add/add_test.go +++ b/pkg/cmd/ssh-key/add/add_test.go @@ -39,6 +39,25 @@ func Test_runAdd(t *testing.T) { wantErrMsg: "", opts: AddOptions{KeyFile: "-"}, }, + { + name: "valid signing key format, not already in use", + stdin: "ssh-ed25519 asdf", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse("[]")) + reg.Register( + httpmock.REST("POST", "user/ssh_signing_keys"), + httpmock.RESTPayload(200, ``, func(payload map[string]interface{}) { + assert.Contains(t, payload, "key") + assert.Empty(t, payload["title"]) + })) + }, + wantStdout: "", + wantStderr: "✓ Public key added to your account\n", + wantErrMsg: "", + opts: AddOptions{KeyFile: "-", Type: "signing"}, + }, { name: "valid key format, already in use", stdin: "ssh-ed25519 asdf title", @@ -58,6 +77,25 @@ func Test_runAdd(t *testing.T) { wantErrMsg: "", opts: AddOptions{KeyFile: "-"}, }, + { + name: "valid signing key format, already in use", + stdin: "ssh-ed25519 asdf title", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse(`[ + { + "id": 1, + "key": "ssh-ed25519 asdf", + "title": "anything" + } + ]`)) + }, + wantStdout: "", + wantStderr: "✓ Public key already exists on your account\n", + wantErrMsg: "", + opts: AddOptions{KeyFile: "-", Type: "signing"}, + }, { name: "invalid key format", stdin: "ssh-ed25519", @@ -66,6 +104,14 @@ func Test_runAdd(t *testing.T) { wantErrMsg: "provided key is not in a valid format", opts: AddOptions{KeyFile: "-"}, }, + { + name: "invalid signing key format", + stdin: "ssh-ed25519", + wantStdout: "", + wantStderr: "", + wantErrMsg: "provided key is not in a valid format", + opts: AddOptions{KeyFile: "-", Type: "signing"}, + }, } for _, tt := range tests { diff --git a/pkg/cmd/ssh-key/add/http.go b/pkg/cmd/ssh-key/add/http.go index e716425e0..83aa77bdc 100644 --- a/pkg/cmd/ssh-key/add/http.go +++ b/pkg/cmd/ssh-key/add/http.go @@ -46,30 +46,82 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t "key": fullUserKey, } - payloadBytes, err := json.Marshal(payload) - if err != nil { - return false, err - } + err = keyUpload(httpClient, url, payload) - req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) - if err != nil { - return false, err - } - - resp, err := httpClient.Do(req) - if err != nil { - return false, err - } - defer resp.Body.Close() - - if resp.StatusCode > 299 { - return false, api.HandleHTTPError(resp) - } - - _, err = io.Copy(io.Discard, resp.Body) if err != nil { return false, err } return true, nil } + +// Uploads the provided SSH Signing key. Returns true if the key was uploaded, false if it was not. +func SSHSigningKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) (bool, error) { + url := ghinstance.RESTPrefix(hostname) + "user/ssh_signing_keys" + + keyBytes, err := io.ReadAll(keyFile) + if err != nil { + return false, err + } + + fullUserKey := string(keyBytes) + splitKey := strings.Fields(fullUserKey) + if len(splitKey) < 2 { + return false, errors.New("provided key is not in a valid format") + } + + keyToCompare := splitKey[0] + " " + splitKey[1] + + keys, err := shared.UserSigningKeys(httpClient, hostname, "") + if err != nil { + return false, err + } + + for _, k := range keys { + if k.Key == keyToCompare { + return false, nil + } + } + + payload := map[string]string{ + "title": title, + "key": fullUserKey, + } + + err = keyUpload(httpClient, url, payload) + + if err != nil { + return false, err + } + + return true, nil +} + +func keyUpload(httpClient *http.Client, url string, payload map[string]string) error { + payloadBytes, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) + if err != nil { + return err + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + + _, err = io.Copy(io.Discard, resp.Body) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/ssh-key/shared/user_keys.go b/pkg/cmd/ssh-key/shared/user_keys.go index 57f309d19..299d872c5 100644 --- a/pkg/cmd/ssh-key/shared/user_keys.go +++ b/pkg/cmd/ssh-key/shared/user_keys.go @@ -11,6 +11,11 @@ import ( "github.com/cli/cli/v2/internal/ghinstance" ) +const ( + AuthenticationKey = "authentication" + SigningKey = "signing" +) + type sshKey struct { ID int Key string @@ -24,6 +29,33 @@ func UserKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error resource = fmt.Sprintf("users/%s/keys", userHandle) } url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100) + + keys, err := getUserKeys(httpClient, url) + + if err != nil { + return nil, err + } + + return keys, nil +} + +func UserSigningKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) { + resource := "user/ssh_signing_keys" + if userHandle != "" { + resource = fmt.Sprintf("users/%s/ssh_signing_keys", userHandle) + } + url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100) + + keys, err := getUserKeys(httpClient, url) + + if err != nil { + return nil, err + } + + return keys, nil +} + +func getUserKeys(httpClient *http.Client, url string) ([]sshKey, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err From 4668810cbe8cd36d6fe5687dabb28950448b3c19 Mon Sep 17 00:00:00 2001 From: Kousik Mitra Date: Wed, 5 Apr 2023 18:54:29 +0530 Subject: [PATCH 42/65] Enhance ssh-key list to show signing ssh keys also --- pkg/cmd/ssh-key/list/list.go | 35 ++++++++- pkg/cmd/ssh-key/list/list_test.go | 114 ++++++++++++++++++++++++++-- pkg/cmd/ssh-key/shared/user_keys.go | 9 +++ 3 files changed, 147 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/ssh-key/list/list.go b/pkg/cmd/ssh-key/list/list.go index 0c0cbcd0b..12ee6d635 100644 --- a/pkg/cmd/ssh-key/list/list.go +++ b/pkg/cmd/ssh-key/list/list.go @@ -1,10 +1,14 @@ package list import ( + "errors" + "fmt" + "io" "net/http" "strconv" "time" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/ssh-key/shared" @@ -55,12 +59,22 @@ func listRun(opts *ListOptions) error { } host, _ := cfg.Authentication().DefaultHost() - - sshKeys, err := shared.UserKeys(apiClient, host, "") - if err != nil { - return err + sshAuthKeys, authKeyErr := shared.UserKeys(apiClient, host, "") + if authKeyErr != nil { + printError(opts.IO.ErrOut, authKeyErr) } + sshSigningKeys, signKeyErr := shared.UserSigningKeys(apiClient, host, "") + if signKeyErr != nil { + printError(opts.IO.ErrOut, signKeyErr) + } + + if authKeyErr != nil && signKeyErr != nil { + return cmdutil.SilentError + } + + sshKeys := append(sshAuthKeys, sshSigningKeys...) + if len(sshKeys) == 0 { return cmdutil.NewNoResultsError("no SSH keys present in the GitHub account") } @@ -74,6 +88,7 @@ func listRun(opts *ListOptions) error { t.AddField("TITLE", nil, nil) t.AddField("ID", nil, nil) t.AddField("KEY", nil, nil) + t.AddField("TYPE", nil, nil) t.AddField("ADDED", nil, nil) t.EndRow() } @@ -86,12 +101,14 @@ func listRun(opts *ListOptions) error { t.AddField(sshKey.Title, nil, nil) t.AddField(id, nil, nil) t.AddField(sshKey.Key, truncateMiddle, nil) + t.AddField(sshKey.Type, nil, nil) t.AddField(text.FuzzyAgoAbbr(now, sshKey.CreatedAt), nil, cs.Gray) } else { t.AddField(sshKey.Title, nil, nil) t.AddField(sshKey.Key, nil, nil) t.AddField(createdAt, nil, nil) t.AddField(id, nil, nil) + t.AddField(sshKey.Type, nil, nil) } t.EndRow() @@ -114,3 +131,13 @@ func truncateMiddle(maxWidth int, t string) string { remainder := (maxWidth - len(ellipsis)) % 2 return t[0:halfWidth+remainder] + ellipsis + t[len(t)-halfWidth:] } + +func printError(w io.Writer, err error) { + fmt.Fprintln(w, "warning: ", err) + var httpErr api.HTTPError + if errors.As(err, &httpErr) { + if msg := httpErr.ScopesSuggestion(); msg != "" { + fmt.Fprintln(w, msg) + } + } +} diff --git a/pkg/cmd/ssh-key/list/list_test.go b/pkg/cmd/ssh-key/list/list_test.go index 91466ad8f..7334beccf 100644 --- a/pkg/cmd/ssh-key/list/list_test.go +++ b/pkg/cmd/ssh-key/list/list_test.go @@ -22,7 +22,7 @@ func TestListRun(t *testing.T) { wantErr bool }{ { - name: "list tty", + name: "list authentication and signing keys; in tty", opts: ListOptions{ HTTPClient: func() (*http.Client, error) { createdAt := time.Now().Add(time.Duration(-24) * time.Hour) @@ -44,19 +44,31 @@ func TestListRun(t *testing.T) { } ]`, createdAt.Format(time.RFC3339))), ) + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 321, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac Signing", + "created_at": "%[1]s" + } + ]`, createdAt.Format(time.RFC3339))), + ) return &http.Client{Transport: reg}, nil }, }, isTTY: true, wantStdout: heredoc.Doc(` - TITLE ID KEY ADDED - Mac 1234 ssh-rsa AAAABbBB123 1d - hubot@Windows 5678 ssh-rsa EEEEEEEK247 1d + TITLE ID KEY TYPE ADDED + Mac 1234 ssh-rsa AAAABbBB123 authentication 1d + hubot@Windows 5678 ssh-rsa EEEEEEEK247 authentication 1d + Mac Signing 321 ssh-rsa AAAABbBB123 signing 1d `), wantStderr: "", }, { - name: "list non-tty", + name: "list authentication and signing keys; in non-tty", opts: ListOptions{ HTTPClient: func() (*http.Client, error) { createdAt, _ := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00") @@ -78,16 +90,96 @@ func TestListRun(t *testing.T) { } ]`, createdAt.Format(time.RFC3339))), ) + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 321, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac Signing", + "created_at": "%[1]s" + } + ]`, createdAt.Format(time.RFC3339))), + ) return &http.Client{Transport: reg}, nil }, }, isTTY: false, wantStdout: heredoc.Doc(` - Mac ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 1234 - hubot@Windows ssh-rsa EEEEEEEK247 2020-08-31T15:44:24+02:00 5678 + Mac ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 1234 authentication + hubot@Windows ssh-rsa EEEEEEEK247 2020-08-31T15:44:24+02:00 5678 authentication + Mac Signing ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 321 signing `), wantStderr: "", }, + { + name: "only authentication ssh keys are available", + opts: ListOptions{ + HTTPClient: func() (*http.Client, error) { + createdAt := time.Now().Add(time.Duration(-24) * time.Hour) + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 1234, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac", + "created_at": "%[1]s" + } + ]`, createdAt.Format(time.RFC3339))), + ) + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StatusStringResponse(404, "Not Found"), + ) + return &http.Client{Transport: reg}, nil + }, + }, + wantStdout: heredoc.Doc(` + TITLE ID KEY TYPE ADDED + Mac 1234 ssh-rsa AAAABbBB123 authentication 1d + `), + wantStderr: heredoc.Doc(` + warning: HTTP 404 (https://api.github.com/user/ssh_signing_keys?per_page=100) + `), + wantErr: false, + isTTY: true, + }, + { + name: "only signing ssh keys are available", + opts: ListOptions{ + HTTPClient: func() (*http.Client, error) { + createdAt := time.Now().Add(time.Duration(-24) * time.Hour) + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 1234, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac", + "created_at": "%[1]s" + } + ]`, createdAt.Format(time.RFC3339))), + ) + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StatusStringResponse(404, "Not Found"), + ) + return &http.Client{Transport: reg}, nil + }, + }, + wantStdout: heredoc.Doc(` + TITLE ID KEY TYPE ADDED + Mac 1234 ssh-rsa AAAABbBB123 signing 1d + `), + wantStderr: heredoc.Doc(` + warning: HTTP 404 (https://api.github.com/user/keys?per_page=100) + `), + wantErr: false, + isTTY: true, + }, { name: "no keys tty", opts: ListOptions{ @@ -97,6 +189,10 @@ func TestListRun(t *testing.T) { httpmock.REST("GET", "user/keys"), httpmock.StringResponse(`[]`), ) + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse(`[]`), + ) return &http.Client{Transport: reg}, nil }, }, @@ -114,6 +210,10 @@ func TestListRun(t *testing.T) { httpmock.REST("GET", "user/keys"), httpmock.StringResponse(`[]`), ) + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse(`[]`), + ) return &http.Client{Transport: reg}, nil }, }, diff --git a/pkg/cmd/ssh-key/shared/user_keys.go b/pkg/cmd/ssh-key/shared/user_keys.go index 299d872c5..035d00244 100644 --- a/pkg/cmd/ssh-key/shared/user_keys.go +++ b/pkg/cmd/ssh-key/shared/user_keys.go @@ -20,6 +20,7 @@ type sshKey struct { ID int Key string Title string + Type string CreatedAt time.Time `json:"created_at"` } @@ -36,6 +37,10 @@ func UserKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error return nil, err } + for i := 0; i < len(keys); i++ { + keys[i].Type = AuthenticationKey + } + return keys, nil } @@ -52,6 +57,10 @@ func UserSigningKeys(httpClient *http.Client, host, userHandle string) ([]sshKey return nil, err } + for i := 0; i < len(keys); i++ { + keys[i].Type = SigningKey + } + return keys, nil } From ba37e2333d3a4b8b404ae17aa6d65a683b98c425 Mon Sep 17 00:00:00 2001 From: Josh Kraft Date: Wed, 5 Apr 2023 15:36:45 -0600 Subject: [PATCH 43/65] add "showing x of y organizations" header --- pkg/cmd/org/list/http.go | 27 ++++++++++----- pkg/cmd/org/list/list.go | 18 ++++++++-- pkg/cmd/org/list/list_test.go | 62 ++++++++++++++++++++--------------- 3 files changed, 71 insertions(+), 36 deletions(-) diff --git a/pkg/cmd/org/list/http.go b/pkg/cmd/org/list/http.go index 37f19f39c..5bd06d092 100644 --- a/pkg/cmd/org/list/http.go +++ b/pkg/cmd/org/list/http.go @@ -6,17 +6,24 @@ import ( "github.com/cli/cli/v2/api" ) +type OrganizationList struct { + Organizations []Organization + TotalCount int + User string +} + type Organization struct { Login string } -func listOrgs(httpClient *http.Client, hostname string, limit int) (*[]Organization, error) { +func listOrgs(httpClient *http.Client, hostname string, limit int) (*OrganizationList, error) { type response struct { User struct { Login string Organizations struct { - Nodes []Organization - PageInfo struct { + TotalCount int + Nodes []Organization + PageInfo struct { HasNextPage bool EndCursor string } @@ -28,6 +35,7 @@ func listOrgs(httpClient *http.Client, hostname string, limit int) (*[]Organizat user(login: $user) { login organizations(first: $limit, after: $endCursor) { + totalCount nodes { login } @@ -46,7 +54,8 @@ func listOrgs(httpClient *http.Client, hostname string, limit int) (*[]Organizat return nil, err } - orgs := []Organization{} + listResult := OrganizationList{} + listResult.User = user pageLimit := min(limit, 100) variables := map[string]interface{}{ "user": user, @@ -61,22 +70,24 @@ loop: return nil, err } + listResult.TotalCount = data.User.Organizations.TotalCount + for _, org := range data.User.Organizations.Nodes { - orgs = append(orgs, org) - if len(orgs) == limit { + listResult.Organizations = append(listResult.Organizations, org) + if len(listResult.Organizations) == limit { break loop } } if data.User.Organizations.PageInfo.HasNextPage { variables["endCursor"] = data.User.Organizations.PageInfo.EndCursor - pageLimit = min(pageLimit, limit-len(orgs)) + pageLimit = min(pageLimit, limit-len(listResult.Organizations)) } else { break } } - return &orgs, nil + return &listResult, nil } func min(a, b int) int { diff --git a/pkg/cmd/org/list/list.go b/pkg/cmd/org/list/list.go index 6006cf237..c30dc8b1a 100644 --- a/pkg/cmd/org/list/list.go +++ b/pkg/cmd/org/list/list.go @@ -7,6 +7,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -78,9 +79,14 @@ func listRun(opts *ListOptions) error { } defer opts.IO.StopPager() + if opts.IO.IsStdoutTTY() { + header := listHeader(listResult.User, len(listResult.Organizations), listResult.TotalCount) + fmt.Fprintf(opts.IO.Out, "\n%s\n\n", header) + } + table := tableprinter.New(opts.IO) - table.HeaderRow("Name") - for _, org := range *listResult { + + for _, org := range listResult.Organizations { table.AddField(org.Login) table.EndRow() } @@ -92,3 +98,11 @@ func listRun(opts *ListOptions) error { return nil } + +func listHeader(user string, resultCount, totalCount int) string { + if totalCount == 0 { + return "There are no organizations associated with @" + user + } + + return fmt.Sprintf("Showing %d of %s", resultCount, text.Pluralize(totalCount, "organization")) +} diff --git a/pkg/cmd/org/list/list_test.go b/pkg/cmd/org/list/list_test.go index e8aa98cfc..8efc0a49e 100644 --- a/pkg/cmd/org/list/list_test.go +++ b/pkg/cmd/org/list/list_test.go @@ -2,11 +2,10 @@ package list import ( "bytes" - "fmt" "net/http" - "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -82,7 +81,7 @@ func TestListRun(t *testing.T) { name string opts ListOptions isTTY bool - wantOut []string + wantOut string }{ { name: "no organizations found", @@ -97,7 +96,7 @@ func TestListRun(t *testing.T) { httpmock.GraphQL(`query OrganizationList\b`), httpmock.StringResponse(` { "data": { "user": { - "organizations": { "nodes": [] } + "organizations": { "nodes": [], "totalCount": 0 } } } }`, ), ) @@ -105,9 +104,11 @@ func TestListRun(t *testing.T) { return &http.Client{Transport: r}, nil }}, isTTY: true, - wantOut: []string{ - "NAME", - }, + wantOut: heredoc.Doc(` + +There are no organizations associated with @octocat + +`), }, { name: "default behavior", @@ -121,7 +122,9 @@ func TestListRun(t *testing.T) { r.Register( httpmock.GraphQL(`query OrganizationList\b`), httpmock.StringResponse(` - { "data": { "user": { "organizations": { "nodes": [ + { "data": { "user": { "organizations": { + "totalCount": 2, + "nodes": [ { "login": "github" }, @@ -135,11 +138,13 @@ func TestListRun(t *testing.T) { return &http.Client{Transport: r}, nil }}, isTTY: true, - wantOut: []string{ - "NAME", - "github", - "cli", - }, + wantOut: heredoc.Doc(` + +Showing 2 of 2 organizations + +github +cli +`), }, { name: "with limit", @@ -153,7 +158,9 @@ func TestListRun(t *testing.T) { r.Register( httpmock.GraphQL(`query OrganizationList\b`), httpmock.StringResponse(` - { "data": { "user": { "organizations": { "nodes": [ + { "data": { "user": { "organizations": { + "totalCount": 2, + "nodes": [ { "login": "github" }, @@ -167,10 +174,12 @@ func TestListRun(t *testing.T) { return &http.Client{Transport: r}, nil }}, isTTY: true, - wantOut: []string{ - "NAME", - "github", - }, + wantOut: heredoc.Doc(` + +Showing 1 of 2 organizations + +github +`), }, { name: "nontty output", @@ -184,7 +193,9 @@ func TestListRun(t *testing.T) { r.Register( httpmock.GraphQL(`query OrganizationList\b`), httpmock.StringResponse(` - { "data": { "user": { "organizations": { "nodes": [ + { "data": { "user": { "organizations": { + "totalCount": 2, + "nodes": [ { "login": "github" }, @@ -197,10 +208,10 @@ func TestListRun(t *testing.T) { return &http.Client{Transport: r}, nil }}, isTTY: false, - wantOut: []string{ - "github", - "cli", - }, + wantOut: heredoc.Doc(` +github +cli +`), }, } @@ -218,10 +229,9 @@ func TestListRun(t *testing.T) { } err := listRun(&tt.opts) - assert.NoError(t, err) - expected := fmt.Sprintf("%s\n", strings.Join(tt.wantOut, "\n")) - assert.Equal(t, expected, stdout.String()) + assert.NoError(t, err) + assert.Equal(t, tt.wantOut, stdout.String()) }) } } From 27a3fcc8072b2d55b9d59cb44352281ba448a2cd Mon Sep 17 00:00:00 2001 From: Caleb Brose <5447118+cmbrose@users.noreply.github.com> Date: Wed, 5 Apr 2023 19:33:50 -0500 Subject: [PATCH 44/65] Fix invoker immediately closing --- pkg/cmd/codespace/jupyter.go | 12 +++++++++--- pkg/cmd/codespace/ssh.go | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/codespace/jupyter.go b/pkg/cmd/codespace/jupyter.go index 2fe6083bc..db55c434d 100644 --- a/pkg/cmd/codespace/jupyter.go +++ b/pkg/cmd/codespace/jupyter.go @@ -45,17 +45,23 @@ func (a *App) Jupyter(ctx context.Context, selector *CodespaceSelector) (err err } defer safeClose(session, &err) - serverPort, serverUrl := 0, "" + var ( + invoker rpc.Invoker + serverPort int + serverUrl string + ) err = a.RunWithProgress("Starting JupyterLab on codespace", func() (err error) { - invoker, err := rpc.CreateInvoker(ctx, session) + invoker, err = rpc.CreateInvoker(ctx, session) if err != nil { return } - defer safeClose(invoker, &err) serverPort, serverUrl, err = invoker.StartJupyterServer(ctx) return }) + if invoker != nil { + defer safeClose(invoker, &err) + } if err != nil { return err } diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go index f8889a56b..37a4ee88c 100644 --- a/pkg/cmd/codespace/ssh.go +++ b/pkg/cmd/codespace/ssh.go @@ -171,17 +171,23 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e } defer safeClose(session, &err) - remoteSSHServerPort, sshUser := 0, "" + var ( + invoker rpc.Invoker + remoteSSHServerPort int + sshUser string + ) err = a.RunWithProgress("Fetching SSH Details", func() (err error) { - invoker, err := rpc.CreateInvoker(ctx, session) + invoker, err = rpc.CreateInvoker(ctx, session) if err != nil { return } - defer safeClose(invoker, &err) remoteSSHServerPort, sshUser, err = invoker.StartSSHServerWithOptions(ctx, startSSHOptions) return }) + if invoker != nil { + defer safeClose(invoker, &err) + } if err != nil { return fmt.Errorf("error getting ssh server details: %w", err) } From 89caedf1817c21e4bdb9b136a46444225ec4c982 Mon Sep 17 00:00:00 2001 From: Kenichi Kocha <64531971+kkocha@users.noreply.github.com> Date: Fri, 7 Apr 2023 16:41:51 +0900 Subject: [PATCH 45/65] Add `-R` as shorthand of `--repo` flag for search commands (#7274) --- pkg/cmd/search/commits/commits.go | 2 +- pkg/cmd/search/issues/issues.go | 2 +- pkg/cmd/search/prs/prs.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/search/commits/commits.go b/pkg/cmd/search/commits/commits.go index 7e98cae8a..3a6bd9e2a 100644 --- a/pkg/cmd/search/commits/commits.go +++ b/pkg/cmd/search/commits/commits.go @@ -112,7 +112,7 @@ func NewCmdCommits(f *cmdutil.Factory, runF func(*CommitsOptions) error) *cobra. cmd.Flags().StringVar(&opts.Query.Qualifiers.Hash, "hash", "", "Filter by commit hash") cmdutil.NilBoolFlag(cmd, &opts.Query.Qualifiers.Merge, "merge", "", "Filter on merge commits") cmd.Flags().StringVar(&opts.Query.Qualifiers.Parent, "parent", "", "Filter by parent hash") - cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Repo, "repo", nil, "Filter on repository") + cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository") cmd.Flags().StringVar(&opts.Query.Qualifiers.Tree, "tree", "", "Filter by tree hash") cmd.Flags().StringVar(&opts.Query.Qualifiers.User, "owner", "", "Filter on repository owner") cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.Is, "visibility", "", nil, []string{"public", "private", "internal"}, "Filter based on repository visibility") diff --git a/pkg/cmd/search/issues/issues.go b/pkg/cmd/search/issues/issues.go index 90794053b..5349268a7 100644 --- a/pkg/cmd/search/issues/issues.go +++ b/pkg/cmd/search/issues/issues.go @@ -156,7 +156,7 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c cmd.Flags().BoolVar(&noProject, "no-project", false, "Filter on missing project") cmd.Flags().StringVar(&opts.Query.Qualifiers.Project, "project", "", "Filter on project board `number`") cmd.Flags().StringVar(&opts.Query.Qualifiers.Reactions, "reactions", "", "Filter on `number` of reactions") - cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Repo, "repo", nil, "Filter on repository") + cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository") cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.State, "state", "", "", []string{"open", "closed"}, "Filter based on state") cmd.Flags().StringVar(&opts.Query.Qualifiers.Team, "team-mentions", "", "Filter based on team mentions") cmd.Flags().StringVar(&opts.Query.Qualifiers.Updated, "updated", "", "Filter on last updated at `date`") diff --git a/pkg/cmd/search/prs/prs.go b/pkg/cmd/search/prs/prs.go index 2ef37b2ef..a7ff37e5e 100644 --- a/pkg/cmd/search/prs/prs.go +++ b/pkg/cmd/search/prs/prs.go @@ -167,7 +167,7 @@ func NewCmdPrs(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobr cmd.Flags().BoolVar(&noProject, "no-project", false, "Filter on missing project") cmd.Flags().StringVar(&opts.Query.Qualifiers.Project, "project", "", "Filter on project board `number`") cmd.Flags().StringVar(&opts.Query.Qualifiers.Reactions, "reactions", "", "Filter on `number` of reactions") - cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Repo, "repo", nil, "Filter on repository") + cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository") cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.State, "state", "", "", []string{"open", "closed"}, "Filter based on state") cmd.Flags().StringVar(&opts.Query.Qualifiers.Team, "team-mentions", "", "Filter based on team mentions") cmd.Flags().StringVar(&opts.Query.Qualifiers.Updated, "updated", "", "Filter on last updated at `date`") From be45eedc18c1a087234e19ea03bd071e37ade951 Mon Sep 17 00:00:00 2001 From: Aleksei Khudiakov Date: Sat, 8 Apr 2023 08:55:45 +1000 Subject: [PATCH 46/65] Generate completion scripts for bundling in rpm and deb on release --- .gitignore | 3 +++ .goreleaser.yml | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/.gitignore b/.gitignore index 905701534..c9a58ce00 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ /bin +/share/bash-completion/completions +/share/fish/vendor_completions.d /share/man/man1 +/share/zsh/site-functions /gh-cli .envrc /dist diff --git a/.goreleaser.yml b/.goreleaser.yml index d67d023ae..e7c823030 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -9,6 +9,10 @@ before: hooks: - go mod tidy - make manpages GH_VERSION={{.Version}} + - mkdir -p ./share/bash-completion/completions ./share/fish/vendor_completions.d ./share/zsh/site-functions + - sh -c "go run ./cmd/gh completion -s bash > ./share/bash-completion/completions/gh" + - sh -c "go run ./cmd/gh completion -s fish > ./share/fish/vendor_completions.d/gh.fish" + - sh -c "go run ./cmd/gh completion -s zsh > ./share/zsh/site-functions/_gh" builds: - <<: &build_defaults @@ -70,3 +74,9 @@ nfpms: contents: - src: "./share/man/man1/gh*.1" dst: "/usr/share/man/man1" + - src: "./share/bash-completion/completions/gh" + dst: "/usr/share/bash-completion/completions/gh" + - src: "./share/fish/vendor_completions.d/gh.fish" + dst: "/usr/share/fish/vendor_completions.d/gh.fish" + - src: "./share/zsh/site-functions/_gh" + dst: "/usr/share/zsh/site-functions/_gh" From 80f91af1711b077d2ea20aaae693dccfef714433 Mon Sep 17 00:00:00 2001 From: Aleksei Khudiakov Date: Sat, 8 Apr 2023 11:31:09 +1000 Subject: [PATCH 47/65] Move completions generation from goreleaser to Makefile --- .goreleaser.yml | 5 +---- Makefile | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index e7c823030..26277d3c5 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -9,10 +9,7 @@ before: hooks: - go mod tidy - make manpages GH_VERSION={{.Version}} - - mkdir -p ./share/bash-completion/completions ./share/fish/vendor_completions.d ./share/zsh/site-functions - - sh -c "go run ./cmd/gh completion -s bash > ./share/bash-completion/completions/gh" - - sh -c "go run ./cmd/gh completion -s fish > ./share/fish/vendor_completions.d/gh.fish" - - sh -c "go run ./cmd/gh completion -s zsh > ./share/zsh/site-functions/_gh" + - make completions builds: - <<: &build_defaults diff --git a/Makefile b/Makefile index 46d40a7a9..ffe694e19 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,13 @@ clean: script/build manpages: script/build @script/build $@ +.PHONY: completions +completions: + mkdir -p ./share/bash-completion/completions ./share/fish/vendor_completions.d ./share/zsh/site-functions + go run ./cmd/gh completion -s bash > ./share/bash-completion/completions/gh + go run ./cmd/gh completion -s fish > ./share/fish/vendor_completions.d/gh.fish + go run ./cmd/gh completion -s zsh > ./share/zsh/site-functions/_gh + # just a convenience task around `go test` .PHONY: test test: @@ -60,15 +67,22 @@ endif DESTDIR := prefix := /usr/local bindir := ${prefix}/bin -mandir := ${prefix}/share/man +datadir := ${prefix}/share +mandir := ${datadir}/man .PHONY: install -install: bin/gh manpages +install: bin/gh manpages completions install -d ${DESTDIR}${bindir} install -m755 bin/gh ${DESTDIR}${bindir}/ install -d ${DESTDIR}${mandir}/man1 install -m644 ./share/man/man1/* ${DESTDIR}${mandir}/man1/ + install -DT -m644 ./share/bash-completion/completions/gh ${DESTDIR}${datadir}/bash-completion/completions/gh + install -DT -m644 ./share/fish/vendor_completions.d/gh.fish ${DESTDIR}${datadir}/fish/vendor_completions.d/gh.fish + install -DT -m644 ./share/zsh/site-functions/_gh ${DESTDIR}${datadir}/zsh/site-functions/_gh .PHONY: uninstall uninstall: rm -f ${DESTDIR}${bindir}/gh ${DESTDIR}${mandir}/man1/gh.1 ${DESTDIR}${mandir}/man1/gh-*.1 + rm -f ${DESTDIR}${datadir}/bash-completion/completions/gh + rm -f ${DESTDIR}${datadir}/fish/vendor_completions.d/gh.fish + rm -f ${DESTDIR}${datadir}/zsh/site-functions/_gh From 7ee35e43cc2a120a53037472bc93229e7f21a1b4 Mon Sep 17 00:00:00 2001 From: Kousik Mitra Date: Wed, 12 Apr 2023 01:02:08 +0530 Subject: [PATCH 48/65] Enable owner flag to take multiple values for searching --- pkg/cmd/extension/command.go | 2 +- pkg/cmd/repo/list/http.go | 2 +- pkg/cmd/search/commits/commits.go | 2 +- pkg/cmd/search/commits/commits_test.go | 2 +- pkg/cmd/search/issues/issues.go | 2 +- pkg/cmd/search/prs/prs.go | 2 +- pkg/cmd/search/repos/repos.go | 2 +- pkg/cmd/search/repos/repos_test.go | 2 +- pkg/search/query.go | 2 +- pkg/search/query_test.go | 13 ++++++++----- 10 files changed, 17 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 21279c1e2..0f2add8aa 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -253,7 +253,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { // Qualifier flags cmd.Flags().StringSliceVar(&qualifiers.License, "license", nil, "Filter based on license type") - cmd.Flags().StringVar(&qualifiers.User, "owner", "", "Filter on owner") + cmd.Flags().StringSliceVar(&qualifiers.User, "owner", nil, "Filter on owner") return cmd }(), diff --git a/pkg/cmd/repo/list/http.go b/pkg/cmd/repo/list/http.go index 191ba0d18..0508c6d80 100644 --- a/pkg/cmd/repo/list/http.go +++ b/pkg/cmd/repo/list/http.go @@ -209,7 +209,7 @@ func searchQuery(owner string, filter FilterOptions) string { Is: []string{filter.Visibility}, Language: filter.Language, Topic: filter.Topic, - User: owner, + User: []string{owner}, }, } diff --git a/pkg/cmd/search/commits/commits.go b/pkg/cmd/search/commits/commits.go index 3a6bd9e2a..7f74c51d8 100644 --- a/pkg/cmd/search/commits/commits.go +++ b/pkg/cmd/search/commits/commits.go @@ -114,7 +114,7 @@ func NewCmdCommits(f *cmdutil.Factory, runF func(*CommitsOptions) error) *cobra. cmd.Flags().StringVar(&opts.Query.Qualifiers.Parent, "parent", "", "Filter by parent hash") cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository") cmd.Flags().StringVar(&opts.Query.Qualifiers.Tree, "tree", "", "Filter by tree hash") - cmd.Flags().StringVar(&opts.Query.Qualifiers.User, "owner", "", "Filter on repository owner") + cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.User, "owner", nil, "Filter on repository owner") cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.Is, "visibility", "", nil, []string{"public", "private", "internal"}, "Filter based on repository visibility") return cmd diff --git a/pkg/cmd/search/commits/commits_test.go b/pkg/cmd/search/commits/commits_test.go index 9728f3ee1..efe022f32 100644 --- a/pkg/cmd/search/commits/commits_test.go +++ b/pkg/cmd/search/commits/commits_test.go @@ -106,7 +106,7 @@ func TestNewCmdCommits(t *testing.T) { Parent: "bbb", Repo: []string{"owner/repo"}, Tree: "ccc", - User: "owner", + User: []string{"owner"}, Is: []string{"public"}, }, }, diff --git a/pkg/cmd/search/issues/issues.go b/pkg/cmd/search/issues/issues.go index 5349268a7..e8da9edbb 100644 --- a/pkg/cmd/search/issues/issues.go +++ b/pkg/cmd/search/issues/issues.go @@ -160,7 +160,7 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.State, "state", "", "", []string{"open", "closed"}, "Filter based on state") cmd.Flags().StringVar(&opts.Query.Qualifiers.Team, "team-mentions", "", "Filter based on team mentions") cmd.Flags().StringVar(&opts.Query.Qualifiers.Updated, "updated", "", "Filter on last updated at `date`") - cmd.Flags().StringVar(&opts.Query.Qualifiers.User, "owner", "", "Filter on repository owner") + cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.User, "owner", nil, "Filter on repository owner") return cmd } diff --git a/pkg/cmd/search/prs/prs.go b/pkg/cmd/search/prs/prs.go index a7ff37e5e..fc68e51cc 100644 --- a/pkg/cmd/search/prs/prs.go +++ b/pkg/cmd/search/prs/prs.go @@ -171,7 +171,7 @@ func NewCmdPrs(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobr cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.State, "state", "", "", []string{"open", "closed"}, "Filter based on state") cmd.Flags().StringVar(&opts.Query.Qualifiers.Team, "team-mentions", "", "Filter based on team mentions") cmd.Flags().StringVar(&opts.Query.Qualifiers.Updated, "updated", "", "Filter on last updated at `date`") - cmd.Flags().StringVar(&opts.Query.Qualifiers.User, "owner", "", "Filter on repository owner") + cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.User, "owner", nil, "Filter on repository owner") // Pull request query qualifier flags cmd.Flags().StringVarP(&opts.Query.Qualifiers.Base, "base", "B", "", "Filter on base branch name") diff --git a/pkg/cmd/search/repos/repos.go b/pkg/cmd/search/repos/repos.go index a8066bc7b..759b3d120 100644 --- a/pkg/cmd/search/repos/repos.go +++ b/pkg/cmd/search/repos/repos.go @@ -118,7 +118,7 @@ func NewCmdRepos(f *cmdutil.Factory, runF func(*ReposOptions) error) *cobra.Comm cmd.Flags().StringVar(&opts.Query.Qualifiers.Stars, "stars", "", "Filter on `number` of stars") cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Topic, "topic", nil, "Filter on topic") cmd.Flags().StringVar(&opts.Query.Qualifiers.Topics, "number-topics", "", "Filter on `number` of topics") - cmd.Flags().StringVar(&opts.Query.Qualifiers.User, "owner", "", "Filter on owner") + cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.User, "owner", nil, "Filter on owner") return cmd } diff --git a/pkg/cmd/search/repos/repos_test.go b/pkg/cmd/search/repos/repos_test.go index 7b80c9177..85e2547f1 100644 --- a/pkg/cmd/search/repos/repos_test.go +++ b/pkg/cmd/search/repos/repos_test.go @@ -110,7 +110,7 @@ func TestNewCmdRepos(t *testing.T) { Stars: "6", Topic: []string{"topic"}, Topics: "7", - User: "owner", + User: []string{"owner"}, Is: []string{"public"}, }, }, diff --git a/pkg/search/query.go b/pkg/search/query.go index fab6305f6..50c7029bd 100644 --- a/pkg/search/query.go +++ b/pkg/search/query.go @@ -79,7 +79,7 @@ type Qualifiers struct { Tree string Type string Updated string - User string + User []string } func (q Query) String() string { diff --git a/pkg/search/query_test.go b/pkg/search/query_test.go index dacc033f7..b82bec85f 100644 --- a/pkg/search/query_test.go +++ b/pkg/search/query_test.go @@ -36,11 +36,14 @@ func TestQueryString(t *testing.T) { Stars: "6", Topic: []string{"topic"}, Topics: "7", - User: "user", + User: []string{"user1", "user2"}, Is: []string{"public"}, }, }, - out: "some keywords archived:true author-email:foo@example.com committer-date:2021-02-28 created:created followers:1 fork:true forks:2 good-first-issues:3 help-wanted-issues:4 in:description in:readme is:public language:language license:license pushed:updated size:5 stars:6 topic:topic topics:7 user:user", + out: "some keywords archived:true author-email:foo@example.com committer-date:2021-02-28 " + + "created:created followers:1 fork:true forks:2 good-first-issues:3 help-wanted-issues:4 " + + "in:description in:readme is:public language:language license:license pushed:updated size:5 " + + "stars:6 topic:topic topics:7 user:user1 user:user2", }, { name: "quotes keywords", @@ -100,7 +103,7 @@ func TestQualifiersMap(t *testing.T) { Stars: "6", Topic: []string{"topic"}, Topics: "7", - User: "user", + User: []string{"user1", "user2"}, }, out: map[string][]string{ "archived": {"true"}, @@ -121,7 +124,7 @@ func TestQualifiersMap(t *testing.T) { "stars": {"6"}, "topic": {"topic"}, "topics": {"7"}, - "user": {"user"}, + "user": {"user1", "user2"}, }, }, { @@ -130,7 +133,7 @@ func TestQualifiersMap(t *testing.T) { Pushed: "updated", Size: "5", Stars: "6", - User: "user", + User: []string{"user"}, }, out: map[string][]string{ "pushed": {"updated"}, From 83322104b71c9248f8b77eef71492a969f78bc5a Mon Sep 17 00:00:00 2001 From: doaortu <113872927+doaortu@users.noreply.github.com> Date: Thu, 13 Apr 2023 22:37:16 +0700 Subject: [PATCH 49/65] feat: add web flag for codespace list & create subcommand (#7288) * feat: add web flag to cs list subcommand web flag only works with repo flag, because, currently there only param for listing with repo_id * feat: add web flag to cs crate subcommand web flag used for creating codespace through web UI instead of terminal. web flag cannot be used with display-name, idle-timeout, or retention retention-period because there's no option for that in the Web UI * refactor: extract mutual excusive logic to PreRunE - changed web flag mutual exclusive logic, using cmdutil - extract that logic to PreRunE clause in createCmd - move web flag up to make it close to PreRunE clause (for clarity) - add new param to newCreateCmd fn to facilitate test logic - apply new newCreateCmd fn to root.go * fix: clarify flag desc and error message - remove 'yet' from error messages that can cause misunderstanding - clarify list web flag can only be used with repo flag * fix: skip machine check when we use web flag ... (..and no machine flag provided) + add test for this new case + adjust related test cases for this new change. * refactor: move flag check logic to PreRunE why: err on PreRunE or RunE will also print help if error happened + move web, repo, org, user mutual exclusive logic to PreRunE clause + move repo, org, user mutual exclusive logic to PreRunE + move limit check flag to PreRunE + modify newListCmd fn to facilitate test logic + apply new newListCmd to root.go + add test cases to check PreRunE clause - remove mutual exclusive test cases from Test_AppList * refactor: remove the opts equality checks * fix: mutually exclusive misfires because of wrong logic + refine test case too * cleanup:removing useWeb check in fn getMachineName because it's no longer needed + remove redundant test-case * refactor: remove redundant ifs * refactor: clarify test name * re-clarify web flag desc in list.go * refactor: break long lines, use more idiomatic err * add test case for nonexistent/wrong machine --- pkg/cmd/codespace/create.go | 39 +++++-- pkg/cmd/codespace/create_test.go | 176 ++++++++++++++++++++++++++++++- pkg/cmd/codespace/list.go | 34 +++++- pkg/cmd/codespace/list_test.go | 118 +++++++++++++++++++-- 4 files changed, 344 insertions(+), 23 deletions(-) diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go index 38908ce3b..6f973d3cb 100644 --- a/pkg/cmd/codespace/create.go +++ b/pkg/cmd/codespace/create.go @@ -69,6 +69,7 @@ type createOptions struct { idleTimeout time.Duration retentionPeriod NullableDuration displayName string + useWeb bool } func newCreateCmd(app *App) *cobra.Command { @@ -78,11 +79,20 @@ func newCreateCmd(app *App) *cobra.Command { Use: "create", Short: "Create a codespace", Args: noArgsConstraint, + PreRunE: func(cmd *cobra.Command, args []string) error { + return cmdutil.MutuallyExclusive( + "using --web with --display-name, --idle-timeout, or --retention-period is not supported", + opts.useWeb, + opts.displayName != "" || opts.idleTimeout != 0 || opts.retentionPeriod.Duration != nil, + ) + }, RunE: func(cmd *cobra.Command, args []string) error { return app.Create(cmd.Context(), opts) }, } + createCmd.Flags().BoolVarP(&opts.useWeb, "web", "w", false, "create codespace from browser, cannot be used with --display-name, --idle-timeout, or --retention-period") + createCmd.Flags().StringVarP(&opts.repo, "repo", "R", "", "repository name with owner: user/repo") if err := addDeprecatedRepoShorthand(createCmd, &opts.repo); err != nil { fmt.Fprintf(app.io.ErrOut, "%v\n", err) @@ -118,7 +128,11 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { Location: opts.location, } - promptForRepoAndBranch := userInputs.Repository == "" + if opts.useWeb && userInputs.Repository == "" { + return a.browser.Browse("https://github.com/codespaces/new") + } + + promptForRepoAndBranch := userInputs.Repository == "" && !opts.useWeb if promptForRepoAndBranch { var defaultRepo string if remotes, _ := a.remotes(); remotes != nil { @@ -249,12 +263,19 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { } } - machine, err := getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, userInputs.Location, devContainerPath) - if err != nil { - return fmt.Errorf("error getting machine type: %w", err) - } - if machine == "" { - return errors.New("there are no available machine types for this repository") + machine := opts.machine + // skip this if we have useWeb and no machine name provided, + // because web UI will select default machine type if none is provided + // web UI also provide a way to select machine type + // therefore we let the user choose from the web UI instead of prompting from CLI + if !(opts.useWeb && opts.machine == "") { + machine, err = getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, userInputs.Location, devContainerPath) + if err != nil { + return fmt.Errorf("error getting machine type: %w", err) + } + if machine == "" { + return errors.New("there are no available machine types for this repository") + } } createParams := &api.CreateCodespaceParams{ @@ -271,6 +292,10 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { DisplayName: opts.displayName, } + if opts.useWeb { + return a.browser.Browse(fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", createParams.RepositoryID, createParams.Branch, createParams.Machine, createParams.Location)) + } + var codespace *api.Codespace err = a.RunWithProgress("Creating codespace", func() (err error) { codespace, err = a.apiClient.CreateCodespace(ctx, createParams) diff --git a/pkg/cmd/codespace/create_test.go b/pkg/cmd/codespace/create_test.go index 4f084ce12..7b74c206d 100644 --- a/pkg/cmd/codespace/create_test.go +++ b/pkg/cmd/codespace/create_test.go @@ -1,17 +1,60 @@ package codespace import ( + "bytes" "context" "fmt" "testing" "time" + "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/codespaces/api" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" "github.com/stretchr/testify/assert" ) +func TestCreateCmdFlagError(t *testing.T) { + tests := []struct { + name string + args string + wantsErr error + }{ + { + name: "return error when using web flag with display-name, idle-timeout, or retention-period flags", + args: "--web --display-name foo --idle-timeout 30m", + wantsErr: fmt.Errorf("using --web with --display-name, --idle-timeout, or --retention-period is not supported"), + }, + { + name: "return error when using web flag with one of display-name, idle-timeout or retention-period flags", + args: "--web --idle-timeout 30m", + wantsErr: fmt.Errorf("using --web with --display-name, --idle-timeout, or --retention-period is not supported"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + a := &App{ + io: ios, + } + cmd := newCreateCmd(a) + + args, _ := shlex.Split(tt.args) + cmd.SetArgs(args) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err := cmd.ExecuteC() + + assert.Error(t, err) + assert.EqualError(t, err, tt.wantsErr.Error()) + }) + } +} + func TestApp_Create(t *testing.T) { type fields struct { apiClient apiClient @@ -23,6 +66,7 @@ func TestApp_Create(t *testing.T) { wantErr error wantStdout string wantStderr string + wantURL string isTTY bool }{ { @@ -115,6 +159,31 @@ func TestApp_Create(t *testing.T) { wantStdout: "monalisa-dotfiles-abcd1234\n", wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n", }, + { + name: "create codespace with nonexistent machine results in error", + fields: fields{ + apiClient: apiCreateDefaults(&apiClientMock{ + GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) { + return []*api.Machine{ + { + Name: "GIGA", + DisplayName: "Gigabits of a machine", + }, + { + Name: "TERA", + DisplayName: "Terabits of a machine", + }, + }, nil + }, + }), + }, + opts: createOptions{ + repo: "monalisa/dotfiles", + machine: "MEGA", + }, + wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n", + wantErr: fmt.Errorf("error getting machine type: there is no such machine for the repository: %s\nAvailable machines: %v", "MEGA", []string{"GIGA", "TERA"}), + }, { name: "create codespace with devcontainer path results in selecting the correct machine type", fields: fields{ @@ -382,7 +451,95 @@ Alternatively, you can run "create" with the "--default-permissions" option to c }, wantStdout: "megacorp-private-abcd1234\n", }, + { + name: "return default url when using web flag without other flags", + opts: createOptions{ + useWeb: true, + }, + wantURL: "https://github.com/codespaces/new", + }, + { + name: "skip machine check when using web flag and no machine provided", + fields: fields{ + apiClient: apiCreateDefaults(&apiClientMock{ + GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) { + return &api.Repository{ + ID: 123, + DefaultBranch: "main", + }, nil + }, + CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) { + return &api.Codespace{ + Name: "monalisa-dotfiles-abcd1234", + }, nil + }, + }), + }, + opts: createOptions{ + repo: "monalisa/dotfiles", + useWeb: true, + branch: "custom", + location: "EastUS", + }, + wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n", + wantURL: fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", 123, "custom", "", "EastUS"), + }, + { + name: "return correct url with correct params when using web flag and repo flag", + fields: fields{ + apiClient: apiCreateDefaults(&apiClientMock{ + GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) { + return &api.Repository{ + ID: 123, + DefaultBranch: "main", + }, nil + }, + CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) { + return &api.Codespace{ + Name: "monalisa-dotfiles-abcd1234", + }, nil + }, + }), + }, + opts: createOptions{ + repo: "monalisa/dotfiles", + useWeb: true, + }, + wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n", + wantURL: fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", 123, "main", "", ""), + }, + { + name: "return correct url with correct params when using web flag, repo, branch, location, machine flag", + fields: fields{ + apiClient: apiCreateDefaults(&apiClientMock{ + GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) { + return &api.Repository{ + ID: 123, + DefaultBranch: "main", + }, nil + }, + CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) { + return &api.Codespace{ + Name: "monalisa-dotfiles-abcd1234", + Machine: api.CodespaceMachine{Name: "GIGA"}, + }, nil + }, + }), + }, + opts: createOptions{ + repo: "monalisa/dotfiles", + machine: "GIGA", + branch: "custom", + location: "EastUS", + useWeb: true, + }, + wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n", + wantURL: fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", 123, "custom", "GIGA", "EastUS"), + }, } + var a *App + var b *browser.Stub + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, stdout, stderr := iostreams.Test() @@ -390,9 +547,18 @@ Alternatively, you can run "create" with the "--default-permissions" option to c ios.SetStdinTTY(tt.isTTY) ios.SetStderrTTY(tt.isTTY) - a := &App{ - io: ios, - apiClient: tt.fields.apiClient, + if tt.opts.useWeb { + b = &browser.Stub{} + a = &App{ + io: ios, + apiClient: tt.fields.apiClient, + browser: b, + } + } else { + a = &App{ + io: ios, + apiClient: tt.fields.apiClient, + } } err := a.Create(context.Background(), tt.opts) @@ -410,6 +576,10 @@ Alternatively, you can run "create" with the "--default-permissions" option to c t.Logf(t.Name()) t.Errorf(" stderr = %v, want %v", got, tt.wantStderr) } + + if tt.opts.useWeb { + b.Verify(t, tt.wantURL) + } }) } } diff --git a/pkg/cmd/codespace/list.go b/pkg/cmd/codespace/list.go index 64779e75d..6b1cd1cc0 100644 --- a/pkg/cmd/codespace/list.go +++ b/pkg/cmd/codespace/list.go @@ -18,6 +18,7 @@ type listOptions struct { repo string orgName string userName string + useWeb bool } func newListCmd(app *App) *cobra.Command { @@ -34,11 +35,29 @@ func newListCmd(app *App) *cobra.Command { `), Aliases: []string{"ls"}, Args: noArgsConstraint, - RunE: func(cmd *cobra.Command, args []string) error { + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.MutuallyExclusive( + "using `--org` or `--user` with `--repo` is not allowed", + opts.repo != "", + opts.orgName != "" || opts.userName != "", + ); err != nil { + return err + } + + if err := cmdutil.MutuallyExclusive( + "using `--web` with `--org` or `--user` is not supported, please use with `--repo` instead", + opts.useWeb, + opts.orgName != "" || opts.userName != "", + ); err != nil { + return err + } + if opts.limit < 1 { return cmdutil.FlagErrorf("invalid limit: %v", opts.limit) } - + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { return app.List(cmd.Context(), opts, exporter) }, } @@ -53,13 +72,14 @@ func newListCmd(app *App) *cobra.Command { listCmd.Flags().StringVarP(&opts.userName, "user", "u", "", "The `username` to list codespaces for (used with --org)") cmdutil.AddJSONFlags(listCmd, &exporter, api.CodespaceFields) + listCmd.Flags().BoolVarP(&opts.useWeb, "web", "w", false, "List codespaces in the web browser, cannot be used with --user or --org") + return listCmd } func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Exporter) error { - // if repo is provided, we don't accept orgName or userName - if opts.repo != "" && (opts.orgName != "" || opts.userName != "") { - return cmdutil.FlagErrorf("using `--org` or `--user` with `--repo` is not allowed") + if opts.useWeb && opts.repo == "" { + return a.browser.Browse("https://github.com/codespaces") } var codespaces []*api.Codespace @@ -92,6 +112,10 @@ func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Expo return cmdutil.NewNoResultsError("no codespaces found") } + if opts.useWeb && codespaces[0].Repository.ID > 0 { + return a.browser.Browse(fmt.Sprintf("https://github.com/codespaces?repository_id=%d", codespaces[0].Repository.ID)) + } + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(a.io) if tp.IsTTY() { diff --git a/pkg/cmd/codespace/list_test.go b/pkg/cmd/codespace/list_test.go index 46448869f..d99b8b97e 100644 --- a/pkg/cmd/codespace/list_test.go +++ b/pkg/cmd/codespace/list_test.go @@ -1,15 +1,68 @@ package codespace import ( + "bytes" "context" "fmt" "testing" + "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/codespaces/api" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" ) +func TestListCmdFlagError(t *testing.T) { + tests := []struct { + name string + args string + wantsErr error + }{ + { + name: "list codespaces,--repo, --org and --user flag", + args: "--repo foo/bar --org github --user github", + wantsErr: fmt.Errorf("using `--org` or `--user` with `--repo` is not allowed"), + }, + { + name: "list codespaces,--web, --org and --user flag", + args: "--web --org github --user github", + wantsErr: fmt.Errorf("using `--web` with `--org` or `--user` is not supported, please use with `--repo` instead"), + }, + { + name: "list codespaces, negative --limit flag", + args: "--limit -1", + wantsErr: fmt.Errorf("invalid limit: -1"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + a := &App{ + io: ios, + } + + cmd := newListCmd(a) + + args, _ := shlex.Split(tt.args) + cmd.SetArgs(args) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err := cmd.ExecuteC() + + if tt.wantsErr != nil { + assert.Error(t, err) + assert.EqualError(t, err, tt.wantsErr.Error()) + return + } + }) + } +} + func TestApp_List(t *testing.T) { type fields struct { apiClient apiClient @@ -19,6 +72,7 @@ func TestApp_List(t *testing.T) { fields fields opts *listOptions wantError error + wantURL string }{ { name: "list codespaces, no flags", @@ -115,22 +169,66 @@ func TestApp_List(t *testing.T) { }, }, { - name: "list codespaces,--repo, --org and --user flag", + name: "list codespaces,--web", opts: &listOptions{ - repo: "cli/cli", - orgName: "TestOrg", - userName: "jimmy", + useWeb: true, }, - wantError: fmt.Errorf("using `--org` or `--user` with `--repo` is not allowed"), + wantURL: "https://github.com/codespaces", + }, + { + name: "list codespaces,--web, --repo flag", + fields: fields{ + apiClient: &apiClientMock{ + ListCodespacesFunc: func(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) { + if opts.RepoName == "" { + return nil, fmt.Errorf("Expected repository to not be nil") + } + if opts.RepoName != "cli/cli" { + return nil, fmt.Errorf("Expected repository name to be cli/cli. Got %s", opts.RepoName) + } + if opts.OrgName != "" { + return nil, fmt.Errorf("Expected orgName to be blank. Got %s", opts.OrgName) + } + if opts.UserName != "" { + return nil, fmt.Errorf("Expected userName to be blank. Got %s", opts.UserName) + } + return []*api.Codespace{ + { + DisplayName: "CS1", + Repository: api.Repository{ID: 123}, + }, + }, nil + }, + }, + }, + opts: &listOptions{ + useWeb: true, + repo: "cli/cli", + }, + wantURL: "https://github.com/codespaces?repository_id=123", }, } + + var b *browser.Stub + var a *App + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() - a := &App{ - io: ios, - apiClient: tt.fields.apiClient, + if tt.opts.useWeb { + b = &browser.Stub{} + a = &App{ + browser: b, + io: ios, + apiClient: tt.fields.apiClient, + } + } else { + a = &App{ + io: ios, + apiClient: tt.fields.apiClient, + } } + var exporter cmdutil.Exporter err := a.List(context.Background(), tt.opts, exporter) @@ -142,6 +240,10 @@ func TestApp_List(t *testing.T) { if err != nil && err.Error() != tt.wantError.Error() { t.Errorf("error = %v, wantErr %v", err, tt.wantError) } + + if tt.opts.useWeb { + b.Verify(t, tt.wantURL) + } }) } } From c0855a3e611036e52d272138ee62eafb9336a985 Mon Sep 17 00:00:00 2001 From: Navaneethan <59121948+FalseDev@users.noreply.github.com> Date: Sat, 15 Apr 2023 00:38:42 +0530 Subject: [PATCH 50/65] Fix typo in `cs stop` command: `Stoppping` -> `Stopping` (#7318) --- pkg/cmd/codespace/stop.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/codespace/stop.go b/pkg/cmd/codespace/stop.go index e95fd3231..c2b675033 100644 --- a/pkg/cmd/codespace/stop.go +++ b/pkg/cmd/codespace/stop.go @@ -97,7 +97,7 @@ func (a *App) StopCodespace(ctx context.Context, opts *stopOptions) error { } } - err := a.RunWithProgress("Stoppping codespace", func() (err error) { + err := a.RunWithProgress("Stopping codespace", func() (err error) { err = a.apiClient.StopCodespace(ctx, codespaceName, opts.orgName, ownerName) return }) From dbc2f05124e7cbc7648967a5f9df7dbb448ce31c Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Sun, 16 Apr 2023 15:34:23 +1000 Subject: [PATCH 51/65] Update go-gh to v2 (#7299) * Update go-gh * Update code for go-gh v2 --- api/client.go | 27 ++++++++++---------- api/http_client.go | 5 ++-- api/queries_projects_v2.go | 2 +- api/queries_repo.go | 10 ++++---- go.mod | 4 +-- go.sum | 8 +++--- internal/browser/browser.go | 4 +-- internal/config/config.go | 4 +-- internal/config/stub.go | 2 +- internal/ghrepo/repo.go | 6 ++--- internal/tableprinter/table_printer.go | 2 +- internal/text/text.go | 2 +- pkg/cmd/api/api.go | 6 ++--- pkg/cmd/api/api_test.go | 4 +-- pkg/cmd/auth/login/login.go | 2 +- pkg/cmd/extension/ext_tmpls/goBinMain.go.txt | 4 +-- pkg/cmd/factory/remote_resolver.go | 2 +- pkg/cmd/repo/delete/delete.go | 2 +- pkg/cmd/status/status.go | 6 ++--- pkg/cmdutil/json_flags.go | 4 +-- pkg/iostreams/iostreams.go | 2 +- pkg/markdown/markdown.go | 2 +- utils/table_printer.go | 2 +- 23 files changed, 55 insertions(+), 57 deletions(-) diff --git a/api/client.go b/api/client.go index 0447051cc..e32856554 100644 --- a/api/client.go +++ b/api/client.go @@ -11,8 +11,7 @@ import ( "strings" "github.com/cli/cli/v2/internal/ghinstance" - "github.com/cli/go-gh" - ghAPI "github.com/cli/go-gh/pkg/api" + ghAPI "github.com/cli/go-gh/v2/pkg/api" ) const ( @@ -40,11 +39,11 @@ func (c *Client) HTTP() *http.Client { } type GraphQLError struct { - ghAPI.GQLError + *ghAPI.GraphQLError } type HTTPError struct { - ghAPI.HTTPError + *ghAPI.HTTPError scopesSuggestion string } @@ -57,7 +56,7 @@ func (err HTTPError) ScopesSuggestion() string { func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error { opts := clientOptions(hostname, c.http.Transport) opts.Headers[graphqlFeatures] = features - gqlClient, err := gh.GQLClient(&opts) + gqlClient, err := ghAPI.NewGraphQLClient(opts) if err != nil { return err } @@ -69,7 +68,7 @@ func (c Client) GraphQL(hostname string, query string, variables map[string]inte func (c Client) Mutate(hostname, name string, mutation interface{}, variables map[string]interface{}) error { opts := clientOptions(hostname, c.http.Transport) opts.Headers[graphqlFeatures] = features - gqlClient, err := gh.GQLClient(&opts) + gqlClient, err := ghAPI.NewGraphQLClient(opts) if err != nil { return err } @@ -81,7 +80,7 @@ func (c Client) Mutate(hostname, name string, mutation interface{}, variables ma func (c Client) Query(hostname, name string, query interface{}, variables map[string]interface{}) error { opts := clientOptions(hostname, c.http.Transport) opts.Headers[graphqlFeatures] = features - gqlClient, err := gh.GQLClient(&opts) + gqlClient, err := ghAPI.NewGraphQLClient(opts) if err != nil { return err } @@ -93,7 +92,7 @@ func (c Client) Query(hostname, name string, query interface{}, variables map[st func (c Client) QueryWithContext(ctx context.Context, hostname, name string, query interface{}, variables map[string]interface{}) error { opts := clientOptions(hostname, c.http.Transport) opts.Headers[graphqlFeatures] = features - gqlClient, err := gh.GQLClient(&opts) + gqlClient, err := ghAPI.NewGraphQLClient(opts) if err != nil { return err } @@ -103,7 +102,7 @@ func (c Client) QueryWithContext(ctx context.Context, hostname, name string, que // REST performs a REST request and parses the response. func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error { opts := clientOptions(hostname, c.http.Transport) - restClient, err := gh.RESTClient(&opts) + restClient, err := ghAPI.NewRESTClient(opts) if err != nil { return err } @@ -112,7 +111,7 @@ func (c Client) REST(hostname string, method string, p string, body io.Reader, d func (c Client) RESTWithNext(hostname string, method string, p string, body io.Reader, data interface{}) (string, error) { opts := clientOptions(hostname, c.http.Transport) - restClient, err := gh.RESTClient(&opts) + restClient, err := ghAPI.NewRESTClient(opts) if err != nil { return "", err } @@ -157,14 +156,14 @@ func HandleHTTPError(resp *http.Response) error { return handleResponse(ghAPI.HandleHTTPError(resp)) } -// handleResponse takes a ghAPI.HTTPError or ghAPI.GQLError and converts it into an +// handleResponse takes a ghAPI.HTTPError or ghAPI.GraphQLError and converts it into an // HTTPError or GraphQLError respectively. func handleResponse(err error) error { if err == nil { return nil } - var restErr ghAPI.HTTPError + var restErr *ghAPI.HTTPError if errors.As(err, &restErr) { return HTTPError{ HTTPError: restErr, @@ -175,10 +174,10 @@ func handleResponse(err error) error { } } - var gqlErr ghAPI.GQLError + var gqlErr *ghAPI.GraphQLError if errors.As(err, &gqlErr) { return GraphQLError{ - GQLError: gqlErr, + GraphQLError: gqlErr, } } diff --git a/api/http_client.go b/api/http_client.go index 3fb83974e..691f99481 100644 --- a/api/http_client.go +++ b/api/http_client.go @@ -9,8 +9,7 @@ import ( "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/utils" - "github.com/cli/go-gh" - ghAPI "github.com/cli/go-gh/pkg/api" + ghAPI "github.com/cli/go-gh/v2/pkg/api" ) type tokenGetter interface { @@ -55,7 +54,7 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) { clientOpts.CacheTTL = opts.CacheTTL } - client, err := gh.HTTPClient(&clientOpts) + client, err := ghAPI.NewHTTPClient(clientOpts) if err != nil { return nil, err } diff --git a/api/queries_projects_v2.go b/api/queries_projects_v2.go index d706ae45e..a602653af 100644 --- a/api/queries_projects_v2.go +++ b/api/queries_projects_v2.go @@ -255,7 +255,7 @@ func CurrentUserProjectsV2(client *Client, hostname string) ([]ProjectV2, error) // When querying ProjectsV2 fields we generally dont want to show the user // scope errors and field does not exist errors. ProjectsV2IgnorableError // checks against known error strings to see if an error can be safely ignored. -// Due to the fact that the GQLClient can return multiple types of errors +// Due to the fact that the GraphQLClient can return multiple types of errors // this uses brittle string comparison to check against the known error strings. func ProjectsV2IgnorableError(err error) bool { msg := err.Error() diff --git a/api/queries_repo.go b/api/queries_repo.go index 06bea47bf..1d6b795b2 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -16,7 +16,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/cli/cli/v2/internal/ghrepo" - ghAPI "github.com/cli/go-gh/pkg/api" + ghAPI "github.com/cli/go-gh/v2/pkg/api" "github.com/shurcooL/githubv4" ) @@ -263,8 +263,8 @@ func FetchRepository(client *Client, repo ghrepo.Interface, fields []string) (*R // guaranteed to happen when an authentication token with insufficient permissions is being used. if result.Repository == nil { return nil, GraphQLError{ - GQLError: ghAPI.GQLError{ - Errors: []ghAPI.GQLErrorItem{{ + GraphQLError: &ghAPI.GraphQLError{ + Errors: []ghAPI.GraphQLErrorItem{{ Type: "NOT_FOUND", Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()), }}, @@ -316,8 +316,8 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { // guaranteed to happen when an authentication token with insufficient permissions is being used. if result.Repository == nil { return nil, GraphQLError{ - GQLError: ghAPI.GQLError{ - Errors: []ghAPI.GQLErrorItem{{ + GraphQLError: &ghAPI.GraphQLError{ + Errors: []ghAPI.GraphQLErrorItem{{ Type: "NOT_FOUND", Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()), }}, diff --git a/go.mod b/go.mod index be436c04b..4b00bca92 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/cenkalti/backoff/v4 v4.2.0 github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da github.com/charmbracelet/lipgloss v0.5.0 - github.com/cli/go-gh v1.2.1 + github.com/cli/go-gh/v2 v2.0.0 github.com/cli/oauth v1.0.1 github.com/cli/safeexec v1.0.1 github.com/cpuguy83/go-md2man/v2 v2.0.2 @@ -50,7 +50,7 @@ require ( github.com/alessio/shellescape v1.4.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/cli/browser v1.1.0 // indirect - github.com/cli/shurcooL-graphql v0.0.2 // indirect + github.com/cli/shurcooL-graphql v0.0.3 // indirect github.com/danieljoos/wincred v1.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect diff --git a/go.sum b/go.sum index 86db0a40a..d5a53af67 100644 --- a/go.sum +++ b/go.sum @@ -62,15 +62,15 @@ github.com/cli/browser v1.1.0 h1:xOZBfkfY9L9vMBgqb1YwRirGu6QFaQ5dP/vXt5ENSOY= github.com/cli/browser v1.1.0/go.mod h1:HKMQAt9t12kov91Mn7RfZxyJQQgWgyS/3SZswlZ5iTI= github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03 h1:3f4uHLfWx4/WlnMPXGai03eoWAI+oGHJwr+5OXfxCr8= github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -github.com/cli/go-gh v1.2.1 h1:xFrjejSsgPiwXFP6VYynKWwxLQcNJy3Twbu82ZDlR/o= -github.com/cli/go-gh v1.2.1/go.mod h1:Jxk8X+TCO4Ui/GarwY9tByWm/8zp4jJktzVZNlTW5VM= +github.com/cli/go-gh/v2 v2.0.0 h1:JAgQY7VNHletsO0Eqr+/PzF7fF5QEjhY2t2+Tev3vmk= +github.com/cli/go-gh/v2 v2.0.0/go.mod h1:2/ox3Dnc8wDBT5bnTAH1aKGy6Qt1ztlFBe10EufnvoA= github.com/cli/oauth v1.0.1 h1:pXnTFl/qUegXHK531Dv0LNjW4mLx626eS42gnzfXJPA= github.com/cli/oauth v1.0.1/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4= github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= -github.com/cli/shurcooL-graphql v0.0.2 h1:rwP5/qQQ2fM0TzkUTwtt6E2LbIYf6R+39cUXTa04NYk= -github.com/cli/shurcooL-graphql v0.0.2/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA= +github.com/cli/shurcooL-graphql v0.0.3 h1:CtpPxyGDs136/+ZeyAfUKYmcQBjDlq5aqnrDCW5Ghh8= +github.com/cli/shurcooL-graphql v0.0.3/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= diff --git a/internal/browser/browser.go b/internal/browser/browser.go index e3c225b0f..0f231ddc8 100644 --- a/internal/browser/browser.go +++ b/internal/browser/browser.go @@ -3,7 +3,7 @@ package browser import ( "io" - ghBrowser "github.com/cli/go-gh/pkg/browser" + ghBrowser "github.com/cli/go-gh/v2/pkg/browser" ) type Browser interface { @@ -12,5 +12,5 @@ type Browser interface { func New(launcher string, stdout, stderr io.Writer) Browser { b := ghBrowser.New(launcher, stdout, stderr) - return &b + return b } diff --git a/internal/config/config.go b/internal/config/config.go index 890dcec9f..f86e566c7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,8 +4,8 @@ import ( "os" "path/filepath" - ghAuth "github.com/cli/go-gh/pkg/auth" - ghConfig "github.com/cli/go-gh/pkg/config" + ghAuth "github.com/cli/go-gh/v2/pkg/auth" + ghConfig "github.com/cli/go-gh/v2/pkg/config" "github.com/zalando/go-keyring" ) diff --git a/internal/config/stub.go b/internal/config/stub.go index f8e56ee4e..5e37a8ccb 100644 --- a/internal/config/stub.go +++ b/internal/config/stub.go @@ -6,7 +6,7 @@ import ( "path/filepath" "testing" - ghConfig "github.com/cli/go-gh/pkg/config" + ghConfig "github.com/cli/go-gh/v2/pkg/config" ) func NewBlankConfig() *ConfigMock { diff --git a/internal/ghrepo/repo.go b/internal/ghrepo/repo.go index 5151f65d4..9c6c48a4d 100644 --- a/internal/ghrepo/repo.go +++ b/internal/ghrepo/repo.go @@ -6,8 +6,8 @@ import ( "strings" "github.com/cli/cli/v2/internal/ghinstance" - ghAuth "github.com/cli/go-gh/pkg/auth" - "github.com/cli/go-gh/pkg/repository" + ghAuth "github.com/cli/go-gh/v2/pkg/auth" + "github.com/cli/go-gh/v2/pkg/repository" ) // Interface describes an object that represents a GitHub repository @@ -54,7 +54,7 @@ func FromFullNameWithHost(nwo, fallbackHost string) (Interface, error) { if err != nil { return nil, err } - return NewWithHost(repo.Owner(), repo.Name(), repo.Host()), nil + return NewWithHost(repo.Owner, repo.Name, repo.Host), nil } // FromURL extracts the GitHub repository information from a git remote URL diff --git a/internal/tableprinter/table_printer.go b/internal/tableprinter/table_printer.go index 059203a6e..1cd7c7d6e 100644 --- a/internal/tableprinter/table_printer.go +++ b/internal/tableprinter/table_printer.go @@ -6,7 +6,7 @@ import ( "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/go-gh/pkg/tableprinter" + "github.com/cli/go-gh/v2/pkg/tableprinter" ) type TablePrinter struct { diff --git a/internal/text/text.go b/internal/text/text.go index 0671e1e74..afa74f7dc 100644 --- a/internal/text/text.go +++ b/internal/text/text.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/cli/go-gh/pkg/text" + "github.com/cli/go-gh/v2/pkg/text" "golang.org/x/text/cases" "golang.org/x/text/language" ) diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 1a6f84d0b..2a80c464c 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -23,8 +23,8 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/jsoncolor" - "github.com/cli/go-gh/pkg/jq" - "github.com/cli/go-gh/pkg/template" + "github.com/cli/go-gh/v2/pkg/jq" + "github.com/cli/go-gh/v2/pkg/template" "github.com/spf13/cobra" ) @@ -328,7 +328,7 @@ func apiRun(opts *ApiOptions) error { return err } - endCursor, err := processResponse(resp, opts, bodyWriter, headersWriter, &tmpl) + endCursor, err := processResponse(resp, opts, bodyWriter, headersWriter, tmpl) if err != nil { return err } diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index 27a47dd92..d2fdc3c3c 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -19,7 +19,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/go-gh/pkg/template" + "github.com/cli/go-gh/v2/pkg/template" "github.com/google/shlex" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1206,7 +1206,7 @@ func Test_processResponse_template(t *testing.T) { tmpl := template.New(ios.Out, ios.TerminalWidth(), ios.ColorEnabled()) err := tmpl.Parse(opts.Template) require.NoError(t, err) - _, err = processResponse(&resp, &opts, ios.Out, io.Discard, &tmpl) + _, err = processResponse(&resp, &opts, ios.Out, io.Discard, tmpl) require.NoError(t, err) err = tmpl.Flush() require.NoError(t, err) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 258853164..699cdcf77 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -14,7 +14,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/auth/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - ghAuth "github.com/cli/go-gh/pkg/auth" + ghAuth "github.com/cli/go-gh/v2/pkg/auth" "github.com/spf13/cobra" ) diff --git a/pkg/cmd/extension/ext_tmpls/goBinMain.go.txt b/pkg/cmd/extension/ext_tmpls/goBinMain.go.txt index c9d2bdd43..4f65f68d5 100644 --- a/pkg/cmd/extension/ext_tmpls/goBinMain.go.txt +++ b/pkg/cmd/extension/ext_tmpls/goBinMain.go.txt @@ -3,12 +3,12 @@ package main import ( "fmt" - "github.com/cli/go-gh" + "github.com/cli/go-gh/v2/pkg/api" ) func main() { fmt.Println("hi world, this is the %s extension!") - client, err := gh.RESTClient(nil) + client, err := api.DefaultRESTClient() if err != nil { fmt.Println(err) return diff --git a/pkg/cmd/factory/remote_resolver.go b/pkg/cmd/factory/remote_resolver.go index 08ff297c6..6ef7c5e02 100644 --- a/pkg/cmd/factory/remote_resolver.go +++ b/pkg/cmd/factory/remote_resolver.go @@ -10,7 +10,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/set" - "github.com/cli/go-gh/pkg/ssh" + "github.com/cli/go-gh/v2/pkg/ssh" ) const ( diff --git a/pkg/cmd/repo/delete/delete.go b/pkg/cmd/repo/delete/delete.go index 99997c439..f6f1e87ca 100644 --- a/pkg/cmd/repo/delete/delete.go +++ b/pkg/cmd/repo/delete/delete.go @@ -10,7 +10,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - ghAuth "github.com/cli/go-gh/pkg/auth" + ghAuth "github.com/cli/go-gh/v2/pkg/auth" "github.com/spf13/cobra" ) diff --git a/pkg/cmd/status/status.go b/pkg/cmd/status/status.go index 438175d72..cf927d40f 100644 --- a/pkg/cmd/status/status.go +++ b/pkg/cmd/status/status.go @@ -20,7 +20,7 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/set" "github.com/cli/cli/v2/utils" - ghAPI "github.com/cli/go-gh/pkg/api" + ghAPI "github.com/cli/go-gh/v2/pkg/api" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" ) @@ -429,7 +429,7 @@ func (s *StatusGetter) LoadSearchResults() error { if err != nil { var gqlErrResponse api.GraphQLError if errors.As(err, &gqlErrResponse) { - gqlErrors := make([]ghAPI.GQLErrorItem, 0, len(gqlErrResponse.Errors)) + gqlErrors := make([]ghAPI.GraphQLErrorItem, 0, len(gqlErrResponse.Errors)) // Exclude any FORBIDDEN errors and show status for what we can. for _, gqlErr := range gqlErrResponse.Errors { if gqlErr.Type == "FORBIDDEN" { @@ -442,7 +442,7 @@ func (s *StatusGetter) LoadSearchResults() error { err = nil } else { err = api.GraphQLError{ - GQLError: ghAPI.GQLError{ + GraphQLError: &ghAPI.GraphQLError{ Errors: gqlErrors, }, } diff --git a/pkg/cmdutil/json_flags.go b/pkg/cmdutil/json_flags.go index 3d44c0ada..d9bb89762 100644 --- a/pkg/cmdutil/json_flags.go +++ b/pkg/cmdutil/json_flags.go @@ -13,8 +13,8 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/jsoncolor" "github.com/cli/cli/v2/pkg/set" - "github.com/cli/go-gh/pkg/jq" - "github.com/cli/go-gh/pkg/template" + "github.com/cli/go-gh/v2/pkg/jq" + "github.com/cli/go-gh/v2/pkg/template" "github.com/spf13/cobra" "github.com/spf13/pflag" ) diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 0d4558b3b..2b41288d2 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -13,7 +13,7 @@ import ( "time" "github.com/briandowns/spinner" - ghTerm "github.com/cli/go-gh/pkg/term" + ghTerm "github.com/cli/go-gh/v2/pkg/term" "github.com/cli/safeexec" "github.com/google/shlex" "github.com/mattn/go-colorable" diff --git a/pkg/markdown/markdown.go b/pkg/markdown/markdown.go index fd43e23c0..5466bcdd5 100644 --- a/pkg/markdown/markdown.go +++ b/pkg/markdown/markdown.go @@ -2,7 +2,7 @@ package markdown import ( "github.com/charmbracelet/glamour" - ghMarkdown "github.com/cli/go-gh/pkg/markdown" + ghMarkdown "github.com/cli/go-gh/v2/pkg/markdown" ) func WithoutIndentation() glamour.TermRendererOption { diff --git a/utils/table_printer.go b/utils/table_printer.go index 73f65a6a6..3ccf93484 100644 --- a/utils/table_printer.go +++ b/utils/table_printer.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/go-gh/pkg/tableprinter" + "github.com/cli/go-gh/v2/pkg/tableprinter" ) type TablePrinter interface { From 3d8057d856b074609350f62967854a3dfb322b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E1=BA=A1i=20Tu=E1=BA=A5n=20Anh?= <42168740+tuananhlai@users.noreply.github.com> Date: Mon, 17 Apr 2023 22:35:30 +0900 Subject: [PATCH 52/65] fix: normalize hostname for auth login (#6999) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mislav Marohnić --- pkg/cmd/auth/login/login.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 699cdcf77..84894bf10 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -151,6 +151,11 @@ func loginRun(opts *LoginOptions) error { } } + // The go-gh Config object currently does not support case-insensitive lookups for host names, + // so normalize the host name case here before performing any lookups with it or persisting it. + // https://github.com/cli/go-gh/pull/105 + hostname = strings.ToLower(hostname) + if src, writeable := shared.AuthTokenWriteable(authCfg, hostname); !writeable { fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", src) fmt.Fprint(opts.IO.ErrOut, "To have GitHub CLI store credentials instead, first clear the value from the environment.\n") From e9ce94a3b8c27772348da421d79f13a2163cc1c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Apr 2023 15:00:39 +0000 Subject: [PATCH 53/65] build(deps): bump github.com/cenkalti/backoff/v4 from 4.2.0 to 4.2.1 Bumps [github.com/cenkalti/backoff/v4](https://github.com/cenkalti/backoff) from 4.2.0 to 4.2.1. - [Release notes](https://github.com/cenkalti/backoff/releases) - [Commits](https://github.com/cenkalti/backoff/compare/v4.2.0...v4.2.1) --- updated-dependencies: - dependency-name: github.com/cenkalti/backoff/v4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4b00bca92..3730d469d 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.6 github.com/MakeNowJust/heredoc v1.0.0 github.com/briandowns/spinner v1.18.1 - github.com/cenkalti/backoff/v4 v4.2.0 + github.com/cenkalti/backoff/v4 v4.2.1 github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da github.com/charmbracelet/lipgloss v0.5.0 github.com/cli/go-gh/v2 v2.0.0 diff --git a/go.sum b/go.sum index d5a53af67..8808b1ec9 100644 --- a/go.sum +++ b/go.sum @@ -47,8 +47,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY= github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= -github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4= -github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da h1:FGz53GWQRiKQ/5xUsoCCkewSQIC7u81Scaxx2nUy3nM= github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da/go.mod h1:HXz79SMFnF9arKxqeoHWxmo1BhplAH7wehlRhKQIL94= From 4b5a9032fe61a8e68c12cd005abbe7bcbe91c186 Mon Sep 17 00:00:00 2001 From: John Keech Date: Mon, 17 Apr 2023 10:19:03 -0700 Subject: [PATCH 54/65] Clarify how SSH keys are selected (#7325) Fixes: https://github.com/orgs/community/discussions/52556 Update the help text for `gh codespace ssh` to provide more details about how SSH keys are selected. --- pkg/cmd/codespace/ssh.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go index 37a4ee88c..c2950ef64 100644 --- a/pkg/cmd/codespace/ssh.go +++ b/pkg/cmd/codespace/ssh.go @@ -56,8 +56,14 @@ func newSSHCmd(app *App) *cobra.Command { The 'ssh' command is used to SSH into a codespace. In its simplest form, you can run 'gh cs ssh', select a codespace interactively, and connect. - By default, the 'ssh' command will create a public/private ssh key pair to - authenticate with the codespace inside the ~/.ssh directory. + The 'ssh' command will automatically create a public/private ssh key pair in the + ~/.ssh directory if you do not have an existing valid key pair. When selecting the + key pair to use, the preferred order is: + + 1. Key specified by -i in + 2. Automatic key, if it already exists + 3. First valid key pair in ssh config (according to ssh -G) + 4. Automatic key, newly created The 'ssh' command also supports deeper integration with OpenSSH using a '--config' option that generates per-codespace ssh configuration in OpenSSH format. From 57a5997e36c1dc471a50841fd5d66cc6d09045fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 17 Apr 2023 20:00:04 +0200 Subject: [PATCH 55/65] Initialize deployment.yml workflow file --- .github/workflows/deployment.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/deployment.yml diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml new file mode 100644 index 000000000..c011fc408 --- /dev/null +++ b/.github/workflows/deployment.yml @@ -0,0 +1,25 @@ +name: Deployment + +permissions: + contents: read + +on: + workflow_dispatch: + inputs: + tag_name: + required: true + type: string + go_version: + default: "1.19" + type: string + +jobs: + linux: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ inputs.go_version }} From 09e86d0ebf5d8afd0c49770c5a580ee04c039c00 Mon Sep 17 00:00:00 2001 From: Caleb Brose <5447118+cmbrose@users.noreply.github.com> Date: Wed, 19 Apr 2023 07:00:44 -0500 Subject: [PATCH 56/65] Fix `gh cs ports` requiring `sudo` for privileged port numbers (#7326) --- internal/codespaces/codespaces.go | 11 ++++++++--- internal/codespaces/rpc/invoker.go | 1 + internal/codespaces/states.go | 2 +- pkg/cmd/codespace/jupyter.go | 2 +- pkg/cmd/codespace/logs.go | 2 +- pkg/cmd/codespace/ports.go | 2 +- pkg/cmd/codespace/ssh.go | 2 +- 7 files changed, 14 insertions(+), 8 deletions(-) diff --git a/internal/codespaces/codespaces.go b/internal/codespaces/codespaces.go index 13b27c3da..9db12f01e 100644 --- a/internal/codespaces/codespaces.go +++ b/internal/codespaces/codespaces.go @@ -88,9 +88,14 @@ func ConnectToLiveshare(ctx context.Context, progress progressIndicator, session }) } -// ListenTCP starts a localhost tcp listener and returns the listener and bound port -func ListenTCP(port int) (*net.TCPListener, int, error) { - addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("127.0.0.1:%d", port)) +// ListenTCP starts a localhost tcp listener on 127.0.0.1 (unless allInterfaces is true) and returns the listener and bound port +func ListenTCP(port int, allInterfaces bool) (*net.TCPListener, int, error) { + host := "127.0.0.1" + if allInterfaces { + host = "" + } + + addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", host, port)) if err != nil { return nil, 0, fmt.Errorf("failed to build tcp address: %w", err) } diff --git a/internal/codespaces/rpc/invoker.go b/internal/codespaces/rpc/invoker.go index bb2e25a55..39ae5ab19 100644 --- a/internal/codespaces/rpc/invoker.go +++ b/internal/codespaces/rpc/invoker.go @@ -239,6 +239,7 @@ func (i *invoker) StartSSHServerWithOptions(ctx context.Context, options StartSS } func listenTCP() (*net.TCPListener, error) { + // We will end up using this same address to connect, so specify the IP also or the connect will fail addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0") if err != nil { return nil, fmt.Errorf("failed to build tcp address: %w", err) diff --git a/internal/codespaces/states.go b/internal/codespaces/states.go index 9874d1a62..3a2872365 100644 --- a/internal/codespaces/states.go +++ b/internal/codespaces/states.go @@ -52,7 +52,7 @@ func PollPostCreateStates(ctx context.Context, progress progressIndicator, apiCl }() // Ensure local port is listening before client (getPostCreateOutput) connects. - listen, localPort, err := ListenTCP(0) + listen, localPort, err := ListenTCP(0, false) if err != nil { return err } diff --git a/pkg/cmd/codespace/jupyter.go b/pkg/cmd/codespace/jupyter.go index db55c434d..91c798eda 100644 --- a/pkg/cmd/codespace/jupyter.go +++ b/pkg/cmd/codespace/jupyter.go @@ -67,7 +67,7 @@ func (a *App) Jupyter(ctx context.Context, selector *CodespaceSelector) (err err } // Pass 0 to pick a random port - listen, _, err := codespaces.ListenTCP(0) + listen, _, err := codespaces.ListenTCP(0, false) if err != nil { return err } diff --git a/pkg/cmd/codespace/logs.go b/pkg/cmd/codespace/logs.go index b0dc80459..3ec950849 100644 --- a/pkg/cmd/codespace/logs.go +++ b/pkg/cmd/codespace/logs.go @@ -49,7 +49,7 @@ func (a *App) Logs(ctx context.Context, selector *CodespaceSelector, follow bool defer safeClose(session, &err) // Ensure local port is listening before client (getPostCreateOutput) connects. - listen, localPort, err := codespaces.ListenTCP(0) + listen, localPort, err := codespaces.ListenTCP(0, false) if err != nil { return err } diff --git a/pkg/cmd/codespace/ports.go b/pkg/cmd/codespace/ports.go index fcf50744b..7b697ffdb 100644 --- a/pkg/cmd/codespace/ports.go +++ b/pkg/cmd/codespace/ports.go @@ -379,7 +379,7 @@ func (a *App) ForwardPorts(ctx context.Context, selector *CodespaceSelector, por for _, pair := range portPairs { pair := pair group.Go(func() error { - listen, _, err := codespaces.ListenTCP(pair.local) + listen, _, err := codespaces.ListenTCP(pair.local, true) if err != nil { return err } diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go index c2950ef64..c5abadd0d 100644 --- a/pkg/cmd/codespace/ssh.go +++ b/pkg/cmd/codespace/ssh.go @@ -211,7 +211,7 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e // Ensure local port is listening before client (Shell) connects. // Unless the user specifies a server port, localSSHServerPort is 0 // and thus the client will pick a random port. - listen, localSSHServerPort, err := codespaces.ListenTCP(localSSHServerPort) + listen, localSSHServerPort, err := codespaces.ListenTCP(localSSHServerPort, false) if err != nil { return err } From 218f29f296c9eae696fc649a4fa787d1b9cd91bf Mon Sep 17 00:00:00 2001 From: Alexander Seiler Date: Fri, 21 Apr 2023 03:51:31 +0200 Subject: [PATCH 57/65] Correct some typos --- api/sanitize_ascii.go | 4 ++-- git/client_test.go | 4 ++-- internal/codespaces/api/api.go | 28 ++++++++++++++-------------- internal/codespaces/api/api_test.go | 4 ++-- internal/config/config.go | 2 +- pkg/cmd/browse/browse_test.go | 2 +- pkg/cmd/codespace/ports.go | 2 +- pkg/cmd/codespace/ports_test.go | 2 +- pkg/cmd/extension/manager_test.go | 2 +- pkg/cmd/pr/merge/merge.go | 2 +- pkg/cmd/pr/shared/editable_http.go | 2 +- pkg/cmd/repo/delete/delete.go | 2 +- pkg/cmd/repo/delete/delete_test.go | 4 ++-- pkg/cmd/run/view/view.go | 2 +- pkg/cmd/status/status.go | 2 +- pkg/liveshare/client_test.go | 2 +- pkg/liveshare/ports.go | 2 +- pkg/liveshare/session_test.go | 2 +- pkg/markdown/markdown.go | 2 +- utils/table_printer.go | 2 +- 20 files changed, 37 insertions(+), 37 deletions(-) diff --git a/api/sanitize_ascii.go b/api/sanitize_ascii.go index aeedaccbf..75cb3062b 100644 --- a/api/sanitize_ascii.go +++ b/api/sanitize_ascii.go @@ -47,9 +47,9 @@ type sanitizer struct { addEscape bool } -// Transform uses a sliding window alogorithm to detect C0 and C1 +// Transform uses a sliding window algorithm to detect C0 and C1 // ASCII control sequences as they are read and replaces them -// with equivelent inert characters. Characters that are not part +// with equivalent inert characters. Characters that are not part // of a control sequence are not modified. func (t *sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) { lSrc := len(src) diff --git a/git/client_test.go b/git/client_test.go index 68cfc2739..6bbeee27e 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -303,7 +303,7 @@ func TestClientCurrentBranch(t *testing.T) { wantBranch: "branch\u00A0with\u00A0non\u00A0breaking\u00A0space", }, { - name: "detatched head", + name: "detached head", cmdExitStatus: 1, wantCmdArgs: `path/to/git symbolic-ref --quiet HEAD`, wantErrorMsg: "failed to run git: not on any branch", @@ -339,7 +339,7 @@ func TestClientShowRefs(t *testing.T) { wantErrorMsg string }{ { - name: "show refs with one vaid ref and one invalid ref", + name: "show refs with one valid ref and one invalid ref", cmdExitStatus: 128, cmdStdout: "9ea76237a557015e73446d33268569a114c0649c refs/heads/valid", cmdStderr: "fatal: 'refs/heads/invalid' - not a valid ref", diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index 0f7c71aa9..667a9c3dc 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -122,7 +122,7 @@ func (a *API) GetUser(ctx context.Context) (*User, error) { var response User if err := json.Unmarshal(b, &response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } return &response, nil @@ -160,7 +160,7 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error var response Repository if err := json.Unmarshal(b, &response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } return &response, nil @@ -336,7 +336,7 @@ func (a *API) ListCodespaces(ctx context.Context, opts ListCodespacesOptions) (c dec := json.NewDecoder(resp.Body) if err := dec.Decode(&response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } nextURL := findNextPage(resp.Header.Get("Link")) @@ -398,7 +398,7 @@ func (a *API) GetOrgMemberCodespace(ctx context.Context, orgName string, userNam dec := json.NewDecoder(resp.Body) if err := dec.Decode(&response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } for _, cs := range response.Codespaces { @@ -455,7 +455,7 @@ func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeCon var response Codespace if err := json.Unmarshal(b, &response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } return &response, nil @@ -563,7 +563,7 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc Machines []*Machine `json:"machines"` } if err := json.Unmarshal(b, &response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } return response.Machines, nil @@ -636,7 +636,7 @@ func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch str Items []*Repository `json:"items"` } if err := json.Unmarshal(b, &response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } repoNames := make([]string, len(response.Items)) @@ -683,7 +683,7 @@ func (a *API) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*User, } } if err := json.Unmarshal(b, &response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } // While this response contains further helpful information ahead of codespace creation, @@ -815,7 +815,7 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (* var response Codespace if err := json.Unmarshal(b, &response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } return &response, errProvisioningInProgress // RPC finished before result of creation known @@ -831,7 +831,7 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (* return nil, fmt.Errorf("error reading response body: %w", err) } if err := json.Unmarshal(b, &ue); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } if ue.AllowPermissionsURL != "" { @@ -853,7 +853,7 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (* var response Codespace if err := json.Unmarshal(b, &response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } return &response, nil @@ -934,7 +934,7 @@ func (a *API) ListDevContainers(ctx context.Context, repoID int, branch string, dec := json.NewDecoder(resp.Body) if err := dec.Decode(&response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } nextURL := findNextPage(resp.Header.Get("Link")) @@ -1007,7 +1007,7 @@ func (a *API) EditCodespace(ctx context.Context, codespaceName string, params *E var response Codespace if err := json.Unmarshal(b, &response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } return &response, nil @@ -1055,7 +1055,7 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod var response getCodespaceRepositoryContentsResponse if err := json.Unmarshal(b, &response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) + return nil, fmt.Errorf("error unmarshalling response: %w", err) } decoded, err := base64.StdEncoding.DecodeString(response.Content) diff --git a/internal/codespaces/api/api_test.go b/internal/codespaces/api/api_test.go index e8fab74f1..5e9316fc1 100644 --- a/internal/codespaces/api/api_test.go +++ b/internal/codespaces/api/api_test.go @@ -21,7 +21,7 @@ func generateCodespaceList(start int, end int) []*Codespace { return codespacesList } -func createFakeListEndpointServer(t *testing.T, initalTotal int, finalTotal int) *httptest.Server { +func createFakeListEndpointServer(t *testing.T, initialTotal int, finalTotal int) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/user/codespaces" { t.Fatal("Incorrect path") @@ -48,7 +48,7 @@ func createFakeListEndpointServer(t *testing.T, initalTotal int, finalTotal int) switch page { case 1: response.Codespaces = generateCodespaceList(0, per_page) - response.TotalCount = initalTotal + response.TotalCount = initialTotal w.Header().Set("Link", fmt.Sprintf(`; rel="last", ; rel="next"`, r.Host, per_page)) case 2: response.Codespaces = generateCodespaceList(per_page, per_page*2) diff --git a/internal/config/config.go b/internal/config/config.go index f86e566c7..02756dad5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -124,7 +124,7 @@ type AuthConfig struct { // Token will retrieve the auth token for the given hostname, // searching environment variables, plain text config, and -// lastly encypted storage. +// lastly encrypted storage. func (c *AuthConfig) Token(hostname string) (string, string) { if c.tokenOverride != nil { return c.tokenOverride(hostname) diff --git a/pkg/cmd/browse/browse_test.go b/pkg/cmd/browse/browse_test.go index 4995e5cdf..616c1f7e4 100644 --- a/pkg/cmd/browse/browse_test.go +++ b/pkg/cmd/browse/browse_test.go @@ -369,7 +369,7 @@ func Test_runBrowse(t *testing.T) { opts: BrowseOptions{ SelectorArg: "chocolate-pecan-pie.txt", }, - baseRepo: ghrepo.New("andrewhsu", "recipies"), + baseRepo: ghrepo.New("andrewhsu", "recipes"), defaultBranch: "", wantsErr: true, }, diff --git a/pkg/cmd/codespace/ports.go b/pkg/cmd/codespace/ports.go index 7b697ffdb..cbe48b7b3 100644 --- a/pkg/cmd/codespace/ports.go +++ b/pkg/cmd/codespace/ports.go @@ -214,7 +214,7 @@ func getDevContainer(ctx context.Context, apiClient apiClient, codespace *api.Co var container devContainer if err := json.Unmarshal(convertedJSON, &container); err != nil { - ch <- devContainerResult{nil, fmt.Errorf("error unmarshaling: %w", err)} + ch <- devContainerResult{nil, fmt.Errorf("error unmarshalling: %w", err)} return } diff --git a/pkg/cmd/codespace/ports_test.go b/pkg/cmd/codespace/ports_test.go index 9979345b2..bb7554238 100644 --- a/pkg/cmd/codespace/ports_test.go +++ b/pkg/cmd/codespace/ports_test.go @@ -225,7 +225,7 @@ func TestPendingOperationDisallowsListPorts(t *testing.T) { } } -func TestPendingOperationDisallowsUpdatePortVisability(t *testing.T) { +func TestPendingOperationDisallowsUpdatePortVisibility(t *testing.T) { app := testingPortsApp() selector := &CodespaceSelector{api: app.apiClient, codespaceName: "disabledCodespace"} diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index 4f8b3d75d..b3fe64567 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -713,7 +713,7 @@ func TestManager_UpgradeExtension_BinaryExtension_Pinned(t *testing.T) { assert.Equal(t, err, pinnedExtensionUpgradeError) } -func TestManager_UpgradeExtenion_GitExtension_Pinned(t *testing.T) { +func TestManager_UpgradeExtension_GitExtension_Pinned(t *testing.T) { tempDir := t.TempDir() extDir := filepath.Join(tempDir, "extensions", "gh-remote") assert.NoError(t, stubPinnedExtension(filepath.Join(extDir, "gh-remote"), "abcd1234")) diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 1a331597c..d49139011 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -477,7 +477,7 @@ func (m *mergeContext) infof(format string, args ...interface{}) error { return err } -// Creates a new MergeConext from MergeOptions. +// Creates a new MergeContext from MergeOptions. func NewMergeContext(opts *MergeOptions) (*mergeContext, error) { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, diff --git a/pkg/cmd/pr/shared/editable_http.go b/pkg/cmd/pr/shared/editable_http.go index 3353cd5f5..fcc30095a 100644 --- a/pkg/cmd/pr/shared/editable_http.go +++ b/pkg/cmd/pr/shared/editable_http.go @@ -35,7 +35,7 @@ func UpdateIssue(httpClient *http.Client, repo ghrepo.Interface, id string, isPR } } - // updateIssue mutation does not support ProjectsV2 so do them in a seperate request. + // updateIssue mutation does not support ProjectsV2 so do them in a separate request. if options.Projects.Edited { wg.Go(func() error { apiClient := api.NewClientFromHTTP(httpClient) diff --git a/pkg/cmd/repo/delete/delete.go b/pkg/cmd/repo/delete/delete.go index f6f1e87ca..1e96e404d 100644 --- a/pkg/cmd/repo/delete/delete.go +++ b/pkg/cmd/repo/delete/delete.go @@ -114,7 +114,7 @@ func deleteRun(opts *DeleteOptions) error { statusCode == http.StatusTemporaryRedirect || statusCode == http.StatusPermanentRedirect { cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.ErrOut, "%s Failed to delete repository: %s has changed name or transfered ownership\n", cs.FailureIcon(), fullName) + fmt.Fprintf(opts.IO.ErrOut, "%s Failed to delete repository: %s has changed name or transferred ownership\n", cs.FailureIcon(), fullName) return cmdutil.SilentError } } diff --git a/pkg/cmd/repo/delete/delete_test.go b/pkg/cmd/repo/delete/delete_test.go index 16b2b4006..b52ac1de9 100644 --- a/pkg/cmd/repo/delete/delete_test.go +++ b/pkg/cmd/repo/delete/delete_test.go @@ -157,11 +157,11 @@ func Test_deleteRun(t *testing.T) { }, }, { - name: "repo transfered ownership", + name: "repo transferred ownership", opts: &DeleteOptions{RepoArg: "OWNER/REPO", Confirmed: true}, wantErr: true, errMsg: "SilentError", - wantStderr: "X Failed to delete repository: OWNER/REPO has changed name or transfered ownership\n", + wantStderr: "X Failed to delete repository: OWNER/REPO has changed name or transferred ownership\n", httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("DELETE", "repos/OWNER/REPO"), diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 34c5fcbc7..5cb265b55 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -503,7 +503,7 @@ func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp { // ├── 1_stepname.txt // └── 2_somestepname.txt // -// It iterates through the list of jobs and trys to find the matching +// It iterates through the list of jobs and tries to find the matching // log in the zip file. If the matching log is found it is attached // to the job. func attachRunLog(rlz *zip.ReadCloser, jobs []shared.Job) { diff --git a/pkg/cmd/status/status.go b/pkg/cmd/status/status.go index cf927d40f..7e39d63d0 100644 --- a/pkg/cmd/status/status.go +++ b/pkg/cmd/status/status.go @@ -467,7 +467,7 @@ func (s *StatusGetter) LoadSearchResults() error { case "PullRequest": prs = append(prs, *node) default: - return fmt.Errorf("unkown Assignments type: %q", node.Type) + return fmt.Errorf("unknown Assignments type: %q", node.Type) } } diff --git a/pkg/liveshare/client_test.go b/pkg/liveshare/client_test.go index a39c48f7b..4ccc42729 100644 --- a/pkg/liveshare/client_test.go +++ b/pkg/liveshare/client_test.go @@ -24,7 +24,7 @@ func TestConnect(t *testing.T) { joinWorkspace := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) { var joinWorkspaceReq joinWorkspaceArgs if err := json.Unmarshal(*req.Params, &joinWorkspaceReq); err != nil { - return nil, fmt.Errorf("error unmarshaling req: %w", err) + return nil, fmt.Errorf("error unmarshalling req: %w", err) } if joinWorkspaceReq.ID != opts.SessionID { return nil, errors.New("connection session id does not match") diff --git a/pkg/liveshare/ports.go b/pkg/liveshare/ports.go index 6cef1584b..b39a4f630 100644 --- a/pkg/liveshare/ports.go +++ b/pkg/liveshare/ports.go @@ -52,7 +52,7 @@ func (s *Session) WaitForPortNotification(ctx context.Context, port int, notifTy notification := new(PortNotification) if err := json.Unmarshal(*req.Params, ¬ification); err != nil { select { - case errCh <- fmt.Errorf("error unmarshaling notification: %w", err): + case errCh <- fmt.Errorf("error unmarshalling notification: %w", err): default: } return diff --git a/pkg/liveshare/session_test.go b/pkg/liveshare/session_test.go index 9de50ff92..ab7cf18bd 100644 --- a/pkg/liveshare/session_test.go +++ b/pkg/liveshare/session_test.go @@ -51,7 +51,7 @@ func TestServerStartSharing(t *testing.T) { startSharing := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) { var args []interface{} if err := json.Unmarshal(*req.Params, &args); err != nil { - return nil, fmt.Errorf("error unmarshaling request: %w", err) + return nil, fmt.Errorf("error unmarshalling request: %w", err) } if len(args) < 3 { return nil, errors.New("not enough arguments to start sharing") diff --git a/pkg/markdown/markdown.go b/pkg/markdown/markdown.go index 5466bcdd5..1aab0bcd3 100644 --- a/pkg/markdown/markdown.go +++ b/pkg/markdown/markdown.go @@ -10,7 +10,7 @@ func WithoutIndentation() glamour.TermRendererOption { } // WithoutWrap is a rendering option that set the character limit for soft -// wraping the markdown rendering. There is a max limit of 120 characters. +// wrapping the markdown rendering. There is a max limit of 120 characters. // If 0 is passed then wrapping is disabled. func WithWrap(w int) glamour.TermRendererOption { if w > 120 { diff --git a/utils/table_printer.go b/utils/table_printer.go index 3ccf93484..1bf9a955b 100644 --- a/utils/table_printer.go +++ b/utils/table_printer.go @@ -63,7 +63,7 @@ func (p printer) IsTTY() bool { func (p *printer) AddField(s string, truncateFunc func(int, string) string, colorFunc func(string) string) { if truncateFunc == nil { - // Disallow ever truncating the 1st colum or any URL value + // Disallow ever truncating the 1st column or any URL value if p.colIndex == 0 || isURL(s) { p.tp.AddField(s, tableprinter.WithTruncate(nil), tableprinter.WithColor(colorFunc)) } else { From 7cfbf478d5f03dbaeb85d68dc16632601a3b17ed Mon Sep 17 00:00:00 2001 From: Benjamin Levesque <14175665+benjlevesque@users.noreply.github.com> Date: Fri, 21 Apr 2023 16:09:59 +0200 Subject: [PATCH 58/65] Diacritics substitution in prompt (#7205) --- internal/prompter/prompter.go | 15 ++++++ internal/prompter/prompter_test.go | 78 ++++++++++++++++++++++++++++++ internal/text/text.go | 20 ++++++++ internal/text/text_test.go | 54 +++++++++++++++++++++ pkg/cmd/pr/shared/editable.go | 2 + 5 files changed, 169 insertions(+) create mode 100644 internal/prompter/prompter_test.go diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index a9cf77992..b72a1a8f8 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -7,6 +7,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/surveyext" ) @@ -49,11 +50,24 @@ type surveyPrompter struct { stderr io.Writer } +// LatinMatchingFilter returns whether the value matches the input filter. +// The strings are compared normalized in case. +// The filter's diactritics are kept as-is, but the value's are normalized, +// so that a missing diactritic in the filter still returns a result. +func LatinMatchingFilter(filter, value string, index int) bool { + filter = strings.ToLower(filter) + value = strings.ToLower(value) + + // include this option if it matches. + return strings.Contains(value, filter) || strings.Contains(text.RemoveDiacritics(value), filter) +} + func (p *surveyPrompter) Select(message, defaultValue string, options []string) (result int, err error) { q := &survey.Select{ Message: message, Options: options, PageSize: 20, + Filter: LatinMatchingFilter, } if defaultValue != "" { @@ -77,6 +91,7 @@ func (p *surveyPrompter) MultiSelect(message, defaultValue string, options []str Message: message, Options: options, PageSize: 20, + Filter: LatinMatchingFilter, } if defaultValue != "" { diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go new file mode 100644 index 000000000..98d78786b --- /dev/null +++ b/internal/prompter/prompter_test.go @@ -0,0 +1,78 @@ +package prompter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFilterDiacritics(t *testing.T) { + tests := []struct { + name string + filter string + value string + want bool + }{ + { + name: "exact match no diacritics", + filter: "Mikelis", + value: "Mikelis", + want: true, + }, + { + name: "exact match no diacritics", + filter: "Mikelis", + value: "Mikelis", + want: true, + }, + { + name: "exact match diacritics", + filter: "Miķelis", + value: "Miķelis", + want: true, + }, + { + name: "partial match diacritics", + filter: "Miķe", + value: "Miķelis", + want: true, + }, + { + name: "exact match diacritics in value", + filter: "Mikelis", + value: "Miķelis", + want: true, + }, + { + name: "partial match diacritics in filter", + filter: "Miķe", + value: "Miķelis", + want: true, + }, + { + name: "no match when removing diacritics in filter", + filter: "Mielis", + value: "Mikelis", + want: false, + }, + { + name: "no match when removing diacritics in value", + filter: "Mikelis", + value: "Mielis", + want: false, + }, + { + name: "no match diacritics in filter", + filter: "Miķelis", + value: "Mikelis", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, LatinMatchingFilter(tt.filter, tt.value, 0), tt.want) + }) + } + +} diff --git a/internal/text/text.go b/internal/text/text.go index afa74f7dc..034d2f6e6 100644 --- a/internal/text/text.go +++ b/internal/text/text.go @@ -6,10 +6,14 @@ import ( "regexp" "strings" "time" + "unicode" "github.com/cli/go-gh/v2/pkg/text" "golang.org/x/text/cases" "golang.org/x/text/language" + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" ) var whitespaceRE = regexp.MustCompile(`\s+`) @@ -72,3 +76,19 @@ func DisplayURL(urlStr string) string { } return u.Hostname() + u.Path } + +// RemoveDiacritics returns the input value without "diacritics", or accent marks +func RemoveDiacritics(value string) string { + // Mn = "Mark, nonspacing" unicode character category + removeMnTransfomer := runes.Remove(runes.In(unicode.Mn)) + + // 1/ Decompose the text into characters and diacritical marks, + // 2/ Remove the diacriticals marks + // 3/ Recompose the text + t := transform.Chain(norm.NFD, removeMnTransfomer, norm.NFC) + normalized, _, err := transform.String(t, value) + if err != nil { + return value + } + return normalized +} diff --git a/internal/text/text_test.go b/internal/text/text_test.go index 98f16da5d..794d2327b 100644 --- a/internal/text/text_test.go +++ b/internal/text/text_test.go @@ -54,3 +54,57 @@ func TestFuzzyAgoAbbr(t *testing.T) { assert.Equal(t, expected, fuzzy) } } + +func TestRemoveDiacritics(t *testing.T) { + tests := [][]string{ + // no diacritics + {"e", "e"}, + {"و", "و"}, + {"И", "И"}, + {"ж", "ж"}, + {"私", "私"}, + {"万", "万"}, + + // diacritics test sets + {"à", "a"}, + {"é", "e"}, + {"è", "e"}, + {"ô", "o"}, + {"ᾳ", "α"}, + {"εͅ", "ε"}, + {"ῃ", "η"}, + {"ιͅ", "ι"}, + + {"ؤ", "و"}, + + {"ā", "a"}, + {"č", "c"}, + {"ģ", "g"}, + {"ķ", "k"}, + {"ņ", "n"}, + {"š", "s"}, + {"ž", "z"}, + + {"ŵ", "w"}, + {"ŷ", "y"}, + {"ä", "a"}, + {"ÿ", "y"}, + {"á", "a"}, + {"ẁ", "w"}, + {"ỳ", "y"}, + {"ō", "o"}, + + // full words + {"Miķelis", "Mikelis"}, + {"François", "Francois"}, + {"žluťoučký", "zlutoucky"}, + {"învățătorița", "invatatorita"}, + {"Kękę przy łóżku", "Keke przy łozku"}, + } + + for _, tt := range tests { + t.Run(RemoveDiacritics(tt[0]), func(t *testing.T) { + assert.Equal(t, tt[1], RemoveDiacritics(tt[0])) + }) + } +} diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 7564c24cd..9e685270d 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -7,6 +7,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/set" "github.com/cli/cli/v2/pkg/surveyext" ) @@ -395,6 +396,7 @@ func multiSelectSurvey(message string, defaults, options []string) ([]string, er Message: message, Options: options, Default: defaults, + Filter: prompter.LatinMatchingFilter, } err := survey.AskOne(q, &results) return results, err From d7512bb2e79c7e34ef984df7068a522933eed571 Mon Sep 17 00:00:00 2001 From: cawfeecake Date: Mon, 24 Apr 2023 20:50:20 -0700 Subject: [PATCH 59/65] add `--event` flag to `gh run ls` to filter runs by their trigger --- pkg/cmd/run/list/list.go | 3 +++ pkg/cmd/run/list/list_test.go | 28 ++++++++++++++++++++++++++++ pkg/cmd/run/shared/shared.go | 4 ++++ 3 files changed, 35 insertions(+) diff --git a/pkg/cmd/run/list/list.go b/pkg/cmd/run/list/list.go index 4b085a1a1..b98a40f08 100644 --- a/pkg/cmd/run/list/list.go +++ b/pkg/cmd/run/list/list.go @@ -32,6 +32,7 @@ type ListOptions struct { Branch string Actor string Status string + Event string now time.Time } @@ -68,6 +69,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.WorkflowSelector, "workflow", "w", "", "Filter runs by workflow") cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Filter runs by branch") cmd.Flags().StringVarP(&opts.Actor, "user", "u", "", "Filter runs by user who triggered the run") + cmd.Flags().StringVarP(&opts.Event, "event", "e", "", "Filter runs by which `event` triggered the run") cmdutil.StringEnumFlag(cmd, &opts.Status, "status", "s", "", shared.AllStatuses, "Filter runs by status") cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.RunFields) @@ -92,6 +94,7 @@ func listRun(opts *ListOptions) error { Branch: opts.Branch, Actor: opts.Actor, Status: opts.Status, + Event: opts.Event, } opts.IO.StartProgressIndicator() diff --git a/pkg/cmd/run/list/list_test.go b/pkg/cmd/run/list/list_test.go index d33af7167..116c1d829 100644 --- a/pkg/cmd/run/list/list_test.go +++ b/pkg/cmd/run/list/list_test.go @@ -77,6 +77,14 @@ func TestNewCmdList(t *testing.T) { Status: "completed", }, }, + { + name: "event", + cli: "--event push", + wants: ListOptions{ + Limit: defaultLimit, + Event: "push", + }, + }, } for _, tt := range tests { @@ -112,6 +120,8 @@ func TestNewCmdList(t *testing.T) { assert.Equal(t, tt.wants.WorkflowSelector, gotOpts.WorkflowSelector) assert.Equal(t, tt.wants.Branch, gotOpts.Branch) assert.Equal(t, tt.wants.Actor, gotOpts.Actor) + assert.Equal(t, tt.wants.Status, gotOpts.Status) + assert.Equal(t, tt.wants.Event, gotOpts.Event) }) } } @@ -424,6 +434,24 @@ func TestListRun(t *testing.T) { wantErr: true, wantErrMsg: "no runs found", }, + { + name: "event filter applied", + opts: &ListOptions{ + Limit: defaultLimit, + Event: "push", + }, + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{ + "event": []string{"push"}, + }), + httpmock.JSONResponse(shared.RunsPayload{}), + ) + }, + wantErr: true, + wantErrMsg: "no runs found", + }, } for _, tt := range tests { diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index 94a5590a3..f5893b505 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -303,6 +303,7 @@ type FilterOptions struct { // avoid loading workflow name separately and use the provided one WorkflowName string Status string + Event string } // GetRunsWithFilter fetches 50 runs from the API and filters them in-memory @@ -348,6 +349,9 @@ func GetRuns(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, lim if opts.Status != "" { path += fmt.Sprintf("&status=%s", url.QueryEscape(opts.Status)) } + if opts.Event != "" { + path += fmt.Sprintf("&event=%s", url.QueryEscape(opts.Event)) + } } var result *RunsPayload From 9473ec890c54e918d8b0ab8dac8f092f04844a53 Mon Sep 17 00:00:00 2001 From: cawfeecake <48775802+cawfeecake@users.noreply.github.com> Date: Tue, 25 Apr 2023 03:43:19 -0700 Subject: [PATCH 60/65] add `--created` flag to `gh run ls` to filter listed runs by when they were created (#7352) --- pkg/cmd/run/list/list.go | 11 +++++++---- pkg/cmd/run/list/list_test.go | 27 +++++++++++++++++++++++++++ pkg/cmd/run/shared/shared.go | 4 ++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/run/list/list.go b/pkg/cmd/run/list/list.go index b98a40f08..af84b089e 100644 --- a/pkg/cmd/run/list/list.go +++ b/pkg/cmd/run/list/list.go @@ -33,6 +33,7 @@ type ListOptions struct { Actor string Status string Event string + Created string now time.Time } @@ -70,6 +71,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Filter runs by branch") cmd.Flags().StringVarP(&opts.Actor, "user", "u", "", "Filter runs by user who triggered the run") cmd.Flags().StringVarP(&opts.Event, "event", "e", "", "Filter runs by which `event` triggered the run") + cmd.Flags().StringVarP(&opts.Created, "created", "", "", "Filter runs by the `date` it was created") cmdutil.StringEnumFlag(cmd, &opts.Status, "status", "s", "", shared.AllStatuses, "Filter runs by status") cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.RunFields) @@ -91,10 +93,11 @@ func listRun(opts *ListOptions) error { client := api.NewClientFromHTTP(c) filters := &shared.FilterOptions{ - Branch: opts.Branch, - Actor: opts.Actor, - Status: opts.Status, - Event: opts.Event, + Branch: opts.Branch, + Actor: opts.Actor, + Status: opts.Status, + Event: opts.Event, + Created: opts.Created, } opts.IO.StartProgressIndicator() diff --git a/pkg/cmd/run/list/list_test.go b/pkg/cmd/run/list/list_test.go index 116c1d829..cf5ba97ad 100644 --- a/pkg/cmd/run/list/list_test.go +++ b/pkg/cmd/run/list/list_test.go @@ -85,6 +85,14 @@ func TestNewCmdList(t *testing.T) { Event: "push", }, }, + { + name: "created", + cli: "--created >=2023-04-24", + wants: ListOptions{ + Limit: defaultLimit, + Created: ">=2023-04-24", + }, + }, } for _, tt := range tests { @@ -122,6 +130,7 @@ func TestNewCmdList(t *testing.T) { assert.Equal(t, tt.wants.Actor, gotOpts.Actor) assert.Equal(t, tt.wants.Status, gotOpts.Status) assert.Equal(t, tt.wants.Event, gotOpts.Event) + assert.Equal(t, tt.wants.Created, gotOpts.Created) }) } } @@ -452,6 +461,24 @@ func TestListRun(t *testing.T) { wantErr: true, wantErrMsg: "no runs found", }, + { + name: "created filter applied", + opts: &ListOptions{ + Limit: defaultLimit, + Created: ">=2023-04-24", + }, + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{ + "created": []string{">=2023-04-24"}, + }), + httpmock.JSONResponse(shared.RunsPayload{}), + ) + }, + wantErr: true, + wantErrMsg: "no runs found", + }, } for _, tt := range tests { diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index f5893b505..70d44df95 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -304,6 +304,7 @@ type FilterOptions struct { WorkflowName string Status string Event string + Created string } // GetRunsWithFilter fetches 50 runs from the API and filters them in-memory @@ -352,6 +353,9 @@ func GetRuns(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, lim if opts.Event != "" { path += fmt.Sprintf("&event=%s", url.QueryEscape(opts.Event)) } + if opts.Created != "" { + path += fmt.Sprintf("&created=%s", url.QueryEscape(opts.Created)) + } } var result *RunsPayload From 17679cf65ff6425322f6f28d66e31b7cc36c7ffa Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Tue, 25 Apr 2023 05:45:41 -0700 Subject: [PATCH 61/65] Edit multiple issues, PRs in parallel (#7259) Allows multiple issues or PRs to be edited in parallel, and querying for shared fields once to reduce network requests. Co-authored-by: Sam Coe --- pkg/cmd/issue/edit/edit.go | 178 +++++++++++------ pkg/cmd/issue/edit/edit_test.go | 285 ++++++++++++++++++++++++---- pkg/cmd/issue/shared/lookup.go | 79 +++++++- pkg/cmd/issue/shared/lookup_test.go | 106 +++++++++++ pkg/cmd/pr/shared/editable.go | 53 ++++++ pkg/httpmock/stub.go | 27 +++ 6 files changed, 621 insertions(+), 107 deletions(-) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index abea9eaae..2fe4c62b7 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -3,10 +3,13 @@ package edit import ( "fmt" "net/http" + "sort" + "sync" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/text" shared "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -24,8 +27,8 @@ type EditOptions struct { EditFieldsSurvey func(*prShared.Editable, string) error FetchOptions func(*api.Client, ghrepo.Interface, *prShared.Editable) error - SelectorArg string - Interactive bool + SelectorArgs []string + Interactive bool prShared.Editable } @@ -43,12 +46,12 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman var bodyFile string cmd := &cobra.Command{ - Use: "edit { | }", - Short: "Edit an issue", + Use: "edit { | }", + Short: "Edit issues", Long: heredoc.Doc(` - Edit an issue. + Edit one or more issues within the same repository. - Editing an issue's projects requires authorization with the "project" scope. + Editing issues' projects requires authorization with the "project" scope. To authorize, run "gh auth refresh -s project". `), Example: heredoc.Doc(` @@ -58,13 +61,14 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman $ gh issue edit 23 --add-project "Roadmap" --remove-project v1,v2 $ gh issue edit 23 --milestone "Version 1" $ gh issue edit 23 --body-file body.txt + $ gh issue edit 23 34 --add-label "help wanted" `), - Args: cobra.ExactArgs(1), + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo - opts.SelectorArg = args[0] + opts.SelectorArgs = args flags := cmd.Flags() @@ -113,6 +117,10 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman return cmdutil.FlagErrorf("field to edit flag required when not running interactively") } + if opts.Interactive && len(opts.SelectorArgs) > 1 { + return cmdutil.FlagErrorf("multiple issues cannot be edited interactively") + } + if runF != nil { return runF(opts) } @@ -141,41 +149,8 @@ func editRun(opts *EditOptions) error { return err } + // Prompt the user which fields they'd like to edit. editable := opts.Editable - lookupFields := []string{"id", "number", "title", "body", "url"} - if opts.Interactive || editable.Assignees.Edited { - lookupFields = append(lookupFields, "assignees") - } - if opts.Interactive || editable.Labels.Edited { - lookupFields = append(lookupFields, "labels") - } - if opts.Interactive || editable.Projects.Edited { - lookupFields = append(lookupFields, "projectCards") - lookupFields = append(lookupFields, "projectItems") - } - if opts.Interactive || editable.Milestone.Edited { - lookupFields = append(lookupFields, "milestone") - } - - issue, repo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, lookupFields) - if err != nil { - return err - } - - editable.Title.Default = issue.Title - editable.Body.Default = issue.Body - editable.Assignees.Default = issue.Assignees.Logins() - editable.Labels.Default = issue.Labels.Names() - editable.Projects.Default = append(issue.ProjectCards.ProjectNames(), issue.ProjectItems.ProjectTitles()...) - projectItems := map[string]string{} - for _, n := range issue.ProjectItems.Nodes { - projectItems[n.Project.ID] = n.ID - } - editable.Projects.ProjectItems = projectItems - if issue.Milestone != nil { - editable.Milestone.Default = issue.Milestone.Title - } - if opts.Interactive { err = opts.FieldsToEditSurvey(&editable) if err != nil { @@ -183,33 +158,122 @@ func editRun(opts *EditOptions) error { } } + lookupFields := []string{"id", "number", "title", "body", "url"} + if editable.Assignees.Edited { + lookupFields = append(lookupFields, "assignees") + } + if editable.Labels.Edited { + lookupFields = append(lookupFields, "labels") + } + if editable.Projects.Edited { + lookupFields = append(lookupFields, "projectCards") + lookupFields = append(lookupFields, "projectItems") + } + if editable.Milestone.Edited { + lookupFields = append(lookupFields, "milestone") + } + + // Get all specified issues and make sure they are within the same repo. + issues, repo, err := shared.IssuesFromArgsWithFields(httpClient, opts.BaseRepo, opts.SelectorArgs, lookupFields) + if err != nil { + return err + } + + // Fetch editable shared fields once for all issues. apiClient := api.NewClientFromHTTP(httpClient) - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel("Fetching repository information") err = opts.FetchOptions(apiClient, repo, &editable) opts.IO.StopProgressIndicator() if err != nil { return err } - if opts.Interactive { - editorCommand, err := opts.DetermineEditor() - if err != nil { - return err - } - err = opts.EditFieldsSurvey(&editable, editorCommand) - if err != nil { - return err - } + // Update all issues in parallel. + editedIssueChan := make(chan string, len(issues)) + failedIssueChan := make(chan string, len(issues)) + g := sync.WaitGroup{} + + // Only show progress if we will not prompt below or the survey will break up the progress indicator. + if !opts.Interactive { + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Updating %d issues", len(issues))) } - opts.IO.StartProgressIndicator() - err = prShared.UpdateIssue(httpClient, repo, issue.ID, issue.IsPullRequest(), editable) + for _, issue := range issues { + // Copy variables to capture in the go routine below. + editable := editable.Clone() + + editable.Title.Default = issue.Title + editable.Body.Default = issue.Body + editable.Assignees.Default = issue.Assignees.Logins() + editable.Labels.Default = issue.Labels.Names() + editable.Projects.Default = append(issue.ProjectCards.ProjectNames(), issue.ProjectItems.ProjectTitles()...) + projectItems := map[string]string{} + for _, n := range issue.ProjectItems.Nodes { + projectItems[n.Project.ID] = n.ID + } + editable.Projects.ProjectItems = projectItems + if issue.Milestone != nil { + editable.Milestone.Default = issue.Milestone.Title + } + + // Allow interactive prompts for one issue; failed earlier if multiple issues specified. + if opts.Interactive { + editorCommand, err := opts.DetermineEditor() + if err != nil { + return err + } + err = opts.EditFieldsSurvey(&editable, editorCommand) + if err != nil { + return err + } + } + + g.Add(1) + go func(issue *api.Issue) { + defer g.Done() + + err := prShared.UpdateIssue(httpClient, repo, issue.ID, issue.IsPullRequest(), editable) + if err != nil { + failedIssueChan <- fmt.Sprintf("failed to update %s: %s", issue.URL, err) + return + } + + editedIssueChan <- issue.URL + }(issue) + } + + g.Wait() + close(editedIssueChan) + close(failedIssueChan) + + // Does nothing if progress was not started above. opts.IO.StopProgressIndicator() - if err != nil { - return err + + // Print a sorted list of successfully edited issue URLs to stdout. + editedIssueURLs := make([]string, 0, len(issues)) + for editedIssueURL := range editedIssueChan { + editedIssueURLs = append(editedIssueURLs, editedIssueURL) } - fmt.Fprintln(opts.IO.Out, issue.URL) + sort.Strings(editedIssueURLs) + for _, editedIssueURL := range editedIssueURLs { + fmt.Fprintln(opts.IO.Out, editedIssueURL) + } + + // Print a sorted list of failures to stderr. + failedIssueErrors := make([]string, 0, len(issues)) + for failedIssueError := range failedIssueChan { + failedIssueErrors = append(failedIssueErrors, failedIssueError) + } + + sort.Strings(failedIssueErrors) + for _, failedIssueError := range failedIssueErrors { + fmt.Fprintln(opts.IO.ErrOut, failedIssueError) + } + + if len(failedIssueErrors) > 0 { + return fmt.Errorf("failed to update %s", text.Pluralize(len(failedIssueErrors), "issue")) + } return nil } diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 34f6b6d89..d73132f35 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -41,8 +42,8 @@ func TestNewCmdEdit(t *testing.T) { name: "issue number argument", input: "23", output: EditOptions{ - SelectorArg: "23", - Interactive: true, + SelectorArgs: []string{"23"}, + Interactive: true, }, wantsErr: false, }, @@ -50,7 +51,7 @@ func TestNewCmdEdit(t *testing.T) { name: "title flag", input: "23 --title test", output: EditOptions{ - SelectorArg: "23", + SelectorArgs: []string{"23"}, Editable: prShared.Editable{ Title: prShared.EditableString{ Value: "test", @@ -64,7 +65,7 @@ func TestNewCmdEdit(t *testing.T) { name: "body flag", input: "23 --body test", output: EditOptions{ - SelectorArg: "23", + SelectorArgs: []string{"23"}, Editable: prShared.Editable{ Body: prShared.EditableString{ Value: "test", @@ -79,7 +80,7 @@ func TestNewCmdEdit(t *testing.T) { input: "23 --body-file -", stdin: "this is on standard input", output: EditOptions{ - SelectorArg: "23", + SelectorArgs: []string{"23"}, Editable: prShared.Editable{ Body: prShared.EditableString{ Value: "this is on standard input", @@ -93,7 +94,7 @@ func TestNewCmdEdit(t *testing.T) { name: "body from file", input: fmt.Sprintf("23 --body-file '%s'", tmpFile), output: EditOptions{ - SelectorArg: "23", + SelectorArgs: []string{"23"}, Editable: prShared.Editable{ Body: prShared.EditableString{ Value: "a body from file", @@ -107,7 +108,7 @@ func TestNewCmdEdit(t *testing.T) { name: "add-assignee flag", input: "23 --add-assignee monalisa,hubot", output: EditOptions{ - SelectorArg: "23", + SelectorArgs: []string{"23"}, Editable: prShared.Editable{ Assignees: prShared.EditableSlice{ Add: []string{"monalisa", "hubot"}, @@ -121,7 +122,7 @@ func TestNewCmdEdit(t *testing.T) { name: "remove-assignee flag", input: "23 --remove-assignee monalisa,hubot", output: EditOptions{ - SelectorArg: "23", + SelectorArgs: []string{"23"}, Editable: prShared.Editable{ Assignees: prShared.EditableSlice{ Remove: []string{"monalisa", "hubot"}, @@ -135,7 +136,7 @@ func TestNewCmdEdit(t *testing.T) { name: "add-label flag", input: "23 --add-label feature,TODO,bug", output: EditOptions{ - SelectorArg: "23", + SelectorArgs: []string{"23"}, Editable: prShared.Editable{ Labels: prShared.EditableSlice{ Add: []string{"feature", "TODO", "bug"}, @@ -149,7 +150,7 @@ func TestNewCmdEdit(t *testing.T) { name: "remove-label flag", input: "23 --remove-label feature,TODO,bug", output: EditOptions{ - SelectorArg: "23", + SelectorArgs: []string{"23"}, Editable: prShared.Editable{ Labels: prShared.EditableSlice{ Remove: []string{"feature", "TODO", "bug"}, @@ -163,7 +164,7 @@ func TestNewCmdEdit(t *testing.T) { name: "add-project flag", input: "23 --add-project Cleanup,Roadmap", output: EditOptions{ - SelectorArg: "23", + SelectorArgs: []string{"23"}, Editable: prShared.Editable{ Projects: prShared.EditableProjects{ EditableSlice: prShared.EditableSlice{ @@ -179,7 +180,7 @@ func TestNewCmdEdit(t *testing.T) { name: "remove-project flag", input: "23 --remove-project Cleanup,Roadmap", output: EditOptions{ - SelectorArg: "23", + SelectorArgs: []string{"23"}, Editable: prShared.Editable{ Projects: prShared.EditableProjects{ EditableSlice: prShared.EditableSlice{ @@ -195,7 +196,7 @@ func TestNewCmdEdit(t *testing.T) { name: "milestone flag", input: "23 --milestone GA", output: EditOptions{ - SelectorArg: "23", + SelectorArgs: []string{"23"}, Editable: prShared.Editable{ Milestone: prShared.EditableString{ Value: "GA", @@ -205,6 +206,34 @@ func TestNewCmdEdit(t *testing.T) { }, wantsErr: false, }, + { + name: "add label to multiple issues", + input: "23 34 --add-label bug", + output: EditOptions{ + SelectorArgs: []string{"23", "34"}, + Editable: prShared.Editable{ + Labels: prShared.EditableSlice{ + Add: []string{"bug"}, + Edited: true, + }, + }, + }, + wantsErr: false, + }, + { + name: "interactive multiple issues", + input: "23 34", + output: EditOptions{ + SelectorArgs: []string{"23", "34"}, + Editable: prShared.Editable{ + Labels: prShared.EditableSlice{ + Add: []string{"bug"}, + Edited: true, + }, + }, + }, + wantsErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -243,7 +272,7 @@ func TestNewCmdEdit(t *testing.T) { } assert.NoError(t, err) - assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg) + assert.Equal(t, tt.output.SelectorArgs, gotOpts.SelectorArgs) assert.Equal(t, tt.output.Interactive, gotOpts.Interactive) assert.Equal(t, tt.output.Editable, gotOpts.Editable) }) @@ -257,12 +286,13 @@ func Test_editRun(t *testing.T) { httpStubs func(*testing.T, *httpmock.Registry) stdout string stderr string + wantErr bool }{ { name: "non-interactive", input: &EditOptions{ - SelectorArg: "123", - Interactive: false, + SelectorArgs: []string{"123"}, + Interactive: false, Editable: prShared.Editable{ Title: prShared.EditableString{ Value: "new title", @@ -311,11 +341,176 @@ func Test_editRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/issue/123\n", }, + { + name: "non-interactive multiple issues", + input: &EditOptions{ + SelectorArgs: []string{"456", "123"}, + Interactive: false, + Editable: prShared.Editable{ + Assignees: prShared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Remove: []string{"octocat"}, + Edited: true, + }, + Labels: prShared.EditableSlice{ + Add: []string{"feature", "TODO", "bug"}, + Remove: []string{"docs"}, + Edited: true, + }, + Projects: prShared.EditableProjects{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"Cleanup", "CleanupV2"}, + Remove: []string{"Roadmap", "RoadmapV2"}, + Edited: true, + }, + }, + Milestone: prShared.EditableString{ + Value: "GA", + Edited: true, + }, + }, + FetchOptions: prShared.FetchOptions, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + // Should only be one fetch of metadata. + mockRepoMetadata(t, reg) + // All other queries and mutations should be doubled. + mockIssueNumberGet(t, reg, 123) + mockIssueNumberGet(t, reg, 456) + mockIssueProjectItemsGet(t, reg) + mockIssueProjectItemsGet(t, reg) + mockIssueUpdate(t, reg) + mockIssueUpdate(t, reg) + mockIssueUpdateLabels(t, reg) + mockIssueUpdateLabels(t, reg) + mockProjectV2ItemUpdate(t, reg) + mockProjectV2ItemUpdate(t, reg) + }, + stdout: heredoc.Doc(` + https://github.com/OWNER/REPO/issue/123 + https://github.com/OWNER/REPO/issue/456 + `), + }, + { + name: "non-interactive multiple issues with fetch failures", + input: &EditOptions{ + SelectorArgs: []string{"123", "9999"}, + Interactive: false, + Editable: prShared.Editable{ + Assignees: prShared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Remove: []string{"octocat"}, + Edited: true, + }, + Labels: prShared.EditableSlice{ + Add: []string{"feature", "TODO", "bug"}, + Remove: []string{"docs"}, + Edited: true, + }, + Projects: prShared.EditableProjects{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"Cleanup", "CleanupV2"}, + Remove: []string{"Roadmap", "RoadmapV2"}, + Edited: true, + }, + }, + Milestone: prShared.EditableString{ + Value: "GA", + Edited: true, + }, + }, + FetchOptions: prShared.FetchOptions, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueNumberGet(t, reg, 123) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "errors": [ + { + "type": "NOT_FOUND", + "message": "Could not resolve to an Issue with the number of 9999." + } + ] }`), + ) + }, + wantErr: true, + }, + { + name: "non-interactive multiple issues with update failures", + input: &EditOptions{ + SelectorArgs: []string{"123", "456"}, + Interactive: false, + Editable: prShared.Editable{ + Assignees: prShared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Remove: []string{"octocat"}, + Edited: true, + }, + Milestone: prShared.EditableString{ + Value: "GA", + Edited: true, + }, + }, + FetchOptions: prShared.FetchOptions, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + // Should only be one fetch of metadata. + reg.Register( + httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + httpmock.StringResponse(` + { "data": { "repository": { "assignableUsers": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID" }, + { "login": "MonaLisa", "id": "MONAID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query RepositoryMilestoneList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "milestones": { + "nodes": [ + { "title": "GA", "id": "GAID" }, + { "title": "Big One.oh", "id": "BIGONEID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + // All other queries should be doubled. + mockIssueNumberGet(t, reg, 123) + mockIssueNumberGet(t, reg, 456) + // Updating 123 should succeed. + reg.Register( + httpmock.GraphQLMutationMatcher(`mutation IssueUpdate\b`, func(m map[string]interface{}) bool { + return m["id"] == "123" + }), + httpmock.GraphQLMutation(` + { "data": { "updateIssue": { "__typename": "" } } }`, + func(inputs map[string]interface{}) {}), + ) + // Updating 456 should fail. + reg.Register( + httpmock.GraphQLMutationMatcher(`mutation IssueUpdate\b`, func(m map[string]interface{}) bool { + return m["id"] == "456" + }), + httpmock.GraphQLMutation(` + { "errors": [ { "message": "test error" } ] }`, + func(inputs map[string]interface{}) {}), + ) + }, + stdout: heredoc.Doc(` + https://github.com/OWNER/REPO/issue/123 + `), + stderr: `failed to update https://github.com/OWNER/REPO/issue/456:.*test error`, + wantErr: true, + }, { name: "interactive", input: &EditOptions{ - SelectorArg: "123", - Interactive: true, + SelectorArgs: []string{"123"}, + Interactive: true, FieldsToEditSurvey: func(eo *prShared.Editable) error { eo.Title.Edited = true eo.Body.Edited = true @@ -351,38 +546,48 @@ func Test_editRun(t *testing.T) { }, } for _, tt := range tests { - ios, _, stdout, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - reg := &httpmock.Registry{} - defer reg.Verify(t) - tt.httpStubs(t, reg) - - httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } - baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } - - tt.input.IO = ios - tt.input.HttpClient = httpClient - tt.input.BaseRepo = baseRepo - t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.httpStubs(t, reg) + + httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } + + tt.input.IO = ios + tt.input.HttpClient = httpClient + tt.input.BaseRepo = baseRepo + err := editRun(tt.input) - assert.NoError(t, err) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } assert.Equal(t, tt.stdout, stdout.String()) - assert.Equal(t, tt.stderr, stderr.String()) + // Use regex match since mock errors and service errors will differ. + assert.Regexp(t, tt.stderr, stderr.String()) }) } } func mockIssueGet(_ *testing.T, reg *httpmock.Registry) { + mockIssueNumberGet(nil, reg, 123) +} + +func mockIssueNumberGet(_ *testing.T, reg *httpmock.Registry, number int) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(` + httpmock.StringResponse(fmt.Sprintf(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { - "number": 123, - "url": "https://github.com/OWNER/REPO/issue/123", + "id": "%[1]d", + "number": %[1]d, + "url": "https://github.com/OWNER/REPO/issue/%[1]d", "labels": { "nodes": [ { "id": "DOCSID", "name": "docs" } @@ -393,7 +598,7 @@ func mockIssueGet(_ *testing.T, reg *httpmock.Registry) { { "project": { "name": "Roadmap" } } ], "totalCount": 1 } - } } } }`), + } } } }`, number)), ) } diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index ef0652b26..1aa58c951 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -14,25 +14,20 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/set" + "golang.org/x/sync/errgroup" ) // IssueFromArgWithFields loads an issue or pull request with the specified fields. If some of the fields // could not be fetched by GraphQL, this returns a non-nil issue and a *PartialLoadError. func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), arg string, fields []string) (*api.Issue, ghrepo.Interface, error) { - issueNumber, baseRepo := issueMetadataFromURL(arg) - - if issueNumber == 0 { - var err error - issueNumber, err = strconv.Atoi(strings.TrimPrefix(arg, "#")) - if err != nil { - return nil, nil, fmt.Errorf("invalid issue format: %q", arg) - } + issueNumber, baseRepo, err := IssueNumberAndRepoFromArg(arg) + if err != nil { + return nil, nil, err } if baseRepo == nil { var err error - baseRepo, err = baseRepoFn() - if err != nil { + if baseRepo, err = baseRepoFn(); err != nil { return nil, nil, err } } @@ -41,6 +36,70 @@ func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.I return issue, baseRepo, err } +// IssuesFromArgWithFields loads 1 or more issues or pull requests with the specified fields. If some of the fields +// could not be fetched by GraphQL, this returns non-nil issues and a *PartialLoadError. +func IssuesFromArgsWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), args []string, fields []string) ([]*api.Issue, ghrepo.Interface, error) { + var issuesRepo ghrepo.Interface + issueNumbers := make([]int, 0, len(args)) + + for _, arg := range args { + issueNumber, baseRepo, err := IssueNumberAndRepoFromArg(arg) + if err != nil { + return nil, nil, err + } + + issueNumbers = append(issueNumbers, issueNumber) + if baseRepo == nil { + var err error + if baseRepo, err = baseRepoFn(); err != nil { + return nil, nil, err + } + } + + if issuesRepo == nil { + issuesRepo = baseRepo + continue + } + + if !ghrepo.IsSame(issuesRepo, baseRepo) { + return nil, nil, fmt.Errorf( + "multiple issues must be in same repo: found %q, expected %q", + ghrepo.FullName(baseRepo), + ghrepo.FullName(issuesRepo), + ) + } + } + + issuesChan := make(chan *api.Issue, len(args)) + g := errgroup.Group{} + for _, num := range issueNumbers { + issueNumber := num + g.Go(func() error { + issue, err := findIssueOrPR(httpClient, issuesRepo, issueNumber, fields) + if err != nil { + return err + } + + issuesChan <- issue + return nil + }) + } + + err := g.Wait() + close(issuesChan) + + if err != nil { + return nil, nil, err + } + + issues := make([]*api.Issue, 0, len(args)) + for issue := range issuesChan { + issues = append(issues, issue) + } + + return issues, issuesRepo, nil +} + var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/issues/(\d+)`) func issueMetadataFromURL(s string) (int, ghrepo.Interface) { diff --git a/pkg/cmd/issue/shared/lookup_test.go b/pkg/cmd/issue/shared/lookup_test.go index 2ce96d11b..8e1595ebc 100644 --- a/pkg/cmd/issue/shared/lookup_test.go +++ b/pkg/cmd/issue/shared/lookup_test.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" ) func TestIssueFromArgWithFields(t *testing.T) { @@ -195,3 +196,108 @@ func TestIssueFromArgWithFields(t *testing.T) { }) } } + +func TestIssuesFromArgsWithFields(t *testing.T) { + type args struct { + baseRepoFn func() (ghrepo.Interface, error) + selectors []string + } + tests := []struct { + name string + args args + httpStub func(*httpmock.Registry) + wantIssues []int + wantRepo string + wantErr bool + wantErrMsg string + }{ + { + name: "multiple repos", + args: args{ + selectors: []string{"1", "https://github.com/OWNER/OTHERREPO/issues/2"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasIssuesEnabled": true, + "issue":{"number":1} + }}}`)) + r.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasIssuesEnabled": true, + "issue":{"number":2} + }}}`)) + }, + wantErr: true, + wantErrMsg: "multiple issues must be in same repo", + }, + { + name: "multiple issues", + args: args{ + selectors: []string{"1", "2"}, + baseRepoFn: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasIssuesEnabled": true, + "issue":{"number":1} + }}}`)) + r.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasIssuesEnabled": true, + "issue":{"number":2} + }}}`)) + }, + wantIssues: []int{1, 2}, + wantRepo: "https://github.com/OWNER/REPO", + }, + } + for _, tt := range tests { + if !tt.wantErr && len(tt.args.selectors) != len(tt.wantIssues) { + t.Fatal("number of selectors and issues not equal") + } + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStub != nil { + tt.httpStub(reg) + } + httpClient := &http.Client{Transport: reg} + issues, repo, err := IssuesFromArgsWithFields(httpClient, tt.args.baseRepoFn, tt.args.selectors, []string{"number"}) + if (err != nil) != tt.wantErr { + t.Errorf("IssuesFromArgsWithFields() error = %v, wantErr %v", err, tt.wantErr) + if issues == nil { + return + } + } + if tt.wantErr { + assert.Error(t, err) + assert.ErrorContains(t, err, tt.wantErrMsg) + return + } + assert.NoError(t, err) + for i, issue := range issues { + if issue.Number != tt.wantIssues[i] { + t.Errorf("want issue #%d, got #%d", tt.wantIssues[i], issue.Number) + } + } + if repo != nil { + repoURL := ghrepo.GenerateRepoURL(repo, "") + if repoURL != tt.wantRepo { + t.Errorf("want repo %s, got %s", tt.wantRepo, repoURL) + } + } else if tt.wantRepo != "" { + t.Errorf("want repo %sw, got nil", tt.wantRepo) + } + }) + } +} diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 9e685270d..0fbe689fc 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -202,6 +202,59 @@ func (e Editable) MilestoneId() (*string, error) { return &m, err } +// Clone creates a mostly-shallow copy of Editable suitable for use in parallel +// go routines. Fields that would be mutated will be copied. +func (e *Editable) Clone() Editable { + return Editable{ + Title: e.Title.clone(), + Body: e.Body.clone(), + Base: e.Base.clone(), + Reviewers: e.Reviewers.clone(), + Assignees: e.Assignees.clone(), + Labels: e.Labels.clone(), + Projects: e.Projects.clone(), + Milestone: e.Milestone.clone(), + // Shallow copy since no mutation. + Metadata: e.Metadata, + } +} + +func (es *EditableString) clone() EditableString { + return EditableString{ + Value: es.Value, + Default: es.Default, + Edited: es.Edited, + // Shallow copies since no mutation. + Options: es.Options, + } +} + +func (es *EditableSlice) clone() EditableSlice { + cpy := EditableSlice{ + Edited: es.Edited, + Allowed: es.Allowed, + // Shallow copies since no mutation. + Options: es.Options, + // Copy mutable string slices. + Add: make([]string, len(es.Add)), + Remove: make([]string, len(es.Remove)), + Value: make([]string, len(es.Value)), + Default: make([]string, len(es.Default)), + } + copy(cpy.Add, es.Add) + copy(cpy.Remove, es.Remove) + copy(cpy.Value, es.Value) + copy(cpy.Default, es.Default) + return cpy +} + +func (ep *EditableProjects) clone() EditableProjects { + return EditableProjects{ + EditableSlice: ep.EditableSlice.clone(), + ProjectItems: ep.ProjectItems, + } +} + func EditFieldsSurvey(editable *Editable, editorCommand string) error { var err error if editable.Title.Edited { diff --git a/pkg/httpmock/stub.go b/pkg/httpmock/stub.go index 031db449e..b26d433fa 100644 --- a/pkg/httpmock/stub.go +++ b/pkg/httpmock/stub.go @@ -57,6 +57,33 @@ func GraphQL(q string) Matcher { } } +func GraphQLMutationMatcher(q string, cb func(map[string]interface{}) bool) Matcher { + re := regexp.MustCompile(q) + + return func(req *http.Request) bool { + if !strings.EqualFold(req.Method, "POST") { + return false + } + if req.URL.Path != "/graphql" && req.URL.Path != "/api/graphql" { + return false + } + + var bodyData struct { + Query string + Variables struct { + Input map[string]interface{} + } + } + _ = decodeJSONBody(req, &bodyData) + + if re.MatchString(bodyData.Query) { + return cb(bodyData.Variables.Input) + } + + return false + } +} + func QueryMatcher(method string, path string, query url.Values) Matcher { return func(req *http.Request) bool { if !REST(method, path)(req) { From 9b75e274e47655b9c8ebd24824cfa67a6805b6f5 Mon Sep 17 00:00:00 2001 From: Yeikel Date: Tue, 25 Apr 2023 08:58:01 -0400 Subject: [PATCH 62/65] feat: add visibility field (#7337) --- api/query_builder.go | 1 + 1 file changed, 1 insertion(+) diff --git a/api/query_builder.go b/api/query_builder.go index 3aa7464c4..877f79788 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -372,6 +372,7 @@ var RepositoryFields = []string{ "isInOrganization", "isMirror", "isPrivate", + "visibility", "isTemplate", "isUserConfigurationRepository", "licenseInfo", From 6a6df0d39b7325929702c81aa87fe01c9f4143a6 Mon Sep 17 00:00:00 2001 From: Saurav Maheshkar Date: Tue, 25 Apr 2023 14:02:16 +0100 Subject: [PATCH 63/65] gh: move `CODEOWNERS` inside the `.github/` dir (#7366) --- CODEOWNERS => .github/CODEOWNERS | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CODEOWNERS => .github/CODEOWNERS (100%) diff --git a/CODEOWNERS b/.github/CODEOWNERS similarity index 100% rename from CODEOWNERS rename to .github/CODEOWNERS From 530002ee7a080fc681ac13093d7b226923e87a02 Mon Sep 17 00:00:00 2001 From: Martijn Pieters Date: Tue, 25 Apr 2023 14:27:17 +0100 Subject: [PATCH 64/65] Pretty-print JSON results of jq filtering (#7236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When connected to a TTY, tell the jq formatter to indent the output, and enable colorization of the output if the terminal supports it. Co-authored-by: Mislav Marohnić --- pkg/cmd/api/api.go | 6 +++++- pkg/cmd/api/api_test.go | 30 +++++++++++++++++++++++++++ pkg/cmd/root/help_topic.go | 38 +++++++++++++++++++++++++++++++--- pkg/cmdutil/json_flags.go | 8 ++++++- pkg/cmdutil/json_flags_test.go | 17 +++++++++++++++ 5 files changed, 94 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 2a80c464c..ea0734d21 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -387,7 +387,11 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW if opts.FilterOutput != "" && serverError == "" { // TODO: reuse parsed query across pagination invocations - err = jq.Evaluate(responseBody, bodyWriter, opts.FilterOutput) + indent := "" + if opts.IO.IsStdoutTTY() { + indent = " " + } + err = jq.EvaluateFormatted(responseBody, bodyWriter, opts.FilterOutput, indent, opts.IO.ColorEnabled()) if err != nil { return } diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index d2fdc3c3c..ebbff6b6e 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -378,6 +378,7 @@ func Test_apiRun(t *testing.T) { err error stdout string stderr string + isatty bool }{ { name: "success", @@ -388,6 +389,7 @@ func Test_apiRun(t *testing.T) { err: nil, stdout: `bam!`, stderr: ``, + isatty: false, }, { name: "show response headers", @@ -404,6 +406,7 @@ func Test_apiRun(t *testing.T) { err: nil, stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\nbody", stderr: ``, + isatty: false, }, { name: "success 204", @@ -414,6 +417,7 @@ func Test_apiRun(t *testing.T) { err: nil, stdout: ``, stderr: ``, + isatty: false, }, { name: "REST error", @@ -425,6 +429,7 @@ func Test_apiRun(t *testing.T) { err: cmdutil.SilentError, stdout: `{"message": "THIS IS FINE"}`, stderr: "gh: THIS IS FINE (HTTP 400)\n", + isatty: false, }, { name: "REST string errors", @@ -436,6 +441,7 @@ func Test_apiRun(t *testing.T) { err: cmdutil.SilentError, stdout: `{"errors": ["ALSO", "FINE"]}`, stderr: "gh: ALSO\nFINE\n", + isatty: false, }, { name: "GraphQL error", @@ -450,6 +456,7 @@ func Test_apiRun(t *testing.T) { err: cmdutil.SilentError, stdout: `{"errors": [{"message":"AGAIN"}, {"message":"FINE"}]}`, stderr: "gh: AGAIN\nFINE\n", + isatty: false, }, { name: "failure", @@ -460,6 +467,7 @@ func Test_apiRun(t *testing.T) { err: cmdutil.SilentError, stdout: `gateway timeout`, stderr: "gh: HTTP 502\n", + isatty: false, }, { name: "silent", @@ -473,6 +481,7 @@ func Test_apiRun(t *testing.T) { err: nil, stdout: ``, stderr: ``, + isatty: false, }, { name: "show response headers even when silent", @@ -490,6 +499,7 @@ func Test_apiRun(t *testing.T) { err: nil, stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\n", stderr: ``, + isatty: false, }, { name: "output template", @@ -504,6 +514,7 @@ func Test_apiRun(t *testing.T) { err: nil, stdout: "not a cat", stderr: ``, + isatty: false, }, { name: "output template when REST error", @@ -518,6 +529,7 @@ func Test_apiRun(t *testing.T) { err: cmdutil.SilentError, stdout: `{"message": "THIS IS FINE"}`, stderr: "gh: THIS IS FINE (HTTP 400)\n", + isatty: false, }, { name: "jq filter", @@ -532,6 +544,7 @@ func Test_apiRun(t *testing.T) { err: nil, stdout: "Mona\nHubot\n", stderr: ``, + isatty: false, }, { name: "jq filter when REST error", @@ -546,12 +559,29 @@ func Test_apiRun(t *testing.T) { err: cmdutil.SilentError, stdout: `{"message": "THIS IS FINE"}`, stderr: "gh: THIS IS FINE (HTTP 400)\n", + isatty: false, + }, + { + name: "jq filter outputting JSON to a TTY", + options: ApiOptions{ + FilterOutput: `.`, + }, + httpResponse: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`[{"name":"Mona"},{"name":"Hubot"}]`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, + err: nil, + stdout: "[\n {\n \"name\": \"Mona\"\n },\n {\n \"name\": \"Hubot\"\n }\n]\n", + stderr: ``, + isatty: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isatty) tt.options.IO = ios tt.options.Config = func() (config.Config, error) { return config.NewBlankConfig(), nil } diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index 4a4d19a51..4df7d9e1a 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -111,8 +111,9 @@ var HelpTopics = map[string]map[string]string{ The %[1]s--jq%[1]s flag requires a string argument in jq query syntax, and will only print those JSON values which match the query. jq queries can be used to select elements from an array, fields from an object, create a new array, and more. The jq utility does not need - to be installed on the system to use this formatting directive. - To learn about jq query syntax, see: + to be installed on the system to use this formatting directive. When connected to a terminal, + the output is automatically pretty-printed. To learn about jq query syntax, see: + The %[1]s--template%[1]s flag requires a string argument in Go template syntax, and will only print those JSON values which match the query. @@ -174,7 +175,38 @@ var HelpTopics = map[string]map[string]string{ codercat cli-maintainer - + # --jq can be used to implement more complex filtering and output changes: + $ bin/gh issue list --json number,title,labels --jq \ + 'map(select((.labels | length) > 0)) # must have labels + | map(.labels = (.labels | map(.name))) # show only the label names + | .[:3] # select the first 3 results' + [ + { + "labels": [ + "enhancement", + "needs triage" + ], + "number": 123, + "title": "A helpful contribution" + }, + { + "labels": [ + "help wanted", + "docs", + "good first issue" + ], + "number": 125, + "title": "Improve the docs" + }, + { + "labels": [ + "enhancement", + ], + "number": 7221, + "title": "An exciting new feature" + } + ] + # using the --template flag with the hyperlink helper gh issue list --json title,url --template '{{range .}}{{hyperlink .url .title}}{{"\n"}}{{end}}' diff --git a/pkg/cmdutil/json_flags.go b/pkg/cmdutil/json_flags.go index d9bb89762..0aef38db4 100644 --- a/pkg/cmdutil/json_flags.go +++ b/pkg/cmdutil/json_flags.go @@ -138,7 +138,13 @@ func (e *exportFormat) Write(ios *iostreams.IOStreams, data interface{}) error { w := ios.Out if e.filter != "" { - return jq.Evaluate(&buf, w, e.filter) + indent := "" + if ios.IsStdoutTTY() { + indent = " " + } + if err := jq.EvaluateFormatted(&buf, w, e.filter, indent, ios.ColorEnabled()); err != nil { + return err + } } else if e.template != "" { t := template.New(w, ios.TerminalWidth(), ios.ColorEnabled()) if err := t.Parse(e.template); err != nil { diff --git a/pkg/cmdutil/json_flags_test.go b/pkg/cmdutil/json_flags_test.go index dd65647ea..975530df4 100644 --- a/pkg/cmdutil/json_flags_test.go +++ b/pkg/cmdutil/json_flags_test.go @@ -126,6 +126,7 @@ func Test_exportFormat_Write(t *testing.T) { args args wantW string wantErr bool + istty bool }{ { name: "regular JSON output", @@ -135,6 +136,7 @@ func Test_exportFormat_Write(t *testing.T) { }, wantW: "{\"name\":\"hubot\"}\n", wantErr: false, + istty: false, }, { name: "call ExportData", @@ -144,6 +146,7 @@ func Test_exportFormat_Write(t *testing.T) { }, wantW: "{\"field1\":\"item1:field1\",\"field2\":\"item1:field2\"}\n", wantErr: false, + istty: false, }, { name: "recursively call ExportData", @@ -156,6 +159,7 @@ func Test_exportFormat_Write(t *testing.T) { }, wantW: "{\"s1\":[{\"f1\":\"i1:f1\",\"f2\":\"i1:f2\"},{\"f1\":\"i2:f1\",\"f2\":\"i2:f2\"}],\"s2\":[{\"f1\":\"i3:f1\",\"f2\":\"i3:f2\"}]}\n", wantErr: false, + istty: false, }, { name: "with jq filter", @@ -165,6 +169,17 @@ func Test_exportFormat_Write(t *testing.T) { }, wantW: "hubot\n", wantErr: false, + istty: false, + }, + { + name: "with jq filter pretty printing", + exporter: exportFormat{filter: "."}, + args: args{ + data: map[string]string{"name": "hubot"}, + }, + wantW: "{\n \"name\": \"hubot\"\n}\n", + wantErr: false, + istty: true, }, { name: "with Go template", @@ -174,11 +189,13 @@ func Test_exportFormat_Write(t *testing.T) { }, wantW: "hubot", wantErr: false, + istty: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { io, _, w, _ := iostreams.Test() + io.SetStdoutTTY(tt.istty) if err := tt.exporter.Write(io, tt.args.data); (err != nil) != tt.wantErr { t.Errorf("exportFormat.Write() error = %v, wantErr %v", err, tt.wantErr) return From c64032b9815efef846b93cc29ed4eb47a2ce8d6c Mon Sep 17 00:00:00 2001 From: Aleksei Khudiakov Date: Wed, 26 Apr 2023 00:39:56 +1000 Subject: [PATCH 65/65] Fix Makefile compatibility with macOS `install` command on macOS does not support all flags supported by coreutils version. Create directories as separate `install -d` call. Signed-off-by: Aleksei Khudiakov --- Makefile | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index ffe694e19..ab1e28a01 100644 --- a/Makefile +++ b/Makefile @@ -76,9 +76,12 @@ install: bin/gh manpages completions install -m755 bin/gh ${DESTDIR}${bindir}/ install -d ${DESTDIR}${mandir}/man1 install -m644 ./share/man/man1/* ${DESTDIR}${mandir}/man1/ - install -DT -m644 ./share/bash-completion/completions/gh ${DESTDIR}${datadir}/bash-completion/completions/gh - install -DT -m644 ./share/fish/vendor_completions.d/gh.fish ${DESTDIR}${datadir}/fish/vendor_completions.d/gh.fish - install -DT -m644 ./share/zsh/site-functions/_gh ${DESTDIR}${datadir}/zsh/site-functions/_gh + install -d ${DESTDIR}${datadir}/bash-completion/completions + install -m644 ./share/bash-completion/completions/gh ${DESTDIR}${datadir}/bash-completion/completions/gh + install -d ${DESTDIR}${datadir}/fish/vendor_completions.d + install -m644 ./share/fish/vendor_completions.d/gh.fish ${DESTDIR}${datadir}/fish/vendor_completions.d/gh.fish + install -d ${DESTDIR}${datadir}/zsh/site-functions + install -m644 ./share/zsh/site-functions/_gh ${DESTDIR}${datadir}/zsh/site-functions/_gh .PHONY: uninstall uninstall: