dsh-github-router 0.3.1 → 0.4.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 CHANGED
@@ -1,429 +1,454 @@
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
- }
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: json.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
+ // Which parts a route actually served. A successfully fetched empty
136
+ // list is authoritative and must NOT trigger a fallback ladder — the
137
+ // routes are failover, not fan-out (docs/design.md).
138
+ fetched: { meta: false, discussion: false, reviews: false, commits: false, files: false, diff: false },
139
+ }
140
+
141
+ // ------------------------------------------------------------ 1) api route
142
+ if (options.routes.api) {
143
+ try {
144
+ const attempt = await attemptLadder(
145
+ (proxy) =>
146
+ api.getJson(`/repos/${owner}/${repo}/pulls/${number}`, {
147
+ token,
148
+ signal,
149
+ proxy,
150
+ cache,
151
+ ttlMs: options.cacheTtlSeconds.meta,
152
+ forceRefresh,
153
+ fetchImpl,
154
+ }),
155
+ options,
156
+ )
157
+ const meta = attempt.value
158
+ state.pr = { ...shapeApiPr(meta), owner, repo, number }
159
+ state.routes.meta = 'api'
160
+ state.fetched.meta = true
161
+ rateNote(meta._headers, state.notes)
162
+ } catch (error) {
163
+ state.notes.push(`api meta: ${String(error && error.message ? error.message : error).slice(0, 220)}`)
164
+ }
165
+ }
166
+
167
+ if (state.pr !== null) {
168
+ const apiGet = (path, query, ttl) =>
169
+ attemptLadder(
170
+ (proxy) => api.getJson(path, { token, signal, proxy, cache, ttlMs: ttl, forceRefresh, fetchImpl, query }),
171
+ options,
172
+ )
173
+ const settle = []
174
+ if (include.files) {
175
+ settle.push(
176
+ (async () => {
177
+ try {
178
+ const out = await apiGet(`/repos/${owner}/${repo}/pulls/${number}/files`, { per_page: 100 }, options.cacheTtlSeconds.content)
179
+ state.files = capItems(
180
+ (Array.isArray(out.value) ? out.value : []).map((f) => ({ path: f.filename ?? null, status: f.status ?? null, additions: f.additions ?? 0, deletions: f.deletions ?? 0 })),
181
+ maxItems,
182
+ state.notes,
183
+ 'files',
184
+ )
185
+ state.routes.files = 'api'
186
+ state.fetched.files = true
187
+ } catch (error) {
188
+ state.notes.push(`api files: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
189
+ }
190
+ })(),
191
+ )
192
+ }
193
+ if (include.commits) {
194
+ settle.push(
195
+ (async () => {
196
+ try {
197
+ const out = await apiGet(`/repos/${owner}/${repo}/pulls/${number}/commits`, { per_page: 100 }, options.cacheTtlSeconds.content)
198
+ state.commits = capItems(
199
+ (Array.isArray(out.value) ? out.value : []).map((c) => ({
200
+ sha: c.sha ?? null,
201
+ author: c.author && c.author.login ? c.author.login : c.commit && c.commit.author ? c.commit.author.name : null,
202
+ date: c.commit && c.commit.author ? c.commit.author.date : null,
203
+ message: truncateText(c.commit ? c.commit.message ?? '' : '', 2000).text,
204
+ })),
205
+ maxItems,
206
+ state.notes,
207
+ 'commits',
208
+ )
209
+ state.routes.commits = 'api'
210
+ state.fetched.commits = true
211
+ } catch (error) {
212
+ state.notes.push(`api commits: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
213
+ }
214
+ })(),
215
+ )
216
+ }
217
+ if (include.reviews) {
218
+ settle.push(
219
+ (async () => {
220
+ try {
221
+ const out = await apiGet(`/repos/${owner}/${repo}/pulls/${number}/reviews`, { per_page: 100 }, options.cacheTtlSeconds.meta)
222
+ state.reviews = capItems(
223
+ (Array.isArray(out.value) ? out.value : []).map((r) => ({
224
+ author: r.user && r.user.login ? r.user.login : null,
225
+ state: r.state ?? null,
226
+ submittedAt: r.submitted_at ?? null,
227
+ body: truncateText(r.body ?? '', BODY_CAP).text,
228
+ })),
229
+ maxItems,
230
+ state.notes,
231
+ 'reviews',
232
+ )
233
+ state.routes.reviews = 'api'
234
+ state.fetched.reviews = true
235
+ } catch (error) {
236
+ state.notes.push(`api reviews: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
237
+ }
238
+ })(),
239
+ )
240
+ }
241
+ if (include.discussion) {
242
+ settle.push(
243
+ (async () => {
244
+ try {
245
+ const out = await apiGet(`/repos/${owner}/${repo}/issues/${number}/comments`, { per_page: 100 }, options.cacheTtlSeconds.meta)
246
+ const comments = (Array.isArray(out.value) ? out.value : []).map((c) => ({
247
+ kind: 'comment',
248
+ author: c.user && c.user.login ? c.user.login : null,
249
+ createdAt: c.created_at ?? null,
250
+ body: truncateText(c.body ?? '', BODY_CAP).text,
251
+ path: null,
252
+ url: c.html_url ?? null,
253
+ }))
254
+ let inline = []
255
+ try {
256
+ const out2 = await apiGet(`/repos/${owner}/${repo}/pulls/${number}/comments`, { per_page: 100 }, options.cacheTtlSeconds.meta)
257
+ inline = (Array.isArray(out2.value) ? out2.value : []).map((c) => ({
258
+ kind: 'review-comment',
259
+ author: c.user && c.user.login ? c.user.login : null,
260
+ createdAt: c.created_at ?? null,
261
+ body: truncateText(c.body ?? '', BODY_CAP).text,
262
+ path: c.path ?? null,
263
+ url: c.html_url ?? null,
264
+ }))
265
+ } catch { /* inline comments are best-effort */ }
266
+ state.discussion = capItems([...inline, ...comments], maxItems, state.notes, 'discussion')
267
+ state.routes.discussion = 'api'
268
+ state.fetched.discussion = true
269
+ } catch (error) {
270
+ state.notes.push(`api discussion: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
271
+ }
272
+ })(),
273
+ )
274
+ }
275
+ if (include.diff) {
276
+ settle.push(
277
+ (async () => {
278
+ try {
279
+ const attempt = await attemptLadder(
280
+ (proxy) =>
281
+ api.getText(`/repos/${owner}/${repo}/pulls/${number}`, 'application/vnd.github.v3.diff', {
282
+ token,
283
+ signal,
284
+ proxy,
285
+ fetchImpl,
286
+ }),
287
+ options,
288
+ )
289
+ const capped = truncateText(attempt.value.text, maxDiffBytes)
290
+ state.diff = capped.text
291
+ state.diffTruncated = capped.truncated || attempt.value.truncated
292
+ state.routes.diff = 'api'
293
+ state.fetched.diff = true
294
+ } catch (error) {
295
+ state.notes.push(`api diff: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
296
+ }
297
+ })(),
298
+ )
299
+ }
300
+ await Promise.allSettled(settle)
301
+ }
302
+
303
+ // ------------------------------------------------------------ 2) gh route
304
+ const needGh =
305
+ state.pr === null ||
306
+ (include.files && !state.fetched.files) ||
307
+ (include.commits && !state.fetched.commits) ||
308
+ (include.reviews && !state.fetched.reviews) ||
309
+ (include.discussion && !state.fetched.discussion)
310
+ if (options.routes.gh && subprocess !== undefined && needGh) {
311
+ let ghUsable = false
312
+ try {
313
+ ghUsable = (await ghVersion(subprocess, signal)) && (await ghAuthed(subprocess, signal))
314
+ } catch { ghUsable = false }
315
+ if (ghUsable) {
316
+ try {
317
+ const g = await ghPrView(subprocess, owner, repo, number, { signal })
318
+ if (state.pr === null) {
319
+ state.pr = { ...shapeGhPr(g), owner, repo, number }
320
+ state.routes.meta = 'gh'
321
+ state.fetched.meta = true
322
+ }
323
+ if (include.files && !state.fetched.files && Array.isArray(g.files)) {
324
+ state.files = capItems(shapeGhFiles(g.files), maxItems, state.notes, 'files')
325
+ state.routes.files = 'gh'
326
+ state.fetched.files = true
327
+ }
328
+ if (include.commits && !state.fetched.commits) {
329
+ state.commits = capItems(shapeGhCommits(g.commits), maxItems, state.notes, 'commits')
330
+ state.routes.commits = 'gh'
331
+ state.fetched.commits = true
332
+ }
333
+ if (include.reviews && !state.fetched.reviews) {
334
+ state.reviews = capItems(shapeGhReviews(g.reviews), maxItems, state.notes, 'reviews')
335
+ state.routes.reviews = 'gh'
336
+ state.fetched.reviews = true
337
+ }
338
+ if (include.discussion && !state.fetched.discussion) {
339
+ state.discussion = capItems(shapeGhComments(g.comments), maxItems, state.notes, 'discussion')
340
+ state.routes.discussion = 'gh'
341
+ state.fetched.discussion = true
342
+ }
343
+ } catch (error) {
344
+ state.notes.push(`gh pr view: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
345
+ }
346
+ if (include.diff && !state.fetched.diff) {
347
+ try {
348
+ const d = await ghPrDiff(subprocess, owner, repo, number, { signal, maxStdout: maxDiffBytes })
349
+ state.diff = d.text
350
+ state.diffTruncated = d.truncated
351
+ state.routes.diff = 'gh'
352
+ state.fetched.diff = true
353
+ } catch (error) {
354
+ state.notes.push(`gh pr diff: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
355
+ }
356
+ }
357
+ } else {
358
+ state.notes.push('gh route skipped: gh CLI not installed or not authenticated')
359
+ }
360
+ }
361
+
362
+ // ---------------------------------------------------------- 3) html route
363
+ if (options.routes.html && (state.pr === null || (include.discussion && !state.fetched.discussion))) {
364
+ try {
365
+ await attemptLadder(
366
+ async (proxy) => {
367
+ const parsed = await fetchAndParsePage(owner, repo, number, 'pull', {
368
+ fetchImpl,
369
+ proxy,
370
+ signal,
371
+ timeoutMs: proxy !== undefined ? options.proxyTimeoutMs : options.directTimeoutMs,
372
+ })
373
+ if (state.pr === null && parsed.pr !== null) {
374
+ state.pr = { ...parsed.pr, owner, repo, number }
375
+ state.routes.meta = 'html'
376
+ state.fetched.meta = true
377
+ }
378
+ if (include.discussion && !state.fetched.discussion && parsed.discussion.length > 0) {
379
+ state.discussion = capItems(parsed.discussion, maxItems, state.notes, 'discussion')
380
+ state.routes.discussion = 'html'
381
+ state.fetched.discussion = true
382
+ }
383
+ },
384
+ options,
385
+ { proxyFirst: true },
386
+ )
387
+ } catch (error) {
388
+ state.notes.push(`html page: ${String(error && error.message ? error.message : error).slice(0, 220)}`)
389
+ }
390
+ }
391
+
392
+ // ------------------------------------------------------------ 4) git route
393
+ if (options.routes.git && subprocess !== undefined && ((include.commits && !state.fetched.commits) || (include.diff && !state.fetched.diff))) {
394
+ let gitUsable = false
395
+ try {
396
+ gitUsable = await gitVersion(subprocess, signal)
397
+ } catch { gitUsable = false }
398
+ if (gitUsable) {
399
+ try {
400
+ let read = null
401
+ let localDir = null
402
+ try {
403
+ localDir = await findLocalRepo(subprocess, cwd, owner, repo, options.repos, args.localRepo)
404
+ if (localDir !== null) read = await readLocalPr(subprocess, localDir, number, { signal, maxDiffBytes })
405
+ } catch (error) {
406
+ state.notes.push(`git local repo: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
407
+ }
408
+ if (read === null) {
409
+ const cacheRepo = new GitFetchCache(subprocess, options.gitCacheDir)
410
+ const fetched = await cacheRepo.fetchPr(owner, repo, number, { proxy: effectiveProxy(options), signal })
411
+ read = await readCachePr(subprocess, fetched.dir, number, { signal, maxDiffBytes })
412
+ if (fetched.mergeError !== null) state.notes.push(`git: merge ref unavailable (${fetched.mergeError.slice(0, 120)}); diff falls back to head-only commits`)
413
+ }
414
+ if (read !== null && read !== undefined) {
415
+ if (include.commits && !state.fetched.commits && read.commits.length > 0) {
416
+ state.commits = capItems(read.commits, maxItems, state.notes, 'commits')
417
+ state.routes.commits = 'git'
418
+ state.fetched.commits = true
419
+ }
420
+ if (include.diff && !state.fetched.diff && read.diff !== null) {
421
+ state.diff = read.diff
422
+ state.diffTruncated = read.diffTruncated
423
+ state.routes.diff = 'git'
424
+ state.fetched.diff = true
425
+ }
426
+ for (const note of read.notes ?? []) if (note.length > 0) state.notes.push(`git: ${note.slice(0, 160)}`)
427
+ }
428
+ } catch (error) {
429
+ state.notes.push(`git route: ${String(error && error.message ? error.message : error).slice(0, 220)}`)
430
+ }
431
+ } else {
432
+ state.notes.push('git route skipped: git not available')
433
+ }
434
+ }
435
+
436
+ if (state.pr === null) {
437
+ return {
438
+ error: `all GitHub routes failed to load PR ${owner}/${repo}#${number}`,
439
+ routes: state.routes,
440
+ notes: state.notes,
441
+ }
442
+ }
443
+ return {
444
+ pr: state.pr,
445
+ discussion: state.discussion,
446
+ reviews: state.reviews,
447
+ commits: state.commits,
448
+ files: state.files,
449
+ diff: state.diff,
450
+ diffTruncated: state.diffTruncated,
451
+ routes: state.routes,
452
+ notes: state.notes,
453
+ }
454
+ }