@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 +81 -0
- package/config.example.yaml +16 -0
- package/dist/cli.js +122 -0
- package/dist/commands/init.js +48 -0
- package/dist/config.js +97 -0
- package/dist/index.js +114 -0
- package/dist/logger.js +36 -0
- package/dist/processes.js +138 -0
- package/dist/server.js +58 -0
- package/dist/service/index.js +11 -0
- package/dist/service/launchd.js +136 -0
- package/dist/service/paths.js +27 -0
- package/dist/service/systemd.js +117 -0
- package/dist/slug.js +21 -0
- package/dist/spawner.js +242 -0
- package/dist/types.js +1 -0
- package/dist/webhook.js +52 -0
- package/package.json +33 -0
- package/skills/issue-worker/SKILL.md +53 -0
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
|
+
}
|