Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54f704b660 |
@@ -10,39 +10,6 @@ export function openCodeArgs(model: string, prompt: string): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
|
||||
|
||||
export async function collectOpenCodeOutput(
|
||||
stdout: ReadableStream<Uint8Array>,
|
||||
stderr: ReadableStream<Uint8Array>,
|
||||
writeStdout: ChunkWriter,
|
||||
writeStderr: ChunkWriter,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const collect = async (
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
write: ChunkWriter,
|
||||
) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
await write(chunk);
|
||||
}
|
||||
const length = chunks.reduce((total, chunk) => total + chunk.length, 0);
|
||||
const joined = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
joined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return new TextDecoder().decode(joined);
|
||||
};
|
||||
const [output, error] = await Promise.all([
|
||||
collect(stdout, writeStdout),
|
||||
collect(stderr, writeStderr),
|
||||
]);
|
||||
return { stdout: output, stderr: error };
|
||||
}
|
||||
|
||||
export function parseOpenCodeEvents(output: string): string {
|
||||
let result: string | undefined;
|
||||
for (const line of output.split("\n")) {
|
||||
|
||||
+1
-37
@@ -1,9 +1,5 @@
|
||||
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
||||
import {
|
||||
collectOpenCodeOutput,
|
||||
openCodeArgs,
|
||||
parseOpenCodeEvents,
|
||||
} from "./opencode.ts";
|
||||
import { openCodeArgs, parseOpenCodeEvents } from "./opencode.ts";
|
||||
|
||||
Deno.test("builds a non-interactive auto-approved OpenCode command", () => {
|
||||
assertEquals(
|
||||
@@ -42,35 +38,3 @@ Deno.test("rejects a successful OpenCode run without visible text", async () =>
|
||||
"no text output",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("streams OpenCode output while collecting it", async () => {
|
||||
const encode = (value: string) => new TextEncoder().encode(value);
|
||||
const stdout = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encode("first\n"));
|
||||
controller.enqueue(encode("second\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const stderr = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encode("warning\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const shown: string[] = [];
|
||||
|
||||
const result = await collectOpenCodeOutput(
|
||||
stdout,
|
||||
stderr,
|
||||
(chunk: Uint8Array) => {
|
||||
shown.push(`out:${new TextDecoder().decode(chunk)}`);
|
||||
},
|
||||
(chunk: Uint8Array) => {
|
||||
shown.push(`err:${new TextDecoder().decode(chunk)}`);
|
||||
},
|
||||
);
|
||||
|
||||
assertEquals(result, { stdout: "first\nsecond\n", stderr: "warning\n" });
|
||||
assertEquals(shown, ["out:first\n", "err:warning\n", "out:second\n"]);
|
||||
});
|
||||
|
||||
+50
-57
@@ -26,9 +26,6 @@ const VERDICTS: Record<string, [event: string, mark: string]> = {
|
||||
Approved: ["APPROVED", "✅"],
|
||||
"Changes requested": ["REQUEST_CHANGES", "🛑"],
|
||||
};
|
||||
const FAILURE = "AI bot failed.\n\n> Profile: none · all profiles failed";
|
||||
|
||||
class GiteaRequestError extends Error {}
|
||||
|
||||
function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||
const entries: unknown = JSON.parse(text);
|
||||
@@ -131,9 +128,7 @@ export async function postRuntime(
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new GiteaRequestError(
|
||||
`${path}: ${response.status} ${await response.text()}`,
|
||||
);
|
||||
throw new Error(`${path}: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
};
|
||||
const postComment = (body: string) =>
|
||||
@@ -146,65 +141,63 @@ export async function postRuntime(
|
||||
try {
|
||||
outcome = await readOutcome(manifest.outcomeFile);
|
||||
} catch (error) {
|
||||
await postComment(FAILURE);
|
||||
await postComment(
|
||||
"AI bot failed.\n\n> Profile: none · all profiles failed",
|
||||
);
|
||||
throw new Error("agent phase failed", { cause: error });
|
||||
}
|
||||
if (!outcome.ok) {
|
||||
await postComment(FAILURE);
|
||||
await postComment(
|
||||
"AI bot failed.\n\n> Profile: none · all profiles failed",
|
||||
);
|
||||
throw new Error("agent phase failed");
|
||||
}
|
||||
|
||||
try {
|
||||
const review = await readAgentArtifact(
|
||||
manifest,
|
||||
outcome.reviewPath,
|
||||
"review.md",
|
||||
);
|
||||
if (review === null) {
|
||||
if (context.eventName === "pull_request") {
|
||||
throw new Error("agent did not write a pull request review");
|
||||
}
|
||||
await postComment(withProfile(outcome.body, outcome.profile));
|
||||
return;
|
||||
const review = await readAgentArtifact(
|
||||
manifest,
|
||||
outcome.reviewPath,
|
||||
"review.md",
|
||||
);
|
||||
if (review === null) {
|
||||
if (context.eventName === "pull_request") {
|
||||
throw new Error("agent did not write a pull request review");
|
||||
}
|
||||
|
||||
const [verdict, ...rest] = review.split("\n");
|
||||
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
|
||||
const anchorText = await readAgentArtifact(
|
||||
manifest,
|
||||
outcome.anchorsPath,
|
||||
"anchors.json",
|
||||
);
|
||||
const anchors = anchorText === null
|
||||
? []
|
||||
: parseAnchors(anchorText, outcome.anchorsPath);
|
||||
const postReview = (entries: Anchor[]) =>
|
||||
request(
|
||||
`repos/${context.repository}/pulls/${context.issueIndex}/reviews`,
|
||||
{
|
||||
body: stripAnsi(withProfile(
|
||||
[`${mark} ${verdict.trim()}`, ...rest].join("\n"),
|
||||
outcome.profile,
|
||||
)),
|
||||
event,
|
||||
comments: entries.map(({ path, line, side, body }) => ({
|
||||
path,
|
||||
body: stripAnsi(withProfile(body, outcome.profile)),
|
||||
new_position: side === "new" ? line : 0,
|
||||
old_position: side === "old" ? line : 0,
|
||||
})),
|
||||
},
|
||||
);
|
||||
await postReview(anchors).catch(async (error: Error) => {
|
||||
if (anchors.length === 0) throw error;
|
||||
console.error(`inline comments rejected: ${error.message}`);
|
||||
await postReview([]);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof GiteaRequestError) throw error;
|
||||
await postComment(FAILURE);
|
||||
throw error;
|
||||
await postComment(withProfile(outcome.body, outcome.profile));
|
||||
return;
|
||||
}
|
||||
|
||||
const [verdict, ...rest] = review.split("\n");
|
||||
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
|
||||
const anchorText = await readAgentArtifact(
|
||||
manifest,
|
||||
outcome.anchorsPath,
|
||||
"anchors.json",
|
||||
);
|
||||
const anchors = anchorText === null
|
||||
? []
|
||||
: parseAnchors(anchorText, outcome.anchorsPath);
|
||||
const postReview = (entries: Anchor[]) =>
|
||||
request(
|
||||
`repos/${context.repository}/pulls/${context.issueIndex}/reviews`,
|
||||
{
|
||||
body: stripAnsi(withProfile(
|
||||
[`${mark} ${verdict.trim()}`, ...rest].join("\n"),
|
||||
outcome.profile,
|
||||
)),
|
||||
event,
|
||||
comments: entries.map(({ path, line, side, body }) => ({
|
||||
path,
|
||||
body: stripAnsi(withProfile(body, outcome.profile)),
|
||||
new_position: side === "new" ? line : 0,
|
||||
old_position: side === "old" ? line : 0,
|
||||
})),
|
||||
},
|
||||
);
|
||||
await postReview(anchors).catch(async (error: Error) => {
|
||||
if (anchors.length === 0) throw error;
|
||||
console.error(`inline comments rejected: ${error.message}`);
|
||||
await postReview([]);
|
||||
});
|
||||
} finally {
|
||||
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(
|
||||
(error) => {
|
||||
|
||||
+1
-24
@@ -212,29 +212,6 @@ Deno.test("refuses to follow an agent artifact symlink", async () => {
|
||||
"regular private file",
|
||||
);
|
||||
|
||||
assertEquals(requests, 1);
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
Deno.test("reports an attributed failure when a pull request review is missing", async () => {
|
||||
const result = await fixture({ ok: true, body: "done" });
|
||||
const bodies: unknown[] = [];
|
||||
|
||||
await assertRejects(
|
||||
() =>
|
||||
postRuntime(result.manifestPath, {
|
||||
...context,
|
||||
eventName: "pull_request",
|
||||
}, (_url, init) => {
|
||||
bodies.push(JSON.parse(String(init?.body)));
|
||||
return Promise.resolve(new Response(null, { status: 201 }));
|
||||
}),
|
||||
Error,
|
||||
"did not write",
|
||||
);
|
||||
|
||||
assertEquals(bodies, [{
|
||||
body: "AI bot failed.\n\n> Profile: none · all profiles failed",
|
||||
}]);
|
||||
assertEquals(requests, 0);
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
+11
-25
@@ -19,11 +19,7 @@ import {
|
||||
sanitizedEnvironment,
|
||||
withProfile,
|
||||
} from "./profiles.ts";
|
||||
import {
|
||||
collectOpenCodeOutput,
|
||||
openCodeArgs,
|
||||
parseOpenCodeEvents,
|
||||
} from "./opencode.ts";
|
||||
import { openCodeArgs, parseOpenCodeEvents } from "./opencode.ts";
|
||||
import {
|
||||
readRuntimeManifest,
|
||||
type RuntimeOutcome,
|
||||
@@ -116,6 +112,7 @@ async function runAgentCommand(
|
||||
|
||||
async function prepareProfile(
|
||||
profile: RuntimeProfile,
|
||||
token: string | undefined,
|
||||
): Promise<void> {
|
||||
await runAgentCommand(profile, "git", [
|
||||
"config",
|
||||
@@ -123,7 +120,7 @@ async function prepareProfile(
|
||||
"--add",
|
||||
"safe.directory",
|
||||
WORKSPACE,
|
||||
]);
|
||||
], token);
|
||||
await runAgentCommand(profile, "npx", [
|
||||
"-y",
|
||||
...SKILLS_INSTALL.split(" "),
|
||||
@@ -131,7 +128,7 @@ async function prepareProfile(
|
||||
"--agent",
|
||||
profile.type === "claude" ? "claude-code" : profile.type,
|
||||
"--yes",
|
||||
]);
|
||||
], token);
|
||||
}
|
||||
|
||||
async function renderPrompt(
|
||||
@@ -368,7 +365,7 @@ async function runOpenCode(
|
||||
token: string,
|
||||
prompt: string,
|
||||
): Promise<string> {
|
||||
const process = agentCommand(
|
||||
const result = await agentCommand(
|
||||
profile,
|
||||
"opencode",
|
||||
openCodeArgs(profile.model, prompt),
|
||||
@@ -379,23 +376,12 @@ async function runOpenCode(
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
},
|
||||
).spawn();
|
||||
const output = collectOpenCodeOutput(
|
||||
process.stdout,
|
||||
process.stderr,
|
||||
async (chunk) => {
|
||||
await Deno.stdout.write(chunk);
|
||||
},
|
||||
async (chunk) => {
|
||||
await Deno.stderr.write(chunk);
|
||||
},
|
||||
);
|
||||
const [status, streams] = await Promise.all([process.status, output]);
|
||||
const error = streams.stderr.trim();
|
||||
if (!status.success) {
|
||||
throw new Error(error || `opencode exited with ${status.code}`);
|
||||
).output();
|
||||
const error = new TextDecoder().decode(result.stderr).trim();
|
||||
if (!result.success) {
|
||||
throw new Error(error || `opencode exited with ${result.code}`);
|
||||
}
|
||||
return parseOpenCodeEvents(streams.stdout);
|
||||
return parseOpenCodeEvents(new TextDecoder().decode(result.stdout));
|
||||
}
|
||||
|
||||
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
||||
@@ -417,7 +403,7 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
||||
profile.gid,
|
||||
);
|
||||
}
|
||||
await prepareProfile(profile);
|
||||
await prepareProfile(profile, token);
|
||||
const rendered = await renderPrompt(profile);
|
||||
reviewPath = rendered.reviewPath;
|
||||
anchorsPath = rendered.anchorsPath;
|
||||
|
||||
@@ -169,12 +169,6 @@ Deno.test("installs the OpenCode CLI", async () => {
|
||||
assert(action.includes("opencode-ai"));
|
||||
});
|
||||
|
||||
Deno.test("profile preparation does not receive provider credentials", async () => {
|
||||
const runner = await Deno.readTextFile("src/run.ts");
|
||||
assert(runner.includes("await prepareProfile(profile);"));
|
||||
assertEquals(runner.includes("prepareProfile(profile, token)"), false);
|
||||
});
|
||||
|
||||
Deno.test("central profiles fall back from Codex to Claude to OpenCode", async () => {
|
||||
const credentials = await yaml("profiles.yml");
|
||||
const profiles = sequence(credentials.profiles, "profiles");
|
||||
|
||||
Reference in New Issue
Block a user