@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.
Files changed (52) hide show
  1. package/README.md +2 -1
  2. package/lib/ci-coexist.d.ts +6 -4
  3. package/lib/ci-coexist.d.ts.map +1 -1
  4. package/lib/ci-coexist.js +6 -4
  5. package/lib/ci-coexist.js.map +1 -1
  6. package/lib/events.d.ts +4 -0
  7. package/lib/events.d.ts.map +1 -1
  8. package/lib/gitlab-client.d.ts +7 -0
  9. package/lib/gitlab-client.d.ts.map +1 -1
  10. package/lib/gitlab-client.js +21 -0
  11. package/lib/gitlab-client.js.map +1 -1
  12. package/lib/incremental.d.ts.map +1 -1
  13. package/lib/incremental.js +3 -2
  14. package/lib/incremental.js.map +1 -1
  15. package/lib/orchestrator.d.ts +15 -2
  16. package/lib/orchestrator.d.ts.map +1 -1
  17. package/lib/orchestrator.js +61 -11
  18. package/lib/orchestrator.js.map +1 -1
  19. package/lib/providers/ci-trigger.d.ts +2 -1
  20. package/lib/providers/ci-trigger.d.ts.map +1 -1
  21. package/lib/providers/ci-trigger.js +12 -8
  22. package/lib/providers/ci-trigger.js.map +1 -1
  23. package/lib/providers/gitlab.d.ts.map +1 -1
  24. package/lib/providers/gitlab.js +16 -4
  25. package/lib/providers/gitlab.js.map +1 -1
  26. package/lib/review-history.d.ts +2 -1
  27. package/lib/review-history.d.ts.map +1 -1
  28. package/lib/review-history.js +67 -40
  29. package/lib/review-history.js.map +1 -1
  30. package/lib/review-intake.d.ts +9 -0
  31. package/lib/review-intake.d.ts.map +1 -1
  32. package/lib/review-intake.js +34 -1
  33. package/lib/review-intake.js.map +1 -1
  34. package/lib/review-signals.d.ts.map +1 -1
  35. package/lib/review-signals.js +9 -3
  36. package/lib/review-signals.js.map +1 -1
  37. package/package.json +10 -9
  38. package/profiles/reviewer-ci/package.json +4 -4
  39. package/profiles/reviewer-ci/pnpm-lock.yaml +4934 -22
  40. package/profiles/reviewer-ci/pnpm-workspace.yaml +11 -0
  41. package/src/host/ci-coexist.ts +6 -4
  42. package/src/host/events.ts +4 -0
  43. package/src/host/gitlab-client.ts +21 -0
  44. package/src/host/incremental.ts +3 -2
  45. package/src/host/orchestrator.ts +65 -11
  46. package/src/host/providers/ci-trigger.ts +14 -10
  47. package/src/host/providers/gitlab.ts +19 -5
  48. package/src/host/review-history.ts +65 -35
  49. package/src/host/review-intake.ts +36 -1
  50. package/src/host/review-signals.ts +6 -4
  51. package/templates/reviewer-project.gitlab-ci.yml +15 -12
  52. 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'
@@ -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 any completed review of
6
- * the same head SHA (whatever flow posted it) and to an in-flight review
7
- * (eyes running-marker). Fetch failures fail open toward running — a missing
8
- * signal must never block a review, only a present one skips it.
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,
@@ -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
@@ -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 fetch(
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 fetch(
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
  )
@@ -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
- return `${payload.projectId}:${payload.mrIid}:${payload.mode}:${scope}`
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)) return ''
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)) return ''
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
- export async function declineUnmappedDeepReview(payload: ReviewRequest, deps: ReviewCommentDeps): Promise<void> {
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)) return
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 fetch(
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 fetch(
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
- return { sourceBranch: body.source_branch, headSha: body.sha }
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
- // 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
+ // 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: `project/${config.sourceProjectId}`,
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
- if ((body.object_kind === 'merge_request' || body.object_kind === 'note') && !hasValidGitlabMrIdentity(body)) { res.writeHead(400).end(); return }
131
- // The orchestrator is the sole push gate (resolveReviewTriggers); intake always emits.
132
- const request = routeGitlabReviewRequest(body, userConfig.botUsername ?? config.botUsername ?? 'maestro', { pushEnabled: true })
133
- if (request !== undefined) ctx.emit('maestro/review-request', request)
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
- const all = await readAll(dshHome)
68
- if (failStaleRunning(all)) await writeAll(all, dshHome)
69
- all.unshift({ ...entry, status: 'running' })
70
- await writeAll(all.slice(0, HISTORY_CAP), dshHome)
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
- const all = await readAll(dshHome)
80
- const entry = all.find((candidate) => candidate.id === id)
81
- if (entry === undefined) return
82
- Object.assign(entry, patch, { finishedAt: Date.now() })
83
- await writeAll(all, dshHome)
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
- const all = await readAll(dshHome)
111
- failStaleRunning(all)
112
- const cutoff = Date.now() - retentionDays * DAY_MS
113
- const kept: ReviewHistoryEntry[] = []
114
- for (const entry of all) {
115
- if ((entry.finishedAt ?? entry.startedAt) < cutoff) continue
116
- kept.push(entry)
117
- }
118
- await writeAll(kept, dshHome)
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
- const home = dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')
121
- const failedDir = join(home, 'maestro', 'failed-reports')
122
- let names: string[]
123
- try {
124
- names = await readdir(failedDir)
125
- } catch {
126
- return
127
- }
128
- await Promise.all(names.map(async (name) => {
129
- // Failed reports are named `<mrIid>-<epoch-ms>.md`.
130
- const match = /^-?\d+-(\d{13,})\.md$/.exec(name)
131
- if (match === null) return
132
- const stamp = Number(match[1])
133
- if (!Number.isFinite(stamp) || stamp >= cutoff) return
134
- await rm(join(failedDir, name), { force: true }).catch(() => {})
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 fetch(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji`, {
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 fetch(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji`, {
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 fetch(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji/${awardItem.id}`, {
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(() => {})