kanbot 0.5.1 → 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.
@@ -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 { configPath, saveConfig, writeConfigSchema } from "../config.ts";
8
- import { DOCS_URL, UserError } from "../errors.ts";
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 { applyUpgrade, NEW_SUFFIX, planUpgrade } from "../templates.ts";
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
- "Scaffold kanbot agents, skills and GitHub files into this repo",
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: "Agent provider",
26
- default: "claude",
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
- !existsSync(configPath(root)) || args.force
53
- ? (JSON.parse(
54
- await run([
55
- "repo",
56
- "view",
57
- "--json",
58
- "nameWithOwner,defaultBranchRef",
59
- ]),
60
- ) as { nameWithOwner: string; defaultBranchRef: { name: string } })
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
- const plan = await planUpgrade(root, { force: args.force });
64
- await applyUpgrade(root, plan);
65
- for (const c of plan.changes) {
66
- if (c.action === "add" || c.action === "update") log.info(`+ ${c.path}`);
67
- if (c.action === "conflict")
68
- log.warn(
69
- `! ${c.path} exists; kanbot's version is in ${c.path}${NEW_SUFFIX}`,
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: args.provider as "claude" | "codex",
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
- // Only ask when someone can answer.
84
- if (process.stdin.isTTY) {
85
- const caps = askCapabilities(await ownWorkers(root), prompt);
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
- const codeowners = join(root, ".github", "CODEOWNERS");
96
- if (!existsSync(codeowners)) {
97
- const login = (await run(["api", "user", "--jq", ".login"])).trim();
98
- await Bun.write(
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 appendGitignore(root);
106
- const changed = await sync(root, await loadAgents(root));
107
- if (await writeConfigSchema(root))
108
- changed.push(".kanbot/config.schema.json");
109
- for (const f of changed) log.info(`~ ${f} (generated)`);
110
-
111
- console.log(`
112
- Next steps:
113
- 1. Review AGENTS.md and fill in .agents/skills/project-conventions/SKILL.md
114
- (ask your agent to draft it; see ${DOCS_URL}/project-conventions)
115
- 2. Adjust .github/workflows/ci.yml to your stack (agents fix failing CI)
116
- 3. Map your own skills under capabilities in .kanbot/config.yml (kanbot-guide can help)
117
- 4. kanbot board create # board and labels; enable the workflows it prints
118
- 5. Commit and push these files to the default branch (agents work from it)
119
- 6. kanbot doctor # checks auth, board, generated files
120
- 7. kanbot run # start the loop (or kanbot run --once --dry-run)`);
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
+ });
@@ -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
- console.log(`\nwaiting on you:\n${rows.join("\n")}`);
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()
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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": [