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.
package/lib/core/pr.js ADDED
@@ -0,0 +1,429 @@
1
+ /**
2
+ * PR aggregation: tries api.github.com → gh CLI → page HTML → git protocol,
3
+ * filling each requested part from the first route that can provide it and
4
+ * recording per-part route attribution plus every failure note. When no
5
+ * route can produce the metadata, the result is a structured error with the
6
+ * route matrix instead of a partial guess.
7
+ * @module dsh-github-router/core/pr
8
+ */
9
+ import { effectiveProxy } from '../config.js'
10
+ import { ghAuthed, ghPrDiff, ghPrView, ghVersion } from '../routes/gh.js'
11
+ import { GitFetchCache, findLocalRepo, gitVersion, readCachePr, readLocalPr } from '../routes/git.js'
12
+ import { fetchAndParsePage } from '../routes/html.js'
13
+ import { guardByteCount, guardOwnerRepo, guardPrNumber, truncateText } from '../util.js'
14
+ import { attemptLadder, buildRuntime, rateNote } from './runtime.js'
15
+
16
+ const BODY_CAP = 20000
17
+
18
+ function shapeApiPr(meta) {
19
+ return {
20
+ owner: meta.base && meta.base.repo ? meta.base.repo.owner.login : null,
21
+ repo: meta.base && meta.base.repo ? meta.base.repo.name : null,
22
+ number: meta.number ?? null,
23
+ title: meta.title ?? null,
24
+ body: truncateText(meta.body ?? '', BODY_CAP).text,
25
+ state: meta.state ?? null,
26
+ draft: meta.draft === true,
27
+ merged: meta.merged === true,
28
+ author: meta.user && meta.user.login ? meta.user.login : null,
29
+ additions: meta.additions ?? null,
30
+ deletions: meta.deletions ?? null,
31
+ changedFiles: meta.changed_files ?? null,
32
+ createdAt: meta.created_at ?? null,
33
+ updatedAt: meta.updated_at ?? null,
34
+ mergedAt: meta.merged_at ?? null,
35
+ baseRef: meta.base ? meta.base.ref : null,
36
+ headRef: meta.head ? meta.head.ref : null,
37
+ baseSha: meta.base ? meta.base.sha : null,
38
+ headSha: meta.head ? meta.head.sha : null,
39
+ url: meta.html_url ?? null,
40
+ }
41
+ }
42
+
43
+ function shapeGhPr(json) {
44
+ return {
45
+ owner: null,
46
+ repo: null,
47
+ number: null,
48
+ title: json.title ?? null,
49
+ body: truncateText(json.body ?? '', BODY_CAP).text,
50
+ state: json.state ?? null,
51
+ draft: json.isDraft === true,
52
+ merged: json.mergedAt !== null && json.mergedAt !== undefined,
53
+ author: json.author && json.author.login ? json.author.login : null,
54
+ additions: json.additions ?? null,
55
+ deletions: json.deletions ?? null,
56
+ changedFiles: json.changedFiles ?? null,
57
+ createdAt: json.createdAt ?? null,
58
+ updatedAt: json.updatedAt ?? null,
59
+ mergedAt: json.mergedAt ?? null,
60
+ baseRef: json.baseRefName ?? null,
61
+ headRef: json.headRefName ?? null,
62
+ baseSha: json.baseRefOid ?? null,
63
+ headSha: json.headRefOid ?? null,
64
+ url: null,
65
+ }
66
+ }
67
+
68
+ function shapeGhFiles(json) {
69
+ return Array.isArray(json) ? json.map((f) => ({ path: f.path ?? null, status: null, additions: f.additions ?? 0, deletions: f.deletions ?? 0 })) : []
70
+ }
71
+
72
+ function shapeGhCommits(json) {
73
+ if (!Array.isArray(json)) return []
74
+ return json.map((c) => ({
75
+ sha: c.oid ?? null,
76
+ author: Array.isArray(c.authors) && c.authors.length > 0 ? c.authors[0].login : null,
77
+ date: c.authoredDate ?? null,
78
+ message: truncateText(`${c.messageHeadline ?? ''}${c.messageBody ? '\n\n' + c.messageBody : ''}`, 2000).text,
79
+ }))
80
+ }
81
+
82
+ function shapeGhReviews(json) {
83
+ if (!Array.isArray(json)) return []
84
+ return json.map((r) => ({ author: r.author && r.author.login ? r.author.login : null, state: r.state ?? null, submittedAt: r.submittedAt ?? null, body: truncateText(r.body ?? '', BODY_CAP).text }))
85
+ }
86
+
87
+ function shapeGhComments(json) {
88
+ if (!Array.isArray(json)) return []
89
+ return json.map((c) => ({
90
+ kind: 'comment',
91
+ author: c.author && c.author.login ? c.author.login : null,
92
+ createdAt: c.createdAt ?? null,
93
+ body: truncateText(c.body ?? '', BODY_CAP).text,
94
+ path: null,
95
+ url: c.url ?? null,
96
+ }))
97
+ }
98
+
99
+ /** Cap one list at maxItems and report how many entries were dropped. */
100
+ function capItems(items, maxItems, notes, label) {
101
+ if (items.length <= maxItems) return items
102
+ notes.push(`${label}: showing ${maxItems} of ${items.length}`)
103
+ return items.slice(0, maxItems)
104
+ }
105
+
106
+ export async function aggregatePr(ctx, options, args) {
107
+ const owner = guardOwnerRepo(args.owner, 'owner')
108
+ const repo = guardOwnerRepo(args.repo, 'repo')
109
+ const number = guardPrNumber(args.number, 'number')
110
+ const maxDiffBytes = guardByteCount(args.maxDiffBytes, 'maxDiffBytes', 65536, 4096, 1048576)
111
+ const maxItems = guardByteCount(args.maxItems, 'maxItems', 50, 5, 200)
112
+ const include = {
113
+ discussion: args.includeDiscussion !== false,
114
+ reviews: args.includeReviews !== false,
115
+ commits: args.includeCommits !== false,
116
+ files: args.includeFiles !== false,
117
+ diff: args.includeDiff !== false,
118
+ }
119
+ const forceRefresh = args.forceRefresh === true
120
+ const { cwd, signal } = args
121
+
122
+ const runtime = buildRuntime(ctx, options)
123
+ const { subprocess, fetchImpl, cache, api } = runtime
124
+ const token = await runtime.resolveToken()
125
+ const state = {
126
+ pr: null,
127
+ discussion: [],
128
+ reviews: [],
129
+ commits: [],
130
+ files: [],
131
+ diff: null,
132
+ diffTruncated: false,
133
+ routes: { meta: null, discussion: null, reviews: null, commits: null, files: null, diff: null },
134
+ notes: [],
135
+ }
136
+
137
+ // ------------------------------------------------------------ 1) api route
138
+ if (options.routes.api) {
139
+ try {
140
+ const attempt = await attemptLadder(
141
+ (proxy) =>
142
+ api.getJson(`/repos/${owner}/${repo}/pulls/${number}`, {
143
+ token,
144
+ signal,
145
+ proxy,
146
+ cache,
147
+ ttlMs: options.cacheTtlSeconds.meta,
148
+ forceRefresh,
149
+ fetchImpl,
150
+ }),
151
+ options,
152
+ )
153
+ const meta = attempt.value
154
+ state.pr = { ...shapeApiPr(meta), owner, repo, number }
155
+ state.routes.meta = 'api'
156
+ rateNote(meta._headers, state.notes)
157
+ } catch (error) {
158
+ state.notes.push(`api meta: ${String(error && error.message ? error.message : error).slice(0, 220)}`)
159
+ }
160
+ }
161
+
162
+ if (state.pr !== null) {
163
+ const apiGet = (path, query, ttl) =>
164
+ attemptLadder(
165
+ (proxy) => api.getJson(path, { token, signal, proxy, cache, ttlMs: ttl, forceRefresh, fetchImpl, query }),
166
+ options,
167
+ )
168
+ const settle = []
169
+ if (include.files) {
170
+ settle.push(
171
+ (async () => {
172
+ try {
173
+ const out = await apiGet(`/repos/${owner}/${repo}/pulls/${number}/files`, { per_page: 100 }, options.cacheTtlSeconds.content)
174
+ state.files = capItems(
175
+ (Array.isArray(out.value) ? out.value : []).map((f) => ({ path: f.filename ?? null, status: f.status ?? null, additions: f.additions ?? 0, deletions: f.deletions ?? 0 })),
176
+ maxItems,
177
+ state.notes,
178
+ 'files',
179
+ )
180
+ state.routes.files = 'api'
181
+ } catch (error) {
182
+ state.notes.push(`api files: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
183
+ }
184
+ })(),
185
+ )
186
+ }
187
+ if (include.commits) {
188
+ settle.push(
189
+ (async () => {
190
+ try {
191
+ const out = await apiGet(`/repos/${owner}/${repo}/pulls/${number}/commits`, { per_page: 100 }, options.cacheTtlSeconds.content)
192
+ state.commits = capItems(
193
+ (Array.isArray(out.value) ? out.value : []).map((c) => ({
194
+ sha: c.sha ?? null,
195
+ author: c.author && c.author.login ? c.author.login : c.commit && c.commit.author ? c.commit.author.name : null,
196
+ date: c.commit && c.commit.author ? c.commit.author.date : null,
197
+ message: truncateText(c.commit ? c.commit.message ?? '' : '', 2000).text,
198
+ })),
199
+ maxItems,
200
+ state.notes,
201
+ 'commits',
202
+ )
203
+ state.routes.commits = 'api'
204
+ } catch (error) {
205
+ state.notes.push(`api commits: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
206
+ }
207
+ })(),
208
+ )
209
+ }
210
+ if (include.reviews) {
211
+ settle.push(
212
+ (async () => {
213
+ try {
214
+ const out = await apiGet(`/repos/${owner}/${repo}/pulls/${number}/reviews`, { per_page: 100 }, options.cacheTtlSeconds.meta)
215
+ state.reviews = capItems(
216
+ (Array.isArray(out.value) ? out.value : []).map((r) => ({
217
+ author: r.user && r.user.login ? r.user.login : null,
218
+ state: r.state ?? null,
219
+ submittedAt: r.submitted_at ?? null,
220
+ body: truncateText(r.body ?? '', BODY_CAP).text,
221
+ })),
222
+ maxItems,
223
+ state.notes,
224
+ 'reviews',
225
+ )
226
+ state.routes.reviews = 'api'
227
+ } catch (error) {
228
+ state.notes.push(`api reviews: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
229
+ }
230
+ })(),
231
+ )
232
+ }
233
+ if (include.discussion) {
234
+ settle.push(
235
+ (async () => {
236
+ try {
237
+ const out = await apiGet(`/repos/${owner}/${repo}/issues/${number}/comments`, { per_page: 100 }, options.cacheTtlSeconds.meta)
238
+ const comments = (Array.isArray(out.value) ? out.value : []).map((c) => ({
239
+ kind: 'comment',
240
+ author: c.user && c.user.login ? c.user.login : null,
241
+ createdAt: c.created_at ?? null,
242
+ body: truncateText(c.body ?? '', BODY_CAP).text,
243
+ path: null,
244
+ url: c.html_url ?? null,
245
+ }))
246
+ let inline = []
247
+ try {
248
+ const out2 = await apiGet(`/repos/${owner}/${repo}/pulls/${number}/comments`, { per_page: 100 }, options.cacheTtlSeconds.meta)
249
+ inline = (Array.isArray(out2.value) ? out2.value : []).map((c) => ({
250
+ kind: 'review-comment',
251
+ author: c.user && c.user.login ? c.user.login : null,
252
+ createdAt: c.created_at ?? null,
253
+ body: truncateText(c.body ?? '', BODY_CAP).text,
254
+ path: c.path ?? null,
255
+ url: c.html_url ?? null,
256
+ }))
257
+ } catch { /* inline comments are best-effort */ }
258
+ state.discussion = capItems([...inline, ...comments], maxItems, state.notes, 'discussion')
259
+ state.routes.discussion = 'api'
260
+ } catch (error) {
261
+ state.notes.push(`api discussion: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
262
+ }
263
+ })(),
264
+ )
265
+ }
266
+ if (include.diff) {
267
+ settle.push(
268
+ (async () => {
269
+ try {
270
+ const attempt = await attemptLadder(
271
+ (proxy) =>
272
+ api.getText(`/repos/${owner}/${repo}/pulls/${number}`, 'application/vnd.github.v3.diff', {
273
+ token,
274
+ signal,
275
+ proxy,
276
+ fetchImpl,
277
+ }),
278
+ options,
279
+ )
280
+ const capped = truncateText(attempt.value.text, maxDiffBytes)
281
+ state.diff = capped.text
282
+ state.diffTruncated = capped.truncated || attempt.value.truncated
283
+ state.routes.diff = 'api'
284
+ } catch (error) {
285
+ state.notes.push(`api diff: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
286
+ }
287
+ })(),
288
+ )
289
+ }
290
+ await Promise.allSettled(settle)
291
+ }
292
+
293
+ // ------------------------------------------------------------ 2) gh route
294
+ const needGh = state.pr === null || (include.files && state.files.length === 0) || (include.commits && state.commits.length === 0) || (include.reviews && state.reviews.length === 0) || (include.discussion && state.discussion.length === 0)
295
+ if (options.routes.gh && subprocess !== undefined && needGh) {
296
+ let ghUsable = false
297
+ try {
298
+ ghUsable = (await ghVersion(subprocess, signal)) && (await ghAuthed(subprocess, signal))
299
+ } catch { ghUsable = false }
300
+ if (ghUsable) {
301
+ try {
302
+ const g = await ghPrView(subprocess, owner, repo, number, { signal })
303
+ if (state.pr === null) {
304
+ state.pr = { ...shapeGhPr(g), owner, repo, number }
305
+ state.routes.meta = 'gh'
306
+ }
307
+ if (include.files && state.files.length === 0 && Array.isArray(g.files)) {
308
+ state.files = capItems(shapeGhFiles(g.files), maxItems, state.notes, 'files')
309
+ state.routes.files = 'gh'
310
+ }
311
+ if (include.commits && state.commits.length === 0) {
312
+ state.commits = capItems(shapeGhCommits(g.commits), maxItems, state.notes, 'commits')
313
+ state.routes.commits = 'gh'
314
+ }
315
+ if (include.reviews && state.reviews.length === 0) {
316
+ state.reviews = capItems(shapeGhReviews(g.reviews), maxItems, state.notes, 'reviews')
317
+ state.routes.reviews = 'gh'
318
+ }
319
+ if (include.discussion && state.discussion.length === 0) {
320
+ state.discussion = capItems(shapeGhComments(g.comments), maxItems, state.notes, 'discussion')
321
+ state.routes.discussion = 'gh'
322
+ }
323
+ } catch (error) {
324
+ state.notes.push(`gh pr view: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
325
+ }
326
+ if (include.diff && state.diff === null) {
327
+ try {
328
+ const d = await ghPrDiff(subprocess, owner, repo, number, { signal, maxStdout: maxDiffBytes })
329
+ state.diff = d.text
330
+ state.diffTruncated = d.truncated
331
+ state.routes.diff = 'gh'
332
+ } catch (error) {
333
+ state.notes.push(`gh pr diff: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
334
+ }
335
+ }
336
+ } else {
337
+ state.notes.push('gh route skipped: gh CLI not installed or not authenticated')
338
+ }
339
+ }
340
+
341
+ // ---------------------------------------------------------- 3) html route
342
+ if (options.routes.html && (state.pr === null || (include.discussion && state.discussion.length === 0))) {
343
+ try {
344
+ await attemptLadder(
345
+ async (proxy) => {
346
+ const parsed = await fetchAndParsePage(owner, repo, number, 'pull', {
347
+ fetchImpl,
348
+ proxy,
349
+ signal,
350
+ timeoutMs: proxy !== undefined ? options.proxyTimeoutMs : options.directTimeoutMs,
351
+ })
352
+ if (state.pr === null && parsed.pr !== null) {
353
+ state.pr = { ...parsed.pr, owner, repo, number }
354
+ state.routes.meta = 'html'
355
+ }
356
+ if (include.discussion && state.discussion.length === 0 && parsed.discussion.length > 0) {
357
+ state.discussion = capItems(parsed.discussion, maxItems, state.notes, 'discussion')
358
+ state.routes.discussion = 'html'
359
+ }
360
+ },
361
+ options,
362
+ { proxyFirst: true },
363
+ )
364
+ } catch (error) {
365
+ state.notes.push(`html page: ${String(error && error.message ? error.message : error).slice(0, 220)}`)
366
+ }
367
+ }
368
+
369
+ // ------------------------------------------------------------ 4) git route
370
+ if (options.routes.git && subprocess !== undefined && (state.commits.length === 0 || state.diff === null)) {
371
+ let gitUsable = false
372
+ try {
373
+ gitUsable = await gitVersion(subprocess, signal)
374
+ } catch { gitUsable = false }
375
+ if (gitUsable) {
376
+ try {
377
+ let read = null
378
+ let localDir = null
379
+ try {
380
+ localDir = await findLocalRepo(subprocess, cwd, owner, repo, options.repos, args.localRepo)
381
+ if (localDir !== null) read = await readLocalPr(subprocess, localDir, number, { signal, maxDiffBytes })
382
+ } catch (error) {
383
+ state.notes.push(`git local repo: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
384
+ }
385
+ if (read === null) {
386
+ const cacheRepo = new GitFetchCache(subprocess, options.gitCacheDir)
387
+ const fetched = await cacheRepo.fetchPr(owner, repo, number, { proxy: effectiveProxy(options), signal })
388
+ read = await readCachePr(subprocess, fetched.dir, number, { signal, maxDiffBytes })
389
+ if (fetched.mergeError !== null) state.notes.push(`git: merge ref unavailable (${fetched.mergeError.slice(0, 120)}); diff falls back to head-only commits`)
390
+ }
391
+ if (read !== null && read !== undefined) {
392
+ if (include.commits && state.commits.length === 0 && read.commits.length > 0) {
393
+ state.commits = capItems(read.commits, maxItems, state.notes, 'commits')
394
+ state.routes.commits = 'git'
395
+ }
396
+ if (include.diff && state.diff === null && read.diff !== null) {
397
+ state.diff = read.diff
398
+ state.diffTruncated = read.diffTruncated
399
+ state.routes.diff = 'git'
400
+ }
401
+ for (const note of read.notes ?? []) if (note.length > 0) state.notes.push(`git: ${note.slice(0, 160)}`)
402
+ }
403
+ } catch (error) {
404
+ state.notes.push(`git route: ${String(error && error.message ? error.message : error).slice(0, 220)}`)
405
+ }
406
+ } else {
407
+ state.notes.push('git route skipped: git not available')
408
+ }
409
+ }
410
+
411
+ if (state.pr === null) {
412
+ return {
413
+ error: `all GitHub routes failed to load PR ${owner}/${repo}#${number}`,
414
+ routes: state.routes,
415
+ notes: state.notes,
416
+ }
417
+ }
418
+ return {
419
+ pr: state.pr,
420
+ discussion: state.discussion,
421
+ reviews: state.reviews,
422
+ commits: state.commits,
423
+ files: state.files,
424
+ diff: state.diff,
425
+ diffTruncated: state.diffTruncated,
426
+ routes: state.routes,
427
+ notes: state.notes,
428
+ }
429
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Connectivity probe: runs every route once (short timeouts, GET only) and
3
+ * reports a matrix of what works from the HOST side, plus a recommended
4
+ * route chain. This is the first tool to call when GitHub access misbehaves
5
+ * — one call replaces a dozen shell retries.
6
+ * @module dsh-github-router/core/probe
7
+ */
8
+ import { effectiveProxy } from '../config.js'
9
+ import { ghAuthed, ghVersion } from '../routes/gh.js'
10
+ import { gitLsRemote, gitVersion } from '../routes/git.js'
11
+ import { guardOwnerRepo, guardPrNumber } from '../util.js'
12
+ import { buildRuntime } from './runtime.js'
13
+
14
+ const UA = { accept: 'text/html,application/xhtml+xml', 'user-agent': 'dsh-github-router/0.1.0' }
15
+
16
+ export async function aggregateProbe(ctx, options, args) {
17
+ const runtime = buildRuntime(ctx, options)
18
+ const { subprocess, fetchImpl, api } = runtime
19
+ const signal = args._signal
20
+ const token = await runtime.resolveToken()
21
+ const owner = args.owner !== undefined && args.owner !== null && String(args.owner).trim() !== '' ? guardOwnerRepo(args.owner, 'owner') : null
22
+ const repo = args.repo !== undefined && args.repo !== null && String(args.repo).trim() !== '' ? guardOwnerRepo(args.repo, 'repo') : null
23
+ const number = args.prNumber !== undefined && args.prNumber !== null ? guardPrNumber(args.prNumber, 'prNumber') : null
24
+ const proxy = effectiveProxy(options)
25
+
26
+ const checks = []
27
+ const run = async (route, fn) => {
28
+ const t0 = Date.now()
29
+ try {
30
+ const detail = await fn()
31
+ checks.push({ route, ok: true, ms: Date.now() - t0, detail: String(detail ?? '').slice(0, 200) })
32
+ } catch (error) {
33
+ if (error && error.code === 'ABORTED') throw error
34
+ checks.push({
35
+ route,
36
+ ok: false,
37
+ ms: Date.now() - t0,
38
+ detail: String(`${error && error.code ? error.code + ': ' : ''}${error && error.message ? error.message : error}`).slice(0, 200),
39
+ })
40
+ }
41
+ }
42
+
43
+ checks.push({ route: 'token', ok: token !== undefined && token !== '', ms: 0, detail: token ? 'token configured' : 'no token — anonymous API limits (60/h) apply' })
44
+
45
+ const apiCheck = async (proxyUrl) => {
46
+ const out = await api.getJson('/rate_limit', { token, signal, proxy: proxyUrl, fetchImpl, ttlMs: 0 })
47
+ const remaining = out._headers && out._headers['x-ratelimit-remaining']
48
+ const limit = out._headers && out._headers['x-ratelimit-limit']
49
+ return `HTTP 200; rate limit remaining ${remaining ?? '?'}${limit !== undefined ? '/' + limit : ''}`
50
+ }
51
+ await run('api-direct', () => apiCheck(undefined))
52
+ if (proxy !== undefined) await run('api-proxy', () => apiCheck(proxy))
53
+
54
+ if (subprocess !== undefined) {
55
+ await run('gh-installed', async () => {
56
+ if (!(await ghVersion(subprocess, signal))) throw new Error('gh not on PATH')
57
+ return 'gh CLI present'
58
+ })
59
+ await run('gh-authed', async () => {
60
+ if (!(await ghAuthed(subprocess, signal))) throw new Error('gh auth status failed — run gh auth login')
61
+ return 'gh CLI authenticated'
62
+ })
63
+ await run('git-installed', async () => {
64
+ if (!(await gitVersion(subprocess, signal))) throw new Error('git not on PATH')
65
+ return 'git present'
66
+ })
67
+ if (owner !== null && repo !== null) {
68
+ await run('git-ls-remote', async () => {
69
+ const ref = number !== null ? `refs/pull/${number}/head` : 'HEAD'
70
+ const out = await gitLsRemote(subprocess, `https://github.com/${owner}/${repo}.git`, ref, { signal, proxy })
71
+ if (!out.ok) throw new Error(out.error ?? 'ls-remote failed')
72
+ return `ref ${ref} -> ${out.sha ?? 'unknown'}`
73
+ })
74
+ }
75
+ }
76
+
77
+ const page = owner !== null && repo !== null
78
+ ? `https://github.com/${owner}/${repo}${number !== null ? `/pull/${number}` : ''}`
79
+ : 'https://github.com'
80
+ const htmlCheck = async (proxyUrl) => {
81
+ const out = await fetchImpl(page, {
82
+ headers: UA,
83
+ timeoutMs: proxyUrl !== undefined ? options.proxyTimeoutMs : options.directTimeoutMs,
84
+ proxy: proxyUrl,
85
+ signal,
86
+ maxBytes: 65536,
87
+ retries: 0,
88
+ })
89
+ if (!out.ok) throw new Error(`HTTP ${out.status}`)
90
+ return `HTTP ${out.status} (${out.body.length} bytes)`
91
+ }
92
+ await run('html-direct', () => htmlCheck(undefined))
93
+ if (proxy !== undefined) await run('html-proxy', () => htmlCheck(proxy))
94
+
95
+ if (options.routes.mirror && options.mirrors.length > 0) {
96
+ await run('mirror', async () => {
97
+ const base = options.mirrors[0].replace(/\/+$/, '') + '/'
98
+ const out = await fetchImpl(base, { headers: UA, timeoutMs: options.directTimeoutMs, signal, maxBytes: 65536, retries: 0 })
99
+ if (!out.ok) throw new Error(`HTTP ${out.status}`)
100
+ return `mirror ${base} reachable (HTTP ${out.status})`
101
+ })
102
+ }
103
+
104
+ const live = checks.filter((c) => c.ok).map((c) => c.route)
105
+ const recommendation = []
106
+ if (live.includes('api-direct')) recommendation.push('api (direct) — richest data, primary route')
107
+ if (live.includes('api-proxy')) recommendation.push('api (proxy)')
108
+ if (live.includes('gh-authed')) recommendation.push('gh CLI — fallback for API failures')
109
+ if (live.includes('html-proxy') || live.includes('html-direct')) recommendation.push('page HTML parse — metadata + discussion fallback')
110
+ if (live.includes('git-ls-remote')) recommendation.push('git protocol — commits/diff/file fallback (survives HTTP blocks)')
111
+ if (live.includes('mirror')) recommendation.push('mirrors — raw file fallback')
112
+ if (recommendation.length === 0) recommendation.push('no route is live — GitHub is unreachable from this host; fix the proxy or network first')
113
+
114
+ return {
115
+ checks,
116
+ live,
117
+ recommendation,
118
+ proxy: proxy ?? null,
119
+ tokenConfigured: token !== undefined && token !== '',
120
+ }
121
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Per-call runtime assembly: cache, API client, transport binding, token
3
+ * resolution, and the direct→proxy attempt ladder each route uses.
4
+ * @module dsh-github-router/core/runtime
5
+ */
6
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
7
+ import { TtlCache } from '../cache.js'
8
+ import { effectiveProxy } from '../config.js'
9
+ import { fetchWithRetry } from '../net.js'
10
+ import { GithubApi } from '../routes/api.js'
11
+
12
+ /**
13
+ * Assemble the per-call runtime. Token resolution is memoized per tokenEnv
14
+ * so one PR aggregation does not re-read the credentials service per
15
+ * request.
16
+ */
17
+ export function buildRuntime(ctx, options) {
18
+ const cache = new TtlCache()
19
+ const api = new GithubApi({
20
+ retries: options.retries,
21
+ directTimeoutMs: options.directTimeoutMs,
22
+ proxyTimeoutMs: options.proxyTimeoutMs,
23
+ maxBytes: options.maxBytes,
24
+ })
25
+ const fetchImpl = (url, o) => fetchWithRetry(url, o)
26
+ const credentials = ctx.get('credentials')
27
+ const tokenMemo = new Map()
28
+ const resolveToken = () => {
29
+ const literal = options.token
30
+ if (literal !== undefined && literal.length > 0) return Promise.resolve(literal)
31
+ const envName = options.tokenEnv
32
+ const hit = tokenMemo.get(envName)
33
+ if (hit !== undefined) return hit
34
+ const promise = (async () => {
35
+ if (credentials !== undefined && typeof credentials.resolve === 'function') {
36
+ try {
37
+ const resolved = await credentials.resolve(credentialRef(envName))
38
+ if (resolved && typeof resolved.value === 'string' && resolved.value.length > 0) return resolved.value
39
+ } catch { /* fall through to env */ }
40
+ }
41
+ const env = process.env[envName]
42
+ return typeof env === 'string' && env.length > 0 ? env : undefined
43
+ })()
44
+ tokenMemo.set(envName, promise)
45
+ return promise
46
+ }
47
+ return { cache, api, fetchImpl, resolveToken, subprocess: ctx.get('subprocess'), options }
48
+ }
49
+
50
+ /**
51
+ * Run one route function across the direct/proxy ladder. The order flips
52
+ * with `proxyFirst` (the page-HTML route empirically needs the proxy first
53
+ * on machines where direct TLS is reset). Aborts propagate; every other
54
+ * failure is collected so the next attempt can run.
55
+ */
56
+ export async function attemptLadder(fn, options, spec = {}) {
57
+ const proxy = effectiveProxy(options)
58
+ const proxyFirst = spec.proxyFirst === true
59
+ const attempts = []
60
+ if (proxyFirst) {
61
+ if (proxy !== undefined) attempts.push(['proxy', proxy])
62
+ attempts.push(['direct', undefined])
63
+ } else {
64
+ attempts.push(['direct', undefined])
65
+ if (proxy !== undefined) attempts.push(['proxy', proxy])
66
+ }
67
+ const errors = []
68
+ for (const [label, proxyUrl] of attempts) {
69
+ try {
70
+ return { value: await fn(proxyUrl), via: label }
71
+ } catch (error) {
72
+ if (error && error.code === 'ABORTED') throw error
73
+ errors.push(`${label}: ${String(error && error.code ? error.code + ' — ' : '')}${String(error && error.message ? error.message : error)}`)
74
+ }
75
+ }
76
+ const err = new Error(errors.join(' | ').slice(0, 600))
77
+ err.code = 'ROUTE_FAILED'
78
+ err.attempts = errors
79
+ throw err
80
+ }
81
+
82
+ /** Surface the remaining-rate-limit note without exposing anything secret. */
83
+ export function rateNote(headers, notes) {
84
+ if (headers && typeof headers === 'object' && typeof headers['x-ratelimit-remaining'] === 'string') {
85
+ const remaining = headers['x-ratelimit-remaining']
86
+ const limit = headers['x-ratelimit-limit']
87
+ notes.push(`api rate limit: ${remaining}${limit !== undefined ? '/' + limit : ''} requests remaining`)
88
+ }
89
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * One compact system-prompt guidance section (the 100–129 tool-guidance
3
+ * band): GitHub reads go through the github_* tools, not the shell.
4
+ * @module dsh-github-router/guidance
5
+ */
6
+
7
+ const SECTION_NAME = 'dsh-github-router:guidance'
8
+ const SECTION_ORDER = 118
9
+
10
+ const GUIDANCE_TEXT =
11
+ 'GitHub reads (PR / issue / file / API data) should go through the github_* tools provided by the dsh-github-router plugin: ' +
12
+ 'github_pr, github_issue, github_file, github_api, and github_probe for connectivity diagnosis. ' +
13
+ 'Do not fight shell-side GitHub failures (curl / gh / git / Invoke-WebRequest) with retries or sandbox escalation — ' +
14
+ 'the shell sandbox commonly breaks GitHub TLS/proxy access, while the tools run host-side and route internally ' +
15
+ '(api.github.com → gh CLI → git protocol → page HTML parse → configured mirrors), returning per-part route attribution in one call. ' +
16
+ 'All github_* tools are read-only: they never write, push, comment, or mutate anything.'
17
+
18
+ export function registerGuidance(ctx) {
19
+ ctx.systemPrompt.section({
20
+ name: SECTION_NAME,
21
+ order: SECTION_ORDER,
22
+ text: () => GUIDANCE_TEXT,
23
+ })
24
+ }