@ddtcorex/dsh-maestro-review 0.4.0 โ 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.map +1 -1
- package/lib/gitlab-client.js +2 -1
- package/lib/gitlab-client.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 +51 -4
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +201 -59
- 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-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 +2 -1
- package/src/host/incremental.ts +3 -2
- package/src/host/orchestrator.ts +202 -37
- package/src/host/providers/ci-trigger.ts +164 -0
- package/src/host/providers/gitlab.ts +2 -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
|
@@ -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)
|
|
@@ -546,6 +579,63 @@ export function auditorOutputFromSession(session: unknown) {
|
|
|
546
579
|
}
|
|
547
580
|
|
|
548
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
|
+
|
|
549
639
|
export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndAuditDeps): Promise<string> {
|
|
550
640
|
assertSafeId(payload.projectId, 'projectId')
|
|
551
641
|
assertSafeId(payload.mrIid, 'mrIid')
|
|
@@ -581,6 +671,7 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
|
|
|
581
671
|
summary: summaryText,
|
|
582
672
|
failures,
|
|
583
673
|
findings: { newCount, replyCount, severityCounts },
|
|
674
|
+
marker: commentMarker(payload),
|
|
584
675
|
})
|
|
585
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>` : ''}`
|
|
586
677
|
sections.push(richOpts)
|
|
@@ -597,7 +688,8 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
|
|
|
597
688
|
try {
|
|
598
689
|
if (payload.scope.kind === 'discussion') await deps.replyToDiscussion(payload.scope.discussionId, body)
|
|
599
690
|
else await deps.postComment(body)
|
|
600
|
-
} catch {
|
|
691
|
+
} catch (err) {
|
|
692
|
+
console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
601
693
|
await deps.writeFailedReport(payload.mrIid, body)
|
|
602
694
|
}
|
|
603
695
|
return body
|
|
@@ -632,13 +724,15 @@ export async function runDiffOnlyReview(payload: ReviewRequest, deps: DiffOnlyRe
|
|
|
632
724
|
summary: summary ?? '',
|
|
633
725
|
failures,
|
|
634
726
|
isDiffOnly: true,
|
|
727
|
+
marker: commentMarker(payload),
|
|
635
728
|
})
|
|
636
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>` : ''}`
|
|
637
730
|
const body = diffBody
|
|
638
731
|
try {
|
|
639
732
|
if (payload.scope.kind === 'discussion') await deps.replyToDiscussion(payload.scope.discussionId, body)
|
|
640
733
|
else await deps.postComment(body)
|
|
641
|
-
} catch {
|
|
734
|
+
} catch (err) {
|
|
735
|
+
console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
642
736
|
await deps.writeFailedReport(payload.mrIid, body)
|
|
643
737
|
}
|
|
644
738
|
return body
|
|
@@ -657,13 +751,14 @@ export async function declineUnmappedDeepReview(payload: ReviewRequest, deps: Re
|
|
|
657
751
|
const depsWithUrl = deps as unknown as { gitlabBaseUrl?: string; projectPath?: string }
|
|
658
752
|
const baseUrl = depsWithUrl.gitlabBaseUrl
|
|
659
753
|
const body = baseUrl !== undefined
|
|
660
|
-
? buildNotStartedComment({ gitlabBaseUrl: baseUrl, projectPath: payload.projectPath, mrIid: payload.mrIid })
|
|
754
|
+
? buildNotStartedComment({ gitlabBaseUrl: baseUrl, projectPath: payload.projectPath, mrIid: payload.mrIid, marker: commentMarker(payload) })
|
|
661
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.'
|
|
662
756
|
try {
|
|
663
757
|
try {
|
|
664
758
|
if (payload.scope.kind === 'discussion') await deps.replyToDiscussion(payload.scope.discussionId, body)
|
|
665
759
|
else await deps.postComment(body)
|
|
666
|
-
} catch {
|
|
760
|
+
} catch (err) {
|
|
761
|
+
console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
667
762
|
await deps.writeFailedReport(payload.mrIid, body)
|
|
668
763
|
}
|
|
669
764
|
} finally {
|
|
@@ -731,7 +826,7 @@ export async function fetchMrBaseSha(
|
|
|
731
826
|
try {
|
|
732
827
|
const response = await fetcher(
|
|
733
828
|
`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}`,
|
|
734
|
-
{ headers:
|
|
829
|
+
{ headers: gitlabAuthHeaders(token) },
|
|
735
830
|
)
|
|
736
831
|
if (!response.ok) return undefined
|
|
737
832
|
const mr = await response.json() as { diff_refs?: { base_sha?: string } }
|
|
@@ -1039,7 +1134,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1039
1134
|
}
|
|
1040
1135
|
}
|
|
1041
1136
|
|
|
1042
|
-
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> {
|
|
1043
1138
|
let handle: AgentHandle
|
|
1044
1139
|
const agentOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection())
|
|
1045
1140
|
try {
|
|
@@ -1066,7 +1161,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1066
1161
|
}
|
|
1067
1162
|
ctx.sessionTitle.rename(handle.agent.session, `Maestro Auditor โ MR !${payload.mrIid} (${payload.projectPath})`)
|
|
1068
1163
|
try {
|
|
1069
|
-
const prompt =
|
|
1164
|
+
const prompt = buildAuditorPrompt({ staticOnly: opts?.staticOnly === true })
|
|
1070
1165
|
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } }))
|
|
1071
1166
|
await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
|
|
1072
1167
|
const output = auditorOutputFromSession(handle.agent.session)
|
|
@@ -1077,8 +1172,9 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1077
1172
|
}
|
|
1078
1173
|
}
|
|
1079
1174
|
|
|
1080
|
-
|
|
1081
|
-
|
|
1175
|
+
async function runReview(payload: ReviewRequest): Promise<ReviewResult> {
|
|
1176
|
+
const t0 = Date.now()
|
|
1177
|
+
try {
|
|
1082
1178
|
const userConfig = await loadUserConfig()
|
|
1083
1179
|
const effective = {
|
|
1084
1180
|
gitlabBaseUrl: userConfig.gitlabBaseUrl ?? config.gitlabBaseUrl,
|
|
@@ -1096,16 +1192,37 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1096
1192
|
})
|
|
1097
1193
|
}
|
|
1098
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)
|
|
1099
1203
|
// Unmapped reviewer assignments remain no-ops. Only an explicit mention
|
|
1100
1204
|
// may opt into the intentionally limited, diff-only fallback below.
|
|
1101
|
-
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
|
+
}
|
|
1102
1216
|
// A push only re-reviews an MR that already has a completed review;
|
|
1103
1217
|
// otherwise every newly opened MR would be reviewed twice.
|
|
1104
|
-
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
|
+
}
|
|
1105
1221
|
const { gitlabToken } = effective
|
|
1106
1222
|
if (gitlabToken === undefined) {
|
|
1107
|
-
|
|
1108
|
-
|
|
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 }
|
|
1109
1226
|
}
|
|
1110
1227
|
const resolved = { ...effective, gitlabToken }
|
|
1111
1228
|
const historyId = `${payload.projectId}-${payload.mrIid}-${Date.now()}`
|
|
@@ -1136,7 +1253,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1136
1253
|
const fallbackSelection: ModelSelection = (ctx.get?.('agentDefaultModel') as { currentSelection(): ModelSelection } | undefined)?.currentSelection()
|
|
1137
1254
|
?? (ctx as unknown as { agentDefaultModel?: { currentSelection(): ModelSelection } }).agentDefaultModel?.currentSelection()
|
|
1138
1255
|
?? { provider: 'fallback', model: 'fallback' }
|
|
1139
|
-
const reviewModelSelection = resolveReviewModel(userConfig, mapping, fallbackSelection)
|
|
1256
|
+
const reviewModelSelection = resolveReviewModel(userConfig, mapping, fallbackSelection, config.reviewModel)
|
|
1140
1257
|
// Opt-in Telegram digest; a delivery failure is logged and dropped.
|
|
1141
1258
|
const reviewStartMsOuter = Date.now()
|
|
1142
1259
|
const notifyTelegram = (
|
|
@@ -1194,7 +1311,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1194
1311
|
`${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/notes`,
|
|
1195
1312
|
{
|
|
1196
1313
|
method: 'POST',
|
|
1197
|
-
headers: {
|
|
1314
|
+
headers: { ...gitlabAuthHeaders(resolved.gitlabToken), 'Content-Type': 'application/json' },
|
|
1198
1315
|
body: JSON.stringify({ body }),
|
|
1199
1316
|
},
|
|
1200
1317
|
)
|
|
@@ -1205,23 +1322,25 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1205
1322
|
`${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/discussions/${encodeURIComponent(discussionId)}/notes`,
|
|
1206
1323
|
{
|
|
1207
1324
|
method: 'POST',
|
|
1208
|
-
headers: {
|
|
1325
|
+
headers: { ...gitlabAuthHeaders(resolved.gitlabToken), 'Content-Type': 'application/json' },
|
|
1209
1326
|
body: JSON.stringify({ body }),
|
|
1210
1327
|
},
|
|
1211
1328
|
)
|
|
1212
1329
|
if (!response.ok) throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
1213
1330
|
}
|
|
1214
1331
|
try {
|
|
1215
|
-
if (mapping === undefined) {
|
|
1216
|
-
|
|
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)) {
|
|
1217
1336
|
await declineUnmappedDeepReview(payload, { postComment, replyToDiscussion, writeFailedReport, gitlabBaseUrl: resolved.gitlabBaseUrl } as unknown as ReviewCommentDeps)
|
|
1218
1337
|
await recordReviewFinish(historyId, { status: 'completed', summary: 'Deep review declined (unmapped project)' })
|
|
1219
1338
|
notifyTelegram('completed', 'Deep review declined (unmapped project)')
|
|
1220
1339
|
await signals?.finish('completed')
|
|
1221
|
-
return
|
|
1340
|
+
return { ok: true, summary: 'Deep review declined (unmapped project)', failures: [], durationMs: Date.now() - t0 }
|
|
1222
1341
|
}
|
|
1223
1342
|
const diffBody = await runDiffOnlyReview(payload, {
|
|
1224
|
-
runReviewer: (p) => runReviewer(undefined, p, resolved,
|
|
1343
|
+
runReviewer: (p) => runReviewer(undefined, p, resolved, payload.reviewProfile, reviewModelSelection, incrementalBlock),
|
|
1225
1344
|
postComment,
|
|
1226
1345
|
replyToDiscussion,
|
|
1227
1346
|
writeFailedReport,
|
|
@@ -1230,9 +1349,47 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1230
1349
|
await recordReviewFinish(historyId, { status: 'completed', summary: summarize(diffBody) })
|
|
1231
1350
|
notifyTelegram('completed', summarize(diffBody))
|
|
1232
1351
|
await signals?.finish('completed')
|
|
1233
|
-
return
|
|
1352
|
+
return { ok: true, summary: summarize(diffBody), failures: [], durationMs: Date.now() - t0 }
|
|
1234
1353
|
}
|
|
1235
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')
|
|
1236
1393
|
try {
|
|
1237
1394
|
fullBody = await runReviewAndAudit(payload, {
|
|
1238
1395
|
localRepoPath: mapping.localRepoPath,
|
|
@@ -1250,7 +1407,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1250
1407
|
const isBranchNotFound = (err as { code?: string })?.code === 'BRANCH_NOT_FOUND' || isBranchNotFoundError(err)
|
|
1251
1408
|
if (isBranchNotFound) {
|
|
1252
1409
|
const diffBody = await runDiffOnlyReview(payload, {
|
|
1253
|
-
runReviewer: (p) => runReviewer(undefined, p, resolved,
|
|
1410
|
+
runReviewer: (p) => runReviewer(undefined, p, resolved, payload.reviewProfile, reviewModelSelection, incrementalBlock),
|
|
1254
1411
|
postComment,
|
|
1255
1412
|
replyToDiscussion,
|
|
1256
1413
|
writeFailedReport,
|
|
@@ -1261,25 +1418,33 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1261
1418
|
await recordReviewFinish(historyId, { status: 'completed', summary: branchNote })
|
|
1262
1419
|
notifyTelegram('completed', branchNote)
|
|
1263
1420
|
await signals?.finish('completed')
|
|
1264
|
-
return
|
|
1421
|
+
return { ok: true, summary: branchNote, failures: [], durationMs: Date.now() - t0 }
|
|
1265
1422
|
}
|
|
1266
1423
|
throw err
|
|
1267
1424
|
}
|
|
1425
|
+
}
|
|
1268
1426
|
await recordReviewFinish(historyId, { status: 'completed', summary: summarize(fullBody) })
|
|
1269
1427
|
notifyTelegram('completed', summarize(fullBody))
|
|
1270
1428
|
await signals?.finish('completed')
|
|
1429
|
+
return { ok: true, summary: summarize(fullBody), failures: [], durationMs: Date.now() - t0 }
|
|
1271
1430
|
} catch (err) {
|
|
1272
1431
|
const message = err instanceof Error ? err.message : String(err)
|
|
1273
1432
|
await recordReviewFinish(historyId, { status: 'failed', error: message }).catch(() => {})
|
|
1274
1433
|
notifyTelegram('failed', message)
|
|
1275
1434
|
await signals?.finish('failed')
|
|
1276
|
-
|
|
1435
|
+
return { ok: false, summary: undefined, failures: [message], durationMs: Date.now() - t0 }
|
|
1277
1436
|
}
|
|
1278
|
-
}
|
|
1437
|
+
} catch (err) {
|
|
1279
1438
|
// Worktree creation, agent creation, and fallback delivery all run from
|
|
1280
|
-
//
|
|
1281
|
-
// 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)
|
|
1282
1443
|
console.error(`maestro-orchestrator: review run failed for MR !${String(payload.mrIid)}:`, err)
|
|
1283
|
-
|
|
1284
|
-
|
|
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)
|
|
1285
1450
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import z from '@deepseek-ai/schemastery'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import type { ReviewRequest, ReviewResult } from '../events.js'
|
|
5
|
+
import { lastCompletedReview } from '../review-history.js'
|
|
6
|
+
import type { ReviewSkillProfile } from '../skills-tool.js'
|
|
7
|
+
import { gitlabAuthHeaders } from '../gitlab-auth.js'
|
|
8
|
+
import { hasCompletedReviewForSha, hasRunningEyes } from '../ci-coexist.js'
|
|
9
|
+
|
|
10
|
+
export const name = 'maestro-review-ci-trigger'
|
|
11
|
+
export const inject = ['reviewRunner'] as const
|
|
12
|
+
|
|
13
|
+
const REVIEW_PROFILES = ['magento2', 'laravel', 'symfony', 'wordpress', 'generic'] as const
|
|
14
|
+
|
|
15
|
+
export interface CiEnvConfig {
|
|
16
|
+
gitlabBaseUrl: string
|
|
17
|
+
gitlabToken: string
|
|
18
|
+
sourceProjectId: number
|
|
19
|
+
mrIid: number
|
|
20
|
+
mode: 'quick' | 'deep'
|
|
21
|
+
dryRun: boolean
|
|
22
|
+
reviewProfile?: ReviewSkillProfile
|
|
23
|
+
/** Mirror of Settings UI autoRereviewOnPush (default off): re-review new pushes. */
|
|
24
|
+
rereviewOnPush: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const CiEnvConfig: z<CiEnvConfig> = z.object({
|
|
28
|
+
gitlabBaseUrl: z.string().required(),
|
|
29
|
+
gitlabToken: z.string().role('secret').required(),
|
|
30
|
+
sourceProjectId: z.number().required(),
|
|
31
|
+
mrIid: z.number().required(),
|
|
32
|
+
mode: z.union([z.const('quick'), z.const('deep')]).default('quick'),
|
|
33
|
+
dryRun: z.boolean().default(false),
|
|
34
|
+
reviewProfile: z.union([z.const('magento2'), z.const('laravel'), z.const('symfony'), z.const('wordpress'), z.const('generic')]),
|
|
35
|
+
rereviewOnPush: z.boolean().default(false),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reads the CI contract env vars (spec ยง5.2) โ not Cordis-supplied config, since these are
|
|
40
|
+
* per-run values a static profile config cannot hold. schemastery's `.required()` only rejects
|
|
41
|
+
* `undefined`, not an empty string, so GITLAB_HOST/MAESTRO_GITLAB_TOKEN presence is checked here
|
|
42
|
+
* before defaulting to `''` โ otherwise a missing env var would silently pass validation.
|
|
43
|
+
*/
|
|
44
|
+
export function parseCiEnvConfig(env: Record<string, string | undefined>): CiEnvConfig {
|
|
45
|
+
if (env.GITLAB_HOST === undefined) throw new Error('missing GITLAB_HOST')
|
|
46
|
+
if (env.MAESTRO_GITLAB_TOKEN === undefined) throw new Error('missing MAESTRO_GITLAB_TOKEN')
|
|
47
|
+
if (env.REVIEW_PROFILE !== undefined && !(REVIEW_PROFILES as readonly string[]).includes(env.REVIEW_PROFILE)) {
|
|
48
|
+
throw new Error(`unsupported REVIEW_PROFILE "${env.REVIEW_PROFILE}" (supported: ${REVIEW_PROFILES.join(', ')})`)
|
|
49
|
+
}
|
|
50
|
+
return CiEnvConfig({
|
|
51
|
+
gitlabBaseUrl: `https://${env.GITLAB_HOST}`,
|
|
52
|
+
gitlabToken: env.MAESTRO_GITLAB_TOKEN,
|
|
53
|
+
sourceProjectId: Number(env.SOURCE_PROJECT_ID),
|
|
54
|
+
mrIid: Number(env.MR_IID),
|
|
55
|
+
mode: env.REVIEW_MODE === 'deep' ? 'deep' : 'quick',
|
|
56
|
+
dryRun: env.REVIEW_DRY_RUN === '1',
|
|
57
|
+
reviewProfile: env.REVIEW_PROFILE as ReviewSkillProfile | undefined,
|
|
58
|
+
rereviewOnPush: env.REVIEW_ON_PUSH === '1',
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The one field a webhook payload carries that env vars don't โ orchestrator fetches the diff itself. */
|
|
63
|
+
export async function fetchMrSourceBranch(config: CiEnvConfig, fetcher: typeof fetch = fetch): Promise<string> {
|
|
64
|
+
return (await fetchMrDetail(config, fetcher)).sourceBranch
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** MR detail pieces CI needs in one call: source branch (worktree) + head SHA (push-gate). */
|
|
68
|
+
export async function fetchMrDetail(config: CiEnvConfig, fetcher: typeof fetch = fetch): Promise<{ sourceBranch: string; headSha: string }> {
|
|
69
|
+
const url = `${config.gitlabBaseUrl}/api/v4/projects/${config.sourceProjectId}/merge_requests/${config.mrIid}`
|
|
70
|
+
const res = await fetcher(url, { headers: gitlabAuthHeaders(config.gitlabToken) })
|
|
71
|
+
if (!res.ok) throw new Error(`GitLab API error ${res.status}: ${await res.text()}`)
|
|
72
|
+
const body = await res.json() as { source_branch?: string; sha?: string }
|
|
73
|
+
if (typeof body.source_branch !== 'string') throw new Error('GitLab merge request response is missing source_branch')
|
|
74
|
+
if (typeof body.sha !== 'string') throw new Error('GitLab merge request response is missing sha')
|
|
75
|
+
return { sourceBranch: body.source_branch, headSha: body.sha }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function runCiTrigger(
|
|
79
|
+
ctx: Context,
|
|
80
|
+
config: CiEnvConfig,
|
|
81
|
+
deps: {
|
|
82
|
+
fetcher?: typeof fetch
|
|
83
|
+
writeFile?: typeof import('node:fs/promises').writeFile
|
|
84
|
+
history?: { lastCompletedReview(projectId: number, mrIid: number): Promise<{ headSha?: string } | undefined> }
|
|
85
|
+
} = {},
|
|
86
|
+
): Promise<ReviewResult> {
|
|
87
|
+
const { sourceBranch, headSha } = await fetchMrDetail(config, deps.fetcher)
|
|
88
|
+
// Push-gate (mirror of the webhook autoRereviewOnPush semantics): the bridge fires
|
|
89
|
+
// on every MR pipeline, so skip when this exact head SHA already completed โ and
|
|
90
|
+
// skip new pushes too unless REVIEW_ON_PUSH=1. A re-run then gets the H6 incremental
|
|
91
|
+
// block from history for free. Cache miss (no history) fails open toward running.
|
|
92
|
+
const history = deps.history ?? { lastCompletedReview }
|
|
93
|
+
const prior = await history.lastCompletedReview(config.sourceProjectId, config.mrIid)
|
|
94
|
+
let result: ReviewResult
|
|
95
|
+
if (prior?.headSha === headSha) {
|
|
96
|
+
result = { ok: true, summary: `already reviewed at ${headSha}, skipping`, failures: [], durationMs: 0 }
|
|
97
|
+
} else if (prior !== undefined && !config.rereviewOnPush) {
|
|
98
|
+
result = { ok: true, summary: `new commits since ${prior.headSha} but REVIEW_ON_PUSH is not set, skipping`, failures: [], durationMs: 0 }
|
|
99
|
+
} else {
|
|
100
|
+
// Coexistence (CI yields to webhook): the CI history store is invisible to
|
|
101
|
+
// the webhook flow and vice versa, so check the MR itself for a completed
|
|
102
|
+
// review marker of this SHA or an in-flight eyes marker before booting.
|
|
103
|
+
const coFetcher = deps.fetcher ?? fetch
|
|
104
|
+
if (await hasCompletedReviewForSha(coFetcher, config.gitlabBaseUrl, config.gitlabToken, config.sourceProjectId, config.mrIid, headSha)) {
|
|
105
|
+
result = { ok: true, summary: `webhook already reviewed ${headSha} โ skipping`, failures: [], durationMs: 0 }
|
|
106
|
+
} else if (await hasRunningEyes(coFetcher, config.gitlabBaseUrl, config.gitlabToken, config.sourceProjectId, config.mrIid)) {
|
|
107
|
+
result = { ok: true, summary: 'another review is running โ skipping', failures: [], durationMs: 0 }
|
|
108
|
+
} else {
|
|
109
|
+
// projectPath is synthetic (no webhook payload to read it from) โ harmless: the
|
|
110
|
+
// reviewer-ci profile's own projectMappings is always [], so orchestrator's
|
|
111
|
+
// mapping lookup on projectPath never matches regardless of its exact value
|
|
112
|
+
// (same precedent as the PR #52 runner it replaces).
|
|
113
|
+
const request: ReviewRequest = {
|
|
114
|
+
projectPath: `project/${config.sourceProjectId}`,
|
|
115
|
+
projectId: config.sourceProjectId,
|
|
116
|
+
mrIid: config.mrIid,
|
|
117
|
+
sourceBranch,
|
|
118
|
+
headSha,
|
|
119
|
+
trigger: 'mention',
|
|
120
|
+
mode: config.mode,
|
|
121
|
+
scope: { kind: 'mr' },
|
|
122
|
+
reviewProfile: config.reviewProfile,
|
|
123
|
+
}
|
|
124
|
+
result = await ctx.reviewRunner(request)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const { writeFile } = deps.writeFile !== undefined ? { writeFile: deps.writeFile } : await import('node:fs/promises')
|
|
128
|
+
// entrypoint.sh cds into deepseek-harness before execing dsh, so a bare relative
|
|
129
|
+
// path would land inside the harness checkout (lost with the container). The
|
|
130
|
+
// entrypoint exports REVIEW_REPORT_DIR=$PWD captured before the cd, so reports land
|
|
131
|
+
// in the CI job's working directory where `artifacts:` picks them up.
|
|
132
|
+
const reportDir = process.env.REVIEW_REPORT_DIR?.trim() || '.'
|
|
133
|
+
const report = { ...result, projectId: config.sourceProjectId, mrIid: config.mrIid, mode: config.mode, generatedAt: new Date().toISOString() }
|
|
134
|
+
await writeFile(join(reportDir, 'review-report.json'), JSON.stringify(report, null, 2), 'utf-8')
|
|
135
|
+
const md = `# Review Report\n\n**Project:** ${config.sourceProjectId} !${config.mrIid}\n**Mode:** ${config.mode}\n**Duration:** ${result.durationMs}ms\n\n${result.summary ?? '(no summary)'}\n${result.failures.length > 0 ? `\nFailures:\n${result.failures.map(f => `- ${f}`).join('\n')}\n` : ''}`
|
|
136
|
+
await writeFile(join(reportDir, 'review-report.md'), md, 'utf-8')
|
|
137
|
+
return result
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function apply(ctx: Context): void {
|
|
141
|
+
ctx.effect(() => {
|
|
142
|
+
void (async () => {
|
|
143
|
+
try {
|
|
144
|
+
const config = parseCiEnvConfig(process.env)
|
|
145
|
+
console.log(`[review] ci-trigger starting: project ${config.sourceProjectId} !${config.mrIid} mode=${config.mode} dryRun=${config.dryRun}`)
|
|
146
|
+
const result = config.dryRun
|
|
147
|
+
? { ok: true, summary: '[dry-run] config valid, skipping review', failures: [], durationMs: 0 }
|
|
148
|
+
: await runCiTrigger(ctx, config)
|
|
149
|
+
console.log(`[review] ci-trigger finished: ok=${result.ok} summary=${JSON.stringify(result.summary)} failures=${result.failures.length}`)
|
|
150
|
+
for (const failure of result.failures) console.error(`[review] ci-trigger failure: ${failure}`)
|
|
151
|
+
const exit = ctx.get('appExit')
|
|
152
|
+
if (exit === undefined) {
|
|
153
|
+
console.error('[review] ctx.get(\'appExit\') is undefined โ the launcher did not provide it, process cannot signal its real exit code')
|
|
154
|
+
} else {
|
|
155
|
+
exit(result.ok ? 0 : 1)
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error('maestro-review-ci-trigger:', err instanceof Error ? err.message : String(err))
|
|
159
|
+
ctx.get('appExit')?.(1)
|
|
160
|
+
}
|
|
161
|
+
})()
|
|
162
|
+
return () => {}
|
|
163
|
+
}, 'maestro-review-ci-trigger run')
|
|
164
|
+
}
|
|
@@ -128,7 +128,8 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
128
128
|
let body: GitlabMrWebhookBody; try { body = JSON.parse(raw) } catch { res.writeHead(400).end(); return }
|
|
129
129
|
const userConfig = await loadUserConfig()
|
|
130
130
|
if ((body.object_kind === 'merge_request' || body.object_kind === 'note') && !hasValidGitlabMrIdentity(body)) { res.writeHead(400).end(); return }
|
|
131
|
-
|
|
131
|
+
// The orchestrator is the sole push gate (resolveReviewTriggers); intake always emits.
|
|
132
|
+
const request = routeGitlabReviewRequest(body, userConfig.botUsername ?? config.botUsername ?? 'maestro', { pushEnabled: true })
|
|
132
133
|
if (request !== undefined) ctx.emit('maestro/review-request', request)
|
|
133
134
|
res.writeHead(200).end()
|
|
134
135
|
})
|