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