Composing a flow
The review loop’s recipes each cover one stage and hand work to each other through GitHub’s own review requests: implement asks a reviewer from the pool, review runs as that persona, address-review answers a request for changes and asks it again, and re-review resumes its own review. Each flow is a short sequence of SDK steps, so a repo changes a stage by editing its flow: reorder the steps, leave one out, put a step of its own in between, or end differently.
The implement flow
Section titled “The implement flow”This is the implement recipe, as chernabog recipes add implement bakes it, without its comments:
import { Claims, defineFlow, GitHub, Implement, reportAs, Settings, Skip } from '@chernabog/sdk';import * as Effect from 'effect/Effect';import * as Option from 'effect/Option';import * as Schema from 'effect/Schema';
const trigger = Effect.gen(function* () { const github = yield* GitHub; const labels = yield* Settings.labels; const eligible = yield* github.queuedIssuesLabelled(labels.eligible); const shipping = (yield* github.queuedIssuesLabelled(labels.ship)).filter((issue) => !eligible.some((queued) => queued.number === issue.number));
return [...eligible.map((issue) => Implement.issueItem(issue)), ...shipping.map((issue) => Implement.issueItem(issue, Option.some(labels.ship)))];}).pipe(reportAs('trigger'));
// What config.yaml may set for this flow: the SDK's settings its blocks read.const settings = Schema.Struct({ ...Settings.Labels.fields, ...Settings.Board.fields, ...Settings.LocalGate.fields, ...Settings.Rules.fields, ...Settings.Ci.fields, ...Settings.Reviewers.fields });
export default defineFlow({ trigger, gate: Implement.gate, concurrency: 1, labels: Effect.map(Settings.declaredLabels, (set) => [...set, ...Implement.adoptionLabels]), settings, workflow: (item) => Effect.gen(function* () { const issue = yield* Implement.issueOf(item); const queued = yield* Implement.queuedBy(item); let pullRequest = Option.none<number>(); const toPerson = (reason: string) => Implement.handToHuman(issue, reason, pullRequest).pipe(Effect.flatMap(Implement.endForPerson));
return yield* Effect.gen(function* () { const { branch } = yield* Implement.claim(issue, queued); const worktree = yield* Implement.prepareWorktree(branch); const outcome = yield* Implement.implementWithAgent(issue, worktree);
if (outcome.status === 'needs-clarification') return yield* Implement.askForClarification(issue, outcome, queued).pipe(Effect.flatMap(Implement.endForPerson));
return yield* Effect.gen(function* () { yield* Implement.localGate(worktree); yield* Implement.push('push', worktree, branch); const opened = yield* Implement.openPullRequest(issue, outcome, queued); pullRequest = Option.some(opened.number); yield* Implement.waitForCi(opened.number, worktree, { ...Implement.thisRunsSession, branch });
return yield* Implement.handOff(issue, opened, outcome); }).pipe( Effect.catchTags({ NeedsHuman: (needsHuman) => toPerson(needsHuman.reason), NothingCommitted: (nothing) => toPerson(`the agent finished without committing anything on ${nothing.branch}`), PushRejected: (rejected) => toPerson(rejected.message), CiTimedOut: (timedOut) => toPerson(timedOut.message), PullRequestClosed: (closed) => toPerson(closed.message), }), ); }).pipe( Effect.catchTag('StepFailed', (failed) => toPerson(`${failed.step}: ${failed.message}`)), Claims.releasing(Claims.issue(issue)), ); }).pipe(Effect.catchTag('NotFound', (gone) => Effect.fail(new Skip({ reason: gone.message })))),});- Trigger and claim. The trigger queues issues labelled
auto:eligible, thenauto:ship, each asImplement.issueItem(issue, label), andreportAs('trigger')turns a failed read of GitHub intoStepFailed.Implement.queuedBy(item)says which label queued the issue;claim,askForClarificationandopenPullRequesttake it, so the claim removes that label and anauto:shipissue’s pull request is labelledauto:shiptoo.Claims.releasing(Claims.issue(issue))around the claim and the work lets the claim go however the run ends. - Implement, gate, push, open, wait for CI. Each is one activity, so a resumed run picks up after the last one done.
- Hand off.
handOffcloses out the issue and asks one reviewer from the team’s pool to review the pull request. That request is what startsreview, as the reviewer’s persona. - Hand to a person. A
NeedsHumanorStepFailedanywhere after the claim ends inhandToHuman, which labels the issue and the pull requestauto:needs-humanand says why. So do the work’s errors the steps raise: nothing committed, a push the token may not make (whose message says how to fix it), CI that stays pending, or a pull request closed under the run. An issue deleted under the run ends it as skipped. The compiler holds the flow to handling each one.
A review flow of your own
Section titled “A review flow of your own”A repo that wants every request for changes to go to a person, rather than to address-review, changes the ending of its baked review recipe:
// .chernabog/flows/review.ts, in its workflowconst review = yield* Review.round(pullRequest, 1, { role: 'reviewer' });yield* Review.doneReviewing(pullRequest);
if (review.verdict === 'approve') return yield* mergeIfShipping(pullRequest);
return yield* PullRequest.handToPerson(pullRequest, 'review 1 requested changes');Review.round reviews in a fresh session of the reviewer role and posts a real GitHub review. It requires the persona’s ReviewerSeat, so the flow does too: run as a persona without a seat in the pool, it stops before its work, and the daemon says why. Review.doneReviewing(pr) removes the review’s checkout, review-pr-<n>, once it’s done. mergeIfShipping is the recipe’s own: it reads PullRequest.shipping(pr), and merges only an auto:ship pull request.
Endings
Section titled “Endings”The ending is the flow’s choice. Each of these leaves the labels the way the label contract asks:
- Merge on the automated path.
PullRequest.mergeOrLeave(pr)merges anauto:shippull request withPullRequest.merge(pr, { method: "squash" }), and leaves any other one for a person to merge.mergemerges only when the required checks are green, the latest review approved that very head (or one the head has only cleanly merged its base into since), nohumanOnlyPathsare touched, and GitHub allows it; otherwise it fails withMergeRefused, andmergeOrLeavelabels the pull requestauto:needs-humanwith the reason. A layer of a stack waits for the layer below instead:PullRequest.waitingOnLayer(pr)says why, andmergeOrLeaveleaves it approved, for the epic run to merge once it is on the base. A recipe’s ownmergeIfShippingdoes the same, withwaitingOnLayerandEffect.catchTag('MergeRefused', …). See Merging. - Hand to a person.
PullRequest.handToPerson(pr, reason)labels the pull requestauto:needs-humanand says why;Implement.handToHumandoes the same for the issue and its pull request. - Ask for a review.
PullRequest.requestReview(pr)asks one reviewer from the pool, ashandOffdoes. - Comment and stop.
PullRequest.comment(pr, …)posts and does nothing else.
Keep the merge to mergeOrLeave, or to a check with PullRequest.ships, so chernabog merges only what carries auto:ship.