feat(attach): upload and reference local files with --attach

Closes #1040 (closed)

What

Adds an experimental --attach flag that uploads a local file to the project and references it at the end of the markdown body. Wired into every command that writes a markdown body:

Command Notes
issue create
issue update Appends to the existing description without --description
issue note / incident note Shared implementation
mr create Uploads to the target project
mr update Appends to the existing body without --description
mr note create Mutually exclusive with --unique
mr note update Appends to the existing note body without --message
work-items create / update Mutually exclusive with --group
# Attach a file
glab issue note 123 --message "Here is the repro." --attach ./screenshot.png

# Attach an image piped from the clipboard
pngpaste - | glab issue note 123 --attach -

An attachment counts as content, so a comment or description consisting only of --attach publishes without opening an editor.

No new dependencies. The upload uses ProjectMarkdownUploads.UploadProjectMarkdown, already used by glab release upload, and takes GitLab's returned markdown field verbatim rather than building a reference string, so images and non-images need no special casing.

Design decisions worth review

Uploads go to the project that renders the body. For mr create that is the target project, not the head repo the merge request is created against. An upload reference only resolves against the project rendering it, so getting this wrong yields a broken image on cross-fork merge requests.

--attach - sniffs the content type to pick an extension. Standard input carries no filename, and GitLab decides between an inline image and a plain download link by extension. Without sniffing, a piped screenshot renders as a link, which defeats the point. mime.ExtensionsByType is unusable here: it returns .jfif first for image/jpeg, so there is a small explicit map of the four types http.DetectContentType recognizes.

Two combinations are rejected rather than silently degraded.

  • mr note create --unique --attach: each upload gets a fresh URL, so an attached note can never match an existing one and --unique could not skip anything.
  • work-items --group --attach: uploads are project-scoped, and there is no group-level upload endpoint. client-go's GroupMarkdownUploadsService exposes List, Download, and Delete but no upload.

Progress goes to stderr. Stdout carries the note or issue URL, which people pipe.

Known limitation

If an upload succeeds and the create or update call then fails, the upload is left unreferenced. There is no rollback: per the markdown uploads API, uploading has no role requirement but every delete endpoint requires the Maintainer or Owner role, so a cleanup call would 403 for exactly the users most likely to hit a failed write. Instead, when one of several uploads fails, the error lists the references that did succeed so a retry can reuse them. An unreferenced upload is inert and a Maintainer can prune it.

Why experimental

The flag is unconventional next to the rest of the flag surface: it performs a network write during flag resolution rather than only shaping a request. Marking it (EXPERIMENTAL) leaves room to change the placement or naming once it sees real use.

Testing

  • internal/upload unit tests at 96.2% coverage, including content sniffing, the .jfif case, ordering, partial-failure messaging, and stdin not being consumed by the sniff.
  • Command-level tests through cmdtest.SetupCmdForTest with gitlabtesting mocks: reference appended to a message, attachment-only note, multiple attachments in order, stdin naming, and a missing file uploading nothing.
  • make test (4795 tests) and full lefthook run pre-push pass.

Live verification

The three comments below were posted by this branch's binary against this merge request, covering each branch of the feature:

  1. File path — body became the message, a blank line, then ![glab-attach-test](/uploads/.../glab-attach-test.png). Downloading the upload returns a byte-identical PNG.
  2. --attach - with no --message — no editor opened, and the sniffed filename made the body ![upload](/uploads/.../upload.png), so it renders inline.
  3. Non-image file — body became [notes.txt](/uploads/.../notes.txt), a plain link with no !, confirming GitLab's markdown field handles the distinction and the code needs no content-type branching.
Edited by Kai Armstrong

Merge request reports

Loading
Loading