@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.
Files changed (110) hide show
  1. package/README.md +53 -0
  2. package/client/settings-card.tsx +4 -0
  3. package/lib/config-store.d.ts +53 -0
  4. package/lib/config-store.d.ts.map +1 -0
  5. package/lib/config-store.js +60 -0
  6. package/lib/config-store.js.map +1 -0
  7. package/lib/events.d.ts +27 -0
  8. package/lib/events.d.ts.map +1 -0
  9. package/lib/events.js +2 -0
  10. package/lib/events.js.map +1 -0
  11. package/lib/gitlab-client.d.ts +14 -0
  12. package/lib/gitlab-client.d.ts.map +1 -0
  13. package/lib/gitlab-client.js +146 -0
  14. package/lib/gitlab-client.js.map +1 -0
  15. package/lib/gitlab-webhook.d.ts +17 -0
  16. package/lib/gitlab-webhook.d.ts.map +1 -0
  17. package/lib/gitlab-webhook.js +144 -0
  18. package/lib/gitlab-webhook.js.map +1 -0
  19. package/lib/govard-tool.d.ts +11 -0
  20. package/lib/govard-tool.d.ts.map +1 -0
  21. package/lib/govard-tool.js +95 -0
  22. package/lib/govard-tool.js.map +1 -0
  23. package/lib/index.d.ts +4 -0
  24. package/lib/index.d.ts.map +1 -0
  25. package/lib/index.js +15 -0
  26. package/lib/index.js.map +1 -0
  27. package/lib/notify.d.ts +23 -0
  28. package/lib/notify.d.ts.map +1 -0
  29. package/lib/notify.js +10 -0
  30. package/lib/notify.js.map +1 -0
  31. package/lib/orchestrator.d.ts +144 -0
  32. package/lib/orchestrator.d.ts.map +1 -0
  33. package/lib/orchestrator.js +655 -0
  34. package/lib/orchestrator.js.map +1 -0
  35. package/lib/pin-store.d.ts +5 -0
  36. package/lib/pin-store.d.ts.map +1 -0
  37. package/lib/pin-store.js +5 -0
  38. package/lib/pin-store.js.map +1 -0
  39. package/lib/providers/github.stub.d.ts +3 -0
  40. package/lib/providers/github.stub.d.ts.map +1 -0
  41. package/lib/providers/github.stub.js +10 -0
  42. package/lib/providers/github.stub.js.map +1 -0
  43. package/lib/providers/gitlab.d.ts +13 -0
  44. package/lib/providers/gitlab.d.ts.map +1 -0
  45. package/lib/providers/gitlab.js +173 -0
  46. package/lib/providers/gitlab.js.map +1 -0
  47. package/lib/providers/interface.d.ts +12 -0
  48. package/lib/providers/interface.d.ts.map +1 -0
  49. package/lib/providers/interface.js +2 -0
  50. package/lib/providers/interface.js.map +1 -0
  51. package/lib/review-findings-tool.d.ts +15 -0
  52. package/lib/review-findings-tool.d.ts.map +1 -0
  53. package/lib/review-findings-tool.js +37 -0
  54. package/lib/review-findings-tool.js.map +1 -0
  55. package/lib/review-history.d.ts +35 -0
  56. package/lib/review-history.d.ts.map +1 -0
  57. package/lib/review-history.js +87 -0
  58. package/lib/review-history.js.map +1 -0
  59. package/lib/review-intake.d.ts +7 -0
  60. package/lib/review-intake.d.ts.map +1 -0
  61. package/lib/review-intake.js +80 -0
  62. package/lib/review-intake.js.map +1 -0
  63. package/lib/review-signals.d.ts +17 -0
  64. package/lib/review-signals.d.ts.map +1 -0
  65. package/lib/review-signals.js +49 -0
  66. package/lib/review-signals.js.map +1 -0
  67. package/lib/secure-compare.d.ts +7 -0
  68. package/lib/secure-compare.d.ts.map +1 -0
  69. package/lib/secure-compare.js +14 -0
  70. package/lib/secure-compare.js.map +1 -0
  71. package/lib/settings-rpc.d.ts +22 -0
  72. package/lib/settings-rpc.d.ts.map +1 -0
  73. package/lib/settings-rpc.js +252 -0
  74. package/lib/settings-rpc.js.map +1 -0
  75. package/lib/skills-tool.d.ts +29 -0
  76. package/lib/skills-tool.d.ts.map +1 -0
  77. package/lib/skills-tool.js +160 -0
  78. package/lib/skills-tool.js.map +1 -0
  79. package/lib/workspace-tool.d.ts +10 -0
  80. package/lib/workspace-tool.d.ts.map +1 -0
  81. package/lib/workspace-tool.js +86 -0
  82. package/lib/workspace-tool.js.map +1 -0
  83. package/package.json +53 -0
  84. package/presets/maestro-auditor/agent.cordis.yml +10 -0
  85. package/presets/maestro-auditor/preset.yml +2 -0
  86. package/presets/maestro-coder/agent.cordis.yml +223 -0
  87. package/presets/maestro-coder/preset.yml +2 -0
  88. package/presets/maestro-reviewer/agent.cordis.yml +14 -0
  89. package/presets/maestro-reviewer/preset.yml +2 -0
  90. package/src/augment.d.ts +47 -0
  91. package/src/config-store.ts +109 -0
  92. package/src/events.ts +25 -0
  93. package/src/gitlab-client.ts +186 -0
  94. package/src/gitlab-webhook.ts +166 -0
  95. package/src/govard-tool.ts +114 -0
  96. package/src/index.ts +15 -0
  97. package/src/notify.ts +30 -0
  98. package/src/orchestrator.ts +740 -0
  99. package/src/pin-store.ts +4 -0
  100. package/src/providers/github.stub.ts +11 -0
  101. package/src/providers/gitlab.ts +190 -0
  102. package/src/providers/interface.ts +2 -0
  103. package/src/review-findings-tool.ts +51 -0
  104. package/src/review-history.ts +112 -0
  105. package/src/review-intake.ts +86 -0
  106. package/src/review-signals.ts +52 -0
  107. package/src/secure-compare.ts +13 -0
  108. package/src/settings-rpc.ts +249 -0
  109. package/src/skills-tool.ts +193 -0
  110. package/src/workspace-tool.ts +90 -0
@@ -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,2 @@
1
+ export interface ReviewRequest { provider: string; projectPath: string; mrId: string; profile: 'magento2'|'generic'|'laravel'|'custom'; }
2
+ export interface ReviewProvider { id: string; intake(req: Request): Promise<ReviewRequest>; postFindings(f: any[]): Promise<void>; }
@@ -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
+ }