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.
- package/CHANGELOG.md +79 -56
- package/CONTRIBUTING.md +17 -6
- package/README.md +26 -18
- package/README.zh.md +22 -17
- package/docs/design.md +51 -43
- package/lib/client.js +165 -116
- package/lib/config.js +167 -116
- package/lib/core/issue.js +212 -202
- package/lib/core/pr.js +454 -429
- package/lib/index.js +11 -6
- package/lib/routes/gh.js +120 -109
- package/lib/routes/html.js +266 -250
- package/lib/settings.js +23 -21
- package/lib/tunnel.js +363 -247
- package/package.json +11 -17
package/lib/routes/html.js
CHANGED
|
@@ -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 ||
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return null
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
const
|
|
249
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 {
|
|
22
|
+
import { liveSection, resolveOptions } from './config.js'
|
|
19
23
|
|
|
20
24
|
/**
|
|
21
|
-
*
|
|
25
|
+
* Wire the plugin's settings surface and return the runtime-options thunk.
|
|
22
26
|
* @param ctx - the plugin context.
|
|
23
|
-
* @param config - the
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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: () =>
|
|
38
|
+
options: () => resolveOptions(liveSection(config)),
|
|
37
39
|
}
|
|
38
40
|
}
|