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