@goodandready/dsh-github-ops 0.4.1 → 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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Features
6
+ - **Organization Repositories Status Matrix** (#48): Added `gh_matrix_status` tool and dedicated `/dsh-github-ops/matrix` route (plus `what=matrix` in sidebar panel) collecting releases/tags, npm published versions, CI workflow run status, and open issue counts with in-memory TTL caching and graceful degradation. Added interactive Organization Matrix mode and table with status badges in the sidebar panel.
7
+ - **External GitHub Issues Triage to Gitea Registry** (#66): Added `gh_triage_external_issues` tool scanning public GitHub repositories for open issues, cross-referencing against Gitea issue tracker via issue URLs and markers, generating canonical 5-section draft issues with priority and type labels, and providing optional automated creation via `sync: true` and `confirm: true`.
8
+ - **Visual Merge Conflict Resolution in Source Control** (#49): Added merge conflict hunk parsing (`parseConflictBlocks`, `hasConflicts`, `resolveConflictContent`) supporting both 2-way and diff3 markers. Added `resolveConflict` action with auto-staging (`git add`), conflict detection in SCM route, and interactive diff banner in the Web UI with one-click actions: "Accept Current (Ours)", "Accept Incoming (Theirs)", and "Accept Both".
9
+
3
10
  ## 0.4.1
4
11
 
5
12
  ### Fixed
package/lib/client.js CHANGED
@@ -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({ phase: 'ready', text: payload.diff || '' }))
358
- .catch((err) => setDiff({ phase: 'error', text: String((err && err.message) || err) }))
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-row' },
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
- return sendJson(res, 200, { file, staged, diff: String(text || '').slice(0, 200000) })
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) })
@@ -390,6 +434,30 @@ export function registerRoutes(ctx, deps = {}) {
390
434
  })
391
435
  if (typeof wctx.effect === 'function') wctx.effect(registerMirror, 'dsh-github-ops: mirror route')
392
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()
460
+
393
461
  } catch (err) {
394
462
  ctx.logger?.warn?.('[dsh-github-ops] panel route not registered:', err && err.message)
395
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.4.1",
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",