playbook/outfitter-agents/plugins/outfitter/skills/check-status/scripts/gatherers/graphite-gatherer.ts

376 lines
8.5 KiB
TypeScript
Executable File

#!/usr/bin/env bun
/**
* Graphite gatherer for status
*
* Collects stack and branch data via `gt` CLI:
* - Stack structure and hierarchy
* - Branch PR status
* - Restack/submit needs
* - Recent commits via git
*/
import { parseArgs } from "node:util";
import { parseTimeConstraint, toGitSince } from "../lib/time";
import type {
GathererResult,
GraphiteBranch,
GraphiteData,
} from "../lib/types";
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
time: { type: "string", short: "t", default: "24h" },
help: { type: "boolean", short: "h" },
},
});
if (values.help) {
console.log(`
graphite-gatherer.ts - Gather Graphite stack data
Usage:
./graphite-gatherer.ts [options]
Options:
-t, --time <constraint> Time constraint for commits (24h, 7d, 2w) [default: 24h]
-h, --help Show this help
Output:
JSON GathererResult with GraphiteData
`);
process.exit(0);
}
/**
* Result of running a shell command.
*/
interface CmdOutput {
success: boolean;
stdout: string;
stderr: string;
}
/**
* Runs a shell command and captures output.
* @param cmd - Command to run
* @param args - Arguments to pass
* @returns Command output
*/
async function runCmd(cmd: string, args: string[]): Promise<CmdOutput> {
const proc = Bun.spawn([cmd, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
return { success: exitCode === 0, stdout, stderr };
}
/**
* Checks if Graphite CLI is installed.
* @returns True if gt is available
*/
async function checkGtAvailable(): Promise<boolean> {
const result = await runCmd("which", ["gt"]);
return result.success;
}
/**
* Checks if current directory is a git repository.
* @returns True if in a git repo
*/
async function checkGitRepo(): Promise<boolean> {
const result = await runCmd("git", ["rev-parse", "--git-dir"]);
return result.success;
}
/**
* Gets Graphite stack state from gt CLI.
* @returns Stack state or null on failure
*/
async function getGtState(): Promise<{
branches: GraphiteBranch[];
stacks: string[][];
currentBranch: string;
trunk: string;
} | null> {
// Get structured state from gt
const result = await runCmd("gt", ["log", "--json"]);
if (!result.success) {
// Try alternate: gt state
const stateResult = await runCmd("gt", ["state"]);
if (!stateResult.success) return null;
// Parse text output as fallback
return parseGtStateText(stateResult.stdout);
}
try {
const data = JSON.parse(result.stdout);
return parseGtLogJson(data);
} catch {
return null;
}
}
/**
* Parses JSON output from gt log command.
* @param data - Raw JSON data
* @returns Structured Graphite state
*/
function parseGtLogJson(data: unknown): {
branches: GraphiteBranch[];
stacks: string[][];
currentBranch: string;
trunk: string;
} {
// gt log --json returns array of branch entries
const entries = Array.isArray(data) ? data : [];
const branchMap = new Map<string, GraphiteBranch>();
let currentBranch = "main";
const trunk = "main";
for (const entry of entries) {
const branch: GraphiteBranch = {
name: entry.branch || entry.name || "",
prNumber: entry.pr?.number,
prStatus: mapPrState(entry.pr?.state, entry.pr?.isDraft),
prUrl: entry.pr?.url,
parent: entry.parent,
children: [],
isCurrent: entry.isCurrent || entry.current || false,
needsRestack: entry.needsRestack || false,
needsSubmit: entry.needsSubmit || false,
commitCount: entry.commitCount || entry.commits?.length || 0,
};
if (branch.isCurrent) {
currentBranch = branch.name;
}
branchMap.set(branch.name, branch);
}
// Build children relationships
for (const branch of branchMap.values()) {
if (branch.parent && branchMap.has(branch.parent)) {
branchMap.get(branch.parent)?.children.push(branch.name);
}
}
// Build stacks (branches that share a root)
const stacks = buildStacks(branchMap, trunk);
return {
branches: Array.from(branchMap.values()),
stacks,
currentBranch,
trunk,
};
}
/**
* Parses text output from gt state command (fallback).
* @param text - Raw text output
* @returns Structured Graphite state
*/
function parseGtStateText(text: string): {
branches: GraphiteBranch[];
stacks: string[][];
currentBranch: string;
trunk: string;
} {
// Fallback parser for text output
const lines = text.split("\n").filter((l) => l.trim());
const branches: GraphiteBranch[] = [];
let currentBranch = "main";
for (const line of lines) {
// Look for branch indicators like "◉ branch-name" or "○ branch-name"
const match = line.match(/[◉○●◐]\s+(\S+)/);
if (match) {
const name = match[1];
const isCurrent = line.includes("◉") || line.includes("●");
if (isCurrent) currentBranch = name;
branches.push({
name,
children: [],
isCurrent,
needsRestack: line.includes("restack"),
needsSubmit: line.includes("submit"),
commitCount: 0,
});
}
}
return {
branches,
stacks: branches.length > 0 ? [branches.map((b) => b.name)] : [],
currentBranch,
trunk: "main",
};
}
/**
* Maps PR state from API to internal status.
* @param state - API state string
* @param isDraft - Whether PR is a draft
* @returns Normalized status
*/
function mapPrState(
state?: string,
isDraft?: boolean,
): "draft" | "open" | "ready" | "merged" | "closed" | undefined {
if (!state) return undefined;
if (isDraft) return "draft";
switch (state.toLowerCase()) {
case "open":
return "open";
case "merged":
return "merged";
case "closed":
return "closed";
default:
return "open";
}
}
/**
* Builds stack arrays from branch relationships.
* @param branchMap - Map of branch names to branch data
* @param trunk - Trunk branch name
* @returns Array of stacks (each stack is array of branch names)
*/
function buildStacks(
branchMap: Map<string, GraphiteBranch>,
trunk: string,
): string[][] {
const stacks: string[][] = [];
const visited = new Set<string>();
// Find root branches (parent is trunk or undefined)
const roots = Array.from(branchMap.values()).filter(
(b) => !b.parent || b.parent === trunk || !branchMap.has(b.parent),
);
for (const root of roots) {
if (visited.has(root.name)) continue;
const stack: string[] = [];
const queue = [root.name];
while (queue.length > 0) {
const name = queue.shift();
if (!name || visited.has(name)) continue;
visited.add(name);
stack.push(name);
const branch = branchMap.get(name);
if (branch) {
queue.push(...branch.children);
}
}
if (stack.length > 0) {
stacks.push(stack);
}
}
return stacks;
}
/**
* Gets count of recent commits within time window.
* @param timeMs - Time window in milliseconds
* @returns Number of commits
*/
async function getRecentCommits(timeMs: number): Promise<number> {
const since = toGitSince(timeMs);
const result = await runCmd("git", ["log", `--since=${since}`, "--oneline"]);
if (!result.success) return 0;
return result.stdout.split("\n").filter((l) => l.trim()).length;
}
/**
* Gathers Graphite stack and branch data.
* @returns Gatherer result with Graphite data
*/
async function gatherGraphiteData(): Promise<GathererResult<GraphiteData>> {
const timestamp = new Date().toISOString();
// Check prerequisites
const gtAvailable = await checkGtAvailable();
if (!gtAvailable) {
return {
source: "graphite",
status: "unavailable",
reason: "gt CLI not installed",
timestamp,
};
}
const isGitRepo = await checkGitRepo();
if (!isGitRepo) {
return {
source: "graphite",
status: "unavailable",
reason: "Not in a git repository",
timestamp,
};
}
// Parse time constraint
const timeValue = values.time ?? "24h";
let timeMs: number;
try {
timeMs = parseTimeConstraint(timeValue);
} catch (e) {
return {
source: "graphite",
status: "error",
error: e instanceof Error ? e.message : "Invalid time constraint",
timestamp,
};
}
// Get graphite state
const state = await getGtState();
if (!state) {
return {
source: "graphite",
status: "error",
error: "Failed to parse gt output",
timestamp,
};
}
// Get recent commit count (informational)
const _recentCommits = await getRecentCommits(timeMs);
return {
source: "graphite",
status: "success",
data: {
currentBranch: state.currentBranch,
trunk: state.trunk,
branches: state.branches,
stacks: state.stacks,
},
timestamp,
};
}
// Main execution
const result = await gatherGraphiteData();
console.log(JSON.stringify(result, null, 2));