@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.
Files changed (83) hide show
  1. package/README.md +12 -0
  2. package/cordis.patch.yml +18 -0
  3. package/lib/ci-clone.d.ts +21 -0
  4. package/lib/ci-clone.d.ts.map +1 -0
  5. package/lib/ci-clone.js +35 -0
  6. package/lib/ci-clone.js.map +1 -0
  7. package/lib/ci-coexist.d.ts +9 -0
  8. package/lib/ci-coexist.d.ts.map +1 -0
  9. package/lib/ci-coexist.js +36 -0
  10. package/lib/ci-coexist.js.map +1 -0
  11. package/lib/config-store.d.ts +13 -6
  12. package/lib/config-store.d.ts.map +1 -1
  13. package/lib/config-store.js.map +1 -1
  14. package/lib/events.d.ts +23 -0
  15. package/lib/events.d.ts.map +1 -1
  16. package/lib/gitlab-auth.d.ts +11 -0
  17. package/lib/gitlab-auth.d.ts.map +1 -0
  18. package/lib/gitlab-auth.js +15 -0
  19. package/lib/gitlab-auth.js.map +1 -0
  20. package/lib/gitlab-client.d.ts +57 -0
  21. package/lib/gitlab-client.d.ts.map +1 -1
  22. package/lib/gitlab-client.js +120 -17
  23. package/lib/gitlab-client.js.map +1 -1
  24. package/lib/govard-audit-lint-tool.d.ts +81 -0
  25. package/lib/govard-audit-lint-tool.d.ts.map +1 -1
  26. package/lib/govard-audit-lint-tool.js +121 -36
  27. package/lib/govard-audit-lint-tool.js.map +1 -1
  28. package/lib/govard-tool.js +1 -1
  29. package/lib/govard-tool.js.map +1 -1
  30. package/lib/incremental.d.ts.map +1 -1
  31. package/lib/incremental.js +3 -2
  32. package/lib/incremental.js.map +1 -1
  33. package/lib/orchestrator.d.ts +137 -5
  34. package/lib/orchestrator.d.ts.map +1 -1
  35. package/lib/orchestrator.js +439 -70
  36. package/lib/orchestrator.js.map +1 -1
  37. package/lib/providers/ci-trigger.d.ts +43 -0
  38. package/lib/providers/ci-trigger.d.ts.map +1 -0
  39. package/lib/providers/ci-trigger.js +146 -0
  40. package/lib/providers/ci-trigger.js.map +1 -0
  41. package/lib/providers/gitlab.d.ts.map +1 -1
  42. package/lib/providers/gitlab.js +2 -1
  43. package/lib/providers/gitlab.js.map +1 -1
  44. package/lib/review-findings-tool.d.ts +5 -0
  45. package/lib/review-findings-tool.d.ts.map +1 -1
  46. package/lib/review-findings-tool.js +3 -1
  47. package/lib/review-findings-tool.js.map +1 -1
  48. package/lib/review-intake.d.ts.map +1 -1
  49. package/lib/review-intake.js +5 -1
  50. package/lib/review-intake.js.map +1 -1
  51. package/lib/review-marker.d.ts +17 -0
  52. package/lib/review-marker.d.ts.map +1 -0
  53. package/lib/review-marker.js +23 -0
  54. package/lib/review-marker.js.map +1 -0
  55. package/lib/review-signals.d.ts.map +1 -1
  56. package/lib/review-signals.js +4 -8
  57. package/lib/review-signals.js.map +1 -1
  58. package/lib/settings-rpc.js +1 -1
  59. package/lib/settings-rpc.js.map +1 -1
  60. package/package.json +14 -9
  61. package/profiles/reviewer-ci/cordis.patch.yml +61 -0
  62. package/profiles/reviewer-ci/package.json +19 -0
  63. package/profiles/reviewer-ci/pnpm-lock.yaml +62 -0
  64. package/profiles/reviewer-ci/pnpm-workspace.yaml +12 -0
  65. package/src/host/ci-clone.ts +54 -0
  66. package/src/host/ci-coexist.ts +43 -0
  67. package/src/host/config-store.ts +14 -1
  68. package/src/host/events.ts +25 -0
  69. package/src/host/gitlab-auth.ts +13 -0
  70. package/src/host/gitlab-client.ts +142 -17
  71. package/src/host/govard-audit-lint-tool.ts +144 -31
  72. package/src/host/govard-tool.ts +1 -1
  73. package/src/host/incremental.ts +3 -2
  74. package/src/host/orchestrator.ts +462 -50
  75. package/src/host/providers/ci-trigger.ts +164 -0
  76. package/src/host/providers/gitlab.ts +2 -1
  77. package/src/host/review-findings-tool.ts +9 -1
  78. package/src/host/review-intake.ts +5 -1
  79. package/src/host/review-marker.ts +25 -0
  80. package/src/host/review-signals.ts +4 -3
  81. package/src/host/settings-rpc.ts +1 -1
  82. package/templates/reviewer-project.gitlab-ci.yml +90 -0
  83. package/templates/source-project.gitlab-ci.yml +42 -0
@@ -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
- return `${title}\n\n${metaLine}\n\n${scopeNote}${opts.summary}${findingsLine}${failuresBlock}${footer}`;
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 > DSH default (`fallback`).
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 = { 'PRIVATE-TOKEN': config.token, 'Content-Type': 'application/json' };
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: finding.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${finding.body}` }),
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: finding.body, position: {
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
- return `project_name: ${name}\ndomain: ${name}.test\n`;
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
- ? `${profileInstruction}Review only the requested inline discussion ${payload.scope.discussionId} at ${payload.scope.path}:${payload.scope.line}. Do not review unrelated files or start a broad audit. Call gitlab_get_mr_diff, then call report_review_findings exactly once when done.`
640
- : `${profileInstruction}Review this merge request (${payload.mode} mode). Call gitlab_list_own_review_threads and gitlab_get_mr_diff first, then call report_review_findings exactly once when done.`;
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 = '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.';
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 = finalAssistantOutput(handle.agent.session.events) ?? [];
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
- ctx.on('maestro/review-request', (payload) => {
758
- void (async () => {
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
- console.error(`maestro-orchestrator: MR !${String(payload.mrIid)} for project ${payload.projectPath} has no GitLab token — set one in Maestro Settings or MAESTRO_GITLAB_TOKEN`);
787
- return;
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: { 'PRIVATE-TOKEN': resolved.gitlabToken, 'Content-Type': 'application/json' },
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: { 'PRIVATE-TOKEN': resolved.gitlabToken, 'Content-Type': 'application/json' },
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
- if (payload.mode === 'deep') {
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, undefined, reviewModelSelection, incrementalBlock),
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
- try {
909
- fullBody = await runReviewAndAudit(payload, {
910
- localRepoPath: mapping.localRepoPath,
911
- ensureWorktree,
912
- removeWorktree,
913
- runReviewer: (worktreePath, p) => runReviewer(worktreePath, p, resolved, mapping.reviewProfile ?? 'magento2', reviewModelSelection, incrementalBlock),
914
- runAuditor: (worktreePath, p) => runAuditor(worktreePath, p, resolved, reviewModelSelection),
915
- postComment,
916
- replyToDiscussion,
917
- writeFailedReport,
918
- gitlabBaseUrl: resolved.gitlabBaseUrl,
919
- reviewProfile: mapping.reviewProfile ?? 'magento2',
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
- catch (err) {
923
- const isBranchNotFound = err?.code === 'BRANCH_NOT_FOUND' || isBranchNotFoundError(err);
924
- if (isBranchNotFound) {
925
- const diffBody = await runDiffOnlyReview(payload, {
926
- runReviewer: (p) => runReviewer(undefined, p, resolved, undefined, reviewModelSelection, incrementalBlock),
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
- throw err;
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
- throw err;
1313
+ return { ok: false, summary: undefined, failures: [message], durationMs: Date.now() - t0 };
951
1314
  }
952
- })().catch((err) => {
1315
+ }
1316
+ catch (err) {
953
1317
  // Worktree creation, agent creation, and fallback delivery all run from
954
- // an event callback, so surface failures rather than leaking a rejected
955
- // fire-and-forget Promise.
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