Repository navigation
Conversation
Update saves a new version of one artifact. Its PATCH request sends only the name or body it changes, since the API keeps any field a request leaves out. An artifact's type can't change.
gh issue artifact edit needs the uploader create builds: the one issue lookup that refuses a pull request and returns the repository ID and permission an upload needs, then the checks attachments.NewUploader makes. shared.NewUploader now builds it for both.
edit renames an artifact with --name, replaces its content with --body or --body-file, or both, and saves a new version. --body-file - is the only way standard input is read, and a file never renames the artifact. The type never changes, so it is enforced before anything is uploaded or saved. A link's new content must be an http(s) URL: a .url file is read as an Internet Shortcut, and anything else must be a bare URL, with its surrounding whitespace trimmed. A document never takes a .url file, and a link can't take --attach. Types gh doesn't know are refused. --attach on its own appends the files to the current body, like gh issue comment --edit-last --attach. With new content, it points the content's references at the uploads. The artifact is saved when any file uploaded, since uploads can't be undone, and the upload error is the reason on its line. edit reports its artifact on one result line in the format create, delete and download share. A failed lookup or save is a failed line, like in delete.
gh issue artifact stack 8/10: Add editgh issue artifact stack 8/11: Add edit
babakks
left a comment
There was a problem hiding this comment.
Thanks for another thoughtful addition to the stack, @BagToad! 🎉
The edit flow handles the different artifact types and attachment behavior clearly. My main feedback is around user-facing error handling: consistently classifying invalid positional and attachment inputs as usage errors, and ensuring preparation failures do not imply that an API update was attempted. The remaining comments are smaller consistency and wording suggestions.
| fields := map[string]string{} | ||
| if name != nil { | ||
| fields["name"] = *name | ||
| } | ||
| if body != nil { | ||
| fields["body"] = *body | ||
| } |
There was a problem hiding this comment.
Let's keep this consistent with the create method and use an ad hoc struct with explicit JSON fields. Keeping the fields as pointers with omitempty preserves the distinction between an omitted field and an explicitly empty value. I verified that nil pointers are omitted while nonnil pointers to empty strings are encoded as "name": "" or "body": "".
| fields := map[string]string{} | |
| if name != nil { | |
| fields["name"] = *name | |
| } | |
| if body != nil { | |
| fields["body"] = *body | |
| } | |
| fields := struct { | |
| Name *string `json:"name,omitempty"` | |
| Body *string `json:"body,omitempty"` | |
| }{ | |
| Name: name, | |
| Body: body, | |
| } |
| # Point a link at a new URL | ||
| $ gh issue artifact edit 142 3 --body https://github.com/monalisa/monas-cafe/wiki/OAuth-runbook-v2 | ||
|
|
||
| # Append a screenshot to a document |
There was a problem hiding this comment.
nitpick: Let's say "image" rather than "screenshot" here.
| opts.IssueNumber, opts.BaseRepo, err = shared.ParseIssueArg(args[0], f.BaseRepo) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| opts.ArtifactNumber, err = shared.ParseArtifactNumber(args[1]) | ||
| if err != nil { | ||
| return err | ||
| } |
There was a problem hiding this comment.
Both errors come from parsing positional command arguments, so they should be classified as usage errors with cmdutil.FlagErrorWrap, consistent with the other artifact commands.
| opts.IssueNumber, opts.BaseRepo, err = shared.ParseIssueArg(args[0], f.BaseRepo) | |
| if err != nil { | |
| return err | |
| } | |
| opts.ArtifactNumber, err = shared.ParseArtifactNumber(args[1]) | |
| if err != nil { | |
| return err | |
| } | |
| opts.IssueNumber, opts.BaseRepo, err = shared.ParseIssueArg(args[0], f.BaseRepo) | |
| if err != nil { | |
| return cmdutil.FlagErrorWrap(err) | |
| } | |
| opts.ArtifactNumber, err = shared.ParseArtifactNumber(args[1]) | |
| if err != nil { | |
| return cmdutil.FlagErrorWrap(err) | |
| } |
| case opts.Name != nil && *opts.Name == "": | ||
| return cmdutil.FlagErrorf("--name cannot be blank") | ||
| case opts.Body != nil && strings.TrimSpace(*opts.Body) == "": | ||
| return cmdutil.FlagErrorf("--body cannot be blank") |
There was a problem hiding this comment.
Let's format the flag names with backticks for consistency with the other commands in this command set.
| case opts.Name != nil && *opts.Name == "": | |
| return cmdutil.FlagErrorf("--name cannot be blank") | |
| case opts.Body != nil && strings.TrimSpace(*opts.Body) == "": | |
| return cmdutil.FlagErrorf("--body cannot be blank") | |
| case opts.Name != nil && *opts.Name == "": | |
| return cmdutil.FlagErrorf("`--name` cannot be blank") | |
| case opts.Body != nil && strings.TrimSpace(*opts.Body) == "": | |
| return cmdutil.FlagErrorf("`--body` cannot be blank") |
| opts.Assets, err = opts.AttachFlag.UserAssets() | ||
| if err != nil { | ||
| return err | ||
| } |
There was a problem hiding this comment.
UserAssets validates values supplied through --attach, so its errors should be classified as usage errors. The create command introduced in the previous PR has the same unwrapped return and should be updated too.
| opts.Assets, err = opts.AttachFlag.UserAssets() | |
| if err != nil { | |
| return err | |
| } | |
| opts.Assets, err = opts.AttachFlag.UserAssets() | |
| if err != nil { | |
| return cmdutil.FlagErrorWrap(err) | |
| } |
| if source == "-" { | ||
| source = "standard input" | ||
| } | ||
| return nil, fmt.Errorf("failed to update artifact %d from %s: %w", opts.ArtifactNumber, source, err) |
There was a problem hiding this comment.
"Failed to update artifact" can be misleading because no update request has happened at this stage. Could we identify the content read operation that actually failed instead?
| return nil, fmt.Errorf("failed to update artifact %d from %s: %w", opts.ArtifactNumber, source, err) | |
| return nil, fmt.Errorf("failed to read new content for artifact %d from %s: %w", opts.ArtifactNumber, source, err) |
| } | ||
|
|
||
| if len(opts.Assets) > 0 { | ||
| return nil, fmt.Errorf("artifact %d is a link; --attach can't be used with link artifacts", a.Number) |
There was a problem hiding this comment.
nitpick: Let's format the flag name with backticks for consistency.
| return nil, fmt.Errorf("artifact %d is a link; --attach can't be used with link artifacts", a.Number) | |
| return nil, fmt.Errorf("artifact %d is a link; `--attach` can't be used with link artifacts", a.Number) |
| } | ||
| u, ok := shared.HTTPURL(*content) | ||
| if !ok { | ||
| return nil, fmt.Errorf("artifact %d is a link; its new content must be an http(s) URL or a .url file", a.Number) |
There was a problem hiding this comment.
Could we make this actionable like the shortcut error above by naming both accepted input forms?
| return nil, fmt.Errorf("artifact %d is a link; its new content must be an http(s) URL or a .url file", a.Number) | |
| return nil, fmt.Errorf("artifact %d is a link; pass an http(s) URL with `--body` or a .url file with `--body-file`", a.Number) |
gh issue artifact stack 8/11: Add editgh issue artifact stack 8/12: Add edit
Part of the pull request stack tracked in #14563.
This adds
gh issue artifact editfrom #14526.Description
Issue artifacts are Markdown documents and links attached to an issue. The previous pull requests added the
gh issue artifactcommand group,list,view,delete,downloadandcreate. This one addsedit. It renames an artifact or replaces its content, and each edit saves a new version in the artifact's edit history:--namerenames the artifact.--bodyor--body-filereplaces its content, and--body-file -reads standard input. A file never renames the artifact..urlInternet Shortcut file or as the whole content, with surrounding whitespace trimmed. A document never takes a.urlfile.--attachuploads images and videos and points the new content's references at them, asgh issue editdoes. On its own, it appends them to the current content, likegh issue comment --edit-last --attach. A link can't take--attach.view.create,deleteanddownload, with the statusupdated. The source column is the--body-filepath, or-for standard input.Flags and the new content are checked before any request, so a missing, binary or empty file fails without one. The checks that depend on the type run after the artifact's lookup, before anything is uploaded or saved.
The API client gains
Update. It sends only the fields that change, so--nameon its own leaves the content alone.create's--attachuploader moves to the shared package asNewUploader, unchanged, so both commands build it the same way.How did you test this change?
I recorded the built
ghediting two artifacts on one issue in bagtoad/issue-artifacts-demo, checking each edit withgh issue artifact view, whose edit history grows by one version with each save: a rename, new content from a file and from standard input, a link's new URL from--bodyand from a.urlfile,--attachreplacing a reference and appending an image on its own, the three type errors, nothing to change, and piped output. It leaves out failed uploads and saves, a type gh doesn't know, pull requests and GitHub Enterprise Server.recording.mp4
Open chapters and agent notes
Key points
A link's new content is checked before anything is saved.
newBodyworks out what to save from the artifact's type. For a link, a.urlpath is read as a shortcut, and anything else must be a bare URL:HTTPURLis the strict checkviewanddownloadalready use, soeditcan't save a link thatdownloadwould refuse to write as a shortcut.--attachon its own starts from the current content. With no--bodyor--body-file, the files are attached to the body the artifact's lookup returned, and references in it resolve against the working directory.An upload can't be undone, so an artifact is saved if any of its files uploaded. This is the
--attachrulecreatefollows for each document:An artifact saved with only some of its files keeps the status
updated, with the upload error as its reason, through the same line ascreate. In a terminal, gh prints this on stderr and exits 1:A failed lookup or save is a result line. An artifact that doesn't exist gives
X Failed to update artifact 9: Not Found, likedelete, and piped, afailedline. Mistakes in what was asked stay plain errors, as [Design]gh issue artifact edit#14526 shows for the type errors, and so does a type gh doesn't know, as inview.No artifact URLs yet. [Design]
gh issue artifact edit#14526 also names an artifact by its URL. The API doesn't return artifact URLs yet, as discussed in RFC:gh issue artifact#14529, soedittakes an issue and a number, and its help leaves URLs out. A later pull request in this stack will add them.--attachrecords the same telemetry event as the other--attachcommands.Notes for reviewers
Start with the
editrun function, thennewBodyafter it.TestEditRunruns each row in a temporary directory, with the client mock for artifact requests and HTTP stubs for uploads. It has rows for failed and partial uploads, failed lookups and saves, and a type gh doesn't know, which the recording can't show.These messages aren't in #14526:
--name cannot be blankand--body cannot be blank, as increate.failed to update artifact 2 from missing.md: no such file or directory, and the same form for a binary or empty file and for standard input.broken.url: no [InternetShortcut] section; pass the URL with `--body` instead, for a link's.urlfile that isn't a shortcut. The other.urlerrors matchcreate's.X Failed to update artifact 9: Not Found, for an artifact that doesn't exist.Related issues:
gh issue artifact edit#14526 is theeditdesign, with a screen for each scenario.gh issue artifact#14529 is the RFC for the command group. Its discussion covers artifact URLs.Authorship and follow-up
Who wrote this:
Who answers review comments: