kanbot 0.1.0 → 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/README.md +10 -0
- package/package.json +21 -2
- package/src/agents/load.ts +29 -7
- package/src/agents/sync.ts +6 -3
- package/src/cli.ts +24 -2
- package/src/commands/completion.ts +7 -1
- package/src/commands/doctor.ts +14 -4
- package/src/commands/init.ts +26 -9
- package/src/commands/status.ts +11 -2
- package/src/config.ts +29 -2
- package/src/daemon/lock.ts +6 -1
- package/src/daemon/loop.ts +6 -1
- package/src/errors.ts +171 -0
- package/src/github/project.ts +13 -3
package/README.md
CHANGED
|
@@ -101,3 +101,13 @@ bun test
|
|
|
101
101
|
pnpm check
|
|
102
102
|
pnpm typecheck
|
|
103
103
|
```
|
|
104
|
+
|
|
105
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org). A `commit-msg` hook and CI lint them.
|
|
106
|
+
|
|
107
|
+
### Releasing
|
|
108
|
+
|
|
109
|
+
1. Merge or push conventional commits to `main`.
|
|
110
|
+
2. A draft release updates with the next version and notes. `feat` bumps minor, `fix` patch. On 0.x, breaking changes bump minor.
|
|
111
|
+
3. Publish the draft on GitHub. This tags the commit, publishes to npm with provenance, and commits the version to `package.json` on `main`.
|
|
112
|
+
|
|
113
|
+
Preview the next release with `pnpm release:preview`. Mark the draft as a pre-release to publish under the `next` tag.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kanbot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Run an autonomous dev team (Claude Code or Codex) from a GitHub Project board",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,9 @@
|
|
|
41
41
|
"docs:dev": "pnpm --dir site dev",
|
|
42
42
|
"docs:build": "pnpm --dir site build",
|
|
43
43
|
"hashes": "bun scripts/known-hashes.ts",
|
|
44
|
-
"prepublishOnly": "bun run check && bun run typecheck && bun test"
|
|
44
|
+
"prepublishOnly": "bun run check && bun run typecheck && bun test",
|
|
45
|
+
"prepare": "lefthook install",
|
|
46
|
+
"release:preview": "git-cliff --unreleased --bump --strip all && git-cliff --bumped-version"
|
|
45
47
|
},
|
|
46
48
|
"dependencies": {
|
|
47
49
|
"citty": "^0.2.2",
|
|
@@ -51,7 +53,24 @@
|
|
|
51
53
|
},
|
|
52
54
|
"devDependencies": {
|
|
53
55
|
"@biomejs/biome": "^2.6.4",
|
|
56
|
+
"@commitlint/cli": "^21.2.4",
|
|
57
|
+
"@commitlint/config-conventional": "^21.2.4",
|
|
54
58
|
"@types/bun": "^1.4.4",
|
|
59
|
+
"git-cliff": "^2.14.2",
|
|
60
|
+
"lefthook": "^2.2.2",
|
|
55
61
|
"typescript": "^7.0.2"
|
|
62
|
+
},
|
|
63
|
+
"commitlint": {
|
|
64
|
+
"extends": [
|
|
65
|
+
"@commitlint/config-conventional"
|
|
66
|
+
],
|
|
67
|
+
"rules": {
|
|
68
|
+
"body-max-line-length": [
|
|
69
|
+
0
|
|
70
|
+
],
|
|
71
|
+
"footer-max-line-length": [
|
|
72
|
+
0
|
|
73
|
+
]
|
|
74
|
+
}
|
|
56
75
|
}
|
|
57
76
|
}
|
package/src/agents/load.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { readdir } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { parse } from "yaml";
|
|
4
5
|
import { z } from "zod";
|
|
6
|
+
import { UserError } from "../errors.ts";
|
|
5
7
|
import { RESULT_SCHEMAS } from "../schemas/results.ts";
|
|
6
8
|
|
|
7
9
|
export const TOOL_CAPABILITIES = ["read", "edit", "bash", "web"] as const;
|
|
@@ -34,16 +36,23 @@ export function agentsDir(root: string) {
|
|
|
34
36
|
|
|
35
37
|
export function parseAgent(text: string, file: string): AgentDef {
|
|
36
38
|
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
37
|
-
if (!m) throw
|
|
39
|
+
if (!m) throw invalid(file, "Missing YAML frontmatter between `---` lines.");
|
|
38
40
|
const fm = Frontmatter.safeParse(parse(m[1] as string));
|
|
39
41
|
if (!fm.success) {
|
|
40
|
-
throw
|
|
42
|
+
throw invalid(file, z.prettifyError(fm.error));
|
|
41
43
|
}
|
|
42
44
|
return { ...fm.data, body: (m[2] as string).trim(), file };
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
export async function loadAgents(root: string): Promise<AgentDef[]> {
|
|
46
48
|
const dir = agentsDir(root);
|
|
49
|
+
if (!existsSync(dir)) {
|
|
50
|
+
throw new UserError("kanbot agents are missing.", {
|
|
51
|
+
why: "No .kanbot/agents directory in this repo.",
|
|
52
|
+
fix: "kanbot init (or kanbot upgrade to restore missing files)",
|
|
53
|
+
docs: "getting-started#set-up-a-repository",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
47
56
|
const files = (await readdir(dir)).filter((f) => f.endsWith(".md")).sort();
|
|
48
57
|
const agents = await Promise.all(
|
|
49
58
|
files.map(async (f) =>
|
|
@@ -57,25 +66,38 @@ export async function loadAgents(root: string): Promise<AgentDef[]> {
|
|
|
57
66
|
function validate(agents: AgentDef[]) {
|
|
58
67
|
const names = new Set(agents.map((a) => a.name));
|
|
59
68
|
if (names.size !== agents.length) {
|
|
60
|
-
throw new
|
|
69
|
+
throw new UserError("Duplicate agent names in .kanbot/agents.", {
|
|
70
|
+
why: "Two agent files declare the same `name`.",
|
|
71
|
+
fix: "Give each agent a unique name matching its filename.",
|
|
72
|
+
docs: "agents#frontmatter",
|
|
73
|
+
});
|
|
61
74
|
}
|
|
62
75
|
for (const a of agents) {
|
|
63
76
|
if (!a.file.endsWith(`${a.name}.md`)) {
|
|
64
|
-
throw
|
|
77
|
+
throw invalid(a.file, `name "${a.name}" must match the filename.`);
|
|
65
78
|
}
|
|
66
79
|
if (a.tier === "column" && !a.output) {
|
|
67
|
-
throw
|
|
80
|
+
throw invalid(a.file, "Column agents must declare an `output` schema.");
|
|
68
81
|
}
|
|
69
82
|
for (const s of a.subagents) {
|
|
70
83
|
const sub = agents.find((x) => x.name === s);
|
|
71
|
-
if (!sub)
|
|
84
|
+
if (!sub)
|
|
85
|
+
throw invalid(a.file, `Unknown subagent "${s}" in \`subagents\`.`);
|
|
72
86
|
if (sub.tier !== "sub") {
|
|
73
|
-
throw
|
|
87
|
+
throw invalid(a.file, `Subagent "${s}" must have \`tier: sub\`.`);
|
|
74
88
|
}
|
|
75
89
|
}
|
|
76
90
|
}
|
|
77
91
|
}
|
|
78
92
|
|
|
93
|
+
function invalid(file: string, why: string) {
|
|
94
|
+
return new UserError(`Invalid agent file ${file}`, {
|
|
95
|
+
why,
|
|
96
|
+
fix: "Fix the file, then run `kanbot sync`.",
|
|
97
|
+
docs: "agents#frontmatter",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
79
101
|
export function getAgent(agents: AgentDef[], name: string): AgentDef {
|
|
80
102
|
const a = agents.find((x) => x.name === name);
|
|
81
103
|
if (!a) throw new Error(`No agent named "${name}" in .kanbot/agents`);
|
package/src/agents/sync.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdir, readdir, rm, symlink } from "node:fs/promises";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { stringify } from "yaml";
|
|
5
5
|
import { configPath, loadConfig } from "../config.ts";
|
|
6
|
+
import { UserError } from "../errors.ts";
|
|
6
7
|
import type { AgentDef } from "./load.ts";
|
|
7
8
|
|
|
8
9
|
/*
|
|
@@ -205,9 +206,11 @@ async function linkSkills(root: string, check: boolean) {
|
|
|
205
206
|
const ok = existsSync(link) && lstatSync(link).isSymbolicLink();
|
|
206
207
|
if (ok || check) return ok;
|
|
207
208
|
if (existsSync(link)) {
|
|
208
|
-
throw new
|
|
209
|
-
".claude/skills exists and is not a symlink.
|
|
210
|
-
|
|
209
|
+
throw new UserError("Cannot link .claude/skills.", {
|
|
210
|
+
why: ".claude/skills exists and is not a symlink. kanbot keeps skills in .agents/skills and links to them.",
|
|
211
|
+
fix: "Move its skills into .agents/skills, delete .claude/skills, then rerun.",
|
|
212
|
+
docs: "skills#add-a-skill",
|
|
213
|
+
});
|
|
211
214
|
}
|
|
212
215
|
await mkdir(join(root, ".agents", "skills"), { recursive: true });
|
|
213
216
|
await mkdir(join(root, ".claude"), { recursive: true });
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { runMain } from "citty";
|
|
2
|
+
import { runCommand, runMain } from "citty";
|
|
3
|
+
import { formatError } from "./errors.ts";
|
|
3
4
|
import { main } from "./main.ts";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const usage =
|
|
8
|
+
!args.length ||
|
|
9
|
+
args.some((a) => a === "--help" || a === "-h") ||
|
|
10
|
+
(args.length === 1 && (args[0] === "--version" || args[0] === "-v"));
|
|
11
|
+
|
|
12
|
+
// citty prints usage for help, version and a bare `kanbot`.
|
|
13
|
+
if (usage) {
|
|
14
|
+
await runMain(main);
|
|
15
|
+
} else {
|
|
16
|
+
try {
|
|
17
|
+
await runCommand(main, { rawArgs: args });
|
|
18
|
+
} catch (e) {
|
|
19
|
+
// Leading positionals name the command, e.g. "board create".
|
|
20
|
+
const end = args.findIndex((a) => a.startsWith("-"));
|
|
21
|
+
const command = args.slice(0, end < 0 ? undefined : end).join(" ");
|
|
22
|
+
console.error(
|
|
23
|
+
formatError(e, { debug: !!process.env.KANBOT_DEBUG, command }),
|
|
24
|
+
);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { defineCommand } from "citty";
|
|
3
3
|
import { buildSpec, render, SHELLS, type Shell } from "../completion.ts";
|
|
4
|
+
import { UserError } from "../errors.ts";
|
|
4
5
|
|
|
5
6
|
export default defineCommand({
|
|
6
7
|
meta: {
|
|
@@ -17,7 +18,12 @@ export default defineCommand({
|
|
|
17
18
|
async run({ args }) {
|
|
18
19
|
const shell = args.shell ?? basename(process.env.SHELL ?? "");
|
|
19
20
|
if (!SHELLS.includes(shell as Shell))
|
|
20
|
-
throw new
|
|
21
|
+
throw new UserError("Could not detect your shell.", {
|
|
22
|
+
why: `$SHELL is "${shell}", which is not supported.`,
|
|
23
|
+
fix: `kanbot completion --shell ${SHELLS.join("|")}`,
|
|
24
|
+
docs: "getting-started#shell-completion",
|
|
25
|
+
more: "",
|
|
26
|
+
});
|
|
21
27
|
const { main } = await import("../main.ts");
|
|
22
28
|
process.stdout.write(render(shell as Shell, await buildSpec(main)));
|
|
23
29
|
},
|
package/src/commands/doctor.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { loadAgents } from "../agents/load.ts";
|
|
|
5
5
|
import { sync } from "../agents/sync.ts";
|
|
6
6
|
import { loadConfig, writeConfigSchema } from "../config.ts";
|
|
7
7
|
import { git } from "../daemon/git.ts";
|
|
8
|
+
import { describeError } from "../errors.ts";
|
|
8
9
|
import { copilotReviewRule } from "../github/api.ts";
|
|
9
10
|
import { Gh, run } from "../github/gh.ts";
|
|
10
11
|
import {
|
|
@@ -27,6 +28,16 @@ async function cmd(args: string[]) {
|
|
|
27
28
|
return { ok: code === 0, out: `${out}${err}`.trim() };
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/** Failure detail and fix for a check, from a thrown error. */
|
|
32
|
+
function explain(e: unknown, fallback?: string) {
|
|
33
|
+
const d = describeError(e);
|
|
34
|
+
const detail = [d.message, d.why].filter(Boolean).join("\n");
|
|
35
|
+
return {
|
|
36
|
+
detail: detail.replace(/\n/g, "\n "),
|
|
37
|
+
fix: d.fix ?? fallback,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
export default defineCommand({
|
|
31
42
|
meta: {
|
|
32
43
|
name: "doctor",
|
|
@@ -59,7 +70,7 @@ export default defineCommand({
|
|
|
59
70
|
detail: `${config.repo}, provider ${config.provider}`,
|
|
60
71
|
});
|
|
61
72
|
} catch (e) {
|
|
62
|
-
add({ name: "config", ok: false,
|
|
73
|
+
add({ name: "config", ok: false, ...explain(e, "kanbot init") });
|
|
63
74
|
return process.exit(1);
|
|
64
75
|
}
|
|
65
76
|
|
|
@@ -119,7 +130,7 @@ export default defineCommand({
|
|
|
119
130
|
fix: "kanbot sync",
|
|
120
131
|
});
|
|
121
132
|
} catch (e) {
|
|
122
|
-
add({ name: "agents", ok: false,
|
|
133
|
+
add({ name: "agents", ok: false, ...explain(e) });
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
const plan = await planUpgrade(root);
|
|
@@ -197,8 +208,7 @@ export default defineCommand({
|
|
|
197
208
|
add({
|
|
198
209
|
name: "project board",
|
|
199
210
|
ok: false,
|
|
200
|
-
|
|
201
|
-
fix: "kanbot board create",
|
|
211
|
+
...explain(e, "kanbot board create"),
|
|
202
212
|
});
|
|
203
213
|
}
|
|
204
214
|
|
package/src/commands/init.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { defineCommand } from "citty";
|
|
|
4
4
|
import { loadAgents } from "../agents/load.ts";
|
|
5
5
|
import { sync } from "../agents/sync.ts";
|
|
6
6
|
import { configPath, saveConfig, writeConfigSchema } from "../config.ts";
|
|
7
|
+
import { UserError } from "../errors.ts";
|
|
7
8
|
import { run } from "../github/gh.ts";
|
|
8
9
|
import { log } from "../log.ts";
|
|
9
10
|
import { applyUpgrade, NEW_SUFFIX, planUpgrade } from "../templates.ts";
|
|
@@ -32,9 +33,31 @@ export default defineCommand({
|
|
|
32
33
|
async run({ args }) {
|
|
33
34
|
const root = process.cwd();
|
|
34
35
|
if (!existsSync(join(root, ".git")))
|
|
35
|
-
throw new
|
|
36
|
+
throw new UserError("Not a git repository.", {
|
|
37
|
+
why: `No .git in ${root}. Run init from the repo root.`,
|
|
38
|
+
fix: "cd into your repo's root, or run `git init`.",
|
|
39
|
+
docs: "getting-started#set-up-a-repository",
|
|
40
|
+
more: "",
|
|
41
|
+
});
|
|
36
42
|
if (!["claude", "codex"].includes(args.provider))
|
|
37
|
-
throw new
|
|
43
|
+
throw new UserError(`Unknown provider "${args.provider}".`, {
|
|
44
|
+
fix: "kanbot init --provider claude|codex",
|
|
45
|
+
docs: "providers#choose-a-provider",
|
|
46
|
+
more: "",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Fail on gh or remote problems before writing anything.
|
|
50
|
+
const repo =
|
|
51
|
+
!existsSync(configPath(root)) || args.force
|
|
52
|
+
? (JSON.parse(
|
|
53
|
+
await run([
|
|
54
|
+
"repo",
|
|
55
|
+
"view",
|
|
56
|
+
"--json",
|
|
57
|
+
"nameWithOwner,defaultBranchRef",
|
|
58
|
+
]),
|
|
59
|
+
) as { nameWithOwner: string; defaultBranchRef: { name: string } })
|
|
60
|
+
: null;
|
|
38
61
|
|
|
39
62
|
const plan = await planUpgrade(root, { force: args.force });
|
|
40
63
|
await applyUpgrade(root, plan);
|
|
@@ -47,13 +70,7 @@ export default defineCommand({
|
|
|
47
70
|
}
|
|
48
71
|
log.info("+ .kanbot/manifest.json");
|
|
49
72
|
|
|
50
|
-
if (
|
|
51
|
-
const repo = JSON.parse(
|
|
52
|
-
await run(["repo", "view", "--json", "nameWithOwner,defaultBranchRef"]),
|
|
53
|
-
) as {
|
|
54
|
-
nameWithOwner: string;
|
|
55
|
-
defaultBranchRef: { name: string };
|
|
56
|
-
};
|
|
73
|
+
if (repo) {
|
|
57
74
|
await saveConfig(root, {
|
|
58
75
|
provider: args.provider as "claude" | "codex",
|
|
59
76
|
repo: repo.nameWithOwner,
|
package/src/commands/status.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { COLUMN_KEYS, LABELS, loadConfig } from "../config.ts";
|
|
5
5
|
import { State } from "../daemon/state.ts";
|
|
6
|
-
import { Gh } from "../github/gh.ts";
|
|
6
|
+
import { Gh, run } from "../github/gh.ts";
|
|
7
7
|
import { listItems, loadBoard } from "../github/project.ts";
|
|
8
8
|
|
|
9
9
|
export default defineCommand({
|
|
@@ -38,7 +38,16 @@ export default defineCommand({
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const needsHuman = JSON.parse(
|
|
41
|
-
await
|
|
41
|
+
await run([
|
|
42
|
+
"issue",
|
|
43
|
+
"list",
|
|
44
|
+
"--repo",
|
|
45
|
+
config.repo,
|
|
46
|
+
"--label",
|
|
47
|
+
LABELS.needsHuman,
|
|
48
|
+
"--json",
|
|
49
|
+
"number,title",
|
|
50
|
+
]),
|
|
42
51
|
) as { number: number; title: string }[];
|
|
43
52
|
if (needsHuman.length) {
|
|
44
53
|
console.log(
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { parse, parseDocument } from "yaml";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { UserError } from "./errors.ts";
|
|
5
6
|
|
|
6
7
|
export const COLUMN_KEYS = [
|
|
7
8
|
"ideas",
|
|
@@ -197,9 +198,35 @@ export async function writeConfigSchema(
|
|
|
197
198
|
export async function loadConfig(root: string): Promise<Config> {
|
|
198
199
|
const path = configPath(root);
|
|
199
200
|
if (!existsSync(path)) {
|
|
200
|
-
throw new
|
|
201
|
+
throw new UserError("kanbot is not set up in this repo.", {
|
|
202
|
+
why: "No .kanbot/config.yml in this directory.",
|
|
203
|
+
fix: "kanbot init",
|
|
204
|
+
docs: "getting-started#set-up-a-repository",
|
|
205
|
+
});
|
|
201
206
|
}
|
|
202
|
-
|
|
207
|
+
let raw: unknown;
|
|
208
|
+
try {
|
|
209
|
+
raw = parse(await Bun.file(path).text());
|
|
210
|
+
} catch (e) {
|
|
211
|
+
throw new UserError(
|
|
212
|
+
".kanbot/config.yml is not valid YAML.",
|
|
213
|
+
{
|
|
214
|
+
why: (e as Error).message.trim(),
|
|
215
|
+
fix: "Fix the syntax at the line shown above.",
|
|
216
|
+
docs: "configuration#config-file",
|
|
217
|
+
},
|
|
218
|
+
{ cause: e },
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const config = ConfigSchema.safeParse(raw ?? {});
|
|
222
|
+
if (!config.success) {
|
|
223
|
+
throw new UserError(".kanbot/config.yml has invalid values.", {
|
|
224
|
+
why: z.prettifyError(config.error),
|
|
225
|
+
fix: "Fix the fields above. .kanbot/config.schema.json lists allowed values.",
|
|
226
|
+
docs: "configuration#config-file",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return config.data;
|
|
203
230
|
}
|
|
204
231
|
|
|
205
232
|
/** Sets top-level keys, keeping the user's comments and formatting. */
|
package/src/daemon/lock.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, rmSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { UserError } from "../errors.ts";
|
|
3
4
|
|
|
4
5
|
/** One daemon per repo. Stale locks (dead pid) are taken over. */
|
|
5
6
|
export async function acquireLock(root: string): Promise<() => void> {
|
|
@@ -7,7 +8,11 @@ export async function acquireLock(root: string): Promise<() => void> {
|
|
|
7
8
|
if (existsSync(file)) {
|
|
8
9
|
const pid = Number(await Bun.file(file).text());
|
|
9
10
|
if (pid && isAlive(pid)) {
|
|
10
|
-
throw new
|
|
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
|
+
});
|
|
11
16
|
}
|
|
12
17
|
}
|
|
13
18
|
await Bun.write(file, String(process.pid));
|
package/src/daemon/loop.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadAgents } from "../agents/load.ts";
|
|
2
2
|
import { sync } from "../agents/sync.ts";
|
|
3
3
|
import { type Config, loadConfig } from "../config.ts";
|
|
4
|
+
import { UserError } from "../errors.ts";
|
|
4
5
|
import { Gh } from "../github/gh.ts";
|
|
5
6
|
import { loadBoard, missingColumns } from "../github/project.ts";
|
|
6
7
|
import { Trust } from "../github/trust.ts";
|
|
@@ -45,7 +46,11 @@ export async function runDaemon(root: string, opts: RunOptions) {
|
|
|
45
46
|
const board = await loadBoard(gh, config);
|
|
46
47
|
const missing = missingColumns(board);
|
|
47
48
|
if (missing.length)
|
|
48
|
-
throw new
|
|
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
|
+
});
|
|
49
54
|
|
|
50
55
|
const release = opts.dryRun ? () => {} : await acquireLock(root);
|
|
51
56
|
const state = await new State(root).load();
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export const DOCS_URL = "https://kanbot.mib.im";
|
|
2
|
+
const ISSUES_URL = "https://github.com/mikaelbr/kanbot/issues";
|
|
3
|
+
|
|
4
|
+
export type ErrorHelp = {
|
|
5
|
+
/** Why it failed: the underlying error or the rule that was broken. */
|
|
6
|
+
why?: string;
|
|
7
|
+
/** What to do about it, usually a command. */
|
|
8
|
+
fix?: string;
|
|
9
|
+
/** Docs page and anchor, e.g. "configuration#config-file". */
|
|
10
|
+
docs?: string;
|
|
11
|
+
/** How to learn more. Defaults to `kanbot doctor`; "" hides it. */
|
|
12
|
+
more?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** An expected failure. Printed without a stack trace. */
|
|
16
|
+
export class UserError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
message: string,
|
|
19
|
+
readonly help: ErrorHelp = {},
|
|
20
|
+
options?: ErrorOptions,
|
|
21
|
+
) {
|
|
22
|
+
super(message, options);
|
|
23
|
+
this.name = "UserError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const INSTALL: Record<string, ErrorHelp> = {
|
|
28
|
+
gh: {
|
|
29
|
+
fix: "Install the GitHub CLI from https://cli.github.com, then run `gh auth login`.",
|
|
30
|
+
docs: "getting-started#requirements",
|
|
31
|
+
},
|
|
32
|
+
git: {
|
|
33
|
+
fix: "Install git from https://git-scm.com/downloads.",
|
|
34
|
+
docs: "getting-started#requirements",
|
|
35
|
+
},
|
|
36
|
+
claude: {
|
|
37
|
+
fix: "Install Claude Code, then run `claude auth login`.",
|
|
38
|
+
docs: "providers#claude-code",
|
|
39
|
+
},
|
|
40
|
+
codex: {
|
|
41
|
+
fix: "Install Codex (npm i -g @openai/codex), then run `codex login`.",
|
|
42
|
+
docs: "providers#codex",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Known failures from gh, git and the GitHub API, matched on message. */
|
|
47
|
+
const KNOWN: [RegExp, string, ErrorHelp][] = [
|
|
48
|
+
[
|
|
49
|
+
/gh auth login|not logged in|HTTP 401|Bad credentials/i,
|
|
50
|
+
"GitHub CLI is not logged in.",
|
|
51
|
+
{ fix: "gh auth login", docs: "getting-started#requirements" },
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
/INSUFFICIENT_SCOPES|required scopes|scope.*project/i,
|
|
55
|
+
"GitHub token is missing the `project` scope.",
|
|
56
|
+
{ fix: "gh auth refresh -s project", docs: "getting-started#requirements" },
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
/rate limit/i,
|
|
60
|
+
"GitHub API rate limit reached.",
|
|
61
|
+
{ fix: "Wait a few minutes and try again." },
|
|
62
|
+
],
|
|
63
|
+
[
|
|
64
|
+
/Could not resolve to a ProjectV2/i,
|
|
65
|
+
"Project board not found.",
|
|
66
|
+
{
|
|
67
|
+
fix: "Check `project` in .kanbot/config.yml, or run `kanbot board create`.",
|
|
68
|
+
docs: "getting-started#create-the-board",
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
[
|
|
72
|
+
/Could not resolve to a Repository|HTTP 404/i,
|
|
73
|
+
"GitHub repository not found, or gh has no access to it.",
|
|
74
|
+
{
|
|
75
|
+
fix: "Check `repo` in .kanbot/config.yml and run `gh repo view <owner/name>`.",
|
|
76
|
+
docs: "configuration#config-file",
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
/not a git repository/i,
|
|
81
|
+
"Not inside a git repository.",
|
|
82
|
+
{ fix: "cd into your repo, or run `git init`." },
|
|
83
|
+
],
|
|
84
|
+
[
|
|
85
|
+
/no git remotes|none of the git remotes/i,
|
|
86
|
+
"This repo has no GitHub remote.",
|
|
87
|
+
{
|
|
88
|
+
fix: "git remote add origin <url> && git push -u origin HEAD",
|
|
89
|
+
docs: "getting-started#set-up-a-repository",
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
[
|
|
93
|
+
/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|dial tcp|error connecting/i,
|
|
94
|
+
"Could not reach GitHub.",
|
|
95
|
+
{ fix: "Check your network connection and try again." },
|
|
96
|
+
],
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
export type Described = ErrorHelp & { message: string; expected: boolean };
|
|
100
|
+
|
|
101
|
+
export function describeError(e: unknown, command = ""): Described {
|
|
102
|
+
if (e instanceof UserError)
|
|
103
|
+
return { message: e.message, ...e.help, expected: true };
|
|
104
|
+
if (!(e instanceof Error)) return { message: String(e), expected: false };
|
|
105
|
+
const raw = e.message.trim();
|
|
106
|
+
|
|
107
|
+
// citty argument errors: unknown command, bad flag value, …
|
|
108
|
+
if (e.name === "CLIError") {
|
|
109
|
+
const unknown = (e as { code?: string }).code === "E_UNKNOWN_COMMAND";
|
|
110
|
+
return {
|
|
111
|
+
message: Bun.stripANSI(raw),
|
|
112
|
+
fix: `Run \`kanbot ${unknown ? "" : `${command} `}--help\` to see valid options.`,
|
|
113
|
+
docs: "usage",
|
|
114
|
+
more: "",
|
|
115
|
+
expected: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const bin = raw.match(/Executable not found in \$PATH: "(.+)"/)?.[1];
|
|
120
|
+
if (bin)
|
|
121
|
+
return {
|
|
122
|
+
message: `\`${bin}\` is not installed or not on PATH.`,
|
|
123
|
+
why: raw,
|
|
124
|
+
...INSTALL[bin],
|
|
125
|
+
expected: true,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const known = KNOWN.find(([re]) => re.test(raw));
|
|
129
|
+
if (known)
|
|
130
|
+
return { message: known[1], why: raw, ...known[2], expected: true };
|
|
131
|
+
const tool = raw.match(/^(gh|git) .* failed/)?.[1];
|
|
132
|
+
if (tool)
|
|
133
|
+
return {
|
|
134
|
+
message: `A \`${tool}\` command failed.`,
|
|
135
|
+
why: raw,
|
|
136
|
+
fix:
|
|
137
|
+
tool === "gh"
|
|
138
|
+
? "Read the gh error above. `gh auth status` shows login and scopes."
|
|
139
|
+
: "Read the git error above. `git status` shows the repo state.",
|
|
140
|
+
expected: true,
|
|
141
|
+
};
|
|
142
|
+
return { message: raw, expected: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const indent = (s: string) => s.replace(/^(?=.)/gm, " ");
|
|
146
|
+
|
|
147
|
+
export function formatError(
|
|
148
|
+
e: unknown,
|
|
149
|
+
opts: { debug?: boolean; command?: string } = {},
|
|
150
|
+
): string {
|
|
151
|
+
const d = describeError(e, opts.command);
|
|
152
|
+
const lines = [`✖ ${d.message}`];
|
|
153
|
+
if (d.why) lines.push("", indent(d.why));
|
|
154
|
+
lines.push("");
|
|
155
|
+
if (d.fix) lines.push(` Fix: ${d.fix}`);
|
|
156
|
+
if (d.docs) lines.push(` Docs: ${DOCS_URL}/${d.docs}`);
|
|
157
|
+
if (opts.debug && e instanceof Error && e.stack) {
|
|
158
|
+
lines.push("", e.stack);
|
|
159
|
+
if (e.cause instanceof Error && e.cause.stack)
|
|
160
|
+
lines.push("", `Caused by: ${e.cause.stack}`);
|
|
161
|
+
} else if (d.expected) {
|
|
162
|
+
const more = d.more ?? "Run `kanbot doctor` to check your setup.";
|
|
163
|
+
if (more) lines.push(` More: ${more}`);
|
|
164
|
+
} else {
|
|
165
|
+
lines.push(
|
|
166
|
+
" More: rerun with KANBOT_DEBUG=1 for a stack trace.",
|
|
167
|
+
` If it looks like a bug, report it at ${ISSUES_URL}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
package/src/github/project.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { COLUMN_KEYS, type ColumnKey, type Config } from "../config.ts";
|
|
2
|
+
import { UserError } from "../errors.ts";
|
|
2
3
|
import type { Gh } from "./gh.ts";
|
|
3
4
|
|
|
4
5
|
export type Board = {
|
|
@@ -60,7 +61,11 @@ function toBoard(p: RawProject, config: Config): Board {
|
|
|
60
61
|
|
|
61
62
|
export async function loadBoard(gh: Gh, config: Config): Promise<Board> {
|
|
62
63
|
if (!config.project) {
|
|
63
|
-
throw new
|
|
64
|
+
throw new UserError("No project board configured.", {
|
|
65
|
+
why: "`project` is not set in .kanbot/config.yml.",
|
|
66
|
+
fix: "kanbot board create (or kanbot board link --owner <login> --number <n>)",
|
|
67
|
+
docs: "getting-started#create-the-board",
|
|
68
|
+
});
|
|
64
69
|
}
|
|
65
70
|
const data = await gh.query<{
|
|
66
71
|
repositoryOwner: { projectV2: RawProject | null } | null;
|
|
@@ -74,8 +79,13 @@ export async function loadBoard(gh: Gh, config: Config): Promise<Board> {
|
|
|
74
79
|
);
|
|
75
80
|
const p = data.repositoryOwner?.projectV2;
|
|
76
81
|
if (!p) {
|
|
77
|
-
throw new
|
|
78
|
-
`Project ${config.project.owner}#${config.project.number} not found
|
|
82
|
+
throw new UserError(
|
|
83
|
+
`Project ${config.project.owner}#${config.project.number} not found.`,
|
|
84
|
+
{
|
|
85
|
+
why: "It does not exist, or your gh token lacks the `project` scope.",
|
|
86
|
+
fix: "gh auth refresh -s project, then check `project` in .kanbot/config.yml",
|
|
87
|
+
docs: "getting-started#create-the-board",
|
|
88
|
+
},
|
|
79
89
|
);
|
|
80
90
|
}
|
|
81
91
|
return toBoard(p, config);
|