@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 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({ phase: 'ready', text: payload.diff || '' }))
358
- .catch((err) => setDiff({ phase: 'error', text: String((err && err.message) || err) }))
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
- ? React.createElement('pre', { className: 'gho-scm-diff' },
553
- diff.phase === 'loading' ? t('scmLoading') : (diff.text || t('scmDiffEmpty')))
641
+ ? (diff.phase === 'loading'
642
+ ? React.createElement('pre', { className: 'gho-scm-diff' }, t('scmLoading'))
643
+ : (diff.hunks && diff.hunks.length > 0
644
+ ? React.createElement('div', { className: 'gho-diff-container' },
645
+ diff.hunks.map((hunk, hIdx) => React.createElement('div', { key: hIdx, className: 'gho-diff-hunk' },
646
+ React.createElement('div', { className: 'gho-diff-hunk-head' },
647
+ React.createElement('span', { className: 'gho-diff-hunk-title' }, hunk.header),
648
+ button(
649
+ selected.staged ? t('scmUnstageHunk') : t('scmStageHunk'),
650
+ () => runAction(selected.staged ? 'unstage_patch' : 'stage_patch', { patch: hunk.patch }),
651
+ ),
652
+ ),
653
+ React.createElement('div', { className: 'gho-diff-hunk-body' },
654
+ hunk.lines.map((line, lIdx) => React.createElement('div', {
655
+ key: lIdx,
656
+ className: `gho-diff-line gho-diff-${line.type}`,
657
+ },
658
+ React.createElement('span', { className: 'gho-diff-char' }, line.type === 'add' ? '+' : (line.type === 'del' ? '-' : ' ')),
659
+ React.createElement('span', { className: 'gho-diff-text' }, line.type === 'add' || line.type === 'del' ? line.text.slice(1) : line.text),
660
+ )),
661
+ ),
662
+ )),
663
+ )
664
+ : React.createElement('pre', { className: 'gho-scm-diff' }, diff.text || t('scmDiffEmpty'))
665
+ )
666
+ )
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-row' },
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
- try {
49
- const { stdout, stderr } = await execFileAsync('git', args, {
50
- cwd: cwd || process.cwd(),
51
- env: { ...process.env, ...(env || {}) },
52
- timeout: typeof timeoutMs === 'number' ? timeoutMs : (typeof timeout === 'number' ? timeout : undefined),
53
- maxBuffer: 8 * 1024 * 1024,
54
- })
55
- return { code: 0, stdout, stderr }
56
- } catch (err) {
57
- return {
58
- code: typeof err.code === 'number' ? err.code : 1,
59
- stdout: err.stdout || '',
60
- stderr: err.stderr || String((err && err.message) || err),
47
+ return async (args, { cwd, env, timeoutMs, timeout, input } = {}) => {
48
+ return new Promise((resolve) => {
49
+ const child = execFile(
50
+ 'git',
51
+ args,
52
+ {
53
+ cwd: cwd || process.cwd(),
54
+ env: { ...process.env, ...(env || {}) },
55
+ timeout: typeof timeoutMs === 'number' ? timeoutMs : (typeof timeout === 'number' ? timeout : undefined),
56
+ maxBuffer: 8 * 1024 * 1024,
57
+ },
58
+ (err, stdout, stderr) => {
59
+ if (err) {
60
+ resolve({
61
+ code: typeof err.code === 'number' ? err.code : 1,
62
+ stdout: stdout || (err && err.stdout) || '',
63
+ stderr: stderr || (err && err.stderr) || String((err && err.message) || err),
64
+ })
65
+ } else {
66
+ resolve({
67
+ code: 0,
68
+ stdout: stdout || '',
69
+ stderr: stderr || '',
70
+ })
71
+ }
72
+ }
73
+ )
74
+ if (input !== undefined && input !== null && child.stdin) {
75
+ child.stdin.on('error', () => {})
76
+ child.stdin.end(input)
61
77
  }
62
- }
78
+ })
63
79
  }
64
80
  }
65
81