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 [--type ] [--priority [--status ] [--type ] [--limit ] [--json]
+butter builds show --project [--json]
butter builds investigate --project [--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 [--source lore|git|perforce] [--since ] [--identifier ] [--orphaned] [--limit ] [--after ] [--json]
+butter changes show --project [--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-`, or a Lore `lore-`. 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 `.
+
+`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-",
+// 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-" while its Change stores the bare "".
+// 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 ${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 --project ${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-, a lore-),
+ // 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-, or lore-. A bare Perforce changelist number is read as a change id; use: butter changes list --project ${projectId} --identifier ${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 Commits and changelists (git, Perforce, Lore).
+ ${colors.bold}show${colors.reset} takes a change id, or a build's commit
+ (SHA, p4-, lore-) to find the change it belongs to.
+ list filters: --source lore|git|perforce, --since ,
+ --identifier , --orphaned, --limit , --after
${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 ` 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-` 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 --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();