@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 +175 -4
- package/lib/events.js +201 -0
- package/lib/index.js +31 -15
- package/lib/routes.js +73 -3
- package/lib/scm-write.js +27 -0
- 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,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 =
|
|
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.
|
|
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",
|