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