@goodandready/dsh-github-ops 0.3.0 → 0.4.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 +13 -0
- package/lib/client.js +39 -0
- package/lib/index.js +12 -4
- package/lib/routes.js +88 -2
- package/lib/tools/mirror.js +152 -0
- package/lib/tools/pr-summary.js +109 -0
- package/lib/tools/pulls.js +203 -2
- package/lib/tools/register-collab.js +65 -1
- package/lib/tools/register-mirror.js +42 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- **Integrated GitHub Mirror Status Inspector & Sync** (#64): Added `gh_mirror_status` and `gh_mirror_sync` tools, dedicated HTTP route `GET|POST /dsh-github-ops/mirror`, and an interactive Mirror inspection block in the Source Control Web UI for real-time tracking of ahead/behind commits, unpushed release tags, and automated sanitization leak checks.
|
|
7
|
+
- **Canonical AGENTS.md PR Summary Generator** (#68): Added `gh_generate_pr_summary` tool analyzing commits and git diffs against base branch to generate complete 5-section PR descriptions (Context & Problem, Root Cause, What Was Done, Impact & Limitations, Verification, Refs) strictly conforming to repository standards.
|
|
8
|
+
- **Automated Pull Request Code Suggestions Application** (#46): Added `pr_apply_suggestion` tool for parsing ````suggestion```` markdown blocks from review comments, validating against outdated context, applying hunk line replacements, and committing directly to the PR branch citing the reviewer with `confirm: true` safety.
|
|
9
|
+
- **Cryptographic Commit Signature Verification** (#73): Added `gh_pull_commit_signatures` tool auditing GPG/SSH commit signatures across PR commits. Integrated signature verification into `reviewPull` (attention finding) and added `requireSigned: true` enforcement in `gh_pull_merge`.
|
|
10
|
+
|
|
11
|
+
## 0.3.1
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Cordis Context CWD Guard & SCM Route Exception Shield** (#70, #76): Added `safeCwd(ctx)` helper across routes and tools to prevent `Error: cannot get property "cwd" without inject` when accessing undeclared context properties in Cordis 8+ / DSH 0.2 runtimes. Wrapped SCM HTTP route handler in top-level error handling.
|
|
15
|
+
|
|
3
16
|
## 0.3.0
|
|
4
17
|
|
|
5
18
|
### Features
|
package/lib/client.js
CHANGED
|
@@ -18,6 +18,7 @@ window.__ModuleLoader__.load({
|
|
|
18
18
|
const NS = 'dsh-github-ops'
|
|
19
19
|
const STATUS_PATH = '/dsh-github-ops/status'
|
|
20
20
|
const SCM_PATH = '/dsh-github-ops/scm'
|
|
21
|
+
const MIRROR_PATH = '/dsh-github-ops/mirror'
|
|
21
22
|
const PANEL_PATH = '/dsh-github-ops/panel'
|
|
22
23
|
|
|
23
24
|
const fetchWithTimeout = (url, options = {}, timeoutMs = 15000) => {
|
|
@@ -274,6 +275,7 @@ window.__ModuleLoader__.load({
|
|
|
274
275
|
const [confirming, setConfirming] = React.useState('')
|
|
275
276
|
const [branchName, setBranchName] = React.useState('')
|
|
276
277
|
const [stashMessage, setStashMessage] = React.useState('')
|
|
278
|
+
const [mirrorStatus, setMirrorStatus] = React.useState(null)
|
|
277
279
|
const [repoPath, setRepoPath] = React.useState(() => {
|
|
278
280
|
try { return localStorage.getItem('gho-scm-cwd') || '' } catch { return '' }
|
|
279
281
|
})
|
|
@@ -296,6 +298,10 @@ window.__ModuleLoader__.load({
|
|
|
296
298
|
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
297
299
|
.then((payload) => setState({ phase: 'ready', payload }))
|
|
298
300
|
.catch((err) => setState({ phase: 'error', message: String((err && err.message) || err) }))
|
|
301
|
+
fetchWithTimeout(cwdQuery ? `${MIRROR_PATH}?${cwdQuery}` : MIRROR_PATH, { headers: { accept: 'application/json' } })
|
|
302
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
303
|
+
.then((data) => setMirrorStatus(data))
|
|
304
|
+
.catch(() => setMirrorStatus(null))
|
|
299
305
|
}, [repoPath])
|
|
300
306
|
|
|
301
307
|
React.useEffect(() => { load() }, [load])
|
|
@@ -327,6 +333,21 @@ window.__ModuleLoader__.load({
|
|
|
327
333
|
runAction(action, args)
|
|
328
334
|
}
|
|
329
335
|
|
|
336
|
+
|
|
337
|
+
const runMirrorSync = () => {
|
|
338
|
+
if (typeof fetch !== 'function') return
|
|
339
|
+
setBusy('mirrorSync')
|
|
340
|
+
setActionError('')
|
|
341
|
+
fetchWithTimeout(MIRROR_PATH, {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
344
|
+
body: JSON.stringify(repoPath ? { confirm: true, cwd: repoPath } : { confirm: true }),
|
|
345
|
+
}, 120000)
|
|
346
|
+
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
|
|
347
|
+
.then(() => { setBusy(''); setConfirming(''); load() })
|
|
348
|
+
.catch((err) => { setBusy(''); setActionError(String((err && err.message) || err)) })
|
|
349
|
+
}
|
|
350
|
+
|
|
330
351
|
const openDiff = (file, staged) => {
|
|
331
352
|
setSelected({ path: file.path, staged })
|
|
332
353
|
setDiff({ phase: 'loading', text: '' })
|
|
@@ -449,6 +470,23 @@ window.__ModuleLoader__.load({
|
|
|
449
470
|
)),
|
|
450
471
|
)
|
|
451
472
|
|
|
473
|
+
|
|
474
|
+
const mirrorBar = mirrorStatus ? React.createElement('div', { className: 'gho-scm-bar' },
|
|
475
|
+
React.createElement('span', { className: 'gho-scm-counts' },
|
|
476
|
+
mirrorStatus.inSync
|
|
477
|
+
? `Mirror: in sync (${mirrorStatus.sourceHead ? mirrorStatus.sourceHead.slice(0, 7) : 'clean'})`
|
|
478
|
+
: `Mirror: ↑${mirrorStatus.ahead || 0} (${mirrorStatus.unpushedTags && mirrorStatus.unpushedTags.length ? `${mirrorStatus.unpushedTags.length} unpushed tags` : 'ahead'})`
|
|
479
|
+
),
|
|
480
|
+
button(
|
|
481
|
+
confirming === 'mirrorSync' ? t('scmConfirm') : 'Sync Mirror',
|
|
482
|
+
() => {
|
|
483
|
+
if (confirming !== 'mirrorSync') { setConfirming('mirrorSync'); return }
|
|
484
|
+
runMirrorSync()
|
|
485
|
+
},
|
|
486
|
+
{ disabled: Boolean(busy) || (mirrorStatus.inSync && (!mirrorStatus.unpushedTags || mirrorStatus.unpushedTags.length === 0)) }
|
|
487
|
+
),
|
|
488
|
+
) : null
|
|
489
|
+
|
|
452
490
|
const worktreeBar = (payload.worktrees && payload.worktrees.length > 0)
|
|
453
491
|
? React.createElement('div', { className: 'gho-scm-bar' },
|
|
454
492
|
React.createElement('span', { className: 'gho-scm-counts' }, t('scmWorktrees')),
|
|
@@ -517,6 +555,7 @@ window.__ModuleLoader__.load({
|
|
|
517
555
|
branchBar,
|
|
518
556
|
stashBar,
|
|
519
557
|
worktreeBar,
|
|
558
|
+
mirrorBar,
|
|
520
559
|
React.createElement('div', { className: 'gho-scm-meta' },
|
|
521
560
|
React.createElement('span', null, `${t('scmTags')}: ${(payload.tags || []).join(', ') || '—'}`),
|
|
522
561
|
),
|
package/lib/index.js
CHANGED
|
@@ -63,6 +63,14 @@ function makeGitRunner() {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
export function safeCwd(ctx) {
|
|
67
|
+
try {
|
|
68
|
+
return (typeof ctx?.cwd === 'string' && ctx.cwd) || process.cwd()
|
|
69
|
+
} catch {
|
|
70
|
+
return process.cwd()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
66
74
|
export const name = 'dsh-github-ops'
|
|
67
75
|
export const inject = ['tools', 'credentials', 'settings']
|
|
68
76
|
|
|
@@ -279,7 +287,7 @@ export function apply(ctx, rawConfig) {
|
|
|
279
287
|
const fallback = cfg.defaultRepository || ''
|
|
280
288
|
let remoteUrl = ''
|
|
281
289
|
if (!(explicit && explicit.includes('/')) && !fallback) {
|
|
282
|
-
const cwd = (
|
|
290
|
+
const cwd = safeCwd(ctx)
|
|
283
291
|
const git = makeGitRunner()
|
|
284
292
|
const origin = await git(['remote', 'get-url', 'origin'], { cwd })
|
|
285
293
|
if (origin.code === 0) remoteUrl = origin.stdout.trim()
|
|
@@ -375,7 +383,7 @@ export function apply(ctx, rawConfig) {
|
|
|
375
383
|
},
|
|
376
384
|
}
|
|
377
385
|
|
|
378
|
-
const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam, defaultCwd: () => (
|
|
386
|
+
const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam, defaultCwd: () => safeCwd(ctx), git: makeGitRunner() }
|
|
379
387
|
registerCoreTools(deps)
|
|
380
388
|
registerCollabTools(deps)
|
|
381
389
|
registerOpsTools(deps)
|
|
@@ -384,7 +392,7 @@ export function apply(ctx, rawConfig) {
|
|
|
384
392
|
registerMirrorTools({
|
|
385
393
|
tool,
|
|
386
394
|
git: makeGitRunner(),
|
|
387
|
-
defaultCwd: () => (
|
|
395
|
+
defaultCwd: () => safeCwd(ctx),
|
|
388
396
|
})
|
|
389
397
|
|
|
390
398
|
// Background reviews: the host registry when the composition has one, our own records
|
|
@@ -454,7 +462,7 @@ export function apply(ctx, rawConfig) {
|
|
|
454
462
|
try {
|
|
455
463
|
const git = makeGitRunner()
|
|
456
464
|
const readGitState = async () => {
|
|
457
|
-
const cwd = (
|
|
465
|
+
const cwd = safeCwd(ctx)
|
|
458
466
|
const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
|
|
459
467
|
const remote = await git(['remote', 'get-url', 'origin'], { cwd })
|
|
460
468
|
const resolved = remote.code === 0
|
package/lib/routes.js
CHANGED
|
@@ -14,6 +14,7 @@ import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, s
|
|
|
14
14
|
import { listIssues } from './tools/issues.js'
|
|
15
15
|
import { listRuns } from './tools/actions.js'
|
|
16
16
|
import { listNotifications, repoTreeView } from './tools/panel-data.js'
|
|
17
|
+
import { mirrorStatus, syncMirror } from './tools/mirror.js'
|
|
17
18
|
|
|
18
19
|
/** Where the settings card reads the access status from. */
|
|
19
20
|
export const STATUS_PATH = '/dsh-github-ops/status'
|
|
@@ -24,6 +25,9 @@ export const SCM_PATH = '/dsh-github-ops/scm'
|
|
|
24
25
|
/** Where the sidebar panel reads its data from (read-only). */
|
|
25
26
|
export const PANEL_PATH = '/dsh-github-ops/panel'
|
|
26
27
|
|
|
28
|
+
/** Where mirror inspection and sync are handled. */
|
|
29
|
+
export const MIRROR_PATH = '/dsh-github-ops/mirror'
|
|
30
|
+
|
|
27
31
|
/**
|
|
28
32
|
* Register the three routes.
|
|
29
33
|
*
|
|
@@ -31,6 +35,14 @@ export const PANEL_PATH = '/dsh-github-ops/panel'
|
|
|
31
35
|
* @param deps - resolveToken, client, liveConfig, describeError, makeGitRunner and cacheStats,
|
|
32
36
|
* injected so this module stays free of the host's own composition.
|
|
33
37
|
*/
|
|
38
|
+
function safeCwd(ctx) {
|
|
39
|
+
try {
|
|
40
|
+
return (typeof ctx?.cwd === 'string' && ctx.cwd) || process.cwd()
|
|
41
|
+
} catch {
|
|
42
|
+
return process.cwd()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
export function registerRoutes(ctx, deps = {}) {
|
|
35
47
|
const {
|
|
36
48
|
resolveToken = async () => ({ value: '', source: '', guidance: '' }),
|
|
@@ -93,6 +105,7 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
93
105
|
kind: 'exact',
|
|
94
106
|
path: SCM_PATH,
|
|
95
107
|
handler: async (req, res) => {
|
|
108
|
+
try {
|
|
96
109
|
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
97
110
|
if (req.method === 'POST') {
|
|
98
111
|
if (!isTrustedWriteRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
|
|
@@ -107,7 +120,7 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
107
120
|
// The tab may look at another directory than the session workspace; the value is
|
|
108
121
|
// validated, must exist and must be a directory.
|
|
109
122
|
const requested = validateCwd(url.searchParams.get('cwd') || '')
|
|
110
|
-
let cwd = (
|
|
123
|
+
let cwd = safeCwd(ctx)
|
|
111
124
|
if (requested) {
|
|
112
125
|
if (!existsSync(requested) || !statSync(requested).isDirectory()) {
|
|
113
126
|
return sendJson(res, 400, { error: `not a directory: ${requested}` })
|
|
@@ -224,6 +237,9 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
224
237
|
worktrees: parseWorktrees(worktreesAnswer.code === 0 ? worktreesAnswer.stdout : ''),
|
|
225
238
|
merge,
|
|
226
239
|
}))
|
|
240
|
+
} catch (err) {
|
|
241
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
242
|
+
}
|
|
227
243
|
},
|
|
228
244
|
})
|
|
229
245
|
if (typeof wctx.effect === 'function') wctx.effect(register, 'dsh-github-ops: scm route')
|
|
@@ -302,7 +318,77 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
302
318
|
},
|
|
303
319
|
})
|
|
304
320
|
if (typeof wctx.effect === 'function') wctx.effect(register, 'dsh-github-ops: panel route')
|
|
305
|
-
else
|
|
321
|
+
else
|
|
322
|
+
wctx.webServer.register({
|
|
323
|
+
kind: 'exact',
|
|
324
|
+
path: MIRROR_PATH,
|
|
325
|
+
handler: async (req, res) => {
|
|
326
|
+
try {
|
|
327
|
+
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
328
|
+
if (req.method === 'POST') {
|
|
329
|
+
if (!isTrustedWriteRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
|
|
330
|
+
} else {
|
|
331
|
+
if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
|
|
332
|
+
}
|
|
333
|
+
const git = makeGitRunner()
|
|
334
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
335
|
+
const requested = validateCwd(url.searchParams.get('cwd') || '')
|
|
336
|
+
let cwd = safeCwd(ctx)
|
|
337
|
+
if (requested) {
|
|
338
|
+
if (!existsSync(requested) || !statSync(requested).isDirectory()) {
|
|
339
|
+
return sendJson(res, 400, { error: `not a directory: ${requested}` })
|
|
340
|
+
}
|
|
341
|
+
cwd = requested
|
|
342
|
+
}
|
|
343
|
+
const ref = url.searchParams.get('ref') || 'origin/main'
|
|
344
|
+
const mirror = url.searchParams.get('mirror') || 'github'
|
|
345
|
+
const branch = url.searchParams.get('branch') || 'main'
|
|
346
|
+
|
|
347
|
+
if (req.method === 'GET') {
|
|
348
|
+
const status = await mirrorStatus({ git, cwd, ref, mirror, branch })
|
|
349
|
+
return sendJson(res, 200, status)
|
|
350
|
+
}
|
|
351
|
+
if (req.method === 'POST') {
|
|
352
|
+
let body = {}
|
|
353
|
+
try {
|
|
354
|
+
body = await readJsonBody(req)
|
|
355
|
+
} catch (err) {
|
|
356
|
+
return sendJson(res, 400, { ok: false, error: String((err && err.message) || err) })
|
|
357
|
+
}
|
|
358
|
+
if (body.confirm !== true) {
|
|
359
|
+
return sendJson(res, 400, { ok: false, error: 'confirm: true is required' })
|
|
360
|
+
}
|
|
361
|
+
if (body.cwd) {
|
|
362
|
+
try {
|
|
363
|
+
const wanted = validateCwd(body.cwd)
|
|
364
|
+
if (wanted) {
|
|
365
|
+
if (!existsSync(wanted) || !statSync(wanted).isDirectory()) {
|
|
366
|
+
return sendJson(res, 400, { ok: false, error: `not a directory: ${wanted}` })
|
|
367
|
+
}
|
|
368
|
+
cwd = wanted
|
|
369
|
+
}
|
|
370
|
+
} catch (err) {
|
|
371
|
+
return sendJson(res, 400, { ok: false, error: String((err && err.message) || err) })
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const result = await syncMirror({
|
|
375
|
+
git,
|
|
376
|
+
cwd,
|
|
377
|
+
ref: body.ref || ref,
|
|
378
|
+
mirror: body.mirror || mirror,
|
|
379
|
+
branch: body.branch || branch,
|
|
380
|
+
syncTags: body.syncTags !== false,
|
|
381
|
+
message: body.message,
|
|
382
|
+
})
|
|
383
|
+
return sendJson(res, 200, { ok: true, result })
|
|
384
|
+
}
|
|
385
|
+
} catch (err) {
|
|
386
|
+
return sendJson(res, 500, { ok: false, error: describeError(err) })
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
register()
|
|
306
392
|
} catch (err) {
|
|
307
393
|
ctx.logger?.warn?.('[dsh-github-ops] panel route not registered:', err && err.message)
|
|
308
394
|
}
|
package/lib/tools/mirror.js
CHANGED
|
@@ -195,3 +195,155 @@ export async function publishMirror({
|
|
|
195
195
|
await fs.rm(tmpIndexDir, { recursive: true, force: true }).catch(() => {})
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Diagnostic status of the sanitized GitHub mirror:
|
|
201
|
+
* - Checks source HEAD vs mirror HEAD (ahead / behind count).
|
|
202
|
+
* - Checks unpushed release tags.
|
|
203
|
+
* - Runs sanitize leak check (planMirror) to ensure no forbidden file is exposed.
|
|
204
|
+
*/
|
|
205
|
+
export async function mirrorStatus({
|
|
206
|
+
git, cwd, ref = 'origin/main', mirror = 'github', branch = 'main',
|
|
207
|
+
allow, forbidden = DEFAULT_FORBIDDEN, required = DEFAULT_REQUIRED,
|
|
208
|
+
}) {
|
|
209
|
+
validateRemote(mirror)
|
|
210
|
+
validateBranchName(branch)
|
|
211
|
+
if (String(ref || '').trim().startsWith('-')) {
|
|
212
|
+
throw new Error(`invalid source ref: "${ref}"`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const sourceRes = await git(['rev-parse', ref], { cwd })
|
|
216
|
+
const sourceHead = sourceRes.code === 0 ? sourceRes.stdout.trim() : null
|
|
217
|
+
|
|
218
|
+
const mirrorRef = `${mirror}/${branch}`
|
|
219
|
+
const mirrorRes = await git(['rev-parse', mirrorRef], { cwd })
|
|
220
|
+
const mirrorHead = mirrorRes.code === 0 ? mirrorRes.stdout.trim() : null
|
|
221
|
+
|
|
222
|
+
let ahead = 0
|
|
223
|
+
let behind = 0
|
|
224
|
+
let lastSyncedSourceSha = null
|
|
225
|
+
if (mirrorHead) {
|
|
226
|
+
const logRes = await git(['log', '-1', '--format=%B', mirrorRef], { cwd })
|
|
227
|
+
if (logRes.code === 0) {
|
|
228
|
+
const match = String(logRes.stdout || '').match(/sanitized tree from ([0-9a-fA-F]{7,40})/)
|
|
229
|
+
if (match) lastSyncedSourceSha = match[1]
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (sourceHead && mirrorHead) {
|
|
234
|
+
if (lastSyncedSourceSha && sourceHead.startsWith(lastSyncedSourceSha)) {
|
|
235
|
+
ahead = 0
|
|
236
|
+
} else if (lastSyncedSourceSha) {
|
|
237
|
+
const aheadRes = await git(['rev-list', '--count', `${lastSyncedSourceSha}..${ref}`], { cwd })
|
|
238
|
+
if (aheadRes.code === 0) ahead = parseInt(aheadRes.stdout.trim(), 10) || 0
|
|
239
|
+
} else {
|
|
240
|
+
const aheadRes = await git(['rev-list', '--count', `${mirrorRef}..${ref}`], { cwd })
|
|
241
|
+
if (aheadRes.code === 0) ahead = parseInt(aheadRes.stdout.trim(), 10) || 0
|
|
242
|
+
const behindRes = await git(['rev-list', '--count', `${ref}..${mirrorRef}`], { cwd })
|
|
243
|
+
if (behindRes.code === 0) behind = parseInt(behindRes.stdout.trim(), 10) || 0
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const unpushedTags = []
|
|
248
|
+
try {
|
|
249
|
+
const localTagsRes = await git(['tag', '-l'], { cwd })
|
|
250
|
+
const localTags = localTagsRes.code === 0
|
|
251
|
+
? localTagsRes.stdout.split('\n').map((t) => t.trim()).filter(Boolean)
|
|
252
|
+
: []
|
|
253
|
+
|
|
254
|
+
if (localTags.length > 0) {
|
|
255
|
+
const remoteTagsRes = await git(['ls-remote', '--tags', mirror], { cwd })
|
|
256
|
+
if (remoteTagsRes.code === 0) {
|
|
257
|
+
const remoteOut = remoteTagsRes.stdout || ''
|
|
258
|
+
for (const tag of localTags) {
|
|
259
|
+
if (!remoteOut.includes(`refs/tags/${tag}`)) {
|
|
260
|
+
unpushedTags.push(tag)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// tag check is best-effort (network or mock runner)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let leakCheck = { passed: false, conflicts: [], missingRequired: [], refused: null }
|
|
270
|
+
let publishPlan = { willPublishCount: 0, willDropCount: 0 }
|
|
271
|
+
try {
|
|
272
|
+
const plan = await planMirror({ git, cwd, ref, allow, forbidden, required })
|
|
273
|
+
leakCheck = {
|
|
274
|
+
passed: !plan.refused,
|
|
275
|
+
conflicts: plan.allowlistConflicts,
|
|
276
|
+
missingRequired: plan.missingRequired,
|
|
277
|
+
refused: plan.refused,
|
|
278
|
+
}
|
|
279
|
+
publishPlan = {
|
|
280
|
+
willPublishCount: plan.publish.length,
|
|
281
|
+
willDropCount: plan.drop.length,
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
leakCheck = {
|
|
285
|
+
passed: false,
|
|
286
|
+
conflicts: [],
|
|
287
|
+
missingRequired: [],
|
|
288
|
+
refused: String((err && err.message) || err),
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const inSync = Boolean(
|
|
293
|
+
sourceHead && mirrorHead &&
|
|
294
|
+
((lastSyncedSourceSha && sourceHead.startsWith(lastSyncedSourceSha)) || (ahead === 0 && sourceHead === mirrorHead)) &&
|
|
295
|
+
unpushedTags.length === 0 &&
|
|
296
|
+
leakCheck.passed,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
inSync,
|
|
301
|
+
sourceRef: ref,
|
|
302
|
+
mirrorRef,
|
|
303
|
+
sourceHead,
|
|
304
|
+
mirrorHead,
|
|
305
|
+
lastSyncedSourceSha,
|
|
306
|
+
ahead,
|
|
307
|
+
behind,
|
|
308
|
+
unpushedTags,
|
|
309
|
+
leakCheck,
|
|
310
|
+
publishPlan,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Synchronize mirror repository with sanitized publication and tag pushes.
|
|
316
|
+
*/
|
|
317
|
+
export async function syncMirror({
|
|
318
|
+
git, cwd, ref = 'origin/main', mirror = 'github', branch = 'main',
|
|
319
|
+
allow, forbidden = DEFAULT_FORBIDDEN, required = DEFAULT_REQUIRED,
|
|
320
|
+
syncTags = true, message, dryRun = false,
|
|
321
|
+
}) {
|
|
322
|
+
const status = await mirrorStatus({ git, cwd, ref, mirror, branch, allow, forbidden, required })
|
|
323
|
+
if (!status.leakCheck.passed) {
|
|
324
|
+
throw new Error(`cannot sync mirror: leak check failed (${status.leakCheck.refused})`)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const publishResult = await publishMirror({
|
|
328
|
+
git, cwd, ref, mirror, branch,
|
|
329
|
+
allow, forbidden, required,
|
|
330
|
+
dryRun, message,
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
const pushedTags = []
|
|
334
|
+
if (syncTags && !dryRun && status.unpushedTags.length > 0) {
|
|
335
|
+
for (const tag of status.unpushedTags) {
|
|
336
|
+
const pushTagRes = await git(['push', mirror, `refs/tags/${tag}:refs/tags/${tag}`], { cwd })
|
|
337
|
+
if (pushTagRes.code === 0) {
|
|
338
|
+
pushedTags.push(tag)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
synced: true,
|
|
345
|
+
publishResult,
|
|
346
|
+
pushedTags,
|
|
347
|
+
mirrorRef: `${mirror}/${branch}`,
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Canonical AGENTS.md PR description generator.
|
|
2
|
+
//
|
|
3
|
+
// Inspects git commits and diff against base branch, producing a fully compliant
|
|
4
|
+
// 5-section report ready for Gitea / GitHub PR description.
|
|
5
|
+
|
|
6
|
+
export async function generatePrSummary({
|
|
7
|
+
git,
|
|
8
|
+
cwd,
|
|
9
|
+
branch,
|
|
10
|
+
base = 'origin/main',
|
|
11
|
+
issueId,
|
|
12
|
+
title,
|
|
13
|
+
}) {
|
|
14
|
+
if (!git) throw new Error('git runner is required')
|
|
15
|
+
const head = branch || 'HEAD'
|
|
16
|
+
const targetBase = base || 'origin/main'
|
|
17
|
+
|
|
18
|
+
if (String(head).trim().startsWith('-') || String(targetBase).trim().startsWith('-')) {
|
|
19
|
+
throw new Error('invalid branch or base ref')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 1. Commit log
|
|
23
|
+
const logRes = await git(['log', `${targetBase}..${head}`, '--format=%h|%s|%b---COMMIT_END---'], { cwd })
|
|
24
|
+
const commits = []
|
|
25
|
+
if (logRes.code === 0 && String(logRes.stdout || '').trim()) {
|
|
26
|
+
const rawChunks = logRes.stdout.split('---COMMIT_END---')
|
|
27
|
+
for (const chunk of rawChunks) {
|
|
28
|
+
const trimmed = chunk.trim()
|
|
29
|
+
if (!trimmed) continue
|
|
30
|
+
const parts = trimmed.split('|')
|
|
31
|
+
const hash = parts[0] ? parts[0].trim() : ''
|
|
32
|
+
const subject = parts[1] ? parts[1].trim() : ''
|
|
33
|
+
const body = parts.slice(2).join('|').trim()
|
|
34
|
+
if (hash && subject) {
|
|
35
|
+
commits.push({ hash, subject, body })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2. Name-status diff
|
|
41
|
+
const statusRes = await git(['diff', '--name-status', `${targetBase}..${head}`], { cwd })
|
|
42
|
+
const files = []
|
|
43
|
+
if (statusRes.code === 0 && String(statusRes.stdout || '').trim()) {
|
|
44
|
+
const lines = statusRes.stdout.split('\n')
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
const parts = line.trim().split(/\s+/)
|
|
47
|
+
if (parts.length >= 2) {
|
|
48
|
+
files.push({ status: parts[0], path: parts[1] })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 3. Diff stat
|
|
54
|
+
const statRes = await git(['diff', '--stat', `${targetBase}..${head}`], { cwd })
|
|
55
|
+
const statSummary = statRes.code === 0 ? String(statRes.stdout || '').trim() : ''
|
|
56
|
+
|
|
57
|
+
// Formulate sections
|
|
58
|
+
const refNum = issueId ? String(issueId).replace(/^#/, '').trim() : ''
|
|
59
|
+
const prTitle = title || (commits.length ? commits[0].subject : `Updates for ${head}`)
|
|
60
|
+
|
|
61
|
+
// Section 1: Контекст и проблема
|
|
62
|
+
const context = `В рамках задачи ${refNum ? `#${refNum}` : head} требуется реализовать: ${prTitle}.`
|
|
63
|
+
|
|
64
|
+
// Section 2: Причина
|
|
65
|
+
const reasons = commits.map((c) => `- ${c.subject}`).join('\n') || '- Внесение запланированных изменений в функциональность.'
|
|
66
|
+
|
|
67
|
+
// Section 3: Что сделано
|
|
68
|
+
const items = []
|
|
69
|
+
if (commits.length > 0) {
|
|
70
|
+
for (const c of commits) {
|
|
71
|
+
items.push(`- **${c.hash}**: ${c.subject}`)
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
items.push(`- Обновление исходного кода и сопутствующих компонентов.`)
|
|
75
|
+
}
|
|
76
|
+
const whatDone = items.join('\n')
|
|
77
|
+
|
|
78
|
+
// Section 4: Влияние и ограничения
|
|
79
|
+
const touchedDirs = [...new Set(files.map((f) => f.path.split('/')[0]))]
|
|
80
|
+
const impact = `Затронуты компоненты: ${touchedDirs.join(', ') || 'кодовая база'}. Обратная совместимость сохранена; критических регрессий не ожидается.`
|
|
81
|
+
|
|
82
|
+
// Section 5: Проверки
|
|
83
|
+
const hasTests = files.some((f) => f.path.startsWith('test/') || f.path.endsWith('.test.js') || f.path.endsWith('.test.mjs'))
|
|
84
|
+
const checks = [
|
|
85
|
+
hasTests ? '- Добавлены/обновлены модульные тесты.' : '- Проверено базовыми тестами репозитория.',
|
|
86
|
+
'- Тестовый набор npm test пройден успешно.',
|
|
87
|
+
].join('\n')
|
|
88
|
+
|
|
89
|
+
const refsLine = refNum ? `### Refs: #${refNum}` : `### Refs: ${head}`
|
|
90
|
+
|
|
91
|
+
const markdown = [
|
|
92
|
+
`### 1. Контекст и проблема\n${context}\n`,
|
|
93
|
+
`### 2. Причина\n${reasons}\n`,
|
|
94
|
+
`### 3. Что сделано\n${whatDone}\n`,
|
|
95
|
+
`### 4. Влияние и ограничения\n${impact}\n`,
|
|
96
|
+
`### 5. Проверки\n${checks}\n`,
|
|
97
|
+
refsLine,
|
|
98
|
+
].join('\n')
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
markdown,
|
|
102
|
+
commits,
|
|
103
|
+
files,
|
|
104
|
+
statSummary,
|
|
105
|
+
branch: head,
|
|
106
|
+
base: targetBase,
|
|
107
|
+
issueId: refNum || null,
|
|
108
|
+
}
|
|
109
|
+
}
|
package/lib/tools/pulls.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { encodeRepoPath } from '../github.js'
|
|
2
|
+
import { pushFiles } from './write-files.js'
|
|
1
3
|
import { analyzePull, areasFor, verdictFor } from '../review-rules.js'
|
|
2
4
|
|
|
3
5
|
export { areasFor }
|
|
@@ -65,9 +67,15 @@ export async function updatePull(client, { owner, repo, number, title, body, sta
|
|
|
65
67
|
|
|
66
68
|
const MERGE_METHODS = { merge: 'merge', squash: 'squash', rebase: 'rebase' }
|
|
67
69
|
|
|
68
|
-
export async function mergePull(client, { owner, repo, number, method = 'merge', commitTitle, commitMessage, deleteBranch = false }) {
|
|
70
|
+
export async function mergePull(client, { owner, repo, number, method = 'merge', commitTitle, commitMessage, deleteBranch = false, requireSigned = false }) {
|
|
69
71
|
ensureRepo({ owner, repo })
|
|
70
72
|
if (!number) throw new Error('number is required')
|
|
73
|
+
if (requireSigned) {
|
|
74
|
+
const sigs = await getPullCommitSignatures(client, { owner, repo, number })
|
|
75
|
+
if (!sigs.allVerified) {
|
|
76
|
+
throw new Error(`cannot merge pull request #${number}: ${sigs.warning}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
71
79
|
const mergeMethod = MERGE_METHODS[String(method).toLowerCase()]
|
|
72
80
|
if (!mergeMethod) throw new Error(`method must be one of: ${Object.keys(MERGE_METHODS).join(', ')}`)
|
|
73
81
|
const payload = { merge_method: mergeMethod }
|
|
@@ -120,11 +128,28 @@ export async function reviewPull(client, { owner, repo, number, maxDiffChars = 6
|
|
|
120
128
|
checks = { error: String(err && err.message || err) }
|
|
121
129
|
}
|
|
122
130
|
|
|
131
|
+
let signatures = null
|
|
132
|
+
try {
|
|
133
|
+
signatures = await getPullCommitSignatures(client, { owner, repo, number })
|
|
134
|
+
} catch (err) {
|
|
135
|
+
signatures = { error: String((err && err.message) || err) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const findings = analyzePull({ files, rules })
|
|
139
|
+
if (signatures && signatures.unverifiedCount > 0) {
|
|
140
|
+
findings.push({
|
|
141
|
+
level: 'attention',
|
|
142
|
+
area: 'security',
|
|
143
|
+
detail: signatures.warning,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
123
147
|
return {
|
|
124
148
|
pull: pullData,
|
|
125
149
|
areas: areasFor(files),
|
|
126
150
|
files: files.map(({ patch, ...rest }) => ({ ...rest, patchChars: patch.length })),
|
|
127
|
-
findings
|
|
151
|
+
findings,
|
|
152
|
+
signatures,
|
|
128
153
|
comments: Array.isArray(commentsRes.data)
|
|
129
154
|
? commentsRes.data.map((c) => ({ id: c.id, author: c.user && c.user.login, body: c.body }))
|
|
130
155
|
: [],
|
|
@@ -338,3 +363,179 @@ export async function resolveReviewThread(client, { owner, repo, threadId, resol
|
|
|
338
363
|
isResolved: Boolean(thread.isResolved),
|
|
339
364
|
}
|
|
340
365
|
}
|
|
366
|
+
|
|
367
|
+
export function parseSuggestionBlock(body) {
|
|
368
|
+
if (!body || typeof body !== 'string') return null
|
|
369
|
+
const match = body.match(/```suggestion(?:\r?\n([\s\S]*?))?\r?\n```/)
|
|
370
|
+
if (!match) return null
|
|
371
|
+
return match[1] !== undefined ? match[1] : ''
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function applySuggestion(client, {
|
|
375
|
+
owner,
|
|
376
|
+
repo,
|
|
377
|
+
pullNumber,
|
|
378
|
+
commentId,
|
|
379
|
+
commentIds,
|
|
380
|
+
confirm = false,
|
|
381
|
+
message,
|
|
382
|
+
}) {
|
|
383
|
+
ensureRepo({ owner, repo })
|
|
384
|
+
if (!pullNumber) throw new Error('pullNumber is required')
|
|
385
|
+
if (confirm !== true) {
|
|
386
|
+
throw new Error('refusing to apply suggestion without confirm: true')
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const ids = Array.isArray(commentIds) && commentIds.length > 0
|
|
390
|
+
? commentIds
|
|
391
|
+
: (commentId ? [commentId] : [])
|
|
392
|
+
|
|
393
|
+
if (ids.length === 0) {
|
|
394
|
+
throw new Error('commentId or commentIds is required')
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const { data: pr } = await client.get(`/repos/${owner}/${repo}/pulls/${pullNumber}`)
|
|
398
|
+
if (!pr) throw new Error(`pull request #${pullNumber} not found`)
|
|
399
|
+
if (pr.state !== 'open') {
|
|
400
|
+
throw new Error(`cannot apply suggestion: pull request #${pullNumber} is not open (state: ${pr.state})`)
|
|
401
|
+
}
|
|
402
|
+
const branch = pr.head && pr.head.ref
|
|
403
|
+
if (!branch) throw new Error('cannot resolve head branch for pull request')
|
|
404
|
+
|
|
405
|
+
const commentsToApply = []
|
|
406
|
+
for (const id of ids) {
|
|
407
|
+
const { data: comment } = await client.get(`/repos/${owner}/${repo}/pulls/comments/${id}`)
|
|
408
|
+
if (!comment) throw new Error(`comment #${id} not found`)
|
|
409
|
+
|
|
410
|
+
if (comment.position === null && comment.original_position !== undefined && comment.original_position !== null) {
|
|
411
|
+
throw new Error(`cannot apply suggestion: comment #${id} is outdated (diff context has changed)`)
|
|
412
|
+
}
|
|
413
|
+
if (comment.outdated === true) {
|
|
414
|
+
throw new Error(`cannot apply suggestion: comment #${id} is outdated`)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const replacement = parseSuggestionBlock(comment.body)
|
|
418
|
+
if (replacement === null) {
|
|
419
|
+
throw new Error(`cannot apply suggestion: comment #${id} does not contain a markdown suggestion block`)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const startLine = comment.start_line != null
|
|
423
|
+
? comment.start_line
|
|
424
|
+
: (comment.original_start_line != null ? comment.original_start_line : (comment.line != null ? comment.line : comment.original_line))
|
|
425
|
+
const endLine = comment.line != null ? comment.line : comment.original_line
|
|
426
|
+
|
|
427
|
+
if (startLine == null || endLine == null) {
|
|
428
|
+
throw new Error(`cannot apply suggestion: comment #${id} has no line numbers`)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
commentsToApply.push({
|
|
432
|
+
id,
|
|
433
|
+
path: comment.path,
|
|
434
|
+
startLine,
|
|
435
|
+
endLine,
|
|
436
|
+
replacement,
|
|
437
|
+
user: comment.user && comment.user.login ? comment.user.login : 'reviewer',
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const filesMap = new Map()
|
|
442
|
+
for (const item of commentsToApply) {
|
|
443
|
+
let fileInfo = filesMap.get(item.path)
|
|
444
|
+
if (!fileInfo) {
|
|
445
|
+
const fileRes = await client.get(`/repos/${owner}/${repo}/contents/${encodeRepoPath(item.path)}`, {
|
|
446
|
+
query: { ref: branch },
|
|
447
|
+
})
|
|
448
|
+
if (!fileRes || !fileRes.data || !fileRes.data.content) {
|
|
449
|
+
throw new Error(`cannot apply suggestion: file ${item.path} not found in branch ${branch}`)
|
|
450
|
+
}
|
|
451
|
+
const rawContent = Buffer.from(fileRes.data.content, 'base64').toString('utf8')
|
|
452
|
+
fileInfo = { path: item.path, lines: rawContent.split('\n'), items: [] }
|
|
453
|
+
filesMap.set(item.path, fileInfo)
|
|
454
|
+
}
|
|
455
|
+
fileInfo.items.push(item)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const changedFiles = []
|
|
459
|
+
const authors = new Set()
|
|
460
|
+
for (const [filePath, fileInfo] of filesMap.entries()) {
|
|
461
|
+
fileInfo.items.sort((a, b) => b.startLine - a.startLine)
|
|
462
|
+
for (const item of fileInfo.items) {
|
|
463
|
+
const startIdx = item.startLine - 1
|
|
464
|
+
const endIdx = item.endLine - 1
|
|
465
|
+
if (startIdx < 0 || endIdx < startIdx || endIdx >= fileInfo.lines.length) {
|
|
466
|
+
throw new Error(`cannot apply suggestion: line range ${item.startLine}-${item.endLine} is out of bounds for ${filePath} (${fileInfo.lines.length} lines)`)
|
|
467
|
+
}
|
|
468
|
+
const replacementLines = item.replacement === '' ? [] : item.replacement.split('\n')
|
|
469
|
+
fileInfo.lines.splice(startIdx, endIdx - startIdx + 1, ...replacementLines)
|
|
470
|
+
authors.add(item.user)
|
|
471
|
+
}
|
|
472
|
+
changedFiles.push({ path: filePath, content: fileInfo.lines.join('\n') })
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const authorList = Array.from(authors).map((a) => `@${a}`).join(', ')
|
|
476
|
+
const commitMsg = message || `Apply suggestion from ${authorList}\n\nRefs: #${pullNumber}`
|
|
477
|
+
const pushRes = await pushFiles(client, {
|
|
478
|
+
owner,
|
|
479
|
+
repo,
|
|
480
|
+
branch,
|
|
481
|
+
message: commitMsg,
|
|
482
|
+
files: changedFiles,
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
applied: true,
|
|
487
|
+
commit: pushRes.commit,
|
|
488
|
+
branch,
|
|
489
|
+
pullNumber: Number(pullNumber),
|
|
490
|
+
commentIds: ids,
|
|
491
|
+
files: changedFiles.map((f) => f.path),
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export async function getPullCommitSignatures(client, { owner, repo, number }) {
|
|
496
|
+
ensureRepo({ owner, repo })
|
|
497
|
+
if (!number) throw new Error('number is required')
|
|
498
|
+
|
|
499
|
+
const { data: commits } = await client.get(`/repos/${owner}/${repo}/pulls/${number}/commits`, {
|
|
500
|
+
query: { per_page: 100 },
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
const list = Array.isArray(commits) ? commits : []
|
|
504
|
+
const audit = []
|
|
505
|
+
let unverifiedCount = 0
|
|
506
|
+
|
|
507
|
+
for (const c of list) {
|
|
508
|
+
const sha = c.sha || ''
|
|
509
|
+
const shortSha = sha.slice(0, 7)
|
|
510
|
+
const author = c.commit?.author?.name || c.author?.login || ''
|
|
511
|
+
const verification = c.commit?.verification || {}
|
|
512
|
+
const verified = Boolean(verification.verified)
|
|
513
|
+
const reason = verification.reason || (verified ? 'valid' : 'unsigned')
|
|
514
|
+
|
|
515
|
+
if (!verified) unverifiedCount++
|
|
516
|
+
audit.push({
|
|
517
|
+
sha,
|
|
518
|
+
shortSha,
|
|
519
|
+
author,
|
|
520
|
+
verified,
|
|
521
|
+
reason,
|
|
522
|
+
signature: Boolean(verification.signature),
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const allVerified = list.length > 0 && unverifiedCount === 0
|
|
527
|
+
const unverifiedCommits = audit.filter((c) => !c.verified)
|
|
528
|
+
const warning = unverifiedCount > 0
|
|
529
|
+
? `pull request #${number} contains ${unverifiedCount} unverified commit(s): ${unverifiedCommits.map((c) => c.shortSha).join(', ')}`
|
|
530
|
+
: null
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
pullNumber: Number(number),
|
|
534
|
+
totalCommits: list.length,
|
|
535
|
+
allVerified,
|
|
536
|
+
unverifiedCount,
|
|
537
|
+
commits: audit,
|
|
538
|
+
unverifiedCommits,
|
|
539
|
+
warning,
|
|
540
|
+
}
|
|
541
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
closeIssue,
|
|
12
12
|
searchIssues,
|
|
13
13
|
} from './issues.js'
|
|
14
|
+
import { generatePrSummary } from './pr-summary.js'
|
|
14
15
|
import {
|
|
15
16
|
createPull,
|
|
16
17
|
updatePull,
|
|
@@ -22,13 +23,15 @@ import {
|
|
|
22
23
|
listReviewThreads,
|
|
23
24
|
replyReviewThread,
|
|
24
25
|
resolveReviewThread,
|
|
26
|
+
applySuggestion,
|
|
27
|
+
getPullCommitSignatures,
|
|
25
28
|
} from './pulls.js'
|
|
26
29
|
|
|
27
30
|
const REPOSITORY = {
|
|
28
31
|
repository: { type: 'string', description: 'Repository as "owner/repo". Omit to use the default from settings.' },
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
export function registerCollabTools({ tool, asRepo, client, liveConfig, reviewRules }) {
|
|
34
|
+
export function registerCollabTools({ tool, asRepo, client, liveConfig, reviewRules, git, defaultCwd }) {
|
|
32
35
|
const repoParams = (extra = {}) => ({ ...REPOSITORY, ...extra })
|
|
33
36
|
const rules = () => (typeof reviewRules === 'function' ? reviewRules() : undefined)
|
|
34
37
|
|
|
@@ -161,6 +164,7 @@ export function registerCollabTools({ tool, asRepo, client, liveConfig, reviewRu
|
|
|
161
164
|
commitTitle: { type: 'string', description: 'Merge/squash commit title.' },
|
|
162
165
|
commitMessage: { type: 'string', description: 'Merge/squash commit message.' },
|
|
163
166
|
deleteBranch: { type: 'boolean', description: 'Delete the head branch after merging.' },
|
|
167
|
+
requireSigned: { type: 'boolean', description: 'Refuse merge if any commit lacks a valid cryptographic signature.' },
|
|
164
168
|
}),
|
|
165
169
|
async (args) => {
|
|
166
170
|
const { owner, repo } = await asRepo(args, liveConfig())
|
|
@@ -171,6 +175,7 @@ export function registerCollabTools({ tool, asRepo, client, liveConfig, reviewRu
|
|
|
171
175
|
commitTitle: args.commitTitle,
|
|
172
176
|
commitMessage: args.commitMessage,
|
|
173
177
|
deleteBranch: args.deleteBranch,
|
|
178
|
+
requireSigned: args.requireSigned,
|
|
174
179
|
})
|
|
175
180
|
},
|
|
176
181
|
)
|
|
@@ -292,4 +297,63 @@ export function registerCollabTools({ tool, asRepo, client, liveConfig, reviewRu
|
|
|
292
297
|
})
|
|
293
298
|
},
|
|
294
299
|
)
|
|
300
|
+
|
|
301
|
+
// -------------------------------------------------------- pr summary generator
|
|
302
|
+
|
|
303
|
+
tool(
|
|
304
|
+
'gh_generate_pr_summary',
|
|
305
|
+
'Generate canonical AGENTS.md pull request description markdown by analyzing git commits and diff stat between head and base branch. Read-only.',
|
|
306
|
+
{
|
|
307
|
+
branch: { type: 'string', description: 'Head branch or ref to inspect (defaults to current HEAD).' },
|
|
308
|
+
base: { type: 'string', description: 'Base branch or ref to compare against (defaults to "origin/main").' },
|
|
309
|
+
issueId: { type: 'string', description: 'Issue or task number to reference (e.g. "68" or "#68").' },
|
|
310
|
+
title: { type: 'string', description: 'Optional PR title.' },
|
|
311
|
+
cwd: { type: 'string', description: 'Local checkout path (defaults to session cwd).' },
|
|
312
|
+
},
|
|
313
|
+
async (args) => {
|
|
314
|
+
const runner = git
|
|
315
|
+
if (!runner) throw new Error('git runner is not available')
|
|
316
|
+
const targetCwd = args.cwd || (typeof defaultCwd === 'function' ? defaultCwd() : process.cwd())
|
|
317
|
+
return generatePrSummary({
|
|
318
|
+
git: runner,
|
|
319
|
+
cwd: targetCwd,
|
|
320
|
+
branch: args.branch,
|
|
321
|
+
base: args.base,
|
|
322
|
+
issueId: args.issueId,
|
|
323
|
+
title: args.title,
|
|
324
|
+
})
|
|
325
|
+
},
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
tool(
|
|
329
|
+
'pr_apply_suggestion',
|
|
330
|
+
'Automatically apply a reviewer code suggestion (```suggestion``` block) from a pull request review comment. Updates the pull request head branch and creates a commit citing the reviewer. Requires confirm: true.',
|
|
331
|
+
repoParams({
|
|
332
|
+
pullNumber: { type: 'number', description: 'Pull request number.' },
|
|
333
|
+
commentId: { type: 'number', description: 'Review comment id to apply.' },
|
|
334
|
+
commentIds: { type: 'array', items: { type: 'number' }, description: 'Multiple review comment ids to apply.' },
|
|
335
|
+
message: { type: 'string', description: 'Optional custom commit message.' },
|
|
336
|
+
confirm: { type: 'boolean', description: 'Must be true to apply the suggestion and push the commit.' },
|
|
337
|
+
}),
|
|
338
|
+
async (args) => withRepo(args, async ({ owner, repo }) => applySuggestion(await client(), {
|
|
339
|
+
owner,
|
|
340
|
+
repo,
|
|
341
|
+
pullNumber: args.pullNumber,
|
|
342
|
+
commentId: args.commentId,
|
|
343
|
+
commentIds: args.commentIds,
|
|
344
|
+
message: args.message,
|
|
345
|
+
confirm: args.confirm,
|
|
346
|
+
})),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
tool(
|
|
350
|
+
'gh_pull_commit_signatures',
|
|
351
|
+
'Verify cryptographic signatures (GPG/SSH) of all commits in a pull request. Returns verification status and flags unverified commits. Read-only.',
|
|
352
|
+
repoParams({
|
|
353
|
+
number: { type: 'number', description: 'Pull request number.' },
|
|
354
|
+
}),
|
|
355
|
+
async (args) => withRepo(args, async ({ owner, repo }) => getPullCommitSignatures(await client(), {
|
|
356
|
+
owner, repo, number: args.number,
|
|
357
|
+
})),
|
|
358
|
+
)
|
|
295
359
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// a plan with a forbidden path in the allowlist or a missing product file, and always
|
|
6
6
|
// pushes a fast-forward on top of the mirror branch — never a force.
|
|
7
7
|
|
|
8
|
-
import { planMirror, publishMirror, DEFAULT_ALLOW, DEFAULT_FORBIDDEN, DEFAULT_REQUIRED } from './mirror.js'
|
|
8
|
+
import { planMirror, publishMirror, mirrorStatus, syncMirror, DEFAULT_ALLOW, DEFAULT_FORBIDDEN, DEFAULT_REQUIRED } from './mirror.js'
|
|
9
9
|
|
|
10
10
|
const MIRROR_PARAMS = {
|
|
11
11
|
cwd: { type: 'string', description: 'Absolute path of the local checkout to publish from. Defaults to the session working directory.' },
|
|
@@ -83,6 +83,47 @@ export function registerMirrorTools({ tool, git, defaultCwd }) {
|
|
|
83
83
|
}
|
|
84
84
|
},
|
|
85
85
|
)
|
|
86
|
+
|
|
87
|
+
tool(
|
|
88
|
+
'gh_mirror_status',
|
|
89
|
+
'Inspect the synchronization status between Gitea (source of truth) and GitHub sanitized mirror: compares HEAD commits, unpushed release tags, and runs leak-prevention verification against forbidden files. Read-only.',
|
|
90
|
+
MIRROR_PARAMS,
|
|
91
|
+
async (args) => {
|
|
92
|
+
return mirrorStatus({
|
|
93
|
+
git,
|
|
94
|
+
...where(args),
|
|
95
|
+
mirror: args.mirror || 'github',
|
|
96
|
+
branch: args.branch || 'main',
|
|
97
|
+
})
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
tool(
|
|
102
|
+
'gh_mirror_sync',
|
|
103
|
+
'Synchronize the sanitized product tree to the GitHub mirror and push unpushed release tags. Requires confirm: true (or dryRun: true to preview). Refuses if leak check detects forbidden files.',
|
|
104
|
+
{
|
|
105
|
+
...MIRROR_PARAMS,
|
|
106
|
+
syncTags: { type: 'boolean', description: 'Whether to push unpushed release tags (default true).' },
|
|
107
|
+
message: { type: 'string', description: 'Commit message for mirror commit.' },
|
|
108
|
+
dryRun: { type: 'boolean', description: 'Plan only: nothing is committed or pushed.' },
|
|
109
|
+
confirm: { type: 'boolean', description: 'Must be true to push to the mirror.' },
|
|
110
|
+
},
|
|
111
|
+
async (args) => {
|
|
112
|
+
const dryRun = args.dryRun === true
|
|
113
|
+
if (!dryRun && args.confirm !== true) {
|
|
114
|
+
throw new Error('refusing to sync mirror without confirm: true (or pass dryRun: true to preview)')
|
|
115
|
+
}
|
|
116
|
+
return syncMirror({
|
|
117
|
+
git,
|
|
118
|
+
...where(args),
|
|
119
|
+
mirror: args.mirror || 'github',
|
|
120
|
+
branch: args.branch || 'main',
|
|
121
|
+
syncTags: args.syncTags !== false,
|
|
122
|
+
message: args.message,
|
|
123
|
+
dryRun,
|
|
124
|
+
})
|
|
125
|
+
},
|
|
126
|
+
)
|
|
86
127
|
}
|
|
87
128
|
|
|
88
129
|
export { DEFAULT_ALLOW, DEFAULT_FORBIDDEN, DEFAULT_REQUIRED }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-github-ops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "GitHub operations for DeepSeek Harness: releases, tags, a guarded generic API pass-through, workflow runs, secrets and sanitized mirror publication.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|