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/README.md
CHANGED
|
@@ -114,6 +114,6 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org). A `c
|
|
|
114
114
|
2. A draft release updates with the next version and notes. `feat` bumps minor, `fix` patch. On 0.x, breaking changes bump minor.
|
|
115
115
|
3. Publish the draft on GitHub. This tags the commit, publishes to npm with provenance, and commits the version to `package.json` on `main`.
|
|
116
116
|
|
|
117
|
-
Docs: [kanbot.mib.im](https://kanbot.mib.im) shows the latest release
|
|
117
|
+
Docs: [kanbot.mib.im](https://kanbot.mib.im) shows the latest release plus later commits that only touch `docs/` or `README.md`, so docs fixes need no release. [kanbot.mib.im/main](https://kanbot.mib.im/main/) shows `main`. Both deploy on every push to `main` and on each release. `pnpm docs:dev` serves your working tree.
|
|
118
118
|
|
|
119
119
|
Preview the next release with `pnpm release:preview`. Mark the draft as a pre-release to publish under the `next` tag.
|
package/package.json
CHANGED
|
@@ -79,6 +79,27 @@
|
|
|
79
79
|
"minimum": 1,
|
|
80
80
|
"maximum": 9007199254740991
|
|
81
81
|
},
|
|
82
|
+
"wip": {
|
|
83
|
+
"description": "Work-in-progress limits.",
|
|
84
|
+
"default": {},
|
|
85
|
+
"type": "object",
|
|
86
|
+
"properties": {
|
|
87
|
+
"awaitingHuman": {
|
|
88
|
+
"default": null,
|
|
89
|
+
"description": "When this many items wait on you (`kanbot status`), no new Ready card starts. Started work runs on. null: no limit.",
|
|
90
|
+
"anyOf": [
|
|
91
|
+
{
|
|
92
|
+
"type": "integer",
|
|
93
|
+
"minimum": 1,
|
|
94
|
+
"maximum": 9007199254740991
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"type": "null"
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
},
|
|
82
103
|
"pollSeconds": {
|
|
83
104
|
"default": 60,
|
|
84
105
|
"description": "Seconds between board polls.",
|
package/src/ask.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { color } from "./color.ts";
|
|
2
|
+
|
|
3
|
+
/** Reads one answer. Null means no answer (end of input). */
|
|
4
|
+
export type Ask = (question: string) => string | null;
|
|
5
|
+
export type Say = (line: string) => void;
|
|
6
|
+
|
|
7
|
+
export type Choice<T> = { value: T; label: string; hint?: string };
|
|
8
|
+
|
|
9
|
+
/** Numbered list; Enter picks the default. */
|
|
10
|
+
export function choose<T>(
|
|
11
|
+
ask: Ask,
|
|
12
|
+
say: Say,
|
|
13
|
+
question: string,
|
|
14
|
+
choices: Choice<T>[],
|
|
15
|
+
def = 0,
|
|
16
|
+
): T {
|
|
17
|
+
say(color.bold(question));
|
|
18
|
+
choices.forEach((c, i) => {
|
|
19
|
+
const hint = c.hint ? color.dim(` — ${c.hint}`) : "";
|
|
20
|
+
say(` ${i + 1}. ${c.label}${hint}`);
|
|
21
|
+
});
|
|
22
|
+
for (;;) {
|
|
23
|
+
const a = ask(`Pick 1-${choices.length} [${def + 1}]:`)?.trim();
|
|
24
|
+
if (!a) return (choices[def] as Choice<T>).value;
|
|
25
|
+
const picked =
|
|
26
|
+
choices[Number(a) - 1] ??
|
|
27
|
+
choices.find((c) => String(c.value) === a || c.label === a);
|
|
28
|
+
if (picked) return picked.value;
|
|
29
|
+
say(` Type a number from 1 to ${choices.length}.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function confirm(ask: Ask, question: string, def: boolean): boolean {
|
|
34
|
+
const a = ask(`${question} ${def ? "[Y/n]" : "[y/N]"}`)?.trim();
|
|
35
|
+
if (!a) return def;
|
|
36
|
+
return /^y/i.test(a);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A whole number ≥ min. With `none`, typing it returns null. */
|
|
40
|
+
export function askNumber(
|
|
41
|
+
ask: Ask,
|
|
42
|
+
say: Say,
|
|
43
|
+
question: string,
|
|
44
|
+
def: number | null,
|
|
45
|
+
opts: { min?: number; none?: string } = {},
|
|
46
|
+
): number | null {
|
|
47
|
+
const min = opts.min ?? 1;
|
|
48
|
+
const shown = def ?? opts.none;
|
|
49
|
+
for (;;) {
|
|
50
|
+
const a = ask(`${question} [${shown}]`)?.trim();
|
|
51
|
+
if (!a) return def;
|
|
52
|
+
if (opts.none && a === opts.none) return null;
|
|
53
|
+
const n = Number(a);
|
|
54
|
+
if (Number.isInteger(n) && n >= min) return n;
|
|
55
|
+
say(
|
|
56
|
+
` Type a whole number, ${min} or more${opts.none ? `, or "${opts.none}"` : ""}.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Free text; Enter keeps the default. `valid` returns an error to ask again. */
|
|
62
|
+
export function askText(
|
|
63
|
+
ask: Ask,
|
|
64
|
+
say: Say,
|
|
65
|
+
question: string,
|
|
66
|
+
valid: (s: string) => string | null = () => null,
|
|
67
|
+
): string | null {
|
|
68
|
+
for (;;) {
|
|
69
|
+
const a = ask(question)?.trim();
|
|
70
|
+
if (!a) return null;
|
|
71
|
+
const err = valid(a);
|
|
72
|
+
if (!err) return a;
|
|
73
|
+
say(` ${err}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Opens a URL in the default browser. Silent if it can't. */
|
|
78
|
+
export function openBrowser(url: string) {
|
|
79
|
+
const cmd =
|
|
80
|
+
process.platform === "darwin"
|
|
81
|
+
? "open"
|
|
82
|
+
: process.platform === "win32"
|
|
83
|
+
? "explorer"
|
|
84
|
+
: "xdg-open";
|
|
85
|
+
try {
|
|
86
|
+
Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" });
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
package/src/commands/app.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { chmod, mkdir } from "node:fs/promises";
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import { defineCommand } from "citty";
|
|
6
|
+
import { openBrowser } from "../ask.ts";
|
|
6
7
|
import { loadConfig, saveConfig } from "../config.ts";
|
|
7
8
|
import { UserError } from "../errors.ts";
|
|
8
9
|
import { appKeyPath, appManifest, convertManifest } from "../github/app.ts";
|
|
@@ -11,18 +12,6 @@ import { log } from "../log.ts";
|
|
|
11
12
|
|
|
12
13
|
const WAIT_MS = 10 * 60_000;
|
|
13
14
|
|
|
14
|
-
function openBrowser(url: string) {
|
|
15
|
-
const cmd =
|
|
16
|
-
process.platform === "darwin"
|
|
17
|
-
? "open"
|
|
18
|
-
: process.platform === "win32"
|
|
19
|
-
? "explorer"
|
|
20
|
-
: "xdg-open";
|
|
21
|
-
try {
|
|
22
|
-
Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" });
|
|
23
|
-
} catch {}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
15
|
const escAttr = (s: string) => s.replace(/&/g, "&").replace(/"/g, """);
|
|
27
16
|
|
|
28
17
|
/** A page that posts the manifest to GitHub, as the manifest flow requires. */
|
|
@@ -40,6 +29,77 @@ const page = (msg: string) =>
|
|
|
40
29
|
{ headers: { "content-type": "text/html" } },
|
|
41
30
|
);
|
|
42
31
|
|
|
32
|
+
/** Creates a GitHub App in the browser and switches config to app mode. */
|
|
33
|
+
export async function createApp(root: string, name?: string) {
|
|
34
|
+
const config = await loadConfig(root);
|
|
35
|
+
const gh = new Gh(config.repo);
|
|
36
|
+
const owner = gh.owner;
|
|
37
|
+
const { type } = await gh.rest<{ type: string }>(`users/${owner}`);
|
|
38
|
+
const appName = (name ?? `kanbot-${owner}`).slice(0, 34);
|
|
39
|
+
const state = randomUUID();
|
|
40
|
+
const action =
|
|
41
|
+
type === "Organization"
|
|
42
|
+
? `https://github.com/organizations/${owner}/settings/apps/new?state=${state}`
|
|
43
|
+
: `https://github.com/settings/apps/new?state=${state}`;
|
|
44
|
+
|
|
45
|
+
const got = Promise.withResolvers<string>();
|
|
46
|
+
const server = Bun.serve({
|
|
47
|
+
port: 0,
|
|
48
|
+
hostname: "localhost",
|
|
49
|
+
fetch(req): Response {
|
|
50
|
+
const url = new URL(req.url);
|
|
51
|
+
if (url.pathname === "/")
|
|
52
|
+
return new Response(
|
|
53
|
+
formPage(
|
|
54
|
+
action,
|
|
55
|
+
appManifest(appName, `http://localhost:${url.port}/callback`),
|
|
56
|
+
),
|
|
57
|
+
{ headers: { "content-type": "text/html" } },
|
|
58
|
+
);
|
|
59
|
+
if (url.pathname !== "/callback")
|
|
60
|
+
return new Response("", { status: 404 });
|
|
61
|
+
const code = url.searchParams.get("code");
|
|
62
|
+
if (url.searchParams.get("state") !== state || !code)
|
|
63
|
+
return page("Unexpected request. Run `kanbot app create` again.");
|
|
64
|
+
got.resolve(code);
|
|
65
|
+
return page("App created. Go back to your terminal.");
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const start = `http://localhost:${server.port}/`;
|
|
69
|
+
log.info(`Opening ${start} — confirm the app on GitHub.`);
|
|
70
|
+
openBrowser(start);
|
|
71
|
+
const timer = setTimeout(
|
|
72
|
+
() => got.reject(new Error("timed out waiting for GitHub")),
|
|
73
|
+
WAIT_MS,
|
|
74
|
+
);
|
|
75
|
+
let code: string;
|
|
76
|
+
try {
|
|
77
|
+
code = await got.promise;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
throw new UserError("No app was created.", {
|
|
80
|
+
why: String(e),
|
|
81
|
+
fix: "kanbot app create",
|
|
82
|
+
docs: "identity#app-mode",
|
|
83
|
+
});
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
server.stop();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const app = await convertManifest(code);
|
|
90
|
+
const keyFile = appKeyPath(app.id);
|
|
91
|
+
await mkdir(dirname(keyFile), { recursive: true });
|
|
92
|
+
await Bun.write(keyFile, app.pem);
|
|
93
|
+
await chmod(keyFile, 0o600);
|
|
94
|
+
await saveConfig(root, {
|
|
95
|
+
identity: { mode: "app", app: { id: app.id, slug: app.slug } },
|
|
96
|
+
});
|
|
97
|
+
const install = `https://github.com/apps/${app.slug}/installations/new`;
|
|
98
|
+
log.info(`Created ${app.slug} (id ${app.id}). Key: ${keyFile}`);
|
|
99
|
+
log.info(`Now install it on ${config.repo}: ${install}`);
|
|
100
|
+
openBrowser(install);
|
|
101
|
+
}
|
|
102
|
+
|
|
43
103
|
const create = defineCommand({
|
|
44
104
|
meta: {
|
|
45
105
|
name: "create",
|
|
@@ -53,74 +113,7 @@ const create = defineCommand({
|
|
|
53
113
|
},
|
|
54
114
|
},
|
|
55
115
|
async run({ args }) {
|
|
56
|
-
|
|
57
|
-
const config = await loadConfig(root);
|
|
58
|
-
const gh = new Gh(config.repo);
|
|
59
|
-
const owner = gh.owner;
|
|
60
|
-
const { type } = await gh.rest<{ type: string }>(`users/${owner}`);
|
|
61
|
-
const name = (args.name ?? `kanbot-${owner}`).slice(0, 34);
|
|
62
|
-
const state = randomUUID();
|
|
63
|
-
const action =
|
|
64
|
-
type === "Organization"
|
|
65
|
-
? `https://github.com/organizations/${owner}/settings/apps/new?state=${state}`
|
|
66
|
-
: `https://github.com/settings/apps/new?state=${state}`;
|
|
67
|
-
|
|
68
|
-
const got = Promise.withResolvers<string>();
|
|
69
|
-
const server = Bun.serve({
|
|
70
|
-
port: 0,
|
|
71
|
-
hostname: "localhost",
|
|
72
|
-
fetch(req): Response {
|
|
73
|
-
const url = new URL(req.url);
|
|
74
|
-
if (url.pathname === "/")
|
|
75
|
-
return new Response(
|
|
76
|
-
formPage(
|
|
77
|
-
action,
|
|
78
|
-
appManifest(name, `http://localhost:${url.port}/callback`),
|
|
79
|
-
),
|
|
80
|
-
{ headers: { "content-type": "text/html" } },
|
|
81
|
-
);
|
|
82
|
-
if (url.pathname !== "/callback")
|
|
83
|
-
return new Response("", { status: 404 });
|
|
84
|
-
const code = url.searchParams.get("code");
|
|
85
|
-
if (url.searchParams.get("state") !== state || !code)
|
|
86
|
-
return page("Unexpected request. Run `kanbot app create` again.");
|
|
87
|
-
got.resolve(code);
|
|
88
|
-
return page("App created. Go back to your terminal.");
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
const start = `http://localhost:${server.port}/`;
|
|
92
|
-
log.info(`Opening ${start} — confirm the app on GitHub.`);
|
|
93
|
-
openBrowser(start);
|
|
94
|
-
const timer = setTimeout(
|
|
95
|
-
() => got.reject(new Error("timed out waiting for GitHub")),
|
|
96
|
-
WAIT_MS,
|
|
97
|
-
);
|
|
98
|
-
let code: string;
|
|
99
|
-
try {
|
|
100
|
-
code = await got.promise;
|
|
101
|
-
} catch (e) {
|
|
102
|
-
throw new UserError("No app was created.", {
|
|
103
|
-
why: String(e),
|
|
104
|
-
fix: "kanbot app create",
|
|
105
|
-
docs: "identity#app-mode",
|
|
106
|
-
});
|
|
107
|
-
} finally {
|
|
108
|
-
clearTimeout(timer);
|
|
109
|
-
server.stop();
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const app = await convertManifest(code);
|
|
113
|
-
const keyFile = appKeyPath(app.id);
|
|
114
|
-
await mkdir(dirname(keyFile), { recursive: true });
|
|
115
|
-
await Bun.write(keyFile, app.pem);
|
|
116
|
-
await chmod(keyFile, 0o600);
|
|
117
|
-
await saveConfig(root, {
|
|
118
|
-
identity: { mode: "app", app: { id: app.id, slug: app.slug } },
|
|
119
|
-
});
|
|
120
|
-
const install = `https://github.com/apps/${app.slug}/installations/new`;
|
|
121
|
-
log.info(`Created ${app.slug} (id ${app.id}). Key: ${keyFile}`);
|
|
122
|
-
log.info(`Now install it on ${config.repo}: ${install}`);
|
|
123
|
-
openBrowser(install);
|
|
116
|
+
await createApp(process.cwd(), args.name);
|
|
124
117
|
console.log(
|
|
125
118
|
"\nThen commit .kanbot/config.yml, run `kanbot doctor` and restart the daemon.",
|
|
126
119
|
);
|
package/src/commands/board.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
|
-
import { LABELS, loadConfig, saveConfig } from "../config.ts";
|
|
2
|
+
import { type Config, LABELS, loadConfig, saveConfig } from "../config.ts";
|
|
3
3
|
import { UserError } from "../errors.ts";
|
|
4
4
|
import { ensureCopilotRuleset, ensureLabel } from "../github/api.ts";
|
|
5
5
|
import { Gh } from "../github/gh.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type Board,
|
|
8
|
+
createBoard,
|
|
9
|
+
loadBoard,
|
|
10
|
+
missingColumns,
|
|
11
|
+
neededWorkflows,
|
|
12
|
+
workflowsUrl,
|
|
13
|
+
} from "../github/project.ts";
|
|
7
14
|
import { log } from "../log.ts";
|
|
8
15
|
|
|
9
16
|
export const REPO_LABELS: [name: string, color: string, description: string][] =
|
|
@@ -55,6 +62,76 @@ export async function ensureLabels(gh: Gh) {
|
|
|
55
62
|
await ensureLabel(gh, name, color, description);
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Creates the board (or keeps the configured one), labels and Copilot
|
|
67
|
+
* ruleset, then prints the workflows to turn on. Returns the board, or null
|
|
68
|
+
* if creation was skipped.
|
|
69
|
+
*/
|
|
70
|
+
export async function setupBoard(root: string, title?: string) {
|
|
71
|
+
const config = await loadConfig(root);
|
|
72
|
+
const gh = new Gh(config.repo);
|
|
73
|
+
if (config.project) {
|
|
74
|
+
log.info(
|
|
75
|
+
`Project already configured (${config.project.owner} #${config.project.number}); only ensuring labels.`,
|
|
76
|
+
);
|
|
77
|
+
} else {
|
|
78
|
+
const project = await createBoard(gh, config, title ?? gh.name);
|
|
79
|
+
if (!project) return null;
|
|
80
|
+
await saveConfig(root, {
|
|
81
|
+
project: { owner: project.owner, number: project.number },
|
|
82
|
+
});
|
|
83
|
+
log.info(`Created project ${project.url}`);
|
|
84
|
+
}
|
|
85
|
+
await ensureLabels(gh);
|
|
86
|
+
log.info(`Labels ensured: ${REPO_LABELS.map(([n]) => n).join(", ")}`);
|
|
87
|
+
if (config.review.copilot) {
|
|
88
|
+
await ensureCopilotRuleset(gh, config.review.copilotOnPush);
|
|
89
|
+
log.info("Copilot review ruleset ensured.");
|
|
90
|
+
}
|
|
91
|
+
const board = await loadBoard(gh, await loadConfig(root));
|
|
92
|
+
warnMissing(board);
|
|
93
|
+
printWorkflows(config, board);
|
|
94
|
+
return board;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Saves an existing project as the board and adds the labels. */
|
|
98
|
+
export async function linkBoard(root: string, owner: string, number: number) {
|
|
99
|
+
await saveConfig(root, { project: { owner, number } });
|
|
100
|
+
const config = await loadConfig(root);
|
|
101
|
+
const gh = new Gh(config.repo);
|
|
102
|
+
const board = await loadBoard(gh, config);
|
|
103
|
+
await ensureLabels(gh);
|
|
104
|
+
log.info(`Linked ${board.url}`);
|
|
105
|
+
warnMissing(board);
|
|
106
|
+
return board;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function warnMissing(board: Board) {
|
|
110
|
+
const missing = missingColumns(board);
|
|
111
|
+
if (missing.length)
|
|
112
|
+
log.warn(
|
|
113
|
+
`Board is missing columns: ${missing.join(", ")} — add them to the Status field.`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function printWorkflows(config: Config, board: Board) {
|
|
118
|
+
const steps = neededWorkflows(config).map(
|
|
119
|
+
(w) => ` • ${w.name.padEnd(21)} → ${w.set}`,
|
|
120
|
+
);
|
|
121
|
+
console.log(`
|
|
122
|
+
Turn on these built-in workflows. GitHub's API can't, so do it here:
|
|
123
|
+
${workflowsUrl(board)}
|
|
124
|
+
${steps.join("\n")}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Owner and number from a project URL like github.com/users/<owner>/projects/<n>. */
|
|
128
|
+
export function parseProjectUrl(url: string) {
|
|
129
|
+
const m = url.match(
|
|
130
|
+
/github\.com\/(?:users|orgs)\/([^/\s]+)\/projects\/(\d+)/,
|
|
131
|
+
);
|
|
132
|
+
return m ? { owner: m[1] as string, number: Number(m[2]) } : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
58
135
|
const create = defineCommand({
|
|
59
136
|
meta: {
|
|
60
137
|
name: "create",
|
|
@@ -68,40 +145,8 @@ const create = defineCommand({
|
|
|
68
145
|
},
|
|
69
146
|
},
|
|
70
147
|
async run({ args }) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const gh = new Gh(config.repo);
|
|
74
|
-
if (config.project) {
|
|
75
|
-
log.info(
|
|
76
|
-
`Project already configured (${config.project.owner} #${config.project.number}); only ensuring labels.`,
|
|
77
|
-
);
|
|
78
|
-
} else {
|
|
79
|
-
const project = await createBoard(gh, config, args.title ?? gh.name);
|
|
80
|
-
if (!project) return;
|
|
81
|
-
await saveConfig(root, {
|
|
82
|
-
project: { owner: project.owner, number: project.number },
|
|
83
|
-
});
|
|
84
|
-
log.info(`Created project ${project.url}`);
|
|
85
|
-
}
|
|
86
|
-
await ensureLabels(gh);
|
|
87
|
-
log.info(`Labels ensured: ${REPO_LABELS.map(([n]) => n).join(", ")}`);
|
|
88
|
-
if (config.review.copilot) {
|
|
89
|
-
await ensureCopilotRuleset(gh, config.review.copilotOnPush);
|
|
90
|
-
log.info("Copilot review ruleset ensured.");
|
|
91
|
-
}
|
|
92
|
-
const board = await loadBoard(gh, await loadConfig(root));
|
|
93
|
-
const missing = missingColumns(board);
|
|
94
|
-
if (missing.length)
|
|
95
|
-
log.warn(
|
|
96
|
-
`Board is missing columns: ${missing.join(", ")} — add them to the Status field.`,
|
|
97
|
-
);
|
|
98
|
-
console.log(`
|
|
99
|
-
Enable these built-in project workflows (Project → ⋯ → Workflows), they can't be set via the API:
|
|
100
|
-
• Item closed → set Status: ${config.columns.done}
|
|
101
|
-
• Pull request merged → set Status: ${config.columns.done}
|
|
102
|
-
• Auto-add to project → filter is:issue repo:${config.repo} (new issues land in the board)
|
|
103
|
-
• Item added to project → set Status: ${config.columns.ideas}
|
|
104
|
-
${board.url}`);
|
|
148
|
+
if (await setupBoard(process.cwd(), args.title))
|
|
149
|
+
console.log("Then run kanbot doctor.");
|
|
105
150
|
},
|
|
106
151
|
});
|
|
107
152
|
|
|
@@ -120,24 +165,15 @@ const link = defineCommand({
|
|
|
120
165
|
number: { type: "string", required: true, description: "Project number" },
|
|
121
166
|
},
|
|
122
167
|
async run({ args }) {
|
|
123
|
-
const root = process.cwd();
|
|
124
168
|
const number = Number(args.number);
|
|
125
169
|
if (!Number.isInteger(number))
|
|
126
170
|
throw new UserError("--number must be a number.", {
|
|
127
171
|
why: "It's the number at the end of the project URL.",
|
|
128
172
|
docs: "getting-started#create-the-board",
|
|
129
173
|
});
|
|
130
|
-
await
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const config = await loadConfig(root);
|
|
134
|
-
const board = await loadBoard(new Gh(config.repo), config);
|
|
135
|
-
const missing = missingColumns(board);
|
|
136
|
-
log.info(
|
|
137
|
-
missing.length
|
|
138
|
-
? `Linked, but missing columns: ${missing.join(", ")}`
|
|
139
|
-
: `Linked ${board.url}`,
|
|
140
|
-
);
|
|
174
|
+
const config = await loadConfig(process.cwd());
|
|
175
|
+
const board = await linkBoard(process.cwd(), args.owner, number);
|
|
176
|
+
printWorkflows(config, board);
|
|
141
177
|
},
|
|
142
178
|
});
|
|
143
179
|
|
package/src/commands/doctor.ts
CHANGED
|
@@ -15,7 +15,10 @@ import {
|
|
|
15
15
|
boardWorkflows,
|
|
16
16
|
loadBoard,
|
|
17
17
|
missingColumns,
|
|
18
|
+
neededWorkflows,
|
|
19
|
+
workflowsUrl,
|
|
18
20
|
} from "../github/project.ts";
|
|
21
|
+
import { LOGIN_FIX, providerLogin } from "../providers/login.ts";
|
|
19
22
|
import {
|
|
20
23
|
codexSupports,
|
|
21
24
|
type McpServer,
|
|
@@ -171,27 +174,25 @@ export default defineCommand({
|
|
|
171
174
|
return summary(checks);
|
|
172
175
|
}
|
|
173
176
|
|
|
177
|
+
const login = await providerLogin(config.provider);
|
|
174
178
|
if (config.provider === "claude") {
|
|
175
179
|
section("Claude Code");
|
|
176
|
-
const s = await cmd(["claude", "auth", "status"]);
|
|
177
|
-
const sub = /"authMethod":\s*"claude\.ai"/.test(s.out);
|
|
178
180
|
add({
|
|
179
181
|
name: "claude CLI logged in with a subscription",
|
|
180
|
-
ok:
|
|
181
|
-
detail:
|
|
182
|
+
ok: login.ok,
|
|
183
|
+
detail: login.installed ? login.detail : "claude is not installed.",
|
|
182
184
|
why: "Workers run `claude`. An API key login bills per token instead of your plan.",
|
|
183
|
-
fix:
|
|
185
|
+
fix: LOGIN_FIX.claude,
|
|
184
186
|
docs: "providers#claude-code",
|
|
185
187
|
});
|
|
186
188
|
} else {
|
|
187
189
|
section("Codex");
|
|
188
|
-
const s = await cmd(["codex", "login", "status"]);
|
|
189
190
|
add({
|
|
190
191
|
name: "codex CLI logged in with ChatGPT",
|
|
191
|
-
ok:
|
|
192
|
-
detail:
|
|
192
|
+
ok: login.ok,
|
|
193
|
+
detail: login.installed ? login.detail : "codex is not installed.",
|
|
193
194
|
why: "Workers run `codex` on your ChatGPT plan.",
|
|
194
|
-
fix:
|
|
195
|
+
fix: LOGIN_FIX.codex,
|
|
195
196
|
docs: "providers#codex",
|
|
196
197
|
});
|
|
197
198
|
add({
|
|
@@ -402,16 +403,17 @@ export default defineCommand({
|
|
|
402
403
|
});
|
|
403
404
|
// Moving cards to Done on close/merge and adding new issues are left to GitHub.
|
|
404
405
|
const workflows = await boardWorkflows(gh, board);
|
|
405
|
-
for (const
|
|
406
|
-
|
|
407
|
-
"Pull request merged",
|
|
408
|
-
"Auto-add to project",
|
|
409
|
-
]) {
|
|
406
|
+
for (const w of neededWorkflows(config)) {
|
|
407
|
+
const on = workflows.some((x) => x.name === w.name && x.enabled);
|
|
410
408
|
add({
|
|
411
|
-
name: `board workflow "${name}" enabled`,
|
|
412
|
-
ok:
|
|
413
|
-
|
|
414
|
-
|
|
409
|
+
name: `board workflow "${w.name}" enabled`,
|
|
410
|
+
ok: on || !w.required,
|
|
411
|
+
warn: !on && !w.required,
|
|
412
|
+
why: w.required
|
|
413
|
+
? "kanbot leaves adding new issues and moving closed ones to Done to GitHub."
|
|
414
|
+
: `Without it, new issues land with no Status and kanbot ignores them until you set one.`,
|
|
415
|
+
fix: `open ${workflowsUrl(board)} → "${w.name}" → ${w.set} → turn it on`,
|
|
416
|
+
docs: "getting-started#create-the-board",
|
|
415
417
|
});
|
|
416
418
|
}
|
|
417
419
|
} catch (e) {
|