@ai-sdlc/orchestrator 0.4.0 → 0.6.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/dist/action-enforcement.d.ts +26 -0
- package/dist/action-enforcement.js +70 -0
- package/dist/adapters.d.ts +18 -3
- package/dist/adapters.js +92 -2
- package/dist/admission-score.d.ts +58 -0
- package/dist/admission-score.js +164 -0
- package/dist/cli/commands/init.js +4 -8
- package/dist/cli/commands/run.js +2 -2
- package/dist/config.d.ts +3 -0
- package/dist/config.js +14 -5
- package/dist/cycle-utils.d.ts +51 -0
- package/dist/cycle-utils.js +77 -0
- package/dist/defaults.d.ts +5 -0
- package/dist/defaults.js +5 -0
- package/dist/execute.d.ts +5 -2
- package/dist/execute.js +212 -62
- package/dist/fix-ci.js +45 -13
- package/dist/fix-review.d.ts +66 -0
- package/dist/fix-review.js +441 -0
- package/dist/index.d.ts +14 -4
- package/dist/index.js +18 -3
- package/dist/orchestrator.d.ts +1 -1
- package/dist/orchestrator.js +31 -9
- package/dist/pipeline-cycle-detector.d.ts +70 -0
- package/dist/pipeline-cycle-detector.js +111 -0
- package/dist/plugin.d.ts +9 -3
- package/dist/priority.d.ts +28 -0
- package/dist/priority.js +230 -0
- package/dist/review.d.ts +31 -0
- package/dist/review.js +74 -0
- package/dist/runners/claude-code.js +367 -35
- package/dist/runners/codex.js +15 -4
- package/dist/runners/copilot.js +15 -4
- package/dist/runners/cursor.js +15 -4
- package/dist/runners/generic-llm.js +1 -1
- package/dist/runners/index.d.ts +3 -1
- package/dist/runners/index.js +2 -0
- package/dist/runners/review-agent.d.ts +47 -0
- package/dist/runners/review-agent.js +220 -0
- package/dist/runners/security-triage.d.ts +43 -0
- package/dist/runners/security-triage.js +158 -0
- package/dist/runners/types.d.ts +24 -1
- package/dist/security.d.ts +8 -3
- package/dist/security.js +13 -2
- package/dist/shared.d.ts +17 -0
- package/dist/shared.js +27 -0
- package/dist/state/index.d.ts +1 -1
- package/dist/state/schema.d.ts +4 -1
- package/dist/state/schema.js +89 -1
- package/dist/state/store.d.ts +31 -1
- package/dist/state/store.js +208 -13
- package/dist/state/types.d.ts +52 -0
- package/dist/triage.d.ts +36 -0
- package/dist/triage.js +133 -0
- package/dist/types.d.ts +1 -1
- package/dist/watch.d.ts +6 -2
- package/dist/watch.js +34 -6
- package/dist/workflow-patterns/artifact-writer.d.ts +16 -0
- package/dist/workflow-patterns/artifact-writer.js +34 -0
- package/dist/workflow-patterns/classifiers.d.ts +10 -0
- package/dist/workflow-patterns/classifiers.js +72 -0
- package/dist/workflow-patterns/detector.d.ts +27 -0
- package/dist/workflow-patterns/detector.js +186 -0
- package/dist/workflow-patterns/index.d.ts +8 -0
- package/dist/workflow-patterns/index.js +7 -0
- package/dist/workflow-patterns/proposal-generator.d.ts +15 -0
- package/dist/workflow-patterns/proposal-generator.js +183 -0
- package/dist/workflow-patterns/telemetry-ingest.d.ts +27 -0
- package/dist/workflow-patterns/telemetry-ingest.js +103 -0
- package/dist/workflow-patterns/types.d.ts +61 -0
- package/dist/workflow-patterns/types.js +11 -0
- package/package.json +4 -2
package/dist/fix-ci.js
CHANGED
|
@@ -8,11 +8,12 @@ import { loadConfig } from './config.js';
|
|
|
8
8
|
import { createLogger } from './logger.js';
|
|
9
9
|
import { createStructuredConsoleLogger } from './structured-logger.js';
|
|
10
10
|
import { ClaudeCodeRunner } from './runners/claude-code.js';
|
|
11
|
-
import { execFileAsync, getGitHubConfig,
|
|
11
|
+
import { execFileAsync, getGitHubConfig, extractIssueId, resolveRepoRoot, createDefaultAuditLog, resolveAutonomyLevel, resolveConstraints, recordMetric, validateAndAuditOutput, authorizeFilesChanged, issueIdToNumber, } from './shared.js';
|
|
12
12
|
import { renderTemplate } from './notifications.js';
|
|
13
13
|
import { parseDuration } from './policy-evaluators.js';
|
|
14
14
|
import { checkKillSwitch, issueAgentCredentials, revokeAgentCredentials, } from './security.js';
|
|
15
15
|
import { DEFAULT_MAX_FIX_ATTEMPTS, DEFAULT_MAX_LOG_LINES, DEFAULT_GH_CLI_TIMEOUT_MS, DEFAULT_CONFIG_DIR_NAME, defaultSandboxConstraints, NOTIFICATION_TITLES, } from './defaults.js';
|
|
16
|
+
import { createCycleDetectorFromConfig, checkAndHandleCycle } from './cycle-utils.js';
|
|
16
17
|
export const MAX_FIX_ATTEMPTS = DEFAULT_MAX_FIX_ATTEMPTS;
|
|
17
18
|
export const MAX_LOG_LINES = DEFAULT_MAX_LOG_LINES;
|
|
18
19
|
export const RETRY_MARKER = '<!-- ai-sdlc-fix-ci-attempt -->';
|
|
@@ -107,6 +108,8 @@ export async function executeFixCI(prNumber, runId, options = {}) {
|
|
|
107
108
|
}
|
|
108
109
|
// Tracker is available if injected directly or if we're not in test mode
|
|
109
110
|
const trackerAvailable = !!options.tracker || options._prComments === undefined;
|
|
111
|
+
// Create cycle detector (marker generated after all guard conditions pass)
|
|
112
|
+
const cycleDetector = createCycleDetectorFromConfig(config.pipeline?.spec ?? {});
|
|
110
113
|
// 2. Count retry attempts (via injected comments or IssueTracker)
|
|
111
114
|
log.stage('check-retries');
|
|
112
115
|
let comments;
|
|
@@ -126,6 +129,27 @@ export async function executeFixCI(prNumber, runId, options = {}) {
|
|
|
126
129
|
await getTracker().addComment(String(prNumber), body);
|
|
127
130
|
}
|
|
128
131
|
};
|
|
132
|
+
// Check for pipeline-level cycles AFTER retry counting
|
|
133
|
+
// (so legitimate retries under the limit aren't blocked)
|
|
134
|
+
if (trackerAvailable) {
|
|
135
|
+
const cycleCheck = await checkAndHandleCycle({
|
|
136
|
+
issueOrPrId: String(prNumber),
|
|
137
|
+
stage: 'fix-ci',
|
|
138
|
+
tracker: getTracker(),
|
|
139
|
+
detector: cycleDetector,
|
|
140
|
+
});
|
|
141
|
+
if (cycleCheck.cycleDetected) {
|
|
142
|
+
log.info('Pipeline cycle detected. Halting fix-ci execution.');
|
|
143
|
+
auditLog.record({
|
|
144
|
+
actor: 'system',
|
|
145
|
+
action: 'evaluate',
|
|
146
|
+
resource: `pr#${prNumber}`,
|
|
147
|
+
decision: 'denied',
|
|
148
|
+
details: { reason: 'pipeline-cycle-detected' },
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
129
153
|
if (attempts >= maxFixAttempts) {
|
|
130
154
|
log.info(`Fix-CI retry limit reached (${maxFixAttempts}). Commenting and stopping.`);
|
|
131
155
|
auditLog.record({
|
|
@@ -148,6 +172,9 @@ export async function executeFixCI(prNumber, runId, options = {}) {
|
|
|
148
172
|
await addComment(`## ${limitComment.title}\n\n${limitComment.body}`);
|
|
149
173
|
return;
|
|
150
174
|
}
|
|
175
|
+
// Generate cycle marker AFTER all guard conditions (retry limit + cycle detection)
|
|
176
|
+
// so it's only recorded when we actually proceed with execution
|
|
177
|
+
const cycleMarker = cycleDetector.recordInvocation('fix-ci');
|
|
151
178
|
// 3. Fetch CI logs
|
|
152
179
|
log.stage('fetch-logs');
|
|
153
180
|
const ciLogs = await fetchCILogs(runId, options._ciLogs, options.ciAdapter);
|
|
@@ -157,24 +184,25 @@ export async function executeFixCI(prNumber, runId, options = {}) {
|
|
|
157
184
|
cwd: workDir,
|
|
158
185
|
});
|
|
159
186
|
const currentBranch = branchStdout.trim();
|
|
160
|
-
const
|
|
161
|
-
if (
|
|
162
|
-
throw new Error(`Branch "${currentBranch}" does not match ai-sdlc/issue
|
|
187
|
+
const issueId = extractIssueId(currentBranch);
|
|
188
|
+
if (issueId === null) {
|
|
189
|
+
throw new Error(`Branch "${currentBranch}" does not match ai-sdlc/issue-<id> pattern`);
|
|
163
190
|
}
|
|
191
|
+
const issueNumber = issueIdToNumber(issueId);
|
|
164
192
|
// 5. Resolve autonomy level and constraints
|
|
165
193
|
const currentLevel = resolveAutonomyLevel(autonomyPolicy);
|
|
166
194
|
const resolved = resolveConstraints(agentRole.spec.constraints, currentLevel);
|
|
167
195
|
// 6. Fetch issue data (via tracker when available)
|
|
168
|
-
let issueTitle = `Issue
|
|
196
|
+
let issueTitle = `Issue ${issueId}`;
|
|
169
197
|
let issueBody = '';
|
|
170
198
|
if (trackerAvailable) {
|
|
171
|
-
const issueData = await getTracker().getIssue(
|
|
199
|
+
const issueData = await getTracker().getIssue(issueId);
|
|
172
200
|
issueTitle = issueData.title;
|
|
173
201
|
issueBody = issueData.description ?? '';
|
|
174
202
|
}
|
|
175
203
|
// Store issue context in working memory
|
|
176
204
|
if (options.memory) {
|
|
177
|
-
options.memory.working.set('currentIssue', { prNumber,
|
|
205
|
+
options.memory.working.set('currentIssue', { prNumber, issueId, currentBranch });
|
|
178
206
|
}
|
|
179
207
|
// Query episodic memory for previous fix-CI attempts
|
|
180
208
|
if (options.memory) {
|
|
@@ -195,7 +223,7 @@ export async function executeFixCI(prNumber, runId, options = {}) {
|
|
|
195
223
|
try {
|
|
196
224
|
if (options.security) {
|
|
197
225
|
const timeoutMs = codeStage?.timeout ? parseDuration(codeStage.timeout) : undefined;
|
|
198
|
-
sandboxId = await options.security.sandbox.isolate(`issue-${
|
|
226
|
+
sandboxId = await options.security.sandbox.isolate(`issue-${issueId}`, defaultSandboxConstraints(workDir, timeoutMs));
|
|
199
227
|
}
|
|
200
228
|
// Issue JIT credentials before agent execution
|
|
201
229
|
const jitCred = options.security
|
|
@@ -207,7 +235,8 @@ export async function executeFixCI(prNumber, runId, options = {}) {
|
|
|
207
235
|
[ATTRIBUTE_KEYS.RESOURCE_NAME]: `pr#${prNumber}`,
|
|
208
236
|
}, async () => {
|
|
209
237
|
const r = await runner.run({
|
|
210
|
-
|
|
238
|
+
issueId,
|
|
239
|
+
issueNumber: issueNumber ?? undefined,
|
|
211
240
|
issueTitle,
|
|
212
241
|
issueBody,
|
|
213
242
|
workDir,
|
|
@@ -257,7 +286,8 @@ export async function executeFixCI(prNumber, runId, options = {}) {
|
|
|
257
286
|
const agentFailComment = agentFailTpl
|
|
258
287
|
? renderTemplate(agentFailTpl, { stageName: 'fix-ci', details: errorDetail })
|
|
259
288
|
: { title: NOTIFICATION_TITLES.fixCIAgentFailed, body: errorDetail };
|
|
260
|
-
|
|
289
|
+
// Use pre-created marker (one per execution, prevents double-counting)
|
|
290
|
+
await addComment(`## ${agentFailComment.title}\n\n${agentFailComment.body}\n\n${RETRY_MARKER}\n${cycleMarker}`);
|
|
261
291
|
throw new Error(`Fix-CI agent failed on PR #${prNumber}: ${r.error}`);
|
|
262
292
|
}
|
|
263
293
|
log.stageEnd('agent');
|
|
@@ -306,7 +336,7 @@ export async function executeFixCI(prNumber, runId, options = {}) {
|
|
|
306
336
|
auditLog,
|
|
307
337
|
log,
|
|
308
338
|
onViolation: async (violationList) => {
|
|
309
|
-
await addComment(`## ${NOTIFICATION_TITLES.fixCIGuardrailViolations}\n\n${violationList}\n\n${RETRY_MARKER}`);
|
|
339
|
+
await addComment(`## ${NOTIFICATION_TITLES.fixCIGuardrailViolations}\n\n${violationList}\n\n${RETRY_MARKER}\n${cycleMarker}`);
|
|
310
340
|
},
|
|
311
341
|
});
|
|
312
342
|
});
|
|
@@ -333,6 +363,7 @@ export async function executeFixCI(prNumber, runId, options = {}) {
|
|
|
333
363
|
title: NOTIFICATION_TITLES.fixCIApplied,
|
|
334
364
|
body: `Attempt ${attempts + 1} of ${maxFixAttempts} — pushed fixes to \`${currentBranch}\`.`,
|
|
335
365
|
};
|
|
366
|
+
// Use pre-created marker (one per execution, prevents double-counting)
|
|
336
367
|
await addComment([
|
|
337
368
|
`## ${successComment.title}`,
|
|
338
369
|
'',
|
|
@@ -342,6 +373,7 @@ export async function executeFixCI(prNumber, runId, options = {}) {
|
|
|
342
373
|
result.filesChanged.map((f) => `- \`${f}\``).join('\n'),
|
|
343
374
|
'',
|
|
344
375
|
RETRY_MARKER,
|
|
376
|
+
cycleMarker,
|
|
345
377
|
].join('\n'));
|
|
346
378
|
// 12. Record episodic memory (success)
|
|
347
379
|
if (options.memory) {
|
|
@@ -349,7 +381,7 @@ export async function executeFixCI(prNumber, runId, options = {}) {
|
|
|
349
381
|
key: 'fix-ci-execution',
|
|
350
382
|
value: {
|
|
351
383
|
prNumber,
|
|
352
|
-
|
|
384
|
+
issueId,
|
|
353
385
|
filesChanged: result.filesChanged.length,
|
|
354
386
|
outcome: 'success',
|
|
355
387
|
},
|
|
@@ -365,7 +397,7 @@ export async function executeFixCI(prNumber, runId, options = {}) {
|
|
|
365
397
|
key: 'fix-ci-execution',
|
|
366
398
|
value: {
|
|
367
399
|
prNumber,
|
|
368
|
-
|
|
400
|
+
issueId,
|
|
369
401
|
outcome: 'failure',
|
|
370
402
|
error: err instanceof Error ? err.message : String(err),
|
|
371
403
|
},
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix-Review orchestrator — detects review findings on agent-created PRs,
|
|
3
|
+
* fetches review comments, and re-invokes the agent with review context.
|
|
4
|
+
* Capped at MAX_FIX_ATTEMPTS to prevent infinite loops.
|
|
5
|
+
*/
|
|
6
|
+
import { type IssueTracker, type AuditLog, type MetricStore, type AgentMemory, type SecretStore } from '@ai-sdlc/reference';
|
|
7
|
+
import { type Logger } from './logger.js';
|
|
8
|
+
import type { AgentRunner } from './runners/types.js';
|
|
9
|
+
import { type SecurityContext } from './security.js';
|
|
10
|
+
export declare const MAX_REVIEW_FIX_ATTEMPTS = 2;
|
|
11
|
+
export declare const RETRY_MARKER = "<!-- ai-sdlc-fix-review-attempt -->";
|
|
12
|
+
export interface FixReviewOptions {
|
|
13
|
+
/** Override the config directory (defaults to `.ai-sdlc`). */
|
|
14
|
+
configDir?: string;
|
|
15
|
+
/** Override the working directory (defaults to repo root). */
|
|
16
|
+
workDir?: string;
|
|
17
|
+
/** Inject a custom runner (for testing). */
|
|
18
|
+
runner?: AgentRunner;
|
|
19
|
+
/** Inject a custom logger (for testing). */
|
|
20
|
+
logger?: Logger;
|
|
21
|
+
/** Inject PR comments for testing (bypasses IssueTracker call). */
|
|
22
|
+
_prComments?: string[];
|
|
23
|
+
/** Inject review findings for testing (bypasses GitHub API call). */
|
|
24
|
+
_reviewFindings?: string;
|
|
25
|
+
/** Inject a custom audit log (for testing). */
|
|
26
|
+
auditLog?: AuditLog;
|
|
27
|
+
/** Inject a custom issue tracker (for testing). */
|
|
28
|
+
tracker?: IssueTracker;
|
|
29
|
+
/** In-process metric store for testable telemetry. */
|
|
30
|
+
metricStore?: MetricStore;
|
|
31
|
+
/** Agent memory for episodic recall. */
|
|
32
|
+
memory?: AgentMemory;
|
|
33
|
+
/** Security context for kill switch and JIT credentials. */
|
|
34
|
+
security?: SecurityContext;
|
|
35
|
+
/** Use the reference structured logger instead of the plain console logger. */
|
|
36
|
+
useStructuredLogger?: boolean;
|
|
37
|
+
/** Secret store adapter for resolving credentials (defaults to process.env). */
|
|
38
|
+
secretStore?: SecretStore;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Count how many fix-review retry attempts have been made on a PR
|
|
42
|
+
* by scanning comments for the hidden retry marker.
|
|
43
|
+
*/
|
|
44
|
+
export declare function countRetryAttempts(comments: string[]): number;
|
|
45
|
+
/**
|
|
46
|
+
* Validate that a PR number is a positive integer.
|
|
47
|
+
*/
|
|
48
|
+
export declare function validatePrNumber(prNumber: number): void;
|
|
49
|
+
/**
|
|
50
|
+
* Sanitize a git branch name to prevent command injection.
|
|
51
|
+
* Allows only alphanumeric characters, slashes, dashes, underscores, and dots.
|
|
52
|
+
*/
|
|
53
|
+
export declare function sanitizeBranchName(branch: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* Fetch review findings from PR reviews that requested changes.
|
|
56
|
+
* Returns a formatted string with all findings from review agents.
|
|
57
|
+
*/
|
|
58
|
+
export declare function fetchReviewFindings(prNumber: number, injectedFindings?: string, _secretStore?: SecretStore): Promise<string>;
|
|
59
|
+
/**
|
|
60
|
+
* Execute the fix-review pipeline for a PR with review findings.
|
|
61
|
+
*
|
|
62
|
+
* Returns gracefully (no throw) when the retry limit is reached.
|
|
63
|
+
* Throws on agent failure or guardrail violations.
|
|
64
|
+
*/
|
|
65
|
+
export declare function executeFixReview(prNumber: number, options?: FixReviewOptions): Promise<void>;
|
|
66
|
+
//# sourceMappingURL=fix-review.d.ts.map
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix-Review orchestrator — detects review findings on agent-created PRs,
|
|
3
|
+
* fetches review comments, and re-invokes the agent with review context.
|
|
4
|
+
* Capped at MAX_FIX_ATTEMPTS to prevent infinite loops.
|
|
5
|
+
*/
|
|
6
|
+
import { createGitHubIssueTracker, evaluateDemotion, withSpan, getMeter, SPAN_NAMES, METRIC_NAMES, ATTRIBUTE_KEYS, } from '@ai-sdlc/reference';
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import { createLogger } from './logger.js';
|
|
9
|
+
import { createStructuredConsoleLogger } from './structured-logger.js';
|
|
10
|
+
import { ClaudeCodeRunner } from './runners/claude-code.js';
|
|
11
|
+
import { execFileAsync, getGitHubConfig, extractIssueId, resolveRepoRoot, createDefaultAuditLog, resolveAutonomyLevel, resolveConstraints, recordMetric, validateAndAuditOutput, authorizeFilesChanged, issueIdToNumber, } from './shared.js';
|
|
12
|
+
import { renderTemplate } from './notifications.js';
|
|
13
|
+
import { parseDuration } from './policy-evaluators.js';
|
|
14
|
+
import { checkKillSwitch, issueAgentCredentials, revokeAgentCredentials, } from './security.js';
|
|
15
|
+
import { DEFAULT_GH_CLI_TIMEOUT_MS, DEFAULT_CONFIG_DIR_NAME, defaultSandboxConstraints, NOTIFICATION_TITLES, } from './defaults.js';
|
|
16
|
+
import { createCycleDetectorFromConfig, checkAndHandleCycle } from './cycle-utils.js';
|
|
17
|
+
// Default max review-fix attempts (lower than CI-fix since reviews are more deterministic)
|
|
18
|
+
export const MAX_REVIEW_FIX_ATTEMPTS = 2;
|
|
19
|
+
export const RETRY_MARKER = '<!-- ai-sdlc-fix-review-attempt -->';
|
|
20
|
+
/**
|
|
21
|
+
* Count how many fix-review retry attempts have been made on a PR
|
|
22
|
+
* by scanning comments for the hidden retry marker.
|
|
23
|
+
*/
|
|
24
|
+
export function countRetryAttempts(comments) {
|
|
25
|
+
let count = 0;
|
|
26
|
+
for (const body of comments) {
|
|
27
|
+
const matches = body.match(new RegExp(RETRY_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'));
|
|
28
|
+
if (matches) {
|
|
29
|
+
count += matches.length;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return count;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Validate that a PR number is a positive integer.
|
|
36
|
+
*/
|
|
37
|
+
export function validatePrNumber(prNumber) {
|
|
38
|
+
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|
39
|
+
throw new Error(`Invalid PR number: ${prNumber} (must be a positive integer)`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Sanitize a git branch name to prevent command injection.
|
|
44
|
+
* Allows only alphanumeric characters, slashes, dashes, underscores, and dots.
|
|
45
|
+
*/
|
|
46
|
+
export function sanitizeBranchName(branch) {
|
|
47
|
+
if (!/^[a-zA-Z0-9/_.-]+$/.test(branch)) {
|
|
48
|
+
throw new Error(`Invalid branch name: "${branch}" (only alphanumeric, /, -, _, . allowed)`);
|
|
49
|
+
}
|
|
50
|
+
return branch;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Fetch review findings from PR reviews that requested changes.
|
|
54
|
+
* Returns a formatted string with all findings from review agents.
|
|
55
|
+
*/
|
|
56
|
+
export async function fetchReviewFindings(prNumber, injectedFindings, _secretStore) {
|
|
57
|
+
if (injectedFindings !== undefined) {
|
|
58
|
+
return injectedFindings;
|
|
59
|
+
}
|
|
60
|
+
// Validate PR number to prevent command injection
|
|
61
|
+
validatePrNumber(prNumber);
|
|
62
|
+
// Fetch reviews using gh CLI
|
|
63
|
+
const { stdout } = await execFileAsync('gh', ['pr', 'review', String(prNumber), '--json', 'state,body,author'], { timeout: DEFAULT_GH_CLI_TIMEOUT_MS });
|
|
64
|
+
let reviews;
|
|
65
|
+
try {
|
|
66
|
+
reviews = JSON.parse(stdout);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
throw new Error(`Failed to parse review data from gh CLI: ${err instanceof Error ? err.message : String(err)}`);
|
|
70
|
+
}
|
|
71
|
+
const changesRequestedReviews = reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
|
|
72
|
+
if (changesRequestedReviews.length === 0) {
|
|
73
|
+
return 'No review findings (all reviews approved or pending)';
|
|
74
|
+
}
|
|
75
|
+
// Format findings from all reviews
|
|
76
|
+
const findings = changesRequestedReviews
|
|
77
|
+
.map((r) => `### Review by ${r.author.login}\n\n${r.body}\n\n---\n`)
|
|
78
|
+
.join('\n');
|
|
79
|
+
return findings;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Execute the fix-review pipeline for a PR with review findings.
|
|
83
|
+
*
|
|
84
|
+
* Returns gracefully (no throw) when the retry limit is reached.
|
|
85
|
+
* Throws on agent failure or guardrail violations.
|
|
86
|
+
*/
|
|
87
|
+
export async function executeFixReview(prNumber, options = {}) {
|
|
88
|
+
const workDir = options.workDir ?? (await resolveRepoRoot());
|
|
89
|
+
const configDir = options.configDir ?? `${workDir}/${DEFAULT_CONFIG_DIR_NAME}`;
|
|
90
|
+
const log = options.logger ??
|
|
91
|
+
(options.useStructuredLogger ? createStructuredConsoleLogger() : createLogger());
|
|
92
|
+
const auditLog = options.auditLog ?? createDefaultAuditLog(workDir);
|
|
93
|
+
const metricStore = options.metricStore;
|
|
94
|
+
// 1. Load config
|
|
95
|
+
log.stage('load-config');
|
|
96
|
+
const config = loadConfig(configDir);
|
|
97
|
+
log.stageEnd('load-config');
|
|
98
|
+
// Kill switch check (before any work)
|
|
99
|
+
if (options.security) {
|
|
100
|
+
await checkKillSwitch(options.security);
|
|
101
|
+
}
|
|
102
|
+
if (!config.agentRole) {
|
|
103
|
+
throw new Error('No AgentRole resource found in .ai-sdlc/');
|
|
104
|
+
}
|
|
105
|
+
if (!config.autonomyPolicy) {
|
|
106
|
+
throw new Error('No AutonomyPolicy resource found in .ai-sdlc/');
|
|
107
|
+
}
|
|
108
|
+
const agentRole = config.agentRole;
|
|
109
|
+
const autonomyPolicy = config.autonomyPolicy;
|
|
110
|
+
// Derive max fix attempts from pipeline config (review stage onFailure.maxRetries)
|
|
111
|
+
const reviewStage = config.pipeline?.spec.stages.find((s) => s.name === 'review');
|
|
112
|
+
const maxFixAttempts = reviewStage?.onFailure?.maxRetries ?? MAX_REVIEW_FIX_ATTEMPTS;
|
|
113
|
+
// Notification templates
|
|
114
|
+
const notifTemplates = config.pipeline?.spec.notifications?.templates;
|
|
115
|
+
// Create default tracker when needed (lazy to avoid resolving secrets in test environments).
|
|
116
|
+
// In production (no _prComments injected), the tracker is always available.
|
|
117
|
+
let _tracker = options.tracker;
|
|
118
|
+
function getTracker() {
|
|
119
|
+
if (!_tracker) {
|
|
120
|
+
const { org, repo } = getGitHubConfig(options.secretStore);
|
|
121
|
+
const ghConfig = { org, repo, token: { secretRef: 'github-token' } };
|
|
122
|
+
_tracker = createGitHubIssueTracker(ghConfig);
|
|
123
|
+
}
|
|
124
|
+
return _tracker;
|
|
125
|
+
}
|
|
126
|
+
// Tracker is available if injected directly or if we're not in test mode
|
|
127
|
+
const trackerAvailable = !!options.tracker || options._prComments === undefined;
|
|
128
|
+
// Create cycle detector (marker generated after all guard conditions pass)
|
|
129
|
+
const cycleDetector = createCycleDetectorFromConfig(config.pipeline?.spec ?? {});
|
|
130
|
+
// 2. Count retry attempts (via injected comments or IssueTracker)
|
|
131
|
+
log.stage('check-retries');
|
|
132
|
+
let comments;
|
|
133
|
+
if (options._prComments !== undefined) {
|
|
134
|
+
comments = options._prComments;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
const issueComments = await getTracker().getComments(String(prNumber));
|
|
138
|
+
comments = issueComments.map((c) => c.body);
|
|
139
|
+
}
|
|
140
|
+
const attempts = countRetryAttempts(comments);
|
|
141
|
+
log.info(`Fix-review attempt ${attempts + 1} of ${maxFixAttempts}`);
|
|
142
|
+
log.stageEnd('check-retries');
|
|
143
|
+
// Helper to add a comment via tracker (uses default tracker in production)
|
|
144
|
+
const addComment = async (body) => {
|
|
145
|
+
if (trackerAvailable) {
|
|
146
|
+
await getTracker().addComment(String(prNumber), body);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
if (attempts >= maxFixAttempts) {
|
|
150
|
+
log.info(`Fix-review retry limit reached (${maxFixAttempts}). Commenting and stopping.`);
|
|
151
|
+
auditLog.record({
|
|
152
|
+
actor: 'system',
|
|
153
|
+
action: 'evaluate',
|
|
154
|
+
resource: `pr#${prNumber}`,
|
|
155
|
+
decision: 'denied',
|
|
156
|
+
details: { reason: 'retry-limit-reached', attempts, max: maxFixAttempts },
|
|
157
|
+
});
|
|
158
|
+
const limitTpl = notifTemplates?.['fix-review-limit'];
|
|
159
|
+
const limitComment = limitTpl
|
|
160
|
+
? renderTemplate(limitTpl, {
|
|
161
|
+
attempts: String(attempts),
|
|
162
|
+
max: String(maxFixAttempts),
|
|
163
|
+
})
|
|
164
|
+
: {
|
|
165
|
+
title: NOTIFICATION_TITLES.fixReviewRetryLimit,
|
|
166
|
+
body: `This PR has reached the maximum number of automated review-fix attempts (${maxFixAttempts}). Manual intervention is needed.`,
|
|
167
|
+
};
|
|
168
|
+
await addComment(`## ${limitComment.title}\n\n${limitComment.body}`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// Check for pipeline-level cycles AFTER retry counting
|
|
172
|
+
if (trackerAvailable) {
|
|
173
|
+
const cycleCheck = await checkAndHandleCycle({
|
|
174
|
+
issueOrPrId: String(prNumber),
|
|
175
|
+
stage: 'fix-review',
|
|
176
|
+
tracker: getTracker(),
|
|
177
|
+
detector: cycleDetector,
|
|
178
|
+
});
|
|
179
|
+
if (cycleCheck.cycleDetected) {
|
|
180
|
+
log.info('Pipeline cycle detected. Halting fix-review execution.');
|
|
181
|
+
auditLog.record({
|
|
182
|
+
actor: 'system',
|
|
183
|
+
action: 'evaluate',
|
|
184
|
+
resource: `pr#${prNumber}`,
|
|
185
|
+
decision: 'denied',
|
|
186
|
+
details: { reason: 'pipeline-cycle-detected' },
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Generate cycle marker AFTER all guard conditions pass
|
|
192
|
+
const cycleMarker = cycleDetector.recordInvocation('fix-review');
|
|
193
|
+
// 3. Fetch review findings
|
|
194
|
+
log.stage('fetch-findings');
|
|
195
|
+
const reviewFindings = await fetchReviewFindings(prNumber, options._reviewFindings, options.secretStore);
|
|
196
|
+
log.stageEnd('fetch-findings');
|
|
197
|
+
// Check if review findings are actionable (not a generic "no findings" message)
|
|
198
|
+
const isActionable = reviewFindings &&
|
|
199
|
+
!reviewFindings.startsWith('No review findings') &&
|
|
200
|
+
reviewFindings.trim().length > 0;
|
|
201
|
+
if (!isActionable) {
|
|
202
|
+
log.info('No actionable review findings. Skipping fix-review execution.');
|
|
203
|
+
auditLog.record({
|
|
204
|
+
actor: 'system',
|
|
205
|
+
action: 'evaluate',
|
|
206
|
+
resource: `pr#${prNumber}`,
|
|
207
|
+
decision: 'allowed',
|
|
208
|
+
details: { reason: 'no-actionable-findings', reviewFindings },
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// 4. Determine branch and issue number
|
|
213
|
+
const { stdout: branchStdout } = await execFileAsync('git', ['branch', '--show-current'], {
|
|
214
|
+
cwd: workDir,
|
|
215
|
+
});
|
|
216
|
+
const currentBranch = sanitizeBranchName(branchStdout.trim());
|
|
217
|
+
const issueId = extractIssueId(currentBranch);
|
|
218
|
+
if (issueId === null) {
|
|
219
|
+
throw new Error(`Branch "${currentBranch}" does not match ai-sdlc/issue-<id> pattern`);
|
|
220
|
+
}
|
|
221
|
+
const issueNumber = issueIdToNumber(issueId);
|
|
222
|
+
// 5. Resolve autonomy level and constraints
|
|
223
|
+
const currentLevel = resolveAutonomyLevel(autonomyPolicy);
|
|
224
|
+
const resolved = resolveConstraints(agentRole.spec.constraints, currentLevel);
|
|
225
|
+
// 6. Fetch issue data (via tracker when available)
|
|
226
|
+
let issueTitle = `Issue ${issueId}`;
|
|
227
|
+
let issueBody = '';
|
|
228
|
+
if (trackerAvailable) {
|
|
229
|
+
const issueData = await getTracker().getIssue(issueId);
|
|
230
|
+
issueTitle = issueData.title;
|
|
231
|
+
issueBody = issueData.description ?? '';
|
|
232
|
+
}
|
|
233
|
+
// Store issue context in working memory
|
|
234
|
+
if (options.memory) {
|
|
235
|
+
options.memory.working.set('currentIssue', { prNumber, issueId, currentBranch });
|
|
236
|
+
}
|
|
237
|
+
// Query episodic memory for previous fix-review attempts
|
|
238
|
+
if (options.memory) {
|
|
239
|
+
const previousAttempts = options.memory.episodic.search('fix-review-execution');
|
|
240
|
+
if (previousAttempts.length > 0) {
|
|
241
|
+
log.info(`Found ${previousAttempts.length} previous fix-review episodes in memory`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const meter = getMeter();
|
|
245
|
+
// Wrap agent+validation+push in try/catch for failure episodes
|
|
246
|
+
try {
|
|
247
|
+
// 7. Invoke agent with review findings (with sandbox + JIT credential lifecycle)
|
|
248
|
+
log.stage('agent');
|
|
249
|
+
const runner = options.runner ?? new ClaudeCodeRunner();
|
|
250
|
+
// Sandbox isolation around agent execution
|
|
251
|
+
let sandboxId;
|
|
252
|
+
let result;
|
|
253
|
+
try {
|
|
254
|
+
if (options.security) {
|
|
255
|
+
const timeoutMs = reviewStage?.timeout ? parseDuration(reviewStage.timeout) : undefined;
|
|
256
|
+
sandboxId = await options.security.sandbox.isolate(`issue-${issueId}`, defaultSandboxConstraints(workDir, timeoutMs));
|
|
257
|
+
}
|
|
258
|
+
// Issue JIT credentials before agent execution
|
|
259
|
+
const jitCred = options.security
|
|
260
|
+
? await issueAgentCredentials(options.security, agentRole.metadata.name)
|
|
261
|
+
: undefined;
|
|
262
|
+
try {
|
|
263
|
+
result = await withSpan(SPAN_NAMES.AGENT_TASK, {
|
|
264
|
+
[ATTRIBUTE_KEYS.AGENT]: agentRole.metadata.name,
|
|
265
|
+
[ATTRIBUTE_KEYS.RESOURCE_NAME]: `pr#${prNumber}`,
|
|
266
|
+
}, async () => {
|
|
267
|
+
const r = await runner.run({
|
|
268
|
+
issueId,
|
|
269
|
+
issueNumber: issueNumber ?? undefined,
|
|
270
|
+
issueTitle,
|
|
271
|
+
issueBody,
|
|
272
|
+
workDir,
|
|
273
|
+
branch: currentBranch,
|
|
274
|
+
constraints: {
|
|
275
|
+
maxFilesPerChange: resolved.maxFiles,
|
|
276
|
+
requireTests: resolved.requireTests,
|
|
277
|
+
blockedPaths: resolved.blockedPaths,
|
|
278
|
+
},
|
|
279
|
+
reviewFindings,
|
|
280
|
+
});
|
|
281
|
+
if (!r.success) {
|
|
282
|
+
log.stageEnd('agent');
|
|
283
|
+
auditLog.record({
|
|
284
|
+
actor: 'system',
|
|
285
|
+
action: 'execute',
|
|
286
|
+
resource: `agent/${agentRole.metadata.name}`,
|
|
287
|
+
decision: 'denied',
|
|
288
|
+
details: { error: r.error },
|
|
289
|
+
});
|
|
290
|
+
meter.createCounter(METRIC_NAMES.TASK_FAILURE_TOTAL).add(1);
|
|
291
|
+
recordMetric(metricStore, METRIC_NAMES.TASK_FAILURE_TOTAL, 1);
|
|
292
|
+
// Evaluate demotion on agent failure
|
|
293
|
+
const agentMetrics = {
|
|
294
|
+
name: agentRole.metadata.name,
|
|
295
|
+
currentLevel: currentLevel.level,
|
|
296
|
+
totalTasksCompleted: 0,
|
|
297
|
+
metrics: {},
|
|
298
|
+
approvals: [],
|
|
299
|
+
};
|
|
300
|
+
const demotion = evaluateDemotion(autonomyPolicy, agentMetrics, 'failed-test');
|
|
301
|
+
log.info(`Demotion evaluation: ${demotion.demoted ? `demoted from ${demotion.fromLevel} to ${demotion.toLevel}` : 'no demotion'}`);
|
|
302
|
+
auditLog.record({
|
|
303
|
+
actor: 'system',
|
|
304
|
+
action: 'evaluate',
|
|
305
|
+
resource: `agent/${agentRole.metadata.name}`,
|
|
306
|
+
policy: 'demotion',
|
|
307
|
+
decision: demotion.demoted ? 'denied' : 'allowed',
|
|
308
|
+
details: {
|
|
309
|
+
trigger: demotion.trigger,
|
|
310
|
+
fromLevel: demotion.fromLevel,
|
|
311
|
+
toLevel: demotion.toLevel,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
const agentFailTpl = notifTemplates?.['agent-failure'];
|
|
315
|
+
const errorDetail = r.error ?? 'Unknown error';
|
|
316
|
+
const agentFailComment = agentFailTpl
|
|
317
|
+
? renderTemplate(agentFailTpl, { stageName: 'fix-review', details: errorDetail })
|
|
318
|
+
: { title: NOTIFICATION_TITLES.fixReviewAgentFailed, body: errorDetail };
|
|
319
|
+
// Use pre-created marker (one per execution, prevents double-counting)
|
|
320
|
+
await addComment(`## ${agentFailComment.title}\n\n${agentFailComment.body}\n\n${RETRY_MARKER}\n${cycleMarker}`);
|
|
321
|
+
throw new Error(`Fix-review agent failed on PR #${prNumber}: ${r.error}`);
|
|
322
|
+
}
|
|
323
|
+
log.stageEnd('agent');
|
|
324
|
+
auditLog.record({
|
|
325
|
+
actor: 'system',
|
|
326
|
+
action: 'execute',
|
|
327
|
+
resource: `agent/${agentRole.metadata.name}`,
|
|
328
|
+
decision: 'allowed',
|
|
329
|
+
details: { filesChanged: r.filesChanged.length },
|
|
330
|
+
});
|
|
331
|
+
meter.createCounter(METRIC_NAMES.TASK_SUCCESS_TOTAL).add(1);
|
|
332
|
+
recordMetric(metricStore, METRIC_NAMES.TASK_SUCCESS_TOTAL, 1);
|
|
333
|
+
return r;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
// Revoke JIT credentials after agent execution (success or failure)
|
|
338
|
+
if (jitCred && options.security) {
|
|
339
|
+
await revokeAgentCredentials(options.security, jitCred.id);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
// Destroy sandbox after agent execution
|
|
345
|
+
if (sandboxId && options.security) {
|
|
346
|
+
await options.security.sandbox.destroy(sandboxId);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// 8. ABAC authorization check (if write permissions are defined)
|
|
350
|
+
if (currentLevel.permissions.write.length > 0) {
|
|
351
|
+
authorizeFilesChanged(result.filesChanged, currentLevel.permissions, agentRole.spec.constraints, auditLog, agentRole.metadata.name);
|
|
352
|
+
}
|
|
353
|
+
// 9. Validate agent output against guardrails
|
|
354
|
+
await withSpan(SPAN_NAMES.PIPELINE_STAGE, {
|
|
355
|
+
[ATTRIBUTE_KEYS.STAGE]: 'validate-output',
|
|
356
|
+
}, async () => {
|
|
357
|
+
await validateAndAuditOutput({
|
|
358
|
+
filesChanged: result.filesChanged,
|
|
359
|
+
workDir,
|
|
360
|
+
constraints: {
|
|
361
|
+
maxFilesPerChange: resolved.maxFiles,
|
|
362
|
+
requireTests: resolved.requireTests,
|
|
363
|
+
blockedPaths: resolved.blockedPaths,
|
|
364
|
+
},
|
|
365
|
+
guardrails: { maxLinesPerPR: currentLevel.guardrails.maxLinesPerPR },
|
|
366
|
+
auditLog,
|
|
367
|
+
log,
|
|
368
|
+
onViolation: async (violationList) => {
|
|
369
|
+
await addComment(`## ${NOTIFICATION_TITLES.fixReviewGuardrailViolations}\n\n${violationList}\n\n${RETRY_MARKER}\n${cycleMarker}`);
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
// 10. Push to the same branch (review agents re-run automatically via pull_request.synchronize)
|
|
374
|
+
log.stage('push');
|
|
375
|
+
await execFileAsync('git', ['push', 'origin', currentBranch], { cwd: workDir });
|
|
376
|
+
log.stageEnd('push');
|
|
377
|
+
auditLog.record({
|
|
378
|
+
actor: 'system',
|
|
379
|
+
action: 'create',
|
|
380
|
+
resource: `push/${currentBranch}`,
|
|
381
|
+
decision: 'allowed',
|
|
382
|
+
details: { prNumber, attempt: attempts + 1 },
|
|
383
|
+
});
|
|
384
|
+
// 11. Comment on PR with success details
|
|
385
|
+
const successTpl = notifTemplates?.['fix-review-success'];
|
|
386
|
+
const successComment = successTpl
|
|
387
|
+
? renderTemplate(successTpl, {
|
|
388
|
+
attempt: String(attempts + 1),
|
|
389
|
+
max: String(maxFixAttempts),
|
|
390
|
+
branch: currentBranch,
|
|
391
|
+
})
|
|
392
|
+
: {
|
|
393
|
+
title: NOTIFICATION_TITLES.fixReviewApplied,
|
|
394
|
+
body: `Attempt ${attempts + 1} of ${maxFixAttempts} — pushed review fixes to \`${currentBranch}\`.`,
|
|
395
|
+
};
|
|
396
|
+
await addComment([
|
|
397
|
+
`## ${successComment.title}`,
|
|
398
|
+
'',
|
|
399
|
+
successComment.body,
|
|
400
|
+
'',
|
|
401
|
+
'### Changes',
|
|
402
|
+
result.filesChanged.map((f) => `- \`${f}\``).join('\n'),
|
|
403
|
+
'',
|
|
404
|
+
RETRY_MARKER,
|
|
405
|
+
cycleMarker,
|
|
406
|
+
].join('\n'));
|
|
407
|
+
// 12. Record episodic memory (success)
|
|
408
|
+
if (options.memory) {
|
|
409
|
+
options.memory.episodic.append({
|
|
410
|
+
key: 'fix-review-execution',
|
|
411
|
+
value: {
|
|
412
|
+
prNumber,
|
|
413
|
+
issueId,
|
|
414
|
+
filesChanged: result.filesChanged.length,
|
|
415
|
+
outcome: 'success',
|
|
416
|
+
},
|
|
417
|
+
metadata: { summary: `Fix-review for PR #${prNumber} (attempt ${attempts + 1})` },
|
|
418
|
+
});
|
|
419
|
+
options.memory.working.clear();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
catch (err) {
|
|
423
|
+
// Record failure episode before rethrowing
|
|
424
|
+
if (options.memory) {
|
|
425
|
+
options.memory.episodic.append({
|
|
426
|
+
key: 'fix-review-execution',
|
|
427
|
+
value: {
|
|
428
|
+
prNumber,
|
|
429
|
+
issueId,
|
|
430
|
+
outcome: 'failure',
|
|
431
|
+
error: err instanceof Error ? err.message : String(err),
|
|
432
|
+
},
|
|
433
|
+
metadata: { summary: `Failed fix-review for PR #${prNumber}` },
|
|
434
|
+
});
|
|
435
|
+
options.memory.working.clear();
|
|
436
|
+
}
|
|
437
|
+
throw err;
|
|
438
|
+
}
|
|
439
|
+
log.summary();
|
|
440
|
+
}
|
|
441
|
+
//# sourceMappingURL=fix-review.js.map
|