dsh-github-router 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/CONTRIBUTING.md +80 -0
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/README.zh.md +156 -0
- package/SECURITY.md +112 -0
- package/cordis.patch.yml +8 -0
- package/docs/design.md +198 -0
- package/lib/cache.js +91 -0
- package/lib/client.js +553 -0
- package/lib/config.js +109 -0
- package/lib/core/file.js +166 -0
- package/lib/core/issue.js +202 -0
- package/lib/core/pr.js +429 -0
- package/lib/core/probe.js +121 -0
- package/lib/core/runtime.js +89 -0
- package/lib/guidance.js +24 -0
- package/lib/index.js +52 -0
- package/lib/net.js +213 -0
- package/lib/remote.js +169 -0
- package/lib/render.js +154 -0
- package/lib/routes/api.js +136 -0
- package/lib/routes/gh.js +109 -0
- package/lib/routes/git.js +429 -0
- package/lib/routes/html.js +250 -0
- package/lib/routes/mirror.js +55 -0
- package/lib/skill.js +44 -0
- package/lib/tools/api.js +101 -0
- package/lib/tools/file.js +48 -0
- package/lib/tools/issue.js +46 -0
- package/lib/tools/pr.js +56 -0
- package/lib/tools/probe.js +42 -0
- package/lib/tunnel.js +247 -0
- package/lib/util.js +235 -0
- package/package.json +79 -0
package/lib/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
|
+
}
|