Archived
feat: add conversational agent reviews
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
type CommandAction,
|
||||
type CommandAdmission,
|
||||
type CommandAdmissionRequest,
|
||||
type CommandReceipt,
|
||||
type DatabaseContext,
|
||||
inferCommandAction,
|
||||
type Job,
|
||||
mapCommandReceipt,
|
||||
type NewJobInput,
|
||||
normalizeNewJob,
|
||||
now,
|
||||
type Row,
|
||||
} from "../model.js";
|
||||
import type { JobRepository } from "../repositories/jobs.js";
|
||||
export class ReceiptRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly jobs: JobRepository,
|
||||
) {}
|
||||
|
||||
createCommand(input: NewJobInput): { job: Job; created: boolean } {
|
||||
if (input.triggerKind !== "command")
|
||||
throw new Error("Command job requires command triggerKind");
|
||||
const normalized = normalizeNewJob(input);
|
||||
return this.context.transaction(() => {
|
||||
const receipt = this.getCommand(normalized.triggerKey);
|
||||
if (receipt) {
|
||||
const job = receipt.targetJobId
|
||||
? this.jobs.get(receipt.targetJobId)
|
||||
: undefined;
|
||||
if (!job)
|
||||
throw new Error(
|
||||
`Command ${normalized.triggerKey} was already rejected`,
|
||||
);
|
||||
return { job, created: false };
|
||||
}
|
||||
const existing = this.jobs.byTriggerKey(normalized.triggerKey);
|
||||
if (existing) {
|
||||
this.recordCommand(
|
||||
normalized.triggerKey,
|
||||
inferCommandAction(normalized.triggerKey, normalized.kind),
|
||||
normalized.repositoryId,
|
||||
normalized.issueNumber,
|
||||
"bound",
|
||||
existing.id,
|
||||
"",
|
||||
);
|
||||
return { job: existing, created: false };
|
||||
}
|
||||
const job = this.jobs.insert(normalized);
|
||||
this.recordCommand(
|
||||
normalized.triggerKey,
|
||||
inferCommandAction(normalized.triggerKey, normalized.kind),
|
||||
normalized.repositoryId,
|
||||
normalized.issueNumber,
|
||||
"bound",
|
||||
job.id,
|
||||
"",
|
||||
);
|
||||
return { job, created: true };
|
||||
});
|
||||
}
|
||||
|
||||
admitCommand(request: CommandAdmissionRequest): CommandAdmission {
|
||||
return this.context.transaction(() => {
|
||||
const existing = this.getCommand(request.triggerKey);
|
||||
if (existing) return this.admission(existing, true);
|
||||
if (request.rejection !== undefined) {
|
||||
const receipt = this.recordCommand(
|
||||
request.triggerKey,
|
||||
request.action,
|
||||
request.repositoryId,
|
||||
request.issueNumber,
|
||||
"rejected",
|
||||
null,
|
||||
request.rejection || "rejected",
|
||||
);
|
||||
return { receipt, replayed: false };
|
||||
}
|
||||
if (request.action === "cancel" || request.action === "status")
|
||||
return this.admitControl(request);
|
||||
if (!request.job)
|
||||
throw new Error("Job command admission requires job details");
|
||||
if (this.jobs.active(request.repositoryId, request.issueNumber)) {
|
||||
const receipt = this.recordCommand(
|
||||
request.triggerKey,
|
||||
request.action,
|
||||
request.repositoryId,
|
||||
request.issueNumber,
|
||||
"rejected",
|
||||
null,
|
||||
"active-job",
|
||||
);
|
||||
return { receipt, replayed: false };
|
||||
}
|
||||
const job = this.jobs.insert({
|
||||
...request.job,
|
||||
repositoryId: request.repositoryId,
|
||||
issueNumber: request.issueNumber,
|
||||
triggerKind: "command",
|
||||
triggerKey: request.triggerKey,
|
||||
});
|
||||
const receipt = this.recordCommand(
|
||||
request.triggerKey,
|
||||
request.action,
|
||||
request.repositoryId,
|
||||
request.issueNumber,
|
||||
"bound",
|
||||
job.id,
|
||||
"",
|
||||
);
|
||||
return { receipt, job, replayed: false };
|
||||
});
|
||||
}
|
||||
|
||||
control(
|
||||
triggerKey: string,
|
||||
action: "cancel" | "status",
|
||||
repositoryId: number,
|
||||
issueNumber: number,
|
||||
): Job | undefined {
|
||||
return this.admitCommand({
|
||||
triggerKey,
|
||||
action,
|
||||
repositoryId,
|
||||
issueNumber,
|
||||
}).job;
|
||||
}
|
||||
|
||||
private admitControl(request: CommandAdmissionRequest): CommandAdmission {
|
||||
const job =
|
||||
request.action === "cancel"
|
||||
? request.targetNumber === undefined
|
||||
? this.jobs.active(
|
||||
request.repositoryId,
|
||||
request.issueNumber,
|
||||
)
|
||||
: this.jobs.activeTarget(
|
||||
request.repositoryId,
|
||||
request.targetNumber,
|
||||
)
|
||||
: request.targetNumber === undefined
|
||||
? this.jobs.latest(request.repositoryId, request.issueNumber)
|
||||
: this.jobs.latestTarget(
|
||||
request.repositoryId,
|
||||
request.targetNumber,
|
||||
);
|
||||
const outcome = job ? "bound" : "rejected";
|
||||
const receipt = this.recordCommand(
|
||||
request.triggerKey,
|
||||
request.action,
|
||||
request.repositoryId,
|
||||
request.issueNumber,
|
||||
outcome,
|
||||
job?.id || null,
|
||||
job ? "" : "no-target",
|
||||
);
|
||||
if (request.action === "cancel" && job) {
|
||||
this.context.db
|
||||
.prepare(`
|
||||
UPDATE jobs SET cancel_requested = 1,
|
||||
state = CASE WHEN state IN ('admitted', 'queued') THEN 'cancelled' ELSE state END,
|
||||
updated_at = $now WHERE id = $id
|
||||
`)
|
||||
.run({ $id: job.id, $now: now() });
|
||||
this.context.audit(
|
||||
job.id,
|
||||
"job.cancel-requested",
|
||||
request.triggerKey,
|
||||
);
|
||||
const cancelled = this.jobs.get(job.id);
|
||||
if (!cancelled)
|
||||
throw new Error(`Cancelled job ${job.id} was not found`);
|
||||
return { receipt, job: cancelled, replayed: false };
|
||||
}
|
||||
return { receipt, ...(job ? { job } : {}), replayed: false };
|
||||
}
|
||||
|
||||
private admission(
|
||||
receipt: CommandReceipt,
|
||||
replayed: boolean,
|
||||
): CommandAdmission {
|
||||
const job = receipt.targetJobId
|
||||
? this.jobs.get(receipt.targetJobId)
|
||||
: undefined;
|
||||
return { receipt, ...(job ? { job } : {}), replayed };
|
||||
}
|
||||
|
||||
private getCommand(triggerKey: string): CommandReceipt | undefined {
|
||||
const row = this.context.db
|
||||
.prepare(
|
||||
"SELECT * FROM command_receipts WHERE trigger_key = $triggerKey",
|
||||
)
|
||||
.get({ $triggerKey: triggerKey }) as Row | undefined;
|
||||
return row ? mapCommandReceipt(row) : undefined;
|
||||
}
|
||||
|
||||
private recordCommand(
|
||||
triggerKey: string,
|
||||
action: CommandAction,
|
||||
repositoryId: number,
|
||||
issueNumber: number,
|
||||
outcome: CommandReceipt["outcome"],
|
||||
targetJobId: string | null,
|
||||
reason: string,
|
||||
): CommandReceipt {
|
||||
const createdAt = now();
|
||||
this.context.db
|
||||
.prepare(`
|
||||
INSERT INTO command_receipts
|
||||
(trigger_key, action, repository_id, issue_number, outcome, target_job_id, reason, created_at)
|
||||
VALUES ($triggerKey, $action, $repositoryId, $issueNumber, $outcome, $targetJobId, $reason, $now)
|
||||
`)
|
||||
.run({
|
||||
$triggerKey: triggerKey,
|
||||
$action: action,
|
||||
$repositoryId: repositoryId,
|
||||
$issueNumber: issueNumber,
|
||||
$outcome: outcome,
|
||||
$targetJobId: targetJobId,
|
||||
$reason: reason,
|
||||
$now: createdAt,
|
||||
});
|
||||
return {
|
||||
triggerKey,
|
||||
action,
|
||||
repositoryId,
|
||||
issueNumber,
|
||||
outcome,
|
||||
...(targetJobId ? { targetJobId } : {}),
|
||||
reason,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { type DatabaseContext, type Job, now } from "../model.js";
|
||||
import {
|
||||
type DatabaseContext,
|
||||
type Job,
|
||||
now,
|
||||
type ReviewFeedbackRef,
|
||||
} from "../model.js";
|
||||
|
||||
export class ArtifactRepository {
|
||||
constructor(private readonly context: DatabaseContext) {}
|
||||
@@ -44,4 +49,65 @@ export class ArtifactRepository {
|
||||
$now: now(),
|
||||
});
|
||||
}
|
||||
|
||||
isPublishedImplementation(
|
||||
repositoryId: number,
|
||||
issueNumber: number,
|
||||
pullRequestNumber: number,
|
||||
planDigest: string,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
this.context.db
|
||||
.prepare(`
|
||||
SELECT 1 AS published FROM implementations
|
||||
WHERE repository_id = $repositoryId
|
||||
AND issue_number = $issueNumber
|
||||
AND pull_request_number = $pullRequestNumber
|
||||
AND plan_digest = $planDigest
|
||||
LIMIT 1
|
||||
`)
|
||||
.get({
|
||||
$repositoryId: repositoryId,
|
||||
$issueNumber: issueNumber,
|
||||
$pullRequestNumber: pullRequestNumber,
|
||||
$planDigest: planDigest,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
isReviewFeedbackProcessed(ref: ReviewFeedbackRef): boolean {
|
||||
return Boolean(
|
||||
this.context.db
|
||||
.prepare(`
|
||||
SELECT 1 AS processed FROM review_feedback_receipts
|
||||
WHERE repository_id = $repositoryId AND pull_request_number = $pullRequestNumber
|
||||
AND feedback_kind = $kind AND object_id = $objectId
|
||||
AND content_digest = $contentDigest
|
||||
`)
|
||||
.get(feedbackParameters(ref)),
|
||||
);
|
||||
}
|
||||
|
||||
markReviewFeedbackProcessed(ref: ReviewFeedbackRef): boolean {
|
||||
const result = this.context.db
|
||||
.prepare(`
|
||||
INSERT OR IGNORE INTO review_feedback_receipts
|
||||
(repository_id, pull_request_number, feedback_kind, object_id, content_digest, processed_at)
|
||||
VALUES ($repositoryId, $pullRequestNumber, $kind, $objectId, $contentDigest, $now)
|
||||
`)
|
||||
.run({ ...feedbackParameters(ref), $now: now() });
|
||||
return Number(result.changes) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
function feedbackParameters(
|
||||
ref: ReviewFeedbackRef,
|
||||
): Record<string, string | number> {
|
||||
return {
|
||||
$repositoryId: ref.repositoryId,
|
||||
$pullRequestNumber: ref.pullRequestNumber,
|
||||
$kind: ref.kind,
|
||||
$objectId: ref.objectId,
|
||||
$contentDigest: ref.contentDigest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,22 @@ export type JobState =
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
export type TriggerKind = "label" | "command";
|
||||
export type JobKind =
|
||||
| "plan"
|
||||
| "plan-discuss"
|
||||
| "plan-review"
|
||||
| "implement"
|
||||
| "implementation-fix"
|
||||
| "pull-review";
|
||||
export type CommandAction =
|
||||
| JobKind
|
||||
| "discuss"
|
||||
| "fix"
|
||||
| "review"
|
||||
| "continue"
|
||||
| "retry"
|
||||
| "cancel"
|
||||
| "status";
|
||||
export type Row = Record<string, string | number | bigint | null>;
|
||||
|
||||
export interface DatabaseContext {
|
||||
@@ -22,6 +38,9 @@ export interface Job {
|
||||
id: string;
|
||||
repositoryId: number;
|
||||
issueNumber: number;
|
||||
kind: JobKind;
|
||||
targetNumber: number;
|
||||
scope: string;
|
||||
mode: Mode;
|
||||
triggerKind: TriggerKind;
|
||||
triggerKey: string;
|
||||
@@ -44,6 +63,9 @@ export interface Job {
|
||||
export interface NewJob {
|
||||
repositoryId: number;
|
||||
issueNumber: number;
|
||||
kind: JobKind;
|
||||
targetNumber: number;
|
||||
scope: string;
|
||||
mode: Mode;
|
||||
triggerKind: TriggerKind;
|
||||
triggerKey: string;
|
||||
@@ -53,6 +75,49 @@ export interface NewJob {
|
||||
instruction?: string;
|
||||
}
|
||||
|
||||
export type NewJobInput = Omit<NewJob, "kind" | "targetNumber" | "scope"> &
|
||||
Partial<Pick<NewJob, "kind" | "targetNumber" | "scope">>;
|
||||
|
||||
export interface CommandReceipt {
|
||||
triggerKey: string;
|
||||
action: CommandAction;
|
||||
repositoryId: number;
|
||||
issueNumber: number;
|
||||
outcome: "bound" | "rejected";
|
||||
targetJobId?: string;
|
||||
reason: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface CommandAdmissionRequest {
|
||||
triggerKey: string;
|
||||
action: CommandAction;
|
||||
repositoryId: number;
|
||||
issueNumber: number;
|
||||
targetNumber?: number;
|
||||
job?: Omit<
|
||||
NewJobInput,
|
||||
"repositoryId" | "issueNumber" | "triggerKind" | "triggerKey"
|
||||
>;
|
||||
rejection?: string;
|
||||
}
|
||||
|
||||
export interface CommandAdmission {
|
||||
receipt: CommandReceipt;
|
||||
job?: Job;
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
export type ReviewFeedbackKind = "comment" | "review" | "review-comment";
|
||||
|
||||
export interface ReviewFeedbackRef {
|
||||
repositoryId: number;
|
||||
pullRequestNumber: number;
|
||||
kind: ReviewFeedbackKind;
|
||||
objectId: number;
|
||||
contentDigest: string;
|
||||
}
|
||||
|
||||
export interface Delivery {
|
||||
id: string;
|
||||
event: string;
|
||||
@@ -87,6 +152,9 @@ export function mapJob(row: Row): Job {
|
||||
id: String(row.id),
|
||||
repositoryId: Number(row.repository_id),
|
||||
issueNumber: Number(row.issue_number),
|
||||
kind: String(row.kind) as JobKind,
|
||||
targetNumber: Number(row.target_number),
|
||||
scope: String(row.scope),
|
||||
mode: String(row.mode) as Mode,
|
||||
triggerKind: String(row.trigger_kind) as TriggerKind,
|
||||
triggerKey: String(row.trigger_key),
|
||||
@@ -113,6 +181,43 @@ export function mapJob(row: Row): Job {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeNewJob(input: NewJobInput): NewJob {
|
||||
return {
|
||||
...input,
|
||||
kind: input.kind || input.mode,
|
||||
targetNumber: input.targetNumber ?? input.issueNumber,
|
||||
scope: input.scope || "",
|
||||
};
|
||||
}
|
||||
|
||||
export function inferCommandAction(
|
||||
triggerKey: string,
|
||||
fallback: CommandAction,
|
||||
): CommandAction {
|
||||
const action = triggerKey.slice(triggerKey.lastIndexOf(":") + 1);
|
||||
return action === "continue" ||
|
||||
action === "retry" ||
|
||||
action === "plan" ||
|
||||
action === "implement"
|
||||
? action
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function mapCommandReceipt(row: Row): CommandReceipt {
|
||||
return {
|
||||
triggerKey: String(row.trigger_key),
|
||||
action: String(row.action) as CommandAction,
|
||||
repositoryId: Number(row.repository_id),
|
||||
issueNumber: Number(row.issue_number),
|
||||
outcome: String(row.outcome) as CommandReceipt["outcome"],
|
||||
...(row.target_job_id === null
|
||||
? {}
|
||||
: { targetJobId: String(row.target_job_id) }),
|
||||
reason: String(row.reason),
|
||||
createdAt: Number(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapConversation(row: Row): Conversation {
|
||||
return {
|
||||
repositoryId: Number(row.repository_id),
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
type DatabaseContext,
|
||||
type Job,
|
||||
mapJob,
|
||||
type NewJob,
|
||||
type NewJobInput,
|
||||
normalizeNewJob,
|
||||
now,
|
||||
type Row,
|
||||
} from "../model.js";
|
||||
@@ -11,44 +12,37 @@ import {
|
||||
export class JobRepository {
|
||||
constructor(private readonly context: DatabaseContext) {}
|
||||
|
||||
createCommand(input: NewJob): { job: Job; created: boolean } {
|
||||
return this.context.transaction(() => {
|
||||
const existing = this.byTriggerKey(input.triggerKey);
|
||||
if (existing) return { job: existing, created: false };
|
||||
return { job: this.insert(input), created: true };
|
||||
});
|
||||
}
|
||||
|
||||
createLabel(input: NewJob): Job | undefined {
|
||||
createLabel(input: NewJobInput): Job | undefined {
|
||||
if (!input.triggerLabel)
|
||||
throw new Error("Label job requires triggerLabel");
|
||||
const label = input.triggerLabel;
|
||||
const normalized = normalizeNewJob(input);
|
||||
return this.context.transaction(() => {
|
||||
const claim = this.context.db
|
||||
.prepare(`
|
||||
SELECT claimed FROM label_claims
|
||||
WHERE repository_id = $repositoryId AND issue_number = $issueNumber AND label = $label
|
||||
WHERE repository_id = $repositoryId AND target_number = $targetNumber AND label = $label
|
||||
`)
|
||||
.get({
|
||||
$repositoryId: input.repositoryId,
|
||||
$issueNumber: input.issueNumber,
|
||||
$repositoryId: normalized.repositoryId,
|
||||
$targetNumber: normalized.targetNumber,
|
||||
$label: label,
|
||||
}) as Row | undefined;
|
||||
if (Number(claim?.claimed || 0) === 1) return undefined;
|
||||
const job = this.insert({
|
||||
...input,
|
||||
triggerKey: `label:${input.repositoryId}:${input.issueNumber}:${label}:${randomUUID()}`,
|
||||
...normalized,
|
||||
triggerKey: `label:${normalized.repositoryId}:${normalized.targetNumber}:${label}:${randomUUID()}`,
|
||||
});
|
||||
this.context.db
|
||||
.prepare(`
|
||||
INSERT INTO label_claims(repository_id, issue_number, label, claimed, job_id, updated_at)
|
||||
VALUES ($repositoryId, $issueNumber, $label, 1, $jobId, $now)
|
||||
ON CONFLICT(repository_id, issue_number, label)
|
||||
INSERT INTO label_claims(repository_id, target_number, label, claimed, job_id, updated_at)
|
||||
VALUES ($repositoryId, $targetNumber, $label, 1, $jobId, $now)
|
||||
ON CONFLICT(repository_id, target_number, label)
|
||||
DO UPDATE SET claimed = 1, job_id = excluded.job_id, updated_at = excluded.updated_at
|
||||
`)
|
||||
.run({
|
||||
$repositoryId: input.repositoryId,
|
||||
$issueNumber: input.issueNumber,
|
||||
$repositoryId: normalized.repositoryId,
|
||||
$targetNumber: normalized.targetNumber,
|
||||
$label: label,
|
||||
$jobId: job.id,
|
||||
$now: now(),
|
||||
@@ -59,19 +53,19 @@ export class JobRepository {
|
||||
|
||||
releaseLabel(
|
||||
repositoryId: number,
|
||||
issueNumber: number,
|
||||
targetNumber: number,
|
||||
label: string,
|
||||
): void {
|
||||
this.context.db
|
||||
.prepare(`
|
||||
INSERT INTO label_claims(repository_id, issue_number, label, claimed, job_id, updated_at)
|
||||
VALUES ($repositoryId, $issueNumber, $label, 0, NULL, $now)
|
||||
ON CONFLICT(repository_id, issue_number, label)
|
||||
INSERT INTO label_claims(repository_id, target_number, label, claimed, job_id, updated_at)
|
||||
VALUES ($repositoryId, $targetNumber, $label, 0, NULL, $now)
|
||||
ON CONFLICT(repository_id, target_number, label)
|
||||
DO UPDATE SET claimed = 0, job_id = NULL, updated_at = excluded.updated_at
|
||||
`)
|
||||
.run({
|
||||
$repositoryId: repositoryId,
|
||||
$issueNumber: issueNumber,
|
||||
$targetNumber: targetNumber,
|
||||
$label: label,
|
||||
$now: now(),
|
||||
});
|
||||
@@ -83,7 +77,7 @@ export class JobRepository {
|
||||
SELECT * FROM jobs
|
||||
WHERE repository_id = $repositoryId AND issue_number = $issueNumber
|
||||
AND state IN ('admitted', 'queued', 'running', 'publishing')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
ORDER BY created_at DESC, id DESC LIMIT 1
|
||||
`)
|
||||
.get({ $repositoryId: repositoryId, $issueNumber: issueNumber }) as
|
||||
| Row
|
||||
@@ -95,7 +89,7 @@ export class JobRepository {
|
||||
const row = this.context.db
|
||||
.prepare(`
|
||||
SELECT * FROM jobs WHERE repository_id = $repositoryId AND issue_number = $issueNumber
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
ORDER BY created_at DESC, id DESC LIMIT 1
|
||||
`)
|
||||
.get({ $repositoryId: repositoryId, $issueNumber: issueNumber }) as
|
||||
| Row
|
||||
@@ -103,56 +97,12 @@ export class JobRepository {
|
||||
return row ? mapJob(row) : undefined;
|
||||
}
|
||||
|
||||
control(
|
||||
triggerKey: string,
|
||||
action: "cancel" | "status",
|
||||
repositoryId: number,
|
||||
issueNumber: number,
|
||||
): Job | undefined {
|
||||
return this.context.transaction(() => {
|
||||
const existing = this.context.db
|
||||
.prepare(
|
||||
"SELECT target_job_id FROM command_receipts WHERE trigger_key = $triggerKey",
|
||||
)
|
||||
.get({ $triggerKey: triggerKey }) as Row | undefined;
|
||||
if (existing)
|
||||
return existing.target_job_id === null
|
||||
? undefined
|
||||
: this.get(String(existing.target_job_id));
|
||||
const target =
|
||||
action === "cancel"
|
||||
? this.active(repositoryId, issueNumber)
|
||||
: this.latest(repositoryId, issueNumber);
|
||||
this.context.db
|
||||
.prepare(`
|
||||
INSERT INTO command_receipts(trigger_key, action, repository_id, issue_number, target_job_id, created_at)
|
||||
VALUES ($triggerKey, $action, $repositoryId, $issueNumber, $targetJobId, $now)
|
||||
`)
|
||||
.run({
|
||||
$triggerKey: triggerKey,
|
||||
$action: action,
|
||||
$repositoryId: repositoryId,
|
||||
$issueNumber: issueNumber,
|
||||
$targetJobId: target?.id || null,
|
||||
$now: now(),
|
||||
});
|
||||
if (action === "cancel" && target) {
|
||||
this.context.db
|
||||
.prepare(`
|
||||
UPDATE jobs SET cancel_requested = 1,
|
||||
state = CASE WHEN state IN ('admitted', 'queued') THEN 'cancelled' ELSE state END,
|
||||
updated_at = $now WHERE id = $id
|
||||
`)
|
||||
.run({ $id: target.id, $now: now() });
|
||||
this.context.audit(
|
||||
target.id,
|
||||
"job.cancel-requested",
|
||||
triggerKey,
|
||||
);
|
||||
return this.get(target.id);
|
||||
}
|
||||
return target;
|
||||
});
|
||||
activeTarget(repositoryId: number, targetNumber: number): Job | undefined {
|
||||
return this.byTarget(repositoryId, targetNumber, true);
|
||||
}
|
||||
|
||||
latestTarget(repositoryId: number, targetNumber: number): Job | undefined {
|
||||
return this.byTarget(repositoryId, targetNumber, false);
|
||||
}
|
||||
|
||||
get(id: string): Job | undefined {
|
||||
@@ -162,28 +112,32 @@ export class JobRepository {
|
||||
return row ? mapJob(row) : undefined;
|
||||
}
|
||||
|
||||
private insert(input: NewJob): Job {
|
||||
insert(input: NewJobInput): Job {
|
||||
const jobInput = normalizeNewJob(input);
|
||||
const id = randomUUID();
|
||||
const time = now();
|
||||
this.context.db
|
||||
.prepare(`
|
||||
INSERT INTO jobs
|
||||
(id, repository_id, issue_number, mode, trigger_kind, trigger_key, trigger_label,
|
||||
actor_id, actor_login, instruction, state, created_at, updated_at)
|
||||
VALUES ($id, $repositoryId, $issueNumber, $mode, $triggerKind, $triggerKey, $triggerLabel,
|
||||
$actorId, $actorLogin, $instruction, 'admitted', $now, $now)
|
||||
(id, repository_id, issue_number, kind, target_number, scope, mode, trigger_kind,
|
||||
trigger_key, trigger_label, actor_id, actor_login, instruction, state, created_at, updated_at)
|
||||
VALUES ($id, $repositoryId, $issueNumber, $kind, $targetNumber, $scope, $mode, $triggerKind,
|
||||
$triggerKey, $triggerLabel, $actorId, $actorLogin, $instruction, 'admitted', $now, $now)
|
||||
`)
|
||||
.run({
|
||||
$id: id,
|
||||
$repositoryId: input.repositoryId,
|
||||
$issueNumber: input.issueNumber,
|
||||
$mode: input.mode,
|
||||
$triggerKind: input.triggerKind,
|
||||
$triggerKey: input.triggerKey,
|
||||
$triggerLabel: input.triggerLabel || null,
|
||||
$actorId: input.actorId,
|
||||
$actorLogin: input.actorLogin,
|
||||
$instruction: input.instruction || "",
|
||||
$repositoryId: jobInput.repositoryId,
|
||||
$issueNumber: jobInput.issueNumber,
|
||||
$kind: jobInput.kind,
|
||||
$targetNumber: jobInput.targetNumber,
|
||||
$scope: jobInput.scope,
|
||||
$mode: jobInput.mode,
|
||||
$triggerKind: jobInput.triggerKind,
|
||||
$triggerKey: jobInput.triggerKey,
|
||||
$triggerLabel: jobInput.triggerLabel || null,
|
||||
$actorId: jobInput.actorId,
|
||||
$actorLogin: jobInput.actorLogin,
|
||||
$instruction: jobInput.instruction || "",
|
||||
$now: time,
|
||||
});
|
||||
this.context.db
|
||||
@@ -195,17 +149,36 @@ export class JobRepository {
|
||||
this.context.audit(
|
||||
id,
|
||||
"job.admitted",
|
||||
`${input.triggerKind}:${input.actorLogin}`,
|
||||
`${jobInput.triggerKind}:${jobInput.actorLogin}`,
|
||||
);
|
||||
const job = this.get(id);
|
||||
if (!job) throw new Error(`Inserted job ${id} was not found`);
|
||||
return job;
|
||||
}
|
||||
|
||||
private byTriggerKey(triggerKey: string): Job | undefined {
|
||||
byTriggerKey(triggerKey: string): Job | undefined {
|
||||
const row = this.context.db
|
||||
.prepare("SELECT * FROM jobs WHERE trigger_key = $key")
|
||||
.get({ $key: triggerKey }) as Row | undefined;
|
||||
return row ? mapJob(row) : undefined;
|
||||
}
|
||||
|
||||
private byTarget(
|
||||
repositoryId: number,
|
||||
targetNumber: number,
|
||||
active: boolean,
|
||||
): Job | undefined {
|
||||
const row = this.context.db
|
||||
.prepare(`
|
||||
SELECT * FROM jobs
|
||||
WHERE repository_id = $repositoryId AND target_number = $targetNumber
|
||||
${active ? "AND state IN ('admitted', 'queued', 'running', 'publishing')" : ""}
|
||||
ORDER BY created_at DESC, id DESC LIMIT 1
|
||||
`)
|
||||
.get({
|
||||
$repositoryId: repositoryId,
|
||||
$targetNumber: targetNumber,
|
||||
}) as Row | undefined;
|
||||
return row ? mapJob(row) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
+156
-116
@@ -1,121 +1,161 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
const latestVersion = 2;
|
||||
|
||||
export function migrate(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id TEXT PRIMARY KEY,
|
||||
event TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
body_hash TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'processing', 'done', 'failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
available_at INTEGER NOT NULL,
|
||||
error TEXT,
|
||||
received_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
repository_id INTEGER NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('plan', 'implement')),
|
||||
trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('label', 'command')),
|
||||
trigger_key TEXT NOT NULL UNIQUE,
|
||||
trigger_label TEXT,
|
||||
actor_id INTEGER NOT NULL,
|
||||
actor_login TEXT NOT NULL,
|
||||
instruction TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL CHECK (state IN ('admitted', 'queued', 'running', 'publishing', 'succeeded', 'failed', 'cancelled')),
|
||||
cancel_requested INTEGER NOT NULL DEFAULT 0,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
lease_owner TEXT,
|
||||
lease_expires_at INTEGER,
|
||||
workspace TEXT,
|
||||
result_json TEXT,
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS jobs_issue_created ON jobs(repository_id, issue_number, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS jobs_state_created ON jobs(state, created_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS jobs_one_active_issue
|
||||
ON jobs(repository_id, issue_number)
|
||||
WHERE state IN ('admitted', 'queued', 'running', 'publishing');
|
||||
CREATE TABLE IF NOT EXISTS label_claims (
|
||||
repository_id INTEGER NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
claimed INTEGER NOT NULL CHECK (claimed IN (0, 1)),
|
||||
job_id TEXT,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(repository_id, issue_number, label),
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id)
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
repository_id INTEGER NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('planner', 'implementer')),
|
||||
scope TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(repository_id, issue_number, role, scope)
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS command_receipts (
|
||||
trigger_key TEXT PRIMARY KEY,
|
||||
action TEXT NOT NULL CHECK (action IN ('cancel', 'status')),
|
||||
repository_id INTEGER NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
target_job_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(target_job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('claim', 'publish')),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'processing', 'done', 'failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
available_at INTEGER NOT NULL,
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(job_id, kind),
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS plans (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
repository_id INTEGER NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
plan_digest TEXT NOT NULL,
|
||||
data_json TEXT NOT NULL,
|
||||
comment_id INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS implementations (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
repository_id INTEGER NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
plan_digest TEXT NOT NULL,
|
||||
data_json TEXT NOT NULL,
|
||||
commit_sha TEXT,
|
||||
pull_request_number INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
job_id TEXT,
|
||||
event TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
||||
) STRICT;
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, unixepoch('subsec') * 1000);
|
||||
`);
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
`);
|
||||
const row = db
|
||||
.prepare(
|
||||
// biome-ignore lint/security/noSecrets: This static SQL query is not a credential.
|
||||
"SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations",
|
||||
)
|
||||
.get() as { version: number };
|
||||
const current = Number(row.version);
|
||||
if (current > latestVersion)
|
||||
throw new Error(`Database schema version ${current} is not supported`);
|
||||
if (current < 1) apply(db, 1, migrateV1);
|
||||
if (current < 2) apply(db, 2, migrateV2);
|
||||
}
|
||||
|
||||
function apply(
|
||||
db: DatabaseSync,
|
||||
version: number,
|
||||
migration: (db: DatabaseSync) => void,
|
||||
): void {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
migration(db);
|
||||
db.prepare(
|
||||
"INSERT INTO schema_migrations(version, applied_at) VALUES ($version, $now)",
|
||||
).run({ $version: version, $now: Date.now() });
|
||||
db.exec("COMMIT");
|
||||
} catch (error) {
|
||||
if (db.isTransaction) db.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function migrateV1(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id TEXT PRIMARY KEY, event TEXT NOT NULL, event_type TEXT NOT NULL,
|
||||
body_hash TEXT NOT NULL, payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'processing', 'done', 'failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0, available_at INTEGER NOT NULL,
|
||||
error TEXT, received_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY, repository_id INTEGER NOT NULL, issue_number INTEGER NOT NULL,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('plan', 'implement')),
|
||||
trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('label', 'command')),
|
||||
trigger_key TEXT NOT NULL UNIQUE, trigger_label TEXT, actor_id INTEGER NOT NULL,
|
||||
actor_login TEXT NOT NULL, instruction TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL CHECK (state IN ('admitted', 'queued', 'running', 'publishing', 'succeeded', 'failed', 'cancelled')),
|
||||
cancel_requested INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0,
|
||||
lease_owner TEXT, lease_expires_at INTEGER, workspace TEXT, result_json TEXT,
|
||||
error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS jobs_issue_created ON jobs(repository_id, issue_number, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS jobs_state_created ON jobs(state, created_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS jobs_one_active_issue ON jobs(repository_id, issue_number)
|
||||
WHERE state IN ('admitted', 'queued', 'running', 'publishing');
|
||||
CREATE TABLE IF NOT EXISTS label_claims (
|
||||
repository_id INTEGER NOT NULL, issue_number INTEGER NOT NULL, label TEXT NOT NULL,
|
||||
claimed INTEGER NOT NULL CHECK (claimed IN (0, 1)), job_id TEXT,
|
||||
updated_at INTEGER NOT NULL, PRIMARY KEY(repository_id, issue_number, label),
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id)
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
repository_id INTEGER NOT NULL, issue_number INTEGER NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('planner', 'implementer')),
|
||||
scope TEXT NOT NULL, session_id TEXT NOT NULL, updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(repository_id, issue_number, role, scope)
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS command_receipts (
|
||||
trigger_key TEXT PRIMARY KEY,
|
||||
action TEXT NOT NULL CHECK (action IN ('cancel', 'status')),
|
||||
repository_id INTEGER NOT NULL, issue_number INTEGER NOT NULL,
|
||||
target_job_id TEXT, created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(target_job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
id TEXT PRIMARY KEY, job_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('claim', 'publish')),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'processing', 'done', 'failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0, available_at INTEGER NOT NULL, error TEXT,
|
||||
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(job_id, kind),
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS plans (
|
||||
job_id TEXT PRIMARY KEY, repository_id INTEGER NOT NULL, issue_number INTEGER NOT NULL,
|
||||
plan_digest TEXT NOT NULL, data_json TEXT NOT NULL, comment_id INTEGER,
|
||||
created_at INTEGER NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS implementations (
|
||||
job_id TEXT PRIMARY KEY, repository_id INTEGER NOT NULL, issue_number INTEGER NOT NULL,
|
||||
plan_digest TEXT NOT NULL, data_json TEXT NOT NULL, commit_sha TEXT,
|
||||
pull_request_number INTEGER, created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id INTEGER PRIMARY KEY, job_id TEXT, event TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
||||
) STRICT;
|
||||
`);
|
||||
}
|
||||
|
||||
function migrateV2(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
ALTER TABLE jobs ADD COLUMN kind TEXT NOT NULL DEFAULT 'plan'
|
||||
CHECK (kind IN ('plan', 'plan-discuss', 'plan-review', 'implement', 'implementation-fix', 'pull-review'));
|
||||
ALTER TABLE jobs ADD COLUMN target_number INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE jobs ADD COLUMN scope TEXT NOT NULL DEFAULT '';
|
||||
UPDATE jobs SET kind = mode, target_number = issue_number;
|
||||
|
||||
ALTER TABLE label_claims RENAME COLUMN issue_number TO target_number;
|
||||
|
||||
ALTER TABLE command_receipts RENAME TO command_receipts_v1;
|
||||
CREATE TABLE command_receipts (
|
||||
trigger_key TEXT PRIMARY KEY, action TEXT NOT NULL,
|
||||
repository_id INTEGER NOT NULL, issue_number INTEGER NOT NULL,
|
||||
outcome TEXT NOT NULL CHECK (outcome IN ('bound', 'rejected')),
|
||||
target_job_id TEXT, reason TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(target_job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
||||
) STRICT;
|
||||
INSERT INTO command_receipts
|
||||
(trigger_key, action, repository_id, issue_number, outcome, target_job_id, reason, created_at)
|
||||
SELECT trigger_key, action, repository_id, issue_number,
|
||||
CASE WHEN target_job_id IS NULL THEN 'rejected' ELSE 'bound' END,
|
||||
target_job_id, CASE WHEN target_job_id IS NULL THEN 'no-target' ELSE '' END, created_at
|
||||
FROM command_receipts_v1;
|
||||
INSERT OR IGNORE INTO command_receipts
|
||||
(trigger_key, action, repository_id, issue_number, outcome, target_job_id, reason, created_at)
|
||||
SELECT trigger_key,
|
||||
CASE
|
||||
WHEN trigger_key LIKE '%:continue' THEN 'continue'
|
||||
WHEN trigger_key LIKE '%:retry' THEN 'retry'
|
||||
WHEN trigger_key LIKE '%:implement' THEN 'implement'
|
||||
ELSE mode
|
||||
END,
|
||||
repository_id, issue_number, 'bound', id, '', created_at
|
||||
FROM jobs WHERE trigger_kind = 'command';
|
||||
DROP TABLE command_receipts_v1;
|
||||
|
||||
DROP INDEX jobs_issue_created;
|
||||
CREATE INDEX jobs_issue_created
|
||||
ON jobs(repository_id, issue_number, created_at DESC, id DESC);
|
||||
DROP INDEX jobs_state_created;
|
||||
CREATE INDEX jobs_state_created ON jobs(state, created_at, id);
|
||||
CREATE TABLE review_feedback_receipts (
|
||||
repository_id INTEGER NOT NULL, pull_request_number INTEGER NOT NULL,
|
||||
feedback_kind TEXT NOT NULL CHECK (feedback_kind IN ('comment', 'review', 'review-comment')),
|
||||
object_id INTEGER NOT NULL, content_digest TEXT NOT NULL, processed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(repository_id, pull_request_number, feedback_kind, object_id, content_digest)
|
||||
) STRICT;
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,19 @@ import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import type { Result } from "../../core/contracts.js";
|
||||
import { ReceiptRepository } from "./artifacts/receipts.js";
|
||||
import { ArtifactRepository } from "./artifacts/records.js";
|
||||
import {
|
||||
type CommandAdmission,
|
||||
type CommandAdmissionRequest,
|
||||
type Conversation,
|
||||
type DatabaseContext,
|
||||
type Delivery,
|
||||
type Job,
|
||||
type NewJob,
|
||||
type NewJobInput,
|
||||
now,
|
||||
type OutboxItem,
|
||||
type ReviewFeedbackRef,
|
||||
type Row,
|
||||
} from "./model.js";
|
||||
import { ExecutionRepository } from "./repositories/execution.js";
|
||||
@@ -19,12 +23,20 @@ import { QueueRepository } from "./repositories/queue.js";
|
||||
import { migrate } from "./schema.js";
|
||||
|
||||
export type {
|
||||
CommandAction,
|
||||
CommandAdmission,
|
||||
CommandAdmissionRequest,
|
||||
CommandReceipt,
|
||||
Conversation,
|
||||
Delivery,
|
||||
Job,
|
||||
JobKind,
|
||||
JobState,
|
||||
NewJob,
|
||||
NewJobInput,
|
||||
OutboxItem,
|
||||
ReviewFeedbackKind,
|
||||
ReviewFeedbackRef,
|
||||
TriggerKind,
|
||||
} from "./model.js";
|
||||
|
||||
@@ -34,6 +46,7 @@ export class AgentStore implements DatabaseContext {
|
||||
private readonly execution: ExecutionRepository;
|
||||
private readonly queue: QueueRepository;
|
||||
private readonly artifacts: ArtifactRepository;
|
||||
private readonly receipts: ReceiptRepository;
|
||||
|
||||
constructor(path: string) {
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
@@ -57,6 +70,7 @@ export class AgentStore implements DatabaseContext {
|
||||
this.execution = new ExecutionRepository(this);
|
||||
this.queue = new QueueRepository(this);
|
||||
this.artifacts = new ArtifactRepository(this);
|
||||
this.receipts = new ReceiptRepository(this, this.jobs);
|
||||
}
|
||||
|
||||
transaction<T>(operation: () => T): T {
|
||||
@@ -107,18 +121,21 @@ export class AgentStore implements DatabaseContext {
|
||||
retryDelivery(id: string, error: string, attempts: number): void {
|
||||
this.queue.retryDelivery(id, error, attempts);
|
||||
}
|
||||
createCommandJob(input: NewJob): { job: Job; created: boolean } {
|
||||
return this.jobs.createCommand(input);
|
||||
createCommandJob(input: NewJobInput): { job: Job; created: boolean } {
|
||||
return this.receipts.createCommand(input);
|
||||
}
|
||||
createLabelJob(input: NewJob): Job | undefined {
|
||||
createLabelJob(input: NewJobInput): Job | undefined {
|
||||
return this.jobs.createLabel(input);
|
||||
}
|
||||
admitCommandRequest(request: CommandAdmissionRequest): CommandAdmission {
|
||||
return this.receipts.admitCommand(request);
|
||||
}
|
||||
releaseLabelClaim(
|
||||
repositoryId: number,
|
||||
issueNumber: number,
|
||||
targetNumber: number,
|
||||
label: string,
|
||||
): void {
|
||||
this.jobs.releaseLabel(repositoryId, issueNumber, label);
|
||||
this.jobs.releaseLabel(repositoryId, targetNumber, label);
|
||||
}
|
||||
activeJob(repositoryId: number, issueNumber: number): Job | undefined {
|
||||
return this.jobs.active(repositoryId, issueNumber);
|
||||
@@ -126,13 +143,19 @@ export class AgentStore implements DatabaseContext {
|
||||
latestJob(repositoryId: number, issueNumber: number): Job | undefined {
|
||||
return this.jobs.latest(repositoryId, issueNumber);
|
||||
}
|
||||
latestTargetJob(
|
||||
repositoryId: number,
|
||||
targetNumber: number,
|
||||
): Job | undefined {
|
||||
return this.jobs.latestTarget(repositoryId, targetNumber);
|
||||
}
|
||||
controlCommand(
|
||||
key: string,
|
||||
action: "cancel" | "status",
|
||||
repositoryId: number,
|
||||
issueNumber: number,
|
||||
): Job | undefined {
|
||||
return this.jobs.control(key, action, repositoryId, issueNumber);
|
||||
return this.receipts.control(key, action, repositoryId, issueNumber);
|
||||
}
|
||||
getJob(id: string): Job | undefined {
|
||||
return this.jobs.get(id);
|
||||
@@ -147,6 +170,25 @@ export class AgentStore implements DatabaseContext {
|
||||
): void {
|
||||
this.artifacts.recordImplementation(job, commitSha, pullRequestNumber);
|
||||
}
|
||||
isPublishedImplementation(
|
||||
repositoryId: number,
|
||||
issueNumber: number,
|
||||
pullRequestNumber: number,
|
||||
planDigest: string,
|
||||
): boolean {
|
||||
return this.artifacts.isPublishedImplementation(
|
||||
repositoryId,
|
||||
issueNumber,
|
||||
pullRequestNumber,
|
||||
planDigest,
|
||||
);
|
||||
}
|
||||
isReviewFeedbackProcessed(ref: ReviewFeedbackRef): boolean {
|
||||
return this.artifacts.isReviewFeedbackProcessed(ref);
|
||||
}
|
||||
markReviewFeedbackProcessed(ref: ReviewFeedbackRef): boolean {
|
||||
return this.artifacts.markReviewFeedbackProcessed(ref);
|
||||
}
|
||||
leaseJob(worker: string, leaseMs: number): Job | undefined {
|
||||
return this.execution.lease(worker, leaseMs);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
const maximumDiffBytes = 500_000;
|
||||
const maximumFindings = 100;
|
||||
|
||||
export interface DiffAnchor {
|
||||
path: string;
|
||||
side: "old" | "new";
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface UnifiedDiffLine {
|
||||
text: string;
|
||||
anchors: DiffAnchor[];
|
||||
}
|
||||
|
||||
export interface ParsedUnifiedDiff {
|
||||
lines: UnifiedDiffLine[];
|
||||
anchors: DiffAnchor[];
|
||||
}
|
||||
|
||||
export interface StructuredDiffFinding extends DiffAnchor {
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface PullReviewCommentInput {
|
||||
path: string;
|
||||
body: string;
|
||||
old_position: number;
|
||||
new_position: number;
|
||||
}
|
||||
|
||||
interface HunkState {
|
||||
path: string;
|
||||
oldLine: number;
|
||||
newLine: number;
|
||||
oldEnd: number;
|
||||
newEnd: number;
|
||||
}
|
||||
|
||||
export function parseUnifiedDiff(diff: string): ParsedUnifiedDiff {
|
||||
if (Buffer.byteLength(diff) > maximumDiffBytes)
|
||||
throw new Error(`Unified diff exceeds ${maximumDiffBytes} bytes`);
|
||||
const lines: UnifiedDiffLine[] = [];
|
||||
const anchors: DiffAnchor[] = [];
|
||||
const keys = new Set<string>();
|
||||
let oldPath: string | undefined;
|
||||
let path: string | undefined;
|
||||
let hunk: HunkState | undefined;
|
||||
|
||||
for (const text of diff.split("\n")) {
|
||||
const lineAnchors: DiffAnchor[] = [];
|
||||
if (hunk && text === "\\ No newline at end of file") {
|
||||
lines.push({ text, anchors: lineAnchors });
|
||||
continue;
|
||||
}
|
||||
if (hunk && /^[- +]/.test(text)) {
|
||||
consumeHunkLine(hunk, text, lineAnchors);
|
||||
for (const anchor of lineAnchors) {
|
||||
const key = anchorKey(anchor);
|
||||
if (keys.has(key))
|
||||
throw new Error(`Unified diff repeats anchor ${key}`);
|
||||
keys.add(key);
|
||||
anchors.push(anchor);
|
||||
}
|
||||
lines.push({ text, anchors: lineAnchors });
|
||||
continue;
|
||||
}
|
||||
finishHunk(hunk);
|
||||
hunk = undefined;
|
||||
if (text.startsWith("diff --git ")) {
|
||||
oldPath = undefined;
|
||||
path = undefined;
|
||||
} else if (text.startsWith("--- ")) {
|
||||
oldPath = parseHeaderPath(text.slice(4), "a/");
|
||||
} else if (text.startsWith("+++ ")) {
|
||||
const newPath = parseHeaderPath(text.slice(4), "b/");
|
||||
path = newPath === "/dev/null" ? oldPath : newPath;
|
||||
if (!path || path === "/dev/null")
|
||||
throw new Error("Unified diff has no usable file path");
|
||||
} else if (text.startsWith("@@")) {
|
||||
if (!path) throw new Error("Unified diff hunk has no file path");
|
||||
hunk = parseHunk(text, path);
|
||||
}
|
||||
lines.push({ text, anchors: lineAnchors });
|
||||
}
|
||||
finishHunk(hunk);
|
||||
return { lines, anchors };
|
||||
}
|
||||
|
||||
export function renderUnifiedDiff(value: string | ParsedUnifiedDiff): string {
|
||||
const parsed = typeof value === "string" ? parseUnifiedDiff(value) : value;
|
||||
const rendered = parsed.lines
|
||||
.map((line) => {
|
||||
if (!line.anchors.length) return line.text;
|
||||
const refs = line.anchors.map(renderDiffAnchor).join(" ");
|
||||
return `${refs} ${line.text}`;
|
||||
})
|
||||
.join("\n");
|
||||
if (Buffer.byteLength(rendered) > maximumDiffBytes * 2)
|
||||
throw new Error("Rendered unified diff exceeds the output limit");
|
||||
return rendered;
|
||||
}
|
||||
|
||||
export function renderDiffAnchor(anchor: DiffAnchor): string {
|
||||
return `[${anchor.side}:${anchor.line}:${JSON.stringify(anchor.path)}]`;
|
||||
}
|
||||
|
||||
export function isValidDiffAnchor(
|
||||
parsed: ParsedUnifiedDiff,
|
||||
anchor: DiffAnchor,
|
||||
): boolean {
|
||||
const expected = anchorKey(anchor);
|
||||
return parsed.anchors.some(
|
||||
(candidate) => anchorKey(candidate) === expected,
|
||||
);
|
||||
}
|
||||
|
||||
export function validateStructuredFindings(
|
||||
value: unknown,
|
||||
diff: string | ParsedUnifiedDiff,
|
||||
): StructuredDiffFinding[] {
|
||||
if (!Array.isArray(value) || value.length > maximumFindings)
|
||||
throw new Error("Review findings must be a bounded array");
|
||||
const parsed = typeof diff === "string" ? parseUnifiedDiff(diff) : diff;
|
||||
let bodyBytes = 0;
|
||||
return value.map((item, index) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item))
|
||||
throw new Error(`Review finding ${index} must be an object`);
|
||||
const finding = item as Partial<StructuredDiffFinding>;
|
||||
if (
|
||||
typeof finding.path !== "string" ||
|
||||
!safePath(finding.path) ||
|
||||
(finding.side !== "old" && finding.side !== "new") ||
|
||||
!Number.isSafeInteger(finding.line) ||
|
||||
Number(finding.line) <= 0
|
||||
) {
|
||||
throw new Error(`Review finding ${index} has a malformed anchor`);
|
||||
}
|
||||
const anchor = {
|
||||
path: finding.path,
|
||||
side: finding.side,
|
||||
line: Number(finding.line),
|
||||
};
|
||||
if (!isValidDiffAnchor(parsed, anchor))
|
||||
throw new Error(
|
||||
`Review finding ${index} is not anchored in the diff`,
|
||||
);
|
||||
if (typeof finding.body !== "string" || !finding.body.trim())
|
||||
throw new Error(`Review finding ${index} has no body`);
|
||||
const body = finding.body.trim();
|
||||
bodyBytes += Buffer.byteLength(body);
|
||||
if (body.length > 10_000 || bodyBytes > maximumDiffBytes)
|
||||
throw new Error("Review findings exceed the output limit");
|
||||
return { ...anchor, body };
|
||||
});
|
||||
}
|
||||
|
||||
export function renderPullReviewComments(
|
||||
findings: StructuredDiffFinding[],
|
||||
): PullReviewCommentInput[] {
|
||||
return findings.map((finding) => ({
|
||||
path: finding.path,
|
||||
body: finding.body,
|
||||
old_position: finding.side === "old" ? finding.line : 0,
|
||||
new_position: finding.side === "new" ? finding.line : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function parseHunk(text: string, path: string): HunkState {
|
||||
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(text);
|
||||
if (!match) throw new Error(`Malformed unified diff hunk: ${text}`);
|
||||
const oldLine = Number(match[1]);
|
||||
const oldCount = Number(match[2] ?? 1);
|
||||
const newLine = Number(match[3]);
|
||||
const newCount = Number(match[4] ?? 1);
|
||||
if (
|
||||
![oldLine, oldCount, newLine, newCount].every(Number.isSafeInteger) ||
|
||||
(oldCount > 0 && oldLine < 1) ||
|
||||
(newCount > 0 && newLine < 1)
|
||||
)
|
||||
throw new Error(`Malformed unified diff hunk: ${text}`);
|
||||
return {
|
||||
path,
|
||||
oldLine,
|
||||
newLine,
|
||||
oldEnd: oldLine + oldCount,
|
||||
newEnd: newLine + newCount,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeHunkLine(
|
||||
hunk: HunkState,
|
||||
text: string,
|
||||
anchors: DiffAnchor[],
|
||||
): void {
|
||||
const prefix = text[0];
|
||||
if (prefix === " " || prefix === "-") {
|
||||
if (hunk.oldLine >= hunk.oldEnd)
|
||||
throw new Error("Unified diff exceeds its old-line hunk range");
|
||||
anchors.push({ path: hunk.path, side: "old", line: hunk.oldLine++ });
|
||||
}
|
||||
if (prefix === " " || prefix === "+") {
|
||||
if (hunk.newLine >= hunk.newEnd)
|
||||
throw new Error("Unified diff exceeds its new-line hunk range");
|
||||
anchors.push({ path: hunk.path, side: "new", line: hunk.newLine++ });
|
||||
}
|
||||
}
|
||||
|
||||
function finishHunk(hunk: HunkState | undefined): void {
|
||||
if (hunk && (hunk.oldLine !== hunk.oldEnd || hunk.newLine !== hunk.newEnd))
|
||||
throw new Error(
|
||||
"Unified diff hunk ended before its declared line counts",
|
||||
);
|
||||
}
|
||||
|
||||
function parseHeaderPath(value: string, prefix: string): string {
|
||||
const decoded = value.startsWith('"') ? decodeQuotedPath(value) : value;
|
||||
if (decoded === "/dev/null") return decoded;
|
||||
const path = decoded.startsWith(prefix)
|
||||
? decoded.slice(prefix.length)
|
||||
: decoded;
|
||||
if (!safePath(path))
|
||||
throw new Error("Unified diff contains an unsafe path");
|
||||
return path;
|
||||
}
|
||||
|
||||
function decodeQuotedPath(value: string): string {
|
||||
try {
|
||||
return JSON.parse(value) as string;
|
||||
} catch {
|
||||
throw new Error("Unified diff contains a malformed quoted path");
|
||||
}
|
||||
}
|
||||
|
||||
function safePath(path: string): boolean {
|
||||
return Boolean(
|
||||
path &&
|
||||
path.length <= 1_000 &&
|
||||
!path.startsWith("/") &&
|
||||
!path.includes("\0") &&
|
||||
!path.includes("\n") &&
|
||||
!path.split("/").includes(".."),
|
||||
);
|
||||
}
|
||||
|
||||
function anchorKey(anchor: DiffAnchor): string {
|
||||
return JSON.stringify([anchor.path, anchor.side, anchor.line]);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { mkdir, readdir } from "node:fs/promises";
|
||||
import { gitAuthEnv, run, subprocessOptions } from "../process.js";
|
||||
import { assertNoTrackedSymlinks } from "./checkout.js";
|
||||
|
||||
const mergeBaseRef = "refs/ci-agent/pull-merge-base";
|
||||
|
||||
export interface PullRequestCheckoutInput {
|
||||
workspace: string;
|
||||
serverUrl: string;
|
||||
baseRepository: string;
|
||||
pullRequestNumber: number;
|
||||
headSha: string;
|
||||
mergeBaseSha: string;
|
||||
readToken: string;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PullRequestCheckout {
|
||||
headSha: string;
|
||||
mergeBaseSha: string;
|
||||
mergeBaseRef: string;
|
||||
}
|
||||
|
||||
export async function checkoutPullRequestRevision(
|
||||
input: PullRequestCheckoutInput,
|
||||
): Promise<PullRequestCheckout> {
|
||||
const headSha = assertFullGitSha(input.headSha, "pull request head");
|
||||
const mergeBaseSha = assertFullGitSha(
|
||||
input.mergeBaseSha,
|
||||
"pull request merge base",
|
||||
);
|
||||
const baseUrl = sameGiteaRepositoryUrl(
|
||||
input.serverUrl,
|
||||
input.baseRepository,
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(input.pullRequestNumber) ||
|
||||
input.pullRequestNumber <= 0
|
||||
)
|
||||
throw new Error("Pull request number must be a positive integer");
|
||||
await mkdir(input.workspace, { recursive: true });
|
||||
const entries = await readdir(input.workspace);
|
||||
if (entries.length)
|
||||
throw new Error(
|
||||
`Pull request checkout requires an empty workspace, found ${entries.length} entries`,
|
||||
);
|
||||
|
||||
await run(
|
||||
"git",
|
||||
["init", "--quiet"],
|
||||
subprocessOptions(input.workspace, input),
|
||||
);
|
||||
await run(
|
||||
"git",
|
||||
["remote", "add", "origin", baseUrl],
|
||||
subprocessOptions(input.workspace, input),
|
||||
);
|
||||
const auth = gitAuthEnv(input.readToken);
|
||||
await fetchExactCommit(input, baseUrl, mergeBaseSha, auth);
|
||||
await run(
|
||||
"git",
|
||||
["update-ref", mergeBaseRef, mergeBaseSha],
|
||||
subprocessOptions(input.workspace, input),
|
||||
);
|
||||
await verifyRevision(input, mergeBaseRef, mergeBaseSha);
|
||||
|
||||
await fetchPullHead(input, baseUrl, headSha, auth);
|
||||
await run(
|
||||
"git",
|
||||
["checkout", "--detach", "FETCH_HEAD"],
|
||||
subprocessOptions(input.workspace, input),
|
||||
);
|
||||
await verifyRevision(input, "HEAD", headSha);
|
||||
await verifyRevision(input, mergeBaseRef, mergeBaseSha);
|
||||
await assertNoTrackedSymlinks(input.workspace, input);
|
||||
return { headSha, mergeBaseSha, mergeBaseRef };
|
||||
}
|
||||
|
||||
export const checkoutPullRequest = checkoutPullRequestRevision;
|
||||
|
||||
export function assertFullGitSha(value: string, name = "commit"): string {
|
||||
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value))
|
||||
throw new Error(`${name} SHA is not a full hexadecimal object ID`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function sameGiteaRepositoryUrl(
|
||||
serverUrl: string,
|
||||
fullName: string,
|
||||
): string {
|
||||
const parts = fullName.split("/");
|
||||
if (parts.length !== 2 || parts.some((part) => !safeRepositoryPart(part)))
|
||||
throw new Error(
|
||||
"Gitea repository full_name must contain owner/repository",
|
||||
);
|
||||
const server = new URL(serverUrl);
|
||||
if (
|
||||
(server.protocol !== "https:" && server.protocol !== "http:") ||
|
||||
server.username ||
|
||||
server.password ||
|
||||
server.search ||
|
||||
server.hash
|
||||
) {
|
||||
throw new Error("Gitea server URL is not a trusted HTTP origin");
|
||||
}
|
||||
const root = server.pathname.replace(/\/+$/, "");
|
||||
server.pathname = `${root}/${parts.map(encodeURIComponent).join("/")}.git`;
|
||||
return server.toString();
|
||||
}
|
||||
|
||||
async function fetchExactCommit(
|
||||
input: PullRequestCheckoutInput,
|
||||
repositoryUrl: string,
|
||||
sha: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<void> {
|
||||
await run(
|
||||
"git",
|
||||
[
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--no-recurse-submodules",
|
||||
"--depth=1",
|
||||
repositoryUrl,
|
||||
sha,
|
||||
],
|
||||
subprocessOptions(input.workspace, input, { env }),
|
||||
);
|
||||
await verifyRevision(input, "FETCH_HEAD", sha);
|
||||
}
|
||||
|
||||
async function fetchPullHead(
|
||||
input: PullRequestCheckoutInput,
|
||||
repositoryUrl: string,
|
||||
sha: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<void> {
|
||||
await run(
|
||||
"git",
|
||||
[
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--no-recurse-submodules",
|
||||
"--depth=1",
|
||||
repositoryUrl,
|
||||
`refs/pull/${input.pullRequestNumber}/head`,
|
||||
],
|
||||
subprocessOptions(input.workspace, input, { env }),
|
||||
);
|
||||
await verifyRevision(input, "FETCH_HEAD", sha);
|
||||
}
|
||||
|
||||
async function verifyRevision(
|
||||
input: PullRequestCheckoutInput,
|
||||
revision: string,
|
||||
expected: string,
|
||||
): Promise<void> {
|
||||
const actual = (
|
||||
await run(
|
||||
"git",
|
||||
["rev-parse", "--verify", `${revision}^{commit}`],
|
||||
subprocessOptions(input.workspace, input),
|
||||
)
|
||||
).trim();
|
||||
if (actual !== expected)
|
||||
throw new Error(
|
||||
`${revision} resolved to ${actual}, expected ${expected}`,
|
||||
);
|
||||
}
|
||||
|
||||
function safeRepositoryPart(value: string): boolean {
|
||||
return Boolean(
|
||||
value &&
|
||||
value !== "." &&
|
||||
value !== ".." &&
|
||||
![...value].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return character === "\\" || code < 32 || code === 127;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -5,15 +5,15 @@ import type {
|
||||
GiteaComment,
|
||||
GiteaIssue,
|
||||
GiteaLabel,
|
||||
GiteaPullRequest,
|
||||
GiteaRepository,
|
||||
GiteaUser,
|
||||
} from "../types.js";
|
||||
import { GiteaHttpError, GiteaTransport, pathComponent } from "./transport.js";
|
||||
import { GiteaPullClient } from "./pulls.js";
|
||||
import { GiteaHttpError, pathComponent } from "./transport.js";
|
||||
|
||||
export { GiteaHttpError } from "./transport.js";
|
||||
|
||||
export class GiteaClient extends GiteaTransport {
|
||||
export class GiteaClient extends GiteaPullClient {
|
||||
constructor(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
@@ -42,15 +42,10 @@ export class GiteaClient extends GiteaTransport {
|
||||
);
|
||||
}
|
||||
|
||||
async getComments(number: number): Promise<GiteaComment[]> {
|
||||
const comments: GiteaComment[] = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const batch = await this.request<GiteaComment[]>(
|
||||
`${this.repositoryPath}/issues/${pathComponent(number)}/comments?page=${page}&limit=50`,
|
||||
);
|
||||
comments.push(...batch);
|
||||
if (batch.length < 50) return comments;
|
||||
}
|
||||
getComments(number: number): Promise<GiteaComment[]> {
|
||||
return this.request<GiteaComment[]>(
|
||||
`${this.repositoryPath}/issues/${pathComponent(number)}/comments`,
|
||||
);
|
||||
}
|
||||
|
||||
getBranch(branch: string): Promise<GiteaBranch | undefined> {
|
||||
@@ -119,71 +114,6 @@ export class GiteaClient extends GiteaTransport {
|
||||
}
|
||||
}
|
||||
|
||||
async listOpenPullRequests(): Promise<GiteaPullRequest[]> {
|
||||
const pulls: GiteaPullRequest[] = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const batch = await this.request<GiteaPullRequest[]>(
|
||||
`${this.repositoryPath}/pulls?state=open&page=${page}&limit=50`,
|
||||
);
|
||||
pulls.push(...batch);
|
||||
if (batch.length < 50) return pulls;
|
||||
}
|
||||
}
|
||||
|
||||
async getOpenPullRequestByBaseHead(
|
||||
base: string,
|
||||
head: string,
|
||||
): Promise<GiteaPullRequest | undefined> {
|
||||
try {
|
||||
const pull = await this.request<GiteaPullRequest>(
|
||||
`${this.repositoryPath}/pulls/${pathComponent(base)}/${pathComponent(head)}`,
|
||||
);
|
||||
if (pull.state === "open") return pull;
|
||||
} catch (error) {
|
||||
if (!(error instanceof GiteaHttpError && error.status === 404))
|
||||
throw error;
|
||||
}
|
||||
const headBranch = head.includes(":")
|
||||
? head.slice(head.indexOf(":") + 1)
|
||||
: head;
|
||||
return (await this.listOpenPullRequests()).find((pull) => {
|
||||
const pullBase = pull.base.ref || pull.base.name;
|
||||
const pullHead = pull.head.ref || pull.head.name;
|
||||
return (
|
||||
pullBase === base &&
|
||||
(pullHead === headBranch ||
|
||||
pullHead?.endsWith(`:${headBranch}`))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
createPullRequest(input: {
|
||||
head: string;
|
||||
base: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}): Promise<GiteaPullRequest> {
|
||||
return this.request<GiteaPullRequest>(`${this.repositoryPath}/pulls`, {
|
||||
method: "POST",
|
||||
body: { ...input, allow_maintainer_edit: true },
|
||||
expected: [201],
|
||||
});
|
||||
}
|
||||
|
||||
updatePullRequest(
|
||||
number: number,
|
||||
input: { title: string; body: string; base: string },
|
||||
): Promise<GiteaPullRequest> {
|
||||
return this.request<GiteaPullRequest>(
|
||||
`${this.repositoryPath}/pulls/${pathComponent(number)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: input,
|
||||
expected: [200, 201],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async upsertMarkedComment(
|
||||
issueNumber: number,
|
||||
botLogin: string,
|
||||
@@ -198,7 +128,10 @@ export class GiteaClient extends GiteaTransport {
|
||||
return (
|
||||
found?.kind === expected.kind &&
|
||||
found.issue === expected.issue &&
|
||||
found.mode === expected.mode
|
||||
found.mode === expected.mode &&
|
||||
(expected.kind === "status" ||
|
||||
expected.request === undefined ||
|
||||
found.request === expected.request)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
CreatePullReviewInput,
|
||||
GiteaPullRequest,
|
||||
GiteaPullReview,
|
||||
GiteaPullReviewComment,
|
||||
} from "../types.js";
|
||||
import { GiteaHttpError, GiteaTransport, pathComponent } from "./transport.js";
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
export class GiteaPullClient extends GiteaTransport {
|
||||
getPullRequest(number: number): Promise<GiteaPullRequest> {
|
||||
return this.request<GiteaPullRequest>(
|
||||
`${this.repositoryPath}/pulls/${pathComponent(number)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async listOpenPullRequests(): Promise<GiteaPullRequest[]> {
|
||||
const pulls: GiteaPullRequest[] = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const batch = await this.request<GiteaPullRequest[]>(
|
||||
`${this.repositoryPath}/pulls?state=open&page=${page}&limit=${pageSize}`,
|
||||
);
|
||||
pulls.push(...batch);
|
||||
if (batch.length < pageSize) return pulls;
|
||||
}
|
||||
}
|
||||
|
||||
async getOpenPullRequestByBaseHead(
|
||||
base: string,
|
||||
head: string,
|
||||
): Promise<GiteaPullRequest | undefined> {
|
||||
try {
|
||||
const pull = await this.request<GiteaPullRequest>(
|
||||
`${this.repositoryPath}/pulls/${pathComponent(base)}/${pathComponent(head)}`,
|
||||
);
|
||||
if (pull.state === "open") return pull;
|
||||
} catch (error) {
|
||||
if (!(error instanceof GiteaHttpError && error.status === 404))
|
||||
throw error;
|
||||
}
|
||||
const headBranch = head.includes(":")
|
||||
? head.slice(head.indexOf(":") + 1)
|
||||
: head;
|
||||
return (await this.listOpenPullRequests()).find((pull) => {
|
||||
const pullHead = pull.head.ref || pull.head.name;
|
||||
return (
|
||||
pull.base.ref === base &&
|
||||
(pullHead === headBranch ||
|
||||
pullHead?.endsWith(`:${headBranch}`))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
createPullRequest(input: {
|
||||
head: string;
|
||||
base: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}): Promise<GiteaPullRequest> {
|
||||
return this.request<GiteaPullRequest>(`${this.repositoryPath}/pulls`, {
|
||||
method: "POST",
|
||||
body: { ...input, allow_maintainer_edit: true },
|
||||
expected: [201],
|
||||
});
|
||||
}
|
||||
|
||||
updatePullRequest(
|
||||
number: number,
|
||||
input: { title: string; body: string; base: string },
|
||||
): Promise<GiteaPullRequest> {
|
||||
return this.request<GiteaPullRequest>(
|
||||
`${this.repositoryPath}/pulls/${pathComponent(number)}`,
|
||||
{ method: "PATCH", body: input, expected: [200, 201] },
|
||||
);
|
||||
}
|
||||
|
||||
async listPullReviews(number: number): Promise<GiteaPullReview[]> {
|
||||
const reviews: GiteaPullReview[] = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const batch = await this.request<GiteaPullReview[]>(
|
||||
`${this.repositoryPath}/pulls/${pathComponent(number)}/reviews?page=${page}&limit=${pageSize}`,
|
||||
);
|
||||
reviews.push(...batch);
|
||||
if (batch.length < pageSize) return reviews;
|
||||
}
|
||||
}
|
||||
|
||||
listPullReviewComments(
|
||||
number: number,
|
||||
reviewId: number,
|
||||
): Promise<GiteaPullReviewComment[]> {
|
||||
return this.request<GiteaPullReviewComment[]>(
|
||||
`${this.repositoryPath}/pulls/${pathComponent(number)}/reviews/${pathComponent(reviewId)}/comments`,
|
||||
);
|
||||
}
|
||||
|
||||
createPullReview(
|
||||
number: number,
|
||||
input: CreatePullReviewInput,
|
||||
): Promise<GiteaPullReview> {
|
||||
return this.request<GiteaPullReview>(
|
||||
`${this.repositoryPath}/pulls/${pathComponent(number)}/reviews`,
|
||||
{ method: "POST", body: input, expected: [200] },
|
||||
);
|
||||
}
|
||||
|
||||
deletePullReview(number: number, reviewId: number): Promise<void> {
|
||||
return this.request<void>(
|
||||
`${this.repositoryPath}/pulls/${pathComponent(number)}/reviews/${pathComponent(reviewId)}`,
|
||||
{ method: "DELETE", expected: [204] },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,15 @@ import {
|
||||
sha256,
|
||||
} from "../../core/contracts.js";
|
||||
import { parseAgentCommand } from "../../core/webhook.js";
|
||||
import type { GiteaComment, GiteaIssue } from "./types.js";
|
||||
import type { GiteaComment, GiteaIssue, GiteaPullRequest } from "./types.js";
|
||||
|
||||
export interface AgentPullRequestExpectation {
|
||||
repositoryId: number;
|
||||
sourceIssue: number;
|
||||
planDigest: string;
|
||||
branch: string;
|
||||
baseBranch?: string;
|
||||
}
|
||||
|
||||
export function createIssueSnapshot(
|
||||
issue: GiteaIssue,
|
||||
@@ -103,3 +111,60 @@ export function renderStatus(input: {
|
||||
}): string {
|
||||
return `${marker(input.marker)}\n## ${input.heading}\n\n${input.detail}`;
|
||||
}
|
||||
|
||||
export function isAgentGeneratedPullRequest(
|
||||
pull: GiteaPullRequest,
|
||||
expected: AgentPullRequestExpectation,
|
||||
): boolean {
|
||||
try {
|
||||
validateAgentGeneratedPullRequest(pull, expected);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateAgentGeneratedPullRequest(
|
||||
pull: GiteaPullRequest,
|
||||
expected: AgentPullRequestExpectation,
|
||||
): Marker {
|
||||
const found = parseMarker(pull.body, "pull-request");
|
||||
if (
|
||||
!found ||
|
||||
found.issue !== expected.sourceIssue ||
|
||||
found.planDigest !== expected.planDigest ||
|
||||
found.branch !== expected.branch
|
||||
) {
|
||||
throw new Error("Pull request does not have the expected agent marker");
|
||||
}
|
||||
if (
|
||||
pull.head.repo_id !== expected.repositoryId ||
|
||||
pull.base.repo_id !== expected.repositoryId ||
|
||||
pull.head.repo.id !== expected.repositoryId ||
|
||||
pull.base.repo.id !== expected.repositoryId ||
|
||||
pull.head.repo.full_name !== pull.base.repo.full_name
|
||||
) {
|
||||
throw new Error(
|
||||
"Agent pull request head and base must use this repository",
|
||||
);
|
||||
}
|
||||
if (
|
||||
pull.head.ref !== expected.branch ||
|
||||
(expected.baseBranch !== undefined &&
|
||||
pull.base.ref !== expected.baseBranch)
|
||||
) {
|
||||
throw new Error("Agent pull request branches do not match the marker");
|
||||
}
|
||||
if (
|
||||
!isFullSha(pull.head.sha) ||
|
||||
!isFullSha(pull.base.sha) ||
|
||||
!isFullSha(pull.merge_base)
|
||||
) {
|
||||
throw new Error("Agent pull request contains an invalid commit SHA");
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function isFullSha(value: string): boolean {
|
||||
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { parseMarker, protocolVersion, sha256 } from "../../core/contracts.js";
|
||||
import { parseAgentCommand } from "../../core/webhook.js";
|
||||
import type {
|
||||
GiteaComment,
|
||||
GiteaPullReview,
|
||||
GiteaPullReviewComment,
|
||||
GiteaPullReviewState,
|
||||
} from "./types.js";
|
||||
|
||||
const maximumFeedbackItems = 500;
|
||||
const maximumFeedbackBytes = 500_000;
|
||||
|
||||
export interface PullFeedbackSource {
|
||||
getComments(number: number): Promise<GiteaComment[]>;
|
||||
listPullReviews(number: number): Promise<GiteaPullReview[]>;
|
||||
listPullReviewComments(
|
||||
number: number,
|
||||
reviewId: number,
|
||||
): Promise<GiteaPullReviewComment[]>;
|
||||
}
|
||||
|
||||
interface FeedbackBase {
|
||||
ref: string;
|
||||
digest: string;
|
||||
author: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
sourceId: number;
|
||||
}
|
||||
|
||||
export interface PullCommentFeedback extends FeedbackBase {
|
||||
kind: "comment";
|
||||
}
|
||||
|
||||
export interface PullReviewSummaryFeedback extends FeedbackBase {
|
||||
kind: "review-summary";
|
||||
reviewId: number;
|
||||
state: GiteaPullReviewState;
|
||||
}
|
||||
|
||||
export interface PullInlineFeedback extends FeedbackBase {
|
||||
kind: "inline-comment";
|
||||
reviewId: number;
|
||||
path: string;
|
||||
side: "old" | "new";
|
||||
line: number;
|
||||
}
|
||||
|
||||
export type PullRequestFeedback =
|
||||
| PullCommentFeedback
|
||||
| PullReviewSummaryFeedback
|
||||
| PullInlineFeedback;
|
||||
|
||||
type FeedbackInput<T = PullRequestFeedback> = T extends PullRequestFeedback
|
||||
? Omit<T, "ref" | "digest">
|
||||
: never;
|
||||
|
||||
export async function collectPullRequestFeedback(
|
||||
source: PullFeedbackSource,
|
||||
pullNumber: number,
|
||||
botLogin: string,
|
||||
): Promise<PullRequestFeedback[]> {
|
||||
const [comments, reviews] = await Promise.all([
|
||||
source.getComments(pullNumber),
|
||||
source.listPullReviews(pullNumber),
|
||||
]);
|
||||
if (comments.length + reviews.length > maximumFeedbackItems)
|
||||
throw new Error("Pull request feedback exceeds the item limit");
|
||||
|
||||
const result: PullRequestFeedback[] = [];
|
||||
const bot = botLogin.toLowerCase();
|
||||
for (const comment of comments) {
|
||||
if (
|
||||
comment.user.login.toLowerCase() === bot ||
|
||||
isControlBody(comment.body)
|
||||
)
|
||||
continue;
|
||||
addFeedback(result, {
|
||||
kind: "comment",
|
||||
author: comment.user.login,
|
||||
body: comment.body.trim(),
|
||||
createdAt: comment.created_at,
|
||||
sourceId: comment.id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const review of reviews) {
|
||||
const botReview = review.user.login.toLowerCase() === bot;
|
||||
if (botReview && !hasAgentReviewMarker(review.body)) continue;
|
||||
const summary = stripAgentReviewMarker(review.body).trim();
|
||||
if (summary && !isControlBody(summary)) {
|
||||
addFeedback(result, {
|
||||
kind: "review-summary",
|
||||
author: review.user.login,
|
||||
body: summary,
|
||||
createdAt: review.submitted_at,
|
||||
sourceId: review.id,
|
||||
reviewId: review.id,
|
||||
state: review.state,
|
||||
});
|
||||
}
|
||||
const inline = await source.listPullReviewComments(
|
||||
pullNumber,
|
||||
review.id,
|
||||
);
|
||||
for (const comment of inline) {
|
||||
if (isControlBody(comment.body)) continue;
|
||||
const anchor = reviewCommentAnchor(comment);
|
||||
addFeedback(result, {
|
||||
kind: "inline-comment",
|
||||
author: comment.user.login,
|
||||
body: comment.body.trim(),
|
||||
createdAt: comment.created_at,
|
||||
sourceId: comment.id,
|
||||
reviewId: review.id,
|
||||
...anchor,
|
||||
});
|
||||
}
|
||||
}
|
||||
return uniqueFeedback(result).sort(
|
||||
(a, b) =>
|
||||
a.createdAt.localeCompare(b.createdAt) ||
|
||||
a.ref.localeCompare(b.ref),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAgentReviewMarker(body: string): boolean {
|
||||
if (/<!-- gitea-agent:review -->/.test(body)) return true;
|
||||
for (const match of body.matchAll(/<!-- gitea-agent:(\{[^\n]*\}) -->/g)) {
|
||||
try {
|
||||
const value = JSON.parse(match[1] || "") as Record<string, unknown>;
|
||||
if (value.v === protocolVersion && value.kind === "review")
|
||||
return true;
|
||||
} catch {
|
||||
// Ignore malformed markers.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripAgentReviewMarker(body: string): string {
|
||||
return body
|
||||
.replace(/<!-- gitea-agent:review -->/g, "")
|
||||
.replace(/<!-- gitea-agent:(\{[^\n]*\}) -->/g, (marker, json) => {
|
||||
try {
|
||||
const value = JSON.parse(json) as Record<string, unknown>;
|
||||
return value.v === protocolVersion && value.kind === "review"
|
||||
? ""
|
||||
: marker;
|
||||
} catch {
|
||||
return marker;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isControlBody(body: string): boolean {
|
||||
return Boolean(parseAgentCommand(body) || parseMarker(body, "status"));
|
||||
}
|
||||
|
||||
function reviewCommentAnchor(comment: GiteaPullReviewComment): {
|
||||
path: string;
|
||||
side: "old" | "new";
|
||||
line: number;
|
||||
} {
|
||||
const old = comment.original_position;
|
||||
const current = comment.position;
|
||||
if (old > 0 === current > 0 || !safePath(comment.path))
|
||||
throw new Error(
|
||||
`Review comment ${comment.id} has an invalid diff anchor`,
|
||||
);
|
||||
return old > 0
|
||||
? { path: comment.path, side: "old", line: old }
|
||||
: { path: comment.path, side: "new", line: current };
|
||||
}
|
||||
|
||||
function addFeedback(
|
||||
result: PullRequestFeedback[],
|
||||
value: FeedbackInput,
|
||||
): void {
|
||||
if (!value.body) return;
|
||||
const digest = sha256(JSON.stringify(feedbackContent(value)));
|
||||
result.push({
|
||||
...value,
|
||||
digest,
|
||||
ref: `feedback:${value.kind}:${value.sourceId}:${digest}`,
|
||||
} as PullRequestFeedback);
|
||||
const bytes = result.reduce((total, item) => total + item.body.length, 0);
|
||||
if (result.length > maximumFeedbackItems || bytes > maximumFeedbackBytes)
|
||||
throw new Error("Pull request feedback exceeds the output limit");
|
||||
}
|
||||
|
||||
function feedbackContent(value: FeedbackInput): object {
|
||||
if (value.kind === "inline-comment")
|
||||
return {
|
||||
kind: value.kind,
|
||||
author: value.author,
|
||||
body: value.body,
|
||||
path: value.path,
|
||||
side: value.side,
|
||||
line: value.line,
|
||||
};
|
||||
if (value.kind === "review-summary")
|
||||
return {
|
||||
kind: value.kind,
|
||||
author: value.author,
|
||||
body: value.body,
|
||||
state: value.state,
|
||||
};
|
||||
return { kind: value.kind, author: value.author, body: value.body };
|
||||
}
|
||||
|
||||
function uniqueFeedback(values: PullRequestFeedback[]): PullRequestFeedback[] {
|
||||
return [...new Map(values.map((value) => [value.ref, value])).values()];
|
||||
}
|
||||
|
||||
function safePath(path: string): boolean {
|
||||
return Boolean(
|
||||
path &&
|
||||
!path.startsWith("/") &&
|
||||
!path.includes("\0") &&
|
||||
!path.includes("\n") &&
|
||||
!path.split("/").includes(".."),
|
||||
);
|
||||
}
|
||||
@@ -43,12 +43,14 @@ export interface GiteaBranch {
|
||||
commit: { id: string };
|
||||
}
|
||||
|
||||
interface GiteaPullBranch {
|
||||
ref?: string;
|
||||
export interface GiteaPullBranch {
|
||||
label: string;
|
||||
ref: string;
|
||||
sha: string;
|
||||
repo_id: number;
|
||||
repo: GiteaRepository;
|
||||
// Older fixtures used the Go field name instead of its JSON name.
|
||||
name?: string;
|
||||
sha?: string;
|
||||
repo_id?: number;
|
||||
repo?: GiteaRepository;
|
||||
}
|
||||
|
||||
export interface GiteaPullRequest {
|
||||
@@ -57,7 +59,64 @@ export interface GiteaPullRequest {
|
||||
title: string;
|
||||
body: string;
|
||||
state: string;
|
||||
draft: boolean;
|
||||
html_url: string;
|
||||
head: GiteaPullBranch;
|
||||
base: GiteaPullBranch;
|
||||
merge_base: string;
|
||||
}
|
||||
|
||||
export type GiteaPullReviewState =
|
||||
| "APPROVED"
|
||||
| "PENDING"
|
||||
| "COMMENT"
|
||||
| "REQUEST_CHANGES"
|
||||
| "REQUEST_REVIEW";
|
||||
|
||||
export interface GiteaPullReview {
|
||||
id: number;
|
||||
user: GiteaUser;
|
||||
state: GiteaPullReviewState;
|
||||
body: string;
|
||||
commit_id: string;
|
||||
stale: boolean;
|
||||
official: boolean;
|
||||
dismissed: boolean;
|
||||
comments_count: number;
|
||||
submitted_at: string;
|
||||
updated_at: string;
|
||||
html_url: string;
|
||||
pull_request_url: string;
|
||||
}
|
||||
|
||||
export interface GiteaPullReviewComment {
|
||||
id: number;
|
||||
body: string;
|
||||
user: GiteaUser;
|
||||
resolver: GiteaUser | null;
|
||||
pull_request_review_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
path: string;
|
||||
commit_id: string;
|
||||
original_commit_id: string;
|
||||
diff_hunk: string;
|
||||
position: number;
|
||||
original_position: number;
|
||||
html_url: string;
|
||||
pull_request_url: string;
|
||||
}
|
||||
|
||||
export interface CreatePullReviewCommentInput {
|
||||
path: string;
|
||||
body: string;
|
||||
old_position: number;
|
||||
new_position: number;
|
||||
}
|
||||
|
||||
export interface CreatePullReviewInput {
|
||||
event: GiteaPullReviewState;
|
||||
body: string;
|
||||
commit_id: string;
|
||||
comments: CreatePullReviewCommentInput[];
|
||||
}
|
||||
|
||||
@@ -28,3 +28,50 @@ export const implementationSchema = {
|
||||
},
|
||||
required: ["summary", "files"],
|
||||
};
|
||||
|
||||
export const planConversationSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
reply: { type: "string" },
|
||||
planChanged: { type: "boolean" },
|
||||
planMarkdown: { type: "string" },
|
||||
summary: { type: "string" },
|
||||
},
|
||||
required: ["reply", "planChanged", "planMarkdown", "summary"],
|
||||
};
|
||||
|
||||
export const implementationFixSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
response: { type: "string" },
|
||||
summary: { type: "string" },
|
||||
files: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["response", "summary", "files"],
|
||||
};
|
||||
|
||||
export const pullReviewSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
summary: { type: "string" },
|
||||
generalFindings: { type: "array", items: { type: "string" } },
|
||||
findings: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
side: { type: "string", enum: ["old", "new"] },
|
||||
line: { type: "integer", minimum: 1 },
|
||||
body: { type: "string" },
|
||||
},
|
||||
required: ["path", "side", "line", "body"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["summary", "generalFindings", "findings"],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { AgentStore } from "../../../adapters/database/store.js";
|
||||
import type { ActorPolicy } from "../../../core/config.js";
|
||||
import { actorAllowed } from "../../../core/config.js";
|
||||
import { reviewFixLabel } from "../../../core/contracts.js";
|
||||
import {
|
||||
type PullLabelPayload,
|
||||
parseAgentCommand,
|
||||
type parseCommentPayload,
|
||||
} from "../../../core/webhook.js";
|
||||
import type { PublicationContext } from "../../publication/status.js";
|
||||
import { upsertJobStatus } from "../../publication/status.js";
|
||||
import {
|
||||
admitPullJob,
|
||||
generatedIdentity,
|
||||
operationName,
|
||||
rejectPullCommand as reject,
|
||||
} from "./pull/support.js";
|
||||
import { IgnoreDelivery } from "./reconcile.js";
|
||||
|
||||
export async function reconcilePullCommand(
|
||||
store: AgentStore,
|
||||
context: PublicationContext,
|
||||
repositoryId: number,
|
||||
payload: ReturnType<typeof parseCommentPayload>,
|
||||
botId: number,
|
||||
policy: ActorPolicy,
|
||||
): Promise<void> {
|
||||
if (payload.sender.id === botId || payload.comment.user.id === botId)
|
||||
throw new IgnoreDelivery("Bot comment");
|
||||
if (payload.sender.id !== payload.comment.user.id)
|
||||
throw new IgnoreDelivery("Comment actor does not match its author");
|
||||
if (!actorAllowed(policy, payload.sender))
|
||||
throw new IgnoreDelivery(`Unauthorized actor ${payload.sender.login}`);
|
||||
const command = parseAgentCommand(payload.comment.body);
|
||||
if (!command) throw new IgnoreDelivery("Comment has no agent command");
|
||||
const pull = await context.client.getPullRequest(payload.issue.number);
|
||||
if (pull.state !== "open")
|
||||
throw new IgnoreDelivery("Agent commands require an open pull request");
|
||||
const generated = generatedIdentity(store, pull, repositoryId);
|
||||
const sourceIssue = generated?.issueNumber ?? pull.number;
|
||||
const key = `comment:${payload.comment.id}:${command.action}`;
|
||||
|
||||
if (command.action === "cancel" || command.action === "status") {
|
||||
const admission = store.admitCommandRequest({
|
||||
triggerKey: key,
|
||||
action: command.action,
|
||||
repositoryId,
|
||||
issueNumber: sourceIssue,
|
||||
targetNumber: pull.number,
|
||||
});
|
||||
if (admission.job)
|
||||
await upsertJobStatus(
|
||||
context.client,
|
||||
context.botLogin,
|
||||
admission.job,
|
||||
command.action === "cancel"
|
||||
? `Agent ${operationName(admission.job.kind)} cancellation requested`
|
||||
: `Agent ${operationName(admission.job.kind)} status`,
|
||||
command.action === "cancel"
|
||||
? "The executor will stop at the next cancellation boundary."
|
||||
: `Request \`${admission.job.id.slice(0, 12)}\` is **${admission.job.state}**.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.action === "retry") {
|
||||
const latest = store.latestTargetJob(repositoryId, pull.number);
|
||||
if (!latest)
|
||||
return reject(
|
||||
store,
|
||||
key,
|
||||
"retry",
|
||||
repositoryId,
|
||||
sourceIssue,
|
||||
"no-target",
|
||||
);
|
||||
if (latest.state !== "failed" && latest.state !== "cancelled")
|
||||
return reject(
|
||||
store,
|
||||
key,
|
||||
"retry",
|
||||
repositoryId,
|
||||
sourceIssue,
|
||||
"not-retryable",
|
||||
);
|
||||
await admitPullJob(
|
||||
store,
|
||||
context,
|
||||
key,
|
||||
"retry",
|
||||
latest.kind,
|
||||
pull,
|
||||
sourceIssue,
|
||||
latest.scope,
|
||||
payload,
|
||||
command.instruction || latest.instruction,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command.action === "fix") {
|
||||
if (!generated)
|
||||
return reject(
|
||||
store,
|
||||
key,
|
||||
"fix",
|
||||
repositoryId,
|
||||
sourceIssue,
|
||||
"not-agent-generated",
|
||||
);
|
||||
await admitPullJob(
|
||||
store,
|
||||
context,
|
||||
key,
|
||||
"fix",
|
||||
"implementation-fix",
|
||||
pull,
|
||||
sourceIssue,
|
||||
generated.planDigest,
|
||||
payload,
|
||||
command.instruction,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command.action === "review") {
|
||||
await admitPullJob(
|
||||
store,
|
||||
context,
|
||||
key,
|
||||
"review",
|
||||
"pull-review",
|
||||
pull,
|
||||
sourceIssue,
|
||||
generated?.planDigest || pull.head.sha,
|
||||
payload,
|
||||
command.instruction,
|
||||
);
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
store,
|
||||
key,
|
||||
command.action,
|
||||
repositoryId,
|
||||
sourceIssue,
|
||||
"unsupported-on-pull-request",
|
||||
);
|
||||
}
|
||||
|
||||
export async function reconcilePullLabels(
|
||||
store: AgentStore,
|
||||
context: PublicationContext,
|
||||
repositoryId: number,
|
||||
payload: PullLabelPayload,
|
||||
botId: number,
|
||||
policy: ActorPolicy,
|
||||
): Promise<void> {
|
||||
if (payload.sender.id === botId)
|
||||
throw new IgnoreDelivery("Bot label event");
|
||||
const pull = await context.client.getPullRequest(
|
||||
payload.pullRequest.number,
|
||||
);
|
||||
const issue = await context.client.getIssue(pull.number);
|
||||
const label = issue.labels.find((item) => item.name === reviewFixLabel);
|
||||
if (!label) {
|
||||
store.releaseLabelClaim(repositoryId, pull.number, reviewFixLabel);
|
||||
return;
|
||||
}
|
||||
if (!actorAllowed(policy, payload.sender)) {
|
||||
await context.client.removeLabel(pull.number, label.id);
|
||||
store.releaseLabelClaim(repositoryId, pull.number, reviewFixLabel);
|
||||
throw new IgnoreDelivery(`Unauthorized actor ${payload.sender.login}`);
|
||||
}
|
||||
if (pull.state !== "open") {
|
||||
await context.client.removeLabel(pull.number, label.id);
|
||||
store.releaseLabelClaim(repositoryId, pull.number, reviewFixLabel);
|
||||
throw new IgnoreDelivery("Fixes require an open pull request");
|
||||
}
|
||||
const generated = generatedIdentity(store, pull, repositoryId);
|
||||
if (!generated) {
|
||||
await context.client.removeLabel(pull.number, label.id);
|
||||
throw new IgnoreDelivery(
|
||||
"Fix labels require an agent-generated pull request",
|
||||
);
|
||||
}
|
||||
const active = store.activeJob(repositoryId, generated.issueNumber);
|
||||
if (active) {
|
||||
await context.client.removeLabel(pull.number, label.id);
|
||||
store.releaseLabelClaim(repositoryId, pull.number, reviewFixLabel);
|
||||
await upsertJobStatus(
|
||||
context.client,
|
||||
context.botLogin,
|
||||
active,
|
||||
"Agent already active",
|
||||
`Request \`${active.id.slice(0, 12)}\` is ${active.state}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
store.createLabelJob({
|
||||
repositoryId,
|
||||
issueNumber: generated.issueNumber,
|
||||
kind: "implementation-fix",
|
||||
targetNumber: pull.number,
|
||||
scope: generated.planDigest,
|
||||
mode: "implement",
|
||||
triggerKind: "label",
|
||||
triggerKey: "",
|
||||
triggerLabel: reviewFixLabel,
|
||||
actorId: payload.sender.id,
|
||||
actorLogin: payload.sender.login,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type {
|
||||
AgentStore,
|
||||
JobKind,
|
||||
} from "../../../../adapters/database/store.js";
|
||||
import { validateAgentGeneratedPullRequest } from "../../../../adapters/gitea/issues.js";
|
||||
import type { GiteaPullRequest } from "../../../../adapters/gitea/types.js";
|
||||
import { parseMarker } from "../../../../core/contracts.js";
|
||||
import type { parseCommentPayload } from "../../../../core/webhook.js";
|
||||
import type { PublicationContext } from "../../../publication/status.js";
|
||||
import { upsertJobStatus } from "../../../publication/status.js";
|
||||
|
||||
export interface PullIdentity {
|
||||
issueNumber: number;
|
||||
planDigest: string;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
export async function admitPullJob(
|
||||
store: AgentStore,
|
||||
context: PublicationContext,
|
||||
key: string,
|
||||
action: "fix" | "review" | "retry",
|
||||
kind: JobKind,
|
||||
pull: GiteaPullRequest,
|
||||
issueNumber: number,
|
||||
scope: string,
|
||||
payload: ReturnType<typeof parseCommentPayload>,
|
||||
instruction: string,
|
||||
): Promise<void> {
|
||||
const admission = store.admitCommandRequest({
|
||||
triggerKey: key,
|
||||
action,
|
||||
repositoryId: payload.repository.id,
|
||||
issueNumber,
|
||||
job: {
|
||||
kind,
|
||||
targetNumber: pull.number,
|
||||
scope,
|
||||
mode: "implement",
|
||||
actorId: payload.sender.id,
|
||||
actorLogin: payload.sender.login,
|
||||
instruction,
|
||||
},
|
||||
});
|
||||
if (admission.receipt.reason !== "active-job") return;
|
||||
const active = store.activeJob(payload.repository.id, issueNumber);
|
||||
if (active)
|
||||
await upsertJobStatus(
|
||||
context.client,
|
||||
context.botLogin,
|
||||
active,
|
||||
"Agent already active",
|
||||
`Request \`${active.id.slice(0, 12)}\` is ${active.state}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function generatedIdentity(
|
||||
store: AgentStore,
|
||||
pull: GiteaPullRequest,
|
||||
repositoryId: number,
|
||||
): PullIdentity | undefined {
|
||||
const found = parseMarker(pull.body, "pull-request");
|
||||
if (!found?.issue || !found.planDigest || !found.branch) return undefined;
|
||||
try {
|
||||
validateAgentGeneratedPullRequest(pull, {
|
||||
repositoryId,
|
||||
sourceIssue: found.issue,
|
||||
planDigest: found.planDigest,
|
||||
branch: found.branch,
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!store.isPublishedImplementation(
|
||||
repositoryId,
|
||||
found.issue,
|
||||
pull.number,
|
||||
found.planDigest,
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
return {
|
||||
issueNumber: found.issue,
|
||||
planDigest: found.planDigest,
|
||||
branch: found.branch,
|
||||
};
|
||||
}
|
||||
|
||||
export function rejectPullCommand(
|
||||
store: AgentStore,
|
||||
key: string,
|
||||
action: Parameters<AgentStore["admitCommandRequest"]>[0]["action"],
|
||||
repositoryId: number,
|
||||
issueNumber: number,
|
||||
reason: string,
|
||||
): void {
|
||||
store.admitCommandRequest({
|
||||
triggerKey: key,
|
||||
action,
|
||||
repositoryId,
|
||||
issueNumber,
|
||||
rejection: reason,
|
||||
});
|
||||
}
|
||||
|
||||
export function operationName(kind: JobKind): string {
|
||||
return kind.replaceAll("-", " ");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentStore } from "../../../adapters/database/store.js";
|
||||
import type { AgentStore, JobKind } from "../../../adapters/database/store.js";
|
||||
import type { ActorPolicy } from "../../../core/config.js";
|
||||
import { actorAllowed } from "../../../core/config.js";
|
||||
import {
|
||||
@@ -96,81 +96,112 @@ export async function reconcileCommand(
|
||||
const issue = await context.client.getIssue(payload.issue.number);
|
||||
if (issue.pull_request || issue.state !== "open")
|
||||
throw new IgnoreDelivery("Agent commands require an open issue");
|
||||
if (command.action === "cancel") {
|
||||
const cancelled = store.controlCommand(
|
||||
`comment:${payload.comment.id}:cancel`,
|
||||
"cancel",
|
||||
const key = `comment:${payload.comment.id}:${command.action}`;
|
||||
if (command.action === "cancel" || command.action === "status") {
|
||||
const admission = store.admitCommandRequest({
|
||||
triggerKey: key,
|
||||
action: command.action,
|
||||
repositoryId,
|
||||
issue.number,
|
||||
);
|
||||
if (cancelled)
|
||||
issueNumber: issue.number,
|
||||
});
|
||||
if (admission.job)
|
||||
await upsertJobStatus(
|
||||
context.client,
|
||||
context.botLogin,
|
||||
cancelled,
|
||||
`Agent ${cancelled.mode} cancellation requested`,
|
||||
"The executor will stop at the next cancellation boundary.",
|
||||
admission.job,
|
||||
command.action === "cancel"
|
||||
? `Agent ${admission.job.kind.replaceAll("-", " ")} cancellation requested`
|
||||
: `Agent ${admission.job.kind.replaceAll("-", " ")} status`,
|
||||
command.action === "cancel"
|
||||
? "The executor will stop at the next cancellation boundary."
|
||||
: `Request \`${admission.job.id.slice(0, 12)}\` is **${admission.job.state}**.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command.action === "fix") {
|
||||
store.admitCommandRequest({
|
||||
triggerKey: key,
|
||||
action: "fix",
|
||||
repositoryId,
|
||||
issueNumber: issue.number,
|
||||
rejection: "fix-requires-pull-request",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const latest = store.latestJob(repositoryId, issue.number);
|
||||
if (command.action === "status") {
|
||||
const target = store.controlCommand(
|
||||
`comment:${payload.comment.id}:status`,
|
||||
"status",
|
||||
repositoryId,
|
||||
issue.number,
|
||||
);
|
||||
if (target)
|
||||
await upsertJobStatus(
|
||||
context.client,
|
||||
context.botLogin,
|
||||
target,
|
||||
`Agent ${target.mode} status`,
|
||||
`Request \`${target.id.slice(0, 12)}\` is **${target.state}**.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const active = store.activeJob(repositoryId, issue.number);
|
||||
if (active) {
|
||||
await upsertJobStatus(
|
||||
context.client,
|
||||
context.botLogin,
|
||||
active,
|
||||
`Agent ${active.mode} already active`,
|
||||
`Request \`${active.id.slice(0, 12)}\` is ${active.state}. Cancel it first.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mode: Mode;
|
||||
let kind: JobKind;
|
||||
let targetNumber = issue.number;
|
||||
let scope = "";
|
||||
let instruction = command.instruction;
|
||||
if (command.action === "plan" || command.action === "implement")
|
||||
if (command.action === "plan" || command.action === "implement") {
|
||||
mode = command.mode;
|
||||
else {
|
||||
if (!latest)
|
||||
throw new IgnoreDelivery(
|
||||
`No previous request is available for /agent ${command.action}`,
|
||||
);
|
||||
kind = command.action;
|
||||
} else if (command.action === "discuss") {
|
||||
mode = "plan";
|
||||
kind = "plan-discuss";
|
||||
scope = "issue";
|
||||
} else if (command.action === "review") {
|
||||
mode = "plan";
|
||||
kind = "plan-review";
|
||||
scope = "issue";
|
||||
} else {
|
||||
if (!latest) {
|
||||
store.admitCommandRequest({
|
||||
triggerKey: key,
|
||||
action: command.action,
|
||||
repositoryId,
|
||||
issueNumber: issue.number,
|
||||
rejection: "no-target",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
command.action === "retry" &&
|
||||
latest.state !== "failed" &&
|
||||
latest.state !== "cancelled"
|
||||
) {
|
||||
throw new IgnoreDelivery(
|
||||
"Only failed or cancelled requests can be retried",
|
||||
);
|
||||
store.admitCommandRequest({
|
||||
triggerKey: key,
|
||||
action: "retry",
|
||||
repositoryId,
|
||||
issueNumber: issue.number,
|
||||
rejection: "not-retryable",
|
||||
});
|
||||
return;
|
||||
}
|
||||
mode = latest.mode;
|
||||
kind = command.action === "retry" ? latest.kind : latest.mode;
|
||||
targetNumber =
|
||||
command.action === "retry" ? latest.targetNumber : issue.number;
|
||||
scope = command.action === "retry" ? latest.scope : "";
|
||||
if (!instruction) instruction = latest.instruction;
|
||||
}
|
||||
store.createCommandJob({
|
||||
const admission = store.admitCommandRequest({
|
||||
triggerKey: key,
|
||||
action: command.action,
|
||||
repositoryId,
|
||||
issueNumber: issue.number,
|
||||
mode,
|
||||
triggerKind: "command",
|
||||
triggerKey: `comment:${payload.comment.id}:${command.action}`,
|
||||
actorId: payload.sender.id,
|
||||
actorLogin: payload.sender.login,
|
||||
instruction,
|
||||
job: {
|
||||
mode,
|
||||
kind,
|
||||
targetNumber,
|
||||
scope,
|
||||
actorId: payload.sender.id,
|
||||
actorLogin: payload.sender.login,
|
||||
instruction,
|
||||
},
|
||||
});
|
||||
if (admission.receipt.reason === "active-job") {
|
||||
const active = store.activeJob(repositoryId, issue.number);
|
||||
if (active)
|
||||
await upsertJobStatus(
|
||||
context.client,
|
||||
context.botLogin,
|
||||
active,
|
||||
`Agent ${active.kind.replaceAll("-", " ")} already active`,
|
||||
`Request \`${active.id.slice(0, 12)}\` is ${active.state}. Cancel it first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { log } from "../../../core/contracts.js";
|
||||
import {
|
||||
parseCommentPayload,
|
||||
parseLabelPayload,
|
||||
parsePullLabelPayload,
|
||||
type WebhookRepository,
|
||||
} from "../../../core/webhook.js";
|
||||
import { publishJob } from "../../publication/service.js";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
safeFailure,
|
||||
upsertJobStatus,
|
||||
} from "../../publication/status.js";
|
||||
import { reconcilePullCommand, reconcilePullLabels } from "./pull.js";
|
||||
import {
|
||||
IgnoreDelivery,
|
||||
reconcileCommand,
|
||||
@@ -48,14 +50,42 @@ export async function pumpDeliveries(
|
||||
botId,
|
||||
policy,
|
||||
);
|
||||
} else if (delivery.eventType === "issue_comment") {
|
||||
} else if (
|
||||
delivery.eventType === "issue_comment" ||
|
||||
delivery.eventType === "pull_request_comment"
|
||||
) {
|
||||
const payload = parseCommentPayload(delivery.payload);
|
||||
verifyIdentity(
|
||||
payload.repository,
|
||||
repositoryId,
|
||||
repositoryFullName,
|
||||
);
|
||||
await reconcileCommand(
|
||||
if (payload.is_pull || payload.issue.pull_request)
|
||||
await reconcilePullCommand(
|
||||
store,
|
||||
context,
|
||||
repositoryId,
|
||||
payload,
|
||||
botId,
|
||||
policy,
|
||||
);
|
||||
else
|
||||
await reconcileCommand(
|
||||
store,
|
||||
context,
|
||||
repositoryId,
|
||||
payload,
|
||||
botId,
|
||||
policy,
|
||||
);
|
||||
} else if (delivery.eventType === "pull_request_label") {
|
||||
const payload = parsePullLabelPayload(delivery.payload);
|
||||
verifyIdentity(
|
||||
payload.repository,
|
||||
repositoryId,
|
||||
repositoryFullName,
|
||||
);
|
||||
await reconcilePullLabels(
|
||||
store,
|
||||
context,
|
||||
repositoryId,
|
||||
@@ -120,7 +150,7 @@ export async function pumpOutbox(
|
||||
if (job.triggerLabel)
|
||||
store.releaseLabelClaim(
|
||||
job.repositoryId,
|
||||
job.issueNumber,
|
||||
job.targetNumber,
|
||||
job.triggerLabel,
|
||||
);
|
||||
store.completeClaim(item);
|
||||
@@ -128,12 +158,31 @@ export async function pumpOutbox(
|
||||
const outcome = await publishJob(context, job);
|
||||
if (outcome.planCommentId !== undefined)
|
||||
store.recordPlan(job, outcome.planCommentId);
|
||||
if (job.result?.implementation)
|
||||
if (
|
||||
outcome.terminal === "succeeded" &&
|
||||
job.result?.implementation
|
||||
)
|
||||
store.recordImplementation(
|
||||
job,
|
||||
outcome.commitSha || null,
|
||||
outcome.pullRequestNumber || null,
|
||||
outcome.pullRequestNumber ||
|
||||
job.result.implementation.pullRequestNumber ||
|
||||
null,
|
||||
);
|
||||
if (
|
||||
outcome.terminal === "succeeded" &&
|
||||
job.kind === "implementation-fix"
|
||||
)
|
||||
for (const feedback of job.result?.implementation
|
||||
?.feedback || [])
|
||||
store.markReviewFeedbackProcessed({
|
||||
repositoryId: job.repositoryId,
|
||||
pullRequestNumber: job.targetNumber,
|
||||
kind: feedback.kind,
|
||||
objectId: feedback.objectId,
|
||||
contentDigest: feedback.contentDigest,
|
||||
});
|
||||
store.completePublication(item, outcome.terminal);
|
||||
if (job.workspace)
|
||||
await rm(job.workspace, {
|
||||
recursive: true,
|
||||
@@ -146,7 +195,6 @@ export async function pumpOutbox(
|
||||
}),
|
||||
);
|
||||
});
|
||||
store.completePublication(item, outcome.terminal);
|
||||
}
|
||||
} catch (error) {
|
||||
store.retryOutbox(item, safeFailure(error));
|
||||
|
||||
@@ -49,6 +49,13 @@ async function main(): Promise<void> {
|
||||
writeToken,
|
||||
signal: shutdown.signal,
|
||||
isCancelled: (jobId) => store.isCancelRequested(jobId),
|
||||
isPublishedImplementation: (issueNumber, pullRequestNumber, digest) =>
|
||||
store.isPublishedImplementation(
|
||||
configuredRepository.id,
|
||||
issueNumber,
|
||||
pullRequestNumber,
|
||||
digest,
|
||||
),
|
||||
};
|
||||
let delivering: Promise<void> | undefined;
|
||||
let publishing: Promise<void> | undefined;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { AgentStore } from "../../adapters/database/store.js";
|
||||
import { reviewFixLabel } from "../../core/contracts.js";
|
||||
import {
|
||||
parseAgentCommand,
|
||||
verifyGiteaSignature,
|
||||
@@ -102,9 +103,23 @@ export async function handleHttp(
|
||||
) {
|
||||
return end(response, 403);
|
||||
}
|
||||
if (eventType !== "issue_label" && eventType !== "issue_comment")
|
||||
if (
|
||||
eventType !== "issue_label" &&
|
||||
eventType !== "issue_comment" &&
|
||||
eventType !== "pull_request_comment" &&
|
||||
eventType !== "pull_request_label"
|
||||
)
|
||||
return end(response, 204);
|
||||
if (eventType === "issue_comment" && !isCreatedAgentCommand(payload))
|
||||
if (
|
||||
(eventType === "issue_comment" ||
|
||||
eventType === "pull_request_comment") &&
|
||||
!isCreatedAgentCommand(payload)
|
||||
)
|
||||
return end(response, 204);
|
||||
if (
|
||||
eventType === "pull_request_label" &&
|
||||
!hasAddedLabel(payload, reviewFixLabel)
|
||||
)
|
||||
return end(response, 204);
|
||||
if (input.store.pendingDeliveryCount() >= 1_000) {
|
||||
response.writeHead(503, { "Retry-After": "60" });
|
||||
@@ -152,6 +167,25 @@ function isCreatedAgentCommand(payload: unknown): boolean {
|
||||
return typeof body === "string" && Boolean(parseAgentCommand(body));
|
||||
}
|
||||
|
||||
function hasAddedLabel(payload: unknown, name: string): boolean {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
||||
return false;
|
||||
const changes = (payload as Record<string, unknown>).changes;
|
||||
if (!changes || typeof changes !== "object" || Array.isArray(changes))
|
||||
return false;
|
||||
const labels = (changes as Record<string, unknown>).added_labels;
|
||||
return (
|
||||
Array.isArray(labels) &&
|
||||
labels.some(
|
||||
(label) =>
|
||||
label !== null &&
|
||||
typeof label === "object" &&
|
||||
!Array.isArray(label) &&
|
||||
(label as Record<string, unknown>).name === name,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function header(request: IncomingMessage, name: string): string | undefined {
|
||||
const value = request.headers[name];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { AgentStore, Job } from "../../../adapters/database/store.js";
|
||||
import type { GiteaClient } from "../../../adapters/gitea/client/client.js";
|
||||
import { findAcceptedPlan } from "../../../adapters/gitea/issues.js";
|
||||
import type { GiteaPullRequest } from "../../../adapters/gitea/types.js";
|
||||
import type { OrchestrationOutput } from "../context.js";
|
||||
import { runImplementationFix } from "../orchestration/conversations/implementation.js";
|
||||
import { runPlanDiscussion } from "../orchestration/conversations/plan.js";
|
||||
import { runImplementation } from "../orchestration/implementation.js";
|
||||
import { runPlan } from "../orchestration/plan.js";
|
||||
import { runPlanReview, runPullReview } from "../orchestration/review.js";
|
||||
|
||||
export interface JobExecutionInput {
|
||||
store: AgentStore;
|
||||
job: Job;
|
||||
worker: string;
|
||||
serverUrl: string;
|
||||
repository: { owner: string; repo: string };
|
||||
readToken: string;
|
||||
botLogin: string;
|
||||
workspaceRoot: string;
|
||||
shutdown: AbortSignal;
|
||||
}
|
||||
|
||||
export async function runCheckedOutJob(
|
||||
input: JobExecutionInput,
|
||||
client: GiteaClient,
|
||||
workspace: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<OrchestrationOutput> {
|
||||
if (input.job.kind === "plan")
|
||||
return runPlan({
|
||||
...baseInput(input, client, workspace, signal),
|
||||
...plannerConversation(input),
|
||||
});
|
||||
if (input.job.kind === "plan-discuss")
|
||||
return runPlanDiscussion({
|
||||
...baseInput(input, client, workspace, signal),
|
||||
...plannerConversation(input),
|
||||
});
|
||||
if (input.job.kind === "plan-review")
|
||||
return runPlanReview(baseInput(input, client, workspace, signal));
|
||||
if (input.job.kind === "implementation-fix") {
|
||||
if (
|
||||
!input.store.isPublishedImplementation(
|
||||
input.job.repositoryId,
|
||||
input.job.issueNumber,
|
||||
input.job.targetNumber,
|
||||
input.job.scope,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Fix target is not a published agent implementation",
|
||||
);
|
||||
const conversation = implementerConversation(input, input.job.scope);
|
||||
return runImplementationFix({
|
||||
...baseInput(input, client, workspace, signal),
|
||||
repositoryId: input.job.repositoryId,
|
||||
pullRequestNumber: input.job.targetNumber,
|
||||
expectedPlanDigest: input.job.scope,
|
||||
readToken: input.readToken,
|
||||
isProcessed: (ref) => input.store.isReviewFeedbackProcessed(ref),
|
||||
...conversation,
|
||||
});
|
||||
}
|
||||
if (input.job.kind !== "implement")
|
||||
throw new Error(`Job kind ${input.job.kind} requires a pull checkout`);
|
||||
const accepted = findAcceptedPlan(
|
||||
await client.getComments(input.job.issueNumber),
|
||||
input.botLogin,
|
||||
input.job.issueNumber,
|
||||
);
|
||||
const scope = accepted?.marker.planDigest || "missing-plan";
|
||||
return runImplementation({
|
||||
...baseInput(input, client, workspace, signal),
|
||||
readToken: input.readToken,
|
||||
expectedPlanDigest: scope,
|
||||
...implementerConversation(input, scope),
|
||||
});
|
||||
}
|
||||
|
||||
export function runPullReviewJob(
|
||||
input: JobExecutionInput,
|
||||
client: GiteaClient,
|
||||
workspace: string,
|
||||
pull: GiteaPullRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<OrchestrationOutput> {
|
||||
if (input.job.kind !== "pull-review")
|
||||
throw new Error(`Unexpected pull-checkout job ${input.job.kind}`);
|
||||
return runPullReview({
|
||||
pullRequestNumber: pull.number,
|
||||
client,
|
||||
workspace,
|
||||
mergeBaseSha: pull.merge_base,
|
||||
headSha: pull.head.sha,
|
||||
instruction: input.job.instruction,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
function baseInput(
|
||||
input: JobExecutionInput,
|
||||
client: GiteaClient,
|
||||
workspace: string,
|
||||
signal: AbortSignal,
|
||||
): {
|
||||
issueNumber: number;
|
||||
botLogin: string;
|
||||
client: GiteaClient;
|
||||
workspace: string;
|
||||
instruction: string;
|
||||
signal: AbortSignal;
|
||||
} {
|
||||
return {
|
||||
issueNumber: input.job.issueNumber,
|
||||
botLogin: input.botLogin,
|
||||
client,
|
||||
workspace,
|
||||
instruction: input.job.instruction,
|
||||
signal,
|
||||
};
|
||||
}
|
||||
|
||||
function plannerConversation(input: JobExecutionInput): {
|
||||
existingSessionId?: string;
|
||||
onSession: (sessionId: string) => void;
|
||||
} {
|
||||
const conversation = firstAttemptConversation(input, "planner", "issue");
|
||||
return {
|
||||
...(conversation ? { existingSessionId: conversation.sessionId } : {}),
|
||||
onSession: (sessionId) =>
|
||||
saveConversation(input, "planner", "issue", sessionId),
|
||||
};
|
||||
}
|
||||
|
||||
function implementerConversation(
|
||||
input: JobExecutionInput,
|
||||
scope: string,
|
||||
): {
|
||||
existingSessionId?: string;
|
||||
onSession: (sessionId: string) => void;
|
||||
} {
|
||||
const conversation = firstAttemptConversation(input, "implementer", scope);
|
||||
return {
|
||||
...(conversation ? { existingSessionId: conversation.sessionId } : {}),
|
||||
onSession: (sessionId) =>
|
||||
saveConversation(input, "implementer", scope, sessionId),
|
||||
};
|
||||
}
|
||||
|
||||
function firstAttemptConversation(
|
||||
input: JobExecutionInput,
|
||||
role: "planner" | "implementer",
|
||||
scope: string,
|
||||
) {
|
||||
return input.job.attempts === 1
|
||||
? input.store.getConversation(
|
||||
input.job.repositoryId,
|
||||
input.job.issueNumber,
|
||||
role,
|
||||
scope,
|
||||
)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function saveConversation(
|
||||
input: JobExecutionInput,
|
||||
role: "planner" | "implementer",
|
||||
scope: string,
|
||||
sessionId: string,
|
||||
): void {
|
||||
if (input.job.attempts !== 1) return;
|
||||
input.store.saveConversation({
|
||||
repositoryId: input.job.repositoryId,
|
||||
issueNumber: input.job.issueNumber,
|
||||
role,
|
||||
scope,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { ReviewFeedbackRef } from "../../../../adapters/database/store.js";
|
||||
import { validateChangedFiles } from "../../../../adapters/git/publication.js";
|
||||
import {
|
||||
candidateChangedFiles,
|
||||
changedFiles,
|
||||
workspaceDiff,
|
||||
} from "../../../../adapters/git/repository/changes.js";
|
||||
import {
|
||||
gitSafetyDigest,
|
||||
prepareImplementationBranch,
|
||||
} from "../../../../adapters/git/repository/checkout.js";
|
||||
import type { GiteaClient } from "../../../../adapters/gitea/client/client.js";
|
||||
import {
|
||||
createIssueSnapshot,
|
||||
findAcceptedPlan,
|
||||
validateAgentGeneratedPullRequest,
|
||||
} from "../../../../adapters/gitea/issues.js";
|
||||
import {
|
||||
collectPullRequestFeedback,
|
||||
type PullRequestFeedback,
|
||||
} from "../../../../adapters/gitea/reviews.js";
|
||||
import { OpenCodeRunner } from "../../../../adapters/opencode/runner.js";
|
||||
import { implementationFixSchema } from "../../../../adapters/opencode/schemas.js";
|
||||
import {
|
||||
assertImplementationFixReply,
|
||||
parseMarker,
|
||||
sha256,
|
||||
} from "../../../../core/contracts.js";
|
||||
import { issueContext, type OrchestrationOutput } from "../../context.js";
|
||||
|
||||
export async function runImplementationFix(input: {
|
||||
repositoryId: number;
|
||||
issueNumber: number;
|
||||
pullRequestNumber: number;
|
||||
expectedPlanDigest: string;
|
||||
botLogin: string;
|
||||
client: GiteaClient;
|
||||
workspace: string;
|
||||
readToken: string;
|
||||
existingSessionId?: string;
|
||||
instruction?: string;
|
||||
signal?: AbortSignal;
|
||||
isProcessed: (ref: ReviewFeedbackRef) => boolean;
|
||||
onSession?: (sessionId: string) => void;
|
||||
}): Promise<OrchestrationOutput> {
|
||||
const [issue, comments, repository, pull, allFeedback] = await Promise.all([
|
||||
input.client.getIssue(input.issueNumber),
|
||||
input.client.getComments(input.issueNumber),
|
||||
input.client.getRepository(),
|
||||
input.client.getPullRequest(input.pullRequestNumber),
|
||||
collectPullRequestFeedback(
|
||||
input.client,
|
||||
input.pullRequestNumber,
|
||||
input.botLogin,
|
||||
),
|
||||
]);
|
||||
if (issue.state !== "open" || issue.pull_request || pull.state !== "open")
|
||||
throw new Error(
|
||||
"Implementation fixes require an open issue and pull request",
|
||||
);
|
||||
const snapshot = createIssueSnapshot(issue, comments, input.botLogin);
|
||||
const accepted = findAcceptedPlan(comments, input.botLogin, issue.number);
|
||||
if (!accepted?.marker.planDigest || !accepted.marker.issueDigest)
|
||||
throw new Error("No accepted agent plan was found");
|
||||
if (
|
||||
accepted.marker.planDigest !== input.expectedPlanDigest ||
|
||||
accepted.marker.issueDigest !== snapshot.digest
|
||||
)
|
||||
throw new Error("The accepted plan changed before the fix started");
|
||||
const pullMarker = parseMarker(pull.body, "pull-request");
|
||||
const branch = pullMarker?.branch || "";
|
||||
validateAgentGeneratedPullRequest(pull, {
|
||||
repositoryId: input.repositoryId,
|
||||
sourceIssue: issue.number,
|
||||
planDigest: input.expectedPlanDigest,
|
||||
branch,
|
||||
baseBranch: repository.default_branch,
|
||||
});
|
||||
const feedback = allFeedback.filter(
|
||||
(item) => !input.isProcessed(feedbackRef(input, item)),
|
||||
);
|
||||
const prepared = await prepareImplementationBranch({
|
||||
workspace: input.workspace,
|
||||
branch,
|
||||
baseBranch: repository.default_branch,
|
||||
readToken: input.readToken,
|
||||
...(input.signal ? { signal: input.signal } : {}),
|
||||
});
|
||||
if (prepared.baseSha !== accepted.marker.baseSha)
|
||||
throw new Error("The default branch changed after planning");
|
||||
|
||||
let response = "No new review feedback was found.";
|
||||
let summary = response;
|
||||
let session = input.existingSessionId || "";
|
||||
if (feedback.length || input.instruction?.trim()) {
|
||||
const opencode = new OpenCodeRunner(input.workspace, input.signal);
|
||||
await opencode.start(input.signal);
|
||||
try {
|
||||
session = await opencode.getOrCreateSession(
|
||||
input.existingSessionId,
|
||||
"implementation/ci-implementer",
|
||||
`Address review feedback for issue #${issue.number}`,
|
||||
input.signal,
|
||||
);
|
||||
input.onSession?.(session);
|
||||
const reply = assertImplementationFixReply(
|
||||
await opencode.promptStructured(
|
||||
session,
|
||||
"implementation/ci-implementer",
|
||||
`Address the new pull-request feedback in one conversational turn. Make only necessary code changes. If feedback asks a question or needs clarification, answer it and do not guess. Do not invoke an independent reviewer.\n\n${issueContext(snapshot)}\n\n# Accepted plan\n\n${accepted.markdown}\n\n# New review feedback\n\n${renderFeedback(feedback)}\n\n# Request instruction\n\n${input.instruction?.trim() || "Address the submitted feedback."}`,
|
||||
implementationFixSchema,
|
||||
input.signal,
|
||||
),
|
||||
);
|
||||
response = reply.response;
|
||||
summary = reply.summary;
|
||||
} finally {
|
||||
await opencode.stop();
|
||||
}
|
||||
}
|
||||
if ((await gitSafetyDigest(input.workspace)) !== prepared.gitSafetyDigest)
|
||||
throw new Error("Git metadata changed during execution");
|
||||
const options = input.signal ? { signal: input.signal } : {};
|
||||
const files = await candidateChangedFiles(
|
||||
input.workspace,
|
||||
prepared.baseSha,
|
||||
options,
|
||||
);
|
||||
const pendingFiles = await changedFiles(input.workspace, options);
|
||||
validateChangedFiles(files);
|
||||
validateChangedFiles(pendingFiles);
|
||||
const diff = files.length
|
||||
? await workspaceDiff(input.workspace, prepared.baseSha, options)
|
||||
: "(no changes)";
|
||||
return {
|
||||
sessionId: session,
|
||||
result: {
|
||||
version: 1,
|
||||
mode: "implement",
|
||||
status: pendingFiles.length ? "success" : "no-changes",
|
||||
message: response,
|
||||
implementation: {
|
||||
issueDigest: snapshot.digest,
|
||||
planDigest: input.expectedPlanDigest,
|
||||
branch,
|
||||
baseBranch: repository.default_branch,
|
||||
baseSha: prepared.baseSha,
|
||||
startingRemoteSha: prepared.startingRemoteSha,
|
||||
gitSafetyDigest: prepared.gitSafetyDigest,
|
||||
diffDigest: sha256(diff),
|
||||
changedFiles: files,
|
||||
pendingFiles,
|
||||
summary,
|
||||
iterations: 0,
|
||||
pullRequestNumber: pull.number,
|
||||
response,
|
||||
feedback: feedback.map((item) => {
|
||||
const ref = feedbackRef(input, item);
|
||||
return {
|
||||
kind: ref.kind,
|
||||
objectId: ref.objectId,
|
||||
contentDigest: ref.contentDigest,
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function feedbackRef(
|
||||
input: { repositoryId: number; pullRequestNumber: number },
|
||||
feedback: PullRequestFeedback,
|
||||
): ReviewFeedbackRef {
|
||||
return {
|
||||
repositoryId: input.repositoryId,
|
||||
pullRequestNumber: input.pullRequestNumber,
|
||||
kind:
|
||||
feedback.kind === "review-summary"
|
||||
? "review"
|
||||
: feedback.kind === "inline-comment"
|
||||
? "review-comment"
|
||||
: "comment",
|
||||
objectId: feedback.sourceId,
|
||||
contentDigest: feedback.digest,
|
||||
};
|
||||
}
|
||||
|
||||
function renderFeedback(feedback: PullRequestFeedback[]): string {
|
||||
if (!feedback.length) return "No unprocessed review feedback.";
|
||||
return feedback
|
||||
.map((item) => {
|
||||
const location =
|
||||
item.kind === "inline-comment"
|
||||
? ` at ${item.path} (${item.side}:${item.line})`
|
||||
: "";
|
||||
return `### ${item.author}${location}\n${item.body}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { headSha } from "../../../../adapters/git/repository/checkout.js";
|
||||
import type { GiteaClient } from "../../../../adapters/gitea/client/client.js";
|
||||
import {
|
||||
createIssueSnapshot,
|
||||
findAcceptedPlan,
|
||||
} from "../../../../adapters/gitea/issues.js";
|
||||
import { OpenCodeRunner } from "../../../../adapters/opencode/runner.js";
|
||||
import { planConversationSchema } from "../../../../adapters/opencode/schemas.js";
|
||||
import {
|
||||
assertPlanConversationReply,
|
||||
parseMarker,
|
||||
sha256,
|
||||
} from "../../../../core/contracts.js";
|
||||
import { parseAgentCommand } from "../../../../core/webhook.js";
|
||||
import { issueContext, type OrchestrationOutput } from "../../context.js";
|
||||
|
||||
export async function runPlanDiscussion(input: {
|
||||
issueNumber: number;
|
||||
botLogin: string;
|
||||
client: GiteaClient;
|
||||
workspace: string;
|
||||
existingSessionId?: string;
|
||||
instruction?: string;
|
||||
signal?: AbortSignal;
|
||||
onSession?: (sessionId: string) => void;
|
||||
}): Promise<OrchestrationOutput> {
|
||||
const [issue, comments, repository] = await Promise.all([
|
||||
input.client.getIssue(input.issueNumber),
|
||||
input.client.getComments(input.issueNumber),
|
||||
input.client.getRepository(),
|
||||
]);
|
||||
if (issue.state !== "open" || issue.pull_request)
|
||||
throw new Error("Plan discussion requires an open issue");
|
||||
const accepted = findAcceptedPlan(comments, input.botLogin, issue.number);
|
||||
if (!accepted) throw new Error("No current agent plan was found");
|
||||
const snapshot = createIssueSnapshot(issue, comments, input.botLogin);
|
||||
const base = await input.client.getBranch(repository.default_branch);
|
||||
if (!base) throw new Error("Default branch was not found");
|
||||
if (
|
||||
(await headSha(input.workspace, signalOptions(input.signal))) !==
|
||||
base.commit.id
|
||||
)
|
||||
throw new Error("Trusted checkout does not match the default branch");
|
||||
|
||||
const opencode = new OpenCodeRunner(input.workspace, input.signal);
|
||||
await opencode.start(input.signal);
|
||||
let session = input.existingSessionId || "";
|
||||
try {
|
||||
session = await opencode.getOrCreateSession(
|
||||
input.existingSessionId,
|
||||
"planning/ci-plan-creator",
|
||||
`Discuss plan for issue #${issue.number}`,
|
||||
input.signal,
|
||||
);
|
||||
input.onSession?.(session);
|
||||
const reply = assertPlanConversationReply(
|
||||
await opencode.promptStructured(
|
||||
session,
|
||||
"planning/ci-plan-creator",
|
||||
`Respond to the current plan discussion. Answer questions directly. If the request changes the plan, return a complete replacement plan; otherwise leave planChanged false and both plan fields empty. Do not invoke an independent reviewer. Current issue and repository state are authoritative.\n\n${issueContext(snapshot)}\n\n# Current implementation plan\n\n${accepted.markdown}\n\n# Prior plan conversation\n\n${conversationTranscript(comments, input.botLogin)}\n\n# Current request\n\n${input.instruction?.trim() || "Continue the plan discussion."}`,
|
||||
planConversationSchema,
|
||||
input.signal,
|
||||
),
|
||||
);
|
||||
return {
|
||||
sessionId: session,
|
||||
result: {
|
||||
version: 1,
|
||||
mode: "plan",
|
||||
status: reply.planChanged ? "success" : "no-changes",
|
||||
message: reply.reply,
|
||||
discussion: {
|
||||
issueDigest: snapshot.digest,
|
||||
baseSha: base.commit.id,
|
||||
reply: reply.reply,
|
||||
},
|
||||
...(reply.planChanged
|
||||
? {
|
||||
plan: {
|
||||
issueDigest: snapshot.digest,
|
||||
baseSha: base.commit.id,
|
||||
planDigest: sha256(reply.planMarkdown),
|
||||
markdown: reply.planMarkdown,
|
||||
summary: reply.summary,
|
||||
iterations: 0,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await opencode.stop();
|
||||
}
|
||||
}
|
||||
|
||||
function conversationTranscript(
|
||||
comments: Awaited<ReturnType<GiteaClient["getComments"]>>,
|
||||
botLogin: string,
|
||||
): string {
|
||||
const bot = botLogin.toLowerCase();
|
||||
const selected = comments.filter((comment) => {
|
||||
const command = parseAgentCommand(comment.body);
|
||||
if (comment.user.login.toLowerCase() !== bot)
|
||||
return (
|
||||
command?.action === "discuss" || command?.action === "review"
|
||||
);
|
||||
const found = parseMarker(comment.body);
|
||||
return found?.kind === "conversation" || found?.kind === "review";
|
||||
});
|
||||
const transcript = selected
|
||||
.map(
|
||||
(comment) =>
|
||||
`### ${comment.user.login} (${comment.created_at})\n${comment.body}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
if (transcript.length > 200_000)
|
||||
throw new Error("Plan conversation exceeds the context limit");
|
||||
return transcript || "No prior discussion comments.";
|
||||
}
|
||||
|
||||
function signalOptions(signal?: AbortSignal): { signal?: AbortSignal } {
|
||||
return signal ? { signal } : {};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { validateChangedFiles } from "../../../adapters/git/publication.js";
|
||||
import {
|
||||
candidateChangedFiles,
|
||||
changedFiles,
|
||||
workspaceDiff,
|
||||
} from "../../../adapters/git/repository/changes.js";
|
||||
import {
|
||||
@@ -135,11 +136,16 @@ export async function runImplementation(input: {
|
||||
),
|
||||
);
|
||||
if (review.verdict === "accept") {
|
||||
const pendingFiles = await changedFiles(
|
||||
input.workspace,
|
||||
options,
|
||||
);
|
||||
return acceptedResult({
|
||||
input,
|
||||
accepted,
|
||||
prepared,
|
||||
files,
|
||||
pendingFiles,
|
||||
diff,
|
||||
summary: summary.summary,
|
||||
rationale: review.rationale,
|
||||
@@ -182,6 +188,7 @@ function acceptedResult(value: {
|
||||
gitSafetyDigest: string;
|
||||
};
|
||||
files: string[];
|
||||
pendingFiles: string[];
|
||||
diff: string;
|
||||
summary: string;
|
||||
rationale: string;
|
||||
@@ -212,6 +219,7 @@ function acceptedResult(value: {
|
||||
gitSafetyDigest: value.prepared.gitSafetyDigest,
|
||||
diffDigest: sha256(value.diff),
|
||||
changedFiles: value.files,
|
||||
pendingFiles: value.pendingFiles,
|
||||
summary: value.summary,
|
||||
iterations: value.iteration,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
parseUnifiedDiff,
|
||||
renderUnifiedDiff,
|
||||
validateStructuredFindings,
|
||||
} from "../../../adapters/git/diff.js";
|
||||
import { workspaceDiff } from "../../../adapters/git/repository/changes.js";
|
||||
import { headSha } from "../../../adapters/git/repository/checkout.js";
|
||||
import type { GiteaClient } from "../../../adapters/gitea/client/client.js";
|
||||
import {
|
||||
createIssueSnapshot,
|
||||
findAcceptedPlan,
|
||||
} from "../../../adapters/gitea/issues.js";
|
||||
import { OpenCodeRunner } from "../../../adapters/opencode/runner.js";
|
||||
import {
|
||||
pullReviewSchema,
|
||||
reviewSchema,
|
||||
} from "../../../adapters/opencode/schemas.js";
|
||||
import {
|
||||
assertPullReviewReply,
|
||||
assertReviewDecision,
|
||||
sha256,
|
||||
} from "../../../core/contracts.js";
|
||||
import { issueContext, type OrchestrationOutput } from "../context.js";
|
||||
|
||||
export async function runPlanReview(input: {
|
||||
issueNumber: number;
|
||||
botLogin: string;
|
||||
client: GiteaClient;
|
||||
workspace: string;
|
||||
instruction?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<OrchestrationOutput> {
|
||||
const [issue, comments, repository] = await Promise.all([
|
||||
input.client.getIssue(input.issueNumber),
|
||||
input.client.getComments(input.issueNumber),
|
||||
input.client.getRepository(),
|
||||
]);
|
||||
if (issue.state !== "open" || issue.pull_request)
|
||||
throw new Error("Plan review requires an open issue");
|
||||
const accepted = findAcceptedPlan(comments, input.botLogin, issue.number);
|
||||
if (!accepted?.marker.planDigest)
|
||||
throw new Error("No current agent plan was found");
|
||||
const snapshot = createIssueSnapshot(issue, comments, input.botLogin);
|
||||
const base = await input.client.getBranch(repository.default_branch);
|
||||
if (
|
||||
!base ||
|
||||
(await headSha(input.workspace, options(input.signal))) !==
|
||||
base.commit.id
|
||||
)
|
||||
throw new Error("Trusted checkout does not match the default branch");
|
||||
if (
|
||||
accepted.marker.issueDigest !== snapshot.digest ||
|
||||
accepted.marker.baseSha !== base.commit.id
|
||||
)
|
||||
throw new Error(
|
||||
"The current plan is stale; update it before requesting review",
|
||||
);
|
||||
const opencode = new OpenCodeRunner(input.workspace, input.signal);
|
||||
await opencode.start(input.signal);
|
||||
try {
|
||||
const session = await opencode.createSession(
|
||||
"planning/ci-plan-reviewer",
|
||||
`Review current plan for issue #${issue.number}`,
|
||||
input.signal,
|
||||
);
|
||||
const review = assertReviewDecision(
|
||||
await opencode.promptStructured(
|
||||
session,
|
||||
"planning/ci-plan-reviewer",
|
||||
`Perform one independent review pass. Do not revise the plan. Return only blocking findings and a concise rationale.\n\n${issueContext(snapshot)}\n\n# Current plan\n\n${accepted.markdown}\n\n# Review focus\n\n${input.instruction?.trim() || "Review the complete plan."}`,
|
||||
reviewSchema,
|
||||
input.signal,
|
||||
),
|
||||
);
|
||||
return {
|
||||
sessionId: session,
|
||||
result: {
|
||||
version: 1,
|
||||
mode: "plan",
|
||||
status: "success",
|
||||
message: review.rationale,
|
||||
review: {
|
||||
kind: "plan",
|
||||
issueDigest: snapshot.digest,
|
||||
baseSha: base.commit.id,
|
||||
planDigest: accepted.marker.planDigest,
|
||||
verdict: review.verdict,
|
||||
findings: review.findings,
|
||||
summary: review.rationale,
|
||||
},
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await opencode.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPullReview(input: {
|
||||
pullRequestNumber: number;
|
||||
client: GiteaClient;
|
||||
workspace: string;
|
||||
mergeBaseSha: string;
|
||||
headSha: string;
|
||||
instruction?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<OrchestrationOutput> {
|
||||
const pull = await input.client.getPullRequest(input.pullRequestNumber);
|
||||
if (pull.state !== "open")
|
||||
throw new Error("Review requires an open pull request");
|
||||
if (
|
||||
pull.head.sha !== input.headSha ||
|
||||
pull.merge_base !== input.mergeBaseSha
|
||||
)
|
||||
throw new Error("Pull request changed before review started");
|
||||
if (
|
||||
(await headSha(input.workspace, options(input.signal))) !==
|
||||
input.headSha
|
||||
)
|
||||
throw new Error(
|
||||
"Trusted checkout does not match the pull request head",
|
||||
);
|
||||
const diff = await workspaceDiff(
|
||||
input.workspace,
|
||||
input.mergeBaseSha,
|
||||
options(input.signal),
|
||||
);
|
||||
const parsed = parseUnifiedDiff(diff);
|
||||
const opencode = new OpenCodeRunner(input.workspace, input.signal);
|
||||
await opencode.start(input.signal);
|
||||
try {
|
||||
const session = await opencode.createSession(
|
||||
"implementation/ci-code-reviewer",
|
||||
`Review pull request #${pull.number}`,
|
||||
input.signal,
|
||||
);
|
||||
const reply = assertPullReviewReply(
|
||||
await opencode.promptStructured(
|
||||
session,
|
||||
"implementation/ci-code-reviewer",
|
||||
`Perform one independent review pass for this pull request. Return only concrete, blocking findings. Anchor every line-specific finding using an exact [side:line:path] reference shown in the annotated diff. Put findings that cannot be safely anchored in generalFindings. Do not edit files.\n\n# Pull request\n\n## Title\n${pull.title}\n\n## Description\n${pull.body || "(empty)"}\n\n# Review focus\n\n${input.instruction?.trim() || "Review the complete pull request."}\n\n# Annotated diff\n\n${renderUnifiedDiff(parsed)}`,
|
||||
pullReviewSchema,
|
||||
input.signal,
|
||||
),
|
||||
);
|
||||
const findings = validateStructuredFindings(reply.findings, parsed);
|
||||
return {
|
||||
sessionId: session,
|
||||
result: {
|
||||
version: 1,
|
||||
mode: "implement",
|
||||
status: "success",
|
||||
message: reply.summary,
|
||||
review: {
|
||||
kind: "pull",
|
||||
pullRequestNumber: pull.number,
|
||||
headSha: input.headSha,
|
||||
mergeBaseSha: input.mergeBaseSha,
|
||||
diffDigest: sha256(diff),
|
||||
summary: reply.summary,
|
||||
generalFindings: reply.generalFindings,
|
||||
findings,
|
||||
},
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await opencode.stop();
|
||||
}
|
||||
}
|
||||
|
||||
function options(signal?: AbortSignal): { signal?: AbortSignal } {
|
||||
return signal ? { signal } : {};
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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,
|
||||
headSha,
|
||||
} from "../../../../adapters/git/repository/checkout.js";
|
||||
import {
|
||||
createIssueSnapshot,
|
||||
findAcceptedPlan,
|
||||
validateAgentGeneratedPullRequest,
|
||||
} from "../../../../adapters/gitea/issues.js";
|
||||
import { marker, protocolVersion, sha256 } from "../../../../core/contracts.js";
|
||||
import {
|
||||
type PublicationContext,
|
||||
type PublicationOutcome,
|
||||
upsertJobStatus,
|
||||
} from "../../status.js";
|
||||
|
||||
export async function publishImplementationFix(
|
||||
context: PublicationContext,
|
||||
job: Job,
|
||||
): Promise<PublicationOutcome> {
|
||||
const implementation = job.result?.implementation;
|
||||
const workspace = job.workspace;
|
||||
if (!implementation || !workspace || !implementation.pullRequestNumber)
|
||||
throw new Error("Implementation fix result is incomplete");
|
||||
if (implementation.pullRequestNumber !== job.targetNumber)
|
||||
throw new Error("Implementation fix targets a different pull request");
|
||||
if (
|
||||
!context.isPublishedImplementation?.(
|
||||
job.issueNumber,
|
||||
job.targetNumber,
|
||||
implementation.planDigest,
|
||||
)
|
||||
)
|
||||
throw new Error("Fix target is not a published agent implementation");
|
||||
const [issue, comments, currentBase, pull] = await Promise.all([
|
||||
context.client.getIssue(job.issueNumber),
|
||||
context.client.getComments(job.issueNumber),
|
||||
context.client.getBranch(implementation.baseBranch),
|
||||
context.client.getPullRequest(job.targetNumber),
|
||||
]);
|
||||
const snapshot = createIssueSnapshot(issue, comments, context.botLogin);
|
||||
const accepted = findAcceptedPlan(
|
||||
comments,
|
||||
context.botLogin,
|
||||
job.issueNumber,
|
||||
);
|
||||
if (
|
||||
snapshot.digest !== implementation.issueDigest ||
|
||||
accepted?.marker.planDigest !== implementation.planDigest ||
|
||||
currentBase?.commit.id !== implementation.baseSha
|
||||
)
|
||||
throw new Error(
|
||||
"Issue, plan, or default branch changed while fixing review feedback",
|
||||
);
|
||||
validateAgentGeneratedPullRequest(pull, {
|
||||
repositoryId: context.repositoryId,
|
||||
sourceIssue: job.issueNumber,
|
||||
planDigest: implementation.planDigest,
|
||||
branch: implementation.branch,
|
||||
baseBranch: implementation.baseBranch,
|
||||
});
|
||||
const options = context.signal ? { signal: context.signal } : {};
|
||||
const localHead = await headSha(workspace, options);
|
||||
if (
|
||||
pull.state !== "open" ||
|
||||
(pull.head.sha !== implementation.startingRemoteSha &&
|
||||
pull.head.sha !== localHead)
|
||||
)
|
||||
throw new Error(
|
||||
"Pull request head changed while fixing review feedback",
|
||||
);
|
||||
if ((await gitSafetyDigest(workspace)) !== implementation.gitSafetyDigest)
|
||||
throw new Error("Git metadata changed during execution");
|
||||
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 the reviewed fix");
|
||||
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 the fix result");
|
||||
|
||||
let commitSha: string | undefined;
|
||||
if (implementation.pendingFiles.length) {
|
||||
if (context.isCancelled?.(job.id))
|
||||
throw new Error("Publication cancelled before repository write");
|
||||
commitSha = await commitAndPush({
|
||||
workspace,
|
||||
files: implementation.pendingFiles,
|
||||
branch: implementation.branch,
|
||||
token: context.writeToken,
|
||||
pushUrl: `${context.serverUrl}/${context.repository.owner}/${context.repository.repo}.git`,
|
||||
message: `fix: address review for #${job.issueNumber}`,
|
||||
expectedRemoteSha: implementation.startingRemoteSha,
|
||||
baseSha: implementation.baseSha,
|
||||
expectedDiffDigest: implementation.diffDigest,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
const responseMarker = {
|
||||
v: protocolVersion,
|
||||
kind: "conversation" as const,
|
||||
issue: job.issueNumber,
|
||||
mode: "implement" as const,
|
||||
request: job.id,
|
||||
pullRequest: job.targetNumber,
|
||||
};
|
||||
await context.client.upsertMarkedComment(
|
||||
job.targetNumber,
|
||||
context.botLogin,
|
||||
responseMarker,
|
||||
`${marker(responseMarker)}\n${implementation.response || job.result?.message || "Review feedback addressed."}`,
|
||||
);
|
||||
await upsertJobStatus(
|
||||
context.client,
|
||||
context.botLogin,
|
||||
job,
|
||||
"Agent review fix completed",
|
||||
implementation.pendingFiles.length
|
||||
? "The pull-request branch was updated and a response was posted."
|
||||
: "A response was posted without code changes.",
|
||||
);
|
||||
return {
|
||||
terminal: "succeeded",
|
||||
...(commitSha ? { commitSha } : {}),
|
||||
pullRequestNumber: job.targetNumber,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Job } from "../../../../adapters/database/store.js";
|
||||
import {
|
||||
createIssueSnapshot,
|
||||
findAcceptedPlan,
|
||||
} from "../../../../adapters/gitea/issues.js";
|
||||
import { marker, protocolVersion } from "../../../../core/contracts.js";
|
||||
import {
|
||||
type PublicationContext,
|
||||
type PublicationOutcome,
|
||||
upsertJobStatus,
|
||||
} from "../../status.js";
|
||||
import { publishPlan } from "../plan.js";
|
||||
|
||||
export async function publishPlanDiscussion(
|
||||
context: PublicationContext,
|
||||
job: Job,
|
||||
): Promise<PublicationOutcome> {
|
||||
const discussion = job.result?.discussion;
|
||||
if (!discussion) throw new Error("Plan discussion has no response");
|
||||
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 !== discussion.issueDigest ||
|
||||
base?.commit.id !== discussion.baseSha
|
||||
)
|
||||
throw new Error(
|
||||
"Issue or default branch changed during plan discussion",
|
||||
);
|
||||
const planOutcome = job.result?.plan
|
||||
? await publishPlan(context, job)
|
||||
: { terminal: "succeeded" as const };
|
||||
const responseMarker = {
|
||||
v: protocolVersion,
|
||||
kind: "conversation" as const,
|
||||
issue: job.issueNumber,
|
||||
mode: "plan" as const,
|
||||
request: job.id,
|
||||
};
|
||||
await context.client.upsertMarkedComment(
|
||||
job.targetNumber,
|
||||
context.botLogin,
|
||||
responseMarker,
|
||||
`${marker(responseMarker)}\n${discussion.reply}`,
|
||||
);
|
||||
if (!job.result?.plan)
|
||||
await upsertJobStatus(
|
||||
context.client,
|
||||
context.botLogin,
|
||||
job,
|
||||
"Agent plan discussion completed",
|
||||
"The planner replied without changing the current plan.",
|
||||
);
|
||||
return planOutcome;
|
||||
}
|
||||
|
||||
export async function publishPlanReview(
|
||||
context: PublicationContext,
|
||||
job: Job,
|
||||
): Promise<PublicationOutcome> {
|
||||
const review = job.result?.review;
|
||||
if (review?.kind !== "plan")
|
||||
throw new Error("Plan review result is missing");
|
||||
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 accepted = findAcceptedPlan(
|
||||
comments,
|
||||
context.botLogin,
|
||||
job.issueNumber,
|
||||
);
|
||||
const base = await context.client.getBranch(repository.default_branch);
|
||||
if (
|
||||
snapshot.digest !== review.issueDigest ||
|
||||
accepted?.marker.planDigest !== review.planDigest ||
|
||||
base?.commit.id !== review.baseSha
|
||||
)
|
||||
throw new Error("Plan changed while its review was being published");
|
||||
const responseMarker = {
|
||||
v: protocolVersion,
|
||||
kind: "review" as const,
|
||||
issue: job.issueNumber,
|
||||
mode: "plan" as const,
|
||||
request: job.id,
|
||||
};
|
||||
const findings = review.findings.length
|
||||
? review.findings.map((finding) => `- ${finding}`).join("\n")
|
||||
: "No blocking findings.";
|
||||
const body = `${marker(responseMarker)}\n## Agent plan review\n\n**Verdict:** ${review.verdict}\n\n${findings}\n\n${review.summary}`;
|
||||
await context.client.upsertMarkedComment(
|
||||
job.targetNumber,
|
||||
context.botLogin,
|
||||
responseMarker,
|
||||
body,
|
||||
);
|
||||
await upsertJobStatus(
|
||||
context.client,
|
||||
context.botLogin,
|
||||
job,
|
||||
"Agent plan review completed",
|
||||
review.verdict === "accept"
|
||||
? "No blocking plan findings were reported."
|
||||
: `${review.findings.length} blocking finding(s) were published.`,
|
||||
);
|
||||
return { terminal: "succeeded" };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Job } from "../../../../adapters/database/store.js";
|
||||
import {
|
||||
parseUnifiedDiff,
|
||||
renderPullReviewComments,
|
||||
validateStructuredFindings,
|
||||
} from "../../../../adapters/git/diff.js";
|
||||
import { workspaceDiff } from "../../../../adapters/git/repository/changes.js";
|
||||
import { headSha } from "../../../../adapters/git/repository/checkout.js";
|
||||
import {
|
||||
marker,
|
||||
parseMarker,
|
||||
protocolVersion,
|
||||
sha256,
|
||||
} from "../../../../core/contracts.js";
|
||||
import {
|
||||
type PublicationContext,
|
||||
type PublicationOutcome,
|
||||
upsertJobStatus,
|
||||
} from "../../status.js";
|
||||
|
||||
export async function publishPullReview(
|
||||
context: PublicationContext,
|
||||
job: Job,
|
||||
): Promise<PublicationOutcome> {
|
||||
const review = job.result?.review;
|
||||
const workspace = job.workspace;
|
||||
if (review?.kind !== "pull" || !workspace)
|
||||
throw new Error("Pull review result is incomplete");
|
||||
const pull = await context.client.getPullRequest(job.targetNumber);
|
||||
if (
|
||||
pull.state !== "open" ||
|
||||
pull.number !== review.pullRequestNumber ||
|
||||
pull.head.sha !== review.headSha ||
|
||||
pull.merge_base !== review.mergeBaseSha
|
||||
)
|
||||
throw new Error(
|
||||
"Pull request changed while its review was being published",
|
||||
);
|
||||
const options = context.signal ? { signal: context.signal } : {};
|
||||
if ((await headSha(workspace, options)) !== review.headSha)
|
||||
throw new Error(
|
||||
"Review workspace does not match the pull request head",
|
||||
);
|
||||
const diff = await workspaceDiff(workspace, review.mergeBaseSha, options);
|
||||
if (sha256(diff) !== review.diffDigest)
|
||||
throw new Error("Pull-request diff changed after review");
|
||||
const parsed = parseUnifiedDiff(diff);
|
||||
const findings = validateStructuredFindings(review.findings, parsed);
|
||||
const reviewMarker = {
|
||||
v: protocolVersion,
|
||||
kind: "review" as const,
|
||||
issue: job.issueNumber,
|
||||
mode: "implement" as const,
|
||||
request: job.id,
|
||||
pullRequest: job.targetNumber,
|
||||
};
|
||||
const reviews = await context.client.listPullReviews(job.targetNumber);
|
||||
const existing = reviews.find((candidate) => {
|
||||
if (
|
||||
candidate.user.login.toLowerCase() !==
|
||||
context.botLogin.toLowerCase()
|
||||
)
|
||||
return false;
|
||||
const found = parseMarker(candidate.body, "review");
|
||||
return (
|
||||
found?.request === job.id && found.pullRequest === job.targetNumber
|
||||
);
|
||||
});
|
||||
const pending = reviews.filter(
|
||||
(candidate) =>
|
||||
candidate.user.login.toLowerCase() ===
|
||||
context.botLogin.toLowerCase() &&
|
||||
candidate.state.toUpperCase() === "PENDING" &&
|
||||
(!candidate.commit_id || candidate.commit_id === review.headSha),
|
||||
);
|
||||
for (const candidate of pending)
|
||||
await context.client.deletePullReview(job.targetNumber, candidate.id);
|
||||
if (existing && existing.state.toUpperCase() !== "PENDING") {
|
||||
await publishStatus(context, job, findings.length);
|
||||
return { terminal: "succeeded", reviewId: existing.id };
|
||||
}
|
||||
const general = review.generalFindings.length
|
||||
? `\n\n## General findings\n\n${review.generalFindings.map((item) => `- ${item}`).join("\n")}`
|
||||
: "";
|
||||
const body = `${marker(reviewMarker)}\n${review.summary}${general}`;
|
||||
const published = await context.client.createPullReview(job.targetNumber, {
|
||||
event: "COMMENT",
|
||||
body,
|
||||
commit_id: review.headSha,
|
||||
comments: renderPullReviewComments(findings),
|
||||
});
|
||||
await publishStatus(context, job, findings.length);
|
||||
return { terminal: "succeeded", reviewId: published.id };
|
||||
}
|
||||
|
||||
async function publishStatus(
|
||||
context: PublicationContext,
|
||||
job: Job,
|
||||
findingCount: number,
|
||||
): Promise<void> {
|
||||
await upsertJobStatus(
|
||||
context.client,
|
||||
context.botLogin,
|
||||
job,
|
||||
"Agent pull request review completed",
|
||||
findingCount
|
||||
? `${findingCount} inline finding(s) were published.`
|
||||
: "No blocking inline findings were reported.",
|
||||
);
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export async function publishImplementation(
|
||||
throw new Error("Publication cancelled before repository write");
|
||||
const commitSha = await commitAndPush({
|
||||
workspace,
|
||||
files: actualFiles,
|
||||
files: implementation.pendingFiles || implementation.changedFiles,
|
||||
branch: implementation.branch,
|
||||
token: context.writeToken,
|
||||
pushUrl: `${context.serverUrl}/${context.repository.owner}/${context.repository.repo}.git`,
|
||||
|
||||
@@ -41,7 +41,10 @@ export async function publishPlan(
|
||||
baseSha: plan.baseSha,
|
||||
planDigest: plan.planDigest,
|
||||
};
|
||||
const body = `${marker(planMarker)}\n## Accepted implementation plan\n\n${plan.markdown}\n\n<!-- gitea-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 reviewDetail = plan.iterations
|
||||
? `Review iterations: ${plan.iterations}`
|
||||
: "Independent review: not requested";
|
||||
const body = `${marker(planMarker)}\n## Accepted implementation plan\n\n${plan.markdown}\n\n<!-- gitea-agent:plan-footer -->\n\n**Summary:** ${plan.summary}\n\nBase: \`${plan.baseSha.slice(0, 12)}\` | ${reviewDetail} | Plan digest: \`${plan.planDigest.slice(0, 12)}\``;
|
||||
const comment = await context.client.upsertMarkedComment(
|
||||
job.issueNumber,
|
||||
context.botLogin,
|
||||
@@ -54,7 +57,9 @@ export async function publishPlan(
|
||||
context.botLogin,
|
||||
job,
|
||||
"Agent plan accepted",
|
||||
`The accepted plan was published after ${plan.iterations} review iteration(s).`,
|
||||
plan.iterations
|
||||
? `The accepted plan was published after ${plan.iterations} review iteration(s).`
|
||||
: "The current plan was updated without an independent review.",
|
||||
);
|
||||
return { terminal: "succeeded", planCommentId: comment.id };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { Job } from "../../adapters/database/store.js";
|
||||
import { blockedLabel } from "../../core/contracts.js";
|
||||
import { publishImplementationFix } from "./handlers/conversations/implementation.js";
|
||||
import {
|
||||
publishPlanDiscussion,
|
||||
publishPlanReview,
|
||||
} from "./handlers/conversations/plan.js";
|
||||
import { publishPullReview } from "./handlers/conversations/review.js";
|
||||
import { publishImplementation } from "./handlers/implementation.js";
|
||||
import { publishPlan } from "./handlers/plan.js";
|
||||
import {
|
||||
@@ -25,19 +31,27 @@ export async function publishJob(
|
||||
return { terminal: "cancelled" };
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
await context.client.addLabelIfPresent(job.issueNumber, blockedLabel);
|
||||
if (job.kind === "plan" || job.kind === "implement")
|
||||
await context.client.addLabelIfPresent(
|
||||
job.targetNumber,
|
||||
blockedLabel,
|
||||
);
|
||||
await upsertJobStatus(
|
||||
context.client,
|
||||
context.botLogin,
|
||||
job,
|
||||
`Agent ${job.mode} failed`,
|
||||
`Agent ${job.kind.replaceAll("-", " ")} failed`,
|
||||
failureDetail(job.id, result.message),
|
||||
);
|
||||
return { terminal: "failed" };
|
||||
}
|
||||
return result.mode === "plan"
|
||||
? publishPlan(context, job)
|
||||
: publishImplementation(context, job);
|
||||
if (job.kind === "plan") return publishPlan(context, job);
|
||||
if (job.kind === "plan-discuss") return publishPlanDiscussion(context, job);
|
||||
if (job.kind === "plan-review") return publishPlanReview(context, job);
|
||||
if (job.kind === "implementation-fix")
|
||||
return publishImplementationFix(context, job);
|
||||
if (job.kind === "pull-review") return publishPullReview(context, job);
|
||||
return publishImplementation(context, job);
|
||||
}
|
||||
|
||||
export function failureDetail(jobId: string, message: string): string {
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface PublicationContext {
|
||||
writeToken: string;
|
||||
signal?: AbortSignal;
|
||||
isCancelled?: (jobId: string) => boolean;
|
||||
isPublishedImplementation?: (
|
||||
issueNumber: number,
|
||||
pullRequestNumber: number,
|
||||
planDigest: string,
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
export interface PublicationOutcome {
|
||||
@@ -21,6 +26,7 @@ export interface PublicationOutcome {
|
||||
planCommentId?: number;
|
||||
commitSha?: string;
|
||||
pullRequestNumber?: number;
|
||||
reviewId?: number;
|
||||
}
|
||||
|
||||
export async function upsertJobStatus(
|
||||
@@ -32,8 +38,10 @@ export async function upsertJobStatus(
|
||||
): Promise<GiteaComment> {
|
||||
const expected = statusMarker(job.issueNumber, job.mode);
|
||||
expected.request = job.id;
|
||||
if (job.targetNumber !== job.issueNumber)
|
||||
expected.pullRequest = job.targetNumber;
|
||||
return client.upsertMarkedComment(
|
||||
job.issueNumber,
|
||||
job.targetNumber,
|
||||
botLogin,
|
||||
expected,
|
||||
renderStatus({ marker: expected, heading, detail }),
|
||||
@@ -48,15 +56,15 @@ export async function claimJob(
|
||||
context.client,
|
||||
context.botLogin,
|
||||
job,
|
||||
`Agent ${job.mode} queued`,
|
||||
`Agent ${job.kind.replaceAll("-", " ")} 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 issue = await context.client.getIssue(job.targetNumber);
|
||||
const label = issue.labels.find(
|
||||
(candidate) => candidate.name === job.triggerLabel,
|
||||
);
|
||||
if (label) await context.client.removeLabel(job.issueNumber, label.id);
|
||||
if (label) await context.client.removeLabel(job.targetNumber, label.id);
|
||||
}
|
||||
|
||||
export function safeFailure(value: unknown): string {
|
||||
|
||||
+45
-82
@@ -1,11 +1,36 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type {
|
||||
FeedbackReference,
|
||||
PlanDiscussionData,
|
||||
PlanReviewData,
|
||||
PullReviewData,
|
||||
} from "./features/contracts.js";
|
||||
|
||||
export type {
|
||||
FeedbackReference,
|
||||
ImplementationFixReply,
|
||||
PlanConversationReply,
|
||||
PlanDiscussionData,
|
||||
PlanReviewData,
|
||||
PullReviewData,
|
||||
PullReviewFinding,
|
||||
PullReviewReply,
|
||||
} from "./features/contracts.js";
|
||||
export {
|
||||
assertImplementationFixReply,
|
||||
assertImplementationSummary,
|
||||
assertPlanConversationReply,
|
||||
assertPlanDraft,
|
||||
assertPullReviewReply,
|
||||
assertReviewDecision,
|
||||
} from "./features/validation.js";
|
||||
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 const reviewFixLabel = "agent:fix-review";
|
||||
|
||||
export function log(
|
||||
level: string,
|
||||
@@ -41,8 +66,12 @@ export interface ImplementationData {
|
||||
gitSafetyDigest: string;
|
||||
diffDigest: string;
|
||||
changedFiles: string[];
|
||||
pendingFiles: string[];
|
||||
summary: string;
|
||||
iterations: number;
|
||||
pullRequestNumber?: number;
|
||||
response?: string;
|
||||
feedback?: FeedbackReference[];
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
@@ -52,11 +81,19 @@ export interface Result {
|
||||
message: string;
|
||||
plan?: PlanData;
|
||||
implementation?: ImplementationData;
|
||||
discussion?: PlanDiscussionData;
|
||||
review?: PlanReviewData | PullReviewData;
|
||||
}
|
||||
|
||||
export interface Marker {
|
||||
v: number;
|
||||
kind: "status" | "plan" | "implementation" | "pull-request";
|
||||
kind:
|
||||
| "status"
|
||||
| "plan"
|
||||
| "implementation"
|
||||
| "pull-request"
|
||||
| "conversation"
|
||||
| "review";
|
||||
issue: number;
|
||||
mode?: Mode;
|
||||
request?: string;
|
||||
@@ -65,6 +102,7 @@ export interface Marker {
|
||||
baseSha?: string;
|
||||
planDigest?: string;
|
||||
branch?: string;
|
||||
pullRequest?: number;
|
||||
}
|
||||
|
||||
export interface IssueSnapshot {
|
||||
@@ -131,9 +169,12 @@ export function parseMarker(
|
||||
}
|
||||
}
|
||||
|
||||
export function statusMarker(issue: number, mode: Mode): Marker {
|
||||
return { v: protocolVersion, kind: "status", issue, mode };
|
||||
}
|
||||
export const statusMarker = (issue: number, mode: Mode): Marker => ({
|
||||
v: protocolVersion,
|
||||
kind: "status",
|
||||
issue,
|
||||
mode,
|
||||
});
|
||||
|
||||
export function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
@@ -145,81 +186,3 @@ export function formatError(error: unknown): string {
|
||||
}
|
||||
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,58 @@
|
||||
export interface FeedbackReference {
|
||||
kind: "comment" | "review" | "review-comment";
|
||||
objectId: number;
|
||||
contentDigest: string;
|
||||
}
|
||||
|
||||
export interface PlanDiscussionData {
|
||||
issueDigest: string;
|
||||
baseSha: string;
|
||||
reply: string;
|
||||
}
|
||||
|
||||
export interface PlanConversationReply {
|
||||
reply: string;
|
||||
planChanged: boolean;
|
||||
planMarkdown: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ImplementationFixReply {
|
||||
response: string;
|
||||
summary: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export interface PlanReviewData {
|
||||
kind: "plan";
|
||||
issueDigest: string;
|
||||
baseSha: string;
|
||||
planDigest: string;
|
||||
verdict: "accept" | "revise";
|
||||
findings: string[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface PullReviewFinding {
|
||||
path: string;
|
||||
side: "old" | "new";
|
||||
line: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface PullReviewData {
|
||||
kind: "pull";
|
||||
pullRequestNumber: number;
|
||||
headSha: string;
|
||||
mergeBaseSha: string;
|
||||
diffDigest: string;
|
||||
summary: string;
|
||||
generalFindings: string[];
|
||||
findings: PullReviewFinding[];
|
||||
}
|
||||
|
||||
export interface PullReviewReply {
|
||||
summary: string;
|
||||
generalFindings: string[];
|
||||
findings: PullReviewFinding[];
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type {
|
||||
ImplementationSummary,
|
||||
PlanDraft,
|
||||
ReviewDecision,
|
||||
} from "../contracts.js";
|
||||
import type {
|
||||
ImplementationFixReply,
|
||||
PlanConversationReply,
|
||||
PullReviewReply,
|
||||
} from "./contracts.js";
|
||||
|
||||
const maximumText = 20_000;
|
||||
|
||||
export function assertPlanDraft(value: unknown): PlanDraft {
|
||||
const draft = value as Partial<PlanDraft>;
|
||||
if (
|
||||
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");
|
||||
return {
|
||||
planMarkdown: draft.planMarkdown.trim(),
|
||||
summary: requiredText(draft.summary, "Planner returned no summary"),
|
||||
};
|
||||
}
|
||||
|
||||
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") ||
|
||||
review.findings.length > 100
|
||||
)
|
||||
throw new Error("Reviewer returned invalid findings");
|
||||
return {
|
||||
verdict: review.verdict,
|
||||
findings: review.findings.map((item) => boundedText(item)),
|
||||
rationale: requiredText(
|
||||
review.rationale,
|
||||
"Reviewer returned no rationale",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertImplementationSummary(
|
||||
value: unknown,
|
||||
): ImplementationSummary {
|
||||
const summary = value as Partial<ImplementationSummary>;
|
||||
if (
|
||||
!Array.isArray(summary?.files) ||
|
||||
!summary.files.every((item) => typeof item === "string") ||
|
||||
summary.files.length > 100 ||
|
||||
summary.files.some((item) => item.length > 1_000)
|
||||
)
|
||||
throw new Error("Implementation agent returned an invalid file list");
|
||||
return {
|
||||
summary: requiredText(
|
||||
summary.summary,
|
||||
"Implementation agent returned no summary",
|
||||
),
|
||||
files: summary.files,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertPlanConversationReply(
|
||||
value: unknown,
|
||||
): PlanConversationReply {
|
||||
const reply = value as Partial<PlanConversationReply>;
|
||||
const output = {
|
||||
reply: requiredText(reply?.reply, "Planner returned no reply"),
|
||||
planChanged: reply?.planChanged === true,
|
||||
planMarkdown: optionalText(reply?.planMarkdown, 200_000),
|
||||
summary: optionalText(reply?.summary),
|
||||
};
|
||||
if (
|
||||
output.planChanged &&
|
||||
(output.planMarkdown.length < 40 || !output.summary)
|
||||
) {
|
||||
throw new Error("Planner returned an invalid replacement plan");
|
||||
}
|
||||
if (!output.planChanged && (output.planMarkdown || output.summary))
|
||||
throw new Error("Planner returned plan content without changing it");
|
||||
return output;
|
||||
}
|
||||
|
||||
export function assertImplementationFixReply(
|
||||
value: unknown,
|
||||
): ImplementationFixReply {
|
||||
const reply = value as Partial<ImplementationFixReply>;
|
||||
if (
|
||||
!Array.isArray(reply?.files) ||
|
||||
!reply.files.every((item) => typeof item === "string") ||
|
||||
reply.files.length > 100
|
||||
) {
|
||||
throw new Error("Implementation agent returned an invalid file list");
|
||||
}
|
||||
return {
|
||||
response: requiredText(
|
||||
reply.response,
|
||||
"Implementation agent returned no response",
|
||||
),
|
||||
summary: requiredText(
|
||||
reply.summary,
|
||||
"Implementation agent returned no summary",
|
||||
),
|
||||
files: reply.files,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertPullReviewReply(value: unknown): PullReviewReply {
|
||||
const reply = value as Partial<PullReviewReply>;
|
||||
if (
|
||||
!Array.isArray(reply?.generalFindings) ||
|
||||
!reply.generalFindings.every((item) => typeof item === "string") ||
|
||||
reply.generalFindings.length > 100
|
||||
) {
|
||||
throw new Error("Reviewer returned invalid general findings");
|
||||
}
|
||||
if (!Array.isArray(reply.findings) || reply.findings.length > 100)
|
||||
throw new Error("Reviewer returned invalid inline findings");
|
||||
return {
|
||||
summary: requiredText(reply.summary, "Reviewer returned no summary"),
|
||||
generalFindings: reply.generalFindings.map((item) => boundedText(item)),
|
||||
findings: reply.findings as PullReviewReply["findings"],
|
||||
};
|
||||
}
|
||||
|
||||
function requiredText(value: unknown, message: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error(message);
|
||||
return boundedText(value);
|
||||
}
|
||||
|
||||
function optionalText(value: unknown, maximum = maximumText): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value !== "string")
|
||||
throw new Error("Agent returned invalid text");
|
||||
const text = value.trim();
|
||||
if (text.length > maximum) throw new Error("Agent returned oversized text");
|
||||
return text;
|
||||
}
|
||||
|
||||
function boundedText(value: string): string {
|
||||
const text = value.trim();
|
||||
if (text.length > maximumText)
|
||||
throw new Error("Agent returned oversized text");
|
||||
return text;
|
||||
}
|
||||
+51
-2
@@ -35,9 +35,21 @@ export interface CommentPayload {
|
||||
is_pull: boolean;
|
||||
}
|
||||
|
||||
export interface PullLabelPayload {
|
||||
action: "label_updated" | "label_cleared";
|
||||
pullRequest: { number: number; state: string };
|
||||
addedLabels: Array<{ id: number; name: string }>;
|
||||
removedLabels: Array<{ id: number; name: string }>;
|
||||
repository: WebhookRepository;
|
||||
sender: WebhookUser;
|
||||
}
|
||||
|
||||
export type AgentCommand =
|
||||
| { action: "plan" | "implement"; mode: Mode; instruction: string }
|
||||
| { action: "continue" | "retry"; instruction: string }
|
||||
| {
|
||||
action: "continue" | "retry" | "discuss" | "fix" | "review";
|
||||
instruction: string;
|
||||
}
|
||||
| { action: "cancel" | "status"; instruction: "" };
|
||||
|
||||
export function verifyGiteaSignature(
|
||||
@@ -56,7 +68,7 @@ export function verifyGiteaSignature(
|
||||
|
||||
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,
|
||||
/^\s*\/agent(?:\s+(plan|implement|continue|retry|discuss|fix|review|cancel|status))?(?:[ \t]+([^\n]*))?(?:\n([\s\S]*))?\s*$/i,
|
||||
);
|
||||
if (!match?.[1]) return undefined;
|
||||
const action = match[1].toLowerCase() as AgentCommand["action"];
|
||||
@@ -105,6 +117,29 @@ export function parseCommentPayload(value: unknown): CommentPayload {
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePullLabelPayload(value: unknown): PullLabelPayload {
|
||||
const payload = asObject(value, "payload");
|
||||
const action = payload.action;
|
||||
if (action !== "label_updated" && action !== "label_cleared")
|
||||
throw new Error("Unsupported pull request label action");
|
||||
const pull = asObject(payload.pull_request, "pull_request");
|
||||
const changes = asObject(payload.changes, "changes");
|
||||
return {
|
||||
action,
|
||||
pullRequest: {
|
||||
number: positiveInteger(pull.number, "pull_request.number"),
|
||||
state: stringValue(pull.state, "pull_request.state"),
|
||||
},
|
||||
addedLabels: parseLabels(changes.added_labels, "changes.added_labels"),
|
||||
removedLabels: parseLabels(
|
||||
changes.removed_labels,
|
||||
"changes.removed_labels",
|
||||
),
|
||||
repository: parseRepository(payload.repository),
|
||||
sender: parseUser(payload.sender, "sender"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseIssue(value: unknown): WebhookIssue {
|
||||
const issue = asObject(value, "issue");
|
||||
const labels =
|
||||
@@ -128,6 +163,20 @@ function parseIssue(value: unknown): WebhookIssue {
|
||||
};
|
||||
}
|
||||
|
||||
function parseLabels(
|
||||
value: unknown,
|
||||
name: string,
|
||||
): Array<{ id: number; name: string }> {
|
||||
if (value === undefined || value === null) return [];
|
||||
return arrayValue(value, name).map((value) => {
|
||||
const label = asObject(value, name);
|
||||
return {
|
||||
id: positiveInteger(label.id, `${name}.id`),
|
||||
name: stringValue(label.name, `${name}.name`),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseRepository(value: unknown): WebhookRepository {
|
||||
const repository = asObject(value, "repository");
|
||||
return {
|
||||
|
||||
@@ -5,12 +5,77 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
parseUnifiedDiff,
|
||||
renderPullReviewComments,
|
||||
validateStructuredFindings,
|
||||
} from "../../adapters/git/diff.js";
|
||||
import { commitAndPush } from "../../adapters/git/publication.js";
|
||||
import { workspaceDiff } from "../../adapters/git/repository/changes.js";
|
||||
import { sha256 } from "../../core/contracts.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
const anchoredDiff = `diff --git a/file.txt b/file.txt
|
||||
index 1111111..2222222 100644
|
||||
--- a/file.txt
|
||||
+++ b/file.txt
|
||||
@@ -10,3 +10,3 @@
|
||||
context
|
||||
-deleted
|
||||
+added
|
||||
context again
|
||||
`;
|
||||
|
||||
test("unified diffs expose add, delete, and context anchors", () => {
|
||||
const parsed = parseUnifiedDiff(anchoredDiff);
|
||||
assert.deepEqual(parsed.anchors, [
|
||||
{ path: "file.txt", side: "old", line: 10 },
|
||||
{ path: "file.txt", side: "new", line: 10 },
|
||||
{ path: "file.txt", side: "old", line: 11 },
|
||||
{ path: "file.txt", side: "new", line: 11 },
|
||||
{ path: "file.txt", side: "old", line: 12 },
|
||||
{ path: "file.txt", side: "new", line: 12 },
|
||||
]);
|
||||
|
||||
const findings = validateStructuredFindings(
|
||||
[
|
||||
{ path: "file.txt", side: "old", line: 11, body: "Delete issue" },
|
||||
{ path: "file.txt", side: "new", line: 11, body: "Add issue" },
|
||||
],
|
||||
parsed,
|
||||
);
|
||||
assert.deepEqual(renderPullReviewComments(findings), [
|
||||
{
|
||||
path: "file.txt",
|
||||
body: "Delete issue",
|
||||
old_position: 11,
|
||||
new_position: 0,
|
||||
},
|
||||
{
|
||||
path: "file.txt",
|
||||
body: "Add issue",
|
||||
old_position: 0,
|
||||
new_position: 11,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("structured findings reject malformed or unavailable anchors", () => {
|
||||
const parsed = parseUnifiedDiff(anchoredDiff);
|
||||
for (const finding of [
|
||||
{ path: "file.txt", side: "middle", line: 11, body: "bad side" },
|
||||
{ path: "file.txt", side: "new", line: 0, body: "bad line" },
|
||||
{ path: "other.txt", side: "new", line: 11, body: "bad path" },
|
||||
{ path: "file.txt", side: "new", line: 99, body: "absent line" },
|
||||
]) {
|
||||
assert.throws(() => validateStructuredFindings([finding], parsed));
|
||||
}
|
||||
assert.throws(() =>
|
||||
parseUnifiedDiff("--- a/file.txt\n+++ b/file.txt\n@@ invalid\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("publication push is idempotent after an ambiguous success", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "agent-git-"));
|
||||
const remote = join(root, "remote.git");
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import test from "node:test";
|
||||
import { AgentStore } from "../../adapters/database/store.js";
|
||||
import { protocolVersion } from "../../core/contracts.js";
|
||||
@@ -25,6 +26,9 @@ test("deduplicates deliveries and advances a durable job", async () => {
|
||||
const created = store.createCommandJob({
|
||||
repositoryId: 1,
|
||||
issueNumber: 2,
|
||||
kind: "plan-discuss",
|
||||
targetNumber: 7,
|
||||
scope: "comment:3",
|
||||
mode: "plan",
|
||||
triggerKind: "command",
|
||||
triggerKey: "comment:3:plan",
|
||||
@@ -33,6 +37,9 @@ test("deduplicates deliveries and advances a durable job", async () => {
|
||||
instruction: "Keep it small",
|
||||
});
|
||||
assert.equal(created.created, true);
|
||||
assert.equal(created.job.kind, "plan-discuss");
|
||||
assert.equal(created.job.targetNumber, 7);
|
||||
assert.equal(created.job.scope, "comment:3");
|
||||
assert.equal(
|
||||
store.createCommandJob({
|
||||
repositoryId: 1,
|
||||
@@ -75,6 +82,7 @@ test("label claims prevent duplicate jobs until released", async () => {
|
||||
const input = {
|
||||
repositoryId: 1,
|
||||
issueNumber: 2,
|
||||
targetNumber: 12,
|
||||
mode: "plan" as const,
|
||||
triggerKind: "label" as const,
|
||||
triggerKey: "",
|
||||
@@ -84,6 +92,10 @@ test("label claims prevent duplicate jobs until released", async () => {
|
||||
};
|
||||
const first = required(store.createLabelJob(input));
|
||||
assert.equal(store.createLabelJob(input), undefined);
|
||||
assert.equal(
|
||||
store.createLabelJob({ ...input, issueNumber: 3 }),
|
||||
undefined,
|
||||
);
|
||||
store.completeClaim(required(store.leaseOutbox()));
|
||||
store.leaseJob("worker", 30_000);
|
||||
store.finishExecution(first.id, "worker", {
|
||||
@@ -93,7 +105,7 @@ test("label claims prevent duplicate jobs until released", async () => {
|
||||
message: "finished",
|
||||
});
|
||||
store.completePublication(required(store.leaseOutbox()), "failed");
|
||||
store.releaseLabelClaim(1, 2, "agent:plan");
|
||||
store.releaseLabelClaim(1, 12, "agent:plan");
|
||||
assert.ok(store.createLabelJob(input));
|
||||
} finally {
|
||||
store.close();
|
||||
@@ -196,6 +208,221 @@ test("a replayed cancel command remains bound to its original job", async () =>
|
||||
first.id,
|
||||
);
|
||||
assert.equal(store.getJob(second.id)?.cancelRequested, false);
|
||||
store.db
|
||||
.prepare(
|
||||
"UPDATE jobs SET created_at = 1 WHERE id IN ($first, $second)",
|
||||
)
|
||||
.run({ $first: first.id, $second: second.id });
|
||||
assert.equal(
|
||||
store.latestJob(1, 2)?.id,
|
||||
[first.id, second.id].sort().at(-1),
|
||||
);
|
||||
} finally {
|
||||
store.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("command admission durably replays bindings and rejections", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "agent-command-admission-"));
|
||||
const store = new AgentStore(join(root, "agent.db"));
|
||||
try {
|
||||
const request = {
|
||||
triggerKey: "comment:20:continue",
|
||||
action: "continue" as const,
|
||||
repositoryId: 1,
|
||||
issueNumber: 2,
|
||||
job: {
|
||||
kind: "plan-discuss" as const,
|
||||
targetNumber: 2,
|
||||
scope: "comment:20",
|
||||
mode: "plan" as const,
|
||||
actorId: 4,
|
||||
actorLogin: "alice",
|
||||
instruction: "Answer the follow-up",
|
||||
},
|
||||
};
|
||||
const admitted = store.admitCommandRequest(request);
|
||||
assert.equal(admitted.receipt.outcome, "bound");
|
||||
assert.equal(admitted.replayed, false);
|
||||
assert.equal(admitted.job?.kind, "plan-discuss");
|
||||
const replay = store.admitCommandRequest(request);
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.job?.id, admitted.job?.id);
|
||||
|
||||
const sameTarget = store.admitCommandRequest({
|
||||
...request,
|
||||
triggerKey: "comment:24:plan",
|
||||
action: "plan",
|
||||
issueNumber: 3,
|
||||
});
|
||||
assert.equal(sameTarget.receipt.outcome, "bound");
|
||||
assert.equal(sameTarget.job?.issueNumber, 3);
|
||||
assert.equal(sameTarget.job?.targetNumber, 2);
|
||||
|
||||
const blockedRequest = {
|
||||
...request,
|
||||
triggerKey: "comment:21:retry",
|
||||
action: "retry" as const,
|
||||
};
|
||||
const blocked = store.admitCommandRequest(blockedRequest);
|
||||
assert.equal(blocked.receipt.outcome, "rejected");
|
||||
assert.equal(blocked.receipt.reason, "active-job");
|
||||
store.controlCommand("comment:22:cancel", "cancel", 1, 2);
|
||||
const blockedReplay = store.admitCommandRequest(blockedRequest);
|
||||
assert.equal(blockedReplay.replayed, true);
|
||||
assert.equal(blockedReplay.receipt.outcome, "rejected");
|
||||
assert.equal(blockedReplay.job, undefined);
|
||||
|
||||
const explicit = store.admitCommandRequest({
|
||||
triggerKey: "comment:23:retry",
|
||||
action: "retry",
|
||||
repositoryId: 1,
|
||||
issueNumber: 9,
|
||||
rejection: "not-retryable",
|
||||
});
|
||||
assert.equal(explicit.receipt.reason, "not-retryable");
|
||||
} finally {
|
||||
store.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("review feedback edits are independently durable", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "agent-feedback-"));
|
||||
const store = new AgentStore(join(root, "agent.db"));
|
||||
try {
|
||||
const feedback = {
|
||||
repositoryId: 1,
|
||||
pullRequestNumber: 8,
|
||||
kind: "review-comment" as const,
|
||||
objectId: 42,
|
||||
contentDigest: "first-version",
|
||||
};
|
||||
assert.equal(store.isReviewFeedbackProcessed(feedback), false);
|
||||
assert.equal(store.markReviewFeedbackProcessed(feedback), true);
|
||||
assert.equal(store.isReviewFeedbackProcessed(feedback), true);
|
||||
assert.equal(store.markReviewFeedbackProcessed(feedback), false);
|
||||
const edited = { ...feedback, contentDigest: "edited-version" };
|
||||
assert.equal(store.isReviewFeedbackProcessed(edited), false);
|
||||
assert.equal(store.markReviewFeedbackProcessed(edited), true);
|
||||
assert.equal(store.isReviewFeedbackProcessed(edited), true);
|
||||
} finally {
|
||||
store.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("migrates current v1 databases to durable job schema v2", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "agent-v1-migration-"));
|
||||
const path = join(root, "agent.db");
|
||||
createV1Database(path);
|
||||
const store = new AgentStore(path);
|
||||
try {
|
||||
const plan = required(store.getJob("v1-plan"));
|
||||
assert.equal(plan.kind, "plan");
|
||||
assert.equal(plan.targetNumber, 11);
|
||||
assert.equal(plan.scope, "");
|
||||
assert.equal(store.getJob("v1-implementation")?.kind, "implement");
|
||||
const replay = store.admitCommandRequest({
|
||||
triggerKey: "comment:100:plan",
|
||||
action: "plan",
|
||||
repositoryId: 5,
|
||||
issueNumber: 11,
|
||||
});
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.job?.id, "v1-plan");
|
||||
store.releaseLabelClaim(5, 99, "agent:plan");
|
||||
const claim = store.db
|
||||
.prepare(`
|
||||
SELECT claimed FROM label_claims
|
||||
WHERE repository_id = 5 AND target_number = 99 AND label = 'agent:plan'
|
||||
`)
|
||||
.get() as { claimed: number };
|
||||
assert.equal(claim.claimed, 0);
|
||||
const versions = store.db
|
||||
.prepare("SELECT version FROM schema_migrations ORDER BY version")
|
||||
.all()
|
||||
.map((row) => Number((row as { version: number }).version));
|
||||
assert.deepEqual(versions, [1, 2]);
|
||||
} finally {
|
||||
store.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("pull request controls do not target source issue jobs", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "agent-target-control-"));
|
||||
const store = new AgentStore(join(root, "agent.db"));
|
||||
try {
|
||||
const plan = store.createCommandJob({
|
||||
repositoryId: 1,
|
||||
issueNumber: 2,
|
||||
targetNumber: 2,
|
||||
mode: "plan",
|
||||
triggerKind: "command",
|
||||
triggerKey: "comment:30:plan",
|
||||
actorId: 4,
|
||||
actorLogin: "alice",
|
||||
}).job;
|
||||
const cancellation = store.admitCommandRequest({
|
||||
triggerKey: "comment:31:cancel",
|
||||
action: "cancel",
|
||||
repositoryId: 1,
|
||||
issueNumber: 2,
|
||||
targetNumber: 9,
|
||||
});
|
||||
assert.equal(cancellation.receipt.outcome, "rejected");
|
||||
assert.equal(store.getJob(plan.id)?.cancelRequested, false);
|
||||
} finally {
|
||||
store.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("recognizes only durably published implementation pull requests", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "agent-published-pull-"));
|
||||
const store = new AgentStore(join(root, "agent.db"));
|
||||
try {
|
||||
const job = store.createCommandJob({
|
||||
repositoryId: 1,
|
||||
issueNumber: 2,
|
||||
mode: "implement",
|
||||
triggerKind: "command",
|
||||
triggerKey: "comment:40:implement",
|
||||
actorId: 4,
|
||||
actorLogin: "alice",
|
||||
}).job;
|
||||
store.recordImplementation(
|
||||
{
|
||||
...job,
|
||||
result: {
|
||||
version: protocolVersion,
|
||||
mode: "implement",
|
||||
status: "success",
|
||||
message: "published",
|
||||
implementation: {
|
||||
issueDigest: "issue",
|
||||
planDigest: "plan",
|
||||
branch: "agent/issue-2-pplan",
|
||||
baseBranch: "main",
|
||||
baseSha: "a".repeat(40),
|
||||
startingRemoteSha: null,
|
||||
gitSafetyDigest: "git",
|
||||
diffDigest: "diff",
|
||||
changedFiles: ["src/app.ts"],
|
||||
pendingFiles: ["src/app.ts"],
|
||||
summary: "summary",
|
||||
iterations: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
"b".repeat(40),
|
||||
9,
|
||||
);
|
||||
assert.equal(store.isPublishedImplementation(1, 2, 9, "plan"), true);
|
||||
assert.equal(store.isPublishedImplementation(1, 2, 10, "plan"), false);
|
||||
assert.equal(store.isPublishedImplementation(1, 2, 9, "other"), false);
|
||||
} finally {
|
||||
store.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
@@ -206,3 +433,72 @@ function required<T>(value: T | undefined): T {
|
||||
assert.ok(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function createV1Database(path: string): void {
|
||||
const db = new DatabaseSync(path);
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE schema_migrations (
|
||||
version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE jobs (
|
||||
id TEXT PRIMARY KEY, repository_id INTEGER NOT NULL, issue_number INTEGER NOT NULL,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('plan', 'implement')),
|
||||
trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('label', 'command')),
|
||||
trigger_key TEXT NOT NULL UNIQUE, trigger_label TEXT, actor_id INTEGER NOT NULL,
|
||||
actor_login TEXT NOT NULL, instruction TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL CHECK (state IN ('admitted', 'queued', 'running', 'publishing', 'succeeded', 'failed', 'cancelled')),
|
||||
cancel_requested INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0,
|
||||
lease_owner TEXT, lease_expires_at INTEGER, workspace TEXT, result_json TEXT,
|
||||
error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX jobs_issue_created ON jobs(repository_id, issue_number, created_at DESC);
|
||||
CREATE INDEX jobs_state_created ON jobs(state, created_at);
|
||||
CREATE UNIQUE INDEX jobs_one_active_issue ON jobs(repository_id, issue_number)
|
||||
WHERE state IN ('admitted', 'queued', 'running', 'publishing');
|
||||
CREATE TABLE label_claims (
|
||||
repository_id INTEGER NOT NULL, issue_number INTEGER NOT NULL, label TEXT NOT NULL,
|
||||
claimed INTEGER NOT NULL CHECK (claimed IN (0, 1)), job_id TEXT,
|
||||
updated_at INTEGER NOT NULL, PRIMARY KEY(repository_id, issue_number, label),
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id)
|
||||
) STRICT;
|
||||
CREATE TABLE command_receipts (
|
||||
trigger_key TEXT PRIMARY KEY,
|
||||
action TEXT NOT NULL CHECK (action IN ('cancel', 'status')),
|
||||
repository_id INTEGER NOT NULL, issue_number INTEGER NOT NULL,
|
||||
target_job_id TEXT, created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(target_job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
||||
) STRICT;
|
||||
CREATE TABLE outbox (
|
||||
id TEXT PRIMARY KEY, job_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('claim', 'publish')),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'processing', 'done', 'failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0, available_at INTEGER NOT NULL, error TEXT,
|
||||
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(job_id, kind),
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
CREATE TABLE audit_events (
|
||||
id INTEGER PRIMARY KEY, job_id TEXT, event TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
||||
) STRICT;
|
||||
INSERT INTO schema_migrations VALUES (1, 1);
|
||||
INSERT INTO jobs
|
||||
(id, repository_id, issue_number, mode, trigger_kind, trigger_key, actor_id,
|
||||
actor_login, state, created_at, updated_at)
|
||||
VALUES
|
||||
('v1-plan', 5, 11, 'plan', 'command', 'comment:100:plan', 7,
|
||||
'alice', 'succeeded', 1, 1),
|
||||
('v1-implementation', 5, 12, 'implement', 'label', 'legacy-label', 7,
|
||||
'alice', 'failed', 2, 2);
|
||||
INSERT INTO label_claims
|
||||
(repository_id, issue_number, label, claimed, job_id, updated_at)
|
||||
VALUES (5, 12, 'agent:implement', 1, 'v1-implementation', 2);
|
||||
INSERT INTO command_receipts
|
||||
(trigger_key, action, repository_id, issue_number, target_job_id, created_at)
|
||||
VALUES ('comment:101:status', 'status', 5, 11, 'v1-plan', 3);
|
||||
`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { Job } from "../../adapters/database/store.js";
|
||||
import type { GiteaClient } from "../../adapters/gitea/client/client.js";
|
||||
import { createIssueSnapshot } from "../../adapters/gitea/issues.js";
|
||||
import { publishPlanDiscussion } from "../../application/publication/handlers/conversations/plan.js";
|
||||
import { failureDetail } from "../../application/publication/service.js";
|
||||
import type { PublicationContext } from "../../application/publication/status.js";
|
||||
import type { Marker } from "../../core/contracts.js";
|
||||
import { protocolVersion, sha256 } from "../../core/contracts.js";
|
||||
|
||||
test("renders the full executor failure as literal issue comment text", () => {
|
||||
assert.equal(
|
||||
@@ -11,3 +18,103 @@ test("renders the full executor failure as literal issue comment text", () => {
|
||||
"Request `15f9edbf-ff6` failed.\n\nFailure:\n\n OpenCode prompt failed\n ```embedded markdown```\n final detail",
|
||||
);
|
||||
});
|
||||
|
||||
test("publishes a conversational response and immediately replaces an adjusted plan", async () => {
|
||||
const issue = {
|
||||
id: 1,
|
||||
number: 2,
|
||||
title: "Feature",
|
||||
body: "Requirements",
|
||||
state: "open",
|
||||
html_url: "https://example.test/issues/2",
|
||||
user: { id: 3, login: "alice" },
|
||||
labels: [],
|
||||
};
|
||||
const snapshot = createIssueSnapshot(issue, [], "agent");
|
||||
const published: Array<{ expected: Marker; body: string }> = [];
|
||||
const client = {
|
||||
getIssue: async () => issue,
|
||||
getComments: async () => [],
|
||||
getRepository: async () => ({ default_branch: "main" }),
|
||||
getBranch: async () => ({
|
||||
name: "main",
|
||||
commit: { id: "a".repeat(40) },
|
||||
}),
|
||||
addLabelIfPresent: async () => undefined,
|
||||
upsertMarkedComment: async (
|
||||
_number: number,
|
||||
_bot: string,
|
||||
expected: Marker,
|
||||
body: string,
|
||||
) => {
|
||||
published.push({ expected, body });
|
||||
return {
|
||||
id: published.length,
|
||||
body,
|
||||
html_url: "https://example.test/comment",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
user: { id: 9, login: "agent" },
|
||||
};
|
||||
},
|
||||
} as unknown as GiteaClient;
|
||||
const markdown =
|
||||
"A complete adjusted implementation plan with concrete verification steps.";
|
||||
const job: Job = {
|
||||
id: "discussion-job",
|
||||
repositoryId: 1,
|
||||
issueNumber: 2,
|
||||
kind: "plan-discuss",
|
||||
targetNumber: 2,
|
||||
scope: "issue",
|
||||
mode: "plan",
|
||||
triggerKind: "command",
|
||||
triggerKey: "comment:4:discuss",
|
||||
actorId: 3,
|
||||
actorLogin: "alice",
|
||||
instruction: "Adjust it",
|
||||
state: "publishing",
|
||||
cancelRequested: false,
|
||||
attempts: 1,
|
||||
result: {
|
||||
version: protocolVersion,
|
||||
mode: "plan",
|
||||
status: "success",
|
||||
message: "Updated as requested.",
|
||||
discussion: {
|
||||
issueDigest: snapshot.digest,
|
||||
baseSha: "a".repeat(40),
|
||||
reply: "Updated as requested.",
|
||||
},
|
||||
plan: {
|
||||
issueDigest: snapshot.digest,
|
||||
baseSha: "a".repeat(40),
|
||||
planDigest: sha256(markdown),
|
||||
markdown,
|
||||
summary: "Adjusted plan",
|
||||
iterations: 0,
|
||||
},
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const context: PublicationContext = {
|
||||
client,
|
||||
botLogin: "agent",
|
||||
serverUrl: "https://example.test",
|
||||
repository: { owner: "owner", repo: "repo" },
|
||||
repositoryId: 1,
|
||||
writeToken: "test",
|
||||
};
|
||||
const outcome = await publishPlanDiscussion(context, job);
|
||||
assert.equal(outcome.planCommentId, 1);
|
||||
assert.match(
|
||||
published.find((item) => item.expected.kind === "plan")?.body || "",
|
||||
/Independent review: not requested/,
|
||||
);
|
||||
assert.match(
|
||||
published.find((item) => item.expected.kind === "conversation")?.body ||
|
||||
"",
|
||||
/Updated as requested/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { promisify } from "node:util";
|
||||
import type { Job } from "../../adapters/database/store.js";
|
||||
import {
|
||||
parseUnifiedDiff,
|
||||
type StructuredDiffFinding,
|
||||
} from "../../adapters/git/diff.js";
|
||||
import { workspaceDiff } from "../../adapters/git/repository/changes.js";
|
||||
import type { GiteaClient } from "../../adapters/gitea/client/client.js";
|
||||
import { collectPullRequestFeedback } from "../../adapters/gitea/reviews.js";
|
||||
import type {
|
||||
CreatePullReviewInput,
|
||||
GiteaPullRequest,
|
||||
GiteaPullReview,
|
||||
} from "../../adapters/gitea/types.js";
|
||||
import { publishPullReview } from "../../application/publication/handlers/conversations/review.js";
|
||||
import type { PublicationContext } from "../../application/publication/status.js";
|
||||
import { marker, protocolVersion, sha256 } from "../../core/contracts.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
test("collects human and marked agent review feedback", async () => {
|
||||
const reviews = [
|
||||
pullReview(20, "alice", "REQUEST_CHANGES", "Please handle errors"),
|
||||
pullReview(
|
||||
21,
|
||||
"agent",
|
||||
"COMMENT",
|
||||
`${marker({ v: 1, kind: "review", issue: 2, request: "job" })}\nAgent finding`,
|
||||
),
|
||||
pullReview(22, "agent", "COMMENT", "Unmarked bot review"),
|
||||
];
|
||||
const feedback = await collectPullRequestFeedback(
|
||||
{
|
||||
getComments: async () => [
|
||||
issueComment(10, "alice", "Please add a regression test"),
|
||||
issueComment(11, "alice", "/agent fix"),
|
||||
issueComment(12, "agent", "Bot status"),
|
||||
],
|
||||
listPullReviews: async () => reviews,
|
||||
listPullReviewComments: async (_pull, reviewId) =>
|
||||
reviewId === 20
|
||||
? [reviewComment(30, 20, "alice", "src/app.ts", 4)]
|
||||
: reviewId === 21
|
||||
? [reviewComment(31, 21, "agent", "src/app.ts", 5)]
|
||||
: [reviewComment(32, 22, "agent", "src/app.ts", 6)],
|
||||
},
|
||||
7,
|
||||
"agent",
|
||||
);
|
||||
assert.deepEqual(
|
||||
feedback
|
||||
.map((item) => [item.kind, item.sourceId] as const)
|
||||
.sort((a, b) => `${a[0]}:${a[1]}`.localeCompare(`${b[0]}:${b[1]}`)),
|
||||
[
|
||||
["comment", 10],
|
||||
["inline-comment", 30],
|
||||
["inline-comment", 31],
|
||||
["review-summary", 20],
|
||||
["review-summary", 21],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("cleans a partial pending review before publishing inline findings", async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "agent-review-publish-"));
|
||||
try {
|
||||
await git(workspace, "init", "--quiet");
|
||||
await git(workspace, "config", "user.email", "test@example.invalid");
|
||||
await git(workspace, "config", "user.name", "Test");
|
||||
await writeFile(join(workspace, "app.ts"), "export const value = 1;\n");
|
||||
await git(workspace, "add", "app.ts");
|
||||
await git(workspace, "commit", "--quiet", "-m", "base");
|
||||
const base = await revision(workspace, "HEAD");
|
||||
await writeFile(
|
||||
join(workspace, "app.ts"),
|
||||
"export const value = 1;\nexport const next = value + 1;\n",
|
||||
);
|
||||
await git(workspace, "add", "app.ts");
|
||||
await git(workspace, "commit", "--quiet", "-m", "head");
|
||||
const head = await revision(workspace, "HEAD");
|
||||
const diff = await workspaceDiff(workspace, base);
|
||||
const anchor = parseUnifiedDiff(diff).anchors.find(
|
||||
(item) => item.side === "new" && item.line === 2,
|
||||
);
|
||||
assert.ok(anchor);
|
||||
const finding: StructuredDiffFinding = {
|
||||
...anchor,
|
||||
body: "This calculation needs overflow handling.",
|
||||
};
|
||||
const pull = pullRequest(7, base, head);
|
||||
const deleted: number[] = [];
|
||||
const created: CreatePullReviewInput[] = [];
|
||||
const client = {
|
||||
getPullRequest: async () => pull,
|
||||
listPullReviews: async () => [
|
||||
pullReview(70, "agent", "PENDING", "", head),
|
||||
],
|
||||
deletePullReview: async (_pull: number, reviewId: number) => {
|
||||
deleted.push(reviewId);
|
||||
},
|
||||
createPullReview: async (
|
||||
_pull: number,
|
||||
input: CreatePullReviewInput,
|
||||
) => {
|
||||
created.push(input);
|
||||
return pullReview(71, "agent", "COMMENT", input.body, head);
|
||||
},
|
||||
upsertMarkedComment: async () =>
|
||||
issueComment(99, "agent", "status"),
|
||||
} as unknown as GiteaClient;
|
||||
const job = reviewJob(workspace, base, head, diff, finding);
|
||||
const outcome = await publishPullReview(
|
||||
publicationContext(client),
|
||||
job,
|
||||
);
|
||||
assert.equal(outcome.reviewId, 71);
|
||||
assert.deepEqual(deleted, [70]);
|
||||
assert.equal(created.length, 1);
|
||||
assert.equal(created[0]?.event, "COMMENT");
|
||||
assert.equal(created[0]?.commit_id, head);
|
||||
assert.deepEqual(created[0]?.comments[0], {
|
||||
path: "app.ts",
|
||||
body: finding.body,
|
||||
old_position: 0,
|
||||
new_position: 2,
|
||||
});
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function reviewJob(
|
||||
workspace: string,
|
||||
base: string,
|
||||
head: string,
|
||||
diff: string,
|
||||
finding: StructuredDiffFinding,
|
||||
): Job {
|
||||
return {
|
||||
id: "review-job",
|
||||
repositoryId: 1,
|
||||
issueNumber: 7,
|
||||
kind: "pull-review",
|
||||
targetNumber: 7,
|
||||
scope: head,
|
||||
mode: "implement",
|
||||
triggerKind: "command",
|
||||
triggerKey: "comment:1:review",
|
||||
actorId: 2,
|
||||
actorLogin: "alice",
|
||||
instruction: "",
|
||||
state: "publishing",
|
||||
cancelRequested: false,
|
||||
attempts: 1,
|
||||
workspace,
|
||||
result: {
|
||||
version: protocolVersion,
|
||||
mode: "implement",
|
||||
status: "success",
|
||||
message: "finding",
|
||||
review: {
|
||||
kind: "pull",
|
||||
pullRequestNumber: 7,
|
||||
headSha: head,
|
||||
mergeBaseSha: base,
|
||||
diffDigest: sha256(diff),
|
||||
summary: "One blocking finding.",
|
||||
generalFindings: [],
|
||||
findings: [finding],
|
||||
},
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function publicationContext(client: GiteaClient): PublicationContext {
|
||||
return {
|
||||
client,
|
||||
botLogin: "agent",
|
||||
serverUrl: "https://example.test",
|
||||
repository: { owner: "owner", repo: "repo" },
|
||||
repositoryId: 1,
|
||||
writeToken: "test",
|
||||
};
|
||||
}
|
||||
|
||||
function pullRequest(
|
||||
number: number,
|
||||
base: string,
|
||||
head: string,
|
||||
): GiteaPullRequest {
|
||||
const repository = {
|
||||
id: 1,
|
||||
name: "repo",
|
||||
full_name: "owner/repo",
|
||||
default_branch: "main",
|
||||
html_url: "https://example.test/owner/repo",
|
||||
clone_url: "https://example.test/owner/repo.git",
|
||||
};
|
||||
return {
|
||||
id: number,
|
||||
number,
|
||||
title: "Change",
|
||||
body: "Description",
|
||||
state: "open",
|
||||
draft: false,
|
||||
html_url: `https://example.test/pulls/${number}`,
|
||||
merge_base: base,
|
||||
base: {
|
||||
label: "main",
|
||||
ref: "main",
|
||||
sha: base,
|
||||
repo_id: 1,
|
||||
repo: repository,
|
||||
},
|
||||
head: {
|
||||
label: "feature",
|
||||
ref: "feature",
|
||||
sha: head,
|
||||
repo_id: 1,
|
||||
repo: repository,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pullReview(
|
||||
id: number,
|
||||
login: string,
|
||||
state: GiteaPullReview["state"],
|
||||
body: string,
|
||||
commitId = "a".repeat(40),
|
||||
): GiteaPullReview {
|
||||
return {
|
||||
id,
|
||||
user: { id, login },
|
||||
state,
|
||||
body,
|
||||
commit_id: commitId,
|
||||
stale: false,
|
||||
official: true,
|
||||
dismissed: false,
|
||||
comments_count: 1,
|
||||
submitted_at: `2026-01-01T00:00:${String(id).padStart(2, "0")}Z`,
|
||||
updated_at: `2026-01-01T00:00:${String(id).padStart(2, "0")}Z`,
|
||||
html_url: `https://example.test/reviews/${id}`,
|
||||
pull_request_url: "https://example.test/pulls/7",
|
||||
};
|
||||
}
|
||||
|
||||
function issueComment(id: number, login: string, body: string) {
|
||||
return {
|
||||
id,
|
||||
body,
|
||||
html_url: `https://example.test/comments/${id}`,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
user: { id, login },
|
||||
};
|
||||
}
|
||||
|
||||
function reviewComment(
|
||||
id: number,
|
||||
reviewId: number,
|
||||
login: string,
|
||||
path: string,
|
||||
line: number,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
body: `Finding ${id}`,
|
||||
user: { id, login },
|
||||
resolver: null,
|
||||
pull_request_review_id: reviewId,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
path,
|
||||
commit_id: "b".repeat(40),
|
||||
original_commit_id: "b".repeat(40),
|
||||
diff_hunk: "@@ -1 +1 @@",
|
||||
position: line,
|
||||
original_position: 0,
|
||||
html_url: `https://example.test/comments/${id}`,
|
||||
pull_request_url: "https://example.test/pulls/7",
|
||||
};
|
||||
}
|
||||
|
||||
async function git(workspace: string, ...args: string[]): Promise<void> {
|
||||
await run("git", args, { cwd: workspace });
|
||||
}
|
||||
|
||||
async function revision(workspace: string, ref: string): Promise<string> {
|
||||
const result = await run("git", ["rev-parse", ref], { cwd: workspace });
|
||||
return result.stdout.trim();
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { formatError, marker, sha256 } from "../../core/contracts.js";
|
||||
import {
|
||||
parseAgentCommand,
|
||||
parseCommentPayload,
|
||||
parsePullLabelPayload,
|
||||
verifyGiteaSignature,
|
||||
} from "../../core/webhook.js";
|
||||
|
||||
@@ -51,6 +52,36 @@ test("parses only anchored agent commands", () => {
|
||||
});
|
||||
assert.equal(parseAgentCommand("Quoted text: /agent plan"), undefined);
|
||||
assert.equal(parseAgentCommand("/agent status extra"), undefined);
|
||||
assert.deepEqual(
|
||||
parseAgentCommand("/agent discuss\nCould this use SQLite?"),
|
||||
{
|
||||
action: "discuss",
|
||||
instruction: "Could this use SQLite?",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(parseAgentCommand("/agent fix Address the inline notes"), {
|
||||
action: "fix",
|
||||
instruction: "Address the inline notes",
|
||||
});
|
||||
assert.deepEqual(parseAgentCommand("/agent review Focus on retries"), {
|
||||
action: "review",
|
||||
instruction: "Focus on retries",
|
||||
});
|
||||
});
|
||||
|
||||
test("validates pull request label changes", () => {
|
||||
const payload = parsePullLabelPayload({
|
||||
action: "label_updated",
|
||||
pull_request: { number: 14, state: "open" },
|
||||
changes: {
|
||||
added_labels: [{ id: 8, name: "agent:fix-review" }],
|
||||
removed_labels: [],
|
||||
},
|
||||
repository: { id: 30, full_name: "owner/repo" },
|
||||
sender: { id: 2, login: "alice" },
|
||||
});
|
||||
assert.equal(payload.pullRequest.number, 14);
|
||||
assert.equal(payload.addedLabels[0]?.name, "agent:fix-review");
|
||||
});
|
||||
|
||||
test("validates a created issue comment payload", () => {
|
||||
|
||||
@@ -131,3 +131,89 @@ test("invalid webhook signatures are rejected without persistence", async () =>
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("admits pull request commands and fix labels", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "agent-http-pull-"));
|
||||
const store = new AgentStore(join(root, "agent.db"));
|
||||
const server = createServer((request, response) => {
|
||||
handleHttp(request, response, {
|
||||
store,
|
||||
webhookSecret: "test-secret",
|
||||
repositoryId: 9,
|
||||
repositoryFullName: "owner/repo",
|
||||
}).catch((error) => {
|
||||
response.writeHead(500);
|
||||
response.end(String(error));
|
||||
});
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", resolve),
|
||||
);
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
const command = {
|
||||
action: "created",
|
||||
comment: { body: "/agent review" },
|
||||
repository: { id: 9, full_name: "owner/repo" },
|
||||
};
|
||||
const label = {
|
||||
action: "label_updated",
|
||||
changes: {
|
||||
added_labels: [{ id: 2, name: "agent:fix-review" }],
|
||||
},
|
||||
repository: { id: 9, full_name: "owner/repo" },
|
||||
};
|
||||
assert.equal(
|
||||
(
|
||||
await signedWebhook(
|
||||
address.port,
|
||||
"pull-command",
|
||||
"pull_request_comment",
|
||||
command,
|
||||
)
|
||||
).status,
|
||||
204,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await signedWebhook(
|
||||
address.port,
|
||||
"pull-label",
|
||||
"pull_request_label",
|
||||
label,
|
||||
)
|
||||
).status,
|
||||
204,
|
||||
);
|
||||
assert.equal(store.leaseDelivery()?.id, "pull-command");
|
||||
assert.equal(store.leaseDelivery()?.id, "pull-label");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
store.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function signedWebhook(
|
||||
port: number,
|
||||
delivery: string,
|
||||
eventType: string,
|
||||
payload: unknown,
|
||||
): Promise<Response> {
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = createHmac("sha256", "test-secret")
|
||||
.update(body)
|
||||
.digest("hex");
|
||||
return fetch(`http://127.0.0.1:${port}/webhooks/gitea`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-gitea-signature": signature,
|
||||
"x-gitea-delivery": delivery,
|
||||
"x-gitea-event": "issue_comment",
|
||||
"x-gitea-event-type": eventType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user