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