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,1271 @@
|
|
|
1
|
+
// GitLab, read for the GitHub and GitLab panels (#2179). READ-ONLY: every
|
|
2
|
+
// call here is a REST GET or a GraphQL query — nothing is ever written.
|
|
3
|
+
//
|
|
4
|
+
// The panel copies the screens a GitLab user works in on an instance (the
|
|
5
|
+
// project's Branches page, the Merge requests dashboard, the merge request's
|
|
6
|
+
// own page, Pipelines and Issues) and this file answers in forge-types.ts's
|
|
7
|
+
// shapes. The transport (forge-http.mjs) keeps the token on the instance's
|
|
8
|
+
// origin and names every failure; this file says WHAT to ask.
|
|
9
|
+
//
|
|
10
|
+
// REST FOR LISTS, ONE GRAPHQL QUERY TO FILL THEM. The dashboard's lists come
|
|
11
|
+
// from REST (its filters, its order, its x-total counts). REST lacks what the
|
|
12
|
+
// rows show beside the title — approvals, each reviewer's state, the head
|
|
13
|
+
// pipeline, unresolved threads, the rendered title — so a page is completed
|
|
14
|
+
// with ONE GraphQL query through `projects(fullPaths:)`, whose cost does not
|
|
15
|
+
// grow with the number of projects on the page (aliases per project would hit
|
|
16
|
+
// GitLab's complexity limit of 250 by the fifth project). If that query
|
|
17
|
+
// fails, the rows still draw from REST alone.
|
|
18
|
+
//
|
|
19
|
+
// THE SCHEMA IS ASKED, NOT ASSUMED. Approval rules (approvalsRequired/Left)
|
|
20
|
+
// exist only on GitLab EE, and a reviewer's reviewState only on newer
|
|
21
|
+
// versions; a query naming a field the instance lacks fails whole. The client
|
|
22
|
+
// reads the MergeRequest and reviewer-interaction field lists once (cached)
|
|
23
|
+
// and asks only for what is there. Work items (a task's parent and children)
|
|
24
|
+
// are experimental fields hidden from introspection, so those queries are
|
|
25
|
+
// tried, and retried without the work-item part when the instance refuses it.
|
|
26
|
+
//
|
|
27
|
+
// AHEAD / BEHIND. GitLab has no public bulk endpoint (the Branches page's
|
|
28
|
+
// diverging_commit_counts is a web route that does not take an API token),
|
|
29
|
+
// and the compare API sends every diff (seconds for an old branch). The count
|
|
30
|
+
// is read from the commits API over a revision range (`default..branch` and
|
|
31
|
+
// `branch..default`, by SHA), which lists without diffs: one page of 100
|
|
32
|
+
// answers most branches exactly; past 100, nine one-commit probes at offsets
|
|
33
|
+
// 200…1000 find the hundred and one more page the exact number; 1000 is the
|
|
34
|
+
// cap (GitLab's own 999+). Only non-stale, non-default branches of the page
|
|
35
|
+
// shown are counted, at most four branches and eight requests at once, and a
|
|
36
|
+
// count is cached by the pair of SHAs, which never changes.
|
|
37
|
+
import { createHash } from "node:crypto";
|
|
38
|
+
import { createForgeTransport, epoch, ForgeError, mapLimit } from "./forge-http.mjs";
|
|
39
|
+
|
|
40
|
+
const PAGE = 20;
|
|
41
|
+
const COMMIT_PAGE = 50;
|
|
42
|
+
const FILE_PAGE = 30;
|
|
43
|
+
const COMMENT_PAGE = 50;
|
|
44
|
+
const STALE_MS = 90 * 24 * 60 * 60 * 1000;
|
|
45
|
+
const DIVERGE_CAP = 1000;
|
|
46
|
+
const DIVERGE_REQUESTS = 8;
|
|
47
|
+
const DIVERGE_BRANCHES = 4;
|
|
48
|
+
const DIVERGE_CACHE_MAX = 2000;
|
|
49
|
+
const PROJECT_TTL_MS = 5 * 60_000;
|
|
50
|
+
const CAPS_TTL_MS = 10 * 60_000;
|
|
51
|
+
// A project's pipelines page with commit and user costs ~36 of the 250.
|
|
52
|
+
const PIPELINE_ALIASES_PER_QUERY = 6;
|
|
53
|
+
const RECENT_PROJECTS = 10;
|
|
54
|
+
const CHANGE_PIPELINES = 10;
|
|
55
|
+
const MAX_JOB_PAGES = 5;
|
|
56
|
+
const MAX_DISCUSSION_PAGES = 10;
|
|
57
|
+
|
|
58
|
+
/** Failures that leave a screen drawable without the optional part. A token
|
|
59
|
+
* or network problem is not one of them: the main call would fail too. */
|
|
60
|
+
const SOFT_KINDS = new Set(["upstream", "not-found", "permission", "incompatible"]);
|
|
61
|
+
const isSoft = (err) => err instanceof ForgeError && SOFT_KINDS.has(err.kind);
|
|
62
|
+
|
|
63
|
+
/** A GraphQL query naming a field or argument this instance does not have. */
|
|
64
|
+
const SCHEMA_ERROR = /doesn't exist on type|unknown argument|doesn't accept argument|isn't a defined input type|unknown type/i;
|
|
65
|
+
|
|
66
|
+
const MR = "MergeRequest";
|
|
67
|
+
const IX = "UserMergeRequestInteraction";
|
|
68
|
+
/** What a GitLab of 15+ is taken to have when the schema could not be read. */
|
|
69
|
+
const ASSUMED = {
|
|
70
|
+
[MR]: new Set(["approved", "resolvableDiscussionsCount", "resolvedDiscussionsCount"]),
|
|
71
|
+
[IX]: new Set(["approved"]),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// The tables below are looked up by what GitLab answers with. Without a
|
|
75
|
+
// prototype they find only their own entries: a plain object also answers to
|
|
76
|
+
// `constructor`, `toString` and the other names every object inherits.
|
|
77
|
+
const CI = {
|
|
78
|
+
__proto__: null,
|
|
79
|
+
success: "success",
|
|
80
|
+
failed: "failed",
|
|
81
|
+
running: "running",
|
|
82
|
+
pending: "pending",
|
|
83
|
+
created: "pending",
|
|
84
|
+
waiting_for_resource: "pending",
|
|
85
|
+
preparing: "pending",
|
|
86
|
+
waiting_for_callback: "pending",
|
|
87
|
+
// A delayed job runs on its own when its timer ends — waiting, not manual.
|
|
88
|
+
scheduled: "pending",
|
|
89
|
+
canceled: "canceled",
|
|
90
|
+
canceling: "canceled",
|
|
91
|
+
skipped: "skipped",
|
|
92
|
+
manual: "manual",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** A GitLab pipeline or job status (REST lower case, GraphQL upper case) as
|
|
96
|
+
* the panel's CiState. */
|
|
97
|
+
export function gitlabCiState(status) {
|
|
98
|
+
return CI[String(status ?? "").toLowerCase()] ?? "none";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ACRONYMS = { __proto__: null, api: "API", ci: "CI", ssh: "SSH", url: "URL", id: "ID" };
|
|
102
|
+
|
|
103
|
+
/** GitLab's failure_reason as words: script_failure → "Script failure". */
|
|
104
|
+
export function humanFailureReason(reason) {
|
|
105
|
+
if (typeof reason !== "string" || !reason.trim()) return null;
|
|
106
|
+
const words = reason.trim().split(/_+/).filter(Boolean).map(w => ACRONYMS[w.toLowerCase()] ?? w.toLowerCase());
|
|
107
|
+
if (!words.length) return null;
|
|
108
|
+
const s = words.join(" ");
|
|
109
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `group/sub/project!12`, `…#12` or an epic's `group&12`, split. */
|
|
113
|
+
function splitRef(ref) {
|
|
114
|
+
const m = /^([^!#&\s]+)([!#&])(\d+)$/.exec(String(ref ?? "").trim());
|
|
115
|
+
return m ? { repo: m[1], sigil: m[2], number: Number(m[3]) } : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const gidTail = (gid) => String(gid ?? "").split("/").pop();
|
|
119
|
+
const roundOrNull = (n) => (typeof n === "number" && Number.isFinite(n) ? Math.round(n) : null);
|
|
120
|
+
const hexColor = (c) => (typeof c === "string" && /^#[0-9a-f]{3,8}$/i.test(c) ? c : null);
|
|
121
|
+
const nodesOf = (conn) => (Array.isArray(conn) ? conn : Array.isArray(conn?.nodes) ? conn.nodes : []).filter(Boolean);
|
|
122
|
+
const keyOf = (repo, sigil, number) => `${String(repo).toLowerCase()}${sigil}${number}`;
|
|
123
|
+
const isFailedJob = (j) => String(j?.status ?? "").toLowerCase() === "failed";
|
|
124
|
+
const isBridge = (j) => /\/Ci::Bridge\//.test(String(j?.id ?? ""));
|
|
125
|
+
const sha1 = (s) => createHash("sha1").update(s).digest("hex");
|
|
126
|
+
|
|
127
|
+
/** At most `n` of the calls given to it running at once. */
|
|
128
|
+
function limiter(n) {
|
|
129
|
+
let active = 0;
|
|
130
|
+
const waiting = [];
|
|
131
|
+
return async (fn) => {
|
|
132
|
+
if (active < n) active++;
|
|
133
|
+
else await new Promise(resolve => waiting.push(resolve));
|
|
134
|
+
try {
|
|
135
|
+
return await fn();
|
|
136
|
+
} finally {
|
|
137
|
+
const next = waiting.shift();
|
|
138
|
+
if (next) next();
|
|
139
|
+
else active--;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const USER = "username name avatarUrl webUrl";
|
|
145
|
+
const PIPE = `id iid status source ref sha createdAt startedAt finishedAt duration path commit { title } user { ${USER} }`;
|
|
146
|
+
const JOB = "id name status allowFailure startedAt finishedAt duration webPath stage { name }";
|
|
147
|
+
const PIPE_WITH_JOBS = `${PIPE} stages { nodes { name } } jobs(retried: false, first: 100) { pageInfo { hasNextPage endCursor } nodes { ${JOB} } }`;
|
|
148
|
+
const NOTE = `id system bodyHtml createdAt url author { ${USER} }`;
|
|
149
|
+
const DISCUSSION = `id resolvable resolved notes { nodes { ${NOTE} position { filePath newLine oldLine diffRefs { baseSha headSha } } } }`;
|
|
150
|
+
const ISSUE_REF = "iid title state webUrl reference(full: true) workItemType { name }";
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A client for one GitLab connection.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} opts
|
|
156
|
+
* @param {string} [opts.baseUrl] the instance, with its path when GitLab is installed under one
|
|
157
|
+
* @param {string} opts.token
|
|
158
|
+
* @param {typeof fetch} [opts.fetchImpl]
|
|
159
|
+
* @param {typeof fetch} [opts.publicFetchImpl] a job log's hop off the instance (forge-http.mjs)
|
|
160
|
+
* @param {() => number} [opts.now]
|
|
161
|
+
*/
|
|
162
|
+
export function createGitlabClient({ baseUrl = "https://gitlab.com", token, fetchImpl, publicFetchImpl, now = () => Date.now() } = {}) {
|
|
163
|
+
const baseParsed = new URL(String(baseUrl));
|
|
164
|
+
const origin = baseParsed.origin;
|
|
165
|
+
const prefix = baseParsed.pathname.replace(/\/+$/, "");
|
|
166
|
+
const base = `${origin}${prefix}`;
|
|
167
|
+
const api = `${base}/api/v4`;
|
|
168
|
+
const graphqlUrl = `${base}/api/graphql`;
|
|
169
|
+
const allowedOrigins = [origin];
|
|
170
|
+
const t = createForgeTransport({
|
|
171
|
+
token,
|
|
172
|
+
// Bearer is what GitLab takes from both a personal access token and an
|
|
173
|
+
// OAuth token, so one header serves either.
|
|
174
|
+
authHeaders: (tok) => ({ authorization: `Bearer ${tok}` }),
|
|
175
|
+
allowedOrigins,
|
|
176
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
177
|
+
...(publicFetchImpl ? { publicFetchImpl } : {}),
|
|
178
|
+
now,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ── transport ──────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
const enc = (repo) => encodeURIComponent(repo);
|
|
184
|
+
|
|
185
|
+
function qs(params = {}) {
|
|
186
|
+
const sp = new URLSearchParams();
|
|
187
|
+
for (const [k, v] of Object.entries(params)) {
|
|
188
|
+
if (v == null || v === "") continue;
|
|
189
|
+
if (Array.isArray(v)) for (const x of v) sp.append(`${k}[]`, String(x));
|
|
190
|
+
else sp.append(k, String(v));
|
|
191
|
+
}
|
|
192
|
+
const s = sp.toString();
|
|
193
|
+
return s ? `?${s}` : "";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const rest = (path, params) => t.json(`${api}${path}${qs(params)}`);
|
|
197
|
+
const gql = (query, variables) => t.graphql(graphqlUrl, query, variables);
|
|
198
|
+
|
|
199
|
+
// Avatars and uploads live on the instance — the token may go there, it is
|
|
200
|
+
// the instance it was made on — or, for a user without an uploaded avatar,
|
|
201
|
+
// on Gravatar, which is asked without it.
|
|
202
|
+
const GRAVATAR = new Set(["secure.gravatar.com", "www.gravatar.com", "gravatar.com"]);
|
|
203
|
+
const imageHost = (u) => u.origin === origin || (u.protocol === "https:" && GRAVATAR.has(u.host.toLowerCase()));
|
|
204
|
+
|
|
205
|
+
/** An image on the instance or Gravatar, for the deck to pass on. */
|
|
206
|
+
function fetchAsset(url, { maxBytes, signal } = {}) {
|
|
207
|
+
return t.asset(url, { allow: imageHost, maxBytes, signal });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The first of `queries` this instance accepts: a later one is tried only
|
|
211
|
+
* when an earlier one names a field or argument the schema lacks. */
|
|
212
|
+
async function gqlFirst(queries, variables) {
|
|
213
|
+
for (let i = 0; ; i++) {
|
|
214
|
+
try {
|
|
215
|
+
return await gql(queries[i], variables);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (i >= queries.length - 1 || !(err instanceof ForgeError) || !SCHEMA_ERROR.test(err.message)) throw err;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function optional(promise) {
|
|
223
|
+
try {
|
|
224
|
+
return await promise;
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (isSoft(err)) return null;
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Next page and count from GitLab's x-next-page / x-total. GitLab leaves
|
|
232
|
+
* x-total out past 10,000 rows, and the count is then unknown (null). */
|
|
233
|
+
function pageInfo(headers) {
|
|
234
|
+
const next = headers?.get?.("x-next-page");
|
|
235
|
+
const total = headers?.get?.("x-total");
|
|
236
|
+
const n = total == null || total === "" ? NaN : Number(total);
|
|
237
|
+
return { next: next ? String(next) : null, total: Number.isFinite(n) ? n : null };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function pageParam(cursor) {
|
|
241
|
+
if (cursor == null || cursor === "") return 1;
|
|
242
|
+
if (!/^\d{1,6}$/.test(String(cursor))) throw new ForgeError("upstream", "that cursor is not a page of this list");
|
|
243
|
+
return Number(cursor);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── shared reads, remembered ──────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
let meMemo = null;
|
|
249
|
+
function me() {
|
|
250
|
+
if (!meMemo) {
|
|
251
|
+
meMemo = rest("/user").then(r => r.data);
|
|
252
|
+
meMemo.catch(() => { meMemo = null; });
|
|
253
|
+
}
|
|
254
|
+
return meMemo;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const projectMemo = new Map();
|
|
258
|
+
function projectInfo(repo) {
|
|
259
|
+
const hit = projectMemo.get(repo);
|
|
260
|
+
if (hit && now() - hit.at < PROJECT_TTL_MS) return hit.promise;
|
|
261
|
+
const entry = { at: now(), promise: rest(`/projects/${enc(repo)}`).then(r => r.data) };
|
|
262
|
+
entry.promise.catch(() => { if (projectMemo.get(repo) === entry) projectMemo.delete(repo); });
|
|
263
|
+
projectMemo.set(repo, entry);
|
|
264
|
+
return entry.promise;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let capsMemo = null;
|
|
268
|
+
/** The optional fields this instance has, or null when its schema could
|
|
269
|
+
* not be read (ASSUMED is used then). Never rejects: it is asked for
|
|
270
|
+
* beside a screen's main call, and that call reports a failure. A failure
|
|
271
|
+
* that is not about the schema is not remembered. */
|
|
272
|
+
function caps() {
|
|
273
|
+
if (capsMemo && now() - capsMemo.at < CAPS_TTL_MS) return capsMemo.promise;
|
|
274
|
+
const entry = { at: now(), promise: null };
|
|
275
|
+
const names = (type) => new Set((type?.fields ?? []).map(f => f?.name).filter(Boolean));
|
|
276
|
+
entry.promise = gql(`query { mr: __type(name: "${MR}") { fields { name } } ix: __type(name: "${IX}") { fields { name } } }`)
|
|
277
|
+
.then(({ data }) => ({ [MR]: names(data?.mr), [IX]: names(data?.ix) }), (err) => {
|
|
278
|
+
if (!isSoft(err) && capsMemo === entry) capsMemo = null;
|
|
279
|
+
return null;
|
|
280
|
+
});
|
|
281
|
+
// An answer that cannot be read still rejects: a list with rows that waits
|
|
282
|
+
// for it fails as before. A list without rows never waits, so the
|
|
283
|
+
// rejection must not be left unhandled, which would end the deck's
|
|
284
|
+
// process.
|
|
285
|
+
entry.promise.catch(() => {});
|
|
286
|
+
capsMemo = entry;
|
|
287
|
+
return entry.promise;
|
|
288
|
+
}
|
|
289
|
+
const has = (c, type, field) => (c ? c[type]?.has(field) : ASSUMED[type]?.has(field)) ?? false;
|
|
290
|
+
|
|
291
|
+
// ── mapping ─────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
/** A URL GitLab gave as a path (avatars, webPath) made absolute. Paths of
|
|
294
|
+
* an instance under a prefix already carry the prefix. */
|
|
295
|
+
function abs(u) {
|
|
296
|
+
if (typeof u !== "string" || !u) return null;
|
|
297
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(u)) return u;
|
|
298
|
+
if (u.startsWith("//")) return `${baseParsed.protocol}${u}`;
|
|
299
|
+
if (u.startsWith("/")) return prefix && (u === prefix || u.startsWith(`${prefix}/`)) ? `${origin}${u}` : `${base}${u}`;
|
|
300
|
+
return `${base}/${u}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The project path out of an item's web URL, when it has no reference. */
|
|
304
|
+
function repoFromWebUrl(url) {
|
|
305
|
+
if (typeof url !== "string") return null;
|
|
306
|
+
let path;
|
|
307
|
+
try {
|
|
308
|
+
const u = new URL(url);
|
|
309
|
+
if (u.origin !== origin) return null;
|
|
310
|
+
path = decodeURIComponent(u.pathname);
|
|
311
|
+
} catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
if (prefix) {
|
|
315
|
+
if (!path.startsWith(`${prefix}/`)) return null;
|
|
316
|
+
path = path.slice(prefix.length);
|
|
317
|
+
}
|
|
318
|
+
const m = /^\/(.+?)\/-\//.exec(path);
|
|
319
|
+
return m ? m[1] : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function toUser(u) {
|
|
323
|
+
if (!u || typeof u !== "object") return null;
|
|
324
|
+
const login = u.username ?? u.login;
|
|
325
|
+
if (!login) return null;
|
|
326
|
+
return {
|
|
327
|
+
login,
|
|
328
|
+
name: u.name ?? null,
|
|
329
|
+
avatarUrl: abs(u.avatar_url ?? u.avatarUrl),
|
|
330
|
+
webUrl: abs(u.web_url ?? u.webUrl) ?? `${base}/${login}`,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const users = (list) => nodesOf(list).map(toUser).filter(Boolean);
|
|
334
|
+
|
|
335
|
+
function toLabels(list) {
|
|
336
|
+
return nodesOf(list)
|
|
337
|
+
.map(l => (typeof l === "string" ? { name: l, color: null } : { name: l.name ?? l.title ?? "", color: hexColor(l.color) }))
|
|
338
|
+
.filter(l => l.name);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const changeState = (s) => {
|
|
342
|
+
const v = String(s ?? "").toLowerCase();
|
|
343
|
+
return v === "merged" ? "merged" : v === "closed" ? "closed" : "open";
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
function reviewerState(ix, approved) {
|
|
347
|
+
if (approved || ix?.approved) return "approved";
|
|
348
|
+
switch (ix?.reviewState) {
|
|
349
|
+
case "APPROVED": return "approved";
|
|
350
|
+
case "REQUESTED_CHANGES": return "changes_requested";
|
|
351
|
+
case "REVIEWED": return "commented";
|
|
352
|
+
default: return ix?.reviewed ? "commented" : "pending";
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* What GraphQL adds to a change: the rendered title, approvals, each
|
|
358
|
+
* reviewer's state, threads and the head pipeline. GitLab does not move a
|
|
359
|
+
* reviewer's reviewState when they approve without being asked to review
|
|
360
|
+
* again, so an approval from approvedBy decides "approved" too.
|
|
361
|
+
*/
|
|
362
|
+
function graphExtras(g, meName) {
|
|
363
|
+
const approvedBy = users(g?.approvedBy);
|
|
364
|
+
const approvedNames = new Set(approvedBy.map(u => u.login));
|
|
365
|
+
const ix = new Map(nodesOf(g?.reviewers).map(r => [r.username, r.mergeRequestInteraction ?? null]));
|
|
366
|
+
const resolvable = g?.resolvableDiscussionsCount;
|
|
367
|
+
const resolved = g?.resolvedDiscussionsCount;
|
|
368
|
+
return {
|
|
369
|
+
approvedBy,
|
|
370
|
+
titleHtml: g?.titleHtml ?? null,
|
|
371
|
+
approved: g ? (typeof g.approved === "boolean" ? g.approved : approvedBy.length > 0) : false,
|
|
372
|
+
approvedByMe: !!meName && approvedNames.has(meName),
|
|
373
|
+
approvalsRequired: typeof g?.approvalsRequired === "number" ? g.approvalsRequired : null,
|
|
374
|
+
approvalsLeft: typeof g?.approvalsLeft === "number" ? g.approvalsLeft : null,
|
|
375
|
+
unresolvedThreads: typeof resolvable === "number" && typeof resolved === "number" ? Math.max(0, resolvable - resolved) : null,
|
|
376
|
+
ci: g?.headPipeline ? { state: gitlabCiState(g.headPipeline.status), webUrl: abs(g.headPipeline.path) } : null,
|
|
377
|
+
reviewer: (u) => ({ ...u, state: reviewerState(ix.get(u.login), approvedNames.has(u.login)) }),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** A merge request row: REST `mr`, completed by GraphQL `g` when it came. */
|
|
382
|
+
function toChange(mr, repo, g, meName) {
|
|
383
|
+
const x = graphExtras(g, meName);
|
|
384
|
+
return {
|
|
385
|
+
kind: "gitlab",
|
|
386
|
+
key: `${repo}!${mr.iid}`,
|
|
387
|
+
number: Number(mr.iid),
|
|
388
|
+
repo,
|
|
389
|
+
title: mr.title ?? "",
|
|
390
|
+
titleHtml: x.titleHtml,
|
|
391
|
+
state: changeState(mr.state),
|
|
392
|
+
draft: !!(mr.draft ?? mr.work_in_progress),
|
|
393
|
+
webUrl: mr.web_url ?? `${base}/${repo}/-/merge_requests/${mr.iid}`,
|
|
394
|
+
author: toUser(mr.author),
|
|
395
|
+
assignees: users(mr.assignees),
|
|
396
|
+
reviewers: users(mr.reviewers).map(x.reviewer),
|
|
397
|
+
sourceBranch: mr.source_branch ?? "",
|
|
398
|
+
targetBranch: mr.target_branch ?? "",
|
|
399
|
+
createdAt: epoch(mr.created_at),
|
|
400
|
+
updatedAt: epoch(mr.updated_at),
|
|
401
|
+
mergedAt: epoch(mr.merged_at),
|
|
402
|
+
closedAt: epoch(mr.closed_at),
|
|
403
|
+
comments: mr.user_notes_count ?? 0,
|
|
404
|
+
unresolvedThreads: x.unresolvedThreads,
|
|
405
|
+
approved: x.approved,
|
|
406
|
+
approvedByMe: x.approvedByMe,
|
|
407
|
+
approvalsRequired: x.approvalsRequired,
|
|
408
|
+
approvalsLeft: x.approvalsLeft,
|
|
409
|
+
ci: x.ci,
|
|
410
|
+
labels: toLabels(mr.labels),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function toComment(n) {
|
|
415
|
+
return {
|
|
416
|
+
id: gidTail(n.id),
|
|
417
|
+
author: toUser(n.author),
|
|
418
|
+
bodyHtml: n.bodyHtml ?? "",
|
|
419
|
+
createdAt: epoch(n.createdAt),
|
|
420
|
+
webUrl: abs(n.url),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function toCommit(c, repo) {
|
|
425
|
+
const sha = c?.id ?? "";
|
|
426
|
+
return {
|
|
427
|
+
sha,
|
|
428
|
+
shortSha: c?.short_id ?? sha.slice(0, 8),
|
|
429
|
+
title: c?.title ?? "",
|
|
430
|
+
authorName: c?.author_name ?? null,
|
|
431
|
+
authoredAt: epoch(c?.authored_date),
|
|
432
|
+
webUrl: c?.web_url ?? (sha ? `${base}/${repo}/-/commit/${sha}` : null),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function toPipeline(n, repo) {
|
|
437
|
+
const id = gidTail(n.id);
|
|
438
|
+
const sha = n.sha ?? "";
|
|
439
|
+
return {
|
|
440
|
+
id,
|
|
441
|
+
repo,
|
|
442
|
+
ref: n.ref ?? "",
|
|
443
|
+
sha,
|
|
444
|
+
shortSha: sha.slice(0, 8),
|
|
445
|
+
title: n.commit?.title ?? null,
|
|
446
|
+
state: gitlabCiState(n.status),
|
|
447
|
+
source: n.source ?? null,
|
|
448
|
+
triggeredBy: toUser(n.user),
|
|
449
|
+
createdAt: epoch(n.createdAt),
|
|
450
|
+
startedAt: epoch(n.startedAt),
|
|
451
|
+
finishedAt: epoch(n.finishedAt),
|
|
452
|
+
durationSec: roundOrNull(n.duration),
|
|
453
|
+
webUrl: abs(n.path) ?? `${base}/${repo}/-/pipelines/${id}`,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function toJob(j, reasons) {
|
|
458
|
+
const id = gidTail(j.id);
|
|
459
|
+
const failed = isFailedJob(j);
|
|
460
|
+
return {
|
|
461
|
+
id,
|
|
462
|
+
name: j.name ?? "",
|
|
463
|
+
stage: j.stage?.name ?? null,
|
|
464
|
+
state: gitlabCiState(j.status),
|
|
465
|
+
allowFailure: !!j.allowFailure,
|
|
466
|
+
startedAt: epoch(j.startedAt),
|
|
467
|
+
finishedAt: epoch(j.finishedAt),
|
|
468
|
+
durationSec: roundOrNull(j.duration),
|
|
469
|
+
webUrl: abs(j.webPath),
|
|
470
|
+
failureReason: failed ? humanFailureReason(reasons.get(id)) : null,
|
|
471
|
+
annotations: [],
|
|
472
|
+
// A bridge triggers a downstream pipeline and has no log of its own.
|
|
473
|
+
logAvailable: !isBridge(j),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function toIssueRef(n) {
|
|
478
|
+
const ref = splitRef(n?.reference);
|
|
479
|
+
return {
|
|
480
|
+
key: n?.reference ?? `#${n?.iid}`,
|
|
481
|
+
number: Number(n?.iid),
|
|
482
|
+
repo: ref?.repo ?? "",
|
|
483
|
+
title: n?.title ?? "",
|
|
484
|
+
state: String(n?.state ?? "").toUpperCase() === "CLOSED" ? "closed" : "open",
|
|
485
|
+
webUrl: abs(n?.webUrl) ?? "",
|
|
486
|
+
type: n?.workItemType?.name ?? null,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** A work item's parent and child tasks, from its HIERARCHY widget. */
|
|
491
|
+
function hierarchyOf(wi) {
|
|
492
|
+
const w = (wi?.widgets ?? []).find(x => x && (x.type === "HIERARCHY" || "children" in x || "parent" in x));
|
|
493
|
+
const nodes = nodesOf(w?.children);
|
|
494
|
+
const total = typeof w?.children?.count === "number" ? w.children.count : nodes.length;
|
|
495
|
+
return {
|
|
496
|
+
parent: w?.parent ? toIssueRef(w.parent) : null,
|
|
497
|
+
children: total > 0
|
|
498
|
+
? { total, completed: nodes.filter(n => String(n.state ?? "").toUpperCase() === "CLOSED").length }
|
|
499
|
+
: null,
|
|
500
|
+
childItems: nodes.filter(n => n.iid != null && n.title != null).map(toIssueRef),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function toIssue(i, repo, g, wi) {
|
|
505
|
+
const h = hierarchyOf(wi);
|
|
506
|
+
return {
|
|
507
|
+
kind: "gitlab",
|
|
508
|
+
key: `${repo}#${i.iid}`,
|
|
509
|
+
number: Number(i.iid),
|
|
510
|
+
repo,
|
|
511
|
+
title: i.title ?? "",
|
|
512
|
+
titleHtml: g?.titleHtml ?? null,
|
|
513
|
+
state: i.state === "closed" ? "closed" : "open",
|
|
514
|
+
stateReason: i._links?.closed_as_duplicate_of ? "duplicate" : null,
|
|
515
|
+
webUrl: i.web_url ?? `${base}/${repo}/-/issues/${i.iid}`,
|
|
516
|
+
author: toUser(i.author),
|
|
517
|
+
assignees: users(i.assignees),
|
|
518
|
+
labels: toLabels(i.labels),
|
|
519
|
+
milestone: i.milestone ? { title: i.milestone.title ?? "", webUrl: abs(i.milestone.web_url) } : null,
|
|
520
|
+
createdAt: epoch(i.created_at),
|
|
521
|
+
updatedAt: epoch(i.updated_at),
|
|
522
|
+
closedAt: epoch(i.closed_at),
|
|
523
|
+
comments: i.user_notes_count ?? 0,
|
|
524
|
+
children: h.children,
|
|
525
|
+
parent: h.parent,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const rowRepo = (item, sigil, fallback) => {
|
|
530
|
+
const ref = splitRef(item?.references?.full);
|
|
531
|
+
return (ref && ref.sigil === sigil ? ref.repo : null) ?? repoFromWebUrl(item?.web_url) ?? fallback ?? "";
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
// ── merge requests ──────────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
/** The GraphQL fields a change row is completed with, as far as this
|
|
537
|
+
* instance has them. */
|
|
538
|
+
function changeFields(c) {
|
|
539
|
+
const ix = ["approved", "reviewed", "reviewState"].filter(f => has(c, IX, f));
|
|
540
|
+
return [
|
|
541
|
+
has(c, MR, "approved") && "approved",
|
|
542
|
+
`approvedBy { nodes { ${USER} } }`,
|
|
543
|
+
`reviewers { nodes { ${USER}${ix.length ? ` mergeRequestInteraction { ${ix.join(" ")} }` : ""} } }`,
|
|
544
|
+
"headPipeline { status path }",
|
|
545
|
+
...["resolvableDiscussionsCount", "resolvedDiscussionsCount", "approvalsRequired", "approvalsLeft"].filter(f => has(c, MR, f)),
|
|
546
|
+
].filter(Boolean).join(" ");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** One GraphQL query for a page of merge requests from any projects. Every
|
|
550
|
+
* project is asked for the page's iids and the answer is matched by
|
|
551
|
+
* project and iid, so a project answering for an iid it shares with
|
|
552
|
+
* another is harmless. */
|
|
553
|
+
async function enrichChanges(rows, capsPromise) {
|
|
554
|
+
const byKey = new Map();
|
|
555
|
+
if (!rows.length) return { byKey, meName: null };
|
|
556
|
+
const c = await capsPromise;
|
|
557
|
+
const paths = [...new Set(rows.map(r => r.repo))];
|
|
558
|
+
const iids = [...new Set(rows.map(r => String(r.number)))];
|
|
559
|
+
const res = await optional(gql(
|
|
560
|
+
`query($paths: [String!], $iids: [String!], $n: Int) { currentUser { username } projects(fullPaths: $paths, first: $n) { nodes { fullPath mergeRequests(iids: $iids, first: 100) { nodes { iid titleHtml ${changeFields(c)} } } } } }`,
|
|
561
|
+
{ paths, iids, n: paths.length },
|
|
562
|
+
));
|
|
563
|
+
for (const p of nodesOf(res?.data?.projects)) {
|
|
564
|
+
for (const m of nodesOf(p.mergeRequests)) byKey.set(keyOf(p.fullPath, "!", m.iid), m);
|
|
565
|
+
}
|
|
566
|
+
return { byKey, meName: res?.data?.currentUser?.username ?? null };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function filterParams(filter) {
|
|
570
|
+
if (filter === "mine") return { scope: "all", author_username: (await me()).username };
|
|
571
|
+
if (filter === "review") return { scope: "all", reviewer_username: (await me()).username };
|
|
572
|
+
return { scope: "all" };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const STATE_PARAM = { __proto__: null, open: "opened", merged: "merged", closed: "closed" };
|
|
576
|
+
const changesPath = (repo) => (repo ? `/projects/${enc(repo)}/merge_requests` : "/merge_requests");
|
|
577
|
+
|
|
578
|
+
async function listChanges({ repo = null, filter = "all", state = "open", sort = "created", cursor } = {}) {
|
|
579
|
+
const page = pageParam(cursor);
|
|
580
|
+
const capsPromise = caps();
|
|
581
|
+
const params = {
|
|
582
|
+
...(await filterParams(filter)),
|
|
583
|
+
state: STATE_PARAM[state] ?? "opened",
|
|
584
|
+
order_by: sort === "updated" ? "updated_at" : "created_at",
|
|
585
|
+
sort: "desc",
|
|
586
|
+
with_labels_details: true,
|
|
587
|
+
per_page: PAGE,
|
|
588
|
+
page,
|
|
589
|
+
};
|
|
590
|
+
const { data, headers } = await rest(changesPath(repo), params);
|
|
591
|
+
const rows = (Array.isArray(data) ? data : []).map(mr => ({ mr, repo: rowRepo(mr, "!", repo), number: mr.iid }));
|
|
592
|
+
const { byKey, meName } = await enrichChanges(rows, capsPromise);
|
|
593
|
+
return {
|
|
594
|
+
items: rows.map(r => toChange(r.mr, r.repo, byKey.get(keyOf(r.repo, "!", r.number)), meName)),
|
|
595
|
+
...pageInfo(headers),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function countChanges({ repo = null, filter = "all" } = {}) {
|
|
600
|
+
const params = await filterParams(filter);
|
|
601
|
+
const count = (state) => rest(changesPath(repo), { ...params, state, per_page: 1 }).then(r => pageInfo(r.headers).total);
|
|
602
|
+
const [open, merged, closed] = await Promise.all([count("opened"), count("merged"), count("closed")]);
|
|
603
|
+
return { open, merged, closed };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function getChange(repo, number) {
|
|
607
|
+
const c = await caps();
|
|
608
|
+
const detail = `iid title titleHtml descriptionHtml state draft webUrl sourceBranch targetBranch
|
|
609
|
+
createdAt updatedAt mergedAt closedAt userNotesCount commitCount diffRefs { baseSha headSha }
|
|
610
|
+
author { ${USER} } assignees { nodes { ${USER} } } participants(first: 100) { nodes { ${USER} } }
|
|
611
|
+
labels(first: 100) { nodes { title color } } milestone { title webPath }
|
|
612
|
+
pipelines { count } diffStatsSummary { fileCount } ${changeFields(c)}`;
|
|
613
|
+
const { data } = await gql(
|
|
614
|
+
`query($p: ID!, $iid: String!) { currentUser { username } project(fullPath: $p) { fullPath mergeRequest(iid: $iid) { ${detail} discussions(first: 100) { pageInfo { hasNextPage endCursor } nodes { ${DISCUSSION} } } } } }`,
|
|
615
|
+
{ p: repo, iid: String(number) },
|
|
616
|
+
);
|
|
617
|
+
const mr = data?.project?.mergeRequest;
|
|
618
|
+
if (!mr) throw new ForgeError("not-found", `merge request ${repo}!${number} was not found`);
|
|
619
|
+
const path = data.project.fullPath ?? repo;
|
|
620
|
+
|
|
621
|
+
let discussions = nodesOf(mr.discussions);
|
|
622
|
+
let info = mr.discussions?.pageInfo;
|
|
623
|
+
for (let i = 1; info?.hasNextPage && info.endCursor && i < MAX_DISCUSSION_PAGES; i++) {
|
|
624
|
+
const more = await gql(
|
|
625
|
+
`query($p: ID!, $iid: String!, $after: String) { project(fullPath: $p) { mergeRequest(iid: $iid) { discussions(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { ${DISCUSSION} } } } } }`,
|
|
626
|
+
{ p: repo, iid: String(number), after: info.endCursor },
|
|
627
|
+
);
|
|
628
|
+
const conn = more.data?.project?.mergeRequest?.discussions;
|
|
629
|
+
if (!conn) break;
|
|
630
|
+
discussions = discussions.concat(nodesOf(conn));
|
|
631
|
+
info = conn.pageInfo;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const refs = mr.diffRefs;
|
|
635
|
+
const outdated = (pos) => !!(pos?.diffRefs?.headSha && refs?.headSha
|
|
636
|
+
&& (pos.diffRefs.headSha !== refs.headSha || (pos.diffRefs.baseSha && refs.baseSha && pos.diffRefs.baseSha !== refs.baseSha)));
|
|
637
|
+
const threads = [];
|
|
638
|
+
const generalComments = [];
|
|
639
|
+
for (const d of discussions) {
|
|
640
|
+
const notes = nodesOf(d.notes).filter(n => !n.system);
|
|
641
|
+
if (!notes.length) continue;
|
|
642
|
+
const pos = notes.find(n => n.position)?.position ?? null;
|
|
643
|
+
if (d.resolvable || pos) {
|
|
644
|
+
threads.push({
|
|
645
|
+
id: gidTail(d.id),
|
|
646
|
+
resolvable: !!d.resolvable,
|
|
647
|
+
resolved: !!d.resolved,
|
|
648
|
+
path: pos?.filePath ?? null,
|
|
649
|
+
line: pos ? (pos.newLine ?? pos.oldLine ?? null) : null,
|
|
650
|
+
outdated: outdated(pos),
|
|
651
|
+
comments: notes.map(toComment),
|
|
652
|
+
webUrl: abs(notes[0].url),
|
|
653
|
+
});
|
|
654
|
+
} else {
|
|
655
|
+
generalComments.push(...notes.map(toComment));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const x = graphExtras(mr, data?.currentUser?.username ?? null);
|
|
660
|
+
return {
|
|
661
|
+
kind: "gitlab",
|
|
662
|
+
key: `${path}!${mr.iid}`,
|
|
663
|
+
number: Number(mr.iid),
|
|
664
|
+
repo: path,
|
|
665
|
+
title: mr.title ?? "",
|
|
666
|
+
titleHtml: x.titleHtml,
|
|
667
|
+
state: changeState(mr.state),
|
|
668
|
+
draft: !!mr.draft,
|
|
669
|
+
webUrl: abs(mr.webUrl) ?? `${base}/${path}/-/merge_requests/${mr.iid}`,
|
|
670
|
+
author: toUser(mr.author),
|
|
671
|
+
assignees: users(mr.assignees),
|
|
672
|
+
reviewers: users(mr.reviewers).map(x.reviewer),
|
|
673
|
+
sourceBranch: mr.sourceBranch ?? "",
|
|
674
|
+
targetBranch: mr.targetBranch ?? "",
|
|
675
|
+
createdAt: epoch(mr.createdAt),
|
|
676
|
+
updatedAt: epoch(mr.updatedAt),
|
|
677
|
+
mergedAt: epoch(mr.mergedAt),
|
|
678
|
+
closedAt: epoch(mr.closedAt),
|
|
679
|
+
comments: mr.userNotesCount ?? 0,
|
|
680
|
+
unresolvedThreads: x.unresolvedThreads,
|
|
681
|
+
approved: x.approved,
|
|
682
|
+
approvedByMe: x.approvedByMe,
|
|
683
|
+
approvalsRequired: x.approvalsRequired,
|
|
684
|
+
approvalsLeft: x.approvalsLeft,
|
|
685
|
+
ci: x.ci,
|
|
686
|
+
labels: toLabels(mr.labels),
|
|
687
|
+
descriptionHtml: mr.descriptionHtml ?? "",
|
|
688
|
+
milestone: mr.milestone ? { title: mr.milestone.title ?? "", webUrl: abs(mr.milestone.webPath) } : null,
|
|
689
|
+
participants: users(mr.participants),
|
|
690
|
+
approvedBy: x.approvedBy,
|
|
691
|
+
// The Overview tab's own number is the user notes count.
|
|
692
|
+
counts: {
|
|
693
|
+
discussions: mr.userNotesCount ?? 0,
|
|
694
|
+
commits: mr.commitCount ?? 0,
|
|
695
|
+
pipelines: mr.pipelines?.count ?? 0,
|
|
696
|
+
files: mr.diffStatsSummary?.fileCount ?? 0,
|
|
697
|
+
},
|
|
698
|
+
threads,
|
|
699
|
+
generalComments,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function listChangeCommits(repo, number, { cursor } = {}) {
|
|
704
|
+
const { data, headers } = await rest(`/projects/${enc(repo)}/merge_requests/${number}/commits`, {
|
|
705
|
+
per_page: COMMIT_PAGE, page: pageParam(cursor),
|
|
706
|
+
});
|
|
707
|
+
return { items: (Array.isArray(data) ? data : []).map(c => toCommit(c, repo)), ...pageInfo(headers) };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* The Changes tab without its diff: the file list from REST /diffs (which
|
|
712
|
+
* says new, renamed, deleted) with the line counts from GraphQL diffStats.
|
|
713
|
+
* A file's link is GitLab's own anchor on the diffs page: the SHA-1 of the
|
|
714
|
+
* file's path (Gitlab::Diff::File#file_hash).
|
|
715
|
+
*/
|
|
716
|
+
async function listChangeFiles(repo, number, { cursor } = {}) {
|
|
717
|
+
const page = pageParam(cursor);
|
|
718
|
+
const mrUrl = `${base}/${repo}/-/merge_requests/${number}`;
|
|
719
|
+
const statsPromise = optional(gql(
|
|
720
|
+
"query($p: ID!, $iid: String!) { project(fullPath: $p) { mergeRequest(iid: $iid) { diffStats { path additions deletions } } } }",
|
|
721
|
+
{ p: repo, iid: String(number) },
|
|
722
|
+
));
|
|
723
|
+
// Awaited only once /diffs has answered: a hard failure of the stats that
|
|
724
|
+
// comes back first must not be unhandled meanwhile, which would end the
|
|
725
|
+
// deck's process. The await below still fails the read with it.
|
|
726
|
+
statsPromise.catch(() => {});
|
|
727
|
+
const file = (path, oldPath, status, s) => ({
|
|
728
|
+
path,
|
|
729
|
+
oldPath,
|
|
730
|
+
status,
|
|
731
|
+
additions: s?.additions ?? 0,
|
|
732
|
+
deletions: s?.deletions ?? 0,
|
|
733
|
+
webUrl: `${mrUrl}/diffs#${sha1(path)}`,
|
|
734
|
+
});
|
|
735
|
+
let res;
|
|
736
|
+
try {
|
|
737
|
+
res = await rest(`/projects/${enc(repo)}/merge_requests/${number}/diffs`, { per_page: FILE_PAGE, page });
|
|
738
|
+
} catch (err) {
|
|
739
|
+
// GitLab before 15.7 has no /diffs: list the files from diffStats alone.
|
|
740
|
+
const list = (await statsPromise)?.data?.project?.mergeRequest?.diffStats;
|
|
741
|
+
if (!(err instanceof ForgeError && err.kind === "not-found" && Array.isArray(list))) throw err;
|
|
742
|
+
const slice = list.slice((page - 1) * FILE_PAGE, page * FILE_PAGE);
|
|
743
|
+
return {
|
|
744
|
+
items: slice.map(s => file(s.path, null, "modified", s)),
|
|
745
|
+
next: page * FILE_PAGE < list.length ? String(page + 1) : null,
|
|
746
|
+
total: list.length,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
const stats = new Map(((await statsPromise)?.data?.project?.mergeRequest?.diffStats ?? []).map(s => [s.path, s]));
|
|
750
|
+
const items = (Array.isArray(res.data) ? res.data : []).map(d => {
|
|
751
|
+
const path = d.new_path || d.old_path || "";
|
|
752
|
+
const status = d.new_file ? "added" : d.deleted_file ? "deleted" : d.renamed_file ? "renamed" : "modified";
|
|
753
|
+
return file(path, d.renamed_file ? d.old_path ?? null : null, status, stats.get(path) ?? stats.get(d.old_path));
|
|
754
|
+
});
|
|
755
|
+
return { items, ...pageInfo(res.headers) };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ── pipelines ───────────────────────────────────────────────────────────
|
|
759
|
+
|
|
760
|
+
/** A pipeline node with its jobs as PipelineDetail. GraphQL gives the jobs
|
|
761
|
+
* in their stages; only REST says why a job failed (failure_reason), so a
|
|
762
|
+
* pipeline with failed jobs asks REST for those. */
|
|
763
|
+
async function pipelineDetail(repo, node) {
|
|
764
|
+
let jobs = nodesOf(node.jobs);
|
|
765
|
+
let info = node.jobs?.pageInfo;
|
|
766
|
+
for (let i = 1; info?.hasNextPage && info.endCursor && i < MAX_JOB_PAGES; i++) {
|
|
767
|
+
const more = await gql(
|
|
768
|
+
`query($p: ID!, $id: CiPipelineID!, $after: String) { project(fullPath: $p) { pipeline(id: $id) { jobs(retried: false, first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { ${JOB} } } } } }`,
|
|
769
|
+
{ p: repo, id: node.id, after: info.endCursor },
|
|
770
|
+
);
|
|
771
|
+
const conn = more.data?.project?.pipeline?.jobs;
|
|
772
|
+
if (!conn) break;
|
|
773
|
+
jobs = jobs.concat(nodesOf(conn));
|
|
774
|
+
info = conn.pageInfo;
|
|
775
|
+
}
|
|
776
|
+
const pid = gidTail(node.id);
|
|
777
|
+
const asks = [];
|
|
778
|
+
if (jobs.some(j => isFailedJob(j) && !isBridge(j))) asks.push(`/projects/${enc(repo)}/pipelines/${pid}/jobs`);
|
|
779
|
+
if (jobs.some(j => isFailedJob(j) && isBridge(j))) asks.push(`/projects/${enc(repo)}/pipelines/${pid}/bridges`);
|
|
780
|
+
const reasons = new Map();
|
|
781
|
+
for (const r of await Promise.all(asks.map(p => optional(rest(p, { scope: ["failed"], per_page: 100 }))))) {
|
|
782
|
+
for (const j of Array.isArray(r?.data) ? r.data : []) if (j?.failure_reason) reasons.set(String(j.id), j.failure_reason);
|
|
783
|
+
}
|
|
784
|
+
const order = new Map(nodesOf(node.stages).map((s, i) => [s.name, i]));
|
|
785
|
+
const at = (j) => order.get(j.stage?.name) ?? Number.MAX_SAFE_INTEGER;
|
|
786
|
+
jobs.sort((a, b) => at(a) - at(b) || String(a.name ?? "").localeCompare(String(b.name ?? ""), undefined, { numeric: true }));
|
|
787
|
+
return { ...toPipeline(node, repo), jobs: jobs.map(j => toJob(j, reasons)) };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function getPipeline(repo, id) {
|
|
791
|
+
const { data } = await gql(
|
|
792
|
+
`query($p: ID!, $id: CiPipelineID!) { project(fullPath: $p) { pipeline(id: $id) { ${PIPE_WITH_JOBS} } } }`,
|
|
793
|
+
{ p: repo, id: `gid://gitlab/Ci::Pipeline/${id}` },
|
|
794
|
+
);
|
|
795
|
+
const node = data?.project?.pipeline;
|
|
796
|
+
if (!node) throw new ForgeError("not-found", `pipeline ${id} was not found in ${repo}`);
|
|
797
|
+
return pipelineDetail(repo, node);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async function listChangePipelines(repo, number) {
|
|
801
|
+
const { data } = await gql(
|
|
802
|
+
`query($p: ID!, $iid: String!) { project(fullPath: $p) { mergeRequest(iid: $iid) { pipelines(first: ${CHANGE_PIPELINES}) { nodes { ${PIPE_WITH_JOBS} } } } } }`,
|
|
803
|
+
{ p: repo, iid: String(number) },
|
|
804
|
+
);
|
|
805
|
+
const mr = data?.project?.mergeRequest;
|
|
806
|
+
if (!mr) throw new ForgeError("not-found", `merge request ${repo}!${number} was not found`);
|
|
807
|
+
return mapLimit(nodesOf(mr.pipelines), 4, n => pipelineDetail(repo, n));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** One project's pipelines: GraphQL, which has the commit's title, who ran
|
|
811
|
+
* it and how long it took in the one call REST would need three for. */
|
|
812
|
+
async function listProjectPipelines(repo, cursor) {
|
|
813
|
+
const { data } = await gql(
|
|
814
|
+
`query($p: ID!, $after: String) { project(fullPath: $p) { pipelines(first: ${PAGE}, after: $after) { count pageInfo { hasNextPage endCursor } nodes { ${PIPE} } } } }`,
|
|
815
|
+
{ p: repo, after: cursor ?? null },
|
|
816
|
+
);
|
|
817
|
+
const project = data?.project;
|
|
818
|
+
if (!project) throw new ForgeError("not-found", `project ${repo} was not found`);
|
|
819
|
+
const conn = project.pipelines;
|
|
820
|
+
return {
|
|
821
|
+
items: nodesOf(conn).map(n => toPipeline(n, repo)),
|
|
822
|
+
next: conn?.pageInfo?.hasNextPage ? conn.pageInfo.endCursor ?? null : null,
|
|
823
|
+
total: typeof conn?.count === "number" ? conn.count : null,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// The "all projects" cursor: `m` then, per project still able to give
|
|
828
|
+
// more, `:<project id>.<its GraphQL cursor, empty when from the start>`.
|
|
829
|
+
// Project ids, not paths, keep it inside the 512 characters a route takes.
|
|
830
|
+
function encodeMergedCursor(entries) {
|
|
831
|
+
return entries.length ? `m${entries.map(e => `:${e.id}.${e.after ?? ""}`).join("")}` : null;
|
|
832
|
+
}
|
|
833
|
+
function decodeMergedCursor(cursor) {
|
|
834
|
+
const parts = String(cursor).split(":");
|
|
835
|
+
if (parts[0] !== "m") throw new ForgeError("upstream", "that cursor is not one of this list");
|
|
836
|
+
return parts.slice(1).map(p => {
|
|
837
|
+
const dot = p.indexOf(".");
|
|
838
|
+
const id = dot < 0 ? p : p.slice(0, dot);
|
|
839
|
+
if (!/^\d+$/.test(id)) throw new ForgeError("upstream", "that cursor is not one of this list");
|
|
840
|
+
return { id, after: dot < 0 ? null : p.slice(dot + 1) || null };
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* The latest pipelines of the user's most recently active projects, merged
|
|
846
|
+
* by time. Each project is read a page at a time after its own cursor and
|
|
847
|
+
* the pages are merged newest first; a page is only as long as every
|
|
848
|
+
* project still able to give more has rows left to compare, so the order
|
|
849
|
+
* holds across pages.
|
|
850
|
+
*/
|
|
851
|
+
async function listRecentPipelines(cursor) {
|
|
852
|
+
let projects;
|
|
853
|
+
if (!cursor) {
|
|
854
|
+
const { data } = await rest("/projects", {
|
|
855
|
+
membership: true, archived: false, order_by: "last_activity_at", sort: "desc", simple: true, per_page: RECENT_PROJECTS,
|
|
856
|
+
});
|
|
857
|
+
projects = (Array.isArray(data) ? data : []).map(p => ({ id: String(p.id), path: p.path_with_namespace, after: null }));
|
|
858
|
+
} else {
|
|
859
|
+
const wanted = decodeMergedCursor(cursor);
|
|
860
|
+
if (!wanted.length) return { items: [], next: null, total: null };
|
|
861
|
+
const { data } = await gql(
|
|
862
|
+
"query($ids: [ID!], $n: Int) { projects(ids: $ids, first: $n) { nodes { id fullPath } } }",
|
|
863
|
+
{ ids: wanted.map(w => `gid://gitlab/Project/${w.id}`), n: wanted.length },
|
|
864
|
+
);
|
|
865
|
+
const paths = new Map(nodesOf(data?.projects).map(n => [gidTail(n.id), n.fullPath]));
|
|
866
|
+
projects = wanted.filter(w => paths.has(w.id)).map(w => ({ ...w, path: paths.get(w.id) }));
|
|
867
|
+
}
|
|
868
|
+
if (!projects.length) return { items: [], next: null, total: null };
|
|
869
|
+
|
|
870
|
+
const chunks = [];
|
|
871
|
+
for (let i = 0; i < projects.length; i += PIPELINE_ALIASES_PER_QUERY) chunks.push(projects.slice(i, i + PIPELINE_ALIASES_PER_QUERY));
|
|
872
|
+
const answers = await Promise.all(chunks.map(chunk => {
|
|
873
|
+
const decl = [];
|
|
874
|
+
const vars = {};
|
|
875
|
+
const sel = chunk.map((p, i) => {
|
|
876
|
+
decl.push(`$p${i}: ID!`, `$c${i}: String`);
|
|
877
|
+
vars[`p${i}`] = p.path;
|
|
878
|
+
vars[`c${i}`] = p.after;
|
|
879
|
+
return `a${i}: project(fullPath: $p${i}) { pipelines(first: ${PAGE}, after: $c${i}) { pageInfo { hasNextPage } edges { cursor node { ${PIPE} } } } }`;
|
|
880
|
+
});
|
|
881
|
+
return gql(`query(${decl.join(", ")}) { ${sel.join(" ")} }`, vars).then(r => chunk.map((_, i) => r.data?.[`a${i}`]?.pipelines ?? null));
|
|
882
|
+
}));
|
|
883
|
+
const buffers = answers.flat().map((conn, i) => ({
|
|
884
|
+
project: projects[i],
|
|
885
|
+
edges: (conn?.edges ?? []).filter(e => e?.node),
|
|
886
|
+
more: !!conn?.pageInfo?.hasNextPage,
|
|
887
|
+
taken: 0,
|
|
888
|
+
}));
|
|
889
|
+
|
|
890
|
+
const newer = (a, b) => {
|
|
891
|
+
const ta = epoch(a.createdAt) ?? 0;
|
|
892
|
+
const tb = epoch(b.createdAt) ?? 0;
|
|
893
|
+
return ta !== tb ? ta > tb : Number(gidTail(a.id)) > Number(gidTail(b.id));
|
|
894
|
+
};
|
|
895
|
+
const items = [];
|
|
896
|
+
while (items.length < PAGE) {
|
|
897
|
+
let best = null;
|
|
898
|
+
let blocked = false;
|
|
899
|
+
for (const b of buffers) {
|
|
900
|
+
if (b.taken >= b.edges.length) {
|
|
901
|
+
if (b.more) blocked = true;
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
if (!best || newer(b.edges[b.taken].node, best.edges[best.taken].node)) best = b;
|
|
905
|
+
}
|
|
906
|
+
if (!best || blocked) break;
|
|
907
|
+
items.push(toPipeline(best.edges[best.taken].node, best.project.path));
|
|
908
|
+
best.taken++;
|
|
909
|
+
}
|
|
910
|
+
const left = buffers
|
|
911
|
+
.filter(b => b.taken < b.edges.length || b.more)
|
|
912
|
+
.map(b => ({ id: b.project.id, after: b.taken > 0 ? b.edges[b.taken - 1].cursor : b.project.after }));
|
|
913
|
+
return { items, next: encodeMergedCursor(left), total: null };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async function listPipelines({ repo = null, cursor } = {}) {
|
|
917
|
+
return repo ? listProjectPipelines(repo, cursor) : listRecentPipelines(cursor);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async function jobLogTail(repo, jobId, { lines = 80 } = {}) {
|
|
921
|
+
const r = await t.tail(`${api}/projects/${enc(repo)}/jobs/${encodeURIComponent(jobId)}/trace`, { lines });
|
|
922
|
+
return { lines: r.lines, truncated: r.truncated, webUrl: `${base}/${repo}/-/jobs/${jobId}` };
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// ── branches ────────────────────────────────────────────────────────────
|
|
926
|
+
|
|
927
|
+
const divergeLimit = limiter(DIVERGE_REQUESTS);
|
|
928
|
+
const divergeCache = new Map();
|
|
929
|
+
|
|
930
|
+
/** How many commits a revision range holds, capped at DIVERGE_CAP. */
|
|
931
|
+
async function rangeCount(repo, range) {
|
|
932
|
+
const page = async (perPage, n) => {
|
|
933
|
+
const { data } = await divergeLimit(() => rest(`/projects/${enc(repo)}/repository/commits`, { ref_name: range, per_page: perPage, page: n }));
|
|
934
|
+
return Array.isArray(data) ? data.length : 0;
|
|
935
|
+
};
|
|
936
|
+
const first = await page(100, 1);
|
|
937
|
+
if (first < 100) return first;
|
|
938
|
+
// A page of one is the commit at that position: is there one at 500, at
|
|
939
|
+
// 1000? Then at the other hundreds of the half the count is in.
|
|
940
|
+
const [at500, atCap] = await Promise.all([page(1, 500), page(1, DIVERGE_CAP)]);
|
|
941
|
+
if (atCap > 0) return DIVERGE_CAP;
|
|
942
|
+
const marks = at500 > 0 ? [600, 700, 800, 900] : [200, 300, 400];
|
|
943
|
+
const found = await Promise.all(marks.map(m => page(1, m)));
|
|
944
|
+
let floor = at500 > 0 ? 500 : 100;
|
|
945
|
+
marks.forEach((m, i) => { if (found[i] > 0) floor = m; });
|
|
946
|
+
// The count is in [floor, floor + 100): the page of 100 starting at
|
|
947
|
+
// position floor + 1 says how far into that hundred it goes.
|
|
948
|
+
return floor + await page(100, floor / 100 + 1);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function divergence(repo, defaultSha, sha) {
|
|
952
|
+
if (sha === defaultSha) return Promise.resolve({ ahead: 0, behind: 0 });
|
|
953
|
+
const key = `${repo}\n${defaultSha}\n${sha}`;
|
|
954
|
+
let p = divergeCache.get(key);
|
|
955
|
+
if (!p) {
|
|
956
|
+
p = Promise.all([rangeCount(repo, `${defaultSha}..${sha}`), rangeCount(repo, `${sha}..${defaultSha}`)])
|
|
957
|
+
.then(([ahead, behind]) => ({ ahead, behind }));
|
|
958
|
+
p.catch(() => divergeCache.delete(key));
|
|
959
|
+
divergeCache.set(key, p);
|
|
960
|
+
if (divergeCache.size > DIVERGE_CACHE_MAX) divergeCache.delete(divergeCache.keys().next().value);
|
|
961
|
+
}
|
|
962
|
+
return p;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async function defaultHead(repo, def, rows) {
|
|
966
|
+
if (!def) return null;
|
|
967
|
+
const onPage = rows.find(b => b.default || b.name === def);
|
|
968
|
+
if (onPage) return onPage.commit?.id ?? null;
|
|
969
|
+
const { data } = await rest(`/projects/${enc(repo)}/repository/branches/${enc(def)}`);
|
|
970
|
+
return data?.commit?.id ?? null;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** For a page of branches, in one GraphQL query: the open merge request
|
|
974
|
+
* each is the source of, and the latest pipeline of each one's head. */
|
|
975
|
+
async function branchExtras(repo, rows, c) {
|
|
976
|
+
const empty = { mrs: new Map(), pipes: [] };
|
|
977
|
+
if (!rows.length) return empty;
|
|
978
|
+
const decl = ["$p: ID!", "$names: [String!]"];
|
|
979
|
+
const vars = { p: repo, names: rows.map(b => b.name) };
|
|
980
|
+
const sel = rows.map((b, i) => {
|
|
981
|
+
decl.push(`$s${i}: String!`);
|
|
982
|
+
vars[`s${i}`] = b.commit?.id ?? "";
|
|
983
|
+
return `c${i}: pipelines(sha: $s${i}, first: 1) { nodes { id status path } }`;
|
|
984
|
+
});
|
|
985
|
+
const res = await optional(gql(
|
|
986
|
+
`query(${decl.join(", ")}) { project(fullPath: $p) { mergeRequests(state: opened, sourceBranches: $names, first: 100) { nodes { iid draft ${has(c, MR, "approved") ? "approved " : ""}sourceBranch webUrl approvedBy { nodes { username } } } } ${sel.join(" ")} } }`,
|
|
987
|
+
vars,
|
|
988
|
+
));
|
|
989
|
+
const project = res?.data?.project;
|
|
990
|
+
if (!project) return empty;
|
|
991
|
+
const mrs = new Map();
|
|
992
|
+
for (const m of nodesOf(project.mergeRequests)) {
|
|
993
|
+
const prev = mrs.get(m.sourceBranch);
|
|
994
|
+
if (!prev || Number(m.iid) > Number(prev.iid)) mrs.set(m.sourceBranch, m);
|
|
995
|
+
}
|
|
996
|
+
return { mrs, pipes: rows.map((_, i) => nodesOf(project[`c${i}`])[0] ?? null) };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function toBranch(b, repo, def, { counts = null, pipe = null, mr = null } = {}) {
|
|
1000
|
+
const c = b.commit ?? {};
|
|
1001
|
+
const committedAt = epoch(c.committed_date);
|
|
1002
|
+
return {
|
|
1003
|
+
name: b.name,
|
|
1004
|
+
webUrl: b.web_url ?? `${base}/${repo}/-/tree/${String(b.name).split("/").map(encodeURIComponent).join("/")}`,
|
|
1005
|
+
isDefault: !!b.default || (def != null && b.name === def),
|
|
1006
|
+
protected: !!b.protected,
|
|
1007
|
+
commit: {
|
|
1008
|
+
sha: c.id ?? "",
|
|
1009
|
+
shortSha: c.short_id ?? String(c.id ?? "").slice(0, 8),
|
|
1010
|
+
title: c.title ?? "",
|
|
1011
|
+
committedAt,
|
|
1012
|
+
authorName: c.author_name ?? null,
|
|
1013
|
+
webUrl: c.web_url ?? null,
|
|
1014
|
+
},
|
|
1015
|
+
ahead: counts?.ahead ?? null,
|
|
1016
|
+
behind: counts?.behind ?? null,
|
|
1017
|
+
ci: pipe ? { state: gitlabCiState(pipe.status), webUrl: abs(pipe.path) } : null,
|
|
1018
|
+
change: mr
|
|
1019
|
+
? {
|
|
1020
|
+
number: Number(mr.iid),
|
|
1021
|
+
key: `${repo}!${mr.iid}`,
|
|
1022
|
+
state: "open",
|
|
1023
|
+
draft: !!mr.draft,
|
|
1024
|
+
approved: typeof mr.approved === "boolean" ? mr.approved : nodesOf(mr.approvedBy).length > 0,
|
|
1025
|
+
webUrl: abs(mr.webUrl) ?? `${base}/${repo}/-/merge_requests/${mr.iid}`,
|
|
1026
|
+
}
|
|
1027
|
+
: null,
|
|
1028
|
+
stale: committedAt != null && now() - committedAt > STALE_MS,
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const isStaleRow = (b) => {
|
|
1033
|
+
const at = epoch(b.commit?.committed_date);
|
|
1034
|
+
return at != null && now() - at > STALE_MS;
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* A page of the Branches tab, most recently updated first — GitLab's own
|
|
1039
|
+
* order, which puts its "Active" branches before its "Stale" ones (no
|
|
1040
|
+
* commit for 90 days, `stale`). Ahead/behind is worked out for the active
|
|
1041
|
+
* branches of this page only; a stale branch's would cost the most and
|
|
1042
|
+
* GitLab does not show it either.
|
|
1043
|
+
*/
|
|
1044
|
+
async function listBranches(repo, { cursor } = {}) {
|
|
1045
|
+
const page = pageParam(cursor);
|
|
1046
|
+
const capsPromise = caps();
|
|
1047
|
+
const [project, { data, headers }] = await Promise.all([
|
|
1048
|
+
projectInfo(repo),
|
|
1049
|
+
rest(`/projects/${enc(repo)}/repository/branches`, { sort: "updated_desc", per_page: PAGE, page }),
|
|
1050
|
+
]);
|
|
1051
|
+
const rows = Array.isArray(data) ? data : [];
|
|
1052
|
+
const def = project?.default_branch ?? null;
|
|
1053
|
+
const [extras, counts] = await Promise.all([
|
|
1054
|
+
capsPromise.then(c => branchExtras(repo, rows, c)),
|
|
1055
|
+
optional(defaultHead(repo, def, rows)).then(defSha => mapLimit(rows, DIVERGE_BRANCHES, async (b) => {
|
|
1056
|
+
if (!defSha || !b.commit?.id || b.default || b.name === def || isStaleRow(b)) return null;
|
|
1057
|
+
return optional(divergence(repo, defSha, b.commit.id));
|
|
1058
|
+
})),
|
|
1059
|
+
]);
|
|
1060
|
+
return {
|
|
1061
|
+
items: rows.map((b, i) => toBranch(b, repo, def, { counts: counts[i], pipe: extras.pipes[i], mr: extras.mrs.get(b.name) })),
|
|
1062
|
+
...pageInfo(headers),
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async function getBranch(repo, name) {
|
|
1067
|
+
const capsPromise = caps();
|
|
1068
|
+
const [project, { data: b }] = await Promise.all([
|
|
1069
|
+
projectInfo(repo),
|
|
1070
|
+
rest(`/projects/${enc(repo)}/repository/branches/${enc(name)}`),
|
|
1071
|
+
]);
|
|
1072
|
+
const def = project?.default_branch ?? null;
|
|
1073
|
+
const isDefault = !!b.default || b.name === def;
|
|
1074
|
+
const defSha = isDefault ? b.commit?.id ?? null : await optional(defaultHead(repo, def, []));
|
|
1075
|
+
const diverges = !isDefault && defSha && b.commit?.id;
|
|
1076
|
+
const [extras, aheadList, behind] = await Promise.all([
|
|
1077
|
+
capsPromise.then(c => branchExtras(repo, [b], c)),
|
|
1078
|
+
diverges
|
|
1079
|
+
? rest(`/projects/${enc(repo)}/repository/commits`, { ref_name: `${defSha}..${b.commit.id}`, per_page: 100 }).then(r => (Array.isArray(r.data) ? r.data : []))
|
|
1080
|
+
: [],
|
|
1081
|
+
diverges ? optional(rangeCount(repo, `${b.commit.id}..${defSha}`)) : null,
|
|
1082
|
+
]);
|
|
1083
|
+
const ahead = !diverges ? null : aheadList.length < 100 ? aheadList.length : await optional(rangeCount(repo, `${defSha}..${b.commit.id}`));
|
|
1084
|
+
const pipe = extras.pipes[0];
|
|
1085
|
+
const pipeline = pipe?.id ? await optional(getPipeline(repo, gidTail(pipe.id))) : null;
|
|
1086
|
+
return {
|
|
1087
|
+
branch: toBranch(b, repo, def, { counts: diverges ? { ahead, behind } : null, pipe, mr: extras.mrs.get(b.name) }),
|
|
1088
|
+
pipeline,
|
|
1089
|
+
commitsAhead: aheadList.map(c => toCommit(c, repo)),
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ── issues ──────────────────────────────────────────────────────────────
|
|
1094
|
+
|
|
1095
|
+
async function enrichIssues(rows) {
|
|
1096
|
+
const out = new Map();
|
|
1097
|
+
if (!rows.length) return out;
|
|
1098
|
+
const paths = [...new Set(rows.map(r => r.repo))];
|
|
1099
|
+
const iids = [...new Set(rows.map(r => String(r.number)))];
|
|
1100
|
+
const wi = `workItems(iids: $iids, first: 100) { nodes { iid widgets { type ... on WorkItemWidgetHierarchy { parent { ${ISSUE_REF} } children(first: 100) { count nodes { state } } } } } }`;
|
|
1101
|
+
const q = (withWorkItems) => `query($paths: [String!], $iids: [String!], $n: Int) { projects(fullPaths: $paths, first: $n) { nodes { fullPath issues(iids: $iids, first: 100) { nodes { iid titleHtml } } ${withWorkItems ? wi : ""} } } }`;
|
|
1102
|
+
const res = await optional(gqlFirst([q(true), q(false)], { paths, iids, n: paths.length }));
|
|
1103
|
+
for (const p of nodesOf(res?.data?.projects)) {
|
|
1104
|
+
for (const i of nodesOf(p.issues)) out.set(keyOf(p.fullPath, "#", i.iid), { ...out.get(keyOf(p.fullPath, "#", i.iid)), issue: i });
|
|
1105
|
+
for (const w of nodesOf(p.workItems)) out.set(keyOf(p.fullPath, "#", w.iid), { ...out.get(keyOf(p.fullPath, "#", w.iid)), workItem: w });
|
|
1106
|
+
}
|
|
1107
|
+
return out;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** Issues assigned to the user, newest first — the Issues tab. */
|
|
1111
|
+
async function listIssues({ repo = null, state = "open", cursor } = {}) {
|
|
1112
|
+
const { data, headers } = await rest(repo ? `/projects/${enc(repo)}/issues` : "/issues", {
|
|
1113
|
+
scope: "assigned_to_me",
|
|
1114
|
+
state: state === "closed" ? "closed" : "opened",
|
|
1115
|
+
order_by: "created_at",
|
|
1116
|
+
sort: "desc",
|
|
1117
|
+
with_labels_details: true,
|
|
1118
|
+
per_page: PAGE,
|
|
1119
|
+
page: pageParam(cursor),
|
|
1120
|
+
});
|
|
1121
|
+
const rows = (Array.isArray(data) ? data : []).map(i => ({ i, repo: rowRepo(i, "#", repo), number: i.iid }));
|
|
1122
|
+
const extra = await enrichIssues(rows);
|
|
1123
|
+
return {
|
|
1124
|
+
items: rows.map(r => {
|
|
1125
|
+
const x = extra.get(keyOf(r.repo, "#", r.number));
|
|
1126
|
+
return toIssue(r.i, r.repo, x?.issue, x?.workItem);
|
|
1127
|
+
}),
|
|
1128
|
+
...pageInfo(headers),
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
async function getIssue(repo, number) {
|
|
1133
|
+
const wi = `namespace(fullPath: $p) { workItem(iid: $iid) { iid widgets { type ... on WorkItemWidgetHierarchy { parent { ${ISSUE_REF} } children(first: 100) { count nodes { ${ISSUE_REF} } } } } } }`;
|
|
1134
|
+
const q = (withWorkItem) => `query($p: ID!, $iid: String!) { project(fullPath: $p) { issue(iid: $iid) { titleHtml descriptionHtml labels(first: 100) { nodes { title color } } } } ${withWorkItem ? wi : ""} }`;
|
|
1135
|
+
const [{ data: i }, g] = await Promise.all([
|
|
1136
|
+
rest(`/projects/${enc(repo)}/issues/${number}`),
|
|
1137
|
+
optional(gqlFirst([q(true), q(false)], { p: repo, iid: String(number) })),
|
|
1138
|
+
]);
|
|
1139
|
+
const issue = g?.data?.project?.issue ?? null;
|
|
1140
|
+
const workItem = g?.data?.namespace?.workItem ?? null;
|
|
1141
|
+
const mapped = toIssue(i, rowRepo(i, "#", repo), issue, workItem);
|
|
1142
|
+
// A single issue from REST names its labels without their colours.
|
|
1143
|
+
const labels = issue?.labels ? toLabels(issue.labels) : mapped.labels;
|
|
1144
|
+
return { ...mapped, labels, descriptionHtml: issue?.descriptionHtml ?? "", childItems: hierarchyOf(workItem).childItems };
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async function listIssueComments(repo, number, { cursor } = {}) {
|
|
1148
|
+
const q = (filter) => `query($p: ID!, $iid: String!, $after: String) { project(fullPath: $p) { issue(iid: $iid) { userNotesCount notes(${filter}first: ${COMMENT_PAGE}, after: $after) { pageInfo { hasNextPage endCursor } nodes { ${NOTE} } } } } }`;
|
|
1149
|
+
const { data } = await gqlFirst([q("filter: ONLY_COMMENTS, "), q("")], { p: repo, iid: String(number), after: cursor ?? null });
|
|
1150
|
+
const issue = data?.project?.issue;
|
|
1151
|
+
if (!issue) throw new ForgeError("not-found", `issue ${repo}#${number} was not found`);
|
|
1152
|
+
const conn = issue.notes;
|
|
1153
|
+
return {
|
|
1154
|
+
items: nodesOf(conn).filter(n => !n.system).map(toComment),
|
|
1155
|
+
next: conn?.pageInfo?.hasNextPage ? conn.pageInfo.endCursor ?? null : null,
|
|
1156
|
+
total: typeof issue.userNotesCount === "number" ? issue.userNotesCount : null,
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// ── the rest ────────────────────────────────────────────────────────────
|
|
1161
|
+
|
|
1162
|
+
async function validate() {
|
|
1163
|
+
const [user, version, self] = await Promise.all([
|
|
1164
|
+
rest("/user").then(r => r.data),
|
|
1165
|
+
optional(rest("/version").then(r => r.data)),
|
|
1166
|
+
// An OAuth token is not a personal access token and has no /self.
|
|
1167
|
+
optional(rest("/personal_access_tokens/self").then(r => r.data)).catch(() => null),
|
|
1168
|
+
]);
|
|
1169
|
+
const meta = {};
|
|
1170
|
+
const warnings = [];
|
|
1171
|
+
if (Array.isArray(self?.scopes)) {
|
|
1172
|
+
meta.scopes = self.scopes.map(String);
|
|
1173
|
+
if (!meta.scopes.includes("read_api") && !meta.scopes.includes("api")) {
|
|
1174
|
+
warnings.push("This token has neither the read_api nor the api scope, so GitLab will refuse the lists. Make one with read_api.");
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (typeof version?.version === "string") {
|
|
1178
|
+
meta.serverVersion = version.version;
|
|
1179
|
+
const major = Number(/^(\d+)/.exec(version.version)?.[1]);
|
|
1180
|
+
if (Number.isFinite(major) && major < 16) {
|
|
1181
|
+
warnings.push(`GitLab ${version.version} is older than 16: reviewer states, approvals and child tasks may be missing.`);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (warnings.length) meta.warnings = warnings;
|
|
1185
|
+
const account = toUser(user);
|
|
1186
|
+
return { account: { id: String(user.id), ...account }, meta };
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
async function listRepos({ search, cursor } = {}) {
|
|
1190
|
+
const q = typeof search === "string" ? search.trim() : "";
|
|
1191
|
+
const { data, headers } = await rest("/projects", {
|
|
1192
|
+
membership: true,
|
|
1193
|
+
archived: false,
|
|
1194
|
+
order_by: "last_activity_at",
|
|
1195
|
+
sort: "desc",
|
|
1196
|
+
simple: true,
|
|
1197
|
+
per_page: PAGE,
|
|
1198
|
+
page: pageParam(cursor),
|
|
1199
|
+
...(q ? { search: q, search_namespaces: true } : {}),
|
|
1200
|
+
});
|
|
1201
|
+
return {
|
|
1202
|
+
items: (Array.isArray(data) ? data : []).map(p => ({
|
|
1203
|
+
id: String(p.id),
|
|
1204
|
+
path: p.path_with_namespace,
|
|
1205
|
+
name: p.name ?? p.path ?? "",
|
|
1206
|
+
webUrl: p.web_url ?? `${base}/${p.path_with_namespace}`,
|
|
1207
|
+
defaultBranch: p.default_branch ?? null,
|
|
1208
|
+
lastActivityAt: epoch(p.last_activity_at),
|
|
1209
|
+
archived: !!p.archived,
|
|
1210
|
+
})),
|
|
1211
|
+
...pageInfo(headers),
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** A pasted merge request or issue URL of this instance, or a key
|
|
1216
|
+
* (`group/project!12`, `group/project#12`), read. Nothing is fetched. */
|
|
1217
|
+
async function resolveRef(input) {
|
|
1218
|
+
const s = String(input ?? "").trim();
|
|
1219
|
+
if (!s) return null;
|
|
1220
|
+
const key = /^([A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+)([!#])(\d+)$/.exec(s);
|
|
1221
|
+
if (key) {
|
|
1222
|
+
const number = Number(key[3]);
|
|
1223
|
+
return number > 0 ? { type: key[2] === "!" ? "change" : "issue", repo: key[1], number } : null;
|
|
1224
|
+
}
|
|
1225
|
+
let url;
|
|
1226
|
+
try {
|
|
1227
|
+
url = new URL(s);
|
|
1228
|
+
} catch {
|
|
1229
|
+
return null;
|
|
1230
|
+
}
|
|
1231
|
+
if (url.origin !== origin) return null;
|
|
1232
|
+
let path;
|
|
1233
|
+
try {
|
|
1234
|
+
path = decodeURIComponent(url.pathname);
|
|
1235
|
+
} catch {
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
if (prefix) {
|
|
1239
|
+
if (!path.startsWith(`${prefix}/`)) return null;
|
|
1240
|
+
path = path.slice(prefix.length);
|
|
1241
|
+
}
|
|
1242
|
+
const m = /^\/(.+?)\/(?:-\/)?(merge_requests|issues|work_items)\/(\d+)(?:\/.*)?$/.exec(path);
|
|
1243
|
+
if (!m) return null;
|
|
1244
|
+
const repo = m[1];
|
|
1245
|
+
const number = Number(m[3]);
|
|
1246
|
+
if (!/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/.test(repo) || repo.split("/").includes("-") || number < 1) return null;
|
|
1247
|
+
return { type: m[2] === "merge_requests" ? "change" : "issue", repo, number };
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
return {
|
|
1251
|
+
allowedOrigins,
|
|
1252
|
+
validate,
|
|
1253
|
+
listRepos,
|
|
1254
|
+
listBranches,
|
|
1255
|
+
getBranch,
|
|
1256
|
+
listChanges,
|
|
1257
|
+
countChanges,
|
|
1258
|
+
getChange,
|
|
1259
|
+
listChangeCommits,
|
|
1260
|
+
listChangePipelines,
|
|
1261
|
+
listChangeFiles,
|
|
1262
|
+
listPipelines,
|
|
1263
|
+
getPipeline,
|
|
1264
|
+
jobLogTail,
|
|
1265
|
+
listIssues,
|
|
1266
|
+
getIssue,
|
|
1267
|
+
listIssueComments,
|
|
1268
|
+
resolveRef,
|
|
1269
|
+
fetchAsset,
|
|
1270
|
+
};
|
|
1271
|
+
}
|