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();