@delegance/claude-autopilot 1.7.2 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/package.json +1 -1
- package/src/adapters/review-engine/claude.ts +2 -27
- package/src/adapters/review-engine/codex.ts +2 -27
- package/src/adapters/review-engine/gemini.ts +2 -27
- package/src/adapters/review-engine/openai-compatible.ts +2 -27
- package/src/adapters/review-engine/parse-output.ts +48 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/pr-comment.ts +137 -0
- package/src/cli/run.ts +21 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.0] — 2026-04-22
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **`--post-comments` flag on `run`** — posts a formatted markdown summary to the open PR after the pipeline; edits existing autopilot comment on re-runs instead of creating a new one (tracked via `<!-- autopilot-review -->` marker)
|
|
7
|
+
- **`detectPrNumber()`** — reads `PR_NUMBER`/`GH_PR_NUMBER`/`GITHUB_PR_NUMBER` env vars (CI) or falls back to `gh pr view` (local)
|
|
8
|
+
- **`formatComment()`** — status badge, context line, phase table, critical/warning findings with `file:line`, notes in `<details>`, cost footer
|
|
9
|
+
- 10 new formatter tests — **215 total**
|
|
10
|
+
|
|
11
|
+
## [1.8.0] — 2026-04-22
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **Shared `parseReviewOutput()`** (`src/adapters/review-engine/parse-output.ts`) — extracts `file:line` attribution from review finding bodies; used by all five adapters; eliminates ~100 lines of duplicated parser code
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- `hardcoded-secrets` false positive on route object keys containing `password` (e.g. `forgot_password: '/forgot-password'`)
|
|
18
|
+
|
|
19
|
+
## [1.7.2] — 2026-04-22
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- `hardcoded-secrets` rule no longer fires on route path values (values starting with `/`)
|
|
23
|
+
|
|
24
|
+
## [1.7.1] — 2026-04-22
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- Detection logging: `auto-detected:` line in run output shows stack, protected paths, and test command when inferred; git context (branch + last commit) shown on every run
|
|
28
|
+
|
|
3
29
|
## [1.7.0] — 2026-04-22
|
|
4
30
|
|
|
5
31
|
### Added
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
-
import type { Finding } from '../../core/findings/types.ts';
|
|
3
2
|
import { AutopilotError } from '../../core/errors.ts';
|
|
4
3
|
import type { Capabilities } from '../base.ts';
|
|
5
4
|
import type { ReviewEngine, ReviewInput, ReviewOutput } from './types.ts';
|
|
5
|
+
import { parseReviewOutput } from './parse-output.ts';
|
|
6
6
|
|
|
7
7
|
const DEFAULT_MODEL = 'claude-opus-4-7';
|
|
8
8
|
const MAX_OUTPUT_TOKENS = 4096;
|
|
@@ -90,7 +90,7 @@ export const claudeAdapter: ReviewEngine = {
|
|
|
90
90
|
: undefined;
|
|
91
91
|
|
|
92
92
|
return {
|
|
93
|
-
findings:
|
|
93
|
+
findings: parseReviewOutput(rawOutput, 'claude'),
|
|
94
94
|
rawOutput,
|
|
95
95
|
usage: response.usage
|
|
96
96
|
? { input: response.usage.input_tokens, output: response.usage.output_tokens, costUSD }
|
|
@@ -100,28 +100,3 @@ export const claudeAdapter: ReviewEngine = {
|
|
|
100
100
|
};
|
|
101
101
|
|
|
102
102
|
export default claudeAdapter;
|
|
103
|
-
|
|
104
|
-
function parseClaudeOutput(output: string): Finding[] {
|
|
105
|
-
const findings: Finding[] = [];
|
|
106
|
-
const regex = /### \[(CRITICAL|WARNING|NOTE)\]\s*(.+?)(?=\n### \[|## Review Summary|$)/gs;
|
|
107
|
-
let match: RegExpExecArray | null;
|
|
108
|
-
while ((match = regex.exec(output)) !== null) {
|
|
109
|
-
const severity = match[1]!.toLowerCase() as Finding['severity'];
|
|
110
|
-
const body = match[2]!.trim();
|
|
111
|
-
const titleEnd = body.indexOf('\n');
|
|
112
|
-
const title = (titleEnd > 0 ? body.slice(0, titleEnd) : body).trim();
|
|
113
|
-
const suggestion = body.match(/\*\*Suggestion:\*\*\s*(.+)/s)?.[1]?.trim();
|
|
114
|
-
findings.push({
|
|
115
|
-
id: `claude-${findings.length}`,
|
|
116
|
-
source: 'review-engine',
|
|
117
|
-
severity,
|
|
118
|
-
category: 'claude-review',
|
|
119
|
-
file: '<unspecified>',
|
|
120
|
-
message: title,
|
|
121
|
-
suggestion,
|
|
122
|
-
protectedPath: false,
|
|
123
|
-
createdAt: new Date().toISOString(),
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
return findings;
|
|
127
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
-
import
|
|
2
|
+
import { parseReviewOutput } from './parse-output.ts';
|
|
3
3
|
import { AutopilotError } from '../../core/errors.ts';
|
|
4
4
|
import type { Capabilities } from '../base.ts';
|
|
5
5
|
import type { ReviewEngine, ReviewInput, ReviewOutput } from './types.ts';
|
|
@@ -74,7 +74,7 @@ export const codexAdapter: ReviewEngine = {
|
|
|
74
74
|
|
|
75
75
|
const rawOutput = response.output_text ?? '';
|
|
76
76
|
return {
|
|
77
|
-
findings:
|
|
77
|
+
findings: parseReviewOutput(rawOutput, 'codex'),
|
|
78
78
|
rawOutput,
|
|
79
79
|
usage: response.usage ? { input: response.usage.input_tokens, output: response.usage.output_tokens } : undefined,
|
|
80
80
|
};
|
|
@@ -82,28 +82,3 @@ export const codexAdapter: ReviewEngine = {
|
|
|
82
82
|
};
|
|
83
83
|
|
|
84
84
|
export default codexAdapter;
|
|
85
|
-
|
|
86
|
-
function parseCodexOutput(output: string): Finding[] {
|
|
87
|
-
const findings: Finding[] = [];
|
|
88
|
-
const regex = /### \[(CRITICAL|WARNING|NOTE)\]\s*(.+?)(?=\n### \[|## Review Summary|$)/gs;
|
|
89
|
-
let match: RegExpExecArray | null;
|
|
90
|
-
while ((match = regex.exec(output)) !== null) {
|
|
91
|
-
const severity = match[1]!.toLowerCase() as Finding['severity'];
|
|
92
|
-
const body = match[2]!.trim();
|
|
93
|
-
const titleEnd = body.indexOf('\n');
|
|
94
|
-
const title = (titleEnd > 0 ? body.slice(0, titleEnd) : body).trim();
|
|
95
|
-
const suggestion = body.match(/\*\*Suggestion:\*\*\s*(.+)/s)?.[1]?.trim();
|
|
96
|
-
findings.push({
|
|
97
|
-
id: `codex-${findings.length}`,
|
|
98
|
-
source: 'review-engine',
|
|
99
|
-
severity,
|
|
100
|
-
category: 'codex-review',
|
|
101
|
-
file: '<unspecified>',
|
|
102
|
-
message: title,
|
|
103
|
-
suggestion,
|
|
104
|
-
protectedPath: false,
|
|
105
|
-
createdAt: new Date().toISOString(),
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
return findings;
|
|
109
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
2
|
-
import
|
|
2
|
+
import { parseReviewOutput } from './parse-output.ts';
|
|
3
3
|
import { AutopilotError } from '../../core/errors.ts';
|
|
4
4
|
import type { Capabilities } from '../base.ts';
|
|
5
5
|
import type { ReviewEngine, ReviewInput, ReviewOutput } from './types.ts';
|
|
@@ -95,7 +95,7 @@ export const geminiAdapter: ReviewEngine = {
|
|
|
95
95
|
: undefined;
|
|
96
96
|
|
|
97
97
|
return {
|
|
98
|
-
findings:
|
|
98
|
+
findings: parseReviewOutput(rawOutput, 'gemini'),
|
|
99
99
|
rawOutput,
|
|
100
100
|
usage: usage
|
|
101
101
|
? { input: usage.promptTokenCount, output: usage.candidatesTokenCount, costUSD }
|
|
@@ -105,28 +105,3 @@ export const geminiAdapter: ReviewEngine = {
|
|
|
105
105
|
};
|
|
106
106
|
|
|
107
107
|
export default geminiAdapter;
|
|
108
|
-
|
|
109
|
-
function parseGeminiOutput(output: string): Finding[] {
|
|
110
|
-
const findings: Finding[] = [];
|
|
111
|
-
const regex = /### \[(CRITICAL|WARNING|NOTE)\]\s*(.+?)(?=\n### \[|## Review Summary|$)/gs;
|
|
112
|
-
let match: RegExpExecArray | null;
|
|
113
|
-
while ((match = regex.exec(output)) !== null) {
|
|
114
|
-
const severity = match[1]!.toLowerCase() as Finding['severity'];
|
|
115
|
-
const body = match[2]!.trim();
|
|
116
|
-
const titleEnd = body.indexOf('\n');
|
|
117
|
-
const title = (titleEnd > 0 ? body.slice(0, titleEnd) : body).trim();
|
|
118
|
-
const suggestion = body.match(/\*\*Suggestion:\*\*\s*(.+)/s)?.[1]?.trim();
|
|
119
|
-
findings.push({
|
|
120
|
-
id: `gemini-${findings.length}`,
|
|
121
|
-
source: 'review-engine',
|
|
122
|
-
severity,
|
|
123
|
-
category: 'gemini-review',
|
|
124
|
-
file: '<unspecified>',
|
|
125
|
-
message: title,
|
|
126
|
-
suggestion,
|
|
127
|
-
protectedPath: false,
|
|
128
|
-
createdAt: new Date().toISOString(),
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
return findings;
|
|
132
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
-
import
|
|
2
|
+
import { parseReviewOutput } from './parse-output.ts';
|
|
3
3
|
import { AutopilotError } from '../../core/errors.ts';
|
|
4
4
|
import type { Capabilities } from '../base.ts';
|
|
5
5
|
import type { ReviewEngine, ReviewInput, ReviewOutput } from './types.ts';
|
|
@@ -90,7 +90,7 @@ export const openaiCompatibleAdapter: ReviewEngine = {
|
|
|
90
90
|
|
|
91
91
|
const rawOutput = response.choices[0]?.message.content ?? '';
|
|
92
92
|
return {
|
|
93
|
-
findings:
|
|
93
|
+
findings: parseReviewOutput(rawOutput, 'openai-compatible'),
|
|
94
94
|
rawOutput,
|
|
95
95
|
usage: response.usage
|
|
96
96
|
? { input: response.usage.prompt_tokens, output: response.usage.completion_tokens }
|
|
@@ -100,28 +100,3 @@ export const openaiCompatibleAdapter: ReviewEngine = {
|
|
|
100
100
|
};
|
|
101
101
|
|
|
102
102
|
export default openaiCompatibleAdapter;
|
|
103
|
-
|
|
104
|
-
function parseOutput(output: string): Finding[] {
|
|
105
|
-
const findings: Finding[] = [];
|
|
106
|
-
const regex = /### \[(CRITICAL|WARNING|NOTE)\]\s*(.+?)(?=\n### \[|## Review Summary|$)/gs;
|
|
107
|
-
let match: RegExpExecArray | null;
|
|
108
|
-
while ((match = regex.exec(output)) !== null) {
|
|
109
|
-
const severity = match[1]!.toLowerCase() as Finding['severity'];
|
|
110
|
-
const body = match[2]!.trim();
|
|
111
|
-
const titleEnd = body.indexOf('\n');
|
|
112
|
-
const title = (titleEnd > 0 ? body.slice(0, titleEnd) : body).trim();
|
|
113
|
-
const suggestion = body.match(/\*\*Suggestion:\*\*\s*(.+)/s)?.[1]?.trim();
|
|
114
|
-
findings.push({
|
|
115
|
-
id: `openai-compatible-${findings.length}`,
|
|
116
|
-
source: 'review-engine',
|
|
117
|
-
severity,
|
|
118
|
-
category: 'openai-compatible-review',
|
|
119
|
-
file: '<unspecified>',
|
|
120
|
-
message: title,
|
|
121
|
-
suggestion,
|
|
122
|
-
protectedPath: false,
|
|
123
|
-
createdAt: new Date().toISOString(),
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
return findings;
|
|
127
|
-
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Finding } from '../../core/findings/types.ts';
|
|
2
|
+
|
|
3
|
+
// Matches "path/to/file.ts:42", "`path/to/file.ts`", or bare filenames with common extensions
|
|
4
|
+
const FILE_REF = /(?:`([^`]+\.[a-z]{1,6})`|(\b[\w./\-]+\.[a-z]{1,6})(?::(\d+))?)/;
|
|
5
|
+
|
|
6
|
+
function extractFileRef(text: string): { file: string; line?: number } {
|
|
7
|
+
const m = text.match(FILE_REF);
|
|
8
|
+
if (!m) return { file: '<unspecified>' };
|
|
9
|
+
const raw = (m[1] ?? m[2])!;
|
|
10
|
+
// Skip version strings (v1.2.3) and bare dotfile extensions with no path separator
|
|
11
|
+
if (/^v?\d/.test(raw) || (!raw.includes('/') && raw.startsWith('.') && raw.split('.').length === 2)) {
|
|
12
|
+
return { file: '<unspecified>' };
|
|
13
|
+
}
|
|
14
|
+
const line = m[3] ? parseInt(m[3], 10) : undefined;
|
|
15
|
+
return { file: raw, line };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parses the structured [CRITICAL|WARNING|NOTE] markdown format
|
|
20
|
+
* produced by all review engine adapters. Extracts file:line references
|
|
21
|
+
* from the finding body when present.
|
|
22
|
+
*/
|
|
23
|
+
export function parseReviewOutput(output: string, idPrefix: string): Finding[] {
|
|
24
|
+
const findings: Finding[] = [];
|
|
25
|
+
const regex = /### \[(CRITICAL|WARNING|NOTE)\]\s*(.+?)(?=\n### \[|## Review Summary|$)/gs;
|
|
26
|
+
let match: RegExpExecArray | null;
|
|
27
|
+
while ((match = regex.exec(output)) !== null) {
|
|
28
|
+
const severity = match[1]!.toLowerCase() as Finding['severity'];
|
|
29
|
+
const body = match[2]!.trim();
|
|
30
|
+
const titleEnd = body.indexOf('\n');
|
|
31
|
+
const title = (titleEnd > 0 ? body.slice(0, titleEnd) : body).trim();
|
|
32
|
+
const suggestion = body.match(/\*\*Suggestion:\*\*\s*(.+)/s)?.[1]?.trim();
|
|
33
|
+
const { file, line } = extractFileRef(body);
|
|
34
|
+
findings.push({
|
|
35
|
+
id: `${idPrefix}-${findings.length}`,
|
|
36
|
+
source: 'review-engine',
|
|
37
|
+
severity,
|
|
38
|
+
category: 'review-engine',
|
|
39
|
+
file,
|
|
40
|
+
line,
|
|
41
|
+
message: title,
|
|
42
|
+
suggestion,
|
|
43
|
+
protectedPath: false,
|
|
44
|
+
createdAt: new Date().toISOString(),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return findings;
|
|
48
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ Options (run):
|
|
|
65
65
|
--config <path> Path to config file (default: ./autopilot.config.yaml)
|
|
66
66
|
--files <a,b,c> Explicit comma-separated file list (skips git detection)
|
|
67
67
|
--dry-run Show what would run without executing
|
|
68
|
+
--post-comments Post/update a summary comment on the open PR
|
|
68
69
|
--format <text|sarif> Output format (default: text)
|
|
69
70
|
--output <path> Output file path (required with --format sarif)
|
|
70
71
|
|
|
@@ -118,6 +119,7 @@ switch (subcommand) {
|
|
|
118
119
|
const config = flag('config');
|
|
119
120
|
const filesArg = flag('files');
|
|
120
121
|
const dryRun = boolFlag('dry-run');
|
|
122
|
+
const postComments = boolFlag('post-comments');
|
|
121
123
|
const formatArg = flag('format');
|
|
122
124
|
const outputPath = flag('output');
|
|
123
125
|
|
|
@@ -135,6 +137,7 @@ switch (subcommand) {
|
|
|
135
137
|
configPath: config,
|
|
136
138
|
files: filesArg ? filesArg.split(',').map(f => f.trim()) : undefined,
|
|
137
139
|
dryRun,
|
|
140
|
+
postComments,
|
|
138
141
|
format: formatArg as 'text' | 'sarif' | undefined,
|
|
139
142
|
outputPath,
|
|
140
143
|
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { runSafe } from '../core/shell.ts';
|
|
2
|
+
import type { RunResult } from '../core/pipeline/run.ts';
|
|
3
|
+
import type { AutopilotConfig } from '../core/config/types.ts';
|
|
4
|
+
import type { GitContext } from '../core/detect/git-context.ts';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { join, dirname } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const COMMENT_MARKER = '<!-- autopilot-review -->';
|
|
10
|
+
|
|
11
|
+
function readVersion(): string {
|
|
12
|
+
try {
|
|
13
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
14
|
+
return (JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }).version;
|
|
15
|
+
} catch { return 'unknown'; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Detect the current open PR number via gh CLI or CI env vars. */
|
|
19
|
+
export function detectPrNumber(cwd: string): number | null {
|
|
20
|
+
// CI env vars set by GitHub Actions
|
|
21
|
+
const fromEnv = process.env.PR_NUMBER ?? process.env.GH_PR_NUMBER ?? process.env.GITHUB_PR_NUMBER;
|
|
22
|
+
if (fromEnv && /^\d+$/.test(fromEnv)) return parseInt(fromEnv, 10);
|
|
23
|
+
|
|
24
|
+
// gh CLI — works locally and in CI when gh is authenticated
|
|
25
|
+
const raw = runSafe('gh', ['pr', 'view', '--json', 'number', '--jq', '.number'], { cwd });
|
|
26
|
+
if (raw) {
|
|
27
|
+
const n = parseInt(raw.trim(), 10);
|
|
28
|
+
if (!isNaN(n)) return n;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Find the ID of a previously-posted autopilot comment, if any. */
|
|
34
|
+
function findExistingCommentId(pr: number, cwd: string): number | null {
|
|
35
|
+
const raw = runSafe('gh', ['api', `repos/{owner}/{repo}/issues/${pr}/comments`,
|
|
36
|
+
'--jq', `[.[] | select(.body | startswith("${COMMENT_MARKER}")) | .id] | first`], { cwd });
|
|
37
|
+
if (!raw) return null;
|
|
38
|
+
const n = parseInt(raw.trim(), 10);
|
|
39
|
+
return isNaN(n) ? null : n;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Format a RunResult into a markdown PR comment. */
|
|
43
|
+
export function formatComment(
|
|
44
|
+
result: RunResult,
|
|
45
|
+
config: AutopilotConfig,
|
|
46
|
+
gitCtx: GitContext,
|
|
47
|
+
touchedFileCount: number,
|
|
48
|
+
): string {
|
|
49
|
+
const statusIcon = result.status === 'pass' ? '✅' : result.status === 'warn' ? '⚠️' : '❌';
|
|
50
|
+
const statusLabel = result.status === 'pass' ? 'Passed' : result.status === 'warn' ? 'Passed with warnings' : 'Failed';
|
|
51
|
+
|
|
52
|
+
const lines: string[] = [
|
|
53
|
+
COMMENT_MARKER,
|
|
54
|
+
`## ${statusIcon} Autopilot Review — ${statusLabel}`,
|
|
55
|
+
'',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// Context line
|
|
59
|
+
const ctx: string[] = [];
|
|
60
|
+
if (config.stack) ctx.push(`**Stack:** ${config.stack}`);
|
|
61
|
+
if (gitCtx.branch) ctx.push(`**Branch:** \`${gitCtx.branch}\``);
|
|
62
|
+
if (gitCtx.commitMessage) ctx.push(`**Commit:** ${gitCtx.commitMessage}`);
|
|
63
|
+
ctx.push(`**Files reviewed:** ${touchedFileCount}`);
|
|
64
|
+
lines.push(ctx.join(' · '), '');
|
|
65
|
+
|
|
66
|
+
// Phase table
|
|
67
|
+
lines.push('| Phase | Status | Findings |');
|
|
68
|
+
lines.push('|---|:---:|:---:|');
|
|
69
|
+
for (const phase of result.phases) {
|
|
70
|
+
const icon = phase.status === 'pass' ? '✅' : phase.status === 'skip' ? '—' :
|
|
71
|
+
phase.status === 'warn' ? '⚠️' : '❌';
|
|
72
|
+
lines.push(`| ${phase.phase} | ${icon} | ${phase.findings.length} |`);
|
|
73
|
+
}
|
|
74
|
+
lines.push('');
|
|
75
|
+
|
|
76
|
+
// Findings by severity
|
|
77
|
+
const critical = result.allFindings.filter(f => f.severity === 'critical');
|
|
78
|
+
const warnings = result.allFindings.filter(f => f.severity === 'warning');
|
|
79
|
+
const notes = result.allFindings.filter(f => f.severity === 'note');
|
|
80
|
+
|
|
81
|
+
if (critical.length > 0) {
|
|
82
|
+
lines.push('### 🚨 Critical');
|
|
83
|
+
for (const f of critical) {
|
|
84
|
+
const loc = f.file !== '<unspecified>' ? `\`${f.file}${f.line ? `:${f.line}` : ''}\` — ` : '';
|
|
85
|
+
lines.push(`- ${loc}${f.message}`);
|
|
86
|
+
if (f.suggestion) lines.push(` > ${f.suggestion}`);
|
|
87
|
+
}
|
|
88
|
+
lines.push('');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (warnings.length > 0) {
|
|
92
|
+
lines.push('### ⚠️ Warnings');
|
|
93
|
+
for (const f of warnings) {
|
|
94
|
+
const loc = f.file !== '<unspecified>' ? `\`${f.file}${f.line ? `:${f.line}` : ''}\` — ` : '';
|
|
95
|
+
lines.push(`- ${loc}${f.message}`);
|
|
96
|
+
if (f.suggestion) lines.push(` > ${f.suggestion}`);
|
|
97
|
+
}
|
|
98
|
+
lines.push('');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (notes.length > 0) {
|
|
102
|
+
lines.push('<details><summary>Notes</summary>\n');
|
|
103
|
+
for (const f of notes) {
|
|
104
|
+
const loc = f.file !== '<unspecified>' ? `\`${f.file}${f.line ? `:${f.line}` : ''}\` — ` : '';
|
|
105
|
+
lines.push(`- ${loc}${f.message}`);
|
|
106
|
+
}
|
|
107
|
+
lines.push('\n</details>\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (result.totalCostUSD !== undefined) {
|
|
111
|
+
lines.push(`*Cost: $${result.totalCostUSD.toFixed(4)} · ${result.durationMs}ms · [@delegance/claude-autopilot](https://github.com/axledbetter/claude-autopilot) v${readVersion()}*`);
|
|
112
|
+
} else {
|
|
113
|
+
lines.push(`*${result.durationMs}ms · [@delegance/claude-autopilot](https://github.com/axledbetter/claude-autopilot) v${readVersion()}*`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Post or update the autopilot comment on the given PR. */
|
|
120
|
+
export async function postPrComment(
|
|
121
|
+
pr: number,
|
|
122
|
+
body: string,
|
|
123
|
+
cwd: string,
|
|
124
|
+
): Promise<{ action: 'created' | 'updated'; url: string | null }> {
|
|
125
|
+
const existingId = findExistingCommentId(pr, cwd);
|
|
126
|
+
|
|
127
|
+
if (existingId) {
|
|
128
|
+
runSafe('gh', ['api', `repos/{owner}/{repo}/issues/comments/${existingId}`,
|
|
129
|
+
'--method', 'PATCH', '--field', `body=${body}`], { cwd });
|
|
130
|
+
return { action: 'updated', url: null };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const raw = runSafe('gh', ['pr', 'comment', String(pr), '--body', body], { cwd });
|
|
134
|
+
// gh outputs the comment URL on success
|
|
135
|
+
const url = raw?.trim() ?? null;
|
|
136
|
+
return { action: 'created', url };
|
|
137
|
+
}
|
package/src/cli/run.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { detectStack } from '../core/detect/stack.ts';
|
|
|
37
37
|
import { detectProtectedPaths } from '../core/detect/protected-paths.ts';
|
|
38
38
|
import { detectGitContext } from '../core/detect/git-context.ts';
|
|
39
39
|
import { detectProject } from './detector.ts';
|
|
40
|
+
import { detectPrNumber, formatComment, postPrComment } from './pr-comment.ts';
|
|
40
41
|
|
|
41
42
|
function readToolVersion(): string {
|
|
42
43
|
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
@@ -60,11 +61,12 @@ function fmt(color: keyof typeof C, text: string): string {
|
|
|
60
61
|
export interface RunCommandOptions {
|
|
61
62
|
cwd?: string;
|
|
62
63
|
configPath?: string;
|
|
63
|
-
base?: string;
|
|
64
|
-
files?: string[];
|
|
65
|
-
dryRun?: boolean;
|
|
64
|
+
base?: string; // git base ref (default HEAD~1)
|
|
65
|
+
files?: string[]; // explicit file list (skips git detection)
|
|
66
|
+
dryRun?: boolean; // skip review, print what would run
|
|
66
67
|
format?: 'text' | 'sarif';
|
|
67
68
|
outputPath?: string;
|
|
69
|
+
postComments?: boolean; // post/update summary comment on the open PR
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/**
|
|
@@ -189,6 +191,22 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
189
191
|
console.log(fmt('dim', `[run] SARIF written to ${options.outputPath}`));
|
|
190
192
|
}
|
|
191
193
|
|
|
194
|
+
// Post PR comment if requested
|
|
195
|
+
if (options.postComments) {
|
|
196
|
+
const pr = detectPrNumber(cwd);
|
|
197
|
+
if (!pr) {
|
|
198
|
+
console.log(fmt('yellow', ' [run] --post-comments: no open PR found — skipping comment'));
|
|
199
|
+
} else {
|
|
200
|
+
try {
|
|
201
|
+
const body = formatComment(result, config, gitCtx, touchedFiles.length);
|
|
202
|
+
const { action } = await postPrComment(pr, body, cwd);
|
|
203
|
+
console.log(fmt('dim', ` [run] PR #${pr} comment ${action}`));
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error(fmt('yellow', ` [run] Failed to post PR comment: ${err instanceof Error ? err.message : String(err)}`));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
192
210
|
// Print phase summaries
|
|
193
211
|
for (const phase of result.phases) {
|
|
194
212
|
const icon = phase.status === 'pass' ? fmt('green', '✓') :
|