Skip to content

Commit d945fc5

Browse files
committed
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 <id>` with --source lore|git|perforce, --since, --identifier, --orphaned, --limit, --after <cursor>, --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 <change_id|commit>`: an all-digit argument is the change id; anything else (full SHA, p4-<n>, lore-<n>) is resolved through the exact identifier filter first. A Perforce build stores p4-<n> while its change stores <n>, 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`.
1 parent 6d6f837 commit d945fc5

3 files changed

Lines changed: 536 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,27 @@ butter tasks create "<title>" --project <id> [--type <type>] [--priority <priori
6767

6868
```
6969
butter builds list --project <id> [--status <status>] [--type <type>] [--limit <n>] [--json]
70+
butter builds show <build_id> --project <id> [--json]
7071
butter builds investigate <build_id> --project <id> [--json]
7172
```
7273

7374
`builds investigate` triggers an AI failure investigation on a build run and prints the diagnosis and suggested fix.
7475

76+
### Changes
77+
78+
Commits and changelists across the git, Perforce, and Lore rails.
79+
80+
```
81+
butter changes list --project <id> [--source lore|git|perforce] [--since <date>] [--identifier <id>] [--orphaned] [--limit <n>] [--after <cursor>] [--json]
82+
butter changes show <change_id|commit> --project <id> [--json]
83+
```
84+
85+
`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>`.
86+
87+
`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`.
88+
89+
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.
90+
7591
### Assets
7692

7793
```

‎bin/butter‎

Lines changed: 213 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ function request(method, endpoint, body = null, tokenOverride = null, opts = {})
223223
// raw comma/space-separated permission list directly (passed through
224224
// as-is, validated server-side against the CLI-eligible scope set).
225225
const SCOPE_PRESETS = {
226-
"read-only": "read:projects read:tasks read:builds read:assets",
226+
"read-only": "read:projects read:tasks read:builds read:assets read:changes",
227227
full: "" // empty scope param -> server defaults to the full CLI set
228228
};
229229

@@ -615,6 +615,9 @@ async function buildsShow(args) {
615615
console.log(` Overall: ${res.overall_status || "-"}`);
616616
console.log(` Job: ${res.ci_job_name || "-"}`);
617617
console.log(` Commit: ${res.commit_hash || "-"}`);
618+
if (res.commit_hash) {
619+
console.log(` Change: ${colors.muted}butter changes show ${res.commit_hash} --project ${projectId}${colors.reset}`);
620+
}
618621
console.log(` Type: ${res.build_type || "-"}${res.target_type ? ` / ${res.target_type}` : ""}`);
619622
console.log(` Duration: ${res.duration != null ? `${res.duration}s` : "-"}`);
620623
console.log(` Created: ${res.created_at || "-"}`);
@@ -781,6 +784,201 @@ async function buildsInvestigate(args) {
781784
}
782785
}
783786

787+
// -- Changes: commits and changelists across the git, Perforce and Lore rails --
788+
789+
// Friendly --source names mapped onto the API's source_type. Lore and
790+
// Perforce share source_type "Changelist", so those two are split by
791+
// identifier on this side: Lore changelists are namespaced "lore-<n>",
792+
// Perforce ones are bare numbers. The raw API values are accepted as-is.
793+
const CHANGE_SOURCES = {
794+
git: { sourceType: "GitCommit" },
795+
perforce: { sourceType: "Changelist", keep: (c) => c.source_type === "Changelist" && !String(c.identifier).startsWith("lore-") },
796+
lore: { sourceType: "Changelist", keep: (c) => c.source_type === "Changelist" && String(c.identifier).startsWith("lore-") },
797+
GitCommit: { sourceType: "GitCommit" },
798+
AssetCommit: { sourceType: "AssetCommit" },
799+
Changelist: { sourceType: "Changelist" }
800+
};
801+
802+
function changeSourceLabel(c) {
803+
if (c.source_type === "GitCommit") return "git";
804+
if (c.source_type === "AssetCommit") return "asset";
805+
if (c.source_type === "Changelist") return String(c.identifier).startsWith("lore-") ? "lore" : "perforce";
806+
return c.source_type || "-";
807+
}
808+
809+
// A BuildRun's commit_hash is not always a Change identifier verbatim: a
810+
// Perforce build stores "p4-<n>" while its Change stores the bare "<n>".
811+
// The identifier filter is exact and never validated, so passing "p4-207"
812+
// through unchanged returns an empty list rather than an error.
813+
function changeLookupFor(ref) {
814+
const m = /^p4-(\d+)$/.exec(ref);
815+
if (m) return { identifier: m[1], sourceType: "Changelist" };
816+
return { identifier: ref, sourceType: null };
817+
}
818+
819+
function reportChangesError(action, err) {
820+
console.error(`${colors.err}✗ Failed to ${action}: ${err.message}${colors.reset}`);
821+
const body = err.response;
822+
if (err.statusCode === 403 && body && body.required_scope === "read:changes") {
823+
console.error(
824+
`${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}`
825+
);
826+
}
827+
process.exit(1);
828+
}
829+
830+
async function changesList(args) {
831+
const projectId = args.project || loadConfig().defaultProject;
832+
if (!projectId) {
833+
console.error(`${colors.err}✗ Missing project ID. Use --project <id>${colors.reset}`);
834+
process.exit(1);
835+
}
836+
837+
const query = new URLSearchParams();
838+
let keep = null;
839+
if (args.source) {
840+
const source = CHANGE_SOURCES[args.source];
841+
if (!source) {
842+
console.error(`${colors.err}✗ Unknown --source "${args.source}". Use lore, git, or perforce.${colors.reset}`);
843+
process.exit(1);
844+
}
845+
query.set("source_type", source.sourceType);
846+
keep = source.keep || null;
847+
}
848+
if (args.identifier) {
849+
const lookup = changeLookupFor(String(args.identifier));
850+
query.set("identifier", lookup.identifier);
851+
if (lookup.sourceType && !args.source) query.set("source_type", lookup.sourceType);
852+
}
853+
if (args.since) query.set("updated_since", args.since);
854+
if (args.orphaned) query.set("orphaned", "true");
855+
if (args.limit) query.set("limit", args.limit);
856+
// --after, not --cursor: `--cursor` is already the boolean client flag of
857+
// `butter mcp install`, so it can never carry a value.
858+
if (typeof args.after === "string") query.set("cursor", args.after);
859+
860+
try {
861+
const res = await request("GET", `/api/v1/projects/${projectId}/changes?${query.toString()}`);
862+
const changes = keep ? res.changes.filter(keep) : res.changes;
863+
if (args.json) {
864+
// The whole envelope, not just the array: without pagination a script
865+
// has no next_cursor to fetch the page after this one.
866+
console.log(JSON.stringify({ changes, pagination: res.pagination }, null, 2));
867+
return;
868+
}
869+
console.log(`\n${colors.bold}CHANGES for Project #${projectId} (${changes.length})${colors.reset}`);
870+
console.log("----------------------------------------------------------------------");
871+
changes.forEach((c) => {
872+
const summary = String(c.description || "").split("\n")[0];
873+
const orphaned = c.orphaned_at ? ` ${colors.warn}(orphaned)${colors.reset}` : "";
874+
console.log(
875+
` 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}`
876+
);
877+
});
878+
if (res.pagination && res.pagination.has_more && res.pagination.next_cursor) {
879+
console.log(
880+
`\n ${colors.muted}More: butter changes list --project ${projectId} --after ${res.pagination.next_cursor}${colors.reset}`
881+
);
882+
}
883+
console.log("");
884+
} catch (err) {
885+
reportChangesError("list changes", err);
886+
}
887+
}
888+
889+
async function changesShow(args) {
890+
const projectId = args.project || loadConfig().defaultProject;
891+
const ref = args._[2] !== undefined ? String(args._[2]) : null;
892+
if (!projectId || !ref) {
893+
console.error(`${colors.err}✗ Usage: butter changes show <change_id|commit> --project <id>${colors.reset}`);
894+
process.exit(1);
895+
}
896+
897+
try {
898+
// An all-digit ref is the change id that `changes list` prints. Anything
899+
// else is a commit reference (a git SHA, a build's p4-<n>, a lore-<n>),
900+
// resolved through the exact-match identifier filter first.
901+
let changeId = ref;
902+
if (!/^\d+$/.test(ref)) {
903+
const lookup = changeLookupFor(ref);
904+
const query = new URLSearchParams({ identifier: lookup.identifier, orphaned: "true" });
905+
if (lookup.sourceType) query.set("source_type", lookup.sourceType);
906+
const found = await request("GET", `/api/v1/projects/${projectId}/changes?${query.toString()}`);
907+
if (!found.changes || found.changes.length === 0) {
908+
console.error(`${colors.err}✗ No change with identifier "${lookup.identifier}" in project ${projectId}.${colors.reset}`);
909+
console.error(
910+
`${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}`
911+
);
912+
process.exit(1);
913+
}
914+
changeId = found.changes[0].id;
915+
}
916+
917+
const res = await request("GET", `/api/v1/projects/${projectId}/changes/${changeId}`);
918+
if (args.json) {
919+
console.log(JSON.stringify(res, null, 2));
920+
return;
921+
}
922+
923+
console.log(`\n${colors.bold}CHANGE ${res.id}${colors.reset}`);
924+
console.log("----------------------------------------------------------------------");
925+
console.log(` Identifier: ${colors.accent}${res.identifier || "-"}${colors.reset}`);
926+
console.log(` Source: ${changeSourceLabel(res)} (${res.source_type || "-"})`);
927+
console.log(` Author: ${res.author || "-"}`);
928+
console.log(` Timestamp: ${res.timestamp || "-"}`);
929+
if (res.orphaned_at) {
930+
console.log(` Orphaned: ${colors.warn}${res.orphaned_at}${colors.reset}`);
931+
}
932+
if (res.files_available === false) {
933+
console.log(` Files: ${colors.muted}not available for this change${colors.reset}`);
934+
} else {
935+
console.log(` Files: ${res.file_count != null ? res.file_count : Array.isArray(res.files) ? res.files.length : "-"}`);
936+
}
937+
const a = res.approvals;
938+
if (a) {
939+
console.log(` Approvals: ${a.approved} approved, ${a.pending} pending, ${a.denied} denied, ${a.ignored} ignored`);
940+
}
941+
const tasks = Array.isArray(res.task_ids) && res.task_ids.length ? res.task_ids.map((id) => `#${id}`).join(", ") : "none";
942+
console.log(` Tasks: ${tasks}`);
943+
944+
const b = res.latest_build;
945+
if (b) {
946+
const statusColor = b.status === "completed" ? colors.ok : b.status === "failed" ? colors.err : colors.warn;
947+
console.log(` Latest build: ${b.id} ${statusColor}${b.status}${colors.reset}${b.ci_job_name ? ` ${b.ci_job_name}` : ""}`);
948+
} else {
949+
console.log(` Latest build: ${colors.muted}none${colors.reset}`);
950+
}
951+
952+
if (res.description) {
953+
console.log(`\n${colors.bold} Description:${colors.reset}`);
954+
String(res.description)
955+
.trimEnd()
956+
.split("\n")
957+
.forEach((line) => console.log(` ${line}`));
958+
}
959+
960+
if (Array.isArray(res.build_runs) && res.build_runs.length) {
961+
console.log(`\n${colors.bold} Builds (${res.build_runs.length}):${colors.reset}`);
962+
res.build_runs.forEach((br) => {
963+
const c = br.status === "completed" ? colors.ok : br.status === "failed" ? colors.err : colors.warn;
964+
console.log(` ${br.id} ${c}[${br.status}]${colors.reset} ${br.ci_job_name || ""} ${colors.muted}${br.commit_hash || ""}${colors.reset}`);
965+
});
966+
}
967+
968+
if (Array.isArray(res.files) && res.files.length) {
969+
const shown = res.files.slice(0, 20);
970+
console.log(`\n${colors.bold} Files (${res.files.length}):${colors.reset}`);
971+
shown.forEach((f) => console.log(` ${colors.muted}${(f.action || "").padEnd(8)}${colors.reset} ${f.path || "-"}`));
972+
if (res.files.length > shown.length) {
973+
console.log(` ${colors.muted}... and ${res.files.length - shown.length} more (use --json for all)${colors.reset}`);
974+
}
975+
}
976+
console.log("");
977+
} catch (err) {
978+
reportChangesError("show change", err);
979+
}
980+
}
981+
784982
async function assetsList(args) {
785983
const projectId = args.project || loadConfig().defaultProject;
786984
if (!projectId) {
@@ -1172,7 +1370,8 @@ const BOOLEAN_FLAGS = new Set([
11721370
"dry-run",
11731371
"opencode",
11741372
"claude",
1175-
"cursor"
1373+
"cursor",
1374+
"orphaned"
11761375
]);
11771376

11781377
// `--key=value` is always explicit, so it is honored for any key including
@@ -1220,6 +1419,11 @@ ${colors.bold}COMMANDS:${colors.reset}
12201419
Inspect CI/CD builds and AI failure investigations.
12211420
${colors.bold}investigate${colors.reset} STARTS a paid AI analysis;
12221421
${colors.bold}investigation${colors.reset} reads the result back for free.
1422+
${colors.accent}changes${colors.reset} list | show <id|commit> Commits and changelists (git, Perforce, Lore).
1423+
${colors.bold}show${colors.reset} takes a change id, or a build's commit
1424+
(SHA, p4-<n>, lore-<n>) to find the change it belongs to.
1425+
list filters: --source lore|git|perforce, --since <date>,
1426+
--identifier <x>, --orphaned, --limit <n>, --after <cursor>
12231427
${colors.accent}assets${colors.reset} list | approve | deny Review and approve textures, models, and audio
12241428
${colors.bold}show${colors.reset} is coming soon
12251429
${colors.accent}mcp${colors.reset} install Connect OpenCode, Claude Desktop, and Cursor
@@ -1240,6 +1444,8 @@ ${colors.bold}AUTH OPTIONS:${colors.reset}
12401444
12411445
Tokens expire 90 days after issue; ${colors.bold}butter auth whoami${colors.reset} shows the
12421446
remaining lifetime and ${colors.bold}butter auth login${colors.reset} again renews it.
1447+
The ${colors.bold}changes${colors.reset} commands need the read:changes scope; a token issued before
1448+
they shipped does not have it, so run ${colors.bold}butter auth login${colors.reset} again once.
12431449
`);
12441450
}
12451451

@@ -1281,6 +1487,11 @@ async function main() {
12811487
if (subcmd === "investigate") return buildsInvestigate(args);
12821488
if (subcmd === "investigation") return buildsInvestigation(args);
12831489
break;
1490+
case "changes":
1491+
case "change":
1492+
if (subcmd === "list" || !subcmd) return changesList(args);
1493+
if (subcmd === "show") return changesShow(args);
1494+
break;
12841495
case "assets":
12851496
case "asset":
12861497
if (subcmd === "list" || !subcmd) return assetsList(args);

0 commit comments

Comments
 (0)