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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createSign } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { Config } from "../config.ts";
|
|
6
|
+
import { UserError } from "../errors.ts";
|
|
7
|
+
import { Gh, type TokenSource } from "./gh.ts";
|
|
8
|
+
|
|
9
|
+
const API = "https://api.github.com";
|
|
10
|
+
/** Renew installation tokens this long before GitHub expires them (1 h). */
|
|
11
|
+
const RENEW_MS = 5 * 60_000;
|
|
12
|
+
|
|
13
|
+
export type AppConfig = NonNullable<Config["identity"]["app"]>;
|
|
14
|
+
|
|
15
|
+
/** Where `kanbot app create` stores the app's private key. Never in the repo. */
|
|
16
|
+
export function appKeyPath(appId: number) {
|
|
17
|
+
return join(homedir(), ".kanbot", "apps", `${appId}.pem`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const b64url = (s: string) => Buffer.from(s).toString("base64url");
|
|
21
|
+
|
|
22
|
+
/** A short-lived JWT that authenticates as the app itself. */
|
|
23
|
+
export function appJwt(appId: number, key: string, now = Date.now()) {
|
|
24
|
+
const iat = Math.floor(now / 1000) - 60;
|
|
25
|
+
const head = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
26
|
+
const body = b64url(
|
|
27
|
+
JSON.stringify({ iat, exp: iat + 9 * 60, iss: String(appId) }),
|
|
28
|
+
);
|
|
29
|
+
const sig = createSign("RSA-SHA256")
|
|
30
|
+
.update(`${head}.${body}`)
|
|
31
|
+
.sign(key, "base64url");
|
|
32
|
+
return `${head}.${body}.${sig}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function api<T>(
|
|
36
|
+
path: string,
|
|
37
|
+
jwt: string,
|
|
38
|
+
method = "GET",
|
|
39
|
+
body?: unknown,
|
|
40
|
+
): Promise<T> {
|
|
41
|
+
const res = await fetch(`${API}${path}`, {
|
|
42
|
+
method,
|
|
43
|
+
headers: {
|
|
44
|
+
accept: "application/vnd.github+json",
|
|
45
|
+
authorization: `Bearer ${jwt}`,
|
|
46
|
+
"x-github-api-version": "2022-11-28",
|
|
47
|
+
},
|
|
48
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok)
|
|
51
|
+
throw new Error(
|
|
52
|
+
`GitHub ${method} ${path} failed (${res.status}): ${await res.text()}`,
|
|
53
|
+
);
|
|
54
|
+
return (await res.json()) as T;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Installation tokens for one repo, cached until shortly before they expire.
|
|
59
|
+
* Scoped to that repo only.
|
|
60
|
+
*/
|
|
61
|
+
export function appTokens(app: AppConfig, repo: string): TokenSource {
|
|
62
|
+
const keyFile = appKeyPath(app.id);
|
|
63
|
+
let cached: { token: string; expires: number } | null = null;
|
|
64
|
+
let installation: number | null = null;
|
|
65
|
+
return async () => {
|
|
66
|
+
if (cached && Date.now() < cached.expires - RENEW_MS) return cached.token;
|
|
67
|
+
if (!existsSync(keyFile))
|
|
68
|
+
throw new UserError(`No private key for the kanbot app ${app.slug}.`, {
|
|
69
|
+
why: `Expected it at ${keyFile}.`,
|
|
70
|
+
fix: "Run `kanbot app create`, or copy the key there from the machine that created the app.",
|
|
71
|
+
docs: "identity#app-mode",
|
|
72
|
+
});
|
|
73
|
+
const jwt = appJwt(app.id, await Bun.file(keyFile).text());
|
|
74
|
+
installation ??= await api<{ id: number }>(
|
|
75
|
+
`/repos/${repo}/installation`,
|
|
76
|
+
jwt,
|
|
77
|
+
)
|
|
78
|
+
.then((r) => r.id)
|
|
79
|
+
.catch((e) => {
|
|
80
|
+
throw new UserError(`The kanbot app ${app.slug} can't reach ${repo}.`, {
|
|
81
|
+
why: String(e),
|
|
82
|
+
fix: `Install it on the repo: https://github.com/apps/${app.slug}/installations/new`,
|
|
83
|
+
docs: "identity#app-mode",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
const res = await api<{ token: string; expires_at: string }>(
|
|
87
|
+
`/app/installations/${installation}/access_tokens`,
|
|
88
|
+
jwt,
|
|
89
|
+
"POST",
|
|
90
|
+
{ repositories: [repo.split("/")[1]] },
|
|
91
|
+
);
|
|
92
|
+
cached = { token: res.token, expires: Date.parse(res.expires_at) };
|
|
93
|
+
return res.token;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The client kanbot talks as: the app in app mode, otherwise the user's own.
|
|
99
|
+
* Commits, pushes, board moves and merges always stay on the user's client.
|
|
100
|
+
*/
|
|
101
|
+
export function botClient(config: Config, user: Gh): Gh {
|
|
102
|
+
const { mode, app } = config.identity;
|
|
103
|
+
if (mode !== "app") return user;
|
|
104
|
+
if (!app)
|
|
105
|
+
throw new UserError("identity.mode is app, but no app is set.", {
|
|
106
|
+
fix: "kanbot app create",
|
|
107
|
+
docs: "identity#app-mode",
|
|
108
|
+
});
|
|
109
|
+
return new Gh(config.repo, user.dryRun, appTokens(app, config.repo));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- manifest flow --------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/** Permissions the app needs to talk on issues and PRs. Nothing else. */
|
|
115
|
+
export const APP_PERMISSIONS = {
|
|
116
|
+
issues: "write",
|
|
117
|
+
pull_requests: "write",
|
|
118
|
+
contents: "read",
|
|
119
|
+
metadata: "read",
|
|
120
|
+
} as const;
|
|
121
|
+
|
|
122
|
+
export function appManifest(name: string, redirectUrl: string) {
|
|
123
|
+
return {
|
|
124
|
+
name,
|
|
125
|
+
url: "https://github.com/mikaelbr/kanbot",
|
|
126
|
+
redirect_url: redirectUrl,
|
|
127
|
+
description:
|
|
128
|
+
"kanbot's voice on GitHub: plans, reviews and replies. Commits stay yours.",
|
|
129
|
+
public: false,
|
|
130
|
+
hook_attributes: {
|
|
131
|
+
url: "https://github.com/mikaelbr/kanbot",
|
|
132
|
+
active: false,
|
|
133
|
+
},
|
|
134
|
+
default_permissions: APP_PERMISSIONS,
|
|
135
|
+
default_events: [],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type CreatedApp = { id: number; slug: string; pem: string };
|
|
140
|
+
|
|
141
|
+
/** Trade the manifest flow's one-time code for the new app's credentials. */
|
|
142
|
+
export function convertManifest(code: string): Promise<CreatedApp> {
|
|
143
|
+
return fetch(`${API}/app-manifests/${code}/conversions`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { accept: "application/vnd.github+json" },
|
|
146
|
+
}).then(async (res) => {
|
|
147
|
+
if (!res.ok)
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Creating the app failed (${res.status}): ${await res.text()}`,
|
|
150
|
+
);
|
|
151
|
+
return (await res.json()) as CreatedApp;
|
|
152
|
+
});
|
|
153
|
+
}
|
package/src/github/gh.ts
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
import { log } from "../log.ts";
|
|
2
2
|
|
|
3
|
+
/** Returns a GitHub token to use instead of `gh`'s own login. */
|
|
4
|
+
export type TokenSource = () => Promise<string>;
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* Thin wrapper over the `gh` CLI so kanbot reuses the user's existing auth.
|
|
8
|
+
* With a `token`, calls run as that identity instead (the kanbot app).
|
|
5
9
|
* Reads always run; writes are skipped (and logged) in dry-run mode.
|
|
6
10
|
*/
|
|
7
11
|
export class Gh {
|
|
8
12
|
constructor(
|
|
9
13
|
readonly repo: string,
|
|
10
14
|
readonly dryRun = false,
|
|
15
|
+
private readonly token?: TokenSource,
|
|
11
16
|
) {}
|
|
12
17
|
|
|
18
|
+
/** True when calls run as the kanbot app, not the user. */
|
|
19
|
+
get isBot() {
|
|
20
|
+
return this.token !== undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private async env() {
|
|
24
|
+
return this.token ? { GH_TOKEN: await this.token() } : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
get owner() {
|
|
14
28
|
return this.repo.split("/")[0] as string;
|
|
15
29
|
}
|
|
@@ -35,7 +49,11 @@ export class Gh {
|
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
private async queryOnce<T>(input: string): Promise<T> {
|
|
38
|
-
const out = await run(
|
|
52
|
+
const out = await run(
|
|
53
|
+
["api", "graphql", "--input", "-"],
|
|
54
|
+
input,
|
|
55
|
+
await this.env(),
|
|
56
|
+
);
|
|
39
57
|
const json = JSON.parse(out) as { data: T; errors?: { message: string }[] };
|
|
40
58
|
if (json.errors?.length) {
|
|
41
59
|
throw new Error(
|
|
@@ -58,7 +76,9 @@ export class Gh {
|
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
async rest<T = unknown>(path: string): Promise<T> {
|
|
61
|
-
return JSON.parse(
|
|
79
|
+
return JSON.parse(
|
|
80
|
+
await run(["api", path], undefined, await this.env()),
|
|
81
|
+
) as T;
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
async restWrite<T = unknown>(
|
|
@@ -80,6 +100,7 @@ export class Gh {
|
|
|
80
100
|
...(body === undefined ? [] : ["--input", "-"]),
|
|
81
101
|
],
|
|
82
102
|
body === undefined ? undefined : JSON.stringify(body),
|
|
103
|
+
await this.env(),
|
|
83
104
|
);
|
|
84
105
|
return (out.trim() ? JSON.parse(out) : null) as T;
|
|
85
106
|
}
|
|
@@ -90,9 +111,14 @@ export class Gh {
|
|
|
90
111
|
}
|
|
91
112
|
}
|
|
92
113
|
|
|
93
|
-
export async function run(
|
|
114
|
+
export async function run(
|
|
115
|
+
args: string[],
|
|
116
|
+
input?: string,
|
|
117
|
+
env?: Record<string, string>,
|
|
118
|
+
): Promise<string> {
|
|
94
119
|
const proc = Bun.spawn(["gh", ...args], {
|
|
95
120
|
stdin: input === undefined ? "ignore" : new Blob([input]),
|
|
121
|
+
env: env ? { ...process.env, ...env } : undefined,
|
|
96
122
|
stdout: "pipe",
|
|
97
123
|
stderr: "pipe",
|
|
98
124
|
});
|
package/src/jobs/prompts.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
reviewPasses,
|
|
7
7
|
} from "../daemon/derive.ts";
|
|
8
8
|
import type { Comment, Issue, PR } from "../daemon/types.ts";
|
|
9
|
+
import { SIGNATURE } from "../github/api.ts";
|
|
9
10
|
import { extractPlan, isKanbot, stripPlan } from "../github/markers.ts";
|
|
10
11
|
import type { Finding } from "../schemas/results.ts";
|
|
11
12
|
|
|
@@ -36,7 +37,10 @@ function conversation(comments: Comment[], limit = 30) {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
function stripMarkers(body: string) {
|
|
39
|
-
return body
|
|
40
|
+
return body
|
|
41
|
+
.replace(SIGNATURE, "")
|
|
42
|
+
.replace(/<!--\s*kanbot[^>]*-->/g, "")
|
|
43
|
+
.trim();
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
/**
|
package/src/jobs/run.ts
CHANGED
|
@@ -24,10 +24,12 @@ import {
|
|
|
24
24
|
createPR,
|
|
25
25
|
failedCheckLogs,
|
|
26
26
|
fetchIssue,
|
|
27
|
+
mergePR,
|
|
27
28
|
postComment,
|
|
28
29
|
react,
|
|
29
30
|
removeLabel,
|
|
30
31
|
replyToThread,
|
|
32
|
+
requestReview,
|
|
31
33
|
resolveThread,
|
|
32
34
|
updateIssueBody,
|
|
33
35
|
updatePRBody,
|
|
@@ -62,7 +64,12 @@ import {
|
|
|
62
64
|
export type JobDeps = {
|
|
63
65
|
root: string;
|
|
64
66
|
config: Config;
|
|
67
|
+
/** The user's own client: reads, board moves, merges. */
|
|
65
68
|
gh: Gh;
|
|
69
|
+
/** Who kanbot talks as: comments, labels, PRs. Same as `gh` in user mode. */
|
|
70
|
+
bot: Gh;
|
|
71
|
+
/** Login of the user running the daemon. */
|
|
72
|
+
me: string;
|
|
66
73
|
board: Board;
|
|
67
74
|
agents: AgentDef[];
|
|
68
75
|
provider: Provider;
|
|
@@ -145,9 +152,9 @@ export function runJob(job: Job, deps: JobDeps): Promise<void> {
|
|
|
145
152
|
// ---- plan -------------------------------------------------------------------
|
|
146
153
|
|
|
147
154
|
async function plan(job: PlanJob, deps: JobDeps) {
|
|
148
|
-
const { gh } = deps;
|
|
155
|
+
const { gh, bot } = deps;
|
|
149
156
|
const { issue } = job.item;
|
|
150
|
-
await Promise.all(job.comments.map((c) => react(
|
|
157
|
+
await Promise.all(job.comments.map((c) => react(bot, c.id, "EYES")));
|
|
151
158
|
const cwd = await deps.worktrees.detached(worktreeName(job));
|
|
152
159
|
const result = await runAgent(
|
|
153
160
|
deps,
|
|
@@ -161,13 +168,13 @@ async function plan(job: PlanJob, deps: JobDeps) {
|
|
|
161
168
|
|
|
162
169
|
// Re-read so concurrent human edits to the description are kept.
|
|
163
170
|
const fresh = await fetchIssue(gh, issue.number);
|
|
164
|
-
await updateIssueBody(
|
|
171
|
+
await updateIssueBody(bot, issue.number, withPlan(fresh.body, result.plan));
|
|
165
172
|
|
|
166
173
|
const allowed = result.labels.filter(
|
|
167
174
|
(l) => l === LABELS.technical || l.startsWith(LABELS.typePrefix),
|
|
168
175
|
);
|
|
169
176
|
await addLabels(
|
|
170
|
-
|
|
177
|
+
bot,
|
|
171
178
|
issue.number,
|
|
172
179
|
allowed.filter((l) => !fresh.labels.includes(l)),
|
|
173
180
|
);
|
|
@@ -175,12 +182,12 @@ async function plan(job: PlanJob, deps: JobDeps) {
|
|
|
175
182
|
const created: string[] = [];
|
|
176
183
|
for (const ni of result.newIssues) {
|
|
177
184
|
const res = await createIssue(
|
|
178
|
-
|
|
185
|
+
bot,
|
|
179
186
|
ni.title,
|
|
180
187
|
`${ni.body}\n\n_Split from #${issue.number} by kanbot._\n${marker("created", issue.number)}`,
|
|
181
188
|
);
|
|
182
189
|
if (!res) continue;
|
|
183
|
-
if (ni.subIssue) await addSubIssue(
|
|
190
|
+
if (ni.subIssue) await addSubIssue(bot, issue.id, res.node_id);
|
|
184
191
|
await addToBoard(gh, deps.board, res.node_id, ni.column);
|
|
185
192
|
created.push(`#${res.number}`);
|
|
186
193
|
}
|
|
@@ -206,18 +213,18 @@ async function plan(job: PlanJob, deps: JobDeps) {
|
|
|
206
213
|
lines.push(
|
|
207
214
|
[marker("plan"), ...job.comments.map((c) => marker("re", c.id))].join("\n"),
|
|
208
215
|
);
|
|
209
|
-
await postComment(
|
|
210
|
-
await Promise.all(job.comments.map((c) => react(
|
|
216
|
+
await postComment(bot, issue.number, lines.join("\n\n"));
|
|
217
|
+
await Promise.all(job.comments.map((c) => react(bot, c.id, "THUMBS_UP")));
|
|
211
218
|
}
|
|
212
219
|
|
|
213
220
|
// ---- implement ----------------------------------------------------------------
|
|
214
221
|
|
|
215
222
|
async function implement(job: ImplementJob, deps: JobDeps) {
|
|
216
|
-
const { gh, config, worktrees } = deps;
|
|
223
|
+
const { gh, bot, config, worktrees } = deps;
|
|
217
224
|
const { issue } = job.item;
|
|
218
225
|
if (job.item.column === "ready") {
|
|
219
226
|
await moveItem(gh, deps.board, job.item.itemId, "inProgress");
|
|
220
|
-
await addLabels(
|
|
227
|
+
await addLabels(bot, issue.number, [LABELS.working]);
|
|
221
228
|
}
|
|
222
229
|
|
|
223
230
|
const resumed = await worktrees.remoteHas(job.branch);
|
|
@@ -240,9 +247,9 @@ async function implement(job: ImplementJob, deps: JobDeps) {
|
|
|
240
247
|
implementPrompt(job, config.baseBranch),
|
|
241
248
|
);
|
|
242
249
|
if (result.status === "blocked") {
|
|
243
|
-
await addLabels(
|
|
250
|
+
await addLabels(bot, issue.number, [LABELS.needsHuman]);
|
|
244
251
|
await postComment(
|
|
245
|
-
|
|
252
|
+
bot,
|
|
246
253
|
issue.number,
|
|
247
254
|
`**Implementation blocked${job.variant ? ` (approach ${job.variant})` : ""}.** ${result.blockedReason ?? ""}\n\nRemove \`${LABELS.needsHuman}\` once resolved and kanbot will retry.\n${marker("blocked", job.branch)}`,
|
|
248
255
|
);
|
|
@@ -292,7 +299,7 @@ async function implement(job: ImplementJob, deps: JobDeps) {
|
|
|
292
299
|
]
|
|
293
300
|
.filter(Boolean)
|
|
294
301
|
.join("\n\n");
|
|
295
|
-
const pr = await createPR(
|
|
302
|
+
const pr = await createPR(bot, {
|
|
296
303
|
title: `${result?.prTitle ?? issue.title}${variant}`,
|
|
297
304
|
body,
|
|
298
305
|
head: job.branch,
|
|
@@ -304,7 +311,7 @@ async function implement(job: ImplementJob, deps: JobDeps) {
|
|
|
304
311
|
);
|
|
305
312
|
if (job.variant !== null)
|
|
306
313
|
labels.push(`${LABELS.variantPrefix}${job.variant}`);
|
|
307
|
-
await addLabels(
|
|
314
|
+
await addLabels(bot, pr.number, labels);
|
|
308
315
|
log.info(`✔ opened ${pr.html_url}`);
|
|
309
316
|
}
|
|
310
317
|
}
|
|
@@ -354,13 +361,13 @@ async function review(job: ReviewJob, deps: JobDeps) {
|
|
|
354
361
|
marker("review", job.pass),
|
|
355
362
|
embedJson("findings", result.findings),
|
|
356
363
|
].join("\n\n");
|
|
357
|
-
await postComment(deps.
|
|
364
|
+
await postComment(deps.bot, job.pr.number, body);
|
|
358
365
|
}
|
|
359
366
|
|
|
360
367
|
// ---- fix ----------------------------------------------------------------------
|
|
361
368
|
|
|
362
369
|
async function fix(job: FixJob, deps: JobDeps) {
|
|
363
|
-
const { gh, worktrees, config } = deps;
|
|
370
|
+
const { gh, bot, worktrees, config } = deps;
|
|
364
371
|
const { pr } = job;
|
|
365
372
|
const cwd = await worktrees.checkout(worktreeName(job), pr.headRefName);
|
|
366
373
|
if (job.reason.type === "conflict") return mergeBase(job, deps, cwd);
|
|
@@ -387,7 +394,7 @@ async function fix(job: FixJob, deps: JobDeps) {
|
|
|
387
394
|
if (changed) await worktrees.push(cwd, pr.headRefName);
|
|
388
395
|
if (result.prBody) {
|
|
389
396
|
const desc = absoluteImages(result.prBody, config.repo, pr.headRefName);
|
|
390
|
-
await updatePRBody(
|
|
397
|
+
await updatePRBody(bot, pr.number, withDescription(pr.body, desc));
|
|
391
398
|
}
|
|
392
399
|
const by = new Map(result.addressed.map((a) => [a.ref, a]));
|
|
393
400
|
const pushed = changed ? "Pushed fixes." : "No code changes.";
|
|
@@ -399,7 +406,7 @@ async function fix(job: FixJob, deps: JobDeps) {
|
|
|
399
406
|
return `- ${a?.resolved ? "✅" : "⏭️"} **${f.id}** ${a?.reply ?? "Not addressed."}`;
|
|
400
407
|
});
|
|
401
408
|
await postComment(
|
|
402
|
-
|
|
409
|
+
bot,
|
|
403
410
|
pr.number,
|
|
404
411
|
[
|
|
405
412
|
`**Addressed agent review ${r.pass}/${reviewPasses(job.item.issue).length}.** ${result.summary} ${pushed}`,
|
|
@@ -411,7 +418,7 @@ async function fix(job: FixJob, deps: JobDeps) {
|
|
|
411
418
|
}
|
|
412
419
|
if (r.type === "ci") {
|
|
413
420
|
await postComment(
|
|
414
|
-
|
|
421
|
+
bot,
|
|
415
422
|
pr.number,
|
|
416
423
|
`**CI fix attempt.** ${result.summary} ${pushed}\n\n${marker("ci", pr.headSha)}`,
|
|
417
424
|
);
|
|
@@ -423,11 +430,11 @@ async function fix(job: FixJob, deps: JobDeps) {
|
|
|
423
430
|
for (const t of r.threads) {
|
|
424
431
|
const a = by.get(t.id);
|
|
425
432
|
await replyToThread(
|
|
426
|
-
|
|
433
|
+
bot,
|
|
427
434
|
t.id,
|
|
428
435
|
`${a?.reply ?? "Looked at this but made no change."}\n\n${marker("re", t.id)}`,
|
|
429
436
|
);
|
|
430
|
-
if (a?.resolved) await resolveThread(
|
|
437
|
+
if (a?.resolved) await resolveThread(bot, t.id);
|
|
431
438
|
else open++;
|
|
432
439
|
}
|
|
433
440
|
if (r.type === "copilot") return;
|
|
@@ -438,7 +445,7 @@ async function fix(job: FixJob, deps: JobDeps) {
|
|
|
438
445
|
return `> ${excerpt(c.body)}\n\n${a?.reply ?? "Noted."}`;
|
|
439
446
|
});
|
|
440
447
|
await postComment(
|
|
441
|
-
|
|
448
|
+
bot,
|
|
442
449
|
pr.number,
|
|
443
450
|
[
|
|
444
451
|
`**Feedback round.** ${result.summary} ${pushed}`,
|
|
@@ -452,7 +459,7 @@ async function fix(job: FixJob, deps: JobDeps) {
|
|
|
452
459
|
: "All feedback addressed — ready for another look.";
|
|
453
460
|
if (!r.comments.length)
|
|
454
461
|
await postComment(
|
|
455
|
-
|
|
462
|
+
bot,
|
|
456
463
|
pr.number,
|
|
457
464
|
`${msg} ${pushed}\n\n${marker("feedback-round")}`,
|
|
458
465
|
);
|
|
@@ -469,7 +476,7 @@ const CONFLICT_MARKER = /^(<{7}|>{7})( |$)/m;
|
|
|
469
476
|
|
|
470
477
|
/** Merge the base branch into a conflicting PR; the fixer resolves conflicts. */
|
|
471
478
|
async function mergeBase(job: FixJob, deps: JobDeps, cwd: string) {
|
|
472
|
-
const {
|
|
479
|
+
const { bot, worktrees, config } = deps;
|
|
473
480
|
const { pr } = job;
|
|
474
481
|
const base = `origin/${config.baseBranch}`;
|
|
475
482
|
const files = await worktrees.merge(cwd, base);
|
|
@@ -503,7 +510,7 @@ async function mergeBase(job: FixJob, deps: JobDeps, cwd: string) {
|
|
|
503
510
|
throw new Error(`${base} was not merged`);
|
|
504
511
|
await worktrees.push(cwd, pr.headRefName);
|
|
505
512
|
await postComment(
|
|
506
|
-
|
|
513
|
+
bot,
|
|
507
514
|
pr.number,
|
|
508
515
|
`**Merged \`${config.baseBranch}\`.** ${summary}\n\n${marker("conflict", pr.headSha)}`,
|
|
509
516
|
);
|
|
@@ -512,35 +519,68 @@ async function mergeBase(job: FixJob, deps: JobDeps, cwd: string) {
|
|
|
512
519
|
// ---- deterministic actions ----------------------------------------------------
|
|
513
520
|
|
|
514
521
|
export async function applyAction(action: Action, deps: JobDeps) {
|
|
515
|
-
const { gh } = deps;
|
|
522
|
+
const { gh, bot, config } = deps;
|
|
516
523
|
switch (action.kind) {
|
|
517
524
|
case "move":
|
|
518
525
|
log.info(`#${action.item.issue.number} → ${action.to} (${action.why})`);
|
|
519
526
|
return moveItem(gh, deps.board, action.item.itemId, action.to);
|
|
520
527
|
case "label":
|
|
521
|
-
await addLabels(
|
|
522
|
-
for (const l of action.remove) await removeLabel(
|
|
528
|
+
await addLabels(bot, action.number, action.add);
|
|
529
|
+
for (const l of action.remove) await removeLabel(bot, action.number, l);
|
|
523
530
|
return;
|
|
524
|
-
case "agentReviewed":
|
|
525
|
-
await addLabels(
|
|
531
|
+
case "agentReviewed": {
|
|
532
|
+
await addLabels(bot, action.pr.number, [LABELS.agentReviewed]);
|
|
533
|
+
const next =
|
|
534
|
+
config.merge.onApproval && action.pr.headRefName.startsWith("kanbot/")
|
|
535
|
+
? "Approve to merge."
|
|
536
|
+
: "Ready for you to test and merge.";
|
|
526
537
|
await postComment(
|
|
527
|
-
|
|
538
|
+
bot,
|
|
528
539
|
action.pr.number,
|
|
529
|
-
`**Agent review complete.**
|
|
540
|
+
`**Agent review complete.** ${next}\n\n${marker("ready")}`,
|
|
530
541
|
);
|
|
542
|
+
// The bot opened the PR, so the user can be asked to review it.
|
|
543
|
+
if (bot.isBot)
|
|
544
|
+
await requestReview(bot, action.pr.number, [deps.me]).catch((e) =>
|
|
545
|
+
log.warn(`request review on #${action.pr.number}: ${e}`),
|
|
546
|
+
);
|
|
531
547
|
deps.notify.send(
|
|
532
548
|
`Ready to test: PR #${action.pr.number}`,
|
|
533
549
|
action.pr.title,
|
|
534
550
|
action.pr.url,
|
|
535
551
|
);
|
|
536
552
|
return;
|
|
553
|
+
}
|
|
554
|
+
case "merge": {
|
|
555
|
+
const { pr } = action;
|
|
556
|
+
log.info(`merging #${pr.number}, approved by ${action.by}`);
|
|
557
|
+
try {
|
|
558
|
+
await mergePR(gh, pr.number, pr.headSha, config.merge.method);
|
|
559
|
+
} catch (e) {
|
|
560
|
+
return applyAction(
|
|
561
|
+
{
|
|
562
|
+
kind: "needsHuman",
|
|
563
|
+
number: action.item.issue.number,
|
|
564
|
+
why: `Approved, but merging #${pr.number} failed:\n\n\`\`\`\n${String(e).slice(0, 1500)}\n\`\`\``,
|
|
565
|
+
},
|
|
566
|
+
deps,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
await postComment(
|
|
570
|
+
bot,
|
|
571
|
+
pr.number,
|
|
572
|
+
`**Merged** after approval by @${action.by}.\n\n${marker("merged")}`,
|
|
573
|
+
);
|
|
574
|
+
deps.notify.send(`Merged PR #${pr.number}`, pr.title, pr.url);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
537
577
|
case "compare": {
|
|
538
578
|
const rows = action.prs.map(
|
|
539
579
|
(p) =>
|
|
540
580
|
`| ${p.labels.find((l) => l.startsWith(LABELS.variantPrefix)) ?? "?"} | #${p.number} | ${p.title} |`,
|
|
541
581
|
);
|
|
542
582
|
await postComment(
|
|
543
|
-
|
|
583
|
+
bot,
|
|
544
584
|
action.item.issue.number,
|
|
545
585
|
`**All ${action.prs.length} approaches are agent-reviewed.** Try them and merge the one you want; the others are closed automatically.\n\n| Variant | PR | Title |\n|---|---|---|\n${rows.join("\n")}\n\n${marker("compare")}`,
|
|
546
586
|
);
|
|
@@ -552,9 +592,9 @@ export async function applyAction(action: Action, deps: JobDeps) {
|
|
|
552
592
|
return;
|
|
553
593
|
}
|
|
554
594
|
case "needsHuman":
|
|
555
|
-
await addLabels(
|
|
595
|
+
await addLabels(bot, action.number, [LABELS.needsHuman]);
|
|
556
596
|
await postComment(
|
|
557
|
-
|
|
597
|
+
bot,
|
|
558
598
|
action.number,
|
|
559
599
|
`**kanbot needs a human.** ${action.why}\n\nRemove \`${LABELS.needsHuman}\` to let agents retry.\n${marker("needs-human")}`,
|
|
560
600
|
);
|
package/src/known-hashes.json
CHANGED
|
@@ -19,8 +19,12 @@
|
|
|
19
19
|
],
|
|
20
20
|
".agents/skills/kanbot-admin/SKILL.md": [
|
|
21
21
|
"3789c559be9fb6a78f82708ae9857395a5556f6b",
|
|
22
|
+
"557532f241c0fc1e04f03ce8edb69efaa7b5165b",
|
|
22
23
|
"e427c711b532963c0916f07ecc6b11e9b47cb9fa"
|
|
23
24
|
],
|
|
25
|
+
".agents/skills/kanbot-guide/SKILL.md": [
|
|
26
|
+
"cf3e95d137a5a58a273262bca6ba25fbc95fa037"
|
|
27
|
+
],
|
|
24
28
|
".agents/skills/kanbot-idea/SKILL.md": [
|
|
25
29
|
"8ab28cec8d3f30250ffe080106d0de8e3c2eaa8a"
|
|
26
30
|
],
|
|
@@ -125,6 +129,7 @@
|
|
|
125
129
|
"AGENTS.md": [
|
|
126
130
|
"3da9115cbf52b7b7d0feb8325503a8caf43ff2c7",
|
|
127
131
|
"b821013b5be7051327b349eda603218590a41f33",
|
|
132
|
+
"c688ed7f42f7a6f6a5ac600de857b42fb04aa613",
|
|
128
133
|
"cf7ef593ccfda953055dfb76b3e9c267fc9637f6",
|
|
129
134
|
"e5b76bcbaf155b79a8ff3f2e70977cc0c589566b"
|
|
130
135
|
],
|
package/src/main.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const main = defineCommand({
|
|
|
10
10
|
subCommands: {
|
|
11
11
|
init: () => import("./commands/init.ts").then((m) => m.default),
|
|
12
12
|
board: () => import("./commands/board.ts").then((m) => m.default),
|
|
13
|
+
app: () => import("./commands/app.ts").then((m) => m.default),
|
|
13
14
|
upgrade: () => import("./commands/upgrade.ts").then((m) => m.default),
|
|
14
15
|
skills: () => import("./commands/skills.ts").then((m) => m.default),
|
|
15
16
|
sync: () => import("./commands/sync.ts").then((m) => m.default),
|
package/src/templates.ts
CHANGED
|
@@ -24,6 +24,7 @@ export type FileClass = "kanbot" | "user" | "shared";
|
|
|
24
24
|
const CLASSES: [prefix: string, FileClass][] = [
|
|
25
25
|
[".github/workflows/kanbot-", "kanbot"],
|
|
26
26
|
[".agents/skills/kanbot-admin/", "kanbot"],
|
|
27
|
+
[".agents/skills/kanbot-guide/", "kanbot"],
|
|
27
28
|
[".agents/skills/kanbot-idea/", "kanbot"],
|
|
28
29
|
["AGENTS.md", "user"],
|
|
29
30
|
["CLAUDE.md", "user"],
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
|
|
3
3
|
name: kanbot-admin
|
|
4
|
-
description:
|
|
4
|
+
description: Change this repo's kanbot setup with a human. Use to add or edit kanbot agents, skills or project memory, change `.kanbot/config.yml`, switch identity mode, turn on merge on approval, run `kanbot upgrade` or merge `.kanbot-new` files. Not for board jobs. For setup walkthroughs and explaining options, use kanbot-guide.
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# kanbot admin
|
|
8
8
|
|
|
9
|
-
You are helping a human maintain kanbot, the board-driven agent team in this repo. Board agents (planner, implementers, reviewers, fixer) never use this skill.
|
|
9
|
+
You are helping a human maintain kanbot, the board-driven agent team in this repo. Board agents (planner, implementers, reviewers, fixer) never use this skill. To explain options or set kanbot up from scratch, use `kanbot-guide`.
|
|
10
10
|
|
|
11
11
|
## Map
|
|
12
12
|
|
|
@@ -14,15 +14,30 @@ You are helping a human maintain kanbot, the board-driven agent team in this rep
|
|
|
14
14
|
| -------------------------- | -------------------------------------- | ------------------------------- |
|
|
15
15
|
| `.kanbot/agents/<name>.md` | Agent definitions, the source of truth | Yes, then `kanbot sync` |
|
|
16
16
|
| `.agents/skills/<name>/` | Skills shared by all agents | Yes |
|
|
17
|
-
| `.kanbot/config.yml` | Daemon config; schema next to it | Yes
|
|
17
|
+
| `.kanbot/config.yml` | Daemon config; schema next to it | Yes, then restart the daemon |
|
|
18
18
|
| `.kanbot/manifest.json` | Template hashes from the last upgrade | Never by hand |
|
|
19
19
|
| `.claude/`, `.codex/` | Generated by `kanbot sync` | Never; edit the source and sync |
|
|
20
|
+
| `~/.kanbot/apps/<id>.pem` | Private key of the kanbot GitHub App | Never commit |
|
|
20
21
|
|
|
21
|
-
`kanbot <command> --help` lists every flag.
|
|
22
|
+
`kanbot <command> --help` lists every flag. `.kanbot/config.schema.json` describes every config field. Docs: https://kanbot.mib.im.
|
|
22
23
|
|
|
23
24
|
## Recipes
|
|
24
25
|
|
|
25
|
-
Finish every recipe with `kanbot sync` when agents changed, then `kanbot doctor`, then show the human the diff. The human commits and pushes; worktrees are made from the pushed base branch, so agents see nothing until then.
|
|
26
|
+
Finish every recipe with `kanbot sync` when agents changed, then `kanbot doctor`, then show the human the diff. The human commits and pushes; worktrees are made from the pushed base branch, so agents see nothing until then. Restart the daemon after config changes.
|
|
27
|
+
|
|
28
|
+
### Switch to app mode
|
|
29
|
+
|
|
30
|
+
1. `kanbot app create`. The human clicks **Create GitHub App** on GitHub, then installs it on the repo.
|
|
31
|
+
2. Already have an app from another repo? `kanbot app link --id <id> --slug <slug>`, add the repo to its installation, and copy `~/.kanbot/apps/<id>.pem` if on another machine.
|
|
32
|
+
3. `kanbot doctor` checks the app, its key and the install.
|
|
33
|
+
|
|
34
|
+
Back to user mode: set `identity.mode: user`.
|
|
35
|
+
|
|
36
|
+
### Turn on merge on approval
|
|
37
|
+
|
|
38
|
+
1. Set `merge.onApproval: true` and pick `merge.method` (`squash`, `merge` or `rebase`).
|
|
39
|
+
2. Recommend app mode, so they can approve their own kanbot PRs.
|
|
40
|
+
3. Warn: never enable the branch rule "Require approval of the most recent reviewable push". It blocks their own approval.
|
|
26
41
|
|
|
27
42
|
### Add or change an agent
|
|
28
43
|
|