@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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import z from '@deepseek-ai/schemastery'
|
|
3
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
|
+
|
|
5
|
+
export const name = 'maestro-gitlab-client'
|
|
6
|
+
export const inject = ['tools']
|
|
7
|
+
|
|
8
|
+
export interface Config {
|
|
9
|
+
baseUrl: string
|
|
10
|
+
projectId: string | number
|
|
11
|
+
mrIid: number
|
|
12
|
+
token: string
|
|
13
|
+
botUsername: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const Config: z<Config> = z.object({
|
|
17
|
+
baseUrl: z.string().required(),
|
|
18
|
+
projectId: z.union([z.string(), z.number()]).required(),
|
|
19
|
+
mrIid: z.number().required(),
|
|
20
|
+
token: z.string().role('secret').required(),
|
|
21
|
+
botUsername: z.string().required(),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
interface GitlabDiff {
|
|
25
|
+
old_path: string
|
|
26
|
+
new_path: string
|
|
27
|
+
diff: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface GitlabDiffRefs {
|
|
31
|
+
base_sha: string
|
|
32
|
+
start_sha: string
|
|
33
|
+
head_sha: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface GitlabDiscussionNote {
|
|
37
|
+
body: string
|
|
38
|
+
author: { username: string }
|
|
39
|
+
position?: { new_path: string, new_line: number | null }
|
|
40
|
+
resolved: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface GitlabDiscussion {
|
|
44
|
+
id: string
|
|
45
|
+
notes: GitlabDiscussionNote[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function apply(ctx: Context, config: Config): void {
|
|
49
|
+
const apiBase = `${config.baseUrl}/api/v4/projects/${config.projectId}/merge_requests/${config.mrIid}`
|
|
50
|
+
const headers = { 'PRIVATE-TOKEN': config.token }
|
|
51
|
+
let cachedDiffRefs: GitlabDiffRefs | undefined
|
|
52
|
+
|
|
53
|
+
async function getDiffRefs(): Promise<GitlabDiffRefs> {
|
|
54
|
+
if (cachedDiffRefs !== undefined) return cachedDiffRefs
|
|
55
|
+
const response = await fetch(apiBase, { headers })
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
58
|
+
}
|
|
59
|
+
cachedDiffRefs = (await response.json() as { diff_refs: GitlabDiffRefs }).diff_refs
|
|
60
|
+
return cachedDiffRefs
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
ctx.tools.register(defineTool({
|
|
64
|
+
name: 'gitlab_get_mr_diff',
|
|
65
|
+
description: 'Fetch the current unified diff for this merge request.',
|
|
66
|
+
parameters: {},
|
|
67
|
+
output: {
|
|
68
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
69
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
70
|
+
},
|
|
71
|
+
async execute() {
|
|
72
|
+
const response = await fetch(`${apiBase}/diffs`, { headers })
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
75
|
+
}
|
|
76
|
+
const diffs = await response.json() as GitlabDiff[]
|
|
77
|
+
const text = diffs.map(d => `--- ${d.old_path}\n+++ ${d.new_path}\n${d.diff}`).join('\n\n')
|
|
78
|
+
return { text }
|
|
79
|
+
},
|
|
80
|
+
}))
|
|
81
|
+
|
|
82
|
+
ctx.tools.register(defineTool({
|
|
83
|
+
name: 'gitlab_post_mr_comment',
|
|
84
|
+
description: 'Post a Markdown comment (discussion note) on this merge request.',
|
|
85
|
+
parameters: {
|
|
86
|
+
body: { type: 'string', required: true, description: 'Markdown comment body.' },
|
|
87
|
+
},
|
|
88
|
+
output: {
|
|
89
|
+
schema: { type: 'object', additionalProperties: false, properties: { posted: { type: 'boolean', required: true } } },
|
|
90
|
+
render: () => [{ type: 'text', text: 'Comment posted.' }],
|
|
91
|
+
},
|
|
92
|
+
async execute(args) {
|
|
93
|
+
const response = await fetch(`${apiBase}/notes`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
96
|
+
body: JSON.stringify({ body: args.body }),
|
|
97
|
+
})
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
100
|
+
}
|
|
101
|
+
return { posted: true }
|
|
102
|
+
},
|
|
103
|
+
}))
|
|
104
|
+
|
|
105
|
+
ctx.tools.register(defineTool({
|
|
106
|
+
name: 'gitlab_list_own_review_threads',
|
|
107
|
+
description: 'List this merge request\'s unresolved inline discussion threads previously created by this bot account, so you can reply instead of duplicating.',
|
|
108
|
+
parameters: {},
|
|
109
|
+
output: {
|
|
110
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
111
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
112
|
+
},
|
|
113
|
+
async execute() {
|
|
114
|
+
const response = await fetch(`${apiBase}/discussions`, { headers })
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
117
|
+
}
|
|
118
|
+
const discussions = await response.json() as GitlabDiscussion[]
|
|
119
|
+
const ownThreads = discussions
|
|
120
|
+
.filter(d => d.notes.length > 0 && d.notes[0].author.username === config.botUsername && !d.notes[0].resolved && d.notes[0].position !== undefined)
|
|
121
|
+
.map(d => ({ discussionId: d.id, path: d.notes[0].position!.new_path, line: d.notes[0].position!.new_line, lastCommentBody: d.notes[d.notes.length - 1].body }))
|
|
122
|
+
return { text: JSON.stringify(ownThreads) }
|
|
123
|
+
},
|
|
124
|
+
}))
|
|
125
|
+
|
|
126
|
+
ctx.tools.register(defineTool({
|
|
127
|
+
name: 'gitlab_post_inline_comment',
|
|
128
|
+
description: 'Create a new inline discussion at a specific file and line in this merge request\'s current diff.',
|
|
129
|
+
parameters: {
|
|
130
|
+
path: { type: 'string', required: true, description: 'File path on the new (target) side of the diff.' },
|
|
131
|
+
line: { type: 'number', required: true, description: 'Line number on the new (target) side of the diff.' },
|
|
132
|
+
body: { type: 'string', required: true, description: 'Markdown comment body.' },
|
|
133
|
+
},
|
|
134
|
+
output: {
|
|
135
|
+
schema: { type: 'object', additionalProperties: false, properties: { posted: { type: 'boolean', required: true } } },
|
|
136
|
+
render: () => [{ type: 'text', text: 'Inline comment posted.' }],
|
|
137
|
+
},
|
|
138
|
+
async execute(args) {
|
|
139
|
+
const diffRefs = await getDiffRefs()
|
|
140
|
+
const response = await fetch(`${apiBase}/discussions`, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
body: args.body,
|
|
145
|
+
position: {
|
|
146
|
+
position_type: 'text',
|
|
147
|
+
base_sha: diffRefs.base_sha,
|
|
148
|
+
start_sha: diffRefs.start_sha,
|
|
149
|
+
head_sha: diffRefs.head_sha,
|
|
150
|
+
old_path: args.path,
|
|
151
|
+
new_path: args.path,
|
|
152
|
+
new_line: args.line,
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
})
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
158
|
+
}
|
|
159
|
+
return { posted: true }
|
|
160
|
+
},
|
|
161
|
+
}))
|
|
162
|
+
|
|
163
|
+
ctx.tools.register(defineTool({
|
|
164
|
+
name: 'gitlab_reply_to_thread',
|
|
165
|
+
description: 'Reply to an existing inline discussion thread (discussionId from gitlab_list_own_review_threads) without resolving it.',
|
|
166
|
+
parameters: {
|
|
167
|
+
discussionId: { type: 'string', required: true },
|
|
168
|
+
body: { type: 'string', required: true, description: 'Markdown reply body.' },
|
|
169
|
+
},
|
|
170
|
+
output: {
|
|
171
|
+
schema: { type: 'object', additionalProperties: false, properties: { posted: { type: 'boolean', required: true } } },
|
|
172
|
+
render: () => [{ type: 'text', text: 'Reply posted.' }],
|
|
173
|
+
},
|
|
174
|
+
async execute(args) {
|
|
175
|
+
const response = await fetch(`${apiBase}/discussions/${args.discussionId}/notes`, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
178
|
+
body: JSON.stringify({ body: args.body }),
|
|
179
|
+
})
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
|
|
182
|
+
}
|
|
183
|
+
return { posted: true }
|
|
184
|
+
},
|
|
185
|
+
}))
|
|
186
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
5
|
+
import z from '@deepseek-ai/schemastery'
|
|
6
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
7
|
+
|
|
8
|
+
export const name = 'maestro-govard-tool'
|
|
9
|
+
export const inject = ['tools']
|
|
10
|
+
|
|
11
|
+
export interface Config {
|
|
12
|
+
rootPath?: string
|
|
13
|
+
timeoutMs?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const Config: z<Config> = z.object({
|
|
17
|
+
rootPath: z.string(),
|
|
18
|
+
timeoutMs: z.number().min(1_000).default(300_000),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
interface RunResult {
|
|
22
|
+
code: number | null
|
|
23
|
+
stdout: string
|
|
24
|
+
stderr: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function run(command: string, args: string[], cwd: string, timeoutMs: number): Promise<RunResult> {
|
|
28
|
+
return new Promise((promiseResolve, promiseReject) => {
|
|
29
|
+
const child = spawn(command, args, { cwd })
|
|
30
|
+
let stdout = ''
|
|
31
|
+
let stderr = ''
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
child.kill('SIGKILL')
|
|
34
|
+
promiseReject(new Error(`"${command} ${args.join(' ')}" timed out after ${timeoutMs}ms`))
|
|
35
|
+
}, timeoutMs)
|
|
36
|
+
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
|
37
|
+
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
|
|
38
|
+
child.on('error', (err) => {
|
|
39
|
+
clearTimeout(timer)
|
|
40
|
+
promiseReject(err)
|
|
41
|
+
})
|
|
42
|
+
child.on('close', (code) => {
|
|
43
|
+
clearTimeout(timer)
|
|
44
|
+
promiseResolve({ code, stdout, stderr })
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function textResult(result: RunResult, successText: string): { text: string } {
|
|
50
|
+
if (result.code !== 0) {
|
|
51
|
+
throw new Error(`Exit ${result.code}: ${result.stderr || result.stdout}`)
|
|
52
|
+
}
|
|
53
|
+
return { text: result.stdout.length > 0 ? result.stdout : successText }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function apply(ctx: Context, config: Config): void {
|
|
57
|
+
const rootPath = config.rootPath ?? process.cwd()
|
|
58
|
+
const timeoutMs = config.timeoutMs ?? 300_000
|
|
59
|
+
|
|
60
|
+
async function ensureInitialized(): Promise<void> {
|
|
61
|
+
if (existsSync(join(rootPath, '.govard.yml'))) return
|
|
62
|
+
const result = await run('govard', ['init', '--framework', 'magento2', '-y'], rootPath, timeoutMs)
|
|
63
|
+
if (result.code !== 0) throw new Error(`govard init failed: ${result.stderr || result.stdout}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ctx.tools.register(defineTool({
|
|
67
|
+
name: 'govard_env_up',
|
|
68
|
+
description: 'Bring up the local Magento 2 Govard environment for the current worktree, initializing it first if needed.',
|
|
69
|
+
parameters: {},
|
|
70
|
+
output: {
|
|
71
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
72
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
73
|
+
},
|
|
74
|
+
async execute() {
|
|
75
|
+
await ensureInitialized()
|
|
76
|
+
const result = await run('govard', ['up'], rootPath, timeoutMs)
|
|
77
|
+
return textResult(result, 'Environment is up.')
|
|
78
|
+
},
|
|
79
|
+
}))
|
|
80
|
+
|
|
81
|
+
ctx.tools.register(defineTool({
|
|
82
|
+
name: 'govard_shell',
|
|
83
|
+
description: 'Run one non-interactive command inside the Govard-managed container (e.g. a test suite) and return its output.',
|
|
84
|
+
parameters: {
|
|
85
|
+
command: { type: 'string', required: true, description: 'Shell command to run inside the container, e.g. "vendor/bin/phpunit".' },
|
|
86
|
+
},
|
|
87
|
+
output: {
|
|
88
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
89
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
90
|
+
},
|
|
91
|
+
async execute(args) {
|
|
92
|
+
const result = await run('govard', ['shell', '-c', args.command], rootPath, timeoutMs)
|
|
93
|
+
return textResult(result, '(no output)')
|
|
94
|
+
},
|
|
95
|
+
}))
|
|
96
|
+
|
|
97
|
+
ctx.tools.register(defineTool({
|
|
98
|
+
name: 'govard_env_down',
|
|
99
|
+
description: 'Tear down the local Govard environment for the current worktree, removing volumes.',
|
|
100
|
+
parameters: {},
|
|
101
|
+
output: {
|
|
102
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
103
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
104
|
+
},
|
|
105
|
+
async execute() {
|
|
106
|
+
const result = await run('govard', ['down', '-v'], rootPath, timeoutMs)
|
|
107
|
+
return textResult(result, 'Environment is down.')
|
|
108
|
+
},
|
|
109
|
+
}))
|
|
110
|
+
|
|
111
|
+
ctx.effect(() => async () => {
|
|
112
|
+
await run('govard', ['down', '-v'], rootPath, timeoutMs).catch(() => undefined)
|
|
113
|
+
}, 'govard-tool teardown')
|
|
114
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
|
|
3
|
+
export const name = '@ddtcorex/dsh-maestro-review'
|
|
4
|
+
|
|
5
|
+
export function apply(ctx: Context): void {
|
|
6
|
+
ctx.effect(() => ctx.connection.rpc.handle('/dsh-maestro-review', async (endpoint, payload) => {
|
|
7
|
+
if (endpoint === 'status') return { ok: true, value: { provider: 'gitlab' } }
|
|
8
|
+
if (endpoint === 'providers') return { ok: true, value: { providers: ['gitlab', 'github'] } }
|
|
9
|
+
if (endpoint === 'review') {
|
|
10
|
+
const body = payload as Record<string, unknown> | undefined
|
|
11
|
+
return { ok: true, value: { received: true, provider: (body as any)?.provider ?? 'gitlab' } }
|
|
12
|
+
}
|
|
13
|
+
return { ok: false, error: { code: 'bad-request', message: `Unknown endpoint: ${endpoint}`, details: { issues: [{ message: String(endpoint) }] } as any } }
|
|
14
|
+
}, { authority: 'loopback' }), 'maestro-review rpc')
|
|
15
|
+
}
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Provider-neutral notifier contract plus every message text this plugin owns. */
|
|
2
|
+
|
|
3
|
+
export interface NotifyDeliveryResult {
|
|
4
|
+
sent: boolean
|
|
5
|
+
reason?: 'not-configured' | 'request-failed' | 'unknown-provider'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Structural view of the optional `maestroNotifier` service published by
|
|
10
|
+
* `@ddtcorex/dsh-maestro-notifier`. Consumed via `ctx.get` so an absent
|
|
11
|
+
* notifier plugin degrades to "no notifications" instead of breaking reviews.
|
|
12
|
+
*/
|
|
13
|
+
export interface NotifierLike {
|
|
14
|
+
send(providerId: string, target: Record<string, unknown>, message: { text: string }): Promise<NotifyDeliveryResult>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function reviewDigestText(notification: {
|
|
18
|
+
projectPath: string
|
|
19
|
+
mrIid: number
|
|
20
|
+
status: 'completed' | 'failed'
|
|
21
|
+
summary?: string
|
|
22
|
+
}): string {
|
|
23
|
+
const outcome = notification.status === 'completed' ? '✅ completed' : '⚠️ failed'
|
|
24
|
+
const summary = notification.summary === undefined ? '' : `\n${notification.summary}`
|
|
25
|
+
return `Maestro review of ${notification.projectPath} !${notification.mrIid}: ${outcome}${summary}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function pinRotationText(pin: string): string {
|
|
29
|
+
return `DSH public access PIN was rotated\nNew PIN: ${pin}`
|
|
30
|
+
}
|