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/index.js
ADDED
|
@@ -0,0 +1,2319 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/_shared/src/exec.ts
|
|
3
|
+
import { execFile } from "child_process";
|
|
4
|
+
function exec(file, args, options = {}) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
execFile(file, args, {
|
|
7
|
+
cwd: options.cwd,
|
|
8
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
9
|
+
timeout: options.timeout,
|
|
10
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
11
|
+
encoding: "utf8"
|
|
12
|
+
}, (error, stdout, stderr) => {
|
|
13
|
+
if (!error) {
|
|
14
|
+
resolve({ stdout, stderr, code: 0 });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const code = typeof error.code === "number" ? error.code : -1;
|
|
18
|
+
resolve({ stdout, stderr: stderr || error.message, code });
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
// packages/forgekeeper/src/exec.ts
|
|
23
|
+
var execWithTimeout = (timeout) => (file, args, cwd) => exec(file, args, { cwd, timeout });
|
|
24
|
+
var exec2 = execWithTimeout(30000);
|
|
25
|
+
|
|
26
|
+
// packages/forgekeeper/src/git.ts
|
|
27
|
+
function parseRemote(url) {
|
|
28
|
+
const trimmed = url.trim();
|
|
29
|
+
let host;
|
|
30
|
+
let path;
|
|
31
|
+
const scp = trimmed.match(/^(?:[^@/]+@)?([^:/]+):(?!\/)(.+)$/);
|
|
32
|
+
if (scp && !/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
|
33
|
+
host = scp[1];
|
|
34
|
+
path = scp[2];
|
|
35
|
+
} else {
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = new URL(trimmed);
|
|
39
|
+
} catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!["ssh:", "https:", "http:", "git+ssh:", "ssh+git:"].includes(parsed.protocol))
|
|
43
|
+
return;
|
|
44
|
+
host = parsed.hostname;
|
|
45
|
+
path = decodeURIComponent(parsed.pathname);
|
|
46
|
+
}
|
|
47
|
+
path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, "");
|
|
48
|
+
if (!host || !path.includes("/"))
|
|
49
|
+
return;
|
|
50
|
+
return { host: host.toLowerCase(), path };
|
|
51
|
+
}
|
|
52
|
+
var stripHeads = (ref) => ref.replace(/^refs\/heads\//, "");
|
|
53
|
+
async function git(run, directory, args) {
|
|
54
|
+
const result = await run("git", args, directory);
|
|
55
|
+
return result.code === 0 ? result.stdout.trim() : undefined;
|
|
56
|
+
}
|
|
57
|
+
async function resolveRepository(run, directory, supported) {
|
|
58
|
+
const top = await git(run, directory, ["rev-parse", "--show-toplevel"]);
|
|
59
|
+
if (top === undefined)
|
|
60
|
+
return { kind: "none", reason: "Not a Git repository" };
|
|
61
|
+
const branch = await git(run, directory, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
62
|
+
if (!branch)
|
|
63
|
+
return { kind: "none", reason: "Detached HEAD" };
|
|
64
|
+
const [head, refs, remoteConfig] = await Promise.all([
|
|
65
|
+
git(run, directory, ["rev-parse", "HEAD"]),
|
|
66
|
+
git(run, directory, [
|
|
67
|
+
"for-each-ref",
|
|
68
|
+
"--format=%(push:remotename)%00%(push:remoteref)%00%(upstream:remotename)%00%(upstream:remoteref)",
|
|
69
|
+
`refs/heads/${branch}`
|
|
70
|
+
]),
|
|
71
|
+
git(run, directory, ["config", "--get-regexp", "^remote\\..*\\.url$"])
|
|
72
|
+
]);
|
|
73
|
+
const remotes = new Map;
|
|
74
|
+
for (const line of (remoteConfig ?? "").split(`
|
|
75
|
+
`)) {
|
|
76
|
+
const match = line.match(/^remote\.(.+)\.url\s+(.+)$/);
|
|
77
|
+
if (match)
|
|
78
|
+
remotes.set(match[1], match[2]);
|
|
79
|
+
}
|
|
80
|
+
if (remotes.size === 0)
|
|
81
|
+
return { kind: "none", reason: "No Git remotes" };
|
|
82
|
+
const [pushRemote, pushRef, upstreamRemote, upstreamRef] = (refs ?? "").split("\x00");
|
|
83
|
+
let sourceRemote;
|
|
84
|
+
let sourceBranch = branch;
|
|
85
|
+
if (pushRemote && remotes.has(pushRemote)) {
|
|
86
|
+
sourceRemote = pushRemote;
|
|
87
|
+
if (pushRef)
|
|
88
|
+
sourceBranch = stripHeads(pushRef);
|
|
89
|
+
} else if (upstreamRemote && remotes.has(upstreamRemote)) {
|
|
90
|
+
sourceRemote = upstreamRemote;
|
|
91
|
+
if (upstreamRef)
|
|
92
|
+
sourceBranch = stripHeads(upstreamRef);
|
|
93
|
+
} else if (remotes.has("origin")) {
|
|
94
|
+
sourceRemote = "origin";
|
|
95
|
+
} else if (remotes.size === 1) {
|
|
96
|
+
sourceRemote = [...remotes.keys()][0];
|
|
97
|
+
}
|
|
98
|
+
if (!sourceRemote)
|
|
99
|
+
return { kind: "none", reason: "No remote for the current branch" };
|
|
100
|
+
const source = parseRemote(remotes.get(sourceRemote));
|
|
101
|
+
if (!source || !supported(source.host))
|
|
102
|
+
return { kind: "none", reason: "Not a supported forge remote" };
|
|
103
|
+
const remoteHead = await git(run, directory, [
|
|
104
|
+
"symbolic-ref",
|
|
105
|
+
"--quiet",
|
|
106
|
+
"--short",
|
|
107
|
+
`refs/remotes/${sourceRemote}/HEAD`
|
|
108
|
+
]);
|
|
109
|
+
if (remoteHead && remoteHead === `${sourceRemote}/${sourceBranch}`) {
|
|
110
|
+
return { kind: "none", reason: "Default branch" };
|
|
111
|
+
}
|
|
112
|
+
const targets = [source];
|
|
113
|
+
for (const url of remotes.values()) {
|
|
114
|
+
const project = parseRemote(url);
|
|
115
|
+
if (!project || project.host !== source.host)
|
|
116
|
+
continue;
|
|
117
|
+
if (targets.some((target) => target.path.toLowerCase() === project.path.toLowerCase()))
|
|
118
|
+
continue;
|
|
119
|
+
targets.push(project);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
kind: "repository",
|
|
123
|
+
repository: { head: head ?? "", branch, sourceBranch, source, targets }
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async function resolveProjects(run, directory, accepted) {
|
|
127
|
+
const remoteConfig = await git(run, directory, ["config", "--get-regexp", "^remote\\..*\\.url$"]);
|
|
128
|
+
if (remoteConfig === undefined)
|
|
129
|
+
return { kind: "none", reason: "No Git remotes" };
|
|
130
|
+
const remotes = [];
|
|
131
|
+
for (const line of remoteConfig.split(`
|
|
132
|
+
`)) {
|
|
133
|
+
const match = line.match(/^remote\.(.+)\.url\s+(.+)$/);
|
|
134
|
+
if (match)
|
|
135
|
+
remotes.push([match[1], match[2]]);
|
|
136
|
+
}
|
|
137
|
+
remotes.sort(([a], [b]) => Number(b === "origin") - Number(a === "origin"));
|
|
138
|
+
const projects = [];
|
|
139
|
+
for (const [, url] of remotes) {
|
|
140
|
+
const project = parseRemote(url);
|
|
141
|
+
if (!project || !accepted(project.host))
|
|
142
|
+
continue;
|
|
143
|
+
if (projects.length > 0 && project.host !== projects[0].host)
|
|
144
|
+
continue;
|
|
145
|
+
if (projects.some((known) => known.path.toLowerCase() === project.path.toLowerCase()))
|
|
146
|
+
continue;
|
|
147
|
+
projects.push(project);
|
|
148
|
+
}
|
|
149
|
+
return projects.length > 0 ? { kind: "projects", projects } : { kind: "none", reason: "No matching forge remote" };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// packages/forgekeeper/src/forge.ts
|
|
153
|
+
var ownedBy = (kind) => (request) => request.forge === kind;
|
|
154
|
+
var titlePrefix = (title) => title?.match(/^\[[^\]]*\]/)?.[0] ?? "";
|
|
155
|
+
function prefixReferences(title, sigil) {
|
|
156
|
+
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)] : []);
|
|
157
|
+
}
|
|
158
|
+
function parseWebUrl(kind, value, pattern) {
|
|
159
|
+
let url;
|
|
160
|
+
try {
|
|
161
|
+
url = new URL(value);
|
|
162
|
+
} catch {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!["https:", "http:"].includes(url.protocol))
|
|
166
|
+
return;
|
|
167
|
+
const match = url.pathname.match(pattern);
|
|
168
|
+
if (!match)
|
|
169
|
+
return;
|
|
170
|
+
return {
|
|
171
|
+
forge: kind,
|
|
172
|
+
host: url.hostname.toLowerCase(),
|
|
173
|
+
project: decodeURIComponent(match[1]),
|
|
174
|
+
iid: match[2]
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
var yesNo = (value) => value ? "yes" : "no";
|
|
178
|
+
var humanize = (value) => value.toLowerCase().replace(/_/g, " ");
|
|
179
|
+
|
|
180
|
+
class ForgeError extends Error {
|
|
181
|
+
kind;
|
|
182
|
+
constructor(kind, message) {
|
|
183
|
+
super(message);
|
|
184
|
+
this.kind = kind;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// packages/forgekeeper/src/gitlab-feedback.ts
|
|
189
|
+
var PAGE_SIZE = 100;
|
|
190
|
+
var MAX_COMMENT_PAGES = 20;
|
|
191
|
+
var COMMENTS_QUERY = `query($project: ID!, $iid: String!, $before: String) {
|
|
192
|
+
currentUser { id }
|
|
193
|
+
project(fullPath: $project) {
|
|
194
|
+
mergeRequest(iid: $iid) {
|
|
195
|
+
notes(last: ${PAGE_SIZE}, before: $before, filter: ONLY_COMMENTS) {
|
|
196
|
+
pageInfo { hasPreviousPage startCursor }
|
|
197
|
+
nodes {
|
|
198
|
+
id url createdAt updatedAt lastEditedAt system
|
|
199
|
+
discussion { resolved }
|
|
200
|
+
author { id username bot }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}`;
|
|
206
|
+
var noteNumber = (id) => id.slice(id.lastIndexOf("/") + 1);
|
|
207
|
+
var timestamp = (node) => Date.parse(node.lastEditedAt ?? node.updatedAt ?? node.createdAt);
|
|
208
|
+
function toComment(node) {
|
|
209
|
+
const editedAt = timestamp(node);
|
|
210
|
+
const id = noteNumber(node.id);
|
|
211
|
+
if (node.system || !node.author || !Number.isFinite(editedAt) || !/^\d+$/.test(id))
|
|
212
|
+
return;
|
|
213
|
+
return {
|
|
214
|
+
id,
|
|
215
|
+
order: Number(id),
|
|
216
|
+
url: node.url ?? "",
|
|
217
|
+
editedAt,
|
|
218
|
+
resolved: node.discussion?.resolved === true,
|
|
219
|
+
bot: node.author.bot,
|
|
220
|
+
authorID: node.author.id,
|
|
221
|
+
username: node.author.username
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
async function fetchComments(graphql, host, project, iid) {
|
|
225
|
+
const pages = [];
|
|
226
|
+
let viewerID;
|
|
227
|
+
let before;
|
|
228
|
+
const snapshot = (complete) => ({
|
|
229
|
+
complete: complete && viewerID !== undefined,
|
|
230
|
+
viewerID,
|
|
231
|
+
comments: pages.toReversed().flat()
|
|
232
|
+
});
|
|
233
|
+
for (let page = 0;page < MAX_COMMENT_PAGES; page++) {
|
|
234
|
+
const variables = { project, iid };
|
|
235
|
+
if (before)
|
|
236
|
+
variables.before = before;
|
|
237
|
+
const data = await graphql(host, COMMENTS_QUERY, variables);
|
|
238
|
+
viewerID ??= data?.currentUser?.id ?? undefined;
|
|
239
|
+
const connection = data?.project?.mergeRequest?.notes;
|
|
240
|
+
if (!connection)
|
|
241
|
+
return page === 0 ? undefined : snapshot(false);
|
|
242
|
+
pages.push(connection.nodes.flatMap((node) => toComment(node) ?? []));
|
|
243
|
+
if (!connection.pageInfo.hasPreviousPage)
|
|
244
|
+
return snapshot(true);
|
|
245
|
+
before = connection.pageInfo.startCursor ?? undefined;
|
|
246
|
+
if (!before)
|
|
247
|
+
return snapshot(false);
|
|
248
|
+
}
|
|
249
|
+
return snapshot(true);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// packages/forgekeeper/src/gitlab.ts
|
|
253
|
+
var DISCUSSIONS = "pageInfo { hasNextPage endCursor } nodes { resolvable resolved }";
|
|
254
|
+
var PAGE_SIZE2 = 100;
|
|
255
|
+
var MAX_DISCUSSION_PAGES = 50;
|
|
256
|
+
var MERGE_REQUEST_FIELDS = `
|
|
257
|
+
iid title webUrl state draft conflicts approved detailedMergeStatus diffHeadSha updatedAt targetBranch
|
|
258
|
+
approvedBy(first: 100) { nodes { id bot } }
|
|
259
|
+
sourceProject { fullPath }
|
|
260
|
+
targetProject { fullPath }
|
|
261
|
+
headPipeline { status path detailedStatus { label } }
|
|
262
|
+
reviewers(first: 100) { nodes { type username bot mergeRequestInteraction { reviewState approved } } }
|
|
263
|
+
discussions(first: ${PAGE_SIZE2}) { ${DISCUSSIONS} }
|
|
264
|
+
`;
|
|
265
|
+
function mergeRequestsQuery(projectCount) {
|
|
266
|
+
const variables = Array.from({ length: projectCount }, (_, index) => `$p${index}: ID!`).join(", ");
|
|
267
|
+
const projects = Array.from({ length: projectCount }, (_, index) => `p${index}: project(fullPath: $p${index}) {
|
|
268
|
+
mergeRequests(sourceBranches: [$branch], state: opened, first: 20) { nodes { ${MERGE_REQUEST_FIELDS} } }
|
|
269
|
+
}`).join(`
|
|
270
|
+
`);
|
|
271
|
+
return `query($branch: String!, ${variables}) {
|
|
272
|
+
${projects}
|
|
273
|
+
}`;
|
|
274
|
+
}
|
|
275
|
+
function mergeRequestQuery(projectCount) {
|
|
276
|
+
const variables = Array.from({ length: projectCount }, (_, index) => `$p${index}: ID!`).join(", ");
|
|
277
|
+
const projects = Array.from({ length: projectCount }, (_, index) => `p${index}: project(fullPath: $p${index}) { mergeRequest(iid: $iid) { ${MERGE_REQUEST_FIELDS} } }`).join(`
|
|
278
|
+
`);
|
|
279
|
+
return `query($iid: String!, ${variables}) {
|
|
280
|
+
${projects}
|
|
281
|
+
}`;
|
|
282
|
+
}
|
|
283
|
+
function mergeRequestNumbersQuery(projectCount) {
|
|
284
|
+
const variables = Array.from({ length: projectCount }, (_, index) => `$p${index}: ID!`).join(", ");
|
|
285
|
+
const projects = Array.from({ length: projectCount }, (_, index) => `p${index}: project(fullPath: $p${index}) {
|
|
286
|
+
mergeRequests(sourceBranches: [$branch], state: opened, first: 20) { nodes { iid updatedAt sourceProject { fullPath } } }
|
|
287
|
+
}`).join(`
|
|
288
|
+
`);
|
|
289
|
+
return `query($branch: String!, ${variables}) {
|
|
290
|
+
${projects}
|
|
291
|
+
}`;
|
|
292
|
+
}
|
|
293
|
+
var SOURCE_BRANCH_QUERY = `query($project: ID!, $iid: String!) {
|
|
294
|
+
project(fullPath: $project) { mergeRequest(iid: $iid) { sourceBranch } }
|
|
295
|
+
}`;
|
|
296
|
+
var DISCUSSIONS_QUERY = `query($project: ID!, $iid: String!, $after: String) {
|
|
297
|
+
project(fullPath: $project) {
|
|
298
|
+
mergeRequest(iid: $iid) { discussions(first: ${PAGE_SIZE2}, after: $after) { ${DISCUSSIONS} } }
|
|
299
|
+
}
|
|
300
|
+
}`;
|
|
301
|
+
function classifyError(message, code) {
|
|
302
|
+
const text = message.trim().replace(/^glab:\s*/, "") || `glab exited with code ${code}`;
|
|
303
|
+
if (/ENOENT|command not found/i.test(text))
|
|
304
|
+
return new ForgeError("missing-glab", "glab is not installed");
|
|
305
|
+
if (/\b401\b|unauthori[sz]ed|not logged in|authenticate|invalid token|token.*expired/i.test(text)) {
|
|
306
|
+
return new ForgeError("auth", "GitLab authentication failed. Run `glab auth status`.");
|
|
307
|
+
}
|
|
308
|
+
if (/\b429\b|rate limit|too many requests/i.test(text))
|
|
309
|
+
return new ForgeError("rate-limit", "GitLab rate limit reached");
|
|
310
|
+
return new ForgeError("request", text.split(`
|
|
311
|
+
`)[0]);
|
|
312
|
+
}
|
|
313
|
+
var isResponse = (body) => typeof body === "object" && body !== null && (!("errors" in body) || Array.isArray(body.errors)) && (!("data" in body) || typeof body.data === "object");
|
|
314
|
+
function glabGraphQL(run, cwd) {
|
|
315
|
+
return async (host, query, variables) => {
|
|
316
|
+
const args = ["api", "graphql", "--hostname", host, "-f", `query=${query}`];
|
|
317
|
+
for (const [name, value] of Object.entries(variables))
|
|
318
|
+
args.push("-f", `${name}=${value}`);
|
|
319
|
+
const result = await run("glab", args, cwd);
|
|
320
|
+
let body;
|
|
321
|
+
try {
|
|
322
|
+
body = JSON.parse(result.stdout);
|
|
323
|
+
} catch {
|
|
324
|
+
throw classifyError(result.stderr, result.code);
|
|
325
|
+
}
|
|
326
|
+
if (!isResponse(body))
|
|
327
|
+
throw classifyError(result.stderr || "Unexpected GitLab response", result.code);
|
|
328
|
+
if (body.errors?.length)
|
|
329
|
+
throw classifyError(String(body.errors[0]?.message ?? ""), result.code);
|
|
330
|
+
if (result.code !== 0)
|
|
331
|
+
throw classifyError(result.stderr, result.code);
|
|
332
|
+
if (!body.data)
|
|
333
|
+
throw classifyError("GitLab returned no data", result.code);
|
|
334
|
+
return body.data;
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
var countUnresolved = (page) => page.nodes.filter((discussion) => discussion.resolvable && !discussion.resolved).length;
|
|
338
|
+
var gitlabTraits = {
|
|
339
|
+
kind: "gitlab",
|
|
340
|
+
noun: "MR",
|
|
341
|
+
ci: "pipeline",
|
|
342
|
+
recognizes: (host) => /(^|\.)gitlab\./.test(host),
|
|
343
|
+
open: (run, directory) => new GitLabForge(glabGraphQL(run, directory)),
|
|
344
|
+
owns: ownedBy("gitlab"),
|
|
345
|
+
parseUrl: (url) => parseWebUrl("gitlab", url, /^\/(.+)\/-\/merge_requests\/([1-9]\d*)\/?$/),
|
|
346
|
+
parseIssueUrl: (url) => parseWebUrl("gitlab", url, /^\/(.+)\/-\/(?:issues|work_items)\/([1-9]\d*)\/?$/),
|
|
347
|
+
reference: (iid) => `!${iid}`,
|
|
348
|
+
issueReference: (iid) => `#${iid}`,
|
|
349
|
+
titleReferences: (title) => prefixReferences(title, "!"),
|
|
350
|
+
indicators: () => [],
|
|
351
|
+
details(request) {
|
|
352
|
+
const requirements = request.approvalRequirementsSatisfied === null ? "unknown" : yesNo(request.approvalRequirementsSatisfied);
|
|
353
|
+
const lines = [
|
|
354
|
+
`Human approvals: ${yesNo(request.hasApprovals)} \xB7 Approval requirements satisfied: ${requirements}`
|
|
355
|
+
];
|
|
356
|
+
if (request.mergeStatus)
|
|
357
|
+
lines.push(`Merge status: ${humanize(request.mergeStatus)}`);
|
|
358
|
+
return lines;
|
|
359
|
+
},
|
|
360
|
+
automatedReview: {
|
|
361
|
+
name: "GitLab Duo",
|
|
362
|
+
status(request) {
|
|
363
|
+
const state = request.duoReviewState;
|
|
364
|
+
if (!state)
|
|
365
|
+
return;
|
|
366
|
+
return { state: duoState(state), label: humanize(state) };
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
feedback: {
|
|
370
|
+
async fetch(run, directory, request) {
|
|
371
|
+
const ref = gitlabTraits.parseUrl(request.url);
|
|
372
|
+
return ref ? fetchComments(glabGraphQL(run, directory), ref.host, ref.project, ref.iid) : undefined;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
var DUO_FEEDBACK = new Set(["REVIEWED", "REQUESTED_CHANGES"]);
|
|
377
|
+
function duoState(state) {
|
|
378
|
+
if (state === "REVIEW_STARTED")
|
|
379
|
+
return "running";
|
|
380
|
+
return DUO_FEEDBACK.has(state) ? "feedback" : "settled";
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
class GitLabForge {
|
|
384
|
+
graphql;
|
|
385
|
+
traits = gitlabTraits;
|
|
386
|
+
constructor(graphql) {
|
|
387
|
+
this.graphql = graphql;
|
|
388
|
+
}
|
|
389
|
+
async findByBranch(repository) {
|
|
390
|
+
const { source, targets, sourceBranch } = repository;
|
|
391
|
+
const variables = { branch: sourceBranch };
|
|
392
|
+
targets.forEach((target, index) => variables[`p${index}`] = target.path);
|
|
393
|
+
const data = await this.graphql(source.host, mergeRequestsQuery(targets.length), variables);
|
|
394
|
+
const sourcePath = source.path.toLowerCase();
|
|
395
|
+
const seen = new Set;
|
|
396
|
+
const nodes = [];
|
|
397
|
+
targets.forEach((_, index) => {
|
|
398
|
+
for (const node of data?.[`p${index}`]?.mergeRequests?.nodes ?? []) {
|
|
399
|
+
if (node.sourceProject?.fullPath.toLowerCase() !== sourcePath)
|
|
400
|
+
continue;
|
|
401
|
+
const key = `${node.targetProject.fullPath}!${node.iid}`;
|
|
402
|
+
if (seen.has(key))
|
|
403
|
+
continue;
|
|
404
|
+
seen.add(key);
|
|
405
|
+
nodes.push(node);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
const requests = await Promise.all(nodes.map((node) => this.normalize(source.host, node)));
|
|
409
|
+
return requests.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
410
|
+
}
|
|
411
|
+
async findNumberByBranch(repository) {
|
|
412
|
+
const { source, targets, sourceBranch } = repository;
|
|
413
|
+
const variables = { branch: sourceBranch };
|
|
414
|
+
targets.forEach((target, index) => variables[`p${index}`] = target.path);
|
|
415
|
+
const data = await this.graphql(source.host, mergeRequestNumbersQuery(targets.length), variables);
|
|
416
|
+
const sourcePath = source.path.toLowerCase();
|
|
417
|
+
const nodes = targets.flatMap((_, index) => data?.[`p${index}`]?.mergeRequests?.nodes ?? []);
|
|
418
|
+
return nodes.filter((node) => node.sourceProject?.fullPath.toLowerCase() === sourcePath).sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))[0]?.iid;
|
|
419
|
+
}
|
|
420
|
+
async sourceBranch(ref) {
|
|
421
|
+
const data = await this.graphql(ref.host, SOURCE_BRANCH_QUERY, {
|
|
422
|
+
project: ref.project,
|
|
423
|
+
iid: ref.iid
|
|
424
|
+
});
|
|
425
|
+
const branch = data?.project?.mergeRequest?.sourceBranch;
|
|
426
|
+
return typeof branch === "string" && branch ? branch : undefined;
|
|
427
|
+
}
|
|
428
|
+
async findByNumber(projects, iid) {
|
|
429
|
+
if (projects.length === 0)
|
|
430
|
+
return;
|
|
431
|
+
const host = projects[0].host;
|
|
432
|
+
const variables = { iid };
|
|
433
|
+
projects.forEach((project, index) => variables[`p${index}`] = project.path);
|
|
434
|
+
const data = await this.graphql(host, mergeRequestQuery(projects.length), variables);
|
|
435
|
+
for (let index = 0;index < projects.length; index++) {
|
|
436
|
+
const node = data?.[`p${index}`]?.mergeRequest ?? undefined;
|
|
437
|
+
if (node)
|
|
438
|
+
return this.normalize(host, node);
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
async unresolvedThreads(host, node) {
|
|
443
|
+
let count = countUnresolved(node.discussions);
|
|
444
|
+
let page = node.discussions.pageInfo;
|
|
445
|
+
let pages = 1;
|
|
446
|
+
while (page.hasNextPage && page.endCursor && pages < MAX_DISCUSSION_PAGES) {
|
|
447
|
+
const data = await this.graphql(host, DISCUSSIONS_QUERY, {
|
|
448
|
+
project: node.targetProject.fullPath,
|
|
449
|
+
iid: node.iid,
|
|
450
|
+
after: page.endCursor
|
|
451
|
+
});
|
|
452
|
+
const next = data?.project?.mergeRequest?.discussions;
|
|
453
|
+
if (!next)
|
|
454
|
+
break;
|
|
455
|
+
count += countUnresolved(next);
|
|
456
|
+
page = next.pageInfo;
|
|
457
|
+
pages++;
|
|
458
|
+
}
|
|
459
|
+
return { count, complete: !page.hasNextPage };
|
|
460
|
+
}
|
|
461
|
+
async normalize(host, node) {
|
|
462
|
+
const threads = await this.unresolvedThreads(host, node);
|
|
463
|
+
const pipeline = node.headPipeline;
|
|
464
|
+
const hasApprovals = (node.approvedBy?.nodes ?? []).some((approver) => approver.bot !== true);
|
|
465
|
+
const awaitingReviewers = (node.reviewers?.nodes ?? []).filter((reviewer) => reviewer.bot !== true && reviewer.username && reviewer.mergeRequestInteraction?.approved !== true).map((reviewer) => reviewer.username);
|
|
466
|
+
return {
|
|
467
|
+
forge: "gitlab",
|
|
468
|
+
iid: node.iid,
|
|
469
|
+
title: node.title,
|
|
470
|
+
url: node.webUrl,
|
|
471
|
+
state: node.state.toLowerCase(),
|
|
472
|
+
draft: node.draft,
|
|
473
|
+
conflicts: node.conflicts,
|
|
474
|
+
conflictsKnown: true,
|
|
475
|
+
approved: node.approved === true && hasApprovals && awaitingReviewers.length === 0,
|
|
476
|
+
hasApprovals,
|
|
477
|
+
approvalRequirementsSatisfied: node.approved,
|
|
478
|
+
awaitingReviewers,
|
|
479
|
+
reviewersComplete: true,
|
|
480
|
+
mergeStatus: node.detailedMergeStatus ?? undefined,
|
|
481
|
+
targetProject: node.targetProject.fullPath,
|
|
482
|
+
targetBranch: node.targetBranch,
|
|
483
|
+
headSha: node.diffHeadSha ?? undefined,
|
|
484
|
+
updatedAt: node.updatedAt ?? undefined,
|
|
485
|
+
pipeline: pipeline ? {
|
|
486
|
+
status: pipeline.status,
|
|
487
|
+
label: pipeline.detailedStatus?.label ?? pipeline.status.toLowerCase().replace(/_/g, " "),
|
|
488
|
+
url: pipeline.path ? `https://${host}${pipeline.path}` : undefined
|
|
489
|
+
} : undefined,
|
|
490
|
+
unresolvedThreads: threads.count,
|
|
491
|
+
threadsComplete: threads.complete,
|
|
492
|
+
duoReviewState: node.reviewers?.nodes.find((reviewer) => reviewer.type === "DUO_CODE_REVIEW_BOT")?.mergeRequestInteraction?.reviewState ?? undefined
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// packages/forgekeeper/src/github.ts
|
|
498
|
+
var fields = `number title url state isDraft mergeable reviewDecision headRefOid headRefName
|
|
499
|
+
headRepository { nameWithOwner } baseRepository { nameWithOwner } baseRefName updatedAt
|
|
500
|
+
reviewThreads(first:100) { nodes { isResolved } pageInfo { hasNextPage endCursor } }
|
|
501
|
+
reviewRequests(first:100) { nodes { requestedReviewer { ... on User { login } ... on Team { name } } } pageInfo { hasNextPage } }
|
|
502
|
+
commits(last:1) { nodes { commit { statusCheckRollup { contexts(first:100) {
|
|
503
|
+
nodes { __typename ... on CheckRun { status conclusion detailsUrl } ... on StatusContext { state targetUrl } }
|
|
504
|
+
pageInfo { hasNextPage endCursor }
|
|
505
|
+
} } } } }`;
|
|
506
|
+
var MAX_PAGES = 50;
|
|
507
|
+
function ghGraphQL(run, cwd) {
|
|
508
|
+
return async (host, query, variables) => {
|
|
509
|
+
const args = ["api", "graphql", "--hostname", host, "-f", `query=${query}`];
|
|
510
|
+
for (const [name, value] of Object.entries(variables))
|
|
511
|
+
args.push(typeof value === "number" ? "-F" : "-f", `${name}=${value}`);
|
|
512
|
+
const result = await run("gh", args, cwd);
|
|
513
|
+
let body;
|
|
514
|
+
try {
|
|
515
|
+
body = JSON.parse(result.stdout);
|
|
516
|
+
} catch {
|
|
517
|
+
throw classifyError2(result.stderr, result.code);
|
|
518
|
+
}
|
|
519
|
+
const errors = (body.errors ?? []).filter((error) => error.type !== "NOT_FOUND");
|
|
520
|
+
if (!body.data || errors.length || result.code !== 0 && !body.errors?.length) {
|
|
521
|
+
throw classifyError2(errors[0]?.message ?? result.stderr, result.code);
|
|
522
|
+
}
|
|
523
|
+
return body.data;
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
function classifyError2(message, code) {
|
|
527
|
+
const text = message.trim() || `gh exited with code ${code}`;
|
|
528
|
+
if (/ENOENT|command not found/i.test(text))
|
|
529
|
+
return new ForgeError("missing-gh", "gh is not installed");
|
|
530
|
+
if (/401|unauthori[sz]ed|authenticate|not logged|token.*expired/i.test(text))
|
|
531
|
+
return new ForgeError("auth", "GitHub authentication failed. Run `gh auth status`.");
|
|
532
|
+
if (/429|rate limit|too many requests/i.test(text))
|
|
533
|
+
return new ForgeError("rate-limit", "GitHub rate limit reached");
|
|
534
|
+
return new ForgeError("request", text.split(`
|
|
535
|
+
`)[0]);
|
|
536
|
+
}
|
|
537
|
+
var FAILED = [
|
|
538
|
+
"FAILURE",
|
|
539
|
+
"ERROR",
|
|
540
|
+
"TIMED_OUT",
|
|
541
|
+
"CANCELLED",
|
|
542
|
+
"ACTION_REQUIRED",
|
|
543
|
+
"STARTUP_FAILURE",
|
|
544
|
+
"STALE"
|
|
545
|
+
];
|
|
546
|
+
var CONCLUSIONS = [
|
|
547
|
+
"SUCCESS",
|
|
548
|
+
"NEUTRAL",
|
|
549
|
+
"SKIPPED",
|
|
550
|
+
"FAILURE",
|
|
551
|
+
"TIMED_OUT",
|
|
552
|
+
"CANCELLED",
|
|
553
|
+
"ACTION_REQUIRED",
|
|
554
|
+
"STARTUP_FAILURE",
|
|
555
|
+
"STALE"
|
|
556
|
+
];
|
|
557
|
+
var STATES = ["SUCCESS", "FAILURE", "ERROR", "PENDING", "EXPECTED"];
|
|
558
|
+
function checks(nodes, complete, url) {
|
|
559
|
+
const failed = nodes.filter((node) => FAILED.includes(node.conclusion ?? node.state ?? "")).length;
|
|
560
|
+
const pending = nodes.filter((node) => node.status ? node.status !== "COMPLETED" : ["PENDING", "EXPECTED"].includes(node.state ?? "")).length;
|
|
561
|
+
const unknown = nodes.some((node) => node.status === "COMPLETED" ? !CONCLUSIONS.includes(node.conclusion ?? "") : !node.status && !STATES.includes(node.state ?? ""));
|
|
562
|
+
if (failed)
|
|
563
|
+
return {
|
|
564
|
+
status: "FAILED",
|
|
565
|
+
label: `${failed} failed${pending ? `, ${pending} pending` : ""}${complete ? "" : " (partial)"}`,
|
|
566
|
+
url
|
|
567
|
+
};
|
|
568
|
+
if (pending || !complete || unknown)
|
|
569
|
+
return {
|
|
570
|
+
status: "PENDING",
|
|
571
|
+
label: `${pending ? `${pending} pending` : "unknown"}${complete ? "" : " (partial)"}`,
|
|
572
|
+
url
|
|
573
|
+
};
|
|
574
|
+
return { status: "SUCCESS", label: `${nodes.length} passed or skipped`, url };
|
|
575
|
+
}
|
|
576
|
+
function parseGitHubUrl(url, pattern) {
|
|
577
|
+
const ref = parseWebUrl("github", url, pattern);
|
|
578
|
+
return ref && !gitlabTraits.recognizes(ref.host) ? ref : undefined;
|
|
579
|
+
}
|
|
580
|
+
var githubTraits = {
|
|
581
|
+
kind: "github",
|
|
582
|
+
noun: "PR",
|
|
583
|
+
ci: "checks",
|
|
584
|
+
recognizes: () => false,
|
|
585
|
+
open: (run, directory) => new GitHubForge(ghGraphQL(run, directory)),
|
|
586
|
+
owns: ownedBy("github"),
|
|
587
|
+
parseUrl: (url) => parseGitHubUrl(url, /^\/([^/]+\/[^/]+)\/pull\/([1-9]\d*)\/?$/),
|
|
588
|
+
parseIssueUrl: (url) => parseGitHubUrl(url, /^\/([^/]+\/[^/]+)\/issues\/([1-9]\d*)\/?$/),
|
|
589
|
+
reference: (iid) => `#${iid}`,
|
|
590
|
+
issueReference: (iid) => `#${iid}`,
|
|
591
|
+
titleReferences: (title) => prefixReferences(title, "#"),
|
|
592
|
+
indicators: (request) => request.reviewDecision === "CHANGES_REQUESTED" ? [{ text: "changes requested", tone: "warning", essential: true }] : [],
|
|
593
|
+
details: (request) => [
|
|
594
|
+
`Review decision: ${request.reviewDecision ? humanize(request.reviewDecision) : "not reported"}`
|
|
595
|
+
]
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
class GitHubForge {
|
|
599
|
+
graphql;
|
|
600
|
+
traits = githubTraits;
|
|
601
|
+
constructor(graphql) {
|
|
602
|
+
this.graphql = graphql;
|
|
603
|
+
}
|
|
604
|
+
async findByNumber(projects, number) {
|
|
605
|
+
for (const project of projects) {
|
|
606
|
+
const [owner, repo] = project.path.split("/");
|
|
607
|
+
const data = await this.graphql(project.host, `query($owner:String!,$repo:String!,$number:Int!) {
|
|
608
|
+
repository(owner:$owner,name:$repo) { pullRequest(number:$number) { ${fields} } }
|
|
609
|
+
}`, { owner, repo, number: Number(number) });
|
|
610
|
+
const node = data.repository?.pullRequest;
|
|
611
|
+
if (node)
|
|
612
|
+
return this.normalize(project.host, node);
|
|
613
|
+
}
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
async findNumberByBranch(repository) {
|
|
617
|
+
let newest;
|
|
618
|
+
for (const target of repository.targets) {
|
|
619
|
+
const [owner, repo] = target.path.split("/");
|
|
620
|
+
const data = await this.graphql(target.host, `query($owner:String!,$repo:String!,$branch:String!) {
|
|
621
|
+
repository(owner:$owner,name:$repo) { pullRequests(first:100,states:OPEN,headRefName:$branch,orderBy:{field:UPDATED_AT,direction:DESC}) {
|
|
622
|
+
nodes { number updatedAt headRepository { nameWithOwner } }
|
|
623
|
+
} } }`, { owner, repo, branch: repository.sourceBranch });
|
|
624
|
+
const nodes = data.repository?.pullRequests?.nodes ?? [];
|
|
625
|
+
for (const node of nodes) {
|
|
626
|
+
if (node.headRepository?.nameWithOwner.toLowerCase() !== repository.source.path.toLowerCase())
|
|
627
|
+
continue;
|
|
628
|
+
if (!newest || node.updatedAt > newest.updatedAt)
|
|
629
|
+
newest = node;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return newest ? String(newest.number) : undefined;
|
|
633
|
+
}
|
|
634
|
+
async sourceBranch(ref) {
|
|
635
|
+
const [owner, repo] = ref.project.split("/");
|
|
636
|
+
const data = await this.graphql(ref.host, `query($owner:String!,$repo:String!,$number:Int!) {
|
|
637
|
+
repository(owner:$owner,name:$repo) { pullRequest(number:$number) { headRefName } }
|
|
638
|
+
}`, { owner, repo, number: Number(ref.iid) });
|
|
639
|
+
const branch = data.repository?.pullRequest?.headRefName;
|
|
640
|
+
return typeof branch === "string" && branch ? branch : undefined;
|
|
641
|
+
}
|
|
642
|
+
async findByBranch(repository) {
|
|
643
|
+
const found = new Map;
|
|
644
|
+
for (const target of repository.targets) {
|
|
645
|
+
const [owner, repo] = target.path.split("/");
|
|
646
|
+
let after;
|
|
647
|
+
let complete = false;
|
|
648
|
+
for (let page = 0;page < MAX_PAGES; page++) {
|
|
649
|
+
const data = await this.graphql(target.host, `query($owner:String!,$repo:String!,$branch:String!${after ? ",$after:String!" : ""}) {
|
|
650
|
+
repository(owner:$owner,name:$repo) { pullRequests(first:100,states:OPEN,headRefName:$branch${after ? ",after:$after" : ""}) {
|
|
651
|
+
nodes { ${fields} } pageInfo { hasNextPage endCursor }
|
|
652
|
+
} } }`, { owner, repo, branch: repository.sourceBranch, ...after ? { after } : {} });
|
|
653
|
+
const pulls = data.repository?.pullRequests;
|
|
654
|
+
if (!pulls)
|
|
655
|
+
throw new ForgeError("request", "GitHub repository unavailable");
|
|
656
|
+
for (const node of pulls.nodes) {
|
|
657
|
+
if (node.headRepository?.nameWithOwner.toLowerCase() !== repository.source.path.toLowerCase())
|
|
658
|
+
continue;
|
|
659
|
+
if (!found.has(node.url))
|
|
660
|
+
found.set(node.url, await this.normalize(target.host, node));
|
|
661
|
+
}
|
|
662
|
+
if (!pulls.pageInfo?.hasNextPage) {
|
|
663
|
+
complete = true;
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
after = pulls.pageInfo.endCursor;
|
|
667
|
+
if (!after)
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
if (!complete)
|
|
671
|
+
throw new ForgeError("request", "GitHub PR discovery exceeded its pagination limit");
|
|
672
|
+
}
|
|
673
|
+
return [...found.values()].sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
674
|
+
}
|
|
675
|
+
async threads(host, node) {
|
|
676
|
+
const [owner, repo] = node.baseRepository.nameWithOwner.split("/");
|
|
677
|
+
const threads = [...node.reviewThreads.nodes];
|
|
678
|
+
let page = node.reviewThreads.pageInfo;
|
|
679
|
+
for (let count = 1;page?.hasNextPage && page.endCursor && count < MAX_PAGES; count++) {
|
|
680
|
+
const data = await this.graphql(host, `query($owner:String!,$repo:String!,$number:Int!,$after:String!) {
|
|
681
|
+
repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
|
|
682
|
+
reviewThreads(first:100,after:$after) { nodes { isResolved } pageInfo { hasNextPage endCursor } }
|
|
683
|
+
} } }`, { owner, repo, number: node.number, after: page.endCursor });
|
|
684
|
+
const next = data.repository?.pullRequest?.reviewThreads;
|
|
685
|
+
if (!next)
|
|
686
|
+
break;
|
|
687
|
+
threads.push(...next.nodes);
|
|
688
|
+
page = next.pageInfo;
|
|
689
|
+
}
|
|
690
|
+
return {
|
|
691
|
+
unresolved: threads.filter((thread) => !thread.isResolved).length,
|
|
692
|
+
complete: !page?.hasNextPage
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
async contexts(host, node) {
|
|
696
|
+
const [owner, repo] = node.baseRepository.nameWithOwner.split("/");
|
|
697
|
+
const rollup = node.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
|
|
698
|
+
const contexts = [...rollup?.nodes ?? []];
|
|
699
|
+
let cursor = rollup?.pageInfo;
|
|
700
|
+
for (let count = 1;cursor?.hasNextPage && cursor.endCursor && count < MAX_PAGES; count++) {
|
|
701
|
+
const data = await this.graphql(host, `query($owner:String!,$repo:String!,$oid:GitObjectID!,$after:String!) {
|
|
702
|
+
repository(owner:$owner,name:$repo) { object(oid:$oid) { ... on Commit { statusCheckRollup {
|
|
703
|
+
contexts(first:100,after:$after) { nodes { __typename ... on CheckRun { status conclusion detailsUrl } ... on StatusContext { state targetUrl } } pageInfo { hasNextPage endCursor } }
|
|
704
|
+
} } } } }`, { owner, repo, oid: node.headRefOid, after: cursor.endCursor });
|
|
705
|
+
const next = data.repository?.object?.statusCheckRollup?.contexts;
|
|
706
|
+
if (!next)
|
|
707
|
+
break;
|
|
708
|
+
contexts.push(...next.nodes);
|
|
709
|
+
cursor = next.pageInfo;
|
|
710
|
+
}
|
|
711
|
+
return { nodes: contexts, complete: !cursor?.hasNextPage };
|
|
712
|
+
}
|
|
713
|
+
async normalize(host, node) {
|
|
714
|
+
const [threads, contexts] = [await this.threads(host, node), await this.contexts(host, node)];
|
|
715
|
+
return {
|
|
716
|
+
forge: "github",
|
|
717
|
+
iid: String(node.number),
|
|
718
|
+
title: node.title,
|
|
719
|
+
url: node.url,
|
|
720
|
+
state: node.state === "OPEN" ? "opened" : node.state.toLowerCase(),
|
|
721
|
+
draft: node.isDraft,
|
|
722
|
+
conflicts: node.mergeable === "CONFLICTING",
|
|
723
|
+
conflictsKnown: node.mergeable !== "UNKNOWN",
|
|
724
|
+
approved: node.reviewDecision === "APPROVED",
|
|
725
|
+
reviewDecision: node.reviewDecision ?? undefined,
|
|
726
|
+
awaitingReviewers: node.reviewRequests.nodes.flatMap((request) => {
|
|
727
|
+
const reviewer = request.requestedReviewer;
|
|
728
|
+
return reviewer?.login ? [reviewer.login] : reviewer?.name ? [reviewer.name] : [];
|
|
729
|
+
}),
|
|
730
|
+
reviewersComplete: !node.reviewRequests.pageInfo?.hasNextPage,
|
|
731
|
+
targetProject: node.baseRepository.nameWithOwner,
|
|
732
|
+
targetBranch: node.baseRefName,
|
|
733
|
+
headSha: node.headRefOid,
|
|
734
|
+
updatedAt: node.updatedAt,
|
|
735
|
+
pipeline: contexts.nodes.length || !contexts.complete ? checks(contexts.nodes, contexts.complete, `${node.url}/checks`) : undefined,
|
|
736
|
+
unresolvedThreads: threads.unresolved,
|
|
737
|
+
threadsComplete: threads.complete
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// packages/forgekeeper/src/forges.ts
|
|
743
|
+
var registry = { gitlab: gitlabTraits, github: githubTraits };
|
|
744
|
+
var kinds = Object.keys(registry);
|
|
745
|
+
var traits = (kind) => registry[kind];
|
|
746
|
+
var traitsOf = (request) => registry[request.forge];
|
|
747
|
+
var reference = (request) => traitsOf(request).reference(request.iid);
|
|
748
|
+
function parseUrl(url) {
|
|
749
|
+
for (const kind of kinds) {
|
|
750
|
+
const ref = registry[kind].parseUrl(url);
|
|
751
|
+
if (ref)
|
|
752
|
+
return ref;
|
|
753
|
+
}
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
function parseReference(url) {
|
|
757
|
+
for (const kind of kinds) {
|
|
758
|
+
const request = registry[kind].parseUrl(url);
|
|
759
|
+
if (request)
|
|
760
|
+
return { ref: request, issue: false };
|
|
761
|
+
const issue = registry[kind].parseIssueUrl(url);
|
|
762
|
+
if (issue)
|
|
763
|
+
return { ref: issue, issue: true };
|
|
764
|
+
}
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
function hostsFrom(options) {
|
|
768
|
+
const list = (value, fallback) => Array.isArray(value) ? value.filter((host) => typeof host === "string") : [fallback];
|
|
769
|
+
return {
|
|
770
|
+
gitlab: list(options.hosts, "gitlab.com"),
|
|
771
|
+
github: list(options.githubHosts, "github.com")
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
class Forges {
|
|
776
|
+
run;
|
|
777
|
+
hosts;
|
|
778
|
+
kinds = kinds;
|
|
779
|
+
constructor(run, hosts) {
|
|
780
|
+
this.run = run;
|
|
781
|
+
this.hosts = hosts;
|
|
782
|
+
}
|
|
783
|
+
kind(host) {
|
|
784
|
+
return kinds.find((kind) => this.hosts[kind].includes(host)) ?? kinds.find((kind) => registry[kind].recognizes(host));
|
|
785
|
+
}
|
|
786
|
+
forHost(host, directory) {
|
|
787
|
+
const kind = this.kind(host);
|
|
788
|
+
return kind ? registry[kind].open(this.run, directory) : undefined;
|
|
789
|
+
}
|
|
790
|
+
async projects(directory, kind) {
|
|
791
|
+
const lookup = await resolveProjects(this.run, directory, (host) => this.kind(host) === kind);
|
|
792
|
+
return lookup.kind === "projects" ? lookup.projects : [];
|
|
793
|
+
}
|
|
794
|
+
repository(directory) {
|
|
795
|
+
return resolveRepository(this.run, directory, (host) => this.kind(host) !== undefined);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// packages/forgekeeper/src/limit.ts
|
|
800
|
+
var CONCURRENCY = 4;
|
|
801
|
+
async function mapLimit(items, limit, fn) {
|
|
802
|
+
const results = new Array(items.length);
|
|
803
|
+
let next = 0;
|
|
804
|
+
const worker = async () => {
|
|
805
|
+
while (next < items.length) {
|
|
806
|
+
const index = next++;
|
|
807
|
+
results[index] = await fn(items[index]);
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
811
|
+
return results;
|
|
812
|
+
}
|
|
813
|
+
function createLimiter(limit) {
|
|
814
|
+
let running = 0;
|
|
815
|
+
const queue = [];
|
|
816
|
+
const release = () => {
|
|
817
|
+
const next = queue.shift();
|
|
818
|
+
if (next)
|
|
819
|
+
next();
|
|
820
|
+
else
|
|
821
|
+
running--;
|
|
822
|
+
};
|
|
823
|
+
return async (task) => {
|
|
824
|
+
if (running >= limit)
|
|
825
|
+
await new Promise((resolve) => queue.push(resolve));
|
|
826
|
+
else
|
|
827
|
+
running++;
|
|
828
|
+
try {
|
|
829
|
+
return await task();
|
|
830
|
+
} finally {
|
|
831
|
+
release();
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
function limitExec(run, limit = CONCURRENCY, signal) {
|
|
836
|
+
const limited = createLimiter(limit);
|
|
837
|
+
const cancelled = { stdout: "", stderr: "The plugin stopped", code: -1 };
|
|
838
|
+
return (file, args, cwd) => limited(async () => signal?.aborted ? cancelled : run(file, args, cwd));
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// packages/forgekeeper/src/lookups.ts
|
|
842
|
+
var NUMBER_TTL = 10 * 60000;
|
|
843
|
+
function createTitleLookups(forges, run, now = Date.now) {
|
|
844
|
+
const numbers = new Map;
|
|
845
|
+
return {
|
|
846
|
+
async branch(directory) {
|
|
847
|
+
const lookup = await forges.repository(directory);
|
|
848
|
+
if (lookup.kind === "none")
|
|
849
|
+
return;
|
|
850
|
+
const { repository } = lookup;
|
|
851
|
+
const forge = forges.forHost(repository.source.host, directory);
|
|
852
|
+
if (!forge)
|
|
853
|
+
return;
|
|
854
|
+
const key = JSON.stringify([
|
|
855
|
+
directory,
|
|
856
|
+
repository.source.host,
|
|
857
|
+
repository.source.path,
|
|
858
|
+
repository.sourceBranch
|
|
859
|
+
]);
|
|
860
|
+
const cached = numbers.get(key);
|
|
861
|
+
let number = cached && now() - cached.at < NUMBER_TTL ? cached.number : undefined;
|
|
862
|
+
if (!number) {
|
|
863
|
+
try {
|
|
864
|
+
number = await forge.findNumberByBranch(repository);
|
|
865
|
+
if (number)
|
|
866
|
+
numbers.set(key, { number, at: now() });
|
|
867
|
+
else
|
|
868
|
+
numbers.delete(key);
|
|
869
|
+
} catch {
|
|
870
|
+
number = cached?.number;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return { forge: forge.traits, branch: repository.branch, number };
|
|
874
|
+
},
|
|
875
|
+
async sourceBranch(ref, directory) {
|
|
876
|
+
return traits(ref.forge).open(run, directory).sourceBranch(ref).catch(() => {
|
|
877
|
+
return;
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// packages/forgekeeper/src/options.ts
|
|
884
|
+
function statusOptions(options) {
|
|
885
|
+
const source = options && typeof options === "object" ? options : {};
|
|
886
|
+
const poll = Number(source.pollSeconds);
|
|
887
|
+
return {
|
|
888
|
+
enabled: source.reviewStatus !== false,
|
|
889
|
+
pollSeconds: poll > 0 ? poll : 120,
|
|
890
|
+
notifyAutomatedReviews: (source.notifyAutomatedReviews ?? source.notifyDuoReview) !== false,
|
|
891
|
+
notifyHumanReviews: source.notifyHumanReviews !== false,
|
|
892
|
+
resumeSession: source.resumeSession !== false
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// packages/forgekeeper/src/rpc.ts
|
|
897
|
+
var urls = {
|
|
898
|
+
url: { type: "string" },
|
|
899
|
+
issueUrl: { type: "string" }
|
|
900
|
+
};
|
|
901
|
+
var target = {
|
|
902
|
+
type: "object",
|
|
903
|
+
properties: {
|
|
904
|
+
...urls,
|
|
905
|
+
targets: {
|
|
906
|
+
type: "array",
|
|
907
|
+
items: { type: "object", properties: urls, required: ["url"], additionalProperties: false }
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
additionalProperties: false
|
|
911
|
+
};
|
|
912
|
+
var key = { type: "string" };
|
|
913
|
+
var snapshot = { type: "object" };
|
|
914
|
+
var status = {
|
|
915
|
+
type: "object",
|
|
916
|
+
properties: { enabled: { type: "boolean" }, snapshot },
|
|
917
|
+
required: ["enabled", "snapshot"]
|
|
918
|
+
};
|
|
919
|
+
var ForgeSessionRpc = {
|
|
920
|
+
id: "opencode-forge-session-title",
|
|
921
|
+
methods: {
|
|
922
|
+
target: {
|
|
923
|
+
input: {
|
|
924
|
+
type: "object",
|
|
925
|
+
properties: { sessionID: { type: "string" } },
|
|
926
|
+
required: ["sessionID"],
|
|
927
|
+
additionalProperties: false
|
|
928
|
+
},
|
|
929
|
+
output: target
|
|
930
|
+
},
|
|
931
|
+
watch: {
|
|
932
|
+
input: {
|
|
933
|
+
type: "object",
|
|
934
|
+
properties: {
|
|
935
|
+
clientID: { type: "string" },
|
|
936
|
+
keys: { type: "array", items: key },
|
|
937
|
+
visible: key
|
|
938
|
+
},
|
|
939
|
+
required: ["clientID", "keys"],
|
|
940
|
+
additionalProperties: false
|
|
941
|
+
},
|
|
942
|
+
output: {
|
|
943
|
+
type: "object",
|
|
944
|
+
properties: { enabled: { type: "boolean" }, statuses: { type: "object" } },
|
|
945
|
+
required: ["enabled", "statuses"]
|
|
946
|
+
}
|
|
947
|
+
},
|
|
948
|
+
release: {
|
|
949
|
+
input: {
|
|
950
|
+
type: "object",
|
|
951
|
+
properties: { clientID: { type: "string" } },
|
|
952
|
+
required: ["clientID"],
|
|
953
|
+
additionalProperties: false
|
|
954
|
+
},
|
|
955
|
+
output: { type: "object" }
|
|
956
|
+
},
|
|
957
|
+
refresh: {
|
|
958
|
+
input: {
|
|
959
|
+
type: "object",
|
|
960
|
+
properties: { key },
|
|
961
|
+
required: ["key"],
|
|
962
|
+
additionalProperties: false
|
|
963
|
+
},
|
|
964
|
+
output: status
|
|
965
|
+
}
|
|
966
|
+
},
|
|
967
|
+
events: {
|
|
968
|
+
targetChanged: {
|
|
969
|
+
schema: {
|
|
970
|
+
type: "object",
|
|
971
|
+
properties: { sessionID: { type: "string" }, ...target.properties },
|
|
972
|
+
required: ["sessionID"],
|
|
973
|
+
additionalProperties: false
|
|
974
|
+
}
|
|
975
|
+
},
|
|
976
|
+
status: {
|
|
977
|
+
schema: {
|
|
978
|
+
type: "object",
|
|
979
|
+
properties: { directory: { type: "string" }, key, snapshot },
|
|
980
|
+
required: ["directory", "key", "snapshot"]
|
|
981
|
+
}
|
|
982
|
+
},
|
|
983
|
+
notice: {
|
|
984
|
+
schema: {
|
|
985
|
+
type: "object",
|
|
986
|
+
properties: {
|
|
987
|
+
sessionID: { type: "string" },
|
|
988
|
+
title: { type: "string" },
|
|
989
|
+
message: { type: "string" },
|
|
990
|
+
variant: { type: "string", enum: ["info", "error"] }
|
|
991
|
+
},
|
|
992
|
+
required: ["message", "variant"]
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
var HOME = "";
|
|
998
|
+
var HEARTBEAT = 60000;
|
|
999
|
+
var LEASE_TIME = 3 * HEARTBEAT;
|
|
1000
|
+
|
|
1001
|
+
// packages/forgekeeper/src/automated-review-watch.ts
|
|
1002
|
+
var requests = (lookup) => lookup?.kind === "found" ? lookup.requests : [];
|
|
1003
|
+
function automatedReview(request) {
|
|
1004
|
+
const reviewer = traitsOf(request).automatedReview;
|
|
1005
|
+
const status2 = reviewer?.status(request);
|
|
1006
|
+
return reviewer && status2 ? { name: reviewer.name, ...status2 } : undefined;
|
|
1007
|
+
}
|
|
1008
|
+
var isReviewRunning = (lookup) => requests(lookup).some((request) => automatedReview(request)?.state === "running");
|
|
1009
|
+
function finishedReviews(previous, next) {
|
|
1010
|
+
const running = new Set(requests(previous).filter((request) => automatedReview(request)?.state === "running").map((request) => request.url));
|
|
1011
|
+
return requests(next).filter((request) => running.has(request.url) && automatedReview(request)?.state === "feedback");
|
|
1012
|
+
}
|
|
1013
|
+
var reviewer = (request) => automatedReview(request)?.name ?? "The automated reviewer";
|
|
1014
|
+
var shared = (requests2, value, fallback) => {
|
|
1015
|
+
const values = new Set(requests2.map(value));
|
|
1016
|
+
return values.size === 1 ? [...values][0] : fallback;
|
|
1017
|
+
};
|
|
1018
|
+
var reviewers = (requests2) => shared(requests2, reviewer, "Automated reviewers");
|
|
1019
|
+
var nouns = (requests2) => shared(requests2, (request) => `${traitsOf(request).noun}s`, "PRs/MRs");
|
|
1020
|
+
function reviewOutcome(request) {
|
|
1021
|
+
const label = automatedReview(request)?.label ?? "";
|
|
1022
|
+
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
1023
|
+
}
|
|
1024
|
+
function reviewTitle(requests2) {
|
|
1025
|
+
if (requests2.length === 1)
|
|
1026
|
+
return `${reviewer(requests2[0])} finished reviewing ${reference(requests2[0])}`;
|
|
1027
|
+
return `${reviewers(requests2)} finished reviewing ${requests2.length} ${nouns(requests2)}`;
|
|
1028
|
+
}
|
|
1029
|
+
function reviewMessage(requests2) {
|
|
1030
|
+
const state = (request) => `the state "${automatedReview(request)?.label ?? ""}"`;
|
|
1031
|
+
if (requests2.length === 1) {
|
|
1032
|
+
const [request] = requests2;
|
|
1033
|
+
const name2 = reviewer(request);
|
|
1034
|
+
return [
|
|
1035
|
+
`${name2} finished reviewing ${reference(request)} (${request.url}) with ${state(request)}.`,
|
|
1036
|
+
`Fetch ${name2}'s new comments and discussion threads on the ${traitsOf(request).noun}, then work through its feedback.`
|
|
1037
|
+
].join(" ");
|
|
1038
|
+
}
|
|
1039
|
+
const name = reviewers(requests2);
|
|
1040
|
+
return [
|
|
1041
|
+
`${name} finished reviewing ${requests2.length} ${nouns(requests2)}:`,
|
|
1042
|
+
...requests2.map((request) => `- ${reference(request)} (${request.url}) with ${state(request)}`),
|
|
1043
|
+
"For each one, fetch the reviewer's new comments and discussion threads, then work through its feedback."
|
|
1044
|
+
].join(`
|
|
1045
|
+
`);
|
|
1046
|
+
}
|
|
1047
|
+
function reviewToast(requests2) {
|
|
1048
|
+
const outcome = requests2.length === 1 ? reviewOutcome(requests2[0]) : requests2.map((request) => `${reference(request)}: ${automatedReview(request)?.label ?? ""}`).join(", ");
|
|
1049
|
+
return { title: reviewTitle(requests2), message: `${outcome}. Sent to the agent.` };
|
|
1050
|
+
}
|
|
1051
|
+
var RETRY_DELAY = 10 * 60000;
|
|
1052
|
+
var PENDING_RETENTION = 24 * 60 * 60000;
|
|
1053
|
+
var notificationKey = (sessionID, request) => `${sessionID} ${request.url}`;
|
|
1054
|
+
var isOpen = (request) => request.state === "opened";
|
|
1055
|
+
function createAutomatedReviewWatcher(options) {
|
|
1056
|
+
const now = options.now ?? Date.now;
|
|
1057
|
+
const known = new Map;
|
|
1058
|
+
const sending = new Set;
|
|
1059
|
+
const write = (sessionID, url, delivery) => options.write?.(sessionID, url, delivery);
|
|
1060
|
+
return {
|
|
1061
|
+
async observe(sessionID, previous, next) {
|
|
1062
|
+
const pending = known.get(sessionID) ?? { ...await options.read?.(sessionID) };
|
|
1063
|
+
known.set(sessionID, pending);
|
|
1064
|
+
const at = now();
|
|
1065
|
+
const targets = requests(next).filter((request) => isOpen(request) && options.isTarget(sessionID, request.url));
|
|
1066
|
+
for (const [key2, entry] of Object.entries(pending)) {
|
|
1067
|
+
const request = targets.find((target2) => target2.url === entry.url);
|
|
1068
|
+
if (request && automatedReview(request)?.state === "feedback" && at - entry.since < PENDING_RETENTION)
|
|
1069
|
+
continue;
|
|
1070
|
+
if (sending.has(key2))
|
|
1071
|
+
continue;
|
|
1072
|
+
delete pending[key2];
|
|
1073
|
+
write(sessionID, entry.url);
|
|
1074
|
+
}
|
|
1075
|
+
const finished = finishedReviews(previous, next).filter((request) => isOpen(request) && options.isTarget(sessionID, request.url));
|
|
1076
|
+
const due = targets.filter((request) => (pending[notificationKey(sessionID, request)]?.retryAt ?? Infinity) <= at);
|
|
1077
|
+
const batch = [
|
|
1078
|
+
...new Map([...finished, ...due].map((request) => [request.url, request])).values()
|
|
1079
|
+
].filter((request) => !sending.has(notificationKey(sessionID, request)));
|
|
1080
|
+
if (batch.length === 0)
|
|
1081
|
+
return;
|
|
1082
|
+
const keys = batch.map((request) => notificationKey(sessionID, request));
|
|
1083
|
+
for (const key2 of keys)
|
|
1084
|
+
sending.add(key2);
|
|
1085
|
+
try {
|
|
1086
|
+
await options.send(sessionID, batch);
|
|
1087
|
+
batch.forEach((request, index) => {
|
|
1088
|
+
delete pending[keys[index]];
|
|
1089
|
+
write(sessionID, request.url);
|
|
1090
|
+
});
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
const retryAt = now() + RETRY_DELAY;
|
|
1093
|
+
batch.forEach((request, index) => {
|
|
1094
|
+
const key2 = keys[index];
|
|
1095
|
+
pending[key2] = { sessionID, url: request.url, since: pending[key2]?.since ?? at, retryAt };
|
|
1096
|
+
write(sessionID, request.url, pending[key2]);
|
|
1097
|
+
});
|
|
1098
|
+
options.onSendError?.(error, sessionID, batch);
|
|
1099
|
+
} finally {
|
|
1100
|
+
for (const key2 of keys)
|
|
1101
|
+
sending.delete(key2);
|
|
1102
|
+
}
|
|
1103
|
+
},
|
|
1104
|
+
hasPending: (sessionID) => Object.keys(known.get(sessionID) ?? {}).length > 0,
|
|
1105
|
+
forget: (sessionID) => void known.delete(sessionID)
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// packages/forgekeeper/src/human-review-watch.ts
|
|
1110
|
+
var SETTLE_TIME = 5 * 60000;
|
|
1111
|
+
var FETCH_INTERVAL = 60000;
|
|
1112
|
+
var RETRY_DELAY2 = 10 * 60000;
|
|
1113
|
+
var RECORD_RETENTION = 30 * 24 * 60 * 60000;
|
|
1114
|
+
var TOUCH_INTERVAL = 24 * 60 * 60000;
|
|
1115
|
+
var MAX_LINKS = 20;
|
|
1116
|
+
var feedbackKey = (sessionID, request) => `${sessionID} ${request.url}`;
|
|
1117
|
+
function applyFeedback(record, change, now) {
|
|
1118
|
+
const announced = [...record?.announced ?? []];
|
|
1119
|
+
for (const id of change.announced ?? [])
|
|
1120
|
+
if (!announced.includes(id))
|
|
1121
|
+
announced.push(id);
|
|
1122
|
+
return { baseline: record?.baseline ?? change.baseline ?? 0, announced, seenAt: now };
|
|
1123
|
+
}
|
|
1124
|
+
var isExpired = (record, now) => now - record.seenAt > RECORD_RETENTION;
|
|
1125
|
+
var baselineOf = (snapshot2) => snapshot2.comments.reduce((highest, comment) => Math.max(highest, comment.order), 0);
|
|
1126
|
+
var isNew = (comment, seen) => comment.order > seen.baseline && !seen.announced.has(comment.id);
|
|
1127
|
+
var humanFeedback = (snapshot2) => snapshot2.comments.filter((comment) => !comment.bot && comment.authorID !== snapshot2.viewerID && !comment.resolved);
|
|
1128
|
+
function eligibleFeedback(snapshot2, seen, now) {
|
|
1129
|
+
const human = humanFeedback(snapshot2);
|
|
1130
|
+
const pending = human.filter((comment) => isNew(comment, seen));
|
|
1131
|
+
if (pending.length === 0)
|
|
1132
|
+
return [];
|
|
1133
|
+
const lastActivity = Math.max(...human.map((comment) => comment.editedAt));
|
|
1134
|
+
return now - lastActivity >= SETTLE_TIME ? pending : [];
|
|
1135
|
+
}
|
|
1136
|
+
var authors = (comments) => [
|
|
1137
|
+
...new Set(comments.map((comment) => `@${comment.username}`))
|
|
1138
|
+
];
|
|
1139
|
+
function list(items) {
|
|
1140
|
+
if (items.length <= 2)
|
|
1141
|
+
return items.join(" and ");
|
|
1142
|
+
return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
|
|
1143
|
+
}
|
|
1144
|
+
function feedbackMessage(request, comments) {
|
|
1145
|
+
const links = comments.slice(0, MAX_LINKS).map((comment) => `- @${comment.username}: ${comment.url}`);
|
|
1146
|
+
if (comments.length > MAX_LINKS)
|
|
1147
|
+
links.push(`- and ${comments.length - MAX_LINKS} more`);
|
|
1148
|
+
return [
|
|
1149
|
+
`New review feedback from ${list(authors(comments))} on ${reference(request)} (${request.url}).`,
|
|
1150
|
+
"No review comment has changed for at least 5 minutes. New comments:",
|
|
1151
|
+
...links,
|
|
1152
|
+
"Fetch these comments and their discussion threads, then assess and address the feedback."
|
|
1153
|
+
].join(`
|
|
1154
|
+
`);
|
|
1155
|
+
}
|
|
1156
|
+
function feedbackToast(request, comments) {
|
|
1157
|
+
const count = comments.length === 1 ? "1 comment" : `${comments.length} comments`;
|
|
1158
|
+
return {
|
|
1159
|
+
title: `New review feedback on ${reference(request)}`,
|
|
1160
|
+
message: `${count} from ${list(authors(comments))}. Sent to the agent.`
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
function createFeedbackWatcher(options) {
|
|
1164
|
+
const now = options.now ?? Date.now;
|
|
1165
|
+
const local = new Map;
|
|
1166
|
+
const reserved = new Set;
|
|
1167
|
+
const inflight = new Set;
|
|
1168
|
+
const fetchedAt = new Map;
|
|
1169
|
+
const retryAt = new Map;
|
|
1170
|
+
const seen = (key2, shared2) => {
|
|
1171
|
+
const mine = local.get(key2);
|
|
1172
|
+
if (!shared2 && !mine)
|
|
1173
|
+
return;
|
|
1174
|
+
return {
|
|
1175
|
+
baseline: Math.max(shared2?.baseline ?? 0, mine?.baseline ?? 0),
|
|
1176
|
+
announced: new Set([...shared2?.announced ?? [], ...mine?.announced ?? []])
|
|
1177
|
+
};
|
|
1178
|
+
};
|
|
1179
|
+
const remember = (key2, change, at) => {
|
|
1180
|
+
const mine = local.get(key2) ?? { baseline: change.baseline ?? 0, announced: new Set };
|
|
1181
|
+
for (const id of change.announced ?? [])
|
|
1182
|
+
mine.announced.add(id);
|
|
1183
|
+
local.set(key2, mine);
|
|
1184
|
+
options.persist(key2, change, at);
|
|
1185
|
+
};
|
|
1186
|
+
async function observe(sessionID, request, snapshot2) {
|
|
1187
|
+
if (!snapshot2.complete)
|
|
1188
|
+
return;
|
|
1189
|
+
const key2 = feedbackKey(sessionID, request);
|
|
1190
|
+
const stored = await options.record(key2);
|
|
1191
|
+
const at = now();
|
|
1192
|
+
const previous = seen(key2, stored);
|
|
1193
|
+
if (!previous)
|
|
1194
|
+
return remember(key2, { baseline: baselineOf(snapshot2) }, at);
|
|
1195
|
+
if (at < (retryAt.get(key2) ?? 0))
|
|
1196
|
+
return;
|
|
1197
|
+
const comments = eligibleFeedback(snapshot2, previous, at).filter((comment) => !reserved.has(`${key2} ${comment.id}`));
|
|
1198
|
+
if (comments.length === 0) {
|
|
1199
|
+
if (stored && at - stored.seenAt > TOUCH_INTERVAL)
|
|
1200
|
+
options.persist(key2, {}, at);
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
const ids = comments.map((comment) => comment.id);
|
|
1204
|
+
for (const id of ids)
|
|
1205
|
+
reserved.add(`${key2} ${id}`);
|
|
1206
|
+
try {
|
|
1207
|
+
await options.send(sessionID, request, comments);
|
|
1208
|
+
retryAt.delete(key2);
|
|
1209
|
+
remember(key2, { announced: ids }, now());
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
retryAt.set(key2, now() + RETRY_DELAY2);
|
|
1212
|
+
options.onSendError?.(error);
|
|
1213
|
+
} finally {
|
|
1214
|
+
for (const id of ids)
|
|
1215
|
+
reserved.delete(`${key2} ${id}`);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
return {
|
|
1219
|
+
async check(sessionID, request) {
|
|
1220
|
+
const key2 = feedbackKey(sessionID, request);
|
|
1221
|
+
if (inflight.has(key2) || now() - (fetchedAt.get(key2) ?? -Infinity) < FETCH_INTERVAL)
|
|
1222
|
+
return;
|
|
1223
|
+
if (!options.current(sessionID, request))
|
|
1224
|
+
return;
|
|
1225
|
+
inflight.add(key2);
|
|
1226
|
+
fetchedAt.set(key2, now());
|
|
1227
|
+
try {
|
|
1228
|
+
const snapshot2 = await options.fetch(sessionID, request);
|
|
1229
|
+
if (!snapshot2 || !options.current(sessionID, request))
|
|
1230
|
+
return;
|
|
1231
|
+
await observe(sessionID, request, snapshot2);
|
|
1232
|
+
} catch (error) {
|
|
1233
|
+
options.onFetchError?.(error);
|
|
1234
|
+
} finally {
|
|
1235
|
+
inflight.delete(key2);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// packages/forgekeeper/src/target.ts
|
|
1242
|
+
var MAX_TARGETS = 50;
|
|
1243
|
+
var PREFIX_REFERENCES = 4;
|
|
1244
|
+
var INVALID_URL = "Use a full HTTP(S) issue, pull request, or merge request URL.";
|
|
1245
|
+
function parseTargetUrl(value) {
|
|
1246
|
+
let url;
|
|
1247
|
+
try {
|
|
1248
|
+
url = new URL(value);
|
|
1249
|
+
} catch {
|
|
1250
|
+
throw new Error(INVALID_URL);
|
|
1251
|
+
}
|
|
1252
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password)
|
|
1253
|
+
throw new Error(INVALID_URL);
|
|
1254
|
+
url.search = "";
|
|
1255
|
+
url.hash = "";
|
|
1256
|
+
url.pathname = url.pathname.replace(/\/$/, "");
|
|
1257
|
+
const parsed = parseReference(url.href);
|
|
1258
|
+
if (!parsed)
|
|
1259
|
+
throw new Error("Use a GitHub or GitLab issue, pull request, or merge request URL.");
|
|
1260
|
+
return { url: url.href, ...parsed };
|
|
1261
|
+
}
|
|
1262
|
+
function parseTarget(input) {
|
|
1263
|
+
if (!input || typeof input !== "object" || !("target" in input) || typeof input.target !== "string") {
|
|
1264
|
+
throw new Error('Provide a target URL or "branch".');
|
|
1265
|
+
}
|
|
1266
|
+
const issueUrl = "issue_url" in input ? input.issue_url : undefined;
|
|
1267
|
+
if (input.target === "branch") {
|
|
1268
|
+
if (issueUrl !== undefined)
|
|
1269
|
+
throw new Error("Branch mode does not accept issue_url.");
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
return relate(parseTargetUrl(input.target), issueUrl);
|
|
1273
|
+
}
|
|
1274
|
+
function relate(target2, issueUrl) {
|
|
1275
|
+
if (issueUrl === undefined)
|
|
1276
|
+
return { url: target2.url };
|
|
1277
|
+
if (typeof issueUrl !== "string")
|
|
1278
|
+
throw new Error("issue_url must be a full issue URL.");
|
|
1279
|
+
const issue = parseTargetUrl(issueUrl);
|
|
1280
|
+
if (target2.issue || !issue.issue || target2.ref.forge !== issue.ref.forge) {
|
|
1281
|
+
throw new Error("Only a pull or merge request can have a related issue from the same forge.");
|
|
1282
|
+
}
|
|
1283
|
+
return { url: target2.url, issueUrl: issue.url };
|
|
1284
|
+
}
|
|
1285
|
+
var OPERATIONS = new Set(["replace", "add", "remove"]);
|
|
1286
|
+
var dedupe = (targets) => [
|
|
1287
|
+
...new Map(targets.map((target2) => [target2.url, target2])).values()
|
|
1288
|
+
];
|
|
1289
|
+
function parseTargetChange(input) {
|
|
1290
|
+
if (!input || typeof input !== "object")
|
|
1291
|
+
throw new Error('Provide a target URL, "branch", or a list of targets.');
|
|
1292
|
+
const value = input;
|
|
1293
|
+
const operation = value.operation ?? "replace";
|
|
1294
|
+
if (!OPERATIONS.has(operation))
|
|
1295
|
+
throw new Error('operation must be "replace", "add", or "remove".');
|
|
1296
|
+
if (operation === "remove" && value.issue_url !== undefined)
|
|
1297
|
+
throw new Error("Removing a target does not accept issue_url.");
|
|
1298
|
+
if (value.targets !== undefined) {
|
|
1299
|
+
if (value.target !== undefined)
|
|
1300
|
+
throw new Error("Use either target or targets, not both.");
|
|
1301
|
+
const urls2 = value.targets;
|
|
1302
|
+
if (!Array.isArray(urls2) || urls2.length === 0 || !urls2.every((url) => typeof url === "string")) {
|
|
1303
|
+
throw new Error("targets must be a non-empty list of full URLs.");
|
|
1304
|
+
}
|
|
1305
|
+
if (urls2.includes("branch"))
|
|
1306
|
+
throw new Error('Use target: "branch" on its own to return to branch-based naming.');
|
|
1307
|
+
if (urls2.length > MAX_TARGETS)
|
|
1308
|
+
throw new Error(`A session can have at most ${MAX_TARGETS} targets.`);
|
|
1309
|
+
return {
|
|
1310
|
+
operation,
|
|
1311
|
+
targets: dedupe(urls2.map((url) => relate(parseTargetUrl(url), value.issue_url)))
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
if (value.target === "branch" && operation !== "replace")
|
|
1315
|
+
throw new Error('"branch" replaces every target, so it takes no operation.');
|
|
1316
|
+
const target2 = parseTarget(value);
|
|
1317
|
+
return target2 ? { operation, targets: [target2] } : { operation: "branch" };
|
|
1318
|
+
}
|
|
1319
|
+
function applyTargetChange(current, change) {
|
|
1320
|
+
if (change.operation === "branch")
|
|
1321
|
+
return { targets: [], missing: [] };
|
|
1322
|
+
const known = new Map(current.map((target2) => [target2.url, target2]));
|
|
1323
|
+
if (change.operation === "remove") {
|
|
1324
|
+
const removed = new Set(change.targets.map((target2) => target2.url));
|
|
1325
|
+
return {
|
|
1326
|
+
targets: current.filter((target2) => !removed.has(target2.url)),
|
|
1327
|
+
missing: [...removed].filter((url) => !known.has(url))
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
const merge = (target2) => {
|
|
1331
|
+
const existing = known.get(target2.url);
|
|
1332
|
+
if (!existing || target2.issueUrl)
|
|
1333
|
+
return target2;
|
|
1334
|
+
if (change.operation === "add")
|
|
1335
|
+
return { ...existing };
|
|
1336
|
+
return {
|
|
1337
|
+
url: target2.url,
|
|
1338
|
+
...existing.branchIssue ? { branchIssue: existing.branchIssue } : {}
|
|
1339
|
+
};
|
|
1340
|
+
};
|
|
1341
|
+
const targets = change.operation === "replace" ? change.targets.map(merge) : dedupe([...current.map((target2) => ({ ...target2 })), ...change.targets.map(merge)]);
|
|
1342
|
+
if (targets.length > MAX_TARGETS)
|
|
1343
|
+
throw new Error(`A session can have at most ${MAX_TARGETS} targets.`);
|
|
1344
|
+
return { targets, missing: [] };
|
|
1345
|
+
}
|
|
1346
|
+
function targetRequest(target2) {
|
|
1347
|
+
const { ref, issue } = parseTargetUrl(target2.url);
|
|
1348
|
+
return issue ? undefined : ref;
|
|
1349
|
+
}
|
|
1350
|
+
var sameRef = (a, b) => a.forge === b.forge && a.host === b.host && a.iid === b.iid && a.project.toLowerCase() === b.project.toLowerCase();
|
|
1351
|
+
function includesRequest(targets, url) {
|
|
1352
|
+
const ref = parseUrl(url);
|
|
1353
|
+
if (!ref)
|
|
1354
|
+
return false;
|
|
1355
|
+
return targets.some((target2) => {
|
|
1356
|
+
const request = targetRequest(target2);
|
|
1357
|
+
return request !== undefined && sameRef(request, ref);
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
function mainReference(target2) {
|
|
1361
|
+
const { ref, issue } = parseTargetUrl(target2.url);
|
|
1362
|
+
const forge = traits(ref.forge);
|
|
1363
|
+
return issue ? forge.issueReference(ref.iid) : forge.reference(ref.iid);
|
|
1364
|
+
}
|
|
1365
|
+
function relatedIssue(target2) {
|
|
1366
|
+
const { ref, issue } = parseTargetUrl(target2.url);
|
|
1367
|
+
let related;
|
|
1368
|
+
if (target2.issueUrl)
|
|
1369
|
+
related = parseTargetUrl(target2.issueUrl).ref;
|
|
1370
|
+
else if (!issue && target2.branchIssue)
|
|
1371
|
+
related = { ...ref, iid: target2.branchIssue };
|
|
1372
|
+
else
|
|
1373
|
+
return;
|
|
1374
|
+
return {
|
|
1375
|
+
id: `${related.forge} ${related.host} ${related.project.toLowerCase()} ${related.iid}`,
|
|
1376
|
+
reference: traits(related.forge).issueReference(related.iid)
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
function targetsPrefix(targets) {
|
|
1380
|
+
if (targets.length === 0)
|
|
1381
|
+
return;
|
|
1382
|
+
const issues = targets.map(relatedIssue);
|
|
1383
|
+
const shared2 = issues.every((issue) => issue && issue.id === issues[0]?.id) ? issues[0]?.reference : undefined;
|
|
1384
|
+
const references = targets.map(mainReference);
|
|
1385
|
+
const shown = references.length > PREFIX_REFERENCES ? [
|
|
1386
|
+
...references.slice(0, PREFIX_REFERENCES - 1),
|
|
1387
|
+
`+${references.length - PREFIX_REFERENCES + 1}`
|
|
1388
|
+
] : references;
|
|
1389
|
+
return `[${[...shared2 ? [shared2] : [], ...shown].join(", ")}]`;
|
|
1390
|
+
}
|
|
1391
|
+
var urlsOf = (target2) => ({
|
|
1392
|
+
url: target2.url,
|
|
1393
|
+
...target2.issueUrl ? { issueUrl: target2.issueUrl } : {}
|
|
1394
|
+
});
|
|
1395
|
+
var targetOutput = (targets) => targets?.length ? { ...urlsOf(targets[0]), targets: targets.map(urlsOf) } : {};
|
|
1396
|
+
var classify = (url) => {
|
|
1397
|
+
const ref = parseUrl(url);
|
|
1398
|
+
return ref ? { kind: "merge-request", ref, url } : { kind: "other", url };
|
|
1399
|
+
};
|
|
1400
|
+
function classifyTargets(output) {
|
|
1401
|
+
const value = output;
|
|
1402
|
+
const urls2 = (Array.isArray(value?.targets) ? value.targets.map((target2) => target2?.url) : [value?.url]).filter((url) => typeof url === "string" && url !== "");
|
|
1403
|
+
return urls2.length ? urls2.map(classify) : undefined;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// packages/forgekeeper/src/locate.ts
|
|
1407
|
+
var UNRESOLVED = "Explicit PR/MR target could not be resolved";
|
|
1408
|
+
async function locateTargets(forges, directory, targets, previous) {
|
|
1409
|
+
const requests2 = targets.flatMap((target2) => target2.kind === "merge-request" ? [target2] : []);
|
|
1410
|
+
if (requests2.length === 0) {
|
|
1411
|
+
const reason = targets.length === 1 ? `The session target is not a PR/MR (${targets[0].url})` : "No session target is a PR/MR";
|
|
1412
|
+
return { kind: "none", reason };
|
|
1413
|
+
}
|
|
1414
|
+
const outcomes = await mapLimit(requests2, CONCURRENCY, async ({ ref }) => {
|
|
1415
|
+
const forge = forges.kind(ref.host) === ref.forge ? forges.forHost(ref.host, directory) : undefined;
|
|
1416
|
+
if (!forge)
|
|
1417
|
+
return { missing: "Unsupported session target host" };
|
|
1418
|
+
try {
|
|
1419
|
+
const request = await forge.findByNumber([{ host: ref.host, path: ref.project }], ref.iid);
|
|
1420
|
+
return request ? { request } : { missing: UNRESOLVED };
|
|
1421
|
+
} catch (error2) {
|
|
1422
|
+
return { error: error2 };
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
const earlier = previous?.kind === "found" ? previous.requests : [];
|
|
1426
|
+
const found = [];
|
|
1427
|
+
const failed = [];
|
|
1428
|
+
let error;
|
|
1429
|
+
outcomes.forEach((outcome, index) => {
|
|
1430
|
+
const { ref, url } = requests2[index];
|
|
1431
|
+
if ("request" in outcome) {
|
|
1432
|
+
found.push(outcome.request);
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
if ("missing" in outcome) {
|
|
1436
|
+
failed.push({ url, reason: outcome.missing });
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
error ??= outcome.error;
|
|
1440
|
+
const last = earlier.find((request) => {
|
|
1441
|
+
const parsed = parseUrl(request.url);
|
|
1442
|
+
return parsed && sameRef(parsed, ref);
|
|
1443
|
+
});
|
|
1444
|
+
if (last)
|
|
1445
|
+
found.push(last);
|
|
1446
|
+
failed.push({
|
|
1447
|
+
url,
|
|
1448
|
+
reason: outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
|
|
1449
|
+
});
|
|
1450
|
+
});
|
|
1451
|
+
if (error !== undefined && !outcomes.some((outcome) => ("request" in outcome)))
|
|
1452
|
+
throw error;
|
|
1453
|
+
if (found.length === 0)
|
|
1454
|
+
return {
|
|
1455
|
+
kind: "none",
|
|
1456
|
+
reason: requests2.length === 1 ? failed[0].reason : "No session target PR/MR could be resolved"
|
|
1457
|
+
};
|
|
1458
|
+
return {
|
|
1459
|
+
kind: "found",
|
|
1460
|
+
sessionTarget: requests2.length === 1 ? found[0].url : `${requests2.length} PRs/MRs set with set_session_target`,
|
|
1461
|
+
explicitTarget: true,
|
|
1462
|
+
requests: found,
|
|
1463
|
+
...failed.length ? { failed } : {}
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
async function locate(forges, hints) {
|
|
1467
|
+
const { directory, targets, title } = hints;
|
|
1468
|
+
if (targets?.length)
|
|
1469
|
+
return locateTargets(forges, directory, targets, hints.previous);
|
|
1470
|
+
for (const kind of forges.kinds) {
|
|
1471
|
+
const numbers = traits(kind).titleReferences(title);
|
|
1472
|
+
if (numbers.length === 0)
|
|
1473
|
+
continue;
|
|
1474
|
+
const projects = await forges.projects(directory, kind);
|
|
1475
|
+
const forge2 = projects.length ? forges.forHost(projects[0].host, directory) : undefined;
|
|
1476
|
+
if (!forge2)
|
|
1477
|
+
continue;
|
|
1478
|
+
for (const number of numbers) {
|
|
1479
|
+
const request = await forge2.findByNumber(projects, number);
|
|
1480
|
+
if (request) {
|
|
1481
|
+
return {
|
|
1482
|
+
kind: "found",
|
|
1483
|
+
sessionTarget: `${forge2.traits.reference(number)} (from the session title)`,
|
|
1484
|
+
requests: [request]
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
const lookup = await forges.repository(directory);
|
|
1490
|
+
if (lookup.kind === "none")
|
|
1491
|
+
return lookup;
|
|
1492
|
+
const forge = forges.forHost(lookup.repository.source.host, directory);
|
|
1493
|
+
if (!forge)
|
|
1494
|
+
return { kind: "none", reason: "Not a supported forge remote" };
|
|
1495
|
+
return {
|
|
1496
|
+
kind: "found",
|
|
1497
|
+
repository: lookup.repository,
|
|
1498
|
+
requests: await forge.findByBranch(lookup.repository)
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// packages/forgekeeper/src/store.ts
|
|
1503
|
+
function createStatusStore(options) {
|
|
1504
|
+
const now = options.now ?? Date.now;
|
|
1505
|
+
const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay));
|
|
1506
|
+
const clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer));
|
|
1507
|
+
const interval = options.interval ?? 120000;
|
|
1508
|
+
const maxBackoff = options.maxBackoff ?? 900000;
|
|
1509
|
+
const minEventInterval = options.minEventInterval ?? 15000;
|
|
1510
|
+
const entries = new Map;
|
|
1511
|
+
let disposed = false;
|
|
1512
|
+
let revision = now() * 1000;
|
|
1513
|
+
const entry = (key2) => {
|
|
1514
|
+
let value = entries.get(key2);
|
|
1515
|
+
if (!value) {
|
|
1516
|
+
value = { snapshot: { loading: false }, refs: 0, failures: 0 };
|
|
1517
|
+
entries.set(key2, value);
|
|
1518
|
+
}
|
|
1519
|
+
return value;
|
|
1520
|
+
};
|
|
1521
|
+
const update = (key2, value, patch) => {
|
|
1522
|
+
value.snapshot = { ...value.snapshot, ...patch, revision: ++revision };
|
|
1523
|
+
if (!disposed)
|
|
1524
|
+
options.onChange(key2);
|
|
1525
|
+
};
|
|
1526
|
+
const cancelTimer = (value) => {
|
|
1527
|
+
if (value.timer === undefined)
|
|
1528
|
+
return;
|
|
1529
|
+
clearTimer(value.timer);
|
|
1530
|
+
value.timer = undefined;
|
|
1531
|
+
};
|
|
1532
|
+
const baseInterval = (value) => options.active?.(value.snapshot.lookup) ? options.activeInterval ?? interval : interval;
|
|
1533
|
+
const pollDelay = (value) => Math.min(baseInterval(value) * 2 ** value.failures, maxBackoff);
|
|
1534
|
+
const lastAttempt = (value) => Math.max(value.snapshot.fetchedAt ?? -Infinity, value.snapshot.error?.at ?? -Infinity);
|
|
1535
|
+
const schedule = (key2, value, delay = pollDelay(value)) => {
|
|
1536
|
+
cancelTimer(value);
|
|
1537
|
+
if (disposed || value.refs === 0)
|
|
1538
|
+
return;
|
|
1539
|
+
const lookup = value.snapshot.lookup;
|
|
1540
|
+
if (!value.snapshot.error && (lookup?.kind !== "found" || lookup.requests.length === 0))
|
|
1541
|
+
return;
|
|
1542
|
+
value.timer = setTimer(() => {
|
|
1543
|
+
value.timer = undefined;
|
|
1544
|
+
refresh(key2);
|
|
1545
|
+
}, delay);
|
|
1546
|
+
};
|
|
1547
|
+
function refresh(key2) {
|
|
1548
|
+
if (disposed)
|
|
1549
|
+
return Promise.resolve({ loading: false });
|
|
1550
|
+
const value = entry(key2);
|
|
1551
|
+
if (value.inflight)
|
|
1552
|
+
return value.inflight;
|
|
1553
|
+
value.stale = false;
|
|
1554
|
+
update(key2, value, { loading: true });
|
|
1555
|
+
value.inflight = options.load(key2).then((lookup) => {
|
|
1556
|
+
if (disposed || value.invalidated)
|
|
1557
|
+
return;
|
|
1558
|
+
const previous = value.snapshot.lookup;
|
|
1559
|
+
value.failures = 0;
|
|
1560
|
+
update(key2, value, { lookup, fetchedAt: now(), error: undefined, loading: false });
|
|
1561
|
+
try {
|
|
1562
|
+
if (!disposed)
|
|
1563
|
+
options.onLoad?.(key2, previous, lookup);
|
|
1564
|
+
} catch (error) {
|
|
1565
|
+
try {
|
|
1566
|
+
options.onLoadError?.(error);
|
|
1567
|
+
} catch {}
|
|
1568
|
+
}
|
|
1569
|
+
}, (error) => {
|
|
1570
|
+
if (disposed || value.invalidated)
|
|
1571
|
+
return;
|
|
1572
|
+
value.failures++;
|
|
1573
|
+
const known = error instanceof ForgeError;
|
|
1574
|
+
update(key2, value, {
|
|
1575
|
+
loading: false,
|
|
1576
|
+
error: {
|
|
1577
|
+
kind: known ? error.kind : "request",
|
|
1578
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1579
|
+
at: now()
|
|
1580
|
+
}
|
|
1581
|
+
});
|
|
1582
|
+
}).then(() => {
|
|
1583
|
+
value.inflight = undefined;
|
|
1584
|
+
if (value.invalidated && !disposed) {
|
|
1585
|
+
value.invalidated = false;
|
|
1586
|
+
return refresh(key2);
|
|
1587
|
+
}
|
|
1588
|
+
schedule(key2, value);
|
|
1589
|
+
return value.snapshot;
|
|
1590
|
+
});
|
|
1591
|
+
return value.inflight;
|
|
1592
|
+
}
|
|
1593
|
+
return {
|
|
1594
|
+
get(key2) {
|
|
1595
|
+
return entries.get(key2)?.snapshot ?? { loading: false };
|
|
1596
|
+
},
|
|
1597
|
+
acquire(key2) {
|
|
1598
|
+
const value = entry(key2);
|
|
1599
|
+
value.refs++;
|
|
1600
|
+
if (value.refs === 1 && !value.inflight) {
|
|
1601
|
+
const remaining = lastAttempt(value) + pollDelay(value) - now();
|
|
1602
|
+
if (value.stale || remaining <= 0)
|
|
1603
|
+
refresh(key2);
|
|
1604
|
+
else
|
|
1605
|
+
schedule(key2, value, remaining);
|
|
1606
|
+
}
|
|
1607
|
+
let released = false;
|
|
1608
|
+
return () => {
|
|
1609
|
+
if (released)
|
|
1610
|
+
return;
|
|
1611
|
+
released = true;
|
|
1612
|
+
value.refs--;
|
|
1613
|
+
if (value.refs === 0)
|
|
1614
|
+
cancelTimer(value);
|
|
1615
|
+
};
|
|
1616
|
+
},
|
|
1617
|
+
refresh,
|
|
1618
|
+
keys() {
|
|
1619
|
+
return [...entries.keys()];
|
|
1620
|
+
},
|
|
1621
|
+
notify(key2) {
|
|
1622
|
+
const value = entries.get(key2);
|
|
1623
|
+
if (!value || value.refs === 0 || value.inflight)
|
|
1624
|
+
return;
|
|
1625
|
+
const last = Math.max(value.snapshot.fetchedAt ?? 0, value.snapshot.error?.at ?? 0);
|
|
1626
|
+
if (now() - last < minEventInterval)
|
|
1627
|
+
return;
|
|
1628
|
+
refresh(key2);
|
|
1629
|
+
},
|
|
1630
|
+
invalidate(key2) {
|
|
1631
|
+
const value = entries.get(key2);
|
|
1632
|
+
if (!value)
|
|
1633
|
+
return;
|
|
1634
|
+
if (value.inflight)
|
|
1635
|
+
value.invalidated = true;
|
|
1636
|
+
else if (value.refs > 0)
|
|
1637
|
+
refresh(key2);
|
|
1638
|
+
else
|
|
1639
|
+
value.stale = true;
|
|
1640
|
+
},
|
|
1641
|
+
dispose() {
|
|
1642
|
+
disposed = true;
|
|
1643
|
+
for (const value of entries.values())
|
|
1644
|
+
cancelTimer(value);
|
|
1645
|
+
entries.clear();
|
|
1646
|
+
}
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// packages/forgekeeper/src/status-service.ts
|
|
1651
|
+
function createStatusService(options) {
|
|
1652
|
+
const now = options.now ?? Date.now;
|
|
1653
|
+
const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay));
|
|
1654
|
+
const clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer));
|
|
1655
|
+
const leaseTime = options.leaseTime ?? LEASE_TIME;
|
|
1656
|
+
const leases = new Map;
|
|
1657
|
+
const holds = new Map;
|
|
1658
|
+
const tasks = new Set;
|
|
1659
|
+
const track = (task) => {
|
|
1660
|
+
tasks.add(task);
|
|
1661
|
+
task.finally(() => tasks.delete(task));
|
|
1662
|
+
};
|
|
1663
|
+
const owns = (key2) => key2 !== HOME && (options.owns?.(key2) ?? true);
|
|
1664
|
+
let sweepTimer;
|
|
1665
|
+
let disposed = false;
|
|
1666
|
+
const report = (error) => {
|
|
1667
|
+
try {
|
|
1668
|
+
options.onWatchError?.(error);
|
|
1669
|
+
} catch {}
|
|
1670
|
+
};
|
|
1671
|
+
const store = createStatusStore({
|
|
1672
|
+
load: (key2) => options.load(key2, store.get(key2).lookup),
|
|
1673
|
+
onChange: (key2) => options.publish(key2, store.get(key2)),
|
|
1674
|
+
onLoad: (key2, previous, next) => onLoad(key2, previous, next),
|
|
1675
|
+
onLoadError: report,
|
|
1676
|
+
active: isReviewRunning,
|
|
1677
|
+
interval: options.interval,
|
|
1678
|
+
activeInterval: options.activeInterval,
|
|
1679
|
+
now,
|
|
1680
|
+
setTimer: options.setTimer,
|
|
1681
|
+
clearTimer: options.clearTimer
|
|
1682
|
+
});
|
|
1683
|
+
const targets = (key2, lookup) => owns(key2) && lookup?.kind === "found" && lookup.explicitTarget ? lookup.requests.filter((request) => options.isTarget(key2, request.url)) : [];
|
|
1684
|
+
const watchedForFeedback = (key2, lookup) => options.human ? targets(key2, lookup).filter((request) => traitsOf(request).feedback && request.state === "opened") : [];
|
|
1685
|
+
function needsWatch(key2) {
|
|
1686
|
+
const lookup = store.get(key2).lookup;
|
|
1687
|
+
if (options.automated) {
|
|
1688
|
+
if (targets(key2, lookup).some((request) => automatedReview(request)?.state === "running"))
|
|
1689
|
+
return true;
|
|
1690
|
+
if (owns(key2) && options.automated.hasPending(key2))
|
|
1691
|
+
return true;
|
|
1692
|
+
}
|
|
1693
|
+
return watchedForFeedback(key2, lookup).length > 0;
|
|
1694
|
+
}
|
|
1695
|
+
const unknown = (key2) => owns(key2) && store.get(key2).lookup === undefined;
|
|
1696
|
+
const liveLeases = (key2) => [...leases.get(key2)?.values() ?? []].filter((lease) => lease.until > now());
|
|
1697
|
+
function sync(key2) {
|
|
1698
|
+
if (disposed)
|
|
1699
|
+
return;
|
|
1700
|
+
const live = liveLeases(key2);
|
|
1701
|
+
const wanted = live.length > 0 && (live.some((lease) => lease.visible) || unknown(key2) || needsWatch(key2));
|
|
1702
|
+
const release = holds.get(key2);
|
|
1703
|
+
if (wanted && !release)
|
|
1704
|
+
holds.set(key2, store.acquire(key2));
|
|
1705
|
+
else if (!wanted && release) {
|
|
1706
|
+
release();
|
|
1707
|
+
holds.delete(key2);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
function onLoad(key2, previous, next) {
|
|
1711
|
+
if (owns(key2) && options.automated) {
|
|
1712
|
+
track(options.automated.observe(key2, previous, next).catch(report).finally(() => sync(key2)));
|
|
1713
|
+
}
|
|
1714
|
+
for (const request of watchedForFeedback(key2, next)) {
|
|
1715
|
+
track(Promise.resolve().then(() => options.human?.check(key2, request)).catch(report));
|
|
1716
|
+
}
|
|
1717
|
+
sync(key2);
|
|
1718
|
+
}
|
|
1719
|
+
function sweep() {
|
|
1720
|
+
const at = now();
|
|
1721
|
+
for (const key2 of new Set([...leases.keys(), ...holds.keys()])) {
|
|
1722
|
+
const clients = leases.get(key2);
|
|
1723
|
+
for (const [clientID, lease] of clients ?? [])
|
|
1724
|
+
if (lease.until <= at)
|
|
1725
|
+
clients?.delete(clientID);
|
|
1726
|
+
if (clients?.size === 0)
|
|
1727
|
+
leases.delete(key2);
|
|
1728
|
+
sync(key2);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
function scheduleSweep() {
|
|
1732
|
+
if (disposed || sweepTimer !== undefined || leases.size === 0)
|
|
1733
|
+
return;
|
|
1734
|
+
sweepTimer = setTimer(() => {
|
|
1735
|
+
sweepTimer = undefined;
|
|
1736
|
+
sweep();
|
|
1737
|
+
scheduleSweep();
|
|
1738
|
+
}, leaseTime / 3);
|
|
1739
|
+
}
|
|
1740
|
+
function forget(clientID, keep = new Set) {
|
|
1741
|
+
const changed = [];
|
|
1742
|
+
for (const [key2, clients] of leases) {
|
|
1743
|
+
if (keep.has(key2) || !clients.delete(clientID))
|
|
1744
|
+
continue;
|
|
1745
|
+
if (clients.size === 0)
|
|
1746
|
+
leases.delete(key2);
|
|
1747
|
+
changed.push(key2);
|
|
1748
|
+
}
|
|
1749
|
+
return changed;
|
|
1750
|
+
}
|
|
1751
|
+
return {
|
|
1752
|
+
watch(clientID, keys, visible) {
|
|
1753
|
+
const listed = new Set(keys);
|
|
1754
|
+
const changed = new Set([...forget(clientID, listed), ...listed]);
|
|
1755
|
+
const until = now() + leaseTime;
|
|
1756
|
+
for (const key2 of listed) {
|
|
1757
|
+
const clients = leases.get(key2) ?? new Map;
|
|
1758
|
+
leases.set(key2, clients);
|
|
1759
|
+
clients.set(clientID, { until, visible: key2 === visible });
|
|
1760
|
+
}
|
|
1761
|
+
for (const key2 of changed)
|
|
1762
|
+
sync(key2);
|
|
1763
|
+
scheduleSweep();
|
|
1764
|
+
return Object.fromEntries([...listed].map((key2) => [key2, store.get(key2)]));
|
|
1765
|
+
},
|
|
1766
|
+
release(clientID) {
|
|
1767
|
+
for (const key2 of forget(clientID))
|
|
1768
|
+
sync(key2);
|
|
1769
|
+
},
|
|
1770
|
+
get: (key2) => store.get(key2),
|
|
1771
|
+
refresh: (key2) => store.refresh(key2),
|
|
1772
|
+
invalidate(key2) {
|
|
1773
|
+
store.invalidate(key2);
|
|
1774
|
+
if (!disposed && !holds.has(key2) && liveLeases(key2).length > 0)
|
|
1775
|
+
store.refresh(key2);
|
|
1776
|
+
},
|
|
1777
|
+
notify: (key2) => store.notify(key2),
|
|
1778
|
+
keys: () => store.keys(),
|
|
1779
|
+
polling: (key2) => holds.has(key2),
|
|
1780
|
+
async dispose(wait = 0) {
|
|
1781
|
+
disposed = true;
|
|
1782
|
+
if (sweepTimer !== undefined)
|
|
1783
|
+
clearTimer(sweepTimer);
|
|
1784
|
+
for (const release of holds.values())
|
|
1785
|
+
release();
|
|
1786
|
+
holds.clear();
|
|
1787
|
+
store.dispose();
|
|
1788
|
+
if (wait <= 0 || tasks.size === 0)
|
|
1789
|
+
return;
|
|
1790
|
+
let timer;
|
|
1791
|
+
await Promise.race([
|
|
1792
|
+
Promise.allSettled([...tasks]),
|
|
1793
|
+
new Promise((resolve) => timer = setTimer(resolve, wait))
|
|
1794
|
+
]);
|
|
1795
|
+
clearTimer(timer);
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// packages/forgekeeper/src/status-server.ts
|
|
1801
|
+
var REVIEW_POLL_SECONDS = 30;
|
|
1802
|
+
var DRAIN_TIME = 5000;
|
|
1803
|
+
var PENDING_PREFIX = "status/pending/";
|
|
1804
|
+
var FEEDBACK_PREFIX = "status/feedback/";
|
|
1805
|
+
var pendingPrefix = (sessionID) => `${PENDING_PREFIX}${encodeURIComponent(sessionID)}/`;
|
|
1806
|
+
var pendingKey = (sessionID, url) => `${pendingPrefix(sessionID)}${encodeURIComponent(url)}`;
|
|
1807
|
+
var feedbackRecordKey = (key2) => `${FEEDBACK_PREFIX}${encodeURIComponent(key2)}`;
|
|
1808
|
+
var LEGACY_KEYS = ["status/humanReviewFeedback", "status/automatedReviewPending"];
|
|
1809
|
+
function userTitle(title, prefix) {
|
|
1810
|
+
if (!title || !prefix)
|
|
1811
|
+
return title;
|
|
1812
|
+
if (title === prefix)
|
|
1813
|
+
return "";
|
|
1814
|
+
return title.startsWith(`${prefix} `) ? title.slice(prefix.length + 1) : title;
|
|
1815
|
+
}
|
|
1816
|
+
async function setupStatus(ctx, settings, deps, host) {
|
|
1817
|
+
const directory = ctx.location.directory;
|
|
1818
|
+
let closing = false;
|
|
1819
|
+
let closed = false;
|
|
1820
|
+
const foreign = new Set;
|
|
1821
|
+
let invalidate = () => {};
|
|
1822
|
+
let writes = Promise.resolve();
|
|
1823
|
+
const write = (task) => {
|
|
1824
|
+
if (closed)
|
|
1825
|
+
return;
|
|
1826
|
+
writes = writes.then(task).catch((error) => reportOnce("storage", "Could not store review state", error));
|
|
1827
|
+
};
|
|
1828
|
+
async function scanAll(prefix) {
|
|
1829
|
+
const entries = [];
|
|
1830
|
+
let after;
|
|
1831
|
+
do {
|
|
1832
|
+
const page = await ctx.storage.scan({ prefix, ...after ? { after } : {} });
|
|
1833
|
+
entries.push(...page.entries);
|
|
1834
|
+
after = page.next;
|
|
1835
|
+
} while (after);
|
|
1836
|
+
return entries;
|
|
1837
|
+
}
|
|
1838
|
+
const notice = (value) => {
|
|
1839
|
+
if (closed || closing && value.variant === "error")
|
|
1840
|
+
return;
|
|
1841
|
+
host.notice(value);
|
|
1842
|
+
};
|
|
1843
|
+
const isTarget = (sessionID, url) => !closing && !foreign.has(sessionID) && host.isTarget(sessionID, url);
|
|
1844
|
+
async function owns(sessionID) {
|
|
1845
|
+
const session = await ctx.session.get({ sessionID });
|
|
1846
|
+
if (session.location.directory === directory)
|
|
1847
|
+
return true;
|
|
1848
|
+
markForeign(sessionID);
|
|
1849
|
+
return false;
|
|
1850
|
+
}
|
|
1851
|
+
function markForeign(sessionID) {
|
|
1852
|
+
if (foreign.has(sessionID))
|
|
1853
|
+
return;
|
|
1854
|
+
foreign.add(sessionID);
|
|
1855
|
+
automated?.forget(sessionID);
|
|
1856
|
+
invalidate(sessionID);
|
|
1857
|
+
}
|
|
1858
|
+
const reported = new Set;
|
|
1859
|
+
const reportOnce = (kind, prefix, error) => {
|
|
1860
|
+
if (reported.has(kind))
|
|
1861
|
+
return;
|
|
1862
|
+
reported.add(kind);
|
|
1863
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1864
|
+
notice({ message: `${prefix}: ${reason}`, variant: "error" });
|
|
1865
|
+
};
|
|
1866
|
+
async function tell(sessionID, requests2, compose) {
|
|
1867
|
+
if (closing)
|
|
1868
|
+
throw new Error("The plugin stopped");
|
|
1869
|
+
if (!await owns(sessionID))
|
|
1870
|
+
return [];
|
|
1871
|
+
if (closing)
|
|
1872
|
+
throw new Error("The plugin stopped");
|
|
1873
|
+
const current = requests2.filter((request) => isTarget(sessionID, request.url));
|
|
1874
|
+
if (current.length === 0)
|
|
1875
|
+
return [];
|
|
1876
|
+
const { text, description } = compose(current);
|
|
1877
|
+
await ctx.session.synthetic({
|
|
1878
|
+
sessionID,
|
|
1879
|
+
text,
|
|
1880
|
+
description,
|
|
1881
|
+
delivery: "queue",
|
|
1882
|
+
resume: settings.resumeSession
|
|
1883
|
+
});
|
|
1884
|
+
return current;
|
|
1885
|
+
}
|
|
1886
|
+
const readFeedback = deps.feedback ?? (async (request, cwd) => traitsOf(request).feedback?.fetch(deps.exec, cwd, request));
|
|
1887
|
+
const automated = settings.notifyAutomatedReviews ? createAutomatedReviewWatcher({
|
|
1888
|
+
isTarget,
|
|
1889
|
+
now: deps.now,
|
|
1890
|
+
async read(sessionID) {
|
|
1891
|
+
const deliveries = {};
|
|
1892
|
+
for (const { value } of await scanAll(pendingPrefix(sessionID))) {
|
|
1893
|
+
const delivery = value;
|
|
1894
|
+
deliveries[notificationKey(sessionID, delivery)] = delivery;
|
|
1895
|
+
}
|
|
1896
|
+
return deliveries;
|
|
1897
|
+
},
|
|
1898
|
+
write: (sessionID, url, delivery) => write(() => delivery ? ctx.storage.set(pendingKey(sessionID, url), delivery) : ctx.storage.remove(pendingKey(sessionID, url))),
|
|
1899
|
+
async send(sessionID, requests2) {
|
|
1900
|
+
const sent = await tell(sessionID, requests2, (current) => ({
|
|
1901
|
+
text: reviewMessage(current),
|
|
1902
|
+
description: reviewTitle(current)
|
|
1903
|
+
}));
|
|
1904
|
+
if (sent.length > 0)
|
|
1905
|
+
notice({ sessionID, ...reviewToast(sent), variant: "info" });
|
|
1906
|
+
},
|
|
1907
|
+
onSendError(error, sessionID, requests2) {
|
|
1908
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1909
|
+
const name = automatedReview(requests2[0])?.name ?? "the automated reviewer";
|
|
1910
|
+
notice({
|
|
1911
|
+
sessionID,
|
|
1912
|
+
message: `Could not tell the session about ${name}'s review: ${reason}. Retrying in 10 minutes.`,
|
|
1913
|
+
variant: "error"
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
}) : undefined;
|
|
1917
|
+
const human = settings.notifyHumanReviews ? createFeedbackWatcher({
|
|
1918
|
+
record: async (key2) => await ctx.storage.get(feedbackRecordKey(key2)),
|
|
1919
|
+
now: deps.now,
|
|
1920
|
+
persist: (key2, change, at) => write(async () => {
|
|
1921
|
+
const stored = await ctx.storage.get(feedbackRecordKey(key2));
|
|
1922
|
+
await ctx.storage.set(feedbackRecordKey(key2), applyFeedback(stored, change, at));
|
|
1923
|
+
}),
|
|
1924
|
+
fetch: async (_sessionID, request) => closing ? undefined : readFeedback(request, directory),
|
|
1925
|
+
current: (sessionID, request) => isTarget(sessionID, request.url),
|
|
1926
|
+
async send(sessionID, request, comments) {
|
|
1927
|
+
const sent = await tell(sessionID, [request], () => ({
|
|
1928
|
+
text: feedbackMessage(request, comments),
|
|
1929
|
+
description: `New review feedback on ${reference(request)}`
|
|
1930
|
+
}));
|
|
1931
|
+
if (sent.length > 0)
|
|
1932
|
+
notice({ sessionID, ...feedbackToast(request, comments), variant: "info" });
|
|
1933
|
+
},
|
|
1934
|
+
onFetchError: (error) => reportOnce("feedback-fetch", "Could not check PR/MR review feedback", error),
|
|
1935
|
+
onSendError: (error) => {
|
|
1936
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1937
|
+
notice({
|
|
1938
|
+
message: `Could not tell the session about review feedback: ${reason}`,
|
|
1939
|
+
variant: "error"
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
}) : undefined;
|
|
1943
|
+
const service = createStatusService({
|
|
1944
|
+
async load(key2, previous) {
|
|
1945
|
+
if (key2 === HOME)
|
|
1946
|
+
return locate(deps.forges, { directory });
|
|
1947
|
+
const session = await ctx.session.get({ sessionID: key2 });
|
|
1948
|
+
if (session.location.directory !== directory) {
|
|
1949
|
+
foreign.add(key2);
|
|
1950
|
+
automated?.forget(key2);
|
|
1951
|
+
return { kind: "none", reason: "The session moved to another checkout" };
|
|
1952
|
+
}
|
|
1953
|
+
foreign.delete(key2);
|
|
1954
|
+
const state = await host.state(key2);
|
|
1955
|
+
return locate(deps.forges, {
|
|
1956
|
+
directory,
|
|
1957
|
+
targets: classifyTargets(targetOutput(state.targets)),
|
|
1958
|
+
title: userTitle(session.title, state.prefix),
|
|
1959
|
+
previous
|
|
1960
|
+
});
|
|
1961
|
+
},
|
|
1962
|
+
isTarget,
|
|
1963
|
+
owns: (sessionID) => !foreign.has(sessionID),
|
|
1964
|
+
publish: host.publish,
|
|
1965
|
+
automated,
|
|
1966
|
+
human,
|
|
1967
|
+
onWatchError: (error) => reportOnce("watch", "PR/MR status watcher failed", error),
|
|
1968
|
+
interval: settings.pollSeconds * 1000,
|
|
1969
|
+
activeInterval: Math.min(settings.pollSeconds, REVIEW_POLL_SECONDS) * 1000,
|
|
1970
|
+
now: deps.now
|
|
1971
|
+
});
|
|
1972
|
+
invalidate = (sessionID) => service.invalidate(sessionID);
|
|
1973
|
+
write(async () => {
|
|
1974
|
+
for (const key2 of LEGACY_KEYS)
|
|
1975
|
+
await ctx.storage.remove(key2);
|
|
1976
|
+
const at = (deps.now ?? Date.now)();
|
|
1977
|
+
for (const { key: key2, value } of await scanAll(FEEDBACK_PREFIX)) {
|
|
1978
|
+
if (isExpired(value, at))
|
|
1979
|
+
await ctx.storage.remove(key2);
|
|
1980
|
+
}
|
|
1981
|
+
});
|
|
1982
|
+
return {
|
|
1983
|
+
service,
|
|
1984
|
+
targetsChanged: (sessionID) => service.invalidate(sessionID),
|
|
1985
|
+
async titleChanged(sessionID) {
|
|
1986
|
+
if (!service.keys().includes(sessionID))
|
|
1987
|
+
return;
|
|
1988
|
+
if ((await host.state(sessionID)).targets.length === 0)
|
|
1989
|
+
service.invalidate(sessionID);
|
|
1990
|
+
},
|
|
1991
|
+
turnEnded() {
|
|
1992
|
+
for (const key2 of service.keys())
|
|
1993
|
+
service.notify(key2);
|
|
1994
|
+
},
|
|
1995
|
+
async branchChanged() {
|
|
1996
|
+
for (const key2 of service.keys()) {
|
|
1997
|
+
if (key2 === HOME || (await host.state(key2)).targets.length === 0)
|
|
1998
|
+
service.invalidate(key2);
|
|
1999
|
+
}
|
|
2000
|
+
},
|
|
2001
|
+
sessionMoved(sessionID) {
|
|
2002
|
+
if (service.keys().includes(sessionID))
|
|
2003
|
+
service.invalidate(sessionID);
|
|
2004
|
+
},
|
|
2005
|
+
async dispose() {
|
|
2006
|
+
closing = true;
|
|
2007
|
+
await service.dispose(DRAIN_TIME);
|
|
2008
|
+
closed = true;
|
|
2009
|
+
await writes;
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// packages/forgekeeper/src/title.ts
|
|
2015
|
+
var LEGACY_PREFIX_RE = /^\[[^[\],]+, [!#](?:\d+|N\/A)\] /;
|
|
2016
|
+
var MAX_TITLE_LENGTH = 100;
|
|
2017
|
+
var MAX_BRANCH_LABEL = 40;
|
|
2018
|
+
var ISSUE_PATTERNS = [
|
|
2019
|
+
/(?:^|[/])(\d+)[-/]/,
|
|
2020
|
+
/[-/](\d+)$/,
|
|
2021
|
+
/^(?:issue|gh|bug|fix|feat|feature|hotfix)[-/](\d+)\b/i
|
|
2022
|
+
];
|
|
2023
|
+
function extractIssueNumber(branch) {
|
|
2024
|
+
for (const pattern of ISSUE_PATTERNS) {
|
|
2025
|
+
const match = branch.match(pattern);
|
|
2026
|
+
if (match)
|
|
2027
|
+
return match[1];
|
|
2028
|
+
}
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
function reconcileTitle(title, prefix, previousPrefix) {
|
|
2032
|
+
let rest;
|
|
2033
|
+
if (previousPrefix && title === previousPrefix)
|
|
2034
|
+
rest = "";
|
|
2035
|
+
else if (previousPrefix && title.startsWith(`${previousPrefix} `))
|
|
2036
|
+
rest = title.slice(previousPrefix.length + 1);
|
|
2037
|
+
else
|
|
2038
|
+
rest = title.replace(LEGACY_PREFIX_RE, "");
|
|
2039
|
+
if (!prefix)
|
|
2040
|
+
return rest.slice(0, MAX_TITLE_LENGTH);
|
|
2041
|
+
const kept = rest.slice(0, Math.max(0, MAX_TITLE_LENGTH - prefix.length - 1));
|
|
2042
|
+
return kept ? `${prefix} ${kept}` : prefix;
|
|
2043
|
+
}
|
|
2044
|
+
function branchPrefix(forge, branch, number) {
|
|
2045
|
+
const issue = extractIssueNumber(branch);
|
|
2046
|
+
const label = issue ? forge.issueReference(issue) : shorten(branch, MAX_BRANCH_LABEL);
|
|
2047
|
+
return `[${label}, ${forge.reference(number ?? "N/A")}]`;
|
|
2048
|
+
}
|
|
2049
|
+
var shorten = (text, length) => text.length > length ? `${text.slice(0, length - 1)}\u2026` : text;
|
|
2050
|
+
|
|
2051
|
+
// packages/forgekeeper/src/server.ts
|
|
2052
|
+
var SKIP_BRANCHES = new Set(["master", "main", "HEAD", "develop"]);
|
|
2053
|
+
var GUIDANCE = [
|
|
2054
|
+
"When the user establishes or changes the primary issue, PR, or MR for this session, call set_session_target with its full URL before starting that work.",
|
|
2055
|
+
"When the user asks you to work on several issues, PRs, or MRs together, call set_session_target with all of their full URLs in targets. If the user describes them instead of linking them, find them first, then set them before starting that work. When they are all PRs/MRs for one established issue, pass it as issue_url.",
|
|
2056
|
+
'After you create a PR or MR for the current task (for example with gpsup, glab mr create, gh pr create, or a forge tool), call set_session_target with the new PR/MR URL. Pass the current issue as issue_url when the PR/MR was created for it. If the session has several targets that are still part of the task, use operation "add" so they are kept.',
|
|
2057
|
+
'When the user drops some of several targets from the task, call set_session_target with operation "remove" and their URLs.',
|
|
2058
|
+
"Background references, comparisons, and dependencies do not change the target. Follow-ups without a new primary target keep the current target.",
|
|
2059
|
+
"Include issue_url only when its relationship to the target PRs/MRs is established. Never carry over the checked-out branch\u2019s issue to another target.",
|
|
2060
|
+
'When the user explicitly returns to work on the checked-out branch, call set_session_target with target "branch".'
|
|
2061
|
+
];
|
|
2062
|
+
function describeTargets(targets) {
|
|
2063
|
+
if (targets.length === 0)
|
|
2064
|
+
return "checked-out branch (automatic)";
|
|
2065
|
+
if (targets.length === 1)
|
|
2066
|
+
return JSON.stringify(targets[0]);
|
|
2067
|
+
return `${targets.length} targets: ${JSON.stringify(targets)}`;
|
|
2068
|
+
}
|
|
2069
|
+
function toolResult(targets, missing) {
|
|
2070
|
+
const lines = targets.length === 0 ? ["Session target: checked-out branch."] : targets.length === 1 ? [`Session target: ${targets[0].url}`] : [`Session targets (${targets.length}):`, ...targets.map((target2) => `- ${target2.url}`)];
|
|
2071
|
+
if (missing.length)
|
|
2072
|
+
lines.push(`Not session targets, so not removed: ${missing.join(", ")}`);
|
|
2073
|
+
return lines.join(`
|
|
2074
|
+
`);
|
|
2075
|
+
}
|
|
2076
|
+
var DISABLED = { enabled: false, snapshot: { loading: false } };
|
|
2077
|
+
var DRAIN_TIME2 = 5000;
|
|
2078
|
+
async function drain(tasks, limit) {
|
|
2079
|
+
let timer;
|
|
2080
|
+
await Promise.race([
|
|
2081
|
+
Promise.allSettled(tasks),
|
|
2082
|
+
new Promise((resolve) => timer = setTimeout(resolve, limit))
|
|
2083
|
+
]);
|
|
2084
|
+
clearTimeout(timer);
|
|
2085
|
+
}
|
|
2086
|
+
var json = (value) => JSON.parse(JSON.stringify(value));
|
|
2087
|
+
async function setupServer(ctx, lookups, status2) {
|
|
2088
|
+
const pending = new Map;
|
|
2089
|
+
const controller = new AbortController;
|
|
2090
|
+
let closing = false;
|
|
2091
|
+
const known = new Map;
|
|
2092
|
+
const revisions = new Map;
|
|
2093
|
+
const revision = (sessionID) => revisions.get(sessionID) ?? 0;
|
|
2094
|
+
const bump = (sessionID) => revisions.set(sessionID, revision(sessionID) + 1);
|
|
2095
|
+
async function stateFor(sessionID) {
|
|
2096
|
+
const started = revision(sessionID);
|
|
2097
|
+
const stored = await ctx.storage.get(`sessions/${sessionID}`) ?? {};
|
|
2098
|
+
const targets = Array.isArray(stored.targets) ? stored.targets : stored.target ? [stored.target] : [];
|
|
2099
|
+
if (revision(sessionID) === started)
|
|
2100
|
+
known.set(sessionID, targets);
|
|
2101
|
+
return { targets, ...stored.prefix ? { prefix: stored.prefix } : {} };
|
|
2102
|
+
}
|
|
2103
|
+
const save = async (sessionID, state) => {
|
|
2104
|
+
if (closing)
|
|
2105
|
+
throw new Error("The plugin is reloading. Try again.");
|
|
2106
|
+
bump(sessionID);
|
|
2107
|
+
known.set(sessionID, state.targets);
|
|
2108
|
+
try {
|
|
2109
|
+
await ctx.storage.set(`sessions/${sessionID}`, {
|
|
2110
|
+
...state.targets.length ? { targets: state.targets } : {},
|
|
2111
|
+
...state.prefix ? { prefix: state.prefix } : {}
|
|
2112
|
+
});
|
|
2113
|
+
} finally {
|
|
2114
|
+
bump(sessionID);
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
let watcher;
|
|
2118
|
+
const rpc = await ctx.rpc.register(ForgeSessionRpc, {
|
|
2119
|
+
target: async (input) => {
|
|
2120
|
+
const { sessionID } = input;
|
|
2121
|
+
return targetOutput((await stateFor(sessionID)).targets);
|
|
2122
|
+
},
|
|
2123
|
+
watch: async (input) => {
|
|
2124
|
+
const { clientID, keys, visible } = input;
|
|
2125
|
+
return watcher ? { enabled: true, statuses: json(watcher.service.watch(clientID, keys, visible)) } : { enabled: false, statuses: {} };
|
|
2126
|
+
},
|
|
2127
|
+
release: async (input) => {
|
|
2128
|
+
watcher?.service.release(input.clientID);
|
|
2129
|
+
return {};
|
|
2130
|
+
},
|
|
2131
|
+
refresh: async (input) => {
|
|
2132
|
+
const { key: key2 } = input;
|
|
2133
|
+
return watcher ? { enabled: true, snapshot: json(await watcher.service.refresh(key2)) } : DISABLED;
|
|
2134
|
+
}
|
|
2135
|
+
});
|
|
2136
|
+
const settings = statusOptions(ctx.options);
|
|
2137
|
+
if (status2 && settings.enabled) {
|
|
2138
|
+
watcher = await setupStatus(ctx, settings, status2, {
|
|
2139
|
+
state: stateFor,
|
|
2140
|
+
isTarget: (sessionID, url) => includesRequest(known.get(sessionID) ?? [], url),
|
|
2141
|
+
publish: (key2, snapshot2) => void rpc.events.emit("status", { directory: ctx.location.directory, key: key2, snapshot: json(snapshot2) }).catch(() => {}),
|
|
2142
|
+
notice: (notice) => void rpc.events.emit("notice", json(notice)).catch(() => {})
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
async function update(sessionID) {
|
|
2146
|
+
const session = await ctx.session.get({ sessionID });
|
|
2147
|
+
if (session.parentID || !session.title)
|
|
2148
|
+
return;
|
|
2149
|
+
const directory = session.location.directory;
|
|
2150
|
+
if (directory !== ctx.location.directory)
|
|
2151
|
+
return;
|
|
2152
|
+
const state = await stateFor(sessionID);
|
|
2153
|
+
let prefix = targetsPrefix(state.targets);
|
|
2154
|
+
if (state.targets.length === 0) {
|
|
2155
|
+
const vcs = await ctx.vcs.get({ location: session.location });
|
|
2156
|
+
const info = vcs.data?.branch;
|
|
2157
|
+
const branch = info?.current;
|
|
2158
|
+
if (branch && !SKIP_BRANCHES.has(branch) && branch !== info?.default) {
|
|
2159
|
+
const found = await lookups.branch(directory);
|
|
2160
|
+
if (found)
|
|
2161
|
+
prefix = branchPrefix(found.forge, found.branch, found.number);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
if (closing)
|
|
2165
|
+
return;
|
|
2166
|
+
const title = reconcileTitle(session.title, prefix, state.prefix);
|
|
2167
|
+
if (title !== session.title)
|
|
2168
|
+
await ctx.session.update({ sessionID, title });
|
|
2169
|
+
await save(sessionID, { targets: state.targets, ...prefix ? { prefix } : {} });
|
|
2170
|
+
}
|
|
2171
|
+
function schedule(sessionID, action = () => update(sessionID)) {
|
|
2172
|
+
if (closing)
|
|
2173
|
+
return Promise.reject(new Error("The plugin is reloading. Try again."));
|
|
2174
|
+
const previous = pending.get(sessionID) ?? Promise.resolve();
|
|
2175
|
+
const next = previous.then(action);
|
|
2176
|
+
const settled = next.catch(() => {});
|
|
2177
|
+
pending.set(sessionID, settled);
|
|
2178
|
+
settled.then(() => {
|
|
2179
|
+
if (pending.get(sessionID) === settled)
|
|
2180
|
+
pending.delete(sessionID);
|
|
2181
|
+
});
|
|
2182
|
+
return next;
|
|
2183
|
+
}
|
|
2184
|
+
async function inferIssues(targets, previous) {
|
|
2185
|
+
const known2 = new Set(previous.filter((target2) => !target2.issueUrl).map((target2) => target2.url));
|
|
2186
|
+
await mapLimit(targets, CONCURRENCY, async (target2) => {
|
|
2187
|
+
if (target2.issueUrl || target2.branchIssue || known2.has(target2.url))
|
|
2188
|
+
return;
|
|
2189
|
+
const request = targetRequest(target2);
|
|
2190
|
+
const branch = request ? await lookups.sourceBranch(request, ctx.location.directory) : undefined;
|
|
2191
|
+
const issue = branch ? extractIssueNumber(branch) : undefined;
|
|
2192
|
+
if (issue)
|
|
2193
|
+
target2.branchIssue = issue;
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
const tools = await ctx.tool.transform((editor) => {
|
|
2197
|
+
editor.add({
|
|
2198
|
+
name: "set_session_target",
|
|
2199
|
+
description: 'Set the issues, PRs, or MRs this session works on, for its title and PR/MR status. Use target for one full URL, or "branch" to return to branch-based naming. Use targets for several full URLs. The default operation, "replace", sets exactly the given targets; "add" keeps the current targets, and "remove" drops the given ones. Call it after creating a PR/MR for the current task. Without issue_url, the related issue is inferred from the PR/MR source branch name. This only changes local session metadata.',
|
|
2200
|
+
input: {
|
|
2201
|
+
type: "object",
|
|
2202
|
+
properties: {
|
|
2203
|
+
target: {
|
|
2204
|
+
type: "string",
|
|
2205
|
+
description: 'Full GitHub/GitLab issue, PR, or MR URL, or "branch".'
|
|
2206
|
+
},
|
|
2207
|
+
targets: {
|
|
2208
|
+
type: "array",
|
|
2209
|
+
items: { type: "string" },
|
|
2210
|
+
minItems: 1,
|
|
2211
|
+
maxItems: MAX_TARGETS,
|
|
2212
|
+
description: "Several full GitHub/GitLab issue, PR, or MR URLs, instead of target."
|
|
2213
|
+
},
|
|
2214
|
+
operation: {
|
|
2215
|
+
type: "string",
|
|
2216
|
+
enum: ["replace", "add", "remove"],
|
|
2217
|
+
description: 'How the given targets change the current ones. Defaults to "replace".'
|
|
2218
|
+
},
|
|
2219
|
+
issue_url: {
|
|
2220
|
+
type: "string",
|
|
2221
|
+
description: "Optional full URL of an established related issue for target, or for every PR/MR in targets. Omit when unknown."
|
|
2222
|
+
}
|
|
2223
|
+
},
|
|
2224
|
+
additionalProperties: false
|
|
2225
|
+
},
|
|
2226
|
+
execute: async (input, { sessionID }) => {
|
|
2227
|
+
const change = parseTargetChange(input);
|
|
2228
|
+
let result = { targets: [], missing: [] };
|
|
2229
|
+
await schedule(sessionID, async () => {
|
|
2230
|
+
const session = await ctx.session.get({ sessionID });
|
|
2231
|
+
if (session.parentID || session.location.directory !== ctx.location.directory) {
|
|
2232
|
+
throw new Error("Session targets can only be set in the current root session.");
|
|
2233
|
+
}
|
|
2234
|
+
const state = await stateFor(sessionID);
|
|
2235
|
+
result = applyTargetChange(state.targets, change);
|
|
2236
|
+
await inferIssues(result.targets, state.targets);
|
|
2237
|
+
await save(sessionID, {
|
|
2238
|
+
targets: result.targets,
|
|
2239
|
+
...state.prefix ? { prefix: state.prefix } : {}
|
|
2240
|
+
});
|
|
2241
|
+
watcher?.targetsChanged(sessionID);
|
|
2242
|
+
await rpc.events.emit("targetChanged", { sessionID, ...targetOutput(result.targets) });
|
|
2243
|
+
await update(sessionID);
|
|
2244
|
+
});
|
|
2245
|
+
return { content: toolResult(result.targets, result.missing) };
|
|
2246
|
+
}
|
|
2247
|
+
});
|
|
2248
|
+
});
|
|
2249
|
+
await ctx.session.hook("context", async (event) => {
|
|
2250
|
+
try {
|
|
2251
|
+
const session = await ctx.session.get({ sessionID: event.sessionID });
|
|
2252
|
+
if (session.parentID || session.location.directory !== ctx.location.directory)
|
|
2253
|
+
return;
|
|
2254
|
+
const state = await stateFor(event.sessionID);
|
|
2255
|
+
event.system.push({
|
|
2256
|
+
type: "text",
|
|
2257
|
+
text: [...GUIDANCE, `Current session target: ${describeTargets(state.targets)}.`].join(`
|
|
2258
|
+
`)
|
|
2259
|
+
});
|
|
2260
|
+
} catch {}
|
|
2261
|
+
});
|
|
2262
|
+
(async () => {
|
|
2263
|
+
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
|
2264
|
+
const { type } = event;
|
|
2265
|
+
const sessionID = event.data?.sessionID;
|
|
2266
|
+
if ((type === "session.renamed" || type === "session.execution.succeeded") && sessionID) {
|
|
2267
|
+
schedule(sessionID).catch(() => {});
|
|
2268
|
+
}
|
|
2269
|
+
if (type === "session.renamed" && sessionID)
|
|
2270
|
+
watcher?.titleChanged(sessionID).catch(() => {});
|
|
2271
|
+
if (type === "session.moved" && sessionID)
|
|
2272
|
+
watcher?.sessionMoved(sessionID);
|
|
2273
|
+
if (type === "session.execution.succeeded" || type === "session.execution.failed")
|
|
2274
|
+
watcher?.turnEnded();
|
|
2275
|
+
if (type === "vcs.branch.updated") {
|
|
2276
|
+
const location = event.location;
|
|
2277
|
+
if (!location?.directory || location.directory === ctx.location.directory)
|
|
2278
|
+
watcher?.branchChanged().catch(() => {});
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
})().catch((error) => {
|
|
2282
|
+
if (controller.signal.aborted)
|
|
2283
|
+
return;
|
|
2284
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2285
|
+
rpc.events.emit("notice", {
|
|
2286
|
+
message: `Session title and PR/MR status updates stopped: ${reason}`,
|
|
2287
|
+
variant: "error"
|
|
2288
|
+
}).catch(() => {});
|
|
2289
|
+
});
|
|
2290
|
+
return async () => {
|
|
2291
|
+
closing = true;
|
|
2292
|
+
controller.abort();
|
|
2293
|
+
await drain([...pending.values()], DRAIN_TIME2);
|
|
2294
|
+
await watcher?.dispose();
|
|
2295
|
+
await tools.dispose();
|
|
2296
|
+
await rpc.dispose();
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// packages/forgekeeper/src/index.ts
|
|
2301
|
+
var src_default = {
|
|
2302
|
+
id: "opencode-forge-session-title",
|
|
2303
|
+
async setup(ctx) {
|
|
2304
|
+
const hosts = hostsFrom(ctx.options ?? {});
|
|
2305
|
+
const stopped = new AbortController;
|
|
2306
|
+
const statusExec = limitExec(exec2, CONCURRENCY, stopped.signal);
|
|
2307
|
+
const cleanup = await setupServer(ctx, createTitleLookups(new Forges(exec2, hosts), execWithTimeout(15000)), {
|
|
2308
|
+
forges: new Forges(statusExec, hosts),
|
|
2309
|
+
exec: statusExec
|
|
2310
|
+
});
|
|
2311
|
+
return async () => {
|
|
2312
|
+
stopped.abort();
|
|
2313
|
+
await cleanup();
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
};
|
|
2317
|
+
export {
|
|
2318
|
+
src_default as default
|
|
2319
|
+
};
|