Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
144
planned
1,362
shipped

Motir · Work items

MOTIR-272Done

5.2 Attachments

First-class attachments: the managed, per-issue attachment panel on the detail page — strip of thumbnail cards / list toggle, upload (button + drag-drop), download, permission-gated delete, and an image/PDF preview — reusing the 2.3.7 upload primitive wholesale (finding #52's contract): attachmentsService.uploadAttachment, POST /api/upload/issue-attachment, the shared lib/blob/allowlist.ts (10 MB + MIME set + rate limit), and the attachment table. 2.3.7's rows are deliberately work_item-unlinked (audit only); this story adds the attachment.workItemId link + the management surface. The uploader is NOT rebuilt.

The Jira-verified shape (rung 1, checked at plan time). The panel offers strip (thumbnail cards — image preview or file-type glyph, name, date) and list (adds size) views with a count. Embeds ARE attachments: a file uploaded through the description or comment editor shows in the panel too, marked with its source — and the panel blocks deleting editor-sourced files (pointing at the source field instead), which is both the Jira rule and what prevents Jira's own broken-embed hole (deleting under an embed — closed Won't-Fix there). Permissions are Jira's three, mapped onto the 6.4 roles exactly like 5.1's comments: admin/member (who can view) create; the uploader deletes own; project admin + workspace admin/owner delete all; read-only viewer neither. Delete is permanent (no trash), removes the blob as well as the row, and the issue History records attachment added/removed (we record editor-added files too — Jira documents that gap as a known changelog hole; recording uniformly is the obviously-correct fill, not a deviation).

Scale (finding #57). Jira loads the whole attachment set, force-degrades to list view over 150, and hard-caps at 2,000 per item — load-all-then-degrade. We deviate to the house discipline: a cursor-paged read (take 50) + "Show more (N)" in the panel (justification: strictly bounded reads everywhere; Jira's own cap+degrade is the same pressure handled worse). The upload-count cap is not replicated (no use case at team scale; the page size bounds the read regardless).

Lifecycle (the part 2.3.7 deferred). Editor uploads at create-modal time happen before the issue exists, so rows start unlinked; a cancelled modal strands them; and no delete path exists at all today. This story owns the whole lifecycle: link-on-write — on every description/explanation (and 5.1 comment-body) write, parse the referenced blob URLs and link those rows to the issue as source: 'editor', unlinking rows no longer referenced — delete with blob cleanup (row in the tx, blob best-effort after commit with the GC as backstop), and the orphan-GC job on the 1.6 harness (unlinked rows older than a safety window → blob + row removed; also the backstop for blobs whose delete-after-commit failed). Issue deletion sets workItemId null (SetNull, NOT cascade — a cascade would vaporise rows and strand their blobs invisibly; the GC sweeps the nulled rows instead).

Access-control decision (recorded honestly). Jira serves attachments authenticated. Our shipped storage layer (rung 2) is Vercel Blob access: 'public' + addRandomSuffix — public-but-unguessable URLs, the exact shape Jira's own JSM offers as its "unguessable links" mode. Kept for this story: the markdown-embed render path depends on direct URLs and the storage layer has no private serving. The management API (list/link/delete) is fully workspace- and role-gated. An authed download proxy / private storage is the named Epic-8 hardening extension, not silently dropped.

Completeness — the real-product states. Uploading (per-file progress + the multi-file queue), upload errors inline (413 / 415 / 429 — the typed errors 2.3.7 already returns), empty ("No attachments yet" + the affordance), loading skeleton, ErrorState, the editor-sourced indicator + disabled-delete tooltip, the delete confirm ("can't be restored" — the hard-delete truth), viewer read-only (no upload/delete affordances), and drag-over highlight on the dropzone. All drawn by 5.2.4, asserted in 5.2.8.

Out of scope (documented extension slots, each justified): authed/private attachment serving (Epic-8 hardening — storage-layer dependent); a dedicated attach field in the create modal (files already attach at create via the editor path + link-on-write; a dropzone there is additive UI with no new capability); "Download all" as ZIP (needs a server zipper; no use case at team scale yet); in-preview prev/next navigation (unverified in the mirror itself); Jira's grid view (strip + list is the documented primary pair); per-project storage quotas + admin-configurable size limits (Epic 6/8 admin); comment-attachment coupling beyond source-blocking (the eye-icon jump-to-comment is additive). Custom-field file types are Story 5.3 territory.

Verification

  • Pull the Story branch, pnpm install, pnpm prisma migrate dev (applies the 5.2.1 workItemId/source migration cleanly; re-run reports "No difference detected" — relations modelled on both sides), pnpm db:seed, pnpm dev. BLOB_READ_WRITE_TOKEN is already provisioned (2.3, done).
  • pnpm test:coverage — Vitest (real Postgres) over the attachment service extensions (link/list/delete/permissions), the link-on-write parser, and the GC job stays ≥90% per-file branch/fn/line (the coverage gate); new repo methods carry direct empty-input-guard tests.
  • Panel flow: sign in as zhuyue@motir.co / !QAZ1qaz, open an issue → the Attachments panel (matching design/work-items/attachments.mock.html). Upload via the Attach button AND by dragging a file onto the panel → cards appear (image thumbnail / file-type glyph, name, date; list view adds size); download works; the count updates.
  • Embeds-are-attachments: paste an image into the issue description (edit form) and save → the file appears in the panel marked editor-sourced, and its panel delete is disabled with the points-at-source tooltip; remove the embed from the description and save → the row unlinks (GC-eligible), the panel no longer lists it.
  • Permissions: as the uploader (member) — delete own works (confirm warns "can't be restored"; blob + row gone); as another member — no delete affordance on it; as project admin — delete-any works; as a project viewer — panel visible, no upload or delete affordances. The revision trail records each add/remove.
  • Preview: click an image card → the full-screen preview opens (download affordance inside); a PDF previews; a .zip card downloads instead of previewing.
  • Scale check (finding #57): seed an issue with 120+ attachments (the 5.2.8 fixture) → first paint shows 50 + "Show more (N)"; extending appends; the read is cursor-paged, never the full set.
  • Lifecycle/GC: upload in the create modal, CANCEL the modal → the row is unlinked; run the GC job (Inngest dev) past the safety window → blob + row removed; a failed-blob-delete simulation is swept on the next GC pass.
  • pnpm test:e2e --grep attachments — Playwright over the real stack: upload → panel → preview → delete journey, the editor-sourced block, the viewer pass, the paged walk.
  • a11y check: the panel passes the strict axe sweep (cards/list keyboard-reachable, upload affordance labelled, preview modal focus-trapped with Esc/return-focus, source/state conveyed as text not colour alone); colour via --el-*, shape via element shape tokens.