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
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR/issue page HTML route.
|
|
3
|
+
*
|
|
4
|
+
* SECURITY: pages are parsed with JSON.parse ONLY, applied to exact
|
|
5
|
+
* `<script type="application/json">` islands (react-app.embeddedData).
|
|
6
|
+
* Nothing is ever evaluated or executed. Extraction walks the parsed JSON
|
|
7
|
+
* with a bounded BFS and copies a whitelist of fields (title, body text,
|
|
8
|
+
* state, author, dates, review/discussion items) — CSRF tokens, sessions,
|
|
9
|
+
* and everything else in the payload are dropped on the floor. Inputs and
|
|
10
|
+
* every extracted text are byte-capped.
|
|
11
|
+
* @module dsh-github-router/routes/html
|
|
12
|
+
*/
|
|
13
|
+
import { fetchText } from '../net.js'
|
|
14
|
+
import { stripHtml } from '../util.js'
|
|
15
|
+
|
|
16
|
+
const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi
|
|
17
|
+
const MAX_ISLAND_BYTES = 4194304
|
|
18
|
+
const MAX_WALK_DEPTH = 10
|
|
19
|
+
const MAX_WALK_NODES = 40000
|
|
20
|
+
const MAX_BODY_CHARS = 20000
|
|
21
|
+
const MAX_ITEMS = 60
|
|
22
|
+
|
|
23
|
+
/** Pull every application/json script island out of a GitHub HTML page. */
|
|
24
|
+
export function extractEmbeddedData(html) {
|
|
25
|
+
const out = []
|
|
26
|
+
const source = String(html ?? '').slice(0, MAX_ISLAND_BYTES)
|
|
27
|
+
SCRIPT_RE.lastIndex = 0
|
|
28
|
+
let match
|
|
29
|
+
while ((match = SCRIPT_RE.exec(source)) !== null) {
|
|
30
|
+
const attrs = match[1]
|
|
31
|
+
const body = match[2]
|
|
32
|
+
if (!/type\s*=\s*["']application\/json["']/i.test(attrs)) continue
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(body)
|
|
35
|
+
out.push(parsed)
|
|
36
|
+
} catch { /* not a valid JSON island — skip, never evaluate */ }
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Bounded BFS collecting { key, value } pairs matching a predicate. */
|
|
42
|
+
export function walkJson(root, predicate, options = {}) {
|
|
43
|
+
const maxDepth = options.maxDepth ?? MAX_WALK_DEPTH
|
|
44
|
+
const maxNodes = options.maxNodes ?? MAX_WALK_NODES
|
|
45
|
+
const seen = new WeakSet()
|
|
46
|
+
const hits = []
|
|
47
|
+
let visited = 0
|
|
48
|
+
const visit = (node, depth, key) => {
|
|
49
|
+
// The predicate runs for every node — scalars included — so key-based
|
|
50
|
+
// matchers see `{key: "scalar"}` entries too. The hits cap is checked
|
|
51
|
+
// BEFORE pushing so scalar-heavy trees cannot blow past maxNodes.
|
|
52
|
+
if (hits.length >= maxNodes) return
|
|
53
|
+
if (predicate(key, node, depth)) hits.push({ key, value: node })
|
|
54
|
+
if (node === null || typeof node !== 'object' || seen.has(node)) return
|
|
55
|
+
if (visited >= maxNodes) return
|
|
56
|
+
visited += 1
|
|
57
|
+
seen.add(node)
|
|
58
|
+
if (depth > maxDepth) return
|
|
59
|
+
if (Array.isArray(node)) {
|
|
60
|
+
for (let i = 0; i < node.length; i += 1) visit(node[i], depth + 1, i)
|
|
61
|
+
} else {
|
|
62
|
+
for (const [k, v] of Object.entries(node)) visit(v, depth + 1, k)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
visit(root, 0, null)
|
|
66
|
+
return hits
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isPrLike(node) {
|
|
70
|
+
return (
|
|
71
|
+
node !== null &&
|
|
72
|
+
typeof node === 'object' &&
|
|
73
|
+
Number.isInteger(node.number) &&
|
|
74
|
+
typeof node.title === 'string' &&
|
|
75
|
+
('additions' in node || 'headRefName' in node || 'headRef' in node)
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isIssueLike(node) {
|
|
80
|
+
return (
|
|
81
|
+
node !== null &&
|
|
82
|
+
typeof node === 'object' &&
|
|
83
|
+
Number.isInteger(node.number) &&
|
|
84
|
+
typeof node.title === 'string' &&
|
|
85
|
+
('bodyHTML' in node || 'body' in node) &&
|
|
86
|
+
!('additions' in node) &&
|
|
87
|
+
!('headRefName' in node)
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function bodyOf(node, cap) {
|
|
92
|
+
const raw = typeof node.bodyHTML === 'string' ? node.bodyHTML : typeof node.body === 'string' ? node.body : ''
|
|
93
|
+
return stripHtml(raw).slice(0, cap)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function loginOf(node) {
|
|
97
|
+
if (node && typeof node === 'object' && node.author && typeof node.author === 'object') {
|
|
98
|
+
return typeof node.author.login === 'string' ? node.author.login : null
|
|
99
|
+
}
|
|
100
|
+
if (node && typeof node === 'object' && node.user && typeof node.user === 'object') {
|
|
101
|
+
return typeof node.user.login === 'string' ? node.user.login : null
|
|
102
|
+
}
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Find the best PR-shaped node across all embedded JSON payloads. */
|
|
107
|
+
export function prFromPayloads(objects) {
|
|
108
|
+
const candidates = []
|
|
109
|
+
for (const obj of objects) {
|
|
110
|
+
for (const { key, value } of walkJson(obj, (k, v) => isPrLike(v))) {
|
|
111
|
+
candidates.push({ key, value })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (candidates.length === 0) return null
|
|
115
|
+
// Prefer nodes reachable under a `pullRequest` key; then the richest shape.
|
|
116
|
+
candidates.sort((a, b) => {
|
|
117
|
+
const ak = a.key === 'pullRequest' ? 0 : 1
|
|
118
|
+
const bk = b.key === 'pullRequest' ? 0 : 1
|
|
119
|
+
if (ak !== bk) return ak - bk
|
|
120
|
+
return b.value.title.length - a.value.title.length
|
|
121
|
+
})
|
|
122
|
+
const pr = candidates[0].value
|
|
123
|
+
const refName = (v) => (v && typeof v === 'object' && typeof v.name === 'string' ? v.name : null)
|
|
124
|
+
const shaOf = (v) => (v && typeof v === 'object' && typeof v.oid === 'string' ? v.oid : null)
|
|
125
|
+
return {
|
|
126
|
+
title: pr.title,
|
|
127
|
+
body: bodyOf(pr, MAX_BODY_CHARS),
|
|
128
|
+
state: typeof pr.state === 'string' ? pr.state : null,
|
|
129
|
+
author: loginOf(pr),
|
|
130
|
+
additions: typeof pr.additions === 'number' ? pr.additions : null,
|
|
131
|
+
deletions: typeof pr.deletions === 'number' ? pr.deletions : null,
|
|
132
|
+
changedFiles: typeof pr.changedFiles === 'number' ? pr.changedFiles : typeof pr.changed_files === 'number' ? pr.changed_files : null,
|
|
133
|
+
createdAt: typeof pr.createdAt === 'string' ? pr.createdAt : null,
|
|
134
|
+
updatedAt: typeof pr.updatedAt === 'string' ? pr.updatedAt : null,
|
|
135
|
+
mergedAt: typeof pr.mergedAt === 'string' ? pr.mergedAt : typeof pr.merged_at === 'string' ? pr.merged_at : null,
|
|
136
|
+
baseRef: typeof pr.baseRefName === 'string' ? pr.baseRefName : refName(pr.baseRef ?? pr.base),
|
|
137
|
+
headRef: typeof pr.headRefName === 'string' ? pr.headRefName : refName(pr.headRef ?? pr.head),
|
|
138
|
+
baseSha: typeof pr.baseRefOid === 'string' ? pr.baseRefOid : shaOf(pr.baseRef ?? pr.base),
|
|
139
|
+
headSha: typeof pr.headRefOid === 'string' ? pr.headRefOid : shaOf(pr.headRef ?? pr.head),
|
|
140
|
+
url: typeof pr.url === 'string' && /^https:\/\//.test(pr.url) ? pr.url : typeof pr.permalink === 'string' ? pr.permalink : null,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Find the best issue-shaped node across all embedded JSON payloads. */
|
|
145
|
+
export function issueFromPayloads(objects) {
|
|
146
|
+
const candidates = []
|
|
147
|
+
for (const obj of objects) {
|
|
148
|
+
for (const { key, value } of walkJson(obj, (k, v) => isIssueLike(v))) {
|
|
149
|
+
candidates.push({ key, value })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (candidates.length === 0) return null
|
|
153
|
+
candidates.sort((a, b) => {
|
|
154
|
+
const ak = a.key === 'issue' ? 0 : 1
|
|
155
|
+
const bk = b.key === 'issue' ? 0 : 1
|
|
156
|
+
if (ak !== bk) return ak - bk
|
|
157
|
+
return b.value.title.length - a.value.title.length
|
|
158
|
+
})
|
|
159
|
+
const issue = candidates[0].value
|
|
160
|
+
return {
|
|
161
|
+
title: issue.title,
|
|
162
|
+
body: bodyOf(issue, MAX_BODY_CHARS),
|
|
163
|
+
state: typeof issue.state === 'string' ? issue.state : null,
|
|
164
|
+
stateReason: typeof issue.stateReason === 'string' ? issue.stateReason : null,
|
|
165
|
+
author: loginOf(issue),
|
|
166
|
+
createdAt: typeof issue.createdAt === 'string' ? issue.createdAt : null,
|
|
167
|
+
updatedAt: typeof issue.updatedAt === 'string' ? issue.updatedAt : null,
|
|
168
|
+
closedAt: typeof issue.closedAt === 'string' ? issue.closedAt : null,
|
|
169
|
+
commentsCount: Number.isInteger(issue.commentsCount) ? issue.commentsCount : Number.isInteger(issue.comments?.totalCount) ? issue.comments.totalCount : null,
|
|
170
|
+
url: typeof issue.url === 'string' && /^https:\/\//.test(issue.url) ? issue.url : null,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function timelineItem(node) {
|
|
175
|
+
if (node === null || typeof node !== 'object') return null
|
|
176
|
+
const type = typeof node.__typename === 'string' ? node.__typename : null
|
|
177
|
+
if (type === 'IssueComment') {
|
|
178
|
+
return { kind: 'comment', author: loginOf(node), createdAt: node.createdAt ?? null, body: bodyOf(node, MAX_BODY_CHARS), url: node.url ?? null }
|
|
179
|
+
}
|
|
180
|
+
if (type === 'PullRequestReview') {
|
|
181
|
+
return { kind: 'review', author: loginOf(node), createdAt: node.submittedAt ?? node.createdAt ?? null, state: node.state ?? null, body: bodyOf(node, MAX_BODY_CHARS), url: node.url ?? null }
|
|
182
|
+
}
|
|
183
|
+
if (type === 'PullRequestReviewThread' && Array.isArray(node.comments?.nodes)) {
|
|
184
|
+
const items = []
|
|
185
|
+
for (const c of node.comments.nodes) {
|
|
186
|
+
if (c === null || typeof c !== 'object') continue
|
|
187
|
+
items.push({ kind: 'review-comment', author: loginOf(c), createdAt: c.createdAt ?? null, body: bodyOf(c, MAX_BODY_CHARS), path: c.path ?? null, diffHunk: typeof c.diffHunk === 'string' ? c.diffHunk.slice(0, 4000) : null, url: c.url ?? null })
|
|
188
|
+
}
|
|
189
|
+
return items.length > 0 ? items : null
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Discussion and review items from timelineItems / reviewThreads arrays in
|
|
196
|
+
* the payloads. Only whitelisted shapes are copied.
|
|
197
|
+
*/
|
|
198
|
+
export function discussionFromPayloads(objects) {
|
|
199
|
+
const items = []
|
|
200
|
+
const seen = new Set()
|
|
201
|
+
const considerNodes = (nodes) => {
|
|
202
|
+
if (!Array.isArray(nodes)) return
|
|
203
|
+
for (const node of nodes) {
|
|
204
|
+
const mapped = timelineItem(node)
|
|
205
|
+
const list = Array.isArray(mapped) ? mapped : mapped !== null ? [mapped] : []
|
|
206
|
+
for (const item of list) {
|
|
207
|
+
const key = JSON.stringify(item)
|
|
208
|
+
if (seen.has(key)) continue
|
|
209
|
+
seen.add(key)
|
|
210
|
+
items.push(item)
|
|
211
|
+
if (items.length >= MAX_ITEMS) return
|
|
212
|
+
}
|
|
213
|
+
if (items.length >= MAX_ITEMS) return
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const obj of objects) {
|
|
217
|
+
for (const { value } of walkJson(obj, (k, v) => (k === 'timelineItems' || k === 'reviewThreads') && v !== null && typeof v === 'object' && Array.isArray(v.nodes))) {
|
|
218
|
+
considerNodes(value.nodes)
|
|
219
|
+
if (items.length >= MAX_ITEMS) break
|
|
220
|
+
}
|
|
221
|
+
if (items.length >= MAX_ITEMS) break
|
|
222
|
+
}
|
|
223
|
+
return items
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Fetch one PR/issue HTML page through the net layer and parse it.
|
|
228
|
+
* Returns the parsed view or null when the page cannot be fetched/parsed.
|
|
229
|
+
*/
|
|
230
|
+
export async function fetchAndParsePage(owner, repo, number, kind, call) {
|
|
231
|
+
const url = `https://github.com/${owner}/${repo}/${kind}/${number}`
|
|
232
|
+
const raw = await call.fetchImpl(url, {
|
|
233
|
+
headers: {
|
|
234
|
+
accept: 'text/html,application/xhtml+xml',
|
|
235
|
+
'user-agent': 'dsh-github-router/0.1.0',
|
|
236
|
+
},
|
|
237
|
+
timeoutMs: call.timeoutMs,
|
|
238
|
+
proxy: call.proxy,
|
|
239
|
+
signal: call.signal,
|
|
240
|
+
maxBytes: 4194304,
|
|
241
|
+
retries: 0,
|
|
242
|
+
})
|
|
243
|
+
if (!raw.ok) {
|
|
244
|
+
const err = new Error(`GitHub page returned HTTP ${raw.status}`)
|
|
245
|
+
err.code = 'HTTP'
|
|
246
|
+
throw err
|
|
247
|
+
}
|
|
248
|
+
const objects = extractEmbeddedData(raw.body.toString('utf8'))
|
|
249
|
+
return { objects, pr: prFromPayloads(objects), issue: issueFromPayloads(objects), discussion: discussionFromPayloads(objects) }
|
|
250
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw-content mirror route. Mirrors are OFF by default: they are third
|
|
3
|
+
* parties that see requested paths and serve file bytes, so the user must
|
|
4
|
+
* explicitly configure and enable them. When enabled, each configured base
|
|
5
|
+
* yields two candidate URLs per file (raw.githubusercontent.com passthrough
|
|
6
|
+
* and the github.com /raw/ route).
|
|
7
|
+
* @module dsh-github-router/routes/mirror
|
|
8
|
+
*/
|
|
9
|
+
import { fetchText } from '../net.js'
|
|
10
|
+
|
|
11
|
+
export function rawUrl(owner, repo, ref, path) {
|
|
12
|
+
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function githubRawRouteUrl(owner, repo, ref, path) {
|
|
16
|
+
return `https://github.com/${owner}/${repo}/raw/${ref}/${path}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** All mirror candidate URLs for one file, in configured mirror order. */
|
|
20
|
+
export function mirrorCandidates(owner, repo, ref, path, mirrors) {
|
|
21
|
+
const out = []
|
|
22
|
+
const raw = rawUrl(owner, repo, ref, path)
|
|
23
|
+
const viaGithub = githubRawRouteUrl(owner, repo, ref, path)
|
|
24
|
+
for (const mirror of mirrors ?? []) {
|
|
25
|
+
const base = String(mirror).replace(/\/+$/, '')
|
|
26
|
+
if (base.length === 0 || !/^https?:\/\//.test(base)) continue
|
|
27
|
+
out.push(`${base}/${raw}`)
|
|
28
|
+
out.push(`${base}/${viaGithub}`)
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Try each mirror candidate in order; returns the first usable body. */
|
|
34
|
+
export async function fetchViaMirrors(owner, repo, ref, path, mirrors, call) {
|
|
35
|
+
const errors = []
|
|
36
|
+
for (const url of mirrorCandidates(owner, repo, ref, path, mirrors)) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = await fetchText(url, {
|
|
39
|
+
headers: { accept: 'text/plain', 'user-agent': 'dsh-github-router/0.1.0' },
|
|
40
|
+
timeoutMs: call.timeoutMs,
|
|
41
|
+
signal: call.signal,
|
|
42
|
+
maxBytes: call.maxBytes,
|
|
43
|
+
retries: 0,
|
|
44
|
+
})
|
|
45
|
+
if (raw.ok) return { content: raw.text, truncated: raw.truncated, url, size: raw.body.length }
|
|
46
|
+
errors.push(`${url} -> HTTP ${raw.status}`)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
errors.push(`${url} -> ${String(error && error.code ? error.code + ': ' : '')}${String(error && error.message ? error.message : error)}`)
|
|
49
|
+
if (call.signal && call.signal.aborted) throw error
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const err = new Error(`all mirrors failed: ${errors.join('; ').slice(0, 500)}`)
|
|
53
|
+
err.code = 'MIRROR_FAILED'
|
|
54
|
+
throw err
|
|
55
|
+
}
|
package/lib/skill.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `github-router` skill: teaches the agent to use the github_* tools
|
|
3
|
+
* instead of shell curl/gh/git for GitHub reads, and how to diagnose
|
|
4
|
+
* connectivity with one probe call.
|
|
5
|
+
* @module dsh-github-router/skill
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const SKILL = {
|
|
9
|
+
name: 'github-router',
|
|
10
|
+
description:
|
|
11
|
+
'Read GitHub repositories through the github_* tools (probe, pr, issue, file, api) with internal multi-route fallback (API, gh CLI, git protocol, page HTML, mirrors) — read-only, never writes or pushes.',
|
|
12
|
+
whenToUse:
|
|
13
|
+
'When a task needs GitHub data (PR review, issue triage, file inspection, repo metadata) and especially when shell-side curl / gh / git / Invoke-WebRequest fails with TLS, proxy, or permission errors.',
|
|
14
|
+
source: 'custom',
|
|
15
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
16
|
+
content: `# github-router
|
|
17
|
+
|
|
18
|
+
## Use the tools
|
|
19
|
+
- \`github_probe\` — one call reports which routes are live (api direct/proxy, gh CLI, git, page HTML, mirrors) with timings and a recommendation. Call it FIRST when GitHub access fails or is slow; do not retry shell commands.
|
|
20
|
+
- \`github_pr\` — load a pull request: metadata, description, discussion (issue + inline review comments), reviews, commits, changed files, and the unified diff. Toggle parts with includeDiscussion/includeReviews/includeCommits/includeFiles/includeDiff and cap sizes with maxDiffBytes / maxItems. Pass \`localRepo\` (or rely on session-cwd auto-detection) to read commits/diff from a local clone with zero network.
|
|
21
|
+
- \`github_issue\` — load an issue with body, labels, and comments.
|
|
22
|
+
- \`github_file\` — read one file (or list a directory) at a branch/tag/sha; returns content, size, truncation state, and the serving route.
|
|
23
|
+
- \`github_api\` — READ-ONLY escape hatch: any api.github.com GET path (e.g. /repos/o/r/commits). Always GET; there is no write verb.
|
|
24
|
+
|
|
25
|
+
## Why not curl / gh / git in a shell
|
|
26
|
+
The DSH shell sandbox frequently breaks GitHub access (TLS credential resets, proxy misrouting, permission errors), and retrying costs many turns. The github_* tools run in the host process and route internally: api.github.com → gh CLI → git protocol → PR/issue page HTML parse → user-configured mirrors, returning per-part route attribution and failure notes in ONE call. Do NOT escalate sandbox permissions for GitHub reads — call the tools.
|
|
27
|
+
|
|
28
|
+
## Reviewing a PR (the common case)
|
|
29
|
+
1. \`github_pr\` for the full picture (meta + discussion + commits + files + diff).
|
|
30
|
+
2. \`github_file\` for specific files at the PR head sha when the diff cap is too small.
|
|
31
|
+
3. \`github_probe\` when everything failed, to see which routes are live before any retry.
|
|
32
|
+
For PRs in a local clone, pass \`localRepo\` pointing at the checkout — commits/diff then come from local git with no network at all.
|
|
33
|
+
|
|
34
|
+
## Security and limits
|
|
35
|
+
- Every tool is read-only: no writes, pushes, comments, or mutations exist anywhere in the plugin.
|
|
36
|
+
- Anonymous API use is rate-limited (60 requests/hour per IP); configure a GitHub token in Settings → Plugins → dsh-github-router (token or GITHUB_TOKEN env) for 5000/hour. Responses are cached to save quota.
|
|
37
|
+
- Mirrors are OFF by default (third parties see requested paths); enable them in settings only if you accept that.
|
|
38
|
+
- Token values are redacted in all tool output.
|
|
39
|
+
`,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function registerSkill(ctx) {
|
|
43
|
+
ctx.skills.register(SKILL)
|
|
44
|
+
}
|
package/lib/tools/api.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `github_api` — the escape hatch: a validated GET-only pass-through to
|
|
3
|
+
* api.github.com with caching and rate-limit surfacing. There is no other
|
|
4
|
+
* verb; paths are strictly validated; query values are sanitized.
|
|
5
|
+
* @module dsh-github-router/tools/api
|
|
6
|
+
*/
|
|
7
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
8
|
+
import { attemptLadder, buildRuntime } from '../core/runtime.js'
|
|
9
|
+
import { renderApi } from '../render.js'
|
|
10
|
+
import { guardApiPath } from '../util.js'
|
|
11
|
+
|
|
12
|
+
const QUERY_VALUE_RE = /^[\u0020-\u007e\u00a0-\uffff]*$/
|
|
13
|
+
|
|
14
|
+
function sanitizeQuery(query) {
|
|
15
|
+
if (query === undefined || query === null) return undefined
|
|
16
|
+
if (typeof query !== 'object' || Array.isArray(query)) throw new Error('query must be an object of string/number/boolean values')
|
|
17
|
+
const out = {}
|
|
18
|
+
const keys = Object.keys(query)
|
|
19
|
+
if (keys.length > 20) throw new Error('query must have at most 20 entries')
|
|
20
|
+
for (const key of keys) {
|
|
21
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(key)) throw new Error(`invalid query key "${key}"`)
|
|
22
|
+
const value = query[key]
|
|
23
|
+
if (value === undefined || value === null) continue
|
|
24
|
+
if (typeof value === 'string' && value.length <= 1024 && QUERY_VALUE_RE.test(value)) {
|
|
25
|
+
out[key] = value
|
|
26
|
+
} else if (typeof value === 'number' && Number.isFinite(value)) {
|
|
27
|
+
out[key] = value
|
|
28
|
+
} else if (typeof value === 'boolean') {
|
|
29
|
+
out[key] = value ? 'true' : 'false'
|
|
30
|
+
} else {
|
|
31
|
+
throw new Error(`query value for "${key}" must be a short string, finite number, or boolean`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registerApiTool(ctx, options) {
|
|
38
|
+
ctx.tools.register(
|
|
39
|
+
defineTool({
|
|
40
|
+
name: 'github_api',
|
|
41
|
+
description:
|
|
42
|
+
'READ-ONLY escape hatch for any api.github.com GET endpoint not covered by the specialized tools. Path must be a plain relative API path (e.g. "/repos/octocat/Hello-World/commits"); the method is always GET — no write, mutation, or body exists. Query values are sanitized; responses are cached briefly, rate-limit headers are surfaced, and errors carry a stable code. Prefer github_pr / github_issue / github_file for common reads.',
|
|
43
|
+
parameters: {
|
|
44
|
+
path: { type: 'string', description: 'api.github.com relative path starting with "/", e.g. "/repos/{owner}/{repo}/commits?per_page=5" — pass paging via the query parameter instead.' },
|
|
45
|
+
query: { type: 'object', additionalProperties: true, description: 'Optional query parameters as an object, e.g. {"per_page": 30, "sha": "main"} (max 20 entries; string/number/boolean values).' },
|
|
46
|
+
forceRefresh: { type: 'boolean', description: 'Bypass the plugin cache and re-fetch (default false).' },
|
|
47
|
+
},
|
|
48
|
+
output: {
|
|
49
|
+
schema: { type: 'object', additionalProperties: true },
|
|
50
|
+
render: (_args, value) => renderApi(value),
|
|
51
|
+
},
|
|
52
|
+
timeoutMs: 60_000,
|
|
53
|
+
isConcurrencySafe: () => true,
|
|
54
|
+
async execute(args, exec) {
|
|
55
|
+
try {
|
|
56
|
+
const path = guardApiPath(args && args.path)
|
|
57
|
+
const query = sanitizeQuery(args && args.query)
|
|
58
|
+
const runtime = buildRuntime(ctx, options())
|
|
59
|
+
const token = await runtime.resolveToken()
|
|
60
|
+
const attempt = await attemptLadder(
|
|
61
|
+
(proxy) =>
|
|
62
|
+
runtime.api.getJson(path, {
|
|
63
|
+
token,
|
|
64
|
+
signal: exec && exec.signal,
|
|
65
|
+
proxy,
|
|
66
|
+
cache: runtime.cache,
|
|
67
|
+
ttlMs: options().cacheTtlSeconds.meta,
|
|
68
|
+
forceRefresh: args && args.forceRefresh === true,
|
|
69
|
+
fetchImpl: runtime.fetchImpl,
|
|
70
|
+
query,
|
|
71
|
+
}),
|
|
72
|
+
options(),
|
|
73
|
+
)
|
|
74
|
+
const value = attempt.value
|
|
75
|
+
const { _headers, _status, cached: _cached, ...rest } = Array.isArray(value) ? {} : value
|
|
76
|
+
const clean = Array.isArray(value) ? [...value] : rest
|
|
77
|
+
const out = {
|
|
78
|
+
path,
|
|
79
|
+
status: _status ?? 200,
|
|
80
|
+
rateLimit: _headers && _headers['x-ratelimit-remaining'] !== undefined ? String(_headers['x-ratelimit-remaining']) : null,
|
|
81
|
+
json: clean !== undefined && typeof clean === 'object' ? clean : null,
|
|
82
|
+
text: typeof clean === 'string' ? clean : null,
|
|
83
|
+
truncated: false,
|
|
84
|
+
via: attempt.via,
|
|
85
|
+
}
|
|
86
|
+
// Lossless-JSON guarantee: the tool contract rejects undefined
|
|
87
|
+
// values; the round-trip strips any that slip through.
|
|
88
|
+
return JSON.parse(JSON.stringify(out))
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
91
|
+
const code = error && error.code ? error.code : 'ERROR'
|
|
92
|
+
return { error: `${code}: ${message}`, path: args && args.path ? String(args.path) : undefined }
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
presentCall: (args) => ({
|
|
96
|
+
card: 'generic',
|
|
97
|
+
title: 'github_api' + (args && args.path ? ' ' + args.path : ''),
|
|
98
|
+
}),
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `github_file` — read-only file content with api/raw/mirror/git routing.
|
|
3
|
+
* @module dsh-github-router/tools/file
|
|
4
|
+
*/
|
|
5
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
6
|
+
import { aggregateFile } from '../core/file.js'
|
|
7
|
+
import { renderFile } from '../render.js'
|
|
8
|
+
import { sessionCwd } from '../util.js'
|
|
9
|
+
|
|
10
|
+
export function registerFileTool(ctx, options) {
|
|
11
|
+
ctx.tools.register(
|
|
12
|
+
defineTool({
|
|
13
|
+
name: 'github_file',
|
|
14
|
+
description:
|
|
15
|
+
'Read a file (or directory listing) from a GitHub repository READ-ONLY. Routes internally: api.github.com contents → raw.githubusercontent.com (direct/proxy) → user-configured mirrors (off by default) → git protocol (plugin fetch cache, or a local clone via localRepo/session cwd). Returns the content with size, truncation state, and the route that served it. Directories return their entry list.',
|
|
16
|
+
parameters: {
|
|
17
|
+
owner: { type: 'string', description: 'Repository owner (user or org).' },
|
|
18
|
+
repo: { type: 'string', description: 'Repository name.' },
|
|
19
|
+
path: { type: 'string', description: 'Repository-relative file or directory path, e.g. "src/index.js".' },
|
|
20
|
+
ref: { type: 'string', description: 'Branch, tag, or full commit sha (default "HEAD").' },
|
|
21
|
+
maxBytes: { type: 'number', description: 'Content cap in bytes, 4096..8388608 (default 262144).' },
|
|
22
|
+
localRepo: { type: 'string', description: 'Optional path to a local clone; content is read via git show without network.' },
|
|
23
|
+
forceRefresh: { type: 'boolean', description: 'Bypass the plugin cache and re-fetch (default false).' },
|
|
24
|
+
},
|
|
25
|
+
output: {
|
|
26
|
+
schema: { type: 'object', additionalProperties: true },
|
|
27
|
+
render: (_args, value) => renderFile(value),
|
|
28
|
+
},
|
|
29
|
+
timeoutMs: 120_000,
|
|
30
|
+
isConcurrencySafe: () => true,
|
|
31
|
+
async execute(args, exec) {
|
|
32
|
+
try {
|
|
33
|
+
return await aggregateFile(ctx, options(), {
|
|
34
|
+
...(args ?? {}),
|
|
35
|
+
cwd: sessionCwd(exec),
|
|
36
|
+
signal: exec && exec.signal,
|
|
37
|
+
})
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
presentCall: (args) => ({
|
|
43
|
+
card: 'generic',
|
|
44
|
+
title: `github_file ${args && args.owner ? args.owner : '?'}/${args && args.repo ? args.repo : '?'}${args && args.path ? ' ' + args.path : ''}`,
|
|
45
|
+
}),
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `github_issue` — read-only issue loading with the same route chain.
|
|
3
|
+
* @module dsh-github-router/tools/issue
|
|
4
|
+
*/
|
|
5
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
6
|
+
import { aggregateIssue } from '../core/issue.js'
|
|
7
|
+
import { renderIssue } from '../render.js'
|
|
8
|
+
import { sessionCwd } from '../util.js'
|
|
9
|
+
|
|
10
|
+
export function registerIssueTool(ctx, options) {
|
|
11
|
+
ctx.tools.register(
|
|
12
|
+
defineTool({
|
|
13
|
+
name: 'github_issue',
|
|
14
|
+
description:
|
|
15
|
+
'Load a GitHub issue READ-ONLY, routing api.github.com (direct/proxy) → gh CLI → the issue page HTML parse internally, with metadata, body, labels, and comments plus per-part route attribution. Use instead of shell curl/gh for issue reads; never writes or comments.',
|
|
16
|
+
parameters: {
|
|
17
|
+
owner: { type: 'string', description: 'Repository owner (user or org).' },
|
|
18
|
+
repo: { type: 'string', description: 'Repository name.' },
|
|
19
|
+
number: { type: 'number', description: 'Issue number.' },
|
|
20
|
+
maxComments: { type: 'number', description: 'Cap for comments, 5..200 (default 50).' },
|
|
21
|
+
forceRefresh: { type: 'boolean', description: 'Bypass the plugin cache and re-fetch (default false).' },
|
|
22
|
+
},
|
|
23
|
+
output: {
|
|
24
|
+
schema: { type: 'object', additionalProperties: true },
|
|
25
|
+
render: (_args, value) => renderIssue(value),
|
|
26
|
+
},
|
|
27
|
+
timeoutMs: 90_000,
|
|
28
|
+
isConcurrencySafe: () => true,
|
|
29
|
+
async execute(args, exec) {
|
|
30
|
+
try {
|
|
31
|
+
return await aggregateIssue(ctx, options(), {
|
|
32
|
+
...(args ?? {}),
|
|
33
|
+
cwd: sessionCwd(exec),
|
|
34
|
+
signal: exec && exec.signal,
|
|
35
|
+
})
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
presentCall: (args) => ({
|
|
41
|
+
card: 'generic',
|
|
42
|
+
title: `github_issue ${args && args.owner ? args.owner : '?'}/${args && args.repo ? args.repo : '?'}${args && args.number ? '#' + args.number : ''}`,
|
|
43
|
+
}),
|
|
44
|
+
}),
|
|
45
|
+
)
|
|
46
|
+
}
|
package/lib/tools/pr.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `github_pr` — one call to load a pull request across every route:
|
|
3
|
+
* api.github.com → gh CLI → page HTML parse → git protocol. Returns
|
|
4
|
+
* metadata, discussion, reviews, commits, changed files, and the diff with
|
|
5
|
+
* per-part route attribution. Read-only; no write capability exists.
|
|
6
|
+
* @module dsh-github-router/tools/pr
|
|
7
|
+
*/
|
|
8
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
9
|
+
import { aggregatePr } from '../core/pr.js'
|
|
10
|
+
import { renderPr } from '../render.js'
|
|
11
|
+
import { sessionCwd } from '../util.js'
|
|
12
|
+
|
|
13
|
+
export function registerPrTool(ctx, options) {
|
|
14
|
+
ctx.tools.register(
|
|
15
|
+
defineTool({
|
|
16
|
+
name: 'github_pr',
|
|
17
|
+
description:
|
|
18
|
+
'Load a GitHub pull request READ-ONLY across every available route, internally routing api.github.com (direct/proxy) → gh CLI → the PR page HTML (strict JSON embeddedData parse) → git protocol (plugin fetch cache or a local clone), and returning metadata, description, discussion/review comments, reviews, commits, changed files, and the diff with per-part route attribution. Use this instead of shell curl/gh/git for PR reads — it never writes, pushes, or comments, and one call replaces many failed shell attempts. Parts can be toggled (includeDiscussion/includeReviews/includeCommits/includeFiles/includeDiff) and capped (maxDiffBytes/maxItems). When the local machine already has the repository cloned and fetched, pass localRepo (or the session cwd is auto-detected) to read commits/diff from the local clone with zero network.',
|
|
19
|
+
parameters: {
|
|
20
|
+
owner: { type: 'string', description: 'Repository owner (user or org), e.g. "octocat".' },
|
|
21
|
+
repo: { type: 'string', description: 'Repository name, e.g. "Hello-World".' },
|
|
22
|
+
number: { type: 'number', description: 'Pull request number.' },
|
|
23
|
+
includeDiscussion: { type: 'boolean', description: 'Include issue comments and inline review comments (default true).' },
|
|
24
|
+
includeReviews: { type: 'boolean', description: 'Include formal review summaries (default true).' },
|
|
25
|
+
includeCommits: { type: 'boolean', description: 'Include the commit list (default true).' },
|
|
26
|
+
includeFiles: { type: 'boolean', description: 'Include the changed-files list (default true).' },
|
|
27
|
+
includeDiff: { type: 'boolean', description: 'Include the unified diff (default true).' },
|
|
28
|
+
maxDiffBytes: { type: 'number', description: 'Cap for the diff in bytes, 4096..1048576 (default 65536).' },
|
|
29
|
+
maxItems: { type: 'number', description: 'Cap per list (discussion/reviews/commits/files), 5..200 (default 50).' },
|
|
30
|
+
localRepo: { type: 'string', description: 'Optional path to a local clone of the repo; commits/diff are read from it (log/diff/show only — never fetched, never written).' },
|
|
31
|
+
forceRefresh: { type: 'boolean', description: 'Bypass the plugin cache and re-fetch (default false).' },
|
|
32
|
+
},
|
|
33
|
+
output: {
|
|
34
|
+
schema: { type: 'object', additionalProperties: true },
|
|
35
|
+
render: (_args, value) => renderPr(value),
|
|
36
|
+
},
|
|
37
|
+
timeoutMs: 120_000,
|
|
38
|
+
isConcurrencySafe: () => true,
|
|
39
|
+
async execute(args, exec) {
|
|
40
|
+
try {
|
|
41
|
+
return await aggregatePr(ctx, options(), {
|
|
42
|
+
...(args ?? {}),
|
|
43
|
+
cwd: sessionCwd(exec),
|
|
44
|
+
signal: exec && exec.signal,
|
|
45
|
+
})
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
presentCall: (args) => ({
|
|
51
|
+
card: 'generic',
|
|
52
|
+
title: `github_pr ${args && args.owner ? args.owner : '?'}/${args && args.repo ? args.repo : '?'}${args && args.number ? '#' + args.number : ''}`,
|
|
53
|
+
}),
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `github_probe` — the connectivity matrix. One call, no shell retries.
|
|
3
|
+
* @module dsh-github-router/tools/probe
|
|
4
|
+
*/
|
|
5
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
6
|
+
import { aggregateProbe } from '../core/probe.js'
|
|
7
|
+
import { renderProbe } from '../render.js'
|
|
8
|
+
|
|
9
|
+
export function registerProbeTool(ctx, options) {
|
|
10
|
+
ctx.tools.register(
|
|
11
|
+
defineTool({
|
|
12
|
+
name: 'github_probe',
|
|
13
|
+
description:
|
|
14
|
+
'Probe every GitHub route once from the host side and report which ones are live (api.github.com direct/proxy, gh CLI installed/authed, git ls-remote, github.com page direct/proxy, mirrors, token presence) with timings and a recommended route chain. Call this first when GitHub access fails or is slow instead of retrying shell commands; read-only, short timeouts, no side effects.',
|
|
15
|
+
parameters: {
|
|
16
|
+
owner: { type: 'string', description: 'Optional repository owner to probe against.' },
|
|
17
|
+
repo: { type: 'string', description: 'Optional repository name to probe against.' },
|
|
18
|
+
prNumber: { type: 'number', description: 'Optional PR number; git-ls-remote then probes refs/pull/N/head.' },
|
|
19
|
+
},
|
|
20
|
+
output: {
|
|
21
|
+
schema: { type: 'object', additionalProperties: true },
|
|
22
|
+
render: (_args, value) => renderProbe(value),
|
|
23
|
+
},
|
|
24
|
+
timeoutMs: 90_000,
|
|
25
|
+
isConcurrencySafe: () => true,
|
|
26
|
+
async execute(args, exec) {
|
|
27
|
+
try {
|
|
28
|
+
return await aggregateProbe(ctx, options(), {
|
|
29
|
+
...(args ?? {}),
|
|
30
|
+
_signal: exec && exec.signal,
|
|
31
|
+
})
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
presentCall: (args) => ({
|
|
37
|
+
card: 'generic',
|
|
38
|
+
title: 'github_probe' + (args && args.owner ? ' ' + args.owner : ''),
|
|
39
|
+
}),
|
|
40
|
+
}),
|
|
41
|
+
)
|
|
42
|
+
}
|