@armstrongnate/april 0.1.0 → 0.1.1
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 +0 -0
- package/dist/processes.js +1 -1
- package/dist/server.js +19 -3
- package/dist/spawner.js +22 -0
- package/dist/webhook.js +57 -21
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/processes.js
CHANGED
package/dist/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Fastify from "fastify";
|
|
2
2
|
import { createLogger } from "./logger.js";
|
|
3
3
|
import { parseWebhookEvent } from "./webhook.js";
|
|
4
|
-
import { isIssueActive } from "./spawner.js";
|
|
4
|
+
import { isIssueActive, handlePrClosed } from "./spawner.js";
|
|
5
5
|
const log = createLogger("server");
|
|
6
6
|
export async function startServer(config, onNewIssue) {
|
|
7
7
|
const app = Fastify({ logger: false });
|
|
@@ -28,8 +28,8 @@ export async function startServer(config, onNewIssue) {
|
|
|
28
28
|
const headers = request.headers;
|
|
29
29
|
const body = request.body;
|
|
30
30
|
const result = parseWebhookEvent(headers, body, config);
|
|
31
|
-
if (result) {
|
|
32
|
-
const key =
|
|
31
|
+
if (result?.kind === "issue_assigned") {
|
|
32
|
+
const key = `issue:${result.repo.owner}/${result.repo.name}#${result.issue.number}`;
|
|
33
33
|
if (isIssueActive(result.repo, result.issue.number)) {
|
|
34
34
|
log.debug(`Issue ${key} already active, ignoring webhook`);
|
|
35
35
|
}
|
|
@@ -44,6 +44,22 @@ export async function startServer(config, onNewIssue) {
|
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
else if (result?.kind === "pr_closed") {
|
|
48
|
+
const key = `pr:${result.repo.owner}/${result.repo.name}#${result.prNumber}`;
|
|
49
|
+
if (isRecentlyProcessed(key)) {
|
|
50
|
+
log.debug(`PR close ${key} recently processed, debouncing`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
markProcessed(key);
|
|
54
|
+
log.info(`Processing webhook for ${key}`);
|
|
55
|
+
try {
|
|
56
|
+
handlePrClosed(result.repo, result.branch);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
log.error(`Error handling PR close: ${err instanceof Error ? err.message : String(err)}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
47
63
|
}
|
|
48
64
|
catch (err) {
|
|
49
65
|
log.error(`Error processing webhook: ${err instanceof Error ? err.message : String(err)}`);
|
package/dist/spawner.js
CHANGED
|
@@ -219,6 +219,28 @@ export async function handleNewIssue(repo, issue, config) {
|
|
|
219
219
|
}
|
|
220
220
|
log.info(`Issue #${issue.number} (${repo.owner}/${repo.name}) is now active`);
|
|
221
221
|
}
|
|
222
|
+
export function handlePrClosed(repo, branch) {
|
|
223
|
+
log.info(`Cleaning up worktree for branch ${branch} (${repo.owner}/${repo.name})`);
|
|
224
|
+
try {
|
|
225
|
+
execFileSync("wt", ["remove", branch, "-f", "-D"], {
|
|
226
|
+
cwd: repo.path,
|
|
227
|
+
timeout: 60_000,
|
|
228
|
+
stdio: "pipe",
|
|
229
|
+
});
|
|
230
|
+
log.info(`Removed worktree ${branch}`);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
log.warn(`wt remove ${branch} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
execFileSync("tmux", ["kill-session", "-t", branch], { stdio: "pipe" });
|
|
237
|
+
log.info(`Killed tmux session ${branch}`);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Session already gone — fine
|
|
241
|
+
}
|
|
242
|
+
log.info(`Cleanup complete for ${branch}`);
|
|
243
|
+
}
|
|
222
244
|
export function fetchOpenIssues(repo, config) {
|
|
223
245
|
try {
|
|
224
246
|
const output = execFileSync("gh", [
|
package/dist/webhook.js
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { createLogger } from "./logger.js";
|
|
2
2
|
const log = createLogger("webhook");
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
const APRIL_BRANCH_RE = /^gh-\d+-/;
|
|
4
|
+
function matchRepo(body, config) {
|
|
5
|
+
const repository = body.repository;
|
|
6
|
+
if (!repository) {
|
|
7
|
+
log.debug("No repository payload found");
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
const repoOwner = repository.owner?.login;
|
|
11
|
+
const repoName = repository.name;
|
|
12
|
+
if (!repoOwner || !repoName) {
|
|
13
|
+
log.debug("Could not extract repo owner/name from payload");
|
|
7
14
|
return null;
|
|
8
15
|
}
|
|
16
|
+
const repo = config.repos.find((r) => r.owner.toLowerCase() === repoOwner.toLowerCase() && r.name.toLowerCase() === repoName.toLowerCase());
|
|
17
|
+
if (!repo) {
|
|
18
|
+
log.debug(`Repo ${repoOwner}/${repoName} not in config`);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return repo;
|
|
22
|
+
}
|
|
23
|
+
function parseIssuesEvent(body, config) {
|
|
9
24
|
const action = body.action;
|
|
10
25
|
if (action !== "assigned" && action !== "labeled") {
|
|
11
26
|
log.debug(`Ignoring issues action: ${action}`);
|
|
@@ -16,37 +31,58 @@ export function parseWebhookEvent(headers, body, config) {
|
|
|
16
31
|
log.debug("No issue payload found");
|
|
17
32
|
return null;
|
|
18
33
|
}
|
|
19
|
-
// Check assignees
|
|
20
34
|
const assignees = issue.assignees;
|
|
21
35
|
if (!assignees || !assignees.some((a) => a.login === config.assignee)) {
|
|
22
36
|
log.debug(`Issue not assigned to ${config.assignee}`);
|
|
23
37
|
return null;
|
|
24
38
|
}
|
|
25
|
-
// Check labels
|
|
26
39
|
const labels = issue.labels;
|
|
27
40
|
if (!labels || !labels.some((l) => l.name === config.label)) {
|
|
28
41
|
log.debug(`Issue does not have label "${config.label}"`);
|
|
29
42
|
return null;
|
|
30
43
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
const repo = matchRepo(body, config);
|
|
45
|
+
if (!repo)
|
|
46
|
+
return null;
|
|
47
|
+
const number = issue.number;
|
|
48
|
+
const title = issue.title;
|
|
49
|
+
log.info(`Matched webhook event: ${repo.owner}/${repo.name}#${number} (${action})`);
|
|
50
|
+
return { kind: "issue_assigned", repo, issue: { number, title } };
|
|
51
|
+
}
|
|
52
|
+
function parsePullRequestEvent(body, config) {
|
|
53
|
+
const action = body.action;
|
|
54
|
+
if (action !== "closed") {
|
|
55
|
+
log.debug(`Ignoring pull_request action: ${action}`);
|
|
35
56
|
return null;
|
|
36
57
|
}
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
log.debug("Could not extract repo owner/name from payload");
|
|
58
|
+
const pr = body.pull_request;
|
|
59
|
+
if (!pr) {
|
|
60
|
+
log.debug("No pull_request payload found");
|
|
41
61
|
return null;
|
|
42
62
|
}
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
const head = pr.head;
|
|
64
|
+
const branch = head?.ref;
|
|
65
|
+
const prNumber = pr.number;
|
|
66
|
+
if (!branch || typeof prNumber !== "number") {
|
|
67
|
+
log.debug("pull_request missing head.ref or number");
|
|
46
68
|
return null;
|
|
47
69
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
70
|
+
if (!APRIL_BRANCH_RE.test(branch)) {
|
|
71
|
+
log.debug(`Ignoring pull_request close: not an april branch (${branch})`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const repo = matchRepo(body, config);
|
|
75
|
+
if (!repo)
|
|
76
|
+
return null;
|
|
77
|
+
log.info(`Matched webhook event: ${repo.owner}/${repo.name}#${prNumber} PR closed (${branch})`);
|
|
78
|
+
return { kind: "pr_closed", repo, branch, prNumber };
|
|
79
|
+
}
|
|
80
|
+
export function parseWebhookEvent(headers, body, config) {
|
|
81
|
+
const event = headers["x-github-event"];
|
|
82
|
+
if (event === "issues")
|
|
83
|
+
return parseIssuesEvent(body, config);
|
|
84
|
+
if (event === "pull_request")
|
|
85
|
+
return parsePullRequestEvent(body, config);
|
|
86
|
+
log.debug(`Ignoring event: ${event}`);
|
|
87
|
+
return null;
|
|
52
88
|
}
|
package/package.json
CHANGED