From 5b68fccbd0ef836f75d26f0b2b270b7f2e54b94b Mon Sep 17 00:00:00 2001 From: Prateek Date: Sat, 14 Feb 2026 17:20:48 +0530 Subject: [PATCH] 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 --- .../cli/__tests__/commands/status.test.ts | 198 +++++++++++++++++- packages/cli/package.json | 1 + packages/cli/src/commands/status.ts | 179 +++++++++++++--- packages/cli/src/lib/format.ts | 63 ++++++ packages/cli/src/lib/plugins.ts | 19 +- pnpm-lock.yaml | 3 + 6 files changed, 428 insertions(+), 35 deletions(-) diff --git a/packages/cli/__tests__/commands/status.test.ts b/packages/cli/__tests__/commands/status.test.ts index d460ab051..2ee8d822a 100644 --- a/packages/cli/__tests__/commands/status.test.ts +++ b/packages/cli/__tests__/commands/status.test.ts @@ -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 | 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); + }); }); diff --git a/packages/cli/package.json b/packages/cli/package.json index abb7487be..73de7ad13 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 60237bf0e..08fe41fac 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -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 { 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(); diff --git a/packages/cli/src/lib/format.ts b/packages/cli/src/lib/format.ts index 06a48178a..e1ec552d5 100644 --- a/packages/cli/src/lib/format.ts +++ b/packages/cli/src/lib/format.ts @@ -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)); +} diff --git a/packages/cli/src/lib/plugins.ts b/packages/cli/src/lib/plugins.ts index b5056e98b..dd8bf475d 100644 --- a/packages/cli/src/lib/plugins.ts +++ b/packages/cli/src/lib/plugins.ts @@ -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 = { "claude-code": claudeCodePlugin, @@ -9,6 +10,10 @@ const agentPlugins: Record = { aider: aiderPlugin, }; +const scmPlugins: Record = { + 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(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce57ed72b..5be8a4c71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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