From f77a24aec74604735f2d28f6997894712e84329a Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Mon, 13 Jul 2026 22:14:50 -0400 Subject: [PATCH 01/10] docs: spec for uploads put auto gh.* metadata --- ...-13-uploads-put-auto-gh-metadata-design.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-uploads-put-auto-gh-metadata-design.md diff --git a/docs/superpowers/specs/2026-07-13-uploads-put-auto-gh-metadata-design.md b/docs/superpowers/specs/2026-07-13-uploads-put-auto-gh-metadata-design.md new file mode 100644 index 0000000..21672cd --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-uploads-put-auto-gh-metadata-design.md @@ -0,0 +1,105 @@ +# `uploads put` auto `gh.*` metadata — design + +Date: 2026-07-13 +Related: buildinternet/uploads#159 (per-file metadata follow-ups); motivated by the +`buildinternet` workspace sweep, where 65 `screenshots/` objects had lost their +PR/issue linkage because they were hosted via the default `put` path, which +captures no `gh.*` metadata. + +## Problem + +`uploads put` has two gaps around GitHub context: + +1. **Default path captures nothing.** A plain `uploads put shot.png` + (`--repo`/`--ref` → `screenshots///…` key) writes no `gh.*` + metadata, so the `/f/` file page shows no "Attached to" row even when the + shot was taken while working on a PR. This is how the `github-screenshots` + skill's default flow uploads, so every such screenshot lands context-less. +2. **`put --pr`/`--issue` writes a `gh/` key but no `gh.*` metadata.** + `runAttach` stamps the four `gh.*` pairs from the resolved target + (`commands.ts` ~L401); `runPut` does not merge them, so the stable + attachment key exists without the queryable metadata that `attach` produces. + +The API and CLI already support everything needed: `put` accepts repeatable +`--meta k=v`, the D1 metadata tier stores `gh.*`, and the public `/f/` page +derives an "Attached to" link from `gh.repo`/`gh.kind`/`gh.number`. The only +gap is that `runPut` never derives or attaches those pairs. + +## Goal + +One uniform rule: **whenever `put` has a GitHub target — explicit or +auto-resolved — it stamps the four `gh.*` pairs into the object's metadata.** +The change is entirely in `packages/uploads`; the `github-screenshots` skill and +MCP surface are untouched and inherit the behavior through `uploads put`. + +## Behavior + +In `runPut`, after `--meta` is parsed into `metadata`: + +- **Explicit target** (`--pr`/`--issue` present): merge + `ghMetadataFromTarget(ghTarget)` into `metadata` (gap #2 fix). Key layout + unchanged (`gh/…` as today). Target pairs win over a same-key `--meta` + extra, matching `runAttach`. +- **Auto target** (no `--pr`/`--issue`, auto enabled): resolve a target and + merge its `gh.*` pairs; **key layout stays `screenshots///…`** + (auto never rewrites the key — only explicit attach produces `gh/` keys). + Here explicit `--meta` wins over the auto-derived pairs (auto is inferred, + yields to anything the user typed). + +Auto resolution picks its target in this order: + +1. `--ref` is a positive integer → classify it once via + `gh api repos///issues/` (`pull_request` field present → + `pull`, else `issue`); number = that ref. New helper `classifyGhNumber`. +2. otherwise → `resolveCurrentPullRequest(repo, run)` (existing helper: + `gh pr view` on the current branch → PR). + +Auto resolution is **best-effort and never fails the upload**: missing `gh`, +no PR for the branch, an API error, or an unresolvable repo → skip the metadata +and upload normally (one stderr note in human format, like `--comment`). + +## Control surface + +On by default. Precedence (first match wins): + +- `--auto` flag → force on (overrides repo config). +- `--no-auto` flag → off for this run. +- `UPLOADS_NO_AUTO_META=1` (env or `.uploads` config-file key) → off at + repo/user level. +- default → on. + +`--no-git` (or no resolvable repo) also yields no auto target, since there's +nothing to resolve. Explicit `--pr`/`--issue` short-circuits auto resolution +(the flag is the target). + +## Code changes + +1. **`github-gh.ts`** — add `classifyGhNumber(repo, num, run): GhTarget | undefined` + (returns `undefined` on any failure; caller skips). Reuse existing + `resolveCurrentPullRequest`. +2. **`config-file.ts`** — add `UPLOADS_NO_AUTO_META` to `UPLOADS_CONFIG_KEYS` + and parse into `PutDefaults.noAutoMeta` in both the raw and env parsers, + mirroring `UPLOADS_NO_GIT`. +3. **`commands.ts` `runPut`** — merge `gh.*` for the explicit-target path (gap + #2); add the auto path; parse `--auto`/`--no-auto`; update `PUT_HELP`. +4. **Tests** — `commands` suite, fake `CommandRunner` (the `run` seam attach + tests already use). Cases: explicit `--pr` stamps `gh.*`; numeric `--ref` + classified pull vs issue; branch → resolved PR; no-PR → no metadata + + success (exit 0); `--no-auto`/`UPLOADS_NO_AUTO_META` suppress; explicit + `--meta` wins on the auto path; explicit target wins on the `--pr` path. +5. **Docs** — refresh the `put` help/CLI notes to describe auto behavior and the + `--no-auto` / `UPLOADS_NO_AUTO_META` opt-out. Add a changeset. + +## Out of scope + +- No key-layout change; the default path stays `screenshots/…`. +- No data migration — the existing `buildinternet` objects were already + backfilled by hand (49/65; the remainder have no resolvable PR). +- MCP `put` tool arguments unchanged. +- The `github-screenshots` skill is not modified. + +## Verification + +`pnpm test` + `pnpm lint` in `packages/uploads`; plus a live-free +`uploads put … --dry-run --format json` showing the derived `gh.*` in the +metadata preview (fake or real `gh`). From 1a680382326ba452755473cec8d3bb099b89bc4c Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Mon, 13 Jul 2026 22:40:50 -0400 Subject: [PATCH 02/10] docs: implementation plan for uploads put auto gh.* metadata --- ...2026-07-13-uploads-put-auto-gh-metadata.md | 553 ++++++++++++++++++ 1 file changed, 553 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-uploads-put-auto-gh-metadata.md diff --git a/docs/superpowers/plans/2026-07-13-uploads-put-auto-gh-metadata.md b/docs/superpowers/plans/2026-07-13-uploads-put-auto-gh-metadata.md new file mode 100644 index 0000000..92f581d --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-uploads-put-auto-gh-metadata.md @@ -0,0 +1,553 @@ +# `uploads put` auto `gh.*` metadata — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `uploads put` stamp the four `gh.*` metadata pairs whenever it has a GitHub target — explicit (`--pr`/`--issue`) or auto-resolved from git — so `/f/` file pages show "Attached to". + +**Architecture:** All changes are in `packages/uploads`. Reuse the existing `resolveCurrentPullRequest` (branch→PR) and `ghMetadataFromTarget` (target→4 pairs) helpers; add one `classifyGhNumber` helper (numeric ref → pull/issue) and one `resolveAutoGhTarget` best-effort wrapper. `runPut` gains a single metadata-merge block. Auto resolution is on by default and disabled by `--no-auto`, `--no-git`, or `UPLOADS_NO_AUTO_META`. + +**Tech Stack:** TypeScript (ESM, `.js` import specifiers), Vitest, `gh` CLI (invoked through the injectable `CommandRunner` seam). + +## Global Constraints + +- Package: `@buildinternet/uploads`. Run tests with `pnpm --filter @buildinternet/uploads test`; typecheck with `pnpm --filter @buildinternet/uploads typecheck`. +- Node `>=22`. ESM: every relative import ends in `.js`. +- `GhTarget.kind` is the URL-segment spelling `"pull" | "issues"`. `ghMetadataFromTarget` maps `"issues"` → `gh.kind: "issue"` and lowercases `gh.repo`/`gh.ref`. Do not re-derive these mappings — always go through `ghMetadataFromTarget`. +- Auto resolution is **best-effort and must never fail the upload**: any thrown error → treat as "no gh context" and upload without metadata. +- The default key layout (`screenshots///…`) is **unchanged**; auto only adds metadata. Only explicit `--pr`/`--issue` produces `gh/` keys (existing behavior). +- Precedence: on the **explicit** `--pr`/`--issue` path, target pairs win over a same-key `--meta` extra (matches `runAttach`). On the **auto** path, explicit `--meta` wins over auto-derived pairs. + +--- + +### Task 1: `classifyGhNumber` helper + +**Files:** + +- Modify: `packages/uploads/src/github-gh.ts` (add function after `resolveCurrentPullRequest`, ends ~line 72) +- Test: `packages/uploads/test/github-gh.test.ts` + +**Interfaces:** + +- Consumes: existing `CommandRunner`, `execRunner`, and `type GhTarget` (already imported in `github-gh.ts`). +- Produces: `classifyGhNumber(repo: string, num: number, run?: CommandRunner): GhTarget | undefined` — returns a target with `kind: "pull" | "issues"`, or `undefined` on any failure. + +- [ ] **Step 1: Write the failing test** + +Append to `packages/uploads/test/github-gh.test.ts` (add `classifyGhNumber` to the existing `../src/github-gh.js` import): + +```ts +describe("classifyGhNumber", () => { + it("classifies a pull request", () => { + const run: CommandRunner = (cmd, args) => { + expect(cmd).toBe("gh"); + expect(args).toContain("repos/o/r/issues/280"); + return "pull\n"; + }; + expect(classifyGhNumber("o/r", 280, run)).toEqual({ repo: "o/r", kind: "pull", num: 280 }); + }); + + it("classifies an issue (GhTarget.kind is 'issues')", () => { + const run: CommandRunner = () => "issue\n"; + expect(classifyGhNumber("o/r", 700, run)).toEqual({ repo: "o/r", kind: "issues", num: 700 }); + }); + + it("returns undefined when gh throws", () => { + const run: CommandRunner = () => { + throw new Error("gh: Not Found"); + }; + expect(classifyGhNumber("o/r", 999, run)).toBeUndefined(); + }); + + it("returns undefined on unexpected output", () => { + const run: CommandRunner = () => "weird\n"; + expect(classifyGhNumber("o/r", 1, run)).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @buildinternet/uploads test github-gh` +Expected: FAIL — `classifyGhNumber is not exported` / not a function. + +- [ ] **Step 3: Write minimal implementation** + +Add to `packages/uploads/src/github-gh.ts` after `resolveCurrentPullRequest`: + +```ts +/** + * Classify a bare PR/issue number via the GitHub API so the default `put` + * path can stamp the right `gh.kind`. Returns undefined on any failure (gh + * missing, 404, network) — the caller treats that as "no gh context" and + * uploads without metadata. + */ +export function classifyGhNumber( + repo: string, + num: number, + run: CommandRunner = execRunner, +): GhTarget | undefined { + try { + const out = run("gh", [ + "api", + `repos/${repo}/issues/${num}`, + "--jq", + 'if .pull_request then "pull" else "issue" end', + ]).trim(); + if (out === "pull") return { repo, kind: "pull", num }; + if (out === "issue") return { repo, kind: "issues", num }; + } catch { + // gh missing / not found / network — caller skips + } + return undefined; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @buildinternet/uploads test github-gh` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/uploads/src/github-gh.ts packages/uploads/test/github-gh.test.ts +git commit -m "feat(uploads): add classifyGhNumber helper for gh.* derivation" +``` + +--- + +### Task 2: `UPLOADS_NO_AUTO_META` config default + +**Files:** + +- Modify: `packages/uploads/src/config-file.ts` (6 small edits) +- Test: `packages/uploads/test/config-put-defaults.test.ts` (create) + +**Interfaces:** + +- Produces: `PutDefaults.noAutoMeta?: boolean`, populated from `UPLOADS_NO_AUTO_META` (env or config file) via the existing `resolvePutDefaults({ envFile? })`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/uploads/test/config-put-defaults.test.ts`: + +```ts +import { afterEach, describe, expect, it } from "vitest"; +import { resolvePutDefaults } from "../src/config-file.js"; + +describe("resolvePutDefaults noAutoMeta", () => { + const prev = process.env.UPLOADS_NO_AUTO_META; + afterEach(() => { + if (prev === undefined) delete process.env.UPLOADS_NO_AUTO_META; + else process.env.UPLOADS_NO_AUTO_META = prev; + }); + + it("is undefined by default (auto stays on)", () => { + delete process.env.UPLOADS_NO_AUTO_META; + expect(resolvePutDefaults({}).noAutoMeta).toBeUndefined(); + }); + + it("reads UPLOADS_NO_AUTO_META=1 from env", () => { + process.env.UPLOADS_NO_AUTO_META = "1"; + expect(resolvePutDefaults({}).noAutoMeta).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @buildinternet/uploads test config-put-defaults` +Expected: FAIL — second case yields `undefined` (env key not yet parsed). + +- [ ] **Step 3: Write minimal implementation** + +In `packages/uploads/src/config-file.ts` make these edits: + +1. Add to the `UPLOADS_CONFIG_KEYS` array (after `"UPLOADS_KEEP_EXIF",`): + +```ts + "UPLOADS_NO_AUTO_META", +``` + +2. Add to the `PutDefaults` interface (after the `keepExif?: boolean;` field): + +```ts + /** When true, `put` does NOT auto-resolve/stamp gh.* on the default path. */ + noAutoMeta?: boolean; +``` + +3. Add to `PUT_DEFAULT_KEY_MAP` (after `keepExif: "UPLOADS_KEEP_EXIF",`): + +```ts + noAutoMeta: "UPLOADS_NO_AUTO_META", +``` + +4. Add to `putDefaultsToConfigValues` (after the `keepExif` line): + +```ts +if (defaults.noAutoMeta) out.UPLOADS_NO_AUTO_META = "1"; +``` + +5. In `parsePutDefaultsFromRaw` (after the `UPLOADS_KEEP_EXIF` line): + +```ts +if (isTruthyConfigFlag(raw.UPLOADS_NO_AUTO_META)) out.noAutoMeta = true; +``` + +6. In `parsePutDefaultsFromEnv` (after the `UPLOADS_KEEP_EXIF` line): + +```ts +if (process.env.UPLOADS_NO_AUTO_META) raw.UPLOADS_NO_AUTO_META = process.env.UPLOADS_NO_AUTO_META; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @buildinternet/uploads test config-put-defaults` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/uploads/src/config-file.ts packages/uploads/test/config-put-defaults.test.ts +git commit -m "feat(uploads): add UPLOADS_NO_AUTO_META put default" +``` + +--- + +### Task 3: Stamp `gh.*` on the explicit `--pr`/`--issue` put path + +**Files:** + +- Modify: `packages/uploads/src/commands.ts` — `runPut` (metadata construction, currently ~lines 505–509; new merge placed just before `ctx.client.put(...)` ~line 592) +- Test: `packages/uploads/test/commands-put.test.ts` + +**Interfaces:** + +- Consumes: existing `ghTarget` (from `ghTargetFromFlags`, `runPut` ~line 500) and `ghMetadataFromTarget` (already imported). +- Produces: `runPut` passes `metadata` that includes the 4 `gh.*` pairs when `--pr`/`--issue` is present. + +- [ ] **Step 1: Write the failing test** + +Append to `packages/uploads/test/commands-put.test.ts`: + +```ts +describe("runPut gh.* metadata (explicit target)", () => { + it("stamps gh.* on the --pr path", async () => { + const { client, puts } = fakeClient(); + await runPut(ctxWith(client), [tmpFile(), "--pr", "128", "--repo", "o/r"], false, noRun); + expect(puts[0].metadata).toMatchObject({ + "gh.repo": "o/r", + "gh.kind": "pull", + "gh.number": "128", + "gh.ref": "o/r#128", + }); + }); + + it("stamps gh.kind=issue on the --issue path", async () => { + const { client, puts } = fakeClient(); + await runPut(ctxWith(client), [tmpFile(), "--issue", "7", "--repo", "o/r"], false, noRun); + expect(puts[0].metadata).toMatchObject({ "gh.kind": "issue", "gh.number": "7" }); + }); + + it("explicit target wins over a same-key --meta", async () => { + const { client, puts } = fakeClient(); + await runPut( + ctxWith(client), + [tmpFile(), "--pr", "9", "--repo", "o/r", "--meta", "gh.number=999"], + false, + noRun, + ); + expect(puts[0].metadata!["gh.number"]).toBe("9"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @buildinternet/uploads test commands-put` +Expected: FAIL — `puts[0].metadata` is `undefined` (put doesn't stamp gh.\* yet). + +- [ ] **Step 3: Write minimal implementation** + +In `runPut`, rename the existing metadata block (currently `const metadata = ((): … )();` at ~line 506) to build **`userMeta`** instead: + +```ts +// Validate --meta up front (fail fast, before reading/optimizing the file). +const userMeta = ((): Record | undefined => { + const pairs = flagValues(parsed.flags, "--meta"); + return pairs.length > 0 ? parseMetaFlags(pairs) : undefined; +})(); +``` + +Then, immediately before the `const result = await ctx.client.put(` call (~line 592, after `const noGit = …`), add: + +```ts +// gh.* metadata from an explicit --pr/--issue target (target wins over --meta). +let metadata = userMeta; +if (ghTarget) { + metadata = { ...(userMeta ?? {}), ...ghMetadataFromTarget(ghTarget) }; +} +``` + +(The `metadata` field passed into `ctx.client.put({ …, metadata })` now refers to this new binding.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @buildinternet/uploads test commands-put` +Expected: PASS (new cases plus all existing `runPut` cases). + +- [ ] **Step 5: Commit** + +```bash +git add packages/uploads/src/commands.ts packages/uploads/test/commands-put.test.ts +git commit -m "fix(uploads): stamp gh.* metadata on put --pr/--issue" +``` + +--- + +### Task 4: Auto-resolve `gh.*` on the default put path + +**Files:** + +- Modify: `packages/uploads/src/commands.ts` — add `classifyGhNumber` to the `../github-gh.js` import; add `resolveAutoGhTarget` helper (near `ghTargetFromFlags`, ~line 164); extend the metadata block from Task 3; update `PUT_HELP` +- Test: `packages/uploads/test/commands-put.test.ts` + +**Interfaces:** + +- Consumes: `resolveRepo`, `resolveCurrentPullRequest`, `classifyGhNumber` (imported from `github-gh.js`), `defaults.noAutoMeta` (Task 2), `noGit`. +- Produces: on the default path (no `--pr`/`--issue`), when auto is enabled and a target resolves, `metadata` includes the 4 `gh.*` pairs (explicit `--meta` wins). Key layout unchanged. + +- [ ] **Step 1: Write the failing test** + +Append to `packages/uploads/test/commands-put.test.ts`: + +```ts +/** Fake gh: answers `gh pr view` (branch→PR) and `gh api` (classify). */ +function ghRunner(opts: { pr?: number; classify?: "pull" | "issue" }): CommandRunner { + return (cmd, args) => { + if (cmd === "gh" && args[0] === "repo") return "o/r\n"; // resolveRepo fallback + if (cmd === "gh" && args[0] === "pr" && args[1] === "view") { + if (opts.pr) return `${opts.pr}\n`; + throw new Error("no pull request found"); + } + if (cmd === "gh" && args[0] === "api") return `${opts.classify ?? "pull"}\n`; + throw new Error(`unexpected: ${cmd} ${args.join(" ")}`); + }; +} + +describe("runPut auto gh.* metadata (default path)", () => { + it("stamps the current branch PR on a plain put", async () => { + const { client, puts } = fakeClient(); + await runPut(ctxWith(client), [tmpFile(), "--repo", "o/r"], false, ghRunner({ pr: 481 })); + expect(puts[0].key).toBeUndefined(); // still the screenshots default key + expect(puts[0].metadata).toMatchObject({ + "gh.repo": "o/r", + "gh.kind": "pull", + "gh.number": "481", + "gh.ref": "o/r#481", + }); + }); + + it("classifies a numeric --ref as an issue", async () => { + const { client, puts } = fakeClient(); + await runPut( + ctxWith(client), + [tmpFile(), "--repo", "o/r", "--ref", "700"], + false, + ghRunner({ classify: "issue" }), + ); + expect(puts[0].metadata).toMatchObject({ + "gh.kind": "issue", + "gh.number": "700", + "gh.ref": "o/r#700", + }); + }); + + it("uploads without metadata when no PR resolves", async () => { + const { client, puts } = fakeClient(); + const code = await runPut(ctxWith(client), [tmpFile(), "--repo", "o/r"], false, ghRunner({})); + expect(code).toBe(0); + expect(puts[0].metadata).toBeUndefined(); + }); + + it("--no-auto suppresses auto resolution", async () => { + const { client, puts } = fakeClient(); + await runPut( + ctxWith(client), + [tmpFile(), "--repo", "o/r", "--no-auto"], + false, + ghRunner({ pr: 481 }), + ); + expect(puts[0].metadata).toBeUndefined(); + }); + + it("explicit --meta wins over auto-derived gh.*", async () => { + const { client, puts } = fakeClient(); + await runPut( + ctxWith(client), + [tmpFile(), "--repo", "o/r", "--meta", "gh.number=5"], + false, + ghRunner({ pr: 481 }), + ); + expect(puts[0].metadata!["gh.number"]).toBe("5"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @buildinternet/uploads test commands-put` +Expected: FAIL — auto cases show `metadata` `undefined` (no auto path yet). + +- [ ] **Step 3: Write minimal implementation** + +3a. Add `classifyGhNumber` to the existing `github-gh.js` import block in `commands.ts` (the block that already imports `resolveRepo`, `resolveCurrentPullRequest`): + +```ts + classifyGhNumber, +``` + +3b. Add the helper after `ghTargetFromFlags` (~line 164): + +```ts +/** + * Best-effort GitHub target for the default put path (no --pr/--issue). A + * numeric --ref is classified as pull vs issue; otherwise the current branch's + * PR is resolved. Never throws — any failure yields undefined so the upload + * proceeds without gh metadata. + */ +function resolveAutoGhTarget( + repoArg: string | undefined, + ref: string | undefined, + run: CommandRunner, +): GhTarget | undefined { + try { + const repo = resolveRepo(repoArg, run); + if (ref !== undefined && /^\d+$/.test(ref) && Number(ref) > 0) { + return classifyGhNumber(repo, Number.parseInt(ref, 10), run); + } + return resolveCurrentPullRequest(repo, run); + } catch { + return undefined; + } +} +``` + +3c. Replace the Task 3 metadata block (the `let metadata = userMeta; if (ghTarget) { … }`) with the full explicit-or-auto version: + +```ts +// gh.* metadata: explicit --pr/--issue target wins over --meta; otherwise +// best-effort auto resolution (on by default) where --meta wins. Auto is off +// when --no-auto, --no-git, or UPLOADS_NO_AUTO_META is set (unless --auto forces it). +let metadata = userMeta; +if (ghTarget) { + metadata = { ...(userMeta ?? {}), ...ghMetadataFromTarget(ghTarget) }; +} else { + const autoEnabled = + flagBool(parsed.flags, "--auto") || + (!flagBool(parsed.flags, "--no-auto") && defaults.noAutoMeta !== true && !noGit); + if (autoEnabled) { + const autoTarget = resolveAutoGhTarget( + flagString(parsed.flags, "--repo") ?? defaults.repo, + flagString(parsed.flags, "--ref") ?? defaults.ref, + run, + ); + if (autoTarget) metadata = { ...ghMetadataFromTarget(autoTarget), ...(userMeta ?? {}) }; + } +} +``` + +3d. Update `PUT_HELP` — add these lines in the gh/metadata section (after the `--no-git` line): + +``` + --auto Resolve current PR/issue and stamp gh.* metadata (default on) + --no-auto Skip gh.* auto-resolution on the default path (or UPLOADS_NO_AUTO_META=1) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @buildinternet/uploads test commands-put` +Expected: PASS. Also run the full package suite and typecheck: +`pnpm --filter @buildinternet/uploads test && pnpm --filter @buildinternet/uploads typecheck` +Expected: all green (existing `--no-git` default-path tests still pass — auto is gated off by `noGit`). + +- [ ] **Step 5: Commit** + +```bash +git add packages/uploads/src/commands.ts packages/uploads/test/commands-put.test.ts +git commit -m "feat(uploads): auto-resolve gh.* metadata on the default put path" +``` + +--- + +### Task 5: Docs + changeset + +**Files:** + +- Modify: `skills/uploads-cli/SKILL.md` (the Re-PUT / metadata semantics note) +- Create: `.changeset/put-auto-gh-metadata.md` + +**Interfaces:** none (docs only). + +- [ ] **Step 1: Add the changeset** + +Create `.changeset/put-auto-gh-metadata.md`: + +```markdown +--- +"@buildinternet/uploads": minor +--- + +`uploads put` now stamps the four `gh.*` metadata pairs whenever it has a +GitHub target, so screenshots hosted on the default `screenshots/…` path get an +"Attached to" link on their `/f/` page. On by default: with `--pr`/`--issue` +the explicit target is used (previously the stable key was written without +metadata); otherwise `put` resolves the current branch's PR (or classifies a +numeric `--ref` as pull vs issue) via `gh`. Disable with `--no-auto`, +`--no-git`, or `UPLOADS_NO_AUTO_META=1`. Resolution is best-effort — a missing +`gh`, no PR, or an API error uploads normally without metadata. +``` + +- [ ] **Step 2: Update the CLI skill doc** + +In `skills/uploads-cli/SKILL.md`, find the section documenting `put` re-PUT / metadata semantics and add a sentence describing auto gh.\* metadata and the `--no-auto` / `UPLOADS_NO_AUTO_META` opt-out. Suggested text: + +```markdown +On the default `screenshots/…` path, `put` also auto-derives GitHub context and +stamps `gh.repo`/`gh.kind`/`gh.number`/`gh.ref` from the current branch's PR (or +a numeric `--ref`), so the file's `/f/` page shows an "Attached to" link. This is +on by default and best-effort; disable it with `--no-auto` or `UPLOADS_NO_AUTO_META=1`. +``` + +- [ ] **Step 3: Verify docs build/format (no code)** + +Run: `pnpm --filter @buildinternet/uploads test && pnpm --filter @buildinternet/uploads typecheck` +Expected: still green (sanity check; docs don't affect tests). + +- [ ] **Step 4: Commit** + +```bash +git add .changeset/put-auto-gh-metadata.md skills/uploads-cli/SKILL.md +git commit -m "docs(uploads): document auto gh.* metadata on put + changeset" +``` + +--- + +## Self-Review + +**Spec coverage:** + +- Gap #1 (default path captures nothing) → Task 4. ✅ +- Gap #2 (`put --pr` writes gh/ key but no gh.\*) → Task 3. ✅ +- Numeric `--ref` classification → Task 1 (`classifyGhNumber`) + Task 4 wiring. ✅ +- Branch→PR resolution → reuse `resolveCurrentPullRequest` in Task 4. ✅ +- Control surface (`--auto`/`--no-auto`, `UPLOADS_NO_AUTO_META`, `--no-git` gate) → Task 2 (config) + Task 4 (flags/gate). ✅ +- Best-effort/never-fail → `resolveAutoGhTarget` try/catch + `classifyGhNumber` returning undefined; Task 4 "no PR → uploads without metadata" test. ✅ +- Precedence (explicit target wins; auto yields to --meta) → Task 3 + Task 4 tests. ✅ +- Key layout unchanged → Task 4 asserts `puts[0].key` undefined (default key). ✅ +- Docs + changeset → Task 5. ✅ +- Out of scope (no migration, MCP unchanged, skill untouched) → honored; no tasks touch those. ✅ + +**Placeholder scan:** none — every code/step is concrete. + +**Type consistency:** `classifyGhNumber(repo, num, run?) → GhTarget | undefined` defined in Task 1, consumed in Task 4 with matching arity; `GhTarget.kind` uses `"pull"|"issues"` consistently; `resolveAutoGhTarget(repoArg, ref, run)` signature matches its Task 4 call site; `PutDefaults.noAutoMeta` defined in Task 2, read in Task 4 as `defaults.noAutoMeta`. From 7895087a683f25206062aa5fb23570230e31227a Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Mon, 13 Jul 2026 22:46:19 -0400 Subject: [PATCH 03/10] feat(uploads): add classifyGhNumber helper for gh.* derivation --- packages/uploads/src/github-gh.ts | 26 ++++++++++++++++++ packages/uploads/test/github-gh.test.ts | 35 ++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/uploads/src/github-gh.ts b/packages/uploads/src/github-gh.ts index 37e667c..cf32d25 100644 --- a/packages/uploads/src/github-gh.ts +++ b/packages/uploads/src/github-gh.ts @@ -71,6 +71,32 @@ export function resolveCurrentPullRequest(repo: string, run: CommandRunner = exe ); } +/** + * Classify a bare PR/issue number via the GitHub API so the default `put` + * path can stamp the right `gh.kind`. Returns undefined on any failure (gh + * missing, 404, network) — the caller treats that as "no gh context" and + * uploads without metadata. + */ +export function classifyGhNumber( + repo: string, + num: number, + run: CommandRunner = execRunner, +): GhTarget | undefined { + try { + const out = run("gh", [ + "api", + `repos/${repo}/issues/${num}`, + "--jq", + 'if .pull_request then "pull" else "issue" end', + ]).trim(); + if (out === "pull") return { repo, kind: "pull", num }; + if (out === "issue") return { repo, kind: "issues", num }; + } catch { + // gh missing / not found / network — caller skips + } + return undefined; +} + interface GhComment { id: number; body: string; diff --git a/packages/uploads/test/github-gh.test.ts b/packages/uploads/test/github-gh.test.ts index eacdc42..34e30f0 100644 --- a/packages/uploads/test/github-gh.test.ts +++ b/packages/uploads/test/github-gh.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import { UsageError } from "../src/cli-args.js"; import { ATTACHMENTS_MARKER, type GhTarget } from "../src/github.js"; -import { resolveRepo, upsertAttachmentsComment, type CommandRunner } from "../src/github-gh.js"; +import { + classifyGhNumber, + resolveRepo, + upsertAttachmentsComment, + type CommandRunner, +} from "../src/github-gh.js"; /** Fake runner: matches on command name, records calls. */ function fakeRunner(handlers: Record string>) { @@ -89,3 +94,31 @@ describe("upsertAttachmentsComment", () => { expect(patch.input).toContain("new body"); }); }); + +describe("classifyGhNumber", () => { + it("classifies a pull request", () => { + const run: CommandRunner = (cmd, args) => { + expect(cmd).toBe("gh"); + expect(args).toContain("repos/o/r/issues/280"); + return "pull\n"; + }; + expect(classifyGhNumber("o/r", 280, run)).toEqual({ repo: "o/r", kind: "pull", num: 280 }); + }); + + it("classifies an issue (GhTarget.kind is 'issues')", () => { + const run: CommandRunner = () => "issue\n"; + expect(classifyGhNumber("o/r", 700, run)).toEqual({ repo: "o/r", kind: "issues", num: 700 }); + }); + + it("returns undefined when gh throws", () => { + const run: CommandRunner = () => { + throw new Error("gh: Not Found"); + }; + expect(classifyGhNumber("o/r", 999, run)).toBeUndefined(); + }); + + it("returns undefined on unexpected output", () => { + const run: CommandRunner = () => "weird\n"; + expect(classifyGhNumber("o/r", 1, run)).toBeUndefined(); + }); +}); From 8d429a6d5fce00a2faea0c6d296479c0a05bd8f1 Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Mon, 13 Jul 2026 22:50:05 -0400 Subject: [PATCH 04/10] feat(uploads): add UPLOADS_NO_AUTO_META put default --- packages/uploads/src/config-file.ts | 8 ++++++++ .../uploads/test/config-put-defaults.test.ts | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 packages/uploads/test/config-put-defaults.test.ts diff --git a/packages/uploads/src/config-file.ts b/packages/uploads/src/config-file.ts index 42dac05..cb11f67 100644 --- a/packages/uploads/src/config-file.ts +++ b/packages/uploads/src/config-file.ts @@ -14,6 +14,7 @@ export const UPLOADS_CONFIG_KEYS = [ "UPLOADS_NO_GIT", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF", + "UPLOADS_NO_AUTO_META", ] as const; export type UploadsConfigKey = (typeof UPLOADS_CONFIG_KEYS)[number]; @@ -30,6 +31,8 @@ export interface PutDefaults { noOptimize?: boolean; /** When true, optimize keeps EXIF/XMP/ICC (default strips). */ keepExif?: boolean; + /** When true, `put` does NOT auto-resolve/stamp gh.* on the default path. */ + noAutoMeta?: boolean; } const PUT_DEFAULT_KEY_MAP: Record = { @@ -40,6 +43,7 @@ const PUT_DEFAULT_KEY_MAP: Record = { noGit: "UPLOADS_NO_GIT", noOptimize: "UPLOADS_NO_OPTIMIZE", keepExif: "UPLOADS_KEEP_EXIF", + noAutoMeta: "UPLOADS_NO_AUTO_META", }; function isTruthyConfigFlag(value: string | undefined): boolean { @@ -57,6 +61,7 @@ export function putDefaultsToConfigValues(defaults: PutDefaults): UploadsConfigV if (defaults.noGit) out.UPLOADS_NO_GIT = "1"; if (defaults.noOptimize) out.UPLOADS_NO_OPTIMIZE = "1"; if (defaults.keepExif) out.UPLOADS_KEEP_EXIF = "1"; + if (defaults.noAutoMeta) out.UPLOADS_NO_AUTO_META = "1"; return out; } @@ -72,6 +77,7 @@ function parsePutDefaultsFromRaw(raw: UploadsConfigValues): PutDefaults { if (isTruthyConfigFlag(raw.UPLOADS_NO_GIT)) out.noGit = true; if (isTruthyConfigFlag(raw.UPLOADS_NO_OPTIMIZE)) out.noOptimize = true; if (isTruthyConfigFlag(raw.UPLOADS_KEEP_EXIF)) out.keepExif = true; + if (isTruthyConfigFlag(raw.UPLOADS_NO_AUTO_META)) out.noAutoMeta = true; return out; } @@ -86,6 +92,7 @@ function parsePutDefaultsFromEnv(): PutDefaults { if (process.env.UPLOADS_NO_GIT) raw.UPLOADS_NO_GIT = process.env.UPLOADS_NO_GIT; if (process.env.UPLOADS_NO_OPTIMIZE) raw.UPLOADS_NO_OPTIMIZE = process.env.UPLOADS_NO_OPTIMIZE; if (process.env.UPLOADS_KEEP_EXIF) raw.UPLOADS_KEEP_EXIF = process.env.UPLOADS_KEEP_EXIF; + if (process.env.UPLOADS_NO_AUTO_META) raw.UPLOADS_NO_AUTO_META = process.env.UPLOADS_NO_AUTO_META; return parsePutDefaultsFromRaw(raw); } @@ -150,6 +157,7 @@ export function mergePutDefaults(...layers: PutDefaults[]): PutDefaults { if (layer.noGit != null) out.noGit = layer.noGit; if (layer.noOptimize != null) out.noOptimize = layer.noOptimize; if (layer.keepExif != null) out.keepExif = layer.keepExif; + if (layer.noAutoMeta != null) out.noAutoMeta = layer.noAutoMeta; } return out; } diff --git a/packages/uploads/test/config-put-defaults.test.ts b/packages/uploads/test/config-put-defaults.test.ts new file mode 100644 index 0000000..84ab75b --- /dev/null +++ b/packages/uploads/test/config-put-defaults.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { resolvePutDefaults } from "../src/config-file.js"; + +describe("resolvePutDefaults noAutoMeta", () => { + const prev = process.env.UPLOADS_NO_AUTO_META; + afterEach(() => { + if (prev === undefined) delete process.env.UPLOADS_NO_AUTO_META; + else process.env.UPLOADS_NO_AUTO_META = prev; + }); + + it("is undefined by default (auto stays on)", () => { + delete process.env.UPLOADS_NO_AUTO_META; + expect(resolvePutDefaults({}).noAutoMeta).toBeUndefined(); + }); + + it("reads UPLOADS_NO_AUTO_META=1 from env", () => { + process.env.UPLOADS_NO_AUTO_META = "1"; + expect(resolvePutDefaults({}).noAutoMeta).toBe(true); + }); +}); From 6922150e8f5583d5d6a2507a5f4be9768252be67 Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Mon, 13 Jul 2026 22:54:40 -0400 Subject: [PATCH 05/10] fix(uploads): stamp gh.* metadata on put --pr/--issue --- packages/uploads/src/commands.ts | 7 ++++- packages/uploads/test/commands-put.test.ts | 30 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/uploads/src/commands.ts b/packages/uploads/src/commands.ts index ef0a411..d3bc529 100644 --- a/packages/uploads/src/commands.ts +++ b/packages/uploads/src/commands.ts @@ -503,7 +503,7 @@ export async function runPut( const nameFlag = flagString(parsed.flags, "--name"); const dryRun = flagBool(parsed.flags, "--dry-run"); // Validate --meta up front (fail fast, before reading/optimizing the file). - const metadata = ((): Record | undefined => { + const userMeta = ((): Record | undefined => { const pairs = flagValues(parsed.flags, "--meta"); return pairs.length > 0 ? parseMetaFlags(pairs) : undefined; })(); @@ -587,6 +587,11 @@ export async function runPut( } const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true; + // gh.* metadata from an explicit --pr/--issue target (target wins over --meta). + let metadata = userMeta; + if (ghTarget) { + metadata = { ...userMeta, ...ghMetadataFromTarget(ghTarget) }; + } let key = ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint; if (key && prepared.optimized) key = rewriteKeyExtension(key, filename); const result = await ctx.client.put(prepared.bytes, { diff --git a/packages/uploads/test/commands-put.test.ts b/packages/uploads/test/commands-put.test.ts index d82c4dd..053e9f9 100644 --- a/packages/uploads/test/commands-put.test.ts +++ b/packages/uploads/test/commands-put.test.ts @@ -381,3 +381,33 @@ describe("runPut --meta", () => { ).rejects.toThrow(UsageError); }); }); + +describe("runPut gh.* metadata (explicit target)", () => { + it("stamps gh.* on the --pr path", async () => { + const { client, puts } = fakeClient(); + await runPut(ctxWith(client), [tmpFile(), "--pr", "128", "--repo", "o/r"], false, noRun); + expect(puts[0].metadata).toMatchObject({ + "gh.repo": "o/r", + "gh.kind": "pull", + "gh.number": "128", + "gh.ref": "o/r#128", + }); + }); + + it("stamps gh.kind=issue on the --issue path", async () => { + const { client, puts } = fakeClient(); + await runPut(ctxWith(client), [tmpFile(), "--issue", "7", "--repo", "o/r"], false, noRun); + expect(puts[0].metadata).toMatchObject({ "gh.kind": "issue", "gh.number": "7" }); + }); + + it("explicit target wins over a same-key --meta", async () => { + const { client, puts } = fakeClient(); + await runPut( + ctxWith(client), + [tmpFile(), "--pr", "9", "--repo", "o/r", "--meta", "gh.number=999"], + false, + noRun, + ); + expect(puts[0].metadata!["gh.number"]).toBe("9"); + }); +}); From 8d1c2cd6356a3bb471e4846d9118074ca1d40bc3 Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Mon, 13 Jul 2026 23:00:02 -0400 Subject: [PATCH 06/10] feat(uploads): auto-resolve gh.* metadata on the default put path --- packages/uploads/src/commands.ts | 41 ++++++++++++- packages/uploads/test/commands-put.test.ts | 71 ++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/uploads/src/commands.ts b/packages/uploads/src/commands.ts index d3bc529..e4bd8e9 100644 --- a/packages/uploads/src/commands.ts +++ b/packages/uploads/src/commands.ts @@ -34,6 +34,7 @@ import { import { resolveRepo, resolveCurrentPullRequest, + classifyGhNumber, execRunner, upsertAttachmentsComment, type CommandRunner, @@ -109,6 +110,8 @@ Options: --optimize-quality <1-100> WebP quality (default: 85) --keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy) --no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1) + --auto Resolve current PR/issue and stamp gh.* metadata (default on) + --no-auto Skip gh.* auto-resolution on the default path (or UPLOADS_NO_AUTO_META=1) --workspace, -w Override workspace (wins over UPLOADS_WORKSPACE and token inference) --format human|url|markdown|json --pr Attach to a pull request: key gh///pull// (stable URL, no hash) @@ -163,6 +166,28 @@ function ghTargetFromFlags(flags: CommandFlags["flags"], run: CommandRunner): Gh ); } +/** + * Best-effort GitHub target for the default put path (no --pr/--issue). A + * numeric --ref is classified as pull vs issue; otherwise the current branch's + * PR is resolved. Never throws — any failure yields undefined so the upload + * proceeds without gh metadata. + */ +function resolveAutoGhTarget( + repoArg: string | undefined, + ref: string | undefined, + run: CommandRunner, +): GhTarget | undefined { + try { + const repo = resolveRepo(repoArg, run); + if (ref !== undefined && /^\d+$/.test(ref) && Number(ref) > 0) { + return classifyGhNumber(repo, Number.parseInt(ref, 10), run); + } + return resolveCurrentPullRequest(repo, run); + } catch { + return undefined; + } +} + /** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */ export function optimizeOptionsFromFlags( flags: CommandFlags["flags"], @@ -587,10 +612,24 @@ export async function runPut( } const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true; - // gh.* metadata from an explicit --pr/--issue target (target wins over --meta). + // gh.* metadata: explicit --pr/--issue target wins over --meta; otherwise + // best-effort auto resolution (on by default) where --meta wins. Auto is off + // when --no-auto, --no-git, or UPLOADS_NO_AUTO_META is set (unless --auto forces it). let metadata = userMeta; if (ghTarget) { metadata = { ...userMeta, ...ghMetadataFromTarget(ghTarget) }; + } else { + const autoEnabled = + flagBool(parsed.flags, "--auto") || + (!flagBool(parsed.flags, "--no-auto") && defaults.noAutoMeta !== true && !noGit); + if (autoEnabled) { + const autoTarget = resolveAutoGhTarget( + flagString(parsed.flags, "--repo") ?? defaults.repo, + flagString(parsed.flags, "--ref") ?? defaults.ref, + run, + ); + if (autoTarget) metadata = { ...ghMetadataFromTarget(autoTarget), ...userMeta }; + } } let key = ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint; if (key && prepared.optimized) key = rewriteKeyExtension(key, filename); diff --git a/packages/uploads/test/commands-put.test.ts b/packages/uploads/test/commands-put.test.ts index 053e9f9..0a1cd1d 100644 --- a/packages/uploads/test/commands-put.test.ts +++ b/packages/uploads/test/commands-put.test.ts @@ -411,3 +411,74 @@ describe("runPut gh.* metadata (explicit target)", () => { expect(puts[0].metadata!["gh.number"]).toBe("9"); }); }); + +/** Fake gh: answers `gh pr view` (branch→PR) and `gh api` (classify). */ +function ghRunner(opts: { pr?: number; classify?: "pull" | "issue" }): CommandRunner { + return (cmd, args) => { + if (cmd === "gh" && args[0] === "repo") return "o/r\n"; // resolveRepo fallback + if (cmd === "gh" && args[0] === "pr" && args[1] === "view") { + if (opts.pr) return `${opts.pr}\n`; + throw new Error("no pull request found"); + } + if (cmd === "gh" && args[0] === "api") return `${opts.classify ?? "pull"}\n`; + throw new Error(`unexpected: ${cmd} ${args.join(" ")}`); + }; +} + +describe("runPut auto gh.* metadata (default path)", () => { + it("stamps the current branch PR on a plain put", async () => { + const { client, puts } = fakeClient(); + await runPut(ctxWith(client), [tmpFile(), "--repo", "o/r"], false, ghRunner({ pr: 481 })); + expect(puts[0].key).toBeUndefined(); // still the screenshots default key + expect(puts[0].metadata).toMatchObject({ + "gh.repo": "o/r", + "gh.kind": "pull", + "gh.number": "481", + "gh.ref": "o/r#481", + }); + }); + + it("classifies a numeric --ref as an issue", async () => { + const { client, puts } = fakeClient(); + await runPut( + ctxWith(client), + [tmpFile(), "--repo", "o/r", "--ref", "700"], + false, + ghRunner({ classify: "issue" }), + ); + expect(puts[0].metadata).toMatchObject({ + "gh.kind": "issue", + "gh.number": "700", + "gh.ref": "o/r#700", + }); + }); + + it("uploads without metadata when no PR resolves", async () => { + const { client, puts } = fakeClient(); + const code = await runPut(ctxWith(client), [tmpFile(), "--repo", "o/r"], false, ghRunner({})); + expect(code).toBe(0); + expect(puts[0].metadata).toBeUndefined(); + }); + + it("--no-auto suppresses auto resolution", async () => { + const { client, puts } = fakeClient(); + await runPut( + ctxWith(client), + [tmpFile(), "--repo", "o/r", "--no-auto"], + false, + ghRunner({ pr: 481 }), + ); + expect(puts[0].metadata).toBeUndefined(); + }); + + it("explicit --meta wins over auto-derived gh.*", async () => { + const { client, puts } = fakeClient(); + await runPut( + ctxWith(client), + [tmpFile(), "--repo", "o/r", "--meta", "gh.number=5"], + false, + ghRunner({ pr: 481 }), + ); + expect(puts[0].metadata!["gh.number"]).toBe("5"); + }); +}); From 532e29c2e6b82d358417fb3b9f46f5b436352d78 Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Mon, 13 Jul 2026 23:04:30 -0400 Subject: [PATCH 07/10] docs(uploads): document auto gh.* metadata on put + changeset --- .changeset/put-auto-gh-metadata.md | 12 ++++++++++++ skills/uploads-cli/SKILL.md | 16 +++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 .changeset/put-auto-gh-metadata.md diff --git a/.changeset/put-auto-gh-metadata.md b/.changeset/put-auto-gh-metadata.md new file mode 100644 index 0000000..a187f76 --- /dev/null +++ b/.changeset/put-auto-gh-metadata.md @@ -0,0 +1,12 @@ +--- +"@buildinternet/uploads": minor +--- + +`uploads put` now stamps the four `gh.*` metadata pairs whenever it has a +GitHub target, so screenshots hosted on the default `screenshots/…` path get an +"Attached to" link on their `/f/` page. On by default: with `--pr`/`--issue` +the explicit target is used (previously the stable key was written without +metadata); otherwise `put` resolves the current branch's PR (or classifies a +numeric `--ref` as pull vs issue) via `gh`. Disable with `--no-auto`, +`--no-git`, or `UPLOADS_NO_AUTO_META=1`. Resolution is best-effort — a missing +`gh`, no PR, or an API error uploads normally without metadata. diff --git a/skills/uploads-cli/SKILL.md b/skills/uploads-cli/SKILL.md index f5deb4f..4c48e6a 100644 --- a/skills/uploads-cli/SKILL.md +++ b/skills/uploads-cli/SKILL.md @@ -228,6 +228,11 @@ request; a value may itself contain `=` (only the first `=` splits key from valu visibility gate, respectively). `uploads attach` writes its own `gh.*` reserved-by-convention keys automatically — see below. +On the default `screenshots/…` path, `put` also auto-derives GitHub context and +stamps `gh.repo`/`gh.kind`/`gh.number`/`gh.ref` from the current branch's PR (or +a numeric `--ref`), so the file's `/f/` page shows an "Attached to" link. This is +on by default and best-effort; disable it with `--no-auto` or `UPLOADS_NO_AUTO_META=1`. + **Re-upload semantics:** re-uploading to an existing key **with** `--meta` replaces that file's entire metadata set (delete-then-set, not a merge); re-uploading **without** `--meta` at all preserves the existing metadata untouched. Use @@ -292,11 +297,12 @@ Then reference the **embed** URL in the PR/issue markdown you write with `gh` Keep `url` (storage host) when you need a durable share link outside GitHub. -`uploads attach` (below) additionally writes `gh.repo`/`gh.kind`/`gh.number`/`gh.ref` -as queryable metadata automatically, so `uploads find gh.ref=myorg/myapp#123` or -`uploads list --meta gh.repo=myorg/myapp` finds everything attached to that PR/issue -without needing the `gh/...` prefix. Add `--meta k=v` extras to `attach` for your own -pairs on top — a `--meta gh.*` override loses to the target's own `gh.*` values. +`put --pr`/`--issue` (and `uploads attach`, below) writes `gh.repo`/`gh.kind`/ +`gh.number`/`gh.ref` as queryable metadata automatically, so `uploads find +gh.ref=myorg/myapp#123` or `uploads list --meta gh.repo=myorg/myapp` finds +everything attached to that PR/issue without needing the `gh/...` prefix. Add +`--meta k=v` extras for your own pairs on top — a `--meta gh.*` override loses +to the target's own `gh.*` values. ### Option B — managed attachments comment (`--comment` / `comment`) From fd6b44ebed8400d64668bf502d3f1e36873c10ee Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Mon, 13 Jul 2026 23:21:45 -0400 Subject: [PATCH 08/10] fix(uploads): validate merged put metadata, gate auto on --no-git, success note Final-review fix wave for uploads put auto gh.* metadata: validate the merged metadata map (not just --meta alone) on both the explicit and auto paths, with the auto path falling back to userMeta on cap overflow instead of failing the upload; make --no-git always disable auto resolution even when --auto is passed; add a human-format success note when gh.* is attached (silent on skip); add fail-fast guards for stray --auto/--no-auto values; and mention --no-git in the disable lists (PUT_HELP, SKILL.md) and design doc. --- ...-13-uploads-put-auto-gh-metadata-design.md | 4 +- packages/uploads/src/commands.ts | 41 +++++++-- packages/uploads/test/commands-put.test.ts | 91 ++++++++++++++++++- skills/uploads-cli/SKILL.md | 2 +- 4 files changed, 128 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-07-13-uploads-put-auto-gh-metadata-design.md b/docs/superpowers/specs/2026-07-13-uploads-put-auto-gh-metadata-design.md index 21672cd..5617a09 100644 --- a/docs/superpowers/specs/2026-07-13-uploads-put-auto-gh-metadata-design.md +++ b/docs/superpowers/specs/2026-07-13-uploads-put-auto-gh-metadata-design.md @@ -56,7 +56,9 @@ Auto resolution picks its target in this order: Auto resolution is **best-effort and never fails the upload**: missing `gh`, no PR for the branch, an API error, or an unresolvable repo → skip the metadata -and upload normally (one stderr note in human format, like `--comment`). +and upload normally, silently (no note on skip). When `gh.*` metadata IS +attached — explicit or auto — `runPut` prints one stderr note in human format +(`>> attached to `), the same shape as the `--comment` note. ## Control surface diff --git a/packages/uploads/src/commands.ts b/packages/uploads/src/commands.ts index e4bd8e9..ac11ee5 100644 --- a/packages/uploads/src/commands.ts +++ b/packages/uploads/src/commands.ts @@ -111,7 +111,7 @@ Options: --keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy) --no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1) --auto Resolve current PR/issue and stamp gh.* metadata (default on) - --no-auto Skip gh.* auto-resolution on the default path (or UPLOADS_NO_AUTO_META=1) + --no-auto Skip gh.* auto-resolution (also skipped by --no-git or UPLOADS_NO_AUTO_META=1) --workspace, -w Override workspace (wins over UPLOADS_WORKSPACE and token inference) --format human|url|markdown|json --pr Attach to a pull request: key gh///pull// (stable URL, no hash) @@ -535,6 +535,12 @@ export async function runPut( if (wantComment && typeof parsed.flags.get("--comment") === "string") { throw new UsageError("--comment takes no value — place it after the file argument"); } + if (parsed.flags.has("--auto") && typeof parsed.flags.get("--auto") === "string") { + throw new UsageError("--auto takes no value"); + } + if (parsed.flags.has("--no-auto") && typeof parsed.flags.get("--no-auto") === "string") { + throw new UsageError("--no-auto takes no value"); + } if (wantComment && !ghTarget) throw new UsageError("--comment requires --pr or --issue"); if (ghTarget) { if (keyHint) { @@ -613,24 +619,45 @@ export async function runPut( const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true; // gh.* metadata: explicit --pr/--issue target wins over --meta; otherwise - // best-effort auto resolution (on by default) where --meta wins. Auto is off - // when --no-auto, --no-git, or UPLOADS_NO_AUTO_META is set (unless --auto forces it). + // best-effort auto resolution (on by default) where --meta wins. --no-git, + // --no-auto, or UPLOADS_NO_AUTO_META disable auto; --auto forces past the + // config default but never past --no-git (no repo to resolve). let metadata = userMeta; + let attachedRef: string | undefined; if (ghTarget) { - metadata = { ...userMeta, ...ghMetadataFromTarget(ghTarget) }; + const merged = { ...userMeta, ...ghMetadataFromTarget(ghTarget) }; + validateMetaMap(merged); // enforce 24-key/8KB caps on the merged map (matches attach) + metadata = merged; + attachedRef = merged["gh.ref"]; } else { const autoEnabled = - flagBool(parsed.flags, "--auto") || - (!flagBool(parsed.flags, "--no-auto") && defaults.noAutoMeta !== true && !noGit); + !noGit && + !flagBool(parsed.flags, "--no-auto") && + (flagBool(parsed.flags, "--auto") || defaults.noAutoMeta !== true); if (autoEnabled) { const autoTarget = resolveAutoGhTarget( flagString(parsed.flags, "--repo") ?? defaults.repo, flagString(parsed.flags, "--ref") ?? defaults.ref, run, ); - if (autoTarget) metadata = { ...ghMetadataFromTarget(autoTarget), ...userMeta }; + if (autoTarget) { + const autoMeta = ghMetadataFromTarget(autoTarget); + const merged = { ...autoMeta, ...userMeta }; + // Auto resolution must never fail the upload: if merging the gh.* pairs + // would exceed the metadata caps, drop them and upload with --meta only. + try { + validateMetaMap(merged); + metadata = merged; + attachedRef = autoMeta["gh.ref"]; + } catch { + // keep metadata = userMeta (already validated); skip auto gh.* + } + } } } + if (attachedRef && !ctx.quiet && format === "human") { + process.stderr.write(`>> attached to ${attachedRef}\n`); + } let key = ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint; if (key && prepared.optimized) key = rewriteKeyExtension(key, filename); const result = await ctx.client.put(prepared.bytes, { diff --git a/packages/uploads/test/commands-put.test.ts b/packages/uploads/test/commands-put.test.ts index 0a1cd1d..89607dd 100644 --- a/packages/uploads/test/commands-put.test.ts +++ b/packages/uploads/test/commands-put.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { UsageError } from "../src/cli-args.js"; import { UploadsError } from "../src/errors.js"; import type { UploadsClient } from "../src/client.js"; @@ -482,3 +482,92 @@ describe("runPut auto gh.* metadata (default path)", () => { expect(puts[0].metadata!["gh.number"]).toBe("5"); }); }); + +/** 21 distinct valid `--meta k=v` flags: 21 + 4 gh.* = 25, one over META_MAX_KEYS (24). */ +function metaFlagsNearCap(): string[] { + const flags: string[] = []; + for (let i = 0; i < 21; i++) { + flags.push("--meta", `k${i}=v${i}`); + } + return flags; +} + +describe("runPut gh.* metadata cap enforcement", () => { + it("explicit path throws when the merged map exceeds the key cap", async () => { + const { client, puts } = fakeClient(); + await expect( + runPut( + ctxWith(client), + [tmpFile(), ...metaFlagsNearCap(), "--pr", "9", "--repo", "o/r"], + false, + noRun, + ), + ).rejects.toThrow(UsageError); + expect(puts).toEqual([]); + }); + + it("auto path drops gh.* and succeeds when the merged map exceeds the key cap", async () => { + const { client, puts } = fakeClient(); + const code = await runPut( + ctxWith(client), + [tmpFile(), ...metaFlagsNearCap(), "--repo", "o/r"], + false, + ghRunner({ pr: 481 }), + ); + expect(code).toBe(0); + expect(puts[0].metadata).toBeDefined(); + expect(puts[0].metadata!["gh.repo"]).toBeUndefined(); + expect(puts[0].metadata!["k0"]).toBe("v0"); + expect(puts[0].metadata!["k20"]).toBe("v20"); + expect(Object.keys(puts[0].metadata!)).toHaveLength(21); + }); + + it("--auto cannot force auto resolution past --no-git", async () => { + const { client, puts } = fakeClient(); + await runPut( + ctxWith(client), + [tmpFile(), "--repo", "o/r", "--auto", "--no-git"], + false, + ghRunner({ pr: 481 }), + ); + expect(puts[0].metadata).toBeUndefined(); + }); + + it("--auto takes no value", async () => { + const { client } = fakeClient(); + await expect( + runPut(ctxWith(client), [tmpFile(), "--auto=1", "--repo", "o/r"], false, noRun), + ).rejects.toThrow(UsageError); + }); +}); + +describe("runPut gh.* attach success note", () => { + it("prints a success note when gh.* metadata is attached, silent on skip", async () => { + const writeSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const { client: attachedClient } = fakeClient(); + await runPut( + { ...ctxWith(attachedClient), quiet: false }, + [tmpFile(), "--repo", "o/r"], + false, + ghRunner({ pr: 481 }), + ); + const attachedOutput = writeSpy.mock.calls.map((c) => String(c[0])).join(""); + expect(attachedOutput).toContain("attached to o/r#481"); + + writeSpy.mockClear(); + + const { client: skippedClient } = fakeClient(); + await runPut( + { ...ctxWith(skippedClient), quiet: false }, + [tmpFile(), "--repo", "o/r"], + false, + ghRunner({}), + ); + const skippedOutput = writeSpy.mock.calls.map((c) => String(c[0])).join(""); + expect(skippedOutput).not.toContain("attached to"); + } finally { + writeSpy.mockRestore(); + } + }); +}); diff --git a/skills/uploads-cli/SKILL.md b/skills/uploads-cli/SKILL.md index 4c48e6a..93926a4 100644 --- a/skills/uploads-cli/SKILL.md +++ b/skills/uploads-cli/SKILL.md @@ -231,7 +231,7 @@ reserved-by-convention keys automatically — see below. On the default `screenshots/…` path, `put` also auto-derives GitHub context and stamps `gh.repo`/`gh.kind`/`gh.number`/`gh.ref` from the current branch's PR (or a numeric `--ref`), so the file's `/f/` page shows an "Attached to" link. This is -on by default and best-effort; disable it with `--no-auto` or `UPLOADS_NO_AUTO_META=1`. +on by default and best-effort; disable it with `--no-auto`, `--no-git`, or `UPLOADS_NO_AUTO_META=1`. **Re-upload semantics:** re-uploading to an existing key **with** `--meta` replaces that file's entire metadata set (delete-then-set, not a merge); re-uploading From ae007da4f497fcceafb6e97b67f37086f9e33996 Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Mon, 13 Jul 2026 23:42:47 -0400 Subject: [PATCH 09/10] test(uploads): split bundled success-note test, extract captureStderr helper --- packages/uploads/test/commands-put.test.ts | 44 +++++++++++++--------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/uploads/test/commands-put.test.ts b/packages/uploads/test/commands-put.test.ts index 89607dd..2ab9792 100644 --- a/packages/uploads/test/commands-put.test.ts +++ b/packages/uploads/test/commands-put.test.ts @@ -542,32 +542,40 @@ describe("runPut gh.* metadata cap enforcement", () => { }); describe("runPut gh.* attach success note", () => { - it("prints a success note when gh.* metadata is attached, silent on skip", async () => { + /** Run `fn` with process.stderr.write captured, returning the concatenated output. */ + async function captureStderr(fn: () => Promise): Promise { const writeSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); try { - const { client: attachedClient } = fakeClient(); - await runPut( - { ...ctxWith(attachedClient), quiet: false }, + await fn(); + return writeSpy.mock.calls.map((c) => String(c[0])).join(""); + } finally { + writeSpy.mockRestore(); + } + } + + it("prints a success note when gh.* metadata is attached", async () => { + const { client } = fakeClient(); + const output = await captureStderr(() => + runPut( + { ...ctxWith(client), quiet: false }, [tmpFile(), "--repo", "o/r"], false, ghRunner({ pr: 481 }), - ); - const attachedOutput = writeSpy.mock.calls.map((c) => String(c[0])).join(""); - expect(attachedOutput).toContain("attached to o/r#481"); - - writeSpy.mockClear(); + ), + ); + expect(output).toContain("attached to o/r#481"); + }); - const { client: skippedClient } = fakeClient(); - await runPut( - { ...ctxWith(skippedClient), quiet: false }, + it("stays silent when no PR resolves", async () => { + const { client } = fakeClient(); + const output = await captureStderr(() => + runPut( + { ...ctxWith(client), quiet: false }, [tmpFile(), "--repo", "o/r"], false, ghRunner({}), - ); - const skippedOutput = writeSpy.mock.calls.map((c) => String(c[0])).join(""); - expect(skippedOutput).not.toContain("attached to"); - } finally { - writeSpy.mockRestore(); - } + ), + ); + expect(output).not.toContain("attached to"); }); }); From 374f32f307b953748933e1c955e81960e920e959 Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Mon, 13 Jul 2026 23:58:20 -0400 Subject: [PATCH 10/10] fix(uploads): address CodeRabbit review on auto gh.* metadata - auto path: source the success-note ref from the merged map (consistent with the stored metadata and the explicit-target path) - docs: add UPLOADS_NO_AUTO_META to the recognized config keys, document auto-path --meta precedence, fence the plan-doc help snippet - test: UPLOADS_NO_AUTO_META=1 gating runPut end-to-end --- .../2026-07-13-uploads-put-auto-gh-metadata.md | 2 +- packages/uploads/src/commands.ts | 2 +- packages/uploads/test/commands-put.test.ts | 13 +++++++++++++ skills/uploads-cli/SKILL.md | 5 ++++- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-07-13-uploads-put-auto-gh-metadata.md b/docs/superpowers/plans/2026-07-13-uploads-put-auto-gh-metadata.md index 92f581d..a32efa9 100644 --- a/docs/superpowers/plans/2026-07-13-uploads-put-auto-gh-metadata.md +++ b/docs/superpowers/plans/2026-07-13-uploads-put-auto-gh-metadata.md @@ -459,7 +459,7 @@ if (ghTarget) { 3d. Update `PUT_HELP` — add these lines in the gh/metadata section (after the `--no-git` line): -``` +```text --auto Resolve current PR/issue and stamp gh.* metadata (default on) --no-auto Skip gh.* auto-resolution on the default path (or UPLOADS_NO_AUTO_META=1) ``` diff --git a/packages/uploads/src/commands.ts b/packages/uploads/src/commands.ts index ac11ee5..6eb8db9 100644 --- a/packages/uploads/src/commands.ts +++ b/packages/uploads/src/commands.ts @@ -648,7 +648,7 @@ export async function runPut( try { validateMetaMap(merged); metadata = merged; - attachedRef = autoMeta["gh.ref"]; + attachedRef = merged["gh.ref"]; } catch { // keep metadata = userMeta (already validated); skip auto gh.* } diff --git a/packages/uploads/test/commands-put.test.ts b/packages/uploads/test/commands-put.test.ts index 2ab9792..b5a43aa 100644 --- a/packages/uploads/test/commands-put.test.ts +++ b/packages/uploads/test/commands-put.test.ts @@ -471,6 +471,19 @@ describe("runPut auto gh.* metadata (default path)", () => { expect(puts[0].metadata).toBeUndefined(); }); + it("UPLOADS_NO_AUTO_META=1 suppresses auto resolution end-to-end", async () => { + const prev = process.env.UPLOADS_NO_AUTO_META; + process.env.UPLOADS_NO_AUTO_META = "1"; + try { + const { client, puts } = fakeClient(); + await runPut(ctxWith(client), [tmpFile(), "--repo", "o/r"], false, ghRunner({ pr: 481 })); + expect(puts[0].metadata).toBeUndefined(); + } finally { + if (prev === undefined) delete process.env.UPLOADS_NO_AUTO_META; + else process.env.UPLOADS_NO_AUTO_META = prev; + } + }); + it("explicit --meta wins over auto-derived gh.*", async () => { const { client, puts } = fakeClient(); await runPut( diff --git a/skills/uploads-cli/SKILL.md b/skills/uploads-cli/SKILL.md index 93926a4..7b01ec6 100644 --- a/skills/uploads-cli/SKILL.md +++ b/skills/uploads-cli/SKILL.md @@ -232,6 +232,8 @@ On the default `screenshots/…` path, `put` also auto-derives GitHub context an stamps `gh.repo`/`gh.kind`/`gh.number`/`gh.ref` from the current branch's PR (or a numeric `--ref`), so the file's `/f/` page shows an "Attached to" link. This is on by default and best-effort; disable it with `--no-auto`, `--no-git`, or `UPLOADS_NO_AUTO_META=1`. +On this auto path an explicit `--meta gh.*` overrides the auto-derived value — the +opposite of the `--pr`/`--issue` precedence below, where the target's own `gh.*` always wins. **Re-upload semantics:** re-uploading to an existing key **with** `--meta` replaces that file's entire metadata set (delete-then-set, not a merge); re-uploading @@ -378,7 +380,8 @@ uploads config init --api-url http://localhost:8787 --workspace default --token Recognized keys: `UPLOADS_API_URL`, `UPLOADS_WORKSPACE`, `UPLOADS_TOKEN`, `UPLOADS_DEFAULT_PREFIX`, `UPLOADS_DEFAULT_REPO`, `UPLOADS_DEFAULT_REF`, -`UPLOADS_DEFAULT_WIDTH`, `UPLOADS_NO_GIT`, `UPLOADS_NO_OPTIMIZE`, `UPLOADS_KEEP_EXIF`. +`UPLOADS_DEFAULT_WIDTH`, `UPLOADS_NO_GIT`, `UPLOADS_NO_OPTIMIZE`, `UPLOADS_KEEP_EXIF`, +`UPLOADS_NO_AUTO_META`. Also read (env only, not config-file keys): `UPLOADS_EMBED_PUBLIC_BASE_URL`. ## Local development