diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go index 4794d1fbe..65a9d696a 100644 --- a/pkg/cmd/cache/delete/delete.go +++ b/pkg/cmd/cache/delete/delete.go @@ -35,23 +35,23 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "delete [| | --all]", Short: "Delete GitHub Actions caches", - Long: ` - Delete GitHub Actions caches. + Long: heredoc.Docf(` + Delete GitHub Actions caches. - Deletion requires authorization with the "repo" scope. -`, + Deletion requires authorization with the %[1]srepo%[1]s scope. + `, "`"), Example: heredoc.Doc(` - # Delete a cache by id - $ gh cache delete 1234 + # Delete a cache by id + $ gh cache delete 1234 - # Delete a cache by key - $ gh cache delete cache-key + # Delete a cache by key + $ gh cache delete cache-key - # Delete a cache by id in a specific repo - $ gh cache delete 1234 --repo cli/cli + # Delete a cache by id in a specific repo + $ gh cache delete 1234 --repo cli/cli - # Delete all caches - $ gh cache delete --all + # Delete all caches + $ gh cache delete --all `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/cmd/codespace/rebuild.go b/pkg/cmd/codespace/rebuild.go index 93d43bf29..565406edc 100644 --- a/pkg/cmd/codespace/rebuild.go +++ b/pkg/cmd/codespace/rebuild.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/codespaces" "github.com/cli/cli/v2/internal/codespaces/api" "github.com/cli/cli/v2/internal/codespaces/portforwarder" @@ -20,9 +21,12 @@ func newRebuildCmd(app *App) *cobra.Command { rebuildCmd := &cobra.Command{ Use: "rebuild", Short: "Rebuild a codespace", - Long: `Rebuilding recreates your codespace. Your code and any current changes will be -preserved. Your codespace will be rebuilt using your working directory's -dev container. A full rebuild also removes cached Docker images.`, + Long: heredoc.Doc(` + Rebuilding recreates your codespace. + + Your code and any current changes will be preserved. Your codespace will be rebuilt using + your working directory's dev container. A full rebuild also removes cached Docker images. + `), Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return app.Rebuild(cmd.Context(), selector, fullRebuild) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 0b7c99b23..8e77546d0 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -477,6 +477,21 @@ func createRun(opts *CreateOptions) error { } newRelease, err := createRelease(httpClient, baseRepo, params) + + var errMissingRequiredWorkflowScope *errMissingRequiredWorkflowScope + if errors.As(err, &errMissingRequiredWorkflowScope) { + host := errMissingRequiredWorkflowScope.Hostname + refreshInstructions := fmt.Sprintf("gh auth refresh -h %[1]s -s workflow", host) + cs := opts.IO.ColorScheme() + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("%s Failed to create release, \"workflow\" scope may be required.\n", cs.WarningIcon())) + sb.WriteString(fmt.Sprintf("To request it, run:\n%s\n", cs.Bold(refreshInstructions))) + fmt.Fprint(opts.IO.ErrOut, sb.String()) + + return cmdutil.SilentError + } + if err != nil { return err } diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 85c1c3f3f..c9e9a8c8a 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" @@ -1082,6 +1083,74 @@ func Test_createRun(t *testing.T) { runStubs: defaultRunStubs, wantErr: "cannot generate release notes from tag v1.2.3 as it does not exist locally", }, + { + name: "API returns 404, OAuth token has no workflow scope", + isTTY: false, + opts: CreateOptions{ + TagName: "Does not matter", + }, + runStubs: func(rs *run.CommandStubber) { + rs.Register(contentCmd, 0, "some tag message") + rs.Register(signatureCmd, 0, "") + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL("RepositoryFindRef"), + httpmock.StringResponse(`{"data":{"repository":{"ref": {"id": "tag id"}}}}`), + ) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/releases"), + httpmock.StatusScopesResponder(404, `repo,read:org`)) + }, + wantStderr: heredoc.Doc(` + ! Failed to create release, "workflow" scope may be required. + To request it, run: + gh auth refresh -h github.com -s workflow + `), + wantErr: cmdutil.SilentError.Error(), + }, + { + name: "API returns 404, OAuth token has workflow scope", + isTTY: false, + opts: CreateOptions{ + TagName: "Does not matter", + }, + runStubs: func(rs *run.CommandStubber) { + rs.Register(contentCmd, 0, "some tag message") + rs.Register(signatureCmd, 0, "") + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL("RepositoryFindRef"), + httpmock.StringResponse(`{"data":{"repository":{"ref": {"id": "tag id"}}}}`), + ) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/releases"), + httpmock.StatusScopesResponder(404, `repo,read:org,workflow`)) + }, + wantErr: "HTTP 404 (https://api.github.com/repos/OWNER/REPO/releases)", + }, + { + name: "API returns 404, not an OAuth token", + isTTY: false, + opts: CreateOptions{ + TagName: "Does not matter", + }, + runStubs: func(rs *run.CommandStubber) { + rs.Register(contentCmd, 0, "some tag message") + rs.Register(signatureCmd, 0, "") + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL("RepositoryFindRef"), + httpmock.StringResponse(`{"data":{"repository":{"ref": {"id": "tag id"}}}}`), + ) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/releases"), + httpmock.StatusStringResponse(404, `HTTP 404 (https://api.github.com/repos/OWNER/REPO/releases)`)) + }, + wantErr: "HTTP 404 (https://api.github.com/repos/OWNER/REPO/releases)", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1115,7 +1184,6 @@ func Test_createRun(t *testing.T) { err := createRun(&tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) - return } else { require.NoError(t, err) } diff --git a/pkg/cmd/release/create/http.go b/pkg/cmd/release/create/http.go index 84e95710f..3bb55f39e 100644 --- a/pkg/cmd/release/create/http.go +++ b/pkg/cmd/release/create/http.go @@ -8,12 +8,16 @@ import ( "io" "net/http" "net/url" + "slices" + "strings" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/shurcooL/githubv4" + + ghauth "github.com/cli/go-gh/v2/pkg/auth" ) type tag struct { @@ -27,6 +31,14 @@ type releaseNotes struct { var notImplementedError = errors.New("not implemented") +type errMissingRequiredWorkflowScope struct { + Hostname string +} + +func (e errMissingRequiredWorkflowScope) Error() string { + return "workflow scope may be required" +} + func remoteTagExists(httpClient *http.Client, repo ghrepo.Interface, tagName string) (bool, error) { gql := api.NewClientFromHTTP(httpClient) qualifiedTagName := fmt.Sprintf("refs/tags/%s", tagName) @@ -174,6 +186,24 @@ func createRelease(httpClient *http.Client, repo ghrepo.Interface, params map[st } defer resp.Body.Close() + // Check if we received a 404 while attempting to create a release without + // the workflow scope, and if so, return an error message that explains a possible + // solution to the user. + // + // If the same file (with both the same path and contents) exists + // on another branch in the repo, releases with workflow file changes can be + // created without the workflow scope. Otherwise, the workflow scope is + // required to create the release, but the API does not indicate this criteria + // beyond returning a 404. + // + // https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps#available-scopes + if resp.StatusCode == http.StatusNotFound && !tokenHasWorkflowScope(resp) { + normalizedHostname := ghauth.NormalizeHostname(resp.Request.URL.Hostname()) + return nil, &errMissingRequiredWorkflowScope{ + Hostname: normalizedHostname, + } + } + success := resp.StatusCode >= 200 && resp.StatusCode < 300 if !success { return nil, api.HandleHTTPError(resp) @@ -254,3 +284,18 @@ func deleteRelease(httpClient *http.Client, release *shared.Release) error { } return nil } + +// tokenHasWorkflowScope checks if the given http.Response's token has the workflow scope. +// Tokens that do not have OAuth scopes are assumed to have the workflow scope. +func tokenHasWorkflowScope(resp *http.Response) bool { + scopes := resp.Header.Get("X-Oauth-Scopes") + + // Return true when no scopes are present - no scopes in this header + // means that the user is probably authenticating with a token type other + // than an OAuth token, and we don't know what this token's scopes actually are. + if scopes == "" { + return true + } + + return slices.Contains(strings.Split(scopes, ","), "workflow") +} diff --git a/pkg/cmd/repo/delete/delete.go b/pkg/cmd/repo/delete/delete.go index 722d748b9..7c6476f1d 100644 --- a/pkg/cmd/repo/delete/delete.go +++ b/pkg/cmd/repo/delete/delete.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" @@ -38,12 +39,14 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "delete []", Short: "Delete a repository", - Long: `Delete a GitHub repository. + Long: heredoc.Docf(` + Delete a GitHub repository. + + With no argument, deletes the current repository. Otherwise, deletes the specified repository. -With no argument, deletes the current repository. Otherwise, deletes the specified repository. - -Deletion requires authorization with the "delete_repo" scope. -To authorize, run "gh auth refresh -s delete_repo"`, + Deletion requires authorization with the %[1]sdelete_repo%[1]s scope. + To authorize, run %[1]sgh auth refresh -s delete_repo%[1]s + `, "`"), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { diff --git a/pkg/httpmock/stub.go b/pkg/httpmock/stub.go index 787cdcf9d..196a047d8 100644 --- a/pkg/httpmock/stub.go +++ b/pkg/httpmock/stub.go @@ -225,10 +225,16 @@ func GraphQLQuery(body string, cb func(string, map[string]interface{})) Responde } } +// ScopesResponder returns a response with a 200 status code and the given OAuth scopes. func ScopesResponder(scopes string) func(*http.Request) (*http.Response, error) { + return StatusScopesResponder(http.StatusOK, scopes) +} + +// StatusScopesResponder returns a response with the given status code and OAuth scopes. +func StatusScopesResponder(status int, scopes string) func(*http.Request) (*http.Response, error) { return func(req *http.Request) (*http.Response, error) { return &http.Response{ - StatusCode: 200, + StatusCode: status, Request: req, Header: map[string][]string{ "X-Oauth-Scopes": {scopes},