@dynamicagents/plugins 0.13.0 → 0.15.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 +14 -14
- package/dist/claude-code/cards.d.ts +4 -0
- package/dist/claude-code/cards.js +22 -0
- package/dist/claude-code/config.d.ts +10 -83
- package/dist/claude-code/config.js +0 -2
- package/dist/claude-code/credentials.d.ts +32 -50
- package/dist/claude-code/credentials.js +115 -74
- package/dist/claude-code/daemon/index.d.ts +24 -0
- package/dist/claude-code/daemon/index.js +203 -0
- package/dist/claude-code/egress.d.ts +35 -117
- package/dist/claude-code/egress.js +80 -325
- package/dist/claude-code/events.d.ts +14 -17
- package/dist/claude-code/events.js +19 -15
- package/dist/claude-code/harness-wire.d.ts +45 -0
- package/dist/claude-code/harness-wire.js +47 -0
- package/dist/claude-code/index.d.ts +19 -29
- package/dist/claude-code/index.js +17 -25
- package/dist/claude-code/language-model.d.ts +19 -0
- package/dist/claude-code/language-model.js +71 -0
- package/dist/claude-code/model.d.ts +77 -70
- package/dist/claude-code/model.js +328 -304
- package/dist/claude-code/placeholder.d.ts +12 -0
- package/dist/claude-code/placeholder.js +12 -0
- package/dist/claude-code/spec.d.ts +4 -4
- package/dist/claude-code/spec.js +4 -4
- package/dist/claude-code/verified.d.ts +1 -1
- package/dist/claude-code/verified.js +1 -1
- package/dist/computer/client.d.ts +88 -0
- package/dist/computer/client.js +6 -0
- package/dist/computer/context.d.ts +7 -7
- package/dist/computer/context.js +14 -18
- package/dist/computer/gate.d.ts +4 -23
- package/dist/computer/gate.js +1 -37
- package/dist/computer/index.d.ts +44 -43
- package/dist/computer/index.js +29 -50
- package/dist/computer/paths.d.ts +5 -27
- package/dist/computer/paths.js +7 -30
- package/dist/computer/proxy.d.ts +17 -31
- package/dist/computer/proxy.js +9 -44
- package/dist/computer/render.d.ts +3 -40
- package/dist/computer/render.js +3 -36
- package/dist/computer/sandbox.d.ts +55 -0
- package/dist/computer/sandbox.js +236 -0
- package/dist/computer/shell.d.ts +36 -0
- package/dist/computer/shell.js +41 -0
- package/dist/computer/tools-exec.js +3 -9
- package/dist/computer/tools-file.d.ts +1 -1
- package/dist/computer/tools-file.js +1 -1
- package/dist/{repo → repo-github}/checkout.d.ts +3 -4
- package/dist/{repo → repo-github}/checkout.js +3 -4
- package/dist/{repo → repo-github}/context.d.ts +14 -32
- package/dist/{repo → repo-github}/context.js +46 -81
- package/dist/{repo → repo-github}/index.d.ts +30 -13
- package/dist/{repo → repo-github}/index.js +25 -6
- package/dist/repo-github/mcp.d.ts +95 -0
- package/dist/repo-github/mcp.js +107 -0
- package/dist/repo-github/sandbox-egress.d.ts +68 -0
- package/dist/repo-github/sandbox-egress.js +272 -0
- package/dist/repo-github/sandbox-git.d.ts +30 -0
- package/dist/repo-github/sandbox-git.js +113 -0
- package/dist/{repo → repo-github}/tools-checks.js +43 -4
- package/dist/{repo → repo-github}/tools-clone.js +2 -21
- package/dist/{repo → repo-github}/tools-pr.d.ts +0 -1
- package/dist/{repo → repo-github}/tools-pr.js +85 -20
- package/dist/{repo → repo-github}/tools-review.d.ts +6 -0
- package/dist/{repo → repo-github}/tools-review.js +62 -176
- package/dist/{repo → repo-github}/url.d.ts +11 -0
- package/dist/{repo → repo-github}/url.js +12 -1
- package/dist/{workspace → sandbox}/advisory.d.ts +16 -35
- package/dist/{workspace → sandbox}/advisory.js +2 -29
- package/dist/sandbox/agent-exec.d.ts +31 -0
- package/dist/sandbox/agent-exec.js +62 -0
- package/dist/sandbox/ca-trust.d.ts +9 -0
- package/dist/sandbox/ca-trust.js +21 -0
- package/dist/sandbox/constants.d.ts +22 -0
- package/dist/sandbox/constants.js +22 -0
- package/dist/sandbox/daemon/composite.d.ts +9 -0
- package/dist/sandbox/daemon/composite.js +35 -0
- package/dist/sandbox/daemon/index.d.ts +47 -0
- package/dist/sandbox/daemon/index.js +116 -0
- package/dist/sandbox/egress.d.ts +81 -0
- package/dist/sandbox/egress.js +109 -0
- package/dist/sandbox/exec.d.ts +57 -0
- package/dist/sandbox/exec.js +52 -0
- package/dist/sandbox/format.d.ts +2 -0
- package/dist/sandbox/format.js +7 -0
- package/dist/sandbox/header.d.ts +50 -0
- package/dist/sandbox/header.js +94 -0
- package/dist/sandbox/index.d.ts +17 -0
- package/dist/sandbox/index.js +17 -0
- package/dist/{workspace/install.d.ts → sandbox/install-plan.d.ts} +10 -1
- package/dist/{workspace/install.js → sandbox/install-plan.js} +22 -4
- package/dist/sandbox/name.d.ts +27 -0
- package/dist/sandbox/name.js +32 -0
- package/dist/sandbox/object.d.ts +244 -0
- package/dist/sandbox/object.js +1139 -0
- package/dist/scratch/index.d.ts +1 -9
- package/dist/scratch/index.js +2 -18
- package/package.json +26 -22
- package/dist/claude-code/github.d.ts +0 -39
- package/dist/claude-code/github.js +0 -82
- package/dist/claude-code/run.d.ts +0 -474
- package/dist/claude-code/run.js +0 -901
- package/dist/claude-code/session.d.ts +0 -131
- package/dist/claude-code/session.js +0 -285
- package/dist/workspace/ca-trust.d.ts +0 -95
- package/dist/workspace/ca-trust.js +0 -312
- package/dist/workspace/container-deps.d.ts +0 -54
- package/dist/workspace/container-deps.js +0 -224
- package/dist/workspace/container-fault.d.ts +0 -38
- package/dist/workspace/container-fault.js +0 -73
- package/dist/workspace/exec.d.ts +0 -94
- package/dist/workspace/exec.js +0 -153
- package/dist/workspace/format.d.ts +0 -36
- package/dist/workspace/format.js +0 -57
- package/dist/workspace/git-host.d.ts +0 -79
- package/dist/workspace/git-host.js +0 -226
- package/dist/workspace/git-identity.d.ts +0 -57
- package/dist/workspace/git-identity.js +0 -147
- package/dist/workspace/index.d.ts +0 -49
- package/dist/workspace/index.js +0 -58
- package/dist/workspace/install-job.d.ts +0 -121
- package/dist/workspace/install-job.js +0 -866
- package/dist/workspace/object.d.ts +0 -502
- package/dist/workspace/object.js +0 -1427
- package/dist/workspace/open.d.ts +0 -95
- package/dist/workspace/open.js +0 -63
- package/dist/workspace/read.d.ts +0 -16
- package/dist/workspace/read.js +0 -22
- package/dist/workspace/shell.d.ts +0 -45
- package/dist/workspace/shell.js +0 -80
- package/dist/workspace/sync.d.ts +0 -111
- package/dist/workspace/sync.js +0 -124
- package/dist/workspace/wake.d.ts +0 -19
- package/dist/workspace/wake.js +0 -10
- /package/dist/{repo → repo-github}/tools-checks.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-clone.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-worktree.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-worktree.js +0 -0
- /package/dist/{repo → repo-github}/tools-worktrees.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-worktrees.js +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { action } from "@cloudflare/think";
|
|
2
2
|
import { tool } from "ai";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import { sha256Hex } from "@dynamicagents/core";
|
|
5
|
+
import { actionTask, GITHUB_ACTION_TIMEOUT_MS, GITHUB_PAGE_SIZE } from "./context.js";
|
|
6
6
|
export function prTools(ctx) {
|
|
7
|
-
const { bounded, github, githubRepo } = ctx;
|
|
7
|
+
const { bounded, github, mcp, githubRepo } = ctx;
|
|
8
8
|
return {
|
|
9
9
|
repo_open_pr: tool({
|
|
10
10
|
description: "Open a pull request for a pushed branch and return its URL. Do this once the branch is pushed and the tests pass.",
|
|
@@ -67,14 +67,31 @@ export function prTools(ctx) {
|
|
|
67
67
|
if ("refusal" in target)
|
|
68
68
|
return target.refusal;
|
|
69
69
|
const { owner, repo } = target;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
const where = {
|
|
71
|
+
owner: decodeURIComponent(owner),
|
|
72
|
+
repo: decodeURIComponent(repo)
|
|
73
|
+
};
|
|
74
|
+
// `issue_read` deliberately, even for a pull request: GitHub models
|
|
75
|
+
// every PR as an issue, and that is the view carrying the discussion.
|
|
76
|
+
// `repo_pr_view` is for the parts that are only a PR's.
|
|
77
|
+
const issue = await mcp("repo_issue_view", "issue_read", {
|
|
78
|
+
...where,
|
|
79
|
+
issue_number: number,
|
|
80
|
+
method: "get"
|
|
81
|
+
});
|
|
74
82
|
if (!issue.ok)
|
|
75
83
|
return bounded(issue.message);
|
|
76
84
|
const data = issue.data;
|
|
77
|
-
|
|
85
|
+
// No `pull_request` key on this read, so the kind comes from the URL
|
|
86
|
+
// GitHub builds for it: a pull request's is `/pull/<n>` where an
|
|
87
|
+
// issue's is `/issues/<n>`.
|
|
88
|
+
const isPull = /\/pull\/\d+/.test(data.html_url ?? "");
|
|
89
|
+
const comments = await mcp("repo_issue_view", "issue_read", {
|
|
90
|
+
...where,
|
|
91
|
+
issue_number: number,
|
|
92
|
+
method: "get_comments",
|
|
93
|
+
perPage: GITHUB_PAGE_SIZE
|
|
94
|
+
});
|
|
78
95
|
// A failure here is not a failure of the tool: the issue itself was
|
|
79
96
|
// read, and half an answer beats none.
|
|
80
97
|
const thread = (comments.ok && Array.isArray(comments.data) ? comments.data : []);
|
|
@@ -85,7 +102,7 @@ export function prTools(ctx) {
|
|
|
85
102
|
const moreComments = thread.length >= GITHUB_PAGE_SIZE;
|
|
86
103
|
return bounded([
|
|
87
104
|
`#${number} ${data.title ?? "(no title)"} [${data.state ?? "?"}]` +
|
|
88
|
-
(
|
|
105
|
+
(isPull ? " (pull request)" : ""),
|
|
89
106
|
`opened by ${data.user?.login ?? "unknown"}`,
|
|
90
107
|
"",
|
|
91
108
|
data.body?.trim() || "(no description)",
|
|
@@ -101,7 +118,7 @@ export function prTools(ctx) {
|
|
|
101
118
|
}
|
|
102
119
|
}),
|
|
103
120
|
repo_pr_view: tool({
|
|
104
|
-
description: "Read a pull request's state and the files it touches — whether it is mergeable,
|
|
121
|
+
description: "Read a pull request's state, the reviews that have landed on it, and the files it touches — whether it is mergeable, who has reviewed and with what verdict, and which paths changed. Use this to check on a pull request you opened, to see whether a review has arrived before reading its threads, or to see what an existing one does before duplicating it.",
|
|
105
122
|
inputSchema: z.object({
|
|
106
123
|
dir: z.string().describe("The checkout directory"),
|
|
107
124
|
number: z.number().int().positive().describe("Pull request number")
|
|
@@ -111,28 +128,76 @@ export function prTools(ctx) {
|
|
|
111
128
|
if ("refusal" in target)
|
|
112
129
|
return target.refusal;
|
|
113
130
|
const { owner, repo } = target;
|
|
114
|
-
const
|
|
131
|
+
const where = {
|
|
132
|
+
owner: decodeURIComponent(owner),
|
|
133
|
+
repo: decodeURIComponent(repo),
|
|
134
|
+
pullNumber: number
|
|
135
|
+
};
|
|
136
|
+
const pr = await mcp("repo_pr_view", "pull_request_read", {
|
|
137
|
+
...where,
|
|
138
|
+
method: "get"
|
|
139
|
+
});
|
|
115
140
|
if (!pr.ok)
|
|
116
141
|
return bounded(pr.message);
|
|
117
142
|
const data = pr.data;
|
|
118
|
-
const files = await
|
|
143
|
+
const files = await mcp("repo_pr_view", "pull_request_read", {
|
|
144
|
+
...where,
|
|
145
|
+
method: "get_files",
|
|
146
|
+
perPage: GITHUB_PAGE_SIZE
|
|
147
|
+
});
|
|
119
148
|
const changed = (files.ok && Array.isArray(files.data) ? files.data : []);
|
|
120
149
|
const moreFiles = changed.length >= GITHUB_PAGE_SIZE;
|
|
150
|
+
// The reviews, because "has one landed yet" is the question an agent
|
|
151
|
+
// watching a pull request between sessions actually has, and an empty
|
|
152
|
+
// list of threads does not answer it: a review can finish having left
|
|
153
|
+
// no comments.
|
|
154
|
+
const reviews = await mcp("repo_pr_view", "pull_request_read", {
|
|
155
|
+
...where,
|
|
156
|
+
method: "get_reviews",
|
|
157
|
+
perPage: GITHUB_PAGE_SIZE
|
|
158
|
+
});
|
|
159
|
+
const landed = (reviews.ok && Array.isArray(reviews.data) ? reviews.data : []);
|
|
160
|
+
// A draft its author has not sent, visible only to whoever holds the
|
|
161
|
+
// token that wrote it. Reporting one as a review names a verdict
|
|
162
|
+
// nobody has read.
|
|
163
|
+
const submitted = landed.filter((r) => (r.state ?? "").toUpperCase() !== "PENDING");
|
|
164
|
+
// Reviews arrive oldest first, so a full page leaves the newest unread
|
|
165
|
+
// — and "has a review landed yet" is what this tool is called for. Said
|
|
166
|
+
// rather than left to the list, which would otherwise read as complete.
|
|
167
|
+
const moreReviews = landed.length >= GITHUB_PAGE_SIZE;
|
|
121
168
|
return bounded([
|
|
122
169
|
`#${number} ${data.title ?? "(no title)"}`,
|
|
123
170
|
`${data.merged ? "merged" : (data.state ?? "?")}` +
|
|
124
171
|
(data.draft ? " (draft)" : "") +
|
|
125
|
-
//
|
|
126
|
-
// until it has
|
|
127
|
-
// seconds ago — exactly when this tool is most likely to
|
|
128
|
-
|
|
172
|
+
// GitHub computes mergeability asynchronously and reports
|
|
173
|
+
// `unknown` until it has, which is common on a pull request
|
|
174
|
+
// opened seconds ago — exactly when this tool is most likely to
|
|
175
|
+
// be called.
|
|
176
|
+
(data.mergeable_state === undefined ||
|
|
177
|
+
data.mergeable_state === "unknown"
|
|
129
178
|
? ", mergeability not yet computed"
|
|
130
|
-
: data.
|
|
131
|
-
? "
|
|
132
|
-
:
|
|
179
|
+
: data.mergeable_state === "clean"
|
|
180
|
+
? ""
|
|
181
|
+
: `, mergeable_state ${data.mergeable_state}`),
|
|
133
182
|
`${data.head?.ref ?? "?"} → ${data.base?.ref ?? "?"}`,
|
|
134
183
|
data.html_url ?? "",
|
|
135
184
|
"",
|
|
185
|
+
!reviews.ok
|
|
186
|
+
? "Reviews: (could not be read)"
|
|
187
|
+
: submitted.length === 0
|
|
188
|
+
? "Reviews: none has landed yet."
|
|
189
|
+
: [
|
|
190
|
+
"Reviews:",
|
|
191
|
+
...submitted.map((r) => ` ${r.user?.login ?? "unknown"} — ${(r.state ?? "?").toLowerCase()}` +
|
|
192
|
+
`${r.submitted_at ? ` at ${r.submitted_at}` : ""}`)
|
|
193
|
+
].join("\n"),
|
|
194
|
+
...(moreReviews
|
|
195
|
+
? [
|
|
196
|
+
`(showing the first ${GITHUB_PAGE_SIZE} reviews; this pull ` +
|
|
197
|
+
`request has more, and the newest are not among them)`
|
|
198
|
+
]
|
|
199
|
+
: []),
|
|
200
|
+
"",
|
|
136
201
|
changed.length
|
|
137
202
|
? changed
|
|
138
203
|
.map((f) => ` ${f.filename ?? "?"} (+${f.additions ?? 0} -${f.deletions ?? 0})`)
|
|
@@ -175,7 +240,7 @@ export function prActions(ctx) {
|
|
|
175
240
|
description: "Leave a comment on a pull request or issue in the repository you have checked out. Use this to report what you did, or to answer a review — not to announce work you have not finished. The same comment twice in one task is posted once.",
|
|
176
241
|
inputSchema: commentInput,
|
|
177
242
|
timeoutMs: GITHUB_ACTION_TIMEOUT_MS,
|
|
178
|
-
idempotencyKey: async ({ input, ctx: turn }) => `${actionTask(turn)}:${input.dir}#${input.number}:comment:${await
|
|
243
|
+
idempotencyKey: async ({ input, ctx: turn }) => `${actionTask(turn)}:${input.dir}#${input.number}:comment:${await sha256Hex(input.body)}`,
|
|
179
244
|
execute: async ({ dir, number, body }) => {
|
|
180
245
|
const target = await githubRepo(dir);
|
|
181
246
|
if ("refusal" in target)
|
|
@@ -8,5 +8,11 @@ export declare function reviewTools(ctx: RepoContext): ToolSet;
|
|
|
8
8
|
* thrown. The one outcome returned short of success is a reply that landed on
|
|
9
9
|
* a thread that did not resolve: that reply must never be sent again, so it is
|
|
10
10
|
* settled, and the resolve-only call it asks for is a different key.
|
|
11
|
+
*
|
|
12
|
+
* **Still GraphQL, and still this plugin's own.** Replying and resolving have no
|
|
13
|
+
* REST equivalent, and the hosted MCP server offers them only under a tool that
|
|
14
|
+
* also submits reviews — `APPROVE` included — which is a grant nothing here
|
|
15
|
+
* wants. See `./mcp.ts`. Not installed on claude-coordinator, whose revising
|
|
16
|
+
* Claude Code sessions answer their own reviews from inside their containers.
|
|
11
17
|
*/
|
|
12
18
|
export declare function reviewActions(ctx: RepoContext): Record<string, Action>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { action } from "@cloudflare/think";
|
|
2
2
|
import { tool } from "ai";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import { sha256Hex } from "@dynamicagents/core";
|
|
5
|
+
import { actionTask, GITHUB_ACTION_TIMEOUT_MS, GITHUB_PAGE_SIZE } from "./context.js";
|
|
6
|
+
/** A review as a conversation: what it said, and answering it. */
|
|
6
7
|
/**
|
|
7
8
|
* How many pages of review threads one `repo_pr_threads` call will walk.
|
|
8
9
|
*
|
|
@@ -20,59 +21,10 @@ const MAX_THREAD_PAGES = 10;
|
|
|
20
21
|
*
|
|
21
22
|
* Both ends, because the two uses pull opposite ways: what the reviewer asked for
|
|
22
23
|
* is at the top, and whether a reply landed is at the bottom. A thread shorter
|
|
23
|
-
* than this is returned whole
|
|
24
|
-
*
|
|
24
|
+
* than this is returned whole, which is the ordinary case — the read asks for a
|
|
25
|
+
* hundred comments per thread and almost every thread has a handful.
|
|
25
26
|
*/
|
|
26
27
|
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
28
|
/** Which pull request, in which repository, a thread id names. */
|
|
77
29
|
const THREAD_OWNER_QUERY = `
|
|
78
30
|
query($id:ID!){
|
|
@@ -92,39 +44,59 @@ const THREAD_RESOLVE_MUTATION = `
|
|
|
92
44
|
mutation($id:ID!){
|
|
93
45
|
resolveReviewThread(input:{threadId:$id}){thread{isResolved}}
|
|
94
46
|
}`;
|
|
95
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* One thread, as the model reads it: where it is, its id, and the conversation.
|
|
49
|
+
*
|
|
50
|
+
* Where it is comes from the **first comment** rather than the thread: a thread
|
|
51
|
+
* is a location only through the comments on it, so a thread whose comments did
|
|
52
|
+
* not come back has no file to name.
|
|
53
|
+
*/
|
|
96
54
|
function renderThread(t) {
|
|
97
|
-
const
|
|
98
|
-
|
|
55
|
+
const comments = t.comments ?? [];
|
|
56
|
+
const first = comments[0];
|
|
57
|
+
const line = first?.line ?? first?.original_line;
|
|
58
|
+
const where = first?.path
|
|
59
|
+
? `${first.path}${typeof line === "number" ? `:${line}` : ""}`
|
|
99
60
|
: "(no file)";
|
|
100
|
-
const total = t.
|
|
101
|
-
const tail = t.latest?.nodes ?? [];
|
|
61
|
+
const total = t.total_count ?? comments.length;
|
|
102
62
|
// Whole when it fits, and both ends with the middle named when it does not.
|
|
103
63
|
// The opening comment is the reviewer's actual point and the tail is where a
|
|
104
64
|
// reply this plugin sent would be, so neither end can be the one dropped.
|
|
105
|
-
const
|
|
65
|
+
const shown = comments.length > MAX_THREAD_COMMENTS
|
|
106
66
|
? [
|
|
107
|
-
...(
|
|
67
|
+
...comments.slice(0, 1),
|
|
108
68
|
{
|
|
109
|
-
author:
|
|
110
|
-
body: `(${
|
|
69
|
+
author: "…",
|
|
70
|
+
body: `(${comments.length - MAX_THREAD_COMMENTS} earlier replies not shown)`
|
|
111
71
|
},
|
|
112
|
-
...
|
|
72
|
+
...comments.slice(-(MAX_THREAD_COMMENTS - 1))
|
|
73
|
+
]
|
|
74
|
+
: comments;
|
|
75
|
+
// Said only when the read itself was short of the conversation, which the
|
|
76
|
+
// slice above is not: that one is this render's own choice and both ends
|
|
77
|
+
// survive it.
|
|
78
|
+
const unread = total > comments.length
|
|
79
|
+
? [
|
|
80
|
+
{
|
|
81
|
+
author: "…",
|
|
82
|
+
body: `(${total - comments.length} comments were not read)`
|
|
83
|
+
}
|
|
113
84
|
]
|
|
114
|
-
:
|
|
85
|
+
: [];
|
|
115
86
|
return [
|
|
116
|
-
`--- ${where}${t.
|
|
87
|
+
`--- ${where}${t.is_resolved ? " [resolved]" : ""}${t.is_outdated ? " [outdated]" : ""}`,
|
|
117
88
|
`id: ${t.id}`,
|
|
118
|
-
...
|
|
89
|
+
...[...shown, ...unread].map((c) => `${c.author ?? "unknown"}: ${c.body ?? ""}`)
|
|
119
90
|
].join("\n");
|
|
120
91
|
}
|
|
121
92
|
/**
|
|
122
93
|
* The owner and repository as their own names, rather than as REST path segments.
|
|
123
94
|
*
|
|
124
95
|
* {@link file://./context.ts githubRepo} percent-encodes both, because that is
|
|
125
|
-
* what makes a path safe. A GraphQL *variable*
|
|
126
|
-
* against the repository's actual name — so an encoded one
|
|
127
|
-
* matching the moment a name contains a character worth
|
|
96
|
+
* what makes a path safe. A GraphQL *variable* and an MCP argument are not paths
|
|
97
|
+
* — both are compared against the repository's actual name — so an encoded one
|
|
98
|
+
* would silently stop matching the moment a name contains a character worth
|
|
99
|
+
* encoding.
|
|
128
100
|
*/
|
|
129
101
|
function decoded(target) {
|
|
130
102
|
return {
|
|
@@ -132,105 +104,11 @@ function decoded(target) {
|
|
|
132
104
|
repo: decodeURIComponent(target.repo)
|
|
133
105
|
};
|
|
134
106
|
}
|
|
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
107
|
export function reviewTools(ctx) {
|
|
166
|
-
const { bounded,
|
|
108
|
+
const { bounded, mcp, githubRepo } = ctx;
|
|
167
109
|
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
110
|
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.",
|
|
111
|
+
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. An empty answer on a pull request whose review has not landed means nothing yet rather than nothing to do — repo_pr_view says whether one has.",
|
|
234
112
|
inputSchema: z.object({
|
|
235
113
|
dir: z.string().describe("The checkout directory"),
|
|
236
114
|
number: z.number().int().positive().describe("Pull request number"),
|
|
@@ -245,7 +123,7 @@ export function reviewTools(ctx) {
|
|
|
245
123
|
return target.refusal;
|
|
246
124
|
const { owner, repo } = decoded(target);
|
|
247
125
|
const threads = [];
|
|
248
|
-
let after
|
|
126
|
+
let after;
|
|
249
127
|
let truncated = false;
|
|
250
128
|
// Paged, and that is not an optimisation. A review long enough to spill a
|
|
251
129
|
// page puts its newest threads on the last one — exactly the threads the
|
|
@@ -257,21 +135,23 @@ export function reviewTools(ctx) {
|
|
|
257
135
|
truncated = true;
|
|
258
136
|
break;
|
|
259
137
|
}
|
|
260
|
-
const answered = await
|
|
138
|
+
const answered = await mcp("repo_pr_threads", "pull_request_read", {
|
|
261
139
|
owner,
|
|
262
140
|
repo,
|
|
263
|
-
number,
|
|
264
|
-
|
|
141
|
+
pullNumber: number,
|
|
142
|
+
method: "get_review_comments",
|
|
143
|
+
perPage: GITHUB_PAGE_SIZE,
|
|
144
|
+
...(after ? { after } : {})
|
|
265
145
|
});
|
|
266
146
|
if (!answered.ok)
|
|
267
147
|
return bounded(answered.message);
|
|
268
|
-
const page_ = answered.data
|
|
269
|
-
if (!page_)
|
|
148
|
+
const page_ = answered.data;
|
|
149
|
+
if (!page_?.review_threads)
|
|
270
150
|
return `#${number} is not a pull request in ${owner}/${repo}`;
|
|
271
|
-
threads.push(...
|
|
272
|
-
if (!page_.
|
|
151
|
+
threads.push(...page_.review_threads);
|
|
152
|
+
if (!page_.page_info?.has_next_page)
|
|
273
153
|
break;
|
|
274
|
-
after = page_.
|
|
154
|
+
after = page_.page_info.end_cursor ?? undefined;
|
|
275
155
|
if (!after)
|
|
276
156
|
break;
|
|
277
157
|
}
|
|
@@ -285,7 +165,7 @@ export function reviewTools(ctx) {
|
|
|
285
165
|
: "";
|
|
286
166
|
const shown = includeResolved
|
|
287
167
|
? threads
|
|
288
|
-
: threads.filter((t) => !t.
|
|
168
|
+
: threads.filter((t) => !t.is_resolved);
|
|
289
169
|
if (shown.length === 0)
|
|
290
170
|
return bounded((includeResolved
|
|
291
171
|
? `#${number} has no review threads.`
|
|
@@ -305,6 +185,12 @@ export function reviewTools(ctx) {
|
|
|
305
185
|
* thrown. The one outcome returned short of success is a reply that landed on
|
|
306
186
|
* a thread that did not resolve: that reply must never be sent again, so it is
|
|
307
187
|
* settled, and the resolve-only call it asks for is a different key.
|
|
188
|
+
*
|
|
189
|
+
* **Still GraphQL, and still this plugin's own.** Replying and resolving have no
|
|
190
|
+
* REST equivalent, and the hosted MCP server offers them only under a tool that
|
|
191
|
+
* also submits reviews — `APPROVE` included — which is a grant nothing here
|
|
192
|
+
* wants. See `./mcp.ts`. Not installed on claude-coordinator, whose revising
|
|
193
|
+
* Claude Code sessions answer their own reviews from inside their containers.
|
|
308
194
|
*/
|
|
309
195
|
export function reviewActions(ctx) {
|
|
310
196
|
const { bounded, githubGraphql, githubRepo } = ctx;
|
|
@@ -332,7 +218,7 @@ export function reviewActions(ctx) {
|
|
|
332
218
|
// The thread and the resolve flag as well as the body, or the same reply
|
|
333
219
|
// to two threads — "Fixed." — would be one key.
|
|
334
220
|
idempotencyKey: async ({ input, ctx: turn }) => `${actionTask(turn)}:${input.dir}#${input.number}:${input.threadId}:` +
|
|
335
|
-
`${await
|
|
221
|
+
`${await sha256Hex(input.body ?? "")}:${input.resolve ?? true}`,
|
|
336
222
|
execute: async ({ dir, number, threadId, body, resolve }) => {
|
|
337
223
|
const target = await githubRepo(dir);
|
|
338
224
|
if ("refusal" in target)
|
|
@@ -6,6 +6,17 @@
|
|
|
6
6
|
* naming it": every pattern here turns away a string that resolves to something
|
|
7
7
|
* other than what it appears to name.
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* The one host whose API these tools talk to.
|
|
11
|
+
*
|
|
12
|
+
* Narrower than {@link DEFAULT_ALLOWED_HOSTS}, which a host may widen: cloning
|
|
13
|
+
* is generic git and works anywhere, while every API read goes to github.com's
|
|
14
|
+
* hosted MCP server and every write to github.com's REST and GraphQL. So a
|
|
15
|
+
* checkout on another allowed host has an origin these tools must refuse rather
|
|
16
|
+
* than resolve — see `githubRepo` in `./context.ts`, which is where that is
|
|
17
|
+
* enforced.
|
|
18
|
+
*/
|
|
19
|
+
export declare const GITHUB_HOST = "github.com";
|
|
9
20
|
/** The hosts a clone URL may name. `RepoConfig.allowedHosts` overrides it. */
|
|
10
21
|
export declare const DEFAULT_ALLOWED_HOSTS: string[];
|
|
11
22
|
/** Branch names a push must never target, whatever the model believes. */
|
|
@@ -6,8 +6,19 @@
|
|
|
6
6
|
* naming it": every pattern here turns away a string that resolves to something
|
|
7
7
|
* other than what it appears to name.
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* The one host whose API these tools talk to.
|
|
11
|
+
*
|
|
12
|
+
* Narrower than {@link DEFAULT_ALLOWED_HOSTS}, which a host may widen: cloning
|
|
13
|
+
* is generic git and works anywhere, while every API read goes to github.com's
|
|
14
|
+
* hosted MCP server and every write to github.com's REST and GraphQL. So a
|
|
15
|
+
* checkout on another allowed host has an origin these tools must refuse rather
|
|
16
|
+
* than resolve — see `githubRepo` in `./context.ts`, which is where that is
|
|
17
|
+
* enforced.
|
|
18
|
+
*/
|
|
19
|
+
export const GITHUB_HOST = "github.com";
|
|
9
20
|
/** The hosts a clone URL may name. `RepoConfig.allowedHosts` overrides it. */
|
|
10
|
-
export const DEFAULT_ALLOWED_HOSTS = [
|
|
21
|
+
export const DEFAULT_ALLOWED_HOSTS = [GITHUB_HOST];
|
|
11
22
|
/** Branch names a push must never target, whatever the model believes. */
|
|
12
23
|
export const PROTECTED_BRANCHES = new Set([
|
|
13
24
|
"main",
|