@ddtcorex/dsh-maestro-review 0.3.2 → 0.5.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/README.md +12 -0
- package/cordis.patch.yml +18 -0
- package/lib/ci-clone.d.ts +21 -0
- package/lib/ci-clone.d.ts.map +1 -0
- package/lib/ci-clone.js +35 -0
- package/lib/ci-clone.js.map +1 -0
- package/lib/ci-coexist.d.ts +9 -0
- package/lib/ci-coexist.d.ts.map +1 -0
- package/lib/ci-coexist.js +36 -0
- package/lib/ci-coexist.js.map +1 -0
- package/lib/config-store.d.ts +13 -6
- package/lib/config-store.d.ts.map +1 -1
- package/lib/config-store.js.map +1 -1
- package/lib/events.d.ts +23 -0
- package/lib/events.d.ts.map +1 -1
- package/lib/gitlab-auth.d.ts +11 -0
- package/lib/gitlab-auth.d.ts.map +1 -0
- package/lib/gitlab-auth.js +15 -0
- package/lib/gitlab-auth.js.map +1 -0
- package/lib/gitlab-client.d.ts +57 -0
- package/lib/gitlab-client.d.ts.map +1 -1
- package/lib/gitlab-client.js +120 -17
- package/lib/gitlab-client.js.map +1 -1
- package/lib/govard-audit-lint-tool.d.ts +81 -0
- package/lib/govard-audit-lint-tool.d.ts.map +1 -1
- package/lib/govard-audit-lint-tool.js +121 -36
- package/lib/govard-audit-lint-tool.js.map +1 -1
- package/lib/govard-tool.js +1 -1
- package/lib/govard-tool.js.map +1 -1
- package/lib/incremental.d.ts.map +1 -1
- package/lib/incremental.js +3 -2
- package/lib/incremental.js.map +1 -1
- package/lib/orchestrator.d.ts +137 -5
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +439 -70
- package/lib/orchestrator.js.map +1 -1
- package/lib/providers/ci-trigger.d.ts +43 -0
- package/lib/providers/ci-trigger.d.ts.map +1 -0
- package/lib/providers/ci-trigger.js +146 -0
- package/lib/providers/ci-trigger.js.map +1 -0
- package/lib/providers/gitlab.d.ts.map +1 -1
- package/lib/providers/gitlab.js +2 -1
- package/lib/providers/gitlab.js.map +1 -1
- package/lib/review-findings-tool.d.ts +5 -0
- package/lib/review-findings-tool.d.ts.map +1 -1
- package/lib/review-findings-tool.js +3 -1
- package/lib/review-findings-tool.js.map +1 -1
- package/lib/review-intake.d.ts.map +1 -1
- package/lib/review-intake.js +5 -1
- package/lib/review-intake.js.map +1 -1
- package/lib/review-marker.d.ts +17 -0
- package/lib/review-marker.d.ts.map +1 -0
- package/lib/review-marker.js +23 -0
- package/lib/review-marker.js.map +1 -0
- package/lib/review-signals.d.ts.map +1 -1
- package/lib/review-signals.js +4 -8
- package/lib/review-signals.js.map +1 -1
- package/lib/settings-rpc.js +1 -1
- package/lib/settings-rpc.js.map +1 -1
- package/package.json +14 -9
- package/profiles/reviewer-ci/cordis.patch.yml +61 -0
- package/profiles/reviewer-ci/package.json +19 -0
- package/profiles/reviewer-ci/pnpm-lock.yaml +62 -0
- package/profiles/reviewer-ci/pnpm-workspace.yaml +12 -0
- package/src/host/ci-clone.ts +54 -0
- package/src/host/ci-coexist.ts +43 -0
- package/src/host/config-store.ts +14 -1
- package/src/host/events.ts +25 -0
- package/src/host/gitlab-auth.ts +13 -0
- package/src/host/gitlab-client.ts +142 -17
- package/src/host/govard-audit-lint-tool.ts +144 -31
- package/src/host/govard-tool.ts +1 -1
- package/src/host/incremental.ts +3 -2
- package/src/host/orchestrator.ts +462 -50
- package/src/host/providers/ci-trigger.ts +164 -0
- package/src/host/providers/gitlab.ts +2 -1
- package/src/host/review-findings-tool.ts +9 -1
- package/src/host/review-intake.ts +5 -1
- package/src/host/review-marker.ts +25 -0
- package/src/host/review-signals.ts +4 -3
- package/src/host/settings-rpc.ts +1 -1
- package/templates/reviewer-project.gitlab-ci.yml +90 -0
- package/templates/source-project.gitlab-ci.yml +42 -0
package/lib/orchestrator.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, writeFile, symlink, stat, lstat } from 'node:fs/promises';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
@@ -31,6 +31,9 @@ import { reviewDigestText } from './notify.js';
|
|
|
31
31
|
import { loadedReviewProfile } from './skills-tool.js';
|
|
32
32
|
import { gitlabProvider } from './providers/gitlab.js';
|
|
33
33
|
import './events.js';
|
|
34
|
+
import { gitlabAuthHeaders } from './gitlab-auth.js';
|
|
35
|
+
import { reviewMarker, resolveFlow } from './review-marker.js';
|
|
36
|
+
import { cloneSourceRepo, defaultRun } from './ci-clone.js';
|
|
34
37
|
// Provider-aware wrapper — orchestrator can run reviews via any ReviewProvider.
|
|
35
38
|
// This keeps the GitLab-specific flow intact while allowing Phase C to add GitHub/Jira without modifying core logic.
|
|
36
39
|
export async function runReviewWithProvider(provider, request) {
|
|
@@ -45,6 +48,47 @@ export async function runReviewWithProvider(provider, request) {
|
|
|
45
48
|
export { gitlabProvider };
|
|
46
49
|
const execFileAsync = promisify(execFile);
|
|
47
50
|
const GIT_TIMEOUT_MS = 60_000;
|
|
51
|
+
/**
|
|
52
|
+
* Finding severity, assigned by the reviewer. Display order is fixed
|
|
53
|
+
* (blocking first) wherever counts are rendered.
|
|
54
|
+
*/
|
|
55
|
+
export const SEVERITY_ORDER = ['blocking', 'major', 'minor', 'nit'];
|
|
56
|
+
const SEVERITY_LABEL = {
|
|
57
|
+
blocking: '🔴 Blocking',
|
|
58
|
+
major: '🟡 Major',
|
|
59
|
+
minor: '🔵 Minor',
|
|
60
|
+
nit: '⚪ Nit',
|
|
61
|
+
};
|
|
62
|
+
/** Missing or unknown severities degrade to `minor` — never drop a finding. */
|
|
63
|
+
export function normalizeFindingSeverity(severity) {
|
|
64
|
+
return severity === 'blocking' || severity === 'major' || severity === 'minor' || severity === 'nit'
|
|
65
|
+
? severity
|
|
66
|
+
: 'minor';
|
|
67
|
+
}
|
|
68
|
+
export function severityPrefix(severity) {
|
|
69
|
+
return SEVERITY_LABEL[normalizeFindingSeverity(severity)];
|
|
70
|
+
}
|
|
71
|
+
/** Count findings per severity; zero counts are omitted. */
|
|
72
|
+
export function countFindingSeverities(findings) {
|
|
73
|
+
const counts = {};
|
|
74
|
+
for (const finding of findings) {
|
|
75
|
+
const level = normalizeFindingSeverity(finding.severity);
|
|
76
|
+
counts[level] = (counts[level] ?? 0) + 1;
|
|
77
|
+
}
|
|
78
|
+
return counts;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Reviewer scope prompt. Static analysis is mandatory: reviewers skipped
|
|
82
|
+
* govard_audit_lint for whole rounds (no lint signal at all), so the prompt
|
|
83
|
+
* requires at least one call before report_review_findings. Pure for testing.
|
|
84
|
+
*/
|
|
85
|
+
export function buildReviewerScopePrompt(opts) {
|
|
86
|
+
const lintRule = 'LINT RULE: you MUST call govard_audit_lint at least once (scope "diff"; the MR base default is already wired, no base arg needed) before report_review_findings. A review with no lint call is incomplete.';
|
|
87
|
+
if (opts.scopeKind === 'discussion') {
|
|
88
|
+
return `${opts.profileInstruction}Review only the requested inline discussion ${opts.discussionId} at ${opts.path}:${opts.line}. Do not review unrelated files or start a broad audit. Call gitlab_get_mr_diff, then gitlab_get_file_diff for the file under review, then call report_review_findings exactly once when done. ${lintRule}`;
|
|
89
|
+
}
|
|
90
|
+
return `${opts.profileInstruction}Review this merge request (${opts.mode} mode). Call gitlab_list_own_review_threads and gitlab_get_mr_diff first, then gitlab_get_file_diff per file you inspect (inline results never spill), then call report_review_findings exactly once when done. ${lintRule} DEDUP RULE: when a finding matches the substance of an existing own thread (same file and same underlying issue, even if worded differently — including resolved threads, whose reply reopens them), report it as {status: "reply", discussionId} instead of posting a new thread. Use status "new" only for issues with no matching thread.`;
|
|
91
|
+
}
|
|
48
92
|
/**
|
|
49
93
|
* Build a user-friendly GitLab Markdown comment for a completed review.
|
|
50
94
|
* Shared design language with `reviewDigestText` (Telegram HTML) — same
|
|
@@ -75,6 +119,13 @@ export function buildReviewComment(opts) {
|
|
|
75
119
|
parts.push(`💬 ${opts.findings.newCount} new`);
|
|
76
120
|
if (opts.findings.replyCount > 0)
|
|
77
121
|
parts.push(`🔁 ${opts.findings.replyCount} updated`);
|
|
122
|
+
if (opts.findings.severityCounts !== undefined) {
|
|
123
|
+
for (const level of SEVERITY_ORDER) {
|
|
124
|
+
const count = opts.findings.severityCounts[level] ?? 0;
|
|
125
|
+
if (count > 0)
|
|
126
|
+
parts.push(`${SEVERITY_LABEL[level].split(' ')[0]} ${count} ${level}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
78
129
|
if (parts.length === 0)
|
|
79
130
|
parts.push('no inline findings');
|
|
80
131
|
return `\n\n**Findings:** ${parts.join(' · ')}`;
|
|
@@ -84,7 +135,8 @@ export function buildReviewComment(opts) {
|
|
|
84
135
|
? `\n\n<details>\n<summary>⚠️ Failed to post (${opts.failures.length})</summary>\n\n${opts.failures.map((f) => `- \`${f}\``).join('\n')}\n\n</details>`
|
|
85
136
|
: '';
|
|
86
137
|
const footer = `\n\n---\n\n<sub>Generated by Maestro · [View MR →](${mrUrl})</sub>`;
|
|
87
|
-
|
|
138
|
+
const markerLine = opts.marker === undefined ? '' : `\n\n${reviewMarker(opts.marker.sha, opts.marker.flow)}`;
|
|
139
|
+
return `${title}\n\n${metaLine}\n\n${scopeNote}${opts.summary}${findingsLine}${failuresBlock}${footer}${markerLine}`;
|
|
88
140
|
}
|
|
89
141
|
export function buildNotStartedComment(opts) {
|
|
90
142
|
const mrUrl = `${opts.gitlabBaseUrl.replace(/\/$/, '')}/${opts.projectPath}/-/merge_requests/${opts.mrIid}`;
|
|
@@ -99,6 +151,7 @@ export function buildNotStartedComment(opts) {
|
|
|
99
151
|
`---`,
|
|
100
152
|
'',
|
|
101
153
|
`<sub>Generated by Maestro · [View MR →](${mrUrl})</sub>`,
|
|
154
|
+
...(opts.marker === undefined ? [] : [``, `${reviewMarker(opts.marker.sha, opts.marker.flow)}`]),
|
|
102
155
|
].join('\n');
|
|
103
156
|
}
|
|
104
157
|
export const name = 'maestro-orchestrator';
|
|
@@ -151,10 +204,12 @@ export function getTurnErrorMessage(handle) {
|
|
|
151
204
|
}
|
|
152
205
|
/**
|
|
153
206
|
* Resolve the model to use for an automated review. Priority: per-project
|
|
154
|
-
* override > global reviewModel >
|
|
207
|
+
* override > global reviewModel (Maestro Settings) > row-config reviewModel
|
|
208
|
+
* (lets a headless profile such as reviewer-ci pin a model from env vars,
|
|
209
|
+
* where there is no Settings UI) > DSH default (`fallback`).
|
|
155
210
|
*/
|
|
156
|
-
export function resolveReviewModel(userConfig, mapping, fallback) {
|
|
157
|
-
const raw = mapping?.reviewModel ?? userConfig.reviewModel;
|
|
211
|
+
export function resolveReviewModel(userConfig, mapping, fallback, rowSelection) {
|
|
212
|
+
const raw = mapping?.reviewModel ?? userConfig.reviewModel ?? rowSelection;
|
|
158
213
|
if (raw === undefined || raw === null)
|
|
159
214
|
return fallback;
|
|
160
215
|
return {
|
|
@@ -163,6 +218,13 @@ export function resolveReviewModel(userConfig, mapping, fallback) {
|
|
|
163
218
|
...(raw.reasoningEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(raw.reasoningEffort) }),
|
|
164
219
|
};
|
|
165
220
|
}
|
|
221
|
+
/** Effective auto-trigger flags: per-project row overrides global, unset inherits (design §4). */
|
|
222
|
+
export function resolveReviewTriggers(userConfig, mapping) {
|
|
223
|
+
return {
|
|
224
|
+
onPush: mapping?.rereviewOnPush ?? userConfig.autoRereviewOnPush ?? false,
|
|
225
|
+
onAssign: mapping?.reviewOnAssign ?? userConfig.autoReviewOnAssign ?? true,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
166
228
|
/** Compose a newly-created agent from the preset service owned by the root context. */
|
|
167
229
|
export async function mountAgentPreset(agentPresets, agentCtx, id) {
|
|
168
230
|
await agentPresets.mount(agentCtx, id);
|
|
@@ -273,14 +335,16 @@ function normalizedDiffPath(path) {
|
|
|
273
335
|
export async function postReviewFindings(findings, config) {
|
|
274
336
|
const fetcher = config.fetcher ?? fetch;
|
|
275
337
|
const apiBase = `${config.baseUrl}/api/v4/projects/${config.projectId}/merge_requests/${config.mrIid}`;
|
|
276
|
-
const headers = {
|
|
338
|
+
const headers = { ...gitlabAuthHeaders(config.token), 'Content-Type': 'application/json' };
|
|
277
339
|
for (const finding of findings) {
|
|
278
340
|
let response;
|
|
341
|
+
const label = severityPrefix(finding.severity);
|
|
342
|
+
const body = finding.body.startsWith(label) ? finding.body : `${label}\n\n${finding.body}`;
|
|
279
343
|
if (finding.status === 'reply') {
|
|
280
344
|
if (finding.discussionId === undefined)
|
|
281
345
|
throw new Error('reply finding missing discussionId');
|
|
282
346
|
response = await fetcher(`${apiBase}/discussions/${encodeURIComponent(finding.discussionId)}/notes`, {
|
|
283
|
-
method: 'POST', headers, body: JSON.stringify({ body
|
|
347
|
+
method: 'POST', headers, body: JSON.stringify({ body }),
|
|
284
348
|
});
|
|
285
349
|
}
|
|
286
350
|
else {
|
|
@@ -301,13 +365,13 @@ export async function postReviewFindings(findings, config) {
|
|
|
301
365
|
// Line not in diff — fallback to MR note so finding is not silently lost.
|
|
302
366
|
response = await fetcher(`${apiBase}/notes`, {
|
|
303
367
|
method: 'POST', headers,
|
|
304
|
-
body: JSON.stringify({ body: `**Inline fallback — \`${finding.path}:${finding.line}\` line is not in current MR diff**\n\n${
|
|
368
|
+
body: JSON.stringify({ body: `**Inline fallback — \`${finding.path}:${finding.line}\` line is not in current MR diff**\n\n${body}` }),
|
|
305
369
|
});
|
|
306
370
|
}
|
|
307
371
|
else {
|
|
308
372
|
response = await fetcher(`${apiBase}/discussions`, {
|
|
309
373
|
method: 'POST', headers,
|
|
310
|
-
body: JSON.stringify({ body
|
|
374
|
+
body: JSON.stringify({ body, position: {
|
|
311
375
|
position_type: 'text', ...snapshot.diffRefs, old_path: change.old_path, new_path: change.new_path,
|
|
312
376
|
...linePosition.oldLine === undefined ? {} : { old_line: linePosition.oldLine },
|
|
313
377
|
...linePosition.newLine === undefined ? {} : { new_line: linePosition.newLine },
|
|
@@ -356,7 +420,84 @@ function assertSafeId(value, label) {
|
|
|
356
420
|
throw new Error(`refusing to operate on unsafe ${label}: ${JSON.stringify(value)}`);
|
|
357
421
|
}
|
|
358
422
|
}
|
|
423
|
+
/**
|
|
424
|
+
* Read an agent session's transcript for the auditor's final output across
|
|
425
|
+
* host/session API skew: hosts built from the harness checkout expose
|
|
426
|
+
* `ownEvents()`/`snapshotEvents()` with no `.events` getter, while older
|
|
427
|
+
* packaged `@deepseek-ai/dsh-session` builds expose only the `.events`
|
|
428
|
+
* getter. `ownEvents()` (child-owned suffix, no fork prefix) matches
|
|
429
|
+
* `finalAssistantOutput`'s documented input best, so it wins when present.
|
|
430
|
+
* Returns `[]` — never throws — when no event source exists.
|
|
431
|
+
*/
|
|
432
|
+
export function auditorOutputFromSession(session) {
|
|
433
|
+
const candidate = session;
|
|
434
|
+
let events;
|
|
435
|
+
if (typeof candidate?.ownEvents === 'function') {
|
|
436
|
+
events = candidate.ownEvents();
|
|
437
|
+
}
|
|
438
|
+
else if (typeof candidate?.snapshotEvents === 'function') {
|
|
439
|
+
events = candidate.snapshotEvents();
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
events = candidate?.events;
|
|
443
|
+
}
|
|
444
|
+
if (!Array.isArray(events))
|
|
445
|
+
return [];
|
|
446
|
+
return finalAssistantOutput(events) ?? [];
|
|
447
|
+
}
|
|
359
448
|
/** Full review + performance audit; resolves to the comment body that was posted. */
|
|
449
|
+
/** Marker for CI-posted comments; webhook payloads carry no headSha and stay marker-free. */
|
|
450
|
+
function commentMarker(payload) {
|
|
451
|
+
if (payload.headSha === undefined)
|
|
452
|
+
return undefined;
|
|
453
|
+
return { sha: payload.headSha, flow: resolveFlow(payload.mode) };
|
|
454
|
+
}
|
|
455
|
+
/** CI-deep decision: deep mode + CI env + head SHA (spec §3). Mapping is checked by the caller. */
|
|
456
|
+
export function shouldCiDeepReview(payload) {
|
|
457
|
+
return payload.mode === 'deep'
|
|
458
|
+
&& payload.headSha !== undefined
|
|
459
|
+
&& process.env.SOURCE_PROJECT_ID !== undefined
|
|
460
|
+
&& process.env.MR_IID !== undefined;
|
|
461
|
+
}
|
|
462
|
+
/** CI quick-with-profile decision: quick mode + non-generic profile + CI env
|
|
463
|
+
* + head SHA. Quick + generic/unset stays diff-only (no clone cost); a real
|
|
464
|
+
* profile opts into the clone branch reviewer-only (no auditor — quick never
|
|
465
|
+
* audits, see shouldAudit in runReviewAndAudit). Mapping is checked by the caller. */
|
|
466
|
+
export function shouldCiQuickProfileReview(payload) {
|
|
467
|
+
return payload.mode === 'quick'
|
|
468
|
+
&& payload.reviewProfile !== undefined
|
|
469
|
+
&& payload.reviewProfile !== 'generic'
|
|
470
|
+
&& payload.headSha !== undefined
|
|
471
|
+
&& process.env.SOURCE_PROJECT_ID !== undefined
|
|
472
|
+
&& process.env.MR_IID !== undefined;
|
|
473
|
+
}
|
|
474
|
+
/** Auditor degrade for CI (no runtime env): a govard throw becomes reviewer-only, never a failed review. */
|
|
475
|
+
export function withAuditorDegrade(runAuditor) {
|
|
476
|
+
return async (worktreePath, payload) => {
|
|
477
|
+
try {
|
|
478
|
+
return await runAuditor(worktreePath, payload);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return 'Auditor skipped — no runtime environment in CI (reviewer-only review).';
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Auditor instruction. The mapped flow keeps the full environment + test-suite
|
|
487
|
+
* workflow; the CI flow has no runtime, so the prompt countermands the
|
|
488
|
+
* auditor preset's environment steps and drops the Environment & Test Suite
|
|
489
|
+
* section entirely instead of reporting it "blocked".
|
|
490
|
+
*/
|
|
491
|
+
export function buildAuditorPrompt(opts) {
|
|
492
|
+
if (!opts.staticOnly) {
|
|
493
|
+
return 'Audit this merge request\'s performance: bring up the environment, run the test suite, look for regressions, then write a Markdown report and tear the environment down.';
|
|
494
|
+
}
|
|
495
|
+
return 'Audit this merge request\'s performance from the static diff and checked-out code only. '
|
|
496
|
+
+ 'No runtime environment exists in this container: ignore the auditor preset\'s environment steps '
|
|
497
|
+
+ '(do not bring anything up, do not run the test suite, do not tear anything down). '
|
|
498
|
+
+ 'Look for regressions by static analysis (diff, dependencies, query/shape risks), then write a Markdown report '
|
|
499
|
+
+ 'and OMIT the Environment & Test Suite section entirely — never report it as blocked.';
|
|
500
|
+
}
|
|
360
501
|
export async function runReviewAndAudit(payload, deps) {
|
|
361
502
|
assertSafeId(payload.projectId, 'projectId');
|
|
362
503
|
assertSafeId(payload.mrIid, 'mrIid');
|
|
@@ -375,7 +516,7 @@ export async function runReviewAndAudit(payload, deps) {
|
|
|
375
516
|
]);
|
|
376
517
|
const labels = ['Reviewer', 'Auditor'];
|
|
377
518
|
if (settled[0].status === 'fulfilled') {
|
|
378
|
-
const { summary, failures } = settled[0].value;
|
|
519
|
+
const { summary, failures, severityCounts } = settled[0].value;
|
|
379
520
|
// Try to parse findings counts from summary like "2 new inline comment(s), 1 thread(s) updated."
|
|
380
521
|
const summaryText = summary ?? '';
|
|
381
522
|
const newMatch = /(\d+)\s+new inline/.exec(summaryText);
|
|
@@ -392,7 +533,8 @@ export async function runReviewAndAudit(payload, deps) {
|
|
|
392
533
|
profile: deps.reviewProfile,
|
|
393
534
|
summary: summaryText,
|
|
394
535
|
failures,
|
|
395
|
-
findings: { newCount, replyCount },
|
|
536
|
+
findings: { newCount, replyCount, severityCounts },
|
|
537
|
+
marker: commentMarker(payload),
|
|
396
538
|
})
|
|
397
539
|
: `## 🤖 Maestro Review\n\n**\`${payload.projectPath}\` !${payload.mrIid}** · ✅ Completed · \`${payload.mode}\`${deps.reviewProfile !== undefined ? ` · \`${deps.reviewProfile}\`` : ''}\n\n${summaryText}${failures.length > 0 ? `\n\n<details>\n<summary>⚠️ Failed to post (${failures.length})</summary>\n\n${failures.map((f) => `- \`${f}\``).join('\n')}\n\n</details>` : ''}`;
|
|
398
540
|
sections.push(richOpts);
|
|
@@ -414,7 +556,8 @@ export async function runReviewAndAudit(payload, deps) {
|
|
|
414
556
|
else
|
|
415
557
|
await deps.postComment(body);
|
|
416
558
|
}
|
|
417
|
-
catch {
|
|
559
|
+
catch (err) {
|
|
560
|
+
console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
418
561
|
await deps.writeFailedReport(payload.mrIid, body);
|
|
419
562
|
}
|
|
420
563
|
return body;
|
|
@@ -451,6 +594,7 @@ export async function runDiffOnlyReview(payload, deps) {
|
|
|
451
594
|
summary: summary ?? '',
|
|
452
595
|
failures,
|
|
453
596
|
isDiffOnly: true,
|
|
597
|
+
marker: commentMarker(payload),
|
|
454
598
|
})
|
|
455
599
|
: `## 🤖 Maestro Review — Diff-only\n\n> **Scope:** Diff-only — reviewed the GitLab diff without a local checkout, Magento environment, static analysis, or tests. For a full review, add this project in Settings → Maestro and mention again.\n\n${summary}${failures.length > 0 ? `\n\n<details>\n<summary>⚠️ Failed to post (${failures.length})</summary>\n\n${failures.map((f) => `- \`${f}\``).join('\n')}\n\n</details>` : ''}`;
|
|
456
600
|
const body = diffBody;
|
|
@@ -460,7 +604,8 @@ export async function runDiffOnlyReview(payload, deps) {
|
|
|
460
604
|
else
|
|
461
605
|
await deps.postComment(body);
|
|
462
606
|
}
|
|
463
|
-
catch {
|
|
607
|
+
catch (err) {
|
|
608
|
+
console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
464
609
|
await deps.writeFailedReport(payload.mrIid, body);
|
|
465
610
|
}
|
|
466
611
|
return body;
|
|
@@ -480,7 +625,7 @@ export async function declineUnmappedDeepReview(payload, deps) {
|
|
|
480
625
|
const depsWithUrl = deps;
|
|
481
626
|
const baseUrl = depsWithUrl.gitlabBaseUrl;
|
|
482
627
|
const body = baseUrl !== undefined
|
|
483
|
-
? buildNotStartedComment({ gitlabBaseUrl: baseUrl, projectPath: payload.projectPath, mrIid: payload.mrIid })
|
|
628
|
+
? buildNotStartedComment({ gitlabBaseUrl: baseUrl, projectPath: payload.projectPath, mrIid: payload.mrIid, marker: commentMarker(payload) })
|
|
484
629
|
: '## 🤖 Maestro Review — Not started\n\n**`' + payload.projectPath + '` !' + payload.mrIid + '** · ⏸️ Not started\n\n> Deep review requires a project mapping with a local checkout and Magento environment.\n> Add this project in **Settings → Maestro**, then mention the reviewer again.';
|
|
485
630
|
try {
|
|
486
631
|
try {
|
|
@@ -489,7 +634,8 @@ export async function declineUnmappedDeepReview(payload, deps) {
|
|
|
489
634
|
else
|
|
490
635
|
await deps.postComment(body);
|
|
491
636
|
}
|
|
492
|
-
catch {
|
|
637
|
+
catch (err) {
|
|
638
|
+
console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
493
639
|
await deps.writeFailedReport(payload.mrIid, body);
|
|
494
640
|
}
|
|
495
641
|
}
|
|
@@ -540,7 +686,26 @@ export function isBranchNotFoundError(err) {
|
|
|
540
686
|
*/
|
|
541
687
|
export function govardWorktreeOverride(projectId, mrIid, keySuffix) {
|
|
542
688
|
const name = `maestro-mr-${projectId}-${mrIid}${keySuffix === undefined ? '' : `-${keySuffix}`}`;
|
|
543
|
-
|
|
689
|
+
// Xdebug stays off in review envs: the auditor runs no coverage, and an
|
|
690
|
+
// enabled Xdebug trips govard's lint perf-tax guard (exit 1, 0 findings).
|
|
691
|
+
return `project_name: ${name}\ndomain: ${name}.test\nstack:\n features:\n xdebug: false\n`;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Fetch the MR's diff base SHA for govard diff-scope runs. Best-effort:
|
|
695
|
+
* returns undefined (the lint tool then fail-fasts with guidance) rather
|
|
696
|
+
* than failing the review when GitLab is unreachable.
|
|
697
|
+
*/
|
|
698
|
+
export async function fetchMrBaseSha(baseUrl, token, projectId, mrIid, fetcher = fetch) {
|
|
699
|
+
try {
|
|
700
|
+
const response = await fetcher(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}`, { headers: gitlabAuthHeaders(token) });
|
|
701
|
+
if (!response.ok)
|
|
702
|
+
return undefined;
|
|
703
|
+
const mr = await response.json();
|
|
704
|
+
return mr.diff_refs?.base_sha;
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
return undefined;
|
|
708
|
+
}
|
|
544
709
|
}
|
|
545
710
|
export async function ensureWorktree(localRepoPath, sourceBranch, projectId, mrIid, keySuffix) {
|
|
546
711
|
assertSafeBranchName(sourceBranch);
|
|
@@ -564,8 +729,138 @@ export async function ensureWorktree(localRepoPath, sourceBranch, projectId, mrI
|
|
|
564
729
|
.catch(() => { });
|
|
565
730
|
await execFileAsync('git', ['worktree', 'add', '--', worktreePath, `origin/${sourceBranch}`], { cwd: localRepoPath, timeout: GIT_TIMEOUT_MS });
|
|
566
731
|
await writeFile(join(worktreePath, '.govard.local.yml'), govardWorktreeOverride(projectId, mrIid, keySuffix), 'utf-8');
|
|
732
|
+
await linkVendorIntoWorktree(localRepoPath, worktreePath);
|
|
733
|
+
await writeContainerVendorOverride(localRepoPath, worktreePath);
|
|
567
734
|
return worktreePath;
|
|
568
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* A vendor dir only counts as installed dependencies when the composer
|
|
738
|
+
* autoloader is a real file. Magento tracks `vendor/.htaccess`, so every
|
|
739
|
+
* worktree has a vendor/ stub — that stub must not pass for real deps, and
|
|
740
|
+
* must not block the container bind that provides them.
|
|
741
|
+
*/
|
|
742
|
+
export async function vendorHasAutoload(dir) {
|
|
743
|
+
try {
|
|
744
|
+
return (await stat(join(dir, 'vendor', 'autoload.php'))).isFile();
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Share the primary checkout's `vendor/` (and `app/etc/env.php` when the
|
|
752
|
+
* auditor needs a database) into the review worktree via symlinks, so
|
|
753
|
+
* phpunit/static analysis run against real dependencies instead of falling
|
|
754
|
+
* back to static-only. Links point INTO the worktree only — the primary
|
|
755
|
+
* checkout is never written to — and `git worktree remove --force` deletes
|
|
756
|
+
* the links along with the worktree. Missing sources, or targets that
|
|
757
|
+
* already exist (e.g. Magento's tracked `vendor/.htaccess` stub), are
|
|
758
|
+
* skipped quietly: the review still runs, the auditor discloses it.
|
|
759
|
+
*/
|
|
760
|
+
export async function linkVendorIntoWorktree(localRepoPath, worktreePath) {
|
|
761
|
+
const linked = [];
|
|
762
|
+
const candidates = [
|
|
763
|
+
{ source: join(localRepoPath, 'vendor'), target: join(worktreePath, 'vendor'), dir: true, needsAutoload: true },
|
|
764
|
+
{ source: join(localRepoPath, 'app', 'etc', 'env.php'), target: join(worktreePath, 'app', 'etc', 'env.php'), dir: false, needsAutoload: false },
|
|
765
|
+
];
|
|
766
|
+
let advertised = false;
|
|
767
|
+
for (const { source, target, dir, needsAutoload } of candidates) {
|
|
768
|
+
let isDir = false;
|
|
769
|
+
try {
|
|
770
|
+
isDir = (await stat(source)).isDirectory();
|
|
771
|
+
if (dir !== isDir)
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
catch {
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (needsAutoload && !(await vendorHasAutoload(localRepoPath)))
|
|
778
|
+
continue;
|
|
779
|
+
if ((await lstat(target).catch(() => undefined)) !== undefined) {
|
|
780
|
+
console.error(`maestro-orchestrator: link target exists, skipping ${target}`);
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
if (!advertised) {
|
|
785
|
+
const sha = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: localRepoPath, timeout: GIT_TIMEOUT_MS })
|
|
786
|
+
.then(({ stdout }) => stdout.trim()).catch(() => 'unknown');
|
|
787
|
+
console.error(`maestro-orchestrator: linking vendor from ${localRepoPath} @ ${sha} into ${worktreePath}`);
|
|
788
|
+
advertised = true;
|
|
789
|
+
}
|
|
790
|
+
if (!dir)
|
|
791
|
+
await mkdir(join(worktreePath, 'app', 'etc'), { recursive: true });
|
|
792
|
+
await symlink(source, target, dir ? 'dir' : 'file');
|
|
793
|
+
linked.push(target);
|
|
794
|
+
}
|
|
795
|
+
catch (err) {
|
|
796
|
+
console.error(`maestro-orchestrator: failed to link ${source} into worktree:`, err);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (!advertised) {
|
|
800
|
+
console.error(`maestro-orchestrator: no vendor/ in ${localRepoPath} — worktree ${worktreePath} runs without shared dependencies`);
|
|
801
|
+
}
|
|
802
|
+
return linked;
|
|
803
|
+
}
|
|
804
|
+
/** Container path of the project root inside govard services. */
|
|
805
|
+
const GOVARD_CONTAINER_WORKDIR = '/var/www/html';
|
|
806
|
+
/**
|
|
807
|
+
* Render a compose override that bind-mounts the primary checkout's vendor/
|
|
808
|
+
* read-only into the php service. A host-side symlink alone dangles inside
|
|
809
|
+
* containers (absolute host path), so container tools (phpunit) need this
|
|
810
|
+
* bind to see real dependencies.
|
|
811
|
+
*
|
|
812
|
+
* The project mount (`.`) MUST be repeated first: govard merges overrides
|
|
813
|
+
* with MergeMap, which REPLACES lists instead of appending — an override
|
|
814
|
+
* carrying only the vendor bind would wipe `.:<workdir>` and leave the
|
|
815
|
+
* container docroot holding nothing but vendor/. Relative `.` resolves
|
|
816
|
+
* against the project root because govard passes --project-directory.
|
|
817
|
+
*
|
|
818
|
+
* Only the always-present `php` service is targeted: `php-debug` drops out
|
|
819
|
+
* of the rendered base when xdebug is off, and a volumes-only override
|
|
820
|
+
* entry would recreate it as a hollow service that fails compose validation.
|
|
821
|
+
*/
|
|
822
|
+
export function buildVendorOverrideYaml(vendorHostPath, envHostPath, containerWorkDir = GOVARD_CONTAINER_WORKDIR) {
|
|
823
|
+
const volumes = [
|
|
824
|
+
`.:${containerWorkDir}`,
|
|
825
|
+
`${vendorHostPath}:${containerWorkDir}/vendor:ro`,
|
|
826
|
+
...(envHostPath !== undefined ? [`${envHostPath}:${containerWorkDir}/app/etc/env.php:ro`] : []),
|
|
827
|
+
];
|
|
828
|
+
const service = ` volumes:\n${volumes.map((v) => ` - ${v}\n`).join('')}`;
|
|
829
|
+
return `services:\n php:\n${service}`;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Drop the vendor bind override into the worktree (merged by govard via
|
|
833
|
+
* `.govard/docker-compose.override.yml`). Binds only when the primary
|
|
834
|
+
* checkout carries installed deps and the worktree does not — never shadow
|
|
835
|
+
* real deps with a possibly stale bind. Returns the written path, or
|
|
836
|
+
* undefined when skipped.
|
|
837
|
+
*/
|
|
838
|
+
export async function writeContainerVendorOverride(localRepoPath, worktreePath) {
|
|
839
|
+
const vendorHostPath = join(localRepoPath, 'vendor');
|
|
840
|
+
if (!(await vendorHasAutoload(localRepoPath)))
|
|
841
|
+
return undefined;
|
|
842
|
+
// Our own host-side symlink still needs the bind (it dangles in-container);
|
|
843
|
+
// only a real installed worktree vendor makes the bind redundant.
|
|
844
|
+
const wtLink = await lstat(join(worktreePath, 'vendor')).catch(() => undefined);
|
|
845
|
+
if (wtLink?.isSymbolicLink() !== true && await vendorHasAutoload(worktreePath))
|
|
846
|
+
return undefined;
|
|
847
|
+
const overridePath = join(worktreePath, '.govard', 'docker-compose.override.yml');
|
|
848
|
+
await mkdir(join(worktreePath, '.govard'), { recursive: true });
|
|
849
|
+
// A linked env.php dangles in-container like the vendor symlink did — bind
|
|
850
|
+
// the real file over it when the primary checkout carries one.
|
|
851
|
+
const envHostPath = join(localRepoPath, 'app', 'etc', 'env.php');
|
|
852
|
+
let envBind;
|
|
853
|
+
try {
|
|
854
|
+
if ((await stat(envHostPath)).isFile())
|
|
855
|
+
envBind = envHostPath;
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
envBind = undefined;
|
|
859
|
+
}
|
|
860
|
+
await writeFile(overridePath, buildVendorOverrideYaml(vendorHostPath, envBind), 'utf-8');
|
|
861
|
+
console.error(`maestro-orchestrator: container vendor bind ${vendorHostPath} -> ${GOVARD_CONTAINER_WORKDIR}/vendor (ro) for ${worktreePath}`);
|
|
862
|
+
return overridePath;
|
|
863
|
+
}
|
|
569
864
|
async function removeWorktree(worktreePath) {
|
|
570
865
|
// Best-effort cleanup by design (a failure here must not block or fail the review that
|
|
571
866
|
// already ran) — but a swallowed failure with zero visibility leaves an orphaned worktree
|
|
@@ -585,6 +880,9 @@ export function apply(ctx, config) {
|
|
|
585
880
|
// effect without a plugin restart.
|
|
586
881
|
let effectiveAgentTimeoutMs = config.agentTimeoutMs;
|
|
587
882
|
async function runReviewer(worktreePath, payload, effective, reviewProfile, modelSelection, incrementalBlock) {
|
|
883
|
+
// MR base SHA feeds govard diff-scope runs; undefined degrades to the
|
|
884
|
+
// tool's fail-fast guidance instead of a wasted govard invocation.
|
|
885
|
+
const lintDefaultBase = await fetchMrBaseSha(effective.gitlabBaseUrl, effective.gitlabToken, payload.projectId, payload.mrIid);
|
|
588
886
|
const primaryOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection());
|
|
589
887
|
const fallbackOptions = { provider: primaryOptions.provider, model: primaryOptions.model };
|
|
590
888
|
let lastHandle;
|
|
@@ -619,7 +917,7 @@ export function apply(ctx, config) {
|
|
|
619
917
|
await agentCtx.plugin(ModuleCheckTool, { rootPath: worktreePath });
|
|
620
918
|
await agentCtx.plugin(PhtmlEscapeScanTool, { rootPath: worktreePath });
|
|
621
919
|
await agentCtx.plugin(ScopeSplitTool, { rootPath: worktreePath });
|
|
622
|
-
await agentCtx.plugin(GovardAuditLintTool, { rootPath: worktreePath });
|
|
920
|
+
await agentCtx.plugin(GovardAuditLintTool, { rootPath: worktreePath, defaultBase: lintDefaultBase, allowXdebug: true });
|
|
623
921
|
await agentCtx.plugin(PerfLogStatsTool, { rootPath: worktreePath });
|
|
624
922
|
}
|
|
625
923
|
},
|
|
@@ -636,8 +934,8 @@ export function apply(ctx, config) {
|
|
|
636
934
|
? 'This is a diff-only review with no local checkout or Magento environment. Do not claim that tests, static analysis, or Magento runtime validation ran. '
|
|
637
935
|
: `Call maestro_load_review_profile with {"profile":"${reviewProfile}"} before examining code. `;
|
|
638
936
|
let scopePrompt = payload.scope.kind === 'discussion'
|
|
639
|
-
?
|
|
640
|
-
:
|
|
937
|
+
? buildReviewerScopePrompt({ scopeKind: 'discussion', discussionId: payload.scope.discussionId, path: payload.scope.path, line: payload.scope.line, profileInstruction })
|
|
938
|
+
: buildReviewerScopePrompt({ scopeKind: 'full', mode: payload.mode, profileInstruction });
|
|
641
939
|
if (incrementalBlock !== undefined)
|
|
642
940
|
scopePrompt = `${incrementalBlock}\n\n${scopePrompt}`;
|
|
643
941
|
handle.agent.followup(createUserMessage({
|
|
@@ -680,7 +978,7 @@ export function apply(ctx, config) {
|
|
|
680
978
|
failures.push(`${locator}: ${err instanceof Error ? err.message : String(err)}`);
|
|
681
979
|
}
|
|
682
980
|
}
|
|
683
|
-
return { summary: `${postedNew} new inline comment(s), ${postedReplies} thread(s) updated.`, failures };
|
|
981
|
+
return { summary: `${postedNew} new inline comment(s), ${postedReplies} thread(s) updated.`, failures, severityCounts: countFindingSeverities(capturedFindings) };
|
|
684
982
|
}
|
|
685
983
|
finally {
|
|
686
984
|
await handle.dispose();
|
|
@@ -715,7 +1013,7 @@ export function apply(ctx, config) {
|
|
|
715
1013
|
throw err;
|
|
716
1014
|
}
|
|
717
1015
|
}
|
|
718
|
-
async function runAuditor(worktreePath, payload, effective, modelSelection) {
|
|
1016
|
+
async function runAuditor(worktreePath, payload, effective, modelSelection, opts) {
|
|
719
1017
|
let handle;
|
|
720
1018
|
const agentOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection());
|
|
721
1019
|
try {
|
|
@@ -743,10 +1041,10 @@ export function apply(ctx, config) {
|
|
|
743
1041
|
}
|
|
744
1042
|
ctx.sessionTitle.rename(handle.agent.session, `Maestro Auditor — MR !${payload.mrIid} (${payload.projectPath})`);
|
|
745
1043
|
try {
|
|
746
|
-
const prompt =
|
|
1044
|
+
const prompt = buildAuditorPrompt({ staticOnly: opts?.staticOnly === true });
|
|
747
1045
|
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } }));
|
|
748
1046
|
await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs);
|
|
749
|
-
const output =
|
|
1047
|
+
const output = auditorOutputFromSession(handle.agent.session);
|
|
750
1048
|
const text = output.map(block => ('text' in block ? block.text : '')).join('');
|
|
751
1049
|
return `## Maestro Performance Audit\n\n${text}`;
|
|
752
1050
|
}
|
|
@@ -754,8 +1052,9 @@ export function apply(ctx, config) {
|
|
|
754
1052
|
await handle.dispose();
|
|
755
1053
|
}
|
|
756
1054
|
}
|
|
757
|
-
|
|
758
|
-
|
|
1055
|
+
async function runReview(payload) {
|
|
1056
|
+
const t0 = Date.now();
|
|
1057
|
+
try {
|
|
759
1058
|
const userConfig = await loadUserConfig();
|
|
760
1059
|
const effective = {
|
|
761
1060
|
gitlabBaseUrl: userConfig.gitlabBaseUrl ?? config.gitlabBaseUrl,
|
|
@@ -773,18 +1072,37 @@ export function apply(ctx, config) {
|
|
|
773
1072
|
});
|
|
774
1073
|
}
|
|
775
1074
|
const mapping = effective.projectMappings.find(m => m.projectPath === payload.projectPath);
|
|
1075
|
+
// CI-deep (spec §3): unmapped deep review with CI env + head SHA skips
|
|
1076
|
+
// both the decline and the diff-only fallback and runs the clone branch
|
|
1077
|
+
// below, which shares the mapped path's completion tail.
|
|
1078
|
+
const ciDeep = mapping === undefined && payload.mode === 'deep' && shouldCiDeepReview(payload);
|
|
1079
|
+
// CI quick-with-profile: unmapped quick + non-generic profile + CI env
|
|
1080
|
+
// skips the diff-only fallback and joins the clone branch below, which
|
|
1081
|
+
// runs reviewer-only (quick never audits).
|
|
1082
|
+
const ciQuickProfile = mapping === undefined && shouldCiQuickProfileReview(payload);
|
|
776
1083
|
// Unmapped reviewer assignments remain no-ops. Only an explicit mention
|
|
777
1084
|
// may opt into the intentionally limited, diff-only fallback below.
|
|
778
|
-
if (mapping === undefined && payload.trigger !== 'mention')
|
|
779
|
-
return;
|
|
1085
|
+
if (mapping === undefined && payload.trigger !== 'mention') {
|
|
1086
|
+
return { ok: true, failures: [], durationMs: Date.now() - t0 };
|
|
1087
|
+
}
|
|
1088
|
+
const triggers = resolveReviewTriggers(userConfig, mapping);
|
|
1089
|
+
// Gated-off auto-triggers stay fully silent: no history, no signals, no comment.
|
|
1090
|
+
if (payload.trigger === 'reviewer-assignment' && !triggers.onAssign) {
|
|
1091
|
+
return { ok: true, failures: [], durationMs: Date.now() - t0 };
|
|
1092
|
+
}
|
|
1093
|
+
if (payload.trigger === 'push' && !triggers.onPush) {
|
|
1094
|
+
return { ok: true, failures: [], durationMs: Date.now() - t0 };
|
|
1095
|
+
}
|
|
780
1096
|
// A push only re-reviews an MR that already has a completed review;
|
|
781
1097
|
// otherwise every newly opened MR would be reviewed twice.
|
|
782
|
-
if (payload.trigger === 'push' && !(await hasCompletedReview(payload.projectId, payload.mrIid)))
|
|
783
|
-
return;
|
|
1098
|
+
if (payload.trigger === 'push' && !(await hasCompletedReview(payload.projectId, payload.mrIid))) {
|
|
1099
|
+
return { ok: true, failures: [], durationMs: Date.now() - t0 };
|
|
1100
|
+
}
|
|
784
1101
|
const { gitlabToken } = effective;
|
|
785
1102
|
if (gitlabToken === undefined) {
|
|
786
|
-
|
|
787
|
-
|
|
1103
|
+
const message = `MR !${String(payload.mrIid)} for project ${payload.projectPath} has no GitLab token — set one in Maestro Settings or MAESTRO_GITLAB_TOKEN`;
|
|
1104
|
+
console.error(`maestro-orchestrator: ${message}`);
|
|
1105
|
+
return { ok: false, failures: [message], durationMs: Date.now() - t0 };
|
|
788
1106
|
}
|
|
789
1107
|
const resolved = { ...effective, gitlabToken };
|
|
790
1108
|
const historyId = `${payload.projectId}-${payload.mrIid}-${Date.now()}`;
|
|
@@ -816,7 +1134,7 @@ export function apply(ctx, config) {
|
|
|
816
1134
|
const fallbackSelection = ctx.get?.('agentDefaultModel')?.currentSelection()
|
|
817
1135
|
?? ctx.agentDefaultModel?.currentSelection()
|
|
818
1136
|
?? { provider: 'fallback', model: 'fallback' };
|
|
819
|
-
const reviewModelSelection = resolveReviewModel(userConfig, mapping, fallbackSelection);
|
|
1137
|
+
const reviewModelSelection = resolveReviewModel(userConfig, mapping, fallbackSelection, config.reviewModel);
|
|
820
1138
|
// Opt-in Telegram digest; a delivery failure is logged and dropped.
|
|
821
1139
|
const reviewStartMsOuter = Date.now();
|
|
822
1140
|
const notifyTelegram = (status, summary, extra) => {
|
|
@@ -868,7 +1186,7 @@ export function apply(ctx, config) {
|
|
|
868
1186
|
const postComment = async (body) => {
|
|
869
1187
|
const response = await fetch(`${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/notes`, {
|
|
870
1188
|
method: 'POST',
|
|
871
|
-
headers: {
|
|
1189
|
+
headers: { ...gitlabAuthHeaders(resolved.gitlabToken), 'Content-Type': 'application/json' },
|
|
872
1190
|
body: JSON.stringify({ body }),
|
|
873
1191
|
});
|
|
874
1192
|
if (!response.ok)
|
|
@@ -877,23 +1195,25 @@ export function apply(ctx, config) {
|
|
|
877
1195
|
const replyToDiscussion = async (discussionId, body) => {
|
|
878
1196
|
const response = await fetch(`${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/discussions/${encodeURIComponent(discussionId)}/notes`, {
|
|
879
1197
|
method: 'POST',
|
|
880
|
-
headers: {
|
|
1198
|
+
headers: { ...gitlabAuthHeaders(resolved.gitlabToken), 'Content-Type': 'application/json' },
|
|
881
1199
|
body: JSON.stringify({ body }),
|
|
882
1200
|
});
|
|
883
1201
|
if (!response.ok)
|
|
884
1202
|
throw new Error(`GitLab API error ${response.status}: ${await response.text()}`);
|
|
885
1203
|
};
|
|
886
1204
|
try {
|
|
887
|
-
if (mapping === undefined) {
|
|
888
|
-
|
|
1205
|
+
if (mapping === undefined && !ciDeep && !ciQuickProfile) {
|
|
1206
|
+
// CI-deep (spec §3) skips the decline and falls through to the clone
|
|
1207
|
+
// branch below; every other unmapped deep review still declines here.
|
|
1208
|
+
if (payload.mode === 'deep' && !shouldCiDeepReview(payload)) {
|
|
889
1209
|
await declineUnmappedDeepReview(payload, { postComment, replyToDiscussion, writeFailedReport, gitlabBaseUrl: resolved.gitlabBaseUrl });
|
|
890
1210
|
await recordReviewFinish(historyId, { status: 'completed', summary: 'Deep review declined (unmapped project)' });
|
|
891
1211
|
notifyTelegram('completed', 'Deep review declined (unmapped project)');
|
|
892
1212
|
await signals?.finish('completed');
|
|
893
|
-
return;
|
|
1213
|
+
return { ok: true, summary: 'Deep review declined (unmapped project)', failures: [], durationMs: Date.now() - t0 };
|
|
894
1214
|
}
|
|
895
1215
|
const diffBody = await runDiffOnlyReview(payload, {
|
|
896
|
-
runReviewer: (p) => runReviewer(undefined, p, resolved,
|
|
1216
|
+
runReviewer: (p) => runReviewer(undefined, p, resolved, payload.reviewProfile, reviewModelSelection, incrementalBlock),
|
|
897
1217
|
postComment,
|
|
898
1218
|
replyToDiscussion,
|
|
899
1219
|
writeFailedReport,
|
|
@@ -902,59 +1222,108 @@ export function apply(ctx, config) {
|
|
|
902
1222
|
await recordReviewFinish(historyId, { status: 'completed', summary: summarize(diffBody) });
|
|
903
1223
|
notifyTelegram('completed', summarize(diffBody));
|
|
904
1224
|
await signals?.finish('completed');
|
|
905
|
-
return;
|
|
1225
|
+
return { ok: true, summary: summarize(diffBody), failures: [], durationMs: Date.now() - t0 };
|
|
906
1226
|
}
|
|
907
1227
|
let fullBody;
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1228
|
+
if (ciDeep || ciQuickProfile) {
|
|
1229
|
+
// CI-deep (mapping is always undefined here — the unmapped block above
|
|
1230
|
+
// returned for every other case): clone the source at the head SHA,
|
|
1231
|
+
// map it in memory, and reuse the mapped machinery reviewer-only.
|
|
1232
|
+
// CI quick-with-profile joins the same branch; runReviewAndAudit skips
|
|
1233
|
+
// the auditor for quick (shouldAudit), so it stays reviewer-only.
|
|
1234
|
+
// No govard/vendor linking (ciEnsureWorktree is plain fetch + worktree).
|
|
1235
|
+
const ciHost = process.env.GITLAB_HOST?.trim() || new URL(resolved.gitlabBaseUrl).hostname;
|
|
1236
|
+
const cloneDir = join('/tmp', `maestro-ci-src-${payload.projectId}-${payload.mrIid}`);
|
|
1237
|
+
await cloneSourceRepo({ fetcher: fetch, run: defaultRun, host: ciHost, projectId: payload.projectId, sourceBranch: payload.sourceBranch, headSha: payload.headSha, token: resolved.gitlabToken, dir: cloneDir });
|
|
1238
|
+
try {
|
|
1239
|
+
const ciEnsureWorktree = async (repoPath, branch, pid, iid, suffix) => {
|
|
1240
|
+
assertSafeBranchName(branch);
|
|
1241
|
+
const wt = join('/tmp', `maestro-mr-${pid}-${iid}${suffix === undefined ? '' : `-${suffix}`}`);
|
|
1242
|
+
await defaultRun('git', ['fetch', '--depth', '50', 'origin', branch], repoPath);
|
|
1243
|
+
await defaultRun('git', ['worktree', 'remove', '--force', wt], repoPath).catch(() => { });
|
|
1244
|
+
await defaultRun('git', ['worktree', 'add', '--detach', '--', wt, payload.headSha], repoPath);
|
|
1245
|
+
return wt;
|
|
1246
|
+
};
|
|
1247
|
+
fullBody = await runReviewAndAudit(payload, {
|
|
1248
|
+
localRepoPath: cloneDir,
|
|
1249
|
+
ensureWorktree: ciEnsureWorktree,
|
|
1250
|
+
removeWorktree,
|
|
1251
|
+
runReviewer: (worktreePath, p) => runReviewer(worktreePath, p, resolved, payload.reviewProfile ?? 'generic', reviewModelSelection, incrementalBlock),
|
|
1252
|
+
runAuditor: withAuditorDegrade((worktreePath, p) => runAuditor(worktreePath, p, resolved, reviewModelSelection, { staticOnly: true })),
|
|
1253
|
+
postComment,
|
|
1254
|
+
replyToDiscussion,
|
|
1255
|
+
writeFailedReport,
|
|
1256
|
+
gitlabBaseUrl: resolved.gitlabBaseUrl,
|
|
1257
|
+
reviewProfile: payload.reviewProfile ?? 'generic',
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
finally {
|
|
1261
|
+
await defaultRun('rm', ['-rf', cloneDir]).catch(() => { });
|
|
1262
|
+
}
|
|
921
1263
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1264
|
+
else {
|
|
1265
|
+
// Unreachable when mapping is undefined: the unmapped block returned
|
|
1266
|
+
// for every non-CI-deep case, and ciDeep took the branch above.
|
|
1267
|
+
if (mapping === undefined)
|
|
1268
|
+
throw new Error('unreachable: unmapped review without CI-deep decision');
|
|
1269
|
+
try {
|
|
1270
|
+
fullBody = await runReviewAndAudit(payload, {
|
|
1271
|
+
localRepoPath: mapping.localRepoPath,
|
|
1272
|
+
ensureWorktree,
|
|
1273
|
+
removeWorktree,
|
|
1274
|
+
runReviewer: (worktreePath, p) => runReviewer(worktreePath, p, resolved, mapping.reviewProfile ?? 'magento2', reviewModelSelection, incrementalBlock),
|
|
1275
|
+
runAuditor: (worktreePath, p) => runAuditor(worktreePath, p, resolved, reviewModelSelection),
|
|
927
1276
|
postComment,
|
|
928
1277
|
replyToDiscussion,
|
|
929
1278
|
writeFailedReport,
|
|
930
1279
|
gitlabBaseUrl: resolved.gitlabBaseUrl,
|
|
1280
|
+
reviewProfile: mapping.reviewProfile ?? 'magento2',
|
|
931
1281
|
});
|
|
932
|
-
const summary = summarize(diffBody);
|
|
933
|
-
const branchNote = `fallback diff-only: branch ${payload.sourceBranch} not found on origin — ${summary ?? ''}`.trim();
|
|
934
|
-
await recordReviewFinish(historyId, { status: 'completed', summary: branchNote });
|
|
935
|
-
notifyTelegram('completed', branchNote);
|
|
936
|
-
await signals?.finish('completed');
|
|
937
|
-
return;
|
|
938
1282
|
}
|
|
939
|
-
|
|
1283
|
+
catch (err) {
|
|
1284
|
+
const isBranchNotFound = err?.code === 'BRANCH_NOT_FOUND' || isBranchNotFoundError(err);
|
|
1285
|
+
if (isBranchNotFound) {
|
|
1286
|
+
const diffBody = await runDiffOnlyReview(payload, {
|
|
1287
|
+
runReviewer: (p) => runReviewer(undefined, p, resolved, payload.reviewProfile, reviewModelSelection, incrementalBlock),
|
|
1288
|
+
postComment,
|
|
1289
|
+
replyToDiscussion,
|
|
1290
|
+
writeFailedReport,
|
|
1291
|
+
gitlabBaseUrl: resolved.gitlabBaseUrl,
|
|
1292
|
+
});
|
|
1293
|
+
const summary = summarize(diffBody);
|
|
1294
|
+
const branchNote = `fallback diff-only: branch ${payload.sourceBranch} not found on origin — ${summary ?? ''}`.trim();
|
|
1295
|
+
await recordReviewFinish(historyId, { status: 'completed', summary: branchNote });
|
|
1296
|
+
notifyTelegram('completed', branchNote);
|
|
1297
|
+
await signals?.finish('completed');
|
|
1298
|
+
return { ok: true, summary: branchNote, failures: [], durationMs: Date.now() - t0 };
|
|
1299
|
+
}
|
|
1300
|
+
throw err;
|
|
1301
|
+
}
|
|
940
1302
|
}
|
|
941
1303
|
await recordReviewFinish(historyId, { status: 'completed', summary: summarize(fullBody) });
|
|
942
1304
|
notifyTelegram('completed', summarize(fullBody));
|
|
943
1305
|
await signals?.finish('completed');
|
|
1306
|
+
return { ok: true, summary: summarize(fullBody), failures: [], durationMs: Date.now() - t0 };
|
|
944
1307
|
}
|
|
945
1308
|
catch (err) {
|
|
946
1309
|
const message = err instanceof Error ? err.message : String(err);
|
|
947
1310
|
await recordReviewFinish(historyId, { status: 'failed', error: message }).catch(() => { });
|
|
948
1311
|
notifyTelegram('failed', message);
|
|
949
1312
|
await signals?.finish('failed');
|
|
950
|
-
|
|
1313
|
+
return { ok: false, summary: undefined, failures: [message], durationMs: Date.now() - t0 };
|
|
951
1314
|
}
|
|
952
|
-
}
|
|
1315
|
+
}
|
|
1316
|
+
catch (err) {
|
|
953
1317
|
// Worktree creation, agent creation, and fallback delivery all run from
|
|
954
|
-
//
|
|
955
|
-
// fire-and-forget
|
|
1318
|
+
// this function — surface failures as a result instead of an unhandled
|
|
1319
|
+
// rejection, so both the fire-and-forget webhook path and an awaiting
|
|
1320
|
+
// CI caller observe the same outcome.
|
|
1321
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
956
1322
|
console.error(`maestro-orchestrator: review run failed for MR !${String(payload.mrIid)}:`, err);
|
|
957
|
-
|
|
958
|
-
|
|
1323
|
+
return { ok: false, summary: undefined, failures: [message], durationMs: Date.now() - t0 };
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
ctx.on('maestro/review-request', (payload) => { void runReview(payload); });
|
|
1327
|
+
ctx.provide('reviewRunner', runReview);
|
|
959
1328
|
}
|
|
960
1329
|
//# sourceMappingURL=orchestrator.js.map
|