feat: add conversational agent reviews

This commit is contained in:
2026-07-18 23:39:54 +02:00
parent 595005b1cf
commit 2a6a8f73dd
50 changed files with 4523 additions and 587 deletions
+40 -107
View File
@@ -1,31 +1,23 @@
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import type { AgentStore, Job } from "../../adapters/database/store.js";
import { checkoutTrustedRevision } from "../../adapters/git/repository/checkout.js";
import { checkoutPullRequestRevision } from "../../adapters/git/repository/pull.js";
import { GiteaClient } from "../../adapters/gitea/client/client.js";
import { findAcceptedPlan } from "../../adapters/gitea/issues.js";
import {
formatError,
log,
protocolVersion,
type Result,
} from "../../core/contracts.js";
import { runImplementation } from "./orchestration/implementation.js";
import { runPlan } from "./orchestration/plan.js";
import {
type JobExecutionInput,
runCheckedOutJob,
runPullReviewJob,
} from "./jobs/execute.js";
const leaseMs = 30_000;
export async function executeJob(input: {
store: AgentStore;
job: Job;
worker: string;
serverUrl: string;
repository: { owner: string; repo: string };
readToken: string;
botLogin: string;
workspaceRoot: string;
shutdown: AbortSignal;
}): Promise<void> {
export async function executeJob(input: JobExecutionInput): Promise<void> {
const controller = new AbortController();
const signal = AbortSignal.any([input.shutdown, controller.signal]);
const monitor = setInterval(() => {
@@ -58,6 +50,32 @@ export async function executeJob(input: {
const repository = await client.getRepository();
if (repository.id !== input.job.repositoryId)
throw new Error("Configured repository identity changed");
if (input.job.kind === "pull-review") {
const pull = await client.getPullRequest(input.job.targetNumber);
if (pull.state !== "open" || pull.base.repo_id !== repository.id)
throw new Error(
"Review requires an open pull request in this repository",
);
await checkoutPullRequestRevision({
workspace,
serverUrl: input.serverUrl,
baseRepository: repository.full_name,
pullRequestNumber: pull.number,
headSha: pull.head.sha,
mergeBaseSha: pull.merge_base,
readToken: input.readToken,
signal,
});
const output = await runPullReviewJob(
input,
client,
workspace,
pull,
signal,
);
finish(input, output.result);
return;
}
const base = await client.getBranch(repository.default_branch);
if (!base)
throw new Error(
@@ -71,11 +89,8 @@ export async function executeJob(input: {
readToken: input.readToken,
signal,
});
if (input.job.mode === "plan") {
await executePlan(input, client, workspace, signal);
return;
}
await executeImplementation(input, client, workspace, signal);
const output = await runCheckedOutJob(input, client, workspace, signal);
finish(input, output.result);
} catch (error) {
await handleFailure(input, signal, error);
} finally {
@@ -83,94 +98,12 @@ export async function executeJob(input: {
}
}
async function executePlan(
input: Parameters<typeof executeJob>[0],
client: GiteaClient,
workspace: string,
signal: AbortSignal,
): Promise<void> {
const conversation =
input.job.attempts === 1
? input.store.getConversation(
input.job.repositoryId,
input.job.issueNumber,
"planner",
"issue",
)
: undefined;
const output = await runPlan({
issueNumber: input.job.issueNumber,
botLogin: input.botLogin,
client,
workspace,
instruction: input.job.instruction,
signal,
...(conversation ? { existingSessionId: conversation.sessionId } : {}),
onSession: (sessionId) => {
if (input.job.attempts === 1)
input.store.saveConversation({
repositoryId: input.job.repositoryId,
issueNumber: input.job.issueNumber,
role: "planner",
scope: "issue",
sessionId,
});
},
});
input.store.finishExecution(input.job.id, input.worker, output.result);
logCompletion(input, output.result);
function finish(input: JobExecutionInput, result: Result): void {
input.store.finishExecution(input.job.id, input.worker, result);
logCompletion(input, result);
}
async function executeImplementation(
input: Parameters<typeof executeJob>[0],
client: GiteaClient,
workspace: string,
signal: AbortSignal,
): Promise<void> {
const accepted = findAcceptedPlan(
await client.getComments(input.job.issueNumber),
input.botLogin,
input.job.issueNumber,
);
const scope = accepted?.marker.planDigest || "missing-plan";
const conversation =
input.job.attempts === 1
? input.store.getConversation(
input.job.repositoryId,
input.job.issueNumber,
"implementer",
scope,
)
: undefined;
const output = await runImplementation({
issueNumber: input.job.issueNumber,
botLogin: input.botLogin,
client,
workspace,
readToken: input.readToken,
expectedPlanDigest: scope,
instruction: input.job.instruction,
signal,
...(conversation ? { existingSessionId: conversation.sessionId } : {}),
onSession: (sessionId) => {
if (input.job.attempts === 1)
input.store.saveConversation({
repositoryId: input.job.repositoryId,
issueNumber: input.job.issueNumber,
role: "implementer",
scope,
sessionId,
});
},
});
input.store.finishExecution(input.job.id, input.worker, output.result);
logCompletion(input, output.result);
}
function logCompletion(
input: Parameters<typeof executeJob>[0],
result: Result,
): void {
function logCompletion(input: JobExecutionInput, result: Result): void {
console.log(
log("info", "Execution completed", {
jobId: input.job.id,
@@ -181,7 +114,7 @@ function logCompletion(
}
async function handleFailure(
input: Parameters<typeof executeJob>[0],
input: JobExecutionInput,
signal: AbortSignal,
error: unknown,
): Promise<void> {