@ddtcorex/dsh-maestro-review 0.4.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 +13 -0
- package/cordis.patch.yml +18 -0
- package/lib/ci-clone.d.ts +21 -0
- package/lib/ci-clone.d.ts.map +1 -0
- package/lib/ci-clone.js +35 -0
- package/lib/ci-clone.js.map +1 -0
- package/lib/ci-coexist.d.ts +11 -0
- package/lib/ci-coexist.d.ts.map +1 -0
- package/lib/ci-coexist.js +38 -0
- package/lib/ci-coexist.js.map +1 -0
- package/lib/config-store.d.ts +13 -6
- package/lib/config-store.d.ts.map +1 -1
- package/lib/config-store.js.map +1 -1
- package/lib/events.d.ts +27 -0
- package/lib/events.d.ts.map +1 -1
- package/lib/gitlab-auth.d.ts +11 -0
- package/lib/gitlab-auth.d.ts.map +1 -0
- package/lib/gitlab-auth.js +15 -0
- package/lib/gitlab-auth.js.map +1 -0
- package/lib/gitlab-client.d.ts +7 -0
- package/lib/gitlab-client.d.ts.map +1 -1
- package/lib/gitlab-client.js +23 -1
- package/lib/gitlab-client.js.map +1 -1
- package/lib/incremental.d.ts.map +1 -1
- package/lib/incremental.js +4 -2
- package/lib/incremental.js.map +1 -1
- package/lib/orchestrator.d.ts +56 -6
- package/lib/orchestrator.d.ts.map +1 -1
- package/lib/orchestrator.js +243 -68
- package/lib/orchestrator.js.map +1 -1
- package/lib/providers/ci-trigger.d.ts +44 -0
- package/lib/providers/ci-trigger.d.ts.map +1 -0
- package/lib/providers/ci-trigger.js +150 -0
- package/lib/providers/ci-trigger.js.map +1 -0
- package/lib/providers/gitlab.d.ts.map +1 -1
- package/lib/providers/gitlab.js +16 -3
- 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-marker.d.ts +17 -0
- package/lib/review-marker.d.ts.map +1 -0
- package/lib/review-marker.js +23 -0
- package/lib/review-marker.js.map +1 -0
- package/lib/review-signals.d.ts.map +1 -1
- package/lib/review-signals.js +8 -6
- package/lib/review-signals.js.map +1 -1
- package/lib/settings-rpc.js +1 -1
- package/lib/settings-rpc.js.map +1 -1
- package/package.json +14 -9
- package/profiles/reviewer-ci/cordis.patch.yml +61 -0
- package/profiles/reviewer-ci/package.json +19 -0
- package/profiles/reviewer-ci/pnpm-lock.yaml +62 -0
- package/profiles/reviewer-ci/pnpm-workspace.yaml +12 -0
- package/src/host/ci-clone.ts +54 -0
- package/src/host/ci-coexist.ts +45 -0
- package/src/host/config-store.ts +14 -1
- package/src/host/events.ts +29 -0
- package/src/host/gitlab-auth.ts +13 -0
- package/src/host/gitlab-client.ts +23 -1
- package/src/host/incremental.ts +6 -4
- package/src/host/orchestrator.ts +247 -46
- package/src/host/providers/ci-trigger.ts +168 -0
- package/src/host/providers/gitlab.ts +19 -4
- package/src/host/review-history.ts +65 -35
- package/src/host/review-intake.ts +36 -1
- package/src/host/review-marker.ts +25 -0
- package/src/host/review-signals.ts +9 -6
- package/src/host/settings-rpc.ts +1 -1
- package/templates/reviewer-project.gitlab-ci.yml +90 -0
- package/templates/source-project.gitlab-ci.yml +41 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import z from '@deepseek-ai/schemastery'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import type { ReviewRequest, ReviewResult } from '../events.js'
|
|
5
|
+
import { lastCompletedReview } from '../review-history.js'
|
|
6
|
+
import type { ReviewSkillProfile } from '../skills-tool.js'
|
|
7
|
+
import { gitlabAuthHeaders } from '../gitlab-auth.js'
|
|
8
|
+
import { hasCompletedReviewForSha, hasRunningEyes } from '../ci-coexist.js'
|
|
9
|
+
|
|
10
|
+
export const name = 'maestro-review-ci-trigger'
|
|
11
|
+
export const inject = ['reviewRunner'] as const
|
|
12
|
+
|
|
13
|
+
const REVIEW_PROFILES = ['magento2', 'laravel', 'symfony', 'wordpress', 'generic'] as const
|
|
14
|
+
|
|
15
|
+
export interface CiEnvConfig {
|
|
16
|
+
gitlabBaseUrl: string
|
|
17
|
+
gitlabToken: string
|
|
18
|
+
sourceProjectId: number
|
|
19
|
+
mrIid: number
|
|
20
|
+
mode: 'quick' | 'deep'
|
|
21
|
+
dryRun: boolean
|
|
22
|
+
reviewProfile?: ReviewSkillProfile
|
|
23
|
+
/** Mirror of Settings UI autoRereviewOnPush (default off): re-review new pushes. */
|
|
24
|
+
rereviewOnPush: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const CiEnvConfig: z<CiEnvConfig> = z.object({
|
|
28
|
+
gitlabBaseUrl: z.string().required(),
|
|
29
|
+
gitlabToken: z.string().role('secret').required(),
|
|
30
|
+
sourceProjectId: z.number().required(),
|
|
31
|
+
mrIid: z.number().required(),
|
|
32
|
+
mode: z.union([z.const('quick'), z.const('deep')]).default('quick'),
|
|
33
|
+
dryRun: z.boolean().default(false),
|
|
34
|
+
reviewProfile: z.union([z.const('magento2'), z.const('laravel'), z.const('symfony'), z.const('wordpress'), z.const('generic')]),
|
|
35
|
+
rereviewOnPush: z.boolean().default(false),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reads the CI contract env vars (spec §5.2) — not Cordis-supplied config, since these are
|
|
40
|
+
* per-run values a static profile config cannot hold. schemastery's `.required()` only rejects
|
|
41
|
+
* `undefined`, not an empty string, so GITLAB_HOST/MAESTRO_GITLAB_TOKEN presence is checked here
|
|
42
|
+
* before defaulting to `''` — otherwise a missing env var would silently pass validation.
|
|
43
|
+
*/
|
|
44
|
+
export function parseCiEnvConfig(env: Record<string, string | undefined>): CiEnvConfig {
|
|
45
|
+
if (env.GITLAB_HOST === undefined) throw new Error('missing GITLAB_HOST')
|
|
46
|
+
if (env.MAESTRO_GITLAB_TOKEN === undefined) throw new Error('missing MAESTRO_GITLAB_TOKEN')
|
|
47
|
+
if (env.REVIEW_PROFILE !== undefined && !(REVIEW_PROFILES as readonly string[]).includes(env.REVIEW_PROFILE)) {
|
|
48
|
+
throw new Error(`unsupported REVIEW_PROFILE "${env.REVIEW_PROFILE}" (supported: ${REVIEW_PROFILES.join(', ')})`)
|
|
49
|
+
}
|
|
50
|
+
return CiEnvConfig({
|
|
51
|
+
gitlabBaseUrl: `https://${env.GITLAB_HOST}`,
|
|
52
|
+
gitlabToken: env.MAESTRO_GITLAB_TOKEN,
|
|
53
|
+
sourceProjectId: Number(env.SOURCE_PROJECT_ID),
|
|
54
|
+
mrIid: Number(env.MR_IID),
|
|
55
|
+
mode: env.REVIEW_MODE === 'deep' ? 'deep' : 'quick',
|
|
56
|
+
dryRun: env.REVIEW_DRY_RUN === '1',
|
|
57
|
+
reviewProfile: env.REVIEW_PROFILE as ReviewSkillProfile | undefined,
|
|
58
|
+
rereviewOnPush: env.REVIEW_ON_PUSH === '1',
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The one field a webhook payload carries that env vars don't — orchestrator fetches the diff itself. */
|
|
63
|
+
export async function fetchMrSourceBranch(config: CiEnvConfig, fetcher: typeof fetch = fetch): Promise<string> {
|
|
64
|
+
return (await fetchMrDetail(config, fetcher)).sourceBranch
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** MR detail pieces CI needs in one call: source branch (worktree) + head SHA (push-gate) + project path (comment header/link). */
|
|
68
|
+
export async function fetchMrDetail(config: CiEnvConfig, fetcher: typeof fetch = fetch): Promise<{ sourceBranch: string; headSha: string; projectPath: string }> {
|
|
69
|
+
const url = `${config.gitlabBaseUrl}/api/v4/projects/${config.sourceProjectId}/merge_requests/${config.mrIid}`
|
|
70
|
+
const res = await fetcher(url, { headers: gitlabAuthHeaders(config.gitlabToken) })
|
|
71
|
+
if (!res.ok) throw new Error(`GitLab API error ${res.status}: ${await res.text()}`)
|
|
72
|
+
const body = await res.json() as { source_branch?: string; sha?: string; references?: { full?: string } }
|
|
73
|
+
if (typeof body.source_branch !== 'string') throw new Error('GitLab merge request response is missing source_branch')
|
|
74
|
+
if (typeof body.sha !== 'string') throw new Error('GitLab merge request response is missing sha')
|
|
75
|
+
// 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 }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function runCiTrigger(
|
|
83
|
+
ctx: Context,
|
|
84
|
+
config: CiEnvConfig,
|
|
85
|
+
deps: {
|
|
86
|
+
fetcher?: typeof fetch
|
|
87
|
+
writeFile?: typeof import('node:fs/promises').writeFile
|
|
88
|
+
history?: { lastCompletedReview(projectId: number, mrIid: number): Promise<{ headSha?: string } | undefined> }
|
|
89
|
+
} = {},
|
|
90
|
+
): Promise<ReviewResult> {
|
|
91
|
+
const { sourceBranch, headSha, projectPath } = await fetchMrDetail(config, deps.fetcher)
|
|
92
|
+
// Push-gate (mirror of the webhook autoRereviewOnPush semantics): the bridge fires
|
|
93
|
+
// on every MR pipeline, so skip when this exact head SHA already completed — and
|
|
94
|
+
// skip new pushes too unless REVIEW_ON_PUSH=1. A re-run then gets the H6 incremental
|
|
95
|
+
// block from history for free. Cache miss (no history) fails open toward running.
|
|
96
|
+
const history = deps.history ?? { lastCompletedReview }
|
|
97
|
+
const prior = await history.lastCompletedReview(config.sourceProjectId, config.mrIid)
|
|
98
|
+
let result: ReviewResult
|
|
99
|
+
if (prior?.headSha === headSha) {
|
|
100
|
+
result = { ok: true, summary: `already reviewed at ${headSha}, skipping`, failures: [], durationMs: 0 }
|
|
101
|
+
} else if (prior !== undefined && !config.rereviewOnPush) {
|
|
102
|
+
result = { ok: true, summary: `new commits since ${prior.headSha} but REVIEW_ON_PUSH is not set, skipping`, failures: [], durationMs: 0 }
|
|
103
|
+
} else {
|
|
104
|
+
// Coexistence (CI yields to webhook): the CI history store is invisible to
|
|
105
|
+
// the webhook flow and vice versa, so check the MR itself for a completed
|
|
106
|
+
// review marker of this SHA or an in-flight eyes marker before booting.
|
|
107
|
+
const coFetcher = deps.fetcher ?? fetch
|
|
108
|
+
if (await hasCompletedReviewForSha(coFetcher, config.gitlabBaseUrl, config.gitlabToken, config.sourceProjectId, config.mrIid, headSha)) {
|
|
109
|
+
result = { ok: true, summary: `webhook already reviewed ${headSha} — skipping`, failures: [], durationMs: 0 }
|
|
110
|
+
} else if (await hasRunningEyes(coFetcher, config.gitlabBaseUrl, config.gitlabToken, config.sourceProjectId, config.mrIid)) {
|
|
111
|
+
result = { ok: true, summary: 'another review is running — skipping', failures: [], durationMs: 0 }
|
|
112
|
+
} else {
|
|
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.
|
|
117
|
+
const request: ReviewRequest = {
|
|
118
|
+
projectPath,
|
|
119
|
+
projectId: config.sourceProjectId,
|
|
120
|
+
mrIid: config.mrIid,
|
|
121
|
+
sourceBranch,
|
|
122
|
+
headSha,
|
|
123
|
+
trigger: 'mention',
|
|
124
|
+
mode: config.mode,
|
|
125
|
+
scope: { kind: 'mr' },
|
|
126
|
+
reviewProfile: config.reviewProfile,
|
|
127
|
+
}
|
|
128
|
+
result = await ctx.reviewRunner(request)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const { writeFile } = deps.writeFile !== undefined ? { writeFile: deps.writeFile } : await import('node:fs/promises')
|
|
132
|
+
// entrypoint.sh cds into deepseek-harness before execing dsh, so a bare relative
|
|
133
|
+
// path would land inside the harness checkout (lost with the container). The
|
|
134
|
+
// entrypoint exports REVIEW_REPORT_DIR=$PWD captured before the cd, so reports land
|
|
135
|
+
// in the CI job's working directory where `artifacts:` picks them up.
|
|
136
|
+
const reportDir = process.env.REVIEW_REPORT_DIR?.trim() || '.'
|
|
137
|
+
const report = { ...result, projectId: config.sourceProjectId, mrIid: config.mrIid, mode: config.mode, generatedAt: new Date().toISOString() }
|
|
138
|
+
await writeFile(join(reportDir, 'review-report.json'), JSON.stringify(report, null, 2), 'utf-8')
|
|
139
|
+
const md = `# Review Report\n\n**Project:** ${config.sourceProjectId} !${config.mrIid}\n**Mode:** ${config.mode}\n**Duration:** ${result.durationMs}ms\n\n${result.summary ?? '(no summary)'}\n${result.failures.length > 0 ? `\nFailures:\n${result.failures.map(f => `- ${f}`).join('\n')}\n` : ''}`
|
|
140
|
+
await writeFile(join(reportDir, 'review-report.md'), md, 'utf-8')
|
|
141
|
+
return result
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function apply(ctx: Context): void {
|
|
145
|
+
ctx.effect(() => {
|
|
146
|
+
void (async () => {
|
|
147
|
+
try {
|
|
148
|
+
const config = parseCiEnvConfig(process.env)
|
|
149
|
+
console.log(`[review] ci-trigger starting: project ${config.sourceProjectId} !${config.mrIid} mode=${config.mode} dryRun=${config.dryRun}`)
|
|
150
|
+
const result = config.dryRun
|
|
151
|
+
? { ok: true, summary: '[dry-run] config valid, skipping review', failures: [], durationMs: 0 }
|
|
152
|
+
: await runCiTrigger(ctx, config)
|
|
153
|
+
console.log(`[review] ci-trigger finished: ok=${result.ok} summary=${JSON.stringify(result.summary)} failures=${result.failures.length}`)
|
|
154
|
+
for (const failure of result.failures) console.error(`[review] ci-trigger failure: ${failure}`)
|
|
155
|
+
const exit = ctx.get('appExit')
|
|
156
|
+
if (exit === undefined) {
|
|
157
|
+
console.error('[review] ctx.get(\'appExit\') is undefined — the launcher did not provide it, process cannot signal its real exit code')
|
|
158
|
+
} else {
|
|
159
|
+
exit(result.ok ? 0 : 1)
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error('maestro-review-ci-trigger:', err instanceof Error ? err.message : String(err))
|
|
163
|
+
ctx.get('appExit')?.(1)
|
|
164
|
+
}
|
|
165
|
+
})()
|
|
166
|
+
return () => {}
|
|
167
|
+
}, 'maestro-review-ci-trigger run')
|
|
168
|
+
}
|
|
@@ -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,9 +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
|
-
|
|
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
|
+
}
|
|
133
148
|
res.writeHead(200).end()
|
|
134
149
|
})
|
|
135
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
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-readable review comment identity (spec: coexistence §4-5).
|
|
3
|
+
*
|
|
4
|
+
* Every posted review comment (webhook + CI, quick + deep) ends with an
|
|
5
|
+
* invisible marker so the CI flow can detect "this head SHA already has a
|
|
6
|
+
* completed review" without access to the webhook's history store.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type ReviewFlow = 'ci-quick' | 'ci-deep' | 'webhook'
|
|
10
|
+
|
|
11
|
+
export function reviewMarker(sha: string, flow: ReviewFlow): string {
|
|
12
|
+
return `<!-- maestro-review sha=${sha} flow=${flow} status=completed -->`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseReviewMarker(body: string): { sha: string; flow: string; status: string } | undefined {
|
|
16
|
+
const m = /<!-- maestro-review sha=([0-9a-fA-F]+) flow=([a-z-]+) status=([a-z]+) -->/.exec(body)
|
|
17
|
+
if (!m) return undefined
|
|
18
|
+
return { sha: m[1], flow: m[2], status: m[3] }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** CI detection is env-based (established pattern): the reviewer job always sets these. */
|
|
22
|
+
export function resolveFlow(mode: 'quick' | 'deep'): ReviewFlow {
|
|
23
|
+
if (process.env.SOURCE_PROJECT_ID === undefined) return 'webhook'
|
|
24
|
+
return mode === 'deep' ? 'ci-deep' : 'ci-quick'
|
|
25
|
+
}
|
|
@@ -4,31 +4,34 @@
|
|
|
4
4
|
* GitLab error is swallowed so it can never fail the review itself.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { gitlabAuthHeaders } from './gitlab-auth.js'
|
|
8
|
+
import { fetchWithTimeout } from './gitlab-client.js'
|
|
9
|
+
|
|
7
10
|
export interface ReviewSignals {
|
|
8
11
|
start(): Promise<void>
|
|
9
12
|
finish(outcome: 'completed' | 'failed'): Promise<void>
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
async function award(baseUrl: string, token: string, projectId: number, mrIid: number, name: string): Promise<void> {
|
|
13
|
-
await
|
|
16
|
+
await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji`, {
|
|
14
17
|
method: 'POST',
|
|
15
|
-
headers: {
|
|
18
|
+
headers: { ...gitlabAuthHeaders(token), 'Content-Type': 'application/json' },
|
|
16
19
|
body: JSON.stringify({ name }),
|
|
17
20
|
})
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/** Remove only this bot's stale running markers; other users' awards stay untouched. */
|
|
21
24
|
async function unawardOwn(baseUrl: string, token: string, projectId: number, mrIid: number, botUsername: string): Promise<void> {
|
|
22
|
-
const response = await
|
|
23
|
-
headers:
|
|
25
|
+
const response = await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji`, {
|
|
26
|
+
headers: gitlabAuthHeaders(token),
|
|
24
27
|
})
|
|
25
28
|
if (!response.ok) return
|
|
26
29
|
const awards = (await response.json()) as Array<{ id?: number; name?: string; user?: { username?: string } }>
|
|
27
30
|
for (const awardItem of Array.isArray(awards) ? awards : []) {
|
|
28
31
|
if (awardItem.name !== 'eyes' || awardItem.user?.username !== botUsername || typeof awardItem.id !== 'number') continue
|
|
29
|
-
await
|
|
32
|
+
await fetchWithTimeout(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji/${awardItem.id}`, {
|
|
30
33
|
method: 'DELETE',
|
|
31
|
-
headers:
|
|
34
|
+
headers: gitlabAuthHeaders(token),
|
|
32
35
|
}).catch(() => {})
|
|
33
36
|
}
|
|
34
37
|
}
|
package/src/host/settings-rpc.ts
CHANGED
|
@@ -12,7 +12,7 @@ export const inject = ['connection', 'maestroTunnel']
|
|
|
12
12
|
/** Keys the Settings card may persist; anything else is a rejected save. */
|
|
13
13
|
const SAVABLE_KEYS = new Set<keyof MaestroUserConfig>([
|
|
14
14
|
'gitlabBaseUrl', 'gitlabToken', 'botUsername', 'webhookSecret', 'webhookPort',
|
|
15
|
-
'projectMappings', 'reviewModel', 'autoRereviewOnPush', 'agentTimeoutMs', 'reviewSessionRetentionDays',
|
|
15
|
+
'projectMappings', 'reviewModel', 'autoRereviewOnPush', 'autoReviewOnAssign', 'agentTimeoutMs', 'reviewSessionRetentionDays',
|
|
16
16
|
'tunnelMode', 'quickTarget', 'tunnelId', 'tunnelCredentialsFile', 'tunnelHostname',
|
|
17
17
|
'proxyPort', 'proxyHost', 'lanPinEnabled', 'telegramBotToken', 'telegramChatId',
|
|
18
18
|
'telegramReviewNotifications',
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# ddtcorex/maestro-reviewer — dedicated reviewer project
|
|
2
|
+
stages: [review]
|
|
3
|
+
|
|
4
|
+
variables:
|
|
5
|
+
# Image registry — pin the SHA for reproducibility
|
|
6
|
+
REVIEWER_IMAGE: "ddtcorex/maestro-reviewer:latest"
|
|
7
|
+
# Or use $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA when building the image in the same project
|
|
8
|
+
|
|
9
|
+
review:
|
|
10
|
+
stage: review
|
|
11
|
+
image: $REVIEWER_IMAGE
|
|
12
|
+
# Only runs when triggered from the source project (or manually)
|
|
13
|
+
rules:
|
|
14
|
+
- if: $CI_PIPELINE_SOURCE == "pipeline" # bridge trigger
|
|
15
|
+
- if: $CI_PIPELINE_SOURCE == "trigger" # token trigger
|
|
16
|
+
- if: $CI_PIPELINE_SOURCE == "web" # manual retry
|
|
17
|
+
when: manual
|
|
18
|
+
variables:
|
|
19
|
+
GIT_STRATEGY: none # no need to check out the reviewer repo
|
|
20
|
+
# Every parameter comes from env (SOURCE_PROJECT_ID, MR_IID, GITLAB_HOST, REVIEW_MODE) — no CLI args.
|
|
21
|
+
script:
|
|
22
|
+
- /entrypoint.sh
|
|
23
|
+
# History cache — the push-gate (skip already-reviewed head SHAs) and incremental
|
|
24
|
+
# context read $DSH_HOME/dsh-maestro-review/reviews.json, which is ephemeral in a
|
|
25
|
+
# fresh container. entrypoint.sh symlinks it at this cached path (one store, many
|
|
26
|
+
# MRs — entries are keyed per MR inside). Cache miss fails open toward reviewing.
|
|
27
|
+
cache:
|
|
28
|
+
key: maestro-review-history
|
|
29
|
+
paths:
|
|
30
|
+
- .maestro-history/
|
|
31
|
+
artifacts:
|
|
32
|
+
when: always
|
|
33
|
+
expire_in: 1 week
|
|
34
|
+
paths:
|
|
35
|
+
- review-report.json
|
|
36
|
+
- review-report.md
|
|
37
|
+
# Secrets — Protected + Masked, only available on the reviewer project's protected branches.
|
|
38
|
+
# MAESTRO_GITLAB_TOKEN is REQUIRED and must be a user PAT or Group/Project
|
|
39
|
+
# Access Token (api scope). CI_JOB_TOKEN is NOT enough: proven 2026-09-04 on
|
|
40
|
+
# GitLab 18.11 that reads work (GET=200) but posting the review comment fails
|
|
41
|
+
# (POST /notes=401) even with the source project allowlisting this reviewer —
|
|
42
|
+
# the run would "complete" without ever posting. A human-tied PAT needs yearly
|
|
43
|
+
# rotation; prefer a bot/group token where the instance allows creating one.
|
|
44
|
+
# MAESTRO_GITLAB_TOKEN (api scope) — required
|
|
45
|
+
# Route selection is key-based (entrypoint.sh swaps the baked settings variant):
|
|
46
|
+
# OPENCODE_GO_API_KEY → opencode profile (default, host mirror; serves opencode-go
|
|
47
|
+
# AND deepseek-official-via-zen). Set alone, or with DEEPSEEK_API_KEY (opencode wins).
|
|
48
|
+
# DEEPSEEK_API_KEY → deepseek profile ONLY when OPENCODE_GO_API_KEY is absent
|
|
49
|
+
# (deepseek-official straight from api.deepseek.com — needs a LIVE deepseek.com key).
|
|
50
|
+
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID (optional)
|
|
51
|
+
# Opencode model (optional, one variable — default muse-spark-1.3-contributor):
|
|
52
|
+
# OPENCODE_MODEL: "muse-spark-1.3-contributor"
|
|
53
|
+
# Model pin (optional, as a pair — unset both for the host-mirrored default
|
|
54
|
+
# = opencode-go/muse-spark-1.3-contributor, baked in docker/ci-settings.yaml):
|
|
55
|
+
# REVIEW_MODEL_PROVIDER: "opencode-go" # or "deepseek-official"
|
|
56
|
+
# REVIEW_MODEL: "muse-spark-1.3-contributor" # e.g. deepseek-v4-flash (deepseek-official via zen)
|
|
57
|
+
# Only the provider's key is needed — no settings mount required. ID without
|
|
58
|
+
# provider fails the review with a provider error (not silently the wrong
|
|
59
|
+
# model); provider without ID is ignored.
|
|
60
|
+
# Review skill profile (optional — unset for the legacy diff-only generic review):
|
|
61
|
+
# REVIEW_PROFILE: "magento2" # magento2 | laravel | symfony | wordpress | generic
|
|
62
|
+
# The reviewer must load the full skill set or the review fails closed
|
|
63
|
+
# (no half-skilled review). Runtime-backed checks still degrade without a
|
|
64
|
+
# checkout — the profile adds framework checklists, not a govard run.
|
|
65
|
+
# Review mode (default quick; deep runs on demand — no new variables):
|
|
66
|
+
# REVIEW_MODE: "deep" # manual downstream run with the same
|
|
67
|
+
# bridge variables (SOURCE_PROJECT_ID, MR_IID, GITLAB_HOST) + REVIEW_ON_PUSH=1
|
|
68
|
+
# to re-review pushes. The job clones the source at the MR head SHA with
|
|
69
|
+
# MAESTRO_GITLAB_TOKEN (api scope, oauth2) and runs reviewer (+auditor
|
|
70
|
+
# degraded to reviewer-only without a runtime). No govard in the container.
|
|
71
|
+
# CI yields to webhook: if the head SHA already has a completed review
|
|
72
|
+
# comment (either flow), the run skips instead of double-posting.
|
|
73
|
+
# Re-review pushes (optional — mirrors Settings UI autoRereviewOnPush, default off):
|
|
74
|
+
# REVIEW_ON_PUSH: "1" # re-review new pushes with incremental context;
|
|
75
|
+
# unset = only the first (opened) review runs; same head SHA always skips.
|
|
76
|
+
|
|
77
|
+
# Build the image inside the same reviewer project (optional):
|
|
78
|
+
#
|
|
79
|
+
# build:image:
|
|
80
|
+
# stage: .pre
|
|
81
|
+
# image: docker:27
|
|
82
|
+
# services: [docker:27-dind]
|
|
83
|
+
# rules:
|
|
84
|
+
# - if: $CI_COMMIT_BRANCH == "main"
|
|
85
|
+
# script:
|
|
86
|
+
# - docker build -f packages/dsh-maestro-review/docker/Dockerfile -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -t $CI_REGISTRY_IMAGE:latest .
|
|
87
|
+
# - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
|
|
88
|
+
# - docker push $CI_REGISTRY_IMAGE:latest
|
|
89
|
+
# # Build context must be the maestro-harness workspace root (contains deepseek-harness/) —
|
|
90
|
+
# # the trailing "." above assumes this job checks out maestro-harness, not this repo alone.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# example-group/example-project — source project (copy this template into YOUR source project)
|
|
2
|
+
stages: [review]
|
|
3
|
+
|
|
4
|
+
# Only runs when there is an MR (fork MRs run fine too — this job has no secrets)
|
|
5
|
+
maestro:trigger-review:
|
|
6
|
+
stage: review
|
|
7
|
+
rules:
|
|
8
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
9
|
+
# No image needed, no secrets needed
|
|
10
|
+
trigger:
|
|
11
|
+
project: ddtcorex/maestro-reviewer # ← reviewer project path
|
|
12
|
+
branch: main # ← the reviewer project's default branch (master on some instances)
|
|
13
|
+
strategy: depend # optional: wait for the reviewer pipeline, surface its status on the MR
|
|
14
|
+
variables:
|
|
15
|
+
SOURCE_PROJECT_ID: $CI_PROJECT_ID
|
|
16
|
+
MR_IID: $CI_MERGE_REQUEST_IID
|
|
17
|
+
GITLAB_HOST: $CI_SERVER_HOST
|
|
18
|
+
REVIEW_MODE: "quick" # or "deep" for a static-only audit (no govard runtime in CI)
|
|
19
|
+
REVIEW_PROFILE: "generic" # per-project skill profile: magento2 | laravel | symfony | wordpress | generic
|
|
20
|
+
# REVIEW_ON_PUSH: "1" # uncomment to re-review every push (default: only the opened review runs)
|
|
21
|
+
|
|
22
|
+
# Trigger Token fallback (when not using `trigger: project:`):
|
|
23
|
+
#
|
|
24
|
+
# maestro:trigger-review:token:
|
|
25
|
+
# stage: review
|
|
26
|
+
# rules:
|
|
27
|
+
# - if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
28
|
+
# image: curlimages/curl:8
|
|
29
|
+
# variables:
|
|
30
|
+
# REVIEWER_PROJECT_ID: "123" # id of the maestro-reviewer project
|
|
31
|
+
# script:
|
|
32
|
+
# - |
|
|
33
|
+
# curl --fail --request POST \
|
|
34
|
+
# --form token=$MAESTRO_TRIGGER_TOKEN \
|
|
35
|
+
# --form ref=main \
|
|
36
|
+
# --form variables[SOURCE_PROJECT_ID]=$CI_PROJECT_ID \
|
|
37
|
+
# --form variables[MR_IID]=$CI_MERGE_REQUEST_IID \
|
|
38
|
+
# --form variables[GITLAB_HOST]=$CI_SERVER_HOST \
|
|
39
|
+
# "https://${CI_SERVER_HOST}/api/v4/projects/${REVIEWER_PROJECT_ID}/trigger/pipeline"
|
|
40
|
+
# # MAESTRO_TRIGGER_TOKEN is the reviewer project's Trigger Token, stored in the source project
|
|
41
|
+
# # (can only trigger, never read/write code — safer than GITLAB_TOKEN)
|