kanbot 0.2.0 → 0.4.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 +15 -9
- package/package.json +1 -1
- package/schema/config.schema.json +92 -8
- package/src/agents/load.ts +11 -0
- package/src/agents/sync.ts +8 -8
- package/src/capabilities.ts +157 -0
- package/src/commands/app.ts +169 -0
- package/src/commands/board.ts +14 -7
- package/src/commands/doctor.ts +115 -6
- package/src/commands/init.ts +15 -3
- package/src/commands/skills.ts +1 -1
- package/src/commands/sync.ts +1 -1
- package/src/commands/upgrade.ts +20 -1
- package/src/config.ts +72 -8
- package/src/daemon/derive.ts +79 -1
- package/src/daemon/loop.ts +10 -1
- package/src/daemon/snapshot.ts +1 -0
- package/src/daemon/types.ts +17 -0
- package/src/github/api.ts +47 -2
- package/src/github/app.ts +153 -0
- package/src/github/gh.ts +29 -3
- package/src/jobs/prompts.ts +30 -6
- package/src/jobs/run.ts +135 -56
- package/src/known-hashes.json +47 -57
- package/src/main.ts +1 -0
- package/src/providers/claude.ts +40 -30
- package/src/providers/codex.ts +60 -30
- package/src/providers/mcp.ts +71 -0
- package/src/schemas/results.ts +2 -5
- package/src/templates.ts +1 -0
- package/templates/.agents/skills/approach-designer/SKILL.md +1 -1
- package/templates/.agents/skills/kanbot-admin/SKILL.md +39 -7
- package/templates/.agents/skills/kanbot-guide/SKILL.md +110 -0
- package/templates/.agents/skills/kanbot-idea/SKILL.md +1 -1
- package/templates/.agents/skills/plan-template/SKILL.md +14 -6
- package/templates/.agents/skills/pr-description/SKILL.md +7 -3
- package/templates/.agents/skills/project-conventions/SKILL.md +23 -35
- package/templates/.agents/skills/review-findings/SKILL.md +47 -0
- package/templates/.agents/skills/spec-review/SKILL.md +10 -27
- package/templates/.agents/skills/user-stories/SKILL.md +1 -1
- package/templates/.github/pull_request_template.md +6 -0
- package/templates/.kanbot/agents/acceptance-reviewer.md +43 -0
- package/templates/.kanbot/agents/fixer.md +7 -5
- package/templates/.kanbot/agents/implementer.md +49 -0
- package/templates/.kanbot/agents/plan-critic.md +3 -3
- package/templates/.kanbot/agents/planner.md +10 -9
- package/templates/.kanbot/agents/reviewer.md +42 -0
- package/templates/AGENTS.md +3 -4
- package/templates/.agents/skills/accessibility/SKILL.md +0 -76
- package/templates/.agents/skills/code-review/SKILL.md +0 -83
- package/templates/.agents/skills/frontend-design/LICENSE.txt +0 -177
- package/templates/.agents/skills/frontend-design/SKILL.md +0 -76
- package/templates/.agents/skills/tdd/SKILL.md +0 -51
- package/templates/.agents/skills/verify-ui/SKILL.md +0 -104
- package/templates/.kanbot/agents/implementer-backend.md +0 -57
- package/templates/.kanbot/agents/implementer-docs.md +0 -55
- package/templates/.kanbot/agents/implementer-frontend.md +0 -54
- package/templates/.kanbot/agents/implementer-technical.md +0 -53
- package/templates/.kanbot/agents/reviewer-code.md +0 -46
- package/templates/.kanbot/agents/reviewer-spec.md +0 -49
- package/templates/.kanbot/agents/test-writer.md +0 -35
- package/templates/.kanbot/agents/ui-verifier.md +0 -35
package/src/commands/doctor.ts
CHANGED
|
@@ -1,22 +1,36 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
|
-
import { loadAgents } from "../agents/load.ts";
|
|
4
|
+
import { loadAgents, missingSkills } from "../agents/load.ts";
|
|
5
5
|
import { sync } from "../agents/sync.ts";
|
|
6
|
+
import { capabilitySummary, findCapability } from "../capabilities.ts";
|
|
6
7
|
import { loadConfig, writeConfigSchema } from "../config.ts";
|
|
7
8
|
import { git } from "../daemon/git.ts";
|
|
8
9
|
import { describeError } from "../errors.ts";
|
|
9
10
|
import { copilotReviewRule } from "../github/api.ts";
|
|
11
|
+
import { appKeyPath, appTokens } from "../github/app.ts";
|
|
10
12
|
import { Gh, run } from "../github/gh.ts";
|
|
11
13
|
import {
|
|
12
14
|
boardWorkflows,
|
|
13
15
|
loadBoard,
|
|
14
16
|
missingColumns,
|
|
15
17
|
} from "../github/project.ts";
|
|
18
|
+
import {
|
|
19
|
+
codexSupports,
|
|
20
|
+
type McpServer,
|
|
21
|
+
readMcpServers,
|
|
22
|
+
} from "../providers/mcp.ts";
|
|
16
23
|
import { NEW_SUFFIX, pendingMerges, planUpgrade } from "../templates.ts";
|
|
17
24
|
import { REPO_LABELS } from "./board.ts";
|
|
18
25
|
|
|
19
|
-
type Check = {
|
|
26
|
+
type Check = {
|
|
27
|
+
name: string;
|
|
28
|
+
ok: boolean;
|
|
29
|
+
/** Shown with a warning mark; never fails doctor. */
|
|
30
|
+
warn?: boolean;
|
|
31
|
+
detail?: string;
|
|
32
|
+
fix?: string;
|
|
33
|
+
};
|
|
20
34
|
|
|
21
35
|
async function cmd(args: string[]) {
|
|
22
36
|
const p = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
@@ -49,7 +63,7 @@ export default defineCommand({
|
|
|
49
63
|
const add = (c: Check) => {
|
|
50
64
|
checks.push(c);
|
|
51
65
|
console.log(
|
|
52
|
-
`${c.ok ? "✔" : "✖"} ${c.name}${c.detail ? ` — ${c.detail}` : ""}${!c.ok && c.fix ? `\n fix: ${c.fix}` : ""}`,
|
|
66
|
+
`${c.warn ? "!" : c.ok ? "✔" : "✖"} ${c.name}${c.detail ? ` — ${c.detail}` : ""}${!c.ok && c.fix ? `\n fix: ${c.fix}` : ""}`,
|
|
53
67
|
);
|
|
54
68
|
};
|
|
55
69
|
|
|
@@ -93,6 +107,12 @@ export default defineCommand({
|
|
|
93
107
|
detail: s.out.split("\n")[0],
|
|
94
108
|
fix: "codex login",
|
|
95
109
|
});
|
|
110
|
+
add({
|
|
111
|
+
name: "codex workers ignore ~/.codex/config.toml",
|
|
112
|
+
ok: true,
|
|
113
|
+
detail:
|
|
114
|
+
"models come from agent frontmatter, MCP servers from .mcp.json",
|
|
115
|
+
});
|
|
96
116
|
}
|
|
97
117
|
|
|
98
118
|
try {
|
|
@@ -104,10 +124,11 @@ export default defineCommand({
|
|
|
104
124
|
});
|
|
105
125
|
const needed = [
|
|
106
126
|
"planner",
|
|
107
|
-
"reviewer
|
|
108
|
-
"reviewer
|
|
127
|
+
"reviewer",
|
|
128
|
+
"acceptance-reviewer",
|
|
109
129
|
"fixer",
|
|
110
|
-
|
|
130
|
+
"implementer",
|
|
131
|
+
...Object.values(config.implementers ?? {}),
|
|
111
132
|
];
|
|
112
133
|
const missing = [...new Set(needed)].filter(
|
|
113
134
|
(n) => !agents.some((a) => a.name === n),
|
|
@@ -116,7 +137,43 @@ export default defineCommand({
|
|
|
116
137
|
name: "agents referenced by config exist",
|
|
117
138
|
ok: !missing.length,
|
|
118
139
|
detail: missing.join(", ") || undefined,
|
|
140
|
+
fix: "kanbot upgrade",
|
|
141
|
+
});
|
|
142
|
+
if (config.implementers)
|
|
143
|
+
add({
|
|
144
|
+
name: "implementers is deprecated",
|
|
145
|
+
ok: true,
|
|
146
|
+
warn: true,
|
|
147
|
+
detail:
|
|
148
|
+
"implement uses `implementer`; map craft with capabilities instead",
|
|
149
|
+
fix: "remove implementers from .kanbot/config.yml",
|
|
150
|
+
});
|
|
151
|
+
const noSkill = missingSkills(root, agents);
|
|
152
|
+
add({
|
|
153
|
+
name: "skills referenced by agents exist",
|
|
154
|
+
ok: !noSkill.length,
|
|
155
|
+
detail: noSkill.join(", ") || undefined,
|
|
156
|
+
fix: "add them to .agents/skills or remove them from the agent's `skills`",
|
|
157
|
+
});
|
|
158
|
+
add({
|
|
159
|
+
name: "capabilities",
|
|
160
|
+
ok: true,
|
|
161
|
+
detail: capabilitySummary(config.capabilities),
|
|
119
162
|
});
|
|
163
|
+
// Only problems get their own line.
|
|
164
|
+
for (const [name, c] of Object.entries(config.capabilities)) {
|
|
165
|
+
const found = findCapability(root, agents, c.use);
|
|
166
|
+
if (found === "skill" || found === "agent") continue;
|
|
167
|
+
add({
|
|
168
|
+
name: `capability ${name} → ${c.use}`,
|
|
169
|
+
ok: found !== null,
|
|
170
|
+
warn: found !== null,
|
|
171
|
+
detail: found
|
|
172
|
+
? `${found} agent, works on ${found} only`
|
|
173
|
+
: "not found",
|
|
174
|
+
fix: "add the skill to .agents/skills or the agent to .kanbot/agents",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
120
177
|
const stale = await sync(root, agents, { check: true });
|
|
121
178
|
add({
|
|
122
179
|
name: "generated agent files up to date",
|
|
@@ -133,6 +190,24 @@ export default defineCommand({
|
|
|
133
190
|
add({ name: "agents", ok: false, ...explain(e) });
|
|
134
191
|
}
|
|
135
192
|
|
|
193
|
+
const servers = await readMcpServers(root);
|
|
194
|
+
const names = Object.keys(servers);
|
|
195
|
+
const skipped =
|
|
196
|
+
config.provider === "codex"
|
|
197
|
+
? names.filter((n) => !codexSupports(servers[n] as McpServer))
|
|
198
|
+
: [];
|
|
199
|
+
add({
|
|
200
|
+
name: "MCP servers for workers (.mcp.json)",
|
|
201
|
+
ok: true,
|
|
202
|
+
warn: skipped.length > 0,
|
|
203
|
+
detail: [
|
|
204
|
+
names.join(", ") || "none",
|
|
205
|
+
skipped.length ? `codex can't run: ${skipped.join(", ")}` : "",
|
|
206
|
+
]
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.join("; "),
|
|
209
|
+
});
|
|
210
|
+
|
|
136
211
|
const plan = await planUpgrade(root);
|
|
137
212
|
const behind = plan.changes.filter((c) =>
|
|
138
213
|
["add", "update", "conflict"].includes(c.action),
|
|
@@ -236,6 +311,40 @@ export default defineCommand({
|
|
|
236
311
|
fix: "kanbot board create",
|
|
237
312
|
});
|
|
238
313
|
|
|
314
|
+
const { mode, app } = config.identity;
|
|
315
|
+
if (mode === "app") {
|
|
316
|
+
add({
|
|
317
|
+
name: "kanbot app set",
|
|
318
|
+
ok: app !== null,
|
|
319
|
+
detail: app ? `${app.slug} (id ${app.id})` : undefined,
|
|
320
|
+
fix: "kanbot app create",
|
|
321
|
+
});
|
|
322
|
+
if (app) {
|
|
323
|
+
const key = appKeyPath(app.id);
|
|
324
|
+
add({
|
|
325
|
+
name: "kanbot app private key",
|
|
326
|
+
ok: existsSync(key),
|
|
327
|
+
detail: key,
|
|
328
|
+
fix: "kanbot app create, or copy the key from the machine that created the app",
|
|
329
|
+
});
|
|
330
|
+
if (existsSync(key)) {
|
|
331
|
+
try {
|
|
332
|
+
await appTokens(app, config.repo)();
|
|
333
|
+
add({ name: `kanbot app installed on ${config.repo}`, ok: true });
|
|
334
|
+
} catch (e) {
|
|
335
|
+
add({
|
|
336
|
+
name: `kanbot app installed on ${config.repo}`,
|
|
337
|
+
ok: false,
|
|
338
|
+
...explain(
|
|
339
|
+
e,
|
|
340
|
+
`https://github.com/apps/${app.slug}/installations/new`,
|
|
341
|
+
),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
239
348
|
if (config.review.copilot) {
|
|
240
349
|
const rule = await copilotReviewRule(gh, config.baseBranch).catch(
|
|
241
350
|
() => null,
|
package/src/commands/init.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { loadAgents } from "../agents/load.ts";
|
|
5
5
|
import { sync } from "../agents/sync.ts";
|
|
6
|
+
import { askCapabilities, compact, ownWorkers } from "../capabilities.ts";
|
|
6
7
|
import { configPath, saveConfig, writeConfigSchema } from "../config.ts";
|
|
7
8
|
import { UserError } from "../errors.ts";
|
|
8
9
|
import { run } from "../github/gh.ts";
|
|
@@ -79,6 +80,16 @@ export default defineCommand({
|
|
|
79
80
|
log.info(
|
|
80
81
|
`+ .kanbot/config.yml (${repo.nameWithOwner}, base ${repo.defaultBranchRef.name})`,
|
|
81
82
|
);
|
|
83
|
+
// Only ask when someone can answer.
|
|
84
|
+
if (process.stdin.isTTY) {
|
|
85
|
+
const caps = askCapabilities(await ownWorkers(root), prompt);
|
|
86
|
+
if (Object.keys(caps).length) {
|
|
87
|
+
await saveConfig(root, { capabilities: compact(caps) } as never);
|
|
88
|
+
log.info(
|
|
89
|
+
`~ .kanbot/config.yml (capabilities: ${Object.keys(caps).join(", ")})`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
const codeowners = join(root, ".github", "CODEOWNERS");
|
|
@@ -101,10 +112,11 @@ export default defineCommand({
|
|
|
101
112
|
Next steps:
|
|
102
113
|
1. Review AGENTS.md and fill in .agents/skills/project-conventions/SKILL.md
|
|
103
114
|
2. Adjust .github/workflows/ci.yml to your stack (agents fix failing CI)
|
|
104
|
-
3.
|
|
105
|
-
4. kanbot
|
|
115
|
+
3. Map your own skills under capabilities in .kanbot/config.yml (kanbot-guide can help)
|
|
116
|
+
4. kanbot board create # board and labels; enable the workflows it prints
|
|
106
117
|
5. Commit and push these files to the default branch (agents work from it)
|
|
107
|
-
6. kanbot
|
|
118
|
+
6. kanbot doctor # checks auth, board, generated files
|
|
119
|
+
7. kanbot run # start the loop (or kanbot run --once --dry-run)`);
|
|
108
120
|
},
|
|
109
121
|
});
|
|
110
122
|
|
package/src/commands/skills.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { log } from "../log.ts";
|
|
|
6
6
|
import { listTemplates } from "../templates.ts";
|
|
7
7
|
|
|
8
8
|
/** Skills that work outside a kanbot repo, so they can be installed per user. */
|
|
9
|
-
export const GLOBAL_SKILLS = ["kanbot-idea"];
|
|
9
|
+
export const GLOBAL_SKILLS = ["kanbot-idea", "kanbot-guide"];
|
|
10
10
|
|
|
11
11
|
/** User-level skill folders: Claude Code reads the first, Codex the second. */
|
|
12
12
|
export const globalSkillDirs = (home = homedir()) => ({
|
package/src/commands/sync.ts
CHANGED
|
@@ -8,7 +8,7 @@ export default defineCommand({
|
|
|
8
8
|
meta: {
|
|
9
9
|
name: "sync",
|
|
10
10
|
description:
|
|
11
|
-
"Generate .claude/agents, .codex/agents, permissions and the config schema",
|
|
11
|
+
"Generate .claude/agents, .codex/agents, permissions and the config schema from .kanbot/agents",
|
|
12
12
|
},
|
|
13
13
|
args: {
|
|
14
14
|
check: {
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { defineCommand } from "citty";
|
|
2
3
|
import pkg from "../../package.json" with { type: "json" };
|
|
3
4
|
import { loadAgents } from "../agents/load.ts";
|
|
4
5
|
import { sync } from "../agents/sync.ts";
|
|
5
|
-
import {
|
|
6
|
+
import { compact, legacyCapabilities } from "../capabilities.ts";
|
|
7
|
+
import {
|
|
8
|
+
configPath,
|
|
9
|
+
loadConfig,
|
|
10
|
+
saveConfig,
|
|
11
|
+
writeConfigSchema,
|
|
12
|
+
} from "../config.ts";
|
|
6
13
|
import { log } from "../log.ts";
|
|
7
14
|
import {
|
|
8
15
|
applyUpgrade,
|
|
@@ -48,9 +55,21 @@ export default defineCommand({
|
|
|
48
55
|
);
|
|
49
56
|
}
|
|
50
57
|
if (!plan.changes.length) log.info("Templates are up to date.");
|
|
58
|
+
const caps = existsSync(configPath(root))
|
|
59
|
+
? legacyCapabilities(root, (await loadConfig(root)).capabilities)
|
|
60
|
+
: null;
|
|
61
|
+
if (caps)
|
|
62
|
+
console.log(
|
|
63
|
+
`\nMapped skills you have to capabilities in .kanbot/config.yml:\n${Object.entries(
|
|
64
|
+
caps,
|
|
65
|
+
)
|
|
66
|
+
.map(([n, c]) => ` ${n}: ${c.use}`)
|
|
67
|
+
.join("\n")}`,
|
|
68
|
+
);
|
|
51
69
|
if (args["dry-run"]) return;
|
|
52
70
|
|
|
53
71
|
await applyUpgrade(root, plan);
|
|
72
|
+
if (caps) await saveConfig(root, { capabilities: compact(caps) } as never);
|
|
54
73
|
const generated = await sync(root, await loadAgents(root));
|
|
55
74
|
if (await writeConfigSchema(root))
|
|
56
75
|
generated.push(".kanbot/config.schema.json");
|
package/src/config.ts
CHANGED
|
@@ -17,6 +17,36 @@ export type ColumnKey = (typeof COLUMN_KEYS)[number];
|
|
|
17
17
|
export const JOB_KINDS = ["fix", "review", "plan", "implement"] as const;
|
|
18
18
|
export type JobKind = (typeof JOB_KINDS)[number];
|
|
19
19
|
|
|
20
|
+
export const RESERVED_CAPABILITIES = ["verify", "review", "preview"] as const;
|
|
21
|
+
|
|
22
|
+
const CapabilityObject = z.object({
|
|
23
|
+
use: z
|
|
24
|
+
.string()
|
|
25
|
+
.min(1)
|
|
26
|
+
.describe(
|
|
27
|
+
"Skill in .agents/skills or agent in .kanbot/agents that does the work.",
|
|
28
|
+
),
|
|
29
|
+
when: z
|
|
30
|
+
.string()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("When it applies, e.g. `Changes to UI users see`."),
|
|
33
|
+
required: z
|
|
34
|
+
.boolean()
|
|
35
|
+
.default(false)
|
|
36
|
+
.describe("A miss when it applies blocks acceptance review."),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** `verify: verify-ui` is short for `verify: { use: verify-ui }`. */
|
|
40
|
+
export const Capability = z.union([
|
|
41
|
+
z
|
|
42
|
+
.string()
|
|
43
|
+
.min(1)
|
|
44
|
+
.describe("Skill or agent name, short for `{ use: <name> }`.")
|
|
45
|
+
.transform((use) => CapabilityObject.parse({ use })),
|
|
46
|
+
CapabilityObject,
|
|
47
|
+
]);
|
|
48
|
+
export type Capability = z.output<typeof Capability>;
|
|
49
|
+
|
|
20
50
|
export const ConfigSchema = z.object({
|
|
21
51
|
provider: z
|
|
22
52
|
.enum(["claude", "codex"])
|
|
@@ -78,15 +108,15 @@ export const ConfigSchema = z.object({
|
|
|
78
108
|
.describe("Per-job timeout in minutes."),
|
|
79
109
|
implementers: z
|
|
80
110
|
.record(z.string(), z.string())
|
|
81
|
-
.
|
|
82
|
-
frontend: "implementer-frontend",
|
|
83
|
-
backend: "implementer-backend",
|
|
84
|
-
docs: "implementer-docs",
|
|
85
|
-
infra: "implementer-technical",
|
|
86
|
-
default: "implementer-backend",
|
|
87
|
-
})
|
|
111
|
+
.optional()
|
|
88
112
|
.describe(
|
|
89
|
-
"
|
|
113
|
+
"Deprecated: implement always uses `implementer`. While set, maps a type:<key> label to an agent, with `default` as the fallback. Use capabilities instead.",
|
|
114
|
+
),
|
|
115
|
+
capabilities: z
|
|
116
|
+
.record(z.string().regex(/^[a-z][a-z0-9-]*$/), Capability)
|
|
117
|
+
.default({})
|
|
118
|
+
.describe(
|
|
119
|
+
"Needs mapped to the repo's own skills or agents. Reserved: verify (prove acceptance criteria), review (technical review), preview (something a human can try). Other names are yours.",
|
|
90
120
|
),
|
|
91
121
|
review: z
|
|
92
122
|
.object({
|
|
@@ -112,6 +142,40 @@ export const ConfigSchema = z.object({
|
|
|
112
142
|
),
|
|
113
143
|
})
|
|
114
144
|
.prefault({}),
|
|
145
|
+
identity: z
|
|
146
|
+
.object({
|
|
147
|
+
mode: z
|
|
148
|
+
.enum(["user", "app"])
|
|
149
|
+
.default("user")
|
|
150
|
+
.describe(
|
|
151
|
+
"Who kanbot talks as. `user`: your account. `app`: a kanbot GitHub App, so its comments and PRs show as a bot. Commits are always yours.",
|
|
152
|
+
),
|
|
153
|
+
app: z
|
|
154
|
+
.object({
|
|
155
|
+
id: z.number().int().describe("GitHub App ID."),
|
|
156
|
+
slug: z.string().describe("App slug, as in github.com/apps/<slug>."),
|
|
157
|
+
})
|
|
158
|
+
.nullable()
|
|
159
|
+
.default(null)
|
|
160
|
+
.describe(
|
|
161
|
+
"Set by `kanbot app create`. The private key lives in ~/.kanbot/apps/<id>.pem.",
|
|
162
|
+
),
|
|
163
|
+
})
|
|
164
|
+
.prefault({}),
|
|
165
|
+
merge: z
|
|
166
|
+
.object({
|
|
167
|
+
onApproval: z
|
|
168
|
+
.boolean()
|
|
169
|
+
.default(false)
|
|
170
|
+
.describe(
|
|
171
|
+
"Merge a kanbot PR when a collaborator approves it, checks pass and agent review is done. Merges as you. Needs identity.mode app to approve your own kanbot PRs.",
|
|
172
|
+
),
|
|
173
|
+
method: z
|
|
174
|
+
.enum(["squash", "merge", "rebase"])
|
|
175
|
+
.default("squash")
|
|
176
|
+
.describe("Merge method used for approved PRs."),
|
|
177
|
+
})
|
|
178
|
+
.prefault({}),
|
|
115
179
|
codex: z
|
|
116
180
|
.object({
|
|
117
181
|
networkAccess: z
|
package/src/daemon/derive.ts
CHANGED
|
@@ -73,8 +73,10 @@ export type Action =
|
|
|
73
73
|
| { kind: "move"; item: Item; to: ColumnKey; why: string }
|
|
74
74
|
| { kind: "label"; number: number; add: string[]; remove: string[] }
|
|
75
75
|
| { kind: "agentReviewed"; item: Item; pr: PR }
|
|
76
|
+
| { kind: "merge"; item: Item; pr: PR; by: string }
|
|
76
77
|
| { kind: "compare"; item: Item; prs: PR[] }
|
|
77
78
|
| { kind: "needsHuman"; number: number; why: string }
|
|
79
|
+
| { kind: "openQuestions"; item: Item; questions: string[] }
|
|
78
80
|
| { kind: "resetFailures"; prefix: string }
|
|
79
81
|
| { kind: "cleanup"; number: number };
|
|
80
82
|
|
|
@@ -161,6 +163,52 @@ export function copilotThreads(pr: PR) {
|
|
|
161
163
|
});
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Who approved the PR's current head, if anyone. A trusted reviewer's pending
|
|
168
|
+
* change request blocks it, and approvals of older commits don't count.
|
|
169
|
+
*/
|
|
170
|
+
export function approvedBy(
|
|
171
|
+
pr: PR,
|
|
172
|
+
trusted: ReadonlySet<string>,
|
|
173
|
+
): string | null {
|
|
174
|
+
const verdicts = pr.verdicts.filter((v) => trusted.has(v.author));
|
|
175
|
+
if (verdicts.some((v) => v.state === "CHANGES_REQUESTED")) return null;
|
|
176
|
+
return (
|
|
177
|
+
verdicts.find((v) => v.state === "APPROVED" && v.commit === pr.headSha)
|
|
178
|
+
?.author ?? null
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const OPEN_QUESTIONS = /^##\s+Open questions\s*$[\s\S]*?(?=^##\s|(?![\s\S]))/im;
|
|
183
|
+
|
|
184
|
+
/** The plan with its "## Open questions" section set to `questions`, last. */
|
|
185
|
+
export function withOpenQuestions(plan: string, questions: string[]): string {
|
|
186
|
+
const rest = plan.replace(OPEN_QUESTIONS, "").trimEnd();
|
|
187
|
+
if (!questions.length) return rest;
|
|
188
|
+
return `${rest}\n\n## Open questions\n\n${questions.map((q) => `- ${q}`).join("\n")}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Bullets under the plan's "## Open questions". */
|
|
192
|
+
export function openQuestions(plan: string | null): string[] {
|
|
193
|
+
const m = plan?.match(OPEN_QUESTIONS);
|
|
194
|
+
if (!m) return [];
|
|
195
|
+
return m[0]
|
|
196
|
+
.split("\n")
|
|
197
|
+
.slice(1)
|
|
198
|
+
.map((l) => l.match(/^\s*[-*]\s+(.+)$/)?.[1]?.trim())
|
|
199
|
+
.filter((q): q is string => Boolean(q));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Was the open-questions gate already posted for the latest plan? */
|
|
203
|
+
function gatePosted(comments: Comment[]) {
|
|
204
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
205
|
+
const body = (comments[i] as Comment).body;
|
|
206
|
+
if (hasMarker(body, "questions")) return true;
|
|
207
|
+
if (hasMarker(body, "plan")) return false;
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
164
212
|
const fixCount = (pr: PR, kind: "ci" | "conflict") =>
|
|
165
213
|
pr.comments.reduce((n, c) => n + markerValues(c.body, kind).length, 0);
|
|
166
214
|
|
|
@@ -211,6 +259,16 @@ export function derive(snapshot: Snapshot, ctx: DeriveContext) {
|
|
|
211
259
|
|
|
212
260
|
case "ready":
|
|
213
261
|
case "inProgress": {
|
|
262
|
+
// Open questions keep a plan out of Ready. Once started, it runs on.
|
|
263
|
+
const questions =
|
|
264
|
+
item.column === "ready" && !issue.prs.length
|
|
265
|
+
? openQuestions(extractPlan(issue.body))
|
|
266
|
+
: [];
|
|
267
|
+
if (questions.length) {
|
|
268
|
+
if (!gatePosted(issue.comments))
|
|
269
|
+
actions.push({ kind: "openQuestions", item, questions });
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
214
272
|
const n = exploreCount(issue.labels);
|
|
215
273
|
const variants =
|
|
216
274
|
n === null ? [null] : Array.from({ length: n }, (_, i) => i + 1);
|
|
@@ -263,6 +321,8 @@ export function derive(snapshot: Snapshot, ctx: DeriveContext) {
|
|
|
263
321
|
const job = prJob(item, pr, trusted, ctx, blocked);
|
|
264
322
|
if (job === "agentReviewed")
|
|
265
323
|
actions.push({ kind: "agentReviewed", item, pr });
|
|
324
|
+
else if (typeof job === "object" && job && "mergeBy" in job)
|
|
325
|
+
actions.push({ kind: "merge", item, pr, by: job.mergeBy });
|
|
266
326
|
else if (job === "ciExhausted") {
|
|
267
327
|
actions.push({
|
|
268
328
|
kind: "needsHuman",
|
|
@@ -299,7 +359,13 @@ function prJob(
|
|
|
299
359
|
trusted: ReadonlySet<string>,
|
|
300
360
|
ctx: DeriveContext,
|
|
301
361
|
blocked: (issue: Issue, key: string) => boolean,
|
|
302
|
-
):
|
|
362
|
+
):
|
|
363
|
+
| Job
|
|
364
|
+
| { mergeBy: string }
|
|
365
|
+
| "agentReviewed"
|
|
366
|
+
| "ciExhausted"
|
|
367
|
+
| "conflictExhausted"
|
|
368
|
+
| null {
|
|
303
369
|
const { issue } = item;
|
|
304
370
|
const prKey = `#${issue.number}:pr${pr.number}`;
|
|
305
371
|
// One job per PR at a time, whatever its kind.
|
|
@@ -393,6 +459,18 @@ function prJob(
|
|
|
393
459
|
}
|
|
394
460
|
|
|
395
461
|
if (!pr.labels.includes(LABELS.agentReviewed)) return "agentReviewed";
|
|
462
|
+
|
|
463
|
+
// 6. Approved by a human: merge. Only kanbot's own PRs, never kanbot-review ones.
|
|
464
|
+
if (
|
|
465
|
+
ctx.config.merge.onApproval &&
|
|
466
|
+
pr.headRefName.startsWith("kanbot/") &&
|
|
467
|
+
!issue.labels.includes(LABELS.needsHuman) &&
|
|
468
|
+
pr.mergeable === "MERGEABLE" &&
|
|
469
|
+
(pr.checks === "SUCCESS" || pr.checks === null)
|
|
470
|
+
) {
|
|
471
|
+
const by = approvedBy(pr, trusted);
|
|
472
|
+
if (by) return { mergeBy: by };
|
|
473
|
+
}
|
|
396
474
|
return null;
|
|
397
475
|
}
|
|
398
476
|
|
package/src/daemon/loop.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { loadAgents } from "../agents/load.ts";
|
|
|
2
2
|
import { sync } from "../agents/sync.ts";
|
|
3
3
|
import { type Config, loadConfig } from "../config.ts";
|
|
4
4
|
import { UserError } from "../errors.ts";
|
|
5
|
+
import { viewer } from "../github/api.ts";
|
|
6
|
+
import { botClient } from "../github/app.ts";
|
|
5
7
|
import { Gh } from "../github/gh.ts";
|
|
6
8
|
import { loadBoard, missingColumns } from "../github/project.ts";
|
|
7
9
|
import { Trust } from "../github/trust.ts";
|
|
@@ -54,10 +56,13 @@ export async function runDaemon(root: string, opts: RunOptions) {
|
|
|
54
56
|
|
|
55
57
|
const release = opts.dryRun ? () => {} : await acquireLock(root);
|
|
56
58
|
const state = await new State(root).load();
|
|
59
|
+
const bot = botClient(config, gh);
|
|
57
60
|
const deps: JobDeps = {
|
|
58
61
|
root,
|
|
59
62
|
config,
|
|
60
63
|
gh,
|
|
64
|
+
bot,
|
|
65
|
+
me: await viewer(gh),
|
|
61
66
|
board,
|
|
62
67
|
agents,
|
|
63
68
|
provider: providerFor(config),
|
|
@@ -82,7 +87,7 @@ export async function runDaemon(root: string, opts: RunOptions) {
|
|
|
82
87
|
process.on("SIGTERM", stop);
|
|
83
88
|
|
|
84
89
|
log.info(
|
|
85
|
-
`kanbot: ${config.repo} · ${board.url} · provider ${config.provider} · ${config.concurrency} workers${opts.dryRun ? " · DRY RUN" : ""}`,
|
|
90
|
+
`kanbot: ${config.repo} · ${board.url} · provider ${config.provider} · ${config.concurrency} workers · talks as ${config.identity.mode === "app" ? `${config.identity.app?.slug}[bot]` : deps.me}${opts.dryRun ? " · DRY RUN" : ""}`,
|
|
86
91
|
);
|
|
87
92
|
|
|
88
93
|
try {
|
|
@@ -200,10 +205,14 @@ function describeAction(a: Action) {
|
|
|
200
205
|
return `label #${a.number} +[${a.add}] -[${a.remove}]`;
|
|
201
206
|
case "agentReviewed":
|
|
202
207
|
return `mark PR #${a.pr.number} agent-reviewed`;
|
|
208
|
+
case "merge":
|
|
209
|
+
return `merge PR #${a.pr.number}, approved by ${a.by}`;
|
|
203
210
|
case "compare":
|
|
204
211
|
return `post comparison on #${a.item.issue.number}`;
|
|
205
212
|
case "needsHuman":
|
|
206
213
|
return `needs-human #${a.number}`;
|
|
214
|
+
case "openQuestions":
|
|
215
|
+
return `open questions on #${a.item.issue.number}`;
|
|
207
216
|
case "resetFailures":
|
|
208
217
|
return `reset failures ${a.prefix}`;
|
|
209
218
|
case "cleanup":
|
package/src/daemon/snapshot.ts
CHANGED
|
@@ -63,6 +63,7 @@ export async function takeSnapshot(
|
|
|
63
63
|
...issue.prs.flatMap((p) => [
|
|
64
64
|
...p.comments.map((c) => c.author),
|
|
65
65
|
...p.reviews.map((c) => c.author),
|
|
66
|
+
...p.verdicts.map((v) => v.author),
|
|
66
67
|
...p.threads.flatMap((t) => t.comments.map((c) => c.author)),
|
|
67
68
|
]),
|
|
68
69
|
]);
|
package/src/daemon/types.ts
CHANGED
|
@@ -24,6 +24,21 @@ export type Checks =
|
|
|
24
24
|
| "EXPECTED"
|
|
25
25
|
| null;
|
|
26
26
|
|
|
27
|
+
export type ReviewState =
|
|
28
|
+
| "APPROVED"
|
|
29
|
+
| "CHANGES_REQUESTED"
|
|
30
|
+
| "COMMENTED"
|
|
31
|
+
| "DISMISSED"
|
|
32
|
+
| "PENDING";
|
|
33
|
+
|
|
34
|
+
/** A reviewer's latest review on a PR, body or not. */
|
|
35
|
+
export type ReviewVerdict = {
|
|
36
|
+
author: string;
|
|
37
|
+
state: ReviewState;
|
|
38
|
+
/** Head commit the review was made on. */
|
|
39
|
+
commit: string | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
27
42
|
export type PR = {
|
|
28
43
|
id: string;
|
|
29
44
|
number: number;
|
|
@@ -38,6 +53,8 @@ export type PR = {
|
|
|
38
53
|
comments: Comment[];
|
|
39
54
|
/** Submitted reviews that have a body. */
|
|
40
55
|
reviews: Comment[];
|
|
56
|
+
/** Latest review per reviewer, for approvals. */
|
|
57
|
+
verdicts: ReviewVerdict[];
|
|
41
58
|
threads: ReviewThread[];
|
|
42
59
|
checks: Checks;
|
|
43
60
|
/** UNKNOWN while GitHub is still computing it. */
|
package/src/github/api.ts
CHANGED
|
@@ -8,6 +8,7 @@ const PR_FIELDS = `
|
|
|
8
8
|
labels(first: 30) { nodes { name } }
|
|
9
9
|
comments(last: 100) { nodes { ${COMMENT} } }
|
|
10
10
|
reviews(last: 50) { nodes { ${COMMENT} } }
|
|
11
|
+
latestReviews(last: 20) { nodes { author { login } state commit { oid } } }
|
|
11
12
|
reviewThreads(last: 100) {
|
|
12
13
|
nodes {
|
|
13
14
|
id isResolved path line
|
|
@@ -103,6 +104,12 @@ function mapPR(p: any): PR {
|
|
|
103
104
|
comments: p.comments.nodes.map(comment),
|
|
104
105
|
reviews: p.reviews.nodes.map(comment).filter((r: Comment) => r.body.trim()),
|
|
105
106
|
// biome-ignore lint/suspicious/noExplicitAny: raw GraphQL shape
|
|
107
|
+
verdicts: p.latestReviews.nodes.map((r: any) => ({
|
|
108
|
+
author: r.author?.login ?? "ghost",
|
|
109
|
+
state: r.state,
|
|
110
|
+
commit: r.commit?.oid ?? null,
|
|
111
|
+
})),
|
|
112
|
+
// biome-ignore lint/suspicious/noExplicitAny: raw GraphQL shape
|
|
106
113
|
threads: p.reviewThreads.nodes.map((t: any) => ({
|
|
107
114
|
id: t.id,
|
|
108
115
|
isResolved: t.isResolved,
|
|
@@ -118,12 +125,21 @@ function mapPR(p: any): PR {
|
|
|
118
125
|
|
|
119
126
|
// ---- writes ---------------------------------------------------------------
|
|
120
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Visible sign on kanbot's comments when it posts as the user. In app mode
|
|
130
|
+
* GitHub's bot badge already says so.
|
|
131
|
+
*/
|
|
132
|
+
export const SIGNATURE = "<sub>🤖 kanbot</sub>";
|
|
133
|
+
|
|
134
|
+
export const signed = (gh: Gh, body: string) =>
|
|
135
|
+
gh.isBot ? body : `${SIGNATURE}\n\n${body}`;
|
|
136
|
+
|
|
121
137
|
export function postComment(gh: Gh, number: number, body: string) {
|
|
122
138
|
return gh.restWrite(
|
|
123
139
|
`comment on #${number}`,
|
|
124
140
|
"POST",
|
|
125
141
|
gh.r(`/issues/${number}/comments`),
|
|
126
|
-
{ body },
|
|
142
|
+
{ body: signed(gh, body) },
|
|
127
143
|
);
|
|
128
144
|
}
|
|
129
145
|
|
|
@@ -216,6 +232,35 @@ export function updatePRBody(gh: Gh, number: number, body: string) {
|
|
|
216
232
|
);
|
|
217
233
|
}
|
|
218
234
|
|
|
235
|
+
/** Merge only if the head is still `sha`, so later pushes aren't merged unseen. */
|
|
236
|
+
export function mergePR(
|
|
237
|
+
gh: Gh,
|
|
238
|
+
number: number,
|
|
239
|
+
sha: string,
|
|
240
|
+
method: "squash" | "merge" | "rebase",
|
|
241
|
+
) {
|
|
242
|
+
return gh.restWrite(
|
|
243
|
+
`merge PR #${number} (${method})`,
|
|
244
|
+
"PUT",
|
|
245
|
+
gh.r(`/pulls/${number}/merge`),
|
|
246
|
+
{ sha, merge_method: method },
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function requestReview(gh: Gh, number: number, logins: string[]) {
|
|
251
|
+
return gh.restWrite(
|
|
252
|
+
`request review on #${number} from ${logins.join(",")}`,
|
|
253
|
+
"POST",
|
|
254
|
+
gh.r(`/pulls/${number}/requested_reviewers`),
|
|
255
|
+
{ reviewers: logins },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Login of the account `gh` is signed in as. */
|
|
260
|
+
export async function viewer(gh: Gh) {
|
|
261
|
+
return (await gh.rest<{ login: string }>("user")).login;
|
|
262
|
+
}
|
|
263
|
+
|
|
219
264
|
export function closePR(gh: Gh, number: number) {
|
|
220
265
|
return gh.restWrite(
|
|
221
266
|
`close PR #${number}`,
|
|
@@ -231,7 +276,7 @@ export function replyToThread(gh: Gh, threadId: string, body: string) {
|
|
|
231
276
|
`mutation($t: ID!, $b: String!) {
|
|
232
277
|
addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $t, body: $b }) { comment { id } }
|
|
233
278
|
}`,
|
|
234
|
-
{ t: threadId, b: body },
|
|
279
|
+
{ t: threadId, b: signed(gh, body) },
|
|
235
280
|
);
|
|
236
281
|
}
|
|
237
282
|
|