dsh-github-router 0.3.1 → 0.4.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.
@@ -1,250 +1,266 @@
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
- }
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 ||
76
+ 'headRefName' in node ||
77
+ 'headRef' in node ||
78
+ // current page payload: baseBranch/headBranch are plain strings
79
+ 'headBranch' in node ||
80
+ 'baseBranch' in node)
81
+ )
82
+ }
83
+
84
+ function isIssueLike(node) {
85
+ return (
86
+ node !== null &&
87
+ typeof node === 'object' &&
88
+ Number.isInteger(node.number) &&
89
+ typeof node.title === 'string' &&
90
+ ('bodyHTML' in node || 'body' in node) &&
91
+ !('additions' in node) &&
92
+ !('headRefName' in node)
93
+ )
94
+ }
95
+
96
+ function bodyOf(node, cap) {
97
+ const raw = typeof node.bodyHTML === 'string' ? node.bodyHTML : typeof node.body === 'string' ? node.body : ''
98
+ return stripHtml(raw).slice(0, cap)
99
+ }
100
+
101
+ function loginOf(node) {
102
+ if (node && typeof node === 'object' && node.author && typeof node.author === 'object') {
103
+ return typeof node.author.login === 'string' ? node.author.login : null
104
+ }
105
+ if (node && typeof node === 'object' && node.user && typeof node.user === 'object') {
106
+ return typeof node.user.login === 'string' ? node.user.login : null
107
+ }
108
+ return null
109
+ }
110
+
111
+ /**
112
+ * First non-empty string among the candidates, else null. GitHub renames
113
+ * payload fields between page generations (createdAt/createdTime,
114
+ * headRefName/headBranch, mergedAt/mergedTime), so every field maps from a
115
+ * small ordered list of spellings.
116
+ */
117
+ function str(...values) {
118
+ for (const value of values) if (typeof value === 'string' && value.length > 0) return value
119
+ return null
120
+ }
121
+
122
+ /** Find the best PR-shaped node across all embedded JSON payloads. */
123
+ export function prFromPayloads(objects) {
124
+ const candidates = []
125
+ for (const obj of objects) {
126
+ for (const { key, value } of walkJson(obj, (k, v) => isPrLike(v))) {
127
+ candidates.push({ key, value })
128
+ }
129
+ }
130
+ if (candidates.length === 0) return null
131
+ // Prefer nodes reachable under a `pullRequest` key; then the richest shape.
132
+ candidates.sort((a, b) => {
133
+ const ak = a.key === 'pullRequest' ? 0 : 1
134
+ const bk = b.key === 'pullRequest' ? 0 : 1
135
+ if (ak !== bk) return ak - bk
136
+ return b.value.title.length - a.value.title.length
137
+ })
138
+ const pr = candidates[0].value
139
+ const refName = (v) => (v && typeof v === 'object' && typeof v.name === 'string' ? v.name : null)
140
+ const shaOf = (v) => (v && typeof v === 'object' && typeof v.oid === 'string' ? v.oid : null)
141
+ return {
142
+ title: pr.title,
143
+ body: bodyOf(pr, MAX_BODY_CHARS),
144
+ state: typeof pr.state === 'string' ? pr.state : null,
145
+ author: loginOf(pr),
146
+ additions: typeof pr.additions === 'number' ? pr.additions : null,
147
+ deletions: typeof pr.deletions === 'number' ? pr.deletions : null,
148
+ changedFiles: typeof pr.changedFiles === 'number' ? pr.changedFiles : typeof pr.changed_files === 'number' ? pr.changed_files : null,
149
+ createdAt: str(pr.createdAt, pr.createdTime),
150
+ updatedAt: str(pr.updatedAt, pr.updatedTime),
151
+ mergedAt: str(pr.mergedAt, pr.merged_at, pr.mergedTime),
152
+ baseRef: str(pr.baseRefName, pr.baseBranch) ?? refName(pr.baseRef ?? pr.base),
153
+ headRef: str(pr.headRefName, pr.headBranch) ?? refName(pr.headRef ?? pr.head),
154
+ baseSha: str(pr.baseRefOid, pr.baseSha) ?? shaOf(pr.baseRef ?? pr.base),
155
+ headSha: str(pr.headRefOid, pr.headSha) ?? shaOf(pr.headRef ?? pr.head),
156
+ url: typeof pr.url === 'string' && /^https:\/\//.test(pr.url) ? pr.url : typeof pr.permalink === 'string' ? pr.permalink : null,
157
+ }
158
+ }
159
+
160
+ /** Find the best issue-shaped node across all embedded JSON payloads. */
161
+ export function issueFromPayloads(objects) {
162
+ const candidates = []
163
+ for (const obj of objects) {
164
+ for (const { key, value } of walkJson(obj, (k, v) => isIssueLike(v))) {
165
+ candidates.push({ key, value })
166
+ }
167
+ }
168
+ if (candidates.length === 0) return null
169
+ candidates.sort((a, b) => {
170
+ const ak = a.key === 'issue' ? 0 : 1
171
+ const bk = b.key === 'issue' ? 0 : 1
172
+ if (ak !== bk) return ak - bk
173
+ return b.value.title.length - a.value.title.length
174
+ })
175
+ const issue = candidates[0].value
176
+ return {
177
+ title: issue.title,
178
+ body: bodyOf(issue, MAX_BODY_CHARS),
179
+ state: typeof issue.state === 'string' ? issue.state : null,
180
+ stateReason: typeof issue.stateReason === 'string' ? issue.stateReason : null,
181
+ author: loginOf(issue),
182
+ createdAt: typeof issue.createdAt === 'string' ? issue.createdAt : null,
183
+ updatedAt: typeof issue.updatedAt === 'string' ? issue.updatedAt : null,
184
+ closedAt: typeof issue.closedAt === 'string' ? issue.closedAt : null,
185
+ commentsCount: Number.isInteger(issue.commentsCount) ? issue.commentsCount : Number.isInteger(issue.comments?.totalCount) ? issue.comments.totalCount : null,
186
+ url: typeof issue.url === 'string' && /^https:\/\//.test(issue.url) ? issue.url : null,
187
+ }
188
+ }
189
+
190
+ function timelineItem(node) {
191
+ if (node === null || typeof node !== 'object') return null
192
+ const type = typeof node.__typename === 'string' ? node.__typename : null
193
+ if (type === 'IssueComment') {
194
+ return { kind: 'comment', author: loginOf(node), createdAt: node.createdAt ?? null, body: bodyOf(node, MAX_BODY_CHARS), url: node.url ?? null }
195
+ }
196
+ if (type === 'PullRequestReview') {
197
+ 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 }
198
+ }
199
+ if (type === 'PullRequestReviewThread' && Array.isArray(node.comments?.nodes)) {
200
+ const items = []
201
+ for (const c of node.comments.nodes) {
202
+ if (c === null || typeof c !== 'object') continue
203
+ 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 })
204
+ }
205
+ return items.length > 0 ? items : null
206
+ }
207
+ return null
208
+ }
209
+
210
+ /**
211
+ * Discussion and review items from timelineItems / reviewThreads arrays in
212
+ * the payloads. Only whitelisted shapes are copied.
213
+ */
214
+ export function discussionFromPayloads(objects) {
215
+ const items = []
216
+ const seen = new Set()
217
+ const considerNodes = (nodes) => {
218
+ if (!Array.isArray(nodes)) return
219
+ for (const node of nodes) {
220
+ const mapped = timelineItem(node)
221
+ const list = Array.isArray(mapped) ? mapped : mapped !== null ? [mapped] : []
222
+ for (const item of list) {
223
+ const key = JSON.stringify(item)
224
+ if (seen.has(key)) continue
225
+ seen.add(key)
226
+ items.push(item)
227
+ if (items.length >= MAX_ITEMS) return
228
+ }
229
+ if (items.length >= MAX_ITEMS) return
230
+ }
231
+ }
232
+ for (const obj of objects) {
233
+ for (const { value } of walkJson(obj, (k, v) => (k === 'timelineItems' || k === 'reviewThreads') && v !== null && typeof v === 'object' && Array.isArray(v.nodes))) {
234
+ considerNodes(value.nodes)
235
+ if (items.length >= MAX_ITEMS) break
236
+ }
237
+ if (items.length >= MAX_ITEMS) break
238
+ }
239
+ return items
240
+ }
241
+
242
+ /**
243
+ * Fetch one PR/issue HTML page through the net layer and parse it.
244
+ * Returns the parsed view or null when the page cannot be fetched/parsed.
245
+ */
246
+ export async function fetchAndParsePage(owner, repo, number, kind, call) {
247
+ const url = `https://github.com/${owner}/${repo}/${kind}/${number}`
248
+ const raw = await call.fetchImpl(url, {
249
+ headers: {
250
+ accept: 'text/html,application/xhtml+xml',
251
+ 'user-agent': 'dsh-github-router/0.1.0',
252
+ },
253
+ timeoutMs: call.timeoutMs,
254
+ proxy: call.proxy,
255
+ signal: call.signal,
256
+ maxBytes: 4194304,
257
+ retries: 0,
258
+ })
259
+ if (!raw.ok) {
260
+ const err = new Error(`GitHub page returned HTTP ${raw.status}`)
261
+ err.code = 'HTTP'
262
+ throw err
263
+ }
264
+ const objects = extractEmbeddedData(raw.body.toString('utf8'))
265
+ return { objects, pr: prFromPayloads(objects), issue: issueFromPayloads(objects), discussion: discussionFromPayloads(objects) }
266
+ }
package/lib/settings.js CHANGED
@@ -1,38 +1,40 @@
1
1
  /**
2
2
  * Host-side settings wiring for dsh-github-router.
3
3
  *
4
- * The `dsh-github-router` namespace rides the official settings seam
5
- * (`ctx.settings.register`): the framework persists the section in the user
6
- * settings document, validates it against the schema, fences writes with
7
- * revisions, and redacts secret-role fields on every wire boundary.
4
+ * Since DSH 0.1.7-rc.2 a plugin declares its configuration by EXPORTING a
5
+ * `Config` schema from its Loader entry (see `lib/index.js` and
6
+ * `lib/config.js`): the framework serves the entry id as the settings
7
+ * namespace to the (loopback) browser, derives the configuration form from the
8
+ * schema, validates and persists every write with revision fencing, redacts
9
+ * `role('secret')` fields on every wire boundary, and hands the plugin Volatile
10
+ * references for the live values. The old
11
+ * `ctx.settings.register(namespace, schema, …)` seam no longer exists, so this
12
+ * module keeps only the two things that remain plugin-owned:
8
13
  *
9
- * Since DSH 0.1.0-rc.7 the api-proxy serves every registered settings
10
- * namespace to the (loopback) browser and the Settings → Plugins section
11
- * dispatches a plugin-owned card for it, so the client half binds the same
12
- * namespace through `ctx.settingsScope` and the plugin needs no HTTP routes
13
- * of its own. This module only registers the namespace and exposes the
14
- * runtime-options thunk the tools consume; a settings change therefore
15
- * applies to the NEXT tool call.
14
+ * 1. `configure({ auto: false })` — the plugin ships its own page
15
+ * (`plugins.row.config`, see `lib/client.js`), so the framework must not
16
+ * auto-generate a second one for the same namespace.
17
+ * 2. `options()` — project the live volatile section into the runtime options
18
+ * the tools consume. The section is read per call, so a saved settings
19
+ * change applies to the NEXT tool call.
16
20
  * @module dsh-github-router/settings
17
21
  */
18
- import { Config, NAMESPACE, resolveOptions } from './config.js'
22
+ import { liveSection, resolveOptions } from './config.js'
19
23
 
20
24
  /**
21
- * Register the settings namespace and return the runtime-options thunk.
25
+ * Wire the plugin's settings surface and return the runtime-options thunk.
22
26
  * @param ctx - the plugin context.
23
- * @param config - the composition-layer config subset (the `base` layer).
27
+ * @param config - the config the framework resolved for this entry (volatile references).
24
28
  */
25
29
  export function installSettings(ctx, config) {
26
- const holder = { scope: null }
27
-
28
30
  ctx.inject(['settings'], (sctx) => {
29
- holder.scope = sctx.settings.register(NAMESPACE, Config, { base: config, applies: 'live' })
30
- sctx.effect(() => () => {
31
- holder.scope = null
32
- }, 'dsh-github-router: settings scope teardown')
31
+ sctx.effect(
32
+ () => sctx.settings.configure({ auto: false }, ctx.fiber),
33
+ 'dsh-github-router: settings presentation',
34
+ )
33
35
  })
34
36
 
35
37
  return {
36
- options: () => (holder.scope !== null ? resolveOptions(holder.scope.get()) : resolveOptions(config)),
38
+ options: () => resolveOptions(liveSection(config)),
37
39
  }
38
40
  }