dsh-github-router 0.3.1 → 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 CHANGED
@@ -1,56 +1,70 @@
1
- # Changelog
2
-
3
- All notable changes to this project are documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
-
8
- ## [0.3.1] - 2026-08-28
9
-
10
- ### Fixed
11
-
12
- - Boot with DSH 0.1.2-alpha.4
13
-
14
- ## [0.3.0] - 2026-08-28
15
-
16
- ### Changed
17
-
18
- - Adapted to DeepSeek Harness 0.1.2-alpha.1
19
-
20
- ## [0.2.1] - 2026-08-28
21
-
22
- ### Changed
23
-
24
- - Declare compatibility for dsh 0.1.1-rc.2
25
-
26
- ## [0.2.0] - 2026-08-21
27
-
28
- ### Changed
29
-
30
- - **Migrated plugin configuration to the framework's plugin-settings mechanism** (requires DSH ≥ 0.1.0-rc.7, which serves every registered settings namespace and dispatches plugin-owned configuration cards). The browser half now registers a `settings.plugin.item` card keyed by the `dsh-github-router` namespace inside Settings → Plugins, bound through `ctx.settingsScope` (shared describe mirror, revision fencing, recovery reads, reconnect invalidation); the plugin-owned `/dsh-github-router/config` web routes and the standalone `settings.section` page are removed. The Host keeps the same `settings.register` seam and adds `applies: 'live'`.
31
- - The token control is now a true write-only field: the configured state comes from the describe mirror's secret slot list (badge: "已配置/configured"), a typed value writes the token, and a blank save clears a configured one.
32
- - Runtime requirements: the settings card needs DSH ≥ 0.1.0-rc.7; the Host-side settings seam alone still works on older compositions (tools resolve the composition config as before).
33
-
34
- ## [0.1.0] - 2026-08-19
35
-
36
- First release.
37
-
38
- ### Added
39
-
40
- - Five read-only model tools: `github_probe`, `github_pr`, `github_issue`, `github_file`, `github_api`
41
- - In-tool route ladder: api.github.com (direct → CONNECT-tunnel proxy) → gh CLI (read-only subcommands) → git protocol (plugin fetch cache + read-only local repo reads) → PR/issue page HTML (strict `react-app.embeddedData` JSON extraction) → user-configured raw mirrors (off by default), with per-part route attribution on every result
42
- - `github_probe` connectivity matrix with per-route timings and a recommended route chain
43
- - TTL response cache with atomic writes and corruption-tolerant reads
44
- - Settings: the `dsh-github-router` namespace on the official settings seam (secret token / `tokenEnv` credential ref, proxy, route switches, cache TTLs, byte caps, granted local repos), an independent Settings page (the dsh-notification `settings.section` mechanism; common fields up top, the long tail in a collapsed "Advanced settings" disclosure), and plugin-owned `/dsh-github-router/config` web routes (the dsh-market pattern) with same-origin enforcement and revision fencing
45
- - `github-router` skill and a system-prompt guidance section (order 118)
46
- - Zero runtime dependencies: zero-dependency CONNECT tunnel for proxied requests; peers resolve from the DSH profile
47
- - 72 offline tests (`node --test`) covering extraction, guards, cache, tunnel, tool wiring, client bundle, config routes, and localization compliance
48
- - Documentation: bilingual README, SECURITY, design notes, and a development guide
49
-
50
- ### Security
51
-
52
- - Read-only by construction: no write/push/comment/mutation verb exists anywhere
53
- - Tokens attach only to api.github.com and are redacted on every wire boundary
54
- - Page HTML is parsed with `JSON.parse` only (never evaluated); a bounded BFS copies a whitelist of fields
55
- - Subprocess calls are argv arrays with fixed flag lists — no shell interpolation
56
- - Configuration writes require same-origin POSTs and are body-capped
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.3.2] - 2026-09-08
9
+
10
+ ### Fixed
11
+
12
+ - The `gh` route now builds valid arguments for its viewers.
13
+ - The `gh` route now returns the metadata URL field.
14
+ - The proxy transport now decodes chunked responses.
15
+ - The html route now handles the current GitHub pull request page payload.
16
+ - Fallback routes are now entered only when a part could not be fetched.
17
+
18
+ ### Added
19
+
20
+ - Offline regression tests for the `gh` route, chunked decoding, and failover gating.
21
+
22
+ ## [0.3.1] - 2026-08-28
23
+
24
+ ### Fixed
25
+
26
+ - Boot with DSH 0.1.2-alpha.4
27
+
28
+ ## [0.3.0] - 2026-08-28
29
+
30
+ ### Changed
31
+
32
+ - Adapted to DeepSeek Harness 0.1.2-alpha.1
33
+
34
+ ## [0.2.1] - 2026-08-28
35
+
36
+ ### Changed
37
+
38
+ - Declare compatibility for dsh 0.1.1-rc.2
39
+
40
+ ## [0.2.0] - 2026-08-21
41
+
42
+ ### Changed
43
+
44
+ - **Migrated plugin configuration to the framework's plugin-settings mechanism** (requires DSH ≥ 0.1.0-rc.7, which serves every registered settings namespace and dispatches plugin-owned configuration cards). The browser half now registers a `settings.plugin.item` card keyed by the `dsh-github-router` namespace inside Settings → Plugins, bound through `ctx.settingsScope` (shared describe mirror, revision fencing, recovery reads, reconnect invalidation); the plugin-owned `/dsh-github-router/config` web routes and the standalone `settings.section` page are removed. The Host keeps the same `settings.register` seam and adds `applies: 'live'`.
45
+ - The token control is now a true write-only field: the configured state comes from the describe mirror's secret slot list (badge: "已配置/configured"), a typed value writes the token, and a blank save clears a configured one.
46
+ - Runtime requirements: the settings card needs DSH ≥ 0.1.0-rc.7; the Host-side settings seam alone still works on older compositions (tools resolve the composition config as before).
47
+
48
+ ## [0.1.0] - 2026-08-19
49
+
50
+ First release.
51
+
52
+ ### Added
53
+
54
+ - Five read-only model tools: `github_probe`, `github_pr`, `github_issue`, `github_file`, `github_api`
55
+ - In-tool route ladder: api.github.com (direct → CONNECT-tunnel proxy) → gh CLI (read-only subcommands) → git protocol (plugin fetch cache + read-only local repo reads) → PR/issue page HTML (strict `react-app.embeddedData` JSON extraction) → user-configured raw mirrors (off by default), with per-part route attribution on every result
56
+ - `github_probe` connectivity matrix with per-route timings and a recommended route chain
57
+ - TTL response cache with atomic writes and corruption-tolerant reads
58
+ - Settings: the `dsh-github-router` namespace on the official settings seam (secret token / `tokenEnv` credential ref, proxy, route switches, cache TTLs, byte caps, granted local repos), an independent Settings page (the dsh-notification `settings.section` mechanism; common fields up top, the long tail in a collapsed "Advanced settings" disclosure), and plugin-owned `/dsh-github-router/config` web routes (the dsh-market pattern) with same-origin enforcement and revision fencing
59
+ - `github-router` skill and a system-prompt guidance section (order 118)
60
+ - Zero runtime dependencies: zero-dependency CONNECT tunnel for proxied requests; peers resolve from the DSH profile
61
+ - 72 offline tests (`node --test`) covering extraction, guards, cache, tunnel, tool wiring, client bundle, config routes, and localization compliance
62
+ - Documentation: bilingual README, SECURITY, design notes, and a development guide
63
+
64
+ ### Security
65
+
66
+ - Read-only by construction: no write/push/comment/mutation verb exists anywhere
67
+ - Tokens attach only to api.github.com and are redacted on every wire boundary
68
+ - Page HTML is parsed with `JSON.parse` only (never evaluated); a bounded BFS copies a whitelist of fields
69
+ - Subprocess calls are argv arrays with fixed flag lists — no shell interpolation
70
+ - Configuration writes require same-origin POSTs and are body-capped
package/docs/design.md CHANGED
@@ -189,9 +189,11 @@ within one host process.
189
189
 
190
190
  ## Known limitations
191
191
 
192
- - **Fallback routes are unexercised while the API is healthy.** The ladder
193
- is failover, not fan-out: when api succeeds, gh/html/git are not called
194
- (saving quota). Forcing a specific route per call is not yet supported.
192
+ - **Fallback routes are failover, not fan-out.** A ladder is entered only
193
+ when a part could not be fetched by the route above it: a successfully
194
+ fetched empty list (an issue with no comments, a PR with no reviews) is
195
+ authoritative and does not trigger gh/html/git, saving quota. Forcing a
196
+ specific route per call is not yet supported.
195
197
  - **Anonymous quota is shared per IP** (60 req/h). A PR aggregation costs
196
198
  up to six calls. Token configuration is the intended mitigation; the
197
199
  cache is the second line.
package/lib/core/issue.js CHANGED
@@ -1,202 +1,212 @@
1
- /**
2
- * Issue aggregation: api.github.com → gh CLI → page HTML, with per-part
3
- * route attribution and structured failure.
4
- * @module dsh-github-router/core/issue
5
- */
6
- import { ghAuthed, ghIssueView, ghVersion } from '../routes/gh.js'
7
- import { fetchAndParsePage } from '../routes/html.js'
8
- import { guardByteCount, guardOwnerRepo, guardPrNumber, truncateText } from '../util.js'
9
- import { attemptLadder, buildRuntime, rateNote } from './runtime.js'
10
-
11
- const BODY_CAP = 20000
12
-
13
- function shapeApiIssue(meta, owner, repo, number) {
14
- return {
15
- owner,
16
- repo,
17
- number,
18
- title: meta.title ?? null,
19
- body: truncateText(meta.body ?? '', BODY_CAP).text,
20
- state: meta.state ?? null,
21
- stateReason: meta.state_reason ?? null,
22
- author: meta.user && meta.user.login ? meta.user.login : null,
23
- createdAt: meta.created_at ?? null,
24
- updatedAt: meta.updated_at ?? null,
25
- closedAt: meta.closed_at ?? null,
26
- commentsCount: meta.comments ?? null,
27
- labels: Array.isArray(meta.labels) ? meta.labels.map((l) => l.name ?? null).filter((n) => n !== null) : [],
28
- url: meta.html_url ?? null,
29
- }
30
- }
31
-
32
- function shapeGhIssue(json, owner, repo, number) {
33
- return {
34
- owner,
35
- repo,
36
- number,
37
- title: json.title ?? null,
38
- body: truncateText(json.body ?? '', BODY_CAP).text,
39
- state: json.state ?? null,
40
- stateReason: json.stateReason ?? null,
41
- author: json.author && json.author.login ? json.author.login : null,
42
- createdAt: json.createdAt ?? null,
43
- updatedAt: json.updatedAt ?? null,
44
- closedAt: json.closedAt ?? null,
45
- commentsCount: Array.isArray(json.comments) ? json.comments.length : null,
46
- labels: Array.isArray(json.labels) ? json.labels.map((l) => (typeof l === 'string' ? l : l.name ?? null)).filter((n) => n !== null) : [],
47
- url: null,
48
- }
49
- }
50
-
51
- function capItems(items, maxItems, notes, label) {
52
- if (items.length <= maxItems) return items
53
- notes.push(`${label}: showing ${maxItems} of ${items.length}`)
54
- return items.slice(0, maxItems)
55
- }
56
-
57
- export async function aggregateIssue(ctx, options, args) {
58
- const owner = guardOwnerRepo(args.owner, 'owner')
59
- const repo = guardOwnerRepo(args.repo, 'repo')
60
- const number = guardPrNumber(args.number, 'number')
61
- const maxComments = guardByteCount(args.maxComments, 'maxComments', 50, 5, 200)
62
- const forceRefresh = args.forceRefresh === true
63
- const { signal } = args
64
-
65
- const runtime = buildRuntime(ctx, options)
66
- const { subprocess, fetchImpl, cache, api } = runtime
67
- const token = await runtime.resolveToken()
68
- const state = {
69
- issue: null,
70
- comments: [],
71
- routes: { meta: null, comments: null },
72
- notes: [],
73
- }
74
-
75
- if (options.routes.api) {
76
- try {
77
- const attempt = await attemptLadder(
78
- (proxy) =>
79
- api.getJson(`/repos/${owner}/${repo}/issues/${number}`, {
80
- token,
81
- signal,
82
- proxy,
83
- cache,
84
- ttlMs: options.cacheTtlSeconds.meta,
85
- forceRefresh,
86
- fetchImpl,
87
- }),
88
- options,
89
- )
90
- state.issue = shapeApiIssue(attempt.value, owner, repo, number)
91
- state.routes.meta = 'api'
92
- rateNote(attempt.value._headers, state.notes)
93
- } catch (error) {
94
- state.notes.push(`api issue: ${String(error && error.message ? error.message : error).slice(0, 220)}`)
95
- }
96
- }
97
-
98
- if (state.issue !== null) {
99
- try {
100
- const attempt = await attemptLadder(
101
- (proxy) =>
102
- api.getJson(`/repos/${owner}/${repo}/issues/${number}/comments`, {
103
- token,
104
- signal,
105
- proxy,
106
- cache,
107
- ttlMs: options.cacheTtlSeconds.meta,
108
- forceRefresh,
109
- fetchImpl,
110
- query: { per_page: 100 },
111
- }),
112
- options,
113
- )
114
- state.comments = capItems(
115
- (Array.isArray(attempt.value) ? attempt.value : []).map((c) => ({
116
- kind: 'comment',
117
- author: c.user && c.user.login ? c.user.login : null,
118
- createdAt: c.created_at ?? null,
119
- body: truncateText(c.body ?? '', BODY_CAP).text,
120
- url: c.html_url ?? null,
121
- })),
122
- maxComments,
123
- state.notes,
124
- 'comments',
125
- )
126
- state.routes.comments = 'api'
127
- } catch (error) {
128
- state.notes.push(`api comments: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
129
- }
130
- }
131
-
132
- if (options.routes.gh && subprocess !== undefined && (state.issue === null || state.comments.length === 0)) {
133
- let ghUsable = false
134
- try {
135
- ghUsable = (await ghVersion(subprocess, signal)) && (await ghAuthed(subprocess, signal))
136
- } catch { ghUsable = false }
137
- if (ghUsable) {
138
- try {
139
- const g = await ghIssueView(subprocess, owner, repo, number, { signal })
140
- if (state.issue === null) {
141
- state.issue = shapeGhIssue(g, owner, repo, number)
142
- state.routes.meta = 'gh'
143
- }
144
- if (state.comments.length === 0 && Array.isArray(g.comments)) {
145
- state.comments = capItems(
146
- g.comments.map((c) => ({
147
- kind: 'comment',
148
- author: c.author && c.author.login ? c.author.login : null,
149
- createdAt: c.createdAt ?? null,
150
- body: truncateText(c.body ?? '', BODY_CAP).text,
151
- url: c.url ?? null,
152
- })),
153
- maxComments,
154
- state.notes,
155
- 'comments',
156
- )
157
- state.routes.comments = 'gh'
158
- }
159
- } catch (error) {
160
- state.notes.push(`gh issue view: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
161
- }
162
- } else {
163
- state.notes.push('gh route skipped: gh CLI not installed or not authenticated')
164
- }
165
- }
166
-
167
- if (options.routes.html && (state.issue === null || state.comments.length === 0)) {
168
- try {
169
- await attemptLadder(
170
- async (proxy) => {
171
- const parsed = await fetchAndParsePage(owner, repo, number, 'issues', {
172
- fetchImpl,
173
- proxy,
174
- signal,
175
- timeoutMs: proxy !== undefined ? options.proxyTimeoutMs : options.directTimeoutMs,
176
- })
177
- if (state.issue === null && parsed.issue !== null) {
178
- state.issue = { ...parsed.issue, owner, repo, number }
179
- state.routes.meta = 'html'
180
- }
181
- if (state.comments.length === 0 && parsed.discussion.length > 0) {
182
- state.comments = capItems(parsed.discussion, maxComments, state.notes, 'comments')
183
- state.routes.comments = 'html'
184
- }
185
- },
186
- options,
187
- { proxyFirst: true },
188
- )
189
- } catch (error) {
190
- state.notes.push(`html page: ${String(error && error.message ? error.message : error).slice(0, 220)}`)
191
- }
192
- }
193
-
194
- if (state.issue === null) {
195
- return {
196
- error: `all GitHub routes failed to load issue ${owner}/${repo}#${number}`,
197
- routes: state.routes,
198
- notes: state.notes,
199
- }
200
- }
201
- return { issue: state.issue, comments: state.comments, routes: state.routes, notes: state.notes }
202
- }
1
+ /**
2
+ * Issue aggregation: api.github.com → gh CLI → page HTML, with per-part
3
+ * route attribution and structured failure.
4
+ * @module dsh-github-router/core/issue
5
+ */
6
+ import { ghAuthed, ghIssueView, ghVersion } from '../routes/gh.js'
7
+ import { fetchAndParsePage } from '../routes/html.js'
8
+ import { guardByteCount, guardOwnerRepo, guardPrNumber, truncateText } from '../util.js'
9
+ import { attemptLadder, buildRuntime, rateNote } from './runtime.js'
10
+
11
+ const BODY_CAP = 20000
12
+
13
+ function shapeApiIssue(meta, owner, repo, number) {
14
+ return {
15
+ owner,
16
+ repo,
17
+ number,
18
+ title: meta.title ?? null,
19
+ body: truncateText(meta.body ?? '', BODY_CAP).text,
20
+ state: meta.state ?? null,
21
+ stateReason: meta.state_reason ?? null,
22
+ author: meta.user && meta.user.login ? meta.user.login : null,
23
+ createdAt: meta.created_at ?? null,
24
+ updatedAt: meta.updated_at ?? null,
25
+ closedAt: meta.closed_at ?? null,
26
+ commentsCount: meta.comments ?? null,
27
+ labels: Array.isArray(meta.labels) ? meta.labels.map((l) => l.name ?? null).filter((n) => n !== null) : [],
28
+ url: meta.html_url ?? null,
29
+ }
30
+ }
31
+
32
+ function shapeGhIssue(json, owner, repo, number) {
33
+ return {
34
+ owner,
35
+ repo,
36
+ number,
37
+ title: json.title ?? null,
38
+ body: truncateText(json.body ?? '', BODY_CAP).text,
39
+ state: json.state ?? null,
40
+ stateReason: json.stateReason ?? null,
41
+ author: json.author && json.author.login ? json.author.login : null,
42
+ createdAt: json.createdAt ?? null,
43
+ updatedAt: json.updatedAt ?? null,
44
+ closedAt: json.closedAt ?? null,
45
+ commentsCount: Array.isArray(json.comments) ? json.comments.length : null,
46
+ labels: Array.isArray(json.labels) ? json.labels.map((l) => (typeof l === 'string' ? l : l.name ?? null)).filter((n) => n !== null) : [],
47
+ url: json.url ?? null,
48
+ }
49
+ }
50
+
51
+ function capItems(items, maxItems, notes, label) {
52
+ if (items.length <= maxItems) return items
53
+ notes.push(`${label}: showing ${maxItems} of ${items.length}`)
54
+ return items.slice(0, maxItems)
55
+ }
56
+
57
+ export async function aggregateIssue(ctx, options, args) {
58
+ const owner = guardOwnerRepo(args.owner, 'owner')
59
+ const repo = guardOwnerRepo(args.repo, 'repo')
60
+ const number = guardPrNumber(args.number, 'number')
61
+ const maxComments = guardByteCount(args.maxComments, 'maxComments', 50, 5, 200)
62
+ const forceRefresh = args.forceRefresh === true
63
+ const { signal } = args
64
+
65
+ const runtime = buildRuntime(ctx, options)
66
+ const { subprocess, fetchImpl, cache, api } = runtime
67
+ const token = await runtime.resolveToken()
68
+ const state = {
69
+ issue: null,
70
+ comments: [],
71
+ routes: { meta: null, comments: null },
72
+ notes: [],
73
+ // Which parts a route actually served. A successfully fetched empty
74
+ // list is authoritative and must NOT trigger a fallback ladder — the
75
+ // routes are failover, not fan-out (docs/design.md).
76
+ fetched: { meta: false, comments: false },
77
+ }
78
+
79
+ if (options.routes.api) {
80
+ try {
81
+ const attempt = await attemptLadder(
82
+ (proxy) =>
83
+ api.getJson(`/repos/${owner}/${repo}/issues/${number}`, {
84
+ token,
85
+ signal,
86
+ proxy,
87
+ cache,
88
+ ttlMs: options.cacheTtlSeconds.meta,
89
+ forceRefresh,
90
+ fetchImpl,
91
+ }),
92
+ options,
93
+ )
94
+ state.issue = shapeApiIssue(attempt.value, owner, repo, number)
95
+ state.routes.meta = 'api'
96
+ state.fetched.meta = true
97
+ rateNote(attempt.value._headers, state.notes)
98
+ } catch (error) {
99
+ state.notes.push(`api issue: ${String(error && error.message ? error.message : error).slice(0, 220)}`)
100
+ }
101
+ }
102
+
103
+ if (state.issue !== null) {
104
+ try {
105
+ const attempt = await attemptLadder(
106
+ (proxy) =>
107
+ api.getJson(`/repos/${owner}/${repo}/issues/${number}/comments`, {
108
+ token,
109
+ signal,
110
+ proxy,
111
+ cache,
112
+ ttlMs: options.cacheTtlSeconds.meta,
113
+ forceRefresh,
114
+ fetchImpl,
115
+ query: { per_page: 100 },
116
+ }),
117
+ options,
118
+ )
119
+ state.comments = capItems(
120
+ (Array.isArray(attempt.value) ? attempt.value : []).map((c) => ({
121
+ kind: 'comment',
122
+ author: c.user && c.user.login ? c.user.login : null,
123
+ createdAt: c.created_at ?? null,
124
+ body: truncateText(c.body ?? '', BODY_CAP).text,
125
+ url: c.html_url ?? null,
126
+ })),
127
+ maxComments,
128
+ state.notes,
129
+ 'comments',
130
+ )
131
+ state.routes.comments = 'api'
132
+ state.fetched.comments = true
133
+ } catch (error) {
134
+ state.notes.push(`api comments: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
135
+ }
136
+ }
137
+
138
+ if (options.routes.gh && subprocess !== undefined && (state.issue === null || !state.fetched.comments)) {
139
+ let ghUsable = false
140
+ try {
141
+ ghUsable = (await ghVersion(subprocess, signal)) && (await ghAuthed(subprocess, signal))
142
+ } catch { ghUsable = false }
143
+ if (ghUsable) {
144
+ try {
145
+ const g = await ghIssueView(subprocess, owner, repo, number, { signal })
146
+ if (state.issue === null) {
147
+ state.issue = shapeGhIssue(g, owner, repo, number)
148
+ state.routes.meta = 'gh'
149
+ state.fetched.meta = true
150
+ }
151
+ if (!state.fetched.comments && Array.isArray(g.comments)) {
152
+ state.comments = capItems(
153
+ g.comments.map((c) => ({
154
+ kind: 'comment',
155
+ author: c.author && c.author.login ? c.author.login : null,
156
+ createdAt: c.createdAt ?? null,
157
+ body: truncateText(c.body ?? '', BODY_CAP).text,
158
+ url: c.url ?? null,
159
+ })),
160
+ maxComments,
161
+ state.notes,
162
+ 'comments',
163
+ )
164
+ state.routes.comments = 'gh'
165
+ state.fetched.comments = true
166
+ }
167
+ } catch (error) {
168
+ state.notes.push(`gh issue view: ${String(error && error.message ? error.message : error).slice(0, 180)}`)
169
+ }
170
+ } else {
171
+ state.notes.push('gh route skipped: gh CLI not installed or not authenticated')
172
+ }
173
+ }
174
+
175
+ if (options.routes.html && (state.issue === null || !state.fetched.comments)) {
176
+ try {
177
+ await attemptLadder(
178
+ async (proxy) => {
179
+ const parsed = await fetchAndParsePage(owner, repo, number, 'issues', {
180
+ fetchImpl,
181
+ proxy,
182
+ signal,
183
+ timeoutMs: proxy !== undefined ? options.proxyTimeoutMs : options.directTimeoutMs,
184
+ })
185
+ if (state.issue === null && parsed.issue !== null) {
186
+ state.issue = { ...parsed.issue, owner, repo, number }
187
+ state.routes.meta = 'html'
188
+ state.fetched.meta = true
189
+ }
190
+ if (!state.fetched.comments && parsed.discussion.length > 0) {
191
+ state.comments = capItems(parsed.discussion, maxComments, state.notes, 'comments')
192
+ state.routes.comments = 'html'
193
+ state.fetched.comments = true
194
+ }
195
+ },
196
+ options,
197
+ { proxyFirst: true },
198
+ )
199
+ } catch (error) {
200
+ state.notes.push(`html page: ${String(error && error.message ? error.message : error).slice(0, 220)}`)
201
+ }
202
+ }
203
+
204
+ if (state.issue === null) {
205
+ return {
206
+ error: `all GitHub routes failed to load issue ${owner}/${repo}#${number}`,
207
+ routes: state.routes,
208
+ notes: state.notes,
209
+ }
210
+ }
211
+ return { issue: state.issue, comments: state.comments, routes: state.routes, notes: state.notes }
212
+ }