@pourkit/cli 0.0.0-next-20260529095319
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/dist/cli.js +7877 -0
- package/dist/cli.js.map +1 -0
- package/dist/e2e/run-live-e2e.js +5581 -0
- package/dist/e2e/run-live-e2e.js.map +1 -0
- package/dist/issues/close-issues-on-merge.js +842 -0
- package/dist/issues/close-issues-on-merge.js.map +1 -0
- package/dist/issues/unblock.js +685 -0
- package/dist/issues/unblock.js.map +1 -0
- package/package.json +33 -0
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// ../common/logger/src/index.ts
|
|
13
|
+
import { createWriteStream } from "fs";
|
|
14
|
+
import { mkdirSync } from "fs";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import { styleText } from "util";
|
|
17
|
+
function createLogger(name, filePath) {
|
|
18
|
+
let fileStream;
|
|
19
|
+
if (filePath) {
|
|
20
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
21
|
+
fileStream = createWriteStream(filePath, { flags: "a" });
|
|
22
|
+
}
|
|
23
|
+
const write = (terminal, plain = terminal) => {
|
|
24
|
+
process.stdout.write(`${terminal}
|
|
25
|
+
`);
|
|
26
|
+
if (fileStream) {
|
|
27
|
+
fileStream.write(`${plain}
|
|
28
|
+
`);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
line(msg) {
|
|
33
|
+
const ts = timestamp();
|
|
34
|
+
write(`${ts.terminal} ${msg}`, `${ts.plain} ${msg}`);
|
|
35
|
+
},
|
|
36
|
+
raw(msg) {
|
|
37
|
+
write(msg);
|
|
38
|
+
},
|
|
39
|
+
step(step, msg) {
|
|
40
|
+
const ts = timestamp();
|
|
41
|
+
write(
|
|
42
|
+
`${ts.terminal} ${formatStep(step)} ${formatStepMessage(step, msg)}`,
|
|
43
|
+
`${ts.plain} [${step}] ${msg}`
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
status(status) {
|
|
47
|
+
const ts = timestamp();
|
|
48
|
+
write(
|
|
49
|
+
`${ts.terminal} ${color(["bold", "cyan"], "POURKIT")} ${color("cyan", status)}`,
|
|
50
|
+
`${ts.plain} POURKIT ${status}`
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
kv(key, value) {
|
|
54
|
+
const ts = timestamp();
|
|
55
|
+
write(
|
|
56
|
+
`${ts.terminal} ${color("dim", key)}=${formatValue(key, value)}`,
|
|
57
|
+
`${ts.plain} ${key}=${value}`
|
|
58
|
+
);
|
|
59
|
+
},
|
|
60
|
+
async close() {
|
|
61
|
+
await new Promise((resolve) => {
|
|
62
|
+
if (!fileStream) {
|
|
63
|
+
resolve();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
if (!fileStream.destroyed) {
|
|
68
|
+
fileStream.destroy();
|
|
69
|
+
}
|
|
70
|
+
resolve();
|
|
71
|
+
}, 2e3);
|
|
72
|
+
fileStream.end(() => {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
resolve();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function timestamp() {
|
|
81
|
+
const now = /* @__PURE__ */ new Date();
|
|
82
|
+
const time = now.toTimeString().slice(0, 8);
|
|
83
|
+
const ms = String(now.getMilliseconds()).padStart(3, "0");
|
|
84
|
+
const plain = `${time}.${ms}`;
|
|
85
|
+
return { terminal: color("dim", plain), plain };
|
|
86
|
+
}
|
|
87
|
+
function formatStep(step) {
|
|
88
|
+
return color(stepStyle(step), `[${step}]`);
|
|
89
|
+
}
|
|
90
|
+
function formatStepMessage(step, msg) {
|
|
91
|
+
if (step === "error") {
|
|
92
|
+
return color("red", msg);
|
|
93
|
+
}
|
|
94
|
+
if (step === "warn") {
|
|
95
|
+
return color("yellow", msg);
|
|
96
|
+
}
|
|
97
|
+
return msg;
|
|
98
|
+
}
|
|
99
|
+
function formatValue(key, value) {
|
|
100
|
+
if (/SUCCESS|CREATED|COMMITS/.test(key)) {
|
|
101
|
+
return color("green", value);
|
|
102
|
+
}
|
|
103
|
+
if (/BRANCH|PATH|FILE|URL/.test(key)) {
|
|
104
|
+
return color("cyan", value);
|
|
105
|
+
}
|
|
106
|
+
return color("bold", value);
|
|
107
|
+
}
|
|
108
|
+
function stepStyle(step) {
|
|
109
|
+
switch (step) {
|
|
110
|
+
case "sandcastle":
|
|
111
|
+
return ["bold", "cyan"];
|
|
112
|
+
case "git":
|
|
113
|
+
return ["bold", "magenta"];
|
|
114
|
+
case "review":
|
|
115
|
+
case "reviewer":
|
|
116
|
+
return ["bold", "blue"];
|
|
117
|
+
case "cleanup":
|
|
118
|
+
return ["bold", "yellow"];
|
|
119
|
+
case "error":
|
|
120
|
+
return ["bold", "red"];
|
|
121
|
+
case "warn":
|
|
122
|
+
return ["bold", "yellow"];
|
|
123
|
+
case "info":
|
|
124
|
+
return "cyan";
|
|
125
|
+
default:
|
|
126
|
+
return "green";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function color(format, text) {
|
|
130
|
+
if (process.env.NO_COLOR) {
|
|
131
|
+
return text;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return styleText(format, text);
|
|
135
|
+
} catch {
|
|
136
|
+
return text;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
var init_src = __esm({
|
|
140
|
+
"../common/logger/src/index.ts"() {
|
|
141
|
+
"use strict";
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// shared/common.ts
|
|
146
|
+
var common_exports = {};
|
|
147
|
+
__export(common_exports, {
|
|
148
|
+
TYPE_LABELS: () => TYPE_LABELS,
|
|
149
|
+
createLogger: () => createLogger,
|
|
150
|
+
ensureDir: () => ensureDir,
|
|
151
|
+
execCapture: () => execCapture,
|
|
152
|
+
execCaptureWithRetry: () => execCaptureWithRetry,
|
|
153
|
+
execJson: () => execJson,
|
|
154
|
+
execJsonWithRetry: () => execJsonWithRetry,
|
|
155
|
+
parseWorktreeListPorcelain: () => parseWorktreeListPorcelain,
|
|
156
|
+
readMaybeEnvInt: () => readMaybeEnvInt,
|
|
157
|
+
repoRelative: () => repoRelative,
|
|
158
|
+
repoRoot: () => repoRoot,
|
|
159
|
+
sleep: () => sleep,
|
|
160
|
+
slugify: () => slugify
|
|
161
|
+
});
|
|
162
|
+
import { mkdir } from "fs/promises";
|
|
163
|
+
import path2 from "path";
|
|
164
|
+
import { execFile, spawnSync } from "child_process";
|
|
165
|
+
import { promisify } from "util";
|
|
166
|
+
async function ensureDir(dir) {
|
|
167
|
+
await mkdir(dir, { recursive: true });
|
|
168
|
+
}
|
|
169
|
+
function repoRoot(explicitRoot = process.env.POURKIT_ROOT) {
|
|
170
|
+
if (explicitRoot?.trim()) {
|
|
171
|
+
const root = explicitRoot.trim();
|
|
172
|
+
const insideResult = spawnSync(
|
|
173
|
+
"git",
|
|
174
|
+
["-C", root, "rev-parse", "--is-inside-work-tree"],
|
|
175
|
+
{ encoding: "utf8" }
|
|
176
|
+
);
|
|
177
|
+
if (insideResult.status !== 0 || insideResult.stdout.trim() !== "true") {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`POURKIT_ROOT is not a valid Git worktree: ${root}
|
|
180
|
+
${insideResult.stderr || insideResult.stdout}`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const topLevelResult = spawnSync(
|
|
184
|
+
"git",
|
|
185
|
+
["-C", root, "rev-parse", "--show-toplevel"],
|
|
186
|
+
{ encoding: "utf8" }
|
|
187
|
+
);
|
|
188
|
+
if (topLevelResult.status !== 0) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Failed to validate POURKIT_ROOT as a Git worktree: ${root}
|
|
191
|
+
${topLevelResult.stderr || topLevelResult.stdout}`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return topLevelResult.stdout.trim();
|
|
195
|
+
}
|
|
196
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
197
|
+
encoding: "utf8"
|
|
198
|
+
});
|
|
199
|
+
if (result.status !== 0) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Failed to resolve repo root: ${result.stderr || result.stdout}`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return result.stdout.trim();
|
|
205
|
+
}
|
|
206
|
+
function repoRelative(root, ...segments) {
|
|
207
|
+
return path2.join(root, ...segments);
|
|
208
|
+
}
|
|
209
|
+
function slugify(value) {
|
|
210
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/--+/g, "-").replace(/^-+/, "").replace(/-+$/, "").slice(0, 50);
|
|
211
|
+
return slug || "issue";
|
|
212
|
+
}
|
|
213
|
+
function formatCommand(command, args) {
|
|
214
|
+
return [command, ...args].map((part) => {
|
|
215
|
+
if (/^[A-Za-z0-9_\/.=:,@+-]+$/.test(part)) {
|
|
216
|
+
return part;
|
|
217
|
+
}
|
|
218
|
+
return `'${part.replace(/'/g, "'\\''")}'`;
|
|
219
|
+
}).join(" ");
|
|
220
|
+
}
|
|
221
|
+
function readMaybeEnvInt(value, fallback) {
|
|
222
|
+
const parsed = Number(value ?? fallback);
|
|
223
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
224
|
+
}
|
|
225
|
+
async function execCapture(command, args, options = {}) {
|
|
226
|
+
if (options.logger && options.label) {
|
|
227
|
+
options.logger.step(
|
|
228
|
+
options.label,
|
|
229
|
+
`running ${formatCommand(command, args)}`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
let stdout = "";
|
|
233
|
+
let stderr = "";
|
|
234
|
+
let code = 0;
|
|
235
|
+
try {
|
|
236
|
+
const result = await execFileAsync(command, args, {
|
|
237
|
+
cwd: options.cwd,
|
|
238
|
+
env: options.env,
|
|
239
|
+
encoding: "utf8",
|
|
240
|
+
maxBuffer: 20 * 1024 * 1024
|
|
241
|
+
});
|
|
242
|
+
stdout = typeof result.stdout === "string" ? result.stdout : String(result.stdout ?? "");
|
|
243
|
+
stderr = typeof result.stderr === "string" ? result.stderr : String(result.stderr ?? "");
|
|
244
|
+
} catch (error) {
|
|
245
|
+
const err = error;
|
|
246
|
+
stdout = typeof err.stdout === "string" ? err.stdout : "";
|
|
247
|
+
stderr = typeof err.stderr === "string" ? err.stderr : "";
|
|
248
|
+
code = typeof err.code === "number" ? err.code : 1;
|
|
249
|
+
}
|
|
250
|
+
if (code !== 0) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
[
|
|
253
|
+
`command failed: ${formatCommand(command, args)}`,
|
|
254
|
+
`exit code: ${code}`,
|
|
255
|
+
stdout ? `stdout:
|
|
256
|
+
${stdout}` : "",
|
|
257
|
+
stderr ? `stderr:
|
|
258
|
+
${stderr}` : ""
|
|
259
|
+
].filter(Boolean).join("\n")
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return { code, stdout, stderr };
|
|
263
|
+
}
|
|
264
|
+
async function execJson(command, args, options = {}) {
|
|
265
|
+
const result = await execCapture(command, args, options);
|
|
266
|
+
return JSON.parse(result.stdout);
|
|
267
|
+
}
|
|
268
|
+
function sleep(ms) {
|
|
269
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
270
|
+
}
|
|
271
|
+
async function execCaptureWithRetry(command, args, options = {}) {
|
|
272
|
+
const retries = options.retries ?? 3;
|
|
273
|
+
const backoffMs = options.backoffMs ?? 2e3;
|
|
274
|
+
let lastError = null;
|
|
275
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
276
|
+
try {
|
|
277
|
+
return await execCapture(command, args, options);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
280
|
+
if (!TRANSIENT_GH_ERROR.test(lastError.message)) {
|
|
281
|
+
throw lastError;
|
|
282
|
+
}
|
|
283
|
+
if (options.logger) {
|
|
284
|
+
options.logger.step(
|
|
285
|
+
options.label ?? command,
|
|
286
|
+
`transient failure (attempt ${attempt}/${retries}), retrying`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (attempt < retries) {
|
|
290
|
+
await sleep(backoffMs * Math.pow(2, attempt - 1));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
throw lastError;
|
|
295
|
+
}
|
|
296
|
+
async function execJsonWithRetry(command, args, options = {}) {
|
|
297
|
+
const result = await execCaptureWithRetry(command, args, options);
|
|
298
|
+
return JSON.parse(result.stdout);
|
|
299
|
+
}
|
|
300
|
+
function parseWorktreeListPorcelain(text, branch) {
|
|
301
|
+
const entries = text.trim().split("\n\n");
|
|
302
|
+
for (const entry of entries) {
|
|
303
|
+
const lines = entry.trim().split("\n");
|
|
304
|
+
let path3 = "";
|
|
305
|
+
let entryBranch = "";
|
|
306
|
+
for (const line of lines) {
|
|
307
|
+
if (line.startsWith("worktree ")) {
|
|
308
|
+
path3 = line.slice("worktree ".length);
|
|
309
|
+
} else if (line.startsWith("branch refs/heads/")) {
|
|
310
|
+
entryBranch = line.slice("branch refs/heads/".length);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (entryBranch === branch && path3) {
|
|
314
|
+
return path3;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
var execFileAsync, TRANSIENT_GH_ERROR, TYPE_LABELS;
|
|
320
|
+
var init_common = __esm({
|
|
321
|
+
"shared/common.ts"() {
|
|
322
|
+
"use strict";
|
|
323
|
+
init_src();
|
|
324
|
+
execFileAsync = promisify(execFile);
|
|
325
|
+
TRANSIENT_GH_ERROR = /HTTP (502|503|504)\b|Could not close the issue|GraphQL:.*closeIssue/;
|
|
326
|
+
TYPE_LABELS = [
|
|
327
|
+
"type:bugfix",
|
|
328
|
+
"type:infra",
|
|
329
|
+
"type:feature",
|
|
330
|
+
"type:polish",
|
|
331
|
+
"type:refactor"
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// issues/close-issues-on-merge.ts
|
|
337
|
+
init_common();
|
|
338
|
+
init_common();
|
|
339
|
+
import { fileURLToPath } from "url";
|
|
340
|
+
|
|
341
|
+
// providers/github-client.ts
|
|
342
|
+
import { Octokit } from "octokit";
|
|
343
|
+
var REMOTE_PATTERN = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/;
|
|
344
|
+
function resolveGitHubToken(env) {
|
|
345
|
+
const token = env.POURKIT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
|
|
346
|
+
if (!token) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
"GitHub token is required. Set POURKIT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN."
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
return token;
|
|
352
|
+
}
|
|
353
|
+
async function resolveGitHubRepository(options) {
|
|
354
|
+
if (options?.repository) {
|
|
355
|
+
const parts = options.repository.split("/");
|
|
356
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
`Invalid repository format: "${options.repository}". Expected "owner/repo".`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
return { owner: parts[0], repo: parts[1] };
|
|
362
|
+
}
|
|
363
|
+
const env = options?.env ?? process.env;
|
|
364
|
+
const envRepo = env.GITHUB_REPOSITORY;
|
|
365
|
+
if (envRepo) {
|
|
366
|
+
const parts = envRepo.split("/");
|
|
367
|
+
if (parts.length === 2 && parts[0] && parts[1]) {
|
|
368
|
+
return { owner: parts[0], repo: parts[1] };
|
|
369
|
+
}
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Invalid repository format: "${envRepo}". Expected "owner/repo".`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
const { execCapture: execCapture2 } = await Promise.resolve().then(() => (init_common(), common_exports));
|
|
375
|
+
const cwd = options?.cwd;
|
|
376
|
+
try {
|
|
377
|
+
const result = await execCapture2("git", ["remote", "get-url", "origin"], {
|
|
378
|
+
cwd
|
|
379
|
+
});
|
|
380
|
+
const remote = result.stdout.trim();
|
|
381
|
+
const match = remote.match(REMOTE_PATTERN);
|
|
382
|
+
if (match) {
|
|
383
|
+
return { owner: match[1], repo: match[2] };
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
}
|
|
387
|
+
throw new Error(
|
|
388
|
+
"Could not resolve GitHub repository. Set GITHUB_REPOSITORY env var or ensure a valid 'origin' remote exists."
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
async function requireGitHubClient(options) {
|
|
392
|
+
const env = options?.env ?? process.env;
|
|
393
|
+
const token = resolveGitHubToken(env);
|
|
394
|
+
const repo = await resolveGitHubRepository(options);
|
|
395
|
+
const octokit = new Octokit({ auth: token });
|
|
396
|
+
return { octokit, ...repo };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// issues/blocked-issue.ts
|
|
400
|
+
function parseBlockedBy(body) {
|
|
401
|
+
if (!body) return [];
|
|
402
|
+
const bm = body.match(/## Blocked by\s*\n([\s\S]*?)(?=\n## |$)/i);
|
|
403
|
+
if (!bm) return [];
|
|
404
|
+
const refs = [];
|
|
405
|
+
const re = /#(\d+)/g;
|
|
406
|
+
let m;
|
|
407
|
+
while ((m = re.exec(bm[1])) !== null) {
|
|
408
|
+
refs.push(Number(m[1]));
|
|
409
|
+
}
|
|
410
|
+
return refs;
|
|
411
|
+
}
|
|
412
|
+
async function reconcileBlockedIssue(issue, deps) {
|
|
413
|
+
const blockers = parseBlockedBy(issue.body);
|
|
414
|
+
if (blockers.length === 0) {
|
|
415
|
+
await deps.transitions.moveToNeedsTriage(issue.number);
|
|
416
|
+
return "needs-triage";
|
|
417
|
+
}
|
|
418
|
+
const stillBlocked = await anyBlockerStillOpen(blockers, deps.getIssueState);
|
|
419
|
+
if (stillBlocked) {
|
|
420
|
+
return "still-blocked";
|
|
421
|
+
}
|
|
422
|
+
const labels = issue.labels ?? [];
|
|
423
|
+
const typeLabels = labels.filter((l) => deps.typeLabels.includes(l.name));
|
|
424
|
+
if (typeLabels.length === 1) {
|
|
425
|
+
await deps.transitions.removeBlocked(issue.number);
|
|
426
|
+
const alreadyReady = labels.some((l) => l.name === deps.readyLabel);
|
|
427
|
+
if (!alreadyReady) {
|
|
428
|
+
await deps.transitions.addReadyForAgent(issue.number);
|
|
429
|
+
}
|
|
430
|
+
return "unblocked";
|
|
431
|
+
}
|
|
432
|
+
await deps.transitions.moveToNeedsTriage(issue.number);
|
|
433
|
+
return "needs-triage";
|
|
434
|
+
}
|
|
435
|
+
async function reconcileBlockedIssues(issues, deps) {
|
|
436
|
+
const results = [];
|
|
437
|
+
for (const issue of issues) {
|
|
438
|
+
const result = await reconcileBlockedIssue(issue, deps);
|
|
439
|
+
results.push({ issueNumber: issue.number, result });
|
|
440
|
+
}
|
|
441
|
+
return results;
|
|
442
|
+
}
|
|
443
|
+
async function anyBlockerStillOpen(refs, getIssueState) {
|
|
444
|
+
for (const ref of refs) {
|
|
445
|
+
const state = await getIssueState(ref);
|
|
446
|
+
if (state !== "CLOSED") {
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// issues/issue-transitions.ts
|
|
454
|
+
function createIssueTransitions(deps, labels) {
|
|
455
|
+
return {
|
|
456
|
+
async removeBlocked(issueNumber) {
|
|
457
|
+
await deps.removeLabel(issueNumber, labels.blocked);
|
|
458
|
+
},
|
|
459
|
+
async addReadyForAgent(issueNumber) {
|
|
460
|
+
const issue = await deps.fetchIssue(issueNumber);
|
|
461
|
+
if (!issue.labels.includes(labels.readyForAgent)) {
|
|
462
|
+
await deps.addLabels(issueNumber, [labels.readyForAgent]);
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
async moveToNeedsTriage(issueNumber) {
|
|
466
|
+
if (deps.updateLabels) {
|
|
467
|
+
await deps.updateLabels(
|
|
468
|
+
issueNumber,
|
|
469
|
+
[labels.blocked, labels.readyForAgent],
|
|
470
|
+
[labels.needsTriage]
|
|
471
|
+
);
|
|
472
|
+
} else {
|
|
473
|
+
await deps.removeLabel(issueNumber, labels.blocked);
|
|
474
|
+
const issue = await deps.fetchIssue(issueNumber);
|
|
475
|
+
if (issue.labels.includes(labels.readyForAgent)) {
|
|
476
|
+
await deps.removeLabel(issueNumber, labels.readyForAgent);
|
|
477
|
+
}
|
|
478
|
+
await deps.addLabels(issueNumber, [labels.needsTriage]);
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
async moveToReadyForHuman(issueNumber) {
|
|
482
|
+
try {
|
|
483
|
+
await deps.removeLabel(issueNumber, labels.agentInProgress);
|
|
484
|
+
} catch {
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
await deps.removeLabel(issueNumber, labels.readyForAgent);
|
|
488
|
+
} catch {
|
|
489
|
+
}
|
|
490
|
+
await deps.addLabels(issueNumber, [labels.readyForHuman]);
|
|
491
|
+
},
|
|
492
|
+
async closeCompleted(issueNumber) {
|
|
493
|
+
try {
|
|
494
|
+
await deps.removeLabel(issueNumber, labels.agentInProgress);
|
|
495
|
+
} catch {
|
|
496
|
+
}
|
|
497
|
+
try {
|
|
498
|
+
await deps.removeLabel(issueNumber, labels.prOpenAwaitingMerge);
|
|
499
|
+
} catch {
|
|
500
|
+
}
|
|
501
|
+
if (!deps.closeIssue) {
|
|
502
|
+
throw new Error("closeIssue is required for closeCompleted");
|
|
503
|
+
}
|
|
504
|
+
await deps.closeIssue(issueNumber);
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// issues/close-issues-on-merge.ts
|
|
510
|
+
var ROOT = repoRoot();
|
|
511
|
+
process.chdir(ROOT);
|
|
512
|
+
var LOG_DIR = repoRelative(ROOT, "pourkit", "logs");
|
|
513
|
+
var RUN_ID = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
514
|
+
var LOG_PATH = repoRelative(
|
|
515
|
+
ROOT,
|
|
516
|
+
"pourkit",
|
|
517
|
+
"logs",
|
|
518
|
+
`close-issues-on-merge-${RUN_ID}.log`
|
|
519
|
+
);
|
|
520
|
+
var logger = createLogger("close-issues-on-merge", LOG_PATH);
|
|
521
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
522
|
+
async function main() {
|
|
523
|
+
try {
|
|
524
|
+
await ensureDir(LOG_DIR);
|
|
525
|
+
logger.status("starting");
|
|
526
|
+
const client = await requireGitHubClient();
|
|
527
|
+
const context = readContext(client);
|
|
528
|
+
logger.kv("POURKIT_PR_NUMBER", String(context.prNumber));
|
|
529
|
+
logger.kv("POURKIT_PR_TITLE", context.prTitle);
|
|
530
|
+
await closeIssuesOnMerge(context);
|
|
531
|
+
logger.status("completed");
|
|
532
|
+
} catch (error) {
|
|
533
|
+
logger.status("failed");
|
|
534
|
+
logger.line(
|
|
535
|
+
error instanceof Error ? error.stack ?? error.message : String(error)
|
|
536
|
+
);
|
|
537
|
+
process.exitCode = 1;
|
|
538
|
+
} finally {
|
|
539
|
+
await logger.close();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
async function closeIssuesOnMerge(context) {
|
|
543
|
+
const issueNumbers = parseClosingIssueNumbers(context.prBody);
|
|
544
|
+
if (issueNumbers.length === 0) {
|
|
545
|
+
logger.step(
|
|
546
|
+
"skip",
|
|
547
|
+
"No issues referenced with closing keywords. Skipping."
|
|
548
|
+
);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
logger.kv("closing_issue_count", String(issueNumbers.length));
|
|
552
|
+
for (const issueNumber of issueNumbers) {
|
|
553
|
+
try {
|
|
554
|
+
await processIssue(context, issueNumber);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
logger.step(
|
|
557
|
+
"error",
|
|
558
|
+
`Error processing issue #${issueNumber}: ${error instanceof Error ? error.message : String(error)}`
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function processIssue(context, issueNumber) {
|
|
564
|
+
const issue = await getIssue(context, issueNumber);
|
|
565
|
+
if (issue.pull_request) {
|
|
566
|
+
logger.step(
|
|
567
|
+
"skip",
|
|
568
|
+
`#${issueNumber} is a pull request, not an issue. Skipping.`
|
|
569
|
+
);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (issue.state === "open") {
|
|
573
|
+
await commentOnClosedIssue(context, issueNumber);
|
|
574
|
+
await closeIssue(context, issueNumber);
|
|
575
|
+
logger.step("close", `Closed issue #${issueNumber}`);
|
|
576
|
+
} else {
|
|
577
|
+
logger.step("skip", `Issue #${issueNumber} is already closed.`);
|
|
578
|
+
}
|
|
579
|
+
const blockedIssues = await listIssuesBlockedBy(context, issueNumber);
|
|
580
|
+
if (blockedIssues.length === 0) {
|
|
581
|
+
logger.step(
|
|
582
|
+
"skip",
|
|
583
|
+
`Issue #${issueNumber} was not blocking any other issues.`
|
|
584
|
+
);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
logger.step(
|
|
588
|
+
"unblock",
|
|
589
|
+
`Issue #${issueNumber} was blocking ${blockedIssues.length} issue(s).`
|
|
590
|
+
);
|
|
591
|
+
const getIssueState = async (blockedIssueNumber) => {
|
|
592
|
+
const { data } = await context.client.octokit.rest.issues.get({
|
|
593
|
+
owner: context.client.owner,
|
|
594
|
+
repo: context.client.repo,
|
|
595
|
+
issue_number: blockedIssueNumber
|
|
596
|
+
});
|
|
597
|
+
return data.state.toUpperCase();
|
|
598
|
+
};
|
|
599
|
+
const transitionDeps = {
|
|
600
|
+
fetchIssue: async (issueNumber2) => {
|
|
601
|
+
const { data } = await context.client.octokit.rest.issues.get({
|
|
602
|
+
owner: context.client.owner,
|
|
603
|
+
repo: context.client.repo,
|
|
604
|
+
issue_number: issueNumber2
|
|
605
|
+
});
|
|
606
|
+
return {
|
|
607
|
+
labels: data.labels.map(
|
|
608
|
+
(l) => typeof l === "string" ? l : l.name ?? ""
|
|
609
|
+
)
|
|
610
|
+
};
|
|
611
|
+
},
|
|
612
|
+
addLabels: async (issueNumber2, labels) => {
|
|
613
|
+
await withRetryOnTransient(
|
|
614
|
+
() => context.client.octokit.rest.issues.addLabels({
|
|
615
|
+
owner: context.client.owner,
|
|
616
|
+
repo: context.client.repo,
|
|
617
|
+
issue_number: issueNumber2,
|
|
618
|
+
labels
|
|
619
|
+
})
|
|
620
|
+
);
|
|
621
|
+
},
|
|
622
|
+
removeLabel: async (issueNumber2, label) => {
|
|
623
|
+
try {
|
|
624
|
+
await withRetryOnTransient(
|
|
625
|
+
() => context.client.octokit.rest.issues.removeLabel({
|
|
626
|
+
owner: context.client.owner,
|
|
627
|
+
repo: context.client.repo,
|
|
628
|
+
issue_number: issueNumber2,
|
|
629
|
+
name: label
|
|
630
|
+
})
|
|
631
|
+
);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
if (typeof error === "object" && error !== null && "status" in error && error.status === 404) {
|
|
634
|
+
} else {
|
|
635
|
+
throw error;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
updateLabels: async (issueNumber2, removes, adds) => {
|
|
640
|
+
for (const label of removes) {
|
|
641
|
+
try {
|
|
642
|
+
await withRetryOnTransient(
|
|
643
|
+
() => context.client.octokit.rest.issues.removeLabel({
|
|
644
|
+
owner: context.client.owner,
|
|
645
|
+
repo: context.client.repo,
|
|
646
|
+
issue_number: issueNumber2,
|
|
647
|
+
name: label
|
|
648
|
+
})
|
|
649
|
+
);
|
|
650
|
+
} catch (error) {
|
|
651
|
+
if (typeof error === "object" && error !== null && "status" in error && error.status === 404) {
|
|
652
|
+
} else {
|
|
653
|
+
throw error;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (adds.length > 0) {
|
|
658
|
+
await withRetryOnTransient(
|
|
659
|
+
() => context.client.octokit.rest.issues.addLabels({
|
|
660
|
+
owner: context.client.owner,
|
|
661
|
+
repo: context.client.repo,
|
|
662
|
+
issue_number: issueNumber2,
|
|
663
|
+
labels: adds
|
|
664
|
+
})
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
const transitions = createIssueTransitions(transitionDeps, {
|
|
670
|
+
blocked: "blocked",
|
|
671
|
+
readyForAgent: "ready-for-agent",
|
|
672
|
+
needsTriage: "needs-triage",
|
|
673
|
+
agentInProgress: "agent-in-progress",
|
|
674
|
+
readyForHuman: "ready-for-human",
|
|
675
|
+
prOpenAwaitingMerge: "pr-open-awaiting-merge"
|
|
676
|
+
});
|
|
677
|
+
for (const blockedIssue of blockedIssues) {
|
|
678
|
+
try {
|
|
679
|
+
const [reconcileEntry] = await reconcileBlockedIssues([blockedIssue], {
|
|
680
|
+
getIssueState,
|
|
681
|
+
transitions,
|
|
682
|
+
typeLabels: TYPE_LABELS,
|
|
683
|
+
readyLabel: "ready-for-agent"
|
|
684
|
+
});
|
|
685
|
+
if (reconcileEntry.result === "still-blocked") {
|
|
686
|
+
logger.step(
|
|
687
|
+
"skip",
|
|
688
|
+
`Issue #${blockedIssue.number} is still blocked by another issue.`
|
|
689
|
+
);
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
await commentOnUnblockedIssue(
|
|
694
|
+
context,
|
|
695
|
+
blockedIssue.number,
|
|
696
|
+
issueNumber
|
|
697
|
+
);
|
|
698
|
+
logger.step("unblocked", `Unblocked issue #${blockedIssue.number}.`);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
logger.step(
|
|
701
|
+
"error",
|
|
702
|
+
`Failed to unblock issue #${blockedIssue.number} from #${issueNumber}: ${error instanceof Error ? error.message : String(error)}`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
} catch (error) {
|
|
706
|
+
logger.step(
|
|
707
|
+
"error",
|
|
708
|
+
`Failed to check blockers for issue #${blockedIssue.number}.`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
async function getIssue(context, issueNumber) {
|
|
714
|
+
const { data } = await context.client.octokit.rest.issues.get({
|
|
715
|
+
owner: context.client.owner,
|
|
716
|
+
repo: context.client.repo,
|
|
717
|
+
issue_number: issueNumber
|
|
718
|
+
});
|
|
719
|
+
return {
|
|
720
|
+
number: data.number,
|
|
721
|
+
id: data.id,
|
|
722
|
+
state: data.state,
|
|
723
|
+
pull_request: data.pull_request,
|
|
724
|
+
title: data.title,
|
|
725
|
+
body: data.body ?? null
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
async function closeIssue(context, issueNumber) {
|
|
729
|
+
const maxRetries = 3;
|
|
730
|
+
const backoffMs = 2e3;
|
|
731
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
732
|
+
try {
|
|
733
|
+
await context.client.octokit.rest.issues.update({
|
|
734
|
+
owner: context.client.owner,
|
|
735
|
+
repo: context.client.repo,
|
|
736
|
+
issue_number: issueNumber,
|
|
737
|
+
state: "closed",
|
|
738
|
+
state_reason: "completed"
|
|
739
|
+
});
|
|
740
|
+
return;
|
|
741
|
+
} catch (error) {
|
|
742
|
+
const isTransient = error instanceof Error && /HTTP (502|503|504)\b/.test(error.message);
|
|
743
|
+
const isOctokitTransient = typeof error === "object" && error !== null && "status" in error && (error.status === 502 || error.status === 503 || error.status === 504);
|
|
744
|
+
if (!isTransient && !isOctokitTransient || attempt === maxRetries) {
|
|
745
|
+
throw error;
|
|
746
|
+
}
|
|
747
|
+
await new Promise(
|
|
748
|
+
(r) => setTimeout(r, backoffMs * Math.pow(2, attempt - 1))
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
async function commentOnClosedIssue(context, issueNumber) {
|
|
754
|
+
await context.client.octokit.rest.issues.createComment({
|
|
755
|
+
owner: context.client.owner,
|
|
756
|
+
repo: context.client.repo,
|
|
757
|
+
issue_number: issueNumber,
|
|
758
|
+
body: `Closed automatically because PR #${context.prNumber} (${context.prTitle}) merged into \`next\`.`
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
async function listIssuesBlockedBy(context, blockingIssueNumber) {
|
|
762
|
+
const { data } = await context.client.octokit.rest.issues.listForRepo({
|
|
763
|
+
owner: context.client.owner,
|
|
764
|
+
repo: context.client.repo,
|
|
765
|
+
state: "open",
|
|
766
|
+
labels: "blocked",
|
|
767
|
+
per_page: 100
|
|
768
|
+
});
|
|
769
|
+
const allIssues = data.filter((issue) => !issue.pull_request).map((issue) => ({
|
|
770
|
+
number: issue.number,
|
|
771
|
+
body: issue.body ?? null,
|
|
772
|
+
labels: issue.labels.map((l) => ({
|
|
773
|
+
name: typeof l === "string" ? l : l.name ?? ""
|
|
774
|
+
}))
|
|
775
|
+
}));
|
|
776
|
+
const matching = [];
|
|
777
|
+
for (const issue of allIssues) {
|
|
778
|
+
const blockers = parseBlockedBy(issue.body);
|
|
779
|
+
if (blockers.includes(blockingIssueNumber)) {
|
|
780
|
+
matching.push({
|
|
781
|
+
number: issue.number,
|
|
782
|
+
body: issue.body,
|
|
783
|
+
labels: issue.labels
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return matching;
|
|
788
|
+
}
|
|
789
|
+
async function commentOnUnblockedIssue(context, blockedIssueNumber, issueNumber) {
|
|
790
|
+
await context.client.octokit.rest.issues.createComment({
|
|
791
|
+
owner: context.client.owner,
|
|
792
|
+
repo: context.client.repo,
|
|
793
|
+
issue_number: blockedIssueNumber,
|
|
794
|
+
body: `Unblocked automatically because #${issueNumber} was closed by PR #${context.prNumber}.`
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
function readContext(client) {
|
|
798
|
+
const prNumber = Number(process.env.POURKIT_PR_NUMBER);
|
|
799
|
+
const prTitle = process.env.POURKIT_PR_TITLE?.trim();
|
|
800
|
+
const prBody = process.env.POURKIT_PR_BODY ?? "";
|
|
801
|
+
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|
802
|
+
throw new Error("POURKIT_PR_NUMBER is required");
|
|
803
|
+
}
|
|
804
|
+
if (!prTitle) {
|
|
805
|
+
throw new Error("POURKIT_PR_TITLE is required");
|
|
806
|
+
}
|
|
807
|
+
return { client, prNumber, prTitle, prBody };
|
|
808
|
+
}
|
|
809
|
+
function parseClosingIssueNumbers(body) {
|
|
810
|
+
if (!body) return [];
|
|
811
|
+
const regex = /(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s*:?\s*#(\d+)/gi;
|
|
812
|
+
const issueNumbers = /* @__PURE__ */ new Set();
|
|
813
|
+
let match;
|
|
814
|
+
while ((match = regex.exec(body)) !== null) {
|
|
815
|
+
issueNumbers.add(Number.parseInt(match[1], 10));
|
|
816
|
+
}
|
|
817
|
+
return [...issueNumbers];
|
|
818
|
+
}
|
|
819
|
+
async function withRetryOnTransient(fn, maxAttempts = 2) {
|
|
820
|
+
let lastError;
|
|
821
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
822
|
+
try {
|
|
823
|
+
return await fn();
|
|
824
|
+
} catch (error) {
|
|
825
|
+
lastError = error;
|
|
826
|
+
const isTransient = error instanceof Error && /HTTP (502|503|504)\b/.test(error.message) || typeof error === "object" && error !== null && "status" in error && (error.status === 502 || error.status === 503 || error.status === 504);
|
|
827
|
+
if (!isTransient || attempt === maxAttempts) {
|
|
828
|
+
throw error;
|
|
829
|
+
}
|
|
830
|
+
await new Promise((r) => setTimeout(r, 2e3 * Math.pow(2, attempt - 1)));
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
throw lastError;
|
|
834
|
+
}
|
|
835
|
+
if (process.argv[1] === __filename) {
|
|
836
|
+
void main();
|
|
837
|
+
}
|
|
838
|
+
export {
|
|
839
|
+
main,
|
|
840
|
+
parseClosingIssueNumbers
|
|
841
|
+
};
|
|
842
|
+
//# sourceMappingURL=close-issues-on-merge.js.map
|