kanbot 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +112 -2
- package/package.json +75 -5
- package/schema/config.schema.json +206 -0
- package/src/agents/load.ts +105 -0
- package/src/agents/sync.ts +219 -0
- package/src/cli.ts +27 -0
- package/src/commands/board.ts +140 -0
- package/src/commands/completion.ts +30 -0
- package/src/commands/doctor.ts +260 -0
- package/src/commands/init.ts +119 -0
- package/src/commands/run.ts +32 -0
- package/src/commands/skills.ts +63 -0
- package/src/commands/status.ts +70 -0
- package/src/commands/sync.ts +31 -0
- package/src/commands/upgrade.ts +70 -0
- package/src/completion.ts +217 -0
- package/src/config.ts +240 -0
- package/src/daemon/derive.ts +413 -0
- package/src/daemon/git.ts +187 -0
- package/src/daemon/lock.ts +29 -0
- package/src/daemon/loop.ts +212 -0
- package/src/daemon/notify.ts +44 -0
- package/src/daemon/snapshot.ts +109 -0
- package/src/daemon/state.ts +60 -0
- package/src/daemon/types.ts +73 -0
- package/src/errors.ts +171 -0
- package/src/github/api.ts +332 -0
- package/src/github/gh.ts +110 -0
- package/src/github/markers.ts +82 -0
- package/src/github/project.ts +288 -0
- package/src/github/trust.ts +38 -0
- package/src/jobs/prompts.ts +205 -0
- package/src/jobs/run.ts +569 -0
- package/src/known-hashes.json +132 -0
- package/src/log.ts +11 -0
- package/src/main.ts +21 -0
- package/src/providers/claude.ts +122 -0
- package/src/providers/codex.ts +98 -0
- package/src/providers/types.ts +94 -0
- package/src/schemas/results.ts +116 -0
- package/src/templates.ts +192 -0
- package/templates/.agents/skills/accessibility/SKILL.md +76 -0
- package/templates/.agents/skills/approach-designer/SKILL.md +79 -0
- package/templates/.agents/skills/code-review/SKILL.md +83 -0
- package/templates/.agents/skills/frontend-design/LICENSE.txt +177 -0
- package/templates/.agents/skills/frontend-design/SKILL.md +76 -0
- package/templates/.agents/skills/issue-splitting/SKILL.md +78 -0
- package/templates/.agents/skills/kanbot-admin/SKILL.md +57 -0
- package/templates/.agents/skills/kanbot-idea/SKILL.md +56 -0
- package/templates/.agents/skills/plan-template/SKILL.md +85 -0
- package/templates/.agents/skills/pr-description/SKILL.md +44 -0
- package/templates/.agents/skills/project-conventions/SKILL.md +79 -0
- package/templates/.agents/skills/project-memory/SKILL.md +39 -0
- package/templates/.agents/skills/project-memory/facts/.gitkeep +0 -0
- package/templates/.agents/skills/spec-review/SKILL.md +73 -0
- package/templates/.agents/skills/tdd/SKILL.md +51 -0
- package/templates/.agents/skills/user-stories/SKILL.md +82 -0
- package/templates/.agents/skills/verify-ui/SKILL.md +104 -0
- package/templates/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/templates/.github/ISSUE_TEMPLATE/technical.yml +15 -0
- package/templates/.github/ISSUE_TEMPLATE/user-story.yml +15 -0
- package/templates/.github/pull_request_template.md +15 -0
- package/templates/.github/workflows/ci.yml +23 -0
- package/templates/.github/workflows/kanbot-merged.yml +30 -0
- package/templates/.kanbot/agents/approach-designer.md +31 -0
- package/templates/.kanbot/agents/explorer.md +35 -0
- package/templates/.kanbot/agents/fixer.md +54 -0
- package/templates/.kanbot/agents/implementer-backend.md +57 -0
- package/templates/.kanbot/agents/implementer-docs.md +55 -0
- package/templates/.kanbot/agents/implementer-frontend.md +54 -0
- package/templates/.kanbot/agents/implementer-technical.md +53 -0
- package/templates/.kanbot/agents/plan-critic.md +36 -0
- package/templates/.kanbot/agents/planner.md +52 -0
- package/templates/.kanbot/agents/reviewer-code.md +46 -0
- package/templates/.kanbot/agents/reviewer-spec.md +49 -0
- package/templates/.kanbot/agents/test-writer.md +35 -0
- package/templates/.kanbot/agents/ui-verifier.md +35 -0
- package/templates/AGENTS.md +39 -0
- package/templates/CLAUDE.md +1 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse, parseDocument } from "yaml";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { UserError } from "./errors.ts";
|
|
6
|
+
|
|
7
|
+
export const COLUMN_KEYS = [
|
|
8
|
+
"ideas",
|
|
9
|
+
"planning",
|
|
10
|
+
"ready",
|
|
11
|
+
"inProgress",
|
|
12
|
+
"review",
|
|
13
|
+
"done",
|
|
14
|
+
] as const;
|
|
15
|
+
export type ColumnKey = (typeof COLUMN_KEYS)[number];
|
|
16
|
+
|
|
17
|
+
export const JOB_KINDS = ["fix", "review", "plan", "implement"] as const;
|
|
18
|
+
export type JobKind = (typeof JOB_KINDS)[number];
|
|
19
|
+
|
|
20
|
+
export const ConfigSchema = z.object({
|
|
21
|
+
provider: z
|
|
22
|
+
.enum(["claude", "codex"])
|
|
23
|
+
.default("claude")
|
|
24
|
+
.describe("Agent CLI that runs workers."),
|
|
25
|
+
repo: z
|
|
26
|
+
.string()
|
|
27
|
+
.regex(/^[^/]+\/[^/]+$/, "expected owner/name")
|
|
28
|
+
.describe("GitHub repo as owner/name."),
|
|
29
|
+
baseBranch: z.string().default("main").describe("Branch agents work from."),
|
|
30
|
+
project: z
|
|
31
|
+
.object({ owner: z.string(), number: z.number().int() })
|
|
32
|
+
.nullable()
|
|
33
|
+
.default(null)
|
|
34
|
+
.describe("GitHub Project board. Set by `kanbot board create`."),
|
|
35
|
+
columns: z
|
|
36
|
+
.object({
|
|
37
|
+
ideas: z.string().default("Ideas"),
|
|
38
|
+
planning: z.string().default("Planning"),
|
|
39
|
+
ready: z.string().default("Ready"),
|
|
40
|
+
inProgress: z.string().default("In Progress"),
|
|
41
|
+
review: z.string().default("Review"),
|
|
42
|
+
done: z.string().default("Done"),
|
|
43
|
+
})
|
|
44
|
+
.prefault({})
|
|
45
|
+
.describe("Names of the board's Status options."),
|
|
46
|
+
concurrency: z
|
|
47
|
+
.number()
|
|
48
|
+
.int()
|
|
49
|
+
.min(1)
|
|
50
|
+
.default(3)
|
|
51
|
+
.describe(
|
|
52
|
+
"Max concurrent agent workers. Each exploratory variant is one worker.",
|
|
53
|
+
),
|
|
54
|
+
pollSeconds: z
|
|
55
|
+
.number()
|
|
56
|
+
.int()
|
|
57
|
+
.min(10)
|
|
58
|
+
.default(60)
|
|
59
|
+
.describe("Seconds between board polls."),
|
|
60
|
+
priority: z
|
|
61
|
+
.array(z.enum(JOB_KINDS))
|
|
62
|
+
.default([...JOB_KINDS])
|
|
63
|
+
.describe("Order in which waiting jobs get free slots."),
|
|
64
|
+
maxAttempts: z
|
|
65
|
+
.number()
|
|
66
|
+
.int()
|
|
67
|
+
.min(1)
|
|
68
|
+
.default(2)
|
|
69
|
+
.describe("Attempts per job before the issue is labeled needs-human."),
|
|
70
|
+
timeoutMinutes: z
|
|
71
|
+
.object({
|
|
72
|
+
plan: z.number().default(20),
|
|
73
|
+
implement: z.number().default(90),
|
|
74
|
+
review: z.number().default(30),
|
|
75
|
+
fix: z.number().default(45),
|
|
76
|
+
})
|
|
77
|
+
.prefault({})
|
|
78
|
+
.describe("Per-job timeout in minutes."),
|
|
79
|
+
implementers: z
|
|
80
|
+
.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
|
+
})
|
|
88
|
+
.describe(
|
|
89
|
+
"Maps a type:<key> label to an agent. `default` is the fallback.",
|
|
90
|
+
),
|
|
91
|
+
review: z
|
|
92
|
+
.object({
|
|
93
|
+
copilot: z
|
|
94
|
+
.boolean()
|
|
95
|
+
.default(false)
|
|
96
|
+
.describe(
|
|
97
|
+
"Wait for GitHub Copilot's code review and have the fixer address its comments. Needs a paid Copilot plan; each review uses credits.",
|
|
98
|
+
),
|
|
99
|
+
copilotOnPush: z
|
|
100
|
+
.boolean()
|
|
101
|
+
.default(false)
|
|
102
|
+
.describe(
|
|
103
|
+
"Have Copilot review again after every push. One review per push.",
|
|
104
|
+
),
|
|
105
|
+
copilotWaitMinutes: z
|
|
106
|
+
.number()
|
|
107
|
+
.int()
|
|
108
|
+
.min(1)
|
|
109
|
+
.default(15)
|
|
110
|
+
.describe(
|
|
111
|
+
"Minutes to wait for Copilot's review before going on without it.",
|
|
112
|
+
),
|
|
113
|
+
})
|
|
114
|
+
.prefault({}),
|
|
115
|
+
codex: z
|
|
116
|
+
.object({
|
|
117
|
+
networkAccess: z
|
|
118
|
+
.boolean()
|
|
119
|
+
.default(false)
|
|
120
|
+
.describe("Let codex workers reach the network (package installs)."),
|
|
121
|
+
})
|
|
122
|
+
.prefault({}),
|
|
123
|
+
notify: z
|
|
124
|
+
.object({
|
|
125
|
+
desktop: z
|
|
126
|
+
.boolean()
|
|
127
|
+
.default(true)
|
|
128
|
+
.describe("Show desktop notifications."),
|
|
129
|
+
webhook: z
|
|
130
|
+
.string()
|
|
131
|
+
.url()
|
|
132
|
+
.nullable()
|
|
133
|
+
.default(null)
|
|
134
|
+
.describe(
|
|
135
|
+
"POSTs {title, message, url} as JSON, e.g. an ntfy.sh topic URL.",
|
|
136
|
+
),
|
|
137
|
+
})
|
|
138
|
+
.prefault({}),
|
|
139
|
+
});
|
|
140
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
141
|
+
|
|
142
|
+
export const LABELS = {
|
|
143
|
+
technical: "technical",
|
|
144
|
+
working: "kanbot:working",
|
|
145
|
+
agentReviewed: "agent-reviewed",
|
|
146
|
+
needsHuman: "needs-human",
|
|
147
|
+
review: "kanbot-review",
|
|
148
|
+
explorePrefix: "explore:",
|
|
149
|
+
typePrefix: "type:",
|
|
150
|
+
variantPrefix: "variant:",
|
|
151
|
+
} as const;
|
|
152
|
+
|
|
153
|
+
export function configPath(root: string) {
|
|
154
|
+
return join(root, ".kanbot", "config.yml");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function configSchemaPath(root: string) {
|
|
158
|
+
return join(root, ".kanbot", "config.schema.json");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Points editors (yaml-language-server) at the schema next to the config. */
|
|
162
|
+
const SCHEMA_HEADER = "# yaml-language-server: $schema=./config.schema.json\n";
|
|
163
|
+
|
|
164
|
+
/** JSON Schema for config.yml. Fields with defaults are optional. */
|
|
165
|
+
export function configJsonSchema() {
|
|
166
|
+
return {
|
|
167
|
+
...z.toJSONSchema(ConfigSchema, { io: "input", target: "draft-7" }),
|
|
168
|
+
title: "kanbot config",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Ignores formatting, so a formatter touching the file doesn't make it stale. */
|
|
173
|
+
export function sameJson(a: string, b: string) {
|
|
174
|
+
try {
|
|
175
|
+
return JSON.stringify(JSON.parse(a)) === JSON.stringify(JSON.parse(b));
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function renderConfigSchema(): string {
|
|
182
|
+
return `${JSON.stringify(configJsonSchema(), null, "\t")}\n`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Writes .kanbot/config.schema.json. Returns true if it was missing or stale. */
|
|
186
|
+
export async function writeConfigSchema(
|
|
187
|
+
root: string,
|
|
188
|
+
opts: { check?: boolean } = {},
|
|
189
|
+
): Promise<boolean> {
|
|
190
|
+
const path = configSchemaPath(root);
|
|
191
|
+
const next = renderConfigSchema();
|
|
192
|
+
if (existsSync(path) && sameJson(await Bun.file(path).text(), next))
|
|
193
|
+
return false;
|
|
194
|
+
if (!opts.check) await Bun.write(path, next);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function loadConfig(root: string): Promise<Config> {
|
|
199
|
+
const path = configPath(root);
|
|
200
|
+
if (!existsSync(path)) {
|
|
201
|
+
throw new UserError("kanbot is not set up in this repo.", {
|
|
202
|
+
why: "No .kanbot/config.yml in this directory.",
|
|
203
|
+
fix: "kanbot init",
|
|
204
|
+
docs: "getting-started#set-up-a-repository",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
let raw: unknown;
|
|
208
|
+
try {
|
|
209
|
+
raw = parse(await Bun.file(path).text());
|
|
210
|
+
} catch (e) {
|
|
211
|
+
throw new UserError(
|
|
212
|
+
".kanbot/config.yml is not valid YAML.",
|
|
213
|
+
{
|
|
214
|
+
why: (e as Error).message.trim(),
|
|
215
|
+
fix: "Fix the syntax at the line shown above.",
|
|
216
|
+
docs: "configuration#config-file",
|
|
217
|
+
},
|
|
218
|
+
{ cause: e },
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const config = ConfigSchema.safeParse(raw ?? {});
|
|
222
|
+
if (!config.success) {
|
|
223
|
+
throw new UserError(".kanbot/config.yml has invalid values.", {
|
|
224
|
+
why: z.prettifyError(config.error),
|
|
225
|
+
fix: "Fix the fields above. .kanbot/config.schema.json lists allowed values.",
|
|
226
|
+
docs: "configuration#config-file",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return config.data;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Sets top-level keys, keeping the user's comments and formatting. */
|
|
233
|
+
export async function saveConfig(root: string, patch: Partial<Config>) {
|
|
234
|
+
const path = configPath(root);
|
|
235
|
+
const text = existsSync(path) ? await Bun.file(path).text() : SCHEMA_HEADER;
|
|
236
|
+
const doc = parseDocument(text);
|
|
237
|
+
for (const [key, value] of Object.entries(patch)) doc.set(key, value);
|
|
238
|
+
await Bun.write(path, String(doc));
|
|
239
|
+
await writeConfigSchema(root);
|
|
240
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ColumnKey,
|
|
3
|
+
type Config,
|
|
4
|
+
type JobKind,
|
|
5
|
+
LABELS,
|
|
6
|
+
} from "../config.ts";
|
|
7
|
+
import {
|
|
8
|
+
extractPlan,
|
|
9
|
+
hasMarker,
|
|
10
|
+
isKanbot,
|
|
11
|
+
markerValues,
|
|
12
|
+
readJson,
|
|
13
|
+
} from "../github/markers.ts";
|
|
14
|
+
import type { Finding } from "../schemas/results.ts";
|
|
15
|
+
import type {
|
|
16
|
+
Comment,
|
|
17
|
+
Issue,
|
|
18
|
+
Item,
|
|
19
|
+
PR,
|
|
20
|
+
ReviewThread,
|
|
21
|
+
Snapshot,
|
|
22
|
+
} from "./types.ts";
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* Pure decision core: board snapshot in, work out. Everything the daemon does
|
|
26
|
+
* is decided here, so it can be tested exhaustively without GitHub or LLMs.
|
|
27
|
+
* State lives on GitHub (columns, labels, markers); `ctx` only carries what is
|
|
28
|
+
* running right now and local failure counts.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export type PlanJob = {
|
|
32
|
+
kind: "plan";
|
|
33
|
+
key: string;
|
|
34
|
+
item: Item;
|
|
35
|
+
/** Human comments not yet answered. */
|
|
36
|
+
comments: Comment[];
|
|
37
|
+
initial: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ImplementJob = {
|
|
41
|
+
kind: "implement";
|
|
42
|
+
key: string;
|
|
43
|
+
item: Item;
|
|
44
|
+
variant: number | null;
|
|
45
|
+
variants: number;
|
|
46
|
+
branch: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type ReviewJob = {
|
|
50
|
+
kind: "review";
|
|
51
|
+
key: string;
|
|
52
|
+
item: Item;
|
|
53
|
+
pr: PR;
|
|
54
|
+
pass: 1 | 2;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type FixJob = {
|
|
58
|
+
kind: "fix";
|
|
59
|
+
key: string;
|
|
60
|
+
item: Item;
|
|
61
|
+
pr: PR;
|
|
62
|
+
reason:
|
|
63
|
+
| { type: "review"; pass: 1 | 2; findings: Finding[] }
|
|
64
|
+
| { type: "feedback"; threads: ReviewThread[]; comments: Comment[] }
|
|
65
|
+
| { type: "ci" }
|
|
66
|
+
| { type: "conflict" }
|
|
67
|
+
| { type: "copilot"; threads: ReviewThread[] };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type Job = PlanJob | ImplementJob | ReviewJob | FixJob;
|
|
71
|
+
|
|
72
|
+
export type Action =
|
|
73
|
+
| { kind: "move"; item: Item; to: ColumnKey; why: string }
|
|
74
|
+
| { kind: "label"; number: number; add: string[]; remove: string[] }
|
|
75
|
+
| { kind: "agentReviewed"; item: Item; pr: PR }
|
|
76
|
+
| { kind: "compare"; item: Item; prs: PR[] }
|
|
77
|
+
| { kind: "needsHuman"; number: number; why: string }
|
|
78
|
+
| { kind: "resetFailures"; prefix: string }
|
|
79
|
+
| { kind: "cleanup"; number: number };
|
|
80
|
+
|
|
81
|
+
export type DeriveContext = {
|
|
82
|
+
config: Config;
|
|
83
|
+
/** Keys of jobs currently running. */
|
|
84
|
+
running: ReadonlySet<string>;
|
|
85
|
+
/** Failure count per job key. */
|
|
86
|
+
failures: ReadonlyMap<string, number>;
|
|
87
|
+
/** Current time in ms, for waiting on Copilot. */
|
|
88
|
+
now: number;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const REVIEW_PASSES = [1, 2] as const;
|
|
92
|
+
|
|
93
|
+
/** PRs without a card have no plan to check, so they skip the spec pass. */
|
|
94
|
+
export const reviewPasses = (issue: Issue) =>
|
|
95
|
+
issue.prOnly ? REVIEW_PASSES.slice(0, 1) : REVIEW_PASSES;
|
|
96
|
+
const MAX_CI_FIXES = 3;
|
|
97
|
+
const MAX_CONFLICT_FIXES = 3;
|
|
98
|
+
|
|
99
|
+
export function exploreCount(labels: string[]): number | null {
|
|
100
|
+
for (const l of labels) {
|
|
101
|
+
if (!l.startsWith(LABELS.explorePrefix)) continue;
|
|
102
|
+
const n = Number(l.slice(LABELS.explorePrefix.length));
|
|
103
|
+
if (Number.isInteger(n) && n >= 2 && n <= 4) return n;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function branchFor(issue: number, variant: number | null) {
|
|
109
|
+
return variant === null ? `kanbot/${issue}` : `kanbot/${issue}-v${variant}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function variantOf(branch: string): number | null {
|
|
113
|
+
const m = branch.match(/^kanbot\/\d+-v(\d)$/);
|
|
114
|
+
return m ? Number(m[1]) : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const isHuman = (c: Comment, trusted: ReadonlySet<string>) =>
|
|
118
|
+
!isKanbot(c.body) && trusted.has(c.author);
|
|
119
|
+
|
|
120
|
+
/** Comment ids already answered, via `<!-- kanbot:re:<id> -->` markers. */
|
|
121
|
+
function answered(comments: Comment[]): Set<string> {
|
|
122
|
+
return new Set(comments.flatMap((c) => markerValues(c.body, "re")));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function pendingComments(
|
|
126
|
+
comments: Comment[],
|
|
127
|
+
trusted: ReadonlySet<string>,
|
|
128
|
+
) {
|
|
129
|
+
const done = answered(comments);
|
|
130
|
+
return comments.filter((c) => isHuman(c, trusted) && !done.has(c.id));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function reviewComment(pr: PR, pass: number) {
|
|
134
|
+
return pr.comments.find((c) => hasMarker(c.body, "review", pass));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function reviewFindings(pr: PR, pass: number): Finding[] {
|
|
138
|
+
const c = reviewComment(pr, pass);
|
|
139
|
+
return c ? (readJson<Finding[]>(c.body, "findings") ?? []) : [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function openFeedback(pr: PR, trusted: ReadonlySet<string>) {
|
|
143
|
+
const threads = pr.threads.filter((t) => {
|
|
144
|
+
const last = t.comments.at(-1);
|
|
145
|
+
return !t.isResolved && last !== undefined && isHuman(last, trusted);
|
|
146
|
+
});
|
|
147
|
+
// Top-level comments and review bodies are answered by PR comments with re-markers.
|
|
148
|
+
const comments = pendingComments([...pr.comments, ...pr.reviews], trusted);
|
|
149
|
+
return { threads, comments };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** GitHub Copilot code review's login, with or without the `[bot]` suffix. */
|
|
153
|
+
export const isCopilot = (login: string) =>
|
|
154
|
+
login.startsWith("copilot-pull-request-reviewer");
|
|
155
|
+
|
|
156
|
+
/** Unresolved Copilot threads nobody has answered yet. */
|
|
157
|
+
export function copilotThreads(pr: PR) {
|
|
158
|
+
return pr.threads.filter((t) => {
|
|
159
|
+
const last = t.comments.at(-1);
|
|
160
|
+
return !t.isResolved && last !== undefined && isCopilot(last.author);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const fixCount = (pr: PR, kind: "ci" | "conflict") =>
|
|
165
|
+
pr.comments.reduce((n, c) => n + markerValues(c.body, kind).length, 0);
|
|
166
|
+
|
|
167
|
+
export function derive(snapshot: Snapshot, ctx: DeriveContext) {
|
|
168
|
+
const jobs: Job[] = [];
|
|
169
|
+
const actions: Action[] = [];
|
|
170
|
+
const { trusted } = snapshot;
|
|
171
|
+
const { config } = ctx;
|
|
172
|
+
|
|
173
|
+
const blocked = (issue: Issue, key: string) => {
|
|
174
|
+
if (ctx.running.has(key)) return true;
|
|
175
|
+
if (issue.labels.includes(LABELS.needsHuman)) return true;
|
|
176
|
+
return (ctx.failures.get(key) ?? 0) >= config.maxAttempts;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const items = [...snapshot.items].sort((a, b) => a.position - b.position);
|
|
180
|
+
|
|
181
|
+
for (const item of items) {
|
|
182
|
+
const { issue } = item;
|
|
183
|
+
|
|
184
|
+
if (issue.state === "CLOSED" || item.column === "done") {
|
|
185
|
+
actions.push({ kind: "cleanup", number: issue.number });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Human removed needs-human: give the agents fresh attempts.
|
|
190
|
+
if (!issue.labels.includes(LABELS.needsHuman)) {
|
|
191
|
+
const prefix = `#${issue.number}:`;
|
|
192
|
+
for (const [key, n] of ctx.failures) {
|
|
193
|
+
if (key.includes(prefix) && n >= config.maxAttempts) {
|
|
194
|
+
actions.push({ kind: "resetFailures", prefix });
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
switch (item.column) {
|
|
201
|
+
case "planning": {
|
|
202
|
+
const key = `plan:#${issue.number}:`;
|
|
203
|
+
if (blocked(issue, key)) break;
|
|
204
|
+
const comments = pendingComments(issue.comments, trusted);
|
|
205
|
+
const initial = extractPlan(issue.body) === null;
|
|
206
|
+
if (initial || comments.length) {
|
|
207
|
+
jobs.push({ kind: "plan", key, item, comments, initial });
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
case "ready":
|
|
213
|
+
case "inProgress": {
|
|
214
|
+
const n = exploreCount(issue.labels);
|
|
215
|
+
const variants =
|
|
216
|
+
n === null ? [null] : Array.from({ length: n }, (_, i) => i + 1);
|
|
217
|
+
let missing = 0;
|
|
218
|
+
for (const variant of variants) {
|
|
219
|
+
const branch = branchFor(issue.number, variant);
|
|
220
|
+
if (
|
|
221
|
+
issue.prs.some(
|
|
222
|
+
(p) => p.headRefName === branch && p.state !== "CLOSED",
|
|
223
|
+
)
|
|
224
|
+
) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
missing++;
|
|
228
|
+
const key = `implement:#${issue.number}:${variant ?? 1}`;
|
|
229
|
+
if (blocked(issue, key)) continue;
|
|
230
|
+
jobs.push({
|
|
231
|
+
kind: "implement",
|
|
232
|
+
key,
|
|
233
|
+
item,
|
|
234
|
+
variant,
|
|
235
|
+
variants: variants.length,
|
|
236
|
+
branch,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (missing === 0 && item.column === "inProgress") {
|
|
240
|
+
actions.push({
|
|
241
|
+
kind: "move",
|
|
242
|
+
item,
|
|
243
|
+
to: "review",
|
|
244
|
+
why: "all PRs opened",
|
|
245
|
+
});
|
|
246
|
+
actions.push({
|
|
247
|
+
kind: "label",
|
|
248
|
+
number: issue.number,
|
|
249
|
+
add: [],
|
|
250
|
+
remove: [LABELS.working],
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
case "review": {
|
|
257
|
+
const open = issue.prs.filter(
|
|
258
|
+
(p) =>
|
|
259
|
+
p.state === "OPEN" &&
|
|
260
|
+
(issue.prOnly || p.headRefName.startsWith("kanbot/")),
|
|
261
|
+
);
|
|
262
|
+
for (const pr of open) {
|
|
263
|
+
const job = prJob(item, pr, trusted, ctx, blocked);
|
|
264
|
+
if (job === "agentReviewed")
|
|
265
|
+
actions.push({ kind: "agentReviewed", item, pr });
|
|
266
|
+
else if (job === "ciExhausted") {
|
|
267
|
+
actions.push({
|
|
268
|
+
kind: "needsHuman",
|
|
269
|
+
number: issue.number,
|
|
270
|
+
why: `CI still failing on #${pr.number} after ${MAX_CI_FIXES} fix attempts`,
|
|
271
|
+
});
|
|
272
|
+
} else if (job === "conflictExhausted") {
|
|
273
|
+
actions.push({
|
|
274
|
+
kind: "needsHuman",
|
|
275
|
+
number: issue.number,
|
|
276
|
+
why: `#${pr.number} still conflicts with the base branch after ${MAX_CONFLICT_FIXES} merge attempts`,
|
|
277
|
+
});
|
|
278
|
+
} else if (job) jobs.push(job);
|
|
279
|
+
}
|
|
280
|
+
if (
|
|
281
|
+
exploreCount(issue.labels) !== null &&
|
|
282
|
+
open.length > 1 &&
|
|
283
|
+
open.every((p) => p.labels.includes(LABELS.agentReviewed)) &&
|
|
284
|
+
!issue.comments.some((c) => hasMarker(c.body, "compare"))
|
|
285
|
+
) {
|
|
286
|
+
actions.push({ kind: "compare", item, prs: open });
|
|
287
|
+
}
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { jobs, actions };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function prJob(
|
|
297
|
+
item: Item,
|
|
298
|
+
pr: PR,
|
|
299
|
+
trusted: ReadonlySet<string>,
|
|
300
|
+
ctx: DeriveContext,
|
|
301
|
+
blocked: (issue: Issue, key: string) => boolean,
|
|
302
|
+
): Job | "agentReviewed" | "ciExhausted" | "conflictExhausted" | null {
|
|
303
|
+
const { issue } = item;
|
|
304
|
+
const prKey = `#${issue.number}:pr${pr.number}`;
|
|
305
|
+
// One job per PR at a time, whatever its kind.
|
|
306
|
+
if ([...ctx.running].some((k) => k.endsWith(prKey))) return null;
|
|
307
|
+
|
|
308
|
+
// 1. Human feedback always comes first.
|
|
309
|
+
const feedback = openFeedback(pr, trusted);
|
|
310
|
+
if (feedback.threads.length || feedback.comments.length) {
|
|
311
|
+
const key = `fix:${prKey}`;
|
|
312
|
+
if (blocked(issue, key)) return null;
|
|
313
|
+
return {
|
|
314
|
+
kind: "fix",
|
|
315
|
+
key,
|
|
316
|
+
item,
|
|
317
|
+
pr,
|
|
318
|
+
reason: { type: "feedback", ...feedback },
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 2. Conflicts with the base branch, e.g. after commits made outside kanbot.
|
|
323
|
+
if (pr.mergeable === "CONFLICTING") {
|
|
324
|
+
if (!pr.comments.some((c) => hasMarker(c.body, "conflict", pr.headSha))) {
|
|
325
|
+
if (fixCount(pr, "conflict") >= MAX_CONFLICT_FIXES) {
|
|
326
|
+
return issue.labels.includes(LABELS.needsHuman)
|
|
327
|
+
? null
|
|
328
|
+
: "conflictExhausted";
|
|
329
|
+
}
|
|
330
|
+
const key = `fix:${prKey}`;
|
|
331
|
+
if (blocked(issue, key)) return null;
|
|
332
|
+
return { kind: "fix", key, item, pr, reason: { type: "conflict" } };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 3. Failing CI.
|
|
337
|
+
if (pr.checks === "FAILURE" || pr.checks === "ERROR") {
|
|
338
|
+
if (!pr.comments.some((c) => hasMarker(c.body, "ci", pr.headSha))) {
|
|
339
|
+
if (fixCount(pr, "ci") >= MAX_CI_FIXES) {
|
|
340
|
+
return issue.labels.includes(LABELS.needsHuman) ? null : "ciExhausted";
|
|
341
|
+
}
|
|
342
|
+
const key = `fix:${prKey}`;
|
|
343
|
+
if (blocked(issue, key)) return null;
|
|
344
|
+
return { kind: "fix", key, item, pr, reason: { type: "ci" } };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 4. Agent review passes, each followed by a fix when it found problems.
|
|
349
|
+
for (const pass of reviewPasses(issue)) {
|
|
350
|
+
if (!reviewComment(pr, pass)) {
|
|
351
|
+
const key = `review${pass}:${prKey}`;
|
|
352
|
+
if (blocked(issue, key)) return null;
|
|
353
|
+
return { kind: "review", key, item, pr, pass };
|
|
354
|
+
}
|
|
355
|
+
const findings = reviewFindings(pr, pass).filter(
|
|
356
|
+
(f) => f.severity !== "nit",
|
|
357
|
+
);
|
|
358
|
+
const fixed = pr.comments.some((c) => hasMarker(c.body, "fixed", pass));
|
|
359
|
+
if (findings.length && !fixed) {
|
|
360
|
+
const key = `fix:${prKey}`;
|
|
361
|
+
if (blocked(issue, key)) return null;
|
|
362
|
+
return {
|
|
363
|
+
kind: "fix",
|
|
364
|
+
key,
|
|
365
|
+
item,
|
|
366
|
+
pr,
|
|
367
|
+
reason: { type: "review", pass, findings },
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 5. GitHub Copilot's review, when enabled. It arrives on its own schedule.
|
|
373
|
+
if (ctx.config.review.copilot) {
|
|
374
|
+
const threads = copilotThreads(pr);
|
|
375
|
+
if (threads.length) {
|
|
376
|
+
const key = `fix:${prKey}`;
|
|
377
|
+
if (blocked(issue, key)) return null;
|
|
378
|
+
return {
|
|
379
|
+
kind: "fix",
|
|
380
|
+
key,
|
|
381
|
+
item,
|
|
382
|
+
pr,
|
|
383
|
+
reason: { type: "copilot", threads },
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
const reviewed =
|
|
387
|
+
pr.reviews.some((r) => isCopilot(r.author)) ||
|
|
388
|
+
pr.threads.some((t) => t.comments.some((c) => isCopilot(c.author)));
|
|
389
|
+
const waited =
|
|
390
|
+
ctx.now - Date.parse(pr.createdAt) >=
|
|
391
|
+
ctx.config.review.copilotWaitMinutes * 60_000;
|
|
392
|
+
if (!reviewed && !waited) return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (!pr.labels.includes(LABELS.agentReviewed)) return "agentReviewed";
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Choose which waiting jobs get the free slots. Pure. */
|
|
400
|
+
export function schedule(
|
|
401
|
+
jobs: Job[],
|
|
402
|
+
freeSlots: number,
|
|
403
|
+
priority: JobKind[],
|
|
404
|
+
): Job[] {
|
|
405
|
+
const rank = (j: Job) => {
|
|
406
|
+
const i = priority.indexOf(j.kind);
|
|
407
|
+
return i === -1 ? priority.length : i;
|
|
408
|
+
};
|
|
409
|
+
// Stable sort keeps board order inside each kind.
|
|
410
|
+
return [...jobs]
|
|
411
|
+
.sort((a, b) => rank(a) - rank(b))
|
|
412
|
+
.slice(0, Math.max(0, freeSlots));
|
|
413
|
+
}
|