@goodandready/dsh-github-ops 0.5.0 → 0.6.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/lib/client.js CHANGED
@@ -135,6 +135,16 @@ window.__ModuleLoader__.load({
135
135
  prBarReview: 'Review',
136
136
  prBarMerge: 'Merge',
137
137
  prBarCopied: 'Instruction copied: paste it into the composer and send.',
138
+ scmStageHunk: 'Stage Hunk',
139
+ scmUnstageHunk: 'Unstage Hunk',
140
+ scmHunk: 'Hunk',
141
+ scmGraphTitle: 'Git Graph',
142
+ scmGraphRefresh: 'Refresh Graph',
143
+ scmGraphEmpty: 'No commit history available.',
144
+ scmEventsConnected: 'Live updates active',
145
+ scmCiRunSuccess: 'CI run succeeded',
146
+ scmCiRunFailed: 'CI run failed',
147
+ scmPrUpdated: 'PR updated',
138
148
  prBarCopyFailed: 'Could not copy; the instruction is in the title.',
139
149
  }
140
150
 
@@ -238,6 +248,16 @@ window.__ModuleLoader__.load({
238
248
  prBarReview: '评审',
239
249
  prBarMerge: '合并',
240
250
  prBarCopied: '指令已复制:粘贴到输入框并发送。',
251
+ scmStageHunk: '暂存代码块',
252
+ scmUnstageHunk: '取消暂存块',
253
+ scmHunk: '代码块',
254
+ scmGraphTitle: 'Git 提交图',
255
+ scmGraphRefresh: '刷新提交图',
256
+ scmGraphEmpty: '暂无可用的提交历史。',
257
+ scmEventsConnected: '实时更新已连接',
258
+ scmCiRunSuccess: 'CI 运行成功',
259
+ scmCiRunFailed: 'CI 运行失败',
260
+ scmPrUpdated: 'PR 已更新',
241
261
  prBarCopyFailed: '复制失败;指令在提示中。',
242
262
  }
243
263
 
@@ -276,6 +296,9 @@ window.__ModuleLoader__.load({
276
296
  const [branchName, setBranchName] = React.useState('')
277
297
  const [stashMessage, setStashMessage] = React.useState('')
278
298
  const [mirrorStatus, setMirrorStatus] = React.useState(null)
299
+ const [showGraph, setShowGraph] = React.useState(false)
300
+ const [graphData, setGraphData] = React.useState({ phase: 'idle', entries: [] })
301
+ const [toasts, setToasts] = React.useState([])
279
302
  const [repoPath, setRepoPath] = React.useState(() => {
280
303
  try { return localStorage.getItem('gho-scm-cwd') || '' } catch { return '' }
281
304
  })
@@ -305,6 +328,45 @@ window.__ModuleLoader__.load({
305
328
  }, [repoPath])
306
329
 
307
330
  React.useEffect(() => { load() }, [load])
331
+ const loadGraph = React.useCallback(() => {
332
+ setGraphData((prev) => ({ ...prev, phase: 'loading' }))
333
+ fetchWithTimeout(`/dsh-github-ops/graph?limit=15${cwdQuery ? `&${cwdQuery}` : ''}`, { headers: { accept: 'application/json' } })
334
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
335
+ .then((data) => setGraphData({ phase: 'ready', entries: data.entries || [] }))
336
+ .catch((err) => setGraphData({ phase: 'error', entries: [], error: String((err && err.message) || err) }))
337
+ }, [cwdQuery])
338
+
339
+ React.useEffect(() => {
340
+ if (typeof EventSource === 'undefined') return
341
+ let es = null
342
+ try {
343
+ es = new EventSource('/dsh-github-ops/events')
344
+ es.addEventListener('ci_status', (e) => {
345
+ try {
346
+ const data = JSON.parse(e.data)
347
+ const title = data.conclusion === 'success' ? t('scmCiRunSuccess') : t('scmCiRunFailed')
348
+ const msg = `${data.name || 'CI'} #${data.id} (${data.conclusion || data.status})`
349
+ const tid = Date.now() + Math.random()
350
+ setToasts((prev) => [...prev, { id: tid, type: data.conclusion === 'success' ? 'success' : 'warn', title, message: msg }])
351
+ setTimeout(() => setToasts((prev) => prev.filter((x) => x.id !== tid)), 5000)
352
+ load()
353
+ } catch {}
354
+ })
355
+ es.addEventListener('pr_status', (e) => {
356
+ try {
357
+ const data = JSON.parse(e.data)
358
+ const title = t('scmPrUpdated')
359
+ const msg = `PR #${data.number}: ${data.title || ''}`
360
+ const tid = Date.now() + Math.random()
361
+ setToasts((prev) => [...prev, { id: tid, type: 'info', title, message: msg }])
362
+ setTimeout(() => setToasts((prev) => prev.filter((x) => x.id !== tid)), 5000)
363
+ load()
364
+ } catch {}
365
+ })
366
+ } catch {}
367
+ return () => { if (es) es.close() }
368
+ }, [load, t])
369
+
308
370
 
309
371
  // Every write is one named action; the host validates it and runs argv, never a shell.
310
372
  const runAction = (action, args) => {
@@ -322,6 +384,9 @@ window.__ModuleLoader__.load({
322
384
  setConfirming('')
323
385
  if (!payload || payload.ok !== true) setActionError((payload && payload.error) || t('scmActionFailed'))
324
386
  load()
387
+ if (selected && selected.path && !selected.isStash) {
388
+ openDiff(selected, selected.staged)
389
+ }
325
390
  })
326
391
  .catch((err) => { setBusy(''); setActionError(String((err && err.message) || err)) })
327
392
  }
@@ -350,17 +415,19 @@ window.__ModuleLoader__.load({
350
415
 
351
416
  const openDiff = (file, staged) => {
352
417
  setSelected({ path: file.path, staged })
353
- setDiff({ phase: 'loading', text: '', hasConflicts: false, conflicts: [] })
418
+ setDiff({ phase: 'loading', text: '', hunks: [], hasConflicts: false, conflicts: [], staged })
354
419
  const query = `?file=${encodeURIComponent(file.path)}${staged ? '&staged=1' : ''}${cwdQuery ? `&${cwdQuery}` : ''}`
355
420
  fetchWithTimeout(SCM_PATH + query, { headers: { accept: 'application/json' } })
356
421
  .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
357
422
  .then((payload) => setDiff({
358
423
  phase: 'ready',
359
424
  text: payload.diff || '',
425
+ hunks: payload.hunks || [],
426
+ staged,
360
427
  hasConflicts: Boolean(payload.hasConflicts || (file.status === 'U') || (payload.diff && payload.diff.includes('<<<<<<<'))),
361
428
  conflicts: payload.conflicts || [],
362
429
  }))
363
- .catch((err) => setDiff({ phase: 'error', text: String((err && err.message) || err), hasConflicts: false }))
430
+ .catch((err) => setDiff({ phase: 'error', text: String((err && err.message) || err), hunks: [], hasConflicts: false, staged }))
364
431
  }
365
432
 
366
433
  const resolveConflict = (choice, blockIndex) => {
@@ -571,9 +638,35 @@ window.__ModuleLoader__.load({
571
638
  )
572
639
  : null,
573
640
  selected
574
- ? React.createElement('pre', { className: 'gho-scm-diff' },
575
- diff.phase === 'loading' ? t('scmLoading') : (diff.text || t('scmDiffEmpty')))
641
+ ? (diff.phase === 'loading'
642
+ ? React.createElement('pre', { className: 'gho-scm-diff' }, t('scmLoading'))
643
+ : (diff.hunks && diff.hunks.length > 0
644
+ ? React.createElement('div', { className: 'gho-diff-container' },
645
+ diff.hunks.map((hunk, hIdx) => React.createElement('div', { key: hIdx, className: 'gho-diff-hunk' },
646
+ React.createElement('div', { className: 'gho-diff-hunk-head' },
647
+ React.createElement('span', { className: 'gho-diff-hunk-title' }, hunk.header),
648
+ button(
649
+ selected.staged ? t('scmUnstageHunk') : t('scmStageHunk'),
650
+ () => runAction(selected.staged ? 'unstage_patch' : 'stage_patch', { patch: hunk.patch }),
651
+ ),
652
+ ),
653
+ React.createElement('div', { className: 'gho-diff-hunk-body' },
654
+ hunk.lines.map((line, lIdx) => React.createElement('div', {
655
+ key: lIdx,
656
+ className: `gho-diff-line gho-diff-${line.type}`,
657
+ },
658
+ React.createElement('span', { className: 'gho-diff-char' }, line.type === 'add' ? '+' : (line.type === 'del' ? '-' : ' ')),
659
+ React.createElement('span', { className: 'gho-diff-text' }, line.type === 'add' || line.type === 'del' ? line.text.slice(1) : line.text),
660
+ )),
661
+ ),
662
+ )),
663
+ )
664
+ : React.createElement('pre', { className: 'gho-scm-diff' }, diff.text || t('scmDiffEmpty'))
665
+ )
666
+ )
576
667
  : null,
668
+ graphBar,
669
+ graphView,
577
670
  branchBar,
578
671
  stashBar,
579
672
  worktreeBar,
@@ -581,6 +674,17 @@ window.__ModuleLoader__.load({
581
674
  React.createElement('div', { className: 'gho-scm-meta' },
582
675
  React.createElement('span', null, `${t('scmTags')}: ${(payload.tags || []).join(', ') || '—'}`),
583
676
  ),
677
+ toasts.length > 0
678
+ ? React.createElement('div', { className: 'gho-toasts' },
679
+ toasts.map((toast) => React.createElement('div', {
680
+ key: toast.id,
681
+ className: `gho-toast gho-toast-${toast.type}`,
682
+ },
683
+ React.createElement('strong', null, toast.title),
684
+ toast.message ? React.createElement('div', null, toast.message) : null,
685
+ ))
686
+ )
687
+ : null,
584
688
  )
585
689
  }
586
690
 
@@ -606,6 +710,45 @@ window.__ModuleLoader__.load({
606
710
  }, [])
607
711
 
608
712
  React.useEffect(() => { load() }, [load])
713
+ const loadGraph = React.useCallback(() => {
714
+ setGraphData((prev) => ({ ...prev, phase: 'loading' }))
715
+ fetchWithTimeout(`/dsh-github-ops/graph?limit=15${cwdQuery ? `&${cwdQuery}` : ''}`, { headers: { accept: 'application/json' } })
716
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
717
+ .then((data) => setGraphData({ phase: 'ready', entries: data.entries || [] }))
718
+ .catch((err) => setGraphData({ phase: 'error', entries: [], error: String((err && err.message) || err) }))
719
+ }, [cwdQuery])
720
+
721
+ React.useEffect(() => {
722
+ if (typeof EventSource === 'undefined') return
723
+ let es = null
724
+ try {
725
+ es = new EventSource('/dsh-github-ops/events')
726
+ es.addEventListener('ci_status', (e) => {
727
+ try {
728
+ const data = JSON.parse(e.data)
729
+ const title = data.conclusion === 'success' ? t('scmCiRunSuccess') : t('scmCiRunFailed')
730
+ const msg = `${data.name || 'CI'} #${data.id} (${data.conclusion || data.status})`
731
+ const tid = Date.now() + Math.random()
732
+ setToasts((prev) => [...prev, { id: tid, type: data.conclusion === 'success' ? 'success' : 'warn', title, message: msg }])
733
+ setTimeout(() => setToasts((prev) => prev.filter((x) => x.id !== tid)), 5000)
734
+ load()
735
+ } catch {}
736
+ })
737
+ es.addEventListener('pr_status', (e) => {
738
+ try {
739
+ const data = JSON.parse(e.data)
740
+ const title = t('scmPrUpdated')
741
+ const msg = `PR #${data.number}: ${data.title || ''}`
742
+ const tid = Date.now() + Math.random()
743
+ setToasts((prev) => [...prev, { id: tid, type: 'info', title, message: msg }])
744
+ setTimeout(() => setToasts((prev) => prev.filter((x) => x.id !== tid)), 5000)
745
+ load()
746
+ } catch {}
747
+ })
748
+ } catch {}
749
+ return () => { if (es) es.close() }
750
+ }, [load, t])
751
+
609
752
 
610
753
  const payload = state.payload
611
754
  const ahead = payload && payload.isRepository && payload.upstream ? (payload.ahead || 0) : 0
@@ -946,6 +1089,34 @@ window.__ModuleLoader__.load({
946
1089
  '.gho-note{font-size:12px;color:var(--dsw-alias-label-secondary);margin-right:auto}',
947
1090
  '.gho-chev{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s}',
948
1091
  '.gho-chev-open{transform:rotate(180deg)}',
1092
+ '.gho-diff-container{display:flex;flex-direction:column;gap:8px;max-height:450px;overflow:auto}',
1093
+ '.gho-diff-hunk{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;overflow:hidden;background:var(--dsw-alias-bg-layer-3)}',
1094
+ '.gho-diff-hunk-head{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:rgba(99,102,241,0.08);border-bottom:1px solid var(--dsw-alias-border-l2);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}',
1095
+ '.gho-diff-hunk-title{color:var(--dsw-alias-label-secondary);font-weight:600}',
1096
+ '.gho-diff-hunk-body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.45}',
1097
+ '.gho-diff-line{display:flex;white-space:pre}',
1098
+ '.gho-diff-char{width:20px;user-select:none;text-align:center;flex-shrink:0}',
1099
+ '.gho-diff-text{flex:1;overflow-x:auto}',
1100
+ '.gho-diff-add{background:rgba(34,197,94,0.12);color:var(--dsw-alias-label-primary)}',
1101
+ '.gho-diff-add .gho-diff-char{color:#22c55e}',
1102
+ '.gho-diff-del{background:rgba(239,68,68,0.12);color:var(--dsw-alias-label-primary)}',
1103
+ '.gho-diff-del .gho-diff-char{color:#ef4444}',
1104
+ '.gho-diff-hunk_header{background:rgba(99,102,241,0.05);color:var(--dsw-alias-label-secondary);padding:2px 8px}',
1105
+ '.gho-diff-meta{color:var(--dsw-alias-label-tertiary)}',
1106
+ '.gho-git-graph{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;max-height:220px;overflow:auto;padding:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-3);display:flex;flex-direction:column;gap:3px}',
1107
+ '.gho-graph-row{display:flex;align-items:center;gap:6px;white-space:nowrap}',
1108
+ '.gho-graph-conn{color:var(--dsw-alias-label-tertiary)}',
1109
+ '.gho-graph-glyph{font-weight:700;color:#6366f1;white-space:pre}',
1110
+ '.gho-graph-sha{color:#f59e0b;font-weight:600}',
1111
+ '.gho-graph-refs{display:inline-flex;gap:4px}',
1112
+ '.gho-graph-ref{background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:1px 5px;font-size:10px}',
1113
+ '.gho-graph-msg{color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis}',
1114
+ '.gho-graph-meta{color:var(--dsw-alias-label-tertiary);margin-left:auto;padding-left:8px;font-size:10px}',
1115
+ '.gho-toasts{position:fixed;bottom:16px;right:16px;z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none}',
1116
+ '.gho-toast{pointer-events:auto;min-width:240px;max-width:360px;padding:8px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);box-shadow:0 4px 12px rgba(0,0,0,0.15);font-size:12px;display:flex;flex-direction:column;gap:2px}',
1117
+ '.gho-toast-success{border-left:4px solid #22c55e}',
1118
+ '.gho-toast-warn{border-left:4px solid #ef4444}',
1119
+ '.gho-toast-info{border-left:4px solid #6366f1}',
949
1120
  ].join('\n')
950
1121
  document.head.appendChild(style)
951
1122
  }
package/lib/events.js ADDED
@@ -0,0 +1,201 @@
1
+ // Server-Sent Events (SSE) notification manager for CI runs and PR events.
2
+ //
3
+ // Maintains client subscriptions, broadcasts real-time events, and runs an adaptive
4
+ // background polling loop only when at least one client is connected, conserving
5
+ // GitHub API rate limits.
6
+
7
+ import { listRuns } from './tools/actions.js'
8
+ import { listIssues } from './tools/issues.js'
9
+
10
+ export class SseEventManager {
11
+ constructor({ client, liveConfig, describeError, baseIntervalMs = 60000, activeIntervalMs = 15000 } = {}) {
12
+ this.client = client || (async () => null)
13
+ this.liveConfig = liveConfig || (() => ({}))
14
+ this.describeError = describeError || ((e) => String((e && e.message) || e))
15
+ this.baseIntervalMs = baseIntervalMs
16
+ this.activeIntervalMs = activeIntervalMs
17
+
18
+ this.clients = new Set()
19
+ this.timer = null
20
+ this.isPolling = false
21
+ this.lastRuns = new Map() // runId -> { status, conclusion }
22
+ this.lastPulls = new Map() // pullNumber -> { updated, state }
23
+ this.hasActiveWork = false
24
+ }
25
+
26
+ get clientCount() {
27
+ return this.clients.size
28
+ }
29
+
30
+ addClient(res, req) {
31
+ this.clients.add(res)
32
+
33
+ try {
34
+ res.write(': connected\n\n')
35
+ res.write(`data: ${JSON.stringify({ type: 'connected', clients: this.clients.size, timestamp: Date.now() })}\n\n`)
36
+ } catch {
37
+ this.clients.delete(res)
38
+ return
39
+ }
40
+
41
+ if (req) {
42
+ req.on('close', () => {
43
+ this.removeClient(res)
44
+ })
45
+ }
46
+
47
+ if (this.clients.size === 1) {
48
+ this.startPolling()
49
+ }
50
+ }
51
+
52
+ removeClient(res) {
53
+ this.clients.delete(res)
54
+ if (this.clients.size === 0) {
55
+ this.stopPolling()
56
+ }
57
+ }
58
+
59
+ broadcast(event, data) {
60
+ if (this.clients.size === 0) return
61
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
62
+ for (const res of Array.from(this.clients)) {
63
+ try {
64
+ res.write(payload)
65
+ } catch {
66
+ this.clients.delete(res)
67
+ }
68
+ }
69
+ if (this.clients.size === 0) {
70
+ this.stopPolling()
71
+ }
72
+ }
73
+
74
+ startPolling() {
75
+ if (this.timer) return
76
+ this.scheduleNext(1000)
77
+ }
78
+
79
+ stopPolling() {
80
+ if (this.timer) {
81
+ clearTimeout(this.timer)
82
+ this.timer = null
83
+ }
84
+ }
85
+
86
+ scheduleNext(delayMs) {
87
+ if (this.timer) clearTimeout(this.timer)
88
+ if (this.clients.size === 0) return
89
+ const delay = typeof delayMs === 'number' ? delayMs : (this.hasActiveWork ? this.activeIntervalMs : this.baseIntervalMs)
90
+ this.timer = setTimeout(() => {
91
+ this.poll().finally(() => {
92
+ if (this.clients.size > 0) {
93
+ this.scheduleNext()
94
+ }
95
+ })
96
+ }, delay)
97
+ if (this.timer && typeof this.timer.unref === 'function') {
98
+ this.timer.unref()
99
+ }
100
+ }
101
+
102
+ async poll() {
103
+ if (this.isPolling) return
104
+ this.isPolling = true
105
+ try {
106
+ const cfg = this.liveConfig()
107
+ const owner = cfg.owner
108
+ const repo = cfg.repo
109
+ if (!owner || !repo) {
110
+ this.hasActiveWork = false
111
+ return
112
+ }
113
+
114
+ const github = await this.client()
115
+ if (!github) {
116
+ this.hasActiveWork = false
117
+ return
118
+ }
119
+
120
+ // 1. Check CI workflow runs
121
+ let runs = []
122
+ try {
123
+ const runsResult = await listRuns(github, { owner, repo, limit: 10 })
124
+ runs = (runsResult && runsResult.runs) || []
125
+ } catch {
126
+ // ignore fetch error during polling
127
+ }
128
+
129
+ let activeRunsFound = false
130
+ for (const run of runs) {
131
+ const id = run.id
132
+ const status = run.status
133
+ const conclusion = run.conclusion
134
+ if (status === 'in_progress' || status === 'queued') {
135
+ activeRunsFound = true
136
+ }
137
+ const previous = this.lastRuns.get(id)
138
+ if (previous) {
139
+ if (previous.status !== status || previous.conclusion !== conclusion) {
140
+ this.broadcast('ci_status', {
141
+ id,
142
+ name: run.name,
143
+ status,
144
+ conclusion,
145
+ html_url: run.html_url,
146
+ head_branch: run.head_branch,
147
+ head_sha: run.head_sha ? run.head_sha.slice(0, 7) : '',
148
+ timestamp: Date.now(),
149
+ })
150
+ }
151
+ }
152
+ this.lastRuns.set(id, { status, conclusion })
153
+ }
154
+
155
+ // 2. Check open Pull Requests
156
+ let pulls = []
157
+ try {
158
+ const pullsResult = await listIssues(github, { owner, repo, kind: 'pr', state: 'open', limit: 10 })
159
+ pulls = (pullsResult && pullsResult.issues) || []
160
+ } catch {
161
+ // ignore fetch error during polling
162
+ }
163
+
164
+ for (const pr of pulls) {
165
+ const number = pr.number
166
+ const previous = this.lastPulls.get(number)
167
+ const updated = pr.updatedAt || pr.updated_at || pr.updated
168
+ if (previous && previous.updated !== updated) {
169
+ this.broadcast('pr_status', {
170
+ number,
171
+ title: pr.title,
172
+ state: pr.state,
173
+ user: pr.author || (pr.user && pr.user.login) || '',
174
+ updated_at: updated,
175
+ html_url: pr.html_url || '',
176
+ timestamp: Date.now(),
177
+ })
178
+ }
179
+ this.lastPulls.set(number, { updated, state: pr.state })
180
+ }
181
+
182
+ this.hasActiveWork = activeRunsFound
183
+ } catch {
184
+ // do not crash on polling errors
185
+ } finally {
186
+ this.isPolling = false
187
+ }
188
+ }
189
+
190
+ destroy() {
191
+ this.stopPolling()
192
+ for (const res of this.clients) {
193
+ try {
194
+ res.end()
195
+ } catch {}
196
+ }
197
+ this.clients.clear()
198
+ this.lastRuns.clear()
199
+ this.lastPulls.clear()
200
+ }
201
+ }
package/lib/index.js CHANGED
@@ -44,22 +44,38 @@ const callContext = new AsyncLocalStorage()
44
44
 
45
45
  /** git runner: never throws, always answers with a code and both streams. */
46
46
  function makeGitRunner() {
47
- return async (args, { cwd, env, timeoutMs, timeout } = {}) => {
48
- try {
49
- const { stdout, stderr } = await execFileAsync('git', args, {
50
- cwd: cwd || process.cwd(),
51
- env: { ...process.env, ...(env || {}) },
52
- timeout: typeof timeoutMs === 'number' ? timeoutMs : (typeof timeout === 'number' ? timeout : undefined),
53
- maxBuffer: 8 * 1024 * 1024,
54
- })
55
- return { code: 0, stdout, stderr }
56
- } catch (err) {
57
- return {
58
- code: typeof err.code === 'number' ? err.code : 1,
59
- stdout: err.stdout || '',
60
- stderr: err.stderr || String((err && err.message) || err),
47
+ return async (args, { cwd, env, timeoutMs, timeout, input } = {}) => {
48
+ return new Promise((resolve) => {
49
+ const child = execFile(
50
+ 'git',
51
+ args,
52
+ {
53
+ cwd: cwd || process.cwd(),
54
+ env: { ...process.env, ...(env || {}) },
55
+ timeout: typeof timeoutMs === 'number' ? timeoutMs : (typeof timeout === 'number' ? timeout : undefined),
56
+ maxBuffer: 8 * 1024 * 1024,
57
+ },
58
+ (err, stdout, stderr) => {
59
+ if (err) {
60
+ resolve({
61
+ code: typeof err.code === 'number' ? err.code : 1,
62
+ stdout: stdout || (err && err.stdout) || '',
63
+ stderr: stderr || (err && err.stderr) || String((err && err.message) || err),
64
+ })
65
+ } else {
66
+ resolve({
67
+ code: 0,
68
+ stdout: stdout || '',
69
+ stderr: stderr || '',
70
+ })
71
+ }
72
+ }
73
+ )
74
+ if (input !== undefined && input !== null && child.stdin) {
75
+ child.stdin.on('error', () => {})
76
+ child.stdin.end(input)
61
77
  }
62
- }
78
+ })
63
79
  }
64
80
  }
65
81
 
package/lib/routes.js CHANGED
@@ -10,7 +10,8 @@ import { join as joinPath } from 'node:path'
10
10
 
11
11
  import { statusPayload, isTrustedRequest, isTrustedWriteRequest, sendJson } from './status.js'
12
12
  import { buildAction, validateCwd, validateStashRef, resolveConflictFile } from './scm-write.js'
13
- import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom, hasConflicts, parseConflictBlocks } from './scm.js'
13
+ import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom, hasConflicts, parseConflictBlocks, parseDiffHunks, parseGitGraph } from './scm.js'
14
+ import { SseEventManager } from './events.js'
14
15
  import { listIssues } from './tools/issues.js'
15
16
  import { listRuns } from './tools/actions.js'
16
17
  import { listNotifications, repoTreeView } from './tools/panel-data.js'
@@ -32,6 +33,12 @@ export const MIRROR_PATH = '/dsh-github-ops/mirror'
32
33
  /** Where organization matrix status is read. */
33
34
  export const MATRIX_PATH = '/dsh-github-ops/matrix'
34
35
 
36
+ /** Where Git commit graph is read. */
37
+ export const GRAPH_PATH = '/dsh-github-ops/graph'
38
+
39
+ /** Where real-time CI and PR events are streamed via SSE. */
40
+ export const EVENTS_PATH = '/dsh-github-ops/events'
41
+
35
42
  /**
36
43
  * Register the three routes.
37
44
  *
@@ -176,7 +183,7 @@ export function registerRoutes(ctx, deps = {}) {
176
183
  }
177
184
  const steps = []
178
185
  for (const argv of built.steps) {
179
- const answer = await git(argv, { cwd, timeoutMs: 120000 })
186
+ const answer = await git(argv, { cwd, timeoutMs: 120000, input: built.input })
180
187
  steps.push({
181
188
  argv,
182
189
  code: answer.code,
@@ -209,10 +216,12 @@ export function registerRoutes(ctx, deps = {}) {
209
216
  } catch {
210
217
  // ignore
211
218
  }
219
+ const hunks = diff.code === 0 ? parseDiffHunks(text) : []
212
220
  return sendJson(res, 200, {
213
221
  file,
214
222
  staged,
215
223
  diff: String(text || '').slice(0, 200000),
224
+ hunks,
216
225
  hasConflicts: hasConflictsFlag,
217
226
  conflicts,
218
227
  })
@@ -458,6 +467,67 @@ export function registerRoutes(ctx, deps = {}) {
458
467
  if (typeof wctx.effect === 'function') wctx.effect(registerMatrix, 'dsh-github-ops: matrix route')
459
468
  else registerMatrix()
460
469
 
470
+ const registerGraph = () => wctx.webServer.register({
471
+ kind: 'exact',
472
+ path: GRAPH_PATH,
473
+ handler: async (req, res) => {
474
+ try {
475
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
476
+ if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
477
+ const git = makeGitRunner()
478
+ const url = new URL(req.url || '/', 'http://localhost')
479
+ const requested = validateCwd(url.searchParams.get('cwd') || '')
480
+ let cwd = safeCwd(ctx)
481
+ if (requested) {
482
+ if (!existsSync(requested) || !statSync(requested).isDirectory()) {
483
+ return sendJson(res, 400, { error: `not a directory: ${requested}` })
484
+ }
485
+ cwd = requested
486
+ }
487
+ const limitParam = parseInt(url.searchParams.get('limit') || '15', 10)
488
+ const limit = Math.max(1, Math.min(Number.isFinite(limitParam) ? limitParam : 15, 50))
489
+ const answer = await git(['log', `-n`, String(limit), '--graph', '--date=short', '--format=format:%h|%d|%s|%an|%cd'], { cwd })
490
+ if (answer.code !== 0) {
491
+ return sendJson(res, 200, { ok: false, error: answer.stderr || 'git log failed', entries: [] })
492
+ }
493
+ const entries = parseGitGraph(answer.stdout)
494
+ return sendJson(res, 200, { ok: true, limit, entries, raw: answer.stdout })
495
+ } catch (err) {
496
+ return sendJson(res, 500, { ok: false, error: describeError(err) })
497
+ }
498
+ },
499
+ })
500
+ if (typeof wctx.effect === 'function') wctx.effect(registerGraph, 'dsh-github-ops: graph route')
501
+ else registerGraph()
502
+
503
+ const eventManager = new SseEventManager({
504
+ client,
505
+ liveConfig,
506
+ describeError,
507
+ })
508
+ if (typeof wctx.effect === 'function') {
509
+ wctx.effect(() => () => eventManager.destroy(), 'dsh-github-ops: events teardown')
510
+ }
511
+
512
+ const registerEvents = () => wctx.webServer.register({
513
+ kind: 'exact',
514
+ path: EVENTS_PATH,
515
+ handler: async (req, res) => {
516
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
517
+ if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
518
+ res.writeHead(200, {
519
+ 'Content-Type': 'text/event-stream',
520
+ 'Cache-Control': 'no-cache, no-transform',
521
+ 'Connection': 'keep-alive',
522
+ 'X-Accel-Buffering': 'no',
523
+ })
524
+ if (typeof res.flushHeaders === 'function') res.flushHeaders()
525
+ eventManager.addClient(res, req)
526
+ },
527
+ })
528
+ if (typeof wctx.effect === 'function') wctx.effect(registerEvents, 'dsh-github-ops: events route')
529
+ else registerEvents()
530
+
461
531
  } catch (err) {
462
532
  ctx.logger?.warn?.('[dsh-github-ops] panel route not registered:', err && err.message)
463
533
  }
@@ -469,7 +539,7 @@ export function registerRoutes(ctx, deps = {}) {
469
539
  * Read a small JSON body, bounded, so a POST that names an action cannot exhaust memory.
470
540
  * The panel sends a few hundred bytes; anything larger is refused rather than buffered.
471
541
  */
472
- function readJsonBody(req, limit = 64 * 1024) {
542
+ function readJsonBody(req, limit = 512 * 1024) {
473
543
  return new Promise((resolve, reject) => {
474
544
  let size = 0
475
545
  const chunks = []
package/lib/scm-write.js CHANGED
@@ -46,6 +46,19 @@ export function validateStashRef(ref) {
46
46
  return value
47
47
  }
48
48
 
49
+
50
+ export function validatePatch(patch) {
51
+ const value = String(patch == null ? '' : patch)
52
+ if (value.includes('\u0000')) throw new Error('refusing a patch with a NUL byte')
53
+ const trimmed = value.trim()
54
+ if (!trimmed) throw new Error('a patch cannot be empty')
55
+ if (trimmed.length > 500000) throw new Error('refusing a patch longer than 500000 characters')
56
+ if (!trimmed.includes('@@') && !trimmed.includes('---')) {
57
+ throw new Error('refusing an invalid patch format: missing diff hunk markers')
58
+ }
59
+ return value
60
+ }
61
+
49
62
  const CONTINUE_VERBS = ['merge', 'rebase', 'cherry-pick', 'revert']
50
63
 
51
64
  /**
@@ -115,6 +128,20 @@ export function buildAction(action, args = {}) {
115
128
  const verb = String(args.verb || '').trim()
116
129
  if (!CONTINUE_VERBS.includes(verb)) throw new Error(`refusing to abort an unknown operation: "${verb.slice(0, 20)}"`)
117
130
  return result(name, [[verb, '--abort']], destructive, `abort the ${verb}`)
131
+ }
132
+ case 'stagePatch':
133
+ case 'stage_patch': {
134
+ const patch = validatePatch(args.patch)
135
+ const res = result('stage_patch', [['apply', '--cached', '-']], false, 'stage patch hunk')
136
+ res.input = patch
137
+ return res
138
+ }
139
+ case 'unstagePatch':
140
+ case 'unstage_patch': {
141
+ const patch = validatePatch(args.patch)
142
+ const res = result('unstage_patch', [['apply', '--cached', '--reverse', '-']], false, 'unstage patch hunk')
143
+ res.input = patch
144
+ return res
118
145
  }
119
146
  case 'resolveConflict': {
120
147
  const path = validatePath(args.path)
package/lib/scm.js CHANGED
@@ -334,3 +334,109 @@ export function resolveConflictContent(content, resolutions = 'ours') {
334
334
 
335
335
  return resultLines.join(eol)
336
336
  }
337
+
338
+ /**
339
+ * Splits a unified diff into individual hunks with their headers and ready-to-apply patches.
340
+ */
341
+ export function parseDiffHunks(diffText) {
342
+ if (typeof diffText !== 'string' || !diffText.trim()) return []
343
+ const lines = diffText.split('\n')
344
+ const fileHeaderLines = []
345
+ let i = 0
346
+ while (i < lines.length && !lines[i].startsWith('@@')) {
347
+ fileHeaderLines.push(lines[i])
348
+ i++
349
+ }
350
+ const fileHeader = fileHeaderLines.length > 0 ? fileHeaderLines.join('\n') + '\n' : ''
351
+
352
+ const hunks = []
353
+ let currentHunk = null
354
+
355
+ while (i < lines.length) {
356
+ const line = lines[i]
357
+ if (line.startsWith('@@')) {
358
+ if (currentHunk) {
359
+ currentHunk.patch = fileHeader + currentHunk.lines.map((l) => l.raw).join('\n') + '\n'
360
+ hunks.push(currentHunk)
361
+ }
362
+ const match = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/)
363
+ currentHunk = {
364
+ header: line,
365
+ oldStart: match ? parseInt(match[1], 10) : 0,
366
+ oldCount: match ? (match[2] !== undefined ? parseInt(match[2], 10) : 1) : 0,
367
+ newStart: match ? parseInt(match[3], 10) : 0,
368
+ newCount: match ? (match[4] !== undefined ? parseInt(match[4], 10) : 1) : 0,
369
+ heading: match ? match[5].trim() : '',
370
+ lines: [{ type: 'hunk_header', text: line, raw: line }],
371
+ patch: '',
372
+ }
373
+ } else if (currentHunk) {
374
+ let type = 'context'
375
+ if (line.startsWith('+')) type = 'add'
376
+ else if (line.startsWith('-')) type = 'del'
377
+ else if (line.startsWith('\\')) type = 'meta'
378
+ currentHunk.lines.push({ type, text: line, raw: line })
379
+ }
380
+ i++
381
+ }
382
+
383
+ if (currentHunk) {
384
+ currentHunk.patch = fileHeader + currentHunk.lines.map((l) => l.raw).join('\n') + '\n'
385
+ hunks.push(currentHunk)
386
+ }
387
+
388
+ return hunks
389
+ }
390
+
391
+ /**
392
+ * Parses git log --graph --date=short --format=format:'%h|%d|%s|%an|%cd' output.
393
+ */
394
+ export function parseGitGraph(text) {
395
+ if (typeof text !== 'string' || !text.trim()) return []
396
+ const lines = text.split('\n')
397
+ const results = []
398
+
399
+ for (const rawLine of lines) {
400
+ if (!rawLine.trim()) continue
401
+ const match = rawLine.match(/^([*|/\\_\-\s]*?)\s*([0-9a-f]{7,40})\|([^|]*)\|([^|]*)\|([^|]*)\|(.*)$/)
402
+ if (match) {
403
+ const graph = match[1] || '*'
404
+ const hash = match[2]
405
+ const rawRefs = match[3].trim()
406
+ const subject = match[4].trim()
407
+ const author = match[5].trim()
408
+ const date = match[6].trim()
409
+
410
+ let refs = []
411
+ if (rawRefs) {
412
+ const cleanRefs = rawRefs.replace(/^\(|\)$/g, '').trim()
413
+ if (cleanRefs) {
414
+ refs = cleanRefs.split(',').map((r) => r.trim()).filter(Boolean)
415
+ }
416
+ }
417
+ results.push({
418
+ isCommit: true,
419
+ graph,
420
+ hash,
421
+ refs,
422
+ subject,
423
+ author,
424
+ date,
425
+ raw: rawLine,
426
+ })
427
+ } else {
428
+ results.push({
429
+ isCommit: false,
430
+ graph: rawLine,
431
+ hash: '',
432
+ refs: [],
433
+ subject: '',
434
+ author: '',
435
+ date: '',
436
+ raw: rawLine,
437
+ })
438
+ }
439
+ }
440
+
441
+ return results
442
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-github-ops",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",