dsh-github-router 0.3.0 → 0.3.2
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 +70 -50
- package/docs/design.md +5 -3
- package/lib/config.js +9 -3
- package/lib/core/issue.js +212 -202
- package/lib/core/pr.js +454 -429
- package/lib/routes/gh.js +120 -109
- package/lib/routes/html.js +266 -250
- package/lib/tunnel.js +363 -247
- package/package.json +2 -1
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
|
+
}
|