@goodandready/dsh-github-ops 0.4.0 → 0.5.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 +12 -0
- package/lib/client.js +86 -5
- package/lib/routes.js +76 -7
- package/lib/scm-write.js +31 -1
- package/lib/scm.js +184 -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,17 @@
|
|
|
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
|
+
|
|
10
|
+
## 0.4.1
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Mirror Route Registration Lifecycle** (#64): Fixed `MIRROR_PATH` webServer registration by wrapping it in an independent `wctx.effect(registerMirror, ...)` lifecycle hook, ensuring the mirror inspection route is properly registered in Cordis runtime.
|
|
14
|
+
|
|
3
15
|
## 0.4.0
|
|
4
16
|
|
|
5
17
|
### Features
|
package/lib/client.js
CHANGED
|
@@ -350,12 +350,24 @@ window.__ModuleLoader__.load({
|
|
|
350
350
|
|
|
351
351
|
const openDiff = (file, staged) => {
|
|
352
352
|
setSelected({ path: file.path, staged })
|
|
353
|
-
setDiff({ phase: 'loading', text: '' })
|
|
353
|
+
setDiff({ phase: 'loading', text: '', hasConflicts: false, conflicts: [] })
|
|
354
354
|
const query = `?file=${encodeURIComponent(file.path)}${staged ? '&staged=1' : ''}${cwdQuery ? `&${cwdQuery}` : ''}`
|
|
355
355
|
fetchWithTimeout(SCM_PATH + query, { headers: { accept: 'application/json' } })
|
|
356
356
|
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
357
|
-
.then((payload) => setDiff({
|
|
358
|
-
|
|
357
|
+
.then((payload) => setDiff({
|
|
358
|
+
phase: 'ready',
|
|
359
|
+
text: payload.diff || '',
|
|
360
|
+
hasConflicts: Boolean(payload.hasConflicts || (file.status === 'U') || (payload.diff && payload.diff.includes('<<<<<<<'))),
|
|
361
|
+
conflicts: payload.conflicts || [],
|
|
362
|
+
}))
|
|
363
|
+
.catch((err) => setDiff({ phase: 'error', text: String((err && err.message) || err), hasConflicts: false }))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const resolveConflict = (choice, blockIndex) => {
|
|
367
|
+
if (!selected || !selected.path) return
|
|
368
|
+
const args = { path: selected.path, choice }
|
|
369
|
+
if (blockIndex !== undefined && blockIndex !== null) args.blockIndex = blockIndex
|
|
370
|
+
runAction('resolveConflict', args)
|
|
359
371
|
}
|
|
360
372
|
|
|
361
373
|
const openStashDiff = (stashRef) => {
|
|
@@ -548,6 +560,16 @@ window.__ModuleLoader__.load({
|
|
|
548
560
|
group(t('scmStaged'), files.staged, true),
|
|
549
561
|
group(t('scmChanges'), files.changes, false),
|
|
550
562
|
group(t('scmUntracked'), files.untracked, false),
|
|
563
|
+
selected && diff.hasConflicts
|
|
564
|
+
? React.createElement('div', { className: 'gho-conflict-bar' },
|
|
565
|
+
React.createElement('span', { className: 'gho-conflict-title' }, t('scmConflictDetected')),
|
|
566
|
+
React.createElement('div', { className: 'gho-conflict-actions' },
|
|
567
|
+
button(t('scmAcceptCurrent'), () => resolveConflict('ours')),
|
|
568
|
+
button(t('scmAcceptIncoming'), () => resolveConflict('theirs')),
|
|
569
|
+
button(t('scmAcceptBoth'), () => resolveConflict('both')),
|
|
570
|
+
),
|
|
571
|
+
)
|
|
572
|
+
: null,
|
|
551
573
|
selected
|
|
552
574
|
? React.createElement('pre', { className: 'gho-scm-diff' },
|
|
553
575
|
diff.phase === 'loading' ? t('scmLoading') : (diff.text || t('scmDiffEmpty')))
|
|
@@ -707,6 +729,7 @@ window.__ModuleLoader__.load({
|
|
|
707
729
|
if (!repo) return
|
|
708
730
|
if (tab === 'tree') loadTree('')
|
|
709
731
|
else if (tab === 'inbox') loadInbox()
|
|
732
|
+
else if (tab === 'matrix') loadMatrix()
|
|
710
733
|
else loadList(tab)
|
|
711
734
|
}, [repo, tab, loadTree, loadList, loadInbox])
|
|
712
735
|
|
|
@@ -728,7 +751,11 @@ window.__ModuleLoader__.load({
|
|
|
728
751
|
)
|
|
729
752
|
|
|
730
753
|
const switcher = React.createElement('div', { className: 'gho-panel' },
|
|
731
|
-
React.createElement('div', { className: 'gho-panel-
|
|
754
|
+
React.createElement('div', { className: 'gho-panel-tabs' },
|
|
755
|
+
React.createElement('button', { type: 'button', className: 'gho-panel-tab', 'aria-pressed': switchMode === 'repos', onClick: () => setSwitchMode('repos') }, t('panelRepo')),
|
|
756
|
+
React.createElement('button', { type: 'button', className: 'gho-panel-tab', 'aria-pressed': switchMode === 'matrix', onClick: () => { setSwitchMode('matrix'); if (matrix.phase === 'idle') loadMatrix() } }, t('panelMatrix')),
|
|
757
|
+
),
|
|
758
|
+
switchMode === 'matrix' ? matrixView : React.createElement('div', { className: 'gho-panel-row' },
|
|
732
759
|
React.createElement('input', {
|
|
733
760
|
className: 'gho-panel-input',
|
|
734
761
|
placeholder: t('panelSearch'),
|
|
@@ -778,6 +805,47 @@ window.__ModuleLoader__.load({
|
|
|
778
805
|
file ? React.createElement('pre', { className: 'gho-panel-pre' }, file.truncated ? `${file.text}\n…` : (file.text || t('panelEmpty'))) : null,
|
|
779
806
|
)
|
|
780
807
|
|
|
808
|
+
const matrixView = (() => {
|
|
809
|
+
if (matrix.phase === 'loading') return React.createElement('div', { className: 'gho-panel-muted' }, t('panelLoading'))
|
|
810
|
+
if (!matrix.list.length) return React.createElement('div', { className: 'gho-panel-muted' }, t('panelMatrixEmpty'))
|
|
811
|
+
return React.createElement('table', { className: 'gho-matrix-table' },
|
|
812
|
+
React.createElement('thead', null,
|
|
813
|
+
React.createElement('tr', null,
|
|
814
|
+
React.createElement('th', { className: 'gho-matrix-th' }, t('panelMatrixRepo')),
|
|
815
|
+
React.createElement('th', { className: 'gho-matrix-th' }, t('panelMatrixStatus')),
|
|
816
|
+
React.createElement('th', { className: 'gho-matrix-th' }, t('panelMatrixRelease')),
|
|
817
|
+
React.createElement('th', { className: 'gho-matrix-th' }, t('panelMatrixNpm')),
|
|
818
|
+
React.createElement('th', { className: 'gho-matrix-th' }, t('panelMatrixCi')),
|
|
819
|
+
React.createElement('th', { className: 'gho-matrix-th' }, t('panelMatrixIssues')),
|
|
820
|
+
React.createElement('th', { className: 'gho-matrix-th' }, ''),
|
|
821
|
+
),
|
|
822
|
+
),
|
|
823
|
+
React.createElement('tbody', null,
|
|
824
|
+
matrix.list.map((row) => React.createElement('tr', { key: row.fullName || row.name },
|
|
825
|
+
React.createElement('td', { className: 'gho-matrix-td' },
|
|
826
|
+
React.createElement('button', {
|
|
827
|
+
type: 'button',
|
|
828
|
+
className: 'gho-link',
|
|
829
|
+
style: { margin: 0, fontWeight: 600, textAlign: 'left' },
|
|
830
|
+
title: t('panelMatrixSelect'),
|
|
831
|
+
onClick: () => { setRepo(row.fullName || row.name); setTab('tree'); setFile(null) },
|
|
832
|
+
}, row.name || row.fullName),
|
|
833
|
+
),
|
|
834
|
+
React.createElement('td', { className: 'gho-matrix-td' },
|
|
835
|
+
React.createElement('span', { className: `gho-badge gho-badge-${row.status || 'active'}` }, row.status || 'active'),
|
|
836
|
+
),
|
|
837
|
+
React.createElement('td', { className: 'gho-matrix-td' }, (row.release && row.release.tag) || '—'),
|
|
838
|
+
React.createElement('td', { className: 'gho-matrix-td' }, (row.npm && row.npm.version) || '—'),
|
|
839
|
+
React.createElement('td', { className: 'gho-matrix-td' }, (row.ci && (row.ci.conclusion || row.ci.status)) || '—'),
|
|
840
|
+
React.createElement('td', { className: 'gho-matrix-td' }, String(row.openIssuesCount || 0)),
|
|
841
|
+
React.createElement('td', { className: 'gho-matrix-td' },
|
|
842
|
+
row.htmlUrl ? React.createElement('a', { className: 'gho-link', href: row.htmlUrl, target: '_blank', rel: 'noreferrer' }, '↗') : null,
|
|
843
|
+
),
|
|
844
|
+
)),
|
|
845
|
+
),
|
|
846
|
+
)
|
|
847
|
+
})()
|
|
848
|
+
|
|
781
849
|
return React.createElement('div', { className: 'gho-panel' },
|
|
782
850
|
header,
|
|
783
851
|
React.createElement('div', { className: 'gho-panel-tabs' },
|
|
@@ -786,10 +854,11 @@ window.__ModuleLoader__.load({
|
|
|
786
854
|
tabButton('pulls', t('panelPulls')),
|
|
787
855
|
tabButton('runs', t('panelRuns')),
|
|
788
856
|
tabButton('inbox', t('panelInbox')),
|
|
857
|
+
tabButton('matrix', t('panelMatrix')),
|
|
789
858
|
React.createElement('button', { type: 'button', className: 'gho-panel-tab', onClick: () => { setRepo(''); setFile(null) } }, t('panelRepo')),
|
|
790
859
|
),
|
|
791
860
|
error ? React.createElement('div', { className: 'gho-panel-muted' }, error) : null,
|
|
792
|
-
tab === 'tree' ? treeView : list,
|
|
861
|
+
tab === 'tree' ? treeView : (tab === 'matrix' ? matrixView : list),
|
|
793
862
|
)
|
|
794
863
|
}
|
|
795
864
|
|
|
@@ -818,6 +887,18 @@ window.__ModuleLoader__.load({
|
|
|
818
887
|
'.gho-status-value{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}',
|
|
819
888
|
'.gho-status-head{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
820
889
|
'.gho-status-bad{color:var(--dsw-alias-label-error)}',
|
|
890
|
+
'.gho-badge{display:inline-block;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:600;text-transform:uppercase}',
|
|
891
|
+
'.gho-badge-healthy{background:var(--dsw-alias-color-success-bg,rgba(46,160,67,0.15));color:var(--dsw-alias-color-success,#2ea043)}',
|
|
892
|
+
'.gho-badge-failing{background:var(--dsw-alias-color-danger-bg,rgba(248,81,73,0.15));color:var(--dsw-alias-label-error,#f85149)}',
|
|
893
|
+
'.gho-badge-building{background:var(--dsw-alias-color-warning-bg,rgba(210,153,34,0.15));color:var(--dsw-alias-color-warning,#d29922)}',
|
|
894
|
+
'.gho-badge-released{background:var(--dsw-alias-color-info-bg,rgba(56,139,253,0.15));color:var(--dsw-alias-color-info,#388bfd)}',
|
|
895
|
+
'.gho-badge-active{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-secondary)}',
|
|
896
|
+
'.gho-matrix-table{width:100%;border-collapse:collapse;font-size:11px;margin-top:6px}',
|
|
897
|
+
'.gho-matrix-th{text-align:left;padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}',
|
|
898
|
+
'.gho-matrix-td{padding:5px 6px;border-bottom:1px solid var(--dsw-alias-border-l2);vertical-align:middle}',
|
|
899
|
+
'.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}',
|
|
900
|
+
'.gho-conflict-title{font-weight:600;font-size:12px;color:var(--dsw-alias-label-primary)}',
|
|
901
|
+
'.gho-conflict-actions{display:flex;gap:6px;margin-left:auto}',
|
|
821
902
|
'.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
903
|
'.gho-scm{display:flex;flex-direction:column;gap:12px;padding:4px 2px;font-size:13px;color:var(--dsw-alias-label-primary)}',
|
|
823
904
|
'.gho-scm-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
|
package/lib/routes.js
CHANGED
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
// stays a composition: the card, the Source Control tab and the sidebar panel each need one
|
|
6
6
|
// endpoint, and none of them needs a tool.
|
|
7
7
|
|
|
8
|
-
import { existsSync, statSync } from 'node:fs'
|
|
8
|
+
import { existsSync, statSync, readFileSync } from 'node:fs'
|
|
9
9
|
import { join as joinPath } from 'node:path'
|
|
10
10
|
|
|
11
11
|
import { statusPayload, isTrustedRequest, isTrustedWriteRequest, sendJson } from './status.js'
|
|
12
|
-
import { buildAction, validateCwd, validateStashRef } from './scm-write.js'
|
|
13
|
-
import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom } from './scm.js'
|
|
12
|
+
import { buildAction, validateCwd, validateStashRef, resolveConflictFile } from './scm-write.js'
|
|
13
|
+
import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom, hasConflicts, parseConflictBlocks } from './scm.js'
|
|
14
14
|
import { listIssues } from './tools/issues.js'
|
|
15
15
|
import { listRuns } from './tools/actions.js'
|
|
16
16
|
import { listNotifications, repoTreeView } from './tools/panel-data.js'
|
|
17
17
|
import { mirrorStatus, syncMirror } from './tools/mirror.js'
|
|
18
|
+
import { matrixStatus } from './tools/matrix.js'
|
|
18
19
|
|
|
19
20
|
/** Where the settings card reads the access status from. */
|
|
20
21
|
export const STATUS_PATH = '/dsh-github-ops/status'
|
|
@@ -28,6 +29,9 @@ export const PANEL_PATH = '/dsh-github-ops/panel'
|
|
|
28
29
|
/** Where mirror inspection and sync are handled. */
|
|
29
30
|
export const MIRROR_PATH = '/dsh-github-ops/mirror'
|
|
30
31
|
|
|
32
|
+
/** Where organization matrix status is read. */
|
|
33
|
+
export const MATRIX_PATH = '/dsh-github-ops/matrix'
|
|
34
|
+
|
|
31
35
|
/**
|
|
32
36
|
* Register the three routes.
|
|
33
37
|
*
|
|
@@ -157,6 +161,19 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
157
161
|
} catch (err) {
|
|
158
162
|
return sendJson(res, 400, { ok: false, error: String((err && err.message) || err) })
|
|
159
163
|
}
|
|
164
|
+
if (built.action === 'resolveConflict') {
|
|
165
|
+
try {
|
|
166
|
+
const actionArgs = body.args || body
|
|
167
|
+
resolveConflictFile({
|
|
168
|
+
cwd,
|
|
169
|
+
path: actionArgs.path,
|
|
170
|
+
choice: actionArgs.choice,
|
|
171
|
+
blockIndex: actionArgs.blockIndex,
|
|
172
|
+
})
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return sendJson(res, 400, { ok: false, error: String((err && err.message) || err) })
|
|
175
|
+
}
|
|
176
|
+
}
|
|
160
177
|
const steps = []
|
|
161
178
|
for (const argv of built.steps) {
|
|
162
179
|
const answer = await git(argv, { cwd, timeoutMs: 120000 })
|
|
@@ -178,7 +195,27 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
178
195
|
const args = staged ? ['diff', '--cached', '--', file] : ['diff', '--', file]
|
|
179
196
|
const diff = await git(args, { cwd })
|
|
180
197
|
const text = diff.code === 0 ? diff.stdout : diff.stderr
|
|
181
|
-
|
|
198
|
+
let hasConflictsFlag = false
|
|
199
|
+
let conflicts = []
|
|
200
|
+
try {
|
|
201
|
+
const fullFilePath = joinPath(cwd, file)
|
|
202
|
+
if (existsSync(fullFilePath)) {
|
|
203
|
+
const content = readFileSync(fullFilePath, 'utf8')
|
|
204
|
+
if (hasConflicts(content)) {
|
|
205
|
+
hasConflictsFlag = true
|
|
206
|
+
conflicts = parseConflictBlocks(content)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
// ignore
|
|
211
|
+
}
|
|
212
|
+
return sendJson(res, 200, {
|
|
213
|
+
file,
|
|
214
|
+
staged,
|
|
215
|
+
diff: String(text || '').slice(0, 200000),
|
|
216
|
+
hasConflicts: hasConflictsFlag,
|
|
217
|
+
conflicts,
|
|
218
|
+
})
|
|
182
219
|
}
|
|
183
220
|
|
|
184
221
|
const stash = url.searchParams.get('stash') || ''
|
|
@@ -280,6 +317,12 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
280
317
|
const { data } = await github.get('/user/repos', { query: { per_page: 50, sort: 'pushed', affiliation: 'owner,collaborator,organization_member' } })
|
|
281
318
|
return sendJson(res, 200, { configured: true, repos: (data || []).filter((r) => !r.archived).map((r) => ({ fullName: r.full_name, private: r.private, pushedAt: r.pushed_at, description: r.description || '', stars: r.stargazers_count })) })
|
|
282
319
|
}
|
|
320
|
+
if (what === 'matrix') {
|
|
321
|
+
const org = url.searchParams.get('org') || repoOwner || liveConfig().owner || ''
|
|
322
|
+
const limit = Number(url.searchParams.get('limit')) || 30
|
|
323
|
+
const result = await matrixStatus(github, { org, limit })
|
|
324
|
+
return sendJson(res, 200, { configured: true, ...result })
|
|
325
|
+
}
|
|
283
326
|
if (!repoOwner || !repoName) return sendJson(res, 200, { configured: true, error: 'a repository is required for this view' })
|
|
284
327
|
const repo = { owner: repoOwner, repo: repoName }
|
|
285
328
|
if (what === 'tree') {
|
|
@@ -311,6 +354,7 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
311
354
|
const result = await listNotifications(github, { limit: 30 })
|
|
312
355
|
return sendJson(res, 200, { configured: true, ...result })
|
|
313
356
|
}
|
|
357
|
+
|
|
314
358
|
return sendJson(res, 200, { configured: true, error: `unknown view "${what}"` })
|
|
315
359
|
} catch (err) {
|
|
316
360
|
return sendJson(res, 200, { configured: true, error: describeError(err) })
|
|
@@ -318,8 +362,9 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
318
362
|
},
|
|
319
363
|
})
|
|
320
364
|
if (typeof wctx.effect === 'function') wctx.effect(register, 'dsh-github-ops: panel route')
|
|
321
|
-
else
|
|
322
|
-
|
|
365
|
+
else register()
|
|
366
|
+
|
|
367
|
+
const registerMirror = () => wctx.webServer.register({
|
|
323
368
|
kind: 'exact',
|
|
324
369
|
path: MIRROR_PATH,
|
|
325
370
|
handler: async (req, res) => {
|
|
@@ -387,8 +432,32 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
387
432
|
}
|
|
388
433
|
},
|
|
389
434
|
})
|
|
435
|
+
if (typeof wctx.effect === 'function') wctx.effect(registerMirror, 'dsh-github-ops: mirror route')
|
|
436
|
+
else registerMirror()
|
|
437
|
+
|
|
438
|
+
const registerMatrix = () => wctx.webServer.register({
|
|
439
|
+
kind: 'exact',
|
|
440
|
+
path: MATRIX_PATH,
|
|
441
|
+
handler: async (req, res) => {
|
|
442
|
+
try {
|
|
443
|
+
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
|
|
444
|
+
if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
|
|
445
|
+
const access = await resolveToken()
|
|
446
|
+
if (!access.value) return sendJson(res, 200, { configured: false, guidance: access.guidance })
|
|
447
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
448
|
+
const org = url.searchParams.get('org') || liveConfig().owner || ''
|
|
449
|
+
const limit = Number(url.searchParams.get('limit')) || 30
|
|
450
|
+
const github = await client()
|
|
451
|
+
const result = await matrixStatus(github, { org, limit })
|
|
452
|
+
return sendJson(res, 200, { ok: true, ...result })
|
|
453
|
+
} catch (err) {
|
|
454
|
+
return sendJson(res, 500, { ok: false, error: describeError(err) })
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
})
|
|
458
|
+
if (typeof wctx.effect === 'function') wctx.effect(registerMatrix, 'dsh-github-ops: matrix route')
|
|
459
|
+
else registerMatrix()
|
|
390
460
|
|
|
391
|
-
register()
|
|
392
461
|
} catch (err) {
|
|
393
462
|
ctx.logger?.warn?.('[dsh-github-ops] panel route not registered:', err && err.message)
|
|
394
463
|
}
|
package/lib/scm-write.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { realpathSync } from 'node:fs'
|
|
1
|
+
import { realpathSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join as joinPath } from 'node:path'
|
|
3
|
+
import { hasConflicts, resolveConflictContent } from './scm.js'
|
|
2
4
|
// Writing to a working copy, from the Source Control tab.
|
|
3
5
|
//
|
|
4
6
|
// Every action is a list of argv arrays — never a shell string. The route runs them one by one
|
|
@@ -114,6 +116,14 @@ export function buildAction(action, args = {}) {
|
|
|
114
116
|
if (!CONTINUE_VERBS.includes(verb)) throw new Error(`refusing to abort an unknown operation: "${verb.slice(0, 20)}"`)
|
|
115
117
|
return result(name, [[verb, '--abort']], destructive, `abort the ${verb}`)
|
|
116
118
|
}
|
|
119
|
+
case 'resolveConflict': {
|
|
120
|
+
const path = validatePath(args.path)
|
|
121
|
+
const choice = String(args.choice || 'ours').trim()
|
|
122
|
+
if (!['ours', 'theirs', 'both'].includes(choice)) {
|
|
123
|
+
throw new Error(`invalid conflict resolution choice: "${choice}". Must be "ours", "theirs", or "both".`)
|
|
124
|
+
}
|
|
125
|
+
return result(name, [['add', '--', path]], false, `resolve conflict in ${path} using ${choice}`)
|
|
126
|
+
}
|
|
117
127
|
case 'worktreeAdd': {
|
|
118
128
|
const path = validateCwd(args.path)
|
|
119
129
|
if (!path) throw new Error('worktree path is required')
|
|
@@ -156,6 +166,7 @@ export const ACTIONS = Object.freeze([
|
|
|
156
166
|
'stashPush', 'stashApply', 'stashPop', 'stashDrop', 'stashShow',
|
|
157
167
|
'worktreeAdd', 'worktreeRemove',
|
|
158
168
|
'continue', 'abort',
|
|
169
|
+
'resolveConflict',
|
|
159
170
|
])
|
|
160
171
|
|
|
161
172
|
/** The verbs the panel offers for an unfinished operation. */
|
|
@@ -193,3 +204,22 @@ export function validateCwd(path) {
|
|
|
193
204
|
}
|
|
194
205
|
return value
|
|
195
206
|
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolves conflict markers directly in the working copy file and prepares it for staging.
|
|
211
|
+
*/
|
|
212
|
+
export function resolveConflictFile({ cwd, path, choice = 'ours', blockIndex = null }) {
|
|
213
|
+
const relPath = validatePath(path)
|
|
214
|
+
const fullPath = joinPath(cwd, relPath)
|
|
215
|
+
if (!existsSync(fullPath)) {
|
|
216
|
+
throw new Error(`file not found: ${relPath}`)
|
|
217
|
+
}
|
|
218
|
+
const content = readFileSync(fullPath, 'utf8')
|
|
219
|
+
if (!hasConflicts(content)) {
|
|
220
|
+
throw new Error(`no merge conflict markers found in ${relPath}`)
|
|
221
|
+
}
|
|
222
|
+
const resolved = resolveConflictContent(content, { choice, blockIndex })
|
|
223
|
+
writeFileSync(fullPath, resolved, 'utf8')
|
|
224
|
+
return { path: relPath, choice, blockIndex, resolved: true }
|
|
225
|
+
}
|
package/lib/scm.js
CHANGED
|
@@ -150,3 +150,187 @@ export function mergeStateFrom({ mergeHead = false, rebaseDir = false, cherryPic
|
|
|
150
150
|
if (revertHead) return { inProgress: true, kind: 'revert' }
|
|
151
151
|
return { inProgress: false, kind: '' }
|
|
152
152
|
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
const CONFLICT_START_RE = /^<<<<<<<[ \t]*(.*)$/
|
|
156
|
+
const CONFLICT_BASE_RE = /^\|\|\|\|\|\|\|[ \t]*(.*)$/
|
|
157
|
+
const CONFLICT_MID_RE = /^=======$/
|
|
158
|
+
const CONFLICT_END_RE = /^>>>>>>>[ \t]*(.*)$/
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check whether a string contains merge conflict markers.
|
|
162
|
+
*/
|
|
163
|
+
export function hasConflicts(content) {
|
|
164
|
+
if (!content || typeof content !== 'string') return false
|
|
165
|
+
return content.includes('<<<<<<<') && content.includes('=======') && content.includes('>>>>>>>')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse conflict hunks/blocks from file content.
|
|
170
|
+
*/
|
|
171
|
+
export function parseConflictBlocks(content) {
|
|
172
|
+
if (!hasConflicts(content)) return []
|
|
173
|
+
const lines = String(content).split(/\r?\n/)
|
|
174
|
+
const blocks = []
|
|
175
|
+
|
|
176
|
+
let inBlock = false
|
|
177
|
+
let inBase = false
|
|
178
|
+
let inTheirs = false
|
|
179
|
+
|
|
180
|
+
let startLine = 0
|
|
181
|
+
let oursHeader = ''
|
|
182
|
+
let baseHeader = ''
|
|
183
|
+
let theirsHeader = ''
|
|
184
|
+
let oursLines = []
|
|
185
|
+
let baseLines = []
|
|
186
|
+
let theirsLines = []
|
|
187
|
+
|
|
188
|
+
for (let i = 0; i < lines.length; i++) {
|
|
189
|
+
const line = lines[i]
|
|
190
|
+
const lineNum = i + 1
|
|
191
|
+
|
|
192
|
+
if (!inBlock) {
|
|
193
|
+
const match = CONFLICT_START_RE.exec(line)
|
|
194
|
+
if (match) {
|
|
195
|
+
inBlock = true
|
|
196
|
+
inBase = false
|
|
197
|
+
inTheirs = false
|
|
198
|
+
startLine = lineNum
|
|
199
|
+
oursHeader = match[1] || 'HEAD'
|
|
200
|
+
baseHeader = ''
|
|
201
|
+
theirsHeader = ''
|
|
202
|
+
oursLines = []
|
|
203
|
+
baseLines = []
|
|
204
|
+
theirsLines = []
|
|
205
|
+
}
|
|
206
|
+
} else if (!inTheirs) {
|
|
207
|
+
const baseMatch = CONFLICT_BASE_RE.exec(line)
|
|
208
|
+
if (baseMatch) {
|
|
209
|
+
inBase = true
|
|
210
|
+
baseHeader = baseMatch[1] || ''
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (CONFLICT_MID_RE.test(line)) {
|
|
214
|
+
inTheirs = true
|
|
215
|
+
inBase = false
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
if (inBase) {
|
|
219
|
+
baseLines.push(line)
|
|
220
|
+
} else {
|
|
221
|
+
oursLines.push(line)
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
const endMatch = CONFLICT_END_RE.exec(line)
|
|
225
|
+
if (endMatch) {
|
|
226
|
+
theirsHeader = endMatch[1] || ''
|
|
227
|
+
const oursStr = oursLines.join('\n')
|
|
228
|
+
const theirsStr = theirsLines.join('\n')
|
|
229
|
+
const baseStr = baseLines.length > 0 ? baseLines.join('\n') : undefined
|
|
230
|
+
const bothStr = [oursStr, theirsStr].filter((s) => s.length > 0).join('\n')
|
|
231
|
+
|
|
232
|
+
blocks.push({
|
|
233
|
+
index: blocks.length,
|
|
234
|
+
startLine,
|
|
235
|
+
endLine: lineNum,
|
|
236
|
+
oursHeader,
|
|
237
|
+
theirsHeader,
|
|
238
|
+
baseHeader: baseHeader || undefined,
|
|
239
|
+
ours: oursStr,
|
|
240
|
+
theirs: theirsStr,
|
|
241
|
+
base: baseStr,
|
|
242
|
+
both: bothStr,
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
inBlock = false
|
|
246
|
+
inBase = false
|
|
247
|
+
inTheirs = false
|
|
248
|
+
} else {
|
|
249
|
+
theirsLines.push(line)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return blocks
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Resolve merge conflict markers to chosen resolution: 'ours', 'theirs', or 'both'.
|
|
259
|
+
*/
|
|
260
|
+
export function resolveConflictContent(content, resolutions = 'ours') {
|
|
261
|
+
if (!hasConflicts(content)) return content
|
|
262
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n'
|
|
263
|
+
const lines = String(content).split(/\r?\n/)
|
|
264
|
+
const resultLines = []
|
|
265
|
+
|
|
266
|
+
let inBlock = false
|
|
267
|
+
let inBase = false
|
|
268
|
+
let inTheirs = false
|
|
269
|
+
let blockIndex = 0
|
|
270
|
+
|
|
271
|
+
let oursLines = []
|
|
272
|
+
let theirsLines = []
|
|
273
|
+
|
|
274
|
+
const getChoice = (idx) => {
|
|
275
|
+
if (typeof resolutions === 'string') return resolutions
|
|
276
|
+
if (resolutions && typeof resolutions === 'object') {
|
|
277
|
+
if (Array.isArray(resolutions)) {
|
|
278
|
+
const found = resolutions.find((r) => r.blockIndex === idx)
|
|
279
|
+
return found ? found.choice : null
|
|
280
|
+
}
|
|
281
|
+
if (resolutions.blockIndex === idx) return resolutions.choice
|
|
282
|
+
if (resolutions.blockIndex === null || resolutions.blockIndex === undefined) return resolutions.choice
|
|
283
|
+
}
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (let i = 0; i < lines.length; i++) {
|
|
288
|
+
const line = lines[i]
|
|
289
|
+
|
|
290
|
+
if (!inBlock) {
|
|
291
|
+
if (CONFLICT_START_RE.test(line)) {
|
|
292
|
+
inBlock = true
|
|
293
|
+
inBase = false
|
|
294
|
+
inTheirs = false
|
|
295
|
+
oursLines = []
|
|
296
|
+
theirsLines = []
|
|
297
|
+
} else {
|
|
298
|
+
resultLines.push(line)
|
|
299
|
+
}
|
|
300
|
+
} else if (!inTheirs) {
|
|
301
|
+
if (CONFLICT_BASE_RE.test(line)) {
|
|
302
|
+
inBase = true
|
|
303
|
+
} else if (CONFLICT_MID_RE.test(line)) {
|
|
304
|
+
inTheirs = true
|
|
305
|
+
inBase = false
|
|
306
|
+
} else if (!inBase) {
|
|
307
|
+
oursLines.push(line)
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
if (CONFLICT_END_RE.test(line)) {
|
|
311
|
+
const choice = getChoice(blockIndex)
|
|
312
|
+
if (choice === 'theirs') {
|
|
313
|
+
resultLines.push(...theirsLines)
|
|
314
|
+
} else if (choice === 'both') {
|
|
315
|
+
if (oursLines.length) resultLines.push(...oursLines)
|
|
316
|
+
if (theirsLines.length) resultLines.push(...theirsLines)
|
|
317
|
+
} else if (choice === 'ours') {
|
|
318
|
+
resultLines.push(...oursLines)
|
|
319
|
+
} else {
|
|
320
|
+
resultLines.push('<<<<<<< HEAD')
|
|
321
|
+
resultLines.push(...oursLines)
|
|
322
|
+
resultLines.push('=======')
|
|
323
|
+
resultLines.push(...theirsLines)
|
|
324
|
+
resultLines.push('>>>>>>> incoming')
|
|
325
|
+
}
|
|
326
|
+
blockIndex++
|
|
327
|
+
inBlock = false
|
|
328
|
+
inTheirs = false
|
|
329
|
+
} else {
|
|
330
|
+
theirsLines.push(line)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return resultLines.join(eol)
|
|
336
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Organization repositories status matrix: releases, npm versions, CI status and issues.
|
|
2
|
+
//
|
|
3
|
+
// Read-only, concurrency-batched, cached with TTL to preserve GitHub rate limits.
|
|
4
|
+
|
|
5
|
+
const MATRIX_CACHE = new Map()
|
|
6
|
+
|
|
7
|
+
export function clearMatrixCache() {
|
|
8
|
+
MATRIX_CACHE.clear()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Determine health status badge based on CI, releases, issues.
|
|
13
|
+
*/
|
|
14
|
+
function deriveStatus(release, ci, openIssuesCount) {
|
|
15
|
+
if (ci && (ci.conclusion === 'failure' || ci.conclusion === 'timed_out')) return 'failing'
|
|
16
|
+
if (ci && (ci.status === 'in_progress' || ci.status === 'queued')) return 'building'
|
|
17
|
+
if (ci && ci.conclusion === 'success') return 'healthy'
|
|
18
|
+
if (release) return 'released'
|
|
19
|
+
return 'active'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fetch published package metadata from npm registry.
|
|
24
|
+
*/
|
|
25
|
+
async function fetchNpmVersion(pkgName, fetchFn = globalThis.fetch) {
|
|
26
|
+
if (!pkgName || typeof fetchFn !== 'function') return null
|
|
27
|
+
try {
|
|
28
|
+
const encoded = pkgName.startsWith('@')
|
|
29
|
+
? `@${encodeURIComponent(pkgName.slice(1))}`
|
|
30
|
+
: encodeURIComponent(pkgName)
|
|
31
|
+
const url = `https://registry.npmjs.org/${encoded}`
|
|
32
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
|
|
33
|
+
const timer = controller ? setTimeout(() => controller.abort(), 4000) : null
|
|
34
|
+
const res = await fetchFn(url, {
|
|
35
|
+
headers: { accept: 'application/json' },
|
|
36
|
+
signal: controller ? controller.signal : undefined,
|
|
37
|
+
})
|
|
38
|
+
if (timer) clearTimeout(timer)
|
|
39
|
+
if (!res.ok) return null
|
|
40
|
+
const data = await res.json()
|
|
41
|
+
const latest = data['dist-tags']?.latest || data.version
|
|
42
|
+
return latest ? { name: pkgName, version: latest } : null
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Gather organization matrix status.
|
|
50
|
+
*/
|
|
51
|
+
export async function matrixStatus(client, options = {}, deps = {}) {
|
|
52
|
+
const {
|
|
53
|
+
org = '',
|
|
54
|
+
limit = 30,
|
|
55
|
+
includeNpm = true,
|
|
56
|
+
includeCi = true,
|
|
57
|
+
includeReleases = true,
|
|
58
|
+
cacheTtlMs = 60000,
|
|
59
|
+
} = options
|
|
60
|
+
const { fetchFn = globalThis.fetch } = deps
|
|
61
|
+
|
|
62
|
+
const cappedLimit = Math.max(1, Math.min(Number(limit) || 30, 100))
|
|
63
|
+
|
|
64
|
+
// Determine target org / user
|
|
65
|
+
let targetOrg = String(org || '').trim()
|
|
66
|
+
if (!targetOrg) {
|
|
67
|
+
try {
|
|
68
|
+
const userRes = await client.get('/user')
|
|
69
|
+
targetOrg = userRes.data?.login || ''
|
|
70
|
+
} catch {
|
|
71
|
+
targetOrg = ''
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!targetOrg) {
|
|
75
|
+
throw new Error('an organization or user name is required')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check cache
|
|
79
|
+
const cacheKey = `${targetOrg}:${cappedLimit}:${includeNpm}:${includeCi}:${includeReleases}`
|
|
80
|
+
const cached = MATRIX_CACHE.get(cacheKey)
|
|
81
|
+
if (cached && (Date.now() - cached.timestamp < cacheTtlMs)) {
|
|
82
|
+
return { ...cached.data, cached: true }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fetch repositories
|
|
86
|
+
let rawRepos = []
|
|
87
|
+
try {
|
|
88
|
+
const orgRes = await client.get(`/orgs/${targetOrg}/repos`, {
|
|
89
|
+
query: { per_page: cappedLimit, sort: 'pushed', direction: 'desc' },
|
|
90
|
+
})
|
|
91
|
+
rawRepos = Array.isArray(orgRes.data) ? orgRes.data : []
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// Fallback to user repos if not an org
|
|
94
|
+
try {
|
|
95
|
+
const userRes = await client.get(`/users/${targetOrg}/repos`, {
|
|
96
|
+
query: { per_page: cappedLimit, sort: 'pushed', direction: 'desc' },
|
|
97
|
+
})
|
|
98
|
+
rawRepos = Array.isArray(userRes.data) ? userRes.data : []
|
|
99
|
+
} catch {
|
|
100
|
+
rawRepos = []
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const repos = rawRepos.filter((r) => !r.archived).slice(0, cappedLimit)
|
|
105
|
+
|
|
106
|
+
// Concurrency batching: process in chunks of 5
|
|
107
|
+
const matrix = []
|
|
108
|
+
const chunkSize = 5
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < repos.length; i += chunkSize) {
|
|
111
|
+
const chunk = repos.slice(i, i + chunkSize)
|
|
112
|
+
const chunkResults = await Promise.all(
|
|
113
|
+
chunk.map(async (repo) => {
|
|
114
|
+
const owner = repo.owner?.login || targetOrg
|
|
115
|
+
const repoName = repo.name
|
|
116
|
+
|
|
117
|
+
let release = null
|
|
118
|
+
let ci = null
|
|
119
|
+
let npm = null
|
|
120
|
+
let repoError = null
|
|
121
|
+
|
|
122
|
+
// 1. Release / Tag
|
|
123
|
+
if (includeReleases) {
|
|
124
|
+
try {
|
|
125
|
+
const relRes = await client.get(`/repos/${owner}/${repoName}/releases/latest`)
|
|
126
|
+
if (relRes.data && relRes.data.tag_name) {
|
|
127
|
+
release = {
|
|
128
|
+
tag: relRes.data.tag_name,
|
|
129
|
+
name: relRes.data.name || relRes.data.tag_name,
|
|
130
|
+
publishedAt: relRes.data.published_at,
|
|
131
|
+
prerelease: Boolean(relRes.data.prerelease),
|
|
132
|
+
htmlUrl: relRes.data.html_url,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Fallback to tags
|
|
137
|
+
try {
|
|
138
|
+
const tagsRes = await client.get(`/repos/${owner}/${repoName}/tags`, { query: { per_page: 1 } })
|
|
139
|
+
if (Array.isArray(tagsRes.data) && tagsRes.data[0]) {
|
|
140
|
+
const tag = tagsRes.data[0]
|
|
141
|
+
release = {
|
|
142
|
+
tag: tag.name,
|
|
143
|
+
name: tag.name,
|
|
144
|
+
htmlUrl: `${repo.html_url}/releases/tag/${tag.name}`,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// no release or tag
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2. Latest CI Run
|
|
154
|
+
if (includeCi) {
|
|
155
|
+
try {
|
|
156
|
+
const runsRes = await client.get(`/repos/${owner}/${repoName}/actions/runs`, { query: { per_page: 1 } })
|
|
157
|
+
const runs = runsRes.data?.workflow_runs
|
|
158
|
+
if (Array.isArray(runs) && runs[0]) {
|
|
159
|
+
const run = runs[0]
|
|
160
|
+
ci = {
|
|
161
|
+
id: run.id,
|
|
162
|
+
name: run.name || 'CI',
|
|
163
|
+
status: run.status,
|
|
164
|
+
conclusion: run.conclusion,
|
|
165
|
+
htmlUrl: run.html_url,
|
|
166
|
+
createdAt: run.created_at,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// CI not available or actions disabled
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 3. npm version
|
|
175
|
+
if (includeNpm) {
|
|
176
|
+
// Check package.json in repo first for exact name
|
|
177
|
+
let pkgName = `@${targetOrg}/${repoName}`
|
|
178
|
+
try {
|
|
179
|
+
const pkgFileRes = await client.get(`/repos/${owner}/${repoName}/contents/package.json`)
|
|
180
|
+
if (pkgFileRes.data && pkgFileRes.data.content) {
|
|
181
|
+
const raw = Buffer.from(pkgFileRes.data.content, 'base64').toString('utf8')
|
|
182
|
+
const parsed = JSON.parse(raw)
|
|
183
|
+
if (parsed.name) pkgName = parsed.name
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// package.json missing or unparseable
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
npm = await fetchNpmVersion(pkgName, fetchFn)
|
|
190
|
+
if (!npm && pkgName !== repoName) {
|
|
191
|
+
npm = await fetchNpmVersion(repoName, fetchFn)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const openIssuesCount = Number(repo.open_issues_count) || 0
|
|
196
|
+
const status = deriveStatus(release, ci, openIssuesCount)
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
name: repoName,
|
|
200
|
+
fullName: repo.full_name || `${owner}/${repoName}`,
|
|
201
|
+
private: Boolean(repo.private),
|
|
202
|
+
description: repo.description || '',
|
|
203
|
+
stars: repo.stargazers_count || 0,
|
|
204
|
+
openIssuesCount,
|
|
205
|
+
defaultBranch: repo.default_branch || 'main',
|
|
206
|
+
pushedAt: repo.pushed_at,
|
|
207
|
+
htmlUrl: repo.html_url,
|
|
208
|
+
release,
|
|
209
|
+
ci,
|
|
210
|
+
npm,
|
|
211
|
+
status,
|
|
212
|
+
error: repoError,
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
)
|
|
216
|
+
matrix.push(...chunkResults)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const resultData = {
|
|
220
|
+
org: targetOrg,
|
|
221
|
+
totalRepos: matrix.length,
|
|
222
|
+
cached: false,
|
|
223
|
+
timestamp: new Date().toISOString(),
|
|
224
|
+
matrix,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
MATRIX_CACHE.set(cacheKey, { timestamp: Date.now(), data: resultData })
|
|
228
|
+
return resultData
|
|
229
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { triageExternalIssues } from './triage.js'
|
|
1
2
|
// Collaboration tool registrations: issues, pull requests, reviews and checks.
|
|
2
3
|
//
|
|
3
4
|
// Reads are free; every write goes through the caller's confirmation contour in the
|
|
@@ -356,4 +357,30 @@ export function registerCollabTools({ tool, asRepo, client, liveConfig, reviewRu
|
|
|
356
357
|
owner, repo, number: args.number,
|
|
357
358
|
})),
|
|
358
359
|
)
|
|
360
|
+
|
|
361
|
+
// ------------------------------------------------------------- triage
|
|
362
|
+
|
|
363
|
+
tool(
|
|
364
|
+
'gh_triage_external_issues',
|
|
365
|
+
'Triage external GitHub issues into the Gitea issue tracker. Compares GitHub issues against existing Gitea tasks and prepares canonical issue drafts. Set sync: true with confirm: true to create missing tasks in Gitea.',
|
|
366
|
+
repoParams({
|
|
367
|
+
giteaOwner: { type: 'string', description: 'Target Gitea repository owner (default "goodandready").' },
|
|
368
|
+
giteaRepo: { type: 'string', description: 'Target Gitea repository name (defaults to repository name).' },
|
|
369
|
+
state: { type: 'string', description: 'GitHub issue state: open (default), closed, or all.' },
|
|
370
|
+
limit: { type: 'number', description: 'Max issues to triage (default 20, max 50).' },
|
|
371
|
+
sync: { type: 'boolean', description: 'Create missing issues in Gitea tracker (default false).' },
|
|
372
|
+
confirm: { type: 'boolean', description: 'Required when sync is true.' },
|
|
373
|
+
giteaBaseUrl: { type: 'string', description: 'Gitea API base URL (default "http://127.0.0.1:3005").' },
|
|
374
|
+
}),
|
|
375
|
+
async (args) => withRepo(args, async ({ owner, repo }) => triageExternalIssues(await client(), {
|
|
376
|
+
repository: `${owner}/${repo}`,
|
|
377
|
+
giteaOwner: args.giteaOwner,
|
|
378
|
+
giteaRepo: args.giteaRepo,
|
|
379
|
+
state: args.state,
|
|
380
|
+
limit: args.limit,
|
|
381
|
+
sync: args.sync === true,
|
|
382
|
+
confirm: args.confirm === true,
|
|
383
|
+
giteaBaseUrl: args.giteaBaseUrl,
|
|
384
|
+
})),
|
|
385
|
+
)
|
|
359
386
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matrixStatus } from './matrix.js'
|
|
1
2
|
// Workflow-run and repository-settings tool registrations.
|
|
2
3
|
//
|
|
3
4
|
// Reads are free; writes follow the plugin's model — an explicit confirm flag for the
|
|
@@ -325,4 +326,31 @@ export function registerOpsTools({ tool, asRepo, client, liveConfig }) {
|
|
|
325
326
|
return withRepo(args, async ({ owner, repo }) => deleteBranchProtection(await client(), { owner, repo, branch: args.branch }))
|
|
326
327
|
},
|
|
327
328
|
)
|
|
329
|
+
|
|
330
|
+
// -------------------------------------------------------- matrix status
|
|
331
|
+
|
|
332
|
+
tool(
|
|
333
|
+
'gh_matrix_status',
|
|
334
|
+
'Get organization repositories status matrix: latest release/tag, npm version, CI status and open issues. Read-only.',
|
|
335
|
+
{
|
|
336
|
+
org: { type: 'string', description: 'Organization or user name. Omit to infer from settings or current account.' },
|
|
337
|
+
limit: { type: 'number', description: 'Max repositories to inspect (default 30, max 100).' },
|
|
338
|
+
includeNpm: { type: 'boolean', description: 'Query npm registry for published version (default true).' },
|
|
339
|
+
includeCi: { type: 'boolean', description: 'Query GitHub Actions for latest workflow run status (default true).' },
|
|
340
|
+
includeReleases: { type: 'boolean', description: 'Query latest release or tag (default true).' },
|
|
341
|
+
cacheTtlMs: { type: 'number', description: 'Cache TTL in milliseconds (default 60000).' },
|
|
342
|
+
},
|
|
343
|
+
async (args) => {
|
|
344
|
+
const cfg = typeof liveConfig === 'function' ? liveConfig() : {}
|
|
345
|
+
const targetOrg = args.org || cfg.owner || ''
|
|
346
|
+
return matrixStatus(await client(), {
|
|
347
|
+
org: targetOrg,
|
|
348
|
+
limit: args.limit,
|
|
349
|
+
includeNpm: args.includeNpm !== false,
|
|
350
|
+
includeCi: args.includeCi !== false,
|
|
351
|
+
includeReleases: args.includeReleases !== false,
|
|
352
|
+
cacheTtlMs: args.cacheTtlMs,
|
|
353
|
+
})
|
|
354
|
+
},
|
|
355
|
+
)
|
|
328
356
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Triage external GitHub issues into the local Gitea project registry.
|
|
2
|
+
//
|
|
3
|
+
// Reads public GitHub issues, identifies whether they have already been triaged
|
|
4
|
+
// into Gitea, drafts canonical issues conforming to the project standard, and optionally
|
|
5
|
+
// creates missing issues when sync: true and confirm: true are supplied.
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
+
|
|
9
|
+
export function resolveGiteaToken(explicitToken) {
|
|
10
|
+
if (explicitToken) return explicitToken
|
|
11
|
+
if (typeof process !== 'undefined' && process.env?.GITEA_TOKEN) {
|
|
12
|
+
return process.env.GITEA_TOKEN
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const credPath = '/mnt/external/Project/DEV/.gitea-agent-credentials.json'
|
|
16
|
+
if (existsSync(credPath)) {
|
|
17
|
+
const data = JSON.parse(readFileSync(credPath, 'utf8'))
|
|
18
|
+
return data.agents?.antigravity?.token || ''
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
// ignore
|
|
22
|
+
}
|
|
23
|
+
return ''
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatGiteaDraft({ owner, repo, issue }) {
|
|
27
|
+
const isBug = (issue.labels || []).some((l) => {
|
|
28
|
+
const name = (typeof l === 'string' ? l : l.name || '').toLowerCase()
|
|
29
|
+
return name.includes('bug') || name.includes('fix') || name.includes('error')
|
|
30
|
+
})
|
|
31
|
+
const isHigh = (issue.labels || []).some((l) => {
|
|
32
|
+
const name = (typeof l === 'string' ? l : l.name || '').toLowerCase()
|
|
33
|
+
return name.includes('critical') || name.includes('urgent') || name.includes('security')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const priorityPrefix = isHigh ? 'H:' : 'M:'
|
|
37
|
+
const priorityLabel = isHigh ? 'priority/high' : 'priority/medium'
|
|
38
|
+
const typeLabel = isBug ? 'type/bug' : 'type/feature'
|
|
39
|
+
const typeStr = isBug ? 'fix' : 'feat'
|
|
40
|
+
|
|
41
|
+
const title = `${priorityPrefix} ${typeStr}(ext): ${issue.title} (GH#${issue.number})`
|
|
42
|
+
|
|
43
|
+
const body = [
|
|
44
|
+
'### 1. Источник и контекст',
|
|
45
|
+
`Входящая внешняя issue: [${owner}/${repo}#${issue.number}](${issue.html_url})`,
|
|
46
|
+
`Автор: @${(issue.user && issue.user.login) || 'unknown'}`,
|
|
47
|
+
`Создана: ${issue.created_at || 'unknown'}`,
|
|
48
|
+
'',
|
|
49
|
+
'### 2. Описание проблемы',
|
|
50
|
+
issue.body ? issue.body.trim() : 'Описание отсутствует.',
|
|
51
|
+
'',
|
|
52
|
+
'### 3. Что должно быть сделано',
|
|
53
|
+
'- Проанализировать входящий сигнал из внешнего GitHub-репозитория.',
|
|
54
|
+
'- Определить применимость в локальном проекте Gitea.',
|
|
55
|
+
'- Реализовать решение или сформировать обоснованный ответ.',
|
|
56
|
+
'',
|
|
57
|
+
'### 4. Критерии приёмки',
|
|
58
|
+
'- Решение протестировано при внедрении.',
|
|
59
|
+
'- Первоисточник связан и закрыт после релиза.',
|
|
60
|
+
].join('\n')
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
title,
|
|
64
|
+
body,
|
|
65
|
+
labels: [priorityLabel, typeLabel, 'status/confirmed'],
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isAlreadyTriaged(ghIssue, giteaIssues, { owner, repo }) {
|
|
70
|
+
const ghNumber = Number(ghIssue.number)
|
|
71
|
+
const ghUrl = `github.com/${owner}/${repo}/issues/${ghNumber}`
|
|
72
|
+
const ghMarker = `GH#${ghNumber}`
|
|
73
|
+
const ghTag = `[GH-${ghNumber}]`
|
|
74
|
+
const ghAlt = `(GH#${ghNumber})`
|
|
75
|
+
|
|
76
|
+
for (const gi of giteaIssues) {
|
|
77
|
+
const title = gi.title || ''
|
|
78
|
+
const body = gi.body || ''
|
|
79
|
+
if (
|
|
80
|
+
body.includes(ghUrl) ||
|
|
81
|
+
title.includes(ghMarker) ||
|
|
82
|
+
title.includes(ghTag) ||
|
|
83
|
+
title.includes(ghAlt) ||
|
|
84
|
+
body.includes(ghMarker) ||
|
|
85
|
+
body.includes(ghTag)
|
|
86
|
+
) {
|
|
87
|
+
return { triaged: true, giteaIssueNumber: gi.number, giteaIssueUrl: gi.html_url }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { triaged: false, giteaIssueNumber: null, giteaIssueUrl: null }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function triageExternalIssues(client, options = {}, deps = {}) {
|
|
94
|
+
const {
|
|
95
|
+
repository = '',
|
|
96
|
+
giteaOwner = 'goodandready',
|
|
97
|
+
giteaRepo = '',
|
|
98
|
+
state = 'open',
|
|
99
|
+
limit = 20,
|
|
100
|
+
sync = false,
|
|
101
|
+
confirm = false,
|
|
102
|
+
giteaBaseUrl = 'http://127.0.0.1:3005',
|
|
103
|
+
giteaToken = '',
|
|
104
|
+
} = options
|
|
105
|
+
const { fetchFn = globalThis.fetch, giteaClient = null } = deps
|
|
106
|
+
|
|
107
|
+
if (!repository || !repository.includes('/')) {
|
|
108
|
+
throw new Error('repository must be in "owner/repo" format')
|
|
109
|
+
}
|
|
110
|
+
const [owner, repo] = repository.split('/')
|
|
111
|
+
const targetGiteaRepo = giteaRepo || repo
|
|
112
|
+
|
|
113
|
+
if (sync && confirm !== true) {
|
|
114
|
+
throw new Error('confirm: true is required when sync is true to create issues in Gitea')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const cappedLimit = Math.max(1, Math.min(Number(limit) || 20, 50))
|
|
118
|
+
|
|
119
|
+
// 1. Fetch GitHub issues
|
|
120
|
+
const { data: rawIssues } = await client.get(`/repos/${owner}/${repo}/issues`, {
|
|
121
|
+
query: { state, per_page: cappedLimit, sort: 'created', direction: 'desc' },
|
|
122
|
+
})
|
|
123
|
+
const ghIssues = (Array.isArray(rawIssues) ? rawIssues : [])
|
|
124
|
+
.filter((i) => !i.pull_request)
|
|
125
|
+
.slice(0, cappedLimit)
|
|
126
|
+
|
|
127
|
+
// 2. Fetch Gitea issues
|
|
128
|
+
let giteaIssues = []
|
|
129
|
+
const token = resolveGiteaToken(giteaToken)
|
|
130
|
+
|
|
131
|
+
if (giteaClient && typeof giteaClient.getIssues === 'function') {
|
|
132
|
+
giteaIssues = await giteaClient.getIssues({ owner: giteaOwner, repo: targetGiteaRepo })
|
|
133
|
+
} else if (typeof fetchFn === 'function') {
|
|
134
|
+
try {
|
|
135
|
+
const giteaUrl = `${giteaBaseUrl.replace(/\/$/, '')}/api/v1/repos/${giteaOwner}/${targetGiteaRepo}/issues?state=all&limit=100`
|
|
136
|
+
const headers = { accept: 'application/json' }
|
|
137
|
+
if (token) headers.authorization = `token ${token}`
|
|
138
|
+
const res = await fetchFn(giteaUrl, { headers })
|
|
139
|
+
if (res.ok) {
|
|
140
|
+
giteaIssues = await res.json()
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
giteaIssues = []
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 3. Process each issue
|
|
148
|
+
const items = []
|
|
149
|
+
for (const ghIssue of ghIssues) {
|
|
150
|
+
const match = isAlreadyTriaged(ghIssue, giteaIssues, { owner, repo })
|
|
151
|
+
const draft = formatGiteaDraft({ owner, repo, issue: ghIssue })
|
|
152
|
+
|
|
153
|
+
let synced = false
|
|
154
|
+
let createdIssue = null
|
|
155
|
+
|
|
156
|
+
if (!match.triaged && sync && confirm) {
|
|
157
|
+
if (giteaClient && typeof giteaClient.createIssue === 'function') {
|
|
158
|
+
createdIssue = await giteaClient.createIssue({
|
|
159
|
+
owner: giteaOwner,
|
|
160
|
+
repo: targetGiteaRepo,
|
|
161
|
+
draft,
|
|
162
|
+
})
|
|
163
|
+
synced = true
|
|
164
|
+
} else if (typeof fetchFn === 'function') {
|
|
165
|
+
try {
|
|
166
|
+
const postUrl = `${giteaBaseUrl.replace(/\/$/, '')}/api/v1/repos/${giteaOwner}/${targetGiteaRepo}/issues`
|
|
167
|
+
const headers = {
|
|
168
|
+
'content-type': 'application/json',
|
|
169
|
+
accept: 'application/json',
|
|
170
|
+
}
|
|
171
|
+
if (token) headers.authorization = `token ${token}`
|
|
172
|
+
const postRes = await fetchFn(postUrl, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers,
|
|
175
|
+
body: JSON.stringify({
|
|
176
|
+
title: draft.title,
|
|
177
|
+
body: draft.body,
|
|
178
|
+
}),
|
|
179
|
+
})
|
|
180
|
+
if (postRes.ok) {
|
|
181
|
+
createdIssue = await postRes.json()
|
|
182
|
+
synced = true
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
createdIssue = { error: String((err && err.message) || err) }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
items.push({
|
|
191
|
+
githubIssueNumber: ghIssue.number,
|
|
192
|
+
title: ghIssue.title,
|
|
193
|
+
htmlUrl: ghIssue.html_url,
|
|
194
|
+
state: ghIssue.state,
|
|
195
|
+
alreadyTriaged: match.triaged,
|
|
196
|
+
giteaIssueNumber: match.giteaIssueNumber || (createdIssue && createdIssue.number) || null,
|
|
197
|
+
giteaIssueUrl: match.giteaIssueUrl || (createdIssue && createdIssue.html_url) || null,
|
|
198
|
+
draft,
|
|
199
|
+
synced,
|
|
200
|
+
createdIssue,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
repository: `${owner}/${repo}`,
|
|
206
|
+
giteaRepository: `${giteaOwner}/${targetGiteaRepo}`,
|
|
207
|
+
totalExternalIssues: items.length,
|
|
208
|
+
triagedCount: items.filter((i) => i.alreadyTriaged).length,
|
|
209
|
+
pendingCount: items.filter((i) => !i.alreadyTriaged).length,
|
|
210
|
+
syncedCount: items.filter((i) => i.synced).length,
|
|
211
|
+
items,
|
|
212
|
+
}
|
|
213
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-github-ops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "GitHub operations for DeepSeek Harness: releases, tags, a guarded generic API pass-through, workflow runs, secrets and sanitized mirror publication.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|