@ddtcorex/dsh-maestro-review 0.4.0 → 0.5.1
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 +13 -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 +11 -0
- package/lib/ci-coexist.d.ts.map +1 -0
- package/lib/ci-coexist.js +38 -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 +27 -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 +7 -0
- package/lib/gitlab-client.d.ts.map +1 -1
- package/lib/gitlab-client.js +23 -1
- package/lib/gitlab-client.js.map +1 -1
- package/lib/incremental.d.ts.map +1 -1
- package/lib/incremental.js +4 -2
- package/lib/incremental.js.map +1 -1
- package/lib/orchestrator.d.ts +56 -6
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +243 -68
- package/lib/orchestrator.js.map +1 -1
- package/lib/providers/ci-trigger.d.ts +44 -0
- package/lib/providers/ci-trigger.d.ts.map +1 -0
- package/lib/providers/ci-trigger.js +150 -0
- package/lib/providers/ci-trigger.js.map +1 -0
- package/lib/providers/gitlab.d.ts.map +1 -1
- package/lib/providers/gitlab.js +16 -3
- package/lib/providers/gitlab.js.map +1 -1
- package/lib/review-history.d.ts +2 -1
- package/lib/review-history.d.ts.map +1 -1
- package/lib/review-history.js +67 -40
- package/lib/review-history.js.map +1 -1
- package/lib/review-intake.d.ts +9 -0
- package/lib/review-intake.d.ts.map +1 -1
- package/lib/review-intake.js +34 -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 +8 -6
- 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 +45 -0
- package/src/host/config-store.ts +14 -1
- package/src/host/events.ts +29 -0
- package/src/host/gitlab-auth.ts +13 -0
- package/src/host/gitlab-client.ts +23 -1
- package/src/host/incremental.ts +6 -4
- package/src/host/orchestrator.ts +247 -46
- package/src/host/providers/ci-trigger.ts +168 -0
- package/src/host/providers/gitlab.ts +19 -4
- package/src/host/review-history.ts +65 -35
- package/src/host/review-intake.ts +36 -1
- package/src/host/review-marker.ts +25 -0
- package/src/host/review-signals.ts +9 -6
- package/src/host/settings-rpc.ts +1 -1
- package/templates/reviewer-project.gitlab-ci.yml +90 -0
- package/templates/source-project.gitlab-ci.yml +41 -0
package/src/host/orchestrator.ts
CHANGED
|
@@ -30,8 +30,8 @@ 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
32
|
import type { ReviewFinding, FindingSeverity } from './review-findings-tool.js'
|
|
33
|
-
import type { ReviewRequest } from './events.js'
|
|
34
|
-
import { loadUserConfig, type MaestroUserConfig, type ReviewModelSelection } from './config-store.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.
|
|
@@ -132,6 +135,7 @@ export function buildReviewComment(opts: {
|
|
|
132
135
|
durationMs?: number
|
|
133
136
|
isDiffOnly?: boolean
|
|
134
137
|
isDiscussion?: boolean
|
|
138
|
+
marker?: { sha: string; flow: ReviewFlow }
|
|
135
139
|
}): string {
|
|
136
140
|
const mrUrl = `${opts.gitlabBaseUrl.replace(/\/$/, '')}/${opts.projectPath}/-/merge_requests/${opts.mrIid}`
|
|
137
141
|
const title = opts.isDiffOnly === true ? '## 🤖 Maestro Review — Diff-only' : '## 🤖 Maestro Review'
|
|
@@ -167,10 +171,11 @@ export function buildReviewComment(opts: {
|
|
|
167
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>`
|
|
168
172
|
: ''
|
|
169
173
|
const footer = `\n\n---\n\n<sub>Generated by Maestro · [View MR →](${mrUrl})</sub>`
|
|
170
|
-
|
|
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}`
|
|
171
176
|
}
|
|
172
177
|
|
|
173
|
-
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 {
|
|
174
179
|
const mrUrl = `${opts.gitlabBaseUrl.replace(/\/$/, '')}/${opts.projectPath}/-/merge_requests/${opts.mrIid}`
|
|
175
180
|
return [
|
|
176
181
|
'## 🤖 Maestro Review — Not started',
|
|
@@ -183,6 +188,7 @@ export function buildNotStartedComment(opts: { gitlabBaseUrl: string; projectPat
|
|
|
183
188
|
`---`,
|
|
184
189
|
'',
|
|
185
190
|
`<sub>Generated by Maestro · [View MR →](${mrUrl})</sub>`,
|
|
191
|
+
...(opts.marker === undefined ? [] : [``, `${reviewMarker(opts.marker.sha, opts.marker.flow)}`]),
|
|
186
192
|
].join('\n')
|
|
187
193
|
}
|
|
188
194
|
|
|
@@ -230,14 +236,17 @@ export function getTurnErrorMessage(handle: unknown): string | undefined {
|
|
|
230
236
|
|
|
231
237
|
/**
|
|
232
238
|
* Resolve the model to use for an automated review. Priority: per-project
|
|
233
|
-
* 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`).
|
|
234
242
|
*/
|
|
235
243
|
export function resolveReviewModel(
|
|
236
244
|
userConfig: MaestroUserConfig,
|
|
237
|
-
mapping: { reviewModel?: ReviewModelSelection | null
|
|
245
|
+
mapping: { reviewModel?: ReviewModelSelection | null; projectPath?: string } | undefined,
|
|
238
246
|
fallback: ModelSelection,
|
|
247
|
+
rowSelection?: ReviewModelSelection | null,
|
|
239
248
|
): ModelSelection {
|
|
240
|
-
const raw = (mapping?.reviewModel as ReviewModelSelection | null | undefined) ?? userConfig.reviewModel
|
|
249
|
+
const raw = (mapping?.reviewModel as ReviewModelSelection | null | undefined) ?? userConfig.reviewModel ?? rowSelection
|
|
241
250
|
if (raw === undefined || raw === null) return fallback
|
|
242
251
|
return {
|
|
243
252
|
provider: raw.provider,
|
|
@@ -246,6 +255,17 @@ export function resolveReviewModel(
|
|
|
246
255
|
}
|
|
247
256
|
}
|
|
248
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
|
+
|
|
249
269
|
/** Compose a newly-created agent from the preset service owned by the root context. */
|
|
250
270
|
export async function mountAgentPreset(
|
|
251
271
|
agentPresets: { mount(agentCtx: Context, id: string): Promise<unknown> | unknown },
|
|
@@ -266,6 +286,19 @@ export interface Config {
|
|
|
266
286
|
botUsername: string
|
|
267
287
|
/** Hard ceiling on one automated agent's turn. */
|
|
268
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
|
|
269
302
|
}
|
|
270
303
|
|
|
271
304
|
export const DEFAULT_AGENT_TIMEOUT_MS = 20 * 60_000
|
|
@@ -434,7 +467,7 @@ function normalizedDiffPath(path: string): string {
|
|
|
434
467
|
export async function postReviewFindings(findings: ReviewFinding[], config: GitlabFindingPoster): Promise<void> {
|
|
435
468
|
const fetcher = config.fetcher ?? fetch
|
|
436
469
|
const apiBase = `${config.baseUrl}/api/v4/projects/${config.projectId}/merge_requests/${config.mrIid}`
|
|
437
|
-
const headers = {
|
|
470
|
+
const headers = { ...gitlabAuthHeaders(config.token), 'Content-Type': 'application/json' }
|
|
438
471
|
for (const finding of findings) {
|
|
439
472
|
let response: Response
|
|
440
473
|
const label = severityPrefix(finding.severity)
|
|
@@ -491,7 +524,8 @@ const inFlightKeys = new Set<string>()
|
|
|
491
524
|
|
|
492
525
|
function reviewKey(payload: ReviewRequest): string {
|
|
493
526
|
const scope = payload.scope.kind === 'mr' ? 'mr' : `discussion:${payload.scope.discussionId}`
|
|
494
|
-
|
|
527
|
+
const sha = typeof payload.pushSha === 'string' && payload.pushSha !== '' ? `:${payload.pushSha}` : ''
|
|
528
|
+
return `${payload.projectId}:${payload.mrIid}:${payload.mode}:${scope}:${payload.trigger}${sha}`
|
|
495
529
|
}
|
|
496
530
|
|
|
497
531
|
/**
|
|
@@ -546,11 +580,71 @@ export function auditorOutputFromSession(session: unknown) {
|
|
|
546
580
|
}
|
|
547
581
|
|
|
548
582
|
/** Full review + performance audit; resolves to the comment body that was posted. */
|
|
583
|
+
/** Marker for CI-posted comments; webhook payloads carry no headSha and stay marker-free. */
|
|
584
|
+
function commentMarker(payload: ReviewRequest): { sha: string; flow: ReviewFlow } | undefined {
|
|
585
|
+
if (payload.headSha === undefined) return undefined
|
|
586
|
+
return { sha: payload.headSha, flow: resolveFlow(payload.mode) }
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** CI-deep decision: deep mode + CI env + head SHA (spec §3). Mapping is checked by the caller. */
|
|
590
|
+
export function shouldCiDeepReview(payload: ReviewRequest): boolean {
|
|
591
|
+
return payload.mode === 'deep'
|
|
592
|
+
&& payload.headSha !== undefined
|
|
593
|
+
&& process.env.SOURCE_PROJECT_ID !== undefined
|
|
594
|
+
&& process.env.MR_IID !== undefined
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** CI quick-with-profile decision: quick mode + non-generic profile + CI env
|
|
598
|
+
* + head SHA. Quick + generic/unset stays diff-only (no clone cost); a real
|
|
599
|
+
* profile opts into the clone branch reviewer-only (no auditor — quick never
|
|
600
|
+
* audits, see shouldAudit in runReviewAndAudit). Mapping is checked by the caller. */
|
|
601
|
+
export function shouldCiQuickProfileReview(payload: ReviewRequest): boolean {
|
|
602
|
+
return payload.mode === 'quick'
|
|
603
|
+
&& payload.reviewProfile !== undefined
|
|
604
|
+
&& payload.reviewProfile !== 'generic'
|
|
605
|
+
&& payload.headSha !== undefined
|
|
606
|
+
&& process.env.SOURCE_PROJECT_ID !== undefined
|
|
607
|
+
&& process.env.MR_IID !== undefined
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Auditor degrade for CI (no runtime env): a govard throw becomes reviewer-only, never a failed review. */
|
|
611
|
+
export function withAuditorDegrade(
|
|
612
|
+
runAuditor: (worktreePath: string, payload: ReviewRequest) => Promise<string>,
|
|
613
|
+
): (worktreePath: string, payload: ReviewRequest) => Promise<string> {
|
|
614
|
+
return async (worktreePath, payload) => {
|
|
615
|
+
try {
|
|
616
|
+
return await runAuditor(worktreePath, payload)
|
|
617
|
+
} catch {
|
|
618
|
+
return 'Auditor skipped — no runtime environment in CI (reviewer-only review).'
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Auditor instruction. The mapped flow keeps the full environment + test-suite
|
|
625
|
+
* workflow; the CI flow has no runtime, so the prompt countermands the
|
|
626
|
+
* auditor preset's environment steps and drops the Environment & Test Suite
|
|
627
|
+
* section entirely instead of reporting it "blocked".
|
|
628
|
+
*/
|
|
629
|
+
export function buildAuditorPrompt(opts: { staticOnly: boolean }): string {
|
|
630
|
+
if (!opts.staticOnly) {
|
|
631
|
+
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.'
|
|
632
|
+
}
|
|
633
|
+
return 'Audit this merge request\'s performance from the static diff and checked-out code only. '
|
|
634
|
+
+ 'No runtime environment exists in this container: ignore the auditor preset\'s environment steps '
|
|
635
|
+
+ '(do not bring anything up, do not run the test suite, do not tear anything down). '
|
|
636
|
+
+ 'Look for regressions by static analysis (diff, dependencies, query/shape risks), then write a Markdown report '
|
|
637
|
+
+ 'and OMIT the Environment & Test Suite section entirely — never report it as blocked.'
|
|
638
|
+
}
|
|
639
|
+
|
|
549
640
|
export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndAuditDeps): Promise<string> {
|
|
550
641
|
assertSafeId(payload.projectId, 'projectId')
|
|
551
642
|
assertSafeId(payload.mrIid, 'mrIid')
|
|
552
643
|
const key = reviewKey(payload)
|
|
553
|
-
if (inFlightKeys.has(key))
|
|
644
|
+
if (inFlightKeys.has(key)) {
|
|
645
|
+
console.error(`maestro-orchestrator: deduped duplicate in-flight review key=${key} trigger=${payload.trigger}`)
|
|
646
|
+
return ''
|
|
647
|
+
}
|
|
554
648
|
inFlightKeys.add(key)
|
|
555
649
|
try {
|
|
556
650
|
const worktreePath = await deps.ensureWorktree(deps.localRepoPath, payload.sourceBranch, payload.projectId, payload.mrIid, reviewKeyHash(payload))
|
|
@@ -581,6 +675,7 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
|
|
|
581
675
|
summary: summaryText,
|
|
582
676
|
failures,
|
|
583
677
|
findings: { newCount, replyCount, severityCounts },
|
|
678
|
+
marker: commentMarker(payload),
|
|
584
679
|
})
|
|
585
680
|
: `## 🤖 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>` : ''}`
|
|
586
681
|
sections.push(richOpts)
|
|
@@ -597,7 +692,8 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
|
|
|
597
692
|
try {
|
|
598
693
|
if (payload.scope.kind === 'discussion') await deps.replyToDiscussion(payload.scope.discussionId, body)
|
|
599
694
|
else await deps.postComment(body)
|
|
600
|
-
} catch {
|
|
695
|
+
} catch (err) {
|
|
696
|
+
console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
601
697
|
await deps.writeFailedReport(payload.mrIid, body)
|
|
602
698
|
}
|
|
603
699
|
return body
|
|
@@ -619,7 +715,10 @@ export async function runDiffOnlyReview(payload: ReviewRequest, deps: DiffOnlyRe
|
|
|
619
715
|
assertSafeId(payload.projectId, 'projectId')
|
|
620
716
|
assertSafeId(payload.mrIid, 'mrIid')
|
|
621
717
|
const key = reviewKey(payload)
|
|
622
|
-
if (inFlightKeys.has(key))
|
|
718
|
+
if (inFlightKeys.has(key)) {
|
|
719
|
+
console.error(`maestro-orchestrator: deduped duplicate in-flight review key=${key} trigger=${payload.trigger}`)
|
|
720
|
+
return ''
|
|
721
|
+
}
|
|
623
722
|
inFlightKeys.add(key)
|
|
624
723
|
try {
|
|
625
724
|
const { summary, failures } = await deps.runReviewer(payload)
|
|
@@ -632,13 +731,15 @@ export async function runDiffOnlyReview(payload: ReviewRequest, deps: DiffOnlyRe
|
|
|
632
731
|
summary: summary ?? '',
|
|
633
732
|
failures,
|
|
634
733
|
isDiffOnly: true,
|
|
734
|
+
marker: commentMarker(payload),
|
|
635
735
|
})
|
|
636
736
|
: `## 🤖 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>` : ''}`
|
|
637
737
|
const body = diffBody
|
|
638
738
|
try {
|
|
639
739
|
if (payload.scope.kind === 'discussion') await deps.replyToDiscussion(payload.scope.discussionId, body)
|
|
640
740
|
else await deps.postComment(body)
|
|
641
|
-
} catch {
|
|
741
|
+
} catch (err) {
|
|
742
|
+
console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
642
743
|
await deps.writeFailedReport(payload.mrIid, body)
|
|
643
744
|
}
|
|
644
745
|
return body
|
|
@@ -647,25 +748,33 @@ export async function runDiffOnlyReview(payload: ReviewRequest, deps: DiffOnlyRe
|
|
|
647
748
|
}
|
|
648
749
|
}
|
|
649
750
|
|
|
650
|
-
/** Decline a Deep request that lacks the local mapping it requires.
|
|
651
|
-
|
|
751
|
+
/** Decline a Deep request that lacks the local mapping it requires.
|
|
752
|
+
* Resolves `true` when the decline was posted, `false` when a duplicate
|
|
753
|
+
* in-flight review is already handling this key (caller must not record a
|
|
754
|
+
* completion for work it did not do). */
|
|
755
|
+
export async function declineUnmappedDeepReview(payload: ReviewRequest, deps: ReviewCommentDeps): Promise<boolean> {
|
|
652
756
|
assertSafeId(payload.projectId, 'projectId')
|
|
653
757
|
assertSafeId(payload.mrIid, 'mrIid')
|
|
654
758
|
const key = reviewKey(payload)
|
|
655
|
-
if (inFlightKeys.has(key))
|
|
759
|
+
if (inFlightKeys.has(key)) {
|
|
760
|
+
console.error(`maestro-orchestrator: deduped duplicate in-flight review key=${key} trigger=${payload.trigger}`)
|
|
761
|
+
return false
|
|
762
|
+
}
|
|
656
763
|
inFlightKeys.add(key)
|
|
657
764
|
const depsWithUrl = deps as unknown as { gitlabBaseUrl?: string; projectPath?: string }
|
|
658
765
|
const baseUrl = depsWithUrl.gitlabBaseUrl
|
|
659
766
|
const body = baseUrl !== undefined
|
|
660
|
-
? buildNotStartedComment({ gitlabBaseUrl: baseUrl, projectPath: payload.projectPath, mrIid: payload.mrIid })
|
|
767
|
+
? buildNotStartedComment({ gitlabBaseUrl: baseUrl, projectPath: payload.projectPath, mrIid: payload.mrIid, marker: commentMarker(payload) })
|
|
661
768
|
: '## 🤖 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.'
|
|
662
769
|
try {
|
|
663
770
|
try {
|
|
664
771
|
if (payload.scope.kind === 'discussion') await deps.replyToDiscussion(payload.scope.discussionId, body)
|
|
665
772
|
else await deps.postComment(body)
|
|
666
|
-
} catch {
|
|
773
|
+
} catch (err) {
|
|
774
|
+
console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
667
775
|
await deps.writeFailedReport(payload.mrIid, body)
|
|
668
776
|
}
|
|
777
|
+
return true
|
|
669
778
|
} finally {
|
|
670
779
|
inFlightKeys.delete(key)
|
|
671
780
|
}
|
|
@@ -731,7 +840,7 @@ export async function fetchMrBaseSha(
|
|
|
731
840
|
try {
|
|
732
841
|
const response = await fetcher(
|
|
733
842
|
`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}`,
|
|
734
|
-
{ headers:
|
|
843
|
+
{ headers: gitlabAuthHeaders(token) },
|
|
735
844
|
)
|
|
736
845
|
if (!response.ok) return undefined
|
|
737
846
|
const mr = await response.json() as { diff_refs?: { base_sha?: string } }
|
|
@@ -1039,7 +1148,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1039
1148
|
}
|
|
1040
1149
|
}
|
|
1041
1150
|
|
|
1042
|
-
async function runAuditor(worktreePath: string, payload: ReviewRequest, effective: { gitlabBaseUrl: string; gitlabToken: string; botUsername: string }, modelSelection?: ModelSelection): Promise<string> {
|
|
1151
|
+
async function runAuditor(worktreePath: string, payload: ReviewRequest, effective: { gitlabBaseUrl: string; gitlabToken: string; botUsername: string }, modelSelection?: ModelSelection, opts?: { staticOnly?: boolean }): Promise<string> {
|
|
1043
1152
|
let handle: AgentHandle
|
|
1044
1153
|
const agentOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection())
|
|
1045
1154
|
try {
|
|
@@ -1066,7 +1175,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1066
1175
|
}
|
|
1067
1176
|
ctx.sessionTitle.rename(handle.agent.session, `Maestro Auditor — MR !${payload.mrIid} (${payload.projectPath})`)
|
|
1068
1177
|
try {
|
|
1069
|
-
const prompt =
|
|
1178
|
+
const prompt = buildAuditorPrompt({ staticOnly: opts?.staticOnly === true })
|
|
1070
1179
|
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } }))
|
|
1071
1180
|
await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
|
|
1072
1181
|
const output = auditorOutputFromSession(handle.agent.session)
|
|
@@ -1077,8 +1186,9 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1077
1186
|
}
|
|
1078
1187
|
}
|
|
1079
1188
|
|
|
1080
|
-
|
|
1081
|
-
|
|
1189
|
+
async function runReview(payload: ReviewRequest): Promise<ReviewResult> {
|
|
1190
|
+
const t0 = Date.now()
|
|
1191
|
+
try {
|
|
1082
1192
|
const userConfig = await loadUserConfig()
|
|
1083
1193
|
const effective = {
|
|
1084
1194
|
gitlabBaseUrl: userConfig.gitlabBaseUrl ?? config.gitlabBaseUrl,
|
|
@@ -1096,16 +1206,41 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1096
1206
|
})
|
|
1097
1207
|
}
|
|
1098
1208
|
const mapping = effective.projectMappings.find(m => m.projectPath === payload.projectPath)
|
|
1209
|
+
// CI-deep (spec §3): unmapped deep review with CI env + head SHA skips
|
|
1210
|
+
// both the decline and the diff-only fallback and runs the clone branch
|
|
1211
|
+
// below, which shares the mapped path's completion tail.
|
|
1212
|
+
const ciDeep = mapping === undefined && payload.mode === 'deep' && shouldCiDeepReview(payload)
|
|
1213
|
+
// CI quick-with-profile: unmapped quick + non-generic profile + CI env
|
|
1214
|
+
// skips the diff-only fallback and joins the clone branch below, which
|
|
1215
|
+
// runs reviewer-only (quick never audits).
|
|
1216
|
+
const ciQuickProfile = mapping === undefined && shouldCiQuickProfileReview(payload)
|
|
1099
1217
|
// Unmapped reviewer assignments remain no-ops. Only an explicit mention
|
|
1100
1218
|
// may opt into the intentionally limited, diff-only fallback below.
|
|
1101
|
-
if (mapping === undefined && payload.trigger !== 'mention')
|
|
1219
|
+
if (mapping === undefined && payload.trigger !== 'mention') {
|
|
1220
|
+
console.error(`maestro-orchestrator: drop unmapped project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1221
|
+
return { ok: true, failures: [], durationMs: Date.now() - t0 }
|
|
1222
|
+
}
|
|
1223
|
+
const triggers = resolveReviewTriggers(userConfig, mapping)
|
|
1224
|
+
// Gated-off auto-triggers stay fully silent: no history, no signals, no comment (host log only).
|
|
1225
|
+
if (payload.trigger === 'reviewer-assignment' && !triggers.onAssign) {
|
|
1226
|
+
console.error(`maestro-orchestrator: drop assign-gate-off project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1227
|
+
return { ok: true, failures: [], durationMs: Date.now() - t0 }
|
|
1228
|
+
}
|
|
1229
|
+
if (payload.trigger === 'push' && !triggers.onPush) {
|
|
1230
|
+
console.error(`maestro-orchestrator: drop push-gate-off project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1231
|
+
return { ok: true, failures: [], durationMs: Date.now() - t0 }
|
|
1232
|
+
}
|
|
1102
1233
|
// A push only re-reviews an MR that already has a completed review;
|
|
1103
1234
|
// otherwise every newly opened MR would be reviewed twice.
|
|
1104
|
-
if (payload.trigger === 'push' && !(await hasCompletedReview(payload.projectId, payload.mrIid)))
|
|
1235
|
+
if (payload.trigger === 'push' && !(await hasCompletedReview(payload.projectId, payload.mrIid))) {
|
|
1236
|
+
console.error(`maestro-orchestrator: drop push-no-completed-history project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1237
|
+
return { ok: true, failures: [], durationMs: Date.now() - t0 }
|
|
1238
|
+
}
|
|
1105
1239
|
const { gitlabToken } = effective
|
|
1106
1240
|
if (gitlabToken === undefined) {
|
|
1107
|
-
|
|
1108
|
-
|
|
1241
|
+
const message = `MR !${String(payload.mrIid)} for project ${payload.projectPath} has no GitLab token — set one in Maestro Settings or MAESTRO_GITLAB_TOKEN`
|
|
1242
|
+
console.error(`maestro-orchestrator: drop missing-token project=${payload.projectPath} mr=!${String(payload.mrIid)} trigger=${payload.trigger} — ${message}`)
|
|
1243
|
+
return { ok: false, failures: [message], durationMs: Date.now() - t0 }
|
|
1109
1244
|
}
|
|
1110
1245
|
const resolved = { ...effective, gitlabToken }
|
|
1111
1246
|
const historyId = `${payload.projectId}-${payload.mrIid}-${Date.now()}`
|
|
@@ -1136,7 +1271,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1136
1271
|
const fallbackSelection: ModelSelection = (ctx.get?.('agentDefaultModel') as { currentSelection(): ModelSelection } | undefined)?.currentSelection()
|
|
1137
1272
|
?? (ctx as unknown as { agentDefaultModel?: { currentSelection(): ModelSelection } }).agentDefaultModel?.currentSelection()
|
|
1138
1273
|
?? { provider: 'fallback', model: 'fallback' }
|
|
1139
|
-
const reviewModelSelection = resolveReviewModel(userConfig, mapping, fallbackSelection)
|
|
1274
|
+
const reviewModelSelection = resolveReviewModel(userConfig, mapping, fallbackSelection, config.reviewModel)
|
|
1140
1275
|
// Opt-in Telegram digest; a delivery failure is logged and dropped.
|
|
1141
1276
|
const reviewStartMsOuter = Date.now()
|
|
1142
1277
|
const notifyTelegram = (
|
|
@@ -1190,38 +1325,46 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1190
1325
|
: undefined
|
|
1191
1326
|
await signals?.start()
|
|
1192
1327
|
const postComment = async (body: string) => {
|
|
1193
|
-
const response = await
|
|
1328
|
+
const response = await GitlabClient.fetchWithTimeout(
|
|
1194
1329
|
`${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/notes`,
|
|
1195
1330
|
{
|
|
1196
1331
|
method: 'POST',
|
|
1197
|
-
headers: {
|
|
1332
|
+
headers: { ...gitlabAuthHeaders(resolved.gitlabToken), 'Content-Type': 'application/json' },
|
|
1198
1333
|
body: JSON.stringify({ body }),
|
|
1199
1334
|
},
|
|
1200
1335
|
)
|
|
1201
1336
|
if (!response.ok) throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
1202
1337
|
}
|
|
1203
1338
|
const replyToDiscussion = async (discussionId: string, body: string) => {
|
|
1204
|
-
const response = await
|
|
1339
|
+
const response = await GitlabClient.fetchWithTimeout(
|
|
1205
1340
|
`${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/discussions/${encodeURIComponent(discussionId)}/notes`,
|
|
1206
1341
|
{
|
|
1207
1342
|
method: 'POST',
|
|
1208
|
-
headers: {
|
|
1343
|
+
headers: { ...gitlabAuthHeaders(resolved.gitlabToken), 'Content-Type': 'application/json' },
|
|
1209
1344
|
body: JSON.stringify({ body }),
|
|
1210
1345
|
},
|
|
1211
1346
|
)
|
|
1212
1347
|
if (!response.ok) throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
1213
1348
|
}
|
|
1214
1349
|
try {
|
|
1215
|
-
if (mapping === undefined) {
|
|
1216
|
-
|
|
1217
|
-
|
|
1350
|
+
if (mapping === undefined && !ciDeep && !ciQuickProfile) {
|
|
1351
|
+
// CI-deep (spec §3) skips the decline and falls through to the clone
|
|
1352
|
+
// branch below; every other unmapped deep review still declines here.
|
|
1353
|
+
if (payload.mode === 'deep' && !shouldCiDeepReview(payload)) {
|
|
1354
|
+
const declined = await declineUnmappedDeepReview(payload, { postComment, replyToDiscussion, writeFailedReport, gitlabBaseUrl: resolved.gitlabBaseUrl } as unknown as ReviewCommentDeps)
|
|
1355
|
+
if (!declined) {
|
|
1356
|
+
console.error(`maestro-orchestrator: deduped duplicate in-flight review project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1357
|
+
await recordReviewFinish(historyId, { status: 'completed', summary: 'Duplicate in-flight review deduped — see the active run.' })
|
|
1358
|
+
await signals?.finish('completed')
|
|
1359
|
+
return { ok: true, summary: 'Duplicate in-flight review deduped — see the active run.', failures: [], durationMs: Date.now() - t0 }
|
|
1360
|
+
}
|
|
1218
1361
|
await recordReviewFinish(historyId, { status: 'completed', summary: 'Deep review declined (unmapped project)' })
|
|
1219
1362
|
notifyTelegram('completed', 'Deep review declined (unmapped project)')
|
|
1220
1363
|
await signals?.finish('completed')
|
|
1221
|
-
return
|
|
1364
|
+
return { ok: true, summary: 'Deep review declined (unmapped project)', failures: [], durationMs: Date.now() - t0 }
|
|
1222
1365
|
}
|
|
1223
1366
|
const diffBody = await runDiffOnlyReview(payload, {
|
|
1224
|
-
runReviewer: (p) => runReviewer(undefined, p, resolved,
|
|
1367
|
+
runReviewer: (p) => runReviewer(undefined, p, resolved, payload.reviewProfile, reviewModelSelection, incrementalBlock),
|
|
1225
1368
|
postComment,
|
|
1226
1369
|
replyToDiscussion,
|
|
1227
1370
|
writeFailedReport,
|
|
@@ -1230,9 +1373,47 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1230
1373
|
await recordReviewFinish(historyId, { status: 'completed', summary: summarize(diffBody) })
|
|
1231
1374
|
notifyTelegram('completed', summarize(diffBody))
|
|
1232
1375
|
await signals?.finish('completed')
|
|
1233
|
-
return
|
|
1376
|
+
return { ok: true, summary: summarize(diffBody), failures: [], durationMs: Date.now() - t0 }
|
|
1234
1377
|
}
|
|
1235
1378
|
let fullBody: string
|
|
1379
|
+
if (ciDeep || ciQuickProfile) {
|
|
1380
|
+
// CI-deep (mapping is always undefined here — the unmapped block above
|
|
1381
|
+
// returned for every other case): clone the source at the head SHA,
|
|
1382
|
+
// map it in memory, and reuse the mapped machinery reviewer-only.
|
|
1383
|
+
// CI quick-with-profile joins the same branch; runReviewAndAudit skips
|
|
1384
|
+
// the auditor for quick (shouldAudit), so it stays reviewer-only.
|
|
1385
|
+
// No govard/vendor linking (ciEnsureWorktree is plain fetch + worktree).
|
|
1386
|
+
const ciHost = process.env.GITLAB_HOST?.trim() || new URL(resolved.gitlabBaseUrl).hostname
|
|
1387
|
+
const cloneDir = join('/tmp', `maestro-ci-src-${payload.projectId}-${payload.mrIid}`)
|
|
1388
|
+
await cloneSourceRepo({ fetcher: fetch, run: defaultRun, host: ciHost, projectId: payload.projectId, sourceBranch: payload.sourceBranch, headSha: payload.headSha as string, token: resolved.gitlabToken, dir: cloneDir })
|
|
1389
|
+
try {
|
|
1390
|
+
const ciEnsureWorktree = async (repoPath: string, branch: string, pid: number, iid: number, suffix?: string): Promise<string> => {
|
|
1391
|
+
assertSafeBranchName(branch)
|
|
1392
|
+
const wt = join('/tmp', `maestro-mr-${pid}-${iid}${suffix === undefined ? '' : `-${suffix}`}`)
|
|
1393
|
+
await defaultRun('git', ['fetch', '--depth', '50', 'origin', branch], repoPath)
|
|
1394
|
+
await defaultRun('git', ['worktree', 'remove', '--force', wt], repoPath).catch(() => {})
|
|
1395
|
+
await defaultRun('git', ['worktree', 'add', '--detach', '--', wt, payload.headSha as string], repoPath)
|
|
1396
|
+
return wt
|
|
1397
|
+
}
|
|
1398
|
+
fullBody = await runReviewAndAudit(payload, {
|
|
1399
|
+
localRepoPath: cloneDir,
|
|
1400
|
+
ensureWorktree: ciEnsureWorktree,
|
|
1401
|
+
removeWorktree,
|
|
1402
|
+
runReviewer: (worktreePath, p) => runReviewer(worktreePath, p, resolved, payload.reviewProfile ?? 'generic', reviewModelSelection, incrementalBlock),
|
|
1403
|
+
runAuditor: withAuditorDegrade((worktreePath, p) => runAuditor(worktreePath, p, resolved, reviewModelSelection, { staticOnly: true })),
|
|
1404
|
+
postComment,
|
|
1405
|
+
replyToDiscussion,
|
|
1406
|
+
writeFailedReport,
|
|
1407
|
+
gitlabBaseUrl: resolved.gitlabBaseUrl,
|
|
1408
|
+
reviewProfile: payload.reviewProfile ?? 'generic',
|
|
1409
|
+
} as unknown as ReviewAndAuditDeps)
|
|
1410
|
+
} finally {
|
|
1411
|
+
await defaultRun('rm', ['-rf', cloneDir]).catch(() => {})
|
|
1412
|
+
}
|
|
1413
|
+
} else {
|
|
1414
|
+
// Unreachable when mapping is undefined: the unmapped block returned
|
|
1415
|
+
// for every non-CI-deep case, and ciDeep took the branch above.
|
|
1416
|
+
if (mapping === undefined) throw new Error('unreachable: unmapped review without CI-deep decision')
|
|
1236
1417
|
try {
|
|
1237
1418
|
fullBody = await runReviewAndAudit(payload, {
|
|
1238
1419
|
localRepoPath: mapping.localRepoPath,
|
|
@@ -1250,36 +1431,56 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1250
1431
|
const isBranchNotFound = (err as { code?: string })?.code === 'BRANCH_NOT_FOUND' || isBranchNotFoundError(err)
|
|
1251
1432
|
if (isBranchNotFound) {
|
|
1252
1433
|
const diffBody = await runDiffOnlyReview(payload, {
|
|
1253
|
-
runReviewer: (p) => runReviewer(undefined, p, resolved,
|
|
1434
|
+
runReviewer: (p) => runReviewer(undefined, p, resolved, payload.reviewProfile, reviewModelSelection, incrementalBlock),
|
|
1254
1435
|
postComment,
|
|
1255
1436
|
replyToDiscussion,
|
|
1256
1437
|
writeFailedReport,
|
|
1257
1438
|
gitlabBaseUrl: resolved.gitlabBaseUrl,
|
|
1258
1439
|
} as unknown as DiffOnlyReviewDeps)
|
|
1440
|
+
if (diffBody === '') {
|
|
1441
|
+
console.error(`maestro-orchestrator: deduped duplicate in-flight review project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1442
|
+
await recordReviewFinish(historyId, { status: 'completed', summary: 'Duplicate in-flight review deduped — see the active run.' })
|
|
1443
|
+
await signals?.finish('completed')
|
|
1444
|
+
return { ok: true, summary: 'Duplicate in-flight review deduped — see the active run.', failures: [], durationMs: Date.now() - t0 }
|
|
1445
|
+
}
|
|
1259
1446
|
const summary = summarize(diffBody)
|
|
1260
1447
|
const branchNote = `fallback diff-only: branch ${payload.sourceBranch} not found on origin — ${summary ?? ''}`.trim()
|
|
1261
1448
|
await recordReviewFinish(historyId, { status: 'completed', summary: branchNote })
|
|
1262
1449
|
notifyTelegram('completed', branchNote)
|
|
1263
1450
|
await signals?.finish('completed')
|
|
1264
|
-
return
|
|
1451
|
+
return { ok: true, summary: branchNote, failures: [], durationMs: Date.now() - t0 }
|
|
1265
1452
|
}
|
|
1266
1453
|
throw err
|
|
1267
1454
|
}
|
|
1455
|
+
}
|
|
1456
|
+
if (fullBody === '') {
|
|
1457
|
+
console.error(`maestro-orchestrator: deduped duplicate in-flight review project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1458
|
+
await recordReviewFinish(historyId, { status: 'completed', summary: 'Duplicate in-flight review deduped — see the active run.' })
|
|
1459
|
+
await signals?.finish('completed')
|
|
1460
|
+
return { ok: true, summary: 'Duplicate in-flight review deduped — see the active run.', failures: [], durationMs: Date.now() - t0 }
|
|
1461
|
+
}
|
|
1268
1462
|
await recordReviewFinish(historyId, { status: 'completed', summary: summarize(fullBody) })
|
|
1269
1463
|
notifyTelegram('completed', summarize(fullBody))
|
|
1270
1464
|
await signals?.finish('completed')
|
|
1465
|
+
return { ok: true, summary: summarize(fullBody), failures: [], durationMs: Date.now() - t0 }
|
|
1271
1466
|
} catch (err) {
|
|
1272
1467
|
const message = err instanceof Error ? err.message : String(err)
|
|
1273
1468
|
await recordReviewFinish(historyId, { status: 'failed', error: message }).catch(() => {})
|
|
1274
1469
|
notifyTelegram('failed', message)
|
|
1275
1470
|
await signals?.finish('failed')
|
|
1276
|
-
|
|
1471
|
+
return { ok: false, summary: undefined, failures: [message], durationMs: Date.now() - t0 }
|
|
1277
1472
|
}
|
|
1278
|
-
}
|
|
1473
|
+
} catch (err) {
|
|
1279
1474
|
// Worktree creation, agent creation, and fallback delivery all run from
|
|
1280
|
-
//
|
|
1281
|
-
// fire-and-forget
|
|
1475
|
+
// this function — surface failures as a result instead of an unhandled
|
|
1476
|
+
// rejection, so both the fire-and-forget webhook path and an awaiting
|
|
1477
|
+
// CI caller observe the same outcome.
|
|
1478
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1282
1479
|
console.error(`maestro-orchestrator: review run failed for MR !${String(payload.mrIid)}:`, err)
|
|
1283
|
-
|
|
1284
|
-
|
|
1480
|
+
return { ok: false, summary: undefined, failures: [message], durationMs: Date.now() - t0 }
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
ctx.on('maestro/review-request', (payload) => { void runReview(payload) })
|
|
1485
|
+
ctx.provide('reviewRunner', runReview)
|
|
1285
1486
|
}
|