@goodandready/dsh-github-ops 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/lib/client.js +259 -7
- package/lib/events.js +201 -0
- package/lib/index.js +31 -15
- package/lib/routes.js +144 -6
- package/lib/scm-write.js +58 -1
- package/lib/scm.js +290 -0
- package/lib/tools/matrix.js +229 -0
- package/lib/tools/register-collab.js +27 -0
- package/lib/tools/register-ops.js +28 -0
- package/lib/tools/triage.js +213 -0
- package/package.json +1 -1
|
@@ -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.6.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",
|