@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.
@@ -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.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",