From d945fc589eb85064ef8b868a28f44579ea6b0a3a Mon Sep 17 00:00:00 2001 From: Ryan L'Italien Date: Fri, 25 Sep 2026 09:58:05 -0400 Subject: [PATCH] feat: add butter changes list / show, and resolve a build's commit to its change The changes API (commits and changelists across git, Perforce, and Lore) had no CLI command, so "did my commit land in ButterStack?" could only be answered in the web UI. - `butter changes list --project ` with --source lore|git|perforce, --since, --identifier, --orphaned, --limit, --after , --json. Lore and Perforce share one source type server-side and are split here by the lore- identifier prefix. --json keeps the pagination envelope. - `butter changes show `: an all-digit argument is the change id; anything else (full SHA, p4-, lore-) is resolved through the exact identifier filter first. A Perforce build stores p4- while its change stores , so the prefix is stripped. - `builds show` prints the `changes show` command for its commit. - A 403 naming read:changes says to run `butter auth login` again, since tokens issued before the scope joined the CLI set do not carry it. - The read-only scope preset now includes read:changes. The paging flag is --after, not --cursor: --cursor is already the boolean client flag of `butter mcp install`. --- README.md | 16 +++ bin/butter | 215 ++++++++++++++++++++++++++++++- test/butter.test.js | 307 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 536 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index af58ea6..0d39f4e 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,27 @@ butter tasks create "" --project <id> [--type <type>] [--priority <priori ``` butter builds list --project <id> [--status <status>] [--type <type>] [--limit <n>] [--json] +butter builds show <build_id> --project <id> [--json] butter builds investigate <build_id> --project <id> [--json] ``` `builds investigate` triggers an AI failure investigation on a build run and prints the diagnosis and suggested fix. +### Changes + +Commits and changelists across the git, Perforce, and Lore rails. + +``` +butter changes list --project <id> [--source lore|git|perforce] [--since <date>] [--identifier <id>] [--orphaned] [--limit <n>] [--after <cursor>] [--json] +butter changes show <change_id|commit> --project <id> [--json] +``` + +`changes show` takes either the change id that `changes list` prints, or a commit reference: a full git SHA, a Perforce build's `p4-<n>`, or a Lore `lore-<n>`. That is how a build's commit (the `Commit:` line of `builds show`) is resolved to the change it belongs to. Matching is exact, so short SHAs do not resolve. An all-digit argument is always read as a change id; to look up a bare Perforce changelist number use `changes list --identifier <n>`. + +`lore` and `perforce` share one type server-side and are split on this side, so a filtered page can hold fewer than `--limit` rows. `changes list` prints the `--after` value for the next page when there is one; `--json` returns the whole `{changes, pagination}` envelope. Orphaned (ghost) commits are excluded unless you pass `--orphaned`. + +These commands need the `read:changes` scope. Tokens issued before it was added to the CLI scope set do not carry it and get a `403 insufficient_scope`: run `butter auth login` again to reissue. + ### Assets ``` diff --git a/bin/butter b/bin/butter index a61559e..59bcb5c 100755 --- a/bin/butter +++ b/bin/butter @@ -223,7 +223,7 @@ function request(method, endpoint, body = null, tokenOverride = null, opts = {}) // raw comma/space-separated permission list directly (passed through // as-is, validated server-side against the CLI-eligible scope set). const SCOPE_PRESETS = { - "read-only": "read:projects read:tasks read:builds read:assets", + "read-only": "read:projects read:tasks read:builds read:assets read:changes", full: "" // empty scope param -> server defaults to the full CLI set }; @@ -615,6 +615,9 @@ async function buildsShow(args) { console.log(` Overall: ${res.overall_status || "-"}`); console.log(` Job: ${res.ci_job_name || "-"}`); console.log(` Commit: ${res.commit_hash || "-"}`); + if (res.commit_hash) { + console.log(` Change: ${colors.muted}butter changes show ${res.commit_hash} --project ${projectId}${colors.reset}`); + } console.log(` Type: ${res.build_type || "-"}${res.target_type ? ` / ${res.target_type}` : ""}`); console.log(` Duration: ${res.duration != null ? `${res.duration}s` : "-"}`); console.log(` Created: ${res.created_at || "-"}`); @@ -781,6 +784,201 @@ async function buildsInvestigate(args) { } } +// -- Changes: commits and changelists across the git, Perforce and Lore rails -- + +// Friendly --source names mapped onto the API's source_type. Lore and +// Perforce share source_type "Changelist", so those two are split by +// identifier on this side: Lore changelists are namespaced "lore-<n>", +// Perforce ones are bare numbers. The raw API values are accepted as-is. +const CHANGE_SOURCES = { + git: { sourceType: "GitCommit" }, + perforce: { sourceType: "Changelist", keep: (c) => c.source_type === "Changelist" && !String(c.identifier).startsWith("lore-") }, + lore: { sourceType: "Changelist", keep: (c) => c.source_type === "Changelist" && String(c.identifier).startsWith("lore-") }, + GitCommit: { sourceType: "GitCommit" }, + AssetCommit: { sourceType: "AssetCommit" }, + Changelist: { sourceType: "Changelist" } +}; + +function changeSourceLabel(c) { + if (c.source_type === "GitCommit") return "git"; + if (c.source_type === "AssetCommit") return "asset"; + if (c.source_type === "Changelist") return String(c.identifier).startsWith("lore-") ? "lore" : "perforce"; + return c.source_type || "-"; +} + +// A BuildRun's commit_hash is not always a Change identifier verbatim: a +// Perforce build stores "p4-<n>" while its Change stores the bare "<n>". +// The identifier filter is exact and never validated, so passing "p4-207" +// through unchanged returns an empty list rather than an error. +function changeLookupFor(ref) { + const m = /^p4-(\d+)$/.exec(ref); + if (m) return { identifier: m[1], sourceType: "Changelist" }; + return { identifier: ref, sourceType: null }; +} + +function reportChangesError(action, err) { + console.error(`${colors.err}✗ Failed to ${action}: ${err.message}${colors.reset}`); + const body = err.response; + if (err.statusCode === 403 && body && body.required_scope === "read:changes") { + console.error( + `${colors.muted} This token does not carry read:changes. Tokens issued before the changes commands shipped never got it: run \`butter auth login\` again to reissue.${colors.reset}` + ); + } + process.exit(1); +} + +async function changesList(args) { + const projectId = args.project || loadConfig().defaultProject; + if (!projectId) { + console.error(`${colors.err}✗ Missing project ID. Use --project <id>${colors.reset}`); + process.exit(1); + } + + const query = new URLSearchParams(); + let keep = null; + if (args.source) { + const source = CHANGE_SOURCES[args.source]; + if (!source) { + console.error(`${colors.err}✗ Unknown --source "${args.source}". Use lore, git, or perforce.${colors.reset}`); + process.exit(1); + } + query.set("source_type", source.sourceType); + keep = source.keep || null; + } + if (args.identifier) { + const lookup = changeLookupFor(String(args.identifier)); + query.set("identifier", lookup.identifier); + if (lookup.sourceType && !args.source) query.set("source_type", lookup.sourceType); + } + if (args.since) query.set("updated_since", args.since); + if (args.orphaned) query.set("orphaned", "true"); + if (args.limit) query.set("limit", args.limit); + // --after, not --cursor: `--cursor` is already the boolean client flag of + // `butter mcp install`, so it can never carry a value. + if (typeof args.after === "string") query.set("cursor", args.after); + + try { + const res = await request("GET", `/api/v1/projects/${projectId}/changes?${query.toString()}`); + const changes = keep ? res.changes.filter(keep) : res.changes; + if (args.json) { + // The whole envelope, not just the array: without pagination a script + // has no next_cursor to fetch the page after this one. + console.log(JSON.stringify({ changes, pagination: res.pagination }, null, 2)); + return; + } + console.log(`\n${colors.bold}CHANGES for Project #${projectId} (${changes.length})${colors.reset}`); + console.log("----------------------------------------------------------------------"); + changes.forEach((c) => { + const summary = String(c.description || "").split("\n")[0]; + const orphaned = c.orphaned_at ? ` ${colors.warn}(orphaned)${colors.reset}` : ""; + console.log( + ` Change #${c.id.toString().padEnd(6)} ${colors.accent}[${changeSourceLabel(c)}]${colors.reset} ${c.display_identifier || c.identifier} ${colors.muted}${c.author || "-"}${colors.reset} ${summary}${orphaned}` + ); + }); + if (res.pagination && res.pagination.has_more && res.pagination.next_cursor) { + console.log( + `\n ${colors.muted}More: butter changes list --project ${projectId} --after ${res.pagination.next_cursor}${colors.reset}` + ); + } + console.log(""); + } catch (err) { + reportChangesError("list changes", err); + } +} + +async function changesShow(args) { + const projectId = args.project || loadConfig().defaultProject; + const ref = args._[2] !== undefined ? String(args._[2]) : null; + if (!projectId || !ref) { + console.error(`${colors.err}✗ Usage: butter changes show <change_id|commit> --project <id>${colors.reset}`); + process.exit(1); + } + + try { + // An all-digit ref is the change id that `changes list` prints. Anything + // else is a commit reference (a git SHA, a build's p4-<n>, a lore-<n>), + // resolved through the exact-match identifier filter first. + let changeId = ref; + if (!/^\d+$/.test(ref)) { + const lookup = changeLookupFor(ref); + const query = new URLSearchParams({ identifier: lookup.identifier, orphaned: "true" }); + if (lookup.sourceType) query.set("source_type", lookup.sourceType); + const found = await request("GET", `/api/v1/projects/${projectId}/changes?${query.toString()}`); + if (!found.changes || found.changes.length === 0) { + console.error(`${colors.err}✗ No change with identifier "${lookup.identifier}" in project ${projectId}.${colors.reset}`); + console.error( + `${colors.muted} The match is exact: use the full commit SHA, a Perforce build's p4-<n>, or lore-<n>. A bare Perforce changelist number is read as a change id; use: butter changes list --project ${projectId} --identifier <n>${colors.reset}` + ); + process.exit(1); + } + changeId = found.changes[0].id; + } + + const res = await request("GET", `/api/v1/projects/${projectId}/changes/${changeId}`); + if (args.json) { + console.log(JSON.stringify(res, null, 2)); + return; + } + + console.log(`\n${colors.bold}CHANGE ${res.id}${colors.reset}`); + console.log("----------------------------------------------------------------------"); + console.log(` Identifier: ${colors.accent}${res.identifier || "-"}${colors.reset}`); + console.log(` Source: ${changeSourceLabel(res)} (${res.source_type || "-"})`); + console.log(` Author: ${res.author || "-"}`); + console.log(` Timestamp: ${res.timestamp || "-"}`); + if (res.orphaned_at) { + console.log(` Orphaned: ${colors.warn}${res.orphaned_at}${colors.reset}`); + } + if (res.files_available === false) { + console.log(` Files: ${colors.muted}not available for this change${colors.reset}`); + } else { + console.log(` Files: ${res.file_count != null ? res.file_count : Array.isArray(res.files) ? res.files.length : "-"}`); + } + const a = res.approvals; + if (a) { + console.log(` Approvals: ${a.approved} approved, ${a.pending} pending, ${a.denied} denied, ${a.ignored} ignored`); + } + const tasks = Array.isArray(res.task_ids) && res.task_ids.length ? res.task_ids.map((id) => `#${id}`).join(", ") : "none"; + console.log(` Tasks: ${tasks}`); + + const b = res.latest_build; + if (b) { + const statusColor = b.status === "completed" ? colors.ok : b.status === "failed" ? colors.err : colors.warn; + console.log(` Latest build: ${b.id} ${statusColor}${b.status}${colors.reset}${b.ci_job_name ? ` ${b.ci_job_name}` : ""}`); + } else { + console.log(` Latest build: ${colors.muted}none${colors.reset}`); + } + + if (res.description) { + console.log(`\n${colors.bold} Description:${colors.reset}`); + String(res.description) + .trimEnd() + .split("\n") + .forEach((line) => console.log(` ${line}`)); + } + + if (Array.isArray(res.build_runs) && res.build_runs.length) { + console.log(`\n${colors.bold} Builds (${res.build_runs.length}):${colors.reset}`); + res.build_runs.forEach((br) => { + const c = br.status === "completed" ? colors.ok : br.status === "failed" ? colors.err : colors.warn; + console.log(` ${br.id} ${c}[${br.status}]${colors.reset} ${br.ci_job_name || ""} ${colors.muted}${br.commit_hash || ""}${colors.reset}`); + }); + } + + if (Array.isArray(res.files) && res.files.length) { + const shown = res.files.slice(0, 20); + console.log(`\n${colors.bold} Files (${res.files.length}):${colors.reset}`); + shown.forEach((f) => console.log(` ${colors.muted}${(f.action || "").padEnd(8)}${colors.reset} ${f.path || "-"}`)); + if (res.files.length > shown.length) { + console.log(` ${colors.muted}... and ${res.files.length - shown.length} more (use --json for all)${colors.reset}`); + } + } + console.log(""); + } catch (err) { + reportChangesError("show change", err); + } +} + async function assetsList(args) { const projectId = args.project || loadConfig().defaultProject; if (!projectId) { @@ -1172,7 +1370,8 @@ const BOOLEAN_FLAGS = new Set([ "dry-run", "opencode", "claude", - "cursor" + "cursor", + "orphaned" ]); // `--key=value` is always explicit, so it is honored for any key including @@ -1220,6 +1419,11 @@ ${colors.bold}COMMANDS:${colors.reset} Inspect CI/CD builds and AI failure investigations. ${colors.bold}investigate${colors.reset} STARTS a paid AI analysis; ${colors.bold}investigation${colors.reset} reads the result back for free. + ${colors.accent}changes${colors.reset} list | show <id|commit> Commits and changelists (git, Perforce, Lore). + ${colors.bold}show${colors.reset} takes a change id, or a build's commit + (SHA, p4-<n>, lore-<n>) to find the change it belongs to. + list filters: --source lore|git|perforce, --since <date>, + --identifier <x>, --orphaned, --limit <n>, --after <cursor> ${colors.accent}assets${colors.reset} list | approve | deny Review and approve textures, models, and audio ${colors.bold}show${colors.reset} is coming soon ${colors.accent}mcp${colors.reset} install Connect OpenCode, Claude Desktop, and Cursor @@ -1240,6 +1444,8 @@ ${colors.bold}AUTH OPTIONS:${colors.reset} Tokens expire 90 days after issue; ${colors.bold}butter auth whoami${colors.reset} shows the remaining lifetime and ${colors.bold}butter auth login${colors.reset} again renews it. +The ${colors.bold}changes${colors.reset} commands need the read:changes scope; a token issued before +they shipped does not have it, so run ${colors.bold}butter auth login${colors.reset} again once. `); } @@ -1281,6 +1487,11 @@ async function main() { if (subcmd === "investigate") return buildsInvestigate(args); if (subcmd === "investigation") return buildsInvestigation(args); break; + case "changes": + case "change": + if (subcmd === "list" || !subcmd) return changesList(args); + if (subcmd === "show") return changesShow(args); + break; case "assets": case "asset": if (subcmd === "list" || !subcmd) return assetsList(args); diff --git a/test/butter.test.js b/test/butter.test.js index 8853559..ab1190c 100644 --- a/test/butter.test.js +++ b/test/butter.test.js @@ -709,6 +709,313 @@ function cleanUrl(u) { return stripAnsi(u); } +// -- changes: the read path for commits and changelists ---------------------- +// +// The changes API existed with no client. A build carries a commit_hash, and +// these commands are how that hash is resolved to the change it belongs to +// without the web UI. + +const GIT_SHA = "3f9c2a7be41d5e6f7a8b9c0d1e2f3a4b5c6d7e8f"; + +const CHANGES_PAGE = { + changes: [ + { + id: 456, + identifier: "207", + display_identifier: "#207", + source_type: "Changelist", + author: "jdoe", + description: "Fix spawn timing\n\nLonger body", + timestamp: "2026-09-18T10:50:00Z", + orphaned_at: null, + file_count: 3, + approval_count: 1 + }, + { + id: 457, + identifier: "lore-12", + display_identifier: "lore-12", + source_type: "Changelist", + author: "asmith", + description: "Lore revision", + timestamp: "2026-09-18T11:00:00Z", + orphaned_at: null, + file_count: 1, + approval_count: 0 + }, + { + id: 458, + identifier: GIT_SHA, + display_identifier: "3f9c2a7", + source_type: "GitCommit", + author: "kdev", + description: "Ghost commit", + timestamp: "2026-09-18T12:00:00Z", + orphaned_at: "2026-09-19T00:00:00Z", + file_count: null, + approval_count: 0 + } + ], + pagination: { limit: 20, has_more: true, next_cursor: "CURSOR123" } +}; + +const CHANGE_DETAIL = { + ...CHANGES_PAGE.changes[0], + files: [{ path: "//depot/Game/main.gd", action: "edit" }], + files_available: true, + approvals: { pending: 0, approved: 1, denied: 0, ignored: 0 }, + build_runs: [{ id: BUILD_ID, status: "failed", commit_hash: "p4-207", ci_job_name: "PilotLight_Verify" }], + latest_build: { id: BUILD_ID, status: "failed", commit_hash: "p4-207", ci_job_name: "PilotLight_Verify" }, + task_ids: [12] +}; + +test("changes: `changes list` renders a project's changes and says how to get the next page", async () => { + const home = mkHome(); + const { server, port, requests } = await startApiServer({ + "GET /api/v1/projects/108/changes": { body: CHANGES_PAGE } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const { stdout, stderr, success } = await runButter(["changes", "list", "--project", "108"], { home }); + + assert.equal(success, true, `expected success, got stderr: ${stderr}`); + const out = stripAnsi(stdout); + assert.ok(out.includes("CHANGES for Project #108 (3)")); + assert.match(out, /Change #456\s+\[perforce\] #207 jdoe Fix spawn timing/); + assert.ok(!out.includes("Longer body"), "only the first line of the description belongs in the list"); + assert.match(out, /\[lore\] lore-12/); + assert.match(out, /\[git\] 3f9c2a7 kdev Ghost commit \(orphaned\)/); + assert.ok(out.includes("--after CURSOR123"), "a caller needs the cursor to reach the next page"); + + const req = await requests.pop(2000); + assert.equal(req.url, "/api/v1/projects/108/changes"); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + +test("changes: `changes list` maps its flags onto the API's query params", async () => { + const home = mkHome(); + const { server, port, requests } = await startApiServer({ + "GET /api/v1/projects/108/changes": { body: CHANGES_PAGE } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const { success } = await runButter( + ["changes", "list", "--project", "108", "--source", "git", "--since", "2026-09-01", "--limit", "5", "--orphaned", "--after", "abc"], + { home } + ); + assert.equal(success, true); + + const req = await requests.pop(2000); + const params = new URL(req.url, "http://x").searchParams; + assert.equal(params.get("source_type"), "GitCommit"); + assert.equal(params.get("updated_since"), "2026-09-01"); + assert.equal(params.get("limit"), "5"); + assert.equal(params.get("orphaned"), "true"); + assert.equal(params.get("cursor"), "abc"); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + +test("changes: --source lore and --source perforce split the shared Changelist type", async () => { + const home = mkHome(); + const { server, port, requests } = await startApiServer({ + "GET /api/v1/projects/108/changes": { body: CHANGES_PAGE } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + + const lore = await runButter(["changes", "list", "--project", "108", "--source", "lore", "--json"], { home }); + assert.equal(lore.success, true); + assert.deepEqual(JSON.parse(lore.stdout).changes.map((c) => c.id), [457]); + const loreReq = await requests.pop(2000); + assert.equal(new URL(loreReq.url, "http://x").searchParams.get("source_type"), "Changelist"); + + const p4 = await runButter(["changes", "list", "--project", "108", "--source", "perforce", "--json"], { home }); + assert.equal(p4.success, true); + assert.deepEqual(JSON.parse(p4.stdout).changes.map((c) => c.id), [456]); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + +test("changes: `changes list --json` keeps the pagination envelope", async () => { + const home = mkHome(); + const { server, port } = await startApiServer({ + "GET /api/v1/projects/108/changes": { body: CHANGES_PAGE } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const { stdout, success } = await runButter(["changes", "list", "--json", "--project", "108"], { home }); + assert.equal(success, true); + assert.deepEqual(JSON.parse(stdout), CHANGES_PAGE); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + +test("changes: an unknown --source fails loudly and sends nothing", async () => { + const home = mkHome(); + const { server, port, requests } = await startApiServer({}); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const { stderr, success } = await runButter(["changes", "list", "--project", "108", "--source", "svn"], { home }); + assert.equal(success, false); + assert.ok(stderr.includes('Unknown --source "svn"')); + assert.equal(await requests.pop(200), null, "no request should be sent for a bad flag"); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + +test("changes: `changes show <id>` renders one change with its builds and files", async () => { + const home = mkHome(); + const { server, port, requests } = await startApiServer({ + "GET /api/v1/projects/108/changes/456": { body: CHANGE_DETAIL } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const { stdout, stderr, success } = await runButter(["changes", "show", "456", "--project", "108"], { home }); + + assert.equal(success, true, `expected success, got stderr: ${stderr}`); + const out = stripAnsi(stdout); + assert.ok(out.includes("CHANGE 456")); + assert.match(out, /Identifier:\s+207/); + assert.match(out, /Source:\s+perforce \(Changelist\)/); + assert.match(out, /Approvals:\s+1 approved, 0 pending/); + assert.match(out, /Tasks:\s+#12/); + assert.ok(out.includes("Longer body"), "the full description belongs on the detail view"); + assert.ok(out.includes(BUILD_ID), "linked builds should be listed"); + assert.ok(out.includes("//depot/Game/main.gd"), "files should be listed"); + + const req = await requests.pop(2000); + assert.equal(req.url, "/api/v1/projects/108/changes/456"); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + +test("changes: `changes show p4-<n>` resolves a Perforce build's commit to its change", async () => { + const home = mkHome(); + const { server, port, requests } = await startApiServer({ + "GET /api/v1/projects/108/changes": { body: { changes: [CHANGES_PAGE.changes[0]], pagination: { has_more: false } } }, + "GET /api/v1/projects/108/changes/456": { body: CHANGE_DETAIL } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const { stdout, stderr, success } = await runButter(["changes", "show", "p4-207", "--project", "108"], { home }); + assert.equal(success, true, `expected success, got stderr: ${stderr}`); + assert.ok(stripAnsi(stdout).includes("CHANGE 456")); + + // The change stores the bare number, so "p4-207" verbatim would match nothing. + const lookup = await requests.pop(2000); + const params = new URL(lookup.url, "http://x").searchParams; + assert.equal(params.get("identifier"), "207"); + assert.equal(params.get("source_type"), "Changelist"); + assert.equal(params.get("orphaned"), "true", "a build's commit can belong to an orphaned change"); + + const detail = await requests.pop(2000); + assert.equal(detail.url, "/api/v1/projects/108/changes/456"); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + +test("changes: `changes show <sha> --json` resolves a git commit and prints the detail", async () => { + const home = mkHome(); + const gitDetail = { ...CHANGE_DETAIL, id: 458, identifier: GIT_SHA, source_type: "GitCommit" }; + const { server, port, requests } = await startApiServer({ + "GET /api/v1/projects/108/changes": { body: { changes: [CHANGES_PAGE.changes[2]], pagination: { has_more: false } } }, + "GET /api/v1/projects/108/changes/458": { body: gitDetail } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const { stdout, success } = await runButter(["changes", "show", "--json", GIT_SHA, "--project", "108"], { home }); + assert.equal(success, true); + assert.deepEqual(JSON.parse(stdout), gitDetail); + + const lookup = await requests.pop(2000); + const params = new URL(lookup.url, "http://x").searchParams; + assert.equal(params.get("identifier"), GIT_SHA); + assert.equal(params.get("source_type"), null); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + +test("changes: a commit with no matching change fails loudly instead of printing nothing", async () => { + const home = mkHome(); + const { server, port } = await startApiServer({ + "GET /api/v1/projects/108/changes": { body: { changes: [], pagination: { has_more: false } } } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const { stderr, success } = await runButter(["changes", "show", "deadbeef", "--project", "108"], { home }); + assert.equal(success, false); + assert.ok(stderr.includes('No change with identifier "deadbeef" in project 108')); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + +test("changes: a token without read:changes is told to log in again", async () => { + const home = mkHome(); + const { server, port } = await startApiServer({ + "GET /api/v1/projects/108/changes": { + status: 403, + body: { error: "insufficient_scope", required_scope: "read:changes", token_scopes: ["read:builds"] } + } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const { stderr, success } = await runButter(["changes", "list", "--project", "108"], { home }); + assert.equal(success, false); + assert.ok(stderr.includes("insufficient_scope")); + assert.ok(stderr.includes("butter auth login"), "the fix for an old token should be named"); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + +test("changes: `changes show` without an id prints usage and exits non-zero", async () => { + const home = mkHome(); + try { + const { stderr, success } = await runButter(["changes", "show", "--project", "108"], { home }); + assert.equal(success, false); + assert.ok(stderr.includes("Usage: butter changes show")); + } finally { + rmHome(home); + } +}); + +test("changes: `builds show` points at the change for the build's commit", async () => { + const home = mkHome(); + const { server, port } = await startApiServer({ + [`GET /api/v1/projects/108/build_runs/${BUILD_ID}`]: { body: BUILD_DETAIL } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const { stdout, success } = await runButter(["builds", "show", BUILD_ID, "--project", "108"], { home }); + assert.equal(success, true); + assert.ok(stripAnsi(stdout).includes("butter changes show p4-207 --project 108")); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + test("auth login prints the URL exactly once, and does not claim to open a browser", async () => { const home = mkHome(); const { server, port } = await startCaptureServer();