@goodandready/dsh-github-ops 0.6.5 → 0.6.7
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 +203 -6
- package/lib/routes.js +36 -3
- package/lib/scm-write.js +29 -1
- package/lib/scm.js +35 -1
- package/lib/tools/actions.js +145 -0
- package/lib/tools/insights.js +17 -4
- package/lib/tools/register-insights.js +30 -0
- package/lib/tools/register-ops.js +14 -0
- package/lib/tools/security.js +204 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -127,6 +127,15 @@ window.__ModuleLoader__.load({
|
|
|
127
127
|
panelPulls: 'Pull requests',
|
|
128
128
|
panelRuns: 'Actions',
|
|
129
129
|
panelInbox: 'Inbox',
|
|
130
|
+
panelSecurity: 'Security',
|
|
131
|
+
panelSecurityEmpty: 'No security alerts found.',
|
|
132
|
+
panelSecurityDisabled: 'Security features are disabled or token lacks security scope.',
|
|
133
|
+
panelDiagnose: 'Diagnose',
|
|
134
|
+
panelDiagnosing: 'Diagnosing…',
|
|
135
|
+
panelDiagnoseAsk: 'Ask Agent to Diagnose & Fix',
|
|
136
|
+
panelDiagnoseCopied: 'Prompt copied to clipboard!',
|
|
137
|
+
panelSteps: 'Steps',
|
|
138
|
+
panelFailedStep: 'Failed Step',
|
|
130
139
|
panelOpen: 'Open in browser',
|
|
131
140
|
panelEmpty: 'Nothing here yet.',
|
|
132
141
|
panelLoading: 'Loading…',
|
|
@@ -138,6 +147,11 @@ window.__ModuleLoader__.load({
|
|
|
138
147
|
scmStageHunk: 'Stage Hunk',
|
|
139
148
|
scmUnstageHunk: 'Unstage Hunk',
|
|
140
149
|
scmHunk: 'Hunk',
|
|
150
|
+
scmCherryPick: 'Cherry-pick',
|
|
151
|
+
scmRevert: 'Revert',
|
|
152
|
+
scmSubmodules: 'Submodules',
|
|
153
|
+
scmSubmoduleUpdate: 'Update',
|
|
154
|
+
scmSubmoduleUpdateAll: 'Update All Submodules',
|
|
141
155
|
scmGraphTitle: 'Git Graph',
|
|
142
156
|
scmGraphRefresh: 'Refresh Graph',
|
|
143
157
|
scmGraphEmpty: 'No commit history available.',
|
|
@@ -240,6 +254,15 @@ window.__ModuleLoader__.load({
|
|
|
240
254
|
panelPulls: 'Pull request',
|
|
241
255
|
panelRuns: 'Actions',
|
|
242
256
|
panelInbox: '收件箱',
|
|
257
|
+
panelSecurity: '安全',
|
|
258
|
+
panelSecurityEmpty: '未发现安全警报。',
|
|
259
|
+
panelSecurityDisabled: '安全功能已禁用或令牌缺少安全权限。',
|
|
260
|
+
panelDiagnose: '诊断',
|
|
261
|
+
panelDiagnosing: '诊断中…',
|
|
262
|
+
panelDiagnoseAsk: '请智能体诊断并修复',
|
|
263
|
+
panelDiagnoseCopied: '提示词已复制到剪贴板!',
|
|
264
|
+
panelSteps: '步骤',
|
|
265
|
+
panelFailedStep: '失败步骤',
|
|
243
266
|
panelOpen: '在浏览器中打开',
|
|
244
267
|
panelEmpty: '这里还没有内容。',
|
|
245
268
|
panelLoading: '加载中…',
|
|
@@ -251,6 +274,11 @@ window.__ModuleLoader__.load({
|
|
|
251
274
|
scmStageHunk: '暂存代码块',
|
|
252
275
|
scmUnstageHunk: '取消暂存块',
|
|
253
276
|
scmHunk: '代码块',
|
|
277
|
+
scmCherryPick: '择优拣选',
|
|
278
|
+
scmRevert: '还原提交',
|
|
279
|
+
scmSubmodules: '子模块',
|
|
280
|
+
scmSubmoduleUpdate: '更新',
|
|
281
|
+
scmSubmoduleUpdateAll: '更新所有子模块',
|
|
254
282
|
scmGraphTitle: 'Git 提交图',
|
|
255
283
|
scmGraphRefresh: '刷新提交图',
|
|
256
284
|
scmGraphEmpty: '暂无可用的提交历史。',
|
|
@@ -566,6 +594,62 @@ window.__ModuleLoader__.load({
|
|
|
566
594
|
),
|
|
567
595
|
) : null
|
|
568
596
|
|
|
597
|
+
const graphBar = React.createElement('div', { className: 'gho-scm-bar' },
|
|
598
|
+
button(
|
|
599
|
+
showGraph ? `${t('scmGraphTitle')} ▾` : `${t('scmGraphTitle')} ▸`,
|
|
600
|
+
() => {
|
|
601
|
+
const next = !showGraph
|
|
602
|
+
setShowGraph(next)
|
|
603
|
+
if (next && graphData.phase === 'idle') loadGraph()
|
|
604
|
+
},
|
|
605
|
+
),
|
|
606
|
+
showGraph ? button(t('scmGraphRefresh'), loadGraph, { disabled: graphData.phase === 'loading' }) : null,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
const graphView = showGraph
|
|
610
|
+
? React.createElement('div', { className: 'gho-git-graph' },
|
|
611
|
+
graphData.phase === 'loading'
|
|
612
|
+
? React.createElement('div', { className: 'gho-scm-meta' }, t('scmLoading'))
|
|
613
|
+
: (graphData.entries && graphData.entries.length > 0
|
|
614
|
+
? graphData.entries.map((entry, idx) => {
|
|
615
|
+
const sha = entry.hash || entry.sha
|
|
616
|
+
const subject = entry.subject || entry.message
|
|
617
|
+
return React.createElement('div', { key: sha || idx, className: 'gho-graph-row' },
|
|
618
|
+
entry.graph ? React.createElement('span', { className: 'gho-graph-conn' }, entry.graph) : null,
|
|
619
|
+
sha ? React.createElement('span', { className: 'gho-graph-sha' }, sha.slice(0, 7)) : null,
|
|
620
|
+
entry.refs && entry.refs.length ? React.createElement('span', { className: 'gho-graph-refs' }, entry.refs.map((r, rIdx) => React.createElement('span', { key: rIdx, className: 'gho-graph-ref' }, r))) : null,
|
|
621
|
+
subject ? React.createElement('span', { className: 'gho-graph-msg' }, subject) : null,
|
|
622
|
+
sha ? React.createElement('span', { className: 'gho-scm-actions' },
|
|
623
|
+
button(
|
|
624
|
+
confirming === `cp:${sha}` ? t('scmConfirm') : t('scmCherryPick'),
|
|
625
|
+
() => armed(`cp:${sha}`, 'cherry_pick', { commit: sha, confirm: true }),
|
|
626
|
+
),
|
|
627
|
+
button(
|
|
628
|
+
confirming === `rev:${sha}` ? t('scmConfirm') : t('scmRevert'),
|
|
629
|
+
() => armed(`rev:${sha}`, 'revert', { commit: sha, confirm: true }),
|
|
630
|
+
{ warn: true },
|
|
631
|
+
),
|
|
632
|
+
) : null,
|
|
633
|
+
entry.date ? React.createElement('span', { className: 'gho-graph-meta' }, entry.date) : null,
|
|
634
|
+
)
|
|
635
|
+
})
|
|
636
|
+
: React.createElement('div', { className: 'gho-scm-meta' }, t('scmGraphEmpty'))
|
|
637
|
+
)
|
|
638
|
+
)
|
|
639
|
+
: null
|
|
640
|
+
|
|
641
|
+
const submoduleBar = (payload.submodules && payload.submodules.length > 0)
|
|
642
|
+
? React.createElement('div', { className: 'gho-scm-bar' },
|
|
643
|
+
React.createElement('span', { className: 'gho-scm-counts' }, t('scmSubmodules')),
|
|
644
|
+
button(t('scmSubmoduleUpdateAll'), () => runAction('submodule_update', {})),
|
|
645
|
+
payload.submodules.map((sm, smIdx) => React.createElement('span', { key: sm.path || smIdx, style: { display: 'inline-flex', gap: '4px', alignItems: 'center' } },
|
|
646
|
+
React.createElement('span', { className: 'gho-scm-path' }, `${sm.path} (${sm.shortCommit || ''})`),
|
|
647
|
+
React.createElement('span', { className: `gho-badge gho-badge-${sm.status === 'in-sync' ? 'healthy' : (sm.status === 'conflict' ? 'failing' : 'building')}` }, sm.status),
|
|
648
|
+
button(t('scmSubmoduleUpdate'), () => runAction('submodule_update', { path: sm.path })),
|
|
649
|
+
)),
|
|
650
|
+
)
|
|
651
|
+
: null
|
|
652
|
+
|
|
569
653
|
const worktreeBar = (payload.worktrees && payload.worktrees.length > 0)
|
|
570
654
|
? React.createElement('div', { className: 'gho-scm-bar' },
|
|
571
655
|
React.createElement('span', { className: 'gho-scm-counts' }, t('scmWorktrees')),
|
|
@@ -669,6 +753,7 @@ window.__ModuleLoader__.load({
|
|
|
669
753
|
graphView,
|
|
670
754
|
branchBar,
|
|
671
755
|
stashBar,
|
|
756
|
+
submoduleBar,
|
|
672
757
|
worktreeBar,
|
|
673
758
|
mirrorBar,
|
|
674
759
|
React.createElement('div', { className: 'gho-scm-meta' },
|
|
@@ -784,6 +869,11 @@ window.__ModuleLoader__.load({
|
|
|
784
869
|
const [items, setItems] = React.useState({ phase: 'idle', list: [] })
|
|
785
870
|
const [error, setError] = React.useState('')
|
|
786
871
|
const [loading, setLoading] = React.useState(false)
|
|
872
|
+
const [securityData, setSecurityData] = React.useState({ phase: 'idle', data: null })
|
|
873
|
+
const [diagnostics, setDiagnostics] = React.useState(null)
|
|
874
|
+
const [diagnoseLoadingId, setDiagnoseLoadingId] = React.useState(null)
|
|
875
|
+
const [expandedSteps, setExpandedSteps] = React.useState({})
|
|
876
|
+
const [copiedPrompt, setCopiedPrompt] = React.useState(false)
|
|
787
877
|
|
|
788
878
|
const call = React.useCallback((params) => {
|
|
789
879
|
if (typeof fetch !== 'function') return Promise.reject(new Error('fetch is unavailable in this client'))
|
|
@@ -823,6 +913,37 @@ window.__ModuleLoader__.load({
|
|
|
823
913
|
.catch((err) => { setItems({ phase: 'error', list: [] }); setError(String((err && err.message) || err)) })
|
|
824
914
|
}, [call, repo])
|
|
825
915
|
|
|
916
|
+
const loadSecurity = React.useCallback(() => {
|
|
917
|
+
if (!repo) return
|
|
918
|
+
setSecurityData({ phase: 'loading', data: null })
|
|
919
|
+
call({ what: 'security', repo })
|
|
920
|
+
.then((payload) => setSecurityData({ phase: 'ready', data: payload }))
|
|
921
|
+
.catch((err) => { setSecurityData({ phase: 'error', data: null }); setError(String((err && err.message) || err)) })
|
|
922
|
+
}, [call, repo])
|
|
923
|
+
|
|
924
|
+
const loadDiagnostics = (runId) => {
|
|
925
|
+
if (!repo || !runId) return
|
|
926
|
+
setDiagnoseLoadingId(runId)
|
|
927
|
+
call({ what: 'diagnose_run', repo, runId })
|
|
928
|
+
.then((res) => {
|
|
929
|
+
setDiagnostics(res)
|
|
930
|
+
if (res.failedStep) setExpandedSteps({ [res.failedStep.name]: true })
|
|
931
|
+
})
|
|
932
|
+
.catch((err) => setError(String((err && err.message) || err)))
|
|
933
|
+
.finally(() => setDiagnoseLoadingId(null))
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const askAgent = (diag) => {
|
|
937
|
+
const text = `Please diagnose and fix CI failure for repository ${repo} run #${diag.runId} (${diag.jobName || 'job'}):\nFailed Step: ${diag.failedStep ? diag.failedStep.name : 'Unknown'}\n\nTop Errors:\n` +
|
|
938
|
+
((diag.failedStep && diag.failedStep.errorHighlights) || []).map((e) => ' - ' + e).join('\n') +
|
|
939
|
+
(diag.failedStep && diag.failedStep.lines ? `\n\nStep Log excerpt:\n` + diag.failedStep.lines.slice(-30).join('\n') : '')
|
|
940
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
|
|
941
|
+
navigator.clipboard.writeText(text).catch(() => {})
|
|
942
|
+
}
|
|
943
|
+
setCopiedPrompt(true)
|
|
944
|
+
setTimeout(() => setCopiedPrompt(false), 4000)
|
|
945
|
+
}
|
|
946
|
+
|
|
826
947
|
const loadInbox = React.useCallback(() => {
|
|
827
948
|
setItems({ phase: 'loading', list: [] })
|
|
828
949
|
call({ what: 'inbox' })
|
|
@@ -835,8 +956,9 @@ window.__ModuleLoader__.load({
|
|
|
835
956
|
if (tab === 'tree') loadTree('')
|
|
836
957
|
else if (tab === 'inbox') loadInbox()
|
|
837
958
|
else if (tab === 'matrix') loadMatrix()
|
|
959
|
+
else if (tab === 'security') loadSecurity()
|
|
838
960
|
else loadList(tab)
|
|
839
|
-
}, [repo, tab, loadTree, loadList, loadInbox])
|
|
961
|
+
}, [repo, tab, loadTree, loadList, loadInbox, loadSecurity])
|
|
840
962
|
|
|
841
963
|
const openFile = (entry) => {
|
|
842
964
|
if (entry.type === 'tree') { loadTree(entry.path); return }
|
|
@@ -887,10 +1009,42 @@ window.__ModuleLoader__.load({
|
|
|
887
1009
|
const list = (() => {
|
|
888
1010
|
if (items.phase === 'loading') return React.createElement('div', { className: 'gho-panel-muted' }, t('panelLoading'))
|
|
889
1011
|
if (!items.list.length) return React.createElement('div', { className: 'gho-panel-muted' }, t('panelEmpty'))
|
|
890
|
-
return items.list.slice(0, 40).map((item, index) => React.createElement('div', {
|
|
891
|
-
React.createElement('
|
|
892
|
-
|
|
893
|
-
|
|
1012
|
+
return items.list.slice(0, 40).map((item, index) => React.createElement('div', { key: `${item.number || item.id || index}` },
|
|
1013
|
+
React.createElement('div', { className: 'gho-panel-item' },
|
|
1014
|
+
React.createElement('span', { className: 'gho-panel-file' }, item.number ? `#${item.number}` : (item.name || '')),
|
|
1015
|
+
React.createElement('span', null, item.title || item.displayTitle || item.reason || item.name || ''),
|
|
1016
|
+
tab === 'runs' ? React.createElement('button', {
|
|
1017
|
+
type: 'button',
|
|
1018
|
+
className: 'gho-diagnose-btn',
|
|
1019
|
+
style: { marginLeft: '8px' },
|
|
1020
|
+
onClick: (e) => { e.stopPropagation(); loadDiagnostics(item.id) },
|
|
1021
|
+
}, diagnoseLoadingId === item.id ? t('panelDiagnosing') : t('panelDiagnose')) : null,
|
|
1022
|
+
item.url || item.html_url ? React.createElement('a', { className: 'gho-link', href: item.url || item.html_url, target: '_blank', rel: 'noreferrer' }, '↗') : null,
|
|
1023
|
+
),
|
|
1024
|
+
diagnostics && diagnostics.runId === item.id ? React.createElement('div', { style: { padding: '8px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', margin: '4px 0 8px', background: 'var(--dsw-alias-bg-layer-2)' } },
|
|
1025
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' } },
|
|
1026
|
+
React.createElement('span', { style: { fontWeight: 600, fontSize: '12px' } }, diagnostics.summary || t('panelSteps')),
|
|
1027
|
+
React.createElement('button', {
|
|
1028
|
+
type: 'button',
|
|
1029
|
+
className: 'gho-diagnose-btn',
|
|
1030
|
+
style: { background: 'var(--dsw-alias-color-primary, #1f6feb)', color: '#fff', border: 0 },
|
|
1031
|
+
onClick: () => askAgent(diagnostics),
|
|
1032
|
+
}, copiedPrompt ? t('panelDiagnoseCopied') : t('panelDiagnoseAsk')),
|
|
1033
|
+
),
|
|
1034
|
+
(diagnostics.steps || []).map((step, sIdx) => {
|
|
1035
|
+
const isExp = expandedSteps[step.name] ?? step.hasError
|
|
1036
|
+
return React.createElement('div', { key: sIdx, className: `gho-step-folded ${step.hasError ? 'gho-step-err' : ''}`, style: { margin: '4px 0' } },
|
|
1037
|
+
React.createElement('div', {
|
|
1038
|
+
style: { display: 'flex', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 6px' },
|
|
1039
|
+
onClick: () => setExpandedSteps((prev) => ({ ...prev, [step.name]: !isExp })),
|
|
1040
|
+
},
|
|
1041
|
+
React.createElement('span', { style: { fontSize: '12px', fontWeight: step.hasError ? 600 : 400 } }, `${isExp ? '▾' : '▸'} ${step.name} (${step.lineCount} lines)`),
|
|
1042
|
+
step.hasError ? React.createElement('span', { className: 'gho-badge gho-badge-failing' }, t('panelFailedStep')) : React.createElement('span', { style: { color: 'var(--dsw-alias-color-success, #2ea043)' } }, '✓'),
|
|
1043
|
+
),
|
|
1044
|
+
isExp && step.lines && step.lines.length ? React.createElement('pre', { className: 'gho-panel-pre gho-step-lines' }, step.lines.join('\n')) : null,
|
|
1045
|
+
)
|
|
1046
|
+
}),
|
|
1047
|
+
) : null,
|
|
894
1048
|
))
|
|
895
1049
|
})()
|
|
896
1050
|
|
|
@@ -951,6 +1105,44 @@ window.__ModuleLoader__.load({
|
|
|
951
1105
|
)
|
|
952
1106
|
})()
|
|
953
1107
|
|
|
1108
|
+
const securityView = (() => {
|
|
1109
|
+
if (securityData.phase === 'loading') return React.createElement('div', { className: 'gho-panel-muted' }, t('panelLoading'))
|
|
1110
|
+
const data = securityData.data
|
|
1111
|
+
if (!data) return React.createElement('div', { className: 'gho-panel-muted' }, t('panelEmpty'))
|
|
1112
|
+
|
|
1113
|
+
const allDisabled = !data.dependabot?.enabled && !data.secretScanning?.enabled && !data.codeScanning?.enabled
|
|
1114
|
+
if (allDisabled) {
|
|
1115
|
+
return React.createElement('div', { className: 'gho-panel-muted', style: { padding: '8px 0' } },
|
|
1116
|
+
t('panelSecurityDisabled'),
|
|
1117
|
+
data.dependabot?.reason ? React.createElement('div', { style: { fontSize: '11px', marginTop: '4px' } }, data.dependabot.reason) : null,
|
|
1118
|
+
)
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const alerts = [
|
|
1122
|
+
...(data.dependabot?.alerts || []).map((a) => ({ ...a, kind: 'dependabot' })),
|
|
1123
|
+
...(data.secretScanning?.alerts || []).map((a) => ({ ...a, kind: 'secret' })),
|
|
1124
|
+
...(data.codeScanning?.alerts || []).map((a) => ({ ...a, kind: 'code' })),
|
|
1125
|
+
]
|
|
1126
|
+
|
|
1127
|
+
if (!alerts.length) {
|
|
1128
|
+
return React.createElement('div', { className: 'gho-panel-muted', style: { padding: '8px 0' } }, t('panelSecurityEmpty'))
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
return React.createElement('div', null,
|
|
1132
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px', margin: '4px 0 8px' } },
|
|
1133
|
+
React.createElement('span', { className: 'gho-badge gho-badge-active' }, `${data.totalCount || 0} Total`),
|
|
1134
|
+
data.criticalCount ? React.createElement('span', { className: 'gho-badge gho-badge-failing' }, `${data.criticalCount} Critical`) : null,
|
|
1135
|
+
data.highCount ? React.createElement('span', { className: 'gho-badge gho-badge-building' }, `${data.highCount} High`) : null,
|
|
1136
|
+
),
|
|
1137
|
+
alerts.map((alert, idx) => React.createElement('div', { key: alert.number || idx, className: 'gho-panel-item' },
|
|
1138
|
+
React.createElement('span', { className: 'gho-panel-file' }, alert.kind === 'dependabot' ? (alert.dependency?.packageName || 'pkg') : (alert.kind === 'secret' ? (alert.secretTypeDisplayName || 'Secret') : (alert.ruleId || 'CodeQL'))),
|
|
1139
|
+
React.createElement('span', { style: { flex: 1, fontSize: '12px' } }, alert.advisory?.summary || alert.message || alert.secretType || ''),
|
|
1140
|
+
React.createElement('span', { className: `gho-badge gho-badge-${(alert.advisory?.severity || alert.ruleSeverity || 'active').toLowerCase() === 'critical' ? 'failing' : 'building'}` }, alert.advisory?.severity || alert.ruleSeverity || alert.state),
|
|
1141
|
+
alert.url ? React.createElement('a', { className: 'gho-link', href: alert.url, target: '_blank', rel: 'noreferrer' }, '↗') : null,
|
|
1142
|
+
)),
|
|
1143
|
+
)
|
|
1144
|
+
})()
|
|
1145
|
+
|
|
954
1146
|
return React.createElement('div', { className: 'gho-panel' },
|
|
955
1147
|
header,
|
|
956
1148
|
React.createElement('div', { className: 'gho-panel-tabs' },
|
|
@@ -958,12 +1150,13 @@ window.__ModuleLoader__.load({
|
|
|
958
1150
|
tabButton('issues', t('panelIssues')),
|
|
959
1151
|
tabButton('pulls', t('panelPulls')),
|
|
960
1152
|
tabButton('runs', t('panelRuns')),
|
|
1153
|
+
tabButton('security', t('panelSecurity')),
|
|
961
1154
|
tabButton('inbox', t('panelInbox')),
|
|
962
1155
|
tabButton('matrix', t('panelMatrix')),
|
|
963
1156
|
React.createElement('button', { type: 'button', className: 'gho-panel-tab', onClick: () => { setRepo(''); setFile(null) } }, t('panelRepo')),
|
|
964
1157
|
),
|
|
965
1158
|
error ? React.createElement('div', { className: 'gho-panel-muted' }, error) : null,
|
|
966
|
-
tab === 'tree' ? treeView : (tab === 'matrix' ? matrixView : list),
|
|
1159
|
+
tab === 'tree' ? treeView : (tab === 'matrix' ? matrixView : (tab === 'security' ? securityView : list)),
|
|
967
1160
|
)
|
|
968
1161
|
}
|
|
969
1162
|
|
|
@@ -998,6 +1191,10 @@ window.__ModuleLoader__.load({
|
|
|
998
1191
|
'.gho-badge-building{background:var(--dsw-alias-color-warning-bg,rgba(210,153,34,0.15));color:var(--dsw-alias-color-warning,#d29922)}',
|
|
999
1192
|
'.gho-badge-released{background:var(--dsw-alias-color-info-bg,rgba(56,139,253,0.15));color:var(--dsw-alias-color-info,#388bfd)}',
|
|
1000
1193
|
'.gho-badge-active{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-secondary)}',
|
|
1194
|
+
'.gho-step-folded{border-radius:6px;background:var(--dsw-alias-bg-layer-2);margin-bottom:4px}',
|
|
1195
|
+
'.gho-step-err{border-left:3px solid var(--dsw-alias-label-error,#f85149);background:rgba(248,81,73,0.08)}',
|
|
1196
|
+
'.gho-step-lines{max-height:220px;overflow-y:auto;font-size:11px;padding:6px;margin:2px 0 6px}',
|
|
1197
|
+
'.gho-diagnose-btn{font-size:11px;padding:2px 6px;border-radius:4px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:transparent}',
|
|
1001
1198
|
'.gho-matrix-table{width:100%;border-collapse:collapse;font-size:11px;margin-top:6px}',
|
|
1002
1199
|
'.gho-matrix-th{text-align:left;padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}',
|
|
1003
1200
|
'.gho-matrix-td{padding:5px 6px;border-bottom:1px solid var(--dsw-alias-border-l2);vertical-align:middle}',
|
package/lib/routes.js
CHANGED
|
@@ -10,11 +10,12 @@ import { join as joinPath } from 'node:path'
|
|
|
10
10
|
|
|
11
11
|
import { statusPayload, isTrustedRequest, isTrustedWriteRequest, getRejectionStatus, getWriteRejectionStatus, 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, parseDiffHunks, parseGitGraph } from './scm.js'
|
|
13
|
+
import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseSubmodules, parseCount, scmPayload, mergeStateFrom, hasConflicts, parseConflictBlocks, parseDiffHunks, parseGitGraph } from './scm.js'
|
|
14
14
|
import { SseEventManager } from './events.js'
|
|
15
15
|
import { encodeRepoPath } from './github.js'
|
|
16
16
|
import { listIssues } from './tools/issues.js'
|
|
17
|
-
import { listRuns } from './tools/actions.js'
|
|
17
|
+
import { listRuns, diagnoseRun } from './tools/actions.js'
|
|
18
|
+
import { listSecurityAlerts } from './tools/security.js'
|
|
18
19
|
import { listNotifications, repoTreeView } from './tools/panel-data.js'
|
|
19
20
|
import { mirrorStatus, syncMirror } from './tools/mirror.js'
|
|
20
21
|
import { matrixStatus } from './tools/matrix.js'
|
|
@@ -220,6 +221,25 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
220
221
|
stderr: String(answer.stderr || '').slice(0, 4000),
|
|
221
222
|
})
|
|
222
223
|
if (answer.code !== 0) {
|
|
224
|
+
const gitDirAnswer = await git(['rev-parse', '--absolute-git-dir'], { cwd })
|
|
225
|
+
const gitDir = gitDirAnswer.code === 0 ? gitDirAnswer.stdout.trim() : joinPath(cwd, '.git')
|
|
226
|
+
const merge = mergeStateFrom({
|
|
227
|
+
mergeHead: existsSync(joinPath(gitDir, 'MERGE_HEAD')),
|
|
228
|
+
rebaseDir: existsSync(joinPath(gitDir, 'rebase-merge')) || existsSync(joinPath(gitDir, 'rebase-apply')),
|
|
229
|
+
cherryPickHead: existsSync(joinPath(gitDir, 'CHERRY_PICK_HEAD')),
|
|
230
|
+
revertHead: existsSync(joinPath(gitDir, 'REVERT_HEAD')),
|
|
231
|
+
})
|
|
232
|
+
if (merge.inProgress) {
|
|
233
|
+
return sendJson(res, 200, {
|
|
234
|
+
ok: false,
|
|
235
|
+
action: built.action,
|
|
236
|
+
summary: built.summary,
|
|
237
|
+
steps,
|
|
238
|
+
mergeState: merge,
|
|
239
|
+
hasConflicts: true,
|
|
240
|
+
error: `${built.action} encountered merge conflicts`,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
223
243
|
return sendJson(res, 200, { ok: false, action: built.action, summary: built.summary, steps, error: 'git refused the step' })
|
|
224
244
|
}
|
|
225
245
|
}
|
|
@@ -284,12 +304,13 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
284
304
|
ahead = aheadAnswer.code === 0 ? parseCount(aheadAnswer.stdout) : null
|
|
285
305
|
behind = behindAnswer.code === 0 ? parseCount(behindAnswer.stdout) : null
|
|
286
306
|
}
|
|
287
|
-
const [statusAnswer, branchesAnswer, tagsAnswer, stashesAnswer, worktreesAnswer] = await Promise.all([
|
|
307
|
+
const [statusAnswer, branchesAnswer, tagsAnswer, stashesAnswer, worktreesAnswer, submodulesAnswer] = await Promise.all([
|
|
288
308
|
git(['status', '--porcelain=v1'], { cwd }),
|
|
289
309
|
git(['branch', '--format=%(refname:short)\t%(HEAD)'], { cwd }),
|
|
290
310
|
git(['tag', '--list'], { cwd }),
|
|
291
311
|
git(['stash', 'list', '--format=%gd\t%gs'], { cwd }),
|
|
292
312
|
git(['worktree', 'list', '--porcelain'], { cwd }),
|
|
313
|
+
git(['submodule', 'status'], { cwd }),
|
|
293
314
|
])
|
|
294
315
|
const gitDirAnswer = await git(['rev-parse', '--absolute-git-dir'], { cwd })
|
|
295
316
|
const gitDir = gitDirAnswer.code === 0 ? gitDirAnswer.stdout.trim() : joinPath(cwd, '.git')
|
|
@@ -310,6 +331,7 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
310
331
|
tags: tagsAnswer.code === 0 ? tagsAnswer.stdout.split('\n').map((t) => t.trim()).filter(Boolean).slice(0, 50) : [],
|
|
311
332
|
stashes: parseStashes(stashesAnswer.code === 0 ? stashesAnswer.stdout : ''),
|
|
312
333
|
worktrees: parseWorktrees(worktreesAnswer.code === 0 ? worktreesAnswer.stdout : ''),
|
|
334
|
+
submodules: parseSubmodules(submodulesAnswer.code === 0 ? submodulesAnswer.stdout : ''),
|
|
313
335
|
merge,
|
|
314
336
|
}))
|
|
315
337
|
} catch (err) {
|
|
@@ -391,6 +413,17 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
391
413
|
const result = await listRuns(github, { ...repo, limit: 20 })
|
|
392
414
|
return sendJson(res, 200, { configured: true, items: result.runs })
|
|
393
415
|
}
|
|
416
|
+
if (what === 'security') {
|
|
417
|
+
const severity = url.searchParams.get('severity') || undefined
|
|
418
|
+
const result = await listSecurityAlerts(github, { ...repo, state, severity, limit: 50 })
|
|
419
|
+
return sendJson(res, 200, { configured: true, ...result })
|
|
420
|
+
}
|
|
421
|
+
if (what === 'diagnose_run') {
|
|
422
|
+
const runId = Number(url.searchParams.get('runId'))
|
|
423
|
+
const jobId = url.searchParams.get('jobId') ? Number(url.searchParams.get('jobId')) : undefined
|
|
424
|
+
const result = await diagnoseRun(github, { ...repo, runId, jobId })
|
|
425
|
+
return sendJson(res, 200, { configured: true, ...result })
|
|
426
|
+
}
|
|
394
427
|
if (what === 'inbox') {
|
|
395
428
|
const result = await listNotifications(github, { limit: 30 })
|
|
396
429
|
return sendJson(res, 200, { configured: true, ...result })
|
package/lib/scm-write.js
CHANGED
|
@@ -17,6 +17,15 @@ const BRANCH_RE = /^(?!-)(?!.*\.\.)[A-Za-z0-9._/-]{1,120}$/
|
|
|
17
17
|
/** A repository-relative path: no absolute paths, no traversal, no control characters. */
|
|
18
18
|
const PATH_RE = /^(?!-)(?!\/)(?!.*\.\.)[^\u0000-\u001f]{1,500}$/
|
|
19
19
|
|
|
20
|
+
/** A commit sha: 7 to 40 hexadecimal characters. */
|
|
21
|
+
const SHA_RE = /^[0-9a-fA-F]{7,40}$/
|
|
22
|
+
|
|
23
|
+
export function validateCommitSha(sha) {
|
|
24
|
+
const value = String(sha == null ? '' : sha).trim()
|
|
25
|
+
if (!SHA_RE.test(value)) throw new Error(`refusing an invalid commit hash: "${String(sha).slice(0, 40)}"`)
|
|
26
|
+
return value
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
export function validateBranch(name) {
|
|
21
30
|
const value = String(name == null ? '' : name).trim()
|
|
22
31
|
if (!BRANCH_RE.test(value)) throw new Error(`refusing an unsafe branch name: "${String(name).slice(0, 60)}"`)
|
|
@@ -175,12 +184,30 @@ export function buildAction(action, args = {}) {
|
|
|
175
184
|
step.push(path)
|
|
176
185
|
return result(name, [step], destructive, `remove worktree at ${path}`)
|
|
177
186
|
}
|
|
187
|
+
case 'cherryPick':
|
|
188
|
+
case 'cherry_pick': {
|
|
189
|
+
const sha = validateCommitSha(args.commit || args.sha)
|
|
190
|
+
return result('cherry_pick', [['cherry-pick', sha]], destructive, `cherry-pick ${sha}`)
|
|
191
|
+
}
|
|
192
|
+
case 'revert': {
|
|
193
|
+
const sha = validateCommitSha(args.commit || args.sha)
|
|
194
|
+
const noEdit = args.noEdit !== false
|
|
195
|
+
const step = noEdit ? ['revert', '--no-edit', sha] : ['revert', sha]
|
|
196
|
+
return result('revert', [step], destructive, `revert ${sha}`)
|
|
197
|
+
}
|
|
198
|
+
case 'submoduleUpdate':
|
|
199
|
+
case 'submodule_update': {
|
|
200
|
+
const path = args.path ? validatePath(args.path) : ''
|
|
201
|
+
const step = ['submodule', 'update', '--init', '--recursive']
|
|
202
|
+
if (path) step.push('--', path)
|
|
203
|
+
return result('submodule_update', [step], false, `update submodules${path ? ` in ${path}` : ''}`)
|
|
204
|
+
}
|
|
178
205
|
default:
|
|
179
206
|
throw new Error(`unknown action: "${name.slice(0, 40)}"`)
|
|
180
207
|
}
|
|
181
208
|
}
|
|
182
209
|
|
|
183
|
-
const DESTRUCTIVE = new Set(['discard', 'branchDelete', 'stashDrop', 'stash_drop', 'abort', 'worktreeRemove'])
|
|
210
|
+
const DESTRUCTIVE = new Set(['discard', 'branchDelete', 'stashDrop', 'stash_drop', 'abort', 'worktreeRemove', 'cherry_pick', 'cherryPick', 'revert'])
|
|
184
211
|
|
|
185
212
|
function result(action, steps, destructive, summary) {
|
|
186
213
|
return { action, steps, mutating: true, destructive, needsConfirm: destructive, summary }
|
|
@@ -194,6 +221,7 @@ export const ACTIONS = Object.freeze([
|
|
|
194
221
|
'worktreeAdd', 'worktreeRemove',
|
|
195
222
|
'continue', 'abort',
|
|
196
223
|
'resolveConflict',
|
|
224
|
+
'cherry_pick', 'revert', 'submodule_update',
|
|
197
225
|
])
|
|
198
226
|
|
|
199
227
|
/** The verbs the panel offers for an unfinished operation. */
|
package/lib/scm.js
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
export function parseSubmodules(stdout) {
|
|
2
|
+
if (!stdout || typeof stdout !== "string") return []
|
|
3
|
+
const lines = stdout.split("\n")
|
|
4
|
+
const submodules = []
|
|
5
|
+
for (const rawLine of lines) {
|
|
6
|
+
const line = rawLine.trimEnd()
|
|
7
|
+
if (!line) continue
|
|
8
|
+
const prefix = line[0]
|
|
9
|
+
let status = "in-sync"
|
|
10
|
+
if (prefix === "-") status = "uninitialized"
|
|
11
|
+
else if (prefix === "+") status = "modified"
|
|
12
|
+
else if (prefix === "U") status = "conflict"
|
|
13
|
+
|
|
14
|
+
const rest = (prefix === " " || prefix === "-" || prefix === "+" || prefix === "U") ? line.slice(1).trim() : line.trim()
|
|
15
|
+
const parts = rest.split(/\s+/)
|
|
16
|
+
const commit = parts[0] || ""
|
|
17
|
+
const path = parts[1] || ""
|
|
18
|
+
const describeMatch = rest.match(/\(([^)]+)\)/)
|
|
19
|
+
const describe = describeMatch ? describeMatch[1] : null
|
|
20
|
+
|
|
21
|
+
if (commit && path) {
|
|
22
|
+
submodules.push({
|
|
23
|
+
status,
|
|
24
|
+
commit,
|
|
25
|
+
shortCommit: commit.slice(0, 7),
|
|
26
|
+
path,
|
|
27
|
+
describe,
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return submodules
|
|
32
|
+
}
|
|
33
|
+
|
|
1
34
|
// Reading a working copy, for the Source Control tab.
|
|
2
35
|
//
|
|
3
36
|
// The parsers are pure: they take the text git prints and return a structure. That keeps the
|
|
@@ -123,7 +156,7 @@ export function parseCount(text) {
|
|
|
123
156
|
* ahead/behind, and that is reported as such rather than as an error.
|
|
124
157
|
*/
|
|
125
158
|
export function scmPayload({
|
|
126
|
-
cwd, branch, upstream, ahead, behind, status, branches, tags, stashes, worktrees, merge, error,
|
|
159
|
+
cwd, branch, upstream, ahead, behind, status, branches, tags, stashes, worktrees, submodules, merge, error,
|
|
127
160
|
} = {}) {
|
|
128
161
|
return {
|
|
129
162
|
cwd: cwd || '',
|
|
@@ -137,6 +170,7 @@ export function scmPayload({
|
|
|
137
170
|
tags: Array.isArray(tags) ? tags : [],
|
|
138
171
|
stashes: Array.isArray(stashes) ? stashes : [],
|
|
139
172
|
worktrees: Array.isArray(worktrees) ? worktrees : [],
|
|
173
|
+
submodules: Array.isArray(submodules) ? submodules : [],
|
|
140
174
|
mergeState: merge || { inProgress: false, kind: '' },
|
|
141
175
|
error: error ? String(error).slice(0, 300) : '',
|
|
142
176
|
}
|
package/lib/tools/actions.js
CHANGED
|
@@ -185,3 +185,148 @@ export async function getJobLogSummary(client, { owner, repo, runId, jobId, maxL
|
|
|
185
185
|
errorHighlights,
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
|
+
|
|
189
|
+
export function parseFoldedLog(rawText) {
|
|
190
|
+
if (!rawText || typeof rawText !== 'string') {
|
|
191
|
+
return { steps: [], failedStep: null }
|
|
192
|
+
}
|
|
193
|
+
const stripped = rawText.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '').replace(/\r/g, '')
|
|
194
|
+
const masked = stripped
|
|
195
|
+
.replace(/(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}/g, '[REDACTED_TOKEN]')
|
|
196
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED_TOKEN]')
|
|
197
|
+
|
|
198
|
+
const lines = masked.split('\n')
|
|
199
|
+
const steps = []
|
|
200
|
+
let currentStep = {
|
|
201
|
+
name: 'Setup',
|
|
202
|
+
lines: [],
|
|
203
|
+
hasError: false,
|
|
204
|
+
errorHighlights: [],
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const pushCurrent = () => {
|
|
208
|
+
if (currentStep.lines.length > 0 || currentStep.name !== 'Setup') {
|
|
209
|
+
currentStep.errorHighlights = currentStep.lines
|
|
210
|
+
.filter((l) => /\b(error|failed|failure|fatal)\b|##\[error\]|npm err!/i.test(l))
|
|
211
|
+
.slice(-10)
|
|
212
|
+
steps.push(currentStep)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const rawLine of lines) {
|
|
217
|
+
const line = rawLine.replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s?/, '')
|
|
218
|
+
const groupMatch = line.match(/^##\[group\](.*)$/)
|
|
219
|
+
if (groupMatch) {
|
|
220
|
+
pushCurrent()
|
|
221
|
+
currentStep = {
|
|
222
|
+
name: groupMatch[1].trim() || 'Step',
|
|
223
|
+
lines: [],
|
|
224
|
+
hasError: false,
|
|
225
|
+
errorHighlights: [],
|
|
226
|
+
}
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (line.startsWith('##[endgroup]')) {
|
|
231
|
+
pushCurrent()
|
|
232
|
+
currentStep = {
|
|
233
|
+
name: 'Step',
|
|
234
|
+
lines: [],
|
|
235
|
+
hasError: false,
|
|
236
|
+
errorHighlights: [],
|
|
237
|
+
}
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (/\b(error|failed|failure|fatal)\b|##\[error\]|npm err!/i.test(line)) {
|
|
242
|
+
currentStep.hasError = true
|
|
243
|
+
}
|
|
244
|
+
currentStep.lines.push(line)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
pushCurrent()
|
|
248
|
+
|
|
249
|
+
const validSteps = steps.filter((s) => s.lines.length > 0)
|
|
250
|
+
const failed = validSteps.slice().reverse().find((s) => s.hasError) || null
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
steps: validSteps.map((s) => ({
|
|
254
|
+
name: s.name,
|
|
255
|
+
lineCount: s.lines.length,
|
|
256
|
+
hasError: s.hasError,
|
|
257
|
+
errorHighlights: s.errorHighlights,
|
|
258
|
+
lines: s.lines,
|
|
259
|
+
})),
|
|
260
|
+
failedStep: failed ? {
|
|
261
|
+
name: failed.name,
|
|
262
|
+
lineCount: failed.lines.length,
|
|
263
|
+
hasError: true,
|
|
264
|
+
lines: failed.lines,
|
|
265
|
+
errorHighlights: failed.errorHighlights,
|
|
266
|
+
} : null,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function diagnoseRun(client, { owner, repo, runId, jobId, maxLines = 150 } = {}) {
|
|
271
|
+
ensureRepo({ owner, repo })
|
|
272
|
+
let targetJobId = jobId
|
|
273
|
+
let targetJobName = ''
|
|
274
|
+
|
|
275
|
+
if (!targetJobId) {
|
|
276
|
+
if (!runId) throw new Error('either runId or jobId is required')
|
|
277
|
+
const { jobs } = await listRunJobs(client, { owner, repo, runId })
|
|
278
|
+
const failedJob = jobs.find((j) => j.conclusion === 'failure') || jobs[0]
|
|
279
|
+
if (!failedJob) {
|
|
280
|
+
return { note: `No jobs found for run ${runId}`, steps: [], failedStep: null, summary: 'No jobs found.' }
|
|
281
|
+
}
|
|
282
|
+
targetJobId = failedJob.id
|
|
283
|
+
targetJobName = failedJob.name
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let logText = ''
|
|
287
|
+
try {
|
|
288
|
+
const res = await client.getRaw(`/repos/${owner}/${repo}/actions/jobs/${targetJobId}/logs`)
|
|
289
|
+
logText = typeof res === 'string' ? res : (res && res.data ? res.data : String(res || ''))
|
|
290
|
+
} catch (err) {
|
|
291
|
+
return {
|
|
292
|
+
runId,
|
|
293
|
+
jobId: targetJobId,
|
|
294
|
+
jobName: targetJobName,
|
|
295
|
+
error: `Could not retrieve logs: ${err && err.message}`,
|
|
296
|
+
steps: [],
|
|
297
|
+
failedStep: null,
|
|
298
|
+
summary: `Failed to fetch logs: ${err && err.message}`,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const parsed = parseFoldedLog(logText)
|
|
303
|
+
const cap = Math.max(1, Math.min(Number(maxLines) || 150, 500))
|
|
304
|
+
|
|
305
|
+
let failedStep = parsed.failedStep
|
|
306
|
+
if (failedStep && failedStep.lines.length > cap) {
|
|
307
|
+
failedStep = {
|
|
308
|
+
...failedStep,
|
|
309
|
+
lines: failedStep.lines.slice(-cap),
|
|
310
|
+
lineCount: failedStep.lines.length,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const summary = failedStep
|
|
315
|
+
? `Failed in job "${targetJobName || targetJobId}" at step "${failedStep.name}". Error lines:\n` +
|
|
316
|
+
failedStep.errorHighlights.map((e) => `• ${e}`).join('\n')
|
|
317
|
+
: `No failing step detected in job "${targetJobName || targetJobId}".`
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
runId,
|
|
321
|
+
jobId: targetJobId,
|
|
322
|
+
jobName: targetJobName,
|
|
323
|
+
steps: parsed.steps.map((s) => ({
|
|
324
|
+
name: s.name,
|
|
325
|
+
lineCount: s.lineCount,
|
|
326
|
+
hasError: s.hasError,
|
|
327
|
+
errorHighlights: s.errorHighlights,
|
|
328
|
+
})),
|
|
329
|
+
failedStep,
|
|
330
|
+
summary,
|
|
331
|
+
}
|
|
332
|
+
}
|
package/lib/tools/insights.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { listSecurityAlerts } from './security.js'
|
|
1
2
|
// Composed reports: the answers a person actually asks for.
|
|
2
3
|
//
|
|
3
4
|
// Each function is a small composition over the plain reads, so the cache in the client
|
|
@@ -58,14 +59,23 @@ export async function listContributors(client, { owner, repo, limit = 10 } = {})
|
|
|
58
59
|
export async function repoReport(client, { owner, repo, commits: commitLimit = 5, issues: issueLimit = 5, contributors: contributorLimit = 5 } = {}) {
|
|
59
60
|
ensureRepo({ owner, repo })
|
|
60
61
|
const overview = await getRepo(client, { owner, repo })
|
|
61
|
-
const [releases, openIssues, commitList, contributorList] = await Promise.all([
|
|
62
|
+
const [releases, openIssues, commitList, contributorList, securityAlerts] = await Promise.all([
|
|
62
63
|
listReleases(client, { owner, repo, limit: 1 }).catch(() => ({ releases: [] })),
|
|
63
64
|
listIssues(client, { owner, repo, state: 'open', limit: issueLimit }).catch(() => ({ issues: [] })),
|
|
64
65
|
listCommits(client, { owner, repo, branch: overview.defaultBranch, limit: commitLimit }).catch(() => ({ commits: [] })),
|
|
65
66
|
listContributors(client, { owner, repo, limit: contributorLimit }).catch(() => ({ contributors: [] })),
|
|
67
|
+
listSecurityAlerts(client, { owner, repo, limit: 10 }).catch(() => null),
|
|
66
68
|
])
|
|
67
69
|
return {
|
|
68
70
|
repo: overview.fullName,
|
|
71
|
+
security: securityAlerts ? {
|
|
72
|
+
totalCount: securityAlerts.totalCount,
|
|
73
|
+
criticalCount: securityAlerts.criticalCount,
|
|
74
|
+
highCount: securityAlerts.highCount,
|
|
75
|
+
dependabotEnabled: securityAlerts.dependabot?.enabled,
|
|
76
|
+
secretScanningEnabled: securityAlerts.secretScanning?.enabled,
|
|
77
|
+
codeScanningEnabled: securityAlerts.codeScanning?.enabled,
|
|
78
|
+
} : { enabled: false },
|
|
69
79
|
description: overview.description,
|
|
70
80
|
stars: overview.stars,
|
|
71
81
|
forks: overview.forks,
|
|
@@ -137,7 +147,7 @@ export async function listNotifications(client, { all = false, participating = f
|
|
|
137
147
|
* A transparent maintenance heuristic. Every point of the score carries its evidence, and the
|
|
138
148
|
* result states plainly that it is not a security audit.
|
|
139
149
|
*/
|
|
140
|
-
export function healthOf({ repo, releases = [], issues = [], pulls = [], commits = [] } = {}) {
|
|
150
|
+
export function healthOf({ repo, releases = [], issues = [], pulls = [], commits = [], security = null } = {}) {
|
|
141
151
|
const dimensions = []
|
|
142
152
|
const daysSince = (value) => (value ? Math.floor((Date.now() - Date.parse(value)) / (24 * 60 * 60 * 1000)) : null)
|
|
143
153
|
|
|
@@ -166,12 +176,14 @@ export function healthOf({ repo, releases = [], issues = [], pulls = [], commits
|
|
|
166
176
|
if (!(repo && repo.license)) risks.push('no license declared')
|
|
167
177
|
if (!(repo && repo.description)) risks.push('no description')
|
|
168
178
|
if (openCount > 10) risks.push('large open issue backlog')
|
|
179
|
+
if (security && security.criticalCount > 0) risks.push(`${security.criticalCount} critical security alert(s) detected`)
|
|
169
180
|
|
|
170
181
|
const nextActions = []
|
|
171
182
|
if (!releases.length) nextActions.push('publish the first release')
|
|
172
183
|
if (!(repo && repo.license)) nextActions.push('add a LICENSE')
|
|
173
184
|
if (!(repo && repo.description)) nextActions.push('write a repository description')
|
|
174
185
|
if (pushed !== null && pushed > 90) nextActions.push('push a change or archive the repository')
|
|
186
|
+
if (security && security.criticalCount > 0) nextActions.push('resolve critical security alerts')
|
|
175
187
|
|
|
176
188
|
return {
|
|
177
189
|
repo: repo && repo.fullName,
|
|
@@ -187,14 +199,15 @@ export function healthOf({ repo, releases = [], issues = [], pulls = [], commits
|
|
|
187
199
|
/** Repository health for one repository, gathering its own evidence. */
|
|
188
200
|
export async function repoHealth(client, { owner, repo } = {}) {
|
|
189
201
|
ensureRepo({ owner, repo })
|
|
190
|
-
const [repository, releases, issues, pulls, commits] = await Promise.all([
|
|
202
|
+
const [repository, releases, issues, pulls, commits, security] = await Promise.all([
|
|
191
203
|
getRepo(client, { owner, repo }),
|
|
192
204
|
listReleases(client, { owner, repo, limit: 3 }).catch(() => ({ releases: [] })),
|
|
193
205
|
listIssues(client, { owner, repo, state: 'open', limit: 30 }).catch(() => ({ issues: [] })),
|
|
194
206
|
listIssues(client, { owner, repo, state: 'open', kind: 'pr', limit: 30 }).catch(() => ({ issues: [] })),
|
|
195
207
|
listCommits(client, { owner, repo, limit: 10 }).catch(() => ({ commits: [] })),
|
|
208
|
+
listSecurityAlerts(client, { owner, repo, limit: 10 }).catch(() => null),
|
|
196
209
|
])
|
|
197
|
-
return healthOf({ repo: repository, releases: releases.releases, issues: issues.issues, pulls: pulls.issues, commits: commits.commits })
|
|
210
|
+
return healthOf({ repo: repository, releases: releases.releases, issues: issues.issues, pulls: pulls.issues, commits: commits.commits, security })
|
|
198
211
|
}
|
|
199
212
|
|
|
200
213
|
/** Two repositories side by side, with numeric deltas. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { listSecurityAlerts, getDependabotAlert } from './security.js'
|
|
1
2
|
// Registrations for the composed reports: the answers a person asks for in one call.
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -123,4 +124,33 @@ export function registerInsightTools({ tool, asRepo, client, liveConfig }) {
|
|
|
123
124
|
return listCommits(await client(), { owner, repo, branch: args.branch, since: args.since, limit: args.limit })
|
|
124
125
|
},
|
|
125
126
|
)
|
|
127
|
+
|
|
128
|
+
tool(
|
|
129
|
+
'gh_security_alerts',
|
|
130
|
+
'Summary of Dependabot, Secret Scanning, and CodeQL alerts for a repository. Gracefully degrades if alerts are disabled or token lacks security scope. Read-only.',
|
|
131
|
+
repoParams({
|
|
132
|
+
state: { type: 'string', description: 'Filter by state (open, fixed, dismissed, all; default "open").' },
|
|
133
|
+
severity: { type: 'string', description: 'Filter by severity (critical, high, medium, low).' },
|
|
134
|
+
limit: { type: 'number', description: 'Max alerts per category (default 30, max 100).' },
|
|
135
|
+
}),
|
|
136
|
+
async (args) => {
|
|
137
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
138
|
+
return listSecurityAlerts(await client(), {
|
|
139
|
+
owner, repo, state: args.state, severity: args.severity, limit: args.limit,
|
|
140
|
+
})
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
tool(
|
|
145
|
+
'gh_dependabot_alert_view',
|
|
146
|
+
'Read details of a Dependabot alert: vulnerable package, advisory summary, CVSS score, and recommended safe version. Read-only.',
|
|
147
|
+
repoParams({
|
|
148
|
+
alertNumber: { type: 'number', description: 'The Dependabot alert number.' },
|
|
149
|
+
}),
|
|
150
|
+
async (args) => {
|
|
151
|
+
const { owner, repo } = await asRepo(args, liveConfig())
|
|
152
|
+
const num = args.alertNumber ?? args.alert_number
|
|
153
|
+
return getDependabotAlert(await client(), { owner, repo, alertNumber: num })
|
|
154
|
+
},
|
|
155
|
+
)
|
|
126
156
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getRunLogsUrl,
|
|
14
14
|
dispatchWorkflow,
|
|
15
15
|
getJobLogSummary,
|
|
16
|
+
diagnoseRun,
|
|
16
17
|
} from './actions.js'
|
|
17
18
|
import {
|
|
18
19
|
listVariables,
|
|
@@ -146,6 +147,19 @@ export function registerOpsTools({ tool, asRepo, client, liveConfig }) {
|
|
|
146
147
|
})),
|
|
147
148
|
)
|
|
148
149
|
|
|
150
|
+
tool(
|
|
151
|
+
'gh_run_diagnose',
|
|
152
|
+
'Diagnose a GitHub Actions workflow run failure: parses the job log into folded steps, identifies the failing step, strips ANSI codes, and extracts error highlights and root causes. Read-only.',
|
|
153
|
+
repoParams({
|
|
154
|
+
runId: { type: 'number', description: 'Workflow run id (e.g. from gh_run_list).' },
|
|
155
|
+
jobId: { type: 'number', description: 'Specific job id to inspect (optional, defaults to first failed job).' },
|
|
156
|
+
maxLines: { type: 'number', description: 'Maximum log lines to return for the failed step (default 150, max 500).' },
|
|
157
|
+
}),
|
|
158
|
+
async (args) => withRepo(args, async ({ owner, repo }) => diagnoseRun(await client(), {
|
|
159
|
+
owner, repo, runId: args.runId, jobId: args.jobId, maxLines: args.maxLines,
|
|
160
|
+
})),
|
|
161
|
+
)
|
|
162
|
+
|
|
149
163
|
// ---------------------------------------------------------------- variables
|
|
150
164
|
|
|
151
165
|
tool(
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// GitHub Security Alerts: Dependabot, Secret Scanning, and CodeQL (Code Scanning).
|
|
2
|
+
//
|
|
3
|
+
// These endpoints return 404 or 403 if the repository does not have the feature enabled
|
|
4
|
+
// or if the token lacks the security_events / repo scope. The tools degrade gracefully
|
|
5
|
+
// with { enabled: false, reason: "..." } instead of failing the caller.
|
|
6
|
+
|
|
7
|
+
function ensureRepo({ owner, repo }) {
|
|
8
|
+
if (!owner || !repo) {
|
|
9
|
+
throw new Error('owner and repo are required (pass "repository": "owner/repo" or set defaults in settings)')
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function redactSecrets(text) {
|
|
14
|
+
if (typeof text !== 'string') return text
|
|
15
|
+
return text
|
|
16
|
+
.replace(/(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}/g, '[REDACTED_TOKEN]')
|
|
17
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED_TOKEN]')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function safeAlertCall(client, path, query, featureName) {
|
|
21
|
+
try {
|
|
22
|
+
const { data } = await client.get(path, { query })
|
|
23
|
+
return { enabled: true, data: Array.isArray(data) ? data : [] }
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const status = err && err.status
|
|
26
|
+
if (status === 404 || status === 403) {
|
|
27
|
+
return {
|
|
28
|
+
enabled: false,
|
|
29
|
+
reason: `${featureName} is not enabled or token lacks required security scope (HTTP ${status})`,
|
|
30
|
+
data: [],
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
throw err
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizeDependabotAlert(item) {
|
|
38
|
+
if (!item || typeof item !== 'object') return item
|
|
39
|
+
const adv = item.security_advisory || {}
|
|
40
|
+
const dep = item.dependency || {}
|
|
41
|
+
const pkg = dep.package || {}
|
|
42
|
+
const vuln = item.security_vulnerability || {}
|
|
43
|
+
return {
|
|
44
|
+
number: item.number,
|
|
45
|
+
state: item.state,
|
|
46
|
+
dependency: {
|
|
47
|
+
packageName: pkg.name || '',
|
|
48
|
+
ecosystem: pkg.ecosystem || '',
|
|
49
|
+
manifestPath: dep.manifest_path || '',
|
|
50
|
+
scope: dep.scope || '',
|
|
51
|
+
},
|
|
52
|
+
advisory: {
|
|
53
|
+
ghsaId: adv.ghsa_id || '',
|
|
54
|
+
cveId: adv.cve_id || '',
|
|
55
|
+
summary: redactSecrets(adv.summary || ''),
|
|
56
|
+
severity: adv.severity || '',
|
|
57
|
+
cvssScore: (adv.cvss && adv.cvss.score) ?? null,
|
|
58
|
+
},
|
|
59
|
+
fixedIn: (vuln.first_patched_version && vuln.first_patched_version.identifier) || null,
|
|
60
|
+
url: item.html_url || '',
|
|
61
|
+
createdAt: item.created_at || '',
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeSecretAlert(item) {
|
|
66
|
+
if (!item || typeof item !== 'object') return item
|
|
67
|
+
return {
|
|
68
|
+
number: item.number,
|
|
69
|
+
secretType: item.secret_type || '',
|
|
70
|
+
secretTypeDisplayName: item.secret_type_display_name || item.secret_type || '',
|
|
71
|
+
state: item.state || '',
|
|
72
|
+
resolution: item.resolution || null,
|
|
73
|
+
url: item.html_url || '',
|
|
74
|
+
createdAt: item.created_at || '',
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function normalizeCodeScanningAlert(item) {
|
|
79
|
+
if (!item || typeof item !== 'object') return item
|
|
80
|
+
const rule = item.rule || {}
|
|
81
|
+
const inst = item.most_recent_instance || {}
|
|
82
|
+
const loc = inst.location || {}
|
|
83
|
+
return {
|
|
84
|
+
number: item.number,
|
|
85
|
+
ruleId: rule.id || '',
|
|
86
|
+
ruleSeverity: rule.security_severity_level || rule.severity || '',
|
|
87
|
+
ruleDescription: rule.description || '',
|
|
88
|
+
state: item.state || '',
|
|
89
|
+
message: redactSecrets((inst.message && inst.message.text) || ''),
|
|
90
|
+
path: loc.path || '',
|
|
91
|
+
startLine: loc.start_line ?? null,
|
|
92
|
+
url: item.html_url || '',
|
|
93
|
+
createdAt: item.created_at || '',
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* List security alerts across Dependabot, Secret Scanning, and CodeQL.
|
|
99
|
+
* Gracefully degrades if any feature is disabled or permissions are missing.
|
|
100
|
+
*/
|
|
101
|
+
export async function listSecurityAlerts(client, { owner, repo, state = 'open', severity, limit = 30 } = {}) {
|
|
102
|
+
ensureRepo({ owner, repo })
|
|
103
|
+
const perPage = Math.max(1, Math.min(Number(limit) || 30, 100))
|
|
104
|
+
|
|
105
|
+
const depQuery = { per_page: perPage }
|
|
106
|
+
if (state && state !== 'all') depQuery.state = state
|
|
107
|
+
if (severity) depQuery.severity = severity
|
|
108
|
+
|
|
109
|
+
const secQuery = { per_page: perPage }
|
|
110
|
+
if (state && state !== 'all') secQuery.state = state === 'fixed' || state === 'dismissed' ? 'resolved' : state
|
|
111
|
+
|
|
112
|
+
const codeQuery = { per_page: perPage }
|
|
113
|
+
if (state && state !== 'all') codeQuery.state = state
|
|
114
|
+
if (severity) codeQuery.severity = severity
|
|
115
|
+
|
|
116
|
+
const [depRes, secRes, codeRes] = await Promise.all([
|
|
117
|
+
safeAlertCall(client, `/repos/${owner}/${repo}/dependabot/alerts`, depQuery, 'Dependabot'),
|
|
118
|
+
safeAlertCall(client, `/repos/${owner}/${repo}/secret-scanning/alerts`, secQuery, 'Secret Scanning'),
|
|
119
|
+
safeAlertCall(client, `/repos/${owner}/${repo}/code-scanning/alerts`, codeQuery, 'Code Scanning'),
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
const dependabotAlerts = depRes.data.map(normalizeDependabotAlert)
|
|
123
|
+
const secretAlerts = secRes.data.map(normalizeSecretAlert)
|
|
124
|
+
const codeAlerts = codeRes.data.map(normalizeCodeScanningAlert)
|
|
125
|
+
|
|
126
|
+
let criticalCount = 0
|
|
127
|
+
let highCount = 0
|
|
128
|
+
let mediumCount = 0
|
|
129
|
+
let lowCount = 0
|
|
130
|
+
|
|
131
|
+
for (const a of dependabotAlerts) {
|
|
132
|
+
const s = String(a.advisory?.severity || '').toLowerCase()
|
|
133
|
+
if (s === 'critical') criticalCount++
|
|
134
|
+
else if (s === 'high') highCount++
|
|
135
|
+
else if (s === 'medium') mediumCount++
|
|
136
|
+
else if (s === 'low') lowCount++
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const a of codeAlerts) {
|
|
140
|
+
const s = String(a.ruleSeverity || '').toLowerCase()
|
|
141
|
+
if (s === 'critical') criticalCount++
|
|
142
|
+
else if (s === 'high' || s === 'error') highCount++
|
|
143
|
+
else if (s === 'medium' || s === 'warning') mediumCount++
|
|
144
|
+
else if (s === 'low' || s === 'note') lowCount++
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const a of secretAlerts) {
|
|
148
|
+
if (a.state === 'open') criticalCount++
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const totalCount = dependabotAlerts.length + secretAlerts.length + codeAlerts.length
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
owner,
|
|
155
|
+
repo,
|
|
156
|
+
totalCount,
|
|
157
|
+
criticalCount,
|
|
158
|
+
highCount,
|
|
159
|
+
mediumCount,
|
|
160
|
+
lowCount,
|
|
161
|
+
dependabot: {
|
|
162
|
+
enabled: depRes.enabled,
|
|
163
|
+
reason: depRes.reason,
|
|
164
|
+
count: dependabotAlerts.length,
|
|
165
|
+
alerts: dependabotAlerts,
|
|
166
|
+
},
|
|
167
|
+
secretScanning: {
|
|
168
|
+
enabled: secRes.enabled,
|
|
169
|
+
reason: secRes.reason,
|
|
170
|
+
count: secretAlerts.length,
|
|
171
|
+
alerts: secretAlerts,
|
|
172
|
+
},
|
|
173
|
+
codeScanning: {
|
|
174
|
+
enabled: codeRes.enabled,
|
|
175
|
+
reason: codeRes.reason,
|
|
176
|
+
count: codeAlerts.length,
|
|
177
|
+
alerts: codeAlerts,
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get details for a single Dependabot alert.
|
|
184
|
+
*/
|
|
185
|
+
export async function getDependabotAlert(client, { owner, repo, alertNumber }) {
|
|
186
|
+
ensureRepo({ owner, repo })
|
|
187
|
+
if (!alertNumber) throw new Error('alertNumber is required')
|
|
188
|
+
const num = Number(alertNumber)
|
|
189
|
+
const { data } = await client.get(`/repos/${owner}/${repo}/dependabot/alerts/${num}`)
|
|
190
|
+
const norm = normalizeDependabotAlert(data)
|
|
191
|
+
const adv = (data && data.security_advisory) || {}
|
|
192
|
+
return {
|
|
193
|
+
...norm,
|
|
194
|
+
advisory: {
|
|
195
|
+
...norm.advisory,
|
|
196
|
+
description: redactSecrets(adv.description || ''),
|
|
197
|
+
references: (adv.references || []).map((r) => r.url || r),
|
|
198
|
+
},
|
|
199
|
+
updatedAt: data.updated_at || '',
|
|
200
|
+
dismissedAt: data.dismissed_at || '',
|
|
201
|
+
dismissReason: data.dismiss_reason || '',
|
|
202
|
+
fixedAt: data.fixed_at || '',
|
|
203
|
+
}
|
|
204
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-github-ops",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7",
|
|
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",
|