@ddtcorex/dsh-maestro-review 0.1.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 (110) hide show
  1. package/README.md +53 -0
  2. package/client/settings-card.tsx +4 -0
  3. package/lib/config-store.d.ts +53 -0
  4. package/lib/config-store.d.ts.map +1 -0
  5. package/lib/config-store.js +60 -0
  6. package/lib/config-store.js.map +1 -0
  7. package/lib/events.d.ts +27 -0
  8. package/lib/events.d.ts.map +1 -0
  9. package/lib/events.js +2 -0
  10. package/lib/events.js.map +1 -0
  11. package/lib/gitlab-client.d.ts +14 -0
  12. package/lib/gitlab-client.d.ts.map +1 -0
  13. package/lib/gitlab-client.js +146 -0
  14. package/lib/gitlab-client.js.map +1 -0
  15. package/lib/gitlab-webhook.d.ts +17 -0
  16. package/lib/gitlab-webhook.d.ts.map +1 -0
  17. package/lib/gitlab-webhook.js +144 -0
  18. package/lib/gitlab-webhook.js.map +1 -0
  19. package/lib/govard-tool.d.ts +11 -0
  20. package/lib/govard-tool.d.ts.map +1 -0
  21. package/lib/govard-tool.js +95 -0
  22. package/lib/govard-tool.js.map +1 -0
  23. package/lib/index.d.ts +4 -0
  24. package/lib/index.d.ts.map +1 -0
  25. package/lib/index.js +15 -0
  26. package/lib/index.js.map +1 -0
  27. package/lib/notify.d.ts +23 -0
  28. package/lib/notify.d.ts.map +1 -0
  29. package/lib/notify.js +10 -0
  30. package/lib/notify.js.map +1 -0
  31. package/lib/orchestrator.d.ts +144 -0
  32. package/lib/orchestrator.d.ts.map +1 -0
  33. package/lib/orchestrator.js +655 -0
  34. package/lib/orchestrator.js.map +1 -0
  35. package/lib/pin-store.d.ts +5 -0
  36. package/lib/pin-store.d.ts.map +1 -0
  37. package/lib/pin-store.js +5 -0
  38. package/lib/pin-store.js.map +1 -0
  39. package/lib/providers/github.stub.d.ts +3 -0
  40. package/lib/providers/github.stub.d.ts.map +1 -0
  41. package/lib/providers/github.stub.js +10 -0
  42. package/lib/providers/github.stub.js.map +1 -0
  43. package/lib/providers/gitlab.d.ts +13 -0
  44. package/lib/providers/gitlab.d.ts.map +1 -0
  45. package/lib/providers/gitlab.js +173 -0
  46. package/lib/providers/gitlab.js.map +1 -0
  47. package/lib/providers/interface.d.ts +12 -0
  48. package/lib/providers/interface.d.ts.map +1 -0
  49. package/lib/providers/interface.js +2 -0
  50. package/lib/providers/interface.js.map +1 -0
  51. package/lib/review-findings-tool.d.ts +15 -0
  52. package/lib/review-findings-tool.d.ts.map +1 -0
  53. package/lib/review-findings-tool.js +37 -0
  54. package/lib/review-findings-tool.js.map +1 -0
  55. package/lib/review-history.d.ts +35 -0
  56. package/lib/review-history.d.ts.map +1 -0
  57. package/lib/review-history.js +87 -0
  58. package/lib/review-history.js.map +1 -0
  59. package/lib/review-intake.d.ts +7 -0
  60. package/lib/review-intake.d.ts.map +1 -0
  61. package/lib/review-intake.js +80 -0
  62. package/lib/review-intake.js.map +1 -0
  63. package/lib/review-signals.d.ts +17 -0
  64. package/lib/review-signals.d.ts.map +1 -0
  65. package/lib/review-signals.js +49 -0
  66. package/lib/review-signals.js.map +1 -0
  67. package/lib/secure-compare.d.ts +7 -0
  68. package/lib/secure-compare.d.ts.map +1 -0
  69. package/lib/secure-compare.js +14 -0
  70. package/lib/secure-compare.js.map +1 -0
  71. package/lib/settings-rpc.d.ts +22 -0
  72. package/lib/settings-rpc.d.ts.map +1 -0
  73. package/lib/settings-rpc.js +252 -0
  74. package/lib/settings-rpc.js.map +1 -0
  75. package/lib/skills-tool.d.ts +29 -0
  76. package/lib/skills-tool.d.ts.map +1 -0
  77. package/lib/skills-tool.js +160 -0
  78. package/lib/skills-tool.js.map +1 -0
  79. package/lib/workspace-tool.d.ts +10 -0
  80. package/lib/workspace-tool.d.ts.map +1 -0
  81. package/lib/workspace-tool.js +86 -0
  82. package/lib/workspace-tool.js.map +1 -0
  83. package/package.json +53 -0
  84. package/presets/maestro-auditor/agent.cordis.yml +10 -0
  85. package/presets/maestro-auditor/preset.yml +2 -0
  86. package/presets/maestro-coder/agent.cordis.yml +223 -0
  87. package/presets/maestro-coder/preset.yml +2 -0
  88. package/presets/maestro-reviewer/agent.cordis.yml +14 -0
  89. package/presets/maestro-reviewer/preset.yml +2 -0
  90. package/src/augment.d.ts +47 -0
  91. package/src/config-store.ts +109 -0
  92. package/src/events.ts +25 -0
  93. package/src/gitlab-client.ts +186 -0
  94. package/src/gitlab-webhook.ts +166 -0
  95. package/src/govard-tool.ts +114 -0
  96. package/src/index.ts +15 -0
  97. package/src/notify.ts +30 -0
  98. package/src/orchestrator.ts +740 -0
  99. package/src/pin-store.ts +4 -0
  100. package/src/providers/github.stub.ts +11 -0
  101. package/src/providers/gitlab.ts +190 -0
  102. package/src/providers/interface.ts +2 -0
  103. package/src/review-findings-tool.ts +51 -0
  104. package/src/review-history.ts +112 -0
  105. package/src/review-intake.ts +86 -0
  106. package/src/review-signals.ts +52 -0
  107. package/src/secure-compare.ts +13 -0
  108. package/src/settings-rpc.ts +249 -0
  109. package/src/skills-tool.ts +193 -0
  110. package/src/workspace-tool.ts +90 -0
@@ -0,0 +1,655 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { promisify } from 'node:util';
5
+ import { join } from 'node:path';
6
+ import { homedir, tmpdir } from 'node:os';
7
+ import z from '@deepseek-ai/schemastery';
8
+ import { SessionId } from '@deepseek-ai/dsh-session';
9
+ import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
10
+ import { finalAssistantOutput } from '@deepseek-ai/dsh-subagent';
11
+ import { installModelSelection } from '@deepseek-ai/dsh-agent';
12
+ import * as GovardTool from './govard-tool.js';
13
+ import * as GitlabClient from './gitlab-client.js';
14
+ import * as ReviewFindingsTool from './review-findings-tool.js';
15
+ import { loadUserConfig } from './config-store.js';
16
+ import { hasCompletedReview, pruneHistory, recordReviewFinish, recordReviewStart } from './review-history.js';
17
+ import { createReviewSignals } from './review-signals.js';
18
+ import { reviewDigestText } from './notify.js';
19
+ import { loadedReviewProfile } from './skills-tool.js';
20
+ import { gitlabProvider } from './providers/gitlab.js';
21
+ import './events.js';
22
+ // Provider-aware wrapper — orchestrator can run reviews via any ReviewProvider.
23
+ // This keeps the GitLab-specific flow intact while allowing Phase C to add GitHub/Jira without modifying core logic.
24
+ export async function runReviewWithProvider(provider, request) {
25
+ // Currently delegates to GitLab flow; Phase C will branch on provider.id
26
+ if (provider.id === 'gitlab') {
27
+ // Orchestrator already handles GitLab via 'maestro/review-request' event
28
+ // This wrapper exists to prove provider pluggability; real dispatch is via ctx.emit
29
+ void gitlabProvider;
30
+ }
31
+ void request;
32
+ }
33
+ export { gitlabProvider };
34
+ const execFileAsync = promisify(execFile);
35
+ const GIT_TIMEOUT_MS = 60_000;
36
+ export const name = 'maestro-orchestrator';
37
+ export const inject = ['agentDefaultModel', 'agents', 'agentPresets', 'sessionTitle'];
38
+ /** Convert DSH's selected default model into the options required by a child Agent. */
39
+ export function agentOptionsForModel(selection) {
40
+ return {
41
+ provider: selection.provider,
42
+ model: selection.model,
43
+ ...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
44
+ };
45
+ }
46
+ /**
47
+ * Resolve the model to use for an automated review. Priority: per-project
48
+ * override > global reviewModel > DSH default (`fallback`).
49
+ */
50
+ export function resolveReviewModel(userConfig, mapping, fallback) {
51
+ const raw = mapping?.reviewModel ?? userConfig.reviewModel;
52
+ if (raw === undefined || raw === null)
53
+ return fallback;
54
+ return {
55
+ provider: raw.provider,
56
+ model: raw.model,
57
+ ...(raw.reasoningEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(raw.reasoningEffort) }),
58
+ };
59
+ }
60
+ /** Compose a newly-created agent from the preset service owned by the root context. */
61
+ export async function mountAgentPreset(agentPresets, agentCtx, id) {
62
+ await agentPresets.mount(agentCtx, id);
63
+ }
64
+ export const DEFAULT_AGENT_TIMEOUT_MS = 20 * 60_000;
65
+ export const Config = z.object({
66
+ projectMappings: z.array(z.object({
67
+ projectPath: z.string().required(),
68
+ localRepoPath: z.string().required(),
69
+ reviewProfile: z.union([z.const('magento2'), z.const('generic')]).default('magento2'),
70
+ })).required(),
71
+ gitlabBaseUrl: z.string().required(),
72
+ gitlabToken: z.string().role('secret'),
73
+ botUsername: z.string().required(),
74
+ agentTimeoutMs: z.number().min(1000).default(DEFAULT_AGENT_TIMEOUT_MS),
75
+ });
76
+ /**
77
+ * Await an agent's turn with a hard ceiling. A hung automated agent would
78
+ * otherwise hold its session, worktree, and review key forever; the watchdog
79
+ * disposes the handle and rejects so the review is recorded as failed.
80
+ */
81
+ export async function whenIdleWithTimeout(handle, timeoutMs) {
82
+ let timer;
83
+ try {
84
+ await Promise.race([
85
+ handle.agent.whenIdle(),
86
+ new Promise((_resolve, reject) => {
87
+ timer = setTimeout(() => reject(new Error(`automated agent timed out after ${timeoutMs} ms`)), timeoutMs);
88
+ }),
89
+ ]);
90
+ }
91
+ catch (err) {
92
+ await handle.dispose().catch(() => { });
93
+ throw err;
94
+ }
95
+ finally {
96
+ if (timer !== undefined)
97
+ clearTimeout(timer);
98
+ }
99
+ }
100
+ /** Map a new-side line number to its exact unified-diff position. */
101
+ export function diffPositionForNewLine(diff, targetLine) {
102
+ let oldLine = 0;
103
+ let newLine = 0;
104
+ for (const row of diff.split('\n')) {
105
+ const header = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(row);
106
+ if (header !== null) {
107
+ oldLine = Number(header[1]);
108
+ newLine = Number(header[2]);
109
+ continue;
110
+ }
111
+ if (row.startsWith('\\'))
112
+ continue;
113
+ if (row.startsWith('+')) {
114
+ if (newLine === targetLine)
115
+ return { newLine };
116
+ newLine++;
117
+ continue;
118
+ }
119
+ if (row.startsWith('-')) {
120
+ oldLine++;
121
+ continue;
122
+ }
123
+ if (row.startsWith(' ')) {
124
+ if (newLine === targetLine)
125
+ return { oldLine, newLine };
126
+ oldLine++;
127
+ newLine++;
128
+ }
129
+ }
130
+ return undefined;
131
+ }
132
+ async function loadLatestDiffSnapshot(fetcher, apiBase, headers) {
133
+ const versionsResponse = await fetcher(`${apiBase}/versions`, { headers });
134
+ if (!versionsResponse.ok)
135
+ throw new Error(`GitLab API error ${versionsResponse.status}: ${await versionsResponse.text()}`);
136
+ const versions = await versionsResponse.json();
137
+ const latest = versions[0];
138
+ if (latest?.id === undefined)
139
+ throw new Error('GitLab merge request has no available diff version');
140
+ const versionResponse = await fetcher(`${apiBase}/versions/${latest.id}`, { headers });
141
+ if (!versionResponse.ok)
142
+ throw new Error(`GitLab API error ${versionResponse.status}: ${await versionResponse.text()}`);
143
+ const version = await versionResponse.json();
144
+ if (version.base_commit_sha === undefined || version.start_commit_sha === undefined || version.head_commit_sha === undefined) {
145
+ throw new Error('GitLab merge request diff version has incomplete SHA references');
146
+ }
147
+ if (version.diffs === undefined)
148
+ throw new Error('GitLab merge request diff version has no file diffs');
149
+ return {
150
+ diffRefs: { base_sha: version.base_commit_sha, start_sha: version.start_commit_sha, head_sha: version.head_commit_sha },
151
+ changes: version.diffs,
152
+ };
153
+ }
154
+ /**
155
+ * Publish agent findings from the orchestrator's own context. Agent tools are
156
+ * mounted inside a setup child fiber and are not guaranteed to remain
157
+ * executable through `handle.agent.ctx` after the agent turn ends.
158
+ */
159
+ export async function postReviewFindings(findings, config) {
160
+ const fetcher = config.fetcher ?? fetch;
161
+ const apiBase = `${config.baseUrl}/api/v4/projects/${config.projectId}/merge_requests/${config.mrIid}`;
162
+ const headers = { 'PRIVATE-TOKEN': config.token, 'Content-Type': 'application/json' };
163
+ for (const finding of findings) {
164
+ let response;
165
+ if (finding.status === 'reply') {
166
+ if (finding.discussionId === undefined)
167
+ throw new Error('reply finding missing discussionId');
168
+ response = await fetcher(`${apiBase}/discussions/${encodeURIComponent(finding.discussionId)}/notes`, {
169
+ method: 'POST', headers, body: JSON.stringify({ body: finding.body }),
170
+ });
171
+ }
172
+ else {
173
+ if (finding.path === undefined || finding.line === undefined)
174
+ throw new Error('new finding missing path or line');
175
+ config.snapshot ??= loadLatestDiffSnapshot(fetcher, apiBase, headers);
176
+ const snapshot = await config.snapshot;
177
+ const change = snapshot.changes.find(candidate => candidate.new_path === finding.path) ?? snapshot.changes.find(candidate => candidate.old_path === finding.path);
178
+ if (change === undefined)
179
+ throw new Error(`cannot post inline finding at ${finding.path}:${finding.line}: file is not in the current MR diff`);
180
+ if (change.collapsed === true || change.too_large === true || change.diff === '') {
181
+ throw new Error(`cannot post inline finding at ${finding.path}:${finding.line}: GitLab did not return this file's complete diff`);
182
+ }
183
+ const linePosition = diffPositionForNewLine(change.diff, finding.line);
184
+ if (linePosition === undefined)
185
+ throw new Error(`cannot post inline finding at ${finding.path}:${finding.line}: line is not in the current MR diff`);
186
+ response = await fetcher(`${apiBase}/discussions`, {
187
+ method: 'POST', headers,
188
+ body: JSON.stringify({ body: finding.body, position: {
189
+ position_type: 'text', ...snapshot.diffRefs, old_path: change.old_path, new_path: change.new_path,
190
+ ...linePosition.oldLine === undefined ? {} : { old_line: linePosition.oldLine },
191
+ ...linePosition.newLine === undefined ? {} : { new_line: linePosition.newLine },
192
+ } }),
193
+ });
194
+ }
195
+ if (!response.ok)
196
+ throw new Error(`GitLab API error ${response.status}: ${await response.text()}`);
197
+ }
198
+ }
199
+ /**
200
+ * MRs currently being reviewed, so a duplicate webhook delivery (GitLab retries,
201
+ * or an "open" immediately followed by an "update") does not start a second
202
+ * worktree/agent run for the same MR while the first is still in flight.
203
+ *
204
+ * Keyed on `(projectId, mrIid)`, not `mrIid` alone: GitLab's `iid` is scoped to
205
+ * its project, so two different projects both track MR !7 concurrently — keying
206
+ * on `mrIid` alone would make the second event silently dedupe against the
207
+ * first project's unrelated run (or stomp its worktree directory).
208
+ */
209
+ const inFlightKeys = new Set();
210
+ function reviewKey(payload) {
211
+ const scope = payload.scope.kind === 'mr' ? 'mr' : `discussion:${payload.scope.discussionId}`;
212
+ return `${payload.projectId}:${payload.mrIid}:${payload.mode}:${scope}`;
213
+ }
214
+ /**
215
+ * Stable 8-char hex suffix of the review key, so distinct concurrent reviews
216
+ * (quick + deep of one MR) never share one worktree directory.
217
+ */
218
+ export function reviewKeyHash(payload) {
219
+ return createHash('sha1').update(reviewKey(payload)).digest('hex').slice(0, 8);
220
+ }
221
+ /**
222
+ * `projectId`/`mrIid` are typed as `number` but arrive from `gitlab-webhook.ts`'s
223
+ * `JSON.parse(raw)` with no runtime shape check (a pure TypeScript type assertion) —
224
+ * so at runtime they could be any JSON value despite the type. Both flow, unvalidated,
225
+ * into `ensureWorktree`'s worktree path (`path.join` normalizes `..` segments, so a
226
+ * crafted value can escape `/tmp`), `postComment`'s GitLab API URL (sent with the org's
227
+ * real token), the in-flight `reviewKey`, and `runReviewer`/`runAuditor`'s
228
+ * sessionId/`writeFailedReport`'s filename. Validated once, here, before any of those, same trust boundary as
229
+ * `assertSafeBranchName` below.
230
+ */
231
+ function assertSafeId(value, label) {
232
+ if (!Number.isInteger(value) || value <= 0) {
233
+ throw new Error(`refusing to operate on unsafe ${label}: ${JSON.stringify(value)}`);
234
+ }
235
+ }
236
+ /** Full review + performance audit; resolves to the comment body that was posted. */
237
+ export async function runReviewAndAudit(payload, deps) {
238
+ assertSafeId(payload.projectId, 'projectId');
239
+ assertSafeId(payload.mrIid, 'mrIid');
240
+ const key = reviewKey(payload);
241
+ if (inFlightKeys.has(key))
242
+ return '';
243
+ inFlightKeys.add(key);
244
+ try {
245
+ const worktreePath = await deps.ensureWorktree(deps.localRepoPath, payload.sourceBranch, payload.projectId, payload.mrIid, reviewKeyHash(payload));
246
+ try {
247
+ const sections = [];
248
+ const shouldAudit = payload.scope.kind === 'mr' && payload.mode === 'deep';
249
+ const settled = await Promise.allSettled([
250
+ deps.runReviewer(worktreePath, payload),
251
+ ...(shouldAudit ? [deps.runAuditor(worktreePath, payload)] : []),
252
+ ]);
253
+ const labels = ['Reviewer', 'Auditor'];
254
+ if (settled[0].status === 'fulfilled') {
255
+ const { summary, failures } = settled[0].value;
256
+ sections.push(`## Maestro Review\n\n${summary}${failures.length > 0 ? `\n\n**Failed to post:**\n${failures.map(f => `- ${f}`).join('\n')}` : ''}`);
257
+ }
258
+ else {
259
+ sections.push(`## ${labels[0]} failed\n\n${settled[0].reason instanceof Error ? settled[0].reason.message : String(settled[0].reason)}`);
260
+ }
261
+ const auditorResult = settled[1];
262
+ if (shouldAudit && auditorResult !== undefined && auditorResult.status === 'fulfilled') {
263
+ sections.push(auditorResult.value);
264
+ }
265
+ else if (shouldAudit && auditorResult !== undefined && auditorResult.status === 'rejected') {
266
+ sections.push(`## ${labels[1]} failed\n\n${auditorResult.reason instanceof Error ? auditorResult.reason.message : String(auditorResult.reason)}`);
267
+ }
268
+ const body = sections.join('\n\n---\n\n');
269
+ try {
270
+ if (payload.scope.kind === 'discussion')
271
+ await deps.replyToDiscussion(payload.scope.discussionId, body);
272
+ else
273
+ await deps.postComment(body);
274
+ }
275
+ catch {
276
+ await deps.writeFailedReport(payload.mrIid, body);
277
+ }
278
+ return body;
279
+ }
280
+ finally {
281
+ await deps.removeWorktree(worktreePath);
282
+ }
283
+ }
284
+ finally {
285
+ inFlightKeys.delete(key);
286
+ }
287
+ }
288
+ /**
289
+ * Review the GitLab diff without a local checkout. This deliberately has no
290
+ * auditor or Magento profile: those require the mapped repository and its
291
+ * environment. It is only reached for an explicit mention, never assignment.
292
+ */
293
+ /** Diff-only fallback review; resolves to the comment body that was posted. */
294
+ export async function runDiffOnlyReview(payload, deps) {
295
+ assertSafeId(payload.projectId, 'projectId');
296
+ assertSafeId(payload.mrIid, 'mrIid');
297
+ const key = reviewKey(payload);
298
+ if (inFlightKeys.has(key))
299
+ return '';
300
+ inFlightKeys.add(key);
301
+ try {
302
+ const { summary, failures } = await deps.runReviewer(payload);
303
+ const body = `## Maestro Diff-only review\n\n**Scope limitation:** Reviewed only the changed GitLab diff. No local checkout, Magento environment, static analysis, or tests were available.\n\n${summary}${failures.length > 0 ? `\n\n**Failed to post:**\n${failures.map(f => `- ${f}`).join('\n')}` : ''}`;
304
+ try {
305
+ if (payload.scope.kind === 'discussion')
306
+ await deps.replyToDiscussion(payload.scope.discussionId, body);
307
+ else
308
+ await deps.postComment(body);
309
+ }
310
+ catch {
311
+ await deps.writeFailedReport(payload.mrIid, body);
312
+ }
313
+ return body;
314
+ }
315
+ finally {
316
+ inFlightKeys.delete(key);
317
+ }
318
+ }
319
+ /** Decline a Deep request that lacks the local mapping it requires. */
320
+ export async function declineUnmappedDeepReview(payload, deps) {
321
+ assertSafeId(payload.projectId, 'projectId');
322
+ assertSafeId(payload.mrIid, 'mrIid');
323
+ const key = reviewKey(payload);
324
+ if (inFlightKeys.has(key))
325
+ return;
326
+ inFlightKeys.add(key);
327
+ const body = '## Maestro review not started\n\nDeep review requires a project mapping with a local checkout and Magento environment. Add this project in Settings → Maestro, then mention the reviewer again.';
328
+ try {
329
+ try {
330
+ if (payload.scope.kind === 'discussion')
331
+ await deps.replyToDiscussion(payload.scope.discussionId, body);
332
+ else
333
+ await deps.postComment(body);
334
+ }
335
+ catch {
336
+ await deps.writeFailedReport(payload.mrIid, body);
337
+ }
338
+ }
339
+ finally {
340
+ inFlightKeys.delete(key);
341
+ }
342
+ }
343
+ /**
344
+ * Branch names accepted before they reach a `git` shell-out. `sourceBranch`
345
+ * comes straight from the webhook body (Task 6) — fully attacker-controlled
346
+ * for anyone who knows the shared webhook secret — so a value like
347
+ * `--upload-pack=/tmp/evil.sh` must never reach `git fetch`/`git worktree add`
348
+ * as anything other than an inert ref name. Rejects leading `-` (flag
349
+ * injection) and `..` (path-traversal-flavored ref segments) even though the
350
+ * charset already excludes most of what makes those dangerous, as
351
+ * defense-in-depth against a future charset loosening.
352
+ */
353
+ const SAFE_BRANCH_NAME = /^[A-Za-z0-9._/-]+$/;
354
+ function assertSafeBranchName(sourceBranch) {
355
+ if (!SAFE_BRANCH_NAME.test(sourceBranch) || sourceBranch.startsWith('-') || sourceBranch.includes('..')) {
356
+ throw new Error(`refusing to operate on unsafe branch name: ${JSON.stringify(sourceBranch)}`);
357
+ }
358
+ }
359
+ /**
360
+ * Keep a Govard audit worktree isolated from the developer's primary checkout.
361
+ * Without this local override, both checkouts inherit the same `project_name`
362
+ * and Govard may either reject the worktree or run commands in the wrong stack.
363
+ */
364
+ export function govardWorktreeOverride(projectId, mrIid, keySuffix) {
365
+ const name = `maestro-mr-${projectId}-${mrIid}${keySuffix === undefined ? '' : `-${keySuffix}`}`;
366
+ return `project_name: ${name}\ndomain: ${name}.test\n`;
367
+ }
368
+ export async function ensureWorktree(localRepoPath, sourceBranch, projectId, mrIid, keySuffix) {
369
+ assertSafeBranchName(sourceBranch);
370
+ const worktreePath = join('/tmp', `maestro-mr-${projectId}-${mrIid}${keySuffix === undefined ? '' : `-${keySuffix}`}`);
371
+ await execFileAsync('git', ['fetch', '--', 'origin', sourceBranch], { cwd: localRepoPath, timeout: GIT_TIMEOUT_MS });
372
+ // A host restart can interrupt an active review before its `finally` cleanup.
373
+ // Recover only this deterministic Maestro-owned path so the next delivery can
374
+ // retry; an unrelated worktree is never targeted.
375
+ await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: localRepoPath, timeout: GIT_TIMEOUT_MS })
376
+ .catch(() => { });
377
+ await execFileAsync('git', ['worktree', 'add', '--', worktreePath, `origin/${sourceBranch}`], { cwd: localRepoPath, timeout: GIT_TIMEOUT_MS });
378
+ await writeFile(join(worktreePath, '.govard.local.yml'), govardWorktreeOverride(projectId, mrIid, keySuffix), 'utf-8');
379
+ return worktreePath;
380
+ }
381
+ async function removeWorktree(worktreePath) {
382
+ // Best-effort cleanup by design (a failure here must not block or fail the review that
383
+ // already ran) — but a swallowed failure with zero visibility leaves an orphaned worktree
384
+ // in /tmp undetectable, so log it rather than discarding it silently.
385
+ await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: worktreePath, timeout: GIT_TIMEOUT_MS })
386
+ .catch((err) => {
387
+ console.error(`maestro-orchestrator: failed to remove worktree ${worktreePath}:`, err);
388
+ });
389
+ }
390
+ async function writeFailedReport(mrIid, body) {
391
+ const dir = join(homedir(), '.dsh', 'maestro', 'failed-reports');
392
+ await mkdir(dir, { recursive: true });
393
+ await writeFile(join(dir, `${mrIid}-${Date.now()}.md`), body, 'utf-8');
394
+ }
395
+ export function apply(ctx, config) {
396
+ // Resolved per review run from Settings so an agentTimeoutMs change takes
397
+ // effect without a plugin restart.
398
+ let effectiveAgentTimeoutMs = config.agentTimeoutMs;
399
+ async function runReviewer(worktreePath, payload, effective, reviewProfile, modelSelection) {
400
+ let capturedFindings = [];
401
+ let handle;
402
+ let reviewerContext;
403
+ const agentOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection());
404
+ try {
405
+ handle = await ctx.agents.create({
406
+ sessionId: SessionId(`maestro-reviewer-${payload.mrIid}-${Date.now()}`),
407
+ meta: { cwd: worktreePath ?? tmpdir() },
408
+ agentOptions,
409
+ setup: async (agentCtx) => {
410
+ reviewerContext = agentCtx;
411
+ installModelSelection(agentCtx, { current: agentOptions, assembled: undefined });
412
+ await mountAgentPreset(ctx.agentPresets, agentCtx, 'dsh-maestro-reviewer');
413
+ await agentCtx.plugin(GitlabClient, {
414
+ baseUrl: effective.gitlabBaseUrl,
415
+ projectId: payload.projectId,
416
+ mrIid: payload.mrIid,
417
+ token: effective.gitlabToken,
418
+ botUsername: effective.botUsername,
419
+ });
420
+ await agentCtx.plugin(ReviewFindingsTool, { onReport: (findings) => { capturedFindings = findings; } });
421
+ },
422
+ });
423
+ }
424
+ catch (err) {
425
+ throw new Error(`failed to create reviewer agent: ${err instanceof Error ? err.message : String(err)}`);
426
+ }
427
+ ctx.sessionTitle.rename(handle.agent.session, `Maestro Reviewer — MR !${payload.mrIid} (${payload.projectPath})`);
428
+ try {
429
+ const profileInstruction = reviewProfile === undefined
430
+ ? '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. '
431
+ : `Call maestro_load_review_profile with {"profile":"${reviewProfile}"} before examining code. `;
432
+ const scopePrompt = payload.scope.kind === 'discussion'
433
+ ? `${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.`
434
+ : `${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.`;
435
+ handle.agent.followup(createUserMessage({
436
+ content: [{ type: 'text', text: scopePrompt }],
437
+ source: { kind: 'user' },
438
+ }));
439
+ await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs);
440
+ if (reviewProfile !== undefined && (reviewerContext === undefined || loadedReviewProfile(reviewerContext) !== reviewProfile)) {
441
+ throw new Error(`reviewer did not successfully load the required ${reviewProfile} review skill profile; no findings were posted`);
442
+ }
443
+ // An inline command is a request to discuss one existing thread, not a
444
+ // license to fan out new threads across the MR. The report tool remains
445
+ // useful as a structured response channel, but its findings are folded
446
+ // into the one reply made by runReviewAndAudit below.
447
+ if (payload.scope.kind === 'discussion') {
448
+ const response = capturedFindings.length === 0
449
+ ? 'No actionable issue found for the requested line.'
450
+ : capturedFindings.map((finding) => finding.body).join('\n\n');
451
+ return { summary: response, failures: [] };
452
+ }
453
+ const failures = [];
454
+ let postedNew = 0;
455
+ let postedReplies = 0;
456
+ const findingPoster = {
457
+ baseUrl: effective.gitlabBaseUrl,
458
+ token: effective.gitlabToken,
459
+ projectId: payload.projectId,
460
+ mrIid: payload.mrIid,
461
+ };
462
+ for (const [index, finding] of capturedFindings.entries()) {
463
+ try {
464
+ await postReviewFindings([finding], findingPoster);
465
+ if (finding.status === 'new')
466
+ postedNew++;
467
+ else
468
+ postedReplies++;
469
+ }
470
+ catch (err) {
471
+ const locator = finding.status === 'new' ? `${finding.path}:${finding.line}` : finding.discussionId;
472
+ failures.push(`${locator}: ${err instanceof Error ? err.message : String(err)}`);
473
+ }
474
+ }
475
+ return { summary: `${postedNew} new inline comment(s), ${postedReplies} thread(s) updated.`, failures };
476
+ }
477
+ finally {
478
+ await handle.dispose();
479
+ }
480
+ }
481
+ async function runAuditor(worktreePath, payload, effective, modelSelection) {
482
+ let handle;
483
+ const agentOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection());
484
+ try {
485
+ handle = await ctx.agents.create({
486
+ sessionId: SessionId(`maestro-auditor-${payload.mrIid}-${Date.now()}`),
487
+ meta: { cwd: worktreePath },
488
+ agentOptions,
489
+ setup: async (agentCtx) => {
490
+ installModelSelection(agentCtx, { current: agentOptions, assembled: undefined });
491
+ await mountAgentPreset(ctx.agentPresets, agentCtx, 'dsh-maestro-auditor');
492
+ await agentCtx.plugin(GovardTool, { rootPath: worktreePath });
493
+ await agentCtx.plugin(GitlabClient, {
494
+ baseUrl: effective.gitlabBaseUrl,
495
+ projectId: payload.projectId,
496
+ mrIid: payload.mrIid,
497
+ token: effective.gitlabToken,
498
+ botUsername: effective.botUsername,
499
+ });
500
+ },
501
+ });
502
+ }
503
+ catch (err) {
504
+ throw new Error(`failed to create auditor agent: ${err instanceof Error ? err.message : String(err)}`);
505
+ }
506
+ ctx.sessionTitle.rename(handle.agent.session, `Maestro Auditor — MR !${payload.mrIid} (${payload.projectPath})`);
507
+ try {
508
+ 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.';
509
+ handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } }));
510
+ await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs);
511
+ const output = finalAssistantOutput(handle.agent.session.events) ?? [];
512
+ const text = output.map(block => ('text' in block ? block.text : '')).join('');
513
+ return `## Maestro Performance Audit\n\n${text}`;
514
+ }
515
+ finally {
516
+ await handle.dispose();
517
+ }
518
+ }
519
+ ctx.on('maestro/review-request', (payload) => {
520
+ void (async () => {
521
+ const userConfig = await loadUserConfig();
522
+ const effective = {
523
+ gitlabBaseUrl: userConfig.gitlabBaseUrl ?? config.gitlabBaseUrl,
524
+ gitlabToken: userConfig.gitlabToken ?? config.gitlabToken,
525
+ botUsername: userConfig.botUsername ?? config.botUsername,
526
+ projectMappings: userConfig.projectMappings ?? config.projectMappings,
527
+ };
528
+ if (typeof userConfig.agentTimeoutMs === 'number' && userConfig.agentTimeoutMs >= 1000) {
529
+ effectiveAgentTimeoutMs = userConfig.agentTimeoutMs;
530
+ }
531
+ // Best-effort housekeeping; a prune failure must never block a review.
532
+ if (typeof userConfig.reviewSessionRetentionDays === 'number' && userConfig.reviewSessionRetentionDays > 0) {
533
+ void pruneHistory(userConfig.reviewSessionRetentionDays).catch((err) => {
534
+ console.error('maestro-orchestrator: review history prune failed:', err);
535
+ });
536
+ }
537
+ const mapping = effective.projectMappings.find(m => m.projectPath === payload.projectPath);
538
+ // Unmapped reviewer assignments remain no-ops. Only an explicit mention
539
+ // may opt into the intentionally limited, diff-only fallback below.
540
+ if (mapping === undefined && payload.trigger !== 'mention')
541
+ return;
542
+ // A push only re-reviews an MR that already has a completed review;
543
+ // otherwise every newly opened MR would be reviewed twice.
544
+ if (payload.trigger === 'push' && !(await hasCompletedReview(payload.projectId, payload.mrIid)))
545
+ return;
546
+ const { gitlabToken } = effective;
547
+ if (gitlabToken === undefined) {
548
+ 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`);
549
+ return;
550
+ }
551
+ const historyId = `${payload.projectId}-${payload.mrIid}-${Date.now()}`;
552
+ await recordReviewStart({
553
+ id: historyId,
554
+ projectId: payload.projectId,
555
+ projectPath: payload.projectPath,
556
+ mrIid: payload.mrIid,
557
+ mode: payload.mode,
558
+ scope: payload.scope.kind,
559
+ trigger: payload.trigger,
560
+ startedAt: Date.now(),
561
+ });
562
+ const resolved = { ...effective, gitlabToken };
563
+ const fallbackSelection = ctx.get?.('agentDefaultModel')?.currentSelection()
564
+ ?? ctx.agentDefaultModel?.currentSelection()
565
+ ?? { provider: 'fallback', model: 'fallback' };
566
+ const reviewModelSelection = resolveReviewModel(userConfig, mapping, fallbackSelection);
567
+ // Opt-in Telegram digest; a delivery failure is logged and dropped.
568
+ const notifyTelegram = (status, summary) => {
569
+ if (userConfig.telegramReviewNotifications !== true)
570
+ return;
571
+ const notifier = ctx.get?.('maestroNotifier');
572
+ if (notifier === undefined)
573
+ return;
574
+ void notifier.send('telegram', { botToken: userConfig.telegramBotToken, chatId: userConfig.telegramChatId }, { text: reviewDigestText({ projectPath: payload.projectPath, mrIid: payload.mrIid, status, summary }) }).then((result) => {
575
+ if (!result.sent && result.reason === 'request-failed') {
576
+ console.error(`maestro-orchestrator: Telegram review notification for MR !${String(payload.mrIid)} failed to deliver`);
577
+ }
578
+ });
579
+ };
580
+ /** First line + bounded excerpt of a posted report, for the history log. */
581
+ const summarize = (body) => body === undefined ? undefined : body.replace(/\s+/g, ' ').trim().slice(0, 200);
582
+ // Award-emoji acknowledgements only make sense on the MR itself; inline
583
+ // discussion reviews answer in-thread instead.
584
+ const signals = payload.scope.kind === 'mr'
585
+ ? createReviewSignals({ baseUrl: resolved.gitlabBaseUrl, token: resolved.gitlabToken, projectId: payload.projectId, mrIid: payload.mrIid, botUsername: resolved.botUsername })
586
+ : undefined;
587
+ await signals?.start();
588
+ const postComment = async (body) => {
589
+ const response = await fetch(`${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/notes`, {
590
+ method: 'POST',
591
+ headers: { 'PRIVATE-TOKEN': resolved.gitlabToken, 'Content-Type': 'application/json' },
592
+ body: JSON.stringify({ body }),
593
+ });
594
+ if (!response.ok)
595
+ throw new Error(`GitLab API error ${response.status}: ${await response.text()}`);
596
+ };
597
+ const replyToDiscussion = async (discussionId, body) => {
598
+ const response = await fetch(`${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/discussions/${encodeURIComponent(discussionId)}/notes`, {
599
+ method: 'POST',
600
+ headers: { 'PRIVATE-TOKEN': resolved.gitlabToken, 'Content-Type': 'application/json' },
601
+ body: JSON.stringify({ body }),
602
+ });
603
+ if (!response.ok)
604
+ throw new Error(`GitLab API error ${response.status}: ${await response.text()}`);
605
+ };
606
+ try {
607
+ if (mapping === undefined) {
608
+ if (payload.mode === 'deep') {
609
+ await declineUnmappedDeepReview(payload, { postComment, replyToDiscussion, writeFailedReport });
610
+ await recordReviewFinish(historyId, { status: 'completed', summary: 'Deep review declined (unmapped project)' });
611
+ notifyTelegram('completed', 'Deep review declined (unmapped project)');
612
+ await signals?.finish('completed');
613
+ return;
614
+ }
615
+ const diffBody = await runDiffOnlyReview(payload, {
616
+ runReviewer: (p) => runReviewer(undefined, p, resolved, undefined, reviewModelSelection),
617
+ postComment,
618
+ replyToDiscussion,
619
+ writeFailedReport,
620
+ });
621
+ await recordReviewFinish(historyId, { status: 'completed', summary: summarize(diffBody) });
622
+ notifyTelegram('completed', summarize(diffBody));
623
+ await signals?.finish('completed');
624
+ return;
625
+ }
626
+ const fullBody = await runReviewAndAudit(payload, {
627
+ localRepoPath: mapping.localRepoPath,
628
+ ensureWorktree,
629
+ removeWorktree,
630
+ runReviewer: (worktreePath, p) => runReviewer(worktreePath, p, resolved, mapping.reviewProfile ?? 'magento2', reviewModelSelection),
631
+ runAuditor: (worktreePath, p) => runAuditor(worktreePath, p, resolved, reviewModelSelection),
632
+ postComment,
633
+ replyToDiscussion,
634
+ writeFailedReport,
635
+ });
636
+ await recordReviewFinish(historyId, { status: 'completed', summary: summarize(fullBody) });
637
+ notifyTelegram('completed', summarize(fullBody));
638
+ await signals?.finish('completed');
639
+ }
640
+ catch (err) {
641
+ const message = err instanceof Error ? err.message : String(err);
642
+ await recordReviewFinish(historyId, { status: 'failed', error: message }).catch(() => { });
643
+ notifyTelegram('failed', message);
644
+ await signals?.finish('failed');
645
+ throw err;
646
+ }
647
+ })().catch((err) => {
648
+ // Worktree creation, agent creation, and fallback delivery all run from
649
+ // an event callback, so surface failures rather than leaking a rejected
650
+ // fire-and-forget Promise.
651
+ console.error(`maestro-orchestrator: review run failed for MR !${String(payload.mrIid)}:`, err);
652
+ });
653
+ });
654
+ }
655
+ //# sourceMappingURL=orchestrator.js.map