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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/package.json +75 -5
  4. package/schema/config.schema.json +206 -0
  5. package/src/agents/load.ts +105 -0
  6. package/src/agents/sync.ts +219 -0
  7. package/src/cli.ts +27 -0
  8. package/src/commands/board.ts +140 -0
  9. package/src/commands/completion.ts +30 -0
  10. package/src/commands/doctor.ts +260 -0
  11. package/src/commands/init.ts +119 -0
  12. package/src/commands/run.ts +32 -0
  13. package/src/commands/skills.ts +63 -0
  14. package/src/commands/status.ts +70 -0
  15. package/src/commands/sync.ts +31 -0
  16. package/src/commands/upgrade.ts +70 -0
  17. package/src/completion.ts +217 -0
  18. package/src/config.ts +240 -0
  19. package/src/daemon/derive.ts +413 -0
  20. package/src/daemon/git.ts +187 -0
  21. package/src/daemon/lock.ts +29 -0
  22. package/src/daemon/loop.ts +212 -0
  23. package/src/daemon/notify.ts +44 -0
  24. package/src/daemon/snapshot.ts +109 -0
  25. package/src/daemon/state.ts +60 -0
  26. package/src/daemon/types.ts +73 -0
  27. package/src/errors.ts +171 -0
  28. package/src/github/api.ts +332 -0
  29. package/src/github/gh.ts +110 -0
  30. package/src/github/markers.ts +82 -0
  31. package/src/github/project.ts +288 -0
  32. package/src/github/trust.ts +38 -0
  33. package/src/jobs/prompts.ts +205 -0
  34. package/src/jobs/run.ts +569 -0
  35. package/src/known-hashes.json +132 -0
  36. package/src/log.ts +11 -0
  37. package/src/main.ts +21 -0
  38. package/src/providers/claude.ts +122 -0
  39. package/src/providers/codex.ts +98 -0
  40. package/src/providers/types.ts +94 -0
  41. package/src/schemas/results.ts +116 -0
  42. package/src/templates.ts +192 -0
  43. package/templates/.agents/skills/accessibility/SKILL.md +76 -0
  44. package/templates/.agents/skills/approach-designer/SKILL.md +79 -0
  45. package/templates/.agents/skills/code-review/SKILL.md +83 -0
  46. package/templates/.agents/skills/frontend-design/LICENSE.txt +177 -0
  47. package/templates/.agents/skills/frontend-design/SKILL.md +76 -0
  48. package/templates/.agents/skills/issue-splitting/SKILL.md +78 -0
  49. package/templates/.agents/skills/kanbot-admin/SKILL.md +57 -0
  50. package/templates/.agents/skills/kanbot-idea/SKILL.md +56 -0
  51. package/templates/.agents/skills/plan-template/SKILL.md +85 -0
  52. package/templates/.agents/skills/pr-description/SKILL.md +44 -0
  53. package/templates/.agents/skills/project-conventions/SKILL.md +79 -0
  54. package/templates/.agents/skills/project-memory/SKILL.md +39 -0
  55. package/templates/.agents/skills/project-memory/facts/.gitkeep +0 -0
  56. package/templates/.agents/skills/spec-review/SKILL.md +73 -0
  57. package/templates/.agents/skills/tdd/SKILL.md +51 -0
  58. package/templates/.agents/skills/user-stories/SKILL.md +82 -0
  59. package/templates/.agents/skills/verify-ui/SKILL.md +104 -0
  60. package/templates/.github/ISSUE_TEMPLATE/config.yml +1 -0
  61. package/templates/.github/ISSUE_TEMPLATE/technical.yml +15 -0
  62. package/templates/.github/ISSUE_TEMPLATE/user-story.yml +15 -0
  63. package/templates/.github/pull_request_template.md +15 -0
  64. package/templates/.github/workflows/ci.yml +23 -0
  65. package/templates/.github/workflows/kanbot-merged.yml +30 -0
  66. package/templates/.kanbot/agents/approach-designer.md +31 -0
  67. package/templates/.kanbot/agents/explorer.md +35 -0
  68. package/templates/.kanbot/agents/fixer.md +54 -0
  69. package/templates/.kanbot/agents/implementer-backend.md +57 -0
  70. package/templates/.kanbot/agents/implementer-docs.md +55 -0
  71. package/templates/.kanbot/agents/implementer-frontend.md +54 -0
  72. package/templates/.kanbot/agents/implementer-technical.md +53 -0
  73. package/templates/.kanbot/agents/plan-critic.md +36 -0
  74. package/templates/.kanbot/agents/planner.md +52 -0
  75. package/templates/.kanbot/agents/reviewer-code.md +46 -0
  76. package/templates/.kanbot/agents/reviewer-spec.md +49 -0
  77. package/templates/.kanbot/agents/test-writer.md +35 -0
  78. package/templates/.kanbot/agents/ui-verifier.md +35 -0
  79. package/templates/AGENTS.md +39 -0
  80. package/templates/CLAUDE.md +1 -0
@@ -0,0 +1,73 @@
1
+ import type { ColumnKey } from "../config.ts";
2
+
3
+ export type Comment = {
4
+ id: string;
5
+ databaseId: number;
6
+ author: string;
7
+ body: string;
8
+ createdAt: string;
9
+ };
10
+
11
+ export type ReviewThread = {
12
+ id: string;
13
+ isResolved: boolean;
14
+ path: string | null;
15
+ line: number | null;
16
+ comments: Comment[];
17
+ };
18
+
19
+ export type Checks =
20
+ | "SUCCESS"
21
+ | "FAILURE"
22
+ | "ERROR"
23
+ | "PENDING"
24
+ | "EXPECTED"
25
+ | null;
26
+
27
+ export type PR = {
28
+ id: string;
29
+ number: number;
30
+ url: string;
31
+ title: string;
32
+ body: string;
33
+ state: "OPEN" | "CLOSED" | "MERGED";
34
+ headRefName: string;
35
+ headSha: string;
36
+ createdAt: string;
37
+ labels: string[];
38
+ comments: Comment[];
39
+ /** Submitted reviews that have a body. */
40
+ reviews: Comment[];
41
+ threads: ReviewThread[];
42
+ checks: Checks;
43
+ /** UNKNOWN while GitHub is still computing it. */
44
+ mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
45
+ };
46
+
47
+ export type Issue = {
48
+ id: string;
49
+ number: number;
50
+ url: string;
51
+ title: string;
52
+ body: string;
53
+ state: "OPEN" | "CLOSED";
54
+ labels: string[];
55
+ comments: Comment[];
56
+ prs: PR[];
57
+ /** A PR labeled `kanbot-review`, reviewed without a card. Fields mirror the PR. */
58
+ prOnly?: boolean;
59
+ };
60
+
61
+ export type Item = {
62
+ itemId: string;
63
+ column: ColumnKey | null;
64
+ /** Index in board order; lower is higher priority. */
65
+ position: number;
66
+ issue: Issue;
67
+ };
68
+
69
+ export type Snapshot = {
70
+ items: Item[];
71
+ /** Logins whose comments agents act on (collaborators with write access). */
72
+ trusted: ReadonlySet<string>;
73
+ };
package/src/errors.ts ADDED
@@ -0,0 +1,171 @@
1
+ export const DOCS_URL = "https://kanbot.mib.im";
2
+ const ISSUES_URL = "https://github.com/mikaelbr/kanbot/issues";
3
+
4
+ export type ErrorHelp = {
5
+ /** Why it failed: the underlying error or the rule that was broken. */
6
+ why?: string;
7
+ /** What to do about it, usually a command. */
8
+ fix?: string;
9
+ /** Docs page and anchor, e.g. "configuration#config-file". */
10
+ docs?: string;
11
+ /** How to learn more. Defaults to `kanbot doctor`; "" hides it. */
12
+ more?: string;
13
+ };
14
+
15
+ /** An expected failure. Printed without a stack trace. */
16
+ export class UserError extends Error {
17
+ constructor(
18
+ message: string,
19
+ readonly help: ErrorHelp = {},
20
+ options?: ErrorOptions,
21
+ ) {
22
+ super(message, options);
23
+ this.name = "UserError";
24
+ }
25
+ }
26
+
27
+ const INSTALL: Record<string, ErrorHelp> = {
28
+ gh: {
29
+ fix: "Install the GitHub CLI from https://cli.github.com, then run `gh auth login`.",
30
+ docs: "getting-started#requirements",
31
+ },
32
+ git: {
33
+ fix: "Install git from https://git-scm.com/downloads.",
34
+ docs: "getting-started#requirements",
35
+ },
36
+ claude: {
37
+ fix: "Install Claude Code, then run `claude auth login`.",
38
+ docs: "providers#claude-code",
39
+ },
40
+ codex: {
41
+ fix: "Install Codex (npm i -g @openai/codex), then run `codex login`.",
42
+ docs: "providers#codex",
43
+ },
44
+ };
45
+
46
+ /** Known failures from gh, git and the GitHub API, matched on message. */
47
+ const KNOWN: [RegExp, string, ErrorHelp][] = [
48
+ [
49
+ /gh auth login|not logged in|HTTP 401|Bad credentials/i,
50
+ "GitHub CLI is not logged in.",
51
+ { fix: "gh auth login", docs: "getting-started#requirements" },
52
+ ],
53
+ [
54
+ /INSUFFICIENT_SCOPES|required scopes|scope.*project/i,
55
+ "GitHub token is missing the `project` scope.",
56
+ { fix: "gh auth refresh -s project", docs: "getting-started#requirements" },
57
+ ],
58
+ [
59
+ /rate limit/i,
60
+ "GitHub API rate limit reached.",
61
+ { fix: "Wait a few minutes and try again." },
62
+ ],
63
+ [
64
+ /Could not resolve to a ProjectV2/i,
65
+ "Project board not found.",
66
+ {
67
+ fix: "Check `project` in .kanbot/config.yml, or run `kanbot board create`.",
68
+ docs: "getting-started#create-the-board",
69
+ },
70
+ ],
71
+ [
72
+ /Could not resolve to a Repository|HTTP 404/i,
73
+ "GitHub repository not found, or gh has no access to it.",
74
+ {
75
+ fix: "Check `repo` in .kanbot/config.yml and run `gh repo view <owner/name>`.",
76
+ docs: "configuration#config-file",
77
+ },
78
+ ],
79
+ [
80
+ /not a git repository/i,
81
+ "Not inside a git repository.",
82
+ { fix: "cd into your repo, or run `git init`." },
83
+ ],
84
+ [
85
+ /no git remotes|none of the git remotes/i,
86
+ "This repo has no GitHub remote.",
87
+ {
88
+ fix: "git remote add origin <url> && git push -u origin HEAD",
89
+ docs: "getting-started#set-up-a-repository",
90
+ },
91
+ ],
92
+ [
93
+ /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|dial tcp|error connecting/i,
94
+ "Could not reach GitHub.",
95
+ { fix: "Check your network connection and try again." },
96
+ ],
97
+ ];
98
+
99
+ export type Described = ErrorHelp & { message: string; expected: boolean };
100
+
101
+ export function describeError(e: unknown, command = ""): Described {
102
+ if (e instanceof UserError)
103
+ return { message: e.message, ...e.help, expected: true };
104
+ if (!(e instanceof Error)) return { message: String(e), expected: false };
105
+ const raw = e.message.trim();
106
+
107
+ // citty argument errors: unknown command, bad flag value, …
108
+ if (e.name === "CLIError") {
109
+ const unknown = (e as { code?: string }).code === "E_UNKNOWN_COMMAND";
110
+ return {
111
+ message: Bun.stripANSI(raw),
112
+ fix: `Run \`kanbot ${unknown ? "" : `${command} `}--help\` to see valid options.`,
113
+ docs: "usage",
114
+ more: "",
115
+ expected: true,
116
+ };
117
+ }
118
+
119
+ const bin = raw.match(/Executable not found in \$PATH: "(.+)"/)?.[1];
120
+ if (bin)
121
+ return {
122
+ message: `\`${bin}\` is not installed or not on PATH.`,
123
+ why: raw,
124
+ ...INSTALL[bin],
125
+ expected: true,
126
+ };
127
+
128
+ const known = KNOWN.find(([re]) => re.test(raw));
129
+ if (known)
130
+ return { message: known[1], why: raw, ...known[2], expected: true };
131
+ const tool = raw.match(/^(gh|git) .* failed/)?.[1];
132
+ if (tool)
133
+ return {
134
+ message: `A \`${tool}\` command failed.`,
135
+ why: raw,
136
+ fix:
137
+ tool === "gh"
138
+ ? "Read the gh error above. `gh auth status` shows login and scopes."
139
+ : "Read the git error above. `git status` shows the repo state.",
140
+ expected: true,
141
+ };
142
+ return { message: raw, expected: false };
143
+ }
144
+
145
+ const indent = (s: string) => s.replace(/^(?=.)/gm, " ");
146
+
147
+ export function formatError(
148
+ e: unknown,
149
+ opts: { debug?: boolean; command?: string } = {},
150
+ ): string {
151
+ const d = describeError(e, opts.command);
152
+ const lines = [`✖ ${d.message}`];
153
+ if (d.why) lines.push("", indent(d.why));
154
+ lines.push("");
155
+ if (d.fix) lines.push(` Fix: ${d.fix}`);
156
+ if (d.docs) lines.push(` Docs: ${DOCS_URL}/${d.docs}`);
157
+ if (opts.debug && e instanceof Error && e.stack) {
158
+ lines.push("", e.stack);
159
+ if (e.cause instanceof Error && e.cause.stack)
160
+ lines.push("", `Caused by: ${e.cause.stack}`);
161
+ } else if (d.expected) {
162
+ const more = d.more ?? "Run `kanbot doctor` to check your setup.";
163
+ if (more) lines.push(` More: ${more}`);
164
+ } else {
165
+ lines.push(
166
+ " More: rerun with KANBOT_DEBUG=1 for a stack trace.",
167
+ ` If it looks like a bug, report it at ${ISSUES_URL}`,
168
+ );
169
+ }
170
+ return lines.join("\n");
171
+ }
@@ -0,0 +1,332 @@
1
+ import type { Checks, Comment, Issue, PR } from "../daemon/types.ts";
2
+ import type { Gh } from "./gh.ts";
3
+
4
+ const COMMENT = "id databaseId author { login } body createdAt";
5
+
6
+ const PR_FIELDS = `
7
+ id number url title body state headRefName headRefOid mergeable createdAt
8
+ labels(first: 30) { nodes { name } }
9
+ comments(last: 100) { nodes { ${COMMENT} } }
10
+ reviews(last: 50) { nodes { ${COMMENT} } }
11
+ reviewThreads(last: 100) {
12
+ nodes {
13
+ id isResolved path line
14
+ comments(last: 30) { nodes { ${COMMENT} } }
15
+ }
16
+ }
17
+ commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`;
18
+
19
+ const ISSUE_QUERY = `
20
+ query($owner: String!, $name: String!, $number: Int!) {
21
+ repository(owner: $owner, name: $name) {
22
+ issue(number: $number) {
23
+ id number url title body state
24
+ labels(first: 50) { nodes { name } }
25
+ comments(last: 100) { nodes { ${COMMENT} } }
26
+ closedByPullRequestsReferences(first: 10, includeClosedPrs: true) {
27
+ nodes { ${PR_FIELDS} }
28
+ }
29
+ }
30
+ }
31
+ }`;
32
+
33
+ const LABELED_PRS_QUERY = `
34
+ query($owner: String!, $name: String!, $label: String!) {
35
+ repository(owner: $owner, name: $name) {
36
+ pullRequests(labels: [$label], states: OPEN, first: 50) {
37
+ nodes { isCrossRepository ${PR_FIELDS} }
38
+ }
39
+ }
40
+ }`;
41
+
42
+ type RawComment = Omit<Comment, "author"> & {
43
+ author: { login: string } | null;
44
+ };
45
+ type Nodes<T> = { nodes: T[] };
46
+
47
+ const comment = (c: RawComment): Comment => ({
48
+ ...c,
49
+ author: c.author?.login ?? "ghost",
50
+ });
51
+ const names = (l: Nodes<{ name: string }>) => l.nodes.map((n) => n.name);
52
+
53
+ export async function fetchIssue(gh: Gh, number: number): Promise<Issue> {
54
+ // biome-ignore lint/suspicious/noExplicitAny: raw GraphQL shape, mapped below
55
+ const data = await gh.query<any>(ISSUE_QUERY, {
56
+ owner: gh.owner,
57
+ name: gh.name,
58
+ number,
59
+ });
60
+ const i = data.repository.issue;
61
+ return {
62
+ id: i.id,
63
+ number: i.number,
64
+ url: i.url,
65
+ title: i.title,
66
+ body: i.body,
67
+ state: i.state,
68
+ labels: names(i.labels),
69
+ comments: i.comments.nodes.map(comment),
70
+ prs: i.closedByPullRequestsReferences.nodes.map(mapPR),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Open PRs with `label`, for review without a board card. Fork PRs are left
76
+ * out: their branch isn't on origin, so fixes couldn't be pushed.
77
+ */
78
+ export async function fetchLabeledPRs(gh: Gh, label: string): Promise<PR[]> {
79
+ // biome-ignore lint/suspicious/noExplicitAny: raw GraphQL shape, mapped below
80
+ const data = await gh.query<any>(LABELED_PRS_QUERY, {
81
+ owner: gh.owner,
82
+ name: gh.name,
83
+ label,
84
+ });
85
+ return data.repository.pullRequests.nodes
86
+ .filter((p: { isCrossRepository: boolean }) => !p.isCrossRepository)
87
+ .map(mapPR);
88
+ }
89
+
90
+ // biome-ignore lint/suspicious/noExplicitAny: raw GraphQL shape
91
+ function mapPR(p: any): PR {
92
+ return {
93
+ id: p.id,
94
+ number: p.number,
95
+ url: p.url,
96
+ title: p.title,
97
+ body: p.body,
98
+ state: p.state,
99
+ headRefName: p.headRefName,
100
+ headSha: p.headRefOid,
101
+ createdAt: p.createdAt,
102
+ labels: names(p.labels),
103
+ comments: p.comments.nodes.map(comment),
104
+ reviews: p.reviews.nodes.map(comment).filter((r: Comment) => r.body.trim()),
105
+ // biome-ignore lint/suspicious/noExplicitAny: raw GraphQL shape
106
+ threads: p.reviewThreads.nodes.map((t: any) => ({
107
+ id: t.id,
108
+ isResolved: t.isResolved,
109
+ path: t.path,
110
+ line: t.line,
111
+ comments: t.comments.nodes.map(comment),
112
+ })),
113
+ checks: (p.commits.nodes[0]?.commit.statusCheckRollup?.state ??
114
+ null) as Checks,
115
+ mergeable: p.mergeable,
116
+ };
117
+ }
118
+
119
+ // ---- writes ---------------------------------------------------------------
120
+
121
+ export function postComment(gh: Gh, number: number, body: string) {
122
+ return gh.restWrite(
123
+ `comment on #${number}`,
124
+ "POST",
125
+ gh.r(`/issues/${number}/comments`),
126
+ { body },
127
+ );
128
+ }
129
+
130
+ export function updateIssueBody(gh: Gh, number: number, body: string) {
131
+ return gh.restWrite(
132
+ `update body of #${number}`,
133
+ "PATCH",
134
+ gh.r(`/issues/${number}`),
135
+ { body },
136
+ );
137
+ }
138
+
139
+ export function addLabels(gh: Gh, number: number, labels: string[]) {
140
+ if (!labels.length) return Promise.resolve(null);
141
+ return gh.restWrite(
142
+ `label #${number} ${labels.join(",")}`,
143
+ "POST",
144
+ gh.r(`/issues/${number}/labels`),
145
+ {
146
+ labels,
147
+ },
148
+ );
149
+ }
150
+
151
+ export async function removeLabel(gh: Gh, number: number, label: string) {
152
+ try {
153
+ await gh.restWrite(
154
+ `unlabel #${number} ${label}`,
155
+ "DELETE",
156
+ gh.r(`/issues/${number}/labels/${encodeURIComponent(label)}`),
157
+ );
158
+ } catch (e) {
159
+ if (!String(e).includes("404")) throw e;
160
+ }
161
+ }
162
+
163
+ export function react(
164
+ gh: Gh,
165
+ subjectId: string,
166
+ content: "EYES" | "THUMBS_UP" | "ROCKET",
167
+ ) {
168
+ return gh.mutate(
169
+ `react ${content}`,
170
+ `mutation($s: ID!, $c: ReactionContent!) { addReaction(input: { subjectId: $s, content: $c }) { reaction { content } } }`,
171
+ { s: subjectId, c: content },
172
+ );
173
+ }
174
+
175
+ export async function createIssue(gh: Gh, title: string, body: string) {
176
+ return gh.restWrite<{ number: number; node_id: string }>(
177
+ `create issue "${title}"`,
178
+ "POST",
179
+ gh.r("/issues"),
180
+ { title, body },
181
+ );
182
+ }
183
+
184
+ export function addSubIssue(gh: Gh, parentId: string, childId: string) {
185
+ return gh.mutate(
186
+ "add sub-issue",
187
+ `mutation($p: ID!, $c: ID!) { addSubIssue(input: { issueId: $p, subIssueId: $c }) { issue { id } } }`,
188
+ { p: parentId, c: childId },
189
+ );
190
+ }
191
+
192
+ export function createPR(
193
+ gh: Gh,
194
+ pr: {
195
+ title: string;
196
+ body: string;
197
+ head: string;
198
+ base: string;
199
+ draft?: boolean;
200
+ },
201
+ ) {
202
+ return gh.restWrite<{ number: number; html_url: string }>(
203
+ `open PR ${pr.head} → ${pr.base}`,
204
+ "POST",
205
+ gh.r("/pulls"),
206
+ pr,
207
+ );
208
+ }
209
+
210
+ export function updatePRBody(gh: Gh, number: number, body: string) {
211
+ return gh.restWrite(
212
+ `update body of PR #${number}`,
213
+ "PATCH",
214
+ gh.r(`/pulls/${number}`),
215
+ { body },
216
+ );
217
+ }
218
+
219
+ export function closePR(gh: Gh, number: number) {
220
+ return gh.restWrite(
221
+ `close PR #${number}`,
222
+ "PATCH",
223
+ gh.r(`/pulls/${number}`),
224
+ { state: "closed" },
225
+ );
226
+ }
227
+
228
+ export function replyToThread(gh: Gh, threadId: string, body: string) {
229
+ return gh.mutate(
230
+ "reply to review thread",
231
+ `mutation($t: ID!, $b: String!) {
232
+ addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $t, body: $b }) { comment { id } }
233
+ }`,
234
+ { t: threadId, b: body },
235
+ );
236
+ }
237
+
238
+ export function resolveThread(gh: Gh, threadId: string) {
239
+ return gh.mutate(
240
+ "resolve review thread",
241
+ `mutation($t: ID!) { resolveReviewThread(input: { threadId: $t }) { thread { id } } }`,
242
+ { t: threadId },
243
+ );
244
+ }
245
+
246
+ export async function ensureLabel(
247
+ gh: Gh,
248
+ name: string,
249
+ color: string,
250
+ description: string,
251
+ ) {
252
+ try {
253
+ await gh.restWrite(`create label ${name}`, "POST", gh.r("/labels"), {
254
+ name,
255
+ color,
256
+ description,
257
+ });
258
+ } catch (e) {
259
+ if (!String(e).includes("already_exists")) throw e;
260
+ }
261
+ }
262
+
263
+ export async function failedCheckLogs(gh: Gh, sha: string): Promise<string> {
264
+ type Runs = {
265
+ check_runs: {
266
+ name: string;
267
+ conclusion: string | null;
268
+ output: {
269
+ title: string | null;
270
+ summary: string | null;
271
+ text: string | null;
272
+ };
273
+ }[];
274
+ };
275
+ const runs = await gh.rest<Runs>(
276
+ gh.r(`/commits/${sha}/check-runs?per_page=50`),
277
+ );
278
+ return runs.check_runs
279
+ .filter((r) => r.conclusion === "failure" || r.conclusion === "timed_out")
280
+ .map(
281
+ (r) =>
282
+ `### ${r.name} (${r.conclusion})\n${[r.output.title, r.output.summary, r.output.text].filter(Boolean).join("\n").slice(0, 6000)}`,
283
+ )
284
+ .join("\n\n");
285
+ }
286
+
287
+ // ---- Copilot code review ------------------------------------------------------
288
+
289
+ const COPILOT_RULESET = "kanbot: Copilot review";
290
+
291
+ /** Create or update the branch ruleset that requests Copilot review on PRs. */
292
+ export async function ensureCopilotRuleset(gh: Gh, onPush: boolean) {
293
+ const body = {
294
+ name: COPILOT_RULESET,
295
+ target: "branch",
296
+ enforcement: "active",
297
+ conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
298
+ rules: [
299
+ {
300
+ type: "copilot_code_review",
301
+ parameters: {
302
+ review_on_push: onPush,
303
+ review_draft_pull_requests: false,
304
+ },
305
+ },
306
+ ],
307
+ };
308
+ const sets = await gh.rest<{ id: number; name: string }[]>(gh.r("/rulesets"));
309
+ const existing = sets.find((s) => s.name === COPILOT_RULESET);
310
+ return existing
311
+ ? gh.restWrite(
312
+ "update Copilot review ruleset",
313
+ "PUT",
314
+ gh.r(`/rulesets/${existing.id}`),
315
+ body,
316
+ )
317
+ : gh.restWrite(
318
+ "create Copilot review ruleset",
319
+ "POST",
320
+ gh.r("/rulesets"),
321
+ body,
322
+ );
323
+ }
324
+
325
+ /** The active Copilot review rule on `branch`, if any. */
326
+ export async function copilotReviewRule(gh: Gh, branch: string) {
327
+ type Rule = { type: string; parameters?: { review_on_push?: boolean } };
328
+ const rules = await gh.rest<Rule[]>(
329
+ gh.r(`/rules/branches/${encodeURIComponent(branch)}`),
330
+ );
331
+ return rules.find((r) => r.type === "copilot_code_review") ?? null;
332
+ }
@@ -0,0 +1,110 @@
1
+ import { log } from "../log.ts";
2
+
3
+ /**
4
+ * Thin wrapper over the `gh` CLI so kanbot reuses the user's existing auth.
5
+ * Reads always run; writes are skipped (and logged) in dry-run mode.
6
+ */
7
+ export class Gh {
8
+ constructor(
9
+ readonly repo: string,
10
+ readonly dryRun = false,
11
+ ) {}
12
+
13
+ get owner() {
14
+ return this.repo.split("/")[0] as string;
15
+ }
16
+ get name() {
17
+ return this.repo.split("/")[1] as string;
18
+ }
19
+
20
+ async query<T>(
21
+ query: string,
22
+ variables: Record<string, unknown> = {},
23
+ ): Promise<T> {
24
+ const input = JSON.stringify({ query, variables });
25
+ // Projects mutations sometimes fail with "temporary conflict … try again"
26
+ // when a board workflow (e.g. Auto-add) touches the same item.
27
+ for (let attempt = 1; ; attempt++) {
28
+ try {
29
+ return await this.queryOnce<T>(input);
30
+ } catch (e) {
31
+ if (attempt >= 3 || !/try again/i.test(String(e))) throw e;
32
+ await Bun.sleep(1000 * attempt);
33
+ }
34
+ }
35
+ }
36
+
37
+ private async queryOnce<T>(input: string): Promise<T> {
38
+ const out = await run(["api", "graphql", "--input", "-"], input);
39
+ const json = JSON.parse(out) as { data: T; errors?: { message: string }[] };
40
+ if (json.errors?.length) {
41
+ throw new Error(
42
+ `GraphQL: ${json.errors.map((e) => e.message).join("; ")}`,
43
+ );
44
+ }
45
+ return json.data;
46
+ }
47
+
48
+ async mutate<T>(
49
+ what: string,
50
+ query: string,
51
+ variables: Record<string, unknown> = {},
52
+ ): Promise<T | null> {
53
+ if (this.dryRun) {
54
+ log.info(`[dry-run] ${what}`);
55
+ return null;
56
+ }
57
+ return this.query<T>(query, variables);
58
+ }
59
+
60
+ async rest<T = unknown>(path: string): Promise<T> {
61
+ return JSON.parse(await run(["api", path])) as T;
62
+ }
63
+
64
+ async restWrite<T = unknown>(
65
+ what: string,
66
+ method: "POST" | "PATCH" | "PUT" | "DELETE",
67
+ path: string,
68
+ body?: unknown,
69
+ ): Promise<T | null> {
70
+ if (this.dryRun) {
71
+ log.info(`[dry-run] ${what}`);
72
+ return null;
73
+ }
74
+ const out = await run(
75
+ [
76
+ "api",
77
+ "-X",
78
+ method,
79
+ path,
80
+ ...(body === undefined ? [] : ["--input", "-"]),
81
+ ],
82
+ body === undefined ? undefined : JSON.stringify(body),
83
+ );
84
+ return (out.trim() ? JSON.parse(out) : null) as T;
85
+ }
86
+
87
+ /** `repos/{owner}/{name}` + suffix */
88
+ r(suffix: string) {
89
+ return `repos/${this.repo}${suffix}`;
90
+ }
91
+ }
92
+
93
+ export async function run(args: string[], input?: string): Promise<string> {
94
+ const proc = Bun.spawn(["gh", ...args], {
95
+ stdin: input === undefined ? "ignore" : new Blob([input]),
96
+ stdout: "pipe",
97
+ stderr: "pipe",
98
+ });
99
+ const [out, err, code] = await Promise.all([
100
+ new Response(proc.stdout).text(),
101
+ new Response(proc.stderr).text(),
102
+ proc.exited,
103
+ ]);
104
+ if (code !== 0) {
105
+ throw new Error(
106
+ `gh ${args.slice(0, 3).join(" ")} failed (${code}): ${err.trim() || out.trim()}`,
107
+ );
108
+ }
109
+ return out;
110
+ }