@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/dist/server.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
import { createLogger } from "./logger.js";
|
|
3
|
+
import { parseWebhookEvent } from "./webhook.js";
|
|
4
|
+
import { isIssueActive } from "./spawner.js";
|
|
5
|
+
const log = createLogger("server");
|
|
6
|
+
export async function startServer(config, onNewIssue) {
|
|
7
|
+
const app = Fastify({ logger: false });
|
|
8
|
+
// Debounce: track recently processed issue keys with 10s TTL
|
|
9
|
+
const recentlyProcessed = new Map();
|
|
10
|
+
function isRecentlyProcessed(key) {
|
|
11
|
+
const ts = recentlyProcessed.get(key);
|
|
12
|
+
if (ts && Date.now() - ts < 10_000) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
function markProcessed(key) {
|
|
18
|
+
recentlyProcessed.set(key, Date.now());
|
|
19
|
+
// Cleanup old entries
|
|
20
|
+
for (const [k, ts] of recentlyProcessed) {
|
|
21
|
+
if (Date.now() - ts >= 10_000) {
|
|
22
|
+
recentlyProcessed.delete(k);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
app.post("/webhook/github", async (request, reply) => {
|
|
27
|
+
try {
|
|
28
|
+
const headers = request.headers;
|
|
29
|
+
const body = request.body;
|
|
30
|
+
const result = parseWebhookEvent(headers, body, config);
|
|
31
|
+
if (result) {
|
|
32
|
+
const key = `${result.repo.owner}/${result.repo.name}#${result.issue.number}`;
|
|
33
|
+
if (isIssueActive(result.repo, result.issue.number)) {
|
|
34
|
+
log.debug(`Issue ${key} already active, ignoring webhook`);
|
|
35
|
+
}
|
|
36
|
+
else if (isRecentlyProcessed(key)) {
|
|
37
|
+
log.debug(`Issue ${key} recently processed, debouncing`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
markProcessed(key);
|
|
41
|
+
log.info(`Processing webhook for ${key}`);
|
|
42
|
+
onNewIssue(result).catch((err) => {
|
|
43
|
+
log.error(`Error handling new issue: ${err instanceof Error ? err.message : String(err)}`);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
log.error(`Error processing webhook: ${err instanceof Error ? err.message : String(err)}`);
|
|
50
|
+
}
|
|
51
|
+
// Always return 200
|
|
52
|
+
return reply.code(200).send({ ok: true });
|
|
53
|
+
});
|
|
54
|
+
app.get("/health", async () => ({ status: "ok" }));
|
|
55
|
+
await app.listen({ port: config.port, host: "127.0.0.1" });
|
|
56
|
+
log.info(`Server listening on http://127.0.0.1:${config.port}`);
|
|
57
|
+
return app;
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as systemd from "./systemd.js";
|
|
2
|
+
import * as launchd from "./launchd.js";
|
|
3
|
+
const systemdBackend = { ...systemd, serviceFile: systemd.unitContents };
|
|
4
|
+
const launchdBackend = { ...launchd, serviceFile: launchd.plistContents };
|
|
5
|
+
export function backend() {
|
|
6
|
+
if (process.platform === "linux")
|
|
7
|
+
return systemdBackend;
|
|
8
|
+
if (process.platform === "darwin")
|
|
9
|
+
return launchdBackend;
|
|
10
|
+
throw new Error(`Unsupported platform: ${process.platform}. april service supports linux (systemd) and macOS (launchd) only.`);
|
|
11
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { homedir, userInfo } from "node:os";
|
|
5
|
+
import { daemonEntryPath, nodeBinaryPath, launchdPlistPath, launchdLogPath, launchdLogDir, LAUNCHD_LABEL, } from "./paths.js";
|
|
6
|
+
function escapeXml(s) {
|
|
7
|
+
return s
|
|
8
|
+
.replace(/&/g, "&")
|
|
9
|
+
.replace(/</g, "<")
|
|
10
|
+
.replace(/>/g, ">")
|
|
11
|
+
.replace(/"/g, """);
|
|
12
|
+
}
|
|
13
|
+
export function plistContents() {
|
|
14
|
+
const node = nodeBinaryPath();
|
|
15
|
+
const entry = daemonEntryPath();
|
|
16
|
+
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
17
|
+
const log = launchdLogPath();
|
|
18
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
19
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
20
|
+
<plist version="1.0">
|
|
21
|
+
<dict>
|
|
22
|
+
<key>Label</key>
|
|
23
|
+
<string>${escapeXml(LAUNCHD_LABEL)}</string>
|
|
24
|
+
<key>ProgramArguments</key>
|
|
25
|
+
<array>
|
|
26
|
+
<string>${escapeXml(node)}</string>
|
|
27
|
+
<string>${escapeXml(entry)}</string>
|
|
28
|
+
</array>
|
|
29
|
+
<key>RunAtLoad</key>
|
|
30
|
+
<true/>
|
|
31
|
+
<key>KeepAlive</key>
|
|
32
|
+
<dict>
|
|
33
|
+
<key>SuccessfulExit</key>
|
|
34
|
+
<false/>
|
|
35
|
+
<key>Crashed</key>
|
|
36
|
+
<true/>
|
|
37
|
+
</dict>
|
|
38
|
+
<key>WorkingDirectory</key>
|
|
39
|
+
<string>${escapeXml(homedir())}</string>
|
|
40
|
+
<key>EnvironmentVariables</key>
|
|
41
|
+
<dict>
|
|
42
|
+
<key>PATH</key>
|
|
43
|
+
<string>${escapeXml(path)}</string>
|
|
44
|
+
<key>NODE_ENV</key>
|
|
45
|
+
<string>production</string>
|
|
46
|
+
<key>HOME</key>
|
|
47
|
+
<string>${escapeXml(homedir())}</string>
|
|
48
|
+
</dict>
|
|
49
|
+
<key>StandardOutPath</key>
|
|
50
|
+
<string>${escapeXml(log)}</string>
|
|
51
|
+
<key>StandardErrorPath</key>
|
|
52
|
+
<string>${escapeXml(log)}</string>
|
|
53
|
+
<key>ProcessType</key>
|
|
54
|
+
<string>Background</string>
|
|
55
|
+
</dict>
|
|
56
|
+
</plist>
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
59
|
+
function ensureLaunchctl() {
|
|
60
|
+
try {
|
|
61
|
+
execFileSync("launchctl", ["help"], { stdio: "ignore" });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Error("launchctl not found. april service install requires macOS launchd.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function uid() {
|
|
68
|
+
return userInfo().uid;
|
|
69
|
+
}
|
|
70
|
+
function domain() {
|
|
71
|
+
return `gui/${uid()}`;
|
|
72
|
+
}
|
|
73
|
+
function serviceTarget() {
|
|
74
|
+
return `${domain()}/${LAUNCHD_LABEL}`;
|
|
75
|
+
}
|
|
76
|
+
export function install() {
|
|
77
|
+
ensureLaunchctl();
|
|
78
|
+
mkdirSync(launchdLogDir(), { recursive: true });
|
|
79
|
+
const path = launchdPlistPath();
|
|
80
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
81
|
+
writeFileSync(path, plistContents(), "utf-8");
|
|
82
|
+
// bootout (ignore failure if not loaded), then bootstrap.
|
|
83
|
+
spawnSync("launchctl", ["bootout", serviceTarget()], { stdio: "ignore" });
|
|
84
|
+
const res = spawnSync("launchctl", ["bootstrap", domain(), path], { encoding: "utf-8" });
|
|
85
|
+
if ((res.status ?? 1) !== 0) {
|
|
86
|
+
throw new Error(`launchctl bootstrap failed: ${res.stderr ?? ""}`);
|
|
87
|
+
}
|
|
88
|
+
spawnSync("launchctl", ["enable", serviceTarget()], { stdio: "ignore" });
|
|
89
|
+
spawnSync("launchctl", ["kickstart", "-k", serviceTarget()], { stdio: "ignore" });
|
|
90
|
+
console.log(`✓ Installed ${path}`);
|
|
91
|
+
console.log(`✓ Service loaded and started`);
|
|
92
|
+
console.log(` Logs: ${launchdLogPath()}`);
|
|
93
|
+
}
|
|
94
|
+
export function uninstall() {
|
|
95
|
+
ensureLaunchctl();
|
|
96
|
+
const path = launchdPlistPath();
|
|
97
|
+
spawnSync("launchctl", ["bootout", serviceTarget()], { stdio: "ignore" });
|
|
98
|
+
if (existsSync(path)) {
|
|
99
|
+
unlinkSync(path);
|
|
100
|
+
console.log(`✓ Removed ${path}`);
|
|
101
|
+
}
|
|
102
|
+
console.log(`✓ Service uninstalled`);
|
|
103
|
+
}
|
|
104
|
+
export function start() {
|
|
105
|
+
const res = spawnSync("launchctl", ["kickstart", serviceTarget()], { encoding: "utf-8" });
|
|
106
|
+
if ((res.status ?? 1) !== 0)
|
|
107
|
+
throw new Error(`launchctl kickstart failed: ${res.stderr ?? ""}`);
|
|
108
|
+
}
|
|
109
|
+
export function stop() {
|
|
110
|
+
// `kill` sends a signal to the running instance without unloading.
|
|
111
|
+
const res = spawnSync("launchctl", ["kill", "SIGTERM", serviceTarget()], { encoding: "utf-8" });
|
|
112
|
+
if ((res.status ?? 1) !== 0)
|
|
113
|
+
throw new Error(`launchctl kill failed: ${res.stderr ?? ""}`);
|
|
114
|
+
}
|
|
115
|
+
export function restart() {
|
|
116
|
+
const res = spawnSync("launchctl", ["kickstart", "-k", serviceTarget()], { encoding: "utf-8" });
|
|
117
|
+
if ((res.status ?? 1) !== 0)
|
|
118
|
+
throw new Error(`launchctl kickstart -k failed: ${res.stderr ?? ""}`);
|
|
119
|
+
}
|
|
120
|
+
export function status() {
|
|
121
|
+
const res = spawnSync("launchctl", ["print", serviceTarget()], { stdio: "inherit" });
|
|
122
|
+
return res.status ?? 1;
|
|
123
|
+
}
|
|
124
|
+
export function logs(follow, lines) {
|
|
125
|
+
const log = launchdLogPath();
|
|
126
|
+
if (!existsSync(log)) {
|
|
127
|
+
console.log(`(no log file at ${log} yet — service may not have started)`);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
const args = ["-n", String(lines)];
|
|
131
|
+
if (follow)
|
|
132
|
+
args.push("-f");
|
|
133
|
+
args.push(log);
|
|
134
|
+
const res = spawnSync("tail", args, { stdio: "inherit" });
|
|
135
|
+
return res.status ?? 1;
|
|
136
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
// Resolve the daemon entry point relative to this file's installed location.
|
|
5
|
+
// When published to npm, both cli.js and index.js live in dist/ alongside service/.
|
|
6
|
+
export function daemonEntryPath() {
|
|
7
|
+
const here = fileURLToPath(import.meta.url);
|
|
8
|
+
// dist/service/paths.js -> dist/index.js
|
|
9
|
+
return resolve(dirname(here), "..", "index.js");
|
|
10
|
+
}
|
|
11
|
+
export function nodeBinaryPath() {
|
|
12
|
+
return process.execPath;
|
|
13
|
+
}
|
|
14
|
+
export const SERVICE_NAME = "april";
|
|
15
|
+
export const LAUNCHD_LABEL = "dev.april.daemon";
|
|
16
|
+
export function systemdUnitPath() {
|
|
17
|
+
return join(homedir(), ".config", "systemd", "user", `${SERVICE_NAME}.service`);
|
|
18
|
+
}
|
|
19
|
+
export function launchdPlistPath() {
|
|
20
|
+
return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
21
|
+
}
|
|
22
|
+
export function launchdLogPath() {
|
|
23
|
+
return join(homedir(), "Library", "Logs", "april", "april.log");
|
|
24
|
+
}
|
|
25
|
+
export function launchdLogDir() {
|
|
26
|
+
return join(homedir(), "Library", "Logs", "april");
|
|
27
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { daemonEntryPath, nodeBinaryPath, systemdUnitPath, SERVICE_NAME, } from "./paths.js";
|
|
6
|
+
function runSystemctl(args) {
|
|
7
|
+
const res = spawnSync("systemctl", ["--user", ...args], { encoding: "utf-8" });
|
|
8
|
+
return {
|
|
9
|
+
status: res.status ?? 1,
|
|
10
|
+
stdout: res.stdout ?? "",
|
|
11
|
+
stderr: res.stderr ?? "",
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function unitContents() {
|
|
15
|
+
const node = nodeBinaryPath();
|
|
16
|
+
const entry = daemonEntryPath();
|
|
17
|
+
// Capture caller's PATH so child has access to gh, tmux, git, claude.
|
|
18
|
+
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
19
|
+
return `[Unit]
|
|
20
|
+
Description=april — issue worker
|
|
21
|
+
After=network.target
|
|
22
|
+
|
|
23
|
+
[Service]
|
|
24
|
+
Type=simple
|
|
25
|
+
ExecStart=${node} ${entry}
|
|
26
|
+
Restart=on-failure
|
|
27
|
+
RestartSec=5s
|
|
28
|
+
WorkingDirectory=${homedir()}
|
|
29
|
+
Environment=PATH=${path}
|
|
30
|
+
Environment=NODE_ENV=production
|
|
31
|
+
StandardOutput=journal
|
|
32
|
+
StandardError=journal
|
|
33
|
+
|
|
34
|
+
[Install]
|
|
35
|
+
WantedBy=default.target
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
function ensureSystemctlPresent() {
|
|
39
|
+
try {
|
|
40
|
+
execFileSync("systemctl", ["--version"], { stdio: "ignore" });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Error("systemctl not found on PATH. april service install requires systemd.");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function lingerEnabled() {
|
|
47
|
+
try {
|
|
48
|
+
const out = execFileSync("loginctl", ["show-user", process.env.USER ?? ""], {
|
|
49
|
+
encoding: "utf-8",
|
|
50
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
51
|
+
});
|
|
52
|
+
return /Linger=yes/i.test(out);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function install() {
|
|
59
|
+
ensureSystemctlPresent();
|
|
60
|
+
const path = systemdUnitPath();
|
|
61
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
62
|
+
writeFileSync(path, unitContents(), "utf-8");
|
|
63
|
+
let res = runSystemctl(["daemon-reload"]);
|
|
64
|
+
if (res.status !== 0)
|
|
65
|
+
throw new Error(`systemctl daemon-reload failed: ${res.stderr}`);
|
|
66
|
+
res = runSystemctl(["enable", "--now", SERVICE_NAME]);
|
|
67
|
+
if (res.status !== 0)
|
|
68
|
+
throw new Error(`systemctl enable --now ${SERVICE_NAME} failed: ${res.stderr}`);
|
|
69
|
+
console.log(`✓ Installed ${path}`);
|
|
70
|
+
console.log(`✓ Service enabled and started`);
|
|
71
|
+
if (!lingerEnabled()) {
|
|
72
|
+
console.log("");
|
|
73
|
+
console.log("⚠ Linger is not enabled for your user. Without it, april will stop when");
|
|
74
|
+
console.log(" you log out (e.g., end your SSH session). To keep it running:");
|
|
75
|
+
console.log("");
|
|
76
|
+
console.log(` sudo loginctl enable-linger ${process.env.USER ?? "$USER"}`);
|
|
77
|
+
console.log("");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function uninstall() {
|
|
81
|
+
ensureSystemctlPresent();
|
|
82
|
+
runSystemctl(["disable", "--now", SERVICE_NAME]);
|
|
83
|
+
const path = systemdUnitPath();
|
|
84
|
+
if (existsSync(path)) {
|
|
85
|
+
unlinkSync(path);
|
|
86
|
+
console.log(`✓ Removed ${path}`);
|
|
87
|
+
}
|
|
88
|
+
runSystemctl(["daemon-reload"]);
|
|
89
|
+
console.log(`✓ Service uninstalled`);
|
|
90
|
+
}
|
|
91
|
+
export function start() {
|
|
92
|
+
const res = runSystemctl(["start", SERVICE_NAME]);
|
|
93
|
+
if (res.status !== 0)
|
|
94
|
+
throw new Error(`systemctl start failed: ${res.stderr}`);
|
|
95
|
+
}
|
|
96
|
+
export function stop() {
|
|
97
|
+
const res = runSystemctl(["stop", SERVICE_NAME]);
|
|
98
|
+
if (res.status !== 0)
|
|
99
|
+
throw new Error(`systemctl stop failed: ${res.stderr}`);
|
|
100
|
+
}
|
|
101
|
+
export function restart() {
|
|
102
|
+
const res = runSystemctl(["restart", SERVICE_NAME]);
|
|
103
|
+
if (res.status !== 0)
|
|
104
|
+
throw new Error(`systemctl restart failed: ${res.stderr}`);
|
|
105
|
+
}
|
|
106
|
+
export function status() {
|
|
107
|
+
// Inherit stdio so the user sees colored systemctl output directly.
|
|
108
|
+
const res = spawnSync("systemctl", ["--user", "status", SERVICE_NAME], { stdio: "inherit" });
|
|
109
|
+
return res.status ?? 1;
|
|
110
|
+
}
|
|
111
|
+
export function logs(follow, lines) {
|
|
112
|
+
const args = ["--user", "-u", SERVICE_NAME, "-n", String(lines)];
|
|
113
|
+
if (follow)
|
|
114
|
+
args.push("-f");
|
|
115
|
+
const res = spawnSync("journalctl", args, { stdio: "inherit" });
|
|
116
|
+
return res.status ?? 1;
|
|
117
|
+
}
|
package/dist/slug.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function makeSlug(issueNumber, title) {
|
|
2
|
+
let slug = title
|
|
3
|
+
.toLowerCase()
|
|
4
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
5
|
+
.replace(/-{2,}/g, "-")
|
|
6
|
+
.replace(/^-+|-+$/g, "");
|
|
7
|
+
// Truncate to 40 chars at word boundary
|
|
8
|
+
if (slug.length > 40) {
|
|
9
|
+
slug = slug.substring(0, 40);
|
|
10
|
+
const lastHyphen = slug.lastIndexOf("-");
|
|
11
|
+
if (lastHyphen > 0) {
|
|
12
|
+
slug = slug.substring(0, lastHyphen);
|
|
13
|
+
}
|
|
14
|
+
slug = slug.replace(/-+$/, "");
|
|
15
|
+
}
|
|
16
|
+
// Fallback if empty
|
|
17
|
+
if (slug.length === 0) {
|
|
18
|
+
slug = "issue";
|
|
19
|
+
}
|
|
20
|
+
return `gh-${issueNumber}-${slug}`;
|
|
21
|
+
}
|
package/dist/spawner.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createLogger } from "./logger.js";
|
|
5
|
+
import { makeSlug } from "./slug.js";
|
|
6
|
+
const log = createLogger("spawner");
|
|
7
|
+
function checkWorktreesIgnored(repoPath) {
|
|
8
|
+
const gitignorePath = join(repoPath, ".gitignore");
|
|
9
|
+
if (!existsSync(gitignorePath)) {
|
|
10
|
+
log.warn(`${repoPath}: no .gitignore found. Consider adding ".worktrees" to it.`);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const content = readFileSync(gitignorePath, "utf-8");
|
|
15
|
+
const lines = content.split("\n").map((l) => l.trim());
|
|
16
|
+
if (!lines.some((l) => l === ".worktrees" || l === ".worktrees/" || l === "/.worktrees" || l === "/.worktrees/")) {
|
|
17
|
+
log.warn(`${repoPath}/.gitignore does not contain ".worktrees". Consider adding it to avoid committing worktrees.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Non-critical, just skip
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Check if an issue is already active by looking at the filesystem and tmux.
|
|
26
|
+
* Matches any worktree dir or tmux session starting with `gh-{issueNumber}-`.
|
|
27
|
+
*/
|
|
28
|
+
export function isIssueActive(repo, issueNumber) {
|
|
29
|
+
const prefix = `gh-${issueNumber}-`;
|
|
30
|
+
// Check worktrees on disk
|
|
31
|
+
const worktreesDir = join(repo.path, ".worktrees");
|
|
32
|
+
if (existsSync(worktreesDir)) {
|
|
33
|
+
const dirs = readdirSync(worktreesDir);
|
|
34
|
+
const match = dirs.find((d) => d.startsWith(prefix));
|
|
35
|
+
if (match) {
|
|
36
|
+
log.info(`Skipping issue #${issueNumber}: existing worktree found (${match})`);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Check tmux sessions
|
|
41
|
+
try {
|
|
42
|
+
const output = execSync("tmux list-sessions -F '#{session_name}'", {
|
|
43
|
+
encoding: "utf-8",
|
|
44
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
45
|
+
});
|
|
46
|
+
const match = output.trim().split("\n").find((s) => s.startsWith(prefix));
|
|
47
|
+
if (match) {
|
|
48
|
+
log.info(`Skipping issue #${issueNumber}: existing tmux session found (${match})`);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// tmux not running or no sessions
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Count active worktrees and tmux sessions across all configured repos.
|
|
59
|
+
*/
|
|
60
|
+
export function getActiveCounts(config) {
|
|
61
|
+
let worktrees = 0;
|
|
62
|
+
for (const repo of config.repos) {
|
|
63
|
+
const dir = join(repo.path, ".worktrees");
|
|
64
|
+
if (existsSync(dir)) {
|
|
65
|
+
worktrees += readdirSync(dir).filter((d) => d.startsWith("gh-")).length;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
let sessions = 0;
|
|
69
|
+
try {
|
|
70
|
+
const output = execSync("tmux list-sessions -F '#{session_name}'", {
|
|
71
|
+
encoding: "utf-8",
|
|
72
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
73
|
+
});
|
|
74
|
+
sessions = output.trim().split("\n").filter((s) => s.startsWith("gh-")).length;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// tmux not running
|
|
78
|
+
}
|
|
79
|
+
return { worktrees, sessions };
|
|
80
|
+
}
|
|
81
|
+
export async function createWorktree(repo, branch) {
|
|
82
|
+
const worktreesDir = join(repo.path, ".worktrees");
|
|
83
|
+
const worktreePath = join(worktreesDir, branch);
|
|
84
|
+
if (existsSync(worktreePath)) {
|
|
85
|
+
log.info(`Worktree already exists: ${worktreePath}`);
|
|
86
|
+
return worktreePath;
|
|
87
|
+
}
|
|
88
|
+
checkWorktreesIgnored(repo.path);
|
|
89
|
+
// Ensure .worktrees directory exists
|
|
90
|
+
execSync(`mkdir -p ${JSON.stringify(worktreesDir)}`);
|
|
91
|
+
// Fetch origin defaultBranch
|
|
92
|
+
log.info(`Fetching origin/${repo.defaultBranch} for ${repo.owner}/${repo.name}`);
|
|
93
|
+
try {
|
|
94
|
+
execFileSync("git", ["-C", repo.path, "fetch", "origin", repo.defaultBranch], {
|
|
95
|
+
timeout: 60_000,
|
|
96
|
+
stdio: "pipe",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
101
|
+
throw new Error(`Failed to fetch origin/${repo.defaultBranch} in ${repo.path}: ${msg}`);
|
|
102
|
+
}
|
|
103
|
+
// Create worktree with new branch
|
|
104
|
+
log.info(`Creating worktree: ${worktreePath} (branch: ${branch})`);
|
|
105
|
+
try {
|
|
106
|
+
execFileSync("git", ["-C", repo.path, "worktree", "add", worktreePath, "-b", branch, `origin/${repo.defaultBranch}`], { timeout: 30_000, stdio: "pipe" });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Branch may already exist — try without -b
|
|
110
|
+
log.debug(`Branch "${branch}" may already exist, retrying without -b`);
|
|
111
|
+
try {
|
|
112
|
+
execFileSync("git", ["-C", repo.path, "worktree", "add", worktreePath, branch], {
|
|
113
|
+
timeout: 30_000,
|
|
114
|
+
stdio: "pipe",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch (err2) {
|
|
118
|
+
const msg = err2 instanceof Error ? err2.message : String(err2);
|
|
119
|
+
throw new Error(`Failed to create worktree for branch "${branch}": ${msg}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
log.info(`Worktree created at ${worktreePath}`);
|
|
123
|
+
// Run post-worktree hook if configured
|
|
124
|
+
if (repo.postWorktreeHook) {
|
|
125
|
+
log.info(`Running post-worktree hook: ${repo.postWorktreeHook}`);
|
|
126
|
+
try {
|
|
127
|
+
execSync(repo.postWorktreeHook, {
|
|
128
|
+
cwd: worktreePath,
|
|
129
|
+
timeout: 300_000, // 5 min — installs can be slow
|
|
130
|
+
stdio: "pipe",
|
|
131
|
+
});
|
|
132
|
+
log.info("Post-worktree hook completed");
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
136
|
+
log.warn(`Post-worktree hook failed: ${msg}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return worktreePath;
|
|
140
|
+
}
|
|
141
|
+
export function spawnClaude(config, repo, issue, worktreePath, sessionName) {
|
|
142
|
+
// Check if session already exists
|
|
143
|
+
try {
|
|
144
|
+
execSync(`tmux has-session -t ${JSON.stringify(sessionName)}`, { stdio: "pipe" });
|
|
145
|
+
log.info(`tmux session "${sessionName}" already exists, skipping`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Session does not exist, proceed
|
|
150
|
+
}
|
|
151
|
+
const model = config.claudeModel || "opus";
|
|
152
|
+
const allowedTools = [
|
|
153
|
+
...(config.claudeAllowedTools ?? ["Read", "Search", "Edit", "Write", "Bash(*)"]),
|
|
154
|
+
...(repo.slackChannel ? ["mcp__plugin_slack_slack__*"] : []),
|
|
155
|
+
];
|
|
156
|
+
const slackPart = repo.slackChannel ? ` Post the PR to Slack channel #${repo.slackChannel}.` : "";
|
|
157
|
+
const prompt = `/${config.claudeSkill} Read GitHub issue #${issue.number} on ${repo.owner}/${repo.name} using the gh CLI. Implement it and open a PR.${slackPart}`;
|
|
158
|
+
log.debug(`Prompt: ${prompt}`);
|
|
159
|
+
const allowedToolsArgs = allowedTools.map((t) => `--allowedTools '${t}'`).join(" ");
|
|
160
|
+
const claudeCommand = `claude --model ${model} ${allowedToolsArgs}`;
|
|
161
|
+
log.info(`Spawning tmux session "${sessionName}" with claude`);
|
|
162
|
+
execSync(`tmux new-session -d -s ${JSON.stringify(sessionName)} -c ${JSON.stringify(worktreePath)} ${JSON.stringify(claudeCommand)}`);
|
|
163
|
+
// Send the prompt via send-keys after Claude starts
|
|
164
|
+
const escapedPrompt = prompt.replace(/'/g, "'\\''");
|
|
165
|
+
const session = JSON.stringify(sessionName);
|
|
166
|
+
setTimeout(() => {
|
|
167
|
+
try {
|
|
168
|
+
// Send text first, then Enter after a short delay to ensure Claude's input is ready
|
|
169
|
+
execSync(`tmux send-keys -t ${session} '${escapedPrompt}'`, { stdio: "pipe" });
|
|
170
|
+
setTimeout(() => {
|
|
171
|
+
try {
|
|
172
|
+
execSync(`tmux send-keys -t ${session} C-m`, { stdio: "pipe" });
|
|
173
|
+
log.info(`Prompt sent to tmux session "${sessionName}"`);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
log.warn(`Failed to send Enter to session "${sessionName}": ${err instanceof Error ? err.message : String(err)}`);
|
|
177
|
+
}
|
|
178
|
+
}, 1000);
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
log.warn(`Failed to send prompt to session "${sessionName}": ${err instanceof Error ? err.message : String(err)}`);
|
|
182
|
+
}
|
|
183
|
+
}, 3000);
|
|
184
|
+
log.info(`tmux session "${sessionName}" started`);
|
|
185
|
+
}
|
|
186
|
+
export async function handleNewIssue(repo, issue, config) {
|
|
187
|
+
// Check if already active via filesystem/tmux
|
|
188
|
+
if (isIssueActive(repo, issue.number)) {
|
|
189
|
+
log.info(`Issue #${issue.number} already active in ${repo.owner}/${repo.name}, skipping`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const slug = makeSlug(issue.number, issue.title);
|
|
193
|
+
// Create worktree
|
|
194
|
+
let worktreePath;
|
|
195
|
+
try {
|
|
196
|
+
worktreePath = await createWorktree(repo, slug);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
log.error(`Failed to create worktree for #${issue.number}: ${err instanceof Error ? err.message : String(err)}`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Spawn tmux + claude
|
|
203
|
+
try {
|
|
204
|
+
spawnClaude(config, repo, issue, worktreePath, slug);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
log.error(`Failed to spawn claude for #${issue.number}: ${err instanceof Error ? err.message : String(err)}`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// Apply label transition
|
|
211
|
+
try {
|
|
212
|
+
execFileSync("gh", [
|
|
213
|
+
"issue", "edit", String(issue.number),
|
|
214
|
+
"--repo", `${repo.owner}/${repo.name}`,
|
|
215
|
+
"--add-label", "agent:wip",
|
|
216
|
+
"--remove-label", "agent:todo",
|
|
217
|
+
], { timeout: 15_000, stdio: "pipe" });
|
|
218
|
+
log.info(`Labels updated for #${issue.number}: agent:todo -> agent:wip`);
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
log.warn(`Failed to update labels for #${issue.number}: ${err instanceof Error ? err.message : String(err)}`);
|
|
222
|
+
}
|
|
223
|
+
log.info(`Issue #${issue.number} (${repo.owner}/${repo.name}) is now active`);
|
|
224
|
+
}
|
|
225
|
+
export function fetchOpenIssues(repo, config) {
|
|
226
|
+
try {
|
|
227
|
+
const output = execFileSync("gh", [
|
|
228
|
+
"issue", "list",
|
|
229
|
+
"--repo", `${repo.owner}/${repo.name}`,
|
|
230
|
+
"--assignee", config.assignee,
|
|
231
|
+
"--label", config.label,
|
|
232
|
+
"--json", "number,title",
|
|
233
|
+
"--state", "open",
|
|
234
|
+
], { timeout: 30_000, stdio: ["pipe", "pipe", "pipe"] });
|
|
235
|
+
const parsed = JSON.parse(output.toString("utf-8"));
|
|
236
|
+
return parsed.map((i) => ({ number: i.number, title: i.title }));
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
log.error(`Failed to fetch issues for ${repo.owner}/${repo.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createLogger } from "./logger.js";
|
|
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}`);
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const action = body.action;
|
|
10
|
+
if (action !== "assigned" && action !== "labeled") {
|
|
11
|
+
log.debug(`Ignoring issues action: ${action}`);
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const issue = body.issue;
|
|
15
|
+
if (!issue) {
|
|
16
|
+
log.debug("No issue payload found");
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
// Check assignees
|
|
20
|
+
const assignees = issue.assignees;
|
|
21
|
+
if (!assignees || !assignees.some((a) => a.login === config.assignee)) {
|
|
22
|
+
log.debug(`Issue not assigned to ${config.assignee}`);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
// Check labels
|
|
26
|
+
const labels = issue.labels;
|
|
27
|
+
if (!labels || !labels.some((l) => l.name === config.label)) {
|
|
28
|
+
log.debug(`Issue does not have label "${config.label}"`);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
// Match repo
|
|
32
|
+
const repository = body.repository;
|
|
33
|
+
if (!repository) {
|
|
34
|
+
log.debug("No repository payload found");
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
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");
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
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`);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
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 } };
|
|
52
|
+
}
|