@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.
- 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 +5 -2
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +44 -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 +1 -1
- 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 +47 -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/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
|
-
|
|
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(() => {})
|
|
@@ -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
|
-
|
|
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
|
|