Merge branch 'trunk' into phillmv/improve-gh-at-inspect

This commit is contained in:
Phill MV 2024-11-27 16:40:22 -05:00
commit 1cbdcedc80
17 changed files with 564 additions and 114 deletions

View file

@ -35,23 +35,23 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
cmd := &cobra.Command{
Use: "delete [<cache-id>| <cache-key> | --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 {

View file

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

View file

@ -249,7 +249,7 @@ func TestDevelopRun(t *testing.T) {
expectedOut: heredoc.Doc(`
Showing linked branches for OWNER/REPO#42
BRANCH URL
foo https://github.com/OWNER/REPO/tree/foo
bar https://github.com/OWNER/OTHER-REPO/tree/bar

View file

@ -130,7 +130,14 @@ func checkoutRun(opts *CheckoutOptions) error {
cmdQueue = append(cmdQueue, []string{"submodule", "update", "--init", "--recursive"})
}
err = executeCmds(opts.GitClient, cmdQueue)
// Note that although we will probably be fetching from the headRemote, in practice, PR checkout can only
// ever point to one host, and we know baseRemote must be populated, where headRemote might be nil (e.g. when
// it was deleted).
credentialPattern, err := git.CredentialPatternFromRemote(context.Background(), opts.GitClient, baseRemote.Name)
if err != nil {
return err
}
err = executeCmds(opts.GitClient, credentialPattern, cmdQueue)
if err != nil {
return err
}
@ -240,13 +247,16 @@ func localBranchExists(client *git.Client, b string) bool {
return err == nil
}
func executeCmds(client *git.Client, cmdQueue [][]string) error {
func executeCmds(client *git.Client, credentialPattern git.CredentialPattern, cmdQueue [][]string) error {
for _, args := range cmdQueue {
var err error
var cmd *git.Command
if args[0] == "fetch" || args[0] == "submodule" {
cmd, err = client.AuthenticatedCommand(context.Background(), args...)
} else {
switch args[0] {
case "submodule":
cmd, err = client.AuthenticatedCommand(context.Background(), credentialPattern, args...)
case "fetch":
cmd, err = client.AuthenticatedCommand(context.Background(), git.AllMatchingCredentialsPattern, args...)
default:
cmd, err = client.Command(context.Background(), args...)
}
if err != nil {

View file

@ -72,6 +72,32 @@ func Test_checkoutRun(t *testing.T) {
wantStderr string
wantErr bool
}{
{
name: "checkout with ssh remote URL",
opts: &CheckoutOptions{
SelectorArg: "123",
Finder: func() shared.PRFinder {
baseRepo, pr := stubPR("OWNER/REPO:master", "OWNER/REPO:feature")
finder := shared.NewMockFinder("123", pr, baseRepo)
return finder
}(),
Config: func() (gh.Config, error) {
return config.NewBlankConfig(), nil
},
Branch: func() (string, error) {
return "main", nil
},
},
remotes: map[string]string{
"origin": "OWNER/REPO",
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "")
cs.Register(`git remote get-url origin`, 0, "https://github.com/OWNER/REPO.git")
cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
cs.Register(`git checkout -b feature --track origin/feature`, 0, "")
},
},
{
name: "fork repo was deleted",
opts: &CheckoutOptions{
@ -94,6 +120,7 @@ func Test_checkoutRun(t *testing.T) {
"origin": "OWNER/REPO",
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git remote get-url origin`, 0, "https://github.com/OWNER/REPO.git")
cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 1, "")
cs.Register(`git checkout feature`, 0, "")
@ -124,6 +151,7 @@ func Test_checkoutRun(t *testing.T) {
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- refs/heads/foobar`, 1, "")
cs.Register(`git remote get-url origin`, 0, "https://github.com/OWNER/REPO.git")
cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
cs.Register(`git checkout -b foobar --track origin/feature`, 0, "")
},
@ -151,6 +179,7 @@ func Test_checkoutRun(t *testing.T) {
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git config branch\.foobar\.merge`, 1, "")
cs.Register(`git remote get-url origin`, 0, "https://github.com/hubot/REPO.git")
cs.Register(`git fetch origin refs/pull/123/head:foobar`, 0, "")
cs.Register(`git checkout foobar`, 0, "")
cs.Register(`git config branch\.foobar\.remote https://github.com/hubot/REPO.git`, 0, "")
@ -276,6 +305,7 @@ func TestPRCheckout_sameRepo(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git remote get-url origin`, 0, "https://github.com/OWNER/REPO.git")
cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "")
cs.Register(`git checkout -b feature --track origin/feature`, 0, "")
@ -296,6 +326,7 @@ func TestPRCheckout_existingBranch(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git remote get-url origin`, 0, "https://github.com/OWNER/REPO.git")
cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 0, "")
cs.Register(`git checkout feature`, 0, "")
@ -329,6 +360,7 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git remote get-url origin`, 0, "https://github.com/OWNER/REPO.git")
cs.Register(`git fetch robot-fork \+refs/heads/feature:refs/remotes/robot-fork/feature`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "")
cs.Register(`git checkout -b feature --track robot-fork/feature`, 0, "")
@ -350,6 +382,7 @@ func TestPRCheckout_differentRepo(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git remote get-url origin`, 0, "https://github.com/OWNER/REPO.git")
cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 1, "")
cs.Register(`git checkout feature`, 0, "")
@ -373,6 +406,7 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git remote get-url origin`, 0, "https://github.com/OWNER/REPO.git")
cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 0, "refs/heads/feature\n")
cs.Register(`git checkout feature`, 0, "")
@ -393,6 +427,7 @@ func TestPRCheckout_detachedHead(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git remote get-url origin`, 0, "https://github.com/OWNER/REPO.git")
cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 0, "refs/heads/feature\n")
cs.Register(`git checkout feature`, 0, "")
@ -413,6 +448,7 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git remote get-url origin`, 0, "https://github.com/OWNER/REPO.git")
cs.Register(`git fetch origin refs/pull/123/head`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 0, "refs/heads/feature\n")
cs.Register(`git merge --ff-only FETCH_HEAD`, 0, "")
@ -450,6 +486,7 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git remote get-url origin`, 0, "https://github.com/OWNER/REPO.git")
cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 1, "")
cs.Register(`git checkout feature`, 0, "")
@ -472,6 +509,7 @@ func TestPRCheckout_recurseSubmodules(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git remote get-url origin`, 0, "https://github.com/OWNER/REPO.git")
cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 0, "")
cs.Register(`git checkout feature`, 0, "")
@ -494,6 +532,7 @@ func TestPRCheckout_force(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git remote get-url origin`, 0, "https://github.com/OWNER/REPO.git")
cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 0, "")
cs.Register(`git checkout feature`, 0, "")
@ -517,6 +556,7 @@ func TestPRCheckout_detach(t *testing.T) {
defer cmdTeardown(t)
cs.Register(`git checkout --detach FETCH_HEAD`, 0, "")
cs.Register(`git remote get-url origin`, 0, "https://github.com/hubot/REPO.git")
cs.Register(`git fetch origin refs/pull/123/head`, 0, "")
output, err := runCommand(http, nil, "", `123 --detach`)

View file

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

View file

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

View file

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

View file

@ -676,7 +676,7 @@ func createFromLocal(opts *CreateOptions) error {
}
if opts.Push && repoType == bare {
cmd, err := opts.GitClient.AuthenticatedCommand(context.Background(), "push", baseRemote, "--mirror")
cmd, err := opts.GitClient.AuthenticatedCommand(context.Background(), git.AllMatchingCredentialsPattern, "push", baseRemote, "--mirror")
if err != nil {
return err
}

View file

@ -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 [<repository>]",
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 {

View file

@ -55,7 +55,7 @@ func (g *gitExecuter) CurrentBranch() (string, error) {
func (g *gitExecuter) Fetch(remote, ref string) error {
args := []string{"fetch", "-q", remote, ref}
cmd, err := g.client.AuthenticatedCommand(context.Background(), args...)
cmd, err := g.client.AuthenticatedCommand(context.Background(), git.AllMatchingCredentialsPattern, args...)
if err != nil {
return err
}

View file

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