@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 CHANGED
File without changes
package/dist/processes.js CHANGED
@@ -48,7 +48,7 @@ function spawnForwarder(config, repoKey, url) {
48
48
  const child = spawn("gh", [
49
49
  "webhook", "forward",
50
50
  `--repo=${repoKey}`,
51
- "--events=issues",
51
+ "--events=issues,pull_request",
52
52
  `--url=${url}`,
53
53
  ], {
54
54
  stdio: ["ignore", "pipe", "pipe"],
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 = `${result.repo.owner}/${result.repo.name}#${result.issue.number}`;
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
- export function parseWebhookEvent(headers, body, config) {
4
- const event = headers["x-github-event"];
5
- if (event !== "issues") {
6
- log.debug(`Ignoring non-issues event: ${event}`);
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
- // Match repo
32
- const repository = body.repository;
33
- if (!repository) {
34
- log.debug("No repository payload found");
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 repoOwner = repository.owner?.login;
38
- const repoName = repository.name;
39
- if (!repoOwner || !repoName) {
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 repo = config.repos.find((r) => r.owner.toLowerCase() === repoOwner.toLowerCase() && r.name.toLowerCase() === repoName.toLowerCase());
44
- if (!repo) {
45
- log.debug(`Repo ${repoOwner}/${repoName} not in config`);
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
- const number = issue.number;
49
- const title = issue.title;
50
- log.info(`Matched webhook event: ${repo.owner}/${repo.name}#${number} (${action})`);
51
- return { repo, issue: { number, title } };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@armstrongnate/april",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "She does all the work so you don't have to. Watches GitHub issues and spawns coding-agent sessions (Claude Code or Codex) to work them.",
5
5
  "type": "module",
6
6
  "bin": {