kanbot 0.1.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 +17 -1
- package/package.json +21 -2
- package/schema/config.schema.json +54 -0
- package/src/agents/load.ts +29 -7
- package/src/agents/sync.ts +6 -3
- package/src/cli.ts +24 -2
- package/src/commands/app.ts +169 -0
- package/src/commands/completion.ts +7 -1
- package/src/commands/doctor.ts +49 -4
- package/src/commands/init.ts +26 -9
- package/src/commands/skills.ts +1 -1
- package/src/commands/status.ts +11 -2
- package/src/config.ts +63 -2
- package/src/daemon/derive.ts +38 -1
- package/src/daemon/lock.ts +6 -1
- package/src/daemon/loop.ts +14 -2
- package/src/daemon/snapshot.ts +1 -0
- package/src/daemon/types.ts +17 -0
- package/src/errors.ts +171 -0
- package/src/github/api.ts +47 -2
- package/src/github/app.ts +153 -0
- package/src/github/gh.ts +29 -3
- package/src/github/project.ts +13 -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/src/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { parse, parseDocument } from "yaml";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { UserError } from "./errors.ts";
|
|
5
6
|
|
|
6
7
|
export const COLUMN_KEYS = [
|
|
7
8
|
"ideas",
|
|
@@ -111,6 +112,40 @@ export const ConfigSchema = z.object({
|
|
|
111
112
|
),
|
|
112
113
|
})
|
|
113
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({}),
|
|
114
149
|
codex: z
|
|
115
150
|
.object({
|
|
116
151
|
networkAccess: z
|
|
@@ -197,9 +232,35 @@ export async function writeConfigSchema(
|
|
|
197
232
|
export async function loadConfig(root: string): Promise<Config> {
|
|
198
233
|
const path = configPath(root);
|
|
199
234
|
if (!existsSync(path)) {
|
|
200
|
-
throw new
|
|
235
|
+
throw new UserError("kanbot is not set up in this repo.", {
|
|
236
|
+
why: "No .kanbot/config.yml in this directory.",
|
|
237
|
+
fix: "kanbot init",
|
|
238
|
+
docs: "getting-started#set-up-a-repository",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
let raw: unknown;
|
|
242
|
+
try {
|
|
243
|
+
raw = parse(await Bun.file(path).text());
|
|
244
|
+
} catch (e) {
|
|
245
|
+
throw new UserError(
|
|
246
|
+
".kanbot/config.yml is not valid YAML.",
|
|
247
|
+
{
|
|
248
|
+
why: (e as Error).message.trim(),
|
|
249
|
+
fix: "Fix the syntax at the line shown above.",
|
|
250
|
+
docs: "configuration#config-file",
|
|
251
|
+
},
|
|
252
|
+
{ cause: e },
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const config = ConfigSchema.safeParse(raw ?? {});
|
|
256
|
+
if (!config.success) {
|
|
257
|
+
throw new UserError(".kanbot/config.yml has invalid values.", {
|
|
258
|
+
why: z.prettifyError(config.error),
|
|
259
|
+
fix: "Fix the fields above. .kanbot/config.schema.json lists allowed values.",
|
|
260
|
+
docs: "configuration#config-file",
|
|
261
|
+
});
|
|
201
262
|
}
|
|
202
|
-
return
|
|
263
|
+
return config.data;
|
|
203
264
|
}
|
|
204
265
|
|
|
205
266
|
/** Sets top-level keys, keeping the user's comments and formatting. */
|
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/lock.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, rmSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { UserError } from "../errors.ts";
|
|
3
4
|
|
|
4
5
|
/** One daemon per repo. Stale locks (dead pid) are taken over. */
|
|
5
6
|
export async function acquireLock(root: string): Promise<() => void> {
|
|
@@ -7,7 +8,11 @@ export async function acquireLock(root: string): Promise<() => void> {
|
|
|
7
8
|
if (existsSync(file)) {
|
|
8
9
|
const pid = Number(await Bun.file(file).text());
|
|
9
10
|
if (pid && isAlive(pid)) {
|
|
10
|
-
throw new
|
|
11
|
+
throw new UserError("kanbot is already running for this repo.", {
|
|
12
|
+
why: `Process ${pid} holds .kanbot/daemon.pid. Only one daemon runs per repo.`,
|
|
13
|
+
fix: `Stop it with \`kill ${pid}\`, or check it with \`kanbot status\`.`,
|
|
14
|
+
docs: "usage#kanbot-run",
|
|
15
|
+
});
|
|
11
16
|
}
|
|
12
17
|
}
|
|
13
18
|
await Bun.write(file, String(process.pid));
|
package/src/daemon/loop.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { loadAgents } from "../agents/load.ts";
|
|
2
2
|
import { sync } from "../agents/sync.ts";
|
|
3
3
|
import { type Config, loadConfig } from "../config.ts";
|
|
4
|
+
import { UserError } from "../errors.ts";
|
|
5
|
+
import { viewer } from "../github/api.ts";
|
|
6
|
+
import { botClient } from "../github/app.ts";
|
|
4
7
|
import { Gh } from "../github/gh.ts";
|
|
5
8
|
import { loadBoard, missingColumns } from "../github/project.ts";
|
|
6
9
|
import { Trust } from "../github/trust.ts";
|
|
@@ -45,14 +48,21 @@ export async function runDaemon(root: string, opts: RunOptions) {
|
|
|
45
48
|
const board = await loadBoard(gh, config);
|
|
46
49
|
const missing = missingColumns(board);
|
|
47
50
|
if (missing.length)
|
|
48
|
-
throw new
|
|
51
|
+
throw new UserError("The project board is missing columns.", {
|
|
52
|
+
why: `No Status option for: ${missing.map((k) => config.columns[k]).join(", ")}.`,
|
|
53
|
+
fix: "kanbot board create adds them. Or match `columns` in .kanbot/config.yml to your board.",
|
|
54
|
+
docs: "configuration#columns",
|
|
55
|
+
});
|
|
49
56
|
|
|
50
57
|
const release = opts.dryRun ? () => {} : await acquireLock(root);
|
|
51
58
|
const state = await new State(root).load();
|
|
59
|
+
const bot = botClient(config, gh);
|
|
52
60
|
const deps: JobDeps = {
|
|
53
61
|
root,
|
|
54
62
|
config,
|
|
55
63
|
gh,
|
|
64
|
+
bot,
|
|
65
|
+
me: await viewer(gh),
|
|
56
66
|
board,
|
|
57
67
|
agents,
|
|
58
68
|
provider: providerFor(config),
|
|
@@ -77,7 +87,7 @@ export async function runDaemon(root: string, opts: RunOptions) {
|
|
|
77
87
|
process.on("SIGTERM", stop);
|
|
78
88
|
|
|
79
89
|
log.info(
|
|
80
|
-
`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" : ""}`,
|
|
81
91
|
);
|
|
82
92
|
|
|
83
93
|
try {
|
|
@@ -195,6 +205,8 @@ function describeAction(a: Action) {
|
|
|
195
205
|
return `label #${a.number} +[${a.add}] -[${a.remove}]`;
|
|
196
206
|
case "agentReviewed":
|
|
197
207
|
return `mark PR #${a.pr.number} agent-reviewed`;
|
|
208
|
+
case "merge":
|
|
209
|
+
return `merge PR #${a.pr.number}, approved by ${a.by}`;
|
|
198
210
|
case "compare":
|
|
199
211
|
return `post comparison on #${a.item.issue.number}`;
|
|
200
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/errors.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export const DOCS_URL = "https://kanbot.mib.im";
|
|
2
|
+
const ISSUES_URL = "https://github.com/mikaelbr/kanbot/issues";
|
|
3
|
+
|
|
4
|
+
export type ErrorHelp = {
|
|
5
|
+
/** Why it failed: the underlying error or the rule that was broken. */
|
|
6
|
+
why?: string;
|
|
7
|
+
/** What to do about it, usually a command. */
|
|
8
|
+
fix?: string;
|
|
9
|
+
/** Docs page and anchor, e.g. "configuration#config-file". */
|
|
10
|
+
docs?: string;
|
|
11
|
+
/** How to learn more. Defaults to `kanbot doctor`; "" hides it. */
|
|
12
|
+
more?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** An expected failure. Printed without a stack trace. */
|
|
16
|
+
export class UserError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
message: string,
|
|
19
|
+
readonly help: ErrorHelp = {},
|
|
20
|
+
options?: ErrorOptions,
|
|
21
|
+
) {
|
|
22
|
+
super(message, options);
|
|
23
|
+
this.name = "UserError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const INSTALL: Record<string, ErrorHelp> = {
|
|
28
|
+
gh: {
|
|
29
|
+
fix: "Install the GitHub CLI from https://cli.github.com, then run `gh auth login`.",
|
|
30
|
+
docs: "getting-started#requirements",
|
|
31
|
+
},
|
|
32
|
+
git: {
|
|
33
|
+
fix: "Install git from https://git-scm.com/downloads.",
|
|
34
|
+
docs: "getting-started#requirements",
|
|
35
|
+
},
|
|
36
|
+
claude: {
|
|
37
|
+
fix: "Install Claude Code, then run `claude auth login`.",
|
|
38
|
+
docs: "providers#claude-code",
|
|
39
|
+
},
|
|
40
|
+
codex: {
|
|
41
|
+
fix: "Install Codex (npm i -g @openai/codex), then run `codex login`.",
|
|
42
|
+
docs: "providers#codex",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Known failures from gh, git and the GitHub API, matched on message. */
|
|
47
|
+
const KNOWN: [RegExp, string, ErrorHelp][] = [
|
|
48
|
+
[
|
|
49
|
+
/gh auth login|not logged in|HTTP 401|Bad credentials/i,
|
|
50
|
+
"GitHub CLI is not logged in.",
|
|
51
|
+
{ fix: "gh auth login", docs: "getting-started#requirements" },
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
/INSUFFICIENT_SCOPES|required scopes|scope.*project/i,
|
|
55
|
+
"GitHub token is missing the `project` scope.",
|
|
56
|
+
{ fix: "gh auth refresh -s project", docs: "getting-started#requirements" },
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
/rate limit/i,
|
|
60
|
+
"GitHub API rate limit reached.",
|
|
61
|
+
{ fix: "Wait a few minutes and try again." },
|
|
62
|
+
],
|
|
63
|
+
[
|
|
64
|
+
/Could not resolve to a ProjectV2/i,
|
|
65
|
+
"Project board not found.",
|
|
66
|
+
{
|
|
67
|
+
fix: "Check `project` in .kanbot/config.yml, or run `kanbot board create`.",
|
|
68
|
+
docs: "getting-started#create-the-board",
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
[
|
|
72
|
+
/Could not resolve to a Repository|HTTP 404/i,
|
|
73
|
+
"GitHub repository not found, or gh has no access to it.",
|
|
74
|
+
{
|
|
75
|
+
fix: "Check `repo` in .kanbot/config.yml and run `gh repo view <owner/name>`.",
|
|
76
|
+
docs: "configuration#config-file",
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
/not a git repository/i,
|
|
81
|
+
"Not inside a git repository.",
|
|
82
|
+
{ fix: "cd into your repo, or run `git init`." },
|
|
83
|
+
],
|
|
84
|
+
[
|
|
85
|
+
/no git remotes|none of the git remotes/i,
|
|
86
|
+
"This repo has no GitHub remote.",
|
|
87
|
+
{
|
|
88
|
+
fix: "git remote add origin <url> && git push -u origin HEAD",
|
|
89
|
+
docs: "getting-started#set-up-a-repository",
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
[
|
|
93
|
+
/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|dial tcp|error connecting/i,
|
|
94
|
+
"Could not reach GitHub.",
|
|
95
|
+
{ fix: "Check your network connection and try again." },
|
|
96
|
+
],
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
export type Described = ErrorHelp & { message: string; expected: boolean };
|
|
100
|
+
|
|
101
|
+
export function describeError(e: unknown, command = ""): Described {
|
|
102
|
+
if (e instanceof UserError)
|
|
103
|
+
return { message: e.message, ...e.help, expected: true };
|
|
104
|
+
if (!(e instanceof Error)) return { message: String(e), expected: false };
|
|
105
|
+
const raw = e.message.trim();
|
|
106
|
+
|
|
107
|
+
// citty argument errors: unknown command, bad flag value, …
|
|
108
|
+
if (e.name === "CLIError") {
|
|
109
|
+
const unknown = (e as { code?: string }).code === "E_UNKNOWN_COMMAND";
|
|
110
|
+
return {
|
|
111
|
+
message: Bun.stripANSI(raw),
|
|
112
|
+
fix: `Run \`kanbot ${unknown ? "" : `${command} `}--help\` to see valid options.`,
|
|
113
|
+
docs: "usage",
|
|
114
|
+
more: "",
|
|
115
|
+
expected: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const bin = raw.match(/Executable not found in \$PATH: "(.+)"/)?.[1];
|
|
120
|
+
if (bin)
|
|
121
|
+
return {
|
|
122
|
+
message: `\`${bin}\` is not installed or not on PATH.`,
|
|
123
|
+
why: raw,
|
|
124
|
+
...INSTALL[bin],
|
|
125
|
+
expected: true,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const known = KNOWN.find(([re]) => re.test(raw));
|
|
129
|
+
if (known)
|
|
130
|
+
return { message: known[1], why: raw, ...known[2], expected: true };
|
|
131
|
+
const tool = raw.match(/^(gh|git) .* failed/)?.[1];
|
|
132
|
+
if (tool)
|
|
133
|
+
return {
|
|
134
|
+
message: `A \`${tool}\` command failed.`,
|
|
135
|
+
why: raw,
|
|
136
|
+
fix:
|
|
137
|
+
tool === "gh"
|
|
138
|
+
? "Read the gh error above. `gh auth status` shows login and scopes."
|
|
139
|
+
: "Read the git error above. `git status` shows the repo state.",
|
|
140
|
+
expected: true,
|
|
141
|
+
};
|
|
142
|
+
return { message: raw, expected: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const indent = (s: string) => s.replace(/^(?=.)/gm, " ");
|
|
146
|
+
|
|
147
|
+
export function formatError(
|
|
148
|
+
e: unknown,
|
|
149
|
+
opts: { debug?: boolean; command?: string } = {},
|
|
150
|
+
): string {
|
|
151
|
+
const d = describeError(e, opts.command);
|
|
152
|
+
const lines = [`✖ ${d.message}`];
|
|
153
|
+
if (d.why) lines.push("", indent(d.why));
|
|
154
|
+
lines.push("");
|
|
155
|
+
if (d.fix) lines.push(` Fix: ${d.fix}`);
|
|
156
|
+
if (d.docs) lines.push(` Docs: ${DOCS_URL}/${d.docs}`);
|
|
157
|
+
if (opts.debug && e instanceof Error && e.stack) {
|
|
158
|
+
lines.push("", e.stack);
|
|
159
|
+
if (e.cause instanceof Error && e.cause.stack)
|
|
160
|
+
lines.push("", `Caused by: ${e.cause.stack}`);
|
|
161
|
+
} else if (d.expected) {
|
|
162
|
+
const more = d.more ?? "Run `kanbot doctor` to check your setup.";
|
|
163
|
+
if (more) lines.push(` More: ${more}`);
|
|
164
|
+
} else {
|
|
165
|
+
lines.push(
|
|
166
|
+
" More: rerun with KANBOT_DEBUG=1 for a stack trace.",
|
|
167
|
+
` If it looks like a bug, report it at ${ISSUES_URL}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
package/src/github/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
|
|
|
@@ -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
|
+
}
|