kanbot 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +112 -2
- package/package.json +75 -5
- package/schema/config.schema.json +206 -0
- package/src/agents/load.ts +105 -0
- package/src/agents/sync.ts +219 -0
- package/src/cli.ts +27 -0
- package/src/commands/board.ts +140 -0
- package/src/commands/completion.ts +30 -0
- package/src/commands/doctor.ts +260 -0
- package/src/commands/init.ts +119 -0
- package/src/commands/run.ts +32 -0
- package/src/commands/skills.ts +63 -0
- package/src/commands/status.ts +70 -0
- package/src/commands/sync.ts +31 -0
- package/src/commands/upgrade.ts +70 -0
- package/src/completion.ts +217 -0
- package/src/config.ts +240 -0
- package/src/daemon/derive.ts +413 -0
- package/src/daemon/git.ts +187 -0
- package/src/daemon/lock.ts +29 -0
- package/src/daemon/loop.ts +212 -0
- package/src/daemon/notify.ts +44 -0
- package/src/daemon/snapshot.ts +109 -0
- package/src/daemon/state.ts +60 -0
- package/src/daemon/types.ts +73 -0
- package/src/errors.ts +171 -0
- package/src/github/api.ts +332 -0
- package/src/github/gh.ts +110 -0
- package/src/github/markers.ts +82 -0
- package/src/github/project.ts +288 -0
- package/src/github/trust.ts +38 -0
- package/src/jobs/prompts.ts +205 -0
- package/src/jobs/run.ts +569 -0
- package/src/known-hashes.json +132 -0
- package/src/log.ts +11 -0
- package/src/main.ts +21 -0
- package/src/providers/claude.ts +122 -0
- package/src/providers/codex.ts +98 -0
- package/src/providers/types.ts +94 -0
- package/src/schemas/results.ts +116 -0
- package/src/templates.ts +192 -0
- package/templates/.agents/skills/accessibility/SKILL.md +76 -0
- package/templates/.agents/skills/approach-designer/SKILL.md +79 -0
- package/templates/.agents/skills/code-review/SKILL.md +83 -0
- package/templates/.agents/skills/frontend-design/LICENSE.txt +177 -0
- package/templates/.agents/skills/frontend-design/SKILL.md +76 -0
- package/templates/.agents/skills/issue-splitting/SKILL.md +78 -0
- package/templates/.agents/skills/kanbot-admin/SKILL.md +57 -0
- package/templates/.agents/skills/kanbot-idea/SKILL.md +56 -0
- package/templates/.agents/skills/plan-template/SKILL.md +85 -0
- package/templates/.agents/skills/pr-description/SKILL.md +44 -0
- package/templates/.agents/skills/project-conventions/SKILL.md +79 -0
- package/templates/.agents/skills/project-memory/SKILL.md +39 -0
- package/templates/.agents/skills/project-memory/facts/.gitkeep +0 -0
- package/templates/.agents/skills/spec-review/SKILL.md +73 -0
- package/templates/.agents/skills/tdd/SKILL.md +51 -0
- package/templates/.agents/skills/user-stories/SKILL.md +82 -0
- package/templates/.agents/skills/verify-ui/SKILL.md +104 -0
- package/templates/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/templates/.github/ISSUE_TEMPLATE/technical.yml +15 -0
- package/templates/.github/ISSUE_TEMPLATE/user-story.yml +15 -0
- package/templates/.github/pull_request_template.md +15 -0
- package/templates/.github/workflows/ci.yml +23 -0
- package/templates/.github/workflows/kanbot-merged.yml +30 -0
- package/templates/.kanbot/agents/approach-designer.md +31 -0
- package/templates/.kanbot/agents/explorer.md +35 -0
- package/templates/.kanbot/agents/fixer.md +54 -0
- package/templates/.kanbot/agents/implementer-backend.md +57 -0
- package/templates/.kanbot/agents/implementer-docs.md +55 -0
- package/templates/.kanbot/agents/implementer-frontend.md +54 -0
- package/templates/.kanbot/agents/implementer-technical.md +53 -0
- package/templates/.kanbot/agents/plan-critic.md +36 -0
- package/templates/.kanbot/agents/planner.md +52 -0
- package/templates/.kanbot/agents/reviewer-code.md +46 -0
- package/templates/.kanbot/agents/reviewer-spec.md +49 -0
- package/templates/.kanbot/agents/test-writer.md +35 -0
- package/templates/.kanbot/agents/ui-verifier.md +35 -0
- package/templates/AGENTS.md +39 -0
- package/templates/CLAUDE.md +1 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { log } from "../log.ts";
|
|
6
|
+
|
|
7
|
+
export async function git(cwd: string, ...args: string[]): Promise<string> {
|
|
8
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
9
|
+
cwd,
|
|
10
|
+
stdout: "pipe",
|
|
11
|
+
stderr: "pipe",
|
|
12
|
+
});
|
|
13
|
+
const [out, err, code] = await Promise.all([
|
|
14
|
+
new Response(proc.stdout).text(),
|
|
15
|
+
new Response(proc.stderr).text(),
|
|
16
|
+
proc.exited,
|
|
17
|
+
]);
|
|
18
|
+
if (code !== 0)
|
|
19
|
+
throw new Error(
|
|
20
|
+
`git ${args.join(" ")} failed: ${err.trim() || out.trim()}`,
|
|
21
|
+
);
|
|
22
|
+
return out.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One git worktree per job, outside the repo so the human's checkout and
|
|
27
|
+
* tooling never see agent work in progress. Worktrees are reused per key.
|
|
28
|
+
*/
|
|
29
|
+
export class Worktrees {
|
|
30
|
+
readonly dir: string;
|
|
31
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
readonly root: string,
|
|
35
|
+
repo: string,
|
|
36
|
+
readonly base: string,
|
|
37
|
+
readonly dryRun = false,
|
|
38
|
+
) {
|
|
39
|
+
this.dir = join(homedir(), ".kanbot", "worktrees", repo.replace("/", "__"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run git operations that touch the shared repo (.git/config, refs, the
|
|
44
|
+
* worktree list) one at a time; parallel jobs otherwise race on its locks.
|
|
45
|
+
*/
|
|
46
|
+
private serial<T>(fn: () => Promise<T>): Promise<T> {
|
|
47
|
+
const run = this.queue.then(fn, fn);
|
|
48
|
+
this.queue = run.catch(() => {});
|
|
49
|
+
return run;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private path(name: string) {
|
|
53
|
+
return join(this.dir, name.replace(/[^a-zA-Z0-9._-]/g, "_"));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fetch() {
|
|
57
|
+
return this.serial(() => git(this.root, "fetch", "--prune", "origin"));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async remoteHas(branch: string) {
|
|
61
|
+
return (
|
|
62
|
+
(await git(this.root, "ls-remote", "--heads", "origin", branch)).length >
|
|
63
|
+
0
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check out `branch` in its own worktree. Starts from origin/<branch> when it
|
|
69
|
+
* exists remotely (resuming), otherwise from origin/<base>.
|
|
70
|
+
*/
|
|
71
|
+
checkout(name: string, branch: string): Promise<string> {
|
|
72
|
+
return this.serial(() => this.checkoutNow(name, branch));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async checkoutNow(name: string, branch: string) {
|
|
76
|
+
const path = this.path(name);
|
|
77
|
+
const from = (await this.remoteHas(branch))
|
|
78
|
+
? `origin/${branch}`
|
|
79
|
+
: `origin/${this.base}`;
|
|
80
|
+
await mkdir(this.dir, { recursive: true });
|
|
81
|
+
if (!existsSync(path)) {
|
|
82
|
+
await git(
|
|
83
|
+
this.root,
|
|
84
|
+
"worktree",
|
|
85
|
+
"add",
|
|
86
|
+
"--force",
|
|
87
|
+
"--no-track",
|
|
88
|
+
"-B",
|
|
89
|
+
branch,
|
|
90
|
+
path,
|
|
91
|
+
from,
|
|
92
|
+
);
|
|
93
|
+
} else {
|
|
94
|
+
await git(path, "checkout", "--force", "--no-track", "-B", branch, from);
|
|
95
|
+
await git(path, "reset", "--hard", from);
|
|
96
|
+
// Keep ignored files (node_modules) so installs stay warm.
|
|
97
|
+
await git(path, "clean", "-fd");
|
|
98
|
+
}
|
|
99
|
+
return path;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Read-only snapshot of the base branch for planning. */
|
|
103
|
+
detached(name: string): Promise<string> {
|
|
104
|
+
return this.serial(() => this.detachedNow(name));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async detachedNow(name: string) {
|
|
108
|
+
const path = this.path(name);
|
|
109
|
+
await mkdir(this.dir, { recursive: true });
|
|
110
|
+
const ref = `origin/${this.base}`;
|
|
111
|
+
if (!existsSync(path)) {
|
|
112
|
+
await git(this.root, "worktree", "add", "--force", "--detach", path, ref);
|
|
113
|
+
} else {
|
|
114
|
+
await git(path, "checkout", "--force", "--detach", ref);
|
|
115
|
+
await git(path, "clean", "-fd");
|
|
116
|
+
}
|
|
117
|
+
return path;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Commit anything the agent left uncommitted. */
|
|
121
|
+
async commitAll(path: string, message: string) {
|
|
122
|
+
if (!(await git(path, "status", "--porcelain"))) return;
|
|
123
|
+
await git(path, "add", "-A");
|
|
124
|
+
await git(path, "commit", "-m", message, "--no-verify");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Merge `ref` into HEAD. Returns the conflicted files, leaving the merge in
|
|
129
|
+
* progress for an agent to resolve; empty when it merged cleanly.
|
|
130
|
+
*/
|
|
131
|
+
async merge(path: string, ref: string): Promise<string[]> {
|
|
132
|
+
try {
|
|
133
|
+
await git(path, "merge", "--no-edit", "--no-verify", ref);
|
|
134
|
+
return [];
|
|
135
|
+
} catch (e) {
|
|
136
|
+
const files = await git(path, "diff", "--name-only", "--diff-filter=U");
|
|
137
|
+
if (!files) throw e;
|
|
138
|
+
return files.split("\n");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Stage everything and commit the merge in progress, if any. */
|
|
143
|
+
async concludeMerge(path: string) {
|
|
144
|
+
await git(path, "add", "-A");
|
|
145
|
+
const merging = await git(path, "rev-parse", "-q", "--verify", "MERGE_HEAD")
|
|
146
|
+
.then(() => true)
|
|
147
|
+
.catch(() => false);
|
|
148
|
+
if (merging) await git(path, "commit", "--no-edit", "--no-verify");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async isAncestor(path: string, ref: string) {
|
|
152
|
+
return git(path, "merge-base", "--is-ancestor", ref, "HEAD")
|
|
153
|
+
.then(() => true)
|
|
154
|
+
.catch(() => false);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async commitsAhead(path: string, of: string) {
|
|
158
|
+
return Number(await git(path, "rev-list", "--count", `${of}..HEAD`));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async head(path: string) {
|
|
162
|
+
return git(path, "rev-parse", "HEAD");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async push(path: string, branch: string) {
|
|
166
|
+
if (this.dryRun) return log.info(`[dry-run] push ${branch}`);
|
|
167
|
+
await this.serial(() =>
|
|
168
|
+
git(
|
|
169
|
+
path,
|
|
170
|
+
"push",
|
|
171
|
+
"--force-with-lease",
|
|
172
|
+
"origin",
|
|
173
|
+
`HEAD:refs/heads/${branch}`,
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async remove(prefix: string) {
|
|
179
|
+
if (!existsSync(this.dir)) return;
|
|
180
|
+
for (const name of await readdir(this.dir)) {
|
|
181
|
+
if (!name.startsWith(prefix)) continue;
|
|
182
|
+
await this.serial(() =>
|
|
183
|
+
git(this.root, "worktree", "remove", "--force", join(this.dir, name)),
|
|
184
|
+
).catch((e) => log.warn(String(e)));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { UserError } from "../errors.ts";
|
|
4
|
+
|
|
5
|
+
/** One daemon per repo. Stale locks (dead pid) are taken over. */
|
|
6
|
+
export async function acquireLock(root: string): Promise<() => void> {
|
|
7
|
+
const file = join(root, ".kanbot", "daemon.pid");
|
|
8
|
+
if (existsSync(file)) {
|
|
9
|
+
const pid = Number(await Bun.file(file).text());
|
|
10
|
+
if (pid && isAlive(pid)) {
|
|
11
|
+
throw new UserError("kanbot is already running for this repo.", {
|
|
12
|
+
why: `Process ${pid} holds .kanbot/daemon.pid. Only one daemon runs per repo.`,
|
|
13
|
+
fix: `Stop it with \`kill ${pid}\`, or check it with \`kanbot status\`.`,
|
|
14
|
+
docs: "usage#kanbot-run",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
await Bun.write(file, String(process.pid));
|
|
19
|
+
return () => rmSync(file, { force: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isAlive(pid: number) {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { loadAgents } from "../agents/load.ts";
|
|
2
|
+
import { sync } from "../agents/sync.ts";
|
|
3
|
+
import { type Config, loadConfig } from "../config.ts";
|
|
4
|
+
import { UserError } from "../errors.ts";
|
|
5
|
+
import { Gh } from "../github/gh.ts";
|
|
6
|
+
import { loadBoard, missingColumns } from "../github/project.ts";
|
|
7
|
+
import { Trust } from "../github/trust.ts";
|
|
8
|
+
import {
|
|
9
|
+
applyAction,
|
|
10
|
+
type JobDeps,
|
|
11
|
+
NeedsHuman,
|
|
12
|
+
RateLimited,
|
|
13
|
+
runJob,
|
|
14
|
+
} from "../jobs/run.ts";
|
|
15
|
+
import { log } from "../log.ts";
|
|
16
|
+
import { claude } from "../providers/claude.ts";
|
|
17
|
+
import { codex } from "../providers/codex.ts";
|
|
18
|
+
import type { Provider } from "../providers/types.ts";
|
|
19
|
+
import { type Action, derive, type Job, schedule } from "./derive.ts";
|
|
20
|
+
import { Worktrees } from "./git.ts";
|
|
21
|
+
import { acquireLock } from "./lock.ts";
|
|
22
|
+
import { Notifier } from "./notify.ts";
|
|
23
|
+
import { takeSnapshot } from "./snapshot.ts";
|
|
24
|
+
import { State } from "./state.ts";
|
|
25
|
+
|
|
26
|
+
export type RunOptions = { once: boolean; dryRun: boolean };
|
|
27
|
+
|
|
28
|
+
const RATE_LIMIT_PAUSE_MS = 30 * 60_000;
|
|
29
|
+
|
|
30
|
+
export function providerFor(config: Config): Provider {
|
|
31
|
+
return config.provider === "codex"
|
|
32
|
+
? codex({ networkAccess: config.codex.networkAccess })
|
|
33
|
+
: claude;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runDaemon(root: string, opts: RunOptions) {
|
|
37
|
+
const config = await loadConfig(root);
|
|
38
|
+
const agents = await loadAgents(root);
|
|
39
|
+
const changed = await sync(root, agents, { check: true });
|
|
40
|
+
if (changed.length) {
|
|
41
|
+
log.warn(
|
|
42
|
+
`Generated agent files are stale (${changed.join(", ")}). Run \`kanbot sync\` and commit.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const gh = new Gh(config.repo, opts.dryRun);
|
|
46
|
+
const board = await loadBoard(gh, config);
|
|
47
|
+
const missing = missingColumns(board);
|
|
48
|
+
if (missing.length)
|
|
49
|
+
throw new UserError("The project board is missing columns.", {
|
|
50
|
+
why: `No Status option for: ${missing.map((k) => config.columns[k]).join(", ")}.`,
|
|
51
|
+
fix: "kanbot board create adds them. Or match `columns` in .kanbot/config.yml to your board.",
|
|
52
|
+
docs: "configuration#columns",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const release = opts.dryRun ? () => {} : await acquireLock(root);
|
|
56
|
+
const state = await new State(root).load();
|
|
57
|
+
const deps: JobDeps = {
|
|
58
|
+
root,
|
|
59
|
+
config,
|
|
60
|
+
gh,
|
|
61
|
+
board,
|
|
62
|
+
agents,
|
|
63
|
+
provider: providerFor(config),
|
|
64
|
+
worktrees: new Worktrees(root, config.repo, config.baseBranch, opts.dryRun),
|
|
65
|
+
notify: new Notifier(config.notify),
|
|
66
|
+
state,
|
|
67
|
+
};
|
|
68
|
+
const trust = new Trust(gh);
|
|
69
|
+
const running = new Map<string, Promise<void>>();
|
|
70
|
+
let wake: () => void = () => {};
|
|
71
|
+
// kanbot-review PRs vanish from the snapshot when closed; clean up after them.
|
|
72
|
+
let prOnly = new Set<number>();
|
|
73
|
+
let stopping = false;
|
|
74
|
+
|
|
75
|
+
const stop = () => {
|
|
76
|
+
if (stopping) process.exit(1);
|
|
77
|
+
stopping = true;
|
|
78
|
+
log.info("Stopping after running jobs finish (Ctrl-C again to force)…");
|
|
79
|
+
wake();
|
|
80
|
+
};
|
|
81
|
+
process.on("SIGINT", stop);
|
|
82
|
+
process.on("SIGTERM", stop);
|
|
83
|
+
|
|
84
|
+
log.info(
|
|
85
|
+
`kanbot: ${config.repo} · ${board.url} · provider ${config.provider} · ${config.concurrency} workers${opts.dryRun ? " · DRY RUN" : ""}`,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
while (!stopping) {
|
|
90
|
+
if (Date.now() < state.pausedUntil) {
|
|
91
|
+
log.info(
|
|
92
|
+
`Paused for rate limits until ${new Date(state.pausedUntil).toLocaleTimeString()}`,
|
|
93
|
+
);
|
|
94
|
+
} else {
|
|
95
|
+
await tick().catch((e) =>
|
|
96
|
+
log.error(`tick failed: ${e instanceof Error ? e.message : e}`),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (opts.once) break;
|
|
100
|
+
await new Promise<void>((resolve) => {
|
|
101
|
+
// Clear the timer on wake so Ctrl-C doesn't wait out the poll interval.
|
|
102
|
+
const timer = setTimeout(resolve, config.pollSeconds * 1000);
|
|
103
|
+
wake = () => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
resolve();
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
await Promise.allSettled(running.values());
|
|
110
|
+
} finally {
|
|
111
|
+
release();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function tick() {
|
|
115
|
+
if (!opts.dryRun) await deps.worktrees.fetch();
|
|
116
|
+
const snapshot = await takeSnapshot(gh, board, trust);
|
|
117
|
+
const { jobs, actions } = derive(snapshot, {
|
|
118
|
+
config,
|
|
119
|
+
running: new Set(running.keys()),
|
|
120
|
+
failures: state.failures,
|
|
121
|
+
now: Date.now(),
|
|
122
|
+
});
|
|
123
|
+
const nowPrOnly = new Set(
|
|
124
|
+
snapshot.items.filter((i) => i.issue.prOnly).map((i) => i.issue.number),
|
|
125
|
+
);
|
|
126
|
+
for (const n of prOnly) {
|
|
127
|
+
if (nowPrOnly.has(n)) continue;
|
|
128
|
+
// Retry next tick while a job still uses the worktree.
|
|
129
|
+
if ([...running.keys()].some((k) => k.includes(`#${n}:`)))
|
|
130
|
+
nowPrOnly.add(n);
|
|
131
|
+
else actions.push({ kind: "cleanup", number: n });
|
|
132
|
+
}
|
|
133
|
+
prOnly = nowPrOnly;
|
|
134
|
+
for (const a of actions) {
|
|
135
|
+
if (a.kind === "cleanup" && opts.dryRun) continue;
|
|
136
|
+
if (opts.dryRun) log.info(`[dry-run] action ${describeAction(a)}`);
|
|
137
|
+
await applyAction(a, deps).catch((e) =>
|
|
138
|
+
log.error(`action ${a.kind} failed: ${e}`),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const picked = schedule(
|
|
142
|
+
jobs,
|
|
143
|
+
config.concurrency - running.size,
|
|
144
|
+
config.priority,
|
|
145
|
+
);
|
|
146
|
+
log.debug(
|
|
147
|
+
`${jobs.length} job(s) waiting, ${running.size} running, starting ${picked.length}`,
|
|
148
|
+
);
|
|
149
|
+
for (const job of picked) {
|
|
150
|
+
if (opts.dryRun) {
|
|
151
|
+
log.info(`[dry-run] would start ${job.key}`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
running.set(job.key, start(job));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function start(job: Job) {
|
|
159
|
+
try {
|
|
160
|
+
await runJob(job, deps);
|
|
161
|
+
await state.succeed(job.key);
|
|
162
|
+
log.info(`✔ ${job.key}`);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
165
|
+
if (e instanceof RateLimited) {
|
|
166
|
+
log.warn(
|
|
167
|
+
`${job.key}: rate limited; pausing all work for ${RATE_LIMIT_PAUSE_MS / 60_000} min`,
|
|
168
|
+
);
|
|
169
|
+
await state.pause(RATE_LIMIT_PAUSE_MS);
|
|
170
|
+
} else if (e instanceof NeedsHuman) {
|
|
171
|
+
log.warn(`${job.key}: needs human (${msg})`);
|
|
172
|
+
} else {
|
|
173
|
+
const n = await state.fail(job.key);
|
|
174
|
+
log.error(
|
|
175
|
+
`${job.key} failed (attempt ${n}/${config.maxAttempts}): ${msg}`,
|
|
176
|
+
);
|
|
177
|
+
if (n >= config.maxAttempts) {
|
|
178
|
+
await applyAction(
|
|
179
|
+
{
|
|
180
|
+
kind: "needsHuman",
|
|
181
|
+
number: job.item.issue.number,
|
|
182
|
+
why: `\`${job.key}\` failed ${n} times. Last error:\n\n\`\`\`\n${msg.slice(0, 1500)}\n\`\`\``,
|
|
183
|
+
},
|
|
184
|
+
deps,
|
|
185
|
+
).catch(() => {});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
running.delete(job.key);
|
|
190
|
+
wake();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function describeAction(a: Action) {
|
|
196
|
+
switch (a.kind) {
|
|
197
|
+
case "move":
|
|
198
|
+
return `move #${a.item.issue.number} → ${a.to}`;
|
|
199
|
+
case "label":
|
|
200
|
+
return `label #${a.number} +[${a.add}] -[${a.remove}]`;
|
|
201
|
+
case "agentReviewed":
|
|
202
|
+
return `mark PR #${a.pr.number} agent-reviewed`;
|
|
203
|
+
case "compare":
|
|
204
|
+
return `post comparison on #${a.item.issue.number}`;
|
|
205
|
+
case "needsHuman":
|
|
206
|
+
return `needs-human #${a.number}`;
|
|
207
|
+
case "resetFailures":
|
|
208
|
+
return `reset failures ${a.prefix}`;
|
|
209
|
+
case "cleanup":
|
|
210
|
+
return `cleanup worktrees of #${a.number}`;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import notifier from "node-notifier";
|
|
2
|
+
import type { Config } from "../config.ts";
|
|
3
|
+
import { log } from "../log.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Agents act as your own account, so GitHub never notifies you about their
|
|
7
|
+
* work. This is the replacement: desktop notifications and/or a webhook.
|
|
8
|
+
*/
|
|
9
|
+
export class Notifier {
|
|
10
|
+
constructor(private config: Config["notify"]) {}
|
|
11
|
+
|
|
12
|
+
send(title: string, message: string, url?: string) {
|
|
13
|
+
log.info(`🔔 ${title} — ${message}${url ? ` ${url}` : ""}`);
|
|
14
|
+
if (this.config.desktop)
|
|
15
|
+
desktop(title, message, url).catch((e) =>
|
|
16
|
+
log.warn(`desktop notification failed: ${e}`),
|
|
17
|
+
);
|
|
18
|
+
if (this.config.webhook) {
|
|
19
|
+
fetch(this.config.webhook, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"content-type": "application/json",
|
|
23
|
+
Title: title,
|
|
24
|
+
...(url ? { Click: url } : {}),
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify({ title, message, url }),
|
|
27
|
+
}).catch((e) => log.warn(`webhook failed: ${e}`));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function desktop(title: string, message: string, url?: string) {
|
|
33
|
+
return new Promise<void>((resolve, reject) =>
|
|
34
|
+
notifier.notify(
|
|
35
|
+
{
|
|
36
|
+
title: "kanbot",
|
|
37
|
+
subtitle: title,
|
|
38
|
+
message,
|
|
39
|
+
...(url ? { open: url } : {}),
|
|
40
|
+
},
|
|
41
|
+
(err) => (err ? reject(err) : resolve()),
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ColumnKey } from "../config.ts";
|
|
2
|
+
import { LABELS } from "../config.ts";
|
|
3
|
+
import { fetchIssue, fetchLabeledPRs } from "../github/api.ts";
|
|
4
|
+
import type { Gh } from "../github/gh.ts";
|
|
5
|
+
import { type Board, listItems } from "../github/project.ts";
|
|
6
|
+
import type { Trust } from "../github/trust.ts";
|
|
7
|
+
import type { Item, PR, Snapshot } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const ACTIVE: ReadonlySet<ColumnKey> = new Set([
|
|
10
|
+
"planning",
|
|
11
|
+
"ready",
|
|
12
|
+
"inProgress",
|
|
13
|
+
"review",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
/** Board items plus full detail for issues in columns agents work on. */
|
|
17
|
+
export async function takeSnapshot(
|
|
18
|
+
gh: Gh,
|
|
19
|
+
board: Board,
|
|
20
|
+
trust: Trust,
|
|
21
|
+
): Promise<Snapshot> {
|
|
22
|
+
const raw = await listItems(gh, board);
|
|
23
|
+
const items = await mapLimit(
|
|
24
|
+
raw,
|
|
25
|
+
4,
|
|
26
|
+
async (r, position): Promise<Item | null> => {
|
|
27
|
+
const active =
|
|
28
|
+
r.state === "OPEN" && r.column !== null && ACTIVE.has(r.column);
|
|
29
|
+
if (active) {
|
|
30
|
+
return {
|
|
31
|
+
itemId: r.itemId,
|
|
32
|
+
column: r.column,
|
|
33
|
+
position,
|
|
34
|
+
issue: await fetchIssue(gh, r.number),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (r.state !== "CLOSED") return null;
|
|
38
|
+
// Closed issues only need enough shape for cleanup.
|
|
39
|
+
return {
|
|
40
|
+
itemId: r.itemId,
|
|
41
|
+
column: r.column,
|
|
42
|
+
position,
|
|
43
|
+
issue: {
|
|
44
|
+
id: "",
|
|
45
|
+
number: r.number,
|
|
46
|
+
url: "",
|
|
47
|
+
title: "",
|
|
48
|
+
body: "",
|
|
49
|
+
state: "CLOSED",
|
|
50
|
+
labels: [],
|
|
51
|
+
comments: [],
|
|
52
|
+
prs: [],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
const labeled = (await fetchLabeledPRs(gh, LABELS.review))
|
|
58
|
+
.filter((p) => !p.headRefName.startsWith("kanbot/"))
|
|
59
|
+
.map((p, i) => prItem(p, raw.length + i));
|
|
60
|
+
const present = [...items.filter((i): i is Item => i !== null), ...labeled];
|
|
61
|
+
const authors = present.flatMap(({ issue }) => [
|
|
62
|
+
...issue.comments.map((c) => c.author),
|
|
63
|
+
...issue.prs.flatMap((p) => [
|
|
64
|
+
...p.comments.map((c) => c.author),
|
|
65
|
+
...p.reviews.map((c) => c.author),
|
|
66
|
+
...p.threads.flatMap((t) => t.comments.map((c) => c.author)),
|
|
67
|
+
]),
|
|
68
|
+
]);
|
|
69
|
+
return { items: present, trusted: await trust.resolve(authors) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A `kanbot-review` PR as a Review item, so it gets the same review jobs. */
|
|
73
|
+
export function prItem(pr: PR, position: number): Item {
|
|
74
|
+
return {
|
|
75
|
+
itemId: "",
|
|
76
|
+
column: "review",
|
|
77
|
+
position,
|
|
78
|
+
issue: {
|
|
79
|
+
id: pr.id,
|
|
80
|
+
number: pr.number,
|
|
81
|
+
url: pr.url,
|
|
82
|
+
title: pr.title,
|
|
83
|
+
body: pr.body,
|
|
84
|
+
state: "OPEN",
|
|
85
|
+
labels: pr.labels,
|
|
86
|
+
comments: [],
|
|
87
|
+
prs: [pr],
|
|
88
|
+
prOnly: true,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function mapLimit<T, R>(
|
|
94
|
+
xs: T[],
|
|
95
|
+
limit: number,
|
|
96
|
+
fn: (x: T, i: number) => Promise<R>,
|
|
97
|
+
): Promise<R[]> {
|
|
98
|
+
const out: R[] = new Array(xs.length);
|
|
99
|
+
let next = 0;
|
|
100
|
+
await Promise.all(
|
|
101
|
+
Array.from({ length: Math.min(limit, xs.length) }, async () => {
|
|
102
|
+
while (next < xs.length) {
|
|
103
|
+
const i = next++;
|
|
104
|
+
out[i] = await fn(xs[i] as T, i);
|
|
105
|
+
}
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
type Data = { failures: Record<string, number>; pausedUntil: number };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The only local state: failure counts and a rate-limit pause. Everything else
|
|
8
|
+
* is derived from GitHub on each tick, so deleting this file is always safe.
|
|
9
|
+
*/
|
|
10
|
+
export class State {
|
|
11
|
+
private data: Data = { failures: {}, pausedUntil: 0 };
|
|
12
|
+
private readonly file: string;
|
|
13
|
+
|
|
14
|
+
constructor(root: string) {
|
|
15
|
+
this.file = join(root, ".kanbot", "state.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async load() {
|
|
19
|
+
if (existsSync(this.file)) {
|
|
20
|
+
this.data = { ...this.data, ...(await Bun.file(this.file).json()) };
|
|
21
|
+
}
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private save() {
|
|
26
|
+
return Bun.write(this.file, JSON.stringify(this.data, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get failures(): ReadonlyMap<string, number> {
|
|
30
|
+
return new Map(Object.entries(this.data.failures));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async fail(key: string) {
|
|
34
|
+
this.data.failures[key] = (this.data.failures[key] ?? 0) + 1;
|
|
35
|
+
await this.save();
|
|
36
|
+
return this.data.failures[key];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async succeed(key: string) {
|
|
40
|
+
if (!(key in this.data.failures)) return;
|
|
41
|
+
delete this.data.failures[key];
|
|
42
|
+
await this.save();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async resetFailures(prefix: string) {
|
|
46
|
+
for (const k of Object.keys(this.data.failures)) {
|
|
47
|
+
if (k.includes(prefix)) delete this.data.failures[k];
|
|
48
|
+
}
|
|
49
|
+
await this.save();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get pausedUntil() {
|
|
53
|
+
return this.data.pausedUntil;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async pause(ms: number) {
|
|
57
|
+
this.data.pausedUntil = Date.now() + ms;
|
|
58
|
+
await this.save();
|
|
59
|
+
}
|
|
60
|
+
}
|