Review on event
The problem: review every merge request when it opens, and again whenever it is pushed to, until it closes. Nobody starts a command, and nothing keeps a process open for the days a merge request can stay open.
The workflow: one run per merge request, named by its number. Your forge, a CI job or a poller sends it an event on each push and one when it closes. The run reviews the newest head, records the review, and between events it parks: its process exits, and the next event continues it.
The workflow, examples/site/patterns/review-on-event.ts, opens its reviewer and waits for events:
import { typed } from "agentswf/typebox";
import { AgentEnded, defineWorkflow } from "agentswf/workflow";
import Type from "typebox";
/** A merge request was opened or pushed to: the commit to review, against its base branch. */
export const Changes = typed(
Type.Object(
{ mr: Type.Number(), base: Type.String(), head: Type.String() },
{ additionalProperties: false },
),
);
export type Change = Type.Static<typeof Changes>;
export const Closed = typed(
Type.Object(
{ mr: Type.Number(), merged: Type.Boolean() },
{ additionalProperties: false },
),
);
export const Review = typed(
Type.Object(
{ approved: Type.Boolean(), findings: Type.Array(Type.String()) },
{ additionalProperties: false },
),
);
export type ReviewValue = Type.Static<typeof Review>;
/** A commit hash, which also fits a stage's name. */
const COMMIT = /^[0-9a-f]{7,40}$/;
export default defineWorkflow({
meta: {
name: "review-on-event",
description: "Review a merge request at each new head, until it closes.",
},
file: import.meta.path,
events: {
"mr.changes": Changes,
"mr.closed": Closed,
"reviewer:ended": AgentEnded,
},
starts: ["mr.changes"],
async run(wf) {
const reviewer = await wf.agents.open({
key: "reviewer",
runtime: { alias: "codex", placement: "headless" },
instructions: "Review merge requests. Read the code; change nothing.",
});
let next: Change | null = null;
// A failed or timed-out turn closes its agent; the next push wakes the run,
// which opens it again.
let closed = false;
for (;;) {
const events = await wf.receive({
label: reviewer.turn ? "reviewing" : "waiting for a push",
});It handles each event in code: a push records the head to review, a close ends the run, and a review's end is recorded in a stage named by its head:
for (const e of events) {
if (e.name === "mr.changes") {
if (COMMIT.test(e.data.head)) next = e.data;
else wf.log("not a commit hash", { head: e.data.head });
}
if (e.name === "mr.closed") {
await reviewer.turn?.cancel();
return { mr: e.data.mr, merged: e.data.merged };
}
if (reviewer.is<Change, ReviewValue>(e)) {
const { outcome, meta } = e.data;
if (next?.head === meta.head) next = null;
if (!outcome.answered) {
wf.log("review failed", {
head: meta.head,
reason: outcome.reason,
});
if (outcome.kind === "failed" || outcome.kind === "timed-out")
closed = true;
continue;
}
await wf.stage(
`review-${meta.head}`,
{
result: Review,
summary: (r) =>
r.approved ? "approved" : `${r.findings.length} findings`,
},
async () => outcome.value, // post it to the merge request here, once per head
);
}
}Then, if a head is waiting and the reviewer is free, it starts a review without awaiting it, and goes back to waiting:
if (next && !reviewer.turn && !closed) {
await reviewer.start({
prompt:
`Review merge request !${next.mr}: ` +
`\`git diff ${next.base}...${next.head}\`. ` +
"Fetch the commit if you don't have it.",
schema: Review,
meta: next,
});
}
}
},
});The sample's test covers a review, a push during a review, a close that cancels one, a failed review, and a head that isn't a commit hash.
Save it in the repository it reviews. A run of a workflow with events works in its file's project, the nearest directory at or above the file that holds .awf/, so the reviewer reads that repository. Events has the rest of how such a run lives. Then each push sends:
awf emit --target workflows/review-on-event.ts --id 42 mr.changes \
'{"mr":42,"base":"main","head":"3f2a9c1"}' --event-id mr42-3f2a9c1and the close:
awf emit --target workflows/review-on-event.ts --id 42 mr.closed '{"mr":42,"merged":true}'The first mr.changes for 42 starts the run review-on-event/42; later ones wake it. awf runs review-on-event/42 shows what it is doing, or what it waits for.
Cost and time: not recorded yet. They come from a recorded run of the example, priced at list prices; Cost has where an estimate comes from. Between events the run has no process and runs no turn.
Why it's shaped this way
- A run per merge request. The sender names the run with
--id, so every event about 42 reaches the same run, and the run's id is all it needs to know which merge request it serves.starts: ["mr.changes"]lets only a push start one: a close for a merge request with no run is refused, not turned into an empty run. - The code decides, the agent reviews. Each event is handled in plain code: a push records the head to review, a close ends the run. An agent turn starts only when there is something to review.
- The turn isn't awaited.
reviewer.start()returns at once, and the turn's end comes back as an event atreceive, so the run keeps taking events while the reviewer works. A push during a review is reviewed as soon as that one ends; a close cancels it. - Only the newest head is reviewed. Three pushes during one review leave one review to do, at the last head, not three.
- Each review is a stage named by its head. In a run with events, a stage that succeeded is reused wherever its name comes up again, so
review-{head}records each head's review once. That stage is where you post the review to the merge request: an event sent twice, or a continue after a crash, doesn't post it twice. - State is what the next event brings. When the run parks, its variables go with the process, and the next event runs the code from the top. Nothing here needs to outlive a park: the run only parks once the newest head is reviewed, and the next push carries its own head. A workflow whose state does outlive a park keeps it in files or stages.
Catches
- A run that returns is done for good. Events for it are refused afterwards. That's right for a closed merge request; a run that might be needed again keeps waiting instead.
- A failed or timed-out turn closes its agent for the rest of the attempt. The sample logs it and waits; the run parks, and the next push wakes it with the reviewer opened again on its session. The failed head, and one pushed during the failed review, aren't reviewed unless another push comes. Retry on a timer, as the fuller sketch below does, if a missed review matters.
- Event ids deduplicate.
--event-idnames one occurrence: a webhook delivered twice is accepted once. Without one, nothing deduplicates. - Check what becomes a name. Event ids and stage names are lowercase letters, digits,
.,_,@and-. An event's schema can't hold apattern, so the code checks thatheadis a commit hash before it names a stage with it, and logs one that isn't. - Something has to send the events. A webhook handler, a CI step or a scheduled poller runs
awf emiton the machine that holds the repository. A failed or crashed run is woken byawf tick, which you run on a schedule. Hosting events sets both up.
The full version
examples/event-sketches/mr-review.ts is the same run grown to a real review bot for GitLab: findings kept in a ledger, each posted as a thread and resolved when fixed, a summary note, a retry after a failed review, and compaction between reviews. Beside it, mr-tick.ts is the poller that tells each merge request's run what changed. Both are type-checked sketches: the ledger and GitLab calls are declared, not written, so they don't run as they are.
See also
- Events and Hosting events in the guide.
- Events and Waiting on time in the workflow API.
- A workflow with events in the testing API.
- Review loop, for a review that asks for its fixes.