@goodandready/dsh-github-ops 0.3.1 → 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 CHANGED
@@ -1,5 +1,13 @@
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
+
3
11
  ## 0.3.1
4
12
 
5
13
  ### Fixed
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
@@ -383,7 +383,7 @@ export function apply(ctx, rawConfig) {
383
383
  },
384
384
  }
385
385
 
386
- const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam, defaultCwd: () => safeCwd(ctx) }
386
+ const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam, defaultCwd: () => safeCwd(ctx), git: makeGitRunner() }
387
387
  registerCoreTools(deps)
388
388
  registerCollabTools(deps)
389
389
  registerOpsTools(deps)
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
  *
@@ -314,7 +318,77 @@ export function registerRoutes(ctx, deps = {}) {
314
318
  },
315
319
  })
316
320
  if (typeof wctx.effect === 'function') wctx.effect(register, 'dsh-github-ops: panel route')
317
- else register()
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()
318
392
  } catch (err) {
319
393
  ctx.logger?.warn?.('[dsh-github-ops] panel route not registered:', err && err.message)
320
394
  }
@@ -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
+ }
@@ -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: analyzePull({ files, rules }),
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.1",
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",