wolli 0.0.4 → 0.0.6

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.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * GitHub chat routing, as one workflows file. It maps the transport's generic events onto
3
+ * per-conversation sessions and ships replies back as comments; the transport itself never
4
+ * touches a session.
5
+ *
6
+ * - `inboundIssueComment` / `inboundReviewComment` bind each issue/PR conversation to its own
7
+ * session by a `github:thread` tag and deliver an @mention as a followUp — gated on the
8
+ * `mention` trigger and the self/bot/marker loop guard. The summoning comment gets an `eyes`
9
+ * reaction as an acknowledgment, and when the mention is on a pull request the PR's source is
10
+ * checked out (see `buildReviewTurn`) so the agent reviews real code with its own file tools.
11
+ * - `auto` seeds a "review this PR" turn when a PR is opened/updated, gated on the `auto`
12
+ * trigger and deduped on head SHA via a session tag so the same commits are reviewed once.
13
+ * - `reply` ships the turn's final assistant text back as a comment on `agent_end`, riding the
14
+ * producing session's `github:thread` tag, chunked to GitHub's comment size limit and tagged
15
+ * with the loop-prevention marker.
16
+ */
17
+
18
+ import { type WorkflowContext, wolli } from "wolli";
19
+ import {
20
+ chunkComment,
21
+ extractMention,
22
+ GITHUB_COMMENT_MARKER,
23
+ isIgnoredInboundComment,
24
+ parseThreadTag,
25
+ } from "./github-api.ts";
26
+ import { reviewPaths } from "./github-workspace.ts";
27
+ import github from "./index.ts";
28
+
29
+ /**
30
+ * Check the pull request out under the agent workspace and return the review turn to send (plus the
31
+ * head SHA, which the caller records on the session so the review tool can anchor inline comments):
32
+ * the `lead` (the reviewer's actual ask), then where the working copy is and how to diff it. The
33
+ * agent reads and greps the real tree — nothing about the code is pasted into the prompt.
34
+ */
35
+ async function buildReviewTurn(
36
+ ctx: WorkflowContext,
37
+ repo: string,
38
+ pullRequestNumber: number,
39
+ lead: string,
40
+ ): Promise<{ message: string; headSha: string }> {
41
+ const api = ctx.integration(github);
42
+ const [pr, filesResult] = await Promise.all([
43
+ api.getPullRequest({ repo, pullRequestNumber }) as Promise<{
44
+ title?: string;
45
+ body?: string;
46
+ head?: { sha?: string };
47
+ base?: { sha?: string };
48
+ } | null>,
49
+ api.listPullRequestFiles({ repo, pullRequestNumber }) as Promise<{
50
+ files?: Array<{ filename?: string }>;
51
+ } | null>,
52
+ ]);
53
+
54
+ const headSha = pr?.head?.sha ?? "";
55
+ const baseSha = pr?.base?.sha ?? "";
56
+ const { absDir, relDir } = reviewPaths(ctx.agent.cwd, repo, pullRequestNumber);
57
+ await api.checkoutPullRequest({ repo, pullRequestNumber, destDir: absDir, headSha, baseSha });
58
+
59
+ const changed = (filesResult?.files ?? []).map((f) => ` ${f.filename ?? "?"}`).join("\n");
60
+ const body = (pr?.body ?? "").trim();
61
+
62
+ const message = [
63
+ lead,
64
+ "",
65
+ `Repository: ${repo} Pull request #${pullRequestNumber}: ${pr?.title ?? ""}`.trimEnd(),
66
+ ...(body ? [`Description: ${body.slice(0, 2000)}`] : []),
67
+ "",
68
+ `A read-only checkout of the head commit is at ${relDir} (relative to your working directory).`,
69
+ "You have the full source tree — read any file, and grep the tree to check for duplication or",
70
+ "to see how the changed code is used elsewhere.",
71
+ "",
72
+ `See exactly what changed: git -C ${relDir} diff ${baseSha} ${headSha}`,
73
+ "",
74
+ "Files changed in this PR:",
75
+ changed || " (none reported)",
76
+ "",
77
+ "Review for correctness, code duplication, and potential bugs. Leave each finding as an inline",
78
+ 'comment on the exact line with the github_review tool (action "comment"), then submit your',
79
+ 'verdict with action "submit" (REQUEST_CHANGES if there are real problems, otherwise COMMENT).',
80
+ ].join("\n");
81
+ return { message, headSha };
82
+ }
83
+
84
+ /** Route an @mention comment into the conversation's session, if the mention trigger is enabled. */
85
+ async function routeMention(
86
+ ctx: WorkflowContext,
87
+ comment: {
88
+ repo: string;
89
+ number: number;
90
+ body: string;
91
+ authorLogin: string;
92
+ authorType: string;
93
+ commentId: number;
94
+ subject: "issue_comment" | "pull_request_review_comment";
95
+ isPullRequest: boolean;
96
+ },
97
+ ): Promise<void> {
98
+ const { botLogin, triggers } = await ctx.integration(github).getSettings();
99
+ if (!triggers.includes("mention")) return;
100
+ if (isIgnoredInboundComment(comment.body, comment.authorLogin, comment.authorType, botLogin)) return;
101
+ const mention = extractMention(comment.body, botLogin);
102
+ if (!mention) return;
103
+
104
+ // Acknowledge on sight: drop an eyes reaction on the comment that summoned us. Non-fatal —
105
+ // a reaction failure must never stop the review from running.
106
+ try {
107
+ await ctx.integration(github).addReaction({
108
+ repo: comment.repo,
109
+ subject: comment.subject,
110
+ commentId: comment.commentId,
111
+ content: "eyes",
112
+ });
113
+ } catch (err) {
114
+ console.error("[github] could not add reaction:", err instanceof Error ? err.message : err);
115
+ }
116
+
117
+ const tag = { "github:thread": `${comment.repo}#${comment.number}` };
118
+ const [match] = await ctx.agent.findSessions(tag);
119
+ const session = match
120
+ ? await ctx.agent.openSession(match.id)
121
+ : await ctx.agent.createSession({ setup: (s) => s.appendTags(tag) });
122
+
123
+ // On a PR, check the code out for the agent; on a plain issue, the comment text is the whole ask.
124
+ const text = mention.message || comment.body;
125
+ if (comment.isPullRequest) {
126
+ const { message, headSha } = await buildReviewTurn(ctx, comment.repo, comment.number, text);
127
+ // Record the head SHA so the github_review tool can anchor inline comments to this commit.
128
+ session.setTags({ "github:head-sha": headSha });
129
+ await session.sendUserMessage(message, { deliverAs: "followUp" });
130
+ } else {
131
+ // followUp queues behind a running turn instead of interrupting it.
132
+ await session.sendUserMessage(text, { deliverAs: "followUp" });
133
+ }
134
+ }
135
+
136
+ // evt is typed from the event schema
137
+ export const inboundIssueComment = github.on("issue_comment", async (evt, ctx) => {
138
+ await routeMention(ctx, {
139
+ repo: evt.repo,
140
+ number: evt.issueNumber,
141
+ body: evt.body,
142
+ authorLogin: evt.authorLogin,
143
+ authorType: evt.authorType,
144
+ commentId: evt.commentId,
145
+ subject: "issue_comment",
146
+ isPullRequest: evt.isPullRequest,
147
+ });
148
+ });
149
+
150
+ export const inboundReviewComment = github.on("pull_request_review_comment", async (evt, ctx) => {
151
+ await routeMention(ctx, {
152
+ repo: evt.repo,
153
+ number: evt.pullRequestNumber,
154
+ body: evt.body,
155
+ authorLogin: evt.authorLogin,
156
+ authorType: evt.authorType,
157
+ commentId: evt.commentId,
158
+ subject: "pull_request_review_comment",
159
+ isPullRequest: true,
160
+ });
161
+ });
162
+
163
+ export const auto = github.on("pull_request", async (evt, ctx) => {
164
+ const { triggers } = await ctx.integration(github).getSettings();
165
+ if (!triggers.includes("auto")) return;
166
+ if (evt.draft) return;
167
+ if (!["opened", "synchronize", "reopened", "ready_for_review"].includes(evt.action)) return;
168
+
169
+ const tag = { "github:thread": `${evt.repo}#${evt.pullRequestNumber}` };
170
+ const [match] = await ctx.agent.findSessions(tag);
171
+ const session = match
172
+ ? await ctx.agent.openSession(match.id)
173
+ : await ctx.agent.createSession({ setup: (s) => s.appendTags(tag) });
174
+
175
+ // Dedupe on head SHA via a session tag: the same commits are reviewed once, even if the
176
+ // transport re-emits the PR. Persist the marker before sending (at-most-once).
177
+ if (session.getTags()["github:reviewed-sha"] === evt.headSha) return;
178
+ session.setTags({ "github:reviewed-sha": evt.headSha });
179
+
180
+ const { message, headSha } = await buildReviewTurn(
181
+ ctx,
182
+ evt.repo,
183
+ evt.pullRequestNumber,
184
+ "A pull request was opened or updated for review.",
185
+ );
186
+ // Record the head SHA so the github_review tool can anchor inline comments to this commit.
187
+ session.setTags({ "github:head-sha": headSha });
188
+ await session.sendUserMessage(message, { deliverAs: "followUp" });
189
+ });
190
+
191
+ export const reply = wolli.on("agent_end", async (evt, ctx) => {
192
+ const tags = ctx.session.getTags();
193
+ const thread = tags["github:thread"];
194
+ if (!thread) return; // not a github-bound session
195
+ // The github_review tool posts a formal review and sets this flag; when it did, stand down so the
196
+ // summary is not also posted as a duplicate timeline comment. Clear it for the next turn.
197
+ if (tags["github:review-posted"] === "1") {
198
+ ctx.session.setTags({ "github:review-posted": "" });
199
+ return;
200
+ }
201
+ const { repo, number } = parseThreadTag(thread);
202
+
203
+ const text = evt.messages
204
+ .filter((m) => m.role === "assistant")
205
+ .at(-1)
206
+ ?.content.filter((c) => c.type === "text")
207
+ .map((c) => c.text)
208
+ .join("")
209
+ .trim();
210
+ if (!text) return; // a pure tool-call turn sends nothing
211
+
212
+ // The marker rides the last chunk; combined with the bot-login check it prevents reply loops.
213
+ for (const body of chunkComment(`${text}\n\n${GITHUB_COMMENT_MARKER}`)) {
214
+ await ctx.integration(github).postComment({ repo, issueNumber: number, body });
215
+ }
216
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * GitHub review tool — lets the review agent post an actual pull-request review rather than a
3
+ * plain comment: inline notes anchored to diff lines (`action: "comment"`) and a formal verdict
4
+ * (`action: "submit"`). It drives the github integration's write actions, deriving the target PR
5
+ * and head commit from the session's `github:thread` / `github:head-sha` tags (set when the review
6
+ * turn is seeded), so the agent only supplies path/line/body.
7
+ *
8
+ * `submit` is capped to COMMENT / REQUEST_CHANGES — the reviewer never auto-approves. On submit it
9
+ * sets `github:review-posted` so the `agent_end` reply workflow stands down and does not also post
10
+ * the summary as a duplicate timeline comment.
11
+ */
12
+
13
+ import { defineTool } from "wolli";
14
+ import { Type } from "typebox";
15
+ import { parseThreadTag } from "./github-api.ts";
16
+ import github from "./index.ts";
17
+
18
+ const ReviewParams = Type.Object({
19
+ action: Type.Union([Type.Literal("comment"), Type.Literal("submit")], {
20
+ description: "comment: leave an inline comment on a diff line. submit: submit the overall review verdict.",
21
+ }),
22
+ path: Type.Optional(Type.String({ description: "comment: file path, exactly as it appears in the PR." })),
23
+ line: Type.Optional(
24
+ Type.Number({ description: "comment: line number in the file to attach to. Must be a line this PR changed." }),
25
+ ),
26
+ side: Type.Optional(
27
+ Type.Union([Type.Literal("LEFT"), Type.Literal("RIGHT")], {
28
+ description: "comment: RIGHT (the new version, default) or LEFT (the old version).",
29
+ }),
30
+ ),
31
+ body: Type.Optional(Type.String({ description: "comment: the note. submit: the review summary (Markdown)." })),
32
+ event: Type.Optional(
33
+ Type.Union([Type.Literal("COMMENT"), Type.Literal("REQUEST_CHANGES")], {
34
+ description: "submit: COMMENT for a non-blocking review, REQUEST_CHANGES to block the merge.",
35
+ }),
36
+ ),
37
+ });
38
+
39
+ function text(message: string, details: unknown) {
40
+ return { content: [{ type: "text" as const, text: message }], details };
41
+ }
42
+
43
+ export default defineTool({
44
+ name: "github_review",
45
+ label: "GitHub review",
46
+ description:
47
+ "Post a review on the pull request under review. action=comment leaves an inline comment on a specific file+line of the diff; action=submit submits the overall verdict (COMMENT or REQUEST_CHANGES) with a summary. The target PR is the current review session, so you only supply path/line/body — not the repo or PR number.",
48
+ promptSnippet: "github_review: leave inline PR review comments and submit a verdict (COMMENT / REQUEST_CHANGES).",
49
+ promptGuidelines: [
50
+ "When reviewing a pull request, leave each finding as an inline github_review comment on the exact changed line, then finish with one github_review submit — REQUEST_CHANGES if there are real problems, otherwise COMMENT.",
51
+ ],
52
+ parameters: ReviewParams,
53
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
54
+ const tags = ctx.session.getTags();
55
+ const thread = tags["github:thread"];
56
+ if (!thread) return text("Error: this is not a GitHub pull-request review session.", { error: "no thread" });
57
+
58
+ let repo: string;
59
+ let pullRequestNumber: number;
60
+ try {
61
+ ({ repo, number: pullRequestNumber } = parseThreadTag(thread));
62
+ } catch (err) {
63
+ return text(`Error: ${err instanceof Error ? err.message : String(err)}`, { error: "bad thread tag" });
64
+ }
65
+
66
+ const gh = ctx.integration(github);
67
+ try {
68
+ if (params.action === "comment") {
69
+ if (!params.path || params.line === undefined || !params.body) {
70
+ return text("Error: path, line, and body are required for action=comment.", { error: "missing fields" });
71
+ }
72
+ const commitId = tags["github:head-sha"];
73
+ if (!commitId) {
74
+ return text("Error: the PR head commit is unknown, cannot anchor an inline comment.", { error: "no head sha" });
75
+ }
76
+ const result = await gh.createReviewComment({
77
+ repo,
78
+ pullRequestNumber,
79
+ body: params.body,
80
+ commitId,
81
+ path: params.path,
82
+ line: params.line,
83
+ side: params.side,
84
+ });
85
+ return text(`Left an inline comment on ${params.path}:${params.line}.`, result);
86
+ }
87
+
88
+ // action === "submit"
89
+ if (!params.event) {
90
+ return text("Error: event (COMMENT or REQUEST_CHANGES) is required for action=submit.", { error: "missing event" });
91
+ }
92
+ const result = await gh.submitReview({ repo, pullRequestNumber, event: params.event, body: params.body });
93
+ // Stand down the agent_end reply: the formal review is this turn's output, so it must not
94
+ // also be posted as a duplicate timeline comment.
95
+ ctx.session.setTags({ "github:review-posted": "1" });
96
+ return text(`Submitted a ${params.event} review on ${repo}#${pullRequestNumber}.`, result);
97
+ } catch (err) {
98
+ const message = err instanceof Error ? err.message : String(err);
99
+ return text(`Error: ${message}`, { error: message });
100
+ }
101
+ },
102
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Host-side git plumbing for the review checkout.
3
+ *
4
+ * Unlike `github-api.ts` this module is NOT host-free: it shells out to the `git` CLI and writes
5
+ * under the agent home dir. Its job is to put a pull request's full source tree on disk where the
6
+ * review agent's native `read`/`grep`/`bash` tools can see it, so the agent reviews real code
7
+ * instead of a diff pasted into its prompt.
8
+ *
9
+ * Two safety properties this file is responsible for:
10
+ * - The App installation token is passed to `git` only on the argv of a single `fetch` (in the
11
+ * daemon process), never written to disk and never stored as a remote — so it cannot be
12
+ * recovered from `.git/config` by the agent.
13
+ * - No remote is configured on the checkout, so there is no push target: the working copy is
14
+ * read-only as far as the agent is concerned. (A bash-capable agent could still add a remote
15
+ * and push with the host's ambient credentials; a hard guarantee needs a network-jailed
16
+ * sandbox. This removes the footgun, not the determined path.)
17
+ *
18
+ * The checkout lives under `workspace/` (a subdir of the agent home) because that is the one
19
+ * location visible to the agent's tools across every backend: host, the srt write-jail (rooted
20
+ * at the agent home), and the docker bind-mount (the agent home mounted at the identical path).
21
+ */
22
+
23
+ import { spawn } from "node:child_process";
24
+ import { existsSync } from "node:fs";
25
+ import { mkdir } from "node:fs/promises";
26
+ import { join } from "node:path";
27
+
28
+ /** Absolute and cwd-relative paths of a PR's review checkout, under the agent's `workspace/`. */
29
+ export function reviewPaths(
30
+ agentCwd: string,
31
+ repo: string,
32
+ pullRequestNumber: number,
33
+ ): { absDir: string; relDir: string } {
34
+ const slug = `${repo.replace("/", "__")}__${pullRequestNumber}`;
35
+ const relDir = join("workspace", "reviews", slug);
36
+ return { absDir: join(agentCwd, relDir), relDir };
37
+ }
38
+
39
+ /**
40
+ * Run one git command as an argv array (no shell, so nothing in the args is interpolated). The
41
+ * error carries only the subcommand (`args[0]`) and stderr — never the full args — so a token
42
+ * passed in a fetch URL cannot leak into a thrown/logged error.
43
+ */
44
+ function runGit(args: string[], cwd: string): Promise<void> {
45
+ return new Promise((resolve, reject) => {
46
+ const child = spawn("git", args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
47
+ let stderr = "";
48
+ child.stderr?.on("data", (chunk) => {
49
+ stderr += String(chunk);
50
+ });
51
+ child.on("error", reject);
52
+ child.on("close", (code) => {
53
+ if (code === 0) resolve();
54
+ else reject(new Error(`git ${args[0]} exited ${code}: ${stderr.trim().slice(0, 500)}`));
55
+ });
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Check out `headSha` (detached, depth 1) at `destDir`, with `baseSha` fetched alongside it so the
61
+ * agent can `git diff <base> <head>`. Initializes the dir on first use and refreshes it in place on
62
+ * later turns. The tokenized URL is passed only to the `fetch` and is never stored as a remote.
63
+ */
64
+ export async function checkout(opts: {
65
+ destDir: string;
66
+ repo: string;
67
+ token: string;
68
+ headSha: string;
69
+ baseSha: string;
70
+ }): Promise<void> {
71
+ const { destDir, repo, token, headSha, baseSha } = opts;
72
+ if (!headSha) throw new Error("checkout: missing head sha");
73
+ const tokenUrl = `https://x-access-token:${token}@github.com/${repo}.git`;
74
+ const shas = baseSha && baseSha !== headSha ? [headSha, baseSha] : [headSha];
75
+
76
+ if (!existsSync(join(destDir, ".git"))) {
77
+ await mkdir(destDir, { recursive: true });
78
+ await runGit(["init", "-q"], destDir);
79
+ }
80
+ await runGit(["fetch", "--depth", "1", tokenUrl, ...shas], destDir);
81
+ await runGit(["checkout", "-q", "--detach", headSha], destDir);
82
+ await runGit(["reset", "--hard", "-q", headSha], destDir);
83
+ await runGit(["clean", "-fdxq"], destDir);
84
+ }