Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 216 additions & 11 deletions bin/butter
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,161 @@ async function buildsList(args) {
}
}

async function buildsShow(args) {
const projectId = args.project || loadConfig().defaultProject;
const buildId = args._[2];
if (!projectId || !buildId) {
console.error(`${colors.err}✗ Usage: butter builds show <build_id> --project <id>${colors.reset}`);
process.exit(1);
}

try {
const res = await request("GET", `/api/v1/projects/${projectId}/build_runs/${buildId}`);
if (args.json) {
console.log(JSON.stringify(res, null, 2));
return;
}

const statusColor =
res.status === "completed" ? colors.ok : res.status === "failed" ? colors.err : colors.warn;
console.log(`\n${colors.bold}BUILD ${res.id}${colors.reset}`);
console.log("----------------------------------------------------------------------");
console.log(` Status: ${statusColor}${String(res.status).toUpperCase()}${colors.reset}`);
console.log(` Overall: ${res.overall_status || "-"}`);
console.log(` Job: ${res.ci_job_name || "-"}`);
console.log(` Commit: ${res.commit_hash || "-"}`);
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 || "-"}`);

// #1940: an investigation with no log to read cannot say anything useful,
// so surface that here rather than leaving it to be inferred from a
// vague diagnosis.
if (res.log_available === false) {
console.log(` Build log: ${colors.warn}not available${colors.reset}`);
if (res.log_unavailable_reason) console.log(` ${colors.muted}${res.log_unavailable_reason}${colors.reset}`);
} else if (res.log_available === true) {
console.log(` Build log: ${colors.ok}available${colors.reset}`);
}

if (Array.isArray(res.steps) && res.steps.length) {
console.log(`\n${colors.bold} Steps:${colors.reset}`);
res.steps.forEach((s) => {
const c = s.status === "completed" ? colors.ok : s.status === "failed" ? colors.err : colors.warn;
console.log(
` ${c}[${s.status}]${colors.reset} ${s.step_type}${s.message ? ` ${colors.muted}- ${s.message}${colors.reset}` : ""}`
);
});
}

// Three distinct cases, and conflating them is how #1938 stayed confusing:
// an investigation exists; none has been run; or the server predates the
// read path and cannot tell us either way. A server without the key must
// not be reported as "no investigation" - that is the exact false negative
// that sent people back to POSTing `investigate` to find out.
if (res.investigation) {
console.log("");
printInvestigation(res.investigation);
} else if (Object.prototype.hasOwnProperty.call(res, "investigation")) {
console.log(`\n ${colors.muted}No AI investigation has been run for this build.${colors.reset}`);
console.log(` ${colors.muted}Run: butter builds investigate ${res.id} --project ${projectId}${colors.reset}`);
} else {
console.log(`\n ${colors.warn}This ButterStack server does not expose investigations on the build detail.${colors.reset}`);
console.log(` ${colors.muted}Try: butter builds investigation ${res.id} --project ${projectId}${colors.reset}`);
}
console.log("");
} catch (err) {
console.error(`${colors.err}✗ Failed to show build: ${err.message}${colors.reset}`);
process.exit(1);
}
}

function printInvestigation(inv) {
const statusColor =
inv.status === "completed" ? colors.ok : inv.status === "failed" ? colors.err : colors.warn;
console.log(`${colors.bold}AI Failure Investigation:${colors.reset}`);
console.log(` Status: ${statusColor}${inv.status}${colors.reset}`);

if (inv.status === "pending" || inv.status === "running") {
console.log(` ${colors.muted}Still in flight. Re-read with \`butter builds investigation\` - do not`);
console.log(` re-run \`investigate\`, which starts a new paid analysis.${colors.reset}`);
return;
}

if (inv.status === "failed") {
console.log(` Error: ${colors.err}${inv.error_message || "unknown"}${colors.reset}`);
return;
}

if (inv.diagnosis_category) {
console.log(` Category: ${inv.diagnosis_category}${inv.severity ? ` (${inv.severity})` : ""}`);
}
if (inv.confidence) console.log(` Confidence: ${inv.confidence}`);
if (inv.summary) console.log(` Summary: ${inv.summary}`);
if (inv.diagnosis) console.log(` Diagnosis: ${inv.diagnosis}`);
if (inv.suggested_fix) console.log(` Fix: ${colors.accent}${inv.suggested_fix}${colors.reset}`);

// Attribution is printed only when the investigation actually made one.
// "unattributed" is a real, common and correct answer -- a file that was
// never submitted has no offending changelist and no culpable author.
if (inv.attributed_changelist || inv.attributed_user_id) {
console.log(
` Attributed: ${inv.attributed_changelist ? `CL ${inv.attributed_changelist}` : ""}${
inv.attributed_user_id ? ` (user ${inv.attributed_user_id})` : ""
}`
);
} else {
console.log(` Attributed: ${colors.muted}unattributed${colors.reset}`);
}

if (Array.isArray(inv.affected_files) && inv.affected_files.length) {
console.log(` Files: ${inv.affected_files.join(", ")}`);
}
if (Array.isArray(inv.evidence) && inv.evidence.length) {
console.log(`${colors.bold} Evidence:${colors.reset}`);
inv.evidence.forEach((e) => console.log(` ${colors.muted}- ${e}${colors.reset}`));
}
}

// Read-only counterpart to `investigate` (#1938). Before this existed, the
// only way to see a result was to POST investigate again, which spends
// credits and starts a fresh Bedrock run once the previous one has finished.
async function buildsInvestigation(args) {
const projectId = args.project || loadConfig().defaultProject;
const buildId = args._[2];
if (!projectId || !buildId) {
console.error(`${colors.err}✗ Usage: butter builds investigation <build_id> --project <id>${colors.reset}`);
process.exit(1);
}

try {
const res = await request("GET", `/api/v1/projects/${projectId}/build_runs/${buildId}/investigation`);
if (args.json) {
console.log(JSON.stringify(res, null, 2));
return;
}
console.log("");
printInvestigation(res);
if (args.steps && Array.isArray(res.steps) && res.steps.length) {
console.log(`\n${colors.bold} Steps:${colors.reset}`);
res.steps.forEach((s) => {
console.log(` ${colors.muted}${String(s.step).padStart(3)} ${s.step_type}${colors.reset}${s.content ? ` ${s.content}` : ""}`);
});
} else if (Array.isArray(res.steps) && res.steps.length) {
console.log(`\n ${colors.muted}${res.steps.length} step(s). Pass --steps to print the trail.${colors.reset}`);
}
console.log("");
} catch (err) {
if (err.statusCode === 404) {
console.error(`${colors.warn}No investigation has been run for build ${buildId}.${colors.reset}`);
console.error(`${colors.muted}Start one with: butter builds investigate ${buildId} --project ${projectId}${colors.reset}`);
process.exit(1);
}
console.error(`${colors.err}✗ Failed to read investigation: ${err.message}${colors.reset}`);
process.exit(1);
}
}

async function buildsInvestigate(args) {
const projectId = args.project || loadConfig().defaultProject;
const buildId = args._[2];
Expand All @@ -549,17 +704,24 @@ async function buildsInvestigate(args) {
}

try {
console.log(`${colors.accent}Triggering AI Failure Investigation for Build #${buildId}...${colors.reset}`);
if (!args.json) {
console.log(`${colors.accent}Triggering AI Failure Investigation for Build #${buildId}...${colors.reset}`);
}
const res = await request("POST", `/api/v1/projects/${projectId}/build_runs/${buildId}/investigate`);
if (args.json) {
console.log(JSON.stringify(res, null, 2));
return;
}
console.log(`\n${colors.bold}AI Failure Investigation Report:${colors.reset}`);
console.log(` Status: ${colors.ok}${res.status}${colors.reset}`);
console.log(` Diagnosis: ${res.diagnosis || res.summary || "Pending analysis..."}`);
if (res.suggested_fix) console.log(` Fix: ${colors.accent}${res.suggested_fix}${colors.reset}`);
if (res.attributed_author) console.log(` Attributed: ${res.attributed_author}`);
console.log("");
printInvestigation(res);
// An investigation runs asynchronously, so the POST almost always returns
// `pending`. Point at the read path rather than letting the caller poll
// by re-POSTing, which is what made this cost money to check on (#1938).
if (res.status === "pending" || res.status === "running") {
console.log(
`\n ${colors.muted}Check the result with: butter builds investigation ${buildId} --project ${projectId}${colors.reset}`
);
}
console.log("");
} catch (err) {
console.error(`${colors.err}✗ Failed to investigate build: ${err.message}${colors.reset}`);
Expand Down Expand Up @@ -649,16 +811,45 @@ async function assetsDeny(args) {
}
}

// Flags that are switches, never name/value pairs (#1938).
//
// The parser used to treat every `--flag` as taking a value whenever the next
// argv entry did not start with `-`. That is fine at the end of a line, but
// `butter builds investigate --json <build_id>` parsed as
// `json: "<build_id>"` with no positional left, so the command printed its
// usage error and exited 1 -- looking, from the caller's side, like `--json`
// silently produced no output while the plain form worked. Position in the
// command line must not change what a boolean flag means.
const BOOLEAN_FLAGS = new Set([
"json",
"help",
"h",
"pending",
"verbose",
"yes",
"force",
"no-color",
"steps"
]);

// `--key=value` is always explicit, so it is honored for any key including
// the booleans above (`--json=false` is not special-cased: anything after `=`
// is taken literally, which is the same behavior every other key gets).
function parseArgs(rawArgs) {
const parsed = { _: [] };
for (let i = 0; i < rawArgs.length; i++) {
const arg = rawArgs[i];
if (arg.startsWith("--")) {
const key = arg.substring(2);
if (i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith("-")) {
parsed[key] = rawArgs[++i];
const body = arg.substring(2);
const eq = body.indexOf("=");
if (eq !== -1) {
parsed[body.substring(0, eq)] = body.substring(eq + 1);
} else if (BOOLEAN_FLAGS.has(body)) {
parsed[body] = true;
} else if (i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith("-")) {
parsed[body] = rawArgs[++i];
} else {
parsed[key] = true;
parsed[body] = true;
}
} else if (arg.startsWith("-")) {
const key = arg.substring(1);
Expand All @@ -682,7 +873,10 @@ ${colors.bold}COMMANDS:${colors.reset}
${colors.accent}auth${colors.reset} login | whoami | logout Authenticate via browser OAuth loopback
${colors.accent}projects${colors.reset} list | show <id> View and inspect game projects
${colors.accent}tasks${colors.reset} list | create | update Manage tasks, bugs, and backlog
${colors.accent}builds${colors.reset} list | show | investigate Inspect CI/CD engine builds and AI failure logs
${colors.accent}builds${colors.reset} list | show | investigate | investigation
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}assets${colors.reset} list | show | approve | deny Review and approve textures, models, and audio

${colors.bold}GLOBAL OPTIONS:${colors.reset}
Expand Down Expand Up @@ -732,7 +926,9 @@ async function main() {
case "builds":
case "build":
if (subcmd === "list" || !subcmd) return buildsList(args);
if (subcmd === "show") return buildsShow(args);
if (subcmd === "investigate") return buildsInvestigate(args);
if (subcmd === "investigation") return buildsInvestigation(args);
break;
case "assets":
case "asset":
Expand All @@ -745,6 +941,15 @@ async function main() {
printHelp();
process.exit(1);
}

// Reaching here means the command matched but its subcommand did not, and
// every branch above `break`s rather than returning. That used to fall out
// of main() silently: `butter builds show <id>` printed nothing and exited
// 0, which reads as "this build has no data" rather than "this subcommand
// does not exist" (#1938). An unhandled subcommand is an error.
console.error(`${colors.err}Unknown subcommand: ${cmd} ${subcmd || ""}${colors.reset}`.trimEnd());
printHelp();
process.exit(1);
}

if (require.main === module) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "butterstack-cli",
"version": "0.1.1",
"version": "0.2.0",
"description": "Fast, scriptable, zero-dependency command line interface for the ButterStack game development pipeline: auth, projects, tasks, builds, and asset approvals.",
"bin": {
"butter": "./bin/butter"
Expand Down
Loading
Loading