@goodandready/dsh-github-ops 0.5.0 → 0.6.1
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 +175 -4
- package/lib/events.js +201 -0
- package/lib/index.js +31 -15
- package/lib/routes.js +74 -3
- package/lib/scm-write.js +57 -12
- package/lib/scm.js +106 -0
- package/package.json +1 -1
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
|
-
?
|
|
575
|
-
|
|
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
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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,9 @@ 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'
|
|
15
|
+
import { encodeRepoPath } from './github.js'
|
|
14
16
|
import { listIssues } from './tools/issues.js'
|
|
15
17
|
import { listRuns } from './tools/actions.js'
|
|
16
18
|
import { listNotifications, repoTreeView } from './tools/panel-data.js'
|
|
@@ -32,6 +34,12 @@ export const MIRROR_PATH = '/dsh-github-ops/mirror'
|
|
|
32
34
|
/** Where organization matrix status is read. */
|
|
33
35
|
export const MATRIX_PATH = '/dsh-github-ops/matrix'
|
|
34
36
|
|
|
37
|
+
/** Where Git commit graph is read. */
|
|
38
|
+
export const GRAPH_PATH = '/dsh-github-ops/graph'
|
|
39
|
+
|
|
40
|
+
/** Where real-time CI and PR events are streamed via SSE. */
|
|
41
|
+
export const EVENTS_PATH = '/dsh-github-ops/events'
|
|
42
|
+
|
|
35
43
|
/**
|
|
36
44
|
* Register the three routes.
|
|
37
45
|
*
|
|
@@ -176,7 +184,7 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
176
184
|
}
|
|
177
185
|
const steps = []
|
|
178
186
|
for (const argv of built.steps) {
|
|
179
|
-
const answer = await git(argv, { cwd, timeoutMs: 120000 })
|
|
187
|
+
const answer = await git(argv, { cwd, timeoutMs: 120000, input: built.input })
|
|
180
188
|
steps.push({
|
|
181
189
|
argv,
|
|
182
190
|
code: answer.code,
|
|
@@ -209,10 +217,12 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
209
217
|
} catch {
|
|
210
218
|
// ignore
|
|
211
219
|
}
|
|
220
|
+
const hunks = diff.code === 0 ? parseDiffHunks(text) : []
|
|
212
221
|
return sendJson(res, 200, {
|
|
213
222
|
file,
|
|
214
223
|
staged,
|
|
215
224
|
diff: String(text || '').slice(0, 200000),
|
|
225
|
+
hunks,
|
|
216
226
|
hasConflicts: hasConflictsFlag,
|
|
217
227
|
conflicts,
|
|
218
228
|
})
|
|
@@ -458,6 +468,67 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
458
468
|
if (typeof wctx.effect === 'function') wctx.effect(registerMatrix, 'dsh-github-ops: matrix route')
|
|
459
469
|
else registerMatrix()
|
|
460
470
|
|
|
471
|
+
const registerGraph = () => wctx.webServer.register({
|
|
472
|
+
kind: 'exact',
|
|
473
|
+
path: GRAPH_PATH,
|
|
474
|
+
handler: async (req, res) => {
|
|
475
|
+
try {
|
|
476
|
+
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
|
|
477
|
+
if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
|
|
478
|
+
const git = makeGitRunner()
|
|
479
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
480
|
+
const requested = validateCwd(url.searchParams.get('cwd') || '')
|
|
481
|
+
let cwd = safeCwd(ctx)
|
|
482
|
+
if (requested) {
|
|
483
|
+
if (!existsSync(requested) || !statSync(requested).isDirectory()) {
|
|
484
|
+
return sendJson(res, 400, { error: `not a directory: ${requested}` })
|
|
485
|
+
}
|
|
486
|
+
cwd = requested
|
|
487
|
+
}
|
|
488
|
+
const limitParam = parseInt(url.searchParams.get('limit') || '15', 10)
|
|
489
|
+
const limit = Math.max(1, Math.min(Number.isFinite(limitParam) ? limitParam : 15, 50))
|
|
490
|
+
const answer = await git(['log', `-n`, String(limit), '--graph', '--date=short', '--format=format:%h|%d|%s|%an|%cd'], { cwd })
|
|
491
|
+
if (answer.code !== 0) {
|
|
492
|
+
return sendJson(res, 200, { ok: false, error: answer.stderr || 'git log failed', entries: [] })
|
|
493
|
+
}
|
|
494
|
+
const entries = parseGitGraph(answer.stdout)
|
|
495
|
+
return sendJson(res, 200, { ok: true, limit, entries, raw: answer.stdout })
|
|
496
|
+
} catch (err) {
|
|
497
|
+
return sendJson(res, 500, { ok: false, error: describeError(err) })
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
})
|
|
501
|
+
if (typeof wctx.effect === 'function') wctx.effect(registerGraph, 'dsh-github-ops: graph route')
|
|
502
|
+
else registerGraph()
|
|
503
|
+
|
|
504
|
+
const eventManager = new SseEventManager({
|
|
505
|
+
client,
|
|
506
|
+
liveConfig,
|
|
507
|
+
describeError,
|
|
508
|
+
})
|
|
509
|
+
if (typeof wctx.effect === 'function') {
|
|
510
|
+
wctx.effect(() => () => eventManager.destroy(), 'dsh-github-ops: events teardown')
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const registerEvents = () => wctx.webServer.register({
|
|
514
|
+
kind: 'exact',
|
|
515
|
+
path: EVENTS_PATH,
|
|
516
|
+
handler: async (req, res) => {
|
|
517
|
+
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
|
|
518
|
+
if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
|
|
519
|
+
res.writeHead(200, {
|
|
520
|
+
'Content-Type': 'text/event-stream',
|
|
521
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
522
|
+
'Connection': 'keep-alive',
|
|
523
|
+
'X-Accel-Buffering': 'no',
|
|
524
|
+
})
|
|
525
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders()
|
|
526
|
+
eventManager.addClient(res, req)
|
|
527
|
+
},
|
|
528
|
+
})
|
|
529
|
+
if (typeof wctx.effect === 'function') wctx.effect(registerEvents, 'dsh-github-ops: events route')
|
|
530
|
+
else registerEvents()
|
|
531
|
+
|
|
461
532
|
} catch (err) {
|
|
462
533
|
ctx.logger?.warn?.('[dsh-github-ops] panel route not registered:', err && err.message)
|
|
463
534
|
}
|
|
@@ -469,7 +540,7 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
469
540
|
* Read a small JSON body, bounded, so a POST that names an action cannot exhaust memory.
|
|
470
541
|
* The panel sends a few hundred bytes; anything larger is refused rather than buffered.
|
|
471
542
|
*/
|
|
472
|
-
function readJsonBody(req, limit =
|
|
543
|
+
function readJsonBody(req, limit = 512 * 1024) {
|
|
473
544
|
return new Promise((resolve, reject) => {
|
|
474
545
|
let size = 0
|
|
475
546
|
const chunks = []
|
package/lib/scm-write.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realpathSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { join as joinPath } from 'node:path'
|
|
2
|
+
import { join as joinPath, posix, win32 } from 'node:path'
|
|
3
3
|
import { hasConflicts, resolveConflictContent } from './scm.js'
|
|
4
4
|
// Writing to a working copy, from the Source Control tab.
|
|
5
5
|
//
|
|
@@ -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)
|
|
@@ -179,29 +206,47 @@ export const OPERATION_VERBS = Object.freeze(CONTINUE_VERBS.slice())
|
|
|
179
206
|
*/
|
|
180
207
|
const DISALLOWED_ROOTS = ['/etc', '/proc', '/sys', '/dev', '/root', '/bin', '/sbin', '/usr', '/boot', '/var/run']
|
|
181
208
|
|
|
209
|
+
function checkDisallowed(p) {
|
|
210
|
+
const norm = p.replace(/\\/g, '/')
|
|
211
|
+
for (const root of DISALLOWED_ROOTS) {
|
|
212
|
+
if (norm === root || norm.startsWith(root + '/')) {
|
|
213
|
+
throw new Error(`access to system directory "${root}" is not allowed`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const driveMatch = norm.match(/^[a-zA-Z]:(\/.*)?$/)
|
|
217
|
+
if (driveMatch) {
|
|
218
|
+
const sub = (driveMatch[1] || '/').toLowerCase()
|
|
219
|
+
if (sub === '/' || sub === '') {
|
|
220
|
+
throw new Error('access to system drive root is not allowed')
|
|
221
|
+
}
|
|
222
|
+
for (const winDir of ['/windows', '/winnt', '/system32', '/boot']) {
|
|
223
|
+
if (sub === winDir || sub.startsWith(winDir + '/')) {
|
|
224
|
+
throw new Error(`access to system directory "${winDir.slice(1)}" is not allowed`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
182
230
|
export function validateCwd(path) {
|
|
183
231
|
const value = String(path == null ? '' : path).trim()
|
|
184
232
|
if (!value) return ''
|
|
185
|
-
if (!
|
|
233
|
+
if (!posix.isAbsolute(value) && !win32.isAbsolute(value)) {
|
|
234
|
+
throw new Error('a repository path must be absolute')
|
|
235
|
+
}
|
|
186
236
|
if (value.includes('..')) throw new Error('a repository path may not contain ".."')
|
|
187
237
|
if (/[\x00-\x1f]/.test(value)) throw new Error('a repository path may not contain control characters')
|
|
188
238
|
if (value.length > 500) throw new Error('a repository path is too long')
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
}
|
|
239
|
+
|
|
240
|
+
checkDisallowed(value)
|
|
241
|
+
|
|
194
242
|
let realPath = value
|
|
195
243
|
try {
|
|
196
244
|
realPath = realpathSync(value)
|
|
197
245
|
} catch {
|
|
198
246
|
realPath = value
|
|
199
247
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
throw new Error(`access to system directory "${root}" is not allowed`)
|
|
203
|
-
}
|
|
204
|
-
}
|
|
248
|
+
checkDisallowed(realPath)
|
|
249
|
+
|
|
205
250
|
return value
|
|
206
251
|
}
|
|
207
252
|
|
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.
|
|
3
|
+
"version": "0.6.1",
|
|
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",
|