feat: enrich `ao status` with PR, CI, review, threads, and activity columns
The status command now displays a rich table with branch, PR number, CI status, review decision, pending review threads, and agent activity state. Data is fetched from the SCM plugin (GitHub) in parallel for performance. Closes INT-1348 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1eba42097b
commit
5b68fccbd0
|
|
@ -3,11 +3,15 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const { mockTmux, mockGit, mockConfigRef, mockIntrospect } = vi.hoisted(() => ({
|
||||
const { mockTmux, mockGit, mockConfigRef, mockIntrospect, mockDetectPR, mockGetCISummary, mockGetReviewDecision, mockGetPendingComments } = vi.hoisted(() => ({
|
||||
mockTmux: vi.fn(),
|
||||
mockGit: vi.fn(),
|
||||
mockConfigRef: { current: null as Record<string, unknown> | null },
|
||||
mockIntrospect: vi.fn(),
|
||||
mockDetectPR: vi.fn(),
|
||||
mockGetCISummary: vi.fn(),
|
||||
mockGetReviewDecision: vi.fn(),
|
||||
mockGetPendingComments: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
|
|
@ -38,13 +42,27 @@ vi.mock("../../src/lib/plugins.js", () => ({
|
|||
name: "claude-code",
|
||||
processName: "claude",
|
||||
detectActivity: () => "idle",
|
||||
introspect: mockIntrospect,
|
||||
getSessionInfo: mockIntrospect,
|
||||
}),
|
||||
getAgentByName: () => ({
|
||||
name: "claude-code",
|
||||
processName: "claude",
|
||||
detectActivity: () => "idle",
|
||||
introspect: mockIntrospect,
|
||||
getSessionInfo: mockIntrospect,
|
||||
}),
|
||||
getSCM: () => ({
|
||||
name: "github",
|
||||
detectPR: mockDetectPR,
|
||||
getCISummary: mockGetCISummary,
|
||||
getReviewDecision: mockGetReviewDecision,
|
||||
getPendingComments: mockGetPendingComments,
|
||||
getAutomatedComments: vi.fn().mockResolvedValue([]),
|
||||
getCIChecks: vi.fn().mockResolvedValue([]),
|
||||
getReviews: vi.fn().mockResolvedValue([]),
|
||||
getMergeability: vi.fn().mockResolvedValue({ mergeable: true, ciPassing: true, approved: false, noConflicts: true, blockers: [] }),
|
||||
getPRState: vi.fn().mockResolvedValue("open"),
|
||||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -75,6 +93,7 @@ beforeEach(() => {
|
|||
path: "/home/user/my-app",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
scm: { plugin: "github" },
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
|
|
@ -94,6 +113,14 @@ beforeEach(() => {
|
|||
mockGit.mockReset();
|
||||
mockIntrospect.mockReset();
|
||||
mockIntrospect.mockResolvedValue(null);
|
||||
mockDetectPR.mockReset();
|
||||
mockDetectPR.mockResolvedValue(null);
|
||||
mockGetCISummary.mockReset();
|
||||
mockGetCISummary.mockResolvedValue("none");
|
||||
mockGetReviewDecision.mockReset();
|
||||
mockGetReviewDecision.mockResolvedValue("none");
|
||||
mockGetPendingComments.mockReset();
|
||||
mockGetPendingComments.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -213,4 +240,169 @@ describe("status command", () => {
|
|||
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
|
||||
expect(output).toContain("live-branch");
|
||||
});
|
||||
|
||||
it("shows table header with column names", async () => {
|
||||
const sessionDir = join(tmpDir, "my-app-sessions");
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
writeFileSync(join(sessionDir, "app-1"), "branch=main\nstatus=idle\n");
|
||||
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "app-1";
|
||||
if (args[0] === "display-message") return null;
|
||||
return null;
|
||||
});
|
||||
mockGit.mockResolvedValue(null);
|
||||
|
||||
await program.parseAsync(["node", "test", "status"]);
|
||||
|
||||
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
|
||||
expect(output).toContain("Session");
|
||||
expect(output).toContain("Branch");
|
||||
expect(output).toContain("PR");
|
||||
expect(output).toContain("CI");
|
||||
expect(output).toContain("Activity");
|
||||
});
|
||||
|
||||
it("shows PR number, CI status, review decision, and threads", async () => {
|
||||
const sessionDir = join(tmpDir, "my-app-sessions");
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(sessionDir, "app-1"),
|
||||
"worktree=/tmp/wt\nbranch=feat/test\nstatus=working\n",
|
||||
);
|
||||
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "app-1";
|
||||
if (args[0] === "display-message") return String(Math.floor(Date.now() / 1000) - 60);
|
||||
return null;
|
||||
});
|
||||
mockGit.mockResolvedValue("feat/test");
|
||||
|
||||
mockDetectPR.mockResolvedValue({
|
||||
number: 42,
|
||||
url: "https://github.com/org/repo/pull/42",
|
||||
title: "Test PR",
|
||||
owner: "org",
|
||||
repo: "repo",
|
||||
branch: "feat/test",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
});
|
||||
mockGetCISummary.mockResolvedValue("passing");
|
||||
mockGetReviewDecision.mockResolvedValue("approved");
|
||||
mockGetPendingComments.mockResolvedValue([
|
||||
{ id: "1", author: "reviewer", body: "fix this", isResolved: false, createdAt: new Date(), url: "" },
|
||||
{ id: "2", author: "reviewer2", body: "fix that", isResolved: false, createdAt: new Date(), url: "" },
|
||||
]);
|
||||
|
||||
await program.parseAsync(["node", "test", "status"]);
|
||||
|
||||
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
|
||||
expect(output).toContain("#42");
|
||||
expect(output).toContain("pass");
|
||||
expect(output).toContain("ok"); // approved
|
||||
expect(output).toContain("2"); // pending threads
|
||||
});
|
||||
|
||||
it("shows failing CI and changes_requested review", async () => {
|
||||
const sessionDir = join(tmpDir, "my-app-sessions");
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(sessionDir, "app-1"),
|
||||
"worktree=/tmp/wt\nbranch=feat/broken\nstatus=working\n",
|
||||
);
|
||||
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "app-1";
|
||||
if (args[0] === "display-message") return null;
|
||||
return null;
|
||||
});
|
||||
mockGit.mockResolvedValue("feat/broken");
|
||||
|
||||
mockDetectPR.mockResolvedValue({
|
||||
number: 7,
|
||||
url: "https://github.com/org/repo/pull/7",
|
||||
title: "Broken PR",
|
||||
owner: "org",
|
||||
repo: "repo",
|
||||
branch: "feat/broken",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
});
|
||||
mockGetCISummary.mockResolvedValue("failing");
|
||||
mockGetReviewDecision.mockResolvedValue("changes_requested");
|
||||
mockGetPendingComments.mockResolvedValue([]);
|
||||
|
||||
await program.parseAsync(["node", "test", "status"]);
|
||||
|
||||
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
|
||||
expect(output).toContain("#7");
|
||||
expect(output).toContain("fail");
|
||||
expect(output).toContain("chg!"); // changes_requested
|
||||
});
|
||||
|
||||
it("handles SCM errors gracefully", async () => {
|
||||
const sessionDir = join(tmpDir, "my-app-sessions");
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(sessionDir, "app-1"),
|
||||
"worktree=/tmp/wt\nbranch=feat/err\nstatus=working\n",
|
||||
);
|
||||
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "app-1";
|
||||
if (args[0] === "display-message") return null;
|
||||
return null;
|
||||
});
|
||||
mockGit.mockResolvedValue("feat/err");
|
||||
|
||||
mockDetectPR.mockRejectedValue(new Error("gh failed"));
|
||||
|
||||
await program.parseAsync(["node", "test", "status"]);
|
||||
|
||||
// Should still show the session without crashing
|
||||
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
|
||||
expect(output).toContain("app-1");
|
||||
expect(output).toContain("feat/err");
|
||||
});
|
||||
|
||||
it("outputs JSON with enriched fields", async () => {
|
||||
const sessionDir = join(tmpDir, "my-app-sessions");
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(sessionDir, "app-1"),
|
||||
"worktree=/tmp/wt\nbranch=feat/json\nstatus=working\n",
|
||||
);
|
||||
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "list-sessions") return "app-1";
|
||||
if (args[0] === "display-message") return String(Math.floor(Date.now() / 1000));
|
||||
return null;
|
||||
});
|
||||
mockGit.mockResolvedValue("feat/json");
|
||||
|
||||
mockDetectPR.mockResolvedValue({
|
||||
number: 10,
|
||||
url: "https://github.com/org/repo/pull/10",
|
||||
title: "JSON PR",
|
||||
owner: "org",
|
||||
repo: "repo",
|
||||
branch: "feat/json",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
});
|
||||
mockGetCISummary.mockResolvedValue("passing");
|
||||
mockGetReviewDecision.mockResolvedValue("pending");
|
||||
mockGetPendingComments.mockResolvedValue([]);
|
||||
|
||||
await program.parseAsync(["node", "test", "status", "--json"]);
|
||||
|
||||
const jsonCalls = consoleSpy.mock.calls.map((c) => c[0]).join("");
|
||||
const parsed = JSON.parse(jsonCalls);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0].prNumber).toBe(10);
|
||||
expect(parsed[0].ciStatus).toBe("passing");
|
||||
expect(parsed[0].reviewDecision).toBe("pending");
|
||||
expect(parsed[0].pendingThreads).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"@agent-orchestrator/plugin-agent-claude-code": "workspace:*",
|
||||
"@agent-orchestrator/plugin-agent-codex": "workspace:*",
|
||||
"@agent-orchestrator/plugin-agent-aider": "workspace:*",
|
||||
"@agent-orchestrator/plugin-scm-github": "workspace:*",
|
||||
"chalk": "^5.4.0",
|
||||
"commander": "^13.0.0",
|
||||
"ora": "^8.1.0",
|
||||
|
|
|
|||
|
|
@ -2,15 +2,29 @@ import chalk from "chalk";
|
|||
import type { Command } from "commander";
|
||||
import {
|
||||
type Agent,
|
||||
type SCM,
|
||||
type OrchestratorConfig,
|
||||
type Session,
|
||||
type RuntimeHandle,
|
||||
type ProjectConfig,
|
||||
type PRInfo,
|
||||
type CIStatus,
|
||||
type ReviewDecision,
|
||||
type ActivityState,
|
||||
loadConfig,
|
||||
} from "@agent-orchestrator/core";
|
||||
import { git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js";
|
||||
import { getSessionDir, readMetadata } from "../lib/metadata.js";
|
||||
import { banner, header, formatAge, statusColor } from "../lib/format.js";
|
||||
import { getAgent, getAgentByName } from "../lib/plugins.js";
|
||||
import {
|
||||
banner,
|
||||
header,
|
||||
formatAge,
|
||||
activityIcon,
|
||||
ciStatusIcon,
|
||||
reviewDecisionIcon,
|
||||
padCol,
|
||||
} from "../lib/format.js";
|
||||
import { getAgent, getAgentByName, getSCM } from "../lib/plugins.js";
|
||||
import { matchesPrefix } from "../lib/session-utils.js";
|
||||
|
||||
interface SessionInfo {
|
||||
|
|
@ -20,16 +34,25 @@ interface SessionInfo {
|
|||
summary: string | null;
|
||||
claudeSummary: string | null;
|
||||
pr: string | null;
|
||||
prNumber: number | null;
|
||||
issue: string | null;
|
||||
lastActivity: string;
|
||||
project: string | null;
|
||||
ciStatus: CIStatus | null;
|
||||
reviewDecision: ReviewDecision | null;
|
||||
pendingThreads: number | null;
|
||||
activity: ActivityState | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal Session object for agent.getSessionInfo().
|
||||
* Build a minimal Session object for agent.getSessionInfo() and SCM.detectPR().
|
||||
* Only runtimeHandle and workspacePath are needed by the introspection logic.
|
||||
*/
|
||||
function buildSessionForIntrospect(sessionName: string, workspacePath?: string): Session {
|
||||
function buildSessionForIntrospect(
|
||||
sessionName: string,
|
||||
workspacePath?: string,
|
||||
branch?: string | null,
|
||||
): Session {
|
||||
const handle: RuntimeHandle = {
|
||||
id: sessionName,
|
||||
runtimeName: "tmux",
|
||||
|
|
@ -40,7 +63,7 @@ function buildSessionForIntrospect(sessionName: string, workspacePath?: string):
|
|||
projectId: "",
|
||||
status: "working",
|
||||
activity: "idle",
|
||||
branch: null,
|
||||
branch: branch ?? null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: workspacePath || null,
|
||||
|
|
@ -56,6 +79,8 @@ async function gatherSessionInfo(
|
|||
sessionName: string,
|
||||
sessionDir: string,
|
||||
agent: Agent,
|
||||
scm: SCM,
|
||||
projectConfig: ProjectConfig,
|
||||
): Promise<SessionInfo> {
|
||||
const metaFile = `${sessionDir}/${sessionName}`;
|
||||
const meta = readMetadata(metaFile);
|
||||
|
|
@ -63,7 +88,7 @@ async function gatherSessionInfo(
|
|||
let branch = meta?.branch ?? null;
|
||||
const status = meta?.status ?? null;
|
||||
const summary = meta?.summary ?? null;
|
||||
const pr = meta?.pr ?? null;
|
||||
const prUrl = meta?.pr ?? null;
|
||||
const issue = meta?.issue ?? null;
|
||||
const project = meta?.project ?? null;
|
||||
|
||||
|
|
@ -78,45 +103,122 @@ async function gatherSessionInfo(
|
|||
const activityTs = await getTmuxActivity(sessionName);
|
||||
const lastActivity = activityTs ? formatAge(activityTs) : "-";
|
||||
|
||||
// Get agent's auto-generated summary via introspection
|
||||
// Get agent's auto-generated summary and activity via introspection
|
||||
let claudeSummary: string | null = null;
|
||||
let activity: ActivityState | null = null;
|
||||
try {
|
||||
const session = buildSessionForIntrospect(sessionName, worktree);
|
||||
const session = buildSessionForIntrospect(sessionName, worktree, branch);
|
||||
const introspection = await agent.getSessionInfo(session);
|
||||
claudeSummary = introspection?.summary ?? null;
|
||||
|
||||
// Detect activity from agent's last message type
|
||||
if (introspection?.lastMessageType === "summary") {
|
||||
activity = "idle";
|
||||
} else if (introspection?.lastMessageType) {
|
||||
activity = "active";
|
||||
}
|
||||
} catch {
|
||||
// Introspection failed — not critical
|
||||
}
|
||||
|
||||
// Fetch PR, CI, and review data from SCM
|
||||
let prNumber: number | null = null;
|
||||
let ciStatus: CIStatus | null = null;
|
||||
let reviewDecision: ReviewDecision | null = null;
|
||||
let pendingThreads: number | null = null;
|
||||
|
||||
if (branch) {
|
||||
try {
|
||||
const session = buildSessionForIntrospect(sessionName, worktree, branch);
|
||||
const prInfo: PRInfo | null = await scm.detectPR(session, projectConfig);
|
||||
if (prInfo) {
|
||||
prNumber = prInfo.number;
|
||||
|
||||
// Fetch CI, reviews, and threads in parallel
|
||||
const [ci, review, threads] = await Promise.all([
|
||||
scm.getCISummary(prInfo).catch(() => null),
|
||||
scm.getReviewDecision(prInfo).catch(() => null),
|
||||
scm.getPendingComments(prInfo).catch(() => []),
|
||||
]);
|
||||
|
||||
ciStatus = ci;
|
||||
reviewDecision = review;
|
||||
pendingThreads = threads ? threads.length : null;
|
||||
}
|
||||
} catch {
|
||||
// SCM lookup failed — not critical, we still show what we have
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: sessionName,
|
||||
branch,
|
||||
status,
|
||||
summary,
|
||||
claudeSummary,
|
||||
pr,
|
||||
pr: prUrl,
|
||||
prNumber,
|
||||
issue,
|
||||
lastActivity,
|
||||
project,
|
||||
ciStatus,
|
||||
reviewDecision,
|
||||
pendingThreads,
|
||||
activity,
|
||||
};
|
||||
}
|
||||
|
||||
function printSession(info: SessionInfo): void {
|
||||
const statusStr = info.status ? ` ${statusColor(info.status)}` : "";
|
||||
console.log(` ${chalk.green(info.name)} ${chalk.dim(`(${info.lastActivity})`)}${statusStr}`);
|
||||
if (info.branch) {
|
||||
console.log(` ${chalk.dim("Branch:")} ${info.branch}`);
|
||||
}
|
||||
if (info.issue) {
|
||||
console.log(` ${chalk.dim("Issue:")} ${info.issue}`);
|
||||
}
|
||||
if (info.pr) {
|
||||
console.log(` ${chalk.dim("PR:")} ${chalk.blue(info.pr)}`);
|
||||
}
|
||||
if (info.claudeSummary) {
|
||||
console.log(` ${chalk.dim("Claude:")} ${info.claudeSummary.slice(0, 65)}`);
|
||||
} else if (info.summary) {
|
||||
console.log(` ${chalk.dim("Summary:")} ${info.summary.slice(0, 65)}`);
|
||||
// Column widths for the table
|
||||
const COL = {
|
||||
session: 14,
|
||||
branch: 24,
|
||||
pr: 6,
|
||||
ci: 6,
|
||||
review: 6,
|
||||
threads: 4,
|
||||
activity: 9,
|
||||
age: 8,
|
||||
};
|
||||
|
||||
function printTableHeader(): void {
|
||||
const hdr =
|
||||
padCol("Session", COL.session) +
|
||||
padCol("Branch", COL.branch) +
|
||||
padCol("PR", COL.pr) +
|
||||
padCol("CI", COL.ci) +
|
||||
padCol("Rev", COL.review) +
|
||||
padCol("Thr", COL.threads) +
|
||||
padCol("Activity", COL.activity) +
|
||||
"Age";
|
||||
console.log(chalk.dim(` ${hdr}`));
|
||||
const totalWidth = COL.session + COL.branch + COL.pr + COL.ci + COL.review + COL.threads + COL.activity + 3;
|
||||
console.log(chalk.dim(` ${"─".repeat(totalWidth)}`));
|
||||
}
|
||||
|
||||
function printSessionRow(info: SessionInfo): void {
|
||||
const prStr = info.prNumber ? `#${info.prNumber}` : "-";
|
||||
|
||||
const row =
|
||||
padCol(chalk.green(info.name), COL.session) +
|
||||
padCol(info.branch ? chalk.cyan(info.branch) : chalk.dim("-"), COL.branch) +
|
||||
padCol(info.prNumber ? chalk.blue(prStr) : chalk.dim(prStr), COL.pr) +
|
||||
padCol(ciStatusIcon(info.ciStatus), COL.ci) +
|
||||
padCol(reviewDecisionIcon(info.reviewDecision), COL.review) +
|
||||
padCol(
|
||||
info.pendingThreads !== null && info.pendingThreads > 0
|
||||
? chalk.yellow(String(info.pendingThreads))
|
||||
: chalk.dim(info.pendingThreads !== null ? "0" : "-"),
|
||||
COL.threads,
|
||||
) +
|
||||
padCol(activityIcon(info.activity), COL.activity) +
|
||||
chalk.dim(info.lastActivity);
|
||||
|
||||
console.log(` ${row}`);
|
||||
|
||||
// Show summary on a second line if available
|
||||
const displaySummary = info.claudeSummary || info.summary;
|
||||
if (displaySummary) {
|
||||
console.log(` ${" ".repeat(COL.session)}${chalk.dim(displaySummary.slice(0, 60))}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,8 +262,9 @@ export function registerStatus(program: Command): void {
|
|||
const sessionDir = getSessionDir(config.dataDir, projectId);
|
||||
const projectSessions = allTmux.filter((s) => matchesPrefix(s, prefix));
|
||||
|
||||
// Resolve agent for this project
|
||||
// Resolve plugins for this project
|
||||
const agent = getAgent(config, projectId);
|
||||
const scm = getSCM(config, projectId);
|
||||
|
||||
if (!opts.json) {
|
||||
console.log(header(projectConfig.name || projectId));
|
||||
|
|
@ -177,15 +280,29 @@ export function registerStatus(program: Command): void {
|
|||
|
||||
totalSessions += projectSessions.length;
|
||||
|
||||
for (const session of projectSessions.sort()) {
|
||||
const info = await gatherSessionInfo(session, sessionDir, agent);
|
||||
if (!opts.json) {
|
||||
printTableHeader();
|
||||
}
|
||||
|
||||
// Gather all session info in parallel
|
||||
const infoPromises = projectSessions
|
||||
.sort()
|
||||
.map((session) =>
|
||||
gatherSessionInfo(session, sessionDir, agent, scm, projectConfig),
|
||||
);
|
||||
const sessionInfos = await Promise.all(infoPromises);
|
||||
|
||||
for (const info of sessionInfos) {
|
||||
if (opts.json) {
|
||||
jsonOutput.push(info);
|
||||
} else {
|
||||
printSession(info);
|
||||
console.log();
|
||||
printSessionRow(info);
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.json) {
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
|
|
@ -193,7 +310,7 @@ export function registerStatus(program: Command): void {
|
|||
} else {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`\n ${totalSessions} active session${totalSessions !== 1 ? "s" : ""} across ${Object.keys(projects).length} project${Object.keys(projects).length !== 1 ? "s" : ""}`,
|
||||
` ${totalSessions} active session${totalSessions !== 1 ? "s" : ""} across ${Object.keys(projects).length} project${Object.keys(projects).length !== 1 ? "s" : ""}`,
|
||||
),
|
||||
);
|
||||
console.log();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import chalk from "chalk";
|
||||
import type { CIStatus, ReviewDecision, ActivityState } from "@agent-orchestrator/core";
|
||||
|
||||
export function header(title: string): string {
|
||||
const line = "─".repeat(76);
|
||||
|
|
@ -55,3 +56,65 @@ export function statusColor(status: string): string {
|
|||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
export function ciStatusIcon(status: CIStatus | null): string {
|
||||
switch (status) {
|
||||
case "passing":
|
||||
return chalk.green("pass");
|
||||
case "failing":
|
||||
return chalk.red("fail");
|
||||
case "pending":
|
||||
return chalk.yellow("pend");
|
||||
case "none":
|
||||
case null:
|
||||
return chalk.dim("-");
|
||||
}
|
||||
}
|
||||
|
||||
export function reviewDecisionIcon(decision: ReviewDecision | null): string {
|
||||
switch (decision) {
|
||||
case "approved":
|
||||
return chalk.green("ok");
|
||||
case "changes_requested":
|
||||
return chalk.red("chg!");
|
||||
case "pending":
|
||||
return chalk.yellow("rev?");
|
||||
case "none":
|
||||
case null:
|
||||
return chalk.dim("-");
|
||||
}
|
||||
}
|
||||
|
||||
export function activityIcon(activity: ActivityState | null): string {
|
||||
switch (activity) {
|
||||
case "active":
|
||||
return chalk.green("working");
|
||||
case "idle":
|
||||
return chalk.yellow("idle");
|
||||
case "waiting_input":
|
||||
return chalk.magenta("waiting");
|
||||
case "blocked":
|
||||
return chalk.red("blocked");
|
||||
case "exited":
|
||||
return chalk.dim("exited");
|
||||
case null:
|
||||
return chalk.dim("-");
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
||||
|
||||
/** Pad/truncate a string to exactly `width` visible characters */
|
||||
export function padCol(str: string, width: number): string {
|
||||
// Strip ANSI codes to measure visible length
|
||||
const visible = str.replace(ANSI_RE, "");
|
||||
if (visible.length > width) {
|
||||
// Truncate visible content, re-apply truncation
|
||||
const plain = visible.slice(0, width - 1) + "\u2026";
|
||||
return plain.padEnd(width);
|
||||
}
|
||||
// Pad with spaces based on visible length
|
||||
const padding = width - visible.length;
|
||||
return str + " ".repeat(Math.max(0, padding));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { Agent, OrchestratorConfig } from "@agent-orchestrator/core";
|
||||
import type { Agent, OrchestratorConfig, SCM } from "@agent-orchestrator/core";
|
||||
import claudeCodePlugin from "@agent-orchestrator/plugin-agent-claude-code";
|
||||
import codexPlugin from "@agent-orchestrator/plugin-agent-codex";
|
||||
import aiderPlugin from "@agent-orchestrator/plugin-agent-aider";
|
||||
import githubSCMPlugin from "@agent-orchestrator/plugin-scm-github";
|
||||
|
||||
const agentPlugins: Record<string, { create(): Agent }> = {
|
||||
"claude-code": claudeCodePlugin,
|
||||
|
|
@ -9,6 +10,10 @@ const agentPlugins: Record<string, { create(): Agent }> = {
|
|||
aider: aiderPlugin,
|
||||
};
|
||||
|
||||
const scmPlugins: Record<string, { create(): SCM }> = {
|
||||
github: githubSCMPlugin,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the Agent plugin for a project (or fall back to the config default).
|
||||
* Direct import — no dynamic loading needed since the CLI depends on all agent plugins.
|
||||
|
|
@ -31,3 +36,15 @@ export function getAgentByName(name: string): Agent {
|
|||
}
|
||||
return plugin.create();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the SCM plugin for a project (or fall back to "github").
|
||||
*/
|
||||
export function getSCM(config: OrchestratorConfig, projectId: string): SCM {
|
||||
const scmName = config.projects[projectId]?.scm?.plugin || "github";
|
||||
const plugin = scmPlugins[scmName];
|
||||
if (!plugin) {
|
||||
throw new Error(`Unknown SCM plugin: ${scmName}`);
|
||||
}
|
||||
return plugin.create();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ importers:
|
|||
'@agent-orchestrator/plugin-agent-codex':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/agent-codex
|
||||
'@agent-orchestrator/plugin-scm-github':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/scm-github
|
||||
chalk:
|
||||
specifier: ^5.4.0
|
||||
version: 5.6.2
|
||||
|
|
|
|||
Loading…
Reference in New Issue