@ddtcorex/dsh-maestro-review 0.3.1 → 0.4.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/lib/config-store.d.ts +0 -2
- package/lib/config-store.d.ts.map +1 -1
- package/lib/config-store.js.map +1 -1
- package/lib/gitlab-client.d.ts +57 -0
- package/lib/gitlab-client.d.ts.map +1 -1
- package/lib/gitlab-client.js +118 -16
- 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/orchestrator.d.ts +86 -1
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +239 -12
- package/lib/orchestrator.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/settings-rpc.d.ts.map +1 -1
- package/lib/settings-rpc.js +1 -10
- package/lib/settings-rpc.js.map +1 -1
- package/package.json +1 -1
- package/src/host/config-store.ts +0 -2
- package/src/host/gitlab-client.ts +140 -16
- package/src/host/govard-audit-lint-tool.ts +144 -31
- package/src/host/govard-tool.ts +1 -1
- package/src/host/orchestrator.ts +261 -14
- package/src/host/review-findings-tool.ts +9 -1
- package/src/host/review-intake.ts +5 -1
- package/src/host/settings-rpc.ts +1 -9
|
@@ -7,7 +7,39 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
7
7
|
|
|
8
8
|
export const name='maestro-govard-audit-lint-tool'
|
|
9
9
|
export const inject=['tools']
|
|
10
|
-
export const Config:z<{rootPath?:string, timeoutMs?:number}> = z.object({rootPath:z.string(), timeoutMs:z.number()})
|
|
10
|
+
export const Config:z<{rootPath?:string, timeoutMs?:number, defaultBase?:string, allowXdebug?:boolean}> = z.object({rootPath:z.string(), timeoutMs:z.number(), defaultBase:z.string(), allowXdebug:z.boolean()})
|
|
11
|
+
|
|
12
|
+
export interface AuditLintOptions {
|
|
13
|
+
checks?: string[]
|
|
14
|
+
mode?: string
|
|
15
|
+
scope?: string
|
|
16
|
+
base?: string
|
|
17
|
+
phpVersions?: string[]
|
|
18
|
+
noLintResultCache?: boolean
|
|
19
|
+
timeout?: string
|
|
20
|
+
lintProvider?: string
|
|
21
|
+
allowXdebug?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Explicit call base wins, then the review-wired default (MR base_sha). */
|
|
25
|
+
export function resolveLintBase(argsBase: string | undefined, defaultBase: string | undefined): string | undefined {
|
|
26
|
+
return argsBase ?? defaultBase
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildAuditCliArgs(a: AuditLintOptions): string[] {
|
|
30
|
+
const checks = a.checks && a.checks.length ? a.checks.join(',') : 'lint'
|
|
31
|
+
const mode = a.mode ?? 'auto'
|
|
32
|
+
const timeout = a.timeout ?? 'auto'
|
|
33
|
+
const lintProvider = a.lintProvider ?? 'govard'
|
|
34
|
+
const cliArgs=['audit','run','--checks',checks,'--format','json','--mode',mode,'--timeout',timeout,'--lint-provider',lintProvider]
|
|
35
|
+
if(a.scope) cliArgs.push('--scope', a.scope)
|
|
36
|
+
const base = resolveLintBase(a.base, undefined)
|
|
37
|
+
if(base) cliArgs.push('--base', base)
|
|
38
|
+
if(a.phpVersions && a.phpVersions.length) cliArgs.push('--php', a.phpVersions.join(','))
|
|
39
|
+
if(a.noLintResultCache) cliArgs.push('--no-lint-result-cache')
|
|
40
|
+
if(a.allowXdebug) cliArgs.push('--allow-xdebug')
|
|
41
|
+
return cliArgs
|
|
42
|
+
}
|
|
11
43
|
|
|
12
44
|
interface SC{agent?:{session?:{header?:{cwd?:string}}}}
|
|
13
45
|
function workspaceRootFor(c:string|undefined, e:unknown):string{
|
|
@@ -38,7 +70,98 @@ async function isInsideRoot(r:string,t:string):Promise<boolean>{
|
|
|
38
70
|
}
|
|
39
71
|
}
|
|
40
72
|
export function cleanJson(raw:string):string{
|
|
41
|
-
|
|
73
|
+
// pterm pads the level name: " ERROR audit run ..." (multi-space).
|
|
74
|
+
return raw.replace(/\n\s*ERROR\s+audit run.*$/s, '').replace(/\s{2,}ERROR\s+audit run.*$/s, '').trimEnd()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface LintViolationLike { path?: string; line?: number; rule?: string; message?: string }
|
|
78
|
+
|
|
79
|
+
interface LintResultLike {
|
|
80
|
+
ok: boolean
|
|
81
|
+
exitCode?: number
|
|
82
|
+
lint?: {
|
|
83
|
+
phpcs?: { violations?: LintViolationLike[] }
|
|
84
|
+
phpstan?: { errors?: LintViolationLike[] }
|
|
85
|
+
pubMediaGuard?: { violations?: LintViolationLike[] }
|
|
86
|
+
}
|
|
87
|
+
summary?: { findingCount?: number }
|
|
88
|
+
diagnostics?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One-line-plus render for the agent: a bare "audit lint failed" hides the
|
|
93
|
+
* violations the reviewer needs, so carry counts plus the top findings.
|
|
94
|
+
*/
|
|
95
|
+
export interface CollectedLintFindings {
|
|
96
|
+
phpcsViolations: Array<{ path?: string; line?: number; column?: number; rule?: string; message?: string; severity?: string }>
|
|
97
|
+
phpstanErrors: Array<{ path?: string; line?: number; message?: string }>
|
|
98
|
+
pubMediaViolations: unknown[]
|
|
99
|
+
compat: Array<{ tool?: string; path?: string; line?: number; rule?: string; message?: string }>
|
|
100
|
+
total: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Collect findings across govard audit JSON shapes. The live envelope nests
|
|
105
|
+
* them at results[].evidence.php_results[].findings (older callers used a
|
|
106
|
+
* top-level findings array or evidence.php_results) — and non-phpcs/phpstan
|
|
107
|
+
* tools (e.g. M2-LINT-COMPAT internal errors) go to the compat bucket
|
|
108
|
+
* instead of being silently dropped from counts.
|
|
109
|
+
*/
|
|
110
|
+
export function collectLintFindings(parsed: unknown): CollectedLintFindings {
|
|
111
|
+
const p = (parsed ?? {}) as Record<string, any>
|
|
112
|
+
const lists: unknown[][] = []
|
|
113
|
+
if (Array.isArray(p.findings)) lists.push(p.findings)
|
|
114
|
+
const ev = p.evidence as Record<string, any> | undefined
|
|
115
|
+
if (Array.isArray(ev?.php_results)) lists.push(...ev.php_results.map((r: any) => r?.findings).filter(Array.isArray))
|
|
116
|
+
if (Array.isArray(p.php_results)) lists.push(...p.php_results.map((r: any) => r?.findings).filter(Array.isArray))
|
|
117
|
+
// Live shape (govard audit run): jobs[].evidence.php_results[].findings.
|
|
118
|
+
// Older shapes (results[], bare evidence) kept for backward compatibility.
|
|
119
|
+
for (const key of ['jobs', 'results'] as const) {
|
|
120
|
+
if (!Array.isArray(p[key])) continue
|
|
121
|
+
for (const r of p[key] as Array<Record<string, any>>) {
|
|
122
|
+
const e = r?.evidence as Record<string, any> | undefined
|
|
123
|
+
const pr = e?.php_results ?? r?.php_results
|
|
124
|
+
if (Array.isArray(pr)) lists.push(...pr.map((x: any) => x?.findings).filter(Array.isArray))
|
|
125
|
+
if (Array.isArray(r?.findings)) lists.push(r.findings)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const out: CollectedLintFindings = { phpcsViolations: [], phpstanErrors: [], pubMediaViolations: [], compat: [], total: 0 }
|
|
129
|
+
// DSH tool output must be lossless JSON: drop undefined fields, the
|
|
130
|
+
// runtime rejects values that do not survive a JSON round-trip.
|
|
131
|
+
const compact = (entry: Record<string, unknown>): Record<string, unknown> => {
|
|
132
|
+
const kept: Record<string, unknown> = {}
|
|
133
|
+
for (const [k, v] of Object.entries(entry)) if (v !== undefined) kept[k] = v
|
|
134
|
+
return kept
|
|
135
|
+
}
|
|
136
|
+
for (const list of lists) {
|
|
137
|
+
for (const f of list as Array<Record<string, any>>) {
|
|
138
|
+
if (f?.tool === 'phpstan') out.phpstanErrors.push(compact({ path: f.path, line: f.line, message: f.message }) as CollectedLintFindings['phpstanErrors'][number])
|
|
139
|
+
else if (f?.tool === 'phpcs') {
|
|
140
|
+
if (f.path?.includes('pub/media') || f.rule?.includes('PubMedia')) out.pubMediaViolations.push(f)
|
|
141
|
+
else out.phpcsViolations.push(compact({ path: f.path, line: f.line, column: f.column, rule: f.rule, message: f.message, severity: f.severity }) as CollectedLintFindings['phpcsViolations'][number])
|
|
142
|
+
} else if (f && typeof f === 'object') {
|
|
143
|
+
out.compat.push(compact({ tool: f.tool, path: f.path, line: f.line, rule: f.rule, message: f.message }) as CollectedLintFindings['compat'][number])
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
out.total = out.phpcsViolations.length + out.phpstanErrors.length + out.pubMediaViolations.length + out.compat.length
|
|
148
|
+
return out
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function lintResultText(v: LintResultLike): string {
|
|
152
|
+
if (v.ok) return 'audit lint passed'
|
|
153
|
+
const phpcs = v.lint?.phpcs?.violations ?? []
|
|
154
|
+
const phpstan = v.lint?.phpstan?.errors ?? []
|
|
155
|
+
const pubMedia = v.lint?.pubMediaGuard?.violations ?? []
|
|
156
|
+
const compat = (v.lint as Record<string, any> | undefined)?.compat?.findings ?? []
|
|
157
|
+
const total = v.summary?.findingCount ?? phpcs.length + phpstan.length + pubMedia.length + compat.length
|
|
158
|
+
const bits: string[] = [`audit lint failed — ${total} finding(s) (phpcs ${phpcs.length}, phpstan ${phpstan.length}, pubMedia ${pubMedia.length}, compat ${compat.length})`]
|
|
159
|
+
const top = [...phpcs.map(x => ({ ...x, tool: 'phpcs' })), ...phpstan.map(x => ({ ...x, tool: 'phpstan' })), ...pubMedia.map(x => ({ ...x, tool: 'pubMedia' })), ...compat.map((x: Record<string, any>) => ({ ...x, tool: x.tool ?? 'compat' }))].slice(0, 5)
|
|
160
|
+
for (const f of top) bits.push(`- [${f.tool}] ${f.path ?? (f.message ?? '?').toString().slice(0, 120)}${f.path !== undefined ? `:${f.line ?? '?'}` : ''}${f.rule !== undefined ? ` ${f.rule}` : ''}`)
|
|
161
|
+
if (v.exitCode !== undefined) bits.push(`exit ${v.exitCode}`)
|
|
162
|
+
const diag = (v.diagnostics ?? '').split('\n')[0]?.trim()
|
|
163
|
+
if (total === 0 && diag !== '' && diag !== undefined) bits.push(diag.slice(0, 200))
|
|
164
|
+
return bits.join('\n')
|
|
42
165
|
}
|
|
43
166
|
// Govard 1.67 auto timeout: 90s-30m framework-aware (15m floor for wordpress/magento2 → 22.5m auto)
|
|
44
167
|
// Keep kill timeout above the largest auto value so the outer watchdog doesn't cancel a valid auto run.
|
|
@@ -57,12 +180,16 @@ function run(cmd:string, args:string[], cwd:string, timeoutMs:number):Promise<{c
|
|
|
57
180
|
})
|
|
58
181
|
}
|
|
59
182
|
|
|
60
|
-
export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}={}):void{
|
|
183
|
+
export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number, defaultBase?:string, allowXdebug?:boolean}={}):void{
|
|
61
184
|
const configuredRoot=config.rootPath
|
|
62
185
|
const defaultTimeout=config.timeoutMs ?? DEFAULT_TIMEOUT
|
|
186
|
+
const defaultBase=config.defaultBase
|
|
187
|
+
// Review worktrees disable xdebug via .govard.local.yml, but govard's lint
|
|
188
|
+
// guard probes the base .govard.yml only — so the mount opts out explicitly.
|
|
189
|
+
const allowXdebug=config.allowXdebug ?? false
|
|
63
190
|
ctx.effect(()=>ctx.tools.register(defineTool({
|
|
64
191
|
name:'govard_audit_lint',
|
|
65
|
-
description:'Run govard audit --checks lint --format json and return structured phpcs/phpstan results. Use before hand-parsing text. Govard 1.67+ uses --timeout auto (framework-aware 90s-30m, 22.5m for wordpress/magento2) by default.',
|
|
192
|
+
description:'Run govard audit --checks lint --format json and return structured phpcs/phpstan results. Use before hand-parsing text. Govard 1.67+ uses --timeout auto (framework-aware 90s-30m, 22.5m for wordpress/magento2) by default. scope "diff" requires a base ref: pass base, or rely on the wired defaultBase (MR base_sha) when present.',
|
|
66
193
|
parameters:{
|
|
67
194
|
worktreePath:{type:'string'},
|
|
68
195
|
checks:{type:'array', items:{type:'string'}},
|
|
@@ -93,7 +220,7 @@ export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}=
|
|
|
93
220
|
runId:{type:'string'},
|
|
94
221
|
}
|
|
95
222
|
},
|
|
96
|
-
render:(_a,v:
|
|
223
|
+
render:(_a,v:LintResultLike)=>[{type:'text', text: lintResultText(v)}],
|
|
97
224
|
},
|
|
98
225
|
async execute(args, exec){
|
|
99
226
|
const rawPath=(args as {worktreePath?:string}).worktreePath
|
|
@@ -104,20 +231,13 @@ export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}=
|
|
|
104
231
|
const timeoutMs=(args as {timeoutMs?:number}).timeoutMs ?? defaultTimeout
|
|
105
232
|
if(timeoutMs<5000 || timeoutMs>1_800_000) return {text:'timeoutMs out of range 5000-1800000 (5s-30m). Use --timeout auto for framework-aware estimation.', truncated:false} as never
|
|
106
233
|
|
|
107
|
-
const a = args as {checks?:string[]; mode?:string; scope?:string; base?:string; phpVersions?:string[]; noLintResultCache?:boolean; timeout?:string; lintProvider?:string}
|
|
108
|
-
const checks = a.checks && a.checks.length ? a.checks.join(',') : 'lint'
|
|
109
|
-
const mode = a.mode ?? 'auto'
|
|
234
|
+
const a = args as {checks?:string[]; mode?:string; scope?:string; base?:string; phpVersions?:string[]; noLintResultCache?:boolean; timeout?:string; lintProvider?:string; allowXdebug?:boolean}
|
|
110
235
|
const scope = a.scope
|
|
111
|
-
const base = a.base
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
const cliArgs=['audit','run','--checks',checks,'--format','json','--mode',mode,'--timeout',timeout,'--lint-provider',lintProvider]
|
|
117
|
-
if(scope) cliArgs.push('--scope', scope)
|
|
118
|
-
if(base) cliArgs.push('--base', base)
|
|
119
|
-
if(phpVersionsArg && phpVersionsArg.length) cliArgs.push('--php', phpVersionsArg.join(','))
|
|
120
|
-
if(noLintResultCache) cliArgs.push('--no-lint-result-cache')
|
|
236
|
+
const base = resolveLintBase(a.base, defaultBase)
|
|
237
|
+
if (scope === 'diff' && base === undefined) {
|
|
238
|
+
return {text:'scope "diff" requires a base ref (govard --base): pass base explicitly (e.g. origin/master or the MR base_sha) or mount this tool with defaultBase.', truncated:false} as never
|
|
239
|
+
}
|
|
240
|
+
const cliArgs = buildAuditCliArgs({ ...a, base, allowXdebug: a.allowXdebug ?? allowXdebug })
|
|
121
241
|
const result=await run('govard', cliArgs, worktreePath, timeoutMs)
|
|
122
242
|
if(result.timedOut){
|
|
123
243
|
return {ok:false, exitCode: result.code ?? 124, timedOut:true, worktreePath, lint:{phpcs:{violations:[]}, phpstan:{errors:[]}, pubMediaGuard:{violations:[]}}, summary:{status:null, phpVersions:[], matrixComplete:false, findingCount:0, truncated:false}, rawJson:{}, errors:[{code:'timeout', message:`timed out after ${timeoutMs}ms` }], diagnostics:result.stderr.slice(0,4000)} as never
|
|
@@ -139,20 +259,13 @@ export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}=
|
|
|
139
259
|
if(!parsed){
|
|
140
260
|
return {ok:false, exitCode: result.code ?? 1, timedOut:false, worktreePath, lint:{phpcs:{violations:[]}, phpstan:{errors:[]}, pubMediaGuard:{violations:[]}}, summary:{status:null, phpVersions:[], matrixComplete:false, findingCount:0, truncated:false}, rawJson:{}, errors:[{code:'parse_error', message:'stdout not JSON'}], diagnostics:(cleaned+result.stderr).slice(0,4000)} as never
|
|
141
261
|
}
|
|
142
|
-
const
|
|
143
|
-
const phpcsViolations
|
|
144
|
-
const phpstanErrors
|
|
145
|
-
const pubMediaViolations
|
|
146
|
-
for(const f of findings){
|
|
147
|
-
if(f.tool==='phpstan') phpstanErrors.push({path:f.path, line:f.line, message:f.message})
|
|
148
|
-
else if(f.tool==='phpcs'){
|
|
149
|
-
if(f.path?.includes('pub/media') || f.rule?.includes('PubMedia')) pubMediaViolations.push(f)
|
|
150
|
-
else phpcsViolations.push({path:f.path, line:f.line, column:f.column, rule:f.rule, message:f.message, severity:f.severity})
|
|
151
|
-
}
|
|
152
|
-
}
|
|
262
|
+
const collected = collectLintFindings(parsed)
|
|
263
|
+
const phpcsViolations = collected.phpcsViolations
|
|
264
|
+
const phpstanErrors = collected.phpstanErrors
|
|
265
|
+
const pubMediaViolations = collected.pubMediaViolations
|
|
153
266
|
const status=parsed.status ?? (result.code===0?'passed':'failed')
|
|
154
267
|
const phpVersions=parsed.php_versions ?? parsed.phpVersions ?? []
|
|
155
|
-
const findingCount=
|
|
268
|
+
const findingCount=collected.total
|
|
156
269
|
const sessionId = parsed.session_id ?? parsed.sessionId ?? undefined
|
|
157
270
|
const runId = parsed.run_id ?? parsed.runId ?? undefined
|
|
158
271
|
return {
|
|
@@ -164,7 +277,7 @@ export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}=
|
|
|
164
277
|
...(runId ? { runId: String(runId) } : {}),
|
|
165
278
|
rawJson: parsed as Record<string, unknown>,
|
|
166
279
|
summary:{status, phpVersions, matrixComplete:true, findingCount, truncated: findingCount>100},
|
|
167
|
-
lint:{phpcs:{violations:phpcsViolations}, phpstan:{errors:phpstanErrors}, pubMediaGuard:{violations:pubMediaViolations}},
|
|
280
|
+
lint:{phpcs:{violations:phpcsViolations}, phpstan:{errors:phpstanErrors}, pubMediaGuard:{violations:pubMediaViolations}, compat:{findings:collected.compat}},
|
|
168
281
|
errors:[],
|
|
169
282
|
diagnostics: result.stderr.slice(0,4000),
|
|
170
283
|
} as never
|
package/src/host/govard-tool.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
82
82
|
name: 'govard_shell',
|
|
83
83
|
description: 'Run one non-interactive command inside the Govard-managed container (e.g. a test suite) and return its output.',
|
|
84
84
|
parameters: {
|
|
85
|
-
command: { type: 'string', required: true, description: 'Shell command to run inside the container
|
|
85
|
+
command: { type: 'string', required: true, description: 'Shell command to run inside the container. PHPUnit in a Magento root: always scope it — "vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist --filter <Module>" or "vendor/bin/phpunit --no-coverage --bootstrap dev/tests/unit/framework/bootstrap.php app/code/<Vendor>/<Module>/Test/Unit". The root ships no phpunit.xml so the bare binary prints usage and exits 1; never run the full suite bare (core fixture conflicts), and capture status via ${PIPESTATUS[0]} when piping through tail.' },
|
|
86
86
|
},
|
|
87
87
|
output: {
|
|
88
88
|
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
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,7 +29,7 @@ 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'
|
|
32
|
+
import type { ReviewFinding, FindingSeverity } from './review-findings-tool.js'
|
|
33
33
|
import type { ReviewRequest } from './events.js'
|
|
34
34
|
import { loadUserConfig, type MaestroUserConfig, type ReviewModelSelection } from './config-store.js'
|
|
35
35
|
import { hasCompletedReview, lastCompletedReview, pruneHistory, recordReviewFinish, recordReviewStart } from './review-history.js'
|
|
@@ -58,6 +58,62 @@ export { gitlabProvider }
|
|
|
58
58
|
const execFileAsync = promisify(execFile)
|
|
59
59
|
const GIT_TIMEOUT_MS = 60_000
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Finding severity, assigned by the reviewer. Display order is fixed
|
|
63
|
+
* (blocking first) wherever counts are rendered.
|
|
64
|
+
*/
|
|
65
|
+
export const SEVERITY_ORDER: readonly FindingSeverity[] = ['blocking', 'major', 'minor', 'nit'] as const
|
|
66
|
+
|
|
67
|
+
const SEVERITY_LABEL: Record<FindingSeverity, string> = {
|
|
68
|
+
blocking: '🔴 Blocking',
|
|
69
|
+
major: '🟡 Major',
|
|
70
|
+
minor: '🔵 Minor',
|
|
71
|
+
nit: '⚪ Nit',
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Missing or unknown severities degrade to `minor` — never drop a finding. */
|
|
75
|
+
export function normalizeFindingSeverity(severity: unknown): FindingSeverity {
|
|
76
|
+
return severity === 'blocking' || severity === 'major' || severity === 'minor' || severity === 'nit'
|
|
77
|
+
? severity
|
|
78
|
+
: 'minor'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function severityPrefix(severity: unknown): string {
|
|
82
|
+
return SEVERITY_LABEL[normalizeFindingSeverity(severity)]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Count findings per severity; zero counts are omitted. */
|
|
86
|
+
export function countFindingSeverities(findings: ReviewFinding[]): Partial<Record<FindingSeverity, number>> {
|
|
87
|
+
const counts: Partial<Record<FindingSeverity, number>> = {}
|
|
88
|
+
for (const finding of findings) {
|
|
89
|
+
const level = normalizeFindingSeverity(finding.severity)
|
|
90
|
+
counts[level] = (counts[level] ?? 0) + 1
|
|
91
|
+
}
|
|
92
|
+
return counts
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ReviewerScopePromptOpts {
|
|
96
|
+
scopeKind: 'discussion' | 'full'
|
|
97
|
+
discussionId?: string
|
|
98
|
+
path?: string
|
|
99
|
+
line?: number
|
|
100
|
+
mode?: string
|
|
101
|
+
profileInstruction: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reviewer scope prompt. Static analysis is mandatory: reviewers skipped
|
|
106
|
+
* govard_audit_lint for whole rounds (no lint signal at all), so the prompt
|
|
107
|
+
* requires at least one call before report_review_findings. Pure for testing.
|
|
108
|
+
*/
|
|
109
|
+
export function buildReviewerScopePrompt(opts: ReviewerScopePromptOpts): string {
|
|
110
|
+
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.'
|
|
111
|
+
if (opts.scopeKind === 'discussion') {
|
|
112
|
+
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}`
|
|
113
|
+
}
|
|
114
|
+
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.`
|
|
115
|
+
}
|
|
116
|
+
|
|
61
117
|
/**
|
|
62
118
|
* Build a user-friendly GitLab Markdown comment for a completed review.
|
|
63
119
|
* Shared design language with `reviewDigestText` (Telegram HTML) — same
|
|
@@ -72,7 +128,7 @@ export function buildReviewComment(opts: {
|
|
|
72
128
|
profile?: string
|
|
73
129
|
summary: string
|
|
74
130
|
failures: string[]
|
|
75
|
-
findings?: { newCount: number; replyCount: number }
|
|
131
|
+
findings?: { newCount: number; replyCount: number; severityCounts?: Partial<Record<FindingSeverity, number>> }
|
|
76
132
|
durationMs?: number
|
|
77
133
|
isDiffOnly?: boolean
|
|
78
134
|
isDiscussion?: boolean
|
|
@@ -97,6 +153,12 @@ export function buildReviewComment(opts: {
|
|
|
97
153
|
const parts: string[] = []
|
|
98
154
|
if (opts.findings.newCount > 0) parts.push(`💬 ${opts.findings.newCount} new`)
|
|
99
155
|
if (opts.findings.replyCount > 0) parts.push(`🔁 ${opts.findings.replyCount} updated`)
|
|
156
|
+
if (opts.findings.severityCounts !== undefined) {
|
|
157
|
+
for (const level of SEVERITY_ORDER) {
|
|
158
|
+
const count = opts.findings.severityCounts[level] ?? 0
|
|
159
|
+
if (count > 0) parts.push(`${SEVERITY_LABEL[level].split(' ')[0]} ${count} ${level}`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
100
162
|
if (parts.length === 0) parts.push('no inline findings')
|
|
101
163
|
return `\n\n**Findings:** ${parts.join(' · ')}`
|
|
102
164
|
})()
|
|
@@ -223,6 +285,7 @@ export const Config: z<Config> = z.object({
|
|
|
223
285
|
export interface ReviewOutcome {
|
|
224
286
|
summary: string
|
|
225
287
|
failures: string[]
|
|
288
|
+
severityCounts?: Partial<Record<FindingSeverity, number>>
|
|
226
289
|
}
|
|
227
290
|
|
|
228
291
|
/**
|
|
@@ -374,10 +437,12 @@ export async function postReviewFindings(findings: ReviewFinding[], config: Gitl
|
|
|
374
437
|
const headers = { 'PRIVATE-TOKEN': config.token, 'Content-Type': 'application/json' }
|
|
375
438
|
for (const finding of findings) {
|
|
376
439
|
let response: Response
|
|
440
|
+
const label = severityPrefix(finding.severity)
|
|
441
|
+
const body = finding.body.startsWith(label) ? finding.body : `${label}\n\n${finding.body}`
|
|
377
442
|
if (finding.status === 'reply') {
|
|
378
443
|
if (finding.discussionId === undefined) throw new Error('reply finding missing discussionId')
|
|
379
444
|
response = await fetcher(`${apiBase}/discussions/${encodeURIComponent(finding.discussionId)}/notes`, {
|
|
380
|
-
method: 'POST', headers, body: JSON.stringify({ body
|
|
445
|
+
method: 'POST', headers, body: JSON.stringify({ body }),
|
|
381
446
|
})
|
|
382
447
|
} else {
|
|
383
448
|
if (finding.path === undefined || finding.line === undefined) throw new Error('new finding missing path or line')
|
|
@@ -395,12 +460,12 @@ export async function postReviewFindings(findings: ReviewFinding[], config: Gitl
|
|
|
395
460
|
// Line not in diff — fallback to MR note so finding is not silently lost.
|
|
396
461
|
response = await fetcher(`${apiBase}/notes`, {
|
|
397
462
|
method: 'POST', headers,
|
|
398
|
-
body: JSON.stringify({ body: `**Inline fallback — \`${finding.path}:${finding.line}\` line is not in current MR diff**\n\n${
|
|
463
|
+
body: JSON.stringify({ body: `**Inline fallback — \`${finding.path}:${finding.line}\` line is not in current MR diff**\n\n${body}` }),
|
|
399
464
|
})
|
|
400
465
|
} else {
|
|
401
466
|
response = await fetcher(`${apiBase}/discussions`, {
|
|
402
467
|
method: 'POST', headers,
|
|
403
|
-
body: JSON.stringify({ body
|
|
468
|
+
body: JSON.stringify({ body, position: {
|
|
404
469
|
position_type: 'text', ...snapshot.diffRefs, old_path: change.old_path, new_path: change.new_path,
|
|
405
470
|
...linePosition.oldLine === undefined ? {} : { old_line: linePosition.oldLine },
|
|
406
471
|
...linePosition.newLine === undefined ? {} : { new_line: linePosition.newLine },
|
|
@@ -453,6 +518,33 @@ function assertSafeId(value: number, label: string): void {
|
|
|
453
518
|
}
|
|
454
519
|
}
|
|
455
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Read an agent session's transcript for the auditor's final output across
|
|
523
|
+
* host/session API skew: hosts built from the harness checkout expose
|
|
524
|
+
* `ownEvents()`/`snapshotEvents()` with no `.events` getter, while older
|
|
525
|
+
* packaged `@deepseek-ai/dsh-session` builds expose only the `.events`
|
|
526
|
+
* getter. `ownEvents()` (child-owned suffix, no fork prefix) matches
|
|
527
|
+
* `finalAssistantOutput`'s documented input best, so it wins when present.
|
|
528
|
+
* Returns `[]` — never throws — when no event source exists.
|
|
529
|
+
*/
|
|
530
|
+
export function auditorOutputFromSession(session: unknown) {
|
|
531
|
+
const candidate = session as {
|
|
532
|
+
ownEvents?: unknown
|
|
533
|
+
snapshotEvents?: unknown
|
|
534
|
+
events?: unknown
|
|
535
|
+
} | null | undefined
|
|
536
|
+
let events: unknown
|
|
537
|
+
if (typeof candidate?.ownEvents === 'function') {
|
|
538
|
+
events = (candidate.ownEvents as () => unknown)()
|
|
539
|
+
} else if (typeof candidate?.snapshotEvents === 'function') {
|
|
540
|
+
events = (candidate.snapshotEvents as () => unknown)()
|
|
541
|
+
} else {
|
|
542
|
+
events = candidate?.events
|
|
543
|
+
}
|
|
544
|
+
if (!Array.isArray(events)) return []
|
|
545
|
+
return finalAssistantOutput(events as Parameters<typeof finalAssistantOutput>[0]) ?? []
|
|
546
|
+
}
|
|
547
|
+
|
|
456
548
|
/** Full review + performance audit; resolves to the comment body that was posted. */
|
|
457
549
|
export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndAuditDeps): Promise<string> {
|
|
458
550
|
assertSafeId(payload.projectId, 'projectId')
|
|
@@ -471,7 +563,7 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
|
|
|
471
563
|
])
|
|
472
564
|
const labels = ['Reviewer', 'Auditor']
|
|
473
565
|
if (settled[0].status === 'fulfilled') {
|
|
474
|
-
const { summary, failures } = settled[0].value
|
|
566
|
+
const { summary, failures, severityCounts } = settled[0].value
|
|
475
567
|
// Try to parse findings counts from summary like "2 new inline comment(s), 1 thread(s) updated."
|
|
476
568
|
const summaryText = summary ?? ''
|
|
477
569
|
const newMatch = /(\d+)\s+new inline/.exec(summaryText)
|
|
@@ -488,7 +580,7 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
|
|
|
488
580
|
profile: (deps as unknown as { reviewProfile?: string }).reviewProfile,
|
|
489
581
|
summary: summaryText,
|
|
490
582
|
failures,
|
|
491
|
-
findings: { newCount, replyCount },
|
|
583
|
+
findings: { newCount, replyCount, severityCounts },
|
|
492
584
|
})
|
|
493
585
|
: `## 🤖 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
586
|
sections.push(richOpts)
|
|
@@ -619,7 +711,34 @@ export function isBranchNotFoundError(err: unknown): boolean {
|
|
|
619
711
|
*/
|
|
620
712
|
export function govardWorktreeOverride(projectId: number, mrIid: number, keySuffix?: string): string {
|
|
621
713
|
const name = `maestro-mr-${projectId}-${mrIid}${keySuffix === undefined ? '' : `-${keySuffix}`}`
|
|
622
|
-
|
|
714
|
+
// Xdebug stays off in review envs: the auditor runs no coverage, and an
|
|
715
|
+
// enabled Xdebug trips govard's lint perf-tax guard (exit 1, 0 findings).
|
|
716
|
+
return `project_name: ${name}\ndomain: ${name}.test\nstack:\n features:\n xdebug: false\n`
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Fetch the MR's diff base SHA for govard diff-scope runs. Best-effort:
|
|
721
|
+
* returns undefined (the lint tool then fail-fasts with guidance) rather
|
|
722
|
+
* than failing the review when GitLab is unreachable.
|
|
723
|
+
*/
|
|
724
|
+
export async function fetchMrBaseSha(
|
|
725
|
+
baseUrl: string,
|
|
726
|
+
token: string,
|
|
727
|
+
projectId: number,
|
|
728
|
+
mrIid: number,
|
|
729
|
+
fetcher: typeof fetch = fetch,
|
|
730
|
+
): Promise<string | undefined> {
|
|
731
|
+
try {
|
|
732
|
+
const response = await fetcher(
|
|
733
|
+
`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}`,
|
|
734
|
+
{ headers: { 'PRIVATE-TOKEN': token } },
|
|
735
|
+
)
|
|
736
|
+
if (!response.ok) return undefined
|
|
737
|
+
const mr = await response.json() as { diff_refs?: { base_sha?: string } }
|
|
738
|
+
return mr.diff_refs?.base_sha
|
|
739
|
+
} catch {
|
|
740
|
+
return undefined
|
|
741
|
+
}
|
|
623
742
|
}
|
|
624
743
|
|
|
625
744
|
export async function ensureWorktree(localRepoPath: string, sourceBranch: string, projectId: number, mrIid: number, keySuffix?: string): Promise<string> {
|
|
@@ -643,9 +762,134 @@ export async function ensureWorktree(localRepoPath: string, sourceBranch: string
|
|
|
643
762
|
.catch(() => {})
|
|
644
763
|
await execFileAsync('git', ['worktree', 'add', '--', worktreePath, `origin/${sourceBranch}`], { cwd: localRepoPath, timeout: GIT_TIMEOUT_MS })
|
|
645
764
|
await writeFile(join(worktreePath, '.govard.local.yml'), govardWorktreeOverride(projectId, mrIid, keySuffix), 'utf-8')
|
|
765
|
+
await linkVendorIntoWorktree(localRepoPath, worktreePath)
|
|
766
|
+
await writeContainerVendorOverride(localRepoPath, worktreePath)
|
|
646
767
|
return worktreePath
|
|
647
768
|
}
|
|
648
769
|
|
|
770
|
+
/**
|
|
771
|
+
* A vendor dir only counts as installed dependencies when the composer
|
|
772
|
+
* autoloader is a real file. Magento tracks `vendor/.htaccess`, so every
|
|
773
|
+
* worktree has a vendor/ stub — that stub must not pass for real deps, and
|
|
774
|
+
* must not block the container bind that provides them.
|
|
775
|
+
*/
|
|
776
|
+
export async function vendorHasAutoload(dir: string): Promise<boolean> {
|
|
777
|
+
try {
|
|
778
|
+
return (await stat(join(dir, 'vendor', 'autoload.php'))).isFile()
|
|
779
|
+
} catch {
|
|
780
|
+
return false
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Share the primary checkout's `vendor/` (and `app/etc/env.php` when the
|
|
786
|
+
* auditor needs a database) into the review worktree via symlinks, so
|
|
787
|
+
* phpunit/static analysis run against real dependencies instead of falling
|
|
788
|
+
* back to static-only. Links point INTO the worktree only — the primary
|
|
789
|
+
* checkout is never written to — and `git worktree remove --force` deletes
|
|
790
|
+
* the links along with the worktree. Missing sources, or targets that
|
|
791
|
+
* already exist (e.g. Magento's tracked `vendor/.htaccess` stub), are
|
|
792
|
+
* skipped quietly: the review still runs, the auditor discloses it.
|
|
793
|
+
*/
|
|
794
|
+
export async function linkVendorIntoWorktree(localRepoPath: string, worktreePath: string): Promise<string[]> {
|
|
795
|
+
const linked: string[] = []
|
|
796
|
+
const candidates: Array<{ source: string; target: string; dir: boolean; needsAutoload: boolean }> = [
|
|
797
|
+
{ source: join(localRepoPath, 'vendor'), target: join(worktreePath, 'vendor'), dir: true, needsAutoload: true },
|
|
798
|
+
{ source: join(localRepoPath, 'app', 'etc', 'env.php'), target: join(worktreePath, 'app', 'etc', 'env.php'), dir: false, needsAutoload: false },
|
|
799
|
+
]
|
|
800
|
+
let advertised = false
|
|
801
|
+
for (const { source, target, dir, needsAutoload } of candidates) {
|
|
802
|
+
let isDir = false
|
|
803
|
+
try {
|
|
804
|
+
isDir = (await stat(source)).isDirectory()
|
|
805
|
+
if (dir !== isDir) continue
|
|
806
|
+
} catch {
|
|
807
|
+
continue
|
|
808
|
+
}
|
|
809
|
+
if (needsAutoload && !(await vendorHasAutoload(localRepoPath))) continue
|
|
810
|
+
if ((await lstat(target).catch(() => undefined)) !== undefined) {
|
|
811
|
+
console.error(`maestro-orchestrator: link target exists, skipping ${target}`)
|
|
812
|
+
continue
|
|
813
|
+
}
|
|
814
|
+
try {
|
|
815
|
+
if (!advertised) {
|
|
816
|
+
const sha = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: localRepoPath, timeout: GIT_TIMEOUT_MS })
|
|
817
|
+
.then(({ stdout }) => stdout.trim()).catch(() => 'unknown')
|
|
818
|
+
console.error(`maestro-orchestrator: linking vendor from ${localRepoPath} @ ${sha} into ${worktreePath}`)
|
|
819
|
+
advertised = true
|
|
820
|
+
}
|
|
821
|
+
if (!dir) await mkdir(join(worktreePath, 'app', 'etc'), { recursive: true })
|
|
822
|
+
await symlink(source, target, dir ? 'dir' : 'file')
|
|
823
|
+
linked.push(target)
|
|
824
|
+
} catch (err) {
|
|
825
|
+
console.error(`maestro-orchestrator: failed to link ${source} into worktree:`, err)
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (!advertised) {
|
|
829
|
+
console.error(`maestro-orchestrator: no vendor/ in ${localRepoPath} — worktree ${worktreePath} runs without shared dependencies`)
|
|
830
|
+
}
|
|
831
|
+
return linked
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Container path of the project root inside govard services. */
|
|
835
|
+
const GOVARD_CONTAINER_WORKDIR = '/var/www/html'
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Render a compose override that bind-mounts the primary checkout's vendor/
|
|
839
|
+
* read-only into the php service. A host-side symlink alone dangles inside
|
|
840
|
+
* containers (absolute host path), so container tools (phpunit) need this
|
|
841
|
+
* bind to see real dependencies.
|
|
842
|
+
*
|
|
843
|
+
* The project mount (`.`) MUST be repeated first: govard merges overrides
|
|
844
|
+
* with MergeMap, which REPLACES lists instead of appending — an override
|
|
845
|
+
* carrying only the vendor bind would wipe `.:<workdir>` and leave the
|
|
846
|
+
* container docroot holding nothing but vendor/. Relative `.` resolves
|
|
847
|
+
* against the project root because govard passes --project-directory.
|
|
848
|
+
*
|
|
849
|
+
* Only the always-present `php` service is targeted: `php-debug` drops out
|
|
850
|
+
* of the rendered base when xdebug is off, and a volumes-only override
|
|
851
|
+
* entry would recreate it as a hollow service that fails compose validation.
|
|
852
|
+
*/
|
|
853
|
+
export function buildVendorOverrideYaml(vendorHostPath: string, envHostPath?: string, containerWorkDir: string = GOVARD_CONTAINER_WORKDIR): string {
|
|
854
|
+
const volumes = [
|
|
855
|
+
`.:${containerWorkDir}`,
|
|
856
|
+
`${vendorHostPath}:${containerWorkDir}/vendor:ro`,
|
|
857
|
+
...(envHostPath !== undefined ? [`${envHostPath}:${containerWorkDir}/app/etc/env.php:ro`] : []),
|
|
858
|
+
]
|
|
859
|
+
const service = ` volumes:\n${volumes.map((v) => ` - ${v}\n`).join('')}`
|
|
860
|
+
return `services:\n php:\n${service}`
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Drop the vendor bind override into the worktree (merged by govard via
|
|
865
|
+
* `.govard/docker-compose.override.yml`). Binds only when the primary
|
|
866
|
+
* checkout carries installed deps and the worktree does not — never shadow
|
|
867
|
+
* real deps with a possibly stale bind. Returns the written path, or
|
|
868
|
+
* undefined when skipped.
|
|
869
|
+
*/
|
|
870
|
+
export async function writeContainerVendorOverride(localRepoPath: string, worktreePath: string): Promise<string | undefined> {
|
|
871
|
+
const vendorHostPath = join(localRepoPath, 'vendor')
|
|
872
|
+
if (!(await vendorHasAutoload(localRepoPath))) return undefined
|
|
873
|
+
// Our own host-side symlink still needs the bind (it dangles in-container);
|
|
874
|
+
// only a real installed worktree vendor makes the bind redundant.
|
|
875
|
+
const wtLink = await lstat(join(worktreePath, 'vendor')).catch(() => undefined)
|
|
876
|
+
if (wtLink?.isSymbolicLink() !== true && await vendorHasAutoload(worktreePath)) return undefined
|
|
877
|
+
const overridePath = join(worktreePath, '.govard', 'docker-compose.override.yml')
|
|
878
|
+
await mkdir(join(worktreePath, '.govard'), { recursive: true })
|
|
879
|
+
// A linked env.php dangles in-container like the vendor symlink did — bind
|
|
880
|
+
// the real file over it when the primary checkout carries one.
|
|
881
|
+
const envHostPath = join(localRepoPath, 'app', 'etc', 'env.php')
|
|
882
|
+
let envBind: string | undefined
|
|
883
|
+
try {
|
|
884
|
+
if ((await stat(envHostPath)).isFile()) envBind = envHostPath
|
|
885
|
+
} catch {
|
|
886
|
+
envBind = undefined
|
|
887
|
+
}
|
|
888
|
+
await writeFile(overridePath, buildVendorOverrideYaml(vendorHostPath, envBind), 'utf-8')
|
|
889
|
+
console.error(`maestro-orchestrator: container vendor bind ${vendorHostPath} -> ${GOVARD_CONTAINER_WORKDIR}/vendor (ro) for ${worktreePath}`)
|
|
890
|
+
return overridePath
|
|
891
|
+
}
|
|
892
|
+
|
|
649
893
|
async function removeWorktree(worktreePath: string): Promise<void> {
|
|
650
894
|
// Best-effort cleanup by design (a failure here must not block or fail the review that
|
|
651
895
|
// already ran) — but a swallowed failure with zero visibility leaves an orphaned worktree
|
|
@@ -667,6 +911,9 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
667
911
|
// effect without a plugin restart.
|
|
668
912
|
let effectiveAgentTimeoutMs = config.agentTimeoutMs
|
|
669
913
|
async function runReviewer(worktreePath: string | undefined, payload: ReviewRequest, effective: { gitlabBaseUrl: string; gitlabToken: string; botUsername: string }, reviewProfile?: ReviewSkillProfile, modelSelection?: ModelSelection, incrementalBlock?: string): Promise<ReviewOutcome> {
|
|
914
|
+
// MR base SHA feeds govard diff-scope runs; undefined degrades to the
|
|
915
|
+
// tool's fail-fast guidance instead of a wasted govard invocation.
|
|
916
|
+
const lintDefaultBase = await fetchMrBaseSha(effective.gitlabBaseUrl, effective.gitlabToken, payload.projectId, payload.mrIid)
|
|
670
917
|
const primaryOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection())
|
|
671
918
|
const fallbackOptions: ModelSelection = { provider: primaryOptions.provider, model: primaryOptions.model }
|
|
672
919
|
let lastHandle: AgentHandle | undefined
|
|
@@ -701,7 +948,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
701
948
|
await agentCtx.plugin(ModuleCheckTool, { rootPath: worktreePath })
|
|
702
949
|
await agentCtx.plugin(PhtmlEscapeScanTool, { rootPath: worktreePath })
|
|
703
950
|
await agentCtx.plugin(ScopeSplitTool, { rootPath: worktreePath })
|
|
704
|
-
await agentCtx.plugin(GovardAuditLintTool, { rootPath: worktreePath })
|
|
951
|
+
await agentCtx.plugin(GovardAuditLintTool, { rootPath: worktreePath, defaultBase: lintDefaultBase, allowXdebug: true })
|
|
705
952
|
await agentCtx.plugin(PerfLogStatsTool, { rootPath: worktreePath })
|
|
706
953
|
}
|
|
707
954
|
},
|
|
@@ -717,8 +964,8 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
717
964
|
? '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
965
|
: `Call maestro_load_review_profile with {"profile":"${reviewProfile}"} before examining code. `
|
|
719
966
|
let scopePrompt = payload.scope.kind === 'discussion'
|
|
720
|
-
?
|
|
721
|
-
:
|
|
967
|
+
? buildReviewerScopePrompt({ scopeKind: 'discussion', discussionId: payload.scope.discussionId, path: payload.scope.path, line: payload.scope.line, profileInstruction })
|
|
968
|
+
: buildReviewerScopePrompt({ scopeKind: 'full', mode: payload.mode, profileInstruction })
|
|
722
969
|
if (incrementalBlock !== undefined) scopePrompt = `${incrementalBlock}\n\n${scopePrompt}`
|
|
723
970
|
handle.agent.followup(createUserMessage({
|
|
724
971
|
content: [{ type: 'text', text: scopePrompt }],
|
|
@@ -759,7 +1006,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
759
1006
|
failures.push(`${locator}: ${err instanceof Error ? err.message : String(err)}`)
|
|
760
1007
|
}
|
|
761
1008
|
}
|
|
762
|
-
return { summary: `${postedNew} new inline comment(s), ${postedReplies} thread(s) updated.`, failures }
|
|
1009
|
+
return { summary: `${postedNew} new inline comment(s), ${postedReplies} thread(s) updated.`, failures, severityCounts: countFindingSeverities(capturedFindings) }
|
|
763
1010
|
} finally {
|
|
764
1011
|
await handle.dispose()
|
|
765
1012
|
}
|
|
@@ -822,7 +1069,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
822
1069
|
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.'
|
|
823
1070
|
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } }))
|
|
824
1071
|
await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
|
|
825
|
-
const output =
|
|
1072
|
+
const output = auditorOutputFromSession(handle.agent.session)
|
|
826
1073
|
const text = output.map(block => ('text' in block ? block.text : '')).join('')
|
|
827
1074
|
return `## Maestro Performance Audit\n\n${text}`
|
|
828
1075
|
} finally {
|