@armstrongnate/april 0.0.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/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # april
2
+
3
+ She does all the work so you don't have to, and with about the same level of enthusiasm.
4
+
5
+ april watches for GitHub issues assigned to you with a specific label, then spins up a Claude Code session in a tmux window to work the issue end-to-end — from reading the issue to opening a PR.
6
+
7
+ ## Prerequisites
8
+
9
+ - [Node.js](https://nodejs.org/) >= 22
10
+ - [gh](https://cli.github.com/) (authenticated)
11
+ - [tmux](https://github.com/tmux/tmux)
12
+ - [Claude Code](https://claude.ai/claude-code) CLI
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm i -g @armstrongnate/april
18
+ # or: pnpm add -g @armstrongnate/april
19
+ ```
20
+
21
+ Then:
22
+
23
+ ```bash
24
+ april init # writes ~/.config/april/config.yaml + installs the issue-worker skill
25
+ $EDITOR ~/.config/april/config.yaml
26
+ april install # registers the user service and starts it
27
+ april logs -f
28
+ ```
29
+
30
+ ## Commands
31
+
32
+ | Command | What it does |
33
+ | --- | --- |
34
+ | `april init` | Copies the bundled `config.example.yaml` to `~/.config/april/config.yaml` and the `issue-worker` skill to `~/.claude/skills/`. Won't overwrite without `--force`. |
35
+ | `april install` | Installs and starts the user service. Pass `--print` to see the unit/plist without writing it. |
36
+ | `april uninstall` | Stops and removes the service. |
37
+ | `april start` / `stop` / `restart` | Lifecycle. |
38
+ | `april status` | Shows service status. |
39
+ | `april logs -f [-n N]` | Streams logs. `-n` sets line count (default 100). |
40
+ | `april daemon` | Runs the worker in the foreground (for debugging; the service uses `dist/index.js` directly). |
41
+
42
+ ## Service backend
43
+
44
+ - **Linux** uses systemd user services at `~/.config/systemd/user/april.service`. Logs go to the journal (`journalctl --user -u april`).
45
+ - **macOS** uses launchd LaunchAgents at `~/Library/LaunchAgents/dev.april.daemon.plist`. Logs go to `~/Library/Logs/april/april.log`.
46
+
47
+ ### Linux: keep april running after logout
48
+
49
+ User services stop when you log out unless linger is enabled. On a server you SSH into:
50
+
51
+ ```bash
52
+ sudo loginctl enable-linger $USER
53
+ ```
54
+
55
+ `april install` reminds you if it sees linger is off.
56
+
57
+ ### Node version managers
58
+
59
+ `april install` captures the absolute path of the `node` binary it was invoked with (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`) and bakes it into the unit/plist. If you later remove or change that node version, the service will fail to start — re-run `april install` after switching.
60
+
61
+ ## Usage
62
+
63
+ Once installed, label a GitHub issue with `agent:todo` and assign it to yourself. april will:
64
+
65
+ 1. Create a git worktree for the issue
66
+ 2. Run any configured post-worktree hooks (e.g. `pnpm i`)
67
+ 3. Spawn a tmux session with Claude Code
68
+ 4. Claude reads the issue, implements a fix, and opens a PR
69
+ 5. Issue labels transition: `agent:todo` → `agent:wip` → `agent:review`
70
+
71
+ Attach to a running session anytime with `tmux attach -t <session-name>`.
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ pnpm install
77
+ cp config.example.yaml config.yaml
78
+ pnpm dev
79
+ ```
80
+
81
+ `pnpm dev` runs the daemon in the foreground from the source tree. Config is loaded from `~/.config/april/config.yaml` if it exists, otherwise `./config.yaml`.
@@ -0,0 +1,16 @@
1
+ assignee: "your-github-username"
2
+ label: "agent:todo"
3
+ claudeSkill: "issue-worker"
4
+ # claudeModel: "opus" # optional, defaults to opus
5
+ # claudeAllowedTools: # optional, defaults to Edit, Write, Bash(*)
6
+ # - "Edit"
7
+ # - "Write"
8
+ # - "Bash(*)"
9
+ port: 7890
10
+ repos:
11
+ - owner: "org"
12
+ name: "repo-name"
13
+ path: "~/path/to/repo"
14
+ # defaultBranch: "main" # optional, defaults to main
15
+ # slackChannel: "team-channel" # optional, posts PR link to this channel
16
+ # postWorktreeHook: "pnpm i" # optional, runs in worktree after creation
package/dist/cli.js ADDED
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ import { backend } from "./service/index.js";
3
+ import { run as runInit } from "./commands/init.js";
4
+ const HELP = `april — issue worker
5
+
6
+ Usage:
7
+ april <command> [options]
8
+
9
+ Commands:
10
+ init Copy bundled config + skill to ~/.config/april and ~/.claude
11
+ install [--print] Install and start the user service. --print emits the unit/plist to stdout instead.
12
+ uninstall Stop and remove the user service
13
+ start Start the service
14
+ stop Stop the service
15
+ restart Restart the service
16
+ status Show service status
17
+ logs [-f] [-n N] Show service logs (-f to follow, -n lines, default 100)
18
+ daemon Run april in the foreground (used by the service; rarely invoked directly)
19
+ help Show this help
20
+ version Show version
21
+
22
+ Options for init:
23
+ --force, -f Overwrite existing files
24
+ `;
25
+ function parseLogsArgs(args) {
26
+ let follow = false;
27
+ let lines = 100;
28
+ for (let i = 0; i < args.length; i++) {
29
+ const a = args[i];
30
+ if (a === "-f" || a === "--follow") {
31
+ follow = true;
32
+ }
33
+ else if (a === "-n" || a === "--lines") {
34
+ const v = args[i + 1];
35
+ if (!v)
36
+ throw new Error(`${a} requires a value`);
37
+ const n = parseInt(v, 10);
38
+ if (Number.isNaN(n) || n < 0)
39
+ throw new Error(`${a} value must be a non-negative integer`);
40
+ lines = n;
41
+ i++;
42
+ }
43
+ else if (/^-n\d+$/.test(a)) {
44
+ lines = parseInt(a.slice(2), 10);
45
+ }
46
+ else {
47
+ throw new Error(`unknown option: ${a}`);
48
+ }
49
+ }
50
+ return { follow, lines };
51
+ }
52
+ async function readVersion() {
53
+ const { readFileSync } = await import("node:fs");
54
+ const { fileURLToPath } = await import("node:url");
55
+ const { dirname, resolve } = await import("node:path");
56
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
57
+ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf-8"));
58
+ return pkg.version;
59
+ }
60
+ async function main() {
61
+ const [, , cmd, ...rest] = process.argv;
62
+ if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
63
+ console.log(HELP);
64
+ return 0;
65
+ }
66
+ if (cmd === "version" || cmd === "--version" || cmd === "-v") {
67
+ console.log(await readVersion());
68
+ return 0;
69
+ }
70
+ if (cmd === "init") {
71
+ return runInit(rest);
72
+ }
73
+ if (cmd === "daemon") {
74
+ // Run the long-running process inline. Importing index.js triggers main();
75
+ // we then hang forever and let its SIGINT/SIGTERM handlers terminate the process.
76
+ await import("./index.js");
77
+ await new Promise(() => { });
78
+ return 0; // unreachable
79
+ }
80
+ // Service commands
81
+ const svc = backend();
82
+ switch (cmd) {
83
+ case "install":
84
+ if (rest.includes("--print")) {
85
+ process.stdout.write(svc.serviceFile());
86
+ return 0;
87
+ }
88
+ svc.install();
89
+ return 0;
90
+ case "uninstall":
91
+ svc.uninstall();
92
+ return 0;
93
+ case "start":
94
+ svc.start();
95
+ console.log("✓ Started");
96
+ return 0;
97
+ case "stop":
98
+ svc.stop();
99
+ console.log("✓ Stopped");
100
+ return 0;
101
+ case "restart":
102
+ svc.restart();
103
+ console.log("✓ Restarted");
104
+ return 0;
105
+ case "status":
106
+ return svc.status();
107
+ case "logs": {
108
+ const { follow, lines } = parseLogsArgs(rest);
109
+ return svc.logs(follow, lines);
110
+ }
111
+ default:
112
+ console.error(`Unknown command: ${cmd}`);
113
+ console.error(HELP);
114
+ return 2;
115
+ }
116
+ }
117
+ main()
118
+ .then((code) => process.exit(code))
119
+ .catch((err) => {
120
+ console.error(err instanceof Error ? err.message : String(err));
121
+ process.exit(1);
122
+ });
@@ -0,0 +1,48 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ // Resolve the bundled package root from this file's installed location.
6
+ // dist/commands/init.js -> dist/.. (the package root, where config.example.yaml + skills/ live)
7
+ function packageRoot() {
8
+ const here = fileURLToPath(import.meta.url);
9
+ return resolve(dirname(here), "..", "..");
10
+ }
11
+ function copyIfMissing(src, dst, label, force) {
12
+ mkdirSync(dirname(dst), { recursive: true });
13
+ if (existsSync(dst) && !force) {
14
+ console.log(` ${label}: already exists at ${dst} (use --force to overwrite)`);
15
+ return "exists";
16
+ }
17
+ copyFileSync(src, dst);
18
+ console.log(` ${label}: wrote ${dst}`);
19
+ return "wrote";
20
+ }
21
+ export function run(args) {
22
+ const force = args.includes("--force") || args.includes("-f");
23
+ const root = packageRoot();
24
+ console.log("april init");
25
+ console.log("");
26
+ const configSrc = join(root, "config.example.yaml");
27
+ const configDst = join(homedir(), ".config", "april", "config.yaml");
28
+ if (!existsSync(configSrc)) {
29
+ console.error(` Cannot find bundled config.example.yaml at ${configSrc}`);
30
+ return 1;
31
+ }
32
+ const configResult = copyIfMissing(configSrc, configDst, "config", force);
33
+ const skillSrc = join(root, "skills", "issue-worker", "SKILL.md");
34
+ const skillDst = join(homedir(), ".claude", "skills", "issue-worker", "SKILL.md");
35
+ if (!existsSync(skillSrc)) {
36
+ console.error(` Cannot find bundled skill at ${skillSrc}`);
37
+ return 1;
38
+ }
39
+ copyIfMissing(skillSrc, skillDst, "skill", force);
40
+ console.log("");
41
+ if (configResult === "wrote") {
42
+ console.log(`Next: edit ${configDst}, then run \`april install\`.`);
43
+ }
44
+ else {
45
+ console.log(`Next: review ${configDst}, then run \`april install\`.`);
46
+ }
47
+ return 0;
48
+ }
package/dist/config.js ADDED
@@ -0,0 +1,97 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { execSync } from "node:child_process";
3
+ import { resolve, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { parse as parseYaml } from "yaml";
6
+ import { createLogger } from "./logger.js";
7
+ const log = createLogger("config");
8
+ function findConfigPath() {
9
+ const envPath = process.env.APRIL_CONFIG;
10
+ if (envPath) {
11
+ if (!existsSync(envPath)) {
12
+ throw new Error(`Config file from APRIL_CONFIG not found: ${envPath}`);
13
+ }
14
+ return resolve(envPath);
15
+ }
16
+ const xdgPath = join(homedir(), ".config", "april", "config.yaml");
17
+ if (existsSync(xdgPath)) {
18
+ return xdgPath;
19
+ }
20
+ const localPath = resolve("config.yaml");
21
+ if (existsSync(localPath)) {
22
+ return localPath;
23
+ }
24
+ throw new Error("No config file found. Searched:\n" +
25
+ ` - APRIL_CONFIG env var (not set)\n` +
26
+ ` - ${xdgPath}\n` +
27
+ ` - ${localPath}\n` +
28
+ "Create a config.yaml (see config.example.yaml).");
29
+ }
30
+ function validateTools() {
31
+ const tools = ["gh", "tmux", "git", "claude"];
32
+ for (const tool of tools) {
33
+ try {
34
+ execSync(`which ${tool}`, { stdio: "pipe" });
35
+ }
36
+ catch {
37
+ throw new Error(`Required tool "${tool}" not found on PATH. Install it before running april.`);
38
+ }
39
+ }
40
+ }
41
+ function validateString(obj, key, context) {
42
+ const val = obj[key];
43
+ if (typeof val !== "string" || val.trim().length === 0) {
44
+ throw new Error(`${context}: "${key}" is required and must be a non-empty string`);
45
+ }
46
+ return val.trim();
47
+ }
48
+ export function loadConfig() {
49
+ validateTools();
50
+ const configPath = findConfigPath();
51
+ log.info(`Loading config from ${configPath}`);
52
+ const raw = readFileSync(configPath, "utf-8");
53
+ const parsed = parseYaml(raw);
54
+ if (!parsed || typeof parsed !== "object") {
55
+ throw new Error("Config file is empty or not a valid YAML object");
56
+ }
57
+ const assignee = validateString(parsed, "assignee", "config");
58
+ const label = validateString(parsed, "label", "config");
59
+ const claudeSkill = validateString(parsed, "claudeSkill", "config");
60
+ const claudeModel = typeof parsed.claudeModel === "string" ? parsed.claudeModel.trim() : undefined;
61
+ const claudeAllowedTools = Array.isArray(parsed.claudeAllowedTools)
62
+ ? parsed.claudeAllowedTools.filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => t.trim())
63
+ : undefined;
64
+ const port = Number(parsed.port);
65
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
66
+ throw new Error(`config: "port" must be an integer between 1024 and 65535, got: ${parsed.port}`);
67
+ }
68
+ if (!Array.isArray(parsed.repos) || parsed.repos.length === 0) {
69
+ throw new Error("config: \"repos\" must be a non-empty array");
70
+ }
71
+ const repos = parsed.repos.map((r, i) => {
72
+ if (!r || typeof r !== "object") {
73
+ throw new Error(`config.repos[${i}]: must be an object`);
74
+ }
75
+ const repo = r;
76
+ const owner = validateString(repo, "owner", `config.repos[${i}]`);
77
+ const name = validateString(repo, "name", `config.repos[${i}]`);
78
+ const path = validateString(repo, "path", `config.repos[${i}]`);
79
+ const resolvedPath = resolve(path.startsWith("~") ? path.replace("~", homedir()) : path);
80
+ if (!existsSync(resolvedPath)) {
81
+ throw new Error(`config.repos[${i}]: path does not exist: ${resolvedPath}`);
82
+ }
83
+ const defaultBranch = typeof repo.defaultBranch === "string" && repo.defaultBranch.trim().length > 0
84
+ ? repo.defaultBranch.trim()
85
+ : "main";
86
+ const slackChannel = typeof repo.slackChannel === "string" && repo.slackChannel.trim().length > 0
87
+ ? repo.slackChannel.trim()
88
+ : undefined;
89
+ const postWorktreeHook = typeof repo.postWorktreeHook === "string" && repo.postWorktreeHook.trim().length > 0
90
+ ? repo.postWorktreeHook.trim()
91
+ : undefined;
92
+ return { owner, name, path: resolvedPath, defaultBranch, slackChannel, postWorktreeHook };
93
+ });
94
+ const config = { assignee, label, claudeSkill, claudeModel, claudeAllowedTools, port, repos };
95
+ log.info(`Config loaded: assignee=${assignee}, label=${label}, repos=${repos.map((r) => `${r.owner}/${r.name}`).join(", ")}`);
96
+ return config;
97
+ }
package/dist/index.js ADDED
@@ -0,0 +1,114 @@
1
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { createLogger } from "./logger.js";
5
+ import { loadConfig } from "./config.js";
6
+ import { handleNewIssue, fetchOpenIssues, isIssueActive, getActiveCounts } from "./spawner.js";
7
+ import { startServer } from "./server.js";
8
+ import { startWebhookForwarders, shutdownForwarders } from "./processes.js";
9
+ const log = createLogger("main");
10
+ const PID_PATH = join(homedir(), ".config", "april", "april.pid");
11
+ function checkPidFile() {
12
+ if (!existsSync(PID_PATH))
13
+ return;
14
+ try {
15
+ const pid = parseInt(readFileSync(PID_PATH, "utf-8").trim(), 10);
16
+ if (Number.isNaN(pid))
17
+ return;
18
+ try {
19
+ process.kill(pid, 0);
20
+ log.warn(`Another april instance may be running (pid=${pid}). Proceeding anyway.`);
21
+ }
22
+ catch {
23
+ log.debug("Stale PID file found, removing");
24
+ }
25
+ }
26
+ catch {
27
+ // Can't read PID file, ignore
28
+ }
29
+ }
30
+ function writePidFile() {
31
+ const dir = dirname(PID_PATH);
32
+ mkdirSync(dir, { recursive: true });
33
+ writeFileSync(PID_PATH, String(process.pid), "utf-8");
34
+ }
35
+ function removePidFile() {
36
+ try {
37
+ unlinkSync(PID_PATH);
38
+ }
39
+ catch {
40
+ // Ignore
41
+ }
42
+ }
43
+ async function main() {
44
+ console.log("");
45
+ console.log(" april v0.0.1");
46
+ console.log("");
47
+ // 1. Load config
48
+ const config = loadConfig();
49
+ // 2. PID file check
50
+ checkPidFile();
51
+ writePidFile();
52
+ // 3. Reconcile missed issues
53
+ for (const repo of config.repos) {
54
+ log.info(`Checking for missed issues in ${repo.owner}/${repo.name}...`);
55
+ const openIssues = fetchOpenIssues(repo, config);
56
+ for (const issue of openIssues) {
57
+ if (!isIssueActive(repo, issue.number)) {
58
+ log.info(`Found missed issue: #${issue.number} — "${issue.title}"`);
59
+ await handleNewIssue(repo, issue, config);
60
+ }
61
+ }
62
+ }
63
+ // 4. Start HTTP server
64
+ const onNewIssue = async (result) => {
65
+ await handleNewIssue(result.repo, result.issue, config);
66
+ };
67
+ let server;
68
+ try {
69
+ server = await startServer(config, onNewIssue);
70
+ }
71
+ catch (err) {
72
+ log.error(`Failed to start server: ${err instanceof Error ? err.message : String(err)}`);
73
+ removePidFile();
74
+ process.exit(1);
75
+ }
76
+ // 5. Start webhook forwarders
77
+ const children = startWebhookForwarders(config);
78
+ // Print startup banner
79
+ const { worktrees, sessions } = getActiveCounts(config);
80
+ const repoList = config.repos.map((r) => `${r.owner}/${r.name}`).join(", ");
81
+ console.log(` Assignee: ${config.assignee}`);
82
+ console.log(` Label: ${config.label}`);
83
+ console.log(` Repos: ${repoList}`);
84
+ console.log(` Active: ${worktrees} worktree${worktrees === 1 ? "" : "s"}, ${sessions} tmux session${sessions === 1 ? "" : "s"}`);
85
+ console.log(` Server: http://localhost:${config.port}`);
86
+ console.log(` Forwarders: ${children.length} active`);
87
+ console.log("");
88
+ // 6. Graceful shutdown
89
+ let shuttingDown = false;
90
+ const shutdown = async (signal) => {
91
+ if (shuttingDown)
92
+ return;
93
+ shuttingDown = true;
94
+ log.info(`Received ${signal}, shutting down...`);
95
+ shutdownForwarders(children);
96
+ try {
97
+ await server.close();
98
+ log.info("Server closed");
99
+ }
100
+ catch {
101
+ // Ignore
102
+ }
103
+ removePidFile();
104
+ log.info("Goodbye");
105
+ process.exit(0);
106
+ };
107
+ process.on("SIGINT", () => shutdown("SIGINT"));
108
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
109
+ }
110
+ main().catch((err) => {
111
+ console.error("Fatal error:", err);
112
+ removePidFile();
113
+ process.exit(1);
114
+ });
package/dist/logger.js ADDED
@@ -0,0 +1,36 @@
1
+ import { stdout } from "node:process";
2
+ const isTTY = stdout.isTTY ?? false;
3
+ const isDebug = process.env.APRIL_DEBUG === "1";
4
+ const COLORS = {
5
+ info: "\x1b[36m", // cyan
6
+ warn: "\x1b[33m", // yellow
7
+ error: "\x1b[31m", // red
8
+ debug: "\x1b[90m", // gray
9
+ };
10
+ const RESET = "\x1b[0m";
11
+ function format(level, component, message) {
12
+ const ts = new Date().toISOString();
13
+ const tag = `[${level}] [${component}]`;
14
+ if (isTTY) {
15
+ return `${COLORS[level]}${ts} ${tag}${RESET} ${message}`;
16
+ }
17
+ return `${ts} ${tag} ${message}`;
18
+ }
19
+ export function createLogger(component) {
20
+ return {
21
+ info(message) {
22
+ console.log(format("info", component, message));
23
+ },
24
+ warn(message) {
25
+ console.warn(format("warn", component, message));
26
+ },
27
+ error(message) {
28
+ console.error(format("error", component, message));
29
+ },
30
+ debug(message) {
31
+ if (isDebug) {
32
+ console.log(format("debug", component, message));
33
+ }
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,138 @@
1
+ import { spawn, execFileSync } from "node:child_process";
2
+ import { createLogger } from "./logger.js";
3
+ const log = createLogger("forwarder");
4
+ /**
5
+ * Delete any stale `cli` webhooks on a repo left behind by a previous
6
+ * gh webhook forward process that didn't shut down cleanly.
7
+ */
8
+ function cleanupStaleWebhooks(repoKey) {
9
+ try {
10
+ const output = execFileSync("gh", [
11
+ "api", `repos/${repoKey}/hooks`, "--jq", '.[] | select(.name == "cli") | .id',
12
+ ], { encoding: "utf-8", timeout: 15_000, stdio: ["pipe", "pipe", "pipe"] });
13
+ const ids = output.trim().split("\n").filter(Boolean);
14
+ for (const id of ids) {
15
+ log.info(`Cleaning up stale webhook ${id} on ${repoKey}`);
16
+ try {
17
+ execFileSync("gh", ["api", "-X", "DELETE", `repos/${repoKey}/hooks/${id}`], {
18
+ timeout: 15_000, stdio: "pipe",
19
+ });
20
+ }
21
+ catch (err) {
22
+ log.warn(`Failed to delete webhook ${id} on ${repoKey}: ${err instanceof Error ? err.message : String(err)}`);
23
+ }
24
+ }
25
+ }
26
+ catch {
27
+ // No hooks or API error — not critical, gh webhook forward will report the real error
28
+ }
29
+ }
30
+ const INITIAL_BACKOFF_MS = 1000;
31
+ const MAX_BACKOFF_MS = 30_000;
32
+ const UPTIME_RESET_MS = 60_000;
33
+ const MAX_CONSECUTIVE_FAILURES = 5;
34
+ const forwarders = [];
35
+ function spawnForwarder(config, repoKey, url) {
36
+ const state = {
37
+ child: null,
38
+ repoKey,
39
+ consecutiveFailures: 0,
40
+ lastStartTime: 0,
41
+ backoffMs: INITIAL_BACKOFF_MS,
42
+ stopped: false,
43
+ };
44
+ function start() {
45
+ if (state.stopped)
46
+ return;
47
+ state.lastStartTime = Date.now();
48
+ log.info(`Starting webhook forwarder for ${repoKey}`);
49
+ const child = spawn("gh", [
50
+ "webhook", "forward",
51
+ `--repo=${repoKey}`,
52
+ "--events=issues",
53
+ `--url=${url}`,
54
+ ], {
55
+ stdio: ["ignore", "pipe", "pipe"],
56
+ });
57
+ state.child = child;
58
+ child.stdout?.on("data", (data) => {
59
+ const lines = data.toString("utf-8").trim().split("\n");
60
+ for (const line of lines) {
61
+ if (line.trim())
62
+ log.debug(`[${repoKey}] ${line}`);
63
+ }
64
+ });
65
+ child.stderr?.on("data", (data) => {
66
+ const lines = data.toString("utf-8").trim().split("\n");
67
+ for (const line of lines) {
68
+ if (line.trim())
69
+ log.warn(`[${repoKey}] ${line}`);
70
+ }
71
+ });
72
+ child.on("exit", (code, signal) => {
73
+ if (state.stopped)
74
+ return;
75
+ const uptime = Date.now() - state.lastStartTime;
76
+ if (uptime >= UPTIME_RESET_MS) {
77
+ // Was running long enough, reset backoff
78
+ state.consecutiveFailures = 0;
79
+ state.backoffMs = INITIAL_BACKOFF_MS;
80
+ }
81
+ else {
82
+ state.consecutiveFailures++;
83
+ state.backoffMs = Math.min(state.backoffMs * 2, MAX_BACKOFF_MS);
84
+ }
85
+ if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
86
+ log.error(`Forwarder for ${repoKey} failed ${MAX_CONSECUTIVE_FAILURES} consecutive times, giving up`);
87
+ return;
88
+ }
89
+ log.warn(`Forwarder for ${repoKey} exited (code=${code}, signal=${signal}), ` +
90
+ `restarting in ${state.backoffMs}ms (failure ${state.consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`);
91
+ setTimeout(() => start(), state.backoffMs);
92
+ });
93
+ }
94
+ start();
95
+ return state;
96
+ }
97
+ export function startWebhookForwarders(config) {
98
+ const url = `http://localhost:${config.port}/webhook/github`;
99
+ for (const repo of config.repos) {
100
+ const repoKey = `${repo.owner}/${repo.name}`;
101
+ cleanupStaleWebhooks(repoKey);
102
+ const state = spawnForwarder(config, repoKey, url);
103
+ forwarders.push(state);
104
+ }
105
+ return forwarders.map((f) => f.child);
106
+ }
107
+ export function shutdownForwarders(children) {
108
+ log.info("Shutting down webhook forwarders...");
109
+ // Mark all as stopped to prevent restarts
110
+ for (const f of forwarders) {
111
+ f.stopped = true;
112
+ }
113
+ // Send SIGTERM to all
114
+ for (const child of children) {
115
+ if (child && !child.killed) {
116
+ try {
117
+ child.kill("SIGTERM");
118
+ }
119
+ catch {
120
+ // Already dead
121
+ }
122
+ }
123
+ }
124
+ // Force kill after 5s
125
+ setTimeout(() => {
126
+ for (const child of children) {
127
+ if (child && !child.killed) {
128
+ try {
129
+ log.warn(`Force killing forwarder (pid=${child.pid})`);
130
+ child.kill("SIGKILL");
131
+ }
132
+ catch {
133
+ // Already dead
134
+ }
135
+ }
136
+ }
137
+ }, 5000);
138
+ }