@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.
- package/README.md +53 -0
- package/client/settings-card.tsx +4 -0
- package/lib/config-store.d.ts +53 -0
- package/lib/config-store.d.ts.map +1 -0
- package/lib/config-store.js +60 -0
- package/lib/config-store.js.map +1 -0
- package/lib/events.d.ts +27 -0
- package/lib/events.d.ts.map +1 -0
- package/lib/events.js +2 -0
- package/lib/events.js.map +1 -0
- package/lib/gitlab-client.d.ts +14 -0
- package/lib/gitlab-client.d.ts.map +1 -0
- package/lib/gitlab-client.js +146 -0
- package/lib/gitlab-client.js.map +1 -0
- package/lib/gitlab-webhook.d.ts +17 -0
- package/lib/gitlab-webhook.d.ts.map +1 -0
- package/lib/gitlab-webhook.js +144 -0
- package/lib/gitlab-webhook.js.map +1 -0
- package/lib/govard-tool.d.ts +11 -0
- package/lib/govard-tool.d.ts.map +1 -0
- package/lib/govard-tool.js +95 -0
- package/lib/govard-tool.js.map +1 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +15 -0
- package/lib/index.js.map +1 -0
- package/lib/notify.d.ts +23 -0
- package/lib/notify.d.ts.map +1 -0
- package/lib/notify.js +10 -0
- package/lib/notify.js.map +1 -0
- package/lib/orchestrator.d.ts +144 -0
- package/lib/orchestrator.d.ts.map +1 -0
- package/lib/orchestrator.js +655 -0
- package/lib/orchestrator.js.map +1 -0
- package/lib/pin-store.d.ts +5 -0
- package/lib/pin-store.d.ts.map +1 -0
- package/lib/pin-store.js +5 -0
- package/lib/pin-store.js.map +1 -0
- package/lib/providers/github.stub.d.ts +3 -0
- package/lib/providers/github.stub.d.ts.map +1 -0
- package/lib/providers/github.stub.js +10 -0
- package/lib/providers/github.stub.js.map +1 -0
- package/lib/providers/gitlab.d.ts +13 -0
- package/lib/providers/gitlab.d.ts.map +1 -0
- package/lib/providers/gitlab.js +173 -0
- package/lib/providers/gitlab.js.map +1 -0
- package/lib/providers/interface.d.ts +12 -0
- package/lib/providers/interface.d.ts.map +1 -0
- package/lib/providers/interface.js +2 -0
- package/lib/providers/interface.js.map +1 -0
- package/lib/review-findings-tool.d.ts +15 -0
- package/lib/review-findings-tool.d.ts.map +1 -0
- package/lib/review-findings-tool.js +37 -0
- package/lib/review-findings-tool.js.map +1 -0
- package/lib/review-history.d.ts +35 -0
- package/lib/review-history.d.ts.map +1 -0
- package/lib/review-history.js +87 -0
- package/lib/review-history.js.map +1 -0
- package/lib/review-intake.d.ts +7 -0
- package/lib/review-intake.d.ts.map +1 -0
- package/lib/review-intake.js +80 -0
- package/lib/review-intake.js.map +1 -0
- package/lib/review-signals.d.ts +17 -0
- package/lib/review-signals.d.ts.map +1 -0
- package/lib/review-signals.js +49 -0
- package/lib/review-signals.js.map +1 -0
- package/lib/secure-compare.d.ts +7 -0
- package/lib/secure-compare.d.ts.map +1 -0
- package/lib/secure-compare.js +14 -0
- package/lib/secure-compare.js.map +1 -0
- package/lib/settings-rpc.d.ts +22 -0
- package/lib/settings-rpc.d.ts.map +1 -0
- package/lib/settings-rpc.js +252 -0
- package/lib/settings-rpc.js.map +1 -0
- package/lib/skills-tool.d.ts +29 -0
- package/lib/skills-tool.d.ts.map +1 -0
- package/lib/skills-tool.js +160 -0
- package/lib/skills-tool.js.map +1 -0
- package/lib/workspace-tool.d.ts +10 -0
- package/lib/workspace-tool.d.ts.map +1 -0
- package/lib/workspace-tool.js +86 -0
- package/lib/workspace-tool.js.map +1 -0
- package/package.json +53 -0
- package/presets/maestro-auditor/agent.cordis.yml +10 -0
- package/presets/maestro-auditor/preset.yml +2 -0
- package/presets/maestro-coder/agent.cordis.yml +223 -0
- package/presets/maestro-coder/preset.yml +2 -0
- package/presets/maestro-reviewer/agent.cordis.yml +14 -0
- package/presets/maestro-reviewer/preset.yml +2 -0
- package/src/augment.d.ts +47 -0
- package/src/config-store.ts +109 -0
- package/src/events.ts +25 -0
- package/src/gitlab-client.ts +186 -0
- package/src/gitlab-webhook.ts +166 -0
- package/src/govard-tool.ts +114 -0
- package/src/index.ts +15 -0
- package/src/notify.ts +30 -0
- package/src/orchestrator.ts +740 -0
- package/src/pin-store.ts +4 -0
- package/src/providers/github.stub.ts +11 -0
- package/src/providers/gitlab.ts +190 -0
- package/src/providers/interface.ts +2 -0
- package/src/review-findings-tool.ts +51 -0
- package/src/review-history.ts +112 -0
- package/src/review-intake.ts +86 -0
- package/src/review-signals.ts +52 -0
- package/src/secure-compare.ts +13 -0
- package/src/settings-rpc.ts +249 -0
- package/src/skills-tool.ts +193 -0
- 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
|
+
}
|