kanbot 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +102 -2
- package/package.json +56 -5
- package/schema/config.schema.json +206 -0
- package/src/agents/load.ts +83 -0
- package/src/agents/sync.ts +216 -0
- package/src/cli.ts +5 -0
- package/src/commands/board.ts +140 -0
- package/src/commands/completion.ts +24 -0
- package/src/commands/doctor.ts +250 -0
- package/src/commands/init.ts +102 -0
- package/src/commands/run.ts +32 -0
- package/src/commands/skills.ts +63 -0
- package/src/commands/status.ts +61 -0
- package/src/commands/sync.ts +31 -0
- package/src/commands/upgrade.ts +70 -0
- package/src/completion.ts +217 -0
- package/src/config.ts +213 -0
- package/src/daemon/derive.ts +413 -0
- package/src/daemon/git.ts +187 -0
- package/src/daemon/lock.ts +24 -0
- package/src/daemon/loop.ts +207 -0
- package/src/daemon/notify.ts +44 -0
- package/src/daemon/snapshot.ts +109 -0
- package/src/daemon/state.ts +60 -0
- package/src/daemon/types.ts +73 -0
- package/src/github/api.ts +332 -0
- package/src/github/gh.ts +110 -0
- package/src/github/markers.ts +82 -0
- package/src/github/project.ts +278 -0
- package/src/github/trust.ts +38 -0
- package/src/jobs/prompts.ts +205 -0
- package/src/jobs/run.ts +569 -0
- package/src/known-hashes.json +132 -0
- package/src/log.ts +11 -0
- package/src/main.ts +21 -0
- package/src/providers/claude.ts +122 -0
- package/src/providers/codex.ts +98 -0
- package/src/providers/types.ts +94 -0
- package/src/schemas/results.ts +116 -0
- package/src/templates.ts +192 -0
- package/templates/.agents/skills/accessibility/SKILL.md +76 -0
- package/templates/.agents/skills/approach-designer/SKILL.md +79 -0
- package/templates/.agents/skills/code-review/SKILL.md +83 -0
- package/templates/.agents/skills/frontend-design/LICENSE.txt +177 -0
- package/templates/.agents/skills/frontend-design/SKILL.md +76 -0
- package/templates/.agents/skills/issue-splitting/SKILL.md +78 -0
- package/templates/.agents/skills/kanbot-admin/SKILL.md +57 -0
- package/templates/.agents/skills/kanbot-idea/SKILL.md +56 -0
- package/templates/.agents/skills/plan-template/SKILL.md +85 -0
- package/templates/.agents/skills/pr-description/SKILL.md +44 -0
- package/templates/.agents/skills/project-conventions/SKILL.md +79 -0
- package/templates/.agents/skills/project-memory/SKILL.md +39 -0
- package/templates/.agents/skills/project-memory/facts/.gitkeep +0 -0
- package/templates/.agents/skills/spec-review/SKILL.md +73 -0
- package/templates/.agents/skills/tdd/SKILL.md +51 -0
- package/templates/.agents/skills/user-stories/SKILL.md +82 -0
- package/templates/.agents/skills/verify-ui/SKILL.md +104 -0
- package/templates/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/templates/.github/ISSUE_TEMPLATE/technical.yml +15 -0
- package/templates/.github/ISSUE_TEMPLATE/user-story.yml +15 -0
- package/templates/.github/pull_request_template.md +15 -0
- package/templates/.github/workflows/ci.yml +23 -0
- package/templates/.github/workflows/kanbot-merged.yml +30 -0
- package/templates/.kanbot/agents/approach-designer.md +31 -0
- package/templates/.kanbot/agents/explorer.md +35 -0
- package/templates/.kanbot/agents/fixer.md +54 -0
- package/templates/.kanbot/agents/implementer-backend.md +57 -0
- package/templates/.kanbot/agents/implementer-docs.md +55 -0
- package/templates/.kanbot/agents/implementer-frontend.md +54 -0
- package/templates/.kanbot/agents/implementer-technical.md +53 -0
- package/templates/.kanbot/agents/plan-critic.md +36 -0
- package/templates/.kanbot/agents/planner.md +52 -0
- package/templates/.kanbot/agents/reviewer-code.md +46 -0
- package/templates/.kanbot/agents/reviewer-spec.md +49 -0
- package/templates/.kanbot/agents/test-writer.md +35 -0
- package/templates/.kanbot/agents/ui-verifier.md +35 -0
- package/templates/AGENTS.md +39 -0
- package/templates/CLAUDE.md +1 -0
|
@@ -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
|
+
}
|
package/src/github/gh.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hidden HTML-comment markers. Agents post as the human's own account, so the
|
|
3
|
+
* marker is the only way to tell kanbot output apart from human input, and it
|
|
4
|
+
* doubles as the idempotency key for every side effect.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const ANY = /<!--\s*kanbot(?::[^>]*)?-->/;
|
|
8
|
+
const PLAN_START = "<!-- kanbot:plan:start -->";
|
|
9
|
+
const PLAN_END = "<!-- kanbot:plan:end -->";
|
|
10
|
+
|
|
11
|
+
export function marker(...parts: (string | number)[]) {
|
|
12
|
+
return parts.length
|
|
13
|
+
? `<!-- kanbot:${parts.join(":")} -->`
|
|
14
|
+
: "<!-- kanbot -->";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isKanbot(body: string) {
|
|
18
|
+
return ANY.test(body);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function hasMarker(body: string, ...parts: (string | number)[]) {
|
|
22
|
+
return body.includes(marker(...parts));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** All values `x` from markers shaped `<!-- kanbot:<kind>:x -->`. */
|
|
26
|
+
export function markerValues(body: string, kind: string): string[] {
|
|
27
|
+
const re = new RegExp(`<!-- kanbot:${kind}:([^ ]+) -->`, "g");
|
|
28
|
+
return [...body.matchAll(re)].map((m) => m[1] as string);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function extractPlan(body: string): string | null {
|
|
32
|
+
const start = body.indexOf(PLAN_START);
|
|
33
|
+
const end = body.indexOf(PLAN_END);
|
|
34
|
+
if (start === -1 || end === -1 || end < start) return null;
|
|
35
|
+
return body.slice(start + PLAN_START.length, end).trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The human-written part of an issue body (everything outside the plan block). */
|
|
39
|
+
export function stripPlan(body: string): string {
|
|
40
|
+
const start = body.indexOf(PLAN_START);
|
|
41
|
+
const end = body.indexOf(PLAN_END);
|
|
42
|
+
if (start === -1 || end === -1) return body.trim();
|
|
43
|
+
return (body.slice(0, start) + body.slice(end + PLAN_END.length)).trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function withPlan(body: string, plan: string): string {
|
|
47
|
+
const block = `${PLAN_START}\n\n${plan.trim()}\n\n${PLAN_END}`;
|
|
48
|
+
const human = stripPlan(body);
|
|
49
|
+
return human ? `${human}\n\n---\n\n${block}` : block;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DESC_START = "<!-- kanbot:desc:start -->";
|
|
53
|
+
const DESC_END = "<!-- kanbot:desc:end -->";
|
|
54
|
+
/** Where the daemon's own PR body sections begin, for bodies without a desc block. */
|
|
55
|
+
const PR_TAIL = /^(### Risks|### Checks|> Exploratory|Closes #)/m;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Put the agent-written PR description in a marked block, replacing the
|
|
59
|
+
* previous one and keeping the daemon's sections (risks, checks, Closes #n).
|
|
60
|
+
*/
|
|
61
|
+
export function withDescription(body: string, description: string): string {
|
|
62
|
+
const block = `${DESC_START}\n\n${description.trim()}\n\n${DESC_END}`;
|
|
63
|
+
const start = body.indexOf(DESC_START);
|
|
64
|
+
const end = body.indexOf(DESC_END);
|
|
65
|
+
if (start !== -1 && end > start)
|
|
66
|
+
return body.slice(0, start) + block + body.slice(end + DESC_END.length);
|
|
67
|
+
const tail = body.search(PR_TAIL);
|
|
68
|
+
return tail === -1 ? block : `${block}\n\n${body.slice(tail)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Embed machine-readable data in a comment. */
|
|
72
|
+
export function embedJson(kind: string, data: unknown) {
|
|
73
|
+
return `<!-- kanbot-data:${kind} ${Buffer.from(JSON.stringify(data)).toString("base64")} -->`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function readJson<T>(body: string, kind: string): T | null {
|
|
77
|
+
const m = body.match(
|
|
78
|
+
new RegExp(`<!-- kanbot-data:${kind} ([A-Za-z0-9+/=]+) -->`),
|
|
79
|
+
);
|
|
80
|
+
if (!m?.[1]) return null;
|
|
81
|
+
return JSON.parse(Buffer.from(m[1], "base64").toString("utf8")) as T;
|
|
82
|
+
}
|