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/CHANGELOG.md +32 -0
- package/CONTRIBUTING.md +80 -0
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/README.zh.md +156 -0
- package/SECURITY.md +112 -0
- package/cordis.patch.yml +8 -0
- package/docs/design.md +198 -0
- package/lib/cache.js +91 -0
- package/lib/client.js +553 -0
- package/lib/config.js +109 -0
- package/lib/core/file.js +166 -0
- package/lib/core/issue.js +202 -0
- package/lib/core/pr.js +429 -0
- package/lib/core/probe.js +121 -0
- package/lib/core/runtime.js +89 -0
- package/lib/guidance.js +24 -0
- package/lib/index.js +52 -0
- package/lib/net.js +213 -0
- package/lib/remote.js +169 -0
- package/lib/render.js +154 -0
- package/lib/routes/api.js +136 -0
- package/lib/routes/gh.js +109 -0
- package/lib/routes/git.js +429 -0
- package/lib/routes/html.js +250 -0
- package/lib/routes/mirror.js +55 -0
- package/lib/skill.js +44 -0
- package/lib/tools/api.js +101 -0
- package/lib/tools/file.js +48 -0
- package/lib/tools/issue.js +46 -0
- package/lib/tools/pr.js +56 -0
- package/lib/tools/probe.js +42 -0
- package/lib/tunnel.js +247 -0
- package/lib/util.js +235 -0
- package/package.json +79 -0
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
|
+
}
|
package/lib/guidance.js
ADDED
|
@@ -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
|
+
}
|