@ddtcorex/dsh-maestro-review 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/cordis.patch.yml +18 -0
- package/lib/ci-clone.d.ts +21 -0
- package/lib/ci-clone.d.ts.map +1 -0
- package/lib/ci-clone.js +35 -0
- package/lib/ci-clone.js.map +1 -0
- package/lib/ci-coexist.d.ts +9 -0
- package/lib/ci-coexist.d.ts.map +1 -0
- package/lib/ci-coexist.js +36 -0
- package/lib/ci-coexist.js.map +1 -0
- package/lib/config-store.d.ts +13 -6
- package/lib/config-store.d.ts.map +1 -1
- package/lib/config-store.js.map +1 -1
- package/lib/events.d.ts +23 -0
- package/lib/events.d.ts.map +1 -1
- package/lib/gitlab-auth.d.ts +11 -0
- package/lib/gitlab-auth.d.ts.map +1 -0
- package/lib/gitlab-auth.js +15 -0
- package/lib/gitlab-auth.js.map +1 -0
- package/lib/gitlab-client.d.ts +57 -0
- package/lib/gitlab-client.d.ts.map +1 -1
- package/lib/gitlab-client.js +120 -17
- package/lib/gitlab-client.js.map +1 -1
- package/lib/govard-audit-lint-tool.d.ts +81 -0
- package/lib/govard-audit-lint-tool.d.ts.map +1 -1
- package/lib/govard-audit-lint-tool.js +121 -36
- package/lib/govard-audit-lint-tool.js.map +1 -1
- package/lib/govard-tool.js +1 -1
- package/lib/govard-tool.js.map +1 -1
- package/lib/incremental.d.ts.map +1 -1
- package/lib/incremental.js +3 -2
- package/lib/incremental.js.map +1 -1
- package/lib/orchestrator.d.ts +137 -5
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +439 -70
- package/lib/orchestrator.js.map +1 -1
- package/lib/providers/ci-trigger.d.ts +43 -0
- package/lib/providers/ci-trigger.d.ts.map +1 -0
- package/lib/providers/ci-trigger.js +146 -0
- package/lib/providers/ci-trigger.js.map +1 -0
- package/lib/providers/gitlab.d.ts.map +1 -1
- package/lib/providers/gitlab.js +2 -1
- package/lib/providers/gitlab.js.map +1 -1
- package/lib/review-findings-tool.d.ts +5 -0
- package/lib/review-findings-tool.d.ts.map +1 -1
- package/lib/review-findings-tool.js +3 -1
- package/lib/review-findings-tool.js.map +1 -1
- package/lib/review-intake.d.ts.map +1 -1
- package/lib/review-intake.js +5 -1
- package/lib/review-intake.js.map +1 -1
- package/lib/review-marker.d.ts +17 -0
- package/lib/review-marker.d.ts.map +1 -0
- package/lib/review-marker.js +23 -0
- package/lib/review-marker.js.map +1 -0
- package/lib/review-signals.d.ts.map +1 -1
- package/lib/review-signals.js +4 -8
- package/lib/review-signals.js.map +1 -1
- package/lib/settings-rpc.js +1 -1
- package/lib/settings-rpc.js.map +1 -1
- package/package.json +14 -9
- package/profiles/reviewer-ci/cordis.patch.yml +61 -0
- package/profiles/reviewer-ci/package.json +19 -0
- package/profiles/reviewer-ci/pnpm-lock.yaml +62 -0
- package/profiles/reviewer-ci/pnpm-workspace.yaml +12 -0
- package/src/host/ci-clone.ts +54 -0
- package/src/host/ci-coexist.ts +43 -0
- package/src/host/config-store.ts +14 -1
- package/src/host/events.ts +25 -0
- package/src/host/gitlab-auth.ts +13 -0
- package/src/host/gitlab-client.ts +142 -17
- package/src/host/govard-audit-lint-tool.ts +144 -31
- package/src/host/govard-tool.ts +1 -1
- package/src/host/incremental.ts +3 -2
- package/src/host/orchestrator.ts +462 -50
- package/src/host/providers/ci-trigger.ts +164 -0
- package/src/host/providers/gitlab.ts +2 -1
- package/src/host/review-findings-tool.ts +9 -1
- package/src/host/review-intake.ts +5 -1
- package/src/host/review-marker.ts +25 -0
- package/src/host/review-signals.ts +4 -3
- package/src/host/settings-rpc.ts +1 -1
- package/templates/reviewer-project.gitlab-ci.yml +90 -0
- package/templates/source-project.gitlab-ci.yml +42 -0
package/src/host/orchestrator.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, writeFile } from 'node:fs/promises'
|
|
1
|
+
import { mkdir, writeFile, symlink, stat, lstat } from 'node:fs/promises'
|
|
2
2
|
import { execFile } from 'node:child_process'
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import { promisify } from 'node:util'
|
|
@@ -29,9 +29,9 @@ import * as ScopeSplitTool from './scope-split-tool.js'
|
|
|
29
29
|
import * as GovardAuditLintTool from './govard-audit-lint-tool.js'
|
|
30
30
|
import * as PerfLogStatsTool from './perf-log-stats-tool.js'
|
|
31
31
|
import * as ReviewToolPolicy from './tool-policy.js'
|
|
32
|
-
import type { ReviewFinding } from './review-findings-tool.js'
|
|
33
|
-
import type { ReviewRequest } from './events.js'
|
|
34
|
-
import { loadUserConfig, type MaestroUserConfig, type ReviewModelSelection } from './config-store.js'
|
|
32
|
+
import type { ReviewFinding, FindingSeverity } from './review-findings-tool.js'
|
|
33
|
+
import type { ReviewRequest, ReviewResult } from './events.js'
|
|
34
|
+
import { loadUserConfig, type MaestroUserConfig, type ProjectMapping, type ReviewModelSelection } from './config-store.js'
|
|
35
35
|
import { hasCompletedReview, lastCompletedReview, pruneHistory, recordReviewFinish, recordReviewStart } from './review-history.js'
|
|
36
36
|
import { buildIncrementalBlock, fetchCompare, fetchMrDetailHeadSha } from './incremental.js'
|
|
37
37
|
import { createReviewSignals } from './review-signals.js'
|
|
@@ -40,6 +40,9 @@ import { loadedReviewProfile, type ReviewSkillProfile } from './skills-tool.js'
|
|
|
40
40
|
import type { ReviewProvider } from './providers/interface.js'
|
|
41
41
|
import { gitlabProvider } from './providers/gitlab.js'
|
|
42
42
|
import './events.js'
|
|
43
|
+
import { gitlabAuthHeaders } from './gitlab-auth.js'
|
|
44
|
+
import { reviewMarker, resolveFlow, type ReviewFlow } from './review-marker.js'
|
|
45
|
+
import { cloneSourceRepo, defaultRun } from './ci-clone.js'
|
|
43
46
|
|
|
44
47
|
// Provider-aware wrapper — orchestrator can run reviews via any ReviewProvider.
|
|
45
48
|
// This keeps the GitLab-specific flow intact while allowing Phase C to add GitHub/Jira without modifying core logic.
|
|
@@ -58,6 +61,62 @@ export { gitlabProvider }
|
|
|
58
61
|
const execFileAsync = promisify(execFile)
|
|
59
62
|
const GIT_TIMEOUT_MS = 60_000
|
|
60
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Finding severity, assigned by the reviewer. Display order is fixed
|
|
66
|
+
* (blocking first) wherever counts are rendered.
|
|
67
|
+
*/
|
|
68
|
+
export const SEVERITY_ORDER: readonly FindingSeverity[] = ['blocking', 'major', 'minor', 'nit'] as const
|
|
69
|
+
|
|
70
|
+
const SEVERITY_LABEL: Record<FindingSeverity, string> = {
|
|
71
|
+
blocking: '🔴 Blocking',
|
|
72
|
+
major: '🟡 Major',
|
|
73
|
+
minor: '🔵 Minor',
|
|
74
|
+
nit: '⚪ Nit',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Missing or unknown severities degrade to `minor` — never drop a finding. */
|
|
78
|
+
export function normalizeFindingSeverity(severity: unknown): FindingSeverity {
|
|
79
|
+
return severity === 'blocking' || severity === 'major' || severity === 'minor' || severity === 'nit'
|
|
80
|
+
? severity
|
|
81
|
+
: 'minor'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function severityPrefix(severity: unknown): string {
|
|
85
|
+
return SEVERITY_LABEL[normalizeFindingSeverity(severity)]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Count findings per severity; zero counts are omitted. */
|
|
89
|
+
export function countFindingSeverities(findings: ReviewFinding[]): Partial<Record<FindingSeverity, number>> {
|
|
90
|
+
const counts: Partial<Record<FindingSeverity, number>> = {}
|
|
91
|
+
for (const finding of findings) {
|
|
92
|
+
const level = normalizeFindingSeverity(finding.severity)
|
|
93
|
+
counts[level] = (counts[level] ?? 0) + 1
|
|
94
|
+
}
|
|
95
|
+
return counts
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ReviewerScopePromptOpts {
|
|
99
|
+
scopeKind: 'discussion' | 'full'
|
|
100
|
+
discussionId?: string
|
|
101
|
+
path?: string
|
|
102
|
+
line?: number
|
|
103
|
+
mode?: string
|
|
104
|
+
profileInstruction: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reviewer scope prompt. Static analysis is mandatory: reviewers skipped
|
|
109
|
+
* govard_audit_lint for whole rounds (no lint signal at all), so the prompt
|
|
110
|
+
* requires at least one call before report_review_findings. Pure for testing.
|
|
111
|
+
*/
|
|
112
|
+
export function buildReviewerScopePrompt(opts: ReviewerScopePromptOpts): string {
|
|
113
|
+
const lintRule = 'LINT RULE: you MUST call govard_audit_lint at least once (scope "diff"; the MR base default is already wired, no base arg needed) before report_review_findings. A review with no lint call is incomplete.'
|
|
114
|
+
if (opts.scopeKind === 'discussion') {
|
|
115
|
+
return `${opts.profileInstruction}Review only the requested inline discussion ${opts.discussionId} at ${opts.path}:${opts.line}. Do not review unrelated files or start a broad audit. Call gitlab_get_mr_diff, then gitlab_get_file_diff for the file under review, then call report_review_findings exactly once when done. ${lintRule}`
|
|
116
|
+
}
|
|
117
|
+
return `${opts.profileInstruction}Review this merge request (${opts.mode} mode). Call gitlab_list_own_review_threads and gitlab_get_mr_diff first, then gitlab_get_file_diff per file you inspect (inline results never spill), then call report_review_findings exactly once when done. ${lintRule} DEDUP RULE: when a finding matches the substance of an existing own thread (same file and same underlying issue, even if worded differently — including resolved threads, whose reply reopens them), report it as {status: "reply", discussionId} instead of posting a new thread. Use status "new" only for issues with no matching thread.`
|
|
118
|
+
}
|
|
119
|
+
|
|
61
120
|
/**
|
|
62
121
|
* Build a user-friendly GitLab Markdown comment for a completed review.
|
|
63
122
|
* Shared design language with `reviewDigestText` (Telegram HTML) — same
|
|
@@ -72,10 +131,11 @@ export function buildReviewComment(opts: {
|
|
|
72
131
|
profile?: string
|
|
73
132
|
summary: string
|
|
74
133
|
failures: string[]
|
|
75
|
-
findings?: { newCount: number; replyCount: number }
|
|
134
|
+
findings?: { newCount: number; replyCount: number; severityCounts?: Partial<Record<FindingSeverity, number>> }
|
|
76
135
|
durationMs?: number
|
|
77
136
|
isDiffOnly?: boolean
|
|
78
137
|
isDiscussion?: boolean
|
|
138
|
+
marker?: { sha: string; flow: ReviewFlow }
|
|
79
139
|
}): string {
|
|
80
140
|
const mrUrl = `${opts.gitlabBaseUrl.replace(/\/$/, '')}/${opts.projectPath}/-/merge_requests/${opts.mrIid}`
|
|
81
141
|
const title = opts.isDiffOnly === true ? '## 🤖 Maestro Review — Diff-only' : '## 🤖 Maestro Review'
|
|
@@ -97,6 +157,12 @@ export function buildReviewComment(opts: {
|
|
|
97
157
|
const parts: string[] = []
|
|
98
158
|
if (opts.findings.newCount > 0) parts.push(`💬 ${opts.findings.newCount} new`)
|
|
99
159
|
if (opts.findings.replyCount > 0) parts.push(`🔁 ${opts.findings.replyCount} updated`)
|
|
160
|
+
if (opts.findings.severityCounts !== undefined) {
|
|
161
|
+
for (const level of SEVERITY_ORDER) {
|
|
162
|
+
const count = opts.findings.severityCounts[level] ?? 0
|
|
163
|
+
if (count > 0) parts.push(`${SEVERITY_LABEL[level].split(' ')[0]} ${count} ${level}`)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
100
166
|
if (parts.length === 0) parts.push('no inline findings')
|
|
101
167
|
return `\n\n**Findings:** ${parts.join(' · ')}`
|
|
102
168
|
})()
|
|
@@ -105,10 +171,11 @@ export function buildReviewComment(opts: {
|
|
|
105
171
|
? `\n\n<details>\n<summary>⚠️ Failed to post (${opts.failures.length})</summary>\n\n${opts.failures.map((f) => `- \`${f}\``).join('\n')}\n\n</details>`
|
|
106
172
|
: ''
|
|
107
173
|
const footer = `\n\n---\n\n<sub>Generated by Maestro · [View MR →](${mrUrl})</sub>`
|
|
108
|
-
|
|
174
|
+
const markerLine = opts.marker === undefined ? '' : `\n\n${reviewMarker(opts.marker.sha, opts.marker.flow)}`
|
|
175
|
+
return `${title}\n\n${metaLine}\n\n${scopeNote}${opts.summary}${findingsLine}${failuresBlock}${footer}${markerLine}`
|
|
109
176
|
}
|
|
110
177
|
|
|
111
|
-
export function buildNotStartedComment(opts: { gitlabBaseUrl: string; projectPath: string; mrIid: number }): string {
|
|
178
|
+
export function buildNotStartedComment(opts: { gitlabBaseUrl: string; projectPath: string; mrIid: number; marker?: { sha: string; flow: ReviewFlow } }): string {
|
|
112
179
|
const mrUrl = `${opts.gitlabBaseUrl.replace(/\/$/, '')}/${opts.projectPath}/-/merge_requests/${opts.mrIid}`
|
|
113
180
|
return [
|
|
114
181
|
'## 🤖 Maestro Review — Not started',
|
|
@@ -121,6 +188,7 @@ export function buildNotStartedComment(opts: { gitlabBaseUrl: string; projectPat
|
|
|
121
188
|
`---`,
|
|
122
189
|
'',
|
|
123
190
|
`<sub>Generated by Maestro · [View MR →](${mrUrl})</sub>`,
|
|
191
|
+
...(opts.marker === undefined ? [] : [``, `${reviewMarker(opts.marker.sha, opts.marker.flow)}`]),
|
|
124
192
|
].join('\n')
|
|
125
193
|
}
|
|
126
194
|
|
|
@@ -168,14 +236,17 @@ export function getTurnErrorMessage(handle: unknown): string | undefined {
|
|
|
168
236
|
|
|
169
237
|
/**
|
|
170
238
|
* Resolve the model to use for an automated review. Priority: per-project
|
|
171
|
-
* override > global reviewModel >
|
|
239
|
+
* override > global reviewModel (Maestro Settings) > row-config reviewModel
|
|
240
|
+
* (lets a headless profile such as reviewer-ci pin a model from env vars,
|
|
241
|
+
* where there is no Settings UI) > DSH default (`fallback`).
|
|
172
242
|
*/
|
|
173
243
|
export function resolveReviewModel(
|
|
174
244
|
userConfig: MaestroUserConfig,
|
|
175
|
-
mapping: { reviewModel?: ReviewModelSelection | null
|
|
245
|
+
mapping: { reviewModel?: ReviewModelSelection | null; projectPath?: string } | undefined,
|
|
176
246
|
fallback: ModelSelection,
|
|
247
|
+
rowSelection?: ReviewModelSelection | null,
|
|
177
248
|
): ModelSelection {
|
|
178
|
-
const raw = (mapping?.reviewModel as ReviewModelSelection | null | undefined) ?? userConfig.reviewModel
|
|
249
|
+
const raw = (mapping?.reviewModel as ReviewModelSelection | null | undefined) ?? userConfig.reviewModel ?? rowSelection
|
|
179
250
|
if (raw === undefined || raw === null) return fallback
|
|
180
251
|
return {
|
|
181
252
|
provider: raw.provider,
|
|
@@ -184,6 +255,17 @@ export function resolveReviewModel(
|
|
|
184
255
|
}
|
|
185
256
|
}
|
|
186
257
|
|
|
258
|
+
/** Effective auto-trigger flags: per-project row overrides global, unset inherits (design §4). */
|
|
259
|
+
export function resolveReviewTriggers(
|
|
260
|
+
userConfig: MaestroUserConfig,
|
|
261
|
+
mapping?: ProjectMapping,
|
|
262
|
+
): { onPush: boolean; onAssign: boolean } {
|
|
263
|
+
return {
|
|
264
|
+
onPush: mapping?.rereviewOnPush ?? userConfig.autoRereviewOnPush ?? false,
|
|
265
|
+
onAssign: mapping?.reviewOnAssign ?? userConfig.autoReviewOnAssign ?? true,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
187
269
|
/** Compose a newly-created agent from the preset service owned by the root context. */
|
|
188
270
|
export async function mountAgentPreset(
|
|
189
271
|
agentPresets: { mount(agentCtx: Context, id: string): Promise<unknown> | unknown },
|
|
@@ -204,6 +286,19 @@ export interface Config {
|
|
|
204
286
|
botUsername: string
|
|
205
287
|
/** Hard ceiling on one automated agent's turn. */
|
|
206
288
|
agentTimeoutMs: number
|
|
289
|
+
/**
|
|
290
|
+
* Deployment-level model pin, below Maestro Settings in precedence. Lets a
|
|
291
|
+
* headless profile (reviewer-ci) select the review model from env vars —
|
|
292
|
+
* e.g. REVIEW_MODEL_PROVIDER/REVIEW_MODEL — where no Settings UI exists.
|
|
293
|
+
*
|
|
294
|
+
* Deliberately absent from the zod schema below: schemastery object schemas
|
|
295
|
+
* always descend into inner fields (probed — even `.required(false)` still
|
|
296
|
+
* throws on undefined), so declaring it would fail every boot without the
|
|
297
|
+
* vars. Unknown keys pass validation through (same precedent as mapping-level
|
|
298
|
+
* reviewModel, which the projectMappings schema doesn't declare either),
|
|
299
|
+
* and resolveReviewModel reads it at review time.
|
|
300
|
+
*/
|
|
301
|
+
reviewModel?: ReviewModelSelection | null
|
|
207
302
|
}
|
|
208
303
|
|
|
209
304
|
export const DEFAULT_AGENT_TIMEOUT_MS = 20 * 60_000
|
|
@@ -223,6 +318,7 @@ export const Config: z<Config> = z.object({
|
|
|
223
318
|
export interface ReviewOutcome {
|
|
224
319
|
summary: string
|
|
225
320
|
failures: string[]
|
|
321
|
+
severityCounts?: Partial<Record<FindingSeverity, number>>
|
|
226
322
|
}
|
|
227
323
|
|
|
228
324
|
/**
|
|
@@ -371,13 +467,15 @@ function normalizedDiffPath(path: string): string {
|
|
|
371
467
|
export async function postReviewFindings(findings: ReviewFinding[], config: GitlabFindingPoster): Promise<void> {
|
|
372
468
|
const fetcher = config.fetcher ?? fetch
|
|
373
469
|
const apiBase = `${config.baseUrl}/api/v4/projects/${config.projectId}/merge_requests/${config.mrIid}`
|
|
374
|
-
const headers = {
|
|
470
|
+
const headers = { ...gitlabAuthHeaders(config.token), 'Content-Type': 'application/json' }
|
|
375
471
|
for (const finding of findings) {
|
|
376
472
|
let response: Response
|
|
473
|
+
const label = severityPrefix(finding.severity)
|
|
474
|
+
const body = finding.body.startsWith(label) ? finding.body : `${label}\n\n${finding.body}`
|
|
377
475
|
if (finding.status === 'reply') {
|
|
378
476
|
if (finding.discussionId === undefined) throw new Error('reply finding missing discussionId')
|
|
379
477
|
response = await fetcher(`${apiBase}/discussions/${encodeURIComponent(finding.discussionId)}/notes`, {
|
|
380
|
-
method: 'POST', headers, body: JSON.stringify({ body
|
|
478
|
+
method: 'POST', headers, body: JSON.stringify({ body }),
|
|
381
479
|
})
|
|
382
480
|
} else {
|
|
383
481
|
if (finding.path === undefined || finding.line === undefined) throw new Error('new finding missing path or line')
|
|
@@ -395,12 +493,12 @@ export async function postReviewFindings(findings: ReviewFinding[], config: Gitl
|
|
|
395
493
|
// Line not in diff — fallback to MR note so finding is not silently lost.
|
|
396
494
|
response = await fetcher(`${apiBase}/notes`, {
|
|
397
495
|
method: 'POST', headers,
|
|
398
|
-
body: JSON.stringify({ body: `**Inline fallback — \`${finding.path}:${finding.line}\` line is not in current MR diff**\n\n${
|
|
496
|
+
body: JSON.stringify({ body: `**Inline fallback — \`${finding.path}:${finding.line}\` line is not in current MR diff**\n\n${body}` }),
|
|
399
497
|
})
|
|
400
498
|
} else {
|
|
401
499
|
response = await fetcher(`${apiBase}/discussions`, {
|
|
402
500
|
method: 'POST', headers,
|
|
403
|
-
body: JSON.stringify({ body
|
|
501
|
+
body: JSON.stringify({ body, position: {
|
|
404
502
|
position_type: 'text', ...snapshot.diffRefs, old_path: change.old_path, new_path: change.new_path,
|
|
405
503
|
...linePosition.oldLine === undefined ? {} : { old_line: linePosition.oldLine },
|
|
406
504
|
...linePosition.newLine === undefined ? {} : { new_line: linePosition.newLine },
|
|
@@ -453,7 +551,91 @@ function assertSafeId(value: number, label: string): void {
|
|
|
453
551
|
}
|
|
454
552
|
}
|
|
455
553
|
|
|
554
|
+
/**
|
|
555
|
+
* Read an agent session's transcript for the auditor's final output across
|
|
556
|
+
* host/session API skew: hosts built from the harness checkout expose
|
|
557
|
+
* `ownEvents()`/`snapshotEvents()` with no `.events` getter, while older
|
|
558
|
+
* packaged `@deepseek-ai/dsh-session` builds expose only the `.events`
|
|
559
|
+
* getter. `ownEvents()` (child-owned suffix, no fork prefix) matches
|
|
560
|
+
* `finalAssistantOutput`'s documented input best, so it wins when present.
|
|
561
|
+
* Returns `[]` — never throws — when no event source exists.
|
|
562
|
+
*/
|
|
563
|
+
export function auditorOutputFromSession(session: unknown) {
|
|
564
|
+
const candidate = session as {
|
|
565
|
+
ownEvents?: unknown
|
|
566
|
+
snapshotEvents?: unknown
|
|
567
|
+
events?: unknown
|
|
568
|
+
} | null | undefined
|
|
569
|
+
let events: unknown
|
|
570
|
+
if (typeof candidate?.ownEvents === 'function') {
|
|
571
|
+
events = (candidate.ownEvents as () => unknown)()
|
|
572
|
+
} else if (typeof candidate?.snapshotEvents === 'function') {
|
|
573
|
+
events = (candidate.snapshotEvents as () => unknown)()
|
|
574
|
+
} else {
|
|
575
|
+
events = candidate?.events
|
|
576
|
+
}
|
|
577
|
+
if (!Array.isArray(events)) return []
|
|
578
|
+
return finalAssistantOutput(events as Parameters<typeof finalAssistantOutput>[0]) ?? []
|
|
579
|
+
}
|
|
580
|
+
|
|
456
581
|
/** Full review + performance audit; resolves to the comment body that was posted. */
|
|
582
|
+
/** Marker for CI-posted comments; webhook payloads carry no headSha and stay marker-free. */
|
|
583
|
+
function commentMarker(payload: ReviewRequest): { sha: string; flow: ReviewFlow } | undefined {
|
|
584
|
+
if (payload.headSha === undefined) return undefined
|
|
585
|
+
return { sha: payload.headSha, flow: resolveFlow(payload.mode) }
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** CI-deep decision: deep mode + CI env + head SHA (spec §3). Mapping is checked by the caller. */
|
|
589
|
+
export function shouldCiDeepReview(payload: ReviewRequest): boolean {
|
|
590
|
+
return payload.mode === 'deep'
|
|
591
|
+
&& payload.headSha !== undefined
|
|
592
|
+
&& process.env.SOURCE_PROJECT_ID !== undefined
|
|
593
|
+
&& process.env.MR_IID !== undefined
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** CI quick-with-profile decision: quick mode + non-generic profile + CI env
|
|
597
|
+
* + head SHA. Quick + generic/unset stays diff-only (no clone cost); a real
|
|
598
|
+
* profile opts into the clone branch reviewer-only (no auditor — quick never
|
|
599
|
+
* audits, see shouldAudit in runReviewAndAudit). Mapping is checked by the caller. */
|
|
600
|
+
export function shouldCiQuickProfileReview(payload: ReviewRequest): boolean {
|
|
601
|
+
return payload.mode === 'quick'
|
|
602
|
+
&& payload.reviewProfile !== undefined
|
|
603
|
+
&& payload.reviewProfile !== 'generic'
|
|
604
|
+
&& payload.headSha !== undefined
|
|
605
|
+
&& process.env.SOURCE_PROJECT_ID !== undefined
|
|
606
|
+
&& process.env.MR_IID !== undefined
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Auditor degrade for CI (no runtime env): a govard throw becomes reviewer-only, never a failed review. */
|
|
610
|
+
export function withAuditorDegrade(
|
|
611
|
+
runAuditor: (worktreePath: string, payload: ReviewRequest) => Promise<string>,
|
|
612
|
+
): (worktreePath: string, payload: ReviewRequest) => Promise<string> {
|
|
613
|
+
return async (worktreePath, payload) => {
|
|
614
|
+
try {
|
|
615
|
+
return await runAuditor(worktreePath, payload)
|
|
616
|
+
} catch {
|
|
617
|
+
return 'Auditor skipped — no runtime environment in CI (reviewer-only review).'
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Auditor instruction. The mapped flow keeps the full environment + test-suite
|
|
624
|
+
* workflow; the CI flow has no runtime, so the prompt countermands the
|
|
625
|
+
* auditor preset's environment steps and drops the Environment & Test Suite
|
|
626
|
+
* section entirely instead of reporting it "blocked".
|
|
627
|
+
*/
|
|
628
|
+
export function buildAuditorPrompt(opts: { staticOnly: boolean }): string {
|
|
629
|
+
if (!opts.staticOnly) {
|
|
630
|
+
return 'Audit this merge request\'s performance: bring up the environment, run the test suite, look for regressions, then write a Markdown report and tear the environment down.'
|
|
631
|
+
}
|
|
632
|
+
return 'Audit this merge request\'s performance from the static diff and checked-out code only. '
|
|
633
|
+
+ 'No runtime environment exists in this container: ignore the auditor preset\'s environment steps '
|
|
634
|
+
+ '(do not bring anything up, do not run the test suite, do not tear anything down). '
|
|
635
|
+
+ 'Look for regressions by static analysis (diff, dependencies, query/shape risks), then write a Markdown report '
|
|
636
|
+
+ 'and OMIT the Environment & Test Suite section entirely — never report it as blocked.'
|
|
637
|
+
}
|
|
638
|
+
|
|
457
639
|
export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndAuditDeps): Promise<string> {
|
|
458
640
|
assertSafeId(payload.projectId, 'projectId')
|
|
459
641
|
assertSafeId(payload.mrIid, 'mrIid')
|
|
@@ -471,7 +653,7 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
|
|
|
471
653
|
])
|
|
472
654
|
const labels = ['Reviewer', 'Auditor']
|
|
473
655
|
if (settled[0].status === 'fulfilled') {
|
|
474
|
-
const { summary, failures } = settled[0].value
|
|
656
|
+
const { summary, failures, severityCounts } = settled[0].value
|
|
475
657
|
// Try to parse findings counts from summary like "2 new inline comment(s), 1 thread(s) updated."
|
|
476
658
|
const summaryText = summary ?? ''
|
|
477
659
|
const newMatch = /(\d+)\s+new inline/.exec(summaryText)
|
|
@@ -488,7 +670,8 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
|
|
|
488
670
|
profile: (deps as unknown as { reviewProfile?: string }).reviewProfile,
|
|
489
671
|
summary: summaryText,
|
|
490
672
|
failures,
|
|
491
|
-
findings: { newCount, replyCount },
|
|
673
|
+
findings: { newCount, replyCount, severityCounts },
|
|
674
|
+
marker: commentMarker(payload),
|
|
492
675
|
})
|
|
493
676
|
: `## 🤖 Maestro Review\n\n**\`${payload.projectPath}\` !${payload.mrIid}** · ✅ Completed · \`${payload.mode}\`${(deps as unknown as { reviewProfile?: string }).reviewProfile !== undefined ? ` · \`${(deps as unknown as { reviewProfile: string }).reviewProfile}\`` : ''}\n\n${summaryText}${failures.length > 0 ? `\n\n<details>\n<summary>⚠️ Failed to post (${failures.length})</summary>\n\n${failures.map((f) => `- \`${f}\``).join('\n')}\n\n</details>` : ''}`
|
|
494
677
|
sections.push(richOpts)
|
|
@@ -505,7 +688,8 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
|
|
|
505
688
|
try {
|
|
506
689
|
if (payload.scope.kind === 'discussion') await deps.replyToDiscussion(payload.scope.discussionId, body)
|
|
507
690
|
else await deps.postComment(body)
|
|
508
|
-
} catch {
|
|
691
|
+
} catch (err) {
|
|
692
|
+
console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
509
693
|
await deps.writeFailedReport(payload.mrIid, body)
|
|
510
694
|
}
|
|
511
695
|
return body
|
|
@@ -540,13 +724,15 @@ export async function runDiffOnlyReview(payload: ReviewRequest, deps: DiffOnlyRe
|
|
|
540
724
|
summary: summary ?? '',
|
|
541
725
|
failures,
|
|
542
726
|
isDiffOnly: true,
|
|
727
|
+
marker: commentMarker(payload),
|
|
543
728
|
})
|
|
544
729
|
: `## 🤖 Maestro Review — Diff-only\n\n> **Scope:** Diff-only — reviewed the GitLab diff without a local checkout, Magento environment, static analysis, or tests. For a full review, add this project in Settings → Maestro and mention again.\n\n${summary}${failures.length > 0 ? `\n\n<details>\n<summary>⚠️ Failed to post (${failures.length})</summary>\n\n${failures.map((f) => `- \`${f}\``).join('\n')}\n\n</details>` : ''}`
|
|
545
730
|
const body = diffBody
|
|
546
731
|
try {
|
|
547
732
|
if (payload.scope.kind === 'discussion') await deps.replyToDiscussion(payload.scope.discussionId, body)
|
|
548
733
|
else await deps.postComment(body)
|
|
549
|
-
} catch {
|
|
734
|
+
} catch (err) {
|
|
735
|
+
console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
550
736
|
await deps.writeFailedReport(payload.mrIid, body)
|
|
551
737
|
}
|
|
552
738
|
return body
|
|
@@ -565,13 +751,14 @@ export async function declineUnmappedDeepReview(payload: ReviewRequest, deps: Re
|
|
|
565
751
|
const depsWithUrl = deps as unknown as { gitlabBaseUrl?: string; projectPath?: string }
|
|
566
752
|
const baseUrl = depsWithUrl.gitlabBaseUrl
|
|
567
753
|
const body = baseUrl !== undefined
|
|
568
|
-
? buildNotStartedComment({ gitlabBaseUrl: baseUrl, projectPath: payload.projectPath, mrIid: payload.mrIid })
|
|
754
|
+
? buildNotStartedComment({ gitlabBaseUrl: baseUrl, projectPath: payload.projectPath, mrIid: payload.mrIid, marker: commentMarker(payload) })
|
|
569
755
|
: '## 🤖 Maestro Review — Not started\n\n**`' + payload.projectPath + '` !' + payload.mrIid + '** · ⏸️ Not started\n\n> Deep review requires a project mapping with a local checkout and Magento environment.\n> Add this project in **Settings → Maestro**, then mention the reviewer again.'
|
|
570
756
|
try {
|
|
571
757
|
try {
|
|
572
758
|
if (payload.scope.kind === 'discussion') await deps.replyToDiscussion(payload.scope.discussionId, body)
|
|
573
759
|
else await deps.postComment(body)
|
|
574
|
-
} catch {
|
|
760
|
+
} catch (err) {
|
|
761
|
+
console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
575
762
|
await deps.writeFailedReport(payload.mrIid, body)
|
|
576
763
|
}
|
|
577
764
|
} finally {
|
|
@@ -619,7 +806,34 @@ export function isBranchNotFoundError(err: unknown): boolean {
|
|
|
619
806
|
*/
|
|
620
807
|
export function govardWorktreeOverride(projectId: number, mrIid: number, keySuffix?: string): string {
|
|
621
808
|
const name = `maestro-mr-${projectId}-${mrIid}${keySuffix === undefined ? '' : `-${keySuffix}`}`
|
|
622
|
-
|
|
809
|
+
// Xdebug stays off in review envs: the auditor runs no coverage, and an
|
|
810
|
+
// enabled Xdebug trips govard's lint perf-tax guard (exit 1, 0 findings).
|
|
811
|
+
return `project_name: ${name}\ndomain: ${name}.test\nstack:\n features:\n xdebug: false\n`
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Fetch the MR's diff base SHA for govard diff-scope runs. Best-effort:
|
|
816
|
+
* returns undefined (the lint tool then fail-fasts with guidance) rather
|
|
817
|
+
* than failing the review when GitLab is unreachable.
|
|
818
|
+
*/
|
|
819
|
+
export async function fetchMrBaseSha(
|
|
820
|
+
baseUrl: string,
|
|
821
|
+
token: string,
|
|
822
|
+
projectId: number,
|
|
823
|
+
mrIid: number,
|
|
824
|
+
fetcher: typeof fetch = fetch,
|
|
825
|
+
): Promise<string | undefined> {
|
|
826
|
+
try {
|
|
827
|
+
const response = await fetcher(
|
|
828
|
+
`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}`,
|
|
829
|
+
{ headers: gitlabAuthHeaders(token) },
|
|
830
|
+
)
|
|
831
|
+
if (!response.ok) return undefined
|
|
832
|
+
const mr = await response.json() as { diff_refs?: { base_sha?: string } }
|
|
833
|
+
return mr.diff_refs?.base_sha
|
|
834
|
+
} catch {
|
|
835
|
+
return undefined
|
|
836
|
+
}
|
|
623
837
|
}
|
|
624
838
|
|
|
625
839
|
export async function ensureWorktree(localRepoPath: string, sourceBranch: string, projectId: number, mrIid: number, keySuffix?: string): Promise<string> {
|
|
@@ -643,9 +857,134 @@ export async function ensureWorktree(localRepoPath: string, sourceBranch: string
|
|
|
643
857
|
.catch(() => {})
|
|
644
858
|
await execFileAsync('git', ['worktree', 'add', '--', worktreePath, `origin/${sourceBranch}`], { cwd: localRepoPath, timeout: GIT_TIMEOUT_MS })
|
|
645
859
|
await writeFile(join(worktreePath, '.govard.local.yml'), govardWorktreeOverride(projectId, mrIid, keySuffix), 'utf-8')
|
|
860
|
+
await linkVendorIntoWorktree(localRepoPath, worktreePath)
|
|
861
|
+
await writeContainerVendorOverride(localRepoPath, worktreePath)
|
|
646
862
|
return worktreePath
|
|
647
863
|
}
|
|
648
864
|
|
|
865
|
+
/**
|
|
866
|
+
* A vendor dir only counts as installed dependencies when the composer
|
|
867
|
+
* autoloader is a real file. Magento tracks `vendor/.htaccess`, so every
|
|
868
|
+
* worktree has a vendor/ stub — that stub must not pass for real deps, and
|
|
869
|
+
* must not block the container bind that provides them.
|
|
870
|
+
*/
|
|
871
|
+
export async function vendorHasAutoload(dir: string): Promise<boolean> {
|
|
872
|
+
try {
|
|
873
|
+
return (await stat(join(dir, 'vendor', 'autoload.php'))).isFile()
|
|
874
|
+
} catch {
|
|
875
|
+
return false
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Share the primary checkout's `vendor/` (and `app/etc/env.php` when the
|
|
881
|
+
* auditor needs a database) into the review worktree via symlinks, so
|
|
882
|
+
* phpunit/static analysis run against real dependencies instead of falling
|
|
883
|
+
* back to static-only. Links point INTO the worktree only — the primary
|
|
884
|
+
* checkout is never written to — and `git worktree remove --force` deletes
|
|
885
|
+
* the links along with the worktree. Missing sources, or targets that
|
|
886
|
+
* already exist (e.g. Magento's tracked `vendor/.htaccess` stub), are
|
|
887
|
+
* skipped quietly: the review still runs, the auditor discloses it.
|
|
888
|
+
*/
|
|
889
|
+
export async function linkVendorIntoWorktree(localRepoPath: string, worktreePath: string): Promise<string[]> {
|
|
890
|
+
const linked: string[] = []
|
|
891
|
+
const candidates: Array<{ source: string; target: string; dir: boolean; needsAutoload: boolean }> = [
|
|
892
|
+
{ source: join(localRepoPath, 'vendor'), target: join(worktreePath, 'vendor'), dir: true, needsAutoload: true },
|
|
893
|
+
{ source: join(localRepoPath, 'app', 'etc', 'env.php'), target: join(worktreePath, 'app', 'etc', 'env.php'), dir: false, needsAutoload: false },
|
|
894
|
+
]
|
|
895
|
+
let advertised = false
|
|
896
|
+
for (const { source, target, dir, needsAutoload } of candidates) {
|
|
897
|
+
let isDir = false
|
|
898
|
+
try {
|
|
899
|
+
isDir = (await stat(source)).isDirectory()
|
|
900
|
+
if (dir !== isDir) continue
|
|
901
|
+
} catch {
|
|
902
|
+
continue
|
|
903
|
+
}
|
|
904
|
+
if (needsAutoload && !(await vendorHasAutoload(localRepoPath))) continue
|
|
905
|
+
if ((await lstat(target).catch(() => undefined)) !== undefined) {
|
|
906
|
+
console.error(`maestro-orchestrator: link target exists, skipping ${target}`)
|
|
907
|
+
continue
|
|
908
|
+
}
|
|
909
|
+
try {
|
|
910
|
+
if (!advertised) {
|
|
911
|
+
const sha = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: localRepoPath, timeout: GIT_TIMEOUT_MS })
|
|
912
|
+
.then(({ stdout }) => stdout.trim()).catch(() => 'unknown')
|
|
913
|
+
console.error(`maestro-orchestrator: linking vendor from ${localRepoPath} @ ${sha} into ${worktreePath}`)
|
|
914
|
+
advertised = true
|
|
915
|
+
}
|
|
916
|
+
if (!dir) await mkdir(join(worktreePath, 'app', 'etc'), { recursive: true })
|
|
917
|
+
await symlink(source, target, dir ? 'dir' : 'file')
|
|
918
|
+
linked.push(target)
|
|
919
|
+
} catch (err) {
|
|
920
|
+
console.error(`maestro-orchestrator: failed to link ${source} into worktree:`, err)
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (!advertised) {
|
|
924
|
+
console.error(`maestro-orchestrator: no vendor/ in ${localRepoPath} — worktree ${worktreePath} runs without shared dependencies`)
|
|
925
|
+
}
|
|
926
|
+
return linked
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** Container path of the project root inside govard services. */
|
|
930
|
+
const GOVARD_CONTAINER_WORKDIR = '/var/www/html'
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Render a compose override that bind-mounts the primary checkout's vendor/
|
|
934
|
+
* read-only into the php service. A host-side symlink alone dangles inside
|
|
935
|
+
* containers (absolute host path), so container tools (phpunit) need this
|
|
936
|
+
* bind to see real dependencies.
|
|
937
|
+
*
|
|
938
|
+
* The project mount (`.`) MUST be repeated first: govard merges overrides
|
|
939
|
+
* with MergeMap, which REPLACES lists instead of appending — an override
|
|
940
|
+
* carrying only the vendor bind would wipe `.:<workdir>` and leave the
|
|
941
|
+
* container docroot holding nothing but vendor/. Relative `.` resolves
|
|
942
|
+
* against the project root because govard passes --project-directory.
|
|
943
|
+
*
|
|
944
|
+
* Only the always-present `php` service is targeted: `php-debug` drops out
|
|
945
|
+
* of the rendered base when xdebug is off, and a volumes-only override
|
|
946
|
+
* entry would recreate it as a hollow service that fails compose validation.
|
|
947
|
+
*/
|
|
948
|
+
export function buildVendorOverrideYaml(vendorHostPath: string, envHostPath?: string, containerWorkDir: string = GOVARD_CONTAINER_WORKDIR): string {
|
|
949
|
+
const volumes = [
|
|
950
|
+
`.:${containerWorkDir}`,
|
|
951
|
+
`${vendorHostPath}:${containerWorkDir}/vendor:ro`,
|
|
952
|
+
...(envHostPath !== undefined ? [`${envHostPath}:${containerWorkDir}/app/etc/env.php:ro`] : []),
|
|
953
|
+
]
|
|
954
|
+
const service = ` volumes:\n${volumes.map((v) => ` - ${v}\n`).join('')}`
|
|
955
|
+
return `services:\n php:\n${service}`
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Drop the vendor bind override into the worktree (merged by govard via
|
|
960
|
+
* `.govard/docker-compose.override.yml`). Binds only when the primary
|
|
961
|
+
* checkout carries installed deps and the worktree does not — never shadow
|
|
962
|
+
* real deps with a possibly stale bind. Returns the written path, or
|
|
963
|
+
* undefined when skipped.
|
|
964
|
+
*/
|
|
965
|
+
export async function writeContainerVendorOverride(localRepoPath: string, worktreePath: string): Promise<string | undefined> {
|
|
966
|
+
const vendorHostPath = join(localRepoPath, 'vendor')
|
|
967
|
+
if (!(await vendorHasAutoload(localRepoPath))) return undefined
|
|
968
|
+
// Our own host-side symlink still needs the bind (it dangles in-container);
|
|
969
|
+
// only a real installed worktree vendor makes the bind redundant.
|
|
970
|
+
const wtLink = await lstat(join(worktreePath, 'vendor')).catch(() => undefined)
|
|
971
|
+
if (wtLink?.isSymbolicLink() !== true && await vendorHasAutoload(worktreePath)) return undefined
|
|
972
|
+
const overridePath = join(worktreePath, '.govard', 'docker-compose.override.yml')
|
|
973
|
+
await mkdir(join(worktreePath, '.govard'), { recursive: true })
|
|
974
|
+
// A linked env.php dangles in-container like the vendor symlink did — bind
|
|
975
|
+
// the real file over it when the primary checkout carries one.
|
|
976
|
+
const envHostPath = join(localRepoPath, 'app', 'etc', 'env.php')
|
|
977
|
+
let envBind: string | undefined
|
|
978
|
+
try {
|
|
979
|
+
if ((await stat(envHostPath)).isFile()) envBind = envHostPath
|
|
980
|
+
} catch {
|
|
981
|
+
envBind = undefined
|
|
982
|
+
}
|
|
983
|
+
await writeFile(overridePath, buildVendorOverrideYaml(vendorHostPath, envBind), 'utf-8')
|
|
984
|
+
console.error(`maestro-orchestrator: container vendor bind ${vendorHostPath} -> ${GOVARD_CONTAINER_WORKDIR}/vendor (ro) for ${worktreePath}`)
|
|
985
|
+
return overridePath
|
|
986
|
+
}
|
|
987
|
+
|
|
649
988
|
async function removeWorktree(worktreePath: string): Promise<void> {
|
|
650
989
|
// Best-effort cleanup by design (a failure here must not block or fail the review that
|
|
651
990
|
// already ran) — but a swallowed failure with zero visibility leaves an orphaned worktree
|
|
@@ -667,6 +1006,9 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
667
1006
|
// effect without a plugin restart.
|
|
668
1007
|
let effectiveAgentTimeoutMs = config.agentTimeoutMs
|
|
669
1008
|
async function runReviewer(worktreePath: string | undefined, payload: ReviewRequest, effective: { gitlabBaseUrl: string; gitlabToken: string; botUsername: string }, reviewProfile?: ReviewSkillProfile, modelSelection?: ModelSelection, incrementalBlock?: string): Promise<ReviewOutcome> {
|
|
1009
|
+
// MR base SHA feeds govard diff-scope runs; undefined degrades to the
|
|
1010
|
+
// tool's fail-fast guidance instead of a wasted govard invocation.
|
|
1011
|
+
const lintDefaultBase = await fetchMrBaseSha(effective.gitlabBaseUrl, effective.gitlabToken, payload.projectId, payload.mrIid)
|
|
670
1012
|
const primaryOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection())
|
|
671
1013
|
const fallbackOptions: ModelSelection = { provider: primaryOptions.provider, model: primaryOptions.model }
|
|
672
1014
|
let lastHandle: AgentHandle | undefined
|
|
@@ -701,7 +1043,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
701
1043
|
await agentCtx.plugin(ModuleCheckTool, { rootPath: worktreePath })
|
|
702
1044
|
await agentCtx.plugin(PhtmlEscapeScanTool, { rootPath: worktreePath })
|
|
703
1045
|
await agentCtx.plugin(ScopeSplitTool, { rootPath: worktreePath })
|
|
704
|
-
await agentCtx.plugin(GovardAuditLintTool, { rootPath: worktreePath })
|
|
1046
|
+
await agentCtx.plugin(GovardAuditLintTool, { rootPath: worktreePath, defaultBase: lintDefaultBase, allowXdebug: true })
|
|
705
1047
|
await agentCtx.plugin(PerfLogStatsTool, { rootPath: worktreePath })
|
|
706
1048
|
}
|
|
707
1049
|
},
|
|
@@ -717,8 +1059,8 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
717
1059
|
? '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. '
|
|
718
1060
|
: `Call maestro_load_review_profile with {"profile":"${reviewProfile}"} before examining code. `
|
|
719
1061
|
let scopePrompt = payload.scope.kind === 'discussion'
|
|
720
|
-
?
|
|
721
|
-
:
|
|
1062
|
+
? buildReviewerScopePrompt({ scopeKind: 'discussion', discussionId: payload.scope.discussionId, path: payload.scope.path, line: payload.scope.line, profileInstruction })
|
|
1063
|
+
: buildReviewerScopePrompt({ scopeKind: 'full', mode: payload.mode, profileInstruction })
|
|
722
1064
|
if (incrementalBlock !== undefined) scopePrompt = `${incrementalBlock}\n\n${scopePrompt}`
|
|
723
1065
|
handle.agent.followup(createUserMessage({
|
|
724
1066
|
content: [{ type: 'text', text: scopePrompt }],
|
|
@@ -759,7 +1101,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
759
1101
|
failures.push(`${locator}: ${err instanceof Error ? err.message : String(err)}`)
|
|
760
1102
|
}
|
|
761
1103
|
}
|
|
762
|
-
return { summary: `${postedNew} new inline comment(s), ${postedReplies} thread(s) updated.`, failures }
|
|
1104
|
+
return { summary: `${postedNew} new inline comment(s), ${postedReplies} thread(s) updated.`, failures, severityCounts: countFindingSeverities(capturedFindings) }
|
|
763
1105
|
} finally {
|
|
764
1106
|
await handle.dispose()
|
|
765
1107
|
}
|
|
@@ -792,7 +1134,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
792
1134
|
}
|
|
793
1135
|
}
|
|
794
1136
|
|
|
795
|
-
async function runAuditor(worktreePath: string, payload: ReviewRequest, effective: { gitlabBaseUrl: string; gitlabToken: string; botUsername: string }, modelSelection?: ModelSelection): Promise<string> {
|
|
1137
|
+
async function runAuditor(worktreePath: string, payload: ReviewRequest, effective: { gitlabBaseUrl: string; gitlabToken: string; botUsername: string }, modelSelection?: ModelSelection, opts?: { staticOnly?: boolean }): Promise<string> {
|
|
796
1138
|
let handle: AgentHandle
|
|
797
1139
|
const agentOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection())
|
|
798
1140
|
try {
|
|
@@ -819,10 +1161,10 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
819
1161
|
}
|
|
820
1162
|
ctx.sessionTitle.rename(handle.agent.session, `Maestro Auditor — MR !${payload.mrIid} (${payload.projectPath})`)
|
|
821
1163
|
try {
|
|
822
|
-
const prompt =
|
|
1164
|
+
const prompt = buildAuditorPrompt({ staticOnly: opts?.staticOnly === true })
|
|
823
1165
|
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } }))
|
|
824
1166
|
await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
|
|
825
|
-
const output =
|
|
1167
|
+
const output = auditorOutputFromSession(handle.agent.session)
|
|
826
1168
|
const text = output.map(block => ('text' in block ? block.text : '')).join('')
|
|
827
1169
|
return `## Maestro Performance Audit\n\n${text}`
|
|
828
1170
|
} finally {
|
|
@@ -830,8 +1172,9 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
830
1172
|
}
|
|
831
1173
|
}
|
|
832
1174
|
|
|
833
|
-
|
|
834
|
-
|
|
1175
|
+
async function runReview(payload: ReviewRequest): Promise<ReviewResult> {
|
|
1176
|
+
const t0 = Date.now()
|
|
1177
|
+
try {
|
|
835
1178
|
const userConfig = await loadUserConfig()
|
|
836
1179
|
const effective = {
|
|
837
1180
|
gitlabBaseUrl: userConfig.gitlabBaseUrl ?? config.gitlabBaseUrl,
|
|
@@ -849,16 +1192,37 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
849
1192
|
})
|
|
850
1193
|
}
|
|
851
1194
|
const mapping = effective.projectMappings.find(m => m.projectPath === payload.projectPath)
|
|
1195
|
+
// CI-deep (spec §3): unmapped deep review with CI env + head SHA skips
|
|
1196
|
+
// both the decline and the diff-only fallback and runs the clone branch
|
|
1197
|
+
// below, which shares the mapped path's completion tail.
|
|
1198
|
+
const ciDeep = mapping === undefined && payload.mode === 'deep' && shouldCiDeepReview(payload)
|
|
1199
|
+
// CI quick-with-profile: unmapped quick + non-generic profile + CI env
|
|
1200
|
+
// skips the diff-only fallback and joins the clone branch below, which
|
|
1201
|
+
// runs reviewer-only (quick never audits).
|
|
1202
|
+
const ciQuickProfile = mapping === undefined && shouldCiQuickProfileReview(payload)
|
|
852
1203
|
// Unmapped reviewer assignments remain no-ops. Only an explicit mention
|
|
853
1204
|
// may opt into the intentionally limited, diff-only fallback below.
|
|
854
|
-
if (mapping === undefined && payload.trigger !== 'mention')
|
|
1205
|
+
if (mapping === undefined && payload.trigger !== 'mention') {
|
|
1206
|
+
return { ok: true, failures: [], durationMs: Date.now() - t0 }
|
|
1207
|
+
}
|
|
1208
|
+
const triggers = resolveReviewTriggers(userConfig, mapping)
|
|
1209
|
+
// Gated-off auto-triggers stay fully silent: no history, no signals, no comment.
|
|
1210
|
+
if (payload.trigger === 'reviewer-assignment' && !triggers.onAssign) {
|
|
1211
|
+
return { ok: true, failures: [], durationMs: Date.now() - t0 }
|
|
1212
|
+
}
|
|
1213
|
+
if (payload.trigger === 'push' && !triggers.onPush) {
|
|
1214
|
+
return { ok: true, failures: [], durationMs: Date.now() - t0 }
|
|
1215
|
+
}
|
|
855
1216
|
// A push only re-reviews an MR that already has a completed review;
|
|
856
1217
|
// otherwise every newly opened MR would be reviewed twice.
|
|
857
|
-
if (payload.trigger === 'push' && !(await hasCompletedReview(payload.projectId, payload.mrIid)))
|
|
1218
|
+
if (payload.trigger === 'push' && !(await hasCompletedReview(payload.projectId, payload.mrIid))) {
|
|
1219
|
+
return { ok: true, failures: [], durationMs: Date.now() - t0 }
|
|
1220
|
+
}
|
|
858
1221
|
const { gitlabToken } = effective
|
|
859
1222
|
if (gitlabToken === undefined) {
|
|
860
|
-
|
|
861
|
-
|
|
1223
|
+
const message = `MR !${String(payload.mrIid)} for project ${payload.projectPath} has no GitLab token — set one in Maestro Settings or MAESTRO_GITLAB_TOKEN`
|
|
1224
|
+
console.error(`maestro-orchestrator: ${message}`)
|
|
1225
|
+
return { ok: false, failures: [message], durationMs: Date.now() - t0 }
|
|
862
1226
|
}
|
|
863
1227
|
const resolved = { ...effective, gitlabToken }
|
|
864
1228
|
const historyId = `${payload.projectId}-${payload.mrIid}-${Date.now()}`
|
|
@@ -889,7 +1253,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
889
1253
|
const fallbackSelection: ModelSelection = (ctx.get?.('agentDefaultModel') as { currentSelection(): ModelSelection } | undefined)?.currentSelection()
|
|
890
1254
|
?? (ctx as unknown as { agentDefaultModel?: { currentSelection(): ModelSelection } }).agentDefaultModel?.currentSelection()
|
|
891
1255
|
?? { provider: 'fallback', model: 'fallback' }
|
|
892
|
-
const reviewModelSelection = resolveReviewModel(userConfig, mapping, fallbackSelection)
|
|
1256
|
+
const reviewModelSelection = resolveReviewModel(userConfig, mapping, fallbackSelection, config.reviewModel)
|
|
893
1257
|
// Opt-in Telegram digest; a delivery failure is logged and dropped.
|
|
894
1258
|
const reviewStartMsOuter = Date.now()
|
|
895
1259
|
const notifyTelegram = (
|
|
@@ -947,7 +1311,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
947
1311
|
`${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/notes`,
|
|
948
1312
|
{
|
|
949
1313
|
method: 'POST',
|
|
950
|
-
headers: {
|
|
1314
|
+
headers: { ...gitlabAuthHeaders(resolved.gitlabToken), 'Content-Type': 'application/json' },
|
|
951
1315
|
body: JSON.stringify({ body }),
|
|
952
1316
|
},
|
|
953
1317
|
)
|
|
@@ -958,23 +1322,25 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
958
1322
|
`${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/discussions/${encodeURIComponent(discussionId)}/notes`,
|
|
959
1323
|
{
|
|
960
1324
|
method: 'POST',
|
|
961
|
-
headers: {
|
|
1325
|
+
headers: { ...gitlabAuthHeaders(resolved.gitlabToken), 'Content-Type': 'application/json' },
|
|
962
1326
|
body: JSON.stringify({ body }),
|
|
963
1327
|
},
|
|
964
1328
|
)
|
|
965
1329
|
if (!response.ok) throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
966
1330
|
}
|
|
967
1331
|
try {
|
|
968
|
-
if (mapping === undefined) {
|
|
969
|
-
|
|
1332
|
+
if (mapping === undefined && !ciDeep && !ciQuickProfile) {
|
|
1333
|
+
// CI-deep (spec §3) skips the decline and falls through to the clone
|
|
1334
|
+
// branch below; every other unmapped deep review still declines here.
|
|
1335
|
+
if (payload.mode === 'deep' && !shouldCiDeepReview(payload)) {
|
|
970
1336
|
await declineUnmappedDeepReview(payload, { postComment, replyToDiscussion, writeFailedReport, gitlabBaseUrl: resolved.gitlabBaseUrl } as unknown as ReviewCommentDeps)
|
|
971
1337
|
await recordReviewFinish(historyId, { status: 'completed', summary: 'Deep review declined (unmapped project)' })
|
|
972
1338
|
notifyTelegram('completed', 'Deep review declined (unmapped project)')
|
|
973
1339
|
await signals?.finish('completed')
|
|
974
|
-
return
|
|
1340
|
+
return { ok: true, summary: 'Deep review declined (unmapped project)', failures: [], durationMs: Date.now() - t0 }
|
|
975
1341
|
}
|
|
976
1342
|
const diffBody = await runDiffOnlyReview(payload, {
|
|
977
|
-
runReviewer: (p) => runReviewer(undefined, p, resolved,
|
|
1343
|
+
runReviewer: (p) => runReviewer(undefined, p, resolved, payload.reviewProfile, reviewModelSelection, incrementalBlock),
|
|
978
1344
|
postComment,
|
|
979
1345
|
replyToDiscussion,
|
|
980
1346
|
writeFailedReport,
|
|
@@ -983,9 +1349,47 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
983
1349
|
await recordReviewFinish(historyId, { status: 'completed', summary: summarize(diffBody) })
|
|
984
1350
|
notifyTelegram('completed', summarize(diffBody))
|
|
985
1351
|
await signals?.finish('completed')
|
|
986
|
-
return
|
|
1352
|
+
return { ok: true, summary: summarize(diffBody), failures: [], durationMs: Date.now() - t0 }
|
|
987
1353
|
}
|
|
988
1354
|
let fullBody: string
|
|
1355
|
+
if (ciDeep || ciQuickProfile) {
|
|
1356
|
+
// CI-deep (mapping is always undefined here — the unmapped block above
|
|
1357
|
+
// returned for every other case): clone the source at the head SHA,
|
|
1358
|
+
// map it in memory, and reuse the mapped machinery reviewer-only.
|
|
1359
|
+
// CI quick-with-profile joins the same branch; runReviewAndAudit skips
|
|
1360
|
+
// the auditor for quick (shouldAudit), so it stays reviewer-only.
|
|
1361
|
+
// No govard/vendor linking (ciEnsureWorktree is plain fetch + worktree).
|
|
1362
|
+
const ciHost = process.env.GITLAB_HOST?.trim() || new URL(resolved.gitlabBaseUrl).hostname
|
|
1363
|
+
const cloneDir = join('/tmp', `maestro-ci-src-${payload.projectId}-${payload.mrIid}`)
|
|
1364
|
+
await cloneSourceRepo({ fetcher: fetch, run: defaultRun, host: ciHost, projectId: payload.projectId, sourceBranch: payload.sourceBranch, headSha: payload.headSha as string, token: resolved.gitlabToken, dir: cloneDir })
|
|
1365
|
+
try {
|
|
1366
|
+
const ciEnsureWorktree = async (repoPath: string, branch: string, pid: number, iid: number, suffix?: string): Promise<string> => {
|
|
1367
|
+
assertSafeBranchName(branch)
|
|
1368
|
+
const wt = join('/tmp', `maestro-mr-${pid}-${iid}${suffix === undefined ? '' : `-${suffix}`}`)
|
|
1369
|
+
await defaultRun('git', ['fetch', '--depth', '50', 'origin', branch], repoPath)
|
|
1370
|
+
await defaultRun('git', ['worktree', 'remove', '--force', wt], repoPath).catch(() => {})
|
|
1371
|
+
await defaultRun('git', ['worktree', 'add', '--detach', '--', wt, payload.headSha as string], repoPath)
|
|
1372
|
+
return wt
|
|
1373
|
+
}
|
|
1374
|
+
fullBody = await runReviewAndAudit(payload, {
|
|
1375
|
+
localRepoPath: cloneDir,
|
|
1376
|
+
ensureWorktree: ciEnsureWorktree,
|
|
1377
|
+
removeWorktree,
|
|
1378
|
+
runReviewer: (worktreePath, p) => runReviewer(worktreePath, p, resolved, payload.reviewProfile ?? 'generic', reviewModelSelection, incrementalBlock),
|
|
1379
|
+
runAuditor: withAuditorDegrade((worktreePath, p) => runAuditor(worktreePath, p, resolved, reviewModelSelection, { staticOnly: true })),
|
|
1380
|
+
postComment,
|
|
1381
|
+
replyToDiscussion,
|
|
1382
|
+
writeFailedReport,
|
|
1383
|
+
gitlabBaseUrl: resolved.gitlabBaseUrl,
|
|
1384
|
+
reviewProfile: payload.reviewProfile ?? 'generic',
|
|
1385
|
+
} as unknown as ReviewAndAuditDeps)
|
|
1386
|
+
} finally {
|
|
1387
|
+
await defaultRun('rm', ['-rf', cloneDir]).catch(() => {})
|
|
1388
|
+
}
|
|
1389
|
+
} else {
|
|
1390
|
+
// Unreachable when mapping is undefined: the unmapped block returned
|
|
1391
|
+
// for every non-CI-deep case, and ciDeep took the branch above.
|
|
1392
|
+
if (mapping === undefined) throw new Error('unreachable: unmapped review without CI-deep decision')
|
|
989
1393
|
try {
|
|
990
1394
|
fullBody = await runReviewAndAudit(payload, {
|
|
991
1395
|
localRepoPath: mapping.localRepoPath,
|
|
@@ -1003,7 +1407,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1003
1407
|
const isBranchNotFound = (err as { code?: string })?.code === 'BRANCH_NOT_FOUND' || isBranchNotFoundError(err)
|
|
1004
1408
|
if (isBranchNotFound) {
|
|
1005
1409
|
const diffBody = await runDiffOnlyReview(payload, {
|
|
1006
|
-
runReviewer: (p) => runReviewer(undefined, p, resolved,
|
|
1410
|
+
runReviewer: (p) => runReviewer(undefined, p, resolved, payload.reviewProfile, reviewModelSelection, incrementalBlock),
|
|
1007
1411
|
postComment,
|
|
1008
1412
|
replyToDiscussion,
|
|
1009
1413
|
writeFailedReport,
|
|
@@ -1014,25 +1418,33 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1014
1418
|
await recordReviewFinish(historyId, { status: 'completed', summary: branchNote })
|
|
1015
1419
|
notifyTelegram('completed', branchNote)
|
|
1016
1420
|
await signals?.finish('completed')
|
|
1017
|
-
return
|
|
1421
|
+
return { ok: true, summary: branchNote, failures: [], durationMs: Date.now() - t0 }
|
|
1018
1422
|
}
|
|
1019
1423
|
throw err
|
|
1020
1424
|
}
|
|
1425
|
+
}
|
|
1021
1426
|
await recordReviewFinish(historyId, { status: 'completed', summary: summarize(fullBody) })
|
|
1022
1427
|
notifyTelegram('completed', summarize(fullBody))
|
|
1023
1428
|
await signals?.finish('completed')
|
|
1429
|
+
return { ok: true, summary: summarize(fullBody), failures: [], durationMs: Date.now() - t0 }
|
|
1024
1430
|
} catch (err) {
|
|
1025
1431
|
const message = err instanceof Error ? err.message : String(err)
|
|
1026
1432
|
await recordReviewFinish(historyId, { status: 'failed', error: message }).catch(() => {})
|
|
1027
1433
|
notifyTelegram('failed', message)
|
|
1028
1434
|
await signals?.finish('failed')
|
|
1029
|
-
|
|
1435
|
+
return { ok: false, summary: undefined, failures: [message], durationMs: Date.now() - t0 }
|
|
1030
1436
|
}
|
|
1031
|
-
}
|
|
1437
|
+
} catch (err) {
|
|
1032
1438
|
// Worktree creation, agent creation, and fallback delivery all run from
|
|
1033
|
-
//
|
|
1034
|
-
// fire-and-forget
|
|
1439
|
+
// this function — surface failures as a result instead of an unhandled
|
|
1440
|
+
// rejection, so both the fire-and-forget webhook path and an awaiting
|
|
1441
|
+
// CI caller observe the same outcome.
|
|
1442
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1035
1443
|
console.error(`maestro-orchestrator: review run failed for MR !${String(payload.mrIid)}:`, err)
|
|
1036
|
-
|
|
1037
|
-
|
|
1444
|
+
return { ok: false, summary: undefined, failures: [message], durationMs: Date.now() - t0 }
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
ctx.on('maestro/review-request', (payload) => { void runReview(payload) })
|
|
1449
|
+
ctx.provide('reviewRunner', runReview)
|
|
1038
1450
|
}
|