kanbot 0.2.0 → 0.3.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 +7 -1
- package/package.json +1 -1
- package/schema/config.schema.json +54 -0
- package/src/commands/app.ts +169 -0
- package/src/commands/doctor.ts +35 -0
- package/src/commands/skills.ts +1 -1
- package/src/config.ts +34 -0
- package/src/daemon/derive.ts +38 -1
- package/src/daemon/loop.ts +8 -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 +5 -1
- package/src/jobs/run.ts +75 -35
- package/src/known-hashes.json +5 -0
- package/src/main.ts +1 -0
- package/src/templates.ts +1 -0
- package/templates/.agents/skills/kanbot-admin/SKILL.md +20 -5
- package/templates/.agents/skills/kanbot-guide/SKILL.md +91 -0
- package/templates/AGENTS.md +1 -1
package/README.md
CHANGED
|
@@ -26,10 +26,12 @@ Full docs: [docs/](./docs/00-index.md). Demo video: [docs/assets/demo.mp4](./doc
|
|
|
26
26
|
| **Ready** | Moving a card here approves the plan. Cards are picked top-down. |
|
|
27
27
|
| **In Progress** | An agent implements it in its own worktree and opens a PR. |
|
|
28
28
|
| **Review** | Two agent reviews, with fixes. Then you're notified. Comment on the PR and an agent addresses it. CI failures and merge conflicts are fixed too. |
|
|
29
|
-
| **Done** | You merge.
|
|
29
|
+
| **Done** | You merge, or approve and kanbot merges ([`merge.onApproval`](./docs/12-identity.md#merge-on-approval)). |
|
|
30
30
|
|
|
31
31
|
A Bun daemon polls the board and runs one `claude -p` / `codex exec` process per job. GitHub is the only state, so you can restart it any time. Agents never get a GitHub token; the daemon makes every comment, push and PR. Only collaborators with write access can steer agents.
|
|
32
32
|
|
|
33
|
+
kanbot talks as you by default. Run `kanbot app create` to have it talk as its own bot instead, so you can tell its messages apart and approve its PRs. Commits stay yours. With `merge.onApproval`, approving a PR merges it. [More](./docs/12-identity.md)
|
|
34
|
+
|
|
33
35
|
## Setup
|
|
34
36
|
|
|
35
37
|
Requires [Bun](https://bun.sh) ≥ 1.3, `gh` logged in, and `claude` or `codex` logged in.
|
|
@@ -46,6 +48,8 @@ kanbot run
|
|
|
46
48
|
|
|
47
49
|
Use `kanbot run --once --dry-run` to see what it would do. Run one daemon per repo.
|
|
48
50
|
|
|
51
|
+
For a guided setup, run `bunx kanbot skills install` and ask your agent "help me set up kanbot".
|
|
52
|
+
|
|
49
53
|
## Labels
|
|
50
54
|
|
|
51
55
|
| Label | Meaning |
|
|
@@ -110,4 +114,6 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org). A `c
|
|
|
110
114
|
2. A draft release updates with the next version and notes. `feat` bumps minor, `fix` patch. On 0.x, breaking changes bump minor.
|
|
111
115
|
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
116
|
|
|
117
|
+
Docs: [kanbot.mib.im](https://kanbot.mib.im) shows the latest release, [kanbot.mib.im/main](https://kanbot.mib.im/main/) shows `main`. Both deploy on every push to `main` and on each release. `pnpm docs:dev` serves your working tree.
|
|
118
|
+
|
|
113
119
|
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
|
@@ -165,6 +165,60 @@
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
},
|
|
168
|
+
"identity": {
|
|
169
|
+
"default": {},
|
|
170
|
+
"type": "object",
|
|
171
|
+
"properties": {
|
|
172
|
+
"mode": {
|
|
173
|
+
"default": "user",
|
|
174
|
+
"description": "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.",
|
|
175
|
+
"type": "string",
|
|
176
|
+
"enum": ["user", "app"]
|
|
177
|
+
},
|
|
178
|
+
"app": {
|
|
179
|
+
"default": null,
|
|
180
|
+
"description": "Set by `kanbot app create`. The private key lives in ~/.kanbot/apps/<id>.pem.",
|
|
181
|
+
"anyOf": [
|
|
182
|
+
{
|
|
183
|
+
"type": "object",
|
|
184
|
+
"properties": {
|
|
185
|
+
"id": {
|
|
186
|
+
"type": "integer",
|
|
187
|
+
"minimum": -9007199254740991,
|
|
188
|
+
"maximum": 9007199254740991,
|
|
189
|
+
"description": "GitHub App ID."
|
|
190
|
+
},
|
|
191
|
+
"slug": {
|
|
192
|
+
"type": "string",
|
|
193
|
+
"description": "App slug, as in github.com/apps/<slug>."
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
"required": ["id", "slug"]
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"type": "null"
|
|
200
|
+
}
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
"merge": {
|
|
206
|
+
"default": {},
|
|
207
|
+
"type": "object",
|
|
208
|
+
"properties": {
|
|
209
|
+
"onApproval": {
|
|
210
|
+
"default": false,
|
|
211
|
+
"description": "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.",
|
|
212
|
+
"type": "boolean"
|
|
213
|
+
},
|
|
214
|
+
"method": {
|
|
215
|
+
"default": "squash",
|
|
216
|
+
"description": "Merge method used for approved PRs.",
|
|
217
|
+
"type": "string",
|
|
218
|
+
"enum": ["squash", "merge", "rebase"]
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
},
|
|
168
222
|
"codex": {
|
|
169
223
|
"default": {},
|
|
170
224
|
"type": "object",
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { chmod, mkdir } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { defineCommand } from "citty";
|
|
6
|
+
import { loadConfig, saveConfig } from "../config.ts";
|
|
7
|
+
import { UserError } from "../errors.ts";
|
|
8
|
+
import { appKeyPath, appManifest, convertManifest } from "../github/app.ts";
|
|
9
|
+
import { Gh } from "../github/gh.ts";
|
|
10
|
+
import { log } from "../log.ts";
|
|
11
|
+
|
|
12
|
+
const WAIT_MS = 10 * 60_000;
|
|
13
|
+
|
|
14
|
+
function openBrowser(url: string) {
|
|
15
|
+
const cmd =
|
|
16
|
+
process.platform === "darwin"
|
|
17
|
+
? "open"
|
|
18
|
+
: process.platform === "win32"
|
|
19
|
+
? "explorer"
|
|
20
|
+
: "xdg-open";
|
|
21
|
+
try {
|
|
22
|
+
Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" });
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const escAttr = (s: string) => s.replace(/&/g, "&").replace(/"/g, """);
|
|
27
|
+
|
|
28
|
+
/** A page that posts the manifest to GitHub, as the manifest flow requires. */
|
|
29
|
+
function formPage(action: string, manifest: unknown) {
|
|
30
|
+
return `<!doctype html><meta charset="utf-8"><title>kanbot app</title>
|
|
31
|
+
<form id="f" method="post" action="${escAttr(action)}">
|
|
32
|
+
<input type="hidden" name="manifest" value="${escAttr(JSON.stringify(manifest))}">
|
|
33
|
+
<button>Create the kanbot app on GitHub</button></form>
|
|
34
|
+
<script>document.getElementById("f").submit()</script>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const page = (msg: string) =>
|
|
38
|
+
new Response(
|
|
39
|
+
`<!doctype html><meta charset="utf-8"><title>kanbot app</title><p>${msg}</p>`,
|
|
40
|
+
{ headers: { "content-type": "text/html" } },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const create = defineCommand({
|
|
44
|
+
meta: {
|
|
45
|
+
name: "create",
|
|
46
|
+
description:
|
|
47
|
+
"Create a GitHub App for kanbot to talk as, and switch to app mode",
|
|
48
|
+
},
|
|
49
|
+
args: {
|
|
50
|
+
name: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description: "App name, unique on GitHub (default: kanbot-<owner>)",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
async run({ args }) {
|
|
56
|
+
const root = process.cwd();
|
|
57
|
+
const config = await loadConfig(root);
|
|
58
|
+
const gh = new Gh(config.repo);
|
|
59
|
+
const owner = gh.owner;
|
|
60
|
+
const { type } = await gh.rest<{ type: string }>(`users/${owner}`);
|
|
61
|
+
const name = (args.name ?? `kanbot-${owner}`).slice(0, 34);
|
|
62
|
+
const state = randomUUID();
|
|
63
|
+
const action =
|
|
64
|
+
type === "Organization"
|
|
65
|
+
? `https://github.com/organizations/${owner}/settings/apps/new?state=${state}`
|
|
66
|
+
: `https://github.com/settings/apps/new?state=${state}`;
|
|
67
|
+
|
|
68
|
+
const got = Promise.withResolvers<string>();
|
|
69
|
+
const server = Bun.serve({
|
|
70
|
+
port: 0,
|
|
71
|
+
hostname: "localhost",
|
|
72
|
+
fetch(req): Response {
|
|
73
|
+
const url = new URL(req.url);
|
|
74
|
+
if (url.pathname === "/")
|
|
75
|
+
return new Response(
|
|
76
|
+
formPage(
|
|
77
|
+
action,
|
|
78
|
+
appManifest(name, `http://localhost:${url.port}/callback`),
|
|
79
|
+
),
|
|
80
|
+
{ headers: { "content-type": "text/html" } },
|
|
81
|
+
);
|
|
82
|
+
if (url.pathname !== "/callback")
|
|
83
|
+
return new Response("", { status: 404 });
|
|
84
|
+
const code = url.searchParams.get("code");
|
|
85
|
+
if (url.searchParams.get("state") !== state || !code)
|
|
86
|
+
return page("Unexpected request. Run `kanbot app create` again.");
|
|
87
|
+
got.resolve(code);
|
|
88
|
+
return page("App created. Go back to your terminal.");
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const start = `http://localhost:${server.port}/`;
|
|
92
|
+
log.info(`Opening ${start} — confirm the app on GitHub.`);
|
|
93
|
+
openBrowser(start);
|
|
94
|
+
const timer = setTimeout(
|
|
95
|
+
() => got.reject(new Error("timed out waiting for GitHub")),
|
|
96
|
+
WAIT_MS,
|
|
97
|
+
);
|
|
98
|
+
let code: string;
|
|
99
|
+
try {
|
|
100
|
+
code = await got.promise;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
throw new UserError("No app was created.", {
|
|
103
|
+
why: String(e),
|
|
104
|
+
fix: "kanbot app create",
|
|
105
|
+
docs: "identity#app-mode",
|
|
106
|
+
});
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
server.stop();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const app = await convertManifest(code);
|
|
113
|
+
const keyFile = appKeyPath(app.id);
|
|
114
|
+
await mkdir(dirname(keyFile), { recursive: true });
|
|
115
|
+
await Bun.write(keyFile, app.pem);
|
|
116
|
+
await chmod(keyFile, 0o600);
|
|
117
|
+
await saveConfig(root, {
|
|
118
|
+
identity: { mode: "app", app: { id: app.id, slug: app.slug } },
|
|
119
|
+
});
|
|
120
|
+
const install = `https://github.com/apps/${app.slug}/installations/new`;
|
|
121
|
+
log.info(`Created ${app.slug} (id ${app.id}). Key: ${keyFile}`);
|
|
122
|
+
log.info(`Now install it on ${config.repo}: ${install}`);
|
|
123
|
+
openBrowser(install);
|
|
124
|
+
console.log(
|
|
125
|
+
"\nThen commit .kanbot/config.yml, run `kanbot doctor` and restart the daemon.",
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const link = defineCommand({
|
|
131
|
+
meta: {
|
|
132
|
+
name: "link",
|
|
133
|
+
description: "Use a kanbot app you already created, e.g. for another repo",
|
|
134
|
+
},
|
|
135
|
+
args: {
|
|
136
|
+
id: { type: "string", required: true, description: "GitHub App ID" },
|
|
137
|
+
slug: {
|
|
138
|
+
type: "string",
|
|
139
|
+
required: true,
|
|
140
|
+
description: "App slug, as in github.com/apps/<slug>",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
async run({ args }) {
|
|
144
|
+
const root = process.cwd();
|
|
145
|
+
const id = Number(args.id);
|
|
146
|
+
if (!Number.isInteger(id))
|
|
147
|
+
throw new UserError("--id must be a number.", {
|
|
148
|
+
docs: "identity#use-the-app-in-another-repo",
|
|
149
|
+
});
|
|
150
|
+
await saveConfig(root, {
|
|
151
|
+
identity: { mode: "app", app: { id, slug: args.slug } },
|
|
152
|
+
});
|
|
153
|
+
const config = await loadConfig(root);
|
|
154
|
+
log.info(`kanbot talks as ${args.slug}[bot] in ${config.repo}.`);
|
|
155
|
+
if (!existsSync(appKeyPath(id)))
|
|
156
|
+
log.warn(`No private key at ${appKeyPath(id)}. Copy it there.`);
|
|
157
|
+
log.info(
|
|
158
|
+
`Install it on the repo if needed: https://github.com/apps/${args.slug}/installations/new`,
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export default defineCommand({
|
|
164
|
+
meta: {
|
|
165
|
+
name: "app",
|
|
166
|
+
description: "Manage the GitHub App kanbot talks as",
|
|
167
|
+
},
|
|
168
|
+
subCommands: { create, link },
|
|
169
|
+
});
|
package/src/commands/doctor.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { loadConfig, writeConfigSchema } from "../config.ts";
|
|
|
7
7
|
import { git } from "../daemon/git.ts";
|
|
8
8
|
import { describeError } from "../errors.ts";
|
|
9
9
|
import { copilotReviewRule } from "../github/api.ts";
|
|
10
|
+
import { appKeyPath, appTokens } from "../github/app.ts";
|
|
10
11
|
import { Gh, run } from "../github/gh.ts";
|
|
11
12
|
import {
|
|
12
13
|
boardWorkflows,
|
|
@@ -236,6 +237,40 @@ export default defineCommand({
|
|
|
236
237
|
fix: "kanbot board create",
|
|
237
238
|
});
|
|
238
239
|
|
|
240
|
+
const { mode, app } = config.identity;
|
|
241
|
+
if (mode === "app") {
|
|
242
|
+
add({
|
|
243
|
+
name: "kanbot app set",
|
|
244
|
+
ok: app !== null,
|
|
245
|
+
detail: app ? `${app.slug} (id ${app.id})` : undefined,
|
|
246
|
+
fix: "kanbot app create",
|
|
247
|
+
});
|
|
248
|
+
if (app) {
|
|
249
|
+
const key = appKeyPath(app.id);
|
|
250
|
+
add({
|
|
251
|
+
name: "kanbot app private key",
|
|
252
|
+
ok: existsSync(key),
|
|
253
|
+
detail: key,
|
|
254
|
+
fix: "kanbot app create, or copy the key from the machine that created the app",
|
|
255
|
+
});
|
|
256
|
+
if (existsSync(key)) {
|
|
257
|
+
try {
|
|
258
|
+
await appTokens(app, config.repo)();
|
|
259
|
+
add({ name: `kanbot app installed on ${config.repo}`, ok: true });
|
|
260
|
+
} catch (e) {
|
|
261
|
+
add({
|
|
262
|
+
name: `kanbot app installed on ${config.repo}`,
|
|
263
|
+
ok: false,
|
|
264
|
+
...explain(
|
|
265
|
+
e,
|
|
266
|
+
`https://github.com/apps/${app.slug}/installations/new`,
|
|
267
|
+
),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
239
274
|
if (config.review.copilot) {
|
|
240
275
|
const rule = await copilotReviewRule(gh, config.baseBranch).catch(
|
|
241
276
|
() => null,
|
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/config.ts
CHANGED
|
@@ -112,6 +112,40 @@ export const ConfigSchema = z.object({
|
|
|
112
112
|
),
|
|
113
113
|
})
|
|
114
114
|
.prefault({}),
|
|
115
|
+
identity: z
|
|
116
|
+
.object({
|
|
117
|
+
mode: z
|
|
118
|
+
.enum(["user", "app"])
|
|
119
|
+
.default("user")
|
|
120
|
+
.describe(
|
|
121
|
+
"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.",
|
|
122
|
+
),
|
|
123
|
+
app: z
|
|
124
|
+
.object({
|
|
125
|
+
id: z.number().int().describe("GitHub App ID."),
|
|
126
|
+
slug: z.string().describe("App slug, as in github.com/apps/<slug>."),
|
|
127
|
+
})
|
|
128
|
+
.nullable()
|
|
129
|
+
.default(null)
|
|
130
|
+
.describe(
|
|
131
|
+
"Set by `kanbot app create`. The private key lives in ~/.kanbot/apps/<id>.pem.",
|
|
132
|
+
),
|
|
133
|
+
})
|
|
134
|
+
.prefault({}),
|
|
135
|
+
merge: z
|
|
136
|
+
.object({
|
|
137
|
+
onApproval: z
|
|
138
|
+
.boolean()
|
|
139
|
+
.default(false)
|
|
140
|
+
.describe(
|
|
141
|
+
"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.",
|
|
142
|
+
),
|
|
143
|
+
method: z
|
|
144
|
+
.enum(["squash", "merge", "rebase"])
|
|
145
|
+
.default("squash")
|
|
146
|
+
.describe("Merge method used for approved PRs."),
|
|
147
|
+
})
|
|
148
|
+
.prefault({}),
|
|
115
149
|
codex: z
|
|
116
150
|
.object({
|
|
117
151
|
networkAccess: z
|
package/src/daemon/derive.ts
CHANGED
|
@@ -73,6 +73,7 @@ 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 }
|
|
78
79
|
| { kind: "resetFailures"; prefix: string }
|
|
@@ -161,6 +162,22 @@ export function copilotThreads(pr: PR) {
|
|
|
161
162
|
});
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Who approved the PR's current head, if anyone. A trusted reviewer's pending
|
|
167
|
+
* change request blocks it, and approvals of older commits don't count.
|
|
168
|
+
*/
|
|
169
|
+
export function approvedBy(
|
|
170
|
+
pr: PR,
|
|
171
|
+
trusted: ReadonlySet<string>,
|
|
172
|
+
): string | null {
|
|
173
|
+
const verdicts = pr.verdicts.filter((v) => trusted.has(v.author));
|
|
174
|
+
if (verdicts.some((v) => v.state === "CHANGES_REQUESTED")) return null;
|
|
175
|
+
return (
|
|
176
|
+
verdicts.find((v) => v.state === "APPROVED" && v.commit === pr.headSha)
|
|
177
|
+
?.author ?? null
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
164
181
|
const fixCount = (pr: PR, kind: "ci" | "conflict") =>
|
|
165
182
|
pr.comments.reduce((n, c) => n + markerValues(c.body, kind).length, 0);
|
|
166
183
|
|
|
@@ -263,6 +280,8 @@ export function derive(snapshot: Snapshot, ctx: DeriveContext) {
|
|
|
263
280
|
const job = prJob(item, pr, trusted, ctx, blocked);
|
|
264
281
|
if (job === "agentReviewed")
|
|
265
282
|
actions.push({ kind: "agentReviewed", item, pr });
|
|
283
|
+
else if (typeof job === "object" && job && "mergeBy" in job)
|
|
284
|
+
actions.push({ kind: "merge", item, pr, by: job.mergeBy });
|
|
266
285
|
else if (job === "ciExhausted") {
|
|
267
286
|
actions.push({
|
|
268
287
|
kind: "needsHuman",
|
|
@@ -299,7 +318,13 @@ function prJob(
|
|
|
299
318
|
trusted: ReadonlySet<string>,
|
|
300
319
|
ctx: DeriveContext,
|
|
301
320
|
blocked: (issue: Issue, key: string) => boolean,
|
|
302
|
-
):
|
|
321
|
+
):
|
|
322
|
+
| Job
|
|
323
|
+
| { mergeBy: string }
|
|
324
|
+
| "agentReviewed"
|
|
325
|
+
| "ciExhausted"
|
|
326
|
+
| "conflictExhausted"
|
|
327
|
+
| null {
|
|
303
328
|
const { issue } = item;
|
|
304
329
|
const prKey = `#${issue.number}:pr${pr.number}`;
|
|
305
330
|
// One job per PR at a time, whatever its kind.
|
|
@@ -393,6 +418,18 @@ function prJob(
|
|
|
393
418
|
}
|
|
394
419
|
|
|
395
420
|
if (!pr.labels.includes(LABELS.agentReviewed)) return "agentReviewed";
|
|
421
|
+
|
|
422
|
+
// 6. Approved by a human: merge. Only kanbot's own PRs, never kanbot-review ones.
|
|
423
|
+
if (
|
|
424
|
+
ctx.config.merge.onApproval &&
|
|
425
|
+
pr.headRefName.startsWith("kanbot/") &&
|
|
426
|
+
!issue.labels.includes(LABELS.needsHuman) &&
|
|
427
|
+
pr.mergeable === "MERGEABLE" &&
|
|
428
|
+
(pr.checks === "SUCCESS" || pr.checks === null)
|
|
429
|
+
) {
|
|
430
|
+
const by = approvedBy(pr, trusted);
|
|
431
|
+
if (by) return { mergeBy: by };
|
|
432
|
+
}
|
|
396
433
|
return null;
|
|
397
434
|
}
|
|
398
435
|
|
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,6 +205,8 @@ 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":
|
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
|
|