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/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
+ }