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.
Files changed (97) hide show
  1. package/README.md +79 -18
  2. package/THIRD_PARTY_NOTICES.md +70 -7
  3. package/bin/cli/commands.js +613 -0
  4. package/bin/cli/help.js +26 -3
  5. package/bin/cli/one-shot.js +2 -2
  6. package/bin/cli/picker.js +86 -0
  7. package/bin/cli/startup.js +13 -0
  8. package/bin/deck.js +16 -1
  9. package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
  10. package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
  11. package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
  12. package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
  13. package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
  14. package/dist/web/assets/app-BW3NYUYB.js +318 -0
  15. package/dist/web/assets/app-C5RY_wZ4.css +1 -0
  16. package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
  17. package/dist/web/index.html +3 -3
  18. package/package.json +2 -1
  19. package/release-notes.json +52 -0
  20. package/src/server/account-notify.mjs +268 -9
  21. package/src/server/account-pick.mjs +160 -0
  22. package/src/server/account-projects.mjs +55 -15
  23. package/src/server/account-routes.mjs +47 -4
  24. package/src/server/account-watch.mjs +107 -4
  25. package/src/server/activity.mjs +3 -2
  26. package/src/server/args.mjs +112 -2
  27. package/src/server/autoswitch-failover.mjs +138 -0
  28. package/src/server/claude-accounts.mjs +127 -16
  29. package/src/server/claude-dir.mjs +142 -5
  30. package/src/server/claude-identity.mjs +23 -0
  31. package/src/server/cli-lines.mjs +224 -0
  32. package/src/server/codex-cost-usage.mjs +67 -0
  33. package/src/server/codex-enrichment.mjs +53 -9
  34. package/src/server/codex-review-policy.d.mts +4 -0
  35. package/src/server/codex-review-policy.mjs +11 -0
  36. package/src/server/codex-skills.mjs +146 -0
  37. package/src/server/codex-watch.mjs +5 -2
  38. package/src/server/cswap-auto-choice.mjs +53 -0
  39. package/src/server/cswap-auto-loop.mjs +100 -9
  40. package/src/server/deck-client.mjs +112 -0
  41. package/src/server/deck-prefs.mjs +26 -5
  42. package/src/server/event-pipeline.mjs +19 -3
  43. package/src/server/event-routes.mjs +15 -0
  44. package/src/server/exec.mjs +6 -2
  45. package/src/server/feature-use.mjs +11 -1
  46. package/src/server/forge-http.mjs +514 -0
  47. package/src/server/forge-public-fetch.mjs +228 -0
  48. package/src/server/forge-routes.mjs +154 -0
  49. package/src/server/github-api.mjs +1369 -0
  50. package/src/server/gitlab-api.mjs +1271 -0
  51. package/src/server/index.mjs +23 -3
  52. package/src/server/installer.mjs +149 -8
  53. package/src/server/integrations/cache.mjs +42 -0
  54. package/src/server/integrations/errors.mjs +167 -0
  55. package/src/server/integrations/forge.mjs +76 -0
  56. package/src/server/integrations/jira.mjs +1026 -0
  57. package/src/server/integrations/providers.mjs +11 -0
  58. package/src/server/integrations/proxy.mjs +314 -0
  59. package/src/server/integrations/registry.mjs +142 -0
  60. package/src/server/integrations/routes.mjs +498 -0
  61. package/src/server/integrations/store.mjs +226 -0
  62. package/src/server/lan-deck.mjs +7 -0
  63. package/src/server/lan-elsewhere.d.mts +30 -0
  64. package/src/server/lan-elsewhere.mjs +137 -0
  65. package/src/server/lan-engine.mjs +2 -2
  66. package/src/server/lan-sync.mjs +3 -3
  67. package/src/server/lan-wire.mjs +3 -3
  68. package/src/server/login-guard.mjs +162 -0
  69. package/src/server/login-service.mjs +3 -0
  70. package/src/server/org-telemetry.mjs +173 -0
  71. package/src/server/pinned-build.mjs +8 -0
  72. package/src/server/prefs-routes.mjs +4 -0
  73. package/src/server/request-gates.mjs +26 -0
  74. package/src/server/session-read-gate.mjs +5 -3
  75. package/src/server/session-summary.mjs +281 -0
  76. package/src/server/skill-hook.mjs +162 -0
  77. package/src/server/stop-continue-gate.d.mts +8 -0
  78. package/src/server/stop-continue-gate.mjs +77 -0
  79. package/src/server/stop-continue.mjs +544 -0
  80. package/src/server/stop-failure.d.mts +10 -0
  81. package/src/server/stop-failure.mjs +97 -0
  82. package/src/server/swap-log.mjs +19 -10
  83. package/src/server/tool-audit-report.mjs +5 -3
  84. package/src/server/tool-audit-routes.mjs +5 -3
  85. package/src/server/tool-audit-skills.mjs +82 -0
  86. package/src/server/tool-audit.mjs +53 -11
  87. package/src/server/traffic-radar-capture.mjs +10 -1
  88. package/src/server/traffic-radar-monitor.mjs +4 -2
  89. package/src/server/traffic-radar-routes.mjs +13 -0
  90. package/src/server/traffic-radar-service.mjs +133 -0
  91. package/src/server/usage-day.mjs +3 -1
  92. package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
  93. package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
  94. package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
  95. package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
  96. package/dist/web/assets/app-BiJ8TfUl.css +0 -1
  97. 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
+ }