ccdeck 3.43.4 → 3.45.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/README.md +79 -18
- package/THIRD_PARTY_NOTICES.md +70 -7
- package/bin/cli/commands.js +613 -0
- package/bin/cli/help.js +26 -3
- package/bin/cli/one-shot.js +2 -2
- package/bin/cli/picker.js +86 -0
- package/bin/cli/startup.js +13 -0
- package/bin/deck.js +16 -1
- package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
- package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
- package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
- package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
- package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
- package/dist/web/assets/app-BW3NYUYB.js +318 -0
- package/dist/web/assets/app-C5RY_wZ4.css +1 -0
- package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +2 -1
- package/release-notes.json +52 -0
- package/src/server/account-notify.mjs +268 -9
- package/src/server/account-pick.mjs +160 -0
- package/src/server/account-projects.mjs +55 -15
- package/src/server/account-routes.mjs +47 -4
- package/src/server/account-watch.mjs +107 -4
- package/src/server/activity.mjs +3 -2
- package/src/server/args.mjs +112 -2
- package/src/server/autoswitch-failover.mjs +138 -0
- package/src/server/claude-accounts.mjs +127 -16
- package/src/server/claude-dir.mjs +142 -5
- package/src/server/claude-identity.mjs +23 -0
- package/src/server/cli-lines.mjs +224 -0
- package/src/server/codex-cost-usage.mjs +67 -0
- package/src/server/codex-enrichment.mjs +53 -9
- package/src/server/codex-review-policy.d.mts +4 -0
- package/src/server/codex-review-policy.mjs +11 -0
- package/src/server/codex-skills.mjs +146 -0
- package/src/server/codex-watch.mjs +5 -2
- package/src/server/cswap-auto-choice.mjs +53 -0
- package/src/server/cswap-auto-loop.mjs +100 -9
- package/src/server/deck-client.mjs +112 -0
- package/src/server/deck-prefs.mjs +26 -5
- package/src/server/event-pipeline.mjs +19 -3
- package/src/server/event-routes.mjs +15 -0
- package/src/server/exec.mjs +6 -2
- package/src/server/feature-use.mjs +11 -1
- package/src/server/forge-http.mjs +514 -0
- package/src/server/forge-public-fetch.mjs +228 -0
- package/src/server/forge-routes.mjs +154 -0
- package/src/server/github-api.mjs +1369 -0
- package/src/server/gitlab-api.mjs +1271 -0
- package/src/server/index.mjs +23 -3
- package/src/server/installer.mjs +149 -8
- package/src/server/integrations/cache.mjs +42 -0
- package/src/server/integrations/errors.mjs +167 -0
- package/src/server/integrations/forge.mjs +76 -0
- package/src/server/integrations/jira.mjs +1026 -0
- package/src/server/integrations/providers.mjs +11 -0
- package/src/server/integrations/proxy.mjs +314 -0
- package/src/server/integrations/registry.mjs +142 -0
- package/src/server/integrations/routes.mjs +498 -0
- package/src/server/integrations/store.mjs +226 -0
- package/src/server/lan-deck.mjs +7 -0
- package/src/server/lan-elsewhere.d.mts +30 -0
- package/src/server/lan-elsewhere.mjs +137 -0
- package/src/server/lan-engine.mjs +2 -2
- package/src/server/lan-sync.mjs +3 -3
- package/src/server/lan-wire.mjs +3 -3
- package/src/server/login-guard.mjs +162 -0
- package/src/server/login-service.mjs +3 -0
- package/src/server/org-telemetry.mjs +173 -0
- package/src/server/pinned-build.mjs +8 -0
- package/src/server/prefs-routes.mjs +4 -0
- package/src/server/request-gates.mjs +26 -0
- package/src/server/session-read-gate.mjs +5 -3
- package/src/server/session-summary.mjs +281 -0
- package/src/server/skill-hook.mjs +162 -0
- package/src/server/stop-continue-gate.d.mts +8 -0
- package/src/server/stop-continue-gate.mjs +77 -0
- package/src/server/stop-continue.mjs +544 -0
- package/src/server/stop-failure.d.mts +10 -0
- package/src/server/stop-failure.mjs +97 -0
- package/src/server/swap-log.mjs +19 -10
- package/src/server/tool-audit-report.mjs +5 -3
- package/src/server/tool-audit-routes.mjs +5 -3
- package/src/server/tool-audit-skills.mjs +82 -0
- package/src/server/tool-audit.mjs +53 -11
- package/src/server/traffic-radar-capture.mjs +10 -1
- package/src/server/traffic-radar-monitor.mjs +4 -2
- package/src/server/traffic-radar-routes.mjs +13 -0
- package/src/server/traffic-radar-service.mjs +133 -0
- package/src/server/usage-day.mjs +3 -1
- package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
- package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
- package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
- package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
- package/dist/web/assets/app-BiJ8TfUl.css +0 -1
- package/dist/web/assets/app-DBsNB-2y.js +0 -312
|
@@ -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
|
+
}
|