Archived
make webhook
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user