@dynamicagents/plugins 0.11.1 → 0.13.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 +59 -79
- package/dist/browser/index.d.ts +0 -2
- package/dist/browser/index.js +21 -27
- package/dist/claude-code/cards.d.ts +53 -0
- package/dist/claude-code/cards.js +272 -0
- package/dist/claude-code/config.d.ts +73 -31
- package/dist/claude-code/config.js +0 -2
- package/dist/claude-code/credentials.d.ts +21 -9
- package/dist/claude-code/credentials.js +43 -9
- package/dist/claude-code/egress.d.ts +15 -14
- package/dist/claude-code/egress.js +51 -14
- package/dist/claude-code/events.d.ts +153 -9
- package/dist/claude-code/events.js +289 -58
- package/dist/claude-code/github.d.ts +39 -0
- package/dist/claude-code/github.js +82 -0
- package/dist/claude-code/index.d.ts +24 -80
- package/dist/claude-code/index.js +19 -234
- package/dist/claude-code/model.d.ts +152 -0
- package/dist/claude-code/model.js +342 -0
- package/dist/claude-code/run.d.ts +283 -92
- package/dist/claude-code/run.js +620 -114
- package/dist/claude-code/session.d.ts +131 -0
- package/dist/claude-code/session.js +285 -0
- package/dist/claude-code/spec.d.ts +46 -0
- package/dist/claude-code/spec.js +83 -0
- package/dist/claude-code/verified.d.ts +15 -0
- package/dist/claude-code/verified.js +15 -0
- package/dist/computer/context.d.ts +19 -0
- package/dist/computer/context.js +222 -0
- package/dist/computer/file-lock.d.ts +5 -0
- package/dist/computer/file-lock.js +40 -0
- package/dist/computer/gate.d.ts +12 -12
- package/dist/computer/gate.js +12 -12
- package/dist/computer/index.d.ts +57 -255
- package/dist/computer/index.js +98 -1039
- package/dist/computer/paths.d.ts +47 -22
- package/dist/computer/paths.js +64 -49
- package/dist/computer/proxy.d.ts +45 -0
- package/dist/computer/proxy.js +234 -0
- package/dist/computer/render.d.ts +27 -38
- package/dist/computer/render.js +32 -65
- package/dist/computer/tools-exec.d.ts +3 -0
- package/dist/computer/tools-exec.js +268 -0
- package/dist/computer/tools-file.d.ts +12 -0
- package/dist/computer/tools-file.js +64 -0
- package/dist/computer/tools-grep.d.ts +3 -0
- package/dist/computer/tools-grep.js +129 -0
- package/dist/repo/checkout.d.ts +35 -6
- package/dist/repo/checkout.js +64 -6
- package/dist/repo/context.d.ts +135 -0
- package/dist/repo/context.js +582 -0
- package/dist/repo/index.d.ts +79 -57
- package/dist/repo/index.js +118 -1112
- package/dist/repo/tools-checks.d.ts +3 -0
- package/dist/repo/tools-checks.js +112 -0
- package/dist/repo/tools-clone.d.ts +4 -0
- package/dist/repo/tools-clone.js +192 -0
- package/dist/repo/tools-pr.d.ts +14 -0
- package/dist/repo/tools-pr.js +196 -0
- package/dist/repo/tools-review.d.ts +12 -0
- package/dist/repo/tools-review.js +393 -0
- package/dist/repo/tools-worktree.d.ts +4 -0
- package/dist/repo/tools-worktree.js +305 -0
- package/dist/repo/tools-worktrees.d.ts +12 -0
- package/dist/repo/tools-worktrees.js +48 -0
- package/dist/repo/url.d.ts +2 -2
- package/dist/repo/url.js +7 -7
- package/dist/scratch/index.d.ts +4 -4
- package/dist/scratch/index.js +12 -11
- package/dist/{computer → workspace}/advisory.d.ts +33 -20
- package/dist/{computer → workspace}/advisory.js +32 -7
- package/dist/workspace/ca-trust.d.ts +95 -0
- package/dist/workspace/ca-trust.js +312 -0
- package/dist/workspace/container-deps.d.ts +54 -0
- package/dist/workspace/container-deps.js +224 -0
- package/dist/workspace/container-fault.d.ts +38 -0
- package/dist/workspace/container-fault.js +73 -0
- package/dist/workspace/exec.d.ts +94 -0
- package/dist/workspace/exec.js +153 -0
- package/dist/workspace/format.d.ts +36 -0
- package/dist/workspace/format.js +57 -0
- package/dist/workspace/git-host.d.ts +79 -0
- package/dist/workspace/git-host.js +226 -0
- package/dist/workspace/git-identity.d.ts +57 -0
- package/dist/workspace/git-identity.js +147 -0
- package/dist/workspace/index.d.ts +43 -47
- package/dist/workspace/index.js +52 -112
- package/dist/workspace/install-job.d.ts +121 -0
- package/dist/workspace/install-job.js +866 -0
- package/dist/{computer → workspace}/install.d.ts +15 -19
- package/dist/{computer → workspace}/install.js +8 -11
- package/dist/workspace/object.d.ts +502 -0
- package/dist/workspace/object.js +1427 -0
- package/dist/workspace/open.d.ts +95 -0
- package/dist/workspace/open.js +63 -0
- package/dist/workspace/read.d.ts +16 -0
- package/dist/workspace/read.js +22 -0
- package/dist/workspace/shell.d.ts +45 -0
- package/dist/workspace/shell.js +80 -0
- package/dist/workspace/sync.d.ts +111 -0
- package/dist/workspace/sync.js +124 -0
- package/dist/workspace/wake.d.ts +19 -0
- package/dist/workspace/wake.js +10 -0
- package/package.json +28 -42
- package/dist/arc-agi/analysis.d.ts +0 -336
- package/dist/arc-agi/analysis.js +0 -829
- package/dist/arc-agi/client.d.ts +0 -82
- package/dist/arc-agi/client.js +0 -113
- package/dist/arc-agi/game-tools.d.ts +0 -25
- package/dist/arc-agi/game-tools.js +0 -26
- package/dist/arc-agi/index.d.ts +0 -42
- package/dist/arc-agi/index.js +0 -175
- package/dist/arc-agi/main-agent.d.ts +0 -27
- package/dist/arc-agi/main-agent.js +0 -65
- package/dist/arc-agi/recipe.d.ts +0 -51
- package/dist/arc-agi/recipe.js +0 -100
- package/dist/arc-agi/scorecard.d.ts +0 -118
- package/dist/arc-agi/scorecard.js +0 -163
- package/dist/arc-agi/soul.d.ts +0 -40
- package/dist/arc-agi/soul.js +0 -90
- package/dist/arc-agi/store.d.ts +0 -154
- package/dist/arc-agi/store.js +0 -216
- package/dist/arc-agi/tools.d.ts +0 -36
- package/dist/arc-agi/tools.js +0 -576
- package/dist/arc-agi/types.d.ts +0 -212
- package/dist/arc-agi/types.js +0 -19
- package/dist/claude-code/recipe.d.ts +0 -53
- package/dist/claude-code/recipe.js +0 -100
- package/dist/computer/read.d.ts +0 -137
- package/dist/computer/read.js +0 -203
- package/dist/recall/index.d.ts +0 -99
- package/dist/recall/index.js +0 -171
- package/dist/triage/index.d.ts +0 -355
- package/dist/triage/index.js +0 -302
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { action } from "@cloudflare/think";
|
|
2
|
+
import { tool } from "ai";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { actionTask, GITHUB_ACTION_TIMEOUT_MS, GITHUB_PAGE_SIZE, sha256 } from "./context.js";
|
|
5
|
+
/** A review as a conversation: whether it landed, what it said, and answering it. */
|
|
6
|
+
/**
|
|
7
|
+
* How many pages of review threads one `repo_pr_threads` call will walk.
|
|
8
|
+
*
|
|
9
|
+
* A bound rather than a full walk, for the reason {@link GITHUB_PAGE_SIZE} is one
|
|
10
|
+
* — but unlike the single page everything else here takes, threads genuinely
|
|
11
|
+
* have to page: a review long enough to spill one puts its newest threads on the
|
|
12
|
+
* last page, and those are exactly the ones an agent was sent to answer. So this
|
|
13
|
+
* is high enough that a real review never reaches it and low enough that a
|
|
14
|
+
* pathological pull request cannot turn one tool call into an unbounded number of
|
|
15
|
+
* round trips.
|
|
16
|
+
*/
|
|
17
|
+
const MAX_THREAD_PAGES = 10;
|
|
18
|
+
/**
|
|
19
|
+
* How much of one thread's conversation is read back.
|
|
20
|
+
*
|
|
21
|
+
* Both ends, because the two uses pull opposite ways: what the reviewer asked for
|
|
22
|
+
* is at the top, and whether a reply landed is at the bottom. A thread shorter
|
|
23
|
+
* than this is returned whole and the two selections are the same comments, which
|
|
24
|
+
* is why the render de-duplicates on the count rather than on the text.
|
|
25
|
+
*/
|
|
26
|
+
const MAX_THREAD_COMMENTS = 6;
|
|
27
|
+
/**
|
|
28
|
+
* The review threads on one pull request.
|
|
29
|
+
*
|
|
30
|
+
* `isResolved` is the field the whole workflow turns on and REST does not expose
|
|
31
|
+
* at all — which is why these three operations are GraphQL while everything else
|
|
32
|
+
* in this file is REST. Both ends of each thread are selected; see
|
|
33
|
+
* {@link MAX_THREAD_COMMENTS} for why one end is not enough.
|
|
34
|
+
*/
|
|
35
|
+
const REVIEW_THREADS_QUERY = `
|
|
36
|
+
query($owner:String!,$repo:String!,$number:Int!,$after:String){
|
|
37
|
+
repository(owner:$owner,name:$repo){
|
|
38
|
+
pullRequest(number:$number){
|
|
39
|
+
reviewThreads(first:${GITHUB_PAGE_SIZE},after:$after){
|
|
40
|
+
pageInfo{hasNextPage endCursor}
|
|
41
|
+
nodes{
|
|
42
|
+
id isResolved isOutdated path line
|
|
43
|
+
opening: comments(first:1){nodes{author{login} body}}
|
|
44
|
+
latest: comments(last:${MAX_THREAD_COMMENTS}){totalCount nodes{author{login} body}}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}`;
|
|
50
|
+
/**
|
|
51
|
+
* Who is currently *asked* to review, apps included.
|
|
52
|
+
*
|
|
53
|
+
* REST's `pulls/{n}/requested_reviewers` carries `users` and `teams` and no
|
|
54
|
+
* third key, so a Bot reviewer — Copilot is one — is absent from a request that
|
|
55
|
+
* is live, and its answer for "Copilot is working on it right now" is byte for
|
|
56
|
+
* byte its answer for "nobody was ever asked". GraphQL types the requested
|
|
57
|
+
* reviewer as a union with `Bot` in it, which is what makes the two
|
|
58
|
+
* distinguishable at all.
|
|
59
|
+
*/
|
|
60
|
+
const REVIEW_REQUESTS_QUERY = `
|
|
61
|
+
query($owner:String!,$repo:String!,$number:Int!){
|
|
62
|
+
repository(owner:$owner,name:$repo){
|
|
63
|
+
pullRequest(number:$number){
|
|
64
|
+
reviewRequests(first:${GITHUB_PAGE_SIZE}){
|
|
65
|
+
nodes{
|
|
66
|
+
requestedReviewer{
|
|
67
|
+
... on User{login}
|
|
68
|
+
... on Bot{login}
|
|
69
|
+
... on Team{slug}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}`;
|
|
76
|
+
/** Which pull request, in which repository, a thread id names. */
|
|
77
|
+
const THREAD_OWNER_QUERY = `
|
|
78
|
+
query($id:ID!){
|
|
79
|
+
node(id:$id){
|
|
80
|
+
... on PullRequestReviewThread {
|
|
81
|
+
pullRequest{number repository{nameWithOwner}}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}`;
|
|
85
|
+
const THREAD_REPLY_MUTATION = `
|
|
86
|
+
mutation($id:ID!,$body:String!){
|
|
87
|
+
addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$id,body:$body}){
|
|
88
|
+
comment{url}
|
|
89
|
+
}
|
|
90
|
+
}`;
|
|
91
|
+
const THREAD_RESOLVE_MUTATION = `
|
|
92
|
+
mutation($id:ID!){
|
|
93
|
+
resolveReviewThread(input:{threadId:$id}){thread{isResolved}}
|
|
94
|
+
}`;
|
|
95
|
+
/** One thread, as the model reads it: where it is, its id, and the conversation. */
|
|
96
|
+
function renderThread(t) {
|
|
97
|
+
const where = t.path
|
|
98
|
+
? `${t.path}${typeof t.line === "number" ? `:${t.line}` : ""}`
|
|
99
|
+
: "(no file)";
|
|
100
|
+
const total = t.latest?.totalCount ?? 0;
|
|
101
|
+
const tail = t.latest?.nodes ?? [];
|
|
102
|
+
// Whole when it fits, and both ends with the middle named when it does not.
|
|
103
|
+
// The opening comment is the reviewer's actual point and the tail is where a
|
|
104
|
+
// reply this plugin sent would be, so neither end can be the one dropped.
|
|
105
|
+
const conversation = total > tail.length
|
|
106
|
+
? [
|
|
107
|
+
...(t.opening?.nodes ?? []),
|
|
108
|
+
{
|
|
109
|
+
author: { login: "…" },
|
|
110
|
+
body: `(${total - tail.length - 1} earlier replies not shown)`
|
|
111
|
+
},
|
|
112
|
+
...tail
|
|
113
|
+
]
|
|
114
|
+
: tail;
|
|
115
|
+
return [
|
|
116
|
+
`--- ${where}${t.isResolved ? " [resolved]" : ""}${t.isOutdated ? " [outdated]" : ""}`,
|
|
117
|
+
`id: ${t.id}`,
|
|
118
|
+
...conversation.map((c) => `${c.author?.login ?? "unknown"}: ${c.body ?? ""}`)
|
|
119
|
+
].join("\n");
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The owner and repository as their own names, rather than as REST path segments.
|
|
123
|
+
*
|
|
124
|
+
* {@link file://./context.ts githubRepo} percent-encodes both, because that is
|
|
125
|
+
* what makes a path safe. A GraphQL *variable* is not a path — it is compared
|
|
126
|
+
* against the repository's actual name — so an encoded one would silently stop
|
|
127
|
+
* matching the moment a name contains a character worth encoding.
|
|
128
|
+
*/
|
|
129
|
+
function decoded(target) {
|
|
130
|
+
return {
|
|
131
|
+
owner: decodeURIComponent(target.owner),
|
|
132
|
+
repo: decodeURIComponent(target.repo)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The review bot's logins, which GitHub does not spell the same way twice.
|
|
137
|
+
*
|
|
138
|
+
* One account arrives as `Copilot` from the issue timeline, as
|
|
139
|
+
* `copilot-pull-request-reviewer` from GraphQL, and as
|
|
140
|
+
* `copilot-pull-request-reviewer[bot]` from `pulls/{n}/reviews`. Compared
|
|
141
|
+
* exactly against the login a caller names, the request matches and the review
|
|
142
|
+
* never does, so `repo_pr_review_status` reports "none pending — waiting will
|
|
143
|
+
* not change it" at the moment the review lands. That is the one answer a
|
|
144
|
+
* polling caller stops on, so the mismatch does not degrade the tool, it
|
|
145
|
+
* inverts it.
|
|
146
|
+
*
|
|
147
|
+
* An alternation of the spellings that account answers to, and deliberately not
|
|
148
|
+
* a `copilot-` prefix: `copilot-swe-agent` is a **different** account, one that
|
|
149
|
+
* opens pull requests rather than reviewing them, so a prefix would let its
|
|
150
|
+
* request or its authorship answer a question asked about the reviewer.
|
|
151
|
+
*/
|
|
152
|
+
const COPILOT_LOGIN = /^copilot(-pull-request-reviewer)?$/;
|
|
153
|
+
/**
|
|
154
|
+
* Whether a login from the API is the reviewer the caller asked about.
|
|
155
|
+
*
|
|
156
|
+
* `[bot]` is a suffix REST appends to an app's login and GraphQL does not, so it
|
|
157
|
+
* is never part of the identity — stripping it is what lets a caller name any
|
|
158
|
+
* app reviewer the way GitHub's UI shows it.
|
|
159
|
+
*/
|
|
160
|
+
function sameReviewer(a, b) {
|
|
161
|
+
const bare = (login) => login.toLowerCase().replace(/\[bot\]$/, "");
|
|
162
|
+
const [x, y] = [bare(a), bare(b)];
|
|
163
|
+
return COPILOT_LOGIN.test(x) && COPILOT_LOGIN.test(y) ? true : x === y;
|
|
164
|
+
}
|
|
165
|
+
export function reviewTools(ctx) {
|
|
166
|
+
const { bounded, github, githubGraphql, githubRepo } = ctx;
|
|
167
|
+
return {
|
|
168
|
+
repo_pr_review_status: tool({
|
|
169
|
+
description: "Say whether a reviewer has finished reviewing a pull request in the repository you have checked out. Use it before reading review comments: a review that has not landed has left no comments, and an empty thread list means nothing yet.",
|
|
170
|
+
inputSchema: z.object({
|
|
171
|
+
dir: z.string().describe("The checkout directory"),
|
|
172
|
+
number: z.number().int().positive().describe("Pull request number"),
|
|
173
|
+
reviewer: z
|
|
174
|
+
.string()
|
|
175
|
+
.optional()
|
|
176
|
+
.describe('The reviewer to ask about, as their login. Defaults to "Copilot".')
|
|
177
|
+
}),
|
|
178
|
+
execute: async ({ dir, number, reviewer }) => {
|
|
179
|
+
const target = await githubRepo(dir);
|
|
180
|
+
if ("refusal" in target)
|
|
181
|
+
return target.refusal;
|
|
182
|
+
const { owner, repo } = target;
|
|
183
|
+
const who = reviewer ?? "Copilot";
|
|
184
|
+
// A *pending* request outranks any review already on the pull request,
|
|
185
|
+
// and that ordering is the whole logic here. GitHub clears the request
|
|
186
|
+
// when a review is submitted and writes a new one when a re-review is
|
|
187
|
+
// asked for, so "still requested" is true now and "has reviewed" is
|
|
188
|
+
// about the past. Reading them the other way round reports a re-review
|
|
189
|
+
// as finished the moment it is asked for.
|
|
190
|
+
const requested = await githubGraphql("repo_pr_review_status", REVIEW_REQUESTS_QUERY, { owner, repo, number });
|
|
191
|
+
if (!requested.ok)
|
|
192
|
+
return bounded(requested.message);
|
|
193
|
+
const waiting = requested.data.repository?.pullRequest?.reviewRequests;
|
|
194
|
+
if (!waiting)
|
|
195
|
+
return `#${number} is not a pull request in ${owner}/${repo}`;
|
|
196
|
+
const pending = (waiting.nodes ?? []).some((n) => {
|
|
197
|
+
const name = n?.requestedReviewer?.login ?? n?.requestedReviewer?.slug;
|
|
198
|
+
return !!name && sameReviewer(name, who);
|
|
199
|
+
});
|
|
200
|
+
if (pending)
|
|
201
|
+
return `${who} has been asked to review #${number} and has not finished.`;
|
|
202
|
+
const reviews = await github("repo_pr_review_status", `/repos/${owner}/${repo}/pulls/${number}/reviews?per_page=${GITHUB_PAGE_SIZE}`);
|
|
203
|
+
if (!reviews.ok)
|
|
204
|
+
return bounded(reviews.message);
|
|
205
|
+
const all = reviews.data;
|
|
206
|
+
const submitted = all.filter((r) => !!r.user?.login &&
|
|
207
|
+
sameReviewer(r.user.login, who) &&
|
|
208
|
+
// A `PENDING` review is a draft its author has not sent, and it is
|
|
209
|
+
// visible to whoever holds the token that wrote it. Counting one as
|
|
210
|
+
// finished reports a review nobody has read, which is the answer a
|
|
211
|
+
// poll loop acts on by stopping.
|
|
212
|
+
(r.state ?? "").toUpperCase() !== "PENDING");
|
|
213
|
+
const theirs = submitted.at(-1);
|
|
214
|
+
if (!theirs) {
|
|
215
|
+
// The endpoint pages oldest-first, so a match beyond the first page is
|
|
216
|
+
// **newer** than everything read — which is to say it is exactly the
|
|
217
|
+
// review being waited for. So a truncated read cannot answer the
|
|
218
|
+
// question at all, and must not answer it with "waiting will not help":
|
|
219
|
+
// that is the one reply a caller ends its polling on.
|
|
220
|
+
if (all.length >= GITHUB_PAGE_SIZE)
|
|
221
|
+
return bounded(`#${number} has at least ${GITHUB_PAGE_SIZE} reviews and only the oldest were read, ` +
|
|
222
|
+
`so whether ${who} has reviewed it is unknown. Read the pull request itself with repo_pr_view, ` +
|
|
223
|
+
`or look for threads with repo_pr_threads.`);
|
|
224
|
+
return bounded(`#${number} has no review from ${who}, and none is pending. ` +
|
|
225
|
+
`Nobody asked them, or the request was withdrawn — waiting will not change it.`);
|
|
226
|
+
}
|
|
227
|
+
return bounded(`${who} reviewed #${number}${theirs.submitted_at ? ` at ${theirs.submitted_at}` : ""}` +
|
|
228
|
+
`${theirs.state ? ` (${theirs.state.toLowerCase()})` : ""}. ` +
|
|
229
|
+
`Read what it said with repo_pr_threads — a review can finish having left no comments.`);
|
|
230
|
+
}
|
|
231
|
+
}),
|
|
232
|
+
repo_pr_threads: tool({
|
|
233
|
+
description: "Read the review threads on a pull request in the repository you have checked out — the inline comments a reviewer left on specific lines, which are not on the conversation timeline repo_issue_view reads. Unresolved ones only, unless you ask for all.",
|
|
234
|
+
inputSchema: z.object({
|
|
235
|
+
dir: z.string().describe("The checkout directory"),
|
|
236
|
+
number: z.number().int().positive().describe("Pull request number"),
|
|
237
|
+
includeResolved: z
|
|
238
|
+
.boolean()
|
|
239
|
+
.optional()
|
|
240
|
+
.describe("Include threads already resolved. Defaults to false.")
|
|
241
|
+
}),
|
|
242
|
+
execute: async ({ dir, number, includeResolved }) => {
|
|
243
|
+
const target = await githubRepo(dir);
|
|
244
|
+
if ("refusal" in target)
|
|
245
|
+
return target.refusal;
|
|
246
|
+
const { owner, repo } = decoded(target);
|
|
247
|
+
const threads = [];
|
|
248
|
+
let after = null;
|
|
249
|
+
let truncated = false;
|
|
250
|
+
// Paged, and that is not an optimisation. A review long enough to spill a
|
|
251
|
+
// page puts its newest threads on the last one — exactly the threads the
|
|
252
|
+
// model was sent to answer — so a single page can report a busy review as
|
|
253
|
+
// clean. The page cap is what stops a pathological pull request becoming
|
|
254
|
+
// an unbounded number of round trips.
|
|
255
|
+
for (let page = 0;; page++) {
|
|
256
|
+
if (page >= MAX_THREAD_PAGES) {
|
|
257
|
+
truncated = true;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
const answered = await githubGraphql("repo_pr_threads", REVIEW_THREADS_QUERY, {
|
|
261
|
+
owner,
|
|
262
|
+
repo,
|
|
263
|
+
number,
|
|
264
|
+
after
|
|
265
|
+
});
|
|
266
|
+
if (!answered.ok)
|
|
267
|
+
return bounded(answered.message);
|
|
268
|
+
const page_ = answered.data.repository?.pullRequest?.reviewThreads;
|
|
269
|
+
if (!page_)
|
|
270
|
+
return `#${number} is not a pull request in ${owner}/${repo}`;
|
|
271
|
+
threads.push(...(page_.nodes ?? []));
|
|
272
|
+
if (!page_.pageInfo?.hasNextPage)
|
|
273
|
+
break;
|
|
274
|
+
after = page_.pageInfo.endCursor ?? null;
|
|
275
|
+
if (!after)
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
// Said once, and said on **every** exit including the empty one. A run
|
|
279
|
+
// that stopped early and found nothing open in what it did read must not
|
|
280
|
+
// answer "nothing to do": the unread pages are exactly where the newest
|
|
281
|
+
// threads are, which is the false-clean result the paging exists to
|
|
282
|
+
// prevent — reintroduced at the one exit that skipped the warning.
|
|
283
|
+
const unread = truncated
|
|
284
|
+
? `\n\n(stopped after ${MAX_THREAD_PAGES} pages, so this is not the whole review — there are more threads than were read)`
|
|
285
|
+
: "";
|
|
286
|
+
const shown = includeResolved
|
|
287
|
+
? threads
|
|
288
|
+
: threads.filter((t) => !t.isResolved);
|
|
289
|
+
if (shown.length === 0)
|
|
290
|
+
return bounded((includeResolved
|
|
291
|
+
? `#${number} has no review threads.`
|
|
292
|
+
: `#${number} has no unresolved review threads.` +
|
|
293
|
+
(threads.length > 0
|
|
294
|
+
? ` (${threads.length} resolved — pass includeResolved to read them.)`
|
|
295
|
+
: "")) + unread);
|
|
296
|
+
const rendered = shown.map((t) => renderThread(t)).join("\n\n");
|
|
297
|
+
return bounded(rendered + unread);
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Answering a thread, as a Think action — see
|
|
304
|
+
* {@link file://./tools-pr.ts prActions} for why every failure is
|
|
305
|
+
* thrown. The one outcome returned short of success is a reply that landed on
|
|
306
|
+
* a thread that did not resolve: that reply must never be sent again, so it is
|
|
307
|
+
* settled, and the resolve-only call it asks for is a different key.
|
|
308
|
+
*/
|
|
309
|
+
export function reviewActions(ctx) {
|
|
310
|
+
const { bounded, githubGraphql, githubRepo } = ctx;
|
|
311
|
+
const replyInput = z.object({
|
|
312
|
+
dir: z.string().describe("The checkout directory"),
|
|
313
|
+
number: z.number().int().positive().describe("Pull request number"),
|
|
314
|
+
threadId: z
|
|
315
|
+
.string()
|
|
316
|
+
.describe("The thread's id, exactly as repo_pr_threads printed it"),
|
|
317
|
+
body: z
|
|
318
|
+
.string()
|
|
319
|
+
.min(1)
|
|
320
|
+
.optional()
|
|
321
|
+
.describe("The reply, as markdown. Leave it out to resolve a thread you have already replied to."),
|
|
322
|
+
resolve: z
|
|
323
|
+
.boolean()
|
|
324
|
+
.optional()
|
|
325
|
+
.describe("Resolve the thread after replying. Defaults to true.")
|
|
326
|
+
});
|
|
327
|
+
return {
|
|
328
|
+
repo_pr_thread_reply: action({
|
|
329
|
+
description: "Answer one review thread on a pull request in the repository you have checked out, and resolve it. Reply with what you changed and where, or with why you did not — either way the thread ends resolved unless you say otherwise. The same reply to one thread twice in one task is sent once.",
|
|
330
|
+
inputSchema: replyInput,
|
|
331
|
+
timeoutMs: GITHUB_ACTION_TIMEOUT_MS,
|
|
332
|
+
// The thread and the resolve flag as well as the body, or the same reply
|
|
333
|
+
// to two threads — "Fixed." — would be one key.
|
|
334
|
+
idempotencyKey: async ({ input, ctx: turn }) => `${actionTask(turn)}:${input.dir}#${input.number}:${input.threadId}:` +
|
|
335
|
+
`${await sha256(input.body ?? "")}:${input.resolve ?? true}`,
|
|
336
|
+
execute: async ({ dir, number, threadId, body, resolve }) => {
|
|
337
|
+
const target = await githubRepo(dir);
|
|
338
|
+
if ("refusal" in target)
|
|
339
|
+
throw new Error(target.refusal);
|
|
340
|
+
const { owner, repo } = decoded(target);
|
|
341
|
+
// A thread id is a global node id, so unlike every other tool here this
|
|
342
|
+
// one takes something that can name a pull request in a repository
|
|
343
|
+
// nobody checked out — the reach `githubRepo` exists to prevent. Checked
|
|
344
|
+
// rather than trusted: the id came from a tool result the model read,
|
|
345
|
+
// and a model that misremembers one must not write into a stranger's
|
|
346
|
+
// review.
|
|
347
|
+
const belongs = await githubGraphql("repo_pr_thread_reply", THREAD_OWNER_QUERY, { id: threadId });
|
|
348
|
+
if (!belongs.ok)
|
|
349
|
+
throw new Error(bounded(belongs.message));
|
|
350
|
+
const node = belongs.data.node;
|
|
351
|
+
if (!node?.pullRequest)
|
|
352
|
+
throw new Error(`${threadId} is not a review thread — read the ids with repo_pr_threads`);
|
|
353
|
+
const at = node.pullRequest.repository?.nameWithOwner ?? "";
|
|
354
|
+
if (at.toLowerCase() !== `${owner}/${repo}`.toLowerCase() ||
|
|
355
|
+
node.pullRequest.number !== number)
|
|
356
|
+
throw new Error(`${threadId} belongs to ${at}#${node.pullRequest.number}, not ${owner}/${repo}#${number}. ` +
|
|
357
|
+
`Read the ids for this pull request with repo_pr_threads.`);
|
|
358
|
+
// Resolve-only. The reply and the resolve are two calls, so they fail
|
|
359
|
+
// independently — and the tool that tells a caller "the reply landed,
|
|
360
|
+
// resolve it alone" has to give it a way to do that. Without this the
|
|
361
|
+
// only route back is a second reply the reviewer has already read.
|
|
362
|
+
if (body === undefined) {
|
|
363
|
+
if (resolve === false)
|
|
364
|
+
throw new Error("nothing to do: give a body to reply, or leave resolve unset to resolve the thread");
|
|
365
|
+
const only = await githubGraphql("repo_pr_thread_reply", THREAD_RESOLVE_MUTATION, { id: threadId });
|
|
366
|
+
if (!only.ok)
|
|
367
|
+
throw new Error(bounded(`resolving the thread failed: ${only.message}`));
|
|
368
|
+
return `resolved ${threadId}`;
|
|
369
|
+
}
|
|
370
|
+
const replied = await githubGraphql("repo_pr_thread_reply", THREAD_REPLY_MUTATION, { id: threadId, body });
|
|
371
|
+
if (!replied.ok)
|
|
372
|
+
// The hazard `repo_pr_comment` names, and worse here: a reply that
|
|
373
|
+
// landed and an answer that did not arrive look identical, and the
|
|
374
|
+
// retry leaves the reviewer two of them.
|
|
375
|
+
throw new Error(bounded(`${replied.message}\nIf this was a timeout rather than a rejection the ` +
|
|
376
|
+
`reply may exist anyway — read the thread back with repo_pr_threads before retrying.`));
|
|
377
|
+
const url = replied.data.addPullRequestReviewThreadReply?.comment?.url ?? "(no url)";
|
|
378
|
+
if (resolve === false)
|
|
379
|
+
return `replied: ${url} (left unresolved)`;
|
|
380
|
+
// Reported separately, never folded into the reply's own failure: a
|
|
381
|
+
// thread that was answered and not resolved needs resolving, and one
|
|
382
|
+
// that was never answered needs answering. Saying "it failed" covers
|
|
383
|
+
// both and tells the model to do the wrong one.
|
|
384
|
+
const resolved = await githubGraphql("repo_pr_thread_reply", THREAD_RESOLVE_MUTATION, { id: threadId });
|
|
385
|
+
if (!resolved.ok)
|
|
386
|
+
return bounded(`replied: ${url}\nbut resolving the thread failed: ${resolved.message}\n` +
|
|
387
|
+
`The reply has landed — do not send it again. Call repo_pr_thread_reply on the same ` +
|
|
388
|
+
`threadId with no body to resolve it on its own.`);
|
|
389
|
+
return `replied and resolved: ${url}`;
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
};
|
|
393
|
+
}
|