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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }