@goodandready/dsh-github-ops 0.4.1 → 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/CHANGELOG.md +7 -0
- package/lib/client.js +259 -7
- package/lib/events.js +201 -0
- package/lib/index.js +31 -15
- package/lib/routes.js +144 -6
- package/lib/scm-write.js +58 -1
- package/lib/scm.js +290 -0
- package/lib/tools/matrix.js +229 -0
- package/lib/tools/register-collab.js +27 -0
- package/lib/tools/register-ops.js +28 -0
- package/lib/tools/triage.js +213 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- **Organization Repositories Status Matrix** (#48): Added `gh_matrix_status` tool and dedicated `/dsh-github-ops/matrix` route (plus `what=matrix` in sidebar panel) collecting releases/tags, npm published versions, CI workflow run status, and open issue counts with in-memory TTL caching and graceful degradation. Added interactive Organization Matrix mode and table with status badges in the sidebar panel.
|
|
7
|
+
- **External GitHub Issues Triage to Gitea Registry** (#66): Added `gh_triage_external_issues` tool scanning public GitHub repositories for open issues, cross-referencing against Gitea issue tracker via issue URLs and markers, generating canonical 5-section draft issues with priority and type labels, and providing optional automated creation via `sync: true` and `confirm: true`.
|
|
8
|
+
- **Visual Merge Conflict Resolution in Source Control** (#49): Added merge conflict hunk parsing (`parseConflictBlocks`, `hasConflicts`, `resolveConflictContent`) supporting both 2-way and diff3 markers. Added `resolveConflict` action with auto-staging (`git add`), conflict detection in SCM route, and interactive diff banner in the Web UI with one-click actions: "Accept Current (Ours)", "Accept Incoming (Theirs)", and "Accept Both".
|
|
9
|
+
|
|
3
10
|
## 0.4.1
|
|
4
11
|
|
|
5
12
|
### Fixed
|
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,12 +415,26 @@ window.__ModuleLoader__.load({
|
|
|
350
415
|
|
|
351
416
|
const openDiff = (file, staged) => {
|
|
352
417
|
setSelected({ path: file.path, staged })
|
|
353
|
-
setDiff({ phase: 'loading', text: '' })
|
|
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
|
-
.then((payload) => setDiff({
|
|
358
|
-
|
|
422
|
+
.then((payload) => setDiff({
|
|
423
|
+
phase: 'ready',
|
|
424
|
+
text: payload.diff || '',
|
|
425
|
+
hunks: payload.hunks || [],
|
|
426
|
+
staged,
|
|
427
|
+
hasConflicts: Boolean(payload.hasConflicts || (file.status === 'U') || (payload.diff && payload.diff.includes('<<<<<<<'))),
|
|
428
|
+
conflicts: payload.conflicts || [],
|
|
429
|
+
}))
|
|
430
|
+
.catch((err) => setDiff({ phase: 'error', text: String((err && err.message) || err), hunks: [], hasConflicts: false, staged }))
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const resolveConflict = (choice, blockIndex) => {
|
|
434
|
+
if (!selected || !selected.path) return
|
|
435
|
+
const args = { path: selected.path, choice }
|
|
436
|
+
if (blockIndex !== undefined && blockIndex !== null) args.blockIndex = blockIndex
|
|
437
|
+
runAction('resolveConflict', args)
|
|
359
438
|
}
|
|
360
439
|
|
|
361
440
|
const openStashDiff = (stashRef) => {
|
|
@@ -548,10 +627,46 @@ window.__ModuleLoader__.load({
|
|
|
548
627
|
group(t('scmStaged'), files.staged, true),
|
|
549
628
|
group(t('scmChanges'), files.changes, false),
|
|
550
629
|
group(t('scmUntracked'), files.untracked, false),
|
|
630
|
+
selected && diff.hasConflicts
|
|
631
|
+
? React.createElement('div', { className: 'gho-conflict-bar' },
|
|
632
|
+
React.createElement('span', { className: 'gho-conflict-title' }, t('scmConflictDetected')),
|
|
633
|
+
React.createElement('div', { className: 'gho-conflict-actions' },
|
|
634
|
+
button(t('scmAcceptCurrent'), () => resolveConflict('ours')),
|
|
635
|
+
button(t('scmAcceptIncoming'), () => resolveConflict('theirs')),
|
|
636
|
+
button(t('scmAcceptBoth'), () => resolveConflict('both')),
|
|
637
|
+
),
|
|
638
|
+
)
|
|
639
|
+
: null,
|
|
551
640
|
selected
|
|
552
|
-
?
|
|
553
|
-
|
|
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
|
+
)
|
|
554
667
|
: null,
|
|
668
|
+
graphBar,
|
|
669
|
+
graphView,
|
|
555
670
|
branchBar,
|
|
556
671
|
stashBar,
|
|
557
672
|
worktreeBar,
|
|
@@ -559,6 +674,17 @@ window.__ModuleLoader__.load({
|
|
|
559
674
|
React.createElement('div', { className: 'gho-scm-meta' },
|
|
560
675
|
React.createElement('span', null, `${t('scmTags')}: ${(payload.tags || []).join(', ') || '—'}`),
|
|
561
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,
|
|
562
688
|
)
|
|
563
689
|
}
|
|
564
690
|
|
|
@@ -584,6 +710,45 @@ window.__ModuleLoader__.load({
|
|
|
584
710
|
}, [])
|
|
585
711
|
|
|
586
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
|
+
|
|
587
752
|
|
|
588
753
|
const payload = state.payload
|
|
589
754
|
const ahead = payload && payload.isRepository && payload.upstream ? (payload.ahead || 0) : 0
|
|
@@ -707,6 +872,7 @@ window.__ModuleLoader__.load({
|
|
|
707
872
|
if (!repo) return
|
|
708
873
|
if (tab === 'tree') loadTree('')
|
|
709
874
|
else if (tab === 'inbox') loadInbox()
|
|
875
|
+
else if (tab === 'matrix') loadMatrix()
|
|
710
876
|
else loadList(tab)
|
|
711
877
|
}, [repo, tab, loadTree, loadList, loadInbox])
|
|
712
878
|
|
|
@@ -728,7 +894,11 @@ window.__ModuleLoader__.load({
|
|
|
728
894
|
)
|
|
729
895
|
|
|
730
896
|
const switcher = React.createElement('div', { className: 'gho-panel' },
|
|
731
|
-
React.createElement('div', { className: 'gho-panel-
|
|
897
|
+
React.createElement('div', { className: 'gho-panel-tabs' },
|
|
898
|
+
React.createElement('button', { type: 'button', className: 'gho-panel-tab', 'aria-pressed': switchMode === 'repos', onClick: () => setSwitchMode('repos') }, t('panelRepo')),
|
|
899
|
+
React.createElement('button', { type: 'button', className: 'gho-panel-tab', 'aria-pressed': switchMode === 'matrix', onClick: () => { setSwitchMode('matrix'); if (matrix.phase === 'idle') loadMatrix() } }, t('panelMatrix')),
|
|
900
|
+
),
|
|
901
|
+
switchMode === 'matrix' ? matrixView : React.createElement('div', { className: 'gho-panel-row' },
|
|
732
902
|
React.createElement('input', {
|
|
733
903
|
className: 'gho-panel-input',
|
|
734
904
|
placeholder: t('panelSearch'),
|
|
@@ -778,6 +948,47 @@ window.__ModuleLoader__.load({
|
|
|
778
948
|
file ? React.createElement('pre', { className: 'gho-panel-pre' }, file.truncated ? `${file.text}\n…` : (file.text || t('panelEmpty'))) : null,
|
|
779
949
|
)
|
|
780
950
|
|
|
951
|
+
const matrixView = (() => {
|
|
952
|
+
if (matrix.phase === 'loading') return React.createElement('div', { className: 'gho-panel-muted' }, t('panelLoading'))
|
|
953
|
+
if (!matrix.list.length) return React.createElement('div', { className: 'gho-panel-muted' }, t('panelMatrixEmpty'))
|
|
954
|
+
return React.createElement('table', { className: 'gho-matrix-table' },
|
|
955
|
+
React.createElement('thead', null,
|
|
956
|
+
React.createElement('tr', null,
|
|
957
|
+
React.createElement('th', { className: 'gho-matrix-th' }, t('panelMatrixRepo')),
|
|
958
|
+
React.createElement('th', { className: 'gho-matrix-th' }, t('panelMatrixStatus')),
|
|
959
|
+
React.createElement('th', { className: 'gho-matrix-th' }, t('panelMatrixRelease')),
|
|
960
|
+
React.createElement('th', { className: 'gho-matrix-th' }, t('panelMatrixNpm')),
|
|
961
|
+
React.createElement('th', { className: 'gho-matrix-th' }, t('panelMatrixCi')),
|
|
962
|
+
React.createElement('th', { className: 'gho-matrix-th' }, t('panelMatrixIssues')),
|
|
963
|
+
React.createElement('th', { className: 'gho-matrix-th' }, ''),
|
|
964
|
+
),
|
|
965
|
+
),
|
|
966
|
+
React.createElement('tbody', null,
|
|
967
|
+
matrix.list.map((row) => React.createElement('tr', { key: row.fullName || row.name },
|
|
968
|
+
React.createElement('td', { className: 'gho-matrix-td' },
|
|
969
|
+
React.createElement('button', {
|
|
970
|
+
type: 'button',
|
|
971
|
+
className: 'gho-link',
|
|
972
|
+
style: { margin: 0, fontWeight: 600, textAlign: 'left' },
|
|
973
|
+
title: t('panelMatrixSelect'),
|
|
974
|
+
onClick: () => { setRepo(row.fullName || row.name); setTab('tree'); setFile(null) },
|
|
975
|
+
}, row.name || row.fullName),
|
|
976
|
+
),
|
|
977
|
+
React.createElement('td', { className: 'gho-matrix-td' },
|
|
978
|
+
React.createElement('span', { className: `gho-badge gho-badge-${row.status || 'active'}` }, row.status || 'active'),
|
|
979
|
+
),
|
|
980
|
+
React.createElement('td', { className: 'gho-matrix-td' }, (row.release && row.release.tag) || '—'),
|
|
981
|
+
React.createElement('td', { className: 'gho-matrix-td' }, (row.npm && row.npm.version) || '—'),
|
|
982
|
+
React.createElement('td', { className: 'gho-matrix-td' }, (row.ci && (row.ci.conclusion || row.ci.status)) || '—'),
|
|
983
|
+
React.createElement('td', { className: 'gho-matrix-td' }, String(row.openIssuesCount || 0)),
|
|
984
|
+
React.createElement('td', { className: 'gho-matrix-td' },
|
|
985
|
+
row.htmlUrl ? React.createElement('a', { className: 'gho-link', href: row.htmlUrl, target: '_blank', rel: 'noreferrer' }, '↗') : null,
|
|
986
|
+
),
|
|
987
|
+
)),
|
|
988
|
+
),
|
|
989
|
+
)
|
|
990
|
+
})()
|
|
991
|
+
|
|
781
992
|
return React.createElement('div', { className: 'gho-panel' },
|
|
782
993
|
header,
|
|
783
994
|
React.createElement('div', { className: 'gho-panel-tabs' },
|
|
@@ -786,10 +997,11 @@ window.__ModuleLoader__.load({
|
|
|
786
997
|
tabButton('pulls', t('panelPulls')),
|
|
787
998
|
tabButton('runs', t('panelRuns')),
|
|
788
999
|
tabButton('inbox', t('panelInbox')),
|
|
1000
|
+
tabButton('matrix', t('panelMatrix')),
|
|
789
1001
|
React.createElement('button', { type: 'button', className: 'gho-panel-tab', onClick: () => { setRepo(''); setFile(null) } }, t('panelRepo')),
|
|
790
1002
|
),
|
|
791
1003
|
error ? React.createElement('div', { className: 'gho-panel-muted' }, error) : null,
|
|
792
|
-
tab === 'tree' ? treeView : list,
|
|
1004
|
+
tab === 'tree' ? treeView : (tab === 'matrix' ? matrixView : list),
|
|
793
1005
|
)
|
|
794
1006
|
}
|
|
795
1007
|
|
|
@@ -818,6 +1030,18 @@ window.__ModuleLoader__.load({
|
|
|
818
1030
|
'.gho-status-value{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}',
|
|
819
1031
|
'.gho-status-head{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
820
1032
|
'.gho-status-bad{color:var(--dsw-alias-label-error)}',
|
|
1033
|
+
'.gho-badge{display:inline-block;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:600;text-transform:uppercase}',
|
|
1034
|
+
'.gho-badge-healthy{background:var(--dsw-alias-color-success-bg,rgba(46,160,67,0.15));color:var(--dsw-alias-color-success,#2ea043)}',
|
|
1035
|
+
'.gho-badge-failing{background:var(--dsw-alias-color-danger-bg,rgba(248,81,73,0.15));color:var(--dsw-alias-label-error,#f85149)}',
|
|
1036
|
+
'.gho-badge-building{background:var(--dsw-alias-color-warning-bg,rgba(210,153,34,0.15));color:var(--dsw-alias-color-warning,#d29922)}',
|
|
1037
|
+
'.gho-badge-released{background:var(--dsw-alias-color-info-bg,rgba(56,139,253,0.15));color:var(--dsw-alias-color-info,#388bfd)}',
|
|
1038
|
+
'.gho-badge-active{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-secondary)}',
|
|
1039
|
+
'.gho-matrix-table{width:100%;border-collapse:collapse;font-size:11px;margin-top:6px}',
|
|
1040
|
+
'.gho-matrix-th{text-align:left;padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}',
|
|
1041
|
+
'.gho-matrix-td{padding:5px 6px;border-bottom:1px solid var(--dsw-alias-border-l2);vertical-align:middle}',
|
|
1042
|
+
'.gho-conflict-bar{display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--dsw-alias-color-warning-bg,rgba(210,153,34,0.15));border:1px solid var(--dsw-alias-color-warning,#d29922);border-radius:8px;margin-bottom:6px}',
|
|
1043
|
+
'.gho-conflict-title{font-weight:600;font-size:12px;color:var(--dsw-alias-label-primary)}',
|
|
1044
|
+
'.gho-conflict-actions{display:flex;gap:6px;margin-left:auto}',
|
|
821
1045
|
'.gho-link{appearance:none;border:0;background:0 0;font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary);text-decoration:underline;cursor:pointer;padding:0;margin-left:auto}',
|
|
822
1046
|
'.gho-scm{display:flex;flex-direction:column;gap:12px;padding:4px 2px;font-size:13px;color:var(--dsw-alias-label-primary)}',
|
|
823
1047
|
'.gho-scm-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
|
|
@@ -865,6 +1089,34 @@ window.__ModuleLoader__.load({
|
|
|
865
1089
|
'.gho-note{font-size:12px;color:var(--dsw-alias-label-secondary);margin-right:auto}',
|
|
866
1090
|
'.gho-chev{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s}',
|
|
867
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}',
|
|
868
1120
|
].join('\n')
|
|
869
1121
|
document.head.appendChild(style)
|
|
870
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
|
|