make webhook

This commit is contained in:
2026-07-13 18:50:38 +02:00
parent 2f128550fc
commit 40c822d3bf
67 changed files with 6286 additions and 2053 deletions
@@ -0,0 +1,199 @@
import type { Job } from "../../../adapters/database/store.js";
import {
commitAndPush,
validateChangedFiles,
validateChangedFileTypes,
} from "../../../adapters/git/publication.js";
import {
candidateChangedFiles,
workspaceDiff,
} from "../../../adapters/git/repository/changes.js";
import { gitSafetyDigest } from "../../../adapters/git/repository/checkout.js";
import { GiteaHttpError } from "../../../adapters/gitea/client/client.js";
import {
createIssueSnapshot,
findAcceptedPlan,
} from "../../../adapters/gitea/issues.js";
import type { GiteaPullRequest } from "../../../adapters/gitea/types.js";
import {
generatedLabel,
marker,
parseMarker,
protocolVersion,
sha256,
} from "../../../core/contracts.js";
import {
type PublicationContext,
type PublicationOutcome,
upsertJobStatus,
} from "../status.js";
export async function publishImplementation(
context: PublicationContext,
job: Job,
): Promise<PublicationOutcome> {
const result = job.result;
const implementation = result?.implementation;
const workspace = job.workspace;
if (!implementation || !workspace)
throw new Error(
"Successful implementation job has no workspace result",
);
const [issue, comments, currentBase] = await Promise.all([
context.client.getIssue(job.issueNumber),
context.client.getComments(job.issueNumber),
context.client.getBranch(implementation.baseBranch),
]);
const snapshot = createIssueSnapshot(issue, comments, context.botLogin);
const accepted = findAcceptedPlan(
comments,
context.botLogin,
job.issueNumber,
);
if (snapshot.digest !== implementation.issueDigest)
throw new Error("Issue changed while implementing; result is stale");
if (accepted?.marker.planDigest !== implementation.planDigest)
throw new Error("Accepted plan changed while implementing");
if (currentBase?.commit.id !== implementation.baseSha)
throw new Error("Default branch changed while implementing");
if ((await gitSafetyDigest(workspace)) !== implementation.gitSafetyDigest)
throw new Error("Git metadata changed during execution");
const options = context.signal ? { signal: context.signal } : {};
const actualFiles = await candidateChangedFiles(
workspace,
implementation.baseSha,
options,
);
validateChangedFiles(actualFiles);
await validateChangedFileTypes(workspace, actualFiles);
if (
JSON.stringify(actualFiles) !==
JSON.stringify(implementation.changedFiles)
)
throw new Error("Changed files differ from review");
const actualDiff = actualFiles.length
? await workspaceDiff(workspace, implementation.baseSha, options)
: "(no changes)";
if (sha256(actualDiff) !== implementation.diffDigest)
throw new Error("Working-tree diff differs from review");
if (result.status === "no-changes") {
await upsertJobStatus(
context.client,
context.botLogin,
job,
"Agent implementation completed",
result.message,
);
return { terminal: "succeeded" };
}
if (context.isCancelled?.(job.id))
throw new Error("Publication cancelled before repository write");
const commitSha = await commitAndPush({
workspace,
files: actualFiles,
branch: implementation.branch,
token: context.writeToken,
pushUrl: `${context.serverUrl}/${context.repository.owner}/${context.repository.repo}.git`,
message: `feat: implement issue #${job.issueNumber}`,
expectedRemoteSha: implementation.startingRemoteSha,
baseSha: implementation.baseSha,
expectedDiffDigest: implementation.diffDigest,
...options,
});
if (context.isCancelled?.(job.id))
throw new Error("Publication cancelled before pull request write");
const pull = await upsertPullRequest(
context,
job,
implementation,
issue.title,
);
await context.client.addLabelIfPresent(pull.number, generatedLabel);
await upsertJobStatus(
context.client,
context.botLogin,
job,
"Agent implementation ready",
`[Pull request #${pull.number}](${pull.html_url}) was created or updated.`,
);
return { terminal: "succeeded", commitSha, pullRequestNumber: pull.number };
}
async function upsertPullRequest(
context: PublicationContext,
job: Job,
implementation: NonNullable<NonNullable<Job["result"]>["implementation"]>,
issueTitle: string,
): Promise<GiteaPullRequest> {
const title = `Implement #${job.issueNumber}: ${issueTitle}`;
const body = `${pullMarker(job.issueNumber, implementation.planDigest, implementation.branch)}\nCloses #${job.issueNumber}\n\n${implementation.summary}\n\nGenerated from accepted plan \`${implementation.planDigest.slice(0, 12)}\` after ${implementation.iterations} review iteration(s).`;
let pull = await context.client.getOpenPullRequestByBaseHead(
implementation.baseBranch,
implementation.branch,
);
if (
pull &&
!matchesPull(
pull,
job.issueNumber,
implementation.planDigest,
implementation.branch,
context.repositoryId,
)
) {
throw new Error(
`Branch ${implementation.branch} already has an unrecognized open pull request`,
);
}
if (pull)
return context.client.updatePullRequest(pull.number, {
title,
body,
base: implementation.baseBranch,
});
try {
return await context.client.createPullRequest({
head: implementation.branch,
base: implementation.baseBranch,
title,
body,
});
} catch (error) {
if (!(error instanceof GiteaHttpError && error.status === 409))
throw error;
pull = await context.client.getOpenPullRequestByBaseHead(
implementation.baseBranch,
implementation.branch,
);
if (!pull) throw error;
return pull;
}
}
function pullMarker(issue: number, planDigest: string, branch: string): string {
return marker({
v: protocolVersion,
kind: "pull-request",
issue,
planDigest,
branch,
});
}
function matchesPull(
pull: GiteaPullRequest,
issue: number,
digest: string,
branch: string,
repositoryId: number,
): boolean {
const found = parseMarker(pull.body, "pull-request");
const ref = pull.head.ref || pull.head.name;
return Boolean(
(pull.head.repo_id ?? pull.head.repo?.id) === repositoryId &&
(pull.base.repo_id ?? pull.base.repo?.id) === repositoryId &&
found?.issue === issue &&
found.planDigest === digest &&
(ref === branch || ref?.endsWith(`:${branch}`)),
);
}
@@ -0,0 +1,60 @@
import type { Job } from "../../../adapters/database/store.js";
import { createIssueSnapshot } from "../../../adapters/gitea/issues.js";
import {
marker,
planReadyLabel,
protocolVersion,
} from "../../../core/contracts.js";
import {
type PublicationContext,
type PublicationOutcome,
upsertJobStatus,
} from "../status.js";
export async function publishPlan(
context: PublicationContext,
job: Job,
): Promise<PublicationOutcome> {
const plan = job.result?.plan;
if (!plan) throw new Error("Successful planning job has no plan");
const [issue, comments, repository] = await Promise.all([
context.client.getIssue(job.issueNumber),
context.client.getComments(job.issueNumber),
context.client.getRepository(),
]);
const snapshot = createIssueSnapshot(issue, comments, context.botLogin);
const base = await context.client.getBranch(repository.default_branch);
if (
snapshot.digest !== plan.issueDigest ||
base?.commit.id !== plan.baseSha
) {
throw new Error(
"Issue or default branch changed while planning; the result is stale",
);
}
const planMarker = {
v: protocolVersion,
kind: "plan" as const,
issue: job.issueNumber,
status: "accepted",
issueDigest: plan.issueDigest,
baseSha: plan.baseSha,
planDigest: plan.planDigest,
};
const body = `${marker(planMarker)}\n## Accepted implementation plan\n\n${plan.markdown}\n\n<!-- olixero-ci-agent:plan-footer -->\n\n**Summary:** ${plan.summary}\n\nBase: \`${plan.baseSha.slice(0, 12)}\` | Review iterations: ${plan.iterations} | Plan digest: \`${plan.planDigest.slice(0, 12)}\``;
const comment = await context.client.upsertMarkedComment(
job.issueNumber,
context.botLogin,
planMarker,
body,
);
await context.client.addLabelIfPresent(job.issueNumber, planReadyLabel);
await upsertJobStatus(
context.client,
context.botLogin,
job,
"Agent plan accepted",
`The accepted plan was published after ${plan.iterations} review iteration(s).`,
);
return { terminal: "succeeded", planCommentId: comment.id };
}
@@ -0,0 +1,41 @@
import type { Job } from "../../adapters/database/store.js";
import { blockedLabel } from "../../core/contracts.js";
import { publishImplementation } from "./handlers/implementation.js";
import { publishPlan } from "./handlers/plan.js";
import {
type PublicationContext,
type PublicationOutcome,
upsertJobStatus,
} from "./status.js";
export async function publishJob(
context: PublicationContext,
job: Job,
): Promise<PublicationOutcome> {
const result = job.result;
if (!result) throw new Error("Publishing job has no result");
if (job.cancelRequested) {
await upsertJobStatus(
context.client,
context.botLogin,
job,
`Agent ${job.mode} cancelled`,
"The active request was cancelled.",
);
return { terminal: "cancelled" };
}
if (result.status === "failed") {
await context.client.addLabelIfPresent(job.issueNumber, blockedLabel);
await upsertJobStatus(
context.client,
context.botLogin,
job,
`Agent ${job.mode} failed`,
`Request \`${job.id.slice(0, 12)}\` failed. Inspect the redacted executor and controller logs.`,
);
return { terminal: "failed" };
}
return result.mode === "plan"
? publishPlan(context, job)
: publishImplementation(context, job);
}
+67
View File
@@ -0,0 +1,67 @@
import type { Job } from "../../adapters/database/store.js";
import type { GiteaClient } from "../../adapters/gitea/client/client.js";
import { renderStatus } from "../../adapters/gitea/issues.js";
import type { GiteaComment } from "../../adapters/gitea/types.js";
import type { RepositoryParts } from "../../core/config.js";
import { formatError, statusMarker } from "../../core/contracts.js";
export interface PublicationContext {
client: GiteaClient;
botLogin: string;
serverUrl: string;
repository: RepositoryParts;
repositoryId: number;
writeToken: string;
signal?: AbortSignal;
isCancelled?: (jobId: string) => boolean;
}
export interface PublicationOutcome {
terminal: "succeeded" | "failed" | "cancelled";
planCommentId?: number;
commitSha?: string;
pullRequestNumber?: number;
}
export async function upsertJobStatus(
client: GiteaClient,
botLogin: string,
job: Job,
heading: string,
detail: string,
): Promise<GiteaComment> {
const expected = statusMarker(job.issueNumber, job.mode);
expected.request = job.id;
return client.upsertMarkedComment(
job.issueNumber,
botLogin,
expected,
renderStatus({ marker: expected, heading, detail }),
);
}
export async function claimJob(
context: PublicationContext,
job: Job,
): Promise<void> {
await upsertJobStatus(
context.client,
context.botLogin,
job,
`Agent ${job.mode} queued`,
`Requested by @${job.actorLogin}. Request \`${job.id.slice(0, 12)}\` is durably queued.`,
);
if (!job.triggerLabel) return;
const issue = await context.client.getIssue(job.issueNumber);
const label = issue.labels.find(
(candidate) => candidate.name === job.triggerLabel,
);
if (label) await context.client.removeLabel(job.issueNumber, label.id);
}
export function safeFailure(value: unknown): string {
const message = formatError(value);
if (/token|authorization|credential|secret/i.test(message))
return "The operation failed. Inspect redacted service logs.";
return message.slice(0, 1_000);
}