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/lib/index.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * dsh-github-router — DeepSeek Harness plugin for READ-ONLY GitHub access.
3
+ *
4
+ * Plugin code runs in the host process, so it is free of the sandbox's
5
+ * shell-side TLS/proxy failures — which is exactly why the routing lives
6
+ * inside the tools. The unrestricted token is compensated with a strict
7
+ * security posture:
8
+ *
9
+ * 1. Five read-only tools (github_probe / github_pr / github_issue /
10
+ * github_file / github_api). There is no write, push, comment, or
11
+ * mutation verb anywhere in this package.
12
+ * 2. Every network call is a GET with an explicit direct/proxy decision per
13
+ * attempt (ambient proxy env is not blindly inherited); subprocess calls
14
+ * are argv arrays — no shell interpolation.
15
+ * 3. api.github.com paths, repo file paths, refs, owners, and query values
16
+ * are validated before use; tokens are attached only to api.github.com
17
+ * and redacted from every output; page HTML is parsed by strict JSON
18
+ * island extraction (JSON.parse only, never evaluated).
19
+ * 4. Plugin-owned state (fetch cache, response cache) lives under
20
+ * <DSH_HOME>/storages/dsh-github-router; user repositories are only ever
21
+ * read (log/diff/show) and only when explicitly granted.
22
+ * 5. Configuration rides the official settings seam (`dsh-github-router`
23
+ * namespace: durable document, schema validation, revision fencing) and
24
+ * is exposed to the browser through a plugin-owned remote API
25
+ * (`githubRouterSettings/{describe,mutate}`), because the framework's
26
+ * api-proxy only serves settings namespaces on its hardcoded allowlist.
27
+ * @module dsh-github-router
28
+ */
29
+ import { installRemote } from './remote.js'
30
+ import { registerGuidance } from './guidance.js'
31
+ import { registerSkill } from './skill.js'
32
+ import { registerApiTool } from './tools/api.js'
33
+ import { registerFileTool } from './tools/file.js'
34
+ import { registerIssueTool } from './tools/issue.js'
35
+ import { registerPrTool } from './tools/pr.js'
36
+ import { registerProbeTool } from './tools/probe.js'
37
+
38
+ export const name = 'dsh-github-router'
39
+ export const inject = ['tools', 'subprocess', 'skills', 'systemPrompt']
40
+
41
+ export function apply(ctx, config = {}) {
42
+ const remote = installRemote(ctx, config)
43
+ const options = remote.options
44
+
45
+ registerSkill(ctx)
46
+ registerGuidance(ctx)
47
+ registerProbeTool(ctx, options)
48
+ registerPrTool(ctx, options)
49
+ registerIssueTool(ctx, options)
50
+ registerFileTool(ctx, options)
51
+ registerApiTool(ctx, options)
52
+ }
package/lib/net.js ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Route-aware HTTP layer.
3
+ *
4
+ * Every request is a plain GET with an explicit proxy decision: direct
5
+ * requests use the global fetch, proxied requests go through the
6
+ * zero-dependency CONNECT tunnel client (see tunnel.js). The ambient
7
+ * HTTP(S)_PROXY environment variables are deliberately NOT inherited by
8
+ * global fetch, so routing stays under this plugin's control.
9
+ * @module dsh-github-router/net
10
+ */
11
+ import { tunnelRequest } from './tunnel.js'
12
+
13
+ /** Structured route failure with a stable machine code. */
14
+ export class RouteError extends Error {
15
+ constructor(code, message, options = {}) {
16
+ super(message)
17
+ this.name = 'RouteError'
18
+ this.code = code // TRANSPORT | TIMEOUT | HTTP | PROXY_UNAVAILABLE | ABORTED
19
+ if (options.status !== undefined) this.status = options.status
20
+ if (options.cause !== undefined) this.cause = options.cause
21
+ }
22
+ }
23
+
24
+ /** One combined signal: caller cancellation plus a cooperative deadline. */
25
+ export function makeAbort(signal, timeoutMs) {
26
+ const controller = new AbortController()
27
+ if (signal !== undefined && signal !== null) {
28
+ if (signal.aborted) controller.abort(signal.reason)
29
+ else signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true })
30
+ }
31
+ const timer = setTimeout(
32
+ () => controller.abort(new DOMException(`request timed out after ${timeoutMs}ms`, 'TimeoutError')),
33
+ timeoutMs,
34
+ )
35
+ if (typeof timer.unref === 'function') timer.unref()
36
+ return { signal: controller.signal, cleanup: () => clearTimeout(timer) }
37
+ }
38
+
39
+ function classifyTransportError(error, signal) {
40
+ if (signal !== undefined && signal !== null && signal.aborted) return new RouteError('ABORTED', 'aborted by caller', { cause: error })
41
+ if (error && error.name === 'TimeoutError') return new RouteError('TIMEOUT', String(error.message || 'request timed out'), { cause: error })
42
+ if (error && error.name === 'AbortError') return new RouteError('ABORTED', 'aborted by caller', { cause: error })
43
+ return new RouteError('TRANSPORT', `request failed: ${String(error && error.message ? error.message : error)}`, { cause: error })
44
+ }
45
+ async function readBody(response, maxBytes) {
46
+ const declared = Number(response.headers.get('content-length') || '0')
47
+ if (declared > 0 && declared <= maxBytes) {
48
+ const buf = Buffer.from(await response.arrayBuffer())
49
+ return { data: buf, truncated: false }
50
+ }
51
+ const reader = response.body ? response.body.getReader() : null
52
+ if (reader === null) return { data: Buffer.alloc(0), truncated: false }
53
+ const chunks = []
54
+ let total = 0
55
+ try {
56
+ while (true) {
57
+ const { done, value } = await reader.read()
58
+ if (done) break
59
+ total += value.byteLength
60
+ if (total > maxBytes) {
61
+ chunks.push(Buffer.from(value).subarray(0, maxBytes - (total - value.byteLength)))
62
+ await reader.cancel().catch(() => {})
63
+ return { data: Buffer.concat(chunks), truncated: true }
64
+ }
65
+ chunks.push(Buffer.from(value))
66
+ }
67
+ } catch (error) {
68
+ await reader.cancel().catch(() => {})
69
+ throw error
70
+ }
71
+ return { data: Buffer.concat(chunks), truncated: false }
72
+ }
73
+
74
+ const KEPT_HEADERS = ['etag', 'last-modified', 'x-ratelimit-remaining', 'x-ratelimit-limit', 'x-ratelimit-reset']
75
+
76
+ /** Headers projection compatible with both fetch (Headers) and the tunnel (plain object). */
77
+ function pickHeaders(response) {
78
+ const get = (name) => {
79
+ if (response.headers === null || response.headers === undefined) return null
80
+ if (typeof response.headers.get === 'function') return response.headers.get(name)
81
+ return response.headers[name] ?? null
82
+ }
83
+ const out = {}
84
+ for (const name of KEPT_HEADERS) {
85
+ const value = get(name)
86
+ if (value !== null && value !== undefined && value.length > 0) out[name] = value
87
+ }
88
+ const retryAfter = get('retry-after')
89
+ if (typeof retryAfter === 'string' && retryAfter.length > 0) out['retry-after'] = retryAfter
90
+ return out
91
+ }
92
+
93
+ /**
94
+ * One GET attempt with an explicit proxy decision: direct requests go
95
+ * through global fetch, proxied requests through the zero-dependency
96
+ * CONNECT tunnel.
97
+ * @returns { status, ok, body: Buffer, truncated, headers, url }
98
+ * @throws {RouteError}
99
+ */
100
+ export async function fetchOnce(url, options) {
101
+ const {
102
+ method = 'GET',
103
+ headers = {},
104
+ timeoutMs = 8000,
105
+ proxy,
106
+ signal,
107
+ maxBytes = 1048576,
108
+ } = options
109
+ if (proxy !== undefined && proxy !== null && proxy !== '') {
110
+ try {
111
+ const result = await tunnelRequest(url, { method, headers, timeoutMs, proxy, signal, maxBytes })
112
+ return {
113
+ status: result.status,
114
+ ok: result.ok,
115
+ body: result.body,
116
+ truncated: result.truncated,
117
+ headers: pickHeaders(result),
118
+ url: result.url,
119
+ }
120
+ } catch (error) {
121
+ throw classifyTransportError(error, signal)
122
+ }
123
+ }
124
+
125
+ const { signal: combined, cleanup } = makeAbort(signal, timeoutMs)
126
+ let response
127
+ try {
128
+ response = await fetch(url, {
129
+ method,
130
+ headers,
131
+ redirect: 'follow',
132
+ signal: combined,
133
+ })
134
+ } catch (error) {
135
+ cleanup()
136
+ throw classifyTransportError(error, signal)
137
+ }
138
+ try {
139
+ const body = await readBody(response, maxBytes)
140
+ cleanup()
141
+ return {
142
+ status: response.status,
143
+ ok: response.ok,
144
+ body: body.data,
145
+ truncated: body.truncated,
146
+ headers: pickHeaders(response),
147
+ url: response.url,
148
+ }
149
+ } catch (error) {
150
+ cleanup()
151
+ throw classifyTransportError(error, signal)
152
+ }
153
+ }
154
+
155
+ /** Retryable HTTP statuses for idempotent GETs. */
156
+ export function isRetryableStatus(status) {
157
+ return status === 429 || (status >= 500 && status <= 599)
158
+ }
159
+
160
+ /**
161
+ * GET with bounded retries on 429/5xx (honoring Retry-After). Non-retryable
162
+ * failures surface immediately so the router can fall through to the next
163
+ * route instead of burning time.
164
+ */
165
+ export async function fetchWithRetry(url, options) {
166
+ const { retries = 0, onAttempt } = options
167
+ let attempt = 0
168
+ for (;;) {
169
+ try {
170
+ const result = await fetchOnce(url, options)
171
+ if (onAttempt !== undefined) onAttempt('ok', result.status, attempt)
172
+ if (result.ok || !isRetryableStatus(result.status) || attempt >= retries) return result
173
+ const waitMs = retryDelayMs(result.headers['retry-after'], attempt)
174
+ if (onAttempt !== undefined) onAttempt('retry', result.status, attempt)
175
+ await new Promise((resolve) => setTimeout(resolve, waitMs))
176
+ attempt += 1
177
+ } catch (error) {
178
+ if (error instanceof RouteError && (error.code === 'TIMEOUT' || error.code === 'TRANSPORT') && attempt < retries) {
179
+ if (onAttempt !== undefined) onAttempt('retry', error.code, attempt)
180
+ await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt))
181
+ attempt += 1
182
+ } else {
183
+ throw error
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ function retryDelayMs(retryAfter, attempt) {
190
+ if (typeof retryAfter === 'string' && /^\d+$/.test(retryAfter)) {
191
+ const seconds = Number(retryAfter)
192
+ if (Number.isFinite(seconds) && seconds >= 0 && seconds <= 120) return seconds * 1000
193
+ }
194
+ return Math.min(5000, 500 * 2 ** attempt)
195
+ }
196
+
197
+ /** Fetch and decode text; invalid UTF-8 is replaced, never throws. */
198
+ export async function fetchText(url, options) {
199
+ const result = await fetchWithRetry(url, options)
200
+ return { ...result, text: result.body.toString('utf8') }
201
+ }
202
+
203
+ /** Fetch and parse JSON; parse failures surface as a structured error. */
204
+ export async function fetchJson(url, options) {
205
+ const result = await fetchWithRetry(url, options)
206
+ let json
207
+ try {
208
+ json = JSON.parse(result.body.toString('utf8'))
209
+ } catch (error) {
210
+ throw new RouteError('TRANSPORT', `unprocessable JSON response from ${url}: ${String(error && error.message ? error.message : error)}`, { cause: error })
211
+ }
212
+ return { ...result, json }
213
+ }
package/lib/remote.js ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Host-side configuration routes for the browser settings page.
3
+ *
4
+ * The channel is the dshmarket pattern: the plugin mounts its own HTTP
5
+ * routes on the shared web server (`ctx.webServer.register`, prefix
6
+ * `/dsh-github-router`) and the browser page talks to them with plain
7
+ * same-origin fetch — no typert, no gateway, no framework allowlist. The
8
+ * routes are backed by the SAME official settings seam: the namespace
9
+ * stays durable in the settings document, with schema validation,
10
+ * revision fencing, and secret redaction.
11
+ *
12
+ * Mutating requests are POST-only, same-origin-checked (Origin === Host)
13
+ * and body-capped, mirroring dsh-market's route security posture.
14
+ * @module dsh-github-router/remote
15
+ */
16
+ import { Config, NAMESPACE, resolveOptions } from './config.js'
17
+
18
+ const ROUTE_PREFIX = '/dsh-github-router'
19
+ const MAX_BODY_BYTES = 16384
20
+
21
+ function sendJson(response, status, payload) {
22
+ response.writeHead(status, {
23
+ 'cache-control': 'no-store',
24
+ 'content-type': 'application/json; charset=utf-8',
25
+ })
26
+ response.end(JSON.stringify(payload))
27
+ }
28
+
29
+ /** Same-origin enforcement for mutating routes (dsh-market posture). */
30
+ function sameOrigin(request) {
31
+ const origin = request.headers.origin
32
+ const host = request.headers.host
33
+ if (origin === undefined || host === undefined) return false
34
+ try {
35
+ return new URL(origin).host === host
36
+ } catch {
37
+ return false
38
+ }
39
+ }
40
+
41
+ /** Read and parse a JSON request body with a hard byte cap. */
42
+ async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
43
+ const chunks = []
44
+ let size = 0
45
+ for await (const chunk of request) {
46
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
47
+ size += buffer.length
48
+ if (size > maxBytes) throw new Error('request body too large')
49
+ chunks.push(buffer)
50
+ }
51
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
52
+ }
53
+
54
+ /**
55
+ * Install the settings-namespace registration, the options thunk, and the
56
+ * `/dsh-github-router/config` routes in one wiring. Returns the
57
+ * runtime-options thunk the tools consume.
58
+ */
59
+ export function installRemote(ctx, config) {
60
+ const holder = { scope: null, settings: null }
61
+
62
+ ctx.inject(['settings'], (sctx) => {
63
+ holder.settings = sctx.settings
64
+ holder.scope = sctx.settings.register(NAMESPACE, Config, { base: config })
65
+ sctx.effect(() => () => {
66
+ holder.scope = null
67
+ holder.settings = null
68
+ }, 'dsh-github-router: settings scope teardown')
69
+ })
70
+
71
+ /** Redacted wire view of this namespace (value/base/user/revision/writable). */
72
+ function viewOf() {
73
+ if (holder.scope === null || holder.settings === null) return null
74
+ const descriptors = holder.settings.describe({ redactSecrets: true })
75
+ const descriptor = descriptors.find((d) => String(d.ns) === NAMESPACE)
76
+ if (descriptor === undefined) return null
77
+ return {
78
+ value: descriptor.value ?? {},
79
+ base: descriptor.base ?? {},
80
+ user: descriptor.user ?? {},
81
+ revision: descriptor.revision,
82
+ writable: holder.settings.writable === true,
83
+ }
84
+ }
85
+
86
+ function validateOps(payload) {
87
+ if (payload === null || typeof payload !== 'object' || !Array.isArray(payload.ops) || payload.ops.length === 0) {
88
+ return 'payload.ops must be a non-empty array'
89
+ }
90
+ if (payload.ops.length > 40) return 'too many ops in one write'
91
+ for (const op of payload.ops) {
92
+ if (op === null || typeof op !== 'object' || (op.op !== 'set' && op.op !== 'unset')) {
93
+ return 'each op must be { op: "set" | "unset", path: [field] }'
94
+ }
95
+ if (!Array.isArray(op.path) || op.path.length !== 1 || typeof op.path[0] !== 'string' || op.path[0].length === 0 || op.path[0].length > 64) {
96
+ return 'op.path must be a single field name'
97
+ }
98
+ if (op.op === 'set' && op.value === undefined) return 'set ops require a value'
99
+ }
100
+ return null
101
+ }
102
+
103
+ const routeHandler = async (request, response) => {
104
+ const pathname = new URL(request.url ?? '/', 'http://x').pathname
105
+ if (pathname !== ROUTE_PREFIX + '/config') {
106
+ sendJson(response, 404, { ok: false, error: 'unknown dsh-github-router route' })
107
+ return
108
+ }
109
+ if (request.method === 'GET') {
110
+ if (holder.scope === null || holder.settings === null) {
111
+ sendJson(response, 503, { ok: false, error: 'the settings service is not mounted in this deployment' })
112
+ return
113
+ }
114
+ const view = viewOf()
115
+ if (view === null) {
116
+ sendJson(response, 503, { ok: false, error: 'settings namespace is not registered' })
117
+ return
118
+ }
119
+ sendJson(response, 200, { ok: true, value: view })
120
+ return
121
+ }
122
+ if (request.method === 'POST') {
123
+ if (!sameOrigin(request)) {
124
+ sendJson(response, 403, { ok: false, error: 'configuration writes are limited to same-origin requests' })
125
+ return
126
+ }
127
+ if (holder.scope === null || holder.settings === null) {
128
+ sendJson(response, 503, { ok: false, error: 'the settings service is not mounted in this deployment' })
129
+ return
130
+ }
131
+ let payload
132
+ try {
133
+ payload = await readJsonBody(request)
134
+ } catch (error) {
135
+ sendJson(response, 400, { ok: false, error: `unreadable request body: ${String(error && error.message ? error.message : error)}` })
136
+ return
137
+ }
138
+ const invalid = validateOps(payload)
139
+ if (invalid !== null) {
140
+ sendJson(response, 400, { ok: false, error: invalid })
141
+ return
142
+ }
143
+ const expectedRevision = Number.isInteger(payload.expectedRevision) ? payload.expectedRevision : undefined
144
+ try {
145
+ await holder.scope.mutate(payload.ops, expectedRevision)
146
+ } catch (error) {
147
+ sendJson(response, 409, { ok: false, error: error instanceof Error ? error.message : String(error) })
148
+ return
149
+ }
150
+ const view = viewOf()
151
+ if (view === null) {
152
+ sendJson(response, 503, { ok: false, error: 'settings namespace disappeared after the write' })
153
+ return
154
+ }
155
+ sendJson(response, 200, { ok: true, value: view })
156
+ return
157
+ }
158
+ response.writeHead(405, { allow: 'GET, POST' })
159
+ response.end()
160
+ }
161
+
162
+ ctx.inject(['webServer'], (hctx) => {
163
+ hctx.effect(() => hctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler: routeHandler }), 'dsh-github-router: settings routes')
164
+ })
165
+
166
+ return {
167
+ options: () => (holder.scope !== null ? resolveOptions(holder.scope.get()) : resolveOptions(config)),
168
+ }
169
+ }
package/lib/render.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Model-facing text renderers for the canonical tool values.
3
+ * @module dsh-github-router/render
4
+ */
5
+
6
+ function routeLine(routes) {
7
+ const parts = Object.entries(routes ?? {})
8
+ .filter(([, v]) => v !== null && v !== undefined && v !== '')
9
+ .map(([k, v]) => `${k}=${v}`)
10
+ return parts.length > 0 ? `routes: ${parts.join(', ')}` : 'routes: none'
11
+ }
12
+
13
+ function notesLine(notes) {
14
+ return Array.isArray(notes) && notes.length > 0 ? `notes:\n${notes.map((n) => ' - ' + n).join('\n')}` : ''
15
+ }
16
+
17
+ export function renderPr(value) {
18
+ if (value && typeof value.error === 'string') {
19
+ const routes = value.routes ? `\n${routeLine(value.routes)}` : ''
20
+ const notes = notesLine(value.notes)
21
+ return [{ type: 'text', text: `github_pr failed: ${value.error}${routes}${notes ? '\n' + notes : ''}` }]
22
+ }
23
+ const p = value.pr ?? {}
24
+ const lines = []
25
+ lines.push(`# PR ${p.owner}/${p.repo}#${p.number}: ${p.title ?? '(no title)'}`)
26
+ const flags = []
27
+ if (p.state) flags.push(p.state.toLowerCase())
28
+ if (p.draft) flags.push('draft')
29
+ if (p.merged) flags.push('merged')
30
+ lines.push(
31
+ `state: ${flags.length > 0 ? flags.join(', ') : 'unknown'} | author: ${p.author ?? 'unknown'} | +${p.additions ?? '?'}/-${p.deletions ?? '?'} across ${p.changedFiles ?? '?'} files`,
32
+ )
33
+ if (p.baseRef && p.headRef) lines.push(`base: ${p.baseRef} (${(p.baseSha ?? '').slice(0, 8)}) -> head: ${p.headRef} (${(p.headSha ?? '').slice(0, 8)})`)
34
+ const dates = [p.createdAt ? `created ${p.createdAt}` : null, p.updatedAt ? `updated ${p.updatedAt}` : null, p.mergedAt ? `merged ${p.mergedAt}` : null].filter(Boolean)
35
+ if (dates.length > 0) lines.push(dates.join(' | '))
36
+ if (p.url) lines.push(`url: ${p.url}`)
37
+ if (p.body && p.body.length > 0) {
38
+ lines.push('')
39
+ lines.push('## Description')
40
+ lines.push(p.body)
41
+ }
42
+ if (value.discussion && value.discussion.length > 0) {
43
+ lines.push('', `## Discussion (${value.discussion.length}${value.routes.discussion ? ', via ' + value.routes.discussion : ''})`)
44
+ for (const item of value.discussion) {
45
+ lines.push(`- [${item.kind}] ${item.author ?? 'unknown'}${item.createdAt ? ' @ ' + item.createdAt : ''}${item.path ? ' (' + item.path + ')' : ''}`)
46
+ lines.push(' ' + String(item.body ?? '').split('\n').join('\n '))
47
+ }
48
+ }
49
+ if (value.reviews && value.reviews.length > 0) {
50
+ lines.push('', `## Reviews (${value.reviews.length})`)
51
+ for (const r of value.reviews) {
52
+ lines.push(`- [${r.state ?? 'review'}] ${r.author ?? 'unknown'}${r.submittedAt ? ' @ ' + r.submittedAt : ''}`)
53
+ if (r.body) lines.push(' ' + String(r.body).split('\n').join('\n '))
54
+ }
55
+ }
56
+ if (value.files && value.files.length > 0) {
57
+ lines.push('', `## Files (${value.files.length})`)
58
+ for (const f of value.files) {
59
+ const parts = [`${f.path}`, f.status ? ` [${f.status}]` : '', `+${f.additions ?? 0}/-${f.deletions ?? 0}`]
60
+ lines.push(`- ${parts.join(' ')}`)
61
+ }
62
+ }
63
+ if (value.commits && value.commits.length > 0) {
64
+ lines.push('', `## Commits (${value.commits.length})`)
65
+ for (const c of value.commits) {
66
+ lines.push(`- ${(c.sha ?? '').slice(0, 8)} ${c.author ?? ''}${c.date ? ' ' + c.date : ''}: ${String(c.message ?? '').split('\n')[0].slice(0, 140)}`)
67
+ }
68
+ }
69
+ if (value.diff !== null && value.diff !== undefined) {
70
+ lines.push('', `## Diff (via ${value.routes.diff ?? '?'}${value.diffTruncated ? ', truncated' : ''})`)
71
+ lines.push(value.diff)
72
+ }
73
+ lines.push('', routeLine(value.routes))
74
+ const notes = notesLine(value.notes)
75
+ if (notes.length > 0) lines.push(notes)
76
+ return [{ type: 'text', text: lines.join('\n') }]
77
+ }
78
+
79
+ export function renderIssue(value) {
80
+ if (value && typeof value.error === 'string') {
81
+ const routes = value.routes ? `\n${routeLine(value.routes)}` : ''
82
+ const notes = notesLine(value.notes)
83
+ return [{ type: 'text', text: `github_issue failed: ${value.error}${routes}${notes ? '\n' + notes : ''}` }]
84
+ }
85
+ const i = value.issue ?? {}
86
+ const lines = []
87
+ lines.push(`# Issue ${i.owner}/${i.repo}#${i.number}: ${i.title ?? '(no title)'}`)
88
+ lines.push(`state: ${i.state ?? 'unknown'}${i.stateReason ? ' (' + i.stateReason + ')' : ''} | author: ${i.author ?? 'unknown'} | comments: ${i.commentsCount ?? '?'}`)
89
+ const dates = [i.createdAt ? `created ${i.createdAt}` : null, i.updatedAt ? `updated ${i.updatedAt}` : null, i.closedAt ? `closed ${i.closedAt}` : null].filter(Boolean)
90
+ if (dates.length > 0) lines.push(dates.join(' | '))
91
+ if (i.labels && i.labels.length > 0) lines.push(`labels: ${i.labels.join(', ')}`)
92
+ if (i.url) lines.push(`url: ${i.url}`)
93
+ if (i.body && i.body.length > 0) {
94
+ lines.push('', '## Body')
95
+ lines.push(i.body)
96
+ }
97
+ if (value.comments && value.comments.length > 0) {
98
+ lines.push('', `## Comments (${value.comments.length}${value.routes.comments ? ', via ' + value.routes.comments : ''})`)
99
+ for (const c of value.comments) {
100
+ lines.push(`- [${c.kind}] ${c.author ?? 'unknown'}${c.createdAt ? ' @ ' + c.createdAt : ''}`)
101
+ lines.push(' ' + String(c.body ?? '').split('\n').join('\n '))
102
+ }
103
+ }
104
+ lines.push('', routeLine(value.routes))
105
+ const notes = notesLine(value.notes)
106
+ if (notes.length > 0) lines.push(notes)
107
+ return [{ type: 'text', text: lines.join('\n') }]
108
+ }
109
+
110
+ export function renderFile(value) {
111
+ if (value && typeof value.error === 'string') {
112
+ const failures = Array.isArray(value.failures) && value.failures.length > 0 ? `\nfailures:\n${value.failures.map((f) => ' - ' + f).join('\n')}` : ''
113
+ return [{ type: 'text', text: `github_file failed: ${value.error}${failures}` }]
114
+ }
115
+ if (value.kind === 'directory') {
116
+ const lines = [`directory ${value.path} @ ${value.ref} (${value.entries.length} entries, via ${value.route})`]
117
+ for (const e of value.entries) lines.push(`- ${e.name} (${e.type}${e.size !== null && e.size !== undefined ? ', ' + e.size + ' bytes' : ''})`)
118
+ return [{ type: 'text', text: lines.join('\n') }]
119
+ }
120
+ const head = `file ${value.path} @ ${value.ref} (${value.size} bytes, via ${value.route}${value.truncated ? ', truncated at cap' : ''})`
121
+ return [{ type: 'text', text: head + '\n\n' + value.content }]
122
+ }
123
+
124
+ export function renderProbe(value) {
125
+ if (value && typeof value.error === 'string') return [{ type: 'text', text: `github_probe failed: ${value.error}` }]
126
+ const lines = ['# GitHub route probe (host-side connectivity)']
127
+ lines.push(`proxy in use: ${value.proxy ?? 'none'}`)
128
+ lines.push(`token configured: ${value.tokenConfigured ? 'yes' : 'no (anonymous limits)'}`)
129
+ lines.push('')
130
+ for (const c of value.checks) {
131
+ lines.push(`[${c.ok ? 'OK ' : 'FAIL'}] ${c.route.padEnd(16)} ${c.ms}ms ${c.detail}`)
132
+ }
133
+ lines.push('')
134
+ lines.push('Live routes: ' + (value.live.length > 0 ? value.live.join(', ') : '(none)'))
135
+ lines.push('Recommendation:')
136
+ for (const r of value.recommendation) lines.push(` - ${r}`)
137
+ return [{ type: 'text', text: lines.join('\n') }]
138
+ }
139
+
140
+ export function renderApi(value) {
141
+ if (value && typeof value.error === 'string') {
142
+ return [{ type: 'text', text: `github_api failed: ${value.error}${value.path ? ` (${value.path})` : ''}` }]
143
+ }
144
+ const rateClause = value.rateLimit !== null && value.rateLimit !== undefined ? ` (rate limit remaining: ${value.rateLimit})` : ''
145
+ const head = `GET ${value.path} -> HTTP ${value.status}${rateClause}${value.truncated ? ' [truncated]' : ''}`
146
+ let body
147
+ if (value.json !== undefined && value.json !== null) {
148
+ body = JSON.stringify(value.json, null, 2)
149
+ } else {
150
+ body = value.text ?? ''
151
+ }
152
+ if (body.length > 0) return [{ type: 'text', text: head + '\n\n' + body }]
153
+ return [{ type: 'text', text: head }]
154
+ }