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
+84
View File
@@ -0,0 +1,84 @@
import { readFile } from "node:fs/promises";
import { requireEnv } from "./contracts.js";
export interface RepositoryParts {
owner: string;
repo: string;
}
export interface ActorPolicy {
ids: Set<number>;
logins: Set<string>;
}
export function repositoryParts(): RepositoryParts {
const repository = requireEnv("GITEA_REPOSITORY");
const [owner, repo, ...rest] = repository.split("/");
if (!owner || !repo || rest.length)
throw new Error(`Invalid GITEA_REPOSITORY: ${repository}`);
return { owner, repo };
}
export function validateServerUrl(value: string): string {
const url = new URL(value);
const insecureAllowed = process.env.CI_AGENT_ALLOW_INSECURE_HTTP === "true";
if (
url.protocol !== "https:" &&
!(insecureAllowed && url.protocol === "http:")
) {
throw new Error(
"GITEA_SERVER_URL must use HTTPS unless CI_AGENT_ALLOW_INSECURE_HTTP=true",
);
}
if (url.username || url.password || url.search || url.hash)
throw new Error(
"GITEA_SERVER_URL must not contain credentials, query, or fragment",
);
return value.replace(/\/$/, "");
}
export async function readSecret(name: string): Promise<string> {
const file = process.env[`${name}_FILE`]?.trim();
const value = file
? (await readFile(file, "utf8")).trim()
: process.env[name]?.trim();
if (!value)
throw new Error(`Required secret ${name} or ${name}_FILE is not set`);
return value;
}
export function actorPolicy(): ActorPolicy {
const ids = new Set<number>();
for (const value of (process.env.CI_AGENT_ALLOWED_ACTOR_IDS || "").split(
",",
)) {
const trimmed = value.trim();
if (!trimmed) continue;
const id = Number(trimmed);
if (!Number.isSafeInteger(id) || id <= 0)
throw new Error(`Invalid allowed actor ID: ${trimmed}`);
ids.add(id);
}
const logins = new Set(
(process.env.CI_AGENT_ALLOWED_ACTORS || "")
.split(",")
.map((value) => value.trim().toLowerCase())
.filter(Boolean),
);
if (!ids.size && !logins.size) {
throw new Error(
"Configure CI_AGENT_ALLOWED_ACTOR_IDS or CI_AGENT_ALLOWED_ACTORS; actor policy is fail-closed",
);
}
return { ids, logins };
}
export function actorAllowed(
policy: ActorPolicy,
actor: { id?: number; login?: string },
): boolean {
return Boolean(
(actor.id !== undefined && policy.ids.has(actor.id)) ||
(actor.login && policy.logins.has(actor.login.toLowerCase())),
);
}
+206
View File
@@ -0,0 +1,206 @@
import { createHash } from "node:crypto";
export const protocolVersion = 1;
export const planLabel = "agent:plan";
export const implementLabel = "agent:implement";
export const generatedLabel = "agent:generated";
export const blockedLabel = "agent:blocked";
export const planReadyLabel = "agent:plan-ready";
export type Mode = "plan" | "implement";
export interface PlanData {
issueDigest: string;
baseSha: string;
planDigest: string;
markdown: string;
summary: string;
iterations: number;
}
export interface ImplementationData {
issueDigest: string;
planDigest: string;
branch: string;
baseBranch: string;
baseSha: string;
startingRemoteSha: string | null;
gitSafetyDigest: string;
diffDigest: string;
changedFiles: string[];
summary: string;
iterations: number;
}
export interface Result {
version: number;
mode: Mode;
status: "success" | "no-changes" | "failed";
message: string;
plan?: PlanData;
implementation?: ImplementationData;
}
export interface Marker {
v: number;
kind: "status" | "plan" | "implementation" | "pull-request";
issue: number;
mode?: Mode;
request?: string;
status?: string;
issueDigest?: string;
baseSha?: string;
planDigest?: string;
branch?: string;
}
export interface IssueSnapshot {
digest: string;
title: string;
body: string;
comments: Array<{
id: number;
author: string;
createdAt: string;
body: string;
}>;
}
export interface PlanDraft {
planMarkdown: string;
summary: string;
}
export interface ReviewDecision {
verdict: "accept" | "revise";
findings: string[];
rationale: string;
}
export interface ImplementationSummary {
summary: string;
files: string[];
}
export function requireEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value)
throw new Error(`Required environment variable ${name} is not set`);
return value;
}
export function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
export function marker(value: Marker): string {
return `<!-- olixero-ci-agent:${JSON.stringify(value)} -->`;
}
export function parseMarker(
body: string,
kind?: Marker["kind"],
): Marker | undefined {
const match = body.match(/<!-- olixero-ci-agent:(\{[^\n]*\}) -->/);
if (!match?.[1]) return undefined;
try {
const value = JSON.parse(match[1]) as Marker;
if (
value.v !== protocolVersion ||
!value.kind ||
!Number.isInteger(value.issue)
)
return undefined;
if (kind && value.kind !== kind) return undefined;
return value;
} catch {
return undefined;
}
}
export function statusMarker(issue: number, mode: Mode): Marker {
return { v: protocolVersion, kind: "status", issue, mode };
}
export function formatError(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
export function assertPlanDraft(value: unknown): PlanDraft {
const draft = value as Partial<PlanDraft>;
if (
!draft ||
typeof draft.planMarkdown !== "string" ||
draft.planMarkdown.trim().length < 40
) {
throw new Error("Planner returned an invalid or empty plan");
}
if (draft.planMarkdown.length > 200_000)
throw new Error("Planner returned an oversized plan");
if (typeof draft.summary !== "string" || !draft.summary.trim()) {
throw new Error("Planner returned no summary");
}
if (draft.summary.length > 20_000)
throw new Error("Planner returned an oversized summary");
return {
planMarkdown: draft.planMarkdown.trim(),
summary: draft.summary.trim(),
};
}
export function assertReviewDecision(value: unknown): ReviewDecision {
const review = value as Partial<ReviewDecision>;
if (review?.verdict !== "accept" && review?.verdict !== "revise") {
throw new Error("Reviewer returned an invalid verdict");
}
if (
!Array.isArray(review.findings) ||
!review.findings.every((item) => typeof item === "string")
) {
throw new Error("Reviewer returned invalid findings");
}
if (
review.findings.length > 100 ||
review.findings.some((item) => item.length > 10_000)
) {
throw new Error("Reviewer returned oversized findings");
}
if (typeof review.rationale !== "string")
throw new Error("Reviewer returned no rationale");
if (review.rationale.length > 20_000)
throw new Error("Reviewer returned an oversized rationale");
return {
verdict: review.verdict,
findings: review.findings.map((item) => item.trim()).filter(Boolean),
rationale: review.rationale.trim(),
};
}
export function assertImplementationSummary(
value: unknown,
): ImplementationSummary {
const summary = value as Partial<ImplementationSummary>;
if (
!summary ||
typeof summary.summary !== "string" ||
!summary.summary.trim()
) {
throw new Error("Implementation agent returned no summary");
}
if (summary.summary.length > 20_000)
throw new Error("Implementation agent returned an oversized summary");
if (
!Array.isArray(summary.files) ||
!summary.files.every((item) => typeof item === "string")
) {
throw new Error("Implementation agent returned an invalid file list");
}
if (
summary.files.length > 100 ||
summary.files.some((item) => item.length > 1_000)
) {
throw new Error("Implementation agent returned an oversized file list");
}
return { summary: summary.summary.trim(), files: summary.files };
}
+168
View File
@@ -0,0 +1,168 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { Mode } from "./contracts.js";
export interface WebhookUser {
id: number;
login: string;
}
export interface WebhookRepository {
id: number;
full_name: string;
}
export interface WebhookIssue {
id: number;
number: number;
state: string;
pull_request?: unknown;
labels?: Array<{ id: number; name: string }>;
}
export interface LabelPayload {
action: "label_updated" | "label_cleared";
issue: WebhookIssue;
repository: WebhookRepository;
sender: WebhookUser;
}
export interface CommentPayload {
action: "created";
issue: WebhookIssue;
comment: { id: number; body: string; user: WebhookUser };
repository: WebhookRepository;
sender: WebhookUser;
is_pull: boolean;
}
export type AgentCommand =
| { action: "plan" | "implement"; mode: Mode; instruction: string }
| { action: "continue" | "retry"; instruction: string }
| { action: "cancel" | "status"; instruction: "" };
export function verifyGiteaSignature(
body: Buffer,
signature: string | undefined,
secret: string,
): boolean {
if (!signature || !/^[0-9a-f]{64}$/.test(signature)) return false;
const supplied = Buffer.from(signature, "hex");
const expected = createHmac("sha256", secret).update(body).digest();
return (
supplied.length === expected.length &&
timingSafeEqual(supplied, expected)
);
}
export function parseAgentCommand(body: string): AgentCommand | undefined {
const match = body.match(
/^\s*\/agent(?:\s+(plan|implement|continue|retry|cancel|status))?(?:[ \t]+([^\n]*))?(?:\n([\s\S]*))?\s*$/i,
);
if (!match?.[1]) return undefined;
const action = match[1].toLowerCase() as AgentCommand["action"];
const instruction = [match[2], match[3]].filter(Boolean).join("\n").trim();
if (action === "cancel" || action === "status") {
if (instruction) return undefined;
return { action, instruction: "" };
}
if (action === "plan" || action === "implement") {
return { action, mode: action, instruction };
}
return { action, instruction };
}
export function parseLabelPayload(value: unknown): LabelPayload {
const payload = asObject(value, "payload");
const action = payload.action;
if (action !== "label_updated" && action !== "label_cleared")
throw new Error("Unsupported issue label action");
return {
action,
issue: parseIssue(payload.issue),
repository: parseRepository(payload.repository),
sender: parseUser(payload.sender, "sender"),
};
}
export function parseCommentPayload(value: unknown): CommentPayload {
const payload = asObject(value, "payload");
if (payload.action !== "created")
throw new Error(
"Only newly created issue comments can contain agent commands",
);
const comment = asObject(payload.comment, "comment");
return {
action: "created",
issue: parseIssue(payload.issue),
comment: {
id: positiveInteger(comment.id, "comment.id"),
body: stringValue(comment.body, "comment.body"),
user: parseUser(comment.user, "comment.user"),
},
repository: parseRepository(payload.repository),
sender: parseUser(payload.sender, "sender"),
is_pull: payload.is_pull === true,
};
}
function parseIssue(value: unknown): WebhookIssue {
const issue = asObject(value, "issue");
const labels =
issue.labels === undefined
? undefined
: arrayValue(issue.labels, "issue.labels").map((value) => {
const label = asObject(value, "issue label");
return {
id: positiveInteger(label.id, "label.id"),
name: stringValue(label.name, "label.name"),
};
});
return {
id: positiveInteger(issue.id, "issue.id"),
number: positiveInteger(issue.number, "issue.number"),
state: stringValue(issue.state, "issue.state"),
...(issue.pull_request === undefined || issue.pull_request === null
? {}
: { pull_request: issue.pull_request }),
...(labels === undefined ? {} : { labels }),
};
}
function parseRepository(value: unknown): WebhookRepository {
const repository = asObject(value, "repository");
return {
id: positiveInteger(repository.id, "repository.id"),
full_name: stringValue(repository.full_name, "repository.full_name"),
};
}
function parseUser(value: unknown, name: string): WebhookUser {
const user = asObject(value, name);
const login = typeof user.login === "string" ? user.login : user.username;
return {
id: positiveInteger(user.id, `${name}.id`),
login: stringValue(login, `${name}.login`),
};
}
function asObject(value: unknown, name: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error(`${name} must be an object`);
return value as Record<string, unknown>;
}
function arrayValue(value: unknown, name: string): unknown[] {
if (!Array.isArray(value)) throw new Error(`${name} must be an array`);
return value;
}
function positiveInteger(value: unknown, name: string): number {
if (!Number.isSafeInteger(value) || Number(value) <= 0)
throw new Error(`${name} must be a positive integer`);
return Number(value);
}
function stringValue(value: unknown, name: string): string {
if (typeof value !== "string") throw new Error(`${name} must be a string`);
return value;
}