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/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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
state.
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
|
|
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
|
+
}
|