kanbot 0.5.2 → 0.6.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 +1 -1
- package/package.json +1 -1
- package/schema/config.schema.json +21 -0
- package/src/ask.ts +88 -0
- package/src/commands/app.ts +73 -80
- package/src/commands/board.ts +84 -48
- package/src/commands/doctor.ts +20 -18
- package/src/commands/init.ts +246 -56
- package/src/commands/report.ts +74 -0
- package/src/commands/status.ts +10 -2
- package/src/config.ts +14 -0
- package/src/daemon/derive.ts +10 -0
- package/src/daemon/loop.ts +11 -1
- package/src/github/api.ts +46 -0
- package/src/github/project.ts +55 -0
- package/src/known-hashes.json +3 -0
- package/src/main.ts +1 -0
- package/src/providers/login.ts +46 -0
- package/src/report.ts +245 -0
- package/src/setup.ts +170 -0
- package/templates/.agents/skills/kanbot-guide/SKILL.md +20 -18
package/src/commands/init.ts
CHANGED
|
@@ -3,27 +3,66 @@ import { join } from "node:path";
|
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { loadAgents } from "../agents/load.ts";
|
|
5
5
|
import { sync } from "../agents/sync.ts";
|
|
6
|
+
import { type Ask, confirm, openBrowser, type Say } from "../ask.ts";
|
|
6
7
|
import { askCapabilities, compact, ownWorkers } from "../capabilities.ts";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
8
|
+
import { color } from "../color.ts";
|
|
9
|
+
import {
|
|
10
|
+
type Config,
|
|
11
|
+
configPath,
|
|
12
|
+
loadConfig,
|
|
13
|
+
saveConfig,
|
|
14
|
+
writeConfigSchema,
|
|
15
|
+
} from "../config.ts";
|
|
16
|
+
import { DOCS_URL, describeError, UserError } from "../errors.ts";
|
|
9
17
|
import { run } from "../github/gh.ts";
|
|
18
|
+
import { workflowsUrl } from "../github/project.ts";
|
|
10
19
|
import { log } from "../log.ts";
|
|
11
|
-
import {
|
|
20
|
+
import { LOGIN_FIX, providerLogin } from "../providers/login.ts";
|
|
21
|
+
import {
|
|
22
|
+
askBoard,
|
|
23
|
+
askIdentity,
|
|
24
|
+
askMerge,
|
|
25
|
+
askOptions,
|
|
26
|
+
askProvider,
|
|
27
|
+
} from "../setup.ts";
|
|
28
|
+
import {
|
|
29
|
+
applyUpgrade,
|
|
30
|
+
type Change,
|
|
31
|
+
NEW_SUFFIX,
|
|
32
|
+
planUpgrade,
|
|
33
|
+
} from "../templates.ts";
|
|
34
|
+
import { createApp } from "./app.ts";
|
|
35
|
+
import {
|
|
36
|
+
linkBoard,
|
|
37
|
+
parseProjectUrl,
|
|
38
|
+
printWorkflows,
|
|
39
|
+
setupBoard,
|
|
40
|
+
} from "./board.ts";
|
|
12
41
|
|
|
13
42
|
const GITIGNORE = [".kanbot/state.json", ".kanbot/logs/", ".kanbot/daemon.pid"];
|
|
14
43
|
|
|
44
|
+
const ask: Ask = (q) => prompt(q);
|
|
45
|
+
const say: Say = (l) => console.log(l);
|
|
46
|
+
const step = (title: string) =>
|
|
47
|
+
console.log(`\n${color.bold(color.cyan(title))}`);
|
|
48
|
+
|
|
15
49
|
export default defineCommand({
|
|
16
50
|
meta: {
|
|
17
51
|
name: "init",
|
|
18
52
|
description:
|
|
19
|
-
"
|
|
53
|
+
"Set up kanbot in this repo: pick a provider and options, add agents, skills and GitHub files, and create the board",
|
|
20
54
|
},
|
|
21
55
|
args: {
|
|
22
56
|
provider: {
|
|
23
57
|
type: "enum",
|
|
24
58
|
options: ["claude", "codex"],
|
|
25
|
-
description:
|
|
26
|
-
|
|
59
|
+
description:
|
|
60
|
+
"Agent provider (default: ask, or claude without a terminal)",
|
|
61
|
+
},
|
|
62
|
+
yes: {
|
|
63
|
+
type: "boolean",
|
|
64
|
+
description: "Don't ask; use defaults and set up the board later",
|
|
65
|
+
default: false,
|
|
27
66
|
},
|
|
28
67
|
force: {
|
|
29
68
|
type: "boolean",
|
|
@@ -40,49 +79,94 @@ export default defineCommand({
|
|
|
40
79
|
docs: "getting-started#set-up-a-repository",
|
|
41
80
|
more: "",
|
|
42
81
|
});
|
|
43
|
-
if (!["claude", "codex"].includes(args.provider))
|
|
82
|
+
if (args.provider && !["claude", "codex"].includes(args.provider))
|
|
44
83
|
throw new UserError(`Unknown provider "${args.provider}".`, {
|
|
45
84
|
fix: "kanbot init --provider claude|codex",
|
|
46
85
|
docs: "providers#choose-a-provider",
|
|
47
86
|
more: "",
|
|
48
87
|
});
|
|
88
|
+
// Only ask when someone can answer.
|
|
89
|
+
const interactive = !!process.stdin.isTTY && !args.yes;
|
|
90
|
+
const fresh = !existsSync(configPath(root)) || args.force;
|
|
49
91
|
|
|
50
92
|
// Fail on gh or remote problems before writing anything.
|
|
51
|
-
const repo =
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
: null;
|
|
93
|
+
const repo = fresh
|
|
94
|
+
? (JSON.parse(
|
|
95
|
+
await run([
|
|
96
|
+
"repo",
|
|
97
|
+
"view",
|
|
98
|
+
"--json",
|
|
99
|
+
"nameWithOwner,defaultBranchRef",
|
|
100
|
+
]),
|
|
101
|
+
) as { nameWithOwner: string; defaultBranchRef: { name: string } })
|
|
102
|
+
: null;
|
|
62
103
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
104
|
+
if (interactive)
|
|
105
|
+
say(
|
|
106
|
+
`${color.bold("kanbot init")} ${color.dim(
|
|
107
|
+
repo
|
|
108
|
+
? `sets up ${repo.nameWithOwner}. Press Enter to take the default.`
|
|
109
|
+
: "updates files and asks about what isn't set up yet.",
|
|
110
|
+
)}`,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
let provider = args.provider as Config["provider"] | undefined;
|
|
114
|
+
if (!provider && repo && interactive) {
|
|
115
|
+
step("Provider");
|
|
116
|
+
provider = askProvider(ask, say, {
|
|
117
|
+
claude: await providerLogin("claude"),
|
|
118
|
+
codex: await providerLogin("codex"),
|
|
119
|
+
});
|
|
71
120
|
}
|
|
72
|
-
log.info("+ .kanbot/manifest.json");
|
|
73
121
|
|
|
122
|
+
if (interactive) step("Files");
|
|
123
|
+
const plan = await planUpgrade(root, { force: args.force });
|
|
124
|
+
await applyUpgrade(root, plan);
|
|
125
|
+
printChanges(plan.changes);
|
|
74
126
|
if (repo) {
|
|
75
127
|
await saveConfig(root, {
|
|
76
|
-
provider:
|
|
128
|
+
provider: provider ?? "claude",
|
|
77
129
|
repo: repo.nameWithOwner,
|
|
78
130
|
baseBranch: repo.defaultBranchRef.name,
|
|
79
131
|
});
|
|
80
132
|
log.info(
|
|
81
133
|
`+ .kanbot/config.yml (${repo.nameWithOwner}, base ${repo.defaultBranchRef.name})`,
|
|
82
134
|
);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
135
|
+
}
|
|
136
|
+
await writeCodeowners(root);
|
|
137
|
+
await appendGitignore(root);
|
|
138
|
+
const changed = await sync(root, await loadAgents(root));
|
|
139
|
+
if (await writeConfigSchema(root))
|
|
140
|
+
changed.push(".kanbot/config.schema.json");
|
|
141
|
+
printGrouped(changed, "~", "generated");
|
|
142
|
+
|
|
143
|
+
const config = await loadConfig(root);
|
|
144
|
+
const login = await providerLogin(config.provider);
|
|
145
|
+
if (!login.ok)
|
|
146
|
+
log.warn(
|
|
147
|
+
`${config.provider} is ${login.installed ? "not logged in with a subscription" : "not installed"}. Run: ${LOGIN_FIX[config.provider]}`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
let workflowsShown = false;
|
|
151
|
+
if (interactive) {
|
|
152
|
+
if (repo) {
|
|
153
|
+
step("GitHub identity");
|
|
154
|
+
const identity = askIdentity(ask, say);
|
|
155
|
+
if (identity === "app")
|
|
156
|
+
await tryStep(() => createApp(root), "kanbot app create");
|
|
157
|
+
const merge = askMerge(ask, say, identity);
|
|
158
|
+
if (merge.onApproval)
|
|
159
|
+
await saveConfig(root, { merge: merge as Config["merge"] });
|
|
160
|
+
|
|
161
|
+
step("Options");
|
|
162
|
+
const options = askOptions(ask, say, config.provider);
|
|
163
|
+
if (Object.keys(options).length) await saveConfig(root, options);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const workers = await ownWorkers(root);
|
|
167
|
+
if (workers.length && !Object.keys(config.capabilities).length) {
|
|
168
|
+
step("Capabilities");
|
|
169
|
+
const caps = askCapabilities(workers, ask, say);
|
|
86
170
|
if (Object.keys(caps).length) {
|
|
87
171
|
await saveConfig(root, { capabilities: compact(caps) } as never);
|
|
88
172
|
log.info(
|
|
@@ -90,37 +174,143 @@ export default defineCommand({
|
|
|
90
174
|
);
|
|
91
175
|
}
|
|
92
176
|
}
|
|
93
|
-
}
|
|
94
177
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
codeowners,
|
|
100
|
-
`# Human reviewers. kanbot acts as your account, so this documents ownership\n# rather than triggering review requests for your own PRs.\n* @${login}\n`,
|
|
101
|
-
);
|
|
102
|
-
log.info(`+ .github/CODEOWNERS (@${login})`);
|
|
178
|
+
if (!config.project) {
|
|
179
|
+
step("Board");
|
|
180
|
+
workflowsShown = await boardStep(root);
|
|
181
|
+
}
|
|
103
182
|
}
|
|
104
183
|
|
|
105
|
-
await
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
184
|
+
const { project } = await loadConfig(root);
|
|
185
|
+
const next = [
|
|
186
|
+
`Review AGENTS.md and fill in .agents/skills/project-conventions/SKILL.md
|
|
187
|
+
(ask your agent to draft it; see ${DOCS_URL}/project-conventions)`,
|
|
188
|
+
"Adjust .github/workflows/ci.yml to your stack (agents fix failing CI)",
|
|
189
|
+
...(interactive
|
|
190
|
+
? []
|
|
191
|
+
: [
|
|
192
|
+
"Map your own skills under capabilities in .kanbot/config.yml (kanbot-guide can help)",
|
|
193
|
+
]),
|
|
194
|
+
...(workflowsShown
|
|
195
|
+
? ["Turn on the board workflows listed above"]
|
|
196
|
+
: project
|
|
197
|
+
? []
|
|
198
|
+
: [
|
|
199
|
+
"kanbot board create # board and labels; enable the workflows it prints",
|
|
200
|
+
]),
|
|
201
|
+
"Commit and push these files to the default branch (agents work from it)",
|
|
202
|
+
"kanbot doctor # checks auth, board, generated files",
|
|
203
|
+
"kanbot run # start the loop (or kanbot run --once --dry-run)",
|
|
204
|
+
];
|
|
205
|
+
console.log(
|
|
206
|
+
`\n${color.bold("Next steps:")}\n${next.map((s, i) => ` ${i + 1}. ${s}`).join("\n")}`,
|
|
207
|
+
);
|
|
121
208
|
},
|
|
122
209
|
});
|
|
123
210
|
|
|
211
|
+
/** Lists what changed, one line per folder, and each conflict in full. */
|
|
212
|
+
function printChanges(changes: Change[]) {
|
|
213
|
+
printGrouped(
|
|
214
|
+
changes
|
|
215
|
+
.filter((c) => c.action === "add" || c.action === "update")
|
|
216
|
+
.map((c) => c.path),
|
|
217
|
+
"+",
|
|
218
|
+
);
|
|
219
|
+
for (const c of changes)
|
|
220
|
+
if (c.action === "conflict")
|
|
221
|
+
log.warn(
|
|
222
|
+
`! ${c.path} exists; kanbot's version is in ${c.path}${NEW_SUFFIX}`,
|
|
223
|
+
);
|
|
224
|
+
log.info("+ .kanbot/manifest.json");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** One line per folder two levels deep, e.g. `+ .agents/skills/ (13 files)`. */
|
|
228
|
+
function printGrouped(paths: string[], mark: string, note = "") {
|
|
229
|
+
const groups = new Map<string, number>();
|
|
230
|
+
for (const path of paths) {
|
|
231
|
+
const parts = path.split("/");
|
|
232
|
+
const key = parts.length > 2 ? `${parts.slice(0, 2).join("/")}/` : path;
|
|
233
|
+
groups.set(key, (groups.get(key) ?? 0) + 1);
|
|
234
|
+
}
|
|
235
|
+
for (const [path, n] of groups) {
|
|
236
|
+
const notes = [n > 1 ? `${n} files` : "", note].filter(Boolean);
|
|
237
|
+
log.info(`${mark} ${path}${notes.length ? ` (${notes.join(", ")})` : ""}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Runs a step that may fail without stopping init. */
|
|
242
|
+
async function tryStep(fn: () => Promise<unknown>, retry: string) {
|
|
243
|
+
try {
|
|
244
|
+
await fn();
|
|
245
|
+
return true;
|
|
246
|
+
} catch (e) {
|
|
247
|
+
const d = describeError(e);
|
|
248
|
+
log.warn(`${d.message}${d.fix ? ` Fix: ${d.fix}` : ""}`);
|
|
249
|
+
log.warn(`Skipped. Run \`${retry}\` later.`);
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Asks how to get a board, then creates or links it. True if it printed the workflows to turn on. */
|
|
255
|
+
async function boardStep(root: string) {
|
|
256
|
+
if (!/'project'/.test(await authStatus())) {
|
|
257
|
+
say("Your gh token needs the `project` scope to manage boards.");
|
|
258
|
+
if (!confirm(ask, "Run `gh auth refresh -s project` now?", true)) {
|
|
259
|
+
say(" Skipped. Run it, then `kanbot board create`.");
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
const p = Bun.spawn(["gh", "auth", "refresh", "-s", "project"], {
|
|
263
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
264
|
+
});
|
|
265
|
+
if ((await p.exited) !== 0) {
|
|
266
|
+
log.warn("gh auth refresh failed. Run it, then `kanbot board create`.");
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const choice = askBoard(ask, say, parseProjectUrl);
|
|
271
|
+
if (choice.kind === "skip") return false;
|
|
272
|
+
let url = "";
|
|
273
|
+
const ok = await tryStep(
|
|
274
|
+
async () => {
|
|
275
|
+
const board =
|
|
276
|
+
choice.kind === "link"
|
|
277
|
+
? await linkBoard(root, choice.owner, choice.number)
|
|
278
|
+
: await setupBoard(root);
|
|
279
|
+
if (!board) return;
|
|
280
|
+
if (choice.kind === "link") printWorkflows(await loadConfig(root), board);
|
|
281
|
+
url = workflowsUrl(board);
|
|
282
|
+
},
|
|
283
|
+
choice.kind === "link" ? "kanbot board link" : "kanbot board create",
|
|
284
|
+
);
|
|
285
|
+
if (!ok || !url) return false;
|
|
286
|
+
if (confirm(ask, "Open the Workflows page now?", true)) openBrowser(url);
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function authStatus() {
|
|
291
|
+
const p = Bun.spawn(["gh", "auth", "status"], {
|
|
292
|
+
stdout: "pipe",
|
|
293
|
+
stderr: "pipe",
|
|
294
|
+
});
|
|
295
|
+
const [out, err] = await Promise.all([
|
|
296
|
+
new Response(p.stdout).text(),
|
|
297
|
+
new Response(p.stderr).text(),
|
|
298
|
+
]);
|
|
299
|
+
await p.exited;
|
|
300
|
+
return `${out}${err}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function writeCodeowners(root: string) {
|
|
304
|
+
const path = join(root, ".github", "CODEOWNERS");
|
|
305
|
+
if (existsSync(path)) return;
|
|
306
|
+
const login = (await run(["api", "user", "--jq", ".login"])).trim();
|
|
307
|
+
await Bun.write(
|
|
308
|
+
path,
|
|
309
|
+
`# Human reviewers. kanbot acts as your account, so this documents ownership\n# rather than triggering review requests for your own PRs.\n* @${login}\n`,
|
|
310
|
+
);
|
|
311
|
+
log.info(`+ .github/CODEOWNERS (@${login})`);
|
|
312
|
+
}
|
|
313
|
+
|
|
124
314
|
async function appendGitignore(root: string) {
|
|
125
315
|
const path = join(root, ".gitignore");
|
|
126
316
|
const current = existsSync(path) ? await Bun.file(path).text() : "";
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { LABELS, loadConfig } from "../config.ts";
|
|
3
|
+
import { waitingOnHuman } from "../daemon/derive.ts";
|
|
4
|
+
import { takeSnapshot } from "../daemon/snapshot.ts";
|
|
5
|
+
import { fetchActivity, signed } from "../github/api.ts";
|
|
6
|
+
import { Gh } from "../github/gh.ts";
|
|
7
|
+
import { marker } from "../github/markers.ts";
|
|
8
|
+
import { loadBoard, postStatusUpdate } from "../github/project.ts";
|
|
9
|
+
import { Trust } from "../github/trust.ts";
|
|
10
|
+
import { log } from "../log.ts";
|
|
11
|
+
import { buildReport, parseSince, renderReport } from "../report.ts";
|
|
12
|
+
|
|
13
|
+
export default defineCommand({
|
|
14
|
+
meta: {
|
|
15
|
+
name: "report",
|
|
16
|
+
description:
|
|
17
|
+
"Show flow metrics: merged PRs, cycle time, wait on you, fix loops, needs-human causes",
|
|
18
|
+
},
|
|
19
|
+
args: {
|
|
20
|
+
since: {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "Start of the period: 7d, 2w, 12h or YYYY-MM-DD",
|
|
23
|
+
default: "7d",
|
|
24
|
+
},
|
|
25
|
+
post: {
|
|
26
|
+
type: "boolean",
|
|
27
|
+
description: "Also post it to the board's Status updates",
|
|
28
|
+
default: false,
|
|
29
|
+
},
|
|
30
|
+
json: {
|
|
31
|
+
type: "boolean",
|
|
32
|
+
description: "Print JSON instead of text",
|
|
33
|
+
default: false,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
async run({ args }) {
|
|
37
|
+
const since = parseSince(args.since);
|
|
38
|
+
const config = await loadConfig(process.cwd());
|
|
39
|
+
const gh = new Gh(config.repo);
|
|
40
|
+
const board = await loadBoard(gh, config);
|
|
41
|
+
|
|
42
|
+
const [activity, snapshot] = await Promise.all([
|
|
43
|
+
fetchActivity(gh, since),
|
|
44
|
+
takeSnapshot(gh, board, new Trust(gh)),
|
|
45
|
+
]);
|
|
46
|
+
const open = snapshot.items.filter(
|
|
47
|
+
(i) => i.issue.state === "OPEN" && i.column !== "done",
|
|
48
|
+
);
|
|
49
|
+
const report = buildReport(
|
|
50
|
+
activity,
|
|
51
|
+
{
|
|
52
|
+
waiting: waitingOnHuman(snapshot, config).length,
|
|
53
|
+
limit: config.wip.awaitingHuman,
|
|
54
|
+
needsHuman: open.filter((i) =>
|
|
55
|
+
i.issue.labels.includes(LABELS.needsHuman),
|
|
56
|
+
).length,
|
|
57
|
+
active: open.length,
|
|
58
|
+
},
|
|
59
|
+
since,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const text = renderReport(report);
|
|
63
|
+
console.log(args.json ? JSON.stringify(report, null, 2) : text);
|
|
64
|
+
|
|
65
|
+
if (args.post) {
|
|
66
|
+
await postStatusUpdate(gh, board, {
|
|
67
|
+
body: `${signed(gh, text)}\n\n${marker("report")}`,
|
|
68
|
+
status: report.health,
|
|
69
|
+
startDate: report.since.slice(0, 10),
|
|
70
|
+
});
|
|
71
|
+
if (!args.json) log.info(`Posted to Status updates: ${board.url}`);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
package/src/commands/status.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { COLUMN_KEYS, loadConfig } from "../config.ts";
|
|
5
|
-
import { waitingOnHuman } from "../daemon/derive.ts";
|
|
5
|
+
import { waitingOnHuman, wipFull } from "../daemon/derive.ts";
|
|
6
6
|
import { takeSnapshot } from "../daemon/snapshot.ts";
|
|
7
7
|
import { State } from "../daemon/state.ts";
|
|
8
8
|
import { Gh } from "../github/gh.ts";
|
|
@@ -56,7 +56,15 @@ export default defineCommand({
|
|
|
56
56
|
const ago = since ? age(since) : "?";
|
|
57
57
|
return ` ${ago.padStart(4)} #${item.issue.number} ${why} · ${item.issue.title}\n ${url}`;
|
|
58
58
|
});
|
|
59
|
-
|
|
59
|
+
const limit = config.wip.awaitingHuman;
|
|
60
|
+
console.log(
|
|
61
|
+
`\nwaiting on you${limit === null ? "" : ` (${waiting.length}/${limit})`}:\n${rows.join("\n")}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (wipFull(snapshot, config).full) {
|
|
65
|
+
console.log(
|
|
66
|
+
"WIP limit reached: no new Ready cards start until you clear some.",
|
|
67
|
+
);
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
const state = await new State(root).load();
|
package/src/config.ts
CHANGED
|
@@ -81,6 +81,20 @@ export const ConfigSchema = z.object({
|
|
|
81
81
|
.describe(
|
|
82
82
|
"Max concurrent agent workers. Each exploratory variant is one worker.",
|
|
83
83
|
),
|
|
84
|
+
wip: z
|
|
85
|
+
.object({
|
|
86
|
+
awaitingHuman: z
|
|
87
|
+
.number()
|
|
88
|
+
.int()
|
|
89
|
+
.min(1)
|
|
90
|
+
.nullable()
|
|
91
|
+
.default(null)
|
|
92
|
+
.describe(
|
|
93
|
+
"When this many items wait on you (`kanbot status`), no new Ready card starts. Started work runs on. null: no limit.",
|
|
94
|
+
),
|
|
95
|
+
})
|
|
96
|
+
.prefault({})
|
|
97
|
+
.describe("Work-in-progress limits."),
|
|
84
98
|
pollSeconds: z
|
|
85
99
|
.number()
|
|
86
100
|
.int()
|
package/src/daemon/derive.ts
CHANGED
|
@@ -242,6 +242,7 @@ export function derive(snapshot: Snapshot, ctx: DeriveContext) {
|
|
|
242
242
|
};
|
|
243
243
|
|
|
244
244
|
const items = [...snapshot.items].sort((a, b) => a.position - b.position);
|
|
245
|
+
const full = wipFull(snapshot, config).full;
|
|
245
246
|
|
|
246
247
|
for (const item of items) {
|
|
247
248
|
const { issue } = item;
|
|
@@ -296,6 +297,8 @@ export function derive(snapshot: Snapshot, ctx: DeriveContext) {
|
|
|
296
297
|
actions.push({ kind: "waiting", item, blockers });
|
|
297
298
|
break;
|
|
298
299
|
}
|
|
300
|
+
// Too much waits on the human: don't start new cards.
|
|
301
|
+
if (item.column === "ready" && !issue.prs.length && full) break;
|
|
299
302
|
const n = exploreCount(issue.labels);
|
|
300
303
|
const variants =
|
|
301
304
|
n === null ? [null] : Array.from({ length: n }, (_, i) => i + 1);
|
|
@@ -621,3 +624,10 @@ export function waitingOnHuman(snapshot: Snapshot, config: Config): Waiting[] {
|
|
|
621
624
|
const key = (w: Waiting) => w.since ?? "\uffff";
|
|
622
625
|
return out.sort((a, b) => key(a).localeCompare(key(b)));
|
|
623
626
|
}
|
|
627
|
+
|
|
628
|
+
/** The `wip.awaitingHuman` limit. When full, no new Ready card starts. */
|
|
629
|
+
export function wipFull(snapshot: Snapshot, config: Config) {
|
|
630
|
+
const limit = config.wip.awaitingHuman;
|
|
631
|
+
const waiting = limit === null ? 0 : waitingOnHuman(snapshot, config).length;
|
|
632
|
+
return { waiting, limit, full: limit !== null && waiting >= limit };
|
|
633
|
+
}
|
package/src/daemon/loop.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { log } from "../log.ts";
|
|
|
18
18
|
import { claude } from "../providers/claude.ts";
|
|
19
19
|
import { codex } from "../providers/codex.ts";
|
|
20
20
|
import type { Provider } from "../providers/types.ts";
|
|
21
|
-
import { type Action, derive, type Job, schedule } from "./derive.ts";
|
|
21
|
+
import { type Action, derive, type Job, schedule, wipFull } from "./derive.ts";
|
|
22
22
|
import { Worktrees } from "./git.ts";
|
|
23
23
|
import { acquireLock } from "./lock.ts";
|
|
24
24
|
import { Notifier } from "./notify.ts";
|
|
@@ -75,6 +75,7 @@ export async function runDaemon(root: string, opts: RunOptions) {
|
|
|
75
75
|
let wake: () => void = () => {};
|
|
76
76
|
// kanbot-review PRs vanish from the snapshot when closed; clean up after them.
|
|
77
77
|
let prOnly = new Set<number>();
|
|
78
|
+
let wasFull = false;
|
|
78
79
|
let stopping = false;
|
|
79
80
|
|
|
80
81
|
const stop = () => {
|
|
@@ -125,6 +126,15 @@ export async function runDaemon(root: string, opts: RunOptions) {
|
|
|
125
126
|
failures: state.failures,
|
|
126
127
|
now: Date.now(),
|
|
127
128
|
});
|
|
129
|
+
const wip = wipFull(snapshot, config);
|
|
130
|
+
if (wip.full !== wasFull) {
|
|
131
|
+
if (wip.full)
|
|
132
|
+
log.warn(
|
|
133
|
+
`WIP limit: ${wip.waiting} item(s) wait on you (limit ${wip.limit}). No new Ready cards start. See \`kanbot status\`.`,
|
|
134
|
+
);
|
|
135
|
+
else log.info("WIP limit cleared; Ready cards start again.");
|
|
136
|
+
wasFull = wip.full;
|
|
137
|
+
}
|
|
128
138
|
const nowPrOnly = new Set(
|
|
129
139
|
snapshot.items.filter((i) => i.issue.prOnly).map((i) => i.issue.number),
|
|
130
140
|
);
|
package/src/github/api.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Checks, Comment, Issue, IssueRef, PR } from "../daemon/types.ts";
|
|
2
|
+
import type { Activity } from "../report.ts";
|
|
2
3
|
import type { Gh } from "./gh.ts";
|
|
3
4
|
|
|
4
5
|
const COMMENT = "id databaseId author { login } body createdAt";
|
|
@@ -393,3 +394,48 @@ export async function copilotReviewRule(gh: Gh, branch: string) {
|
|
|
393
394
|
);
|
|
394
395
|
return rules.find((r) => r.type === "copilot_code_review") ?? null;
|
|
395
396
|
}
|
|
397
|
+
|
|
398
|
+
const ACTIVITY_QUERY = `
|
|
399
|
+
query($q: String!, $after: String) {
|
|
400
|
+
search(query: $q, type: ISSUE, first: 50, after: $after) {
|
|
401
|
+
nodes {
|
|
402
|
+
__typename
|
|
403
|
+
... on PullRequest {
|
|
404
|
+
number title url headRefName createdAt mergedAt
|
|
405
|
+
comments(last: 100) { nodes { body createdAt } }
|
|
406
|
+
}
|
|
407
|
+
... on Issue {
|
|
408
|
+
number title url createdAt
|
|
409
|
+
comments(last: 100) { nodes { body createdAt } }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
pageInfo { hasNextPage endCursor }
|
|
413
|
+
}
|
|
414
|
+
}`;
|
|
415
|
+
|
|
416
|
+
/** Issues and PRs updated since `since`, with comments, for `kanbot report`. */
|
|
417
|
+
export async function fetchActivity(gh: Gh, since: Date): Promise<Activity[]> {
|
|
418
|
+
const q = `repo:${gh.repo} updated:>=${since.toISOString().slice(0, 10)}`;
|
|
419
|
+
const out: Activity[] = [];
|
|
420
|
+
let after: string | null = null;
|
|
421
|
+
do {
|
|
422
|
+
// biome-ignore lint/suspicious/noExplicitAny: raw GraphQL shape, mapped below
|
|
423
|
+
const data: any = await gh.query(ACTIVITY_QUERY, { q, after });
|
|
424
|
+
for (const n of data.search.nodes) {
|
|
425
|
+
if (n.__typename !== "PullRequest" && n.__typename !== "Issue") continue;
|
|
426
|
+
out.push({
|
|
427
|
+
kind: n.__typename === "PullRequest" ? "pr" : "issue",
|
|
428
|
+
number: n.number,
|
|
429
|
+
title: n.title,
|
|
430
|
+
url: n.url,
|
|
431
|
+
headRefName: n.headRefName,
|
|
432
|
+
createdAt: n.createdAt,
|
|
433
|
+
mergedAt: n.mergedAt,
|
|
434
|
+
comments: n.comments.nodes,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
const page = data.search.pageInfo;
|
|
438
|
+
after = page.hasNextPage ? page.endCursor : null;
|
|
439
|
+
} while (after);
|
|
440
|
+
return out;
|
|
441
|
+
}
|
package/src/github/project.ts
CHANGED
|
@@ -274,6 +274,39 @@ export async function addToBoard(
|
|
|
274
274
|
if (res) await moveItem(gh, board, res.addProjectV2ItemById.item.id, column);
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Built-in board workflows kanbot relies on, with the setting to pick.
|
|
279
|
+
* GitHub's API can read them but not enable them. `required`: kanbot needs it.
|
|
280
|
+
*/
|
|
281
|
+
export function neededWorkflows(config: Config) {
|
|
282
|
+
return [
|
|
283
|
+
{
|
|
284
|
+
name: "Item closed",
|
|
285
|
+
set: `set Status: ${config.columns.done}`,
|
|
286
|
+
required: true,
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: "Pull request merged",
|
|
290
|
+
set: `set Status: ${config.columns.done}`,
|
|
291
|
+
required: true,
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: "Auto-add to project",
|
|
295
|
+
set: `filter is:issue repo:${config.repo}`,
|
|
296
|
+
required: true,
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: "Item added to project",
|
|
300
|
+
set: `set Status: ${config.columns.ideas}`,
|
|
301
|
+
required: false,
|
|
302
|
+
},
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The board's Workflows settings page. */
|
|
307
|
+
export const workflowsUrl = (board: { url: string }) =>
|
|
308
|
+
`${board.url}/workflows`;
|
|
309
|
+
|
|
277
310
|
/** Built-in board workflows; they can be read but not enabled via the API. */
|
|
278
311
|
export async function boardWorkflows(gh: Gh, board: Board) {
|
|
279
312
|
const data = await gh.query<{
|
|
@@ -286,3 +319,25 @@ export async function boardWorkflows(gh: Gh, board: Board) {
|
|
|
286
319
|
);
|
|
287
320
|
return data.node.workflows.nodes;
|
|
288
321
|
}
|
|
322
|
+
|
|
323
|
+
export type StatusUpdate = {
|
|
324
|
+
body: string;
|
|
325
|
+
status: "ON_TRACK" | "AT_RISK" | "OFF_TRACK" | "INACTIVE" | "COMPLETE";
|
|
326
|
+
/** YYYY-MM-DD */
|
|
327
|
+
startDate: string;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/** Post to the board's "Status updates" in the project details. */
|
|
331
|
+
export function postStatusUpdate(gh: Gh, board: Board, update: StatusUpdate) {
|
|
332
|
+
return gh.mutate<{
|
|
333
|
+
createProjectV2StatusUpdate: { statusUpdate: { id: string } };
|
|
334
|
+
}>(
|
|
335
|
+
"post project status update",
|
|
336
|
+
`mutation($p: ID!, $b: String!, $s: ProjectV2StatusUpdateStatus!, $d: Date!) {
|
|
337
|
+
createProjectV2StatusUpdate(input: { projectId: $p, body: $b, status: $s, startDate: $d }) {
|
|
338
|
+
statusUpdate { id }
|
|
339
|
+
}
|
|
340
|
+
}`,
|
|
341
|
+
{ p: board.id, b: update.body, s: update.status, d: update.startDate },
|
|
342
|
+
);
|
|
343
|
+
}
|
package/src/known-hashes.json
CHANGED
|
@@ -22,10 +22,13 @@
|
|
|
22
22
|
"2c6c77a225c5b880eff023d4b3c1305bfc91bfb3",
|
|
23
23
|
"2ccb86b12e5af121b79ef9f53c2a35dbbdb2ddeb",
|
|
24
24
|
"53c73b1be4a1e8d863a52043eae4ca911c8be71b",
|
|
25
|
+
"5f23634074693b6d9116c2df07d0be7e7efbd10b",
|
|
26
|
+
"7fa56c70c75a2f4120b6d0de0eb6dba728e121ab",
|
|
25
27
|
"a24c0a655d6485e65bd5dea2a71f3afe67f04404",
|
|
26
28
|
"b5a2d074ea61f2552890ca1d78bf8a043d7b73b3",
|
|
27
29
|
"bde0364d60525598bbb28f1648d06d1407c62a73",
|
|
28
30
|
"cf3e95d137a5a58a273262bca6ba25fbc95fa037",
|
|
31
|
+
"e0fcf4ccb461a00302e8ddfe8f9543d58728191c",
|
|
29
32
|
"f9b76db4afbad14ad9d14dff805c71703099bdeb"
|
|
30
33
|
],
|
|
31
34
|
".agents/skills/kanbot-idea/SKILL.md": [
|