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.
- package/README.md +387 -0
- package/dist/index.js +2319 -0
- package/dist/tui.js +1529 -0
- 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
|
+
};
|