Visitar URL original
`gh issue artifact` stack 6/12: Add `download` by BagToad · Pull Request #14569 · cli/cli · GitHub
Skip to content

gh issue artifact stack 6/12: Add download - #14569

Open
BagToad wants to merge 2 commits into
bagtoad/artifact-deletefrom
bagtoad/artifact-download
Open

BagToad wants to merge 2 commits into
bagtoad/artifact-deletefrom
bagtoad/artifact-download

Conversation

@BagToad

@BagToad BagToad commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Part of the pull request stack tracked in #14563.

This adds gh issue artifact download from #14528.

Description

Issue artifacts are Markdown documents and links attached to an issue. The previous pull requests added the gh issue artifact command group, list, view and delete. This one adds download, which writes artifacts to files. Without numbers, it downloads every artifact on the issue:

$ gh issue artifact download 142
✓ 2-OAuth-callback-plan.md
✓ 3-Staging-OAuth-runbook.url
✓ 5-Barista-feedback-notes.md
  • Each file is named <number>-<name>. Documents end in .md, and links are saved as .url Internet 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. --clobber overwrites the file, and --skip-existing skips it without failing. gh never writes through a symlink, even with --clobber.

  • --dir chooses the directory. --output chooses the path for one artifact, and --output - prints its content with nothing else.

  • --version downloads 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. download has no source, so that column is empty:

    $ gh issue artifact download 142 --skip-existing | cat -t
    skipped^I2^I2-OAuth-callback-plan.md^I^Ialready exists
    written^I3^I3-Staging-OAuth-runbook.url^I^I
    written^I5^I5-Barista-feedback-notes.md^I^I
    
  • 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 as FindVersion, since --version follows the same rules in both commands.

How did you test this change?

I recorded the built gh against two issues in bagtoad/issue-artifacts-demo, each case in a new, empty directory and checked with ls or cat: every artifact, with the .url file's contents, selected numbers, the unusual names, --dir, an existing file failing and then --skip-existing and --clobber, --output to 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, so download takes 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, download gets each artifact in argument order, which also gives --version the edit history.

  • Files are written through safepaths. write first checks the path with Lstat, which doesn't follow a symlink, so an existing file or a symlink fails with the spec's message:

    if info, err := os.Lstat(path); err == nil {
    	switch {
    	case w.skipExisting:
    		// Anything already there is skipped, a symlink included
    		return true, nil
    	case info.Mode()&fs.ModeSymlink != 0:
    		// Even with --clobber
    		return false, errors.New("is a symlink")
    	...

    Then it writes the file through the safepaths package, which never follows a symlink at the path, even one that appears after the check. openDir opens the file's directory with OpenRootDir at 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 with Root.Create can mean the file already exists:

    root, err := w.openDir(path)
    if err != nil {
    	// Such as "mkdir notes: file exists" when --dir is a dangling symlink
    	return false, err
    }
    f, err := root.Create(filepath.Base(path), 0o644, 0o755, w.clobber)
    if errors.Is(err, fs.ErrExist) && !w.clobber {
    	// A single name makes no directories, so only the file itself can exist
    	...

    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, which safepaths refuses. It's never written through.

  • File names change only what would cause a problem. artifactFileName keeps 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 like CON from being a Windows device name. A name that already ends in .md, or .url for 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, like view'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 download run function, then write and openDir, which hold the file safety rules. TestDownloadRun runs 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 --dir or 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, like delete's Failed to delete artifact 9.
  • is not a regular file, for --clobber on a directory.
  • --output cannot be blank, like --worktree cannot be blank.

Related issues:

Authorship and follow-up

Who wrote this:

  • A human wrote it.
  • An agent wrote it under close human direction.
  • An agent wrote it independently, and no human has guided the implementation beyond the initial prompt.

Who answers review comments:

  • @BagToad will read and reply directly. Name the account.
  • An agent will draft replies and @BagToad will read them before they are posted.
  • Nobody has explicitly committed to replying.

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.
@BagToad
BagToad requested a review from a team as a code owner October 1, 2026 16:59
@BagToad
BagToad requested review from babakks and removed request for a team October 1, 2026 16:59
@BagToad
BagToad added this pull request to stack #14573 October 1, 2026 16:59
@BagToad BagToad changed the title gh issue artifact stack 6/10: Add download gh issue artifact stack 6/11: Add download Oct 2, 2026

@babakks babakks left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --output path currently follows a symlink to determine whether it is a directory. I think it should use Lstat and refuse that symlink, as repo read-file --output does, especially given the command's safety promise that it does not write through symlinks.
  • A blank --dir silently 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.

@babakks babakks Oct 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@babakks babakks Oct 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@babakks babakks Oct 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 .url files 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,
},
},

@babakks babakks Oct 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 babakks left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 == "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +104 to +113
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
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +119 to +124
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")
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines +183 to +185
if !downloadArtifact(opts, w, &client.ArtifactWithVersions{Artifact: a}) {
failed = true
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: This can be a single assignment while still ensuring every download runs, because the call stays on the left side:

Suggested change
if !downloadArtifact(opts, w, &client.ArtifactWithVersions{Artifact: a}) {
failed = true
}
failed = !downloadArtifact(opts, w, &client.ArtifactWithVersions{Artifact: a}) || failed

Comment on lines +195 to +197
if !downloadArtifact(opts, w, a) {
failed = true
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Same simplification here:

Suggested change
if !downloadArtifact(opts, w, a) {
failed = true
}
failed = !downloadArtifact(opts, w, a) || failed

Comment on lines +295 to +297
if cut := len(stem) - len(ext); cut >= 0 && strings.EqualFold(stem[cut:], ext) {
stem, ext = stem[:cut], stem[cut:]
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@BagToad BagToad changed the title gh issue artifact stack 6/11: Add download gh issue artifact stack 6/12: Add download Oct 9, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants