Skip to content

Commit e930961

Browse files
authored
Merge pull request #10330 from continuedev/nate/cn-checks-command
feat: add cn checks CLI command
2 parents d585c3b + ff124c2 commit e930961

File tree

2 files changed

+329
-0
lines changed

2 files changed

+329
-0
lines changed
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import chalk from "chalk";
2+
3+
import { get, post } from "../util/apiClient.js";
4+
import { gracefulExit } from "../util/exit.js";
5+
import { getGitBranch, getGitRemoteUrl } from "../util/git.js";
6+
import { logger } from "../util/logger.js";
7+
8+
interface CheckStatus {
9+
name: string;
10+
state: "pending" | "success" | "failure";
11+
description: string;
12+
sessionId: string;
13+
commitMessage: string | null;
14+
suggestionStatus: string | null;
15+
agentStatus: string;
16+
}
17+
18+
interface ChecksStatusResponse {
19+
checks: CheckStatus[];
20+
pullRequestUrl: string;
21+
}
22+
23+
/**
24+
* Parse owner/repo from a git remote URL.
25+
* Supports HTTPS and SSH formats.
26+
*/
27+
function parseOwnerRepo(
28+
remoteUrl: string,
29+
): { owner: string; repo: string } | null {
30+
// HTTPS: https://github.com/owner/repo.git
31+
const httpsMatch = remoteUrl.match(
32+
/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/,
33+
);
34+
if (httpsMatch && httpsMatch[1] && httpsMatch[2]) {
35+
return { owner: httpsMatch[1], repo: httpsMatch[2] };
36+
}
37+
// SSH: git@github.com:owner/repo.git
38+
const sshMatch = remoteUrl.match(/github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
39+
if (sshMatch && sshMatch[1] && sshMatch[2]) {
40+
return { owner: sshMatch[1], repo: sshMatch[2] };
41+
}
42+
return null;
43+
}
44+
45+
/**
46+
* Auto-detect the PR URL from the current git branch using the GitHub API.
47+
*/
48+
async function detectPrUrl(): Promise<string | null> {
49+
const branch = getGitBranch();
50+
if (!branch) {
51+
return null;
52+
}
53+
54+
const remoteUrl = getGitRemoteUrl();
55+
if (!remoteUrl) {
56+
return null;
57+
}
58+
59+
const parsed = parseOwnerRepo(remoteUrl);
60+
if (!parsed) {
61+
return null;
62+
}
63+
64+
try {
65+
const response = await fetch(
66+
`https://api.github.com/repos/${parsed.owner}/${parsed.repo}/pulls?head=${parsed.owner}:${branch}&state=open`,
67+
{
68+
headers: {
69+
Accept: "application/vnd.github.v3+json",
70+
...(process.env.GITHUB_TOKEN
71+
? { Authorization: `token ${process.env.GITHUB_TOKEN}` }
72+
: {}),
73+
},
74+
},
75+
);
76+
77+
if (!response.ok) {
78+
logger.debug(`GitHub API returned ${response.status}`);
79+
return null;
80+
}
81+
82+
const prs = (await response.json()) as Array<{ html_url: string }>;
83+
if (prs.length > 0 && prs[0]) {
84+
return prs[0].html_url;
85+
}
86+
} catch (err) {
87+
logger.debug(`Failed to detect PR URL: ${err}`);
88+
}
89+
90+
return null;
91+
}
92+
93+
/**
94+
* Resolve the PR URL from the argument or auto-detect from git.
95+
*/
96+
async function resolvePrUrl(prUrlArg: string | undefined): Promise<string> {
97+
if (prUrlArg) {
98+
return prUrlArg;
99+
}
100+
101+
console.log(chalk.dim("Auto-detecting PR from current branch..."));
102+
const detected = await detectPrUrl();
103+
if (!detected) {
104+
console.error(
105+
chalk.red(
106+
"Could not detect a PR for the current branch. Please provide a PR URL.",
107+
),
108+
);
109+
await gracefulExit(1);
110+
throw new Error("unreachable");
111+
}
112+
113+
console.log(chalk.dim(`Found PR: ${detected}`));
114+
return detected;
115+
}
116+
117+
const STATE_ICONS: Record<string, string> = {
118+
success: chalk.green("\u2714"),
119+
failure: chalk.red("\u2716"),
120+
pending: chalk.yellow("\u25CB"),
121+
};
122+
123+
/**
124+
* Fetch and print the diff for a check, indented under the check output.
125+
*/
126+
async function printCheckDiff(check: CheckStatus): Promise<void> {
127+
try {
128+
const diffResponse = await get<{ diff: string }>(
129+
`agents/${check.sessionId}/diff`,
130+
);
131+
if (!diffResponse.data.diff) {
132+
return;
133+
}
134+
console.log(`\n${chalk.bold(` Diff:`)}`);
135+
for (const line of diffResponse.data.diff.split("\n")) {
136+
console.log(` ${line}`);
137+
}
138+
} catch (err) {
139+
logger.debug(`Failed to fetch diff for ${check.sessionId}: ${err}`);
140+
}
141+
}
142+
143+
/**
144+
* List check statuses for a PR, including diffs for checks with commits.
145+
*/
146+
async function listChecks(prUrl: string): Promise<void> {
147+
const response = await get<ChecksStatusResponse>(
148+
`api/checks/status?pullRequestUrl=${encodeURIComponent(prUrl)}`,
149+
);
150+
const { checks } = response.data;
151+
152+
if (checks.length === 0) {
153+
console.log(chalk.dim("No checks found for this PR."));
154+
return;
155+
}
156+
157+
console.log(chalk.bold(`\nChecks for ${chalk.cyan(prUrl)}\n`));
158+
159+
for (const check of checks) {
160+
const icon = STATE_ICONS[check.state] || "?";
161+
console.log(`${icon} ${chalk.bold(check.name)}`);
162+
console.log(` ${chalk.dim(check.description)}`);
163+
164+
if (check.commitMessage) {
165+
console.log(` Commit: ${check.commitMessage}`);
166+
}
167+
168+
if (check.suggestionStatus) {
169+
const statusColor =
170+
check.suggestionStatus === "pending"
171+
? chalk.yellow
172+
: check.suggestionStatus === "accepted"
173+
? chalk.green
174+
: chalk.red;
175+
console.log(` Suggestion: ${statusColor(check.suggestionStatus)}`);
176+
}
177+
178+
if (check.commitMessage) {
179+
await printCheckDiff(check);
180+
}
181+
182+
console.log();
183+
}
184+
185+
// Summary line
186+
const pending = checks.filter((c) => c.state === "pending").length;
187+
const failures = checks.filter((c) => c.state === "failure").length;
188+
const successes = checks.filter((c) => c.state === "success").length;
189+
190+
const parts: string[] = [];
191+
if (successes > 0) parts.push(chalk.green(`${successes} passed`));
192+
if (failures > 0) parts.push(chalk.red(`${failures} failing`));
193+
if (pending > 0) parts.push(chalk.yellow(`${pending} pending`));
194+
console.log(parts.join(", "));
195+
196+
// Exit code: 0=all pass, 1=any failure, 2=still pending
197+
if (failures > 0) {
198+
await gracefulExit(1);
199+
} else if (pending > 0) {
200+
await gracefulExit(2);
201+
}
202+
}
203+
204+
/**
205+
* Accept all pending suggestions for a PR.
206+
*/
207+
async function acceptChecks(prUrl: string): Promise<void> {
208+
const response = await get<ChecksStatusResponse>(
209+
`api/checks/status?pullRequestUrl=${encodeURIComponent(prUrl)}`,
210+
);
211+
const { checks } = response.data;
212+
213+
const pending = checks.filter(
214+
(c) => c.suggestionStatus === "pending" && c.commitMessage,
215+
);
216+
217+
if (pending.length === 0) {
218+
console.log(chalk.dim("No pending suggestions to accept."));
219+
return;
220+
}
221+
222+
console.log(
223+
chalk.bold(`Accepting ${pending.length} pending suggestion(s)...\n`),
224+
);
225+
226+
for (const check of pending) {
227+
try {
228+
await post(`agents/${check.sessionId}/accept`);
229+
console.log(chalk.green(`\u2714 Accepted: ${check.name}`));
230+
} catch (err) {
231+
const msg = err instanceof Error ? err.message : String(err);
232+
console.error(
233+
chalk.red(`\u2716 Failed to accept ${check.name}: ${msg}`),
234+
);
235+
}
236+
}
237+
}
238+
239+
/**
240+
* Reject all pending suggestions for a PR.
241+
*/
242+
async function rejectChecks(prUrl: string): Promise<void> {
243+
const response = await get<ChecksStatusResponse>(
244+
`api/checks/status?pullRequestUrl=${encodeURIComponent(prUrl)}`,
245+
);
246+
const { checks } = response.data;
247+
248+
const pending = checks.filter(
249+
(c) => c.suggestionStatus === "pending" && c.commitMessage,
250+
);
251+
252+
if (pending.length === 0) {
253+
console.log(chalk.dim("No pending suggestions to reject."));
254+
return;
255+
}
256+
257+
console.log(
258+
chalk.bold(`Rejecting ${pending.length} pending suggestion(s)...\n`),
259+
);
260+
261+
for (const check of pending) {
262+
try {
263+
await post(`agents/${check.sessionId}/reject`);
264+
console.log(chalk.red(`\u2716 Rejected: ${check.name}`));
265+
} catch (err) {
266+
const msg = err instanceof Error ? err.message : String(err);
267+
console.error(
268+
chalk.red(`\u2716 Failed to reject ${check.name}: ${msg}`),
269+
);
270+
}
271+
}
272+
}
273+
274+
/**
275+
* Main entry point for `cn checks` command.
276+
*
277+
* Usage:
278+
* cn checks [pr-url] - List checks with diffs
279+
* cn checks accept [pr-url] - Accept pending suggestions
280+
* cn checks reject [pr-url] - Reject pending suggestions
281+
*/
282+
export async function checks(
283+
actionOrUrl: string | undefined,
284+
prUrlArg: string | undefined,
285+
): Promise<void> {
286+
try {
287+
// Determine if first arg is an action or a PR URL
288+
let action: "list" | "accept" | "reject" = "list";
289+
let rawPrUrl: string | undefined = prUrlArg;
290+
291+
if (actionOrUrl === "accept" || actionOrUrl === "reject") {
292+
action = actionOrUrl;
293+
// prUrlArg already has the right value from Commander
294+
} else if (actionOrUrl) {
295+
// First arg is a PR URL, not an action
296+
rawPrUrl = actionOrUrl;
297+
}
298+
299+
const prUrl = await resolvePrUrl(rawPrUrl);
300+
301+
switch (action) {
302+
case "accept":
303+
await acceptChecks(prUrl);
304+
break;
305+
case "reject":
306+
await rejectChecks(prUrl);
307+
break;
308+
default:
309+
await listChecks(prUrl);
310+
break;
311+
}
312+
} catch (err) {
313+
if (err instanceof Error && err.name === "AuthenticationRequiredError") {
314+
console.error(chalk.red(err.message));
315+
await gracefulExit(1);
316+
}
317+
throw err;
318+
}
319+
}

extensions/cli/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import "./init.js";
66
import { Command } from "commander";
77

88
import { chat } from "./commands/chat.js";
9+
import { checks } from "./commands/checks.js";
910
import { login } from "./commands/login.js";
1011
import { logout } from "./commands/logout.js";
1112
import { listSessionsCommand } from "./commands/ls.js";
@@ -420,6 +421,15 @@ program
420421
await remoteTest(prompt, options.url);
421422
});
422423

424+
// Checks subcommand
425+
program
426+
.command("checks [action] [pr-url]")
427+
.description("Show CI check statuses for a PR")
428+
.action(async (action: string | undefined, prUrl: string | undefined) => {
429+
await posthogService.capture("cliCommand", { command: "checks" });
430+
await checks(action, prUrl);
431+
});
432+
423433
// Review subcommand
424434
program
425435
.command("review")

0 commit comments

Comments
 (0)