@goodandready/dsh-github-ops 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/CHANGELOG.md +81 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/README.ru.md +161 -0
- package/README.zh.md +151 -0
- package/cordis.patch.yml +3 -0
- package/lib/approval.js +168 -0
- package/lib/auth.js +59 -0
- package/lib/client.js +304 -0
- package/lib/commands.js +151 -0
- package/lib/github.js +315 -0
- package/lib/index.js +332 -0
- package/lib/jobs.js +107 -0
- package/lib/present.js +214 -0
- package/lib/repo-context.js +72 -0
- package/lib/review-rules.js +147 -0
- package/lib/tools/actions.js +107 -0
- package/lib/tools/api.js +44 -0
- package/lib/tools/issues.js +101 -0
- package/lib/tools/mirror.js +163 -0
- package/lib/tools/pulls.js +207 -0
- package/lib/tools/register-collab.js +233 -0
- package/lib/tools/register-core.js +259 -0
- package/lib/tools/register-jobs.js +57 -0
- package/lib/tools/register-mirror.js +88 -0
- package/lib/tools/register-ops.js +283 -0
- package/lib/tools/releases.js +111 -0
- package/lib/tools/repo-settings.js +207 -0
- package/lib/tools/repos.js +108 -0
- package/lib/tools/tags.js +68 -0
- package/package.json +73 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// dsh-github-ops — host half.
|
|
2
|
+
//
|
|
3
|
+
// Gives the agent the GitHub operations the release pipeline needs and the plugin
|
|
4
|
+
// ecosystem lacks: releases, tags, a guarded generic API pass-through, repository and
|
|
5
|
+
// file reads, issues, pull requests with a review pack, CI checks, workflow runs,
|
|
6
|
+
// secrets/variables, rulesets and sanitized mirror publication.
|
|
7
|
+
//
|
|
8
|
+
// Security model: the token lives in the DSH credential service (settings hold only
|
|
9
|
+
// the credential NAME), reads are free, and every state-changing call must carry
|
|
10
|
+
// `confirm: true`. A small deny list refuses the calls a single confirmation cannot
|
|
11
|
+
// undo (deleting a repository, transferring ownership, deleting an organization).
|
|
12
|
+
|
|
13
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
14
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
15
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
16
|
+
|
|
17
|
+
import { createGitHubClient, parseRepoSpec, describeError, GitHubError } from './github.js'
|
|
18
|
+
import { resolveAccess, NO_ACCESS_GUIDANCE } from './auth.js'
|
|
19
|
+
import { resolveRepoContext } from './repo-context.js'
|
|
20
|
+
import { presentationFor } from './present.js'
|
|
21
|
+
import { createRecords, startReviewJob } from './jobs.js'
|
|
22
|
+
import { registerCommands } from './commands.js'
|
|
23
|
+
import { registerJobTools } from './tools/register-jobs.js'
|
|
24
|
+
import { registerApprovalGate, ALL_ACTIONS } from './approval.js'
|
|
25
|
+
import { registerCoreTools } from './tools/register-core.js'
|
|
26
|
+
import { registerCollabTools } from './tools/register-collab.js'
|
|
27
|
+
import { registerMirrorTools } from './tools/register-mirror.js'
|
|
28
|
+
import { registerOpsTools } from './tools/register-ops.js'
|
|
29
|
+
import { execFile } from 'node:child_process'
|
|
30
|
+
import { promisify } from 'node:util'
|
|
31
|
+
|
|
32
|
+
const execFileAsync = promisify(execFile)
|
|
33
|
+
|
|
34
|
+
/** git runner: never throws, always answers with a code and both streams. */
|
|
35
|
+
function makeGitRunner() {
|
|
36
|
+
return async (args, { cwd, env } = {}) => {
|
|
37
|
+
try {
|
|
38
|
+
const { stdout, stderr } = await execFileAsync('git', args, {
|
|
39
|
+
cwd: cwd || process.cwd(),
|
|
40
|
+
env: { ...process.env, ...(env || {}) },
|
|
41
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
42
|
+
})
|
|
43
|
+
return { code: 0, stdout, stderr }
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return {
|
|
46
|
+
code: typeof err.code === 'number' ? err.code : 1,
|
|
47
|
+
stdout: err.stdout || '',
|
|
48
|
+
stderr: err.stderr || String((err && err.message) || err),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const name = 'dsh-github-ops'
|
|
55
|
+
export const inject = ['tools', 'credentials', 'settings']
|
|
56
|
+
|
|
57
|
+
/** Output contract shared by every tool: a value or a described failure. */
|
|
58
|
+
export const OUTPUT_SCHEMA = {
|
|
59
|
+
type: 'object',
|
|
60
|
+
additionalProperties: true,
|
|
61
|
+
properties: {
|
|
62
|
+
ok: { type: 'boolean' },
|
|
63
|
+
error: { type: 'string' },
|
|
64
|
+
data: { type: 'json' },
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const MAX_RENDER_CHARS = 20000
|
|
69
|
+
|
|
70
|
+
/** Actions this plugin can be asked to perform; exposed for settings validation. */
|
|
71
|
+
export const KNOWN_ACTIONS = ALL_ACTIONS
|
|
72
|
+
|
|
73
|
+
/** Render a tool result for the model: readable text, bounded so one call cannot flood the turn. */
|
|
74
|
+
export function renderToolResult(toolName, value) {
|
|
75
|
+
if (!value || value.ok !== true) {
|
|
76
|
+
const error = (value && value.error) || 'unknown error'
|
|
77
|
+
return [{ type: 'text', text: `${toolName} failed: ${error}` }]
|
|
78
|
+
}
|
|
79
|
+
const data = value.data
|
|
80
|
+
let text
|
|
81
|
+
if (typeof data === 'string') text = data
|
|
82
|
+
else if (data === undefined || data === null) text = `${toolName}: done`
|
|
83
|
+
else {
|
|
84
|
+
try {
|
|
85
|
+
text = JSON.stringify(data, null, 2)
|
|
86
|
+
} catch {
|
|
87
|
+
text = String(data)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (text.length > MAX_RENDER_CHARS) {
|
|
91
|
+
text = `${text.slice(0, MAX_RENDER_CHARS)}\n… output truncated (${text.length} characters total)`
|
|
92
|
+
}
|
|
93
|
+
return [{ type: 'text', text }]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const Config = Schema.object({
|
|
97
|
+
tokenEnv: Schema.string().role('credential-ref').default('GITHUB_TOKEN')
|
|
98
|
+
.description('Name used to look up access: the DSH credential, then the environment variable, then the gh CLI. The value itself is never stored in settings.'),
|
|
99
|
+
tokenSource: Schema.string().default('auto')
|
|
100
|
+
.description('Where access comes from: "auto" tries the DSH credential, then the environment variable, then the gh CLI; pin it to "credentials", "env" or "gh" to use one source only.'),
|
|
101
|
+
defaultRepository: Schema.string().default('')
|
|
102
|
+
.description('Default repository as "owner/repo"; used when a tool call omits the repository.'),
|
|
103
|
+
baseUrl: Schema.string().default('https://api.github.com')
|
|
104
|
+
.description('GitHub API base URL; change it for GitHub Enterprise.'),
|
|
105
|
+
timeoutMs: Schema.number().default(30000)
|
|
106
|
+
.description('Per-request timeout in milliseconds.'),
|
|
107
|
+
maxRetries: Schema.number().default(2)
|
|
108
|
+
.description('Retries for a read (GET/HEAD) on a network failure, a timeout or a 5xx. Writes are never retried automatically.'),
|
|
109
|
+
reviewRulesJson: Schema.string().default('')
|
|
110
|
+
.description('Optional review-rule overrides as JSON: sensitivePaths, sensitiveSeverity, attentionPaths, migrationPaths, testsRequired, sourcePatterns, testPatterns, largeDiffLines.'),
|
|
111
|
+
allowedActions: Schema.array(Schema.string()).default([])
|
|
112
|
+
.description('Write actions this plugin may perform; anything else is denied. Empty means every known action is allowed, and each one still asks for confirmation.'),
|
|
113
|
+
autoApprove: Schema.array(Schema.string()).default([])
|
|
114
|
+
.description('Actions an unattended run (DSH_GITHUB_OPS_UNATTENDED=1) may perform without asking. Destructive actions are never auto-approved.'),
|
|
115
|
+
reviewJobTimeoutMs: Schema.number().default(120000)
|
|
116
|
+
.description('How long a background review job may run before it is cancelled.'),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
export function apply(ctx, rawConfig) {
|
|
120
|
+
const config = { ...Config(), ...(rawConfig || {}) }
|
|
121
|
+
let settingsScope = null
|
|
122
|
+
|
|
123
|
+
ctx.inject(['settings'], (sctx) => {
|
|
124
|
+
try {
|
|
125
|
+
settingsScope = sctx.settings.register(name, Config, { base: config })
|
|
126
|
+
} catch (err) {
|
|
127
|
+
ctx.logger?.warn?.('[dsh-github-ops] settings namespace unavailable:', err && err.message)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/** Review-rule overrides from settings; a broken value is reported, never silently ignored. */
|
|
132
|
+
function reviewRules() {
|
|
133
|
+
const raw = liveConfig().reviewRulesJson
|
|
134
|
+
if (!raw || typeof raw !== 'string' || !raw.trim()) return undefined
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(raw)
|
|
137
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined
|
|
138
|
+
} catch (err) {
|
|
139
|
+
ctx.logger?.warn?.('[dsh-github-ops] reviewRulesJson is not valid JSON and was ignored:', err && err.message)
|
|
140
|
+
return undefined
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function liveConfig() {
|
|
145
|
+
try {
|
|
146
|
+
const fromScope = settingsScope && settingsScope.get && settingsScope.get()
|
|
147
|
+
return { ...config, ...(fromScope || {}) }
|
|
148
|
+
} catch {
|
|
149
|
+
return config
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The gh CLI session, when the machine already has `gh auth login` done. */
|
|
154
|
+
async function ghCliAccess() {
|
|
155
|
+
const { stdout } = await execFileAsync('gh', ['auth', 'token'], { timeout: 10000, maxBuffer: 1024 * 1024 })
|
|
156
|
+
return stdout
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Resolve access for one operation; nothing is cached, so a rotation lands immediately. */
|
|
160
|
+
async function resolveToken() {
|
|
161
|
+
const cfg = liveConfig()
|
|
162
|
+
return resolveAccess({
|
|
163
|
+
// the credentials service takes a branded reference, not a bare name
|
|
164
|
+
credentials: {
|
|
165
|
+
resolve: async (name) => {
|
|
166
|
+
try {
|
|
167
|
+
return await ctx.credentials.resolve(credentialRef(name))
|
|
168
|
+
} catch {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
tokenEnv: cfg.tokenEnv,
|
|
174
|
+
tokenSource: cfg.tokenSource || 'auto',
|
|
175
|
+
env: process.env,
|
|
176
|
+
runGh: ghCliAccess,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function client() {
|
|
181
|
+
const cfg = liveConfig()
|
|
182
|
+
const access = await resolveToken()
|
|
183
|
+
if (!access.value) {
|
|
184
|
+
throw new Error(`GitHub access is not configured: ${access.guidance || NO_ACCESS_GUIDANCE}`)
|
|
185
|
+
}
|
|
186
|
+
return createGitHubClient({
|
|
187
|
+
token: access.value,
|
|
188
|
+
baseUrl: cfg.baseUrl || 'https://api.github.com',
|
|
189
|
+
timeoutMs: Number(cfg.timeoutMs) || 30000,
|
|
190
|
+
maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Which repository a call is about: the one it names, the configured default, or — inside
|
|
196
|
+
* a checkout — the one the origin remote already identifies. A bare "#12" keeps talking to
|
|
197
|
+
* the repository the previous call used.
|
|
198
|
+
*/
|
|
199
|
+
async function asRepo(args, cfg) {
|
|
200
|
+
const explicit = args && args.repository ? String(args.repository) : ''
|
|
201
|
+
const fallback = cfg.defaultRepository || ''
|
|
202
|
+
let remoteUrl = ''
|
|
203
|
+
if (!(explicit && explicit.includes('/')) && !fallback) {
|
|
204
|
+
const cwd = (typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd()
|
|
205
|
+
const git = makeGitRunner()
|
|
206
|
+
const origin = await git(['remote', 'get-url', 'origin'], { cwd })
|
|
207
|
+
if (origin.code === 0) remoteUrl = origin.stdout.trim()
|
|
208
|
+
}
|
|
209
|
+
const resolved = resolveRepoContext({ explicit, fallback, remoteUrl })
|
|
210
|
+
if (!resolved) {
|
|
211
|
+
throw new Error('repository is required: pass "owner/repo", set a default repository in the plugin settings, or run inside a checkout with an origin remote')
|
|
212
|
+
}
|
|
213
|
+
return resolved
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Register one tool whose failures become values: a GitHub error never breaks a turn.
|
|
218
|
+
* The tool contract requires an output schema plus a renderer, so both are supplied
|
|
219
|
+
* here once instead of in every definition.
|
|
220
|
+
*
|
|
221
|
+
* A tool name already claimed in this scope is a hard error in the core, and an error
|
|
222
|
+
* thrown from apply() takes the whole plugin tree — and therefore the harness — down.
|
|
223
|
+
* Another GitHub plugin may legitimately own the same name (the reference plugin
|
|
224
|
+
* exposes several of them), so a taken name is skipped and reported instead: this
|
|
225
|
+
* plugin must never be the reason a deployment cannot start.
|
|
226
|
+
*/
|
|
227
|
+
const skippedTools = []
|
|
228
|
+
function tool(toolName, description, parameters, run) {
|
|
229
|
+
const definition = defineTool({
|
|
230
|
+
name: toolName,
|
|
231
|
+
description,
|
|
232
|
+
parameters,
|
|
233
|
+
output: {
|
|
234
|
+
schema: OUTPUT_SCHEMA,
|
|
235
|
+
render: (_args, value) => renderToolResult(toolName, value),
|
|
236
|
+
},
|
|
237
|
+
...presentationFor(toolName),
|
|
238
|
+
execute: async (args) => {
|
|
239
|
+
try {
|
|
240
|
+
const value = await run(args || {})
|
|
241
|
+
return { ok: true, data: value }
|
|
242
|
+
} catch (err) {
|
|
243
|
+
const out = { ok: false, error: describeError(err) }
|
|
244
|
+
if (err instanceof GitHubError) {
|
|
245
|
+
if (err.status) out.status = err.status
|
|
246
|
+
if (err.code) out.code = err.code
|
|
247
|
+
if (err.rateLimit) out.rateLimit = err.rateLimit
|
|
248
|
+
}
|
|
249
|
+
return out
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
})
|
|
253
|
+
try {
|
|
254
|
+
ctx.tools.register(definition)
|
|
255
|
+
} catch (err) {
|
|
256
|
+
if (!/is already registered/.test(String((err && err.message) || err))) throw err
|
|
257
|
+
skippedTools.push(toolName)
|
|
258
|
+
ctx.logger?.warn?.(`[dsh-github-ops] tool "${toolName}" is already provided by another plugin; skipping it`)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const deps = { tool, asRepo, client, liveConfig, reviewRules }
|
|
263
|
+
registerCoreTools(deps)
|
|
264
|
+
registerCollabTools(deps)
|
|
265
|
+
registerOpsTools(deps)
|
|
266
|
+
registerMirrorTools({
|
|
267
|
+
tool,
|
|
268
|
+
git: makeGitRunner(),
|
|
269
|
+
defaultCwd: () => (typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd(),
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
// Background reviews: the host registry when the composition has one, our own records
|
|
273
|
+
// otherwise, so the tool works in both worlds.
|
|
274
|
+
const records = createRecords()
|
|
275
|
+
let hostJobs = null
|
|
276
|
+
try {
|
|
277
|
+
hostJobs = ctx.get ? ctx.get('jobs') : undefined
|
|
278
|
+
} catch {
|
|
279
|
+
hostJobs = null
|
|
280
|
+
}
|
|
281
|
+
registerJobTools({
|
|
282
|
+
tool,
|
|
283
|
+
asRepo,
|
|
284
|
+
client,
|
|
285
|
+
liveConfig,
|
|
286
|
+
reviewRules,
|
|
287
|
+
jobs: {
|
|
288
|
+
start: (spec) => startReviewJob({ registry: hostJobs, records, ...spec }),
|
|
289
|
+
get: (id) => records.get(id),
|
|
290
|
+
list: () => records.list(),
|
|
291
|
+
},
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// Slash commands are a fast path for a human: they only hand the model an instruction, so
|
|
295
|
+
// the write still goes through the approval gate.
|
|
296
|
+
ctx.inject(['commands'], (cctx) => {
|
|
297
|
+
try {
|
|
298
|
+
const git = makeGitRunner()
|
|
299
|
+
const readGitState = async () => {
|
|
300
|
+
const cwd = (typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd()
|
|
301
|
+
const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
|
|
302
|
+
const remote = await git(['remote', 'get-url', 'origin'], { cwd })
|
|
303
|
+
const resolved = remote.code === 0
|
|
304
|
+
? resolveRepoContext({ remoteUrl: remote.stdout.trim() })
|
|
305
|
+
: null
|
|
306
|
+
return {
|
|
307
|
+
branch: branch.code === 0 ? branch.stdout.trim() : undefined,
|
|
308
|
+
repo: resolved ? `${resolved.owner}/${resolved.repo}` : undefined,
|
|
309
|
+
remoteUrl: remote.code === 0 ? remote.stdout.trim() : undefined,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
registerCommands(cctx.commands, { readGitState })
|
|
313
|
+
} catch (err) {
|
|
314
|
+
ctx.logger?.warn?.('[dsh-github-ops] slash commands not registered:', err && err.message)
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// Writes go through the approval contour: a named action, a reason a human can decide
|
|
319
|
+
// on, and no auto-approval for anything destructive.
|
|
320
|
+
try {
|
|
321
|
+
registerApprovalGate(ctx, () => liveConfig())
|
|
322
|
+
} catch (err) {
|
|
323
|
+
ctx.logger?.warn?.('[dsh-github-ops] approval gate not registered:', err && err.message)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (skippedTools.length) {
|
|
327
|
+
ctx.logger?.warn?.(
|
|
328
|
+
`[dsh-github-ops] ${skippedTools.length} tool(s) left to the plugin that already provides them: `
|
|
329
|
+
+ skippedTools.join(', '),
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
}
|
package/lib/jobs.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Long reviews as background jobs.
|
|
2
|
+
//
|
|
3
|
+
// A review of a big pull request can take a while, and a turn should not sit and wait for
|
|
4
|
+
// it. When the host exposes a job registry (`ctx.jobs`) the work runs there, where the UI
|
|
5
|
+
// can show it and cancel it; otherwise it runs in this plugin's own registry, so the feature
|
|
6
|
+
// degrades to "works, just without the host UI" instead of "missing".
|
|
7
|
+
//
|
|
8
|
+
// Records are kept in memory, bounded, and never contain the access value.
|
|
9
|
+
|
|
10
|
+
export const REVIEW_JOB_KIND = 'github-review'
|
|
11
|
+
const DEFAULT_RECORD_LIMIT = 20
|
|
12
|
+
|
|
13
|
+
/** Bounded record store for started jobs. */
|
|
14
|
+
export function createRecords({ limit = DEFAULT_RECORD_LIMIT } = {}) {
|
|
15
|
+
const records = new Map()
|
|
16
|
+
return {
|
|
17
|
+
remember(id, record) {
|
|
18
|
+
records.set(id, record)
|
|
19
|
+
while (records.size > limit) {
|
|
20
|
+
const oldest = records.keys().next().value
|
|
21
|
+
records.delete(oldest)
|
|
22
|
+
}
|
|
23
|
+
return id
|
|
24
|
+
},
|
|
25
|
+
get(id) {
|
|
26
|
+
return records.get(id) || null
|
|
27
|
+
},
|
|
28
|
+
list() {
|
|
29
|
+
return [...records.entries()].map(([id, record]) => ({ id, ...record }))
|
|
30
|
+
},
|
|
31
|
+
get size() {
|
|
32
|
+
return records.size
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function splitRepo(repo) {
|
|
38
|
+
const [owner, name] = String(repo || '').split('/')
|
|
39
|
+
if (!owner || !name) throw new Error(`"${repo}" is not an owner/repo pair`)
|
|
40
|
+
return { owner, repo: name }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Start one review job.
|
|
45
|
+
*
|
|
46
|
+
* @param registry - host job registry (ctx.jobs) when available
|
|
47
|
+
* @param records - record store from createRecords()
|
|
48
|
+
* @param spec - { repo, pr, label, owner, timeoutMs, maxDiffChars, rules, client, ciRun }
|
|
49
|
+
* @returns the job id
|
|
50
|
+
*/
|
|
51
|
+
export function startReviewJob({ registry, records, repo, pr, label, owner, timeoutMs = 120000, maxDiffChars, rules, client, ciRun }) {
|
|
52
|
+
const target = splitRepo(repo)
|
|
53
|
+
const record = {
|
|
54
|
+
kind: REVIEW_JOB_KIND,
|
|
55
|
+
status: 'running',
|
|
56
|
+
repo,
|
|
57
|
+
pr,
|
|
58
|
+
startedAt: new Date().toISOString(),
|
|
59
|
+
finishedAt: null,
|
|
60
|
+
report: null,
|
|
61
|
+
error: null,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const run = async () => {
|
|
65
|
+
try {
|
|
66
|
+
const clientInstance = typeof client === 'function' ? await client() : client
|
|
67
|
+
const report = await ciRun(clientInstance, { ...target, number: pr, maxDiffChars, rules })
|
|
68
|
+
record.status = 'done'
|
|
69
|
+
record.report = report
|
|
70
|
+
record.finishedAt = new Date().toISOString()
|
|
71
|
+
return report
|
|
72
|
+
} catch (err) {
|
|
73
|
+
record.status = 'failed'
|
|
74
|
+
record.error = String((err && err.message) || err)
|
|
75
|
+
record.finishedAt = new Date().toISOString()
|
|
76
|
+
throw err
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const useHost = registry && typeof registry.start === 'function'
|
|
81
|
+
const id = useHost
|
|
82
|
+
? registry.start({
|
|
83
|
+
kind: REVIEW_JOB_KIND,
|
|
84
|
+
label: label || `${REVIEW_JOB_KIND} ${repo}#${pr}`,
|
|
85
|
+
owner,
|
|
86
|
+
timeoutMs,
|
|
87
|
+
outputLimitBytes: 64 * 1024,
|
|
88
|
+
run,
|
|
89
|
+
})
|
|
90
|
+
: `local-${Date.now().toString(36)}-${records.size + 1}`
|
|
91
|
+
|
|
92
|
+
records.remember(id, record)
|
|
93
|
+
if (!useHost) {
|
|
94
|
+
// no host registry: run it in the background, but never let a rejection escape
|
|
95
|
+
Promise.resolve().then(run).catch(() => {})
|
|
96
|
+
}
|
|
97
|
+
return id
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One-line status for the model, derived from a record. */
|
|
101
|
+
export function describeRecord(record) {
|
|
102
|
+
if (!record) return 'unknown job'
|
|
103
|
+
if (record.status === 'running') return `running: review of ${record.repo}#${record.pr}`
|
|
104
|
+
if (record.status === 'failed') return `failed: review of ${record.repo}#${record.pr} — ${record.error}`
|
|
105
|
+
const verdict = record.report && record.report.verdict ? record.report.verdict : 'done'
|
|
106
|
+
return `${verdict}: review of ${record.repo}#${record.pr}`
|
|
107
|
+
}
|
package/lib/present.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Tool presentation: what the human sees instead of a JSON blob.
|
|
2
|
+
//
|
|
3
|
+
// `presentCall` renders the pending card before a tool runs, `presentResult` the completed
|
|
4
|
+
// one. Both are pure: they take the arguments and the tool result and return a card, which
|
|
5
|
+
// keeps them testable and cheap. A missing presentation simply falls back to the default
|
|
6
|
+
// renderer, so an unlisted tool is never broken.
|
|
7
|
+
|
|
8
|
+
const text = (title, body) => ({
|
|
9
|
+
card: 'generic',
|
|
10
|
+
title,
|
|
11
|
+
content: [{ type: 'text', text: body }],
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const pending = (title, rawInput) => ({ card: 'generic', title, rawInput })
|
|
15
|
+
|
|
16
|
+
/** Reduce an arbitrary argument set to the fields a pending card should show. */
|
|
17
|
+
function pick(args, keys) {
|
|
18
|
+
const out = {}
|
|
19
|
+
for (const key of keys) if (args && args[key] !== undefined) out[key] = args[key]
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function failure(title, result) {
|
|
24
|
+
if (!result || result.ok !== false) return null
|
|
25
|
+
return text(title, String(result.error || 'unknown error'))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function repoOf(args) {
|
|
29
|
+
return args && args.repository ? String(args.repository) : 'the default repository'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const identityMeta = (args) => ({ title: String((args && args.repository) || '') })
|
|
33
|
+
|
|
34
|
+
const PRESENTATIONS = {
|
|
35
|
+
gh_repo: {
|
|
36
|
+
call: (args) => pending(`Read repository ${repoOf(args)}`, pick(args, ['repository'])),
|
|
37
|
+
result: (_args, r) => failure('Repository not read', r)
|
|
38
|
+
|| text(r.data.fullName, `${r.data.description || '(no description)'}\n${r.data.private ? 'private' : 'public'} · ${r.data.defaultBranch} · ★${r.data.stars} · topics: ${r.data.topics.join(', ') || 'none'}`),
|
|
39
|
+
},
|
|
40
|
+
gh_file: {
|
|
41
|
+
call: (args) => pending(`Read ${args.path || 'a file'}`, pick(args, ['repository', 'path', 'ref'])),
|
|
42
|
+
result: (_args, r) => failure('File not read', r)
|
|
43
|
+
|| text(r.data.path, r.data.kind === 'directory'
|
|
44
|
+
? `${r.data.entries.length} entries: ${r.data.entries.slice(0, 12).map((e) => e.name).join(', ')}`
|
|
45
|
+
: `${r.data.size} bytes · sha ${String(r.data.sha || '').slice(0, 7)}${r.data.truncated ? ' · truncated' : ''}`),
|
|
46
|
+
},
|
|
47
|
+
gh_repo_search: {
|
|
48
|
+
call: (args) => pending(`Search repositories: ${args.query}`, pick(args, ['query'])),
|
|
49
|
+
result: (_args, r) => failure('Search failed', r)
|
|
50
|
+
|| text(`${r.data.total} repositories`, r.data.repos.slice(0, 10).map((x) => `${x.fullName} ★${x.stars} — ${x.description || ''}`).join('\n')),
|
|
51
|
+
},
|
|
52
|
+
gh_release_list: {
|
|
53
|
+
call: (args) => pending(`List releases of ${repoOf(args)}`, pick(args, ['repository'])),
|
|
54
|
+
result: (_args, r) => failure('Releases not listed', r)
|
|
55
|
+
|| text(`${r.data.count} releases`, r.data.releases.map((x) => `${x.tag}${x.draft ? ' (draft)' : ''}${x.prerelease ? ' (pre)' : ''} — ${x.name}`).join('\n')),
|
|
56
|
+
},
|
|
57
|
+
gh_release_view: {
|
|
58
|
+
call: (args) => pending(`Read release ${args.tag}`, pick(args, ['repository', 'tag'])),
|
|
59
|
+
result: (_args, r) => failure('Release not read', r)
|
|
60
|
+
|| text(`Release ${r.data.tag}`, `${r.data.name}\n${r.data.draft ? 'draft · ' : ''}${r.data.assets.length} assets\n${r.data.url}`),
|
|
61
|
+
},
|
|
62
|
+
gh_release_create: {
|
|
63
|
+
call: (args) => pending(`Create release ${args.tag}`, pick(args, ['repository', 'tag', 'name', 'draft', 'prerelease', 'target'])),
|
|
64
|
+
result: (_args, r) => failure('Release not created', r)
|
|
65
|
+
|| text(`Created release ${r.data.tag}`, `${r.data.url}${r.data.draft ? '\n(draft)' : ''}`),
|
|
66
|
+
},
|
|
67
|
+
gh_release_edit: {
|
|
68
|
+
call: (args) => pending(`Edit release ${args.tag || args.releaseId}`, pick(args, ['repository', 'tag', 'releaseId', 'name', 'makeLatest'])),
|
|
69
|
+
result: (_args, r) => failure('Release not edited', r)
|
|
70
|
+
|| text(`Updated release ${r.data.tag}`, `name: ${r.data.name}\nlatest: ${r.data.draft ? 'no (draft)' : 'see the repository page'}\n${r.data.url}`),
|
|
71
|
+
},
|
|
72
|
+
gh_release_delete: {
|
|
73
|
+
call: (args) => pending(`Delete release ${args.tag || args.releaseId}`, pick(args, ['repository', 'tag', 'releaseId'])),
|
|
74
|
+
result: (_args, r) => failure('Release not deleted', r) || text(`Deleted release ${r.data.id}`, `tag ${r.data.tag || '(by id)'} — the git tag itself was not touched`),
|
|
75
|
+
},
|
|
76
|
+
gh_tag_list: {
|
|
77
|
+
call: (args) => pending(`List tags of ${repoOf(args)}`, pick(args, ['repository'])),
|
|
78
|
+
result: (_args, r) => failure('Tags not listed', r)
|
|
79
|
+
|| text(`${r.data.count} tags`, r.data.tags.map((t) => `${t.name} ${String(t.sha).slice(0, 7)}`).join('\n')),
|
|
80
|
+
},
|
|
81
|
+
gh_tag_create: {
|
|
82
|
+
call: (args) => pending(`Create tag ${args.tag}`, pick(args, ['repository', 'tag', 'sha', 'message'])),
|
|
83
|
+
result: (_args, r) => failure('Tag not created', r)
|
|
84
|
+
|| text(`Created tag ${r.data.name}`, `${r.data.annotated ? 'annotated' : 'lightweight'} → ${String(r.data.target).slice(0, 7)}`),
|
|
85
|
+
},
|
|
86
|
+
gh_tag_delete: {
|
|
87
|
+
call: (args) => pending(`Delete tag ${args.tag}`, pick(args, ['repository', 'tag'])),
|
|
88
|
+
result: (_args, r) => failure('Tag not deleted', r) || text(`Deleted tag ${r.data.tag}`, 'the tag is gone from the remote'),
|
|
89
|
+
},
|
|
90
|
+
gh_issue: {
|
|
91
|
+
call: (args) => pending(`${args.action === 'get' || args.action === 'comments' ? 'Read' : 'List'} issues of ${repoOf(args)}`, pick(args, ['repository', 'action', 'number', 'state', 'kind'])),
|
|
92
|
+
result: (_args, r) => {
|
|
93
|
+
const bad = failure('Issues not read', r)
|
|
94
|
+
if (bad) return bad
|
|
95
|
+
const data = r.data
|
|
96
|
+
if (data.comments) return text(`Issue #${data.number}: ${data.comments.length} comments`, data.comments.map((c) => `${c.author}: ${String(c.body).slice(0, 120)}`).join('\n'))
|
|
97
|
+
if (data.number) return text(`Issue #${data.number} (${data.state})`, `${data.title}\n${data.url}`)
|
|
98
|
+
return text(`${data.count} items`, (data.issues || []).map((i) => `#${i.number} [${i.kind}] ${i.title}`).join('\n'))
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
issue_open: {
|
|
102
|
+
call: (args) => pending(`Open issue: ${args.title}`, pick(args, ['repository', 'title', 'labels'])),
|
|
103
|
+
result: (_args, r) => failure('Issue not opened', r) || text(`Opened issue #${r.data.number}`, `${r.data.title}\n${r.data.url}`),
|
|
104
|
+
},
|
|
105
|
+
issue_comment: {
|
|
106
|
+
call: (args) => pending(`Comment on #${args.number}`, pick(args, ['repository', 'number'])),
|
|
107
|
+
result: (_args, r) => failure('Comment not posted', r) || text(`Commented on #${r.data.id ? '' : ''}`, `comment id ${r.data.id}`),
|
|
108
|
+
},
|
|
109
|
+
issue_close: {
|
|
110
|
+
call: (args) => pending(`Close #${args.number}`, pick(args, ['repository', 'number', 'stateReason'])),
|
|
111
|
+
result: (_args, r) => failure('Issue not closed', r) || text(`Closed #${r.data.number}`, `state ${r.data.state}${r.data.stateReason ? ` (${r.data.stateReason})` : ''}`),
|
|
112
|
+
},
|
|
113
|
+
gh_search: {
|
|
114
|
+
call: (args) => pending(`Search issues: ${args.query}`, pick(args, ['query'])),
|
|
115
|
+
result: (_args, r) => failure('Search failed', r)
|
|
116
|
+
|| text(`${r.data.total} results`, r.data.issues.slice(0, 10).map((i) => `${i.repository ? i.repository + ' ' : ''}#${i.number} ${i.title}`).join('\n')),
|
|
117
|
+
},
|
|
118
|
+
pr_create: {
|
|
119
|
+
call: (args) => pending(`Create pull request: ${args.title}`, pick(args, ['repository', 'title', 'head', 'base', 'draft'])),
|
|
120
|
+
result: (_args, r) => failure('Pull request not created', r)
|
|
121
|
+
|| text(`Created pull request #${r.data.number}`, `${r.data.url}\n${r.data.base} ← ${r.data.head}${r.data.draft ? ' (draft)' : ''}`),
|
|
122
|
+
},
|
|
123
|
+
pr_update: {
|
|
124
|
+
call: (args) => pending(`Update pull request #${args.number}`, pick(args, ['repository', 'number', 'title', 'state', 'base'])),
|
|
125
|
+
result: (_args, r) => failure('Pull request not updated', r)
|
|
126
|
+
|| text(`Updated pull request #${r.data.number}`, `${r.data.title}\nstate ${r.data.state}\n${r.data.url}`),
|
|
127
|
+
},
|
|
128
|
+
pr_merge: {
|
|
129
|
+
call: (args) => pending(`Merge pull request #${args.number}`, pick(args, ['repository', 'number', 'method', 'deleteBranch'])),
|
|
130
|
+
result: (_args, r) => failure('Pull request not merged', r)
|
|
131
|
+
|| text(r.data.merged ? `Merged #${'pull request'}` : 'Merge not performed', `${r.data.message || ''}${r.data.sha ? `\n${String(r.data.sha).slice(0, 7)}` : ''}${r.data.branchDeleted ? `\nhead branch ${r.data.branchDeleted} deleted` : ''}`),
|
|
132
|
+
},
|
|
133
|
+
gh_review: {
|
|
134
|
+
call: (args) => pending(`Review pull request #${args.number}`, pick(args, ['repository', 'number', 'maxDiffChars'])),
|
|
135
|
+
result: (_args, r) => failure('Review failed', r)
|
|
136
|
+
|| text(`Pull request #${r.data.pull.number}: ${r.data.pull.title}`, [
|
|
137
|
+
`areas: ${r.data.areas.join(', ') || 'none'}`,
|
|
138
|
+
`files: ${r.data.files.length} · checks: ${r.data.checks ? r.data.checks.rollup : 'unknown'}`,
|
|
139
|
+
r.data.findings.length ? r.data.findings.map((f) => `[${f.level}] ${f.detail}`).join('\n') : 'no findings',
|
|
140
|
+
].join('\n')),
|
|
141
|
+
},
|
|
142
|
+
review_post: {
|
|
143
|
+
call: (args) => pending(`Post review on #${args.number} (${args.mode || 'summary'})`, pick(args, ['repository', 'number', 'mode'])),
|
|
144
|
+
result: (_args, r) => failure('Review not posted', r) || text(`Review posted (${r.data.mode})`, String(r.data.url || `id ${r.data.id}`)),
|
|
145
|
+
},
|
|
146
|
+
gh_checks: {
|
|
147
|
+
call: (args) => pending(`Read checks of ${args.ref}`, pick(args, ['repository', 'ref'])),
|
|
148
|
+
result: (_args, r) => failure('Checks not read', r)
|
|
149
|
+
|| text(`Checks: ${r.data.rollup}`, r.data.checkRuns.map((c) => `${c.name}: ${c.conclusion || c.status}`).join('\n') || 'no check runs'),
|
|
150
|
+
},
|
|
151
|
+
ci_run: {
|
|
152
|
+
call: (args) => pending(`CI review of pull request #${args.number}`, pick(args, ['repository', 'number'])),
|
|
153
|
+
result: (_args, r) => failure('CI review failed', r)
|
|
154
|
+
|| text(`CI verdict for #${r.data.number}: ${r.data.verdict}`, r.data.summary),
|
|
155
|
+
},
|
|
156
|
+
gh_mirror_check: {
|
|
157
|
+
call: (args) => pending(`Mirror plan for ${args.ref || 'origin/main'}`, pick(args, ['cwd', 'ref', 'mirror', 'branch'])),
|
|
158
|
+
result: (_args, r) => failure('Mirror plan failed', r)
|
|
159
|
+
|| text(`Mirror: ${r.data.willPublishCount} files would be published`, `${r.data.willDropCount} stay behind${r.data.refused ? `\nREFUSED: ${r.data.refused}` : ''}`),
|
|
160
|
+
},
|
|
161
|
+
gh_mirror_publish: {
|
|
162
|
+
call: (args) => pending(`Publish mirror ${args.ref || 'origin/main'}${args.dryRun ? ' (dry run)' : ''}`, pick(args, ['cwd', 'ref', 'mirror', 'branch', 'dryRun'])),
|
|
163
|
+
result: (_args, r) => failure('Mirror not published', r)
|
|
164
|
+
|| text(r.data.pushed ? `Mirror published: ${String(r.data.commit).slice(0, 8)}` : 'Mirror dry run', `${r.data.willPublishCount} files, ${r.data.willDropCount} left behind\n${r.data.mirror}`),
|
|
165
|
+
},
|
|
166
|
+
gh_run_list: {
|
|
167
|
+
call: (args) => pending(`List workflow runs of ${repoOf(args)}`, pick(args, ['repository', 'branch', 'workflow', 'status'])),
|
|
168
|
+
result: (_args, r) => failure('Runs not listed', r)
|
|
169
|
+
|| text(`${r.data.count} runs`, r.data.runs.map((x) => `#${x.id} ${x.name} ${x.conclusion || x.status} ${x.branch}`).join('\n')),
|
|
170
|
+
},
|
|
171
|
+
gh_run_jobs: {
|
|
172
|
+
call: (args) => pending(`Jobs of run ${args.runId}`, pick(args, ['repository', 'runId'])),
|
|
173
|
+
result: (_args, r) => failure('Jobs not read', r)
|
|
174
|
+
|| text(`${r.data.count} jobs`, r.data.jobs.map((j) => `${j.name}: ${j.conclusion || j.status}${j.failedSteps.length ? ` — failed: ${j.failedSteps.map((s) => s.name).join(', ')}` : ''}`).join('\n')),
|
|
175
|
+
},
|
|
176
|
+
gh_variable_list: {
|
|
177
|
+
call: (args) => pending(`List variables of ${repoOf(args)}`, pick(args, ['repository', 'environment'])),
|
|
178
|
+
result: (_args, r) => failure('Variables not listed', r) || text(`${r.data.variables.length} variables`, r.data.variables.map((v) => `${v.name}=${v.value}`).join('\n')),
|
|
179
|
+
},
|
|
180
|
+
gh_secret_list: {
|
|
181
|
+
call: (args) => pending(`List secrets of ${repoOf(args)}`, pick(args, ['repository', 'environment'])),
|
|
182
|
+
result: (_args, r) => failure('Secrets not listed', r) || text(`${r.data.secrets.length} secrets`, r.data.secrets.map((s) => s.name).join('\n')),
|
|
183
|
+
},
|
|
184
|
+
gh_ruleset_list: {
|
|
185
|
+
call: (args) => pending(`List rulesets of ${repoOf(args)}`, pick(args, ['repository'])),
|
|
186
|
+
result: (_args, r) => failure('Rulesets not listed', r) || text(`${r.data.rulesets.length} rulesets`, r.data.rulesets.map((x) => `${x.name} (${x.target}, ${x.enforcement})`).join('\n')),
|
|
187
|
+
},
|
|
188
|
+
gh_branch_protection_get: {
|
|
189
|
+
call: (args) => pending(`Read branch protection of ${args.branch}`, pick(args, ['repository', 'branch'])),
|
|
190
|
+
result: (_args, r) => {
|
|
191
|
+
const bad = failure('Branch protection not read', r)
|
|
192
|
+
if (bad) return bad
|
|
193
|
+
const p = r.data
|
|
194
|
+
return text(`Branch protection: ${p.branch}`, `reviews: ${p.requiredReviews ? p.requiredReviews.approvals : 0}\nchecks: ${p.requiredStatusChecks ? p.requiredStatusChecks.contexts.join(', ') || 'none' : 'none'}\nforce pushes: ${p.allowForcePushes} · deletions: ${p.allowDeletions} · admins: ${p.enforceAdmins}`)
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
gh_api: {
|
|
198
|
+
call: (args) => pending(`GitHub API: ${args.method || 'GET'} ${args.path || 'graphql'}`, pick(args, ['method', 'path', 'query'])),
|
|
199
|
+
result: (_args, r) => failure('API call failed', r)
|
|
200
|
+
|| text(`${r.data.method} ${r.data.status || ''}`.trim(), r.data.mutating ? 'state-changing call' : 'read-only call'),
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Presentation hooks for one tool, or an empty object when it has none. */
|
|
205
|
+
export function presentationFor(toolName) {
|
|
206
|
+
const entry = PRESENTATIONS[toolName]
|
|
207
|
+
if (!entry) return {}
|
|
208
|
+
return {
|
|
209
|
+
presentCall: entry.call,
|
|
210
|
+
presentResult: entry.result,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const PRESENTED_TOOLS = Object.keys(PRESENTATIONS)
|