ccdeck 3.44.0 → 3.46.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.
Files changed (57) hide show
  1. package/README.md +21 -9
  2. package/THIRD_PARTY_NOTICES.md +27 -0
  3. package/bin/cli/startup.js +4 -3
  4. package/dist/web/assets/BrowserWatchModal-CoX9FIUE.js +1 -0
  5. package/dist/web/assets/IntegrationModal-B1ypkK-E.js +5 -0
  6. package/dist/web/assets/RadioPanel-biw_BbZq.js +1 -0
  7. package/dist/web/assets/TrafficRadar-nhtFoYVv.js +8 -0
  8. package/dist/web/assets/{UsageHistoryModal-Du0ZkIJ1.js → UsageHistoryModal-Bd-JRAa4.js} +1 -1
  9. package/dist/web/assets/app-BlRY6EME.css +1 -0
  10. package/dist/web/assets/app-DhGvSH7o.js +319 -0
  11. package/dist/web/assets/{index-BQbOfxbL.js → index-DmENqVdt.js} +1 -1
  12. package/dist/web/index.html +3 -3
  13. package/package.json +1 -1
  14. package/release-notes.json +48 -0
  15. package/src/server/account-notify.mjs +68 -6
  16. package/src/server/account-watch.mjs +61 -2
  17. package/src/server/claude-accounts.mjs +45 -19
  18. package/src/server/claude-login.mjs +111 -0
  19. package/src/server/cswap-admin.mjs +12 -3
  20. package/src/server/deck-prefs.mjs +15 -5
  21. package/src/server/feature-use.mjs +11 -1
  22. package/src/server/forge-http.mjs +514 -0
  23. package/src/server/forge-public-fetch.mjs +228 -0
  24. package/src/server/forge-routes.mjs +154 -0
  25. package/src/server/github-api.mjs +1369 -0
  26. package/src/server/gitlab-api.mjs +1271 -0
  27. package/src/server/index.mjs +6 -2
  28. package/src/server/installer.mjs +24 -7
  29. package/src/server/integrations/cache.mjs +42 -0
  30. package/src/server/integrations/errors.mjs +167 -0
  31. package/src/server/integrations/forge.mjs +76 -0
  32. package/src/server/integrations/jira.mjs +1026 -0
  33. package/src/server/integrations/providers.mjs +11 -0
  34. package/src/server/integrations/proxy.mjs +314 -0
  35. package/src/server/integrations/registry.mjs +142 -0
  36. package/src/server/integrations/routes.mjs +498 -0
  37. package/src/server/integrations/store.mjs +226 -0
  38. package/src/server/lan-deck.mjs +7 -0
  39. package/src/server/lan-elsewhere.d.mts +30 -0
  40. package/src/server/lan-elsewhere.mjs +137 -0
  41. package/src/server/login-service.mjs +2 -2
  42. package/src/server/org-telemetry.mjs +173 -0
  43. package/src/server/pinned-build.mjs +3 -0
  44. package/src/server/request-gates.mjs +19 -0
  45. package/src/server/skill-hook.mjs +28 -22
  46. package/src/server/system-metrics.mjs +4 -0
  47. package/src/server/tool-audit-routes.mjs +4 -0
  48. package/src/server/traffic-radar-config.mjs +33 -0
  49. package/src/server/traffic-radar-platform.mjs +84 -17
  50. package/src/server/traffic-radar-service.mjs +6 -3
  51. package/src/server/traffic-radar.mjs +32 -9
  52. package/src/server/usage-day.mjs +2 -0
  53. package/dist/web/assets/BrowserWatchModal-DMIUygWz.js +0 -1
  54. package/dist/web/assets/RadioPanel-DNB_AHSs.js +0 -1
  55. package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +0 -8
  56. package/dist/web/assets/app-CQIstkMF.js +0 -318
  57. package/dist/web/assets/app-Ch-0CYlK.css +0 -1
@@ -0,0 +1,1369 @@
1
+ // What the GitHub panel asks GitHub, and how the answers become the shapes in
2
+ // src/web/forge-types.ts (#2179).
3
+ //
4
+ // READ-ONLY. Every REST call here is a GET and every GraphQL document is a
5
+ // query; nothing in this file can change a repository, a pull request or a
6
+ // run. Posting reviews, re-running workflows and merging are follow-ups with
7
+ // their own issue, and they will not arrive by accident through this file.
8
+ //
9
+ // GRAPHQL FIRST, ONE QUERY PER SCREEN. A pull request row needs its reviews,
10
+ // its review requests, its threads, its labels and its head commit's checks;
11
+ // over REST that is five calls a row. GraphQL search answers a whole page of
12
+ // rows in one request that costs one rate-limit point. REST is used only where
13
+ // GraphQL has nothing: the token's scopes (a header on GET /user), a pull
14
+ // request's files with their old paths, Actions runs, and a job's log.
15
+ //
16
+ // GITHUB'S QUIRKS, MET LIVE AND KEPT HERE SO THE PANEL NEVER SEES THEM:
17
+ // - Branches cannot be ordered by their last commit. `refs(orderBy:
18
+ // TAG_COMMIT_DATE)` only orders tags; for branches it silently falls back
19
+ // to reverse-alphabetical. So the branch list is read whole (names and
20
+ // commit dates only, 100 a call), sorted here, and only the page shown is
21
+ // asked for its ahead/behind, checks and open pull request.
22
+ // - `Ref.compare(headRef)` takes the ref it is called on as the BASE. Asked
23
+ // on a branch against the default branch, `aheadBy` is how far the DEFAULT
24
+ // branch is ahead — the branch's "behind". Both directions are used below
25
+ // and each says which way round it is.
26
+ // - A failed job's steps can stay "in progress" until the whole run ends, on
27
+ // both REST and GraphQL, so the step a job died in is the failed one or,
28
+ // failing that, the one left running.
29
+ // - Asking every check run of every suite for its steps and annotations in
30
+ // one query made GitHub answer 502. Those are asked for separately, and
31
+ // only for the runs that failed — the only ones the panel explains.
32
+ // - A job's log is a 302 to a signed blob-storage URL; the transport follows
33
+ // it without the token. Its lines start with an ISO timestamp and carry
34
+ // the runner's `##[group]` markers, which are not what anyone reads.
35
+ //
36
+ // Newer GraphQL fields an older GitHub Enterprise Server lacks (sub-issues,
37
+ // issue types, state reasons) are asked for, and when the server says the
38
+ // field does not exist the same query is asked again without them — a panel
39
+ // without the hierarchy beats no panel. Every other failure is the
40
+ // transport's ForgeError and leaves as it came, so the panel picks its
41
+ // recovery from `kind`.
42
+ import { createHash } from "node:crypto";
43
+ import { cleanLogLine, createForgeTransport, epoch, ForgeError, graphqlError, mapLimit, nextLink } from "./forge-http.mjs";
44
+
45
+ // The REST API version these shapes were read against.
46
+ const API_VERSION = "2022-11-28";
47
+
48
+ const PAGE_SIZE = 20;
49
+ const REPO_PAGE_SIZE = 30;
50
+ // A repository search filters the viewer's list here (GraphQL has no filter on
51
+ // it): this many pages of 100 are read at most per call before answering.
52
+ const REPO_SCAN_PAGES = 10;
53
+ const COMMENT_PAGE_SIZE = 30;
54
+ const FILE_PAGE_SIZE = 30;
55
+ // GitLab's own line between "Active" and "Stale" branches.
56
+ const STALE_MS = 90 * 24 * 60 * 60 * 1000;
57
+ const COMMITS_AHEAD_CAP = 50;
58
+ // "All repositories" on the Actions tab: the runs of this many of the viewer's
59
+ // most recently pushed repositories, merged by time. One call each.
60
+ const RUN_REPOS = 10;
61
+ // Branches read to sort a repository's list. Past this many, only the first
62
+ // ones in GitHub's alphabetical order are sorted — a repository with more
63
+ // branches than this is not one anyone browses by page.
64
+ const BRANCH_SCAN_MAX = 2000;
65
+ // A later page of branches reuses the sorted list the first page read, so the
66
+ // pages agree with each other. The first page always reads it afresh.
67
+ const BRANCH_SCAN_TTL_MS = 5 * 60_000;
68
+ const BRANCH_SCAN_KEEP = 20;
69
+
70
+ const ACTOR = "login avatarUrl url ... on User { name }";
71
+ const USER = "login name avatarUrl url";
72
+ const COMMENT = `id url createdAt bodyHTML author { ${ACTOR} }`;
73
+
74
+ // What every pull request row shows, list or detail.
75
+ const PR_CORE = `
76
+ number title titleHTML state isDraft url
77
+ repository { nameWithOwner }
78
+ author { ${ACTOR} }
79
+ assignees(first: 10) { nodes { ${USER} } }
80
+ reviewRequests(first: 20) { nodes { requestedReviewer {
81
+ __typename
82
+ ... on User { ${USER} }
83
+ ... on Team { name combinedSlug avatarUrl url }
84
+ ... on Bot { login avatarUrl url }
85
+ ... on Mannequin { login avatarUrl url }
86
+ } } }
87
+ latestReviews(first: 30) { nodes { state viewerDidAuthor author { ${ACTOR} } } }
88
+ headRefName baseRefName createdAt updatedAt mergedAt closedAt
89
+ reviewDecision
90
+ labels(first: 20) { nodes { name color } }`;
91
+
92
+ // A row also counts its comments and open threads, and reads the head
93
+ // commit's combined check state.
94
+ const PR_ROW = `${PR_CORE}
95
+ comments { totalCount }
96
+ reviewThreads(first: 100) { totalCount nodes { isResolved comments { totalCount } } }
97
+ commits(last: 1) { nodes { commit { oid statusCheckRollup { state } } } }`;
98
+
99
+ // The Overview: the same row with the threads and comments themselves, and
100
+ // what the tab counts are made of.
101
+ const PR_DETAIL = `${PR_CORE}
102
+ bodyHTML
103
+ milestone { title url }
104
+ participants(first: 50) { nodes { ${USER} } }
105
+ changedFiles
106
+ comments(last: 100) { totalCount nodes { ${COMMENT} } }
107
+ reviews(last: 100) { nodes { id state body bodyHTML submittedAt url author { ${ACTOR} } } }
108
+ reviewThreads(first: 100) { totalCount nodes {
109
+ id isResolved isOutdated path line originalLine
110
+ comments(first: 50) { totalCount nodes { ${COMMENT} } }
111
+ } }
112
+ commits(last: 1) { totalCount nodes { commit {
113
+ oid statusCheckRollup { state }
114
+ status { contexts { state } }
115
+ checkSuites(first: 50) { nodes { checkRuns { totalCount } } }
116
+ } } }`;
117
+
118
+ const COMMIT = "oid abbreviatedOid messageHeadline authoredDate author { name } url";
119
+
120
+ // A branch row, without its ahead/behind: the list and the detail ask for that
121
+ // in opposite directions (see the header).
122
+ const BRANCH = `
123
+ name
124
+ branchProtectionRule { id }
125
+ rules(first: 1) { totalCount }
126
+ target { ... on Commit { oid abbreviatedOid messageHeadline committedDate author { name } url statusCheckRollup { state } } }
127
+ associatedPullRequests(first: 1, states: [OPEN], orderBy: { field: UPDATED_AT, direction: DESC }) {
128
+ nodes { number isDraft url reviewDecision latestReviews(first: 30) { nodes { state } } repository { nameWithOwner } }
129
+ }`;
130
+
131
+ const CHECK_RUN = "id databaseId name status conclusion startedAt completedAt detailsUrl url title summary";
132
+ const CHECK_SUITE = `
133
+ databaseId status conclusion createdAt url
134
+ app { name slug }
135
+ branch { name }
136
+ commit { oid abbreviatedOid messageHeadline }
137
+ workflowRun { databaseId event url createdAt workflow { name } }
138
+ checkRuns(first: 50, filterBy: { checkType: LATEST }) { totalCount nodes { ${CHECK_RUN} } }`;
139
+ // Asked only for the runs that failed (see the header).
140
+ const CHECK_RUN_DETAIL = `
141
+ id
142
+ steps(first: 100) { nodes { name status conclusion } }
143
+ annotations(first: 50) { nodes { path annotationLevel title message location { start { line } } } }`;
144
+
145
+ const ISSUE_REF = "number title state url repository { nameWithOwner }";
146
+ const ISSUE_BASE = `
147
+ number title titleHTML state url
148
+ repository { nameWithOwner }
149
+ author { ${ACTOR} }
150
+ assignees(first: 10) { nodes { ${USER} } }
151
+ labels(first: 20) { nodes { name color } }
152
+ milestone { title url }
153
+ createdAt updatedAt closedAt
154
+ comments { totalCount }`;
155
+ // The fields an older Enterprise server may not have; see `withNewerFields`.
156
+ const ISSUE_NEWER = `
157
+ stateReason
158
+ issueType { name }
159
+ subIssuesSummary { total completed }
160
+ parent { ${ISSUE_REF} issueType { name } }`;
161
+ const issueFields = (newer) => newer ? `${ISSUE_BASE} ${ISSUE_NEWER}` : ISSUE_BASE;
162
+ const MISSING_FIELD = /doesn't exist on type/i;
163
+
164
+ // ─── CI states ───────────────────────────────────────────────────────────────
165
+
166
+ // The tables below are looked up by what GitHub answers with. Without a
167
+ // prototype they find only their own entries: a plain object also answers to
168
+ // `constructor`, `toString` and the other names every object inherits.
169
+
170
+ // A check run's or an Actions run's verdict once it is done. Neutral is GitHub's
171
+ // grey "nothing to say", drawn like a skip and never a failure; stale is a run
172
+ // GitHub gave up waiting for; action_required needs a person, as a manual job
173
+ // does on GitLab.
174
+ const CHECK_CONCLUSION = {
175
+ __proto__: null,
176
+ success: "success",
177
+ failure: "failed",
178
+ timed_out: "failed",
179
+ startup_failure: "failed",
180
+ cancelled: "canceled",
181
+ stale: "canceled",
182
+ skipped: "skipped",
183
+ neutral: "skipped",
184
+ action_required: "manual",
185
+ };
186
+ // …and while it is not done. `waiting` is a deployment waiting for a reviewer.
187
+ const CHECK_STATUS = {
188
+ __proto__: null,
189
+ queued: "pending",
190
+ requested: "pending",
191
+ pending: "pending",
192
+ waiting: "manual",
193
+ in_progress: "running",
194
+ };
195
+ // The head commit's combined state (statusCheckRollup) and a legacy commit
196
+ // status. EXPECTED is a required check that has not reported yet.
197
+ const ROLLUP = {
198
+ __proto__: null,
199
+ success: "success",
200
+ failure: "failed",
201
+ error: "failed",
202
+ pending: "pending",
203
+ expected: "pending",
204
+ };
205
+
206
+ /**
207
+ * A check run's, check suite's or Actions run's status and conclusion as one
208
+ * CiState. Takes GraphQL's UPPER and REST's lower case alike.
209
+ * @param {string|null|undefined} status
210
+ * @param {string|null|undefined} conclusion
211
+ * @returns {import("../web/forge-types").CiState}
212
+ */
213
+ export function githubCheckState(status, conclusion) {
214
+ const s = String(status ?? "").toLowerCase();
215
+ const c = String(conclusion ?? "").toLowerCase();
216
+ if (s === "completed" || (!s && c)) return CHECK_CONCLUSION[c] ?? "none";
217
+ return CHECK_STATUS[s] ?? "none";
218
+ }
219
+
220
+ /**
221
+ * A commit's combined state (statusCheckRollup) or a commit status as a CiState.
222
+ * @param {string|null|undefined} state
223
+ */
224
+ export function githubRollupState(state) {
225
+ return ROLLUP[String(state ?? "").toLowerCase()] ?? "none";
226
+ }
227
+
228
+ /**
229
+ * One raw line of an Actions job log as a person reads it: without the ISO
230
+ * timestamp the runner stamps every line with, without `##[group]`, and with
231
+ * `##[error]` and its kin said the way GitHub's own log view says them. An
232
+ * `##[endgroup]` line is only a marker and comes back as null, to be dropped.
233
+ * @param {string} line
234
+ * @returns {string|null}
235
+ */
236
+ export function cleanGithubLogLine(line) {
237
+ let s = String(line ?? "").replace(/^\uFEFF/, "").replace(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z ?/, "");
238
+ if (/^##\[endgroup\]/.test(s)) return null;
239
+ s = s
240
+ .replace(/^##\[group\]/, "")
241
+ .replace(/^##\[(error|warning|notice)\]/, (_, level) => `${level[0].toUpperCase()}${level.slice(1)}: `)
242
+ .replace(/^##\[(?:debug|section|command)\]/, "")
243
+ .replace(/^\[command\]/, "");
244
+ return cleanLogLine(s);
245
+ }
246
+
247
+ // ─── Small mappings ──────────────────────────────────────────────────────────
248
+
249
+ function user(a) {
250
+ if (!a?.login) return null;
251
+ return { login: a.login, name: a.name || null, avatarUrl: a.avatarUrl ?? null, webUrl: a.url ?? null };
252
+ }
253
+
254
+ function restUser(u) {
255
+ if (!u?.login) return null;
256
+ return { login: u.login, name: u.name || null, avatarUrl: u.avatar_url ?? null, webUrl: u.html_url ?? null };
257
+ }
258
+
259
+ function label(l) {
260
+ return { name: l.name, color: l.color ? `#${l.color}` : null };
261
+ }
262
+
263
+ function nodes(conn) {
264
+ return (conn?.nodes ?? []).filter(Boolean);
265
+ }
266
+
267
+ function seconds(fromMs, toMs) {
268
+ return fromMs != null && toMs != null && toMs >= fromMs ? Math.round((toMs - fromMs) / 1000) : null;
269
+ }
270
+
271
+ function firstLine(text) {
272
+ const line = String(text ?? "").split("\n").map(l => l.trim()).find(Boolean);
273
+ return line ? line.slice(0, 200) : null;
274
+ }
275
+
276
+ function pageNumber(cursor) {
277
+ const n = Number.parseInt(String(cursor ?? ""), 10);
278
+ return Number.isFinite(n) && n > 1 ? n : 1;
279
+ }
280
+
281
+ /** `owner/name`, checked, with its REST prefix. */
282
+ function splitRepo(repo) {
283
+ const m = /^([^/\s]+)\/([^/\s]+)$/.exec(String(repo ?? "").trim());
284
+ if (!m) throw new ForgeError("not-found", `not a repository path: ${String(repo ?? "").slice(0, 100)}`);
285
+ return {
286
+ owner: m[1],
287
+ name: m[2],
288
+ path: `${m[1]}/${m[2]}`,
289
+ rest: `/repos/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`,
290
+ };
291
+ }
292
+
293
+ /** The value GraphQL answered, or the error it gave for its absence: a
294
+ * repository or item that is gone answers `null` with a NOT_FOUND beside it. */
295
+ function need(value, errors, what) {
296
+ if (value) return value;
297
+ if (errors?.length) throw graphqlError(errors);
298
+ throw new ForgeError("not-found", `${what} not found`);
299
+ }
300
+
301
+ const CHANGE_STATE = { __proto__: null, OPEN: "open", MERGED: "merged", CLOSED: "closed" };
302
+ const REVIEW_STATE = {
303
+ __proto__: null,
304
+ APPROVED: "approved",
305
+ CHANGES_REQUESTED: "changes_requested",
306
+ COMMENTED: "commented",
307
+ // A dismissed review no longer counts, but its author did take part.
308
+ DISMISSED: "commented",
309
+ };
310
+ const STATE_REASON = { __proto__: null, COMPLETED: "completed", NOT_PLANNED: "not_planned", DUPLICATE: "duplicate" };
311
+ const ANNOTATION_LEVEL = { __proto__: null, FAILURE: "failure", WARNING: "warning", NOTICE: "notice" };
312
+
313
+ /** Whether a pull request reads as approved. With a review rule on the base
314
+ * branch GitHub decides (reviewDecision); without one it decides nothing, and
315
+ * the reviews are read the way its own list reads them: someone approved and
316
+ * nobody's latest word is "changes requested". */
317
+ function isApproved(decision, latestReviews) {
318
+ if (decision === "APPROVED") return true;
319
+ if (decision) return false;
320
+ const states = latestReviews.map(r => r.state);
321
+ return states.includes("APPROVED") && !states.includes("CHANGES_REQUESTED");
322
+ }
323
+
324
+ /** Everyone asked to review and everyone who did, one entry each. A review
325
+ * request outranks an earlier verdict: re-requested is waiting again. The
326
+ * author's own reviews are not in latestReviews, and an unsubmitted (PENDING)
327
+ * review is nobody's verdict yet. */
328
+ function reviewersOf(pr) {
329
+ const byLogin = new Map();
330
+ for (const r of nodes(pr.latestReviews)) {
331
+ const u = user(r.author);
332
+ if (!u || r.state === "PENDING") continue;
333
+ byLogin.set(u.login, { ...u, state: REVIEW_STATE[r.state] ?? "commented" });
334
+ }
335
+ for (const req of nodes(pr.reviewRequests)) {
336
+ const who = req.requestedReviewer;
337
+ if (!who) continue;
338
+ const u = who.__typename === "Team"
339
+ ? { login: who.combinedSlug, name: who.name ?? null, avatarUrl: who.avatarUrl ?? null, webUrl: who.url ?? null }
340
+ : user(who);
341
+ if (u?.login) byLogin.set(u.login, { ...u, state: "pending" });
342
+ }
343
+ return [...byLogin.values()];
344
+ }
345
+
346
+ function changeRow(pr) {
347
+ const repo = pr.repository.nameWithOwner;
348
+ const latest = nodes(pr.latestReviews);
349
+ const threads = nodes(pr.reviewThreads);
350
+ const head = nodes(pr.commits)[0]?.commit;
351
+ const rollup = head?.statusCheckRollup?.state;
352
+ return {
353
+ kind: "github",
354
+ key: `${repo}#${pr.number}`,
355
+ number: pr.number,
356
+ repo,
357
+ title: pr.title,
358
+ titleHtml: pr.titleHTML ?? null,
359
+ state: CHANGE_STATE[pr.state] ?? "open",
360
+ draft: !!pr.isDraft,
361
+ webUrl: pr.url,
362
+ author: user(pr.author),
363
+ assignees: nodes(pr.assignees).map(user).filter(Boolean),
364
+ reviewers: reviewersOf(pr),
365
+ sourceBranch: pr.headRefName,
366
+ targetBranch: pr.baseRefName,
367
+ createdAt: epoch(pr.createdAt),
368
+ updatedAt: epoch(pr.updatedAt),
369
+ mergedAt: epoch(pr.mergedAt),
370
+ closedAt: epoch(pr.closedAt),
371
+ // The conversation's comments and the comments in review threads; review
372
+ // summaries and timeline events are not comments.
373
+ comments: (pr.comments?.totalCount ?? 0) + threads.reduce((n, t) => n + (t.comments?.totalCount ?? 0), 0),
374
+ unresolvedThreads: threads.filter(t => !t.isResolved).length,
375
+ approved: isApproved(pr.reviewDecision, latest),
376
+ approvedByMe: latest.some(r => r.viewerDidAuthor && r.state === "APPROVED"),
377
+ // GitHub has required approvals per branch rule, not per pull request, and
378
+ // does not say how many are left.
379
+ approvalsRequired: null,
380
+ approvalsLeft: null,
381
+ ci: rollup ? { state: githubRollupState(rollup), webUrl: `${pr.url}/checks` } : null,
382
+ labels: nodes(pr.labels).map(label),
383
+ };
384
+ }
385
+
386
+ function comment(c) {
387
+ return {
388
+ id: String(c.id),
389
+ author: user(c.author),
390
+ bodyHtml: c.bodyHTML ?? "",
391
+ createdAt: epoch(c.createdAt),
392
+ webUrl: c.url ?? null,
393
+ };
394
+ }
395
+
396
+ function commitOf(c) {
397
+ return {
398
+ sha: c.oid,
399
+ shortSha: c.abbreviatedOid ?? c.oid.slice(0, 7),
400
+ title: c.messageHeadline ?? "",
401
+ authorName: c.author?.name ?? null,
402
+ authoredAt: epoch(c.authoredDate),
403
+ webUrl: c.url ?? null,
404
+ };
405
+ }
406
+
407
+ function issueRef(i) {
408
+ const repo = i.repository.nameWithOwner;
409
+ return {
410
+ key: `${repo}#${i.number}`,
411
+ number: i.number,
412
+ repo,
413
+ title: i.title,
414
+ state: i.state === "CLOSED" ? "closed" : "open",
415
+ webUrl: i.url,
416
+ type: i.issueType?.name ?? null,
417
+ };
418
+ }
419
+
420
+ function issueRow(i) {
421
+ const repo = i.repository.nameWithOwner;
422
+ const summary = i.subIssuesSummary;
423
+ return {
424
+ kind: "github",
425
+ key: `${repo}#${i.number}`,
426
+ number: i.number,
427
+ repo,
428
+ title: i.title,
429
+ titleHtml: i.titleHTML ?? null,
430
+ state: i.state === "CLOSED" ? "closed" : "open",
431
+ // REOPENED is not a reason an issue is in the state it is in.
432
+ stateReason: STATE_REASON[i.stateReason] ?? null,
433
+ webUrl: i.url,
434
+ author: user(i.author),
435
+ assignees: nodes(i.assignees).map(user).filter(Boolean),
436
+ labels: nodes(i.labels).map(label),
437
+ milestone: i.milestone ? { title: i.milestone.title, webUrl: i.milestone.url ?? null } : null,
438
+ createdAt: epoch(i.createdAt),
439
+ updatedAt: epoch(i.updatedAt),
440
+ closedAt: epoch(i.closedAt),
441
+ comments: i.comments?.totalCount ?? 0,
442
+ children: summary?.total > 0 ? { total: summary.total, completed: summary.completed ?? 0 } : null,
443
+ parent: i.parent ? issueRef(i.parent) : null,
444
+ };
445
+ }
446
+
447
+ function repoRow(r) {
448
+ return {
449
+ id: String(r.databaseId ?? r.id),
450
+ path: r.nameWithOwner,
451
+ name: r.name,
452
+ webUrl: r.url,
453
+ defaultBranch: r.defaultBranchRef?.name ?? null,
454
+ lastActivityAt: epoch(r.pushedAt),
455
+ archived: !!r.isArchived,
456
+ };
457
+ }
458
+
459
+ function annotation(a) {
460
+ // The runner's own annotations ("Process completed with exit code 1.") are
461
+ // filed under the path `.github` with a LOG line number, not a file's.
462
+ const runnerOwn = !a.path || a.path === ".github";
463
+ return {
464
+ path: runnerOwn ? null : a.path,
465
+ line: runnerOwn ? null : a.location?.start?.line ?? null,
466
+ level: ANNOTATION_LEVEL[a.annotationLevel] ?? "notice",
467
+ title: a.title || null,
468
+ message: a.message ?? "",
469
+ };
470
+ }
471
+
472
+ /** Why a failed check run failed, in a line: the step it died in, or what
473
+ * the check said about itself. A failed job's steps can still read "in
474
+ * progress" while the rest of the run goes on; the one left running is the
475
+ * one it died in. */
476
+ function failureReason(run, detail) {
477
+ const steps = nodes(detail?.steps);
478
+ const step = steps.find(s => githubCheckState("completed", s.conclusion) === "failed")
479
+ ?? steps.find(s => String(s.status ?? "").toLowerCase() === "in_progress");
480
+ if (step?.name) return `Step “${step.name}” failed`;
481
+ return firstLine(run.title) ?? firstLine(run.summary);
482
+ }
483
+
484
+ function checkRunJob(run, detail, actions) {
485
+ const state = githubCheckState(run.status, run.conclusion);
486
+ const failed = state === "failed";
487
+ const startedAt = epoch(run.startedAt);
488
+ const finishedAt = epoch(run.completedAt);
489
+ return {
490
+ id: String(run.databaseId),
491
+ name: run.name,
492
+ stage: null,
493
+ state,
494
+ allowFailure: false,
495
+ startedAt,
496
+ finishedAt,
497
+ durationSec: seconds(startedAt, finishedAt),
498
+ webUrl: run.detailsUrl ?? run.url ?? null,
499
+ failureReason: failed ? failureReason(run, detail) : null,
500
+ annotations: failed ? nodes(detail?.annotations).map(annotation) : [],
501
+ // An Actions check run's id is its job's id, which has a log; another
502
+ // app's check run has only the page it links to.
503
+ logAvailable: actions,
504
+ };
505
+ }
506
+
507
+ // ─── The client ─────────────────────────────────────────────────────────────
508
+
509
+ /**
510
+ * A read-only GitHub client for one connection.
511
+ *
512
+ * @param {object} opts
513
+ * @param {string} [opts.baseUrl] https://github.com, or a GitHub Enterprise Server's address
514
+ * @param {string} opts.token
515
+ * @param {typeof fetch} [opts.fetchImpl]
516
+ * @param {typeof fetch} [opts.publicFetchImpl] a job log's hop to blob storage (forge-http.mjs)
517
+ * @param {() => number} [opts.now]
518
+ */
519
+ export function createGithubClient({ baseUrl = "https://github.com", token, fetchImpl, publicFetchImpl, now = () => Date.now() } = {}) {
520
+ const typed = String(baseUrl || "https://github.com").trim().replace(/\/+$/, "");
521
+ const host = new URL(typed).host.toLowerCase();
522
+ const dotcom = host === "github.com" || host === "www.github.com";
523
+ const web = dotcom ? "https://github.com" : typed;
524
+ const webHost = new URL(web).host.toLowerCase();
525
+ const api = dotcom ? "https://api.github.com" : `${web}/api/v3`;
526
+ const graphqlUrl = dotcom ? "https://api.github.com/graphql" : `${web}/api/graphql`;
527
+ // The token goes to the API and nowhere else — not even to github.com's web
528
+ // pages, which this client never asks for.
529
+ const allowedOrigins = [new URL(api).origin];
530
+ const transport = createForgeTransport({
531
+ token,
532
+ authHeaders: (tok) => ({ authorization: `Bearer ${tok}`, "x-github-api-version": API_VERSION }),
533
+ allowedOrigins,
534
+ fetchImpl,
535
+ ...(publicFetchImpl ? { publicFetchImpl } : {}),
536
+ now,
537
+ });
538
+
539
+ /** GET a REST path with query parameters; empty ones are left out. */
540
+ function rest(path, params = {}) {
541
+ const url = new URL(`${api}${path}`);
542
+ for (const [k, v] of Object.entries(params)) {
543
+ if (v != null && v !== "") url.searchParams.set(k, String(v));
544
+ }
545
+ return transport.json(url.toString());
546
+ }
547
+
548
+ function gql(query, variables = {}) {
549
+ return transport.graphql(graphqlUrl, query, variables);
550
+ }
551
+
552
+ // Avatars and the images in descriptions come from GitHub's image hosts —
553
+ // a pasted screenshot from github.com/user-attachments by way of a signed
554
+ // private-user-images address — never from the API, so none of them is
555
+ // sent the token. An Enterprise Server serves them from its own host or a
556
+ // subdomain of it; its own host is also its API's, and the token goes there
557
+ // as it does for every read.
558
+ const IMAGE_HOSTS = new Set(["avatars.githubusercontent.com", "private-user-images.githubusercontent.com", "user-images.githubusercontent.com", "camo.githubusercontent.com"]);
559
+ function imageHost(u) {
560
+ if (u.protocol !== "https:" && u.protocol !== new URL(web).protocol) return false;
561
+ const h = u.host.toLowerCase();
562
+ return h === webHost || (dotcom ? IMAGE_HOSTS.has(h) : h.endsWith(`.${webHost}`));
563
+ }
564
+
565
+ /** An image at one of GitHub's image hosts, for the deck to pass on. */
566
+ function fetchAsset(url, { maxBytes, signal } = {}) {
567
+ return transport.asset(url, { allow: imageHost, maxBytes, signal });
568
+ }
569
+
570
+ /** Run a query with the newer issue fields, and again without them when the
571
+ * server says one does not exist (an older Enterprise Server). */
572
+ async function withNewerFields(run) {
573
+ try {
574
+ return await run(true);
575
+ } catch (err) {
576
+ if (err instanceof ForgeError && MISSING_FIELD.test(err.message)) return run(false);
577
+ throw err;
578
+ }
579
+ }
580
+
581
+ // ── Connection ──
582
+
583
+ /** @returns {Promise<import("../web/forge-types").ForgeValidation>} */
584
+ async function validate() {
585
+ const { data, headers } = await rest("/user");
586
+ // Classic tokens list their scopes; fine-grained tokens and app tokens
587
+ // send no header at all, and then nothing is said about scopes.
588
+ const header = headers?.get?.("x-oauth-scopes");
589
+ const scopes = header == null ? undefined : header.split(",").map(s => s.trim()).filter(Boolean);
590
+ const warnings = [];
591
+ if (scopes) {
592
+ if (!scopes.includes("repo")) {
593
+ warnings.push("The token has no repo scope, so private repositories are hidden.");
594
+ }
595
+ if (!scopes.some(s => s === "read:org" || s === "write:org" || s === "admin:org")) {
596
+ warnings.push("The token has no read:org scope, so organization repositories and team review requests may be missing.");
597
+ }
598
+ }
599
+ const serverVersion = headers?.get?.("x-github-enterprise-version") ?? undefined;
600
+ return {
601
+ account: {
602
+ id: String(data.id),
603
+ login: data.login,
604
+ name: data.name || null,
605
+ avatarUrl: data.avatar_url ?? null,
606
+ webUrl: data.html_url ?? null,
607
+ },
608
+ meta: {
609
+ ...(scopes ? { scopes } : {}),
610
+ ...(serverVersion ? { serverVersion } : {}),
611
+ ...(warnings.length ? { warnings } : {}),
612
+ },
613
+ };
614
+ }
615
+
616
+ // ── Repositories ──
617
+
618
+ const VIEWER_REPOS = `query($first: Int!, $after: String) { viewer { repositories(
619
+ first: $first, after: $after,
620
+ orderBy: { field: PUSHED_AT, direction: DESC },
621
+ ownerAffiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER],
622
+ affiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER]
623
+ ) { totalCount pageInfo { hasNextPage endCursor } nodes { databaseId nameWithOwner name url defaultBranchRef { name } pushedAt isArchived } } } }`;
624
+
625
+ /**
626
+ * The viewer's repositories — owned, collaborated on, or in their
627
+ * organizations — most recently pushed first. `search` keeps the ones whose
628
+ * path contains it; GitHub cannot filter this list, so pages of 100 are read
629
+ * until a page's worth matched, and the cursor continues after the last page
630
+ * read.
631
+ */
632
+ async function listRepos({ search, cursor } = {}) {
633
+ const needle = String(search ?? "").trim().toLowerCase();
634
+ if (!needle) {
635
+ const { data } = await gql(VIEWER_REPOS, { first: REPO_PAGE_SIZE, after: cursor || null });
636
+ const conn = data.viewer.repositories;
637
+ return {
638
+ items: nodes(conn).map(repoRow),
639
+ next: conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null,
640
+ total: conn.totalCount ?? null,
641
+ };
642
+ }
643
+ const items = [];
644
+ let after = cursor || null;
645
+ let more = true;
646
+ for (let i = 0; i < REPO_SCAN_PAGES && more && items.length < REPO_PAGE_SIZE; i++) {
647
+ const { data } = await gql(VIEWER_REPOS, { first: 100, after });
648
+ const conn = data.viewer.repositories;
649
+ for (const r of nodes(conn)) if (r.nameWithOwner.toLowerCase().includes(needle)) items.push(repoRow(r));
650
+ more = !!conn.pageInfo.hasNextPage;
651
+ after = conn.pageInfo.endCursor;
652
+ }
653
+ return { items, next: more ? after : null, total: null };
654
+ }
655
+
656
+ // ── Branches ──
657
+
658
+ // repo → { at, defaultBranch, totalCount, list: [{ name, at }] }
659
+ const branchScans = new Map();
660
+
661
+ /** Every branch's name and last commit time, newest first. */
662
+ async function scanBranches(r) {
663
+ const list = [];
664
+ let after = null;
665
+ let defaultBranch = null;
666
+ let totalCount = null;
667
+ for (;;) {
668
+ const { data, errors } = await gql(`query($owner: String!, $name: String!, $after: String) {
669
+ repository(owner: $owner, name: $name) {
670
+ defaultBranchRef { name }
671
+ refs(refPrefix: "refs/heads/", first: 100, after: $after) {
672
+ totalCount pageInfo { hasNextPage endCursor }
673
+ nodes { name target { ... on Commit { committedDate } } }
674
+ }
675
+ } }`, { owner: r.owner, name: r.name, after });
676
+ const repo = need(data.repository, errors, `repository ${r.path}`);
677
+ defaultBranch = repo.defaultBranchRef?.name ?? null;
678
+ totalCount = repo.refs.totalCount ?? null;
679
+ for (const n of nodes(repo.refs)) list.push({ name: n.name, at: epoch(n.target?.committedDate) ?? 0 });
680
+ if (!repo.refs.pageInfo.hasNextPage || list.length >= BRANCH_SCAN_MAX) break;
681
+ after = repo.refs.pageInfo.endCursor;
682
+ }
683
+ list.sort((a, b) => b.at - a.at || a.name.localeCompare(b.name));
684
+ const scan = { at: now(), defaultBranch, totalCount, list };
685
+ branchScans.delete(r.path);
686
+ branchScans.set(r.path, scan);
687
+ while (branchScans.size > BRANCH_SCAN_KEEP) branchScans.delete(branchScans.keys().next().value);
688
+ return scan;
689
+ }
690
+
691
+ function branchRow(r, ref, defaultBranch, aheadBehind) {
692
+ const c = ref.target ?? {};
693
+ const isDefault = ref.name === defaultBranch;
694
+ const committedAt = epoch(c.committedDate);
695
+ const pr = nodes(ref.associatedPullRequests)[0];
696
+ const prRepo = pr?.repository?.nameWithOwner ?? r.path;
697
+ return {
698
+ name: ref.name,
699
+ webUrl: `${web}/${r.path}/tree/${ref.name.split("/").map(encodeURIComponent).join("/")}`,
700
+ isDefault,
701
+ // A classic branch protection rule or an active ruleset; a disabled
702
+ // ruleset applies no rules and does not count.
703
+ protected: !!ref.branchProtectionRule || (ref.rules?.totalCount ?? 0) > 0,
704
+ commit: {
705
+ sha: c.oid ?? "",
706
+ shortSha: c.abbreviatedOid ?? String(c.oid ?? "").slice(0, 7),
707
+ title: c.messageHeadline ?? "",
708
+ committedAt,
709
+ authorName: c.author?.name ?? null,
710
+ webUrl: c.url ?? null,
711
+ },
712
+ ahead: isDefault ? null : aheadBehind?.ahead ?? null,
713
+ behind: isDefault ? null : aheadBehind?.behind ?? null,
714
+ ci: c.statusCheckRollup ? { state: githubRollupState(c.statusCheckRollup.state), webUrl: c.url ? `${c.url}/checks` : null } : null,
715
+ change: pr ? {
716
+ number: pr.number,
717
+ key: `${prRepo}#${pr.number}`,
718
+ state: "open",
719
+ draft: !!pr.isDraft,
720
+ approved: isApproved(pr.reviewDecision, nodes(pr.latestReviews)),
721
+ webUrl: pr.url,
722
+ } : null,
723
+ stale: committedAt != null && now() - committedAt > STALE_MS,
724
+ };
725
+ }
726
+
727
+ /**
728
+ * A page of the repository's branches, most recent commit first, each with
729
+ * its ahead/behind against the default branch, its head's checks, the open
730
+ * pull request it is the source of, and whether it is protected or stale.
731
+ */
732
+ async function listBranches(repo, { cursor } = {}) {
733
+ const r = splitRepo(repo);
734
+ const offset = Math.max(0, Number.parseInt(String(cursor ?? "0"), 10) || 0);
735
+ const cached = branchScans.get(r.path);
736
+ const scan = offset > 0 && cached && now() - cached.at < BRANCH_SCAN_TTL_MS ? cached : await scanBranches(r);
737
+ const page = scan.list.slice(offset, offset + PAGE_SIZE);
738
+ const next = offset + PAGE_SIZE < scan.list.length ? String(offset + PAGE_SIZE) : null;
739
+ if (!page.length) return { items: [], next: null, total: scan.totalCount };
740
+
741
+ const vars = { owner: r.owner, name: r.name, base: scan.defaultBranch ?? "HEAD" };
742
+ const aliases = page.map((b, i) => {
743
+ vars[`q${i}`] = `refs/heads/${b.name}`;
744
+ return `b${i}: ref(qualifiedName: $q${i}) { ...Branch compare(headRef: $base) { aheadBy behindBy } }`;
745
+ });
746
+ const { data, errors } = await gql(`query($owner: String!, $name: String!, $base: String!, ${page.map((_, i) => `$q${i}: String!`).join(", ")}) {
747
+ repository(owner: $owner, name: $name) { ${aliases.join("\n")} } }
748
+ fragment Branch on Ref { ${BRANCH} }`, vars);
749
+ const found = need(data.repository, errors, `repository ${r.path}`);
750
+ const items = [];
751
+ page.forEach((_, i) => {
752
+ const ref = found[`b${i}`];
753
+ // Deleted between the scan and now.
754
+ if (!ref) return;
755
+ // Asked on the BRANCH against the default branch, so the branch is the
756
+ // base: GitHub's aheadBy is the default branch's lead, i.e. how far the
757
+ // branch is behind, and behindBy is how far the branch is ahead.
758
+ const cmp = ref.compare;
759
+ items.push(branchRow(r, ref, scan.defaultBranch, cmp ? { ahead: cmp.behindBy, behind: cmp.aheadBy } : null));
760
+ });
761
+ return { items, next, total: scan.totalCount };
762
+ }
763
+
764
+ /**
765
+ * One branch: its row, its head's latest workflow run with jobs, and the
766
+ * commits it has that the default branch does not (the newest 50, newest
767
+ * first).
768
+ */
769
+ async function getBranch(repo, name) {
770
+ const r = splitRepo(repo);
771
+ const { data, errors } = await gql(`query($owner: String!, $name: String!, $qualified: String!, $branch: String!) {
772
+ repository(owner: $owner, name: $name) {
773
+ defaultBranchRef { name compare(headRef: $branch) {
774
+ aheadBy behindBy
775
+ commits(last: ${COMMITS_AHEAD_CAP}) { nodes { ${COMMIT} } }
776
+ } }
777
+ ref(qualifiedName: $qualified) { ${BRANCH} }
778
+ } }`, { owner: r.owner, name: r.name, qualified: `refs/heads/${name}`, branch: name });
779
+ const found = need(data.repository, errors, `repository ${r.path}`);
780
+ const ref = found.ref;
781
+ if (!ref) throw new ForgeError("not-found", `no branch ${name} in ${r.path}`);
782
+ const defaultBranch = found.defaultBranchRef?.name ?? null;
783
+ // Asked on the DEFAULT branch with this branch as head: aheadBy is this
784
+ // branch's lead, as it reads.
785
+ const cmp = found.defaultBranchRef?.compare;
786
+ const branch = branchRow(r, ref, defaultBranch, cmp ? { ahead: cmp.aheadBy, behind: cmp.behindBy } : null);
787
+ const commitsAhead = branch.isDefault ? [] : nodes(cmp?.commits).map(commitOf).reverse();
788
+
789
+ let pipeline = null;
790
+ if (branch.commit.sha) {
791
+ const { data: runs } = await rest(`${r.rest}/actions/runs`, { head_sha: branch.commit.sha, per_page: 1, exclude_pull_requests: true });
792
+ const run = runs?.workflow_runs?.[0];
793
+ if (run) pipeline = await runDetail(r, run);
794
+ }
795
+ return { branch, pipeline, commitsAhead };
796
+ }
797
+
798
+ // ── Pull requests ──
799
+
800
+ function changeSearch({ repo, filter = "all", state = "open", sort }) {
801
+ const q = ["is:pr", "archived:false"];
802
+ if (repo) q.push(`repo:${splitRepo(repo).path}`);
803
+ if (filter === "mine") q.push("author:@me");
804
+ else if (filter === "review") q.push("user-review-requested:@me");
805
+ // "All" across every repository is everything the viewer is part of.
806
+ else if (!repo) q.push("involves:@me");
807
+ if (state === "merged") q.push("is:merged");
808
+ else if (state === "closed") q.push("is:closed", "is:unmerged");
809
+ else q.push("is:open");
810
+ if (sort) q.push(sort === "updated" ? "sort:updated-desc" : "sort:created-desc");
811
+ return q.join(" ");
812
+ }
813
+
814
+ /** A page of pull requests: all of a repository's (or everything the viewer
815
+ * is part of), the viewer's own, or the ones waiting on the viewer's review. */
816
+ async function listChanges({ repo = null, filter = "all", state = "open", sort = "created", cursor } = {}) {
817
+ const { data } = await gql(`query($q: String!, $first: Int!, $after: String) {
818
+ search(query: $q, type: ISSUE, first: $first, after: $after) {
819
+ issueCount pageInfo { hasNextPage endCursor }
820
+ nodes { ... on PullRequest { ${PR_ROW} } }
821
+ } }`, { q: changeSearch({ repo, filter, state, sort }), first: PAGE_SIZE, after: cursor || null });
822
+ const s = data.search;
823
+ return {
824
+ // A result the token may not read (an SSO-protected organization) comes
825
+ // back empty, with its error beside it.
826
+ items: nodes(s).filter(n => n.number != null && n.repository).map(changeRow),
827
+ next: s.pageInfo.hasNextPage ? s.pageInfo.endCursor : null,
828
+ total: s.issueCount ?? null,
829
+ };
830
+ }
831
+
832
+ /** The Open / Merged / Closed tab counts, in one query. */
833
+ async function countChanges({ repo = null, filter = "all" } = {}) {
834
+ const q = (state) => changeSearch({ repo, filter, state });
835
+ const { data } = await gql(`query($open: String!, $merged: String!, $closed: String!) {
836
+ open: search(query: $open, type: ISSUE, first: 0) { issueCount }
837
+ merged: search(query: $merged, type: ISSUE, first: 0) { issueCount }
838
+ closed: search(query: $closed, type: ISSUE, first: 0) { issueCount }
839
+ }`, { open: q("open"), merged: q("merged"), closed: q("closed") });
840
+ return {
841
+ open: data.open?.issueCount ?? null,
842
+ merged: data.merged?.issueCount ?? null,
843
+ closed: data.closed?.issueCount ?? null,
844
+ };
845
+ }
846
+
847
+ /** The pull request's Overview: its row, description, threads, the
848
+ * conversation, and the tab counts. */
849
+ async function getChange(repo, number) {
850
+ const r = splitRepo(repo);
851
+ const { data, errors } = await gql(`query($owner: String!, $name: String!, $number: Int!) {
852
+ repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PR_DETAIL} } } }`,
853
+ { owner: r.owner, name: r.name, number: Number(number) });
854
+ const pr = need(need(data.repository, errors, `repository ${r.path}`).pullRequest, errors, `pull request ${r.path}#${number}`);
855
+ const row = changeRow(pr);
856
+ const latest = nodes(pr.latestReviews);
857
+ // A review's summary is part of the conversation; an empty one (a review
858
+ // that only left line comments) is not, and a PENDING one is unsent.
859
+ const summaries = nodes(pr.reviews).filter(v => v.state !== "PENDING" && String(v.body ?? "").trim());
860
+ const generalComments = [
861
+ ...nodes(pr.comments).map(comment),
862
+ ...summaries.map(v => comment({ ...v, createdAt: v.submittedAt })),
863
+ ].sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
864
+ const threads = nodes(pr.reviewThreads).map(t => {
865
+ const cs = nodes(t.comments).map(comment);
866
+ return {
867
+ id: t.id,
868
+ resolvable: true,
869
+ resolved: !!t.isResolved,
870
+ path: t.path ?? null,
871
+ // An outdated thread has no line in the current diff; it keeps the
872
+ // one it was written on.
873
+ line: t.line ?? t.originalLine ?? null,
874
+ outdated: !!t.isOutdated,
875
+ comments: cs,
876
+ webUrl: cs[0]?.webUrl ?? null,
877
+ };
878
+ });
879
+ const commits = pr.commits;
880
+ const head = nodes(commits)[0]?.commit;
881
+ const suites = nodes(head?.checkSuites).filter(s => (s.checkRuns?.totalCount ?? 0) > 0).length;
882
+ const statuses = (head?.status?.contexts ?? []).length > 0 ? 1 : 0;
883
+ return {
884
+ ...row,
885
+ descriptionHtml: pr.bodyHTML ?? "",
886
+ milestone: pr.milestone ? { title: pr.milestone.title, webUrl: pr.milestone.url ?? null } : null,
887
+ participants: nodes(pr.participants).map(user).filter(Boolean),
888
+ approvedBy: latest.filter(v => v.state === "APPROVED").map(v => user(v.author)).filter(Boolean),
889
+ counts: {
890
+ discussions: (pr.comments?.totalCount ?? 0) + summaries.length + (pr.reviewThreads?.totalCount ?? 0),
891
+ commits: commits?.totalCount ?? 0,
892
+ pipelines: suites + statuses,
893
+ files: pr.changedFiles ?? 0,
894
+ },
895
+ threads,
896
+ generalComments,
897
+ };
898
+ }
899
+
900
+ /** The pull request's commits, newest first (GitLab's order; GitHub's own
901
+ * tab is oldest first), read backwards from the head. */
902
+ async function listChangeCommits(repo, number, { cursor } = {}) {
903
+ const r = splitRepo(repo);
904
+ const { data, errors } = await gql(`query($owner: String!, $name: String!, $number: Int!, $before: String) {
905
+ repository(owner: $owner, name: $name) { pullRequest(number: $number) {
906
+ commits(last: ${PAGE_SIZE}, before: $before) {
907
+ totalCount pageInfo { hasPreviousPage startCursor }
908
+ nodes { commit { ${COMMIT} } }
909
+ } } } }`, { owner: r.owner, name: r.name, number: Number(number), before: cursor || null });
910
+ const pr = need(need(data.repository, errors, `repository ${r.path}`).pullRequest, errors, `pull request ${r.path}#${number}`);
911
+ const conn = pr.commits;
912
+ return {
913
+ items: nodes(conn).map(n => commitOf(n.commit)).reverse(),
914
+ next: conn.pageInfo.hasPreviousPage ? conn.pageInfo.startCursor : null,
915
+ total: conn.totalCount ?? null,
916
+ };
917
+ }
918
+
919
+ /** Steps and annotations for the check runs that failed, by node id. */
920
+ async function failedRunDetails(runs) {
921
+ const ids = runs.filter(run => githubCheckState(run.status, run.conclusion) === "failed" && run.id).map(run => run.id);
922
+ if (!ids.length) return new Map();
923
+ const { data } = await gql(`query($ids: [ID!]!) { nodes(ids: $ids) { ... on CheckRun { ${CHECK_RUN_DETAIL} } } }`, { ids: ids.slice(0, 100) });
924
+ return new Map((data.nodes ?? []).filter(n => n?.id).map(n => [n.id, n]));
925
+ }
926
+
927
+ /** A check suite as a pipeline: an Actions workflow run, or another app's
928
+ * suite (`suite:<id>`, which getPipeline also reads). */
929
+ function suitePipeline(r, suite, details, fallbackRef) {
930
+ const wf = suite.workflowRun;
931
+ const actions = !!wf;
932
+ const jobs = nodes(suite.checkRuns).map(run => checkRunJob(run, details.get(run.id), actions));
933
+ const starts = jobs.map(j => j.startedAt).filter(t => t != null);
934
+ const ends = jobs.map(j => j.finishedAt).filter(t => t != null);
935
+ const state = githubCheckState(suite.status, suite.conclusion);
936
+ const done = String(suite.status ?? "").toLowerCase() === "completed";
937
+ const startedAt = starts.length ? Math.min(...starts) : null;
938
+ const finishedAt = done && ends.length ? Math.max(...ends) : null;
939
+ const sha = suite.commit?.oid ?? "";
940
+ return {
941
+ id: actions ? String(wf.databaseId) : `suite:${suite.databaseId}`,
942
+ repo: r.path,
943
+ ref: suite.branch?.name ?? fallbackRef ?? "",
944
+ sha,
945
+ shortSha: suite.commit?.abbreviatedOid ?? sha.slice(0, 7),
946
+ title: actions
947
+ ? [wf.workflow?.name, suite.commit?.messageHeadline].filter(Boolean).join(" · ") || null
948
+ : suite.app?.name ?? null,
949
+ state,
950
+ source: wf?.event ?? null,
951
+ triggeredBy: null,
952
+ createdAt: epoch(wf?.createdAt ?? suite.createdAt),
953
+ startedAt,
954
+ finishedAt,
955
+ durationSec: seconds(startedAt, finishedAt),
956
+ webUrl: wf?.url ?? suite.url ?? `${web}/${r.path}/commit/${sha}/checks`,
957
+ jobs,
958
+ };
959
+ }
960
+
961
+ /** A commit's legacy statuses (from CI services that post statuses rather
962
+ * than checks) as one pseudo-pipeline, `status:<sha>`. */
963
+ function statusPipeline(r, commit, ref) {
964
+ const contexts = commit?.status?.contexts ?? [];
965
+ if (!contexts.length) return null;
966
+ const jobs = contexts.map(c => {
967
+ const state = githubRollupState(c.state);
968
+ const at = epoch(c.createdAt);
969
+ return {
970
+ id: String(c.id ?? c.context),
971
+ name: c.context,
972
+ stage: null,
973
+ state,
974
+ allowFailure: false,
975
+ startedAt: at,
976
+ finishedAt: state === "pending" ? null : at,
977
+ durationSec: null,
978
+ webUrl: c.targetUrl || null,
979
+ failureReason: state === "failed" ? c.description || null : null,
980
+ annotations: [],
981
+ logAvailable: false,
982
+ };
983
+ });
984
+ const times = jobs.map(j => j.startedAt).filter(t => t != null);
985
+ const createdAt = times.length ? Math.min(...times) : null;
986
+ return {
987
+ id: `status:${commit.oid}`,
988
+ repo: r.path,
989
+ ref: ref ?? "",
990
+ sha: commit.oid,
991
+ shortSha: commit.abbreviatedOid ?? commit.oid.slice(0, 7),
992
+ title: "Commit statuses",
993
+ state: githubRollupState(commit.status.state),
994
+ source: null,
995
+ triggeredBy: null,
996
+ createdAt,
997
+ startedAt: createdAt,
998
+ finishedAt: null,
999
+ durationSec: null,
1000
+ webUrl: `${web}/${r.path}/commit/${commit.oid}`,
1001
+ jobs,
1002
+ };
1003
+ }
1004
+
1005
+ const STATUS = "status { state contexts { id context state description targetUrl createdAt } }";
1006
+
1007
+ /** The head commit's checks: each check suite with runs is a pipeline (an
1008
+ * Actions workflow run, or another app's suite), and its legacy commit
1009
+ * statuses are one more, "Commit statuses". */
1010
+ async function listChangePipelines(repo, number) {
1011
+ const r = splitRepo(repo);
1012
+ const { data, errors } = await gql(`query($owner: String!, $name: String!, $number: Int!) {
1013
+ repository(owner: $owner, name: $name) { pullRequest(number: $number) {
1014
+ headRefName
1015
+ commits(last: 1) { nodes { commit {
1016
+ oid abbreviatedOid
1017
+ ${STATUS}
1018
+ checkSuites(first: 30) { nodes { ${CHECK_SUITE} } }
1019
+ } } }
1020
+ } } }`, { owner: r.owner, name: r.name, number: Number(number) });
1021
+ const pr = need(need(data.repository, errors, `repository ${r.path}`).pullRequest, errors, `pull request ${r.path}#${number}`);
1022
+ const head = nodes(pr.commits)[0]?.commit;
1023
+ if (!head) return [];
1024
+ // A suite with no runs is an app that was told about the push and did
1025
+ // nothing; GitHub's own checks tab does not show it either.
1026
+ const suites = nodes(head.checkSuites).filter(s => (s.checkRuns?.totalCount ?? 0) > 0);
1027
+ const details = await failedRunDetails(suites.flatMap(s => nodes(s.checkRuns)));
1028
+ const pipelines = suites.map(s => suitePipeline(r, s, details, pr.headRefName));
1029
+ const statuses = statusPipeline(r, head, pr.headRefName);
1030
+ if (statuses) pipelines.push(statuses);
1031
+ return pipelines;
1032
+ }
1033
+
1034
+ // Without a prototype, as the tables above.
1035
+ const FILE_STATUS = {
1036
+ __proto__: null,
1037
+ added: "added",
1038
+ removed: "deleted",
1039
+ modified: "modified",
1040
+ renamed: "renamed",
1041
+ copied: "added",
1042
+ changed: "modified",
1043
+ unchanged: "modified",
1044
+ };
1045
+
1046
+ /** The pull request's files. REST, because GraphQL does not say what a
1047
+ * renamed file was called before. Each links to its place on the files
1048
+ * tab, which GitHub anchors by the SHA-256 of the path. */
1049
+ async function listChangeFiles(repo, number, { cursor } = {}) {
1050
+ const r = splitRepo(repo);
1051
+ const page = pageNumber(cursor);
1052
+ const { data, headers } = await rest(`${r.rest}/pulls/${Number(number)}/files`, { per_page: FILE_PAGE_SIZE, page });
1053
+ const filesTab = `${web}/${r.path}/pull/${Number(number)}/files`;
1054
+ return {
1055
+ items: (Array.isArray(data) ? data : []).map(f => ({
1056
+ path: f.filename,
1057
+ oldPath: f.previous_filename ?? null,
1058
+ status: FILE_STATUS[f.status] ?? "modified",
1059
+ additions: f.additions ?? 0,
1060
+ deletions: f.deletions ?? 0,
1061
+ webUrl: `${filesTab}#diff-${createHash("sha256").update(f.filename).digest("hex")}`,
1062
+ })),
1063
+ next: nextLink(headers) ? String(page + 1) : null,
1064
+ total: null,
1065
+ };
1066
+ }
1067
+
1068
+ // ── Actions ──
1069
+
1070
+ /** An Actions workflow run (REST) as a pipeline row. */
1071
+ function runRow(run, repoPath) {
1072
+ const createdAt = epoch(run.created_at);
1073
+ const startedAt = epoch(run.run_started_at) ?? createdAt;
1074
+ const finishedAt = String(run.status ?? "").toLowerCase() === "completed" ? epoch(run.updated_at) : null;
1075
+ const sha = run.head_sha ?? "";
1076
+ return {
1077
+ id: String(run.id),
1078
+ repo: run.repository?.full_name ?? repoPath,
1079
+ ref: run.head_branch ?? "",
1080
+ sha,
1081
+ shortSha: sha.slice(0, 7),
1082
+ title: [run.name, run.display_title].filter(Boolean).join(" · ") || null,
1083
+ state: githubCheckState(run.status, run.conclusion),
1084
+ source: run.event ?? null,
1085
+ triggeredBy: restUser(run.triggering_actor ?? run.actor),
1086
+ createdAt,
1087
+ startedAt,
1088
+ finishedAt,
1089
+ durationSec: seconds(startedAt, finishedAt),
1090
+ webUrl: run.html_url,
1091
+ };
1092
+ }
1093
+
1094
+ /** A workflow run with its jobs: the run from REST, its jobs from its check
1095
+ * suite (one GraphQL query, the same mapping the pull request's checks use). */
1096
+ async function runDetail(r, run) {
1097
+ const row = runRow(run, r.path);
1098
+ let jobs = [];
1099
+ if (run.check_suite_node_id) {
1100
+ const { data } = await gql(`query($id: ID!) { node(id: $id) { ... on CheckSuite { ${CHECK_SUITE} } } }`, { id: run.check_suite_node_id });
1101
+ if (data.node) {
1102
+ const runs = nodes(data.node.checkRuns);
1103
+ jobs = suitePipeline(r, data.node, await failedRunDetails(runs), row.ref).jobs;
1104
+ }
1105
+ }
1106
+ return { ...row, jobs };
1107
+ }
1108
+
1109
+ /** Errors that mean "this one repository's runs are not readable" — Actions
1110
+ * off, an SSO-protected organization — skip that repository in a merged
1111
+ * list; everything else (auth, rate limit, network) is the list's failure. */
1112
+ const SKIPPABLE = new Set(["not-found", "permission"]);
1113
+
1114
+ /**
1115
+ * Actions workflow runs, newest first: one repository's, or the latest of
1116
+ * the viewer's ten most recently pushed repositories merged by time. The
1117
+ * merged list's cursor is the creation time of its last run and the ids of
1118
+ * the runs at exactly that time, so the next page neither repeats nor skips
1119
+ * the runs that started in the same second.
1120
+ */
1121
+ async function listPipelines({ repo = null, cursor } = {}) {
1122
+ if (repo) {
1123
+ const r = splitRepo(repo);
1124
+ const page = pageNumber(cursor);
1125
+ const { data, headers } = await rest(`${r.rest}/actions/runs`, { per_page: PAGE_SIZE, page, exclude_pull_requests: true });
1126
+ return {
1127
+ items: (data.workflow_runs ?? []).map(run => runRow(run, r.path)),
1128
+ next: nextLink(headers) ? String(page + 1) : null,
1129
+ total: data.total_count ?? null,
1130
+ };
1131
+ }
1132
+ const [before, seenIds] = String(cursor ?? "").split("~");
1133
+ const seen = new Set((seenIds ?? "").split(".").filter(Boolean));
1134
+ const { data } = await gql(VIEWER_REPOS, { first: RUN_REPOS, after: null });
1135
+ const repos = nodes(data.viewer.repositories).map(n => splitRepo(n.nameWithOwner));
1136
+ let more = false;
1137
+ const perRepo = await mapLimit(repos, 5, async (r) => {
1138
+ try {
1139
+ const { data: d } = await rest(`${r.rest}/actions/runs`, {
1140
+ per_page: PAGE_SIZE,
1141
+ exclude_pull_requests: true,
1142
+ created: before ? `<=${before}` : null,
1143
+ });
1144
+ const runs = d.workflow_runs ?? [];
1145
+ if ((d.total_count ?? 0) > runs.length) more = true;
1146
+ return runs.map(run => runRow(run, r.path));
1147
+ } catch (err) {
1148
+ if (err instanceof ForgeError && SKIPPABLE.has(err.kind)) return [];
1149
+ throw err;
1150
+ }
1151
+ });
1152
+ const all = perRepo.flat()
1153
+ .filter(p => !seen.has(p.id))
1154
+ .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
1155
+ const items = all.slice(0, PAGE_SIZE);
1156
+ if (all.length > PAGE_SIZE) more = true;
1157
+ let next = null;
1158
+ const last = items[items.length - 1];
1159
+ if (more && last?.createdAt != null) {
1160
+ const iso = new Date(last.createdAt).toISOString().replace(/\.\d{3}Z$/, "Z");
1161
+ const atLast = items.filter(p => p.createdAt === last.createdAt).map(p => p.id);
1162
+ // The runs at the boundary second the previous page already showed too.
1163
+ if (before === iso) atLast.push(...seen);
1164
+ next = `${iso}~${[...new Set(atLast)].join(".")}`;
1165
+ }
1166
+ return { items, next, total: null };
1167
+ }
1168
+
1169
+ /**
1170
+ * One pipeline with its jobs: an Actions run by its id, another app's check
1171
+ * suite (`suite:<id>`), or a commit's statuses (`status:<sha>`) — the ids
1172
+ * listChangePipelines hands out.
1173
+ */
1174
+ async function getPipeline(repo, id) {
1175
+ const r = splitRepo(repo);
1176
+ const key = String(id);
1177
+ if (key.startsWith("status:")) {
1178
+ const sha = key.slice("status:".length);
1179
+ const { data, errors } = await gql(`query($owner: String!, $name: String!, $sha: GitObjectID!) {
1180
+ repository(owner: $owner, name: $name) { object(oid: $sha) { ... on Commit { oid abbreviatedOid ${STATUS} } } } }`,
1181
+ { owner: r.owner, name: r.name, sha });
1182
+ const commit = need(need(data.repository, errors, `repository ${r.path}`).object, errors, `commit ${sha}`);
1183
+ const p = statusPipeline(r, commit, null);
1184
+ if (!p) throw new ForgeError("not-found", `commit ${sha.slice(0, 7)} has no statuses`);
1185
+ return p;
1186
+ }
1187
+ if (key.startsWith("suite:")) {
1188
+ const { data: suiteRest } = await rest(`${r.rest}/check-suites/${encodeURIComponent(key.slice("suite:".length))}`);
1189
+ const { data, errors } = await gql(`query($id: ID!) { node(id: $id) { ... on CheckSuite { ${CHECK_SUITE} } } }`, { id: suiteRest.node_id });
1190
+ const suite = need(data.node, errors, `check suite ${key}`);
1191
+ return suitePipeline(r, suite, await failedRunDetails(nodes(suite.checkRuns)), suiteRest.head_branch);
1192
+ }
1193
+ const { data: run } = await rest(`${r.rest}/actions/runs/${encodeURIComponent(key)}`);
1194
+ return runDetail(r, run);
1195
+ }
1196
+
1197
+ /**
1198
+ * The end of an Actions job's log. The API answers with a redirect to a
1199
+ * signed blob URL, which the transport follows without the token.
1200
+ *
1201
+ * A job's log ends with the runner's own housekeeping — every action's
1202
+ * "Post job cleanup." (checkout removing its credentials, and so on) and
1203
+ * "Cleaning up orphan processes" — which comes AFTER the step that failed,
1204
+ * and on a failed job is thirty-odd lines between the reader and the error.
1205
+ * That housekeeping is cut, so the last lines shown are the job's own.
1206
+ */
1207
+ async function jobLogTail(repo, jobId, { lines = 80 } = {}) {
1208
+ const r = splitRepo(repo);
1209
+ const id = encodeURIComponent(String(jobId));
1210
+ // More than asked: `##[endgroup]` lines and the housekeeping are dropped
1211
+ // after, and the suffix read is the same 256 KB either way.
1212
+ const t = await transport.tail(`${api}${r.rest}/actions/jobs/${id}/logs`, { lines: lines + 200, clean: cleanGithubLogLine });
1213
+ let out = t.lines.filter(l => l != null);
1214
+ const cleanup = out.indexOf("Post job cleanup.");
1215
+ if (cleanup > 0) out = out.slice(0, cleanup);
1216
+ else if (out[out.length - 1] === "Cleaning up orphan processes") out.pop();
1217
+ while (out.length && out[out.length - 1] === "") out.pop();
1218
+ let truncated = t.truncated;
1219
+ if (out.length > lines) {
1220
+ out = out.slice(-lines);
1221
+ truncated = true;
1222
+ }
1223
+ // A check run's page, which GitHub sends on to the job's own page.
1224
+ return { lines: out, truncated, webUrl: `${web}/${r.path}/runs/${id}` };
1225
+ }
1226
+
1227
+ // ── Issues ──
1228
+
1229
+ /**
1230
+ * A page of issues, newest first: ALL of a repository's (pull requests are
1231
+ * not issues here), or, across repositories, every issue the viewer is part
1232
+ * of (author, assignee, mentioned or commenter).
1233
+ */
1234
+ async function listIssues({ repo = null, state = "open", cursor } = {}) {
1235
+ const closed = state === "closed";
1236
+ if (repo) {
1237
+ const r = splitRepo(repo);
1238
+ return withNewerFields(async (newer) => {
1239
+ const { data, errors } = await gql(`query($owner: String!, $name: String!, $states: [IssueState!], $first: Int!, $after: String) {
1240
+ repository(owner: $owner, name: $name) {
1241
+ issues(states: $states, first: $first, after: $after, orderBy: { field: CREATED_AT, direction: DESC }) {
1242
+ totalCount pageInfo { hasNextPage endCursor }
1243
+ nodes { ${issueFields(newer)} }
1244
+ } } }`, { owner: r.owner, name: r.name, states: [closed ? "CLOSED" : "OPEN"], first: PAGE_SIZE, after: cursor || null });
1245
+ const conn = need(data.repository, errors, `repository ${r.path}`).issues;
1246
+ return {
1247
+ items: nodes(conn).map(issueRow),
1248
+ next: conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null,
1249
+ total: conn.totalCount ?? null,
1250
+ };
1251
+ });
1252
+ }
1253
+ const q = `is:issue involves:@me archived:false ${closed ? "is:closed" : "is:open"} sort:created-desc`;
1254
+ return withNewerFields(async (newer) => {
1255
+ const { data } = await gql(`query($q: String!, $first: Int!, $after: String) {
1256
+ search(query: $q, type: ISSUE, first: $first, after: $after) {
1257
+ issueCount pageInfo { hasNextPage endCursor }
1258
+ nodes { ... on Issue { ${issueFields(newer)} } }
1259
+ } }`, { q, first: PAGE_SIZE, after: cursor || null });
1260
+ const s = data.search;
1261
+ return {
1262
+ items: nodes(s).filter(n => n.number != null && n.repository).map(issueRow),
1263
+ next: s.pageInfo.hasNextPage ? s.pageInfo.endCursor : null,
1264
+ total: s.issueCount ?? null,
1265
+ };
1266
+ });
1267
+ }
1268
+
1269
+ /** One issue: its row, description, parent and sub-issues. */
1270
+ async function getIssue(repo, number) {
1271
+ const r = splitRepo(repo);
1272
+ return withNewerFields(async (newer) => {
1273
+ const { data, errors } = await gql(`query($owner: String!, $name: String!, $number: Int!) {
1274
+ repository(owner: $owner, name: $name) { issue(number: $number) {
1275
+ ${issueFields(newer)}
1276
+ bodyHTML
1277
+ ${newer ? `subIssues(first: 50) { nodes { ${ISSUE_REF} issueType { name } } }` : ""}
1278
+ } } }`, { owner: r.owner, name: r.name, number: Number(number) });
1279
+ const issue = need(need(data.repository, errors, `repository ${r.path}`).issue, errors, `issue ${r.path}#${number}`);
1280
+ return {
1281
+ ...issueRow(issue),
1282
+ descriptionHtml: issue.bodyHTML ?? "",
1283
+ childItems: nodes(issue.subIssues).filter(i => i.repository).map(issueRef),
1284
+ };
1285
+ });
1286
+ }
1287
+
1288
+ /** An issue's comments, oldest first, as GitHub shows them. A pull
1289
+ * request's number answers its conversation's comments the same way. */
1290
+ async function listIssueComments(repo, number, { cursor } = {}) {
1291
+ const r = splitRepo(repo);
1292
+ const page = `comments(first: ${COMMENT_PAGE_SIZE}, after: $after) { totalCount pageInfo { hasNextPage endCursor } nodes { ${COMMENT} } }`;
1293
+ const { data, errors } = await gql(`query($owner: String!, $name: String!, $number: Int!, $after: String) {
1294
+ repository(owner: $owner, name: $name) { issueOrPullRequest(number: $number) {
1295
+ ... on Issue { ${page} }
1296
+ ... on PullRequest { ${page} }
1297
+ } } }`, { owner: r.owner, name: r.name, number: Number(number), after: cursor || null });
1298
+ const item = need(need(data.repository, errors, `repository ${r.path}`).issueOrPullRequest, errors, `issue ${r.path}#${number}`);
1299
+ const conn = item.comments;
1300
+ return {
1301
+ items: nodes(conn).map(comment),
1302
+ next: conn?.pageInfo?.hasNextPage ? conn.pageInfo.endCursor : null,
1303
+ total: conn?.totalCount ?? null,
1304
+ };
1305
+ }
1306
+
1307
+ // ── Pasted references ──
1308
+
1309
+ /**
1310
+ * What a pasted URL or key points at: `https://github.com/o/r/pull/12`,
1311
+ * `…/issues/12`, or `o/r#12` — which GitHub numbers in one sequence for
1312
+ * issues and pull requests alike, so a key is asked about. Null for what is
1313
+ * not this host's, not a reference, or not there.
1314
+ */
1315
+ async function resolveRef(input) {
1316
+ const text = String(input ?? "").trim();
1317
+ if (!text) return null;
1318
+ const k = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)$/.exec(text);
1319
+ if (!k) {
1320
+ // A URL, with or without its scheme ("github.com/o/r/pull/1").
1321
+ const asUrl = /^https?:\/\//i.test(text) ? text : /^[^\s/#]+\.[^\s/#]+\//.test(text) ? `https://${text}` : null;
1322
+ if (!asUrl) return null;
1323
+ let u;
1324
+ try {
1325
+ u = new URL(asUrl);
1326
+ } catch {
1327
+ return null;
1328
+ }
1329
+ if (u.host.toLowerCase().replace(/^www\./, "") !== webHost.replace(/^www\./, "")) return null;
1330
+ const m = /^\/([^/]+)\/([^/]+)\/(pull|issues)\/(\d+)(?:\/|$)/.exec(u.pathname);
1331
+ if (!m) return null;
1332
+ return { type: m[3] === "issues" ? "issue" : "change", repo: `${m[1]}/${m[2]}`, number: Number(m[4]) };
1333
+ }
1334
+ const { data } = await gql(`query($owner: String!, $name: String!, $number: Int!) {
1335
+ repository(owner: $owner, name: $name) { issueOrPullRequest(number: $number) { __typename } } }`,
1336
+ { owner: k[1], name: k[2], number: Number(k[3]) }).catch((err) => {
1337
+ // Not there (or not visible to this token) is "nothing to open", not
1338
+ // a failure of the panel.
1339
+ if (err instanceof ForgeError && err.kind === "not-found") return { data: null };
1340
+ throw err;
1341
+ });
1342
+ const kind = data?.repository?.issueOrPullRequest?.__typename;
1343
+ if (kind === "PullRequest") return { type: "change", repo: `${k[1]}/${k[2]}`, number: Number(k[3]) };
1344
+ if (kind === "Issue") return { type: "issue", repo: `${k[1]}/${k[2]}`, number: Number(k[3]) };
1345
+ return null;
1346
+ }
1347
+
1348
+ return {
1349
+ allowedOrigins,
1350
+ validate,
1351
+ listRepos,
1352
+ listBranches,
1353
+ getBranch,
1354
+ listChanges,
1355
+ countChanges,
1356
+ getChange,
1357
+ listChangeCommits,
1358
+ listChangePipelines,
1359
+ listChangeFiles,
1360
+ listPipelines,
1361
+ getPipeline,
1362
+ jobLogTail,
1363
+ listIssues,
1364
+ getIssue,
1365
+ listIssueComments,
1366
+ resolveRef,
1367
+ fetchAsset,
1368
+ };
1369
+ }