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--uniquecould not skip anything.work-items --group --attach: uploads are project-scoped, and there is no group-level upload endpoint. client-go'sGroupMarkdownUploadsServiceexposes 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/uploadunit tests at 96.2% coverage, including content sniffing, the.jfifcase, ordering, partial-failure messaging, and stdin not being consumed by the sniff.- Command-level tests through
cmdtest.SetupCmdForTestwithgitlabtestingmocks: 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 fulllefthook run pre-pushpass.
Live verification
The three comments below were posted by this branch's binary against this merge request, covering each branch of the feature:
- File path — body became the message, a blank line, then
. Downloading the upload returns a byte-identical PNG. --attach -with no--message— no editor opened, and the sniffed filename made the body, so it renders inline.- Non-image file — body became
[notes.txt](/uploads/.../notes.txt), a plain link with no!, confirming GitLab'smarkdownfield handles the distinction and the code needs no content-type branching.