@ddtcorex/dsh-maestro-review 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/client/settings-card.tsx +4 -0
- package/lib/config-store.d.ts +53 -0
- package/lib/config-store.d.ts.map +1 -0
- package/lib/config-store.js +60 -0
- package/lib/config-store.js.map +1 -0
- package/lib/events.d.ts +27 -0
- package/lib/events.d.ts.map +1 -0
- package/lib/events.js +2 -0
- package/lib/events.js.map +1 -0
- package/lib/gitlab-client.d.ts +14 -0
- package/lib/gitlab-client.d.ts.map +1 -0
- package/lib/gitlab-client.js +146 -0
- package/lib/gitlab-client.js.map +1 -0
- package/lib/gitlab-webhook.d.ts +17 -0
- package/lib/gitlab-webhook.d.ts.map +1 -0
- package/lib/gitlab-webhook.js +144 -0
- package/lib/gitlab-webhook.js.map +1 -0
- package/lib/govard-tool.d.ts +11 -0
- package/lib/govard-tool.d.ts.map +1 -0
- package/lib/govard-tool.js +95 -0
- package/lib/govard-tool.js.map +1 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +15 -0
- package/lib/index.js.map +1 -0
- package/lib/notify.d.ts +23 -0
- package/lib/notify.d.ts.map +1 -0
- package/lib/notify.js +10 -0
- package/lib/notify.js.map +1 -0
- package/lib/orchestrator.d.ts +144 -0
- package/lib/orchestrator.d.ts.map +1 -0
- package/lib/orchestrator.js +655 -0
- package/lib/orchestrator.js.map +1 -0
- package/lib/pin-store.d.ts +5 -0
- package/lib/pin-store.d.ts.map +1 -0
- package/lib/pin-store.js +5 -0
- package/lib/pin-store.js.map +1 -0
- package/lib/providers/github.stub.d.ts +3 -0
- package/lib/providers/github.stub.d.ts.map +1 -0
- package/lib/providers/github.stub.js +10 -0
- package/lib/providers/github.stub.js.map +1 -0
- package/lib/providers/gitlab.d.ts +13 -0
- package/lib/providers/gitlab.d.ts.map +1 -0
- package/lib/providers/gitlab.js +173 -0
- package/lib/providers/gitlab.js.map +1 -0
- package/lib/providers/interface.d.ts +12 -0
- package/lib/providers/interface.d.ts.map +1 -0
- package/lib/providers/interface.js +2 -0
- package/lib/providers/interface.js.map +1 -0
- package/lib/review-findings-tool.d.ts +15 -0
- package/lib/review-findings-tool.d.ts.map +1 -0
- package/lib/review-findings-tool.js +37 -0
- package/lib/review-findings-tool.js.map +1 -0
- package/lib/review-history.d.ts +35 -0
- package/lib/review-history.d.ts.map +1 -0
- package/lib/review-history.js +87 -0
- package/lib/review-history.js.map +1 -0
- package/lib/review-intake.d.ts +7 -0
- package/lib/review-intake.d.ts.map +1 -0
- package/lib/review-intake.js +80 -0
- package/lib/review-intake.js.map +1 -0
- package/lib/review-signals.d.ts +17 -0
- package/lib/review-signals.d.ts.map +1 -0
- package/lib/review-signals.js +49 -0
- package/lib/review-signals.js.map +1 -0
- package/lib/secure-compare.d.ts +7 -0
- package/lib/secure-compare.d.ts.map +1 -0
- package/lib/secure-compare.js +14 -0
- package/lib/secure-compare.js.map +1 -0
- package/lib/settings-rpc.d.ts +22 -0
- package/lib/settings-rpc.d.ts.map +1 -0
- package/lib/settings-rpc.js +252 -0
- package/lib/settings-rpc.js.map +1 -0
- package/lib/skills-tool.d.ts +29 -0
- package/lib/skills-tool.d.ts.map +1 -0
- package/lib/skills-tool.js +160 -0
- package/lib/skills-tool.js.map +1 -0
- package/lib/workspace-tool.d.ts +10 -0
- package/lib/workspace-tool.d.ts.map +1 -0
- package/lib/workspace-tool.js +86 -0
- package/lib/workspace-tool.js.map +1 -0
- package/package.json +53 -0
- package/presets/maestro-auditor/agent.cordis.yml +10 -0
- package/presets/maestro-auditor/preset.yml +2 -0
- package/presets/maestro-coder/agent.cordis.yml +223 -0
- package/presets/maestro-coder/preset.yml +2 -0
- package/presets/maestro-reviewer/agent.cordis.yml +14 -0
- package/presets/maestro-reviewer/preset.yml +2 -0
- package/src/augment.d.ts +47 -0
- package/src/config-store.ts +109 -0
- package/src/events.ts +25 -0
- package/src/gitlab-client.ts +186 -0
- package/src/gitlab-webhook.ts +166 -0
- package/src/govard-tool.ts +114 -0
- package/src/index.ts +15 -0
- package/src/notify.ts +30 -0
- package/src/orchestrator.ts +740 -0
- package/src/pin-store.ts +4 -0
- package/src/providers/github.stub.ts +11 -0
- package/src/providers/gitlab.ts +190 -0
- package/src/providers/interface.ts +2 -0
- package/src/review-findings-tool.ts +51 -0
- package/src/review-history.ts +112 -0
- package/src/review-intake.ts +86 -0
- package/src/review-signals.ts +52 -0
- package/src/secure-compare.ts +13 -0
- package/src/settings-rpc.ts +249 -0
- package/src/skills-tool.ts +193 -0
- package/src/workspace-tool.ts +90 -0
package/src/pin-store.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export async function readPin(): Promise<string> { return '00000000' }
|
|
2
|
+
export async function rotatePin(): Promise<string> { return '00000000' }
|
|
3
|
+
export async function readLanPin(): Promise<string> { return '00000000' }
|
|
4
|
+
export async function rotateLanPin(): Promise<string> { return '00000000' }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReviewProvider, ReviewRequest } from './interface.js'
|
|
2
|
+
|
|
3
|
+
export const githubProvider: ReviewProvider = {
|
|
4
|
+
id: 'github',
|
|
5
|
+
async intake(_req: Request): Promise<ReviewRequest> {
|
|
6
|
+
throw new Error('github not implemented')
|
|
7
|
+
},
|
|
8
|
+
async postFindings(_findings: any[]): Promise<void> {
|
|
9
|
+
throw new Error('not implemented')
|
|
10
|
+
},
|
|
11
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage } from 'node:http'
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
3
|
+
import z from '@deepseek-ai/schemastery'
|
|
4
|
+
import { loadUserConfig } from '../config-store.js'
|
|
5
|
+
import { secretsMatch } from '../secure-compare.js'
|
|
6
|
+
import type { MrOpenedPayload, ReviewRequest as OrchestratorReviewRequest } from '../events.js'
|
|
7
|
+
import { routeGitlabReviewRequest } from '../review-intake.js'
|
|
8
|
+
import type { ReviewProvider, ReviewRequest } from './interface.js'
|
|
9
|
+
|
|
10
|
+
export const name = 'maestro-review-webhook'
|
|
11
|
+
|
|
12
|
+
export interface Config {
|
|
13
|
+
port: number
|
|
14
|
+
botUsername?: string
|
|
15
|
+
secret?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const Config: z<Config> = z.object({
|
|
19
|
+
port: z.natural().max(65535).required(),
|
|
20
|
+
botUsername: z.string(),
|
|
21
|
+
secret: z.string().role('secret'),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
interface GitlabMrWebhookBody {
|
|
25
|
+
object_kind: string
|
|
26
|
+
project: { id: number; path_with_namespace: string }
|
|
27
|
+
object_attributes: { iid: number; action: string; source_branch: string }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MAX_WEBHOOK_BODY_BYTES = 5 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
function readBody(req: IncomingMessage, limit = MAX_WEBHOOK_BODY_BYTES): Promise<string | undefined> {
|
|
33
|
+
return new Promise((resolvePromise) => {
|
|
34
|
+
let raw = ''
|
|
35
|
+
let settled = false
|
|
36
|
+
req.on('data', (chunk: Buffer) => {
|
|
37
|
+
if (settled) return
|
|
38
|
+
if (raw.length + chunk.length > limit) {
|
|
39
|
+
settled = true
|
|
40
|
+
req.resume()
|
|
41
|
+
resolvePromise(undefined)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
raw += chunk.toString()
|
|
45
|
+
})
|
|
46
|
+
req.on('end', () => { if (!settled) { settled = true; resolvePromise(raw) } })
|
|
47
|
+
req.on('error', () => { if (!settled) { settled = true; resolvePromise(undefined) } })
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isValidMrOpenedPayload(value: unknown): value is MrOpenedPayload {
|
|
52
|
+
if (typeof value !== 'object' || value === null) return false
|
|
53
|
+
const v = value as Record<string, unknown>
|
|
54
|
+
return typeof v.projectPath === 'string'
|
|
55
|
+
&& typeof v.projectId === 'number'
|
|
56
|
+
&& typeof v.mrIid === 'number'
|
|
57
|
+
&& typeof v.sourceBranch === 'string'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hasValidGitlabMrIdentity(value: unknown): boolean {
|
|
61
|
+
if (typeof value !== 'object' || value === null) return false
|
|
62
|
+
const body = value as Record<string, unknown>
|
|
63
|
+
const project = body.project as Record<string, unknown> | undefined
|
|
64
|
+
const attributes = body.object_attributes as Record<string, unknown> | undefined
|
|
65
|
+
const mergeRequest = body.merge_request as Record<string, unknown> | undefined
|
|
66
|
+
const source = body.object_kind === 'note' ? mergeRequest : attributes
|
|
67
|
+
return typeof project?.id === 'number' && typeof project.path_with_namespace === 'string'
|
|
68
|
+
&& typeof source?.iid === 'number' && typeof source.source_branch === 'string'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ReviewProvider implementation — pluggable interface for orchestrator
|
|
72
|
+
export const gitlabProvider: ReviewProvider = {
|
|
73
|
+
id: 'gitlab',
|
|
74
|
+
async intake(req: Request): Promise<ReviewRequest> {
|
|
75
|
+
// Try to parse the request body as GitLab webhook JSON
|
|
76
|
+
let body: unknown
|
|
77
|
+
try {
|
|
78
|
+
body = await req.clone().json()
|
|
79
|
+
} catch {
|
|
80
|
+
// Fallback/STUB: return generic empty request (keeps interface working without GitLab)
|
|
81
|
+
return { provider: 'gitlab', projectPath: '', mrId: '', profile: 'generic' }
|
|
82
|
+
}
|
|
83
|
+
// If body looks like a GitLab MR webhook, map to ReviewRequest
|
|
84
|
+
if (typeof body === 'object' && body !== null) {
|
|
85
|
+
const b = body as Record<string, unknown>
|
|
86
|
+
const project = b.project as Record<string, unknown> | undefined
|
|
87
|
+
const attrs = b.object_attributes as Record<string, unknown> | undefined
|
|
88
|
+
const mr = b.merge_request as Record<string, unknown> | undefined
|
|
89
|
+
const source = b.object_kind === 'note' ? mr : attrs
|
|
90
|
+
const projectPath = typeof project?.path_with_namespace === 'string' ? project.path_with_namespace : ''
|
|
91
|
+
const mrId = typeof source?.iid === 'number' ? String(source.iid) : ''
|
|
92
|
+
if (projectPath && mrId) {
|
|
93
|
+
return { provider: 'gitlab', projectPath, mrId, profile: 'generic' }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Fallback stub
|
|
97
|
+
return { provider: 'gitlab', projectPath: '', mrId: '', profile: 'generic' }
|
|
98
|
+
},
|
|
99
|
+
async postFindings(_findings: any[]): Promise<void> {
|
|
100
|
+
// Real posting is handled by orchestrator's GitLab API flow (postReviewFindings)
|
|
101
|
+
// Keep interface compliant — no-op here; orchestrator will call postReviewFindings separately
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function apply(ctx: Context, config: Config): void {
|
|
106
|
+
let warnedUnconfigured = false
|
|
107
|
+
async function requestAuthorized(headerValue: string | string[] | undefined): Promise<boolean> {
|
|
108
|
+
const userConfig = await loadUserConfig()
|
|
109
|
+
const expected = userConfig.webhookSecret ?? config.secret
|
|
110
|
+
if (expected === undefined) {
|
|
111
|
+
if (!warnedUnconfigured) {
|
|
112
|
+
warnedUnconfigured = true
|
|
113
|
+
console.error('maestro-review-webhook: no webhook secret configured — set one in Maestro Settings or MAESTRO_GITLAB_WEBHOOK_SECRET; rejecting all requests until then')
|
|
114
|
+
}
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
return secretsMatch(headerValue, expected)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const server = createServer((req, res) => {
|
|
121
|
+
if (req.method !== 'POST') {
|
|
122
|
+
res.writeHead(404).end()
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
void requestAuthorized(req.headers['x-gitlab-token']).then(async (authorized) => {
|
|
126
|
+
if (!authorized) {
|
|
127
|
+
res.writeHead(401).end()
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
readBody(req).then(async (raw) => {
|
|
131
|
+
if (raw === undefined) {
|
|
132
|
+
res.writeHead(413).end()
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (req.url === '/hooks/gitlab-mr/trigger') {
|
|
136
|
+
let parsed: unknown
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(raw)
|
|
139
|
+
} catch {
|
|
140
|
+
res.writeHead(400).end()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
if (!isValidMrOpenedPayload(parsed)) {
|
|
144
|
+
res.writeHead(400).end()
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
const request: OrchestratorReviewRequest = {
|
|
148
|
+
...parsed,
|
|
149
|
+
trigger: 'mention',
|
|
150
|
+
mode: 'quick',
|
|
151
|
+
scope: { kind: 'mr' },
|
|
152
|
+
}
|
|
153
|
+
ctx.emit('maestro/review-request', request)
|
|
154
|
+
res.writeHead(200).end()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (req.url !== '/hooks/gitlab-mr') {
|
|
159
|
+
res.writeHead(404).end()
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
let body: GitlabMrWebhookBody
|
|
163
|
+
try {
|
|
164
|
+
body = JSON.parse(raw)
|
|
165
|
+
} catch {
|
|
166
|
+
res.writeHead(400).end()
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
const userConfig = await loadUserConfig()
|
|
170
|
+
if ((body.object_kind === 'merge_request' || body.object_kind === 'note') && !hasValidGitlabMrIdentity(body)) {
|
|
171
|
+
res.writeHead(400).end()
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
const request = routeGitlabReviewRequest(
|
|
175
|
+
body,
|
|
176
|
+
userConfig.botUsername ?? config.botUsername ?? 'maestro',
|
|
177
|
+
{ pushEnabled: userConfig.autoRereviewOnPush === true },
|
|
178
|
+
)
|
|
179
|
+
if (request !== undefined) ctx.emit('maestro/review-request', request)
|
|
180
|
+
res.writeHead(200).end()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
server.listen(config.port)
|
|
186
|
+
|
|
187
|
+
ctx.effect(() => async () => {
|
|
188
|
+
await new Promise<void>((resolve) => { server.close(() => resolve()) })
|
|
189
|
+
}, 'gitlab-webhook teardown')
|
|
190
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
3
|
+
|
|
4
|
+
export const name = 'maestro-review-findings-tool'
|
|
5
|
+
export const inject = ['tools']
|
|
6
|
+
|
|
7
|
+
export interface ReviewFinding {
|
|
8
|
+
status: 'new' | 'reply'
|
|
9
|
+
body: string
|
|
10
|
+
path?: string
|
|
11
|
+
line?: number
|
|
12
|
+
discussionId?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Config {
|
|
16
|
+
onReport: (findings: ReviewFinding[]) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function apply(ctx: Context, config: Config): void {
|
|
20
|
+
ctx.tools.register(defineTool({
|
|
21
|
+
name: 'report_review_findings',
|
|
22
|
+
description: 'Submit your complete list of review findings exactly once, when done analyzing. Each finding is {status: "new", path, line, body} for a location with no existing thread, or {status: "reply", discussionId, body} to update an existing thread returned by gitlab_list_own_review_threads.',
|
|
23
|
+
parameters: {
|
|
24
|
+
findings: {
|
|
25
|
+
type: 'array',
|
|
26
|
+
required: true,
|
|
27
|
+
description: 'The complete list of findings for this review.',
|
|
28
|
+
items: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
properties: {
|
|
32
|
+
status: { type: 'string', enum: ['new', 'reply'], required: true },
|
|
33
|
+
body: { type: 'string', required: true },
|
|
34
|
+
path: { type: 'string' },
|
|
35
|
+
line: { type: 'number' },
|
|
36
|
+
discussionId: { type: 'string' },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
output: {
|
|
42
|
+
schema: { type: 'object', additionalProperties: false, properties: { received: { type: 'number', required: true } } },
|
|
43
|
+
render: (_args, value) => [{ type: 'text', text: `Recorded ${value.received} finding(s).` }],
|
|
44
|
+
},
|
|
45
|
+
async execute(args) {
|
|
46
|
+
const findings = args.findings as ReviewFinding[]
|
|
47
|
+
config.onReport(findings)
|
|
48
|
+
return { received: findings.length }
|
|
49
|
+
},
|
|
50
|
+
}))
|
|
51
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import type { ReviewMode, ReviewRequest } from './events.js'
|
|
5
|
+
|
|
6
|
+
export interface ReviewHistoryEntry {
|
|
7
|
+
id: string
|
|
8
|
+
projectId: number
|
|
9
|
+
projectPath: string
|
|
10
|
+
mrIid: number
|
|
11
|
+
mode: ReviewMode
|
|
12
|
+
scope: 'mr' | 'discussion'
|
|
13
|
+
trigger: ReviewRequest['trigger']
|
|
14
|
+
status: 'running' | 'completed' | 'failed'
|
|
15
|
+
startedAt: number
|
|
16
|
+
finishedAt?: number
|
|
17
|
+
summary?: string
|
|
18
|
+
error?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const HISTORY_CAP = 100
|
|
22
|
+
|
|
23
|
+
export function historyPath(dshHome?: string): string {
|
|
24
|
+
const home = dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
25
|
+
return join(home, 'dsh-maestro-review', 'reviews.json')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async function readAll(dshHome?: string): Promise<ReviewHistoryEntry[]> {
|
|
30
|
+
try {
|
|
31
|
+
const parsed: unknown = JSON.parse(await readFile(historyPath(dshHome), 'utf-8'))
|
|
32
|
+
if (!Array.isArray(parsed)) return []
|
|
33
|
+
return parsed.filter((entry): entry is ReviewHistoryEntry =>
|
|
34
|
+
typeof entry === 'object' && entry !== null && typeof (entry as ReviewHistoryEntry).id === 'string')
|
|
35
|
+
} catch {
|
|
36
|
+
// Missing or corrupt store: start from an empty log rather than failing reviews.
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function writeAll(entries: ReviewHistoryEntry[], dshHome?: string): Promise<void> {
|
|
42
|
+
const path = historyPath(dshHome)
|
|
43
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
|
44
|
+
await writeFile(path, JSON.stringify(entries, null, 2), { encoding: 'utf-8', mode: 0o600 })
|
|
45
|
+
await chmod(path, 0o600)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Prepend a running entry; the oldest entries fall off past `HISTORY_CAP`. */
|
|
49
|
+
export async function recordReviewStart(entry: Omit<ReviewHistoryEntry, 'status'>, dshHome?: string): Promise<void> {
|
|
50
|
+
const all = await readAll(dshHome)
|
|
51
|
+
all.unshift({ ...entry, status: 'running' })
|
|
52
|
+
await writeAll(all.slice(0, HISTORY_CAP), dshHome)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Attach an outcome to the entry with this id; unknown ids are ignored. */
|
|
56
|
+
export async function recordReviewFinish(
|
|
57
|
+
id: string,
|
|
58
|
+
patch: { status: 'completed' | 'failed'; summary?: string; error?: string },
|
|
59
|
+
dshHome?: string,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
const all = await readAll(dshHome)
|
|
62
|
+
const entry = all.find((candidate) => candidate.id === id)
|
|
63
|
+
if (entry === undefined) return
|
|
64
|
+
Object.assign(entry, patch, { finishedAt: Date.now() })
|
|
65
|
+
await writeAll(all, dshHome)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Newest-first slice of the log. */
|
|
69
|
+
export async function listReviews(limit = 20, dshHome?: string): Promise<ReviewHistoryEntry[]> {
|
|
70
|
+
return (await readAll(dshHome)).slice(0, limit)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether any review of this MR ever completed — gates push re-reviews. */
|
|
74
|
+
export async function hasCompletedReview(projectId: number, mrIid: number, dshHome?: string): Promise<boolean> {
|
|
75
|
+
return (await readAll(dshHome)).some(entry =>
|
|
76
|
+
entry.projectId === projectId && entry.mrIid === mrIid && entry.status === 'completed')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Drop history entries and failed-report files older than `retentionDays`.
|
|
81
|
+
* `0` disables pruning entirely. Maestro records only — DSH session
|
|
82
|
+
* transcripts are never touched.
|
|
83
|
+
*/
|
|
84
|
+
export async function pruneHistory(retentionDays: number, dshHome?: string): Promise<void> {
|
|
85
|
+
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return
|
|
86
|
+
const cutoff = Date.now() - retentionDays * DAY_MS
|
|
87
|
+
const kept: ReviewHistoryEntry[] = []
|
|
88
|
+
for (const entry of await readAll(dshHome)) {
|
|
89
|
+
if ((entry.finishedAt ?? entry.startedAt) < cutoff) continue
|
|
90
|
+
kept.push(entry)
|
|
91
|
+
}
|
|
92
|
+
await writeAll(kept, dshHome)
|
|
93
|
+
|
|
94
|
+
const home = dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
95
|
+
const failedDir = join(home, 'maestro', 'failed-reports')
|
|
96
|
+
let names: string[]
|
|
97
|
+
try {
|
|
98
|
+
names = await readdir(failedDir)
|
|
99
|
+
} catch {
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
await Promise.all(names.map(async (name) => {
|
|
103
|
+
// Failed reports are named `<mrIid>-<epoch-ms>.md`.
|
|
104
|
+
const match = /^-?\d+-(\d{13,})\.md$/.exec(name)
|
|
105
|
+
if (match === null) return
|
|
106
|
+
const stamp = Number(match[1])
|
|
107
|
+
if (!Number.isFinite(stamp) || stamp >= cutoff) return
|
|
108
|
+
await rm(join(failedDir, name), { force: true }).catch(() => {})
|
|
109
|
+
}))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const DAY_MS = 24 * 60 * 60_000
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { MrOpenedPayload, ReviewMode, ReviewRequest, ReviewScope } from './events.js'
|
|
2
|
+
|
|
3
|
+
type JsonRecord = Record<string, unknown>
|
|
4
|
+
|
|
5
|
+
function record(value: unknown): JsonRecord | undefined {
|
|
6
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as JsonRecord : undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function mrPayload(body: JsonRecord): MrOpenedPayload | undefined {
|
|
10
|
+
const project = record(body.project)
|
|
11
|
+
const attributes = record(body.object_attributes)
|
|
12
|
+
const mergeRequest = record(body.merge_request)
|
|
13
|
+
const source = body.object_kind === 'note' ? mergeRequest : attributes
|
|
14
|
+
if (project === undefined || source === undefined
|
|
15
|
+
|| typeof project.id !== 'number' || typeof project.path_with_namespace !== 'string'
|
|
16
|
+
|| typeof source.iid !== 'number' || typeof source.source_branch !== 'string') return undefined
|
|
17
|
+
return {
|
|
18
|
+
projectId: project.id,
|
|
19
|
+
projectPath: project.path_with_namespace,
|
|
20
|
+
mrIid: source.iid,
|
|
21
|
+
sourceBranch: source.source_branch,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function usernames(value: unknown): string[] {
|
|
26
|
+
if (!Array.isArray(value)) return []
|
|
27
|
+
return value.flatMap((reviewer) => {
|
|
28
|
+
const item = record(reviewer)
|
|
29
|
+
return item !== undefined && typeof item.username === 'string' ? [item.username.toLowerCase()] : []
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isMentioned(text: string, botUsername: string): boolean {
|
|
34
|
+
const escaped = botUsername.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
35
|
+
return new RegExp(`(^|[^A-Za-z0-9_.-])@${escaped}(?=$|[^A-Za-z0-9_.-])`, 'i').test(text)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function noteScope(attributes: JsonRecord): ReviewScope | undefined {
|
|
39
|
+
const position = record(attributes.position)
|
|
40
|
+
if (position === undefined) return { kind: 'mr' }
|
|
41
|
+
const path = typeof position.new_path === 'string' ? position.new_path : position.old_path
|
|
42
|
+
const line = typeof position.new_line === 'number' ? position.new_line : position.old_line
|
|
43
|
+
if (typeof attributes.discussion_id !== 'string' || typeof path !== 'string' || typeof line !== 'number') return undefined
|
|
44
|
+
return { kind: 'discussion', discussionId: attributes.discussion_id, path, line }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RouteOptions {
|
|
48
|
+
/** When true, an MR update carrying new commits routes a push re-review. */
|
|
49
|
+
pushEnabled?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function routeGitlabReviewRequest(body: unknown, botUsername: string, options: RouteOptions = {}): ReviewRequest | undefined {
|
|
53
|
+
const value = record(body)
|
|
54
|
+
if (value === undefined || botUsername.trim() === '') return undefined
|
|
55
|
+
const payload = mrPayload(value)
|
|
56
|
+
if (payload === undefined) return undefined
|
|
57
|
+
const attributes = record(value.object_attributes)
|
|
58
|
+
if (attributes === undefined) return undefined
|
|
59
|
+
|
|
60
|
+
if (value.object_kind === 'merge_request') {
|
|
61
|
+
const changes = record(value.changes)
|
|
62
|
+
|
|
63
|
+
// New commits landed on an already-assigned MR: a quick re-review, gated
|
|
64
|
+
// downstream on a previously completed review of the same MR.
|
|
65
|
+
if (options.pushEnabled === true) {
|
|
66
|
+
const hasNewCommits = typeof attributes.oldrev === 'string' && attributes.oldrev !== ''
|
|
67
|
+
if (hasNewCommits) {
|
|
68
|
+
return { ...payload, trigger: 'push', mode: 'quick', scope: { kind: 'mr' } }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const reviewerChange = changes === undefined ? undefined : record(changes.reviewers)
|
|
73
|
+
if (reviewerChange === undefined) return undefined
|
|
74
|
+
const bot = botUsername.toLowerCase()
|
|
75
|
+
const nowAssigned = usernames(reviewerChange.current).includes(bot)
|
|
76
|
+
const previouslyAssigned = usernames(reviewerChange.previous).includes(bot)
|
|
77
|
+
if (!nowAssigned || previouslyAssigned) return undefined
|
|
78
|
+
return { ...payload, trigger: 'reviewer-assignment', mode: 'quick', scope: { kind: 'mr' } }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (value.object_kind !== 'note' || typeof attributes.note !== 'string' || !isMentioned(attributes.note, botUsername)) return undefined
|
|
82
|
+
const scope = noteScope(attributes)
|
|
83
|
+
if (scope === undefined) return undefined
|
|
84
|
+
const mode: ReviewMode = /(?:^|\s)\/maestro\s+deep(?:\s|$)/i.test(attributes.note) ? 'deep' : 'quick'
|
|
85
|
+
return { ...payload, trigger: 'mention', mode, scope }
|
|
86
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MR award-emoji acknowledgements: the MR shows "eyes" while a review runs and
|
|
3
|
+
* a final marker when it completes or fails. Signalling is best-effort — any
|
|
4
|
+
* GitLab error is swallowed so it can never fail the review itself.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface ReviewSignals {
|
|
8
|
+
start(): Promise<void>
|
|
9
|
+
finish(outcome: 'completed' | 'failed'): Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function award(baseUrl: string, token: string, projectId: number, mrIid: number, name: string): Promise<void> {
|
|
13
|
+
await fetch(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji`, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' },
|
|
16
|
+
body: JSON.stringify({ name }),
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Remove only this bot's stale running markers; other users' awards stay untouched. */
|
|
21
|
+
async function unawardOwn(baseUrl: string, token: string, projectId: number, mrIid: number, botUsername: string): Promise<void> {
|
|
22
|
+
const response = await fetch(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji`, {
|
|
23
|
+
headers: { 'PRIVATE-TOKEN': token },
|
|
24
|
+
})
|
|
25
|
+
if (!response.ok) return
|
|
26
|
+
const awards = (await response.json()) as Array<{ id?: number; name?: string; user?: { username?: string } }>
|
|
27
|
+
for (const awardItem of Array.isArray(awards) ? awards : []) {
|
|
28
|
+
if (awardItem.name !== 'eyes' || awardItem.user?.username !== botUsername || typeof awardItem.id !== 'number') continue
|
|
29
|
+
await fetch(`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/award_emoji/${awardItem.id}`, {
|
|
30
|
+
method: 'DELETE',
|
|
31
|
+
headers: { 'PRIVATE-TOKEN': token },
|
|
32
|
+
}).catch(() => {})
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createReviewSignals(options: { baseUrl: string; token: string; projectId: number; mrIid: number; botUsername: string }): ReviewSignals {
|
|
37
|
+
const { baseUrl, token, projectId, mrIid, botUsername } = options
|
|
38
|
+
return {
|
|
39
|
+
async start() {
|
|
40
|
+
try {
|
|
41
|
+
await unawardOwn(baseUrl, token, projectId, mrIid, botUsername)
|
|
42
|
+
await award(baseUrl, token, projectId, mrIid, 'eyes')
|
|
43
|
+
} catch { /* signalling must never break the review */ }
|
|
44
|
+
},
|
|
45
|
+
async finish(outcome) {
|
|
46
|
+
try {
|
|
47
|
+
await unawardOwn(baseUrl, token, projectId, mrIid, botUsername)
|
|
48
|
+
await award(baseUrl, token, projectId, mrIid, outcome === 'completed' ? 'white_check_mark' : 'warning')
|
|
49
|
+
} catch { /* signalling must never break the review */ }
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Constant-time comparison of two secrets through sha256 digests, so differing
|
|
5
|
+
* lengths leak nothing and `timingSafeEqual`'s equal-length requirement is
|
|
6
|
+
* always met. Fails closed when either side is missing.
|
|
7
|
+
*/
|
|
8
|
+
export function secretsMatch(provided: string | string[] | undefined, expected: string | undefined): boolean {
|
|
9
|
+
if (provided === undefined || expected === undefined || expected === '') return false
|
|
10
|
+
const providedDigest = createHash('sha256').update(Array.isArray(provided) ? provided[0] ?? '' : provided).digest()
|
|
11
|
+
const expectedDigest = createHash('sha256').update(expected).digest()
|
|
12
|
+
return timingSafeEqual(providedDigest, expectedDigest)
|
|
13
|
+
}
|