Merge branch 'trunk' into eugene/attestation/fetch-oci-bundle

This commit is contained in:
Eugene 2024-08-12 07:10:56 -07:00 committed by GitHub
commit 7539f2aea4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 455 additions and 183 deletions

View file

@ -8,6 +8,8 @@ triage role. The initial expectation is that the person in the role for the week
Review and label [open issues missing either the `enhancement`, `bug`, or `docs` label](https://github.com/cli/cli/issues?q=is%3Aopen+is%3Aissue+-label%3Abug%2Cenhancement%2Cdocs+).
Any issue that is accepted to be done as either an `enhancement`, `bug`, or `docs` requires explicit Acceptance Criteria in a comment on the issue before `needs-triage` label is removed.
To be considered triaged, `enhancement` issues require at least one of the following additional labels:
- `core`: reserved for the core CLI team

5
go.mod
View file

@ -20,7 +20,7 @@ require (
github.com/gabriel-vasile/mimetype v1.4.5
github.com/gdamore/tcell/v2 v2.5.4
github.com/google/go-cmp v0.6.0
github.com/google/go-containerregistry v0.20.1
github.com/google/go-containerregistry v0.20.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-multierror v1.1.1
@ -70,9 +70,8 @@ require (
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/docker/cli v24.0.0+incompatible // indirect
github.com/docker/cli v27.1.1+incompatible // indirect
github.com/docker/distribution v2.8.2+incompatible // indirect
github.com/docker/docker v24.0.9+incompatible // indirect
github.com/docker/docker-credential-helpers v0.7.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect

10
go.sum
View file

@ -131,12 +131,10 @@ github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/docker/cli v24.0.0+incompatible h1:0+1VshNwBQzQAx9lOl+OYCTCEAD8fKs/qeXMx3O0wqM=
github.com/docker/cli v24.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v27.1.1+incompatible h1:goaZxOqs4QKxznZjjBWKONQci/MywhtRv2oNn0GkeZE=
github.com/docker/cli v27.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0=
github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A=
github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
@ -201,8 +199,8 @@ github.com/google/certificate-transparency-go v1.2.1 h1:4iW/NwzqOqYEEoCBEFP+jPbB
github.com/google/certificate-transparency-go v1.2.1/go.mod h1:bvn/ytAccv+I6+DGkqpvSsEdiVGramgaSC6RD3tEmeE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-containerregistry v0.20.1 h1:eTgx9QNYugV4DN5mz4U8hiAGTi1ybXn0TPi4Smd8du0=
github.com/google/go-containerregistry v0.20.1/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI=
github.com/google/go-containerregistry v0.20.2 h1:B1wPJ1SN/S7pB+ZAimcciVD+r+yV/l/DSArMxlbwseo=
github.com/google/go-containerregistry v0.20.2/go.mod h1:z38EKdKh4h7IP2gSfUUqEvalZBqs6AoLeWfUy34nQC8=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=

View file

@ -81,19 +81,11 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
opts.BaseRepo = f.BaseRepo
opts.HasRepoOverride = cmd.Flags().Changed("repo")
if err := cmdutil.MutuallyExclusive(
"specify only one of `--editor` or `--web`",
opts.EditorMode,
opts.WebMode,
); err != nil {
return err
}
config, err := f.Config()
var err error
opts.EditorMode, err = prShared.InitEditorMode(f, opts.EditorMode, opts.WebMode, opts.IO.CanPrompt())
if err != nil {
return err
}
opts.EditorMode = !opts.WebMode && (opts.EditorMode || config.PreferEditorPrompt("").Value == "enabled")
titleProvided := cmd.Flags().Changed("title")
bodyProvided := cmd.Flags().Changed("body")
@ -119,9 +111,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
if opts.Interactive && !opts.IO.CanPrompt() {
return cmdutil.FlagErrorf("must provide `--title` and `--body` when not running interactively")
}
if opts.EditorMode && !opts.IO.CanPrompt() {
return errors.New("--editor or enabled prefer_editor_prompt configuration are not supported in non-tty mode")
}
if runF != nil {
return runF(opts)
@ -133,11 +122,11 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.")
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.")
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)")
cmd.Flags().BoolVarP(&opts.EditorMode, "editor", "e", false, "Skip prompts and open the text editor to write the title and body in. The first line is the title and the rest text is the body.")
cmd.Flags().BoolVarP(&opts.EditorMode, "editor", "e", false, "Skip prompts and open the text editor to write the title and body in. The first line is the title and the remaining text is the body.")
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to create an issue")
cmd.Flags().StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`. Use \"@me\" to self-assign.")
cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`")
cmd.Flags().StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the issue to projects by `name`")
cmd.Flags().StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the issue to projects by `title`")
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 `file` to use as starting body text")

View file

@ -153,8 +153,8 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Remove, "remove-assignee", nil, "Remove assigned users by their `login`. Use \"@me\" to unassign yourself.")
cmd.Flags().StringSliceVar(&opts.Editable.Labels.Add, "add-label", nil, "Add labels by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Labels.Remove, "remove-label", nil, "Remove labels by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Add, "add-project", nil, "Add the issue to projects by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the issue from projects by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Add, "add-project", nil, "Add the issue to projects by `title`")
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the issue from projects by `title`")
cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the issue belongs to by `name`")
cmd.Flags().BoolVar(&removeMilestone, "remove-milestone", false, "Remove the milestone association from the issue")

View file

@ -30,15 +30,16 @@ import (
type CreateOptions struct {
// This struct stores user input and factory functions
HttpClient func() (*http.Client, error)
GitClient *git.Client
Config func() (gh.Config, error)
IO *iostreams.IOStreams
Remotes func() (ghContext.Remotes, error)
Branch func() (string, error)
Browser browser.Browser
Prompter shared.Prompt
Finder shared.PRFinder
HttpClient func() (*http.Client, error)
GitClient *git.Client
Config func() (gh.Config, error)
IO *iostreams.IOStreams
Remotes func() (ghContext.Remotes, error)
Branch func() (string, error)
Browser browser.Browser
Prompter shared.Prompt
Finder shared.PRFinder
TitledEditSurvey func(string, string) (string, string, error)
TitleProvided bool
BodyProvided bool
@ -49,6 +50,7 @@ type CreateOptions struct {
Autofill bool
FillVerbose bool
FillFirst bool
EditorMode bool
WebMode bool
RecoverFile string
@ -88,14 +90,15 @@ type CreateContext struct {
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
opts := &CreateOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
GitClient: f.GitClient,
Config: f.Config,
Remotes: f.Remotes,
Branch: f.Branch,
Browser: f.Browser,
Prompter: f.Prompter,
IO: f.IOStreams,
HttpClient: f.HttpClient,
GitClient: f.GitClient,
Config: f.Config,
Remotes: f.Remotes,
Branch: f.Branch,
Browser: f.Browser,
Prompter: f.Prompter,
TitledEditSurvey: shared.TitledEditSurvey(&shared.UserEditor{Config: f.Config, IO: f.IOStreams}),
}
var bodyFile string
@ -177,6 +180,20 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
return cmdutil.FlagErrorf("`--fill-verbose` is not supported with `--fill`")
}
if err := cmdutil.MutuallyExclusive(
"specify only one of `--editor` or `--web`",
opts.EditorMode,
opts.WebMode,
); err != nil {
return err
}
var err error
opts.EditorMode, err = shared.InitEditorMode(f, opts.EditorMode, opts.WebMode, opts.IO.CanPrompt())
if err != nil {
return err
}
opts.BodyProvided = cmd.Flags().Changed("body")
if bodyFile != "" {
b, err := cmdutil.ReadFile(bodyFile, opts.IO.In)
@ -213,6 +230,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
fl.StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)")
fl.StringVarP(&opts.BaseBranch, "base", "B", "", "The `branch` into which you want your code merged")
fl.StringVarP(&opts.HeadBranch, "head", "H", "", "The `branch` that contains commits for your pull request (default [current branch])")
fl.BoolVarP(&opts.EditorMode, "editor", "e", false, "Skip prompts and open the text editor to write the title and body in. The first line is the title and the remaining text is the body.")
fl.BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to create a pull request")
fl.BoolVarP(&opts.FillVerbose, "fill-verbose", "", false, "Use commits msg+body for description")
fl.BoolVarP(&opts.Autofill, "fill", "f", false, "Use commit info for title and body")
@ -220,7 +238,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people or teams by their `handle`")
fl.StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`. Use \"@me\" to self-assign.")
fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`")
fl.StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the pull request to projects by `name`")
fl.StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the pull request to projects by `title`")
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")
@ -315,7 +333,7 @@ func createRun(opts *CreateOptions) (err error) {
ghrepo.FullName(ctx.BaseRepo))
}
if opts.FillVerbose || opts.Autofill || opts.FillFirst || (opts.TitleProvided && opts.BodyProvided) {
if !opts.EditorMode && (opts.FillVerbose || opts.Autofill || opts.FillFirst || (opts.TitleProvided && opts.BodyProvided)) {
err = handlePush(*opts, *ctx)
if err != nil {
return
@ -330,71 +348,101 @@ func createRun(opts *CreateOptions) (err error) {
}
}
if !opts.TitleProvided {
err = shared.TitleSurvey(opts.Prompter, state)
if err != nil {
return
}
action := shared.SubmitAction
if opts.IsDraft {
action = shared.SubmitDraftAction
}
defer shared.PreserveInput(opts.IO, state, &err)()
tpl := shared.NewTemplateManager(client.HTTP(), ctx.BaseRepo, opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true)
if !opts.BodyProvided {
templateContent := ""
if opts.RecoverFile == "" {
tpl := shared.NewTemplateManager(client.HTTP(), ctx.BaseRepo, opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true)
if opts.EditorMode {
if opts.Template != "" {
var template shared.Template
if opts.Template != "" {
template, err = tpl.Select(opts.Template)
if err != nil {
return
}
} else {
template, err = tpl.Choose()
if err != nil {
return
}
template, err = tpl.Select(opts.Template)
if err != nil {
return
}
if state.Title == "" {
state.Title = template.Title()
}
state.Body = string(template.Body())
}
if template != nil {
templateContent = string(template.Body())
state.Title, state.Body, err = opts.TitledEditSurvey(state.Title, state.Body)
if err != nil {
return
}
if state.Title == "" {
err = fmt.Errorf("title can't be blank")
return
}
} else {
if !opts.TitleProvided {
err = shared.TitleSurvey(opts.Prompter, state)
if err != nil {
return
}
}
err = shared.BodySurvey(opts.Prompter, state, templateContent)
if err != nil {
return
defer shared.PreserveInput(opts.IO, state, &err)()
if !opts.BodyProvided {
templateContent := ""
if opts.RecoverFile == "" {
var template shared.Template
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 {
templateContent = string(template.Body())
}
}
err = shared.BodySurvey(opts.Prompter, state, templateContent)
if err != nil {
return
}
}
}
openURL, err = generateCompareURL(*ctx, *state)
if err != nil {
return
}
allowPreview := !state.HasMetadata() && shared.ValidURL(openURL) && !opts.DryRun
allowMetadata := ctx.BaseRepo.ViewerCanTriage()
action, err := shared.ConfirmPRSubmission(opts.Prompter, allowPreview, allowMetadata, state.Draft)
if err != nil {
return fmt.Errorf("unable to confirm: %w", err)
}
if action == shared.MetadataAction {
fetcher := &shared.MetadataFetcher{
IO: opts.IO,
APIClient: client,
Repo: ctx.BaseRepo,
State: state,
}
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state)
openURL, err = generateCompareURL(*ctx, *state)
if err != nil {
return
}
action, err = shared.ConfirmPRSubmission(opts.Prompter, !state.HasMetadata() && !opts.DryRun, false, state.Draft)
allowPreview := !state.HasMetadata() && shared.ValidURL(openURL) && !opts.DryRun
allowMetadata := ctx.BaseRepo.ViewerCanTriage()
action, err = shared.ConfirmPRSubmission(opts.Prompter, allowPreview, allowMetadata, state.Draft)
if err != nil {
return
return fmt.Errorf("unable to confirm: %w", err)
}
if action == shared.MetadataAction {
fetcher := &shared.MetadataFetcher{
IO: opts.IO,
APIClient: client,
Repo: ctx.BaseRepo,
State: state,
}
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state)
if err != nil {
return
}
action, err = shared.ConfirmPRSubmission(opts.Prompter, !state.HasMetadata() && !opts.DryRun, false, state.Draft)
if err != nil {
return
}
}
}

View file

@ -40,6 +40,7 @@ func TestNewCmdCreate(t *testing.T) {
tty bool
stdin string
cli string
config string
wantsErr bool
wantsOpts CreateOptions
}{
@ -202,6 +203,64 @@ func TestNewCmdCreate(t *testing.T) {
cli: "--web --dry-run",
wantsErr: true,
},
{
name: "editor by cli",
tty: true,
cli: "--editor",
wantsErr: false,
wantsOpts: CreateOptions{
Title: "",
Body: "",
RecoverFile: "",
WebMode: false,
EditorMode: true,
MaintainerCanModify: true,
},
},
{
name: "editor by config",
tty: true,
cli: "",
config: "prefer_editor_prompt: enabled",
wantsErr: false,
wantsOpts: CreateOptions{
Title: "",
Body: "",
RecoverFile: "",
WebMode: false,
EditorMode: true,
MaintainerCanModify: true,
},
},
{
name: "editor and web",
tty: true,
cli: "--editor --web",
wantsErr: true,
},
{
name: "can use web even though editor is enabled by config",
tty: true,
cli: `--web --title mytitle --body "issue body"`,
config: "prefer_editor_prompt: enabled",
wantsErr: false,
wantsOpts: CreateOptions{
Title: "mytitle",
Body: "issue body",
TitleProvided: true,
BodyProvided: true,
RecoverFile: "",
WebMode: true,
EditorMode: false,
MaintainerCanModify: true,
},
},
{
name: "editor with non-tty",
tty: false,
cli: "--editor",
wantsErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -215,6 +274,12 @@ func TestNewCmdCreate(t *testing.T) {
f := &cmdutil.Factory{
IOStreams: ios,
Config: func() (gh.Config, error) {
if tt.config != "" {
return config.NewFromString(tt.config), nil
}
return config.NewBlankConfig(), nil
},
}
var opts *CreateOptions
@ -1372,6 +1437,33 @@ func Test_createRun(t *testing.T) {
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
},
{
name: "editor",
httpStubs: func(r *httpmock.Registry, t *testing.T) {
r.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
{
"data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } }
}
`, func(inputs map[string]interface{}) {
assert.Equal(t, "title", inputs["title"])
assert.Equal(t, "body", inputs["body"])
}))
},
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.EditorMode = true
opts.HeadBranch = "feature"
opts.TitledEditSurvey = func(string, string) (string, string, error) { return "title", "body", nil }
return func() {}
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register("git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "")
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -161,8 +161,8 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Remove, "remove-assignee", nil, "Remove assigned users by their `login`. Use \"@me\" to unassign yourself.")
cmd.Flags().StringSliceVar(&opts.Editable.Labels.Add, "add-label", nil, "Add labels by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Labels.Remove, "remove-label", nil, "Remove labels by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Add, "add-project", nil, "Add the pull request to projects by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the pull request from projects by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Add, "add-project", nil, "Add the pull request to projects by `title`")
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the pull request from projects by `title`")
cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the pull request belongs to by `name`")
cmd.Flags().BoolVar(&removeMilestone, "remove-milestone", false, "Remove the milestone association from the pull request")

View file

@ -1,6 +1,7 @@
package shared
import (
"errors"
"fmt"
"strings"
@ -357,3 +358,26 @@ func TitledEditSurvey(editor Editor) func(string, string) (string, string, error
return title, strings.TrimSuffix(body, "\n"), nil
}
}
func InitEditorMode(f *cmdutil.Factory, editorMode bool, webMode bool, canPrompt bool) (bool, error) {
if err := cmdutil.MutuallyExclusive(
"specify only one of `--editor` or `--web`",
editorMode,
webMode,
); err != nil {
return false, err
}
config, err := f.Config()
if err != nil {
return false, err
}
editorMode = !webMode && (editorMode || config.PreferEditorPrompt("").Value == "enabled")
if editorMode && !canPrompt {
return false, errors.New("--editor or enabled prefer_editor_prompt configuration are not supported in non-tty mode")
}
return editorMode, nil
}

View file

@ -116,6 +116,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
Use release notes from a file
$ gh release create v1.2.3 -F changelog.md
Use annotated tag notes
$ gh release create v1.2.3 --notes-from-tag
Don't mark the release as latest
$ gh release create v1.2.3 --latest=false
@ -516,12 +519,38 @@ func createRun(opts *CreateOptions) error {
}
func gitTagInfo(client *git.Client, tagName string) (string, error) {
cmd, err := client.Command(context.Background(), "tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)")
contentCmd, err := client.Command(context.Background(), "tag", "--list", tagName, "--format=%(contents)")
if err != nil {
return "", err
}
b, err := cmd.Output()
return string(b), err
content, err := contentCmd.Output()
if err != nil {
return "", err
}
// If there is a signature, we should strip it from the end of the content.
// Note that, we can achieve this by looking for markers like "-----BEGIN PGP
// SIGNATURE-----" and cut the remaining text from the content, but this is
// not a safe approach, because, although unlikely, the content can contain
// a signature-like section which we shouldn't leave it as is. So, we need
// to get the tag signature as a whole, if any, and remote it from the content.
signatureCmd, err := client.Command(context.Background(), "tag", "--list", tagName, "--format=%(contents:signature)")
if err != nil {
return "", err
}
signature, err := signatureCmd.Output()
if err != nil {
return "", err
}
if len(signature) == 0 {
// The tag annotation content has no trailing signature to strip out,
// so we return the entire content.
return string(content), nil
}
body, _ := strings.CutSuffix(string(content), "\n"+string(signature))
return body, nil
}
func detectPreviousTag(client *git.Client, headRef string) (string, error) {

View file

@ -412,6 +412,14 @@ func Test_NewCmdCreate(t *testing.T) {
}
func Test_createRun(t *testing.T) {
const contentCmd = `git tag --list .* --format=%\(contents\)`
const signatureCmd = `git tag --list .* --format=%\(contents:signature\)`
defaultRunStubs := func(rs *run.CommandStubber) {
rs.Register(contentCmd, 0, "")
rs.Register(signatureCmd, 0, "")
}
tests := []struct {
name string
isTTY bool
@ -432,9 +440,7 @@ func Test_createRun(t *testing.T) {
BodyProvided: true,
Target: "",
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{
"url": "https://api.github.com/releases/123",
@ -464,9 +470,7 @@ func Test_createRun(t *testing.T) {
Target: "",
DiscussionCategory: "General",
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{
"url": "https://api.github.com/releases/123",
@ -496,9 +500,7 @@ func Test_createRun(t *testing.T) {
BodyProvided: true,
Target: "main",
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{
"url": "https://api.github.com/releases/123",
@ -527,9 +529,7 @@ func Test_createRun(t *testing.T) {
Draft: true,
Target: "",
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{
"url": "https://api.github.com/releases/123",
@ -558,9 +558,7 @@ func Test_createRun(t *testing.T) {
BodyProvided: true,
GenerateNotes: false,
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{
"url": "https://api.github.com/releases/123",
@ -589,9 +587,7 @@ func Test_createRun(t *testing.T) {
BodyProvided: true,
GenerateNotes: true,
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{
"url": "https://api.github.com/releases/123",
@ -621,9 +617,7 @@ func Test_createRun(t *testing.T) {
GenerateNotes: true,
NotesStartTag: "v1.1.0",
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases/generate-notes"),
httpmock.RESTPayload(200, `{
@ -664,9 +658,7 @@ func Test_createRun(t *testing.T) {
GenerateNotes: true,
NotesStartTag: "v1.1.0",
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases/generate-notes"),
httpmock.RESTPayload(200, `{
@ -715,9 +707,7 @@ func Test_createRun(t *testing.T) {
},
Concurrency: 1,
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("HEAD", "repos/OWNER/REPO/releases/tags/v1.2.3"), httpmock.StatusStringResponse(404, ``))
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{
@ -776,9 +766,7 @@ func Test_createRun(t *testing.T) {
},
Concurrency: 1,
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("HEAD", "repos/OWNER/REPO/releases/tags/v1.2.3"), httpmock.StatusStringResponse(404, ``))
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{
@ -838,9 +826,7 @@ func Test_createRun(t *testing.T) {
},
Concurrency: 1,
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("HEAD", "repos/OWNER/REPO/releases/tags/v1.2.3"), httpmock.StatusStringResponse(200, ``))
},
@ -868,9 +854,7 @@ func Test_createRun(t *testing.T) {
},
Concurrency: 1,
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("HEAD", "repos/OWNER/REPO/releases/tags/v1.2.3"), httpmock.StatusStringResponse(404, ``))
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.StatusStringResponse(201, `{
@ -905,9 +889,7 @@ func Test_createRun(t *testing.T) {
},
Concurrency: 1,
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("HEAD", "repos/OWNER/REPO/releases/tags/v1.2.3"), httpmock.StatusStringResponse(404, ``))
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.StatusStringResponse(201, `{
@ -943,9 +925,7 @@ func Test_createRun(t *testing.T) {
},
Concurrency: 1,
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("HEAD", "repos/OWNER/REPO/releases/tags/v1.2.3"), httpmock.StatusStringResponse(200, ``))
},
@ -974,9 +954,7 @@ func Test_createRun(t *testing.T) {
DiscussionCategory: "general",
Concurrency: 1,
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
runStubs: defaultRunStubs,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(httpmock.REST("HEAD", "repos/OWNER/REPO/releases/tags/v1.2.3"), httpmock.StatusStringResponse(404, ``))
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{
@ -1027,7 +1005,8 @@ func Test_createRun(t *testing.T) {
NotesFromTag: true,
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "some tag message")
rs.Register(contentCmd, 0, "some tag message")
rs.Register(signatureCmd, 0, "")
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
@ -1064,7 +1043,8 @@ func Test_createRun(t *testing.T) {
NotesFromTag: true,
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "some tag message")
rs.Register(contentCmd, 0, "some tag message")
rs.Register(signatureCmd, 0, "")
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
@ -1099,10 +1079,8 @@ func Test_createRun(t *testing.T) {
Assets: []*shared.AssetForUpload(nil),
NotesFromTag: true,
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "")
},
wantErr: "cannot generate release notes from tag v1.2.3 as it does not exist locally",
runStubs: defaultRunStubs,
wantErr: "cannot generate release notes from tag v1.2.3 as it does not exist locally",
},
}
for _, tt := range tests {
@ -1149,6 +1127,13 @@ func Test_createRun(t *testing.T) {
}
func Test_createRun_interactive(t *testing.T) {
const contentCmd = `git tag --list .* --format=%\(contents\)`
const signatureCmd = `git tag --list .* --format=%\(contents:signature\)`
defaultRunStubs := func(rs *run.CommandStubber) {
rs.Register(contentCmd, 1, "")
}
tests := []struct {
name string
httpStubs func(*httpmock.Registry)
@ -1185,9 +1170,7 @@ func Test_createRun_interactive(t *testing.T) {
return false, nil
})
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 1, "")
},
runStubs: defaultRunStubs,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/tags"), httpmock.StatusStringResponse(200, `[
{ "name": "v1.2.3" }, { "name": "v1.2.2" }, { "name": "v1.0.0" }, { "name": "v0.1.2" }
@ -1234,9 +1217,7 @@ func Test_createRun_interactive(t *testing.T) {
return false, nil
})
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 1, "")
},
runStubs: defaultRunStubs,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/tags"), httpmock.StatusStringResponse(200, `[
{ "name": "v1.2.2" }, { "name": "v1.0.0" }, { "name": "v0.1.2" }
@ -1283,9 +1264,7 @@ func Test_createRun_interactive(t *testing.T) {
return false, nil
})
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 1, "")
},
runStubs: defaultRunStubs,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/tags"), httpmock.StatusStringResponse(200, `[
{ "name": "v1.2.2" }, { "name": "v1.0.0" }, { "name": "v0.1.2" }
@ -1332,9 +1311,7 @@ func Test_createRun_interactive(t *testing.T) {
return false, nil
})
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 1, "")
},
runStubs: defaultRunStubs,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases/generate-notes"),
httpmock.StatusStringResponse(200, `{
@ -1381,7 +1358,7 @@ func Test_createRun_interactive(t *testing.T) {
})
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 1, "")
defaultRunStubs(rs)
rs.Register(`git describe --tags --abbrev=0 HEAD\^`, 0, "v1.2.2\n")
rs.Register(`git .+log .+v1\.2\.2\.\.HEAD$`, 0, "commit subject\n\ncommit body\n")
},
@ -1427,7 +1404,8 @@ func Test_createRun_interactive(t *testing.T) {
})
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "hello from annotated tag")
rs.Register(contentCmd, 0, "hello from annotated tag")
rs.Register(signatureCmd, 0, "")
rs.Register(`git describe --tags --abbrev=0 v1\.2\.3\^`, 1, "")
},
httpStubs: func(reg *httpmock.Registry) {
@ -1456,7 +1434,8 @@ func Test_createRun_interactive(t *testing.T) {
TagName: "v1.2.3",
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "tag exists")
rs.Register(contentCmd, 0, "tag exists")
rs.Register(signatureCmd, 0, "")
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.GraphQL("RepositoryFindRef"),
@ -1489,7 +1468,8 @@ func Test_createRun_interactive(t *testing.T) {
})
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "tag exists")
rs.Register(contentCmd, 0, "tag exists")
rs.Register(signatureCmd, 0, "")
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases/generate-notes"),
@ -1536,9 +1516,7 @@ func Test_createRun_interactive(t *testing.T) {
return false, nil
})
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 1, "")
},
runStubs: defaultRunStubs,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases/generate-notes"),
httpmock.RESTPayload(200, `{
@ -1591,7 +1569,7 @@ func Test_createRun_interactive(t *testing.T) {
})
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 1, "")
defaultRunStubs(rs)
rs.Register(`git .+log .+v1\.1\.0\.\.HEAD$`, 0, "commit subject\n\ncommit body\n")
},
httpStubs: func(reg *httpmock.Registry) {
@ -1639,7 +1617,8 @@ func Test_createRun_interactive(t *testing.T) {
})
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git tag --list`, 0, "tag exists")
rs.Register(contentCmd, 0, "tag exists")
rs.Register(signatureCmd, 0, "")
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.GraphQL("RepositoryFindRef"),
@ -1751,6 +1730,106 @@ func Test_createRun_interactive(t *testing.T) {
}
}
func Test_gitTagInfo(t *testing.T) {
const tagName = "foo"
const contentCmd = `git tag --list foo --format=%\(contents\)`
const signatureCmd = `git tag --list foo --format=%\(contents:signature\)`
tests := []struct {
name string
runStubs func(*run.CommandStubber)
wantErr string
wantResult string
}{
{
name: "no signature",
runStubs: func(cs *run.CommandStubber) {
cs.Register(contentCmd, 0, "some\nmultiline\ncontent")
cs.Register(signatureCmd, 0, "")
},
wantResult: "some\nmultiline\ncontent",
},
{
name: "with signature (PGP)",
runStubs: func(cs *run.CommandStubber) {
cs.Register(contentCmd, 0, "some\nmultiline\ncontent\n-----BEGIN PGP SIGNATURE-----\n\nfoo\n-----END PGP SIGNATURE-----")
cs.Register(signatureCmd, 0, "-----BEGIN PGP SIGNATURE-----\n\nfoo\n-----END PGP SIGNATURE-----")
},
wantResult: "some\nmultiline\ncontent",
},
{
name: "with signature (PGP, RFC1991)",
runStubs: func(cs *run.CommandStubber) {
cs.Register(contentCmd, 0, "some\nmultiline\ncontent\n-----BEGIN PGP MESSAGE-----\n\nfoo\n-----END PGP MESSAGE-----")
cs.Register(signatureCmd, 0, "-----BEGIN PGP MESSAGE-----\n\nfoo\n-----END PGP MESSAGE-----")
},
wantResult: "some\nmultiline\ncontent",
},
{
name: "with signature (SSH)",
runStubs: func(cs *run.CommandStubber) {
cs.Register(contentCmd, 0, "some\nmultiline\ncontent\n-----BEGIN SSH SIGNATURE-----\nfoo\n-----END SSH SIGNATURE-----")
cs.Register(signatureCmd, 0, "-----BEGIN SSH SIGNATURE-----\nfoo\n-----END SSH SIGNATURE-----")
},
wantResult: "some\nmultiline\ncontent",
},
{
name: "with signature (X.509)",
runStubs: func(cs *run.CommandStubber) {
cs.Register(contentCmd, 0, "some\nmultiline\ncontent\n-----BEGIN SIGNED MESSAGE-----\nfoo\n-----END SIGNED MESSAGE-----")
cs.Register(signatureCmd, 0, "-----BEGIN SIGNED MESSAGE-----\nfoo\n-----END SIGNED MESSAGE-----")
},
wantResult: "some\nmultiline\ncontent",
},
{
name: "with signature in content but not as true signature",
runStubs: func(cs *run.CommandStubber) {
cs.Register(contentCmd, 0, "some\nmultiline\ncontent\n-----BEGIN PGP SIGNATURE-----\n\nfoo\n-----END PGP SIGNATURE-----")
cs.Register(signatureCmd, 0, "")
},
wantResult: "some\nmultiline\ncontent\n-----BEGIN PGP SIGNATURE-----\n\nfoo\n-----END PGP SIGNATURE-----",
},
{
name: "error getting content",
runStubs: func(cs *run.CommandStubber) {
cs.Register(contentCmd, 1, "some error")
},
wantErr: fmt.Sprintf("failed to run git: %s exited with status 1", contentCmd),
},
{
name: "error getting signature",
runStubs: func(cs *run.CommandStubber) {
cs.Register(contentCmd, 0, "whatever")
cs.Register(signatureCmd, 1, "some error")
},
wantErr: fmt.Sprintf("failed to run git: %s exited with status 1", signatureCmd),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gitClient := &git.Client{GitPath: "some/path/git"}
rs, teardown := run.Stub()
defer teardown(t)
if tt.runStubs != nil {
tt.runStubs(rs)
}
result, err := gitTagInfo(gitClient, tagName)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantResult, result)
})
}
}
func boolPtr(b bool) *bool {
return &b
}

View file

@ -121,12 +121,11 @@ func setDefaultRun(opts *SetDefaultOptions) error {
if opts.ViewMode {
if currentDefaultRepo != nil {
fmt.Fprintln(opts.IO.Out, displayRemoteRepoName(currentDefaultRepo))
} else if opts.IO.IsStdoutTTY() {
fmt.Fprintln(opts.IO.Out, "no default repository has been set; use `gh repo set-default` to select one")
} else {
fmt.Fprintln(opts.IO.ErrOut, "no default repository has been set; use `gh repo set-default` to select one")
}
return nil
}
cs := opts.IO.ColorScheme()
if opts.UnsetMode {

View file

@ -135,6 +135,7 @@ func TestDefaultRun(t *testing.T) {
gitStubs func(*run.CommandStubber)
prompterStubs func(*prompter.PrompterMock)
wantStdout string
wantStderr string
wantErr bool
errMsg string
}{
@ -175,10 +176,11 @@ func TestDefaultRun(t *testing.T) {
Repo: repo1,
},
},
wantStdout: "no default repository has been set; use `gh repo set-default` to select one\n",
wantStderr: "no default repository has been set; use `gh repo set-default` to select one\n",
},
{
name: "view mode no current default",
tty: false,
opts: SetDefaultOptions{ViewMode: true},
remotes: []*context.Remote{
{
@ -186,6 +188,7 @@ func TestDefaultRun(t *testing.T) {
Repo: repo1,
},
},
wantStderr: "no default repository has been set; use `gh repo set-default` to select one\n",
},
{
name: "view mode with base resolved current default",
@ -466,7 +469,7 @@ func TestDefaultRun(t *testing.T) {
return &http.Client{Transport: reg}, nil
}
io, _, stdout, _ := iostreams.Test()
io, _, stdout, stderr := iostreams.Test()
io.SetStdinTTY(tt.tty)
io.SetStdoutTTY(tt.tty)
io.SetStderrTTY(tt.tty)
@ -498,7 +501,11 @@ func TestDefaultRun(t *testing.T) {
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantStdout, stdout.String())
if tt.wantStdout != "" {
assert.Equal(t, tt.wantStdout, stdout.String())
} else {
assert.Equal(t, tt.wantStderr, stderr.String())
}
})
}
}

View file

@ -45,13 +45,15 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
cmd := &cobra.Command{
Use: "view [<repository>]",
Short: "View a repository",
Long: `Display the description and the README of a GitHub repository.
Long: heredoc.Docf(`
Display the description and the README of a GitHub repository.
With no argument, the repository for the current directory is displayed.
With no argument, the repository for the current directory is displayed.
With '--web', open the repository in a web browser instead.
With %[1]s--web%[1]s, open the repository in a web browser instead.
With '--branch', view a specific branch of the repository.`,
With %[1]s--branch%[1]s, view a specific branch of the repository.
`, "`"),
Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
if len(args) > 0 {

View file

@ -42,13 +42,17 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr
cmd := &cobra.Command{
Use: "download [<run-id>]",
Short: "Download artifacts generated by a workflow run",
Long: heredoc.Doc(`
Long: heredoc.Docf(`
Download artifacts generated by a GitHub Actions workflow run.
The contents of each artifact will be extracted under separate directories based on
the artifact name. If only a single artifact is specified, it will be extracted into
the current directory.
`),
By default, this command downloads the latest artifact created and uploaded through
GitHub Actions. Because workflows can delete or overwrite artifacts, %[1]s<run-id>%[1]s
must be used to select an artifact from a specific workflow run.
`, "`"),
Args: cobra.MaximumNArgs(1),
Example: heredoc.Doc(`
# Download all artifacts generated by a workflow run