@ddtcorex/dsh-maestro-review 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) 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 +5 -2
  16. package/lib/orchestrator.d.ts.map +1 -1
  17. package/lib/orchestrator.js +44 -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 +1 -1
  38. package/src/host/ci-coexist.ts +6 -4
  39. package/src/host/events.ts +4 -0
  40. package/src/host/gitlab-client.ts +21 -0
  41. package/src/host/incremental.ts +3 -2
  42. package/src/host/orchestrator.ts +47 -11
  43. package/src/host/providers/ci-trigger.ts +14 -10
  44. package/src/host/providers/gitlab.ts +19 -5
  45. package/src/host/review-history.ts +65 -35
  46. package/src/host/review-intake.ts +36 -1
  47. package/src/host/review-signals.ts +6 -4
  48. package/templates/source-project.gitlab-ci.yml +1 -2
@@ -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(() => {})
@@ -15,8 +15,7 @@ maestro:trigger-review:
15
15
  SOURCE_PROJECT_ID: $CI_PROJECT_ID
16
16
  MR_IID: $CI_MERGE_REQUEST_IID
17
17
  GITLAB_HOST: $CI_SERVER_HOST
18
- SOURCE_SHA: $CI_COMMIT_SHA
19
- REVIEW_MODE: "quick" # or "deep" for a govard audit
18
+ REVIEW_MODE: "quick" # or "deep" for a static-only audit (no govard runtime in CI)
20
19
  REVIEW_PROFILE: "generic" # per-project skill profile: magento2 | laravel | symfony | wordpress | generic
21
20
  # REVIEW_ON_PUSH: "1" # uncomment to re-review every push (default: only the opened review runs)
22
21