@ddtcorex/dsh-maestro-review 0.1.2 → 0.2.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.
@@ -1,166 +0,0 @@
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 } from './events.ts'
7
- import { routeGitlabReviewRequest } from './review-intake.js'
8
-
9
- export const name = 'maestro-gitlab-webhook'
10
-
11
- export interface Config {
12
- port: number
13
- /** Username of the GitLab service account used for reviewer assignments. */
14
- botUsername?: string
15
- /**
16
- * Fallback when Maestro Settings has no webhook secret; optional so a
17
- * deployment that configures everything through Settings boots without env
18
- * vars. With neither source set, every request is rejected (fail closed).
19
- */
20
- secret?: string
21
- }
22
-
23
- export const Config: z<Config> = z.object({
24
- port: z.natural().max(65535).required(),
25
- botUsername: z.string(),
26
- secret: z.string().role('secret'),
27
- })
28
-
29
- interface GitlabMrWebhookBody {
30
- object_kind: string
31
- project: { id: number; path_with_namespace: string }
32
- object_attributes: { iid: number; action: string; source_branch: string }
33
- }
34
-
35
- /** GitLab payloads are small; 5 MB is generous and stops unbounded buffering. */
36
- const MAX_WEBHOOK_BODY_BYTES = 5 * 1024 * 1024
37
-
38
- function readBody(req: IncomingMessage, limit = MAX_WEBHOOK_BODY_BYTES): Promise<string | undefined> {
39
- return new Promise((resolvePromise) => {
40
- let raw = ''
41
- let settled = false
42
- req.on('data', (chunk: Buffer) => {
43
- if (settled) return
44
- if (raw.length + chunk.length > limit) {
45
- settled = true
46
- req.resume()
47
- resolvePromise(undefined)
48
- return
49
- }
50
- raw += chunk.toString()
51
- })
52
- req.on('end', () => { if (!settled) { settled = true; resolvePromise(raw) } })
53
- req.on('error', () => { if (!settled) { settled = true; resolvePromise(undefined) } })
54
- })
55
- }
56
-
57
- function isValidMrOpenedPayload(value: unknown): value is MrOpenedPayload {
58
- if (typeof value !== 'object' || value === null) return false
59
- const v = value as Record<string, unknown>
60
- return typeof v.projectPath === 'string'
61
- && typeof v.projectId === 'number'
62
- && typeof v.mrIid === 'number'
63
- && typeof v.sourceBranch === 'string'
64
- }
65
-
66
- function hasValidGitlabMrIdentity(value: unknown): boolean {
67
- if (typeof value !== 'object' || value === null) return false
68
- const body = value as Record<string, unknown>
69
- const project = body.project as Record<string, unknown> | undefined
70
- const attributes = body.object_attributes as Record<string, unknown> | undefined
71
- const mergeRequest = body.merge_request as Record<string, unknown> | undefined
72
- const source = body.object_kind === 'note' ? mergeRequest : attributes
73
- return typeof project?.id === 'number' && typeof project.path_with_namespace === 'string'
74
- && typeof source?.iid === 'number' && typeof source.source_branch === 'string'
75
- }
76
-
77
- export function apply(ctx: Context, config: Config): void {
78
- // Settings wins over the boot secret, re-read per request so a change in the
79
- // UI takes effect without a restart. With neither source set, every token
80
- // fails the comparison (a header string never equals `undefined`), so the
81
- // server stays closed until a secret is configured.
82
- let warnedUnconfigured = false
83
- async function requestAuthorized(headerValue: string | string[] | undefined): Promise<boolean> {
84
- const userConfig = await loadUserConfig()
85
- const expected = userConfig.webhookSecret ?? config.secret
86
- if (expected === undefined) {
87
- if (!warnedUnconfigured) {
88
- warnedUnconfigured = true
89
- console.error('maestro-gitlab-webhook: no webhook secret configured — set one in Maestro Settings or MAESTRO_GITLAB_WEBHOOK_SECRET; rejecting all requests until then')
90
- }
91
- return false
92
- }
93
- return secretsMatch(headerValue, expected)
94
- }
95
-
96
- const server = createServer((req, res) => {
97
- if (req.method !== 'POST') {
98
- res.writeHead(404).end()
99
- return
100
- }
101
- void requestAuthorized(req.headers['x-gitlab-token']).then(async (authorized) => {
102
- if (!authorized) {
103
- res.writeHead(401).end()
104
- return
105
- }
106
- readBody(req).then(async (raw) => {
107
- if (raw === undefined) {
108
- res.writeHead(413).end()
109
- return
110
- }
111
- if (req.url === '/hooks/gitlab-mr/trigger') {
112
- let parsed: unknown
113
- try {
114
- parsed = JSON.parse(raw)
115
- } catch {
116
- res.writeHead(400).end()
117
- return
118
- }
119
- if (!isValidMrOpenedPayload(parsed)) {
120
- res.writeHead(400).end()
121
- return
122
- }
123
- const request: ReviewRequest = {
124
- ...parsed,
125
- trigger: 'mention',
126
- mode: 'quick',
127
- scope: { kind: 'mr' },
128
- }
129
- ctx.emit('maestro/review-request', request)
130
- res.writeHead(200).end()
131
- return
132
- }
133
-
134
- if (req.url !== '/hooks/gitlab-mr') {
135
- res.writeHead(404).end()
136
- return
137
- }
138
- let body: GitlabMrWebhookBody
139
- try {
140
- body = JSON.parse(raw)
141
- } catch {
142
- res.writeHead(400).end()
143
- return
144
- }
145
- const userConfig = await loadUserConfig()
146
- if ((body.object_kind === 'merge_request' || body.object_kind === 'note') && !hasValidGitlabMrIdentity(body)) {
147
- res.writeHead(400).end()
148
- return
149
- }
150
- const request = routeGitlabReviewRequest(
151
- body,
152
- userConfig.botUsername ?? config.botUsername ?? 'maestro',
153
- { pushEnabled: userConfig.autoRereviewOnPush === true },
154
- )
155
- if (request !== undefined) ctx.emit('maestro/review-request', request)
156
- res.writeHead(200).end()
157
- })
158
- })
159
- })
160
-
161
- server.listen(config.port)
162
-
163
- ctx.effect(() => async () => {
164
- await new Promise<void>((resolve) => { server.close(() => resolve()) })
165
- }, 'gitlab-webhook teardown')
166
- }