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.
Files changed (62) hide show
  1. package/README.md +15 -9
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +92 -8
  4. package/src/agents/load.ts +11 -0
  5. package/src/agents/sync.ts +8 -8
  6. package/src/capabilities.ts +157 -0
  7. package/src/commands/app.ts +169 -0
  8. package/src/commands/board.ts +14 -7
  9. package/src/commands/doctor.ts +115 -6
  10. package/src/commands/init.ts +15 -3
  11. package/src/commands/skills.ts +1 -1
  12. package/src/commands/sync.ts +1 -1
  13. package/src/commands/upgrade.ts +20 -1
  14. package/src/config.ts +72 -8
  15. package/src/daemon/derive.ts +79 -1
  16. package/src/daemon/loop.ts +10 -1
  17. package/src/daemon/snapshot.ts +1 -0
  18. package/src/daemon/types.ts +17 -0
  19. package/src/github/api.ts +47 -2
  20. package/src/github/app.ts +153 -0
  21. package/src/github/gh.ts +29 -3
  22. package/src/jobs/prompts.ts +30 -6
  23. package/src/jobs/run.ts +135 -56
  24. package/src/known-hashes.json +47 -57
  25. package/src/main.ts +1 -0
  26. package/src/providers/claude.ts +40 -30
  27. package/src/providers/codex.ts +60 -30
  28. package/src/providers/mcp.ts +71 -0
  29. package/src/schemas/results.ts +2 -5
  30. package/src/templates.ts +1 -0
  31. package/templates/.agents/skills/approach-designer/SKILL.md +1 -1
  32. package/templates/.agents/skills/kanbot-admin/SKILL.md +39 -7
  33. package/templates/.agents/skills/kanbot-guide/SKILL.md +110 -0
  34. package/templates/.agents/skills/kanbot-idea/SKILL.md +1 -1
  35. package/templates/.agents/skills/plan-template/SKILL.md +14 -6
  36. package/templates/.agents/skills/pr-description/SKILL.md +7 -3
  37. package/templates/.agents/skills/project-conventions/SKILL.md +23 -35
  38. package/templates/.agents/skills/review-findings/SKILL.md +47 -0
  39. package/templates/.agents/skills/spec-review/SKILL.md +10 -27
  40. package/templates/.agents/skills/user-stories/SKILL.md +1 -1
  41. package/templates/.github/pull_request_template.md +6 -0
  42. package/templates/.kanbot/agents/acceptance-reviewer.md +43 -0
  43. package/templates/.kanbot/agents/fixer.md +7 -5
  44. package/templates/.kanbot/agents/implementer.md +49 -0
  45. package/templates/.kanbot/agents/plan-critic.md +3 -3
  46. package/templates/.kanbot/agents/planner.md +10 -9
  47. package/templates/.kanbot/agents/reviewer.md +42 -0
  48. package/templates/AGENTS.md +3 -4
  49. package/templates/.agents/skills/accessibility/SKILL.md +0 -76
  50. package/templates/.agents/skills/code-review/SKILL.md +0 -83
  51. package/templates/.agents/skills/frontend-design/LICENSE.txt +0 -177
  52. package/templates/.agents/skills/frontend-design/SKILL.md +0 -76
  53. package/templates/.agents/skills/tdd/SKILL.md +0 -51
  54. package/templates/.agents/skills/verify-ui/SKILL.md +0 -104
  55. package/templates/.kanbot/agents/implementer-backend.md +0 -57
  56. package/templates/.kanbot/agents/implementer-docs.md +0 -55
  57. package/templates/.kanbot/agents/implementer-frontend.md +0 -54
  58. package/templates/.kanbot/agents/implementer-technical.md +0 -53
  59. package/templates/.kanbot/agents/reviewer-code.md +0 -46
  60. package/templates/.kanbot/agents/reviewer-spec.md +0 -49
  61. package/templates/.kanbot/agents/test-writer.md +0 -35
  62. package/templates/.kanbot/agents/ui-verifier.md +0 -35
@@ -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 = { name: string; ok: boolean; detail?: string; fix?: string };
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-code",
108
- "reviewer-spec",
127
+ "reviewer",
128
+ "acceptance-reviewer",
109
129
  "fixer",
110
- ...Object.values(config.implementers),
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,
@@ -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. kanbot board create # creates the project board and labels
105
- 4. kanbot doctor # checks auth, board, generated files
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 run # start the loop (or kanbot run --once --dry-run)`);
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
 
@@ -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()) => ({
@@ -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: {
@@ -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 { writeConfigSchema } from "../config.ts";
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
- .default({
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
- "Maps a type:<key> label to an agent. `default` is the fallback.",
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
@@ -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
- ): Job | "agentReviewed" | "ciExhausted" | "conflictExhausted" | null {
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
 
@@ -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":
@@ -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
  ]);
@@ -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