@dynamicagents/plugins 0.11.0 → 0.12.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 +12 -13
- package/dist/arc-agi/analysis.d.ts +16 -30
- package/dist/arc-agi/analysis.js +20 -47
- package/dist/arc-agi/client.d.ts +3 -2
- package/dist/arc-agi/game-tools.d.ts +5 -6
- package/dist/arc-agi/index.d.ts +6 -8
- package/dist/arc-agi/index.js +3 -3
- package/dist/arc-agi/main-agent.d.ts +5 -8
- package/dist/arc-agi/main-agent.js +5 -8
- package/dist/arc-agi/recipe.d.ts +17 -20
- package/dist/arc-agi/recipe.js +17 -20
- package/dist/arc-agi/soul.d.ts +16 -27
- package/dist/arc-agi/soul.js +16 -27
- package/dist/arc-agi/store.d.ts +4 -5
- package/dist/arc-agi/store.js +4 -5
- package/dist/arc-agi/tools.d.ts +1 -1
- package/dist/arc-agi/tools.js +26 -19
- package/dist/arc-agi/types.d.ts +6 -7
- package/dist/claude-code/config.d.ts +88 -11
- package/dist/claude-code/config.js +11 -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 +7 -13
- package/dist/claude-code/egress.js +9 -10
- package/dist/claude-code/events.d.ts +47 -0
- package/dist/claude-code/events.js +96 -10
- package/dist/claude-code/index.d.ts +31 -13
- package/dist/claude-code/index.js +62 -15
- package/dist/claude-code/run.d.ts +135 -41
- package/dist/claude-code/run.js +366 -61
- package/dist/computer/advisory.d.ts +31 -18
- package/dist/computer/advisory.js +30 -5
- package/dist/computer/context.d.ts +23 -0
- package/dist/computer/context.js +225 -0
- package/dist/computer/file-lock.d.ts +5 -0
- package/dist/computer/file-lock.js +39 -0
- package/dist/computer/gate.d.ts +8 -8
- package/dist/computer/gate.js +7 -7
- package/dist/computer/host/ca-trust.d.ts +88 -0
- package/dist/computer/host/ca-trust.js +303 -0
- package/dist/computer/host/container-deps.d.ts +54 -0
- package/dist/computer/host/container-deps.js +224 -0
- package/dist/computer/host/container-fault.d.ts +38 -0
- package/dist/computer/host/container-fault.js +58 -0
- package/dist/computer/host/git-host.d.ts +62 -0
- package/dist/computer/host/git-host.js +156 -0
- package/dist/computer/host/git-identity.d.ts +57 -0
- package/dist/computer/host/git-identity.js +147 -0
- package/dist/computer/host/install-job.d.ts +121 -0
- package/dist/computer/host/install-job.js +766 -0
- package/dist/computer/host/sync.d.ts +111 -0
- package/dist/computer/host/sync.js +124 -0
- package/dist/computer/host/wake.d.ts +19 -0
- package/dist/computer/host/wake.js +10 -0
- package/dist/computer/host/workspace.d.ts +424 -0
- package/dist/computer/host/workspace.js +1258 -0
- package/dist/computer/index.d.ts +70 -50
- package/dist/computer/index.js +79 -767
- package/dist/computer/install.d.ts +8 -12
- package/dist/computer/install.js +6 -9
- package/dist/computer/paths.d.ts +21 -16
- package/dist/computer/paths.js +40 -38
- package/dist/computer/read.d.ts +18 -22
- package/dist/computer/read.js +24 -27
- package/dist/computer/render.d.ts +28 -0
- package/dist/computer/render.js +34 -1
- package/dist/computer/shell.d.ts +45 -0
- package/dist/computer/shell.js +80 -0
- package/dist/computer/tools-exec.d.ts +3 -0
- package/dist/computer/tools-exec.js +269 -0
- package/dist/computer/tools-file.d.ts +4 -0
- package/dist/computer/tools-file.js +124 -0
- package/dist/computer/tools-find.d.ts +3 -0
- package/dist/computer/tools-find.js +216 -0
- package/dist/recall/index.d.ts +9 -3
- package/dist/recall/index.js +22 -12
- package/dist/repo/checkout.d.ts +34 -4
- package/dist/repo/checkout.js +36 -1
- package/dist/repo/context.d.ts +109 -0
- package/dist/repo/context.js +530 -0
- package/dist/repo/index.d.ts +6 -29
- package/dist/repo/index.js +17 -1027
- package/dist/repo/tools-clone.d.ts +4 -0
- package/dist/repo/tools-clone.js +192 -0
- package/dist/repo/tools-forge.d.ts +4 -0
- package/dist/repo/tools-forge.js +179 -0
- package/dist/repo/tools-review.d.ts +3 -0
- package/dist/repo/tools-review.js +373 -0
- package/dist/repo/tools-worktree.d.ts +4 -0
- package/dist/repo/tools-worktree.js +221 -0
- package/dist/triage/index.d.ts +6 -1
- package/dist/triage/index.js +32 -23
- package/dist/workspace/index.d.ts +3 -3
- package/dist/workspace/index.js +3 -3
- package/package.json +21 -15
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { parseRepo, repoLocation, UNSAFE_BRANCH } from "./url.js";
|
|
4
|
+
import { refreshCheckout, writeGitIdentity } from "./checkout.js";
|
|
5
|
+
/** Putting a checkout on disk, fresh or refreshed. */
|
|
6
|
+
export function cloneTools(ctx) {
|
|
7
|
+
const { config, workdir, author, allowedHosts, bounded, notifyCheckout, logFailure, plain, runGit, fetchOrigin } = ctx;
|
|
8
|
+
return {
|
|
9
|
+
repo_clone: tool({
|
|
10
|
+
description: "Clone a git repository into the workspace. Returns the checkout path and the branch you landed on. If the repository is already checked out from an earlier task, it is fetched and reset to the remote instead — unless it has uncommitted changes, which are left alone for you to inspect.",
|
|
11
|
+
inputSchema: z.object({
|
|
12
|
+
url: z.string().describe("HTTPS repository URL"),
|
|
13
|
+
branch: z
|
|
14
|
+
.string()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe("Branch to check out (default: the repo's default)"),
|
|
17
|
+
depth: z
|
|
18
|
+
.number()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe("Shallow-clone depth. Omit for full history; needed if you must rebase.")
|
|
21
|
+
}),
|
|
22
|
+
execute: async ({ url, branch, depth }) => {
|
|
23
|
+
// The gate, before anything runs. `url` is model input, and a clone is
|
|
24
|
+
// the one command that hands a credential to a host it names.
|
|
25
|
+
const location = repoLocation(url);
|
|
26
|
+
if (!location || !allowedHosts.includes(location.host)) {
|
|
27
|
+
return (`refusing to clone from "${location?.host ?? url}" — this agent may ` +
|
|
28
|
+
`only clone over https from: ${allowedHosts.join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
// Everything past this point travels the canonical form, so what the
|
|
31
|
+
// host clones, what lands in `.git/config`, and what `afterCheckout`
|
|
32
|
+
// records are one string. Refusals above still quote what the model
|
|
33
|
+
// actually sent.
|
|
34
|
+
const { host, url: target } = location;
|
|
35
|
+
// Refused rather than worked around. A URL that passes the host check
|
|
36
|
+
// but names no repository is what a model copies out of a browser —
|
|
37
|
+
// `.../tree/main`, `.../pull/4` — and carrying on means cloning without
|
|
38
|
+
// telling `beforeCheckout` which repository this is, so a host keying its
|
|
39
|
+
// filesystem per repository never switches and the checkout lands in
|
|
40
|
+
// whichever workspace was already open.
|
|
41
|
+
//
|
|
42
|
+
// Not silently trimmed to the first two segments either, tempting as it
|
|
43
|
+
// is: that reads `.../orgs/x/repositories` as the repository `x`, and
|
|
44
|
+
// quietly reinterpreting the target is the wrong instinct for the one
|
|
45
|
+
// tool that offers a credential to a host on the model's say-so. A
|
|
46
|
+
// refusal costs one turn and says exactly what to send instead.
|
|
47
|
+
const parsed = parseRepo(target, allowedHosts);
|
|
48
|
+
if (!parsed) {
|
|
49
|
+
return (`refusing to clone "${url}" — it does not name a repository. ` +
|
|
50
|
+
`A clone URL is https://<host>/<owner>/<repo>, so if this came out of ` +
|
|
51
|
+
`a browser, drop everything after the repository name (the /tree/…, ` +
|
|
52
|
+
`/pull/… or /blob/… part) and try again.`);
|
|
53
|
+
}
|
|
54
|
+
// The same guard `repo_push` applies, at the second door a branch name
|
|
55
|
+
// enters by. `refreshCheckout` runs `git checkout "$REPO_BRANCH"`, and
|
|
56
|
+
// quoting stops word-splitting but *not* option parsing: a `branch` of
|
|
57
|
+
// `--detach` arrives as an option, git detaches HEAD, the `reset` that
|
|
58
|
+
// follows fails against `origin/--detach`, and the checkout a later task
|
|
59
|
+
// inherits has moved for reasons nothing reported.
|
|
60
|
+
//
|
|
61
|
+
// Checked here rather than in `refreshCheckout` because this is where
|
|
62
|
+
// model input arrives and where the message can say what to send instead.
|
|
63
|
+
// It also covers the fresh-clone path, where the name reaches
|
|
64
|
+
// isomorphic-git as a ref: no argv there, so no option to parse, but an
|
|
65
|
+
// unresolvable ref is a worse error than this sentence.
|
|
66
|
+
if (branch !== undefined && UNSAFE_BRANCH.test(branch))
|
|
67
|
+
return `"${branch}" is not a plain branch name — use something like "main"`;
|
|
68
|
+
const dir = `${workdir}/${parsed.repo}`;
|
|
69
|
+
// Before anything runs, and before `dir` is touched: a host keying its
|
|
70
|
+
// filesystem per repository needs to have switched by now. See
|
|
71
|
+
// `beforeCheckout` for why this cannot wait for the clone to finish.
|
|
72
|
+
//
|
|
73
|
+
// A throw here stops the clone, which is the exact opposite of how
|
|
74
|
+
// `afterCheckout` is treated — and the asymmetry is the point rather than
|
|
75
|
+
// an inconsistency. This hook *chooses the workspace*; carrying on past a
|
|
76
|
+
// failed one would put the checkout wherever the last task left the
|
|
77
|
+
// selection, which is the failure the hook exists to prevent. By the time
|
|
78
|
+
// `afterCheckout` runs there is a checkout on disk and the model needs to
|
|
79
|
+
// be told about it, so that one is caught and logged.
|
|
80
|
+
try {
|
|
81
|
+
config.beforeCheckout?.({
|
|
82
|
+
url: target,
|
|
83
|
+
host,
|
|
84
|
+
owner: parsed.owner,
|
|
85
|
+
repo: parsed.repo
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
console.error("[repo] beforeCheckout failed", {
|
|
90
|
+
url,
|
|
91
|
+
repo: `${parsed.owner}/${parsed.repo}`,
|
|
92
|
+
err: String(err)
|
|
93
|
+
});
|
|
94
|
+
return (`could not select a workspace for ${parsed.owner}/${parsed.repo}: ` +
|
|
95
|
+
`${String(err)}\nNothing was cloned — going ahead would have put the ` +
|
|
96
|
+
`checkout in whichever workspace was already open.`);
|
|
97
|
+
}
|
|
98
|
+
// The workspace outlives the task, so this path may already hold the
|
|
99
|
+
// checkout a previous task left — see `refreshCheckout`.
|
|
100
|
+
const existing = await plain("rev-parse --git-dir", dir);
|
|
101
|
+
// A failed probe means "nothing here, clone it" — but only when git
|
|
102
|
+
// answered. A command that never ran means nothing, and the distinction
|
|
103
|
+
// is not academic: a replaced container is precisely the case where this
|
|
104
|
+
// call fails and the *next* one lands on a working replacement. Read as
|
|
105
|
+
// "empty", it points `git clone` at a directory that already holds the
|
|
106
|
+
// checkout — which fails with `destination path already exists` and skips
|
|
107
|
+
// the refresh that was the right answer all along.
|
|
108
|
+
if (existing.unreachable)
|
|
109
|
+
return bounded(`could not clone ${url}: ${existing.stderr}`);
|
|
110
|
+
if (existing.success) {
|
|
111
|
+
const refreshed = await refreshCheckout({
|
|
112
|
+
dir,
|
|
113
|
+
url: target,
|
|
114
|
+
branch,
|
|
115
|
+
author,
|
|
116
|
+
plain,
|
|
117
|
+
fetchOrigin
|
|
118
|
+
});
|
|
119
|
+
if (refreshed.branch) {
|
|
120
|
+
await notifyCheckout({
|
|
121
|
+
dir,
|
|
122
|
+
url: target,
|
|
123
|
+
host,
|
|
124
|
+
repo: `${parsed.owner}/${parsed.repo}`,
|
|
125
|
+
branch: refreshed.branch,
|
|
126
|
+
fresh: false
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return refreshed.message;
|
|
130
|
+
}
|
|
131
|
+
// `depth` is bounded here rather than trusted from the schema. It no
|
|
132
|
+
// longer reaches a shell, so this is arithmetic rather than quoting: a
|
|
133
|
+
// fractional or negative depth is not something to hand to git.
|
|
134
|
+
const result = await runGit(() => config.git.clone({
|
|
135
|
+
url: target,
|
|
136
|
+
dir,
|
|
137
|
+
allowedHosts,
|
|
138
|
+
...(branch ? { branch } : {}),
|
|
139
|
+
...(depth ? { depth: Math.max(1, Math.floor(depth)) } : {})
|
|
140
|
+
}));
|
|
141
|
+
if (!result.success) {
|
|
142
|
+
logFailure("repo_clone", result);
|
|
143
|
+
return bounded(`clone failed: ${result.stderr || result.stdout}`);
|
|
144
|
+
}
|
|
145
|
+
// Identity has to exist before the first commit. Written through the
|
|
146
|
+
// container, and deliberately: the commits it names are made there, by
|
|
147
|
+
// `repo_commit`, with no credential in sight — only the three operations
|
|
148
|
+
// that talk to the forge moved.
|
|
149
|
+
//
|
|
150
|
+
// Why a refresh rewrites it, and what answers for a repository this
|
|
151
|
+
// plugin never cloned, is on
|
|
152
|
+
// {@link file://./checkout.ts writeGitIdentity}.
|
|
153
|
+
await writeGitIdentity(plain, dir, author);
|
|
154
|
+
// Two gits now share one `.git`, and this is where they could disagree.
|
|
155
|
+
//
|
|
156
|
+
// The container's git and the host's isomorphic-git read and write the
|
|
157
|
+
// same index. isomorphic-git understands version 2 and the extensions
|
|
158
|
+
// git writes for its own caches are not part of that contract, so a
|
|
159
|
+
// repository that picked up an untracked cache or a split index — from a
|
|
160
|
+
// `feature.manyFiles` default, or a user config in some future image —
|
|
161
|
+
// would be written by one and misread by the other. Pinned at clone
|
|
162
|
+
// time, repo-locally, where it costs three commands and cannot surprise
|
|
163
|
+
// anyone later.
|
|
164
|
+
//
|
|
165
|
+
// Unchecked deliberately, like the upstream-tracking pair in
|
|
166
|
+
// `repo_push`: git's own defaults are already these values, so a failure
|
|
167
|
+
// here means the config could not be written at all, which the very next
|
|
168
|
+
// command will say far more clearly than this one could.
|
|
169
|
+
await plain(`config index.version 2`, dir);
|
|
170
|
+
await plain(`config core.untrackedCache false`, dir);
|
|
171
|
+
await plain(`config core.splitIndex false`, dir);
|
|
172
|
+
// The clone reports the branch it landed on, so nothing has to ask git
|
|
173
|
+
// afterwards — and a failed question would fire `afterCheckout` with no
|
|
174
|
+
// branch at all, starting an install against a checkout nobody can name.
|
|
175
|
+
const landed = result.stdout.trim() || branch;
|
|
176
|
+
if (!landed) {
|
|
177
|
+
logFailure("repo_clone", result);
|
|
178
|
+
return bounded(`cloned to ${dir}, but could not read which branch it landed on`);
|
|
179
|
+
}
|
|
180
|
+
await notifyCheckout({
|
|
181
|
+
dir,
|
|
182
|
+
url: target,
|
|
183
|
+
host,
|
|
184
|
+
repo: `${parsed.owner}/${parsed.repo}`,
|
|
185
|
+
branch: landed,
|
|
186
|
+
fresh: true
|
|
187
|
+
});
|
|
188
|
+
return `cloned to ${dir} on branch ${landed}`;
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { FORGE_PAGE_SIZE } from "./context.js";
|
|
4
|
+
/** The forge's REST surface: pull requests, issues and their comments. */
|
|
5
|
+
export function forgeTools(ctx) {
|
|
6
|
+
const { bounded, forge, forgeRepo } = ctx;
|
|
7
|
+
return {
|
|
8
|
+
repo_open_pr: tool({
|
|
9
|
+
description: "Open a pull request for a pushed branch and return its URL. Do this once the branch is pushed and the tests pass.",
|
|
10
|
+
inputSchema: z.object({
|
|
11
|
+
dir: z.string().describe("The checkout directory"),
|
|
12
|
+
head: z.string().describe("The branch you pushed"),
|
|
13
|
+
base: z.string().describe("The branch to merge into, e.g. 'main'"),
|
|
14
|
+
title: z.string().describe("Pull request title"),
|
|
15
|
+
body: z
|
|
16
|
+
.string()
|
|
17
|
+
.describe("Pull request description — what changed and why, and how you verified it")
|
|
18
|
+
}),
|
|
19
|
+
execute: async ({ dir, head, base, title, body }) => {
|
|
20
|
+
// The checkout's own origin, never a URL the model names — the rule the
|
|
21
|
+
// three read tools follow, and it binds harder here because this one
|
|
22
|
+
// writes. A repository named at the call site is bounded only by the
|
|
23
|
+
// host allowlist, so an agent talked into naming one could open a pull
|
|
24
|
+
// request on anything the token can write to.
|
|
25
|
+
const target = await forgeRepo(dir);
|
|
26
|
+
if ("refusal" in target)
|
|
27
|
+
return target.refusal;
|
|
28
|
+
const { owner, repo } = target;
|
|
29
|
+
// One may be open already: a call abandoned before its answer arrived
|
|
30
|
+
// still opened it, and the retry that follows is the ordinary case.
|
|
31
|
+
// Best-effort — a lookup that fails leaves the POST to answer for itself.
|
|
32
|
+
// The filter wants `owner:branch`; a fork's head already says whose.
|
|
33
|
+
const filter = new URLSearchParams({
|
|
34
|
+
state: "open",
|
|
35
|
+
head: head.includes(":")
|
|
36
|
+
? head
|
|
37
|
+
: `${decodeURIComponent(owner)}:${head}`,
|
|
38
|
+
base
|
|
39
|
+
});
|
|
40
|
+
const existing = await forge("repo_open_pr", `/repos/${owner}/${repo}/pulls?${filter}`);
|
|
41
|
+
const open = existing.ok && Array.isArray(existing.data)
|
|
42
|
+
? existing.data[0]
|
|
43
|
+
: undefined;
|
|
44
|
+
if (open?.html_url)
|
|
45
|
+
return `already open, nothing new was created: ${open.html_url}`;
|
|
46
|
+
const opened = await forge("repo_open_pr", `/repos/${owner}/${repo}/pulls`, { method: "POST", body: { title, head, base, body } });
|
|
47
|
+
if (!opened.ok)
|
|
48
|
+
// A POST whose answer never arrived may still have been received, and
|
|
49
|
+
// a blind retry is how one pull request becomes two.
|
|
50
|
+
return bounded(`${opened.message}\nIf this was a timeout rather than a rejection the ` +
|
|
51
|
+
`pull request may have been opened anyway — check with repo_pr_view ` +
|
|
52
|
+
`before retrying, or the retry will open a second one alongside it.`);
|
|
53
|
+
const data = opened.data;
|
|
54
|
+
return (data.html_url ??
|
|
55
|
+
"pull request opened, but the response carried no URL");
|
|
56
|
+
}
|
|
57
|
+
}),
|
|
58
|
+
repo_issue_view: tool({
|
|
59
|
+
description: "Read an issue or pull request from the repository you have checked out: its title, state, description and comments. Use this when a task refers to an issue number, before guessing what it asks for.",
|
|
60
|
+
inputSchema: z.object({
|
|
61
|
+
dir: z.string().describe("The checkout directory"),
|
|
62
|
+
number: z.number().int().positive().describe("Issue or PR number")
|
|
63
|
+
}),
|
|
64
|
+
execute: async ({ dir, number }) => {
|
|
65
|
+
const target = await forgeRepo(dir);
|
|
66
|
+
if ("refusal" in target)
|
|
67
|
+
return target.refusal;
|
|
68
|
+
const { owner, repo } = target;
|
|
69
|
+
// The issues endpoint, deliberately, even for a pull request: GitHub
|
|
70
|
+
// models every PR as an issue, and this is the one that carries the
|
|
71
|
+
// discussion. `repo_pr_view` is for the parts that are only a PR's.
|
|
72
|
+
const issue = await forge("repo_issue_view", `/repos/${owner}/${repo}/issues/${number}`);
|
|
73
|
+
if (!issue.ok)
|
|
74
|
+
return bounded(issue.message);
|
|
75
|
+
const data = issue.data;
|
|
76
|
+
const comments = await forge("repo_issue_view", `/repos/${owner}/${repo}/issues/${number}/comments?per_page=${FORGE_PAGE_SIZE}`);
|
|
77
|
+
// A failure here is not a failure of the tool: the issue itself was
|
|
78
|
+
// read, and half an answer beats none.
|
|
79
|
+
const thread = (comments.ok && Array.isArray(comments.data) ? comments.data : []);
|
|
80
|
+
// A full page means there is very likely another. Said out loud, because
|
|
81
|
+
// this thread is what the model reasons from: comments arrive oldest
|
|
82
|
+
// first, so the ones it cannot see are the most recent — the review
|
|
83
|
+
// feedback, on the pull request busy enough to have overflowed.
|
|
84
|
+
const moreComments = thread.length >= FORGE_PAGE_SIZE;
|
|
85
|
+
return bounded([
|
|
86
|
+
`#${number} ${data.title ?? "(no title)"} [${data.state ?? "?"}]` +
|
|
87
|
+
(data.pull_request ? " (pull request)" : ""),
|
|
88
|
+
`opened by ${data.user?.login ?? "unknown"}`,
|
|
89
|
+
"",
|
|
90
|
+
data.body?.trim() || "(no description)",
|
|
91
|
+
...thread.map((c) => `\n--- ${c.user?.login ?? "unknown"} ---\n${c.body?.trim() ?? ""}`),
|
|
92
|
+
...(comments.ok ? [] : [`\n(comments could not be read)`]),
|
|
93
|
+
...(moreComments
|
|
94
|
+
? [
|
|
95
|
+
`\n(showing the first ${FORGE_PAGE_SIZE} comments; the thread is ` +
|
|
96
|
+
`longer, and the newest are not among them)`
|
|
97
|
+
]
|
|
98
|
+
: [])
|
|
99
|
+
].join("\n"));
|
|
100
|
+
}
|
|
101
|
+
}),
|
|
102
|
+
repo_pr_view: tool({
|
|
103
|
+
description: "Read a pull request's state and the files it touches — whether it is mergeable, its review state, and which paths changed. Use this to check on a pull request you opened, or to see what an existing one does before duplicating it.",
|
|
104
|
+
inputSchema: z.object({
|
|
105
|
+
dir: z.string().describe("The checkout directory"),
|
|
106
|
+
number: z.number().int().positive().describe("Pull request number")
|
|
107
|
+
}),
|
|
108
|
+
execute: async ({ dir, number }) => {
|
|
109
|
+
const target = await forgeRepo(dir);
|
|
110
|
+
if ("refusal" in target)
|
|
111
|
+
return target.refusal;
|
|
112
|
+
const { owner, repo } = target;
|
|
113
|
+
const pr = await forge("repo_pr_view", `/repos/${owner}/${repo}/pulls/${number}`);
|
|
114
|
+
if (!pr.ok)
|
|
115
|
+
return bounded(pr.message);
|
|
116
|
+
const data = pr.data;
|
|
117
|
+
const files = await forge("repo_pr_view", `/repos/${owner}/${repo}/pulls/${number}/files?per_page=${FORGE_PAGE_SIZE}`);
|
|
118
|
+
const changed = (files.ok && Array.isArray(files.data) ? files.data : []);
|
|
119
|
+
const moreFiles = changed.length >= FORGE_PAGE_SIZE;
|
|
120
|
+
return bounded([
|
|
121
|
+
`#${number} ${data.title ?? "(no title)"}`,
|
|
122
|
+
`${data.merged ? "merged" : (data.state ?? "?")}` +
|
|
123
|
+
(data.draft ? " (draft)" : "") +
|
|
124
|
+
// `mergeable` is computed asynchronously by GitHub and is `null`
|
|
125
|
+
// until it has been, which is common on a pull request opened
|
|
126
|
+
// seconds ago — exactly when this tool is most likely to be called.
|
|
127
|
+
(data.mergeable === null
|
|
128
|
+
? ", mergeability not yet computed"
|
|
129
|
+
: data.mergeable === false
|
|
130
|
+
? ", NOT mergeable"
|
|
131
|
+
: ""),
|
|
132
|
+
`${data.head?.ref ?? "?"} → ${data.base?.ref ?? "?"}`,
|
|
133
|
+
data.html_url ?? "",
|
|
134
|
+
"",
|
|
135
|
+
changed.length
|
|
136
|
+
? changed
|
|
137
|
+
.map((f) => ` ${f.filename ?? "?"} (+${f.additions ?? 0} -${f.deletions ?? 0})`)
|
|
138
|
+
.join("\n")
|
|
139
|
+
: files.ok
|
|
140
|
+
? " (no files reported)"
|
|
141
|
+
: " (changed files could not be read)",
|
|
142
|
+
...(moreFiles
|
|
143
|
+
? [
|
|
144
|
+
`\n(showing the first ${FORGE_PAGE_SIZE} files; this pull request ` +
|
|
145
|
+
`touches more)`
|
|
146
|
+
]
|
|
147
|
+
: [])
|
|
148
|
+
].join("\n"));
|
|
149
|
+
}
|
|
150
|
+
}),
|
|
151
|
+
repo_pr_comment: tool({
|
|
152
|
+
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.",
|
|
153
|
+
inputSchema: z.object({
|
|
154
|
+
dir: z.string().describe("The checkout directory"),
|
|
155
|
+
number: z
|
|
156
|
+
.number()
|
|
157
|
+
.int()
|
|
158
|
+
.positive()
|
|
159
|
+
.describe("Pull request or issue number"),
|
|
160
|
+
body: z.string().describe("The comment, as markdown")
|
|
161
|
+
}),
|
|
162
|
+
execute: async ({ dir, number, body }) => {
|
|
163
|
+
const target = await forgeRepo(dir);
|
|
164
|
+
if ("refusal" in target)
|
|
165
|
+
return target.refusal;
|
|
166
|
+
const { owner, repo } = target;
|
|
167
|
+
const posted = await forge("repo_pr_comment", `/repos/${owner}/${repo}/issues/${number}/comments`, { method: "POST", body: { body } });
|
|
168
|
+
if (!posted.ok)
|
|
169
|
+
// The same hazard `repo_open_pr` names, for the same reason: a POST
|
|
170
|
+
// whose answer never arrived may still have been received, and a blind
|
|
171
|
+
// retry is how one comment becomes two.
|
|
172
|
+
return bounded(`${posted.message}\nIf this was a timeout rather than a rejection the ` +
|
|
173
|
+
`comment may exist anyway — read it back with repo_issue_view before retrying.`);
|
|
174
|
+
const data = posted.data;
|
|
175
|
+
return data.html_url ?? `commented on #${number}`;
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
};
|
|
179
|
+
}
|