dsh-github-router 0.1.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,166 @@
1
+ /**
2
+ * File aggregation: api contents (base64) → raw.githubusercontent.com
3
+ * (direct/proxy) → user-configured mirrors → git protocol (local repo or
4
+ * the plugin fetch cache). Read-only by construction.
5
+ * @module dsh-github-router/core/file
6
+ */
7
+ import { effectiveProxy } from '../config.js'
8
+ import { fetchText } from '../net.js'
9
+ import { GitFetchCache, findLocalRepo, gitVersion, readCacheFile, readLocalFile } from '../routes/git.js'
10
+ import { fetchViaMirrors } from '../routes/mirror.js'
11
+ import { guardByteCount, guardFilePath, guardOwnerRepo, guardRef } from '../util.js'
12
+ import { attemptLadder, buildRuntime } from './runtime.js'
13
+
14
+ const SHA_RE = /^[0-9a-f]{40}$/i
15
+
16
+ function decodeBase64(content) {
17
+ try {
18
+ return Buffer.from(content, 'base64').toString('utf8')
19
+ } catch {
20
+ return String(content ?? '')
21
+ }
22
+ }
23
+
24
+ export async function aggregateFile(ctx, options, args) {
25
+ const owner = guardOwnerRepo(args.owner, 'owner')
26
+ const repo = guardOwnerRepo(args.repo, 'repo')
27
+ const path = guardFilePath(args.path)
28
+ const ref = args.ref === undefined || args.ref === null || String(args.ref).trim() === '' ? 'HEAD' : guardRef(args.ref, 'ref')
29
+ const maxBytes = guardByteCount(args.maxBytes, 'maxBytes', 262144, 4096, options.maxBytes)
30
+ const { cwd, signal } = args
31
+
32
+ const runtime = buildRuntime(ctx, options)
33
+ const { subprocess, fetchImpl, cache, api } = runtime
34
+ const token = await runtime.resolveToken()
35
+ const notes = []
36
+ const failures = []
37
+ let result = null
38
+
39
+ // ------------------------------------------------------------ 1) api route
40
+ if (options.routes.api) {
41
+ try {
42
+ const attempt = await attemptLadder(
43
+ (proxy) =>
44
+ api.getJson(`/repos/${owner}/${repo}/contents/${path}`, {
45
+ token,
46
+ signal,
47
+ proxy,
48
+ cache,
49
+ ttlMs: options.cacheTtlSeconds.content,
50
+ forceRefresh: args.forceRefresh === true,
51
+ fetchImpl,
52
+ query: { ref },
53
+ }),
54
+ options,
55
+ )
56
+ const value = attempt.value
57
+ if (Array.isArray(value)) {
58
+ return {
59
+ kind: 'directory',
60
+ path,
61
+ ref,
62
+ entries: value.slice(0, 200).map((e) => ({ name: e.name ?? null, type: e.type ?? null, size: e.size ?? null })),
63
+ route: 'api',
64
+ notes,
65
+ }
66
+ }
67
+ if (value && typeof value === 'object' && typeof value.content === 'string') {
68
+ const size = value.size ?? Buffer.from(value.content, 'base64').length
69
+ if (size <= maxBytes) {
70
+ result = { kind: 'file', path, ref: value.sha ?? ref, content: decodeBase64(value.content), size, truncated: false, route: 'api', notes }
71
+ } else {
72
+ notes.push(`api contents: file is ${size} bytes (cap ${maxBytes}); falling through to raw/git routes`)
73
+ }
74
+ }
75
+ } catch (error) {
76
+ failures.push(`api: ${String(error && error.message ? error.message : error).slice(0, 200)}`)
77
+ }
78
+ }
79
+
80
+ // ------------------------------------------------------- 2) raw/mirror routes
81
+ if (result === null) {
82
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path}`
83
+ try {
84
+ const attempt = await attemptLadder(
85
+ (proxy) =>
86
+ fetchText(rawUrl, {
87
+ headers: { accept: 'text/plain', 'user-agent': 'dsh-github-router/0.1.0' },
88
+ timeoutMs: proxy !== undefined ? options.proxyTimeoutMs : options.directTimeoutMs,
89
+ proxy,
90
+ signal,
91
+ maxBytes,
92
+ retries: 0,
93
+ }),
94
+ options,
95
+ )
96
+ if (attempt.value.ok) {
97
+ result = { kind: 'file', path, ref, content: attempt.value.text, size: attempt.value.body.length, truncated: attempt.value.truncated, route: attempt.via === 'proxy' ? 'raw-proxy' : 'raw', notes }
98
+ } else {
99
+ failures.push(`raw: HTTP ${attempt.value.status}`)
100
+ }
101
+ } catch (error) {
102
+ failures.push(`raw: ${String(error && error.message ? error.message : error).slice(0, 200)}`)
103
+ }
104
+ }
105
+
106
+ if (result === null && options.routes.mirror && options.mirrors.length > 0) {
107
+ try {
108
+ const via = await fetchViaMirrors(owner, repo, ref, path, options.mirrors, {
109
+ timeoutMs: options.directTimeoutMs,
110
+ signal,
111
+ maxBytes,
112
+ })
113
+ result = { kind: 'file', path, ref, content: via.content, size: via.size, truncated: via.truncated, route: 'mirror', notes }
114
+ } catch (error) {
115
+ failures.push(`mirror: ${String(error && error.message ? error.message : error).slice(0, 200)}`)
116
+ }
117
+ }
118
+
119
+ // ------------------------------------------------------------ 3) git route
120
+ if (result === null && options.routes.git && subprocess !== undefined) {
121
+ let gitUsable = false
122
+ try {
123
+ gitUsable = await gitVersion(subprocess, signal)
124
+ } catch { gitUsable = false }
125
+ if (gitUsable) {
126
+ try {
127
+ const local = await findLocalRepo(subprocess, cwd, owner, repo, options.repos, args.localRepo)
128
+ if (local !== null) {
129
+ const read = await readLocalFile(subprocess, local, ref, path, { signal, maxStdout: maxBytes })
130
+ result = { kind: 'file', path, ref, content: read.content, size: Buffer.byteLength(read.content, 'utf8'), truncated: read.truncated, route: 'git-local', notes }
131
+ } else {
132
+ const cacheRepo = new GitFetchCache(subprocess, options.gitCacheDir)
133
+ const proxy = effectiveProxy(options)
134
+ let targetRef
135
+ let dir
136
+ if (SHA_RE.test(ref)) {
137
+ dir = (await cacheRepo.fetchSha(owner, repo, ref, { proxy, signal })).dir
138
+ targetRef = ref
139
+ } else if (ref === 'HEAD') {
140
+ dir = (await cacheRepo.fetchHead(owner, repo, { proxy, signal })).dir
141
+ targetRef = 'FETCH_HEAD'
142
+ } else {
143
+ dir = (await cacheRepo.fetchRef(owner, repo, ref, { proxy, signal })).dir
144
+ targetRef = `refs/gh-router/${ref}`
145
+ }
146
+ const read = await readCacheFile(subprocess, dir, targetRef, path, { signal, maxStdout: maxBytes })
147
+ result = { kind: 'file', path, ref, content: read.content, size: Buffer.byteLength(read.content, 'utf8'), truncated: read.truncated, route: 'git-cache', notes }
148
+ }
149
+ } catch (error) {
150
+ failures.push(`git: ${String(error && error.message ? error.message : error).slice(0, 200)}`)
151
+ }
152
+ } else {
153
+ failures.push('git: git not available')
154
+ }
155
+ }
156
+
157
+ if (result === null) {
158
+ return {
159
+ error: `all routes failed to load ${owner}/${repo} @ ${ref}: ${path}`,
160
+ failures,
161
+ notes,
162
+ }
163
+ }
164
+ result.failures = failures
165
+ return result
166
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Issue aggregation: api.github.com → gh CLI → page HTML, with per-part
3
+ * route attribution and structured failure.
4
+ * @module dsh-github-router/core/issue
5
+ */
6
+ import { ghAuthed, ghIssueView, ghVersion } from '../routes/gh.js'
7
+ import { fetchAndParsePage } from '../routes/html.js'
8
+ import { guardByteCount, guardOwnerRepo, guardPrNumber, truncateText } from '../util.js'
9
+ import { attemptLadder, buildRuntime, rateNote } from './runtime.js'
10
+
11
+ const BODY_CAP = 20000
12
+
13
+ function shapeApiIssue(meta, owner, repo, number) {
14
+ return {
15
+ owner,
16
+ repo,
17
+ number,
18
+ title: meta.title ?? null,
19
+ body: truncateText(meta.body ?? '', BODY_CAP).text,
20
+ state: meta.state ?? null,
21
+ stateReason: meta.state_reason ?? null,
22
+ author: meta.user && meta.user.login ? meta.user.login : null,
23
+ createdAt: meta.created_at ?? null,
24
+ updatedAt: meta.updated_at ?? null,
25
+ closedAt: meta.closed_at ?? null,
26
+ commentsCount: meta.comments ?? null,
27
+ labels: Array.isArray(meta.labels) ? meta.labels.map((l) => l.name ?? null).filter((n) => n !== null) : [],
28
+ url: meta.html_url ?? null,
29
+ }
30
+ }
31
+
32
+ function shapeGhIssue(json, owner, repo, number) {
33
+ return {
34
+ owner,
35
+ repo,
36
+ number,
37
+ title: json.title ?? null,
38
+ body: truncateText(json.body ?? '', BODY_CAP).text,
39
+ state: json.state ?? null,
40
+ stateReason: json.stateReason ?? null,
41
+ author: json.author && json.author.login ? json.author.login : null,
42
+ createdAt: json.createdAt ?? null,
43
+ updatedAt: json.updatedAt ?? null,
44
+ closedAt: json.closedAt ?? null,
45
+ commentsCount: Array.isArray(json.comments) ? json.comments.length : null,
46
+ labels: Array.isArray(json.labels) ? json.labels.map((l) => (typeof l === 'string' ? l : l.name ?? null)).filter((n) => n !== null) : [],
47
+ url: null,
48
+ }
49
+ }
50
+
51
+ function capItems(items, maxItems, notes, label) {
52
+ if (items.length <= maxItems) return items
53
+ notes.push(`${label}: showing ${maxItems} of ${items.length}`)
54
+ return items.slice(0, maxItems)
55
+ }
56
+
57
+ export async function aggregateIssue(ctx, options, args) {
58
+ const owner = guardOwnerRepo(args.owner, 'owner')
59
+ const repo = guardOwnerRepo(args.repo, 'repo')
60
+ const number = guardPrNumber(args.number, 'number')
61
+ const maxComments = guardByteCount(args.maxComments, 'maxComments', 50, 5, 200)
62
+ const forceRefresh = args.forceRefresh === true
63
+ const { signal } = args
64
+
65
+ const runtime = buildRuntime(ctx, options)
66
+ const { subprocess, fetchImpl, cache, api } = runtime
67
+ const token = await runtime.resolveToken()
68
+ const state = {
69
+ issue: null,
70
+ comments: [],
71
+ routes: { meta: null, comments: null },
72
+ notes: [],
73
+ }
74
+
75
+ if (options.routes.api) {
76
+ try {
77
+ const attempt = await attemptLadder(
78
+ (proxy) =>
79
+ api.getJson(`/repos/${owner}/${repo}/issues/${number}`, {
80
+ token,
81
+ signal,
82
+ proxy,
83
+ cache,
84
+ ttlMs: options.cacheTtlSeconds.meta,
85
+ forceRefresh,
86
+ fetchImpl,
87
+ }),
88
+ options,
89
+ )
90
+ state.issue = shapeApiIssue(attempt.value, owner, repo, number)
91
+ state.routes.meta = 'api'
92
+ rateNote(attempt.value._headers, state.notes)
93
+ } catch (error) {
94
+ state.notes.push(`api issue: ${String(error && error.message ? error.message : error).slice(0, 220)}`)
95
+ }
96
+ }
97
+
98
+ if (state.issue !== null) {
99
+ try {
100
+ const attempt = await attemptLadder(
101
+ (proxy) =>
102
+ api.getJson(`/repos/${owner}/${repo}/issues/${number}/comments`, {
103
+ token,
104
+ signal,
105
+ proxy,
106
+ cache,
107
+ ttlMs: options.cacheTtlSeconds.meta,
108
+ forceRefresh,
109
+ fetchImpl,
110
+ query: { per_page: 100 },
111
+ }),
112
+ options,
113
+ )
114
+ state.comments = capItems(
115
+ (Array.isArray(attempt.value) ? attempt.value : []).map((c) => ({
116
+ kind: 'comment',
117
+ author: c.user && c.user.login ? c.user.login : null,
118
+ createdAt: c.created_at ?? null,
119
+ body: truncateText(c.body ?? '', BODY_CAP).text,
120
+ url: c.html_url ?? null,
121
+ })),
122
+ maxComments,
123
+ state.notes,
124
+ 'comments',
125
+ )
126
+ state.routes.comments = 'api'
127
+ } catch (error) {
128
+ state.notes.push(`api comments: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
129
+ }
130
+ }
131
+
132
+ if (options.routes.gh && subprocess !== undefined && (state.issue === null || state.comments.length === 0)) {
133
+ let ghUsable = false
134
+ try {
135
+ ghUsable = (await ghVersion(subprocess, signal)) && (await ghAuthed(subprocess, signal))
136
+ } catch { ghUsable = false }
137
+ if (ghUsable) {
138
+ try {
139
+ const g = await ghIssueView(subprocess, owner, repo, number, { signal })
140
+ if (state.issue === null) {
141
+ state.issue = shapeGhIssue(g, owner, repo, number)
142
+ state.routes.meta = 'gh'
143
+ }
144
+ if (state.comments.length === 0 && Array.isArray(g.comments)) {
145
+ state.comments = capItems(
146
+ g.comments.map((c) => ({
147
+ kind: 'comment',
148
+ author: c.author && c.author.login ? c.author.login : null,
149
+ createdAt: c.createdAt ?? null,
150
+ body: truncateText(c.body ?? '', BODY_CAP).text,
151
+ url: c.url ?? null,
152
+ })),
153
+ maxComments,
154
+ state.notes,
155
+ 'comments',
156
+ )
157
+ state.routes.comments = 'gh'
158
+ }
159
+ } catch (error) {
160
+ state.notes.push(`gh issue view: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
161
+ }
162
+ } else {
163
+ state.notes.push('gh route skipped: gh CLI not installed or not authenticated')
164
+ }
165
+ }
166
+
167
+ if (options.routes.html && (state.issue === null || state.comments.length === 0)) {
168
+ try {
169
+ await attemptLadder(
170
+ async (proxy) => {
171
+ const parsed = await fetchAndParsePage(owner, repo, number, 'issues', {
172
+ fetchImpl,
173
+ proxy,
174
+ signal,
175
+ timeoutMs: proxy !== undefined ? options.proxyTimeoutMs : options.directTimeoutMs,
176
+ })
177
+ if (state.issue === null && parsed.issue !== null) {
178
+ state.issue = { ...parsed.issue, owner, repo, number }
179
+ state.routes.meta = 'html'
180
+ }
181
+ if (state.comments.length === 0 && parsed.discussion.length > 0) {
182
+ state.comments = capItems(parsed.discussion, maxComments, state.notes, 'comments')
183
+ state.routes.comments = 'html'
184
+ }
185
+ },
186
+ options,
187
+ { proxyFirst: true },
188
+ )
189
+ } catch (error) {
190
+ state.notes.push(`html page: ${String(error && error.message ? error.message : error).slice(0, 220)}`)
191
+ }
192
+ }
193
+
194
+ if (state.issue === null) {
195
+ return {
196
+ error: `all GitHub routes failed to load issue ${owner}/${repo}#${number}`,
197
+ routes: state.routes,
198
+ notes: state.notes,
199
+ }
200
+ }
201
+ return { issue: state.issue, comments: state.comments, routes: state.routes, notes: state.notes }
202
+ }