Repository navigation
Conversation
download --version follows the same rules as view --version, so both commands use one lookup.
Download writes each artifact to <number>-<name>.md, or .url for a link, never through a symlink. The shared result lines gain a skipped line for --skip-existing.
gh issue artifact stack 6/10: Add downloadgh issue artifact stack 6/11: Add download
babakks
left a comment
There was a problem hiding this comment.
Thanks for putting so much care into the filesystem safety and the extensive edge case coverage, @BagToad! 🙏 The behavior is thoughtfully designed and the tests made this large change straightforward to review.
I left two concerns that I think are worth addressing:
- The explicit
--outputpath currently follows a symlink to determine whether it is a directory. I think it should useLstatand refuse that symlink, asrepo read-file --outputdoes, especially given the command's safety promise that it does not write through symlinks. - A blank
--dirsilently becomes the current directory. I think it should be rejected with a flag error and covered by a constructor test.
I also left smaller consistency comments around positional argument errors, empty results, and a few optional future ideas. The cross-platform newline questions are just something I wanted to flag in case you have thoughts; otherwise, I think the current content-preserving behavior is reasonable.
| // don't support. | ||
| // client, its arguments, finding a version in an edit history, the result | ||
| // lines of commands that act on several artifacts and the checks that refuse | ||
| // what artifacts or this version of gh don't support. |
There was a problem hiding this comment.
nitpick: I don't think we need a package comment here. This inventory of shared responsibilities will keep changing as commands are added, and package comments are easy to miss when that happens. Could we remove it rather than maintain a description that may drift?
| } | ||
| } else { | ||
| for _, number := range opts.ArtifactNumbers { | ||
| a, err := c.Get(repo, opts.IssueNumber, number) |
There was a problem hiding this comment.
idea: Capturing this for the future, or now if you're interested: could the request strategy branch by artifact count? With no numbers or more than one number, use List; with exactly one number, use Get. The all-artifacts path already does this, and --version requires exactly one number, so multiple selected artifacts only need the latest bodies that List returns. We could still process the selected numbers in argument order and fall back to Get for any requested number absent from the list, preserving its API error. Since most issues will probably have fewer than 100 artifacts, multiple selected downloads could commonly take one API request. 🤔
| if !ok { | ||
| return nil, fmt.Errorf("artifact %d doesn't link to an http(s) URL", a.Number) | ||
| } | ||
| return []byte("[InternetShortcut]\r\nURL=" + u + "\r\n"), nil |
There was a problem hiding this comment.
I'm just flagging two cross-platform newline questions, @BagToad, in case you have thoughts here; otherwise I think we should proceed as is:
- Document content is persisted exactly as returned by the API, so no LF versus CRLF adaptation happens. This seems like the safest approach because it preserves the stored content, although some users may expect host-native line endings.
- Generated
.urlfiles use CRLF, which is appropriate for the Windows Internet Shortcut format. I'm not sure how consistently other operating systems and desktop environments handle this format and its line endings.
| "artifacts/3-Staging-OAuth-runbook.url": runbookShortcut, | ||
| "artifacts/5-Barista-feedback-notes.md": baristaBody, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
Could we add a neighboring test case that creates the --dir directory during setup and confirms the artifacts are written into the existing directory? This case covers creating a missing directory, but not reusing one that is already present.
babakks
left a comment
There was a problem hiding this comment.
I recreated the inline comments to fix their location.
| artifact fails unless you use %[1]s--clobber%[1]s to overwrite it or %[1]s--skip-existing%[1]s | ||
| to skip it. | ||
|
|
||
| Use %[1]s--output%[1]s to choose the path for a single artifact, or %[1]s--output -%[1]s to |
There was a problem hiding this comment.
nitpick: Could we also mention --dir here as the way to choose the destination directory when downloading multiple artifacts? That would make the single and multiple artifact output paths discoverable in the same paragraph.
| } | ||
| // A blank path would otherwise download every artifact under | ||
| // its usual name. | ||
| if outputSet && opts.Output == "" { |
There was a problem hiding this comment.
Could we add the equivalent validation for an explicitly blank --dir? It currently reaches safepaths.OpenRootDir(""), which treats it as ., so invalid input silently becomes output in the current directory instead of producing a flag error.
Could we also cover it with a constructor test like this?
{
name: "a blank --dir",
args: "142 2 --dir ''",
wantErr: "--dir cannot be blank",
wantFlagErr: true,
},| opts.IssueNumber, opts.BaseRepo, err = shared.ParseIssueArg(args[0], f.BaseRepo) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| opts.ArtifactNumbers = make([]int, 0, len(args)-1) | ||
| for _, arg := range args[1:] { | ||
| number, err := shared.ParseArtifactNumber(arg) | ||
| if err != nil { | ||
| return err | ||
| } |
There was a problem hiding this comment.
As noted on view and delete, the issue and artifact positional argument parsing errors should be wrapped with cmdutil.FlagErrorWrap so they are reported as command-line usage errors.
| if outputSet && len(opts.ArtifactNumbers) != 1 { | ||
| return cmdutil.FlagErrorf("--output requires exactly one artifact number") | ||
| } | ||
| if versionSet && len(opts.ArtifactNumbers) != 1 { | ||
| return cmdutil.FlagErrorf("--version requires exactly one artifact number") | ||
| } |
There was a problem hiding this comment.
idea: Capturing this for the future: I have a hunch that issues with a single artifact may be common, and in that case users might appreciate being able to omit the artifact number when using --output or --version, with the command selecting the only artifact. Definitely not suggesting it for this PR.
| } | ||
|
|
||
| cmd.Flags().StringVarP(&opts.Dir, "dir", "D", ".", "The `directory` to download files into") | ||
| cmd.Flags().StringVarP(&opts.Output, "output", "O", "", "The `file` to write a single artifact to (use \"-\" to write to standard output)") |
There was a problem hiding this comment.
I just noticed that repo read-file uses -o for --output, while release download uses -O, as this command does. I wish repo read-file followed the same convention. 😄
| return err | ||
| } | ||
| if len(artifacts) == 0 { | ||
| return fmt.Errorf("no artifacts to download from %s#%d", ghrepo.FullName(repo), opts.IssueNumber) |
There was a problem hiding this comment.
Should this return cmdutil.NewNoResultsError(...), like artifact list and other commands with valid empty results, so callers can distinguish an issue with no artifacts from an ordinary failure?
| if !downloadArtifact(opts, w, &client.ArtifactWithVersions{Artifact: a}) { | ||
| failed = true | ||
| } |
There was a problem hiding this comment.
nitpick: This can be a single assignment while still ensuring every download runs, because the call stays on the left side:
| if !downloadArtifact(opts, w, &client.ArtifactWithVersions{Artifact: a}) { | |
| failed = true | |
| } | |
| failed = !downloadArtifact(opts, w, &client.ArtifactWithVersions{Artifact: a}) || failed |
| if !downloadArtifact(opts, w, a) { | ||
| failed = true | ||
| } |
There was a problem hiding this comment.
nitpick: Same simplification here:
| if !downloadArtifact(opts, w, a) { | |
| failed = true | |
| } | |
| failed = !downloadArtifact(opts, w, a) || failed |
| if cut := len(stem) - len(ext); cut >= 0 && strings.EqualFold(stem[cut:], ext) { | ||
| stem, ext = stem[:cut], stem[cut:] | ||
| } |
There was a problem hiding this comment.
nitpick: A short comment explaining that this preserves an existing matching extension, including its casing, instead of appending a duplicate extension would make the intent easier to recognize.
| if strings.HasSuffix(w.output, "/") || strings.HasSuffix(w.output, string(filepath.Separator)) { | ||
| return filepath.Join(w.output, fileName) | ||
| } | ||
| if info, err := os.Stat(w.output); err == nil && info.IsDir() { |
There was a problem hiding this comment.
I think this should use os.Lstat and refuse a symlink at the explicit --output path instead of following it to decide that it is a directory. repo read-file --output takes that approach, while the current behavior makes --output linked write through linked when it is a symlink to a directory. That seems surprising for a command whose safety contract says it does not write through symlinks.
gh issue artifact stack 6/11: Add downloadgh issue artifact stack 6/12: Add download
Part of the pull request stack tracked in #14563.
This adds
gh issue artifact downloadfrom #14528.Description
Issue artifacts are Markdown documents and links attached to an issue. The previous pull requests added the
gh issue artifactcommand group,list,viewanddelete. This one addsdownload, which writes artifacts to files. Without numbers, it downloads every artifact on the issue:Each file is named
<number>-<name>. Documents end in.md, and links are saved as.urlInternet Shortcut files. The name changes only where it would cause a problem: separators, whitespace, control characters and the characters Windows forbids each become-, on every OS.An existing file fails, the other artifacts are still written, and gh exits 1.
--clobberoverwrites the file, and--skip-existingskips it without failing. gh never writes through a symlink, even with--clobber.--dirchooses the directory.--outputchooses the path for one artifact, and--output -prints its content with nothing else.--versiondownloads an earlier version of one artifact, named from that version's name.Piped, each artifact gets one line with the same five columns as
delete: status, number, path, source and reason.downloadhas no source, so that column is empty:Like the other commands, it refuses pull requests after one lookup and GitHub Enterprise Server before any artifact request. A type gh doesn't know fails for that artifact, and the rest are still downloaded.
The shared result lines gain the skipped line.
view's version lookup moves to the shared package asFindVersion, since--versionfollows the same rules in both commands.How did you test this change?
I recorded the built
ghagainst two issues in bagtoad/issue-artifacts-demo, each case in a new, empty directory and checked withlsorcat: every artifact, with the.urlfile's contents, selected numbers, the unusual names,--dir, an existing file failing and then--skip-existingand--clobber,--outputto a file and to standard output,--version, and piped output. It leaves out symlinks, the error cases, issue URL arguments, pull requests, GitHub Enterprise Server and types gh doesn't know.recording.mp4
Open chapters and agent notes
Key points
No artifact URLs yet. [Design]
gh issue artifact download#14528 also names artifacts by their URLs. The API doesn't return artifact URLs yet, as discussed in RFC:gh issue artifact#14529, sodownloadtakes an issue and numbers, and its help leaves URLs out. A later pull request in this stack will add them.Every artifact comes from the list. The list endpoint returns each artifact's body, so downloading every artifact requests pages of 100, like
list, and nothing else. With numbers,downloadgets each artifact in argument order, which also gives--versionthe edit history.Files are written through
safepaths.writefirst checks the path withLstat, which doesn't follow a symlink, so an existing file or a symlink fails with the spec's message:Then it writes the file through the
safepathspackage, which never follows a symlink at the path, even one that appears after the check.openDiropens the file's directory withOpenRootDirat the first write, so a download that writes nothing creates no directory. An error from opening it is returned as it is, and only an error from creating the file withRoot.Createcan mean the file already exists:A new file is created exclusively, so a file or symlink that appears after the check fails with the same "already exists" message, or is skipped with
--skip-existing. With--clobber, an existing regular file is truncated in place. A symlink that appears after the check is replaced, unless it points to a directory or outside the directory, whichsafepathsrefuses. It's never written through.File names change only what would cause a problem.
artifactFileNamekeeps case, dots and every language, and shortens a name at a character boundary only when the file name would pass 255 bytes. The number in front keeps a name likeCONfrom being a Windows device name. A name that already ends in.md, or.urlfor a link, in any case, doesn't get a second one.--output -prints only the body. It prints the body as it's stored, with no newline added, so a document matches the file--output <file>writes. With no result lines, its failures are ordinary errors, likeview's.Skipped lines go to stdout in a terminal. Successes go to stdout and failures to stderr, as in
delete. A skip isn't a failure, so its muted-line goes to stdout.Notes for reviewers
Start with the
downloadrun function, thenwriteandopenDir, which hold the file safety rules.TestDownloadRunruns each row in a temporary directory and compares everything left in it, symlinks included. It has rows for names older artifacts may have, a type gh doesn't know, a link that isn't an http(s) URL, a dangling symlink as--diror in--output's path, and changes to the path between the check and the write, such as a symlink to the checked file put in its place.A few messages aren't in #14528. Each follows the closest existing message:
X Failed to write 3-Staging-OAuth-runbook.url: artifact 3 doesn't link to an http(s) URL, for a stored link gh won't write as a shortcut.X Failed to download artifact 9: Not Found, for a failure before there's a file, likedelete'sFailed to delete artifact 9.is not a regular file, for--clobberon a directory.--output cannot be blank, like--worktree cannot be blank.Related issues:
gh issue artifact download#14528 is thedownloaddesign, 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: