opencode-forgekeeper 1.0.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 (4) hide show
  1. package/README.md +387 -0
  2. package/dist/index.js +2319 -0
  3. package/dist/tui.js +1529 -0
  4. package/package.json +49 -0
package/dist/tui.js ADDED
@@ -0,0 +1,1529 @@
1
+ // @bun
2
+ // packages/forgekeeper/src/tui.tsx
3
+ import { memo as _$memo } from "@opentui/solid";
4
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
5
+ import { insertNode as _$insertNode } from "@opentui/solid";
6
+ import { effect as _$effect } from "@opentui/solid";
7
+ import { insert as _$insert } from "@opentui/solid";
8
+ import { createComponent as _$createComponent } from "@opentui/solid";
9
+ import { setProp as _$setProp } from "@opentui/solid";
10
+ import { createElement as _$createElement } from "@opentui/solid";
11
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
12
+ // packages/_shared/src/exec.ts
13
+ import { execFile } from "child_process";
14
+ function exec(file, args, options = {}) {
15
+ return new Promise((resolve) => {
16
+ execFile(file, args, {
17
+ cwd: options.cwd,
18
+ env: options.env ? { ...process.env, ...options.env } : process.env,
19
+ timeout: options.timeout,
20
+ maxBuffer: 16 * 1024 * 1024,
21
+ encoding: "utf8"
22
+ }, (error, stdout, stderr) => {
23
+ if (!error) {
24
+ resolve({ stdout, stderr, code: 0 });
25
+ return;
26
+ }
27
+ const code = typeof error.code === "number" ? error.code : -1;
28
+ resolve({ stdout, stderr: stderr || error.message, code });
29
+ });
30
+ });
31
+ }
32
+ // packages/forgekeeper/src/exec.ts
33
+ var execWithTimeout = (timeout) => (file, args, cwd) => exec(file, args, { cwd, timeout });
34
+ var exec2 = execWithTimeout(30000);
35
+
36
+ // packages/forgekeeper/src/forge.ts
37
+ var ownedBy = (kind) => (request) => request.forge === kind;
38
+ var titlePrefix = (title) => title?.match(/^\[[^\]]*\]/)?.[0] ?? "";
39
+ function prefixReferences(title, sigil) {
40
+ return titlePrefix(title).slice(1, -1).split(/[\s,]+/).flatMap((token) => token.startsWith(sigil) && /^[1-9]\d*$/.test(token.slice(sigil.length)) ? [token.slice(sigil.length)] : []);
41
+ }
42
+ function parseWebUrl(kind, value, pattern) {
43
+ let url;
44
+ try {
45
+ url = new URL(value);
46
+ } catch {
47
+ return;
48
+ }
49
+ if (!["https:", "http:"].includes(url.protocol))
50
+ return;
51
+ const match = url.pathname.match(pattern);
52
+ if (!match)
53
+ return;
54
+ return {
55
+ forge: kind,
56
+ host: url.hostname.toLowerCase(),
57
+ project: decodeURIComponent(match[1]),
58
+ iid: match[2]
59
+ };
60
+ }
61
+ var yesNo = (value) => value ? "yes" : "no";
62
+ var humanize = (value) => value.toLowerCase().replace(/_/g, " ");
63
+
64
+ class ForgeError extends Error {
65
+ kind;
66
+ constructor(kind, message) {
67
+ super(message);
68
+ this.kind = kind;
69
+ }
70
+ }
71
+
72
+ // packages/forgekeeper/src/gitlab-feedback.ts
73
+ var PAGE_SIZE = 100;
74
+ var MAX_COMMENT_PAGES = 20;
75
+ var COMMENTS_QUERY = `query($project: ID!, $iid: String!, $before: String) {
76
+ currentUser { id }
77
+ project(fullPath: $project) {
78
+ mergeRequest(iid: $iid) {
79
+ notes(last: ${PAGE_SIZE}, before: $before, filter: ONLY_COMMENTS) {
80
+ pageInfo { hasPreviousPage startCursor }
81
+ nodes {
82
+ id url createdAt updatedAt lastEditedAt system
83
+ discussion { resolved }
84
+ author { id username bot }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }`;
90
+ var noteNumber = (id) => id.slice(id.lastIndexOf("/") + 1);
91
+ var timestamp = (node) => Date.parse(node.lastEditedAt ?? node.updatedAt ?? node.createdAt);
92
+ function toComment(node) {
93
+ const editedAt = timestamp(node);
94
+ const id = noteNumber(node.id);
95
+ if (node.system || !node.author || !Number.isFinite(editedAt) || !/^\d+$/.test(id))
96
+ return;
97
+ return {
98
+ id,
99
+ order: Number(id),
100
+ url: node.url ?? "",
101
+ editedAt,
102
+ resolved: node.discussion?.resolved === true,
103
+ bot: node.author.bot,
104
+ authorID: node.author.id,
105
+ username: node.author.username
106
+ };
107
+ }
108
+ async function fetchComments(graphql, host, project, iid) {
109
+ const pages = [];
110
+ let viewerID;
111
+ let before;
112
+ const snapshot = (complete) => ({
113
+ complete: complete && viewerID !== undefined,
114
+ viewerID,
115
+ comments: pages.toReversed().flat()
116
+ });
117
+ for (let page = 0;page < MAX_COMMENT_PAGES; page++) {
118
+ const variables = { project, iid };
119
+ if (before)
120
+ variables.before = before;
121
+ const data = await graphql(host, COMMENTS_QUERY, variables);
122
+ viewerID ??= data?.currentUser?.id ?? undefined;
123
+ const connection = data?.project?.mergeRequest?.notes;
124
+ if (!connection)
125
+ return page === 0 ? undefined : snapshot(false);
126
+ pages.push(connection.nodes.flatMap((node) => toComment(node) ?? []));
127
+ if (!connection.pageInfo.hasPreviousPage)
128
+ return snapshot(true);
129
+ before = connection.pageInfo.startCursor ?? undefined;
130
+ if (!before)
131
+ return snapshot(false);
132
+ }
133
+ return snapshot(true);
134
+ }
135
+
136
+ // packages/forgekeeper/src/gitlab.ts
137
+ var DISCUSSIONS = "pageInfo { hasNextPage endCursor } nodes { resolvable resolved }";
138
+ var PAGE_SIZE2 = 100;
139
+ var MAX_DISCUSSION_PAGES = 50;
140
+ var MERGE_REQUEST_FIELDS = `
141
+ iid title webUrl state draft conflicts approved detailedMergeStatus diffHeadSha updatedAt targetBranch
142
+ approvedBy(first: 100) { nodes { id bot } }
143
+ sourceProject { fullPath }
144
+ targetProject { fullPath }
145
+ headPipeline { status path detailedStatus { label } }
146
+ reviewers(first: 100) { nodes { type username bot mergeRequestInteraction { reviewState approved } } }
147
+ discussions(first: ${PAGE_SIZE2}) { ${DISCUSSIONS} }
148
+ `;
149
+ function mergeRequestsQuery(projectCount) {
150
+ const variables = Array.from({ length: projectCount }, (_, index) => `$p${index}: ID!`).join(", ");
151
+ const projects = Array.from({ length: projectCount }, (_, index) => `p${index}: project(fullPath: $p${index}) {
152
+ mergeRequests(sourceBranches: [$branch], state: opened, first: 20) { nodes { ${MERGE_REQUEST_FIELDS} } }
153
+ }`).join(`
154
+ `);
155
+ return `query($branch: String!, ${variables}) {
156
+ ${projects}
157
+ }`;
158
+ }
159
+ function mergeRequestQuery(projectCount) {
160
+ const variables = Array.from({ length: projectCount }, (_, index) => `$p${index}: ID!`).join(", ");
161
+ const projects = Array.from({ length: projectCount }, (_, index) => `p${index}: project(fullPath: $p${index}) { mergeRequest(iid: $iid) { ${MERGE_REQUEST_FIELDS} } }`).join(`
162
+ `);
163
+ return `query($iid: String!, ${variables}) {
164
+ ${projects}
165
+ }`;
166
+ }
167
+ function mergeRequestNumbersQuery(projectCount) {
168
+ const variables = Array.from({ length: projectCount }, (_, index) => `$p${index}: ID!`).join(", ");
169
+ const projects = Array.from({ length: projectCount }, (_, index) => `p${index}: project(fullPath: $p${index}) {
170
+ mergeRequests(sourceBranches: [$branch], state: opened, first: 20) { nodes { iid updatedAt sourceProject { fullPath } } }
171
+ }`).join(`
172
+ `);
173
+ return `query($branch: String!, ${variables}) {
174
+ ${projects}
175
+ }`;
176
+ }
177
+ var SOURCE_BRANCH_QUERY = `query($project: ID!, $iid: String!) {
178
+ project(fullPath: $project) { mergeRequest(iid: $iid) { sourceBranch } }
179
+ }`;
180
+ var DISCUSSIONS_QUERY = `query($project: ID!, $iid: String!, $after: String) {
181
+ project(fullPath: $project) {
182
+ mergeRequest(iid: $iid) { discussions(first: ${PAGE_SIZE2}, after: $after) { ${DISCUSSIONS} } }
183
+ }
184
+ }`;
185
+ function classifyError(message, code) {
186
+ const text = message.trim().replace(/^glab:\s*/, "") || `glab exited with code ${code}`;
187
+ if (/ENOENT|command not found/i.test(text))
188
+ return new ForgeError("missing-glab", "glab is not installed");
189
+ if (/\b401\b|unauthori[sz]ed|not logged in|authenticate|invalid token|token.*expired/i.test(text)) {
190
+ return new ForgeError("auth", "GitLab authentication failed. Run `glab auth status`.");
191
+ }
192
+ if (/\b429\b|rate limit|too many requests/i.test(text))
193
+ return new ForgeError("rate-limit", "GitLab rate limit reached");
194
+ return new ForgeError("request", text.split(`
195
+ `)[0]);
196
+ }
197
+ var isResponse = (body) => typeof body === "object" && body !== null && (!("errors" in body) || Array.isArray(body.errors)) && (!("data" in body) || typeof body.data === "object");
198
+ function glabGraphQL(run, cwd) {
199
+ return async (host, query, variables) => {
200
+ const args = ["api", "graphql", "--hostname", host, "-f", `query=${query}`];
201
+ for (const [name, value] of Object.entries(variables))
202
+ args.push("-f", `${name}=${value}`);
203
+ const result = await run("glab", args, cwd);
204
+ let body;
205
+ try {
206
+ body = JSON.parse(result.stdout);
207
+ } catch {
208
+ throw classifyError(result.stderr, result.code);
209
+ }
210
+ if (!isResponse(body))
211
+ throw classifyError(result.stderr || "Unexpected GitLab response", result.code);
212
+ if (body.errors?.length)
213
+ throw classifyError(String(body.errors[0]?.message ?? ""), result.code);
214
+ if (result.code !== 0)
215
+ throw classifyError(result.stderr, result.code);
216
+ if (!body.data)
217
+ throw classifyError("GitLab returned no data", result.code);
218
+ return body.data;
219
+ };
220
+ }
221
+ var countUnresolved = (page) => page.nodes.filter((discussion) => discussion.resolvable && !discussion.resolved).length;
222
+ var gitlabTraits = {
223
+ kind: "gitlab",
224
+ noun: "MR",
225
+ ci: "pipeline",
226
+ recognizes: (host) => /(^|\.)gitlab\./.test(host),
227
+ open: (run, directory) => new GitLabForge(glabGraphQL(run, directory)),
228
+ owns: ownedBy("gitlab"),
229
+ parseUrl: (url) => parseWebUrl("gitlab", url, /^\/(.+)\/-\/merge_requests\/([1-9]\d*)\/?$/),
230
+ parseIssueUrl: (url) => parseWebUrl("gitlab", url, /^\/(.+)\/-\/(?:issues|work_items)\/([1-9]\d*)\/?$/),
231
+ reference: (iid) => `!${iid}`,
232
+ issueReference: (iid) => `#${iid}`,
233
+ titleReferences: (title) => prefixReferences(title, "!"),
234
+ indicators: () => [],
235
+ details(request) {
236
+ const requirements = request.approvalRequirementsSatisfied === null ? "unknown" : yesNo(request.approvalRequirementsSatisfied);
237
+ const lines = [
238
+ `Human approvals: ${yesNo(request.hasApprovals)} \xB7 Approval requirements satisfied: ${requirements}`
239
+ ];
240
+ if (request.mergeStatus)
241
+ lines.push(`Merge status: ${humanize(request.mergeStatus)}`);
242
+ return lines;
243
+ },
244
+ automatedReview: {
245
+ name: "GitLab Duo",
246
+ status(request) {
247
+ const state = request.duoReviewState;
248
+ if (!state)
249
+ return;
250
+ return { state: duoState(state), label: humanize(state) };
251
+ }
252
+ },
253
+ feedback: {
254
+ async fetch(run, directory, request) {
255
+ const ref = gitlabTraits.parseUrl(request.url);
256
+ return ref ? fetchComments(glabGraphQL(run, directory), ref.host, ref.project, ref.iid) : undefined;
257
+ }
258
+ }
259
+ };
260
+ var DUO_FEEDBACK = new Set(["REVIEWED", "REQUESTED_CHANGES"]);
261
+ function duoState(state) {
262
+ if (state === "REVIEW_STARTED")
263
+ return "running";
264
+ return DUO_FEEDBACK.has(state) ? "feedback" : "settled";
265
+ }
266
+
267
+ class GitLabForge {
268
+ graphql;
269
+ traits = gitlabTraits;
270
+ constructor(graphql) {
271
+ this.graphql = graphql;
272
+ }
273
+ async findByBranch(repository) {
274
+ const { source, targets, sourceBranch } = repository;
275
+ const variables = { branch: sourceBranch };
276
+ targets.forEach((target, index) => variables[`p${index}`] = target.path);
277
+ const data = await this.graphql(source.host, mergeRequestsQuery(targets.length), variables);
278
+ const sourcePath = source.path.toLowerCase();
279
+ const seen = new Set;
280
+ const nodes = [];
281
+ targets.forEach((_, index) => {
282
+ for (const node of data?.[`p${index}`]?.mergeRequests?.nodes ?? []) {
283
+ if (node.sourceProject?.fullPath.toLowerCase() !== sourcePath)
284
+ continue;
285
+ const key = `${node.targetProject.fullPath}!${node.iid}`;
286
+ if (seen.has(key))
287
+ continue;
288
+ seen.add(key);
289
+ nodes.push(node);
290
+ }
291
+ });
292
+ const requests = await Promise.all(nodes.map((node) => this.normalize(source.host, node)));
293
+ return requests.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
294
+ }
295
+ async findNumberByBranch(repository) {
296
+ const { source, targets, sourceBranch } = repository;
297
+ const variables = { branch: sourceBranch };
298
+ targets.forEach((target, index) => variables[`p${index}`] = target.path);
299
+ const data = await this.graphql(source.host, mergeRequestNumbersQuery(targets.length), variables);
300
+ const sourcePath = source.path.toLowerCase();
301
+ const nodes = targets.flatMap((_, index) => data?.[`p${index}`]?.mergeRequests?.nodes ?? []);
302
+ return nodes.filter((node) => node.sourceProject?.fullPath.toLowerCase() === sourcePath).sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))[0]?.iid;
303
+ }
304
+ async sourceBranch(ref) {
305
+ const data = await this.graphql(ref.host, SOURCE_BRANCH_QUERY, {
306
+ project: ref.project,
307
+ iid: ref.iid
308
+ });
309
+ const branch = data?.project?.mergeRequest?.sourceBranch;
310
+ return typeof branch === "string" && branch ? branch : undefined;
311
+ }
312
+ async findByNumber(projects, iid) {
313
+ if (projects.length === 0)
314
+ return;
315
+ const host = projects[0].host;
316
+ const variables = { iid };
317
+ projects.forEach((project, index) => variables[`p${index}`] = project.path);
318
+ const data = await this.graphql(host, mergeRequestQuery(projects.length), variables);
319
+ for (let index = 0;index < projects.length; index++) {
320
+ const node = data?.[`p${index}`]?.mergeRequest ?? undefined;
321
+ if (node)
322
+ return this.normalize(host, node);
323
+ }
324
+ return;
325
+ }
326
+ async unresolvedThreads(host, node) {
327
+ let count = countUnresolved(node.discussions);
328
+ let page = node.discussions.pageInfo;
329
+ let pages = 1;
330
+ while (page.hasNextPage && page.endCursor && pages < MAX_DISCUSSION_PAGES) {
331
+ const data = await this.graphql(host, DISCUSSIONS_QUERY, {
332
+ project: node.targetProject.fullPath,
333
+ iid: node.iid,
334
+ after: page.endCursor
335
+ });
336
+ const next = data?.project?.mergeRequest?.discussions;
337
+ if (!next)
338
+ break;
339
+ count += countUnresolved(next);
340
+ page = next.pageInfo;
341
+ pages++;
342
+ }
343
+ return { count, complete: !page.hasNextPage };
344
+ }
345
+ async normalize(host, node) {
346
+ const threads = await this.unresolvedThreads(host, node);
347
+ const pipeline = node.headPipeline;
348
+ const hasApprovals = (node.approvedBy?.nodes ?? []).some((approver) => approver.bot !== true);
349
+ const awaitingReviewers = (node.reviewers?.nodes ?? []).filter((reviewer) => reviewer.bot !== true && reviewer.username && reviewer.mergeRequestInteraction?.approved !== true).map((reviewer) => reviewer.username);
350
+ return {
351
+ forge: "gitlab",
352
+ iid: node.iid,
353
+ title: node.title,
354
+ url: node.webUrl,
355
+ state: node.state.toLowerCase(),
356
+ draft: node.draft,
357
+ conflicts: node.conflicts,
358
+ conflictsKnown: true,
359
+ approved: node.approved === true && hasApprovals && awaitingReviewers.length === 0,
360
+ hasApprovals,
361
+ approvalRequirementsSatisfied: node.approved,
362
+ awaitingReviewers,
363
+ reviewersComplete: true,
364
+ mergeStatus: node.detailedMergeStatus ?? undefined,
365
+ targetProject: node.targetProject.fullPath,
366
+ targetBranch: node.targetBranch,
367
+ headSha: node.diffHeadSha ?? undefined,
368
+ updatedAt: node.updatedAt ?? undefined,
369
+ pipeline: pipeline ? {
370
+ status: pipeline.status,
371
+ label: pipeline.detailedStatus?.label ?? pipeline.status.toLowerCase().replace(/_/g, " "),
372
+ url: pipeline.path ? `https://${host}${pipeline.path}` : undefined
373
+ } : undefined,
374
+ unresolvedThreads: threads.count,
375
+ threadsComplete: threads.complete,
376
+ duoReviewState: node.reviewers?.nodes.find((reviewer) => reviewer.type === "DUO_CODE_REVIEW_BOT")?.mergeRequestInteraction?.reviewState ?? undefined
377
+ };
378
+ }
379
+ }
380
+
381
+ // packages/forgekeeper/src/github.ts
382
+ var fields = `number title url state isDraft mergeable reviewDecision headRefOid headRefName
383
+ headRepository { nameWithOwner } baseRepository { nameWithOwner } baseRefName updatedAt
384
+ reviewThreads(first:100) { nodes { isResolved } pageInfo { hasNextPage endCursor } }
385
+ reviewRequests(first:100) { nodes { requestedReviewer { ... on User { login } ... on Team { name } } } pageInfo { hasNextPage } }
386
+ commits(last:1) { nodes { commit { statusCheckRollup { contexts(first:100) {
387
+ nodes { __typename ... on CheckRun { status conclusion detailsUrl } ... on StatusContext { state targetUrl } }
388
+ pageInfo { hasNextPage endCursor }
389
+ } } } } }`;
390
+ var MAX_PAGES = 50;
391
+ function ghGraphQL(run, cwd) {
392
+ return async (host, query, variables) => {
393
+ const args = ["api", "graphql", "--hostname", host, "-f", `query=${query}`];
394
+ for (const [name, value] of Object.entries(variables))
395
+ args.push(typeof value === "number" ? "-F" : "-f", `${name}=${value}`);
396
+ const result = await run("gh", args, cwd);
397
+ let body;
398
+ try {
399
+ body = JSON.parse(result.stdout);
400
+ } catch {
401
+ throw classifyError2(result.stderr, result.code);
402
+ }
403
+ const errors = (body.errors ?? []).filter((error) => error.type !== "NOT_FOUND");
404
+ if (!body.data || errors.length || result.code !== 0 && !body.errors?.length) {
405
+ throw classifyError2(errors[0]?.message ?? result.stderr, result.code);
406
+ }
407
+ return body.data;
408
+ };
409
+ }
410
+ function classifyError2(message, code) {
411
+ const text = message.trim() || `gh exited with code ${code}`;
412
+ if (/ENOENT|command not found/i.test(text))
413
+ return new ForgeError("missing-gh", "gh is not installed");
414
+ if (/401|unauthori[sz]ed|authenticate|not logged|token.*expired/i.test(text))
415
+ return new ForgeError("auth", "GitHub authentication failed. Run `gh auth status`.");
416
+ if (/429|rate limit|too many requests/i.test(text))
417
+ return new ForgeError("rate-limit", "GitHub rate limit reached");
418
+ return new ForgeError("request", text.split(`
419
+ `)[0]);
420
+ }
421
+ var FAILED = [
422
+ "FAILURE",
423
+ "ERROR",
424
+ "TIMED_OUT",
425
+ "CANCELLED",
426
+ "ACTION_REQUIRED",
427
+ "STARTUP_FAILURE",
428
+ "STALE"
429
+ ];
430
+ var CONCLUSIONS = [
431
+ "SUCCESS",
432
+ "NEUTRAL",
433
+ "SKIPPED",
434
+ "FAILURE",
435
+ "TIMED_OUT",
436
+ "CANCELLED",
437
+ "ACTION_REQUIRED",
438
+ "STARTUP_FAILURE",
439
+ "STALE"
440
+ ];
441
+ var STATES = ["SUCCESS", "FAILURE", "ERROR", "PENDING", "EXPECTED"];
442
+ function checks(nodes, complete, url) {
443
+ const failed = nodes.filter((node) => FAILED.includes(node.conclusion ?? node.state ?? "")).length;
444
+ const pending = nodes.filter((node) => node.status ? node.status !== "COMPLETED" : ["PENDING", "EXPECTED"].includes(node.state ?? "")).length;
445
+ const unknown = nodes.some((node) => node.status === "COMPLETED" ? !CONCLUSIONS.includes(node.conclusion ?? "") : !node.status && !STATES.includes(node.state ?? ""));
446
+ if (failed)
447
+ return {
448
+ status: "FAILED",
449
+ label: `${failed} failed${pending ? `, ${pending} pending` : ""}${complete ? "" : " (partial)"}`,
450
+ url
451
+ };
452
+ if (pending || !complete || unknown)
453
+ return {
454
+ status: "PENDING",
455
+ label: `${pending ? `${pending} pending` : "unknown"}${complete ? "" : " (partial)"}`,
456
+ url
457
+ };
458
+ return { status: "SUCCESS", label: `${nodes.length} passed or skipped`, url };
459
+ }
460
+ function parseGitHubUrl(url, pattern) {
461
+ const ref = parseWebUrl("github", url, pattern);
462
+ return ref && !gitlabTraits.recognizes(ref.host) ? ref : undefined;
463
+ }
464
+ var githubTraits = {
465
+ kind: "github",
466
+ noun: "PR",
467
+ ci: "checks",
468
+ recognizes: () => false,
469
+ open: (run, directory) => new GitHubForge(ghGraphQL(run, directory)),
470
+ owns: ownedBy("github"),
471
+ parseUrl: (url) => parseGitHubUrl(url, /^\/([^/]+\/[^/]+)\/pull\/([1-9]\d*)\/?$/),
472
+ parseIssueUrl: (url) => parseGitHubUrl(url, /^\/([^/]+\/[^/]+)\/issues\/([1-9]\d*)\/?$/),
473
+ reference: (iid) => `#${iid}`,
474
+ issueReference: (iid) => `#${iid}`,
475
+ titleReferences: (title) => prefixReferences(title, "#"),
476
+ indicators: (request) => request.reviewDecision === "CHANGES_REQUESTED" ? [{ text: "changes requested", tone: "warning", essential: true }] : [],
477
+ details: (request) => [
478
+ `Review decision: ${request.reviewDecision ? humanize(request.reviewDecision) : "not reported"}`
479
+ ]
480
+ };
481
+
482
+ class GitHubForge {
483
+ graphql;
484
+ traits = githubTraits;
485
+ constructor(graphql) {
486
+ this.graphql = graphql;
487
+ }
488
+ async findByNumber(projects, number) {
489
+ for (const project of projects) {
490
+ const [owner, repo] = project.path.split("/");
491
+ const data = await this.graphql(project.host, `query($owner:String!,$repo:String!,$number:Int!) {
492
+ repository(owner:$owner,name:$repo) { pullRequest(number:$number) { ${fields} } }
493
+ }`, { owner, repo, number: Number(number) });
494
+ const node = data.repository?.pullRequest;
495
+ if (node)
496
+ return this.normalize(project.host, node);
497
+ }
498
+ return;
499
+ }
500
+ async findNumberByBranch(repository) {
501
+ let newest;
502
+ for (const target of repository.targets) {
503
+ const [owner, repo] = target.path.split("/");
504
+ const data = await this.graphql(target.host, `query($owner:String!,$repo:String!,$branch:String!) {
505
+ repository(owner:$owner,name:$repo) { pullRequests(first:100,states:OPEN,headRefName:$branch,orderBy:{field:UPDATED_AT,direction:DESC}) {
506
+ nodes { number updatedAt headRepository { nameWithOwner } }
507
+ } } }`, { owner, repo, branch: repository.sourceBranch });
508
+ const nodes = data.repository?.pullRequests?.nodes ?? [];
509
+ for (const node of nodes) {
510
+ if (node.headRepository?.nameWithOwner.toLowerCase() !== repository.source.path.toLowerCase())
511
+ continue;
512
+ if (!newest || node.updatedAt > newest.updatedAt)
513
+ newest = node;
514
+ }
515
+ }
516
+ return newest ? String(newest.number) : undefined;
517
+ }
518
+ async sourceBranch(ref) {
519
+ const [owner, repo] = ref.project.split("/");
520
+ const data = await this.graphql(ref.host, `query($owner:String!,$repo:String!,$number:Int!) {
521
+ repository(owner:$owner,name:$repo) { pullRequest(number:$number) { headRefName } }
522
+ }`, { owner, repo, number: Number(ref.iid) });
523
+ const branch = data.repository?.pullRequest?.headRefName;
524
+ return typeof branch === "string" && branch ? branch : undefined;
525
+ }
526
+ async findByBranch(repository) {
527
+ const found = new Map;
528
+ for (const target of repository.targets) {
529
+ const [owner, repo] = target.path.split("/");
530
+ let after;
531
+ let complete = false;
532
+ for (let page = 0;page < MAX_PAGES; page++) {
533
+ const data = await this.graphql(target.host, `query($owner:String!,$repo:String!,$branch:String!${after ? ",$after:String!" : ""}) {
534
+ repository(owner:$owner,name:$repo) { pullRequests(first:100,states:OPEN,headRefName:$branch${after ? ",after:$after" : ""}) {
535
+ nodes { ${fields} } pageInfo { hasNextPage endCursor }
536
+ } } }`, { owner, repo, branch: repository.sourceBranch, ...after ? { after } : {} });
537
+ const pulls = data.repository?.pullRequests;
538
+ if (!pulls)
539
+ throw new ForgeError("request", "GitHub repository unavailable");
540
+ for (const node of pulls.nodes) {
541
+ if (node.headRepository?.nameWithOwner.toLowerCase() !== repository.source.path.toLowerCase())
542
+ continue;
543
+ if (!found.has(node.url))
544
+ found.set(node.url, await this.normalize(target.host, node));
545
+ }
546
+ if (!pulls.pageInfo?.hasNextPage) {
547
+ complete = true;
548
+ break;
549
+ }
550
+ after = pulls.pageInfo.endCursor;
551
+ if (!after)
552
+ break;
553
+ }
554
+ if (!complete)
555
+ throw new ForgeError("request", "GitHub PR discovery exceeded its pagination limit");
556
+ }
557
+ return [...found.values()].sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
558
+ }
559
+ async threads(host, node) {
560
+ const [owner, repo] = node.baseRepository.nameWithOwner.split("/");
561
+ const threads = [...node.reviewThreads.nodes];
562
+ let page = node.reviewThreads.pageInfo;
563
+ for (let count = 1;page?.hasNextPage && page.endCursor && count < MAX_PAGES; count++) {
564
+ const data = await this.graphql(host, `query($owner:String!,$repo:String!,$number:Int!,$after:String!) {
565
+ repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
566
+ reviewThreads(first:100,after:$after) { nodes { isResolved } pageInfo { hasNextPage endCursor } }
567
+ } } }`, { owner, repo, number: node.number, after: page.endCursor });
568
+ const next = data.repository?.pullRequest?.reviewThreads;
569
+ if (!next)
570
+ break;
571
+ threads.push(...next.nodes);
572
+ page = next.pageInfo;
573
+ }
574
+ return {
575
+ unresolved: threads.filter((thread) => !thread.isResolved).length,
576
+ complete: !page?.hasNextPage
577
+ };
578
+ }
579
+ async contexts(host, node) {
580
+ const [owner, repo] = node.baseRepository.nameWithOwner.split("/");
581
+ const rollup = node.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
582
+ const contexts = [...rollup?.nodes ?? []];
583
+ let cursor = rollup?.pageInfo;
584
+ for (let count = 1;cursor?.hasNextPage && cursor.endCursor && count < MAX_PAGES; count++) {
585
+ const data = await this.graphql(host, `query($owner:String!,$repo:String!,$oid:GitObjectID!,$after:String!) {
586
+ repository(owner:$owner,name:$repo) { object(oid:$oid) { ... on Commit { statusCheckRollup {
587
+ contexts(first:100,after:$after) { nodes { __typename ... on CheckRun { status conclusion detailsUrl } ... on StatusContext { state targetUrl } } pageInfo { hasNextPage endCursor } }
588
+ } } } } }`, { owner, repo, oid: node.headRefOid, after: cursor.endCursor });
589
+ const next = data.repository?.object?.statusCheckRollup?.contexts;
590
+ if (!next)
591
+ break;
592
+ contexts.push(...next.nodes);
593
+ cursor = next.pageInfo;
594
+ }
595
+ return { nodes: contexts, complete: !cursor?.hasNextPage };
596
+ }
597
+ async normalize(host, node) {
598
+ const [threads, contexts] = [await this.threads(host, node), await this.contexts(host, node)];
599
+ return {
600
+ forge: "github",
601
+ iid: String(node.number),
602
+ title: node.title,
603
+ url: node.url,
604
+ state: node.state === "OPEN" ? "opened" : node.state.toLowerCase(),
605
+ draft: node.isDraft,
606
+ conflicts: node.mergeable === "CONFLICTING",
607
+ conflictsKnown: node.mergeable !== "UNKNOWN",
608
+ approved: node.reviewDecision === "APPROVED",
609
+ reviewDecision: node.reviewDecision ?? undefined,
610
+ awaitingReviewers: node.reviewRequests.nodes.flatMap((request) => {
611
+ const reviewer = request.requestedReviewer;
612
+ return reviewer?.login ? [reviewer.login] : reviewer?.name ? [reviewer.name] : [];
613
+ }),
614
+ reviewersComplete: !node.reviewRequests.pageInfo?.hasNextPage,
615
+ targetProject: node.baseRepository.nameWithOwner,
616
+ targetBranch: node.baseRefName,
617
+ headSha: node.headRefOid,
618
+ updatedAt: node.updatedAt,
619
+ pipeline: contexts.nodes.length || !contexts.complete ? checks(contexts.nodes, contexts.complete, `${node.url}/checks`) : undefined,
620
+ unresolvedThreads: threads.unresolved,
621
+ threadsComplete: threads.complete
622
+ };
623
+ }
624
+ }
625
+
626
+ // packages/forgekeeper/src/forges.ts
627
+ var registry = { gitlab: gitlabTraits, github: githubTraits };
628
+ var kinds = Object.keys(registry);
629
+ var traitsOf = (request) => registry[request.forge];
630
+ var reference = (request) => traitsOf(request).reference(request.iid);
631
+
632
+ // packages/forgekeeper/src/automated-review-watch.ts
633
+ function automatedReview(request) {
634
+ const reviewer = traitsOf(request).automatedReview;
635
+ const status = reviewer?.status(request);
636
+ return reviewer && status ? { name: reviewer.name, ...status } : undefined;
637
+ }
638
+ var RETRY_DELAY = 10 * 60000;
639
+ var PENDING_RETENTION = 24 * 60 * 60000;
640
+
641
+ // packages/forgekeeper/src/format.ts
642
+ var RUNNING = new Set([
643
+ "CREATED",
644
+ "WAITING_FOR_RESOURCE",
645
+ "PREPARING",
646
+ "PENDING",
647
+ "RUNNING",
648
+ "SCHEDULED",
649
+ "WAITING_FOR_CALLBACK",
650
+ "CANCELING"
651
+ ]);
652
+ function pipelineTone(pipeline) {
653
+ if (!pipeline)
654
+ return "muted";
655
+ if (pipeline.status === "FAILED")
656
+ return "error";
657
+ if (pipeline.status === "SUCCESS")
658
+ return pipeline.label.includes("warning") ? "warning" : "success";
659
+ if (RUNNING.has(pipeline.status))
660
+ return "warning";
661
+ return "muted";
662
+ }
663
+ var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
664
+ var capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1);
665
+ var COMPACT_CI = {
666
+ success: "CI \u2713",
667
+ error: "CI \u2717",
668
+ warning: "CI \u2026"
669
+ };
670
+ function awaitingText(reviewers) {
671
+ if (reviewers.length > 2)
672
+ return `awaiting ${reviewers.length} reviewers`;
673
+ return `awaiting ${reviewers.map((username) => `@${username}`).join(", ")}`;
674
+ }
675
+ function threadsText(request) {
676
+ return `${request.unresolvedThreads}${request.threadsComplete ? "" : "+"} unresolved thread${request.unresolvedThreads === 1 && request.threadsComplete ? "" : "s"}`;
677
+ }
678
+ var link = (request) => ({
679
+ text: reference(request),
680
+ tone: "muted",
681
+ url: request.url,
682
+ essential: true
683
+ });
684
+ function ci(request) {
685
+ const tone = pipelineTone(request.pipeline);
686
+ if (!request.pipeline)
687
+ return { text: `no ${traitsOf(request).ci}`, compact: "no CI", tone };
688
+ return {
689
+ text: `CI ${request.pipeline.label}`,
690
+ compact: COMPACT_CI[tone],
691
+ tone,
692
+ url: request.pipeline.url
693
+ };
694
+ }
695
+ function targetsSummary(requests) {
696
+ const nouns = new Set(requests.map((request) => traitsOf(request).noun));
697
+ const noun = nouns.size === 1 ? [...nouns][0] : "PR/MR";
698
+ const count = requests.length === 1 ? `1 ${noun}` : `${requests.length} ${noun === "PR/MR" ? "PRs/MRs" : `${noun}s`}`;
699
+ const open = requests.filter((request) => request.state === "opened");
700
+ const segments = [{ text: count, tone: "muted", essential: true }];
701
+ const reviewing = open.filter((request) => automatedReview(request)?.state === "running").length;
702
+ if (reviewing)
703
+ segments.push({
704
+ text: `\uD83E\uDD16 ${reviewing} reviewing`,
705
+ compact: `\uD83E\uDD16 ${reviewing}`,
706
+ tone: "warning",
707
+ essential: true
708
+ });
709
+ const failing = open.filter((request) => pipelineTone(request.pipeline) === "error").length;
710
+ if (failing)
711
+ segments.push({ text: `${failing} CI failed`, compact: `CI \u2717 ${failing}`, tone: "error" });
712
+ const conflicts = open.filter((request) => request.conflicts).length;
713
+ if (conflicts)
714
+ segments.push({ text: plural(conflicts, "conflict"), tone: "error", essential: true });
715
+ const approved = open.filter((request) => request.approved && request.awaitingReviewers.length === 0).length;
716
+ if (approved)
717
+ segments.push({ text: `${approved} approved`, tone: "success" });
718
+ const merged = requests.filter((request) => request.state === "merged").length;
719
+ if (merged)
720
+ segments.push({ text: `${merged} merged`, tone: "success" });
721
+ const closed = requests.length - open.length - merged;
722
+ if (closed)
723
+ segments.push({ text: `${closed} closed`, tone: "muted" });
724
+ return segments;
725
+ }
726
+ function footerSegments(snapshot) {
727
+ const { lookup, error } = snapshot;
728
+ const stale = error ? [{ text: "stale", tone: "warning", essential: true }] : [];
729
+ if (!lookup || lookup.kind === "none" || lookup.requests.length === 0) {
730
+ return error ? [{ text: "PR/MR status unavailable", tone: "warning" }] : [];
731
+ }
732
+ if (lookup.failed?.length) {
733
+ stale.unshift({
734
+ text: `${lookup.failed.length} unavailable`,
735
+ tone: "warning",
736
+ essential: true
737
+ });
738
+ }
739
+ const requests = lookup.requests;
740
+ if (lookup.explicitTarget && requests.length > 1)
741
+ return [...targetsSummary(requests), ...stale];
742
+ if (requests.length > 1) {
743
+ const noun = traitsOf(requests[0]).noun;
744
+ return [
745
+ ...requests.map(link),
746
+ {
747
+ text: `${plural(requests.length, `open ${noun}`)}, run /${noun.toLowerCase()}-status`,
748
+ tone: "warning"
749
+ },
750
+ ...stale
751
+ ];
752
+ }
753
+ const [request] = requests;
754
+ if (request.state !== "opened") {
755
+ return [
756
+ link(request),
757
+ {
758
+ text: request.state,
759
+ tone: request.state === "merged" ? "success" : "muted",
760
+ essential: true
761
+ },
762
+ ...stale
763
+ ];
764
+ }
765
+ const segments = [link(request), ci(request)];
766
+ if (request.unresolvedThreads > 0 || !request.threadsComplete) {
767
+ const text = threadsText(request);
768
+ segments.push({ text, compact: text.replace("unresolved ", ""), tone: "warning" });
769
+ }
770
+ if (automatedReview(request)?.state === "running") {
771
+ segments.push({ text: "\uD83E\uDD16 reviewing", compact: "\uD83E\uDD16", tone: "warning", essential: true });
772
+ }
773
+ segments.push(...traitsOf(request).indicators(request));
774
+ if (request.conflicts)
775
+ segments.push({ text: "conflicts", tone: "error", essential: true });
776
+ if (!request.conflictsKnown)
777
+ segments.push({ text: "mergeability unknown", tone: "muted" });
778
+ if (request.awaitingReviewers.length)
779
+ segments.push({ text: awaitingText(request.awaitingReviewers), tone: "muted" });
780
+ else if (request.approved)
781
+ segments.push({ text: "approved", tone: "success" });
782
+ return [...segments, ...stale];
783
+ }
784
+ var segmentsWidth = (segments) => Bun.stringWidth(segments.map((segment) => segment.text).join(" \xB7 "));
785
+ function responsiveFooterSegments(snapshot, width) {
786
+ const full = footerSegments(snapshot);
787
+ if (segmentsWidth(full) <= width)
788
+ return full;
789
+ const compact = full.map((segment) => ({
790
+ ...segment,
791
+ text: segment.compact ?? segment.text
792
+ }));
793
+ if (segmentsWidth(compact) <= width)
794
+ return compact;
795
+ const minimal = compact.filter((segment) => segment.essential);
796
+ if (segmentsWidth(minimal) <= width)
797
+ return minimal;
798
+ if (minimal[0] && segmentsWidth([minimal[0]]) > width)
799
+ return [];
800
+ const fitted = [];
801
+ for (const segment of minimal) {
802
+ if (segmentsWidth([...fitted, segment]) <= width)
803
+ fitted.push(segment);
804
+ }
805
+ return fitted;
806
+ }
807
+ var time = (value) => new Date(value).toLocaleTimeString();
808
+ function describe(request, head) {
809
+ const traits = traitsOf(request);
810
+ const lines = [
811
+ `${reference(request)} ${request.title}${request.state === "opened" ? "" : ` (${request.state})`}`,
812
+ `Target: ${request.targetProject} \u2192 ${request.targetBranch}`,
813
+ `${capitalize(traits.ci)}: ${request.pipeline ? request.pipeline.label : "none"}${request.pipeline?.url ? ` (${request.pipeline.url})` : ""}`,
814
+ `Unresolved threads: ${request.unresolvedThreads}${request.threadsComplete ? "" : "+"}`,
815
+ `Draft: ${yesNo(request.draft)} \xB7 Conflicts: ${request.conflictsKnown ? yesNo(request.conflicts) : "unknown"}`,
816
+ ...traits.details(request)
817
+ ];
818
+ const review = automatedReview(request);
819
+ if (review)
820
+ lines.push(`${review.name} review: ${review.label}`);
821
+ if (request.awaitingReviewers.length) {
822
+ lines.push(`Awaiting review: ${request.awaitingReviewers.map((username) => `@${username}`).join(", ")}`);
823
+ }
824
+ if (!request.reviewersComplete)
825
+ lines.push("Reviewer list is partial");
826
+ if (head && request.headSha && head !== request.headSha) {
827
+ lines.push(`Local HEAD differs from the ${traits.noun} head commit (unpushed or not fetched)`);
828
+ }
829
+ lines.push(request.url);
830
+ return lines;
831
+ }
832
+ function detailsMessage(snapshot) {
833
+ const { lookup, error, fetchedAt } = snapshot;
834
+ const lines = [];
835
+ if (!lookup) {
836
+ lines.push(error ? "Could not look up the PR/MR." : "No data yet.");
837
+ } else if (lookup.kind === "none") {
838
+ lines.push(`No PR/MR lookup: ${lookup.reason}.`);
839
+ } else {
840
+ const { repository, sessionTarget, requests } = lookup;
841
+ if (repository) {
842
+ const branch = repository.branch === repository.sourceBranch ? repository.branch : `${repository.branch} (pushes to ${repository.sourceBranch})`;
843
+ lines.push(`Branch: ${branch} in ${repository.source.path}`);
844
+ if (requests.length === 0)
845
+ lines.push("", "No open PR/MR for this branch.");
846
+ } else if (sessionTarget) {
847
+ lines.push(`Session target: ${sessionTarget}`);
848
+ }
849
+ for (const { url, reason } of lookup.failed ?? [])
850
+ lines.push(`Unavailable: ${url} (${reason})`);
851
+ for (const request of requests)
852
+ lines.push("", ...describe(request, repository?.head ?? ""));
853
+ }
854
+ if (error)
855
+ lines.push("", `Last refresh failed at ${time(error.at)}: ${error.message}`);
856
+ if (fetchedAt !== undefined)
857
+ lines.push("", `Last fetched: ${time(fetchedAt)}`);
858
+ return lines.join(`
859
+ `);
860
+ }
861
+
862
+ // packages/forgekeeper/src/rpc.ts
863
+ var urls = {
864
+ url: { type: "string" },
865
+ issueUrl: { type: "string" }
866
+ };
867
+ var target = {
868
+ type: "object",
869
+ properties: {
870
+ ...urls,
871
+ targets: {
872
+ type: "array",
873
+ items: { type: "object", properties: urls, required: ["url"], additionalProperties: false }
874
+ }
875
+ },
876
+ additionalProperties: false
877
+ };
878
+ var key = { type: "string" };
879
+ var snapshot = { type: "object" };
880
+ var status = {
881
+ type: "object",
882
+ properties: { enabled: { type: "boolean" }, snapshot },
883
+ required: ["enabled", "snapshot"]
884
+ };
885
+ var ForgeSessionRpc = {
886
+ id: "opencode-forge-session-title",
887
+ methods: {
888
+ target: {
889
+ input: {
890
+ type: "object",
891
+ properties: { sessionID: { type: "string" } },
892
+ required: ["sessionID"],
893
+ additionalProperties: false
894
+ },
895
+ output: target
896
+ },
897
+ watch: {
898
+ input: {
899
+ type: "object",
900
+ properties: {
901
+ clientID: { type: "string" },
902
+ keys: { type: "array", items: key },
903
+ visible: key
904
+ },
905
+ required: ["clientID", "keys"],
906
+ additionalProperties: false
907
+ },
908
+ output: {
909
+ type: "object",
910
+ properties: { enabled: { type: "boolean" }, statuses: { type: "object" } },
911
+ required: ["enabled", "statuses"]
912
+ }
913
+ },
914
+ release: {
915
+ input: {
916
+ type: "object",
917
+ properties: { clientID: { type: "string" } },
918
+ required: ["clientID"],
919
+ additionalProperties: false
920
+ },
921
+ output: { type: "object" }
922
+ },
923
+ refresh: {
924
+ input: {
925
+ type: "object",
926
+ properties: { key },
927
+ required: ["key"],
928
+ additionalProperties: false
929
+ },
930
+ output: status
931
+ }
932
+ },
933
+ events: {
934
+ targetChanged: {
935
+ schema: {
936
+ type: "object",
937
+ properties: { sessionID: { type: "string" }, ...target.properties },
938
+ required: ["sessionID"],
939
+ additionalProperties: false
940
+ }
941
+ },
942
+ status: {
943
+ schema: {
944
+ type: "object",
945
+ properties: { directory: { type: "string" }, key, snapshot },
946
+ required: ["directory", "key", "snapshot"]
947
+ }
948
+ },
949
+ notice: {
950
+ schema: {
951
+ type: "object",
952
+ properties: {
953
+ sessionID: { type: "string" },
954
+ title: { type: "string" },
955
+ message: { type: "string" },
956
+ variant: { type: "string", enum: ["info", "error"] }
957
+ },
958
+ required: ["message", "variant"]
959
+ }
960
+ }
961
+ }
962
+ };
963
+ var HOME = "";
964
+ var HEARTBEAT = 60000;
965
+ var LEASE_TIME = 3 * HEARTBEAT;
966
+
967
+ // packages/forgekeeper/src/status-client.ts
968
+ var samePlace = (a, b) => a.directory === b.directory && a.key === b.key;
969
+ var placeID = (place) => JSON.stringify([place.directory, place.key]);
970
+ function createStatusClient(options) {
971
+ const now = options.now ?? Date.now;
972
+ const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay));
973
+ const clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer));
974
+ const defer = options.defer ?? queueMicrotask;
975
+ const snapshots = new Map;
976
+ const shown = new Map;
977
+ const enabled = new Map;
978
+ let visible;
979
+ let disposed = false;
980
+ const isShown = (place) => shown.get(place.directory)?.has(place.key) === true;
981
+ function apply(place, snapshot2) {
982
+ const id = placeID(place);
983
+ if ((snapshot2.revision ?? -1) >= (snapshots.get(id)?.revision ?? -1))
984
+ snapshots.set(id, snapshot2);
985
+ }
986
+ function unavailable(directory, error) {
987
+ const reason = error instanceof Error ? error.message : String(error);
988
+ const failure = {
989
+ kind: "request",
990
+ message: `PR/MR status service unavailable: ${reason}`,
991
+ at: now()
992
+ };
993
+ for (const key2 of shown.get(directory) ?? []) {
994
+ const id = placeID({ directory, key: key2 });
995
+ snapshots.set(id, { ...snapshots.get(id) ?? {}, loading: false, error: failure });
996
+ }
997
+ options.onChange();
998
+ }
999
+ const failures = new Map;
1000
+ const retries = new Map;
1001
+ function retryLater(directory) {
1002
+ const count = (failures.get(directory) ?? 0) + 1;
1003
+ failures.set(directory, count);
1004
+ const timer = retries.get(directory);
1005
+ if (timer !== undefined)
1006
+ clearTimer(timer);
1007
+ retries.set(directory, setTimer(() => void renew(directory), Math.min(HEARTBEAT, 1000 * 2 ** count)));
1008
+ }
1009
+ async function renew(directory) {
1010
+ if (disposed)
1011
+ return;
1012
+ const timer = retries.get(directory);
1013
+ if (timer !== undefined)
1014
+ clearTimer(timer);
1015
+ retries.delete(directory);
1016
+ const keys = [...shown.get(directory) ?? []];
1017
+ try {
1018
+ const result = await options.watch(directory, {
1019
+ clientID: options.clientID,
1020
+ keys,
1021
+ ...visible?.directory === directory ? { visible: visible.key } : {}
1022
+ });
1023
+ failures.delete(directory);
1024
+ enabled.set(directory, result.enabled);
1025
+ for (const [key2, snapshot2] of Object.entries(result.statuses)) {
1026
+ if (isShown({ directory, key: key2 }))
1027
+ apply({ directory, key: key2 }, snapshot2);
1028
+ }
1029
+ options.onChange();
1030
+ } catch (error) {
1031
+ unavailable(directory, error);
1032
+ retryLater(directory);
1033
+ }
1034
+ }
1035
+ const due = new Set;
1036
+ function renewSoon(directory) {
1037
+ if (due.has(directory))
1038
+ return;
1039
+ due.add(directory);
1040
+ defer(() => {
1041
+ due.delete(directory);
1042
+ renew(directory);
1043
+ });
1044
+ }
1045
+ return {
1046
+ snapshot: (place) => snapshots.get(placeID(place)) ?? { loading: false },
1047
+ enabled: (directory) => enabled.get(directory) ?? true,
1048
+ show(place) {
1049
+ if (place.key !== HOME) {
1050
+ for (const [directory, keys2] of shown) {
1051
+ if (directory === place.directory || !keys2.delete(place.key))
1052
+ continue;
1053
+ snapshots.delete(placeID({ directory, key: place.key }));
1054
+ if (visible && samePlace(visible, { directory, key: place.key }))
1055
+ visible = undefined;
1056
+ renewSoon(directory);
1057
+ }
1058
+ }
1059
+ const keys = shown.get(place.directory) ?? new Set;
1060
+ shown.set(place.directory, keys);
1061
+ keys.add(place.key);
1062
+ visible = place;
1063
+ renewSoon(place.directory);
1064
+ },
1065
+ hide(place) {
1066
+ if (visible && samePlace(visible, place))
1067
+ visible = undefined;
1068
+ renewSoon(place.directory);
1069
+ },
1070
+ async refresh(place) {
1071
+ const id = placeID(place);
1072
+ snapshots.set(id, { ...snapshots.get(id) ?? {}, loading: true });
1073
+ options.onChange();
1074
+ try {
1075
+ const result = await options.refresh(place.directory, place.key);
1076
+ enabled.set(place.directory, result.enabled);
1077
+ apply(place, result.snapshot);
1078
+ options.onChange();
1079
+ } catch (error) {
1080
+ unavailable(place.directory, error);
1081
+ }
1082
+ },
1083
+ heartbeat() {
1084
+ for (const directory of shown.keys())
1085
+ renew(directory);
1086
+ },
1087
+ receive(directory, key2, snapshot2) {
1088
+ if (!isShown({ directory, key: key2 }))
1089
+ return;
1090
+ apply({ directory, key: key2 }, snapshot2);
1091
+ options.onChange();
1092
+ },
1093
+ hasShown: (sessionID) => [...shown.values()].some((keys) => keys.has(sessionID)),
1094
+ dispose() {
1095
+ disposed = true;
1096
+ for (const timer of retries.values())
1097
+ clearTimer(timer);
1098
+ for (const directory of shown.keys())
1099
+ options.release(directory, options.clientID).catch(() => {});
1100
+ }
1101
+ };
1102
+ }
1103
+
1104
+ // packages/forgekeeper/src/tui.tsx
1105
+ var tui_default = {
1106
+ id: "pedropombeiro.forgekeeper",
1107
+ async setup(context) {
1108
+ const rpc = context.client.rpc(ForgeSessionRpc);
1109
+ const [version, setVersion] = createSignal(0);
1110
+ const client = createStatusClient({
1111
+ clientID: crypto.randomUUID(),
1112
+ watch: async (directory, input) => await rpc.watch(input, {
1113
+ location: {
1114
+ directory
1115
+ }
1116
+ }),
1117
+ refresh: async (directory, key2) => await rpc.refresh({
1118
+ key: key2
1119
+ }, {
1120
+ location: {
1121
+ directory
1122
+ }
1123
+ }),
1124
+ release: (directory, clientID) => rpc.release({
1125
+ clientID
1126
+ }, {
1127
+ location: {
1128
+ directory
1129
+ }
1130
+ }),
1131
+ onChange: () => setVersion((value) => value + 1)
1132
+ });
1133
+ const directoryFor = (sessionID) => (sessionID ? context.data.session.get(sessionID)?.location.directory : undefined) ?? context.location?.directory ?? context.data.location.default().directory;
1134
+ const placeFor = (sessionID) => ({
1135
+ directory: directoryFor(sessionID),
1136
+ key: sessionID ?? HOME
1137
+ });
1138
+ const currentPlace = () => {
1139
+ const route = context.ui.router.current();
1140
+ return placeFor(route.type === "session" ? route.sessionID : undefined);
1141
+ };
1142
+ const snapshotOf = (place) => {
1143
+ version();
1144
+ return client.snapshot(place);
1145
+ };
1146
+ const enabledFor = (directory) => {
1147
+ version();
1148
+ return client.enabled(directory);
1149
+ };
1150
+ const refresh = (place) => client.refresh(place);
1151
+ const heartbeat = setInterval(() => client.heartbeat(), HEARTBEAT);
1152
+ const color = (tone) => {
1153
+ const feedback = context.theme.text.feedback;
1154
+ if (tone === "success")
1155
+ return feedback.success.base;
1156
+ if (tone === "warning")
1157
+ return feedback.warning.base;
1158
+ if (tone === "error")
1159
+ return feedback.error.base;
1160
+ return context.theme.text.muted;
1161
+ };
1162
+ function Footer(props) {
1163
+ const place = createMemo(() => placeFor(props.sessionID), undefined, {
1164
+ equals: samePlace
1165
+ });
1166
+ createEffect(() => {
1167
+ const current = place();
1168
+ client.show(current);
1169
+ onCleanup(() => client.hide(current));
1170
+ });
1171
+ createEffect(() => {
1172
+ context.data.location.vcs.sync({
1173
+ directory: place().directory
1174
+ }).catch(() => {});
1175
+ });
1176
+ const snapshot2 = createMemo(() => snapshotOf(place()));
1177
+ const fullWidth = createMemo(() => enabledFor(place().directory) ? segmentsWidth(footerSegments(snapshot2())) : 0);
1178
+ const [availableWidth, setAvailableWidth] = createSignal(0);
1179
+ const segments = createMemo(() => responsiveFooterSegments(snapshot2(), availableWidth()));
1180
+ return _$createComponent(Show, {
1181
+ get when() {
1182
+ return fullWidth() > 0;
1183
+ },
1184
+ get children() {
1185
+ var _el$ = _$createElement("box");
1186
+ _$setProp(_el$, "flexDirection", "row");
1187
+ _$setProp(_el$, "flexShrink", 1);
1188
+ _$setProp(_el$, "minWidth", 0);
1189
+ _$setProp(_el$, "height", 1);
1190
+ _$setProp(_el$, "overflow", "hidden");
1191
+ _$setProp(_el$, "onSizeChange", function() {
1192
+ const width = this.width;
1193
+ queueMicrotask(() => setAvailableWidth(width));
1194
+ });
1195
+ _$insert(_el$, _$createComponent(For, {
1196
+ get each() {
1197
+ return segments();
1198
+ },
1199
+ children: (segment, index) => [_$createComponent(Show, {
1200
+ get when() {
1201
+ return index() > 0;
1202
+ },
1203
+ get children() {
1204
+ var _el$2 = _$createElement("text");
1205
+ _$insertNode(_el$2, _$createTextNode(` \xB7 `));
1206
+ _$setProp(_el$2, "wrapMode", "none");
1207
+ _$setProp(_el$2, "flexShrink", 0);
1208
+ _$effect((_$p) => _$setProp(_el$2, "fg", context.theme.text.muted, _$p));
1209
+ return _el$2;
1210
+ }
1211
+ }), _$createComponent(Show, {
1212
+ get when() {
1213
+ return segment.url;
1214
+ },
1215
+ get fallback() {
1216
+ return (() => {
1217
+ var _el$4 = _$createElement("text");
1218
+ _$setProp(_el$4, "wrapMode", "none");
1219
+ _$setProp(_el$4, "flexShrink", 0);
1220
+ _$setProp(_el$4, "onMouseUp", () => showStatus(place()));
1221
+ _$insert(_el$4, () => segment.text);
1222
+ _$effect((_$p) => _$setProp(_el$4, "fg", color(segment.tone), _$p));
1223
+ return _el$4;
1224
+ })();
1225
+ },
1226
+ children: (url) => (() => {
1227
+ var _el$5 = _$createElement("text"), _el$6 = _$createElement("a"), _el$7 = _$createElement("u");
1228
+ _$insertNode(_el$5, _el$6);
1229
+ _$setProp(_el$5, "wrapMode", "none");
1230
+ _$setProp(_el$5, "flexShrink", 0);
1231
+ _$setProp(_el$5, "onMouseUp", () => openUrl(url()));
1232
+ _$insertNode(_el$6, _el$7);
1233
+ _$insert(_el$7, () => segment.text);
1234
+ _$effect((_p$) => {
1235
+ var _v$ = color(segment.tone), _v$2 = url();
1236
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$5, "fg", _v$, _p$.e));
1237
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$6, "href", _v$2, _p$.t));
1238
+ return _p$;
1239
+ }, {
1240
+ e: undefined,
1241
+ t: undefined
1242
+ });
1243
+ return _el$5;
1244
+ })()
1245
+ })]
1246
+ }));
1247
+ _$effect((_$p) => _$setProp(_el$, "width", fullWidth(), _$p));
1248
+ return _el$;
1249
+ }
1250
+ });
1251
+ }
1252
+ const openUrl = (url) => {
1253
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
1254
+ exec2(opener, [url], process.cwd()).then((result) => {
1255
+ if (result.code !== 0)
1256
+ context.ui.toast.show({
1257
+ message: `Could not open ${url}`,
1258
+ variant: "error"
1259
+ });
1260
+ });
1261
+ };
1262
+ const requestsFor = (place) => {
1263
+ const lookup = snapshotOf(place).lookup;
1264
+ return lookup?.kind === "found" ? lookup.requests : [];
1265
+ };
1266
+ const statusOff = (place) => {
1267
+ if (enabledFor(place.directory))
1268
+ return false;
1269
+ context.ui.toast.show({
1270
+ message: "PR/MR status is off in the plugin's options (reviewStatus: false)",
1271
+ variant: "info"
1272
+ });
1273
+ return true;
1274
+ };
1275
+ function StatusDialog(props) {
1276
+ const [terminalHeight, setTerminalHeight] = createSignal(context.renderer.height);
1277
+ const onResize = () => setTerminalHeight(context.renderer.height);
1278
+ context.renderer.on("resize", onResize);
1279
+ onCleanup(() => context.renderer.off("resize", onResize));
1280
+ const contentHeight = createMemo(() => Math.max(1, Math.min(24, Math.floor(terminalHeight() * 0.6) - 5)));
1281
+ const snapshot2 = createMemo(() => snapshotOf(props.place));
1282
+ const reload = () => {
1283
+ if (!snapshot2().loading)
1284
+ refresh(props.place);
1285
+ };
1286
+ const [activeAction, setActiveAction] = createSignal("refresh");
1287
+ const moveAction = () => setActiveAction((action) => action === "refresh" || snapshot2().loading ? "close" : "refresh");
1288
+ const close = () => context.ui.dialog.clear();
1289
+ context.keymap.layer(() => ({
1290
+ mode: "modal",
1291
+ commands: [{
1292
+ id: "forge.review.status.refresh",
1293
+ bind: "ctrl+r",
1294
+ enabled: () => !snapshot2().loading,
1295
+ run: reload
1296
+ }, {
1297
+ bind: "tab",
1298
+ title: "Next dialog action",
1299
+ run: moveAction
1300
+ }, {
1301
+ bind: "shift+tab",
1302
+ title: "Previous dialog action",
1303
+ run: moveAction
1304
+ }, {
1305
+ bind: "left",
1306
+ title: "Previous dialog action",
1307
+ run: moveAction
1308
+ }, {
1309
+ bind: "right",
1310
+ title: "Next dialog action",
1311
+ run: moveAction
1312
+ }, {
1313
+ bind: "return",
1314
+ title: "Activate dialog action",
1315
+ run: () => {
1316
+ if (activeAction() === "refresh")
1317
+ reload();
1318
+ else
1319
+ close();
1320
+ }
1321
+ }]
1322
+ }));
1323
+ return (() => {
1324
+ var _el$8 = _$createElement("box"), _el$9 = _$createElement("box"), _el$0 = _$createElement("text"), _el$1 = _$createElement("b"), _el$11 = _$createElement("text"), _el$13 = _$createElement("scrollbox"), _el$14 = _$createElement("text"), _el$15 = _$createElement("box");
1325
+ _$insertNode(_el$8, _el$9);
1326
+ _$insertNode(_el$8, _el$13);
1327
+ _$insertNode(_el$8, _el$15);
1328
+ _$setProp(_el$8, "paddingLeft", 2);
1329
+ _$setProp(_el$8, "paddingRight", 2);
1330
+ _$setProp(_el$8, "paddingBottom", 1);
1331
+ _$setProp(_el$8, "gap", 1);
1332
+ _$insertNode(_el$9, _el$0);
1333
+ _$insertNode(_el$9, _el$11);
1334
+ _$setProp(_el$9, "height", 1);
1335
+ _$setProp(_el$9, "flexShrink", 0);
1336
+ _$setProp(_el$9, "flexDirection", "row");
1337
+ _$setProp(_el$9, "justifyContent", "space-between");
1338
+ _$insertNode(_el$0, _el$1);
1339
+ _$insertNode(_el$1, _$createTextNode(`PR/MR status`));
1340
+ _$insertNode(_el$11, _$createTextNode(`esc`));
1341
+ _$setProp(_el$11, "onMouseUp", close);
1342
+ _$insertNode(_el$13, _el$14);
1343
+ _$setProp(_el$13, "minHeight", 1);
1344
+ _$setProp(_el$13, "flexShrink", 0);
1345
+ _$setProp(_el$13, "scrollX", false);
1346
+ _$setProp(_el$13, "contentOptions", {
1347
+ flexShrink: 0
1348
+ });
1349
+ _$insert(_el$14, () => detailsMessage(snapshot2()));
1350
+ _$setProp(_el$15, "height", 1);
1351
+ _$setProp(_el$15, "flexShrink", 0);
1352
+ _$setProp(_el$15, "flexDirection", "row");
1353
+ _$setProp(_el$15, "justifyContent", "flex-end");
1354
+ _$insert(_el$15, _$createComponent(For, {
1355
+ each: ["close", "refresh"],
1356
+ children: (action) => {
1357
+ const disabled = () => action === "refresh" && snapshot2().loading;
1358
+ const active = () => activeAction() === action && !disabled();
1359
+ return (() => {
1360
+ var _el$16 = _$createElement("box"), _el$17 = _$createElement("text");
1361
+ _$insertNode(_el$16, _el$17);
1362
+ _$setProp(_el$16, "paddingLeft", 1);
1363
+ _$setProp(_el$16, "paddingRight", 1);
1364
+ _$setProp(_el$16, "flexShrink", 0);
1365
+ _$setProp(_el$16, "onMouseMove", () => {
1366
+ if (!disabled())
1367
+ setActiveAction(action);
1368
+ });
1369
+ _$setProp(_el$16, "onMouseUp", () => {
1370
+ if (disabled())
1371
+ return;
1372
+ setActiveAction(action);
1373
+ if (action === "refresh")
1374
+ reload();
1375
+ else
1376
+ close();
1377
+ });
1378
+ _$setProp(_el$17, "wrapMode", "none");
1379
+ _$insert(_el$17, () => action === "close" ? "Close" : snapshot2().loading ? "Refreshing\u2026" : "Refresh");
1380
+ _$effect((_p$) => {
1381
+ var _v$8 = active() ? context.theme.background.action.primary.focused : undefined, _v$9 = disabled() ? context.theme.text.action.primary.disabled : active() ? context.theme.text.action.primary.focused : context.theme.text.muted;
1382
+ _v$8 !== _p$.e && (_p$.e = _$setProp(_el$16, "backgroundColor", _v$8, _p$.e));
1383
+ _v$9 !== _p$.t && (_p$.t = _$setProp(_el$17, "fg", _v$9, _p$.t));
1384
+ return _p$;
1385
+ }, {
1386
+ e: undefined,
1387
+ t: undefined
1388
+ });
1389
+ return _el$16;
1390
+ })();
1391
+ }
1392
+ }));
1393
+ _$effect((_p$) => {
1394
+ var _v$3 = contentHeight() + 5, _v$4 = context.theme.text.base, _v$5 = context.theme.text.muted, _v$6 = contentHeight(), _v$7 = context.theme.text.base;
1395
+ _v$3 !== _p$.e && (_p$.e = _$setProp(_el$8, "height", _v$3, _p$.e));
1396
+ _v$4 !== _p$.t && (_p$.t = _$setProp(_el$0, "fg", _v$4, _p$.t));
1397
+ _v$5 !== _p$.a && (_p$.a = _$setProp(_el$11, "fg", _v$5, _p$.a));
1398
+ _v$6 !== _p$.o && (_p$.o = _$setProp(_el$13, "height", _v$6, _p$.o));
1399
+ _v$7 !== _p$.i && (_p$.i = _$setProp(_el$14, "fg", _v$7, _p$.i));
1400
+ return _p$;
1401
+ }, {
1402
+ e: undefined,
1403
+ t: undefined,
1404
+ a: undefined,
1405
+ o: undefined,
1406
+ i: undefined
1407
+ });
1408
+ return _el$8;
1409
+ })();
1410
+ }
1411
+ function showStatus(place = currentPlace()) {
1412
+ if (statusOff(place))
1413
+ return;
1414
+ refresh(place);
1415
+ context.ui.dialog.set({
1416
+ size: "large",
1417
+ centered: true
1418
+ });
1419
+ context.ui.dialog.show(() => _$createComponent(StatusDialog, {
1420
+ place
1421
+ }));
1422
+ }
1423
+ async function openRequest() {
1424
+ const place = currentPlace();
1425
+ if (statusOff(place))
1426
+ return;
1427
+ let requests = requestsFor(place);
1428
+ if (requests.length === 0) {
1429
+ await refresh(place);
1430
+ requests = requestsFor(place);
1431
+ }
1432
+ if (requests.length === 0) {
1433
+ context.ui.toast.show({
1434
+ message: "No PR/MR for this session or branch",
1435
+ variant: "info"
1436
+ });
1437
+ return;
1438
+ }
1439
+ if (requests.length === 1)
1440
+ return openUrl(requests[0].url);
1441
+ const url = await context.ui.dialog.select({
1442
+ title: "Open PR/MR",
1443
+ actions: [{
1444
+ bind: "ctrl+w",
1445
+ title: "Close",
1446
+ side: "right",
1447
+ selection: "none",
1448
+ onTrigger: () => context.ui.dialog.clear()
1449
+ }],
1450
+ options: requests.map((mr) => ({
1451
+ title: `${reference(mr)} ${mr.title}`,
1452
+ value: mr.url,
1453
+ description: mr.targetProject
1454
+ }))
1455
+ });
1456
+ if (url)
1457
+ openUrl(url);
1458
+ }
1459
+ context.ui.slot({
1460
+ append: "prompt.footer.status",
1461
+ render: (input) => _$createComponent(Footer, {
1462
+ get sessionID() {
1463
+ return input.sessionID;
1464
+ }
1465
+ })
1466
+ });
1467
+ context.ui.slot({
1468
+ append: "app",
1469
+ render: () => {
1470
+ context.keymap.layer(() => ({
1471
+ mode: "global",
1472
+ commands: [{
1473
+ id: "forge.review.status",
1474
+ title: "Show PR/MR status",
1475
+ group: "Forge",
1476
+ palette: true,
1477
+ slash: {
1478
+ name: "forge-status",
1479
+ aliases: ["mr-status", "pr-status"]
1480
+ },
1481
+ run: () => showStatus()
1482
+ }, {
1483
+ id: "forge.review.open",
1484
+ title: "Open PR/MR in browser",
1485
+ group: "Forge",
1486
+ palette: true,
1487
+ slash: {
1488
+ name: "forge-open",
1489
+ aliases: ["mr-open", "pr-open"]
1490
+ },
1491
+ run: openRequest
1492
+ }]
1493
+ }));
1494
+ return null;
1495
+ }
1496
+ });
1497
+ const stops = [
1498
+ rpc.events.on("status", (event) => {
1499
+ const {
1500
+ directory,
1501
+ key: key2,
1502
+ snapshot: snapshot2
1503
+ } = event.data;
1504
+ client.receive(directory, key2, snapshot2);
1505
+ }),
1506
+ rpc.events.on("notice", (event) => {
1507
+ const notice = event.data;
1508
+ if (notice.sessionID && !client.hasShown(notice.sessionID))
1509
+ return;
1510
+ context.ui.toast.show({
1511
+ ...notice.title ? {
1512
+ title: notice.title
1513
+ } : {},
1514
+ message: notice.message,
1515
+ variant: notice.variant
1516
+ });
1517
+ })
1518
+ ];
1519
+ return () => {
1520
+ clearInterval(heartbeat);
1521
+ for (const stop of stops)
1522
+ stop();
1523
+ client.dispose();
1524
+ };
1525
+ }
1526
+ };
1527
+ export {
1528
+ tui_default as default
1529
+ };