@ddtcorex/dsh-maestro-review 0.5.0 → 0.6.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 +2 -1
- package/lib/ci-coexist.d.ts +6 -4
- package/lib/ci-coexist.d.ts.map +1 -1
- package/lib/ci-coexist.js +6 -4
- package/lib/ci-coexist.js.map +1 -1
- package/lib/events.d.ts +4 -0
- package/lib/events.d.ts.map +1 -1
- package/lib/gitlab-client.d.ts +7 -0
- package/lib/gitlab-client.d.ts.map +1 -1
- package/lib/gitlab-client.js +21 -0
- 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 +15 -2
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +61 -11
- package/lib/orchestrator.js.map +1 -1
- package/lib/providers/ci-trigger.d.ts +2 -1
- package/lib/providers/ci-trigger.d.ts.map +1 -1
- package/lib/providers/ci-trigger.js +12 -8
- package/lib/providers/ci-trigger.js.map +1 -1
- package/lib/providers/gitlab.d.ts.map +1 -1
- package/lib/providers/gitlab.js +16 -4
- 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-signals.d.ts.map +1 -1
- package/lib/review-signals.js +9 -3
- package/lib/review-signals.js.map +1 -1
- package/package.json +10 -9
- package/profiles/reviewer-ci/package.json +4 -4
- package/profiles/reviewer-ci/pnpm-lock.yaml +4934 -22
- package/profiles/reviewer-ci/pnpm-workspace.yaml +11 -0
- package/src/host/ci-coexist.ts +6 -4
- package/src/host/events.ts +4 -0
- package/src/host/gitlab-client.ts +21 -0
- package/src/host/incremental.ts +3 -2
- package/src/host/orchestrator.ts +65 -11
- package/src/host/providers/ci-trigger.ts +14 -10
- package/src/host/providers/gitlab.ts +19 -5
- package/src/host/review-history.ts +65 -35
- package/src/host/review-intake.ts +36 -1
- package/src/host/review-signals.ts +6 -4
- package/templates/reviewer-project.gitlab-ci.yml +15 -12
- package/templates/source-project.gitlab-ci.yml +1 -2
|
@@ -7,6 +7,17 @@
|
|
|
7
7
|
# printed "Scope: all 2 workspace projects" and resolved relative to `../..`
|
|
8
8
|
# without this file present).
|
|
9
9
|
packages: []
|
|
10
|
+
# Postinstall scripts required at runtime (node-pty/subprocess-local spawn
|
|
11
|
+
# agents, koffi/protobufjs/genai serve model IO). Exact versions pinned in
|
|
12
|
+
# pnpm-lock.yaml; review on every profile dep bump.
|
|
13
|
+
allowBuilds:
|
|
14
|
+
'@deepseek-ai/dsh-subprocess-local': true
|
|
15
|
+
'@google/genai': true
|
|
16
|
+
koffi: true
|
|
17
|
+
node-pty: true
|
|
18
|
+
protobufjs: true
|
|
10
19
|
minimumReleaseAgeExclude:
|
|
11
20
|
- '@ddtcorex/dsh-maestro-review@0.3.1'
|
|
12
21
|
- '@ddtcorex/maestro-skills@2.11.0'
|
|
22
|
+
- '@ddtcorex/dsh-maestro-review@0.5.1'
|
|
23
|
+
- '@ddtcorex/maestro-skills@2.11.1'
|
package/src/host/ci-coexist.ts
CHANGED
|
@@ -2,10 +2,12 @@ import { gitlabAuthHeaders } from './gitlab-auth.js'
|
|
|
2
2
|
import { parseReviewMarker } from './review-marker.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Coexistence checks (spec §4): the CI flow yields to
|
|
6
|
-
* the same head SHA
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Coexistence checks (spec §4): the CI flow yields to a completed review of
|
|
6
|
+
* the same head SHA carrying a review marker (i.e. an earlier CI review —
|
|
7
|
+
* webhook reviews post marker-free and stay invisible here) and to an
|
|
8
|
+
* in-flight review (eyes running-marker). Fetch failures fail open toward
|
|
9
|
+
* running — a missing signal must never block a review, only a present one
|
|
10
|
+
* skips it.
|
|
9
11
|
*/
|
|
10
12
|
export async function hasCompletedReviewForSha(
|
|
11
13
|
fetcher: typeof fetch, baseUrl: string, token: string, projectId: number, mrIid: number, sha: string,
|
package/src/host/events.ts
CHANGED
|
@@ -30,6 +30,10 @@ export interface ReviewRequest extends MrOpenedPayload {
|
|
|
30
30
|
* webhook producer never sets it — webhook comments stay marker-free.
|
|
31
31
|
*/
|
|
32
32
|
headSha?: string
|
|
33
|
+
/** Short (8-char) head-sha hint for `push` triggers, taken from the webhook
|
|
34
|
+
* body — feeds the in-flight key so two concurrent pushes stop deduping
|
|
35
|
+
* each other. Absent when the body carries no sha. */
|
|
36
|
+
pushSha?: string
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export interface ReviewResult {
|
|
@@ -47,6 +47,27 @@ export function selectFileDiff(diffs: GitlabDiff[], path: string): string | unde
|
|
|
47
47
|
return `--- ${d.old_path}\n+++ ${d.new_path}\n${d.diff}`
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* `fetch` with a hard ceiling: aborts the request after `ms` (default 15s)
|
|
52
|
+
* and throws `GitLab API timeout after <ms>ms` on abort. The timer is always
|
|
53
|
+
* cleared on settle. Pre-agent lookups degrade the throw to `undefined` at
|
|
54
|
+
* their own call site; post-agent callers let it reach `writeFailedReport`.
|
|
55
|
+
*/
|
|
56
|
+
export async function fetchWithTimeout(
|
|
57
|
+
url: string, init: RequestInit | undefined, ms = 15_000,
|
|
58
|
+
): Promise<Response> {
|
|
59
|
+
const controller = new AbortController()
|
|
60
|
+
const timer = setTimeout(() => controller.abort(), ms)
|
|
61
|
+
try {
|
|
62
|
+
return await fetch(url, { ...init, signal: controller.signal })
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if ((err as Error)?.name === 'AbortError') throw new Error(`GitLab API timeout after ${ms}ms`)
|
|
65
|
+
throw err
|
|
66
|
+
} finally {
|
|
67
|
+
clearTimeout(timer)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
50
71
|
interface GitlabDiffRefs {
|
|
51
72
|
base_sha: string
|
|
52
73
|
start_sha: string
|
package/src/host/incremental.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { gitlabAuthHeaders } from './gitlab-auth.js'
|
|
2
|
+
import { fetchWithTimeout } from './gitlab-client.js'
|
|
2
3
|
export interface CompareCommit {
|
|
3
4
|
short_id?: string
|
|
4
5
|
title?: string
|
|
@@ -56,7 +57,7 @@ export async function fetchMrDetailHeadSha(
|
|
|
56
57
|
baseUrl: string, token: string, projectId: number, mrIid: number,
|
|
57
58
|
): Promise<string | undefined> {
|
|
58
59
|
try {
|
|
59
|
-
const response = await
|
|
60
|
+
const response = await fetchWithTimeout(
|
|
60
61
|
`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}`,
|
|
61
62
|
{ headers: gitlabAuthHeaders(token) },
|
|
62
63
|
)
|
|
@@ -73,7 +74,7 @@ export async function fetchCompare(
|
|
|
73
74
|
baseUrl: string, token: string, projectId: number, from: string, to: string,
|
|
74
75
|
): Promise<CompareResult | undefined> {
|
|
75
76
|
try {
|
|
76
|
-
const response = await
|
|
77
|
+
const response = await fetchWithTimeout(
|
|
77
78
|
`${baseUrl}/api/v4/projects/${projectId}/repository/compare?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
|
|
78
79
|
{ headers: gitlabAuthHeaders(token) },
|
|
79
80
|
)
|
package/src/host/orchestrator.ts
CHANGED
|
@@ -234,6 +234,22 @@ export function getTurnErrorMessage(handle: unknown): string | undefined {
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
/**
|
|
238
|
+
* A turn can go idle without ever calling a tool when the model provider
|
|
239
|
+
* itself rejects the request (auth/billing/rate-limit) — `whenIdleWithTimeout`
|
|
240
|
+
* only guards against a hang, not this. Left unchecked, the reviewer's
|
|
241
|
+
* missing-skill-profile check and the auditor's empty-output path both treat
|
|
242
|
+
* this the same as "the agent just didn't call anything", misreporting a
|
|
243
|
+
* provider outage as a maestro-skills installation problem (or, for the
|
|
244
|
+
* auditor, as a silent clean pass). Surface the real turn error immediately.
|
|
245
|
+
*/
|
|
246
|
+
export function assertTurnSucceeded(handle: unknown): void {
|
|
247
|
+
const turnMsg = getTurnErrorMessage(handle)
|
|
248
|
+
if (turnMsg !== undefined) {
|
|
249
|
+
throw new Error(`Review turn failed before completing: ${turnMsg}`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
237
253
|
/**
|
|
238
254
|
* Resolve the model to use for an automated review. Priority: per-project
|
|
239
255
|
* override > global reviewModel (Maestro Settings) > row-config reviewModel
|
|
@@ -524,7 +540,8 @@ const inFlightKeys = new Set<string>()
|
|
|
524
540
|
|
|
525
541
|
function reviewKey(payload: ReviewRequest): string {
|
|
526
542
|
const scope = payload.scope.kind === 'mr' ? 'mr' : `discussion:${payload.scope.discussionId}`
|
|
527
|
-
|
|
543
|
+
const sha = typeof payload.pushSha === 'string' && payload.pushSha !== '' ? `:${payload.pushSha}` : ''
|
|
544
|
+
return `${payload.projectId}:${payload.mrIid}:${payload.mode}:${scope}:${payload.trigger}${sha}`
|
|
528
545
|
}
|
|
529
546
|
|
|
530
547
|
/**
|
|
@@ -640,7 +657,10 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
|
|
|
640
657
|
assertSafeId(payload.projectId, 'projectId')
|
|
641
658
|
assertSafeId(payload.mrIid, 'mrIid')
|
|
642
659
|
const key = reviewKey(payload)
|
|
643
|
-
if (inFlightKeys.has(key))
|
|
660
|
+
if (inFlightKeys.has(key)) {
|
|
661
|
+
console.error(`maestro-orchestrator: deduped duplicate in-flight review key=${key} trigger=${payload.trigger}`)
|
|
662
|
+
return ''
|
|
663
|
+
}
|
|
644
664
|
inFlightKeys.add(key)
|
|
645
665
|
try {
|
|
646
666
|
const worktreePath = await deps.ensureWorktree(deps.localRepoPath, payload.sourceBranch, payload.projectId, payload.mrIid, reviewKeyHash(payload))
|
|
@@ -711,7 +731,10 @@ export async function runDiffOnlyReview(payload: ReviewRequest, deps: DiffOnlyRe
|
|
|
711
731
|
assertSafeId(payload.projectId, 'projectId')
|
|
712
732
|
assertSafeId(payload.mrIid, 'mrIid')
|
|
713
733
|
const key = reviewKey(payload)
|
|
714
|
-
if (inFlightKeys.has(key))
|
|
734
|
+
if (inFlightKeys.has(key)) {
|
|
735
|
+
console.error(`maestro-orchestrator: deduped duplicate in-flight review key=${key} trigger=${payload.trigger}`)
|
|
736
|
+
return ''
|
|
737
|
+
}
|
|
715
738
|
inFlightKeys.add(key)
|
|
716
739
|
try {
|
|
717
740
|
const { summary, failures } = await deps.runReviewer(payload)
|
|
@@ -741,12 +764,18 @@ export async function runDiffOnlyReview(payload: ReviewRequest, deps: DiffOnlyRe
|
|
|
741
764
|
}
|
|
742
765
|
}
|
|
743
766
|
|
|
744
|
-
/** Decline a Deep request that lacks the local mapping it requires.
|
|
745
|
-
|
|
767
|
+
/** Decline a Deep request that lacks the local mapping it requires.
|
|
768
|
+
* Resolves `true` when the decline was posted, `false` when a duplicate
|
|
769
|
+
* in-flight review is already handling this key (caller must not record a
|
|
770
|
+
* completion for work it did not do). */
|
|
771
|
+
export async function declineUnmappedDeepReview(payload: ReviewRequest, deps: ReviewCommentDeps): Promise<boolean> {
|
|
746
772
|
assertSafeId(payload.projectId, 'projectId')
|
|
747
773
|
assertSafeId(payload.mrIid, 'mrIid')
|
|
748
774
|
const key = reviewKey(payload)
|
|
749
|
-
if (inFlightKeys.has(key))
|
|
775
|
+
if (inFlightKeys.has(key)) {
|
|
776
|
+
console.error(`maestro-orchestrator: deduped duplicate in-flight review key=${key} trigger=${payload.trigger}`)
|
|
777
|
+
return false
|
|
778
|
+
}
|
|
750
779
|
inFlightKeys.add(key)
|
|
751
780
|
const depsWithUrl = deps as unknown as { gitlabBaseUrl?: string; projectPath?: string }
|
|
752
781
|
const baseUrl = depsWithUrl.gitlabBaseUrl
|
|
@@ -761,6 +790,7 @@ export async function declineUnmappedDeepReview(payload: ReviewRequest, deps: Re
|
|
|
761
790
|
console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
762
791
|
await deps.writeFailedReport(payload.mrIid, body)
|
|
763
792
|
}
|
|
793
|
+
return true
|
|
764
794
|
} finally {
|
|
765
795
|
inFlightKeys.delete(key)
|
|
766
796
|
}
|
|
@@ -1067,6 +1097,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1067
1097
|
source: { kind: 'user' },
|
|
1068
1098
|
}))
|
|
1069
1099
|
await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
|
|
1100
|
+
assertTurnSucceeded(handle)
|
|
1070
1101
|
if (reviewProfile !== undefined && (reviewerContext === undefined || loadedReviewProfile(reviewerContext) !== reviewProfile)) {
|
|
1071
1102
|
throw new Error(`reviewer did not successfully load the required ${reviewProfile} review skill profile; no findings were posted`)
|
|
1072
1103
|
}
|
|
@@ -1164,6 +1195,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1164
1195
|
const prompt = buildAuditorPrompt({ staticOnly: opts?.staticOnly === true })
|
|
1165
1196
|
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } }))
|
|
1166
1197
|
await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
|
|
1198
|
+
assertTurnSucceeded(handle)
|
|
1167
1199
|
const output = auditorOutputFromSession(handle.agent.session)
|
|
1168
1200
|
const text = output.map(block => ('text' in block ? block.text : '')).join('')
|
|
1169
1201
|
return `## Maestro Performance Audit\n\n${text}`
|
|
@@ -1203,25 +1235,29 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1203
1235
|
// Unmapped reviewer assignments remain no-ops. Only an explicit mention
|
|
1204
1236
|
// may opt into the intentionally limited, diff-only fallback below.
|
|
1205
1237
|
if (mapping === undefined && payload.trigger !== 'mention') {
|
|
1238
|
+
console.error(`maestro-orchestrator: drop unmapped project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1206
1239
|
return { ok: true, failures: [], durationMs: Date.now() - t0 }
|
|
1207
1240
|
}
|
|
1208
1241
|
const triggers = resolveReviewTriggers(userConfig, mapping)
|
|
1209
|
-
// Gated-off auto-triggers stay fully silent: no history, no signals, no comment.
|
|
1242
|
+
// Gated-off auto-triggers stay fully silent: no history, no signals, no comment (host log only).
|
|
1210
1243
|
if (payload.trigger === 'reviewer-assignment' && !triggers.onAssign) {
|
|
1244
|
+
console.error(`maestro-orchestrator: drop assign-gate-off project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1211
1245
|
return { ok: true, failures: [], durationMs: Date.now() - t0 }
|
|
1212
1246
|
}
|
|
1213
1247
|
if (payload.trigger === 'push' && !triggers.onPush) {
|
|
1248
|
+
console.error(`maestro-orchestrator: drop push-gate-off project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1214
1249
|
return { ok: true, failures: [], durationMs: Date.now() - t0 }
|
|
1215
1250
|
}
|
|
1216
1251
|
// A push only re-reviews an MR that already has a completed review;
|
|
1217
1252
|
// otherwise every newly opened MR would be reviewed twice.
|
|
1218
1253
|
if (payload.trigger === 'push' && !(await hasCompletedReview(payload.projectId, payload.mrIid))) {
|
|
1254
|
+
console.error(`maestro-orchestrator: drop push-no-completed-history project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1219
1255
|
return { ok: true, failures: [], durationMs: Date.now() - t0 }
|
|
1220
1256
|
}
|
|
1221
1257
|
const { gitlabToken } = effective
|
|
1222
1258
|
if (gitlabToken === undefined) {
|
|
1223
1259
|
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}`)
|
|
1260
|
+
console.error(`maestro-orchestrator: drop missing-token project=${payload.projectPath} mr=!${String(payload.mrIid)} trigger=${payload.trigger} — ${message}`)
|
|
1225
1261
|
return { ok: false, failures: [message], durationMs: Date.now() - t0 }
|
|
1226
1262
|
}
|
|
1227
1263
|
const resolved = { ...effective, gitlabToken }
|
|
@@ -1307,7 +1343,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1307
1343
|
: undefined
|
|
1308
1344
|
await signals?.start()
|
|
1309
1345
|
const postComment = async (body: string) => {
|
|
1310
|
-
const response = await
|
|
1346
|
+
const response = await GitlabClient.fetchWithTimeout(
|
|
1311
1347
|
`${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/notes`,
|
|
1312
1348
|
{
|
|
1313
1349
|
method: 'POST',
|
|
@@ -1318,7 +1354,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1318
1354
|
if (!response.ok) throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
1319
1355
|
}
|
|
1320
1356
|
const replyToDiscussion = async (discussionId: string, body: string) => {
|
|
1321
|
-
const response = await
|
|
1357
|
+
const response = await GitlabClient.fetchWithTimeout(
|
|
1322
1358
|
`${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/discussions/${encodeURIComponent(discussionId)}/notes`,
|
|
1323
1359
|
{
|
|
1324
1360
|
method: 'POST',
|
|
@@ -1333,7 +1369,13 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1333
1369
|
// CI-deep (spec §3) skips the decline and falls through to the clone
|
|
1334
1370
|
// branch below; every other unmapped deep review still declines here.
|
|
1335
1371
|
if (payload.mode === 'deep' && !shouldCiDeepReview(payload)) {
|
|
1336
|
-
await declineUnmappedDeepReview(payload, { postComment, replyToDiscussion, writeFailedReport, gitlabBaseUrl: resolved.gitlabBaseUrl } as unknown as ReviewCommentDeps)
|
|
1372
|
+
const declined = await declineUnmappedDeepReview(payload, { postComment, replyToDiscussion, writeFailedReport, gitlabBaseUrl: resolved.gitlabBaseUrl } as unknown as ReviewCommentDeps)
|
|
1373
|
+
if (!declined) {
|
|
1374
|
+
console.error(`maestro-orchestrator: deduped duplicate in-flight review project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1375
|
+
await recordReviewFinish(historyId, { status: 'completed', summary: 'Duplicate in-flight review deduped — see the active run.' })
|
|
1376
|
+
await signals?.finish('completed')
|
|
1377
|
+
return { ok: true, summary: 'Duplicate in-flight review deduped — see the active run.', failures: [], durationMs: Date.now() - t0 }
|
|
1378
|
+
}
|
|
1337
1379
|
await recordReviewFinish(historyId, { status: 'completed', summary: 'Deep review declined (unmapped project)' })
|
|
1338
1380
|
notifyTelegram('completed', 'Deep review declined (unmapped project)')
|
|
1339
1381
|
await signals?.finish('completed')
|
|
@@ -1413,6 +1455,12 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1413
1455
|
writeFailedReport,
|
|
1414
1456
|
gitlabBaseUrl: resolved.gitlabBaseUrl,
|
|
1415
1457
|
} as unknown as DiffOnlyReviewDeps)
|
|
1458
|
+
if (diffBody === '') {
|
|
1459
|
+
console.error(`maestro-orchestrator: deduped duplicate in-flight review project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1460
|
+
await recordReviewFinish(historyId, { status: 'completed', summary: 'Duplicate in-flight review deduped — see the active run.' })
|
|
1461
|
+
await signals?.finish('completed')
|
|
1462
|
+
return { ok: true, summary: 'Duplicate in-flight review deduped — see the active run.', failures: [], durationMs: Date.now() - t0 }
|
|
1463
|
+
}
|
|
1416
1464
|
const summary = summarize(diffBody)
|
|
1417
1465
|
const branchNote = `fallback diff-only: branch ${payload.sourceBranch} not found on origin — ${summary ?? ''}`.trim()
|
|
1418
1466
|
await recordReviewFinish(historyId, { status: 'completed', summary: branchNote })
|
|
@@ -1423,6 +1471,12 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
1423
1471
|
throw err
|
|
1424
1472
|
}
|
|
1425
1473
|
}
|
|
1474
|
+
if (fullBody === '') {
|
|
1475
|
+
console.error(`maestro-orchestrator: deduped duplicate in-flight review project=${payload.projectPath} mr=!${payload.mrIid} trigger=${payload.trigger}`)
|
|
1476
|
+
await recordReviewFinish(historyId, { status: 'completed', summary: 'Duplicate in-flight review deduped — see the active run.' })
|
|
1477
|
+
await signals?.finish('completed')
|
|
1478
|
+
return { ok: true, summary: 'Duplicate in-flight review deduped — see the active run.', failures: [], durationMs: Date.now() - t0 }
|
|
1479
|
+
}
|
|
1426
1480
|
await recordReviewFinish(historyId, { status: 'completed', summary: summarize(fullBody) })
|
|
1427
1481
|
notifyTelegram('completed', summarize(fullBody))
|
|
1428
1482
|
await signals?.finish('completed')
|
|
@@ -64,15 +64,19 @@ export async function fetchMrSourceBranch(config: CiEnvConfig, fetcher: typeof f
|
|
|
64
64
|
return (await fetchMrDetail(config, fetcher)).sourceBranch
|
|
65
65
|
}
|
|
66
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 }> {
|
|
67
|
+
/** MR detail pieces CI needs in one call: source branch (worktree) + head SHA (push-gate) + project path (comment header/link). */
|
|
68
|
+
export async function fetchMrDetail(config: CiEnvConfig, fetcher: typeof fetch = fetch): Promise<{ sourceBranch: string; headSha: string; projectPath: string }> {
|
|
69
69
|
const url = `${config.gitlabBaseUrl}/api/v4/projects/${config.sourceProjectId}/merge_requests/${config.mrIid}`
|
|
70
70
|
const res = await fetcher(url, { headers: gitlabAuthHeaders(config.gitlabToken) })
|
|
71
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 }
|
|
72
|
+
const body = await res.json() as { source_branch?: string; sha?: string; references?: { full?: string } }
|
|
73
73
|
if (typeof body.source_branch !== 'string') throw new Error('GitLab merge request response is missing source_branch')
|
|
74
74
|
if (typeof body.sha !== 'string') throw new Error('GitLab merge request response is missing sha')
|
|
75
|
-
|
|
75
|
+
// references.full looks like "group/sub/project!123" — the real path feeds the
|
|
76
|
+
// comment header and View-MR link. Absent on old servers: fall back to project/<id>.
|
|
77
|
+
const full = body.references?.full
|
|
78
|
+
const projectPath = typeof full === 'string' && full.includes('!') ? full.slice(0, full.indexOf('!')) : `project/${config.sourceProjectId}`
|
|
79
|
+
return { sourceBranch: body.source_branch, headSha: body.sha, projectPath }
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
export async function runCiTrigger(
|
|
@@ -84,7 +88,7 @@ export async function runCiTrigger(
|
|
|
84
88
|
history?: { lastCompletedReview(projectId: number, mrIid: number): Promise<{ headSha?: string } | undefined> }
|
|
85
89
|
} = {},
|
|
86
90
|
): Promise<ReviewResult> {
|
|
87
|
-
const { sourceBranch, headSha } = await fetchMrDetail(config, deps.fetcher)
|
|
91
|
+
const { sourceBranch, headSha, projectPath } = await fetchMrDetail(config, deps.fetcher)
|
|
88
92
|
// Push-gate (mirror of the webhook autoRereviewOnPush semantics): the bridge fires
|
|
89
93
|
// on every MR pipeline, so skip when this exact head SHA already completed — and
|
|
90
94
|
// skip new pushes too unless REVIEW_ON_PUSH=1. A re-run then gets the H6 incremental
|
|
@@ -106,12 +110,12 @@ export async function runCiTrigger(
|
|
|
106
110
|
} else if (await hasRunningEyes(coFetcher, config.gitlabBaseUrl, config.gitlabToken, config.sourceProjectId, config.mrIid)) {
|
|
107
111
|
result = { ok: true, summary: 'another review is running — skipping', failures: [], durationMs: 0 }
|
|
108
112
|
} else {
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
+
// The reviewer-ci profile's own projectMappings is always [], so the
|
|
114
|
+
// orchestrator's mapping lookup on projectPath never matches regardless
|
|
115
|
+
// of its exact value — but the real path feeds the comment header and
|
|
116
|
+
// View-MR link, so prefer it over the synthetic project/<id> fallback.
|
|
113
117
|
const request: ReviewRequest = {
|
|
114
|
-
projectPath
|
|
118
|
+
projectPath,
|
|
115
119
|
projectId: config.sourceProjectId,
|
|
116
120
|
mrIid: config.mrIid,
|
|
117
121
|
sourceBranch,
|
|
@@ -4,7 +4,7 @@ import z from '@deepseek-ai/schemastery'
|
|
|
4
4
|
import { loadUserConfig } from '../config-store.js'
|
|
5
5
|
import { secretsMatch } from '../secure-compare.js'
|
|
6
6
|
import type { MrOpenedPayload, ReviewRequest as OrchestratorReviewRequest } from '../events.js'
|
|
7
|
-
import { routeGitlabReviewRequest } from '../review-intake.js'
|
|
7
|
+
import { describeDrop, routeGitlabReviewRequest } from '../review-intake.js'
|
|
8
8
|
import type { ReviewProvider, ReviewRequest } from './interface.js'
|
|
9
9
|
|
|
10
10
|
export const name = 'maestro-review-webhook'
|
|
@@ -127,10 +127,24 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
127
127
|
}
|
|
128
128
|
let body: GitlabMrWebhookBody; try { body = JSON.parse(raw) } catch { res.writeHead(400).end(); return }
|
|
129
129
|
const userConfig = await loadUserConfig()
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
const botUsername = userConfig.botUsername ?? config.botUsername ?? 'maestro'
|
|
131
|
+
if ((body.object_kind === 'merge_request' || body.object_kind === 'note') && !hasValidGitlabMrIdentity(body)) {
|
|
132
|
+
console.error(`maestro-review-webhook: drop invalid-identity object_kind=${(body as { object_kind?: unknown }).object_kind ?? 'unknown'}`)
|
|
133
|
+
res.writeHead(400).end(); return
|
|
134
|
+
}
|
|
135
|
+
// The orchestrator is the sole push gate (resolveReviewTriggers);
|
|
136
|
+
// intake always emits so per-project overrides keep working.
|
|
137
|
+
const request = routeGitlabReviewRequest(body, botUsername, { pushEnabled: true })
|
|
138
|
+
if (request === undefined) {
|
|
139
|
+
const reason = describeDrop(body, botUsername, { pushEnabled: true })
|
|
140
|
+
if (reason !== undefined) {
|
|
141
|
+
const project = (body as { project?: { path_with_namespace?: unknown } }).project
|
|
142
|
+
const source = (body as { object_attributes?: { iid?: unknown } }).object_attributes
|
|
143
|
+
console.error(`maestro-review-webhook: drop ${reason} project=${typeof project?.path_with_namespace === 'string' ? project.path_with_namespace : 'unknown'} mr=!${typeof source?.iid === 'number' ? source.iid : '?'}`)
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
ctx.emit('maestro/review-request', request)
|
|
147
|
+
}
|
|
134
148
|
res.writeHead(200).end()
|
|
135
149
|
})
|
|
136
150
|
}
|
|
@@ -64,10 +64,12 @@ function failStaleRunning(entries: ReviewHistoryEntry[]): boolean {
|
|
|
64
64
|
|
|
65
65
|
/** Prepend a running entry; the oldest entries fall off past `HISTORY_CAP`. */
|
|
66
66
|
export async function recordReviewStart(entry: Omit<ReviewHistoryEntry, 'status'>, dshHome?: string): Promise<void> {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
return withHistoryLock(async () => {
|
|
68
|
+
const all = await readAll(dshHome)
|
|
69
|
+
if (failStaleRunning(all)) await writeAll(all, dshHome)
|
|
70
|
+
all.unshift({ ...entry, status: 'running' })
|
|
71
|
+
await writeAll(all.slice(0, HISTORY_CAP), dshHome)
|
|
72
|
+
})
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
/** Attach an outcome to the entry with this id; unknown ids are ignored. */
|
|
@@ -76,11 +78,13 @@ export async function recordReviewFinish(
|
|
|
76
78
|
patch: { status: 'completed' | 'failed'; summary?: string; error?: string },
|
|
77
79
|
dshHome?: string,
|
|
78
80
|
): Promise<void> {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
return withHistoryLock(async () => {
|
|
82
|
+
const all = await readAll(dshHome)
|
|
83
|
+
const entry = all.find((candidate) => candidate.id === id)
|
|
84
|
+
if (entry === undefined) return
|
|
85
|
+
Object.assign(entry, patch, { finishedAt: Date.now() })
|
|
86
|
+
await writeAll(all, dshHome)
|
|
87
|
+
})
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
/** Newest-first slice of the log. */
|
|
@@ -103,36 +107,62 @@ export async function lastCompletedReview(projectId: number, mrIid: number, dshH
|
|
|
103
107
|
/**
|
|
104
108
|
* Drop history entries and failed-report files older than `retentionDays`.
|
|
105
109
|
* `0` disables pruning entirely. Maestro records only — DSH session
|
|
106
|
-
* transcripts are never touched.
|
|
110
|
+
* transcripts are never touched. Entries still `running` are always kept:
|
|
111
|
+
* a prune must never delete a review that just started (see D3).
|
|
107
112
|
*/
|
|
108
113
|
export async function pruneHistory(retentionDays: number, dshHome?: string): Promise<void> {
|
|
109
114
|
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
return withHistoryLock(async () => {
|
|
116
|
+
const all = await readAll(dshHome)
|
|
117
|
+
failStaleRunning(all)
|
|
118
|
+
const cutoff = Date.now() - retentionDays * DAY_MS
|
|
119
|
+
const kept: ReviewHistoryEntry[] = []
|
|
120
|
+
for (const entry of all) {
|
|
121
|
+
if (entry.status === 'running') {
|
|
122
|
+
kept.push(entry)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if ((entry.finishedAt ?? entry.startedAt) < cutoff) continue
|
|
126
|
+
kept.push(entry)
|
|
127
|
+
}
|
|
128
|
+
await writeAll(kept, dshHome)
|
|
129
|
+
}).then(async () => {
|
|
130
|
+
const home = dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
131
|
+
const failedDir = join(home, 'maestro', 'failed-reports')
|
|
132
|
+
const cutoff = Date.now() - retentionDays * DAY_MS
|
|
133
|
+
let names: string[]
|
|
134
|
+
try {
|
|
135
|
+
names = await readdir(failedDir)
|
|
136
|
+
} catch {
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
await Promise.all(names.map(async (name) => {
|
|
140
|
+
// Failed reports are named `<mrIid>-<epoch-ms>.md`.
|
|
141
|
+
const match = /^-?\d+-(\d{13,})\.md$/.exec(name)
|
|
142
|
+
if (match === null) return
|
|
143
|
+
const stamp = Number(match[1])
|
|
144
|
+
if (!Number.isFinite(stamp) || stamp >= cutoff) return
|
|
145
|
+
await rm(join(failedDir, name), { force: true }).catch(() => {})
|
|
146
|
+
}))
|
|
147
|
+
})
|
|
148
|
+
}
|
|
119
149
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
150
|
+
/**
|
|
151
|
+
* In-process promise-chain mutex serializing the read-modify-write cycles of
|
|
152
|
+
* `recordReviewStart` / `recordReviewFinish` / `pruneHistory`, so a
|
|
153
|
+
* fire-and-forget `void pruneHistory()` can no longer clobber an entry that
|
|
154
|
+
* started concurrently (last-writer-wins on a stale read). Two `dsh web`
|
|
155
|
+
* processes sharing one `DSH_HOME` is unsupported, so a cross-process file
|
|
156
|
+
* lock is deliberately out of scope.
|
|
157
|
+
*/
|
|
158
|
+
let historyMutex: Promise<void> = Promise.resolve()
|
|
159
|
+
function withHistoryLock<T>(work: () => Promise<T>): Promise<T> {
|
|
160
|
+
const next = historyMutex.then(work, work)
|
|
161
|
+
historyMutex = next.then(
|
|
162
|
+
() => undefined,
|
|
163
|
+
() => undefined,
|
|
164
|
+
)
|
|
165
|
+
return next
|
|
136
166
|
}
|
|
137
167
|
|
|
138
168
|
const DAY_MS = 24 * 60 * 60_000
|
|
@@ -35,6 +35,19 @@ function isMentioned(text: string, botUsername: string): boolean {
|
|
|
35
35
|
return new RegExp(`(^|[^A-Za-z0-9_.-])@${escaped}(?=$|[^A-Za-z0-9_.-])`, 'i').test(text)
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Short head-sha hint for the in-flight key: prefers `last_commit.id`, falls
|
|
40
|
+
* back to `checkout_sha`. Returns `undefined` when the body carries no sha.
|
|
41
|
+
*/
|
|
42
|
+
function shortPushSha(attributes: JsonRecord): string | undefined {
|
|
43
|
+
const lastCommit = record(attributes.last_commit)
|
|
44
|
+
const raw = typeof lastCommit?.id === 'string' ? lastCommit.id
|
|
45
|
+
: typeof attributes.checkout_sha === 'string' ? attributes.checkout_sha
|
|
46
|
+
: undefined
|
|
47
|
+
if (raw === undefined || raw.trim() === '') return undefined
|
|
48
|
+
return raw.slice(0, 8)
|
|
49
|
+
}
|
|
50
|
+
|
|
38
51
|
function noteScope(attributes: JsonRecord): ReviewScope | undefined {
|
|
39
52
|
const position = record(attributes.position)
|
|
40
53
|
if (position === undefined) return { kind: 'mr' }
|
|
@@ -49,6 +62,28 @@ export interface RouteOptions {
|
|
|
49
62
|
pushEnabled?: boolean
|
|
50
63
|
}
|
|
51
64
|
|
|
65
|
+
/** Actionable provider-layer drop reasons. Generic non-review GitLab noise
|
|
66
|
+
* (closes, label edits, ...) intentionally has no reason and stays silent. */
|
|
67
|
+
export type DropReason = 'invalid-identity' | 'push-gate-off'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Name why this webhook body will NOT route at the provider layer, or
|
|
71
|
+
* `undefined` when it routes (or is generic noise the provider ignores).
|
|
72
|
+
* Pure — the provider logs the returned reason with project/mr context.
|
|
73
|
+
*/
|
|
74
|
+
export function describeDrop(body: unknown, botUsername: string, options: RouteOptions = {}): DropReason | undefined {
|
|
75
|
+
const value = record(body)
|
|
76
|
+
if (value === undefined || botUsername.trim() === '') return 'invalid-identity'
|
|
77
|
+
const payload = mrPayload(value)
|
|
78
|
+
if (payload === undefined) return 'invalid-identity'
|
|
79
|
+
if (value.object_kind === 'merge_request') {
|
|
80
|
+
const attributes = record(value.object_attributes)
|
|
81
|
+
const hasNewCommits = typeof attributes?.oldrev === 'string' && attributes.oldrev !== ''
|
|
82
|
+
if (hasNewCommits && options.pushEnabled !== true) return 'push-gate-off'
|
|
83
|
+
}
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
52
87
|
export function routeGitlabReviewRequest(body: unknown, botUsername: string, options: RouteOptions = {}): ReviewRequest | undefined {
|
|
53
88
|
const value = record(body)
|
|
54
89
|
if (value === undefined || botUsername.trim() === '') return undefined
|
|
@@ -65,7 +100,7 @@ export function routeGitlabReviewRequest(body: unknown, botUsername: string, opt
|
|
|
65
100
|
if (options.pushEnabled === true) {
|
|
66
101
|
const hasNewCommits = typeof attributes.oldrev === 'string' && attributes.oldrev !== ''
|
|
67
102
|
if (hasNewCommits) {
|
|
68
|
-
return { ...payload, trigger: 'push', mode: 'quick', scope: { kind: 'mr' } }
|
|
103
|
+
return { ...payload, trigger: 'push', mode: 'quick', scope: { kind: 'mr' }, pushSha: shortPushSha(attributes) }
|
|
69
104
|
}
|
|
70
105
|
}
|
|
71
106
|
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import { gitlabAuthHeaders } from './gitlab-auth.js'
|
|
2
1
|
/**
|
|
3
2
|
* MR award-emoji acknowledgements: the MR shows "eyes" while a review runs and
|
|
4
3
|
* a final marker when it completes or fails. Signalling is best-effort — any
|
|
5
4
|
* GitLab error is swallowed so it can never fail the review itself.
|
|
6
5
|
*/
|
|
7
6
|
|
|
7
|
+
import { gitlabAuthHeaders } from './gitlab-auth.js'
|
|
8
|
+
import { fetchWithTimeout } from './gitlab-client.js'
|
|
9
|
+
|
|
8
10
|
export interface ReviewSignals {
|
|
9
11
|
start(): Promise<void>
|
|
10
12
|
finish(outcome: 'completed' | 'failed'): Promise<void>
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
async function award(baseUrl: string, token: string, projectId: number, mrIid: number, name: string): Promise<void> {
|
|
14
|
-
await
|
|
16
|
+
await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji`, {
|
|
15
17
|
method: 'POST',
|
|
16
18
|
headers: { ...gitlabAuthHeaders(token), 'Content-Type': 'application/json' },
|
|
17
19
|
body: JSON.stringify({ name }),
|
|
@@ -20,14 +22,14 @@ async function award(baseUrl: string, token: string, projectId: number, mrIid: n
|
|
|
20
22
|
|
|
21
23
|
/** Remove only this bot's stale running markers; other users' awards stay untouched. */
|
|
22
24
|
async function unawardOwn(baseUrl: string, token: string, projectId: number, mrIid: number, botUsername: string): Promise<void> {
|
|
23
|
-
const response = await
|
|
25
|
+
const response = await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji`, {
|
|
24
26
|
headers: gitlabAuthHeaders(token),
|
|
25
27
|
})
|
|
26
28
|
if (!response.ok) return
|
|
27
29
|
const awards = (await response.json()) as Array<{ id?: number; name?: string; user?: { username?: string } }>
|
|
28
30
|
for (const awardItem of Array.isArray(awards) ? awards : []) {
|
|
29
31
|
if (awardItem.name !== 'eyes' || awardItem.user?.username !== botUsername || typeof awardItem.id !== 'number') continue
|
|
30
|
-
await
|
|
32
|
+
await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji/${awardItem.id}`, {
|
|
31
33
|
method: 'DELETE',
|
|
32
34
|
headers: gitlabAuthHeaders(token),
|
|
33
35
|
}).catch(() => {})
|