Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Clearance

Clearance determines who must review a pull request and tracks whether the required approvals are current.

## Language

**Review requirement**:
An approval obligation derived from the ownership rules for the files changed by a pull request.

**Review transition**:
A change to a pull request's Clearance state together with the comments, statuses, reviewer requests,
and notifications that must follow that change. Acceptance means that all of this work is retained
for execution; it does not mean GitHub has completed it.

**Escalation**:
A warning, additional reviewer request, or fallback notification triggered by a review requirement
remaining pending beyond its configured time.
10 changes: 9 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ Covered areas:
- `state`: hidden state parsing, rendering, review approvals, scoped invalidation.
- `review`: GitHub-native review marker parsing, patchset/index projection, attention state, and
file review marks.
- `db`: persistence boundary behavior is covered through workflow tests; use Supabase local database checks when changing migrations or Drizzle schema.
- `db`: durable review transitions use Drizzle with embedded PostgreSQL (PGlite). Tests inject SQL
constraint failures, verify rollback through stored state and claimed jobs, and retry PR changes,
submitted reviews, and escalation. The fixture applies the Clearance migration with native
`gen_random_uuid` in place of the Supabase `pgcrypto` wrapper; it does not verify Supabase hosting,
connection pooling, or concurrent workers. Use Supabase local database checks when changing
migrations or Drizzle schema.
- `checks`: status decisions.
- `escalation`: timing decisions.
- `override`: break-glass authorization.
Expand Down Expand Up @@ -66,6 +71,7 @@ Covered areas:
- invalid config behavior
- scoped stale approval retention and invalidation
- escalation action application
- atomic acceptance of review state and outgoing GitHub job intent; failures remain retryable

### Webhook handler tests

Expand All @@ -87,4 +93,6 @@ Reference: [`@octokit/webhooks` README](https://github.com/octokit/webhooks.js#r
- Include one success case and one unhappy path for each new adapter or workflow branch.
- Prefer result values and diagnostics for expected operational failures.
- Reserve thrown errors for programmer errors, test helpers, or truly unexpected runtime failures.
Durable review acceptance is an explicit exception: transaction failures propagate so webhook
deliveries are recorded as failed and escalation runs can retry without losing outgoing work.
- Keep hidden sticky comment snapshot round-trip tests whenever the state shape changes.
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@electric-sql/pglite": "0.5.8",
"@playwright/test": "^1.60.0",
"@types/node": "^25.8.0",
"@types/react": "^19.2.15",
Expand Down
23 changes: 12 additions & 11 deletions src/db/store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { and, asc, eq, inArray, lte, sql } from "drizzle-orm";

import type { ClearanceDatabase } from "./client.js";
import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
import type * as schema from "./schema.js";
import {
approvals,
assignments,
Expand Down Expand Up @@ -58,7 +59,7 @@ export type OutboxJobFailureInput = {
};

export class DrizzleClearanceStore {
constructor(private readonly db: ClearanceDatabase) {}
constructor(private readonly db: PgDatabase<PgQueryResultHKT, typeof schema>) {}

async listTrackedRepositories(): Promise<TrackedRepository[]> {
return this.db
Expand Down Expand Up @@ -88,7 +89,11 @@ export class DrizzleClearanceStore {
return rows[0]?.state;
}

async savePullRequestState(input: PullRequestStateRef, state: ClearanceState): Promise<void> {
async savePullRequestTransition(
input: PullRequestStateRef,
state: ClearanceState,
jobs: OutboxJobInput[],
): Promise<void> {
await this.db.transaction(async (tx) => {
const rows = await tx
.insert(pullRequests)
Expand Down Expand Up @@ -208,6 +213,10 @@ export class DrizzleClearanceStore {
})),
);
}

if (jobs.length > 0) {
await tx.insert(outboxJobs).values(jobs);
}
});
}

Expand Down Expand Up @@ -278,14 +287,6 @@ export class DrizzleClearanceStore {
return true;
}

async enqueueOutboxJob(input: OutboxJobInput): Promise<void> {
await this.db.insert(outboxJobs).values({
availableAt: input.availableAt,
payload: input.payload,
type: input.type,
});
}

async claimOutboxJobs(limit: number): Promise<OutboxJobRecord[]> {
if (limit <= 0) {
return [];
Expand Down
69 changes: 11 additions & 58 deletions src/github/escalation-runner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { buildEscalationRequirementsFromState, processEscalationRun } from "../workflow/index.js";
import type { ClearanceState } from "../state/index.js";
import { enqueueGithubOutboxJob, githubOutboxJobTypes, type GithubOutboxStore } from "./outbox.js";
import {
commitGithubReviewTransition,
type GithubReviewTransitionStore,
} from "./review-transition.js";
import {
listOpenPullRequests,
type OpenPullRequest,
Expand All @@ -14,25 +17,12 @@ export type EscalationRunnerRepository = {

export type GithubEscalationRunnerOctokit = OpenPullRequestsOctokit;

export type GithubEscalationRunnerStateStore = {
enqueueOutboxJob: GithubOutboxStore["enqueueOutboxJob"];
export type GithubEscalationRunnerStateStore = GithubReviewTransitionStore & {
loadPullRequestState(input: {
owner: string;
pullNumber: number;
repo: string;
}): Promise<ClearanceState | undefined>;
savePullRequestState(
input: {
author: string;
headSha: string;
labels: string[];
now: string;
owner: string;
pullNumber: number;
repo: string;
},
state: ClearanceState,
): Promise<void>;
};

export type GithubEscalationRunnerOptions = {
Expand All @@ -44,7 +34,6 @@ export type EscalationPullResult =
| {
actions: number;
pullNumber: number;
sideEffectFailures: number;
status: "processed";
}
| {
Expand Down Expand Up @@ -101,32 +90,9 @@ async function processPullRequest(
state: storedState,
},
{
postComment: async (body) => {
await enqueueGithubOutboxJob(options.stateStore, {
payload: {
body,
installationId: options.installationId,
owner: repository.owner,
pullNumber: pullRequest.number,
repo: repository.repo,
},
type: githubOutboxJobTypes.postComment,
});
},
requestReviewers: async (reviewers) => {
await enqueueGithubOutboxJob(options.stateStore, {
payload: {
installationId: options.installationId,
owner: repository.owner,
pullNumber: pullRequest.number,
repo: repository.repo,
reviewers,
},
type: githubOutboxJobTypes.requestReviewers,
});
},
saveState: async (nextState) => {
await options.stateStore.savePullRequestState(
commitTransition: (transition) =>
commitGithubReviewTransition(
options.stateStore,
{
author: pullRequest.author,
headSha: pullRequest.headSha,
Expand All @@ -136,28 +102,15 @@ async function processPullRequest(
pullNumber: pullRequest.number,
repo: repository.repo,
},
nextState,
);
},
upsertComment: async (body) => {
await enqueueGithubOutboxJob(options.stateStore, {
payload: {
body,
installationId: options.installationId,
owner: repository.owner,
pullNumber: pullRequest.number,
repo: repository.repo,
},
type: githubOutboxJobTypes.upsertComment,
});
},
options.installationId,
transition,
),
},
);

return {
actions: result.actions.length,
pullNumber: pullRequest.number,
sideEffectFailures: result.sideEffectFailures.length,
status: "processed",
};
}
79 changes: 7 additions & 72 deletions src/github/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,11 @@ import {
listPullRequestReviewerSignals,
loadOwnershipTree,
parseReviewThreadMarker,
enqueueGithubOutboxJob,
githubOutboxJobTypes,
resolveGithubIdentities,
stripReviewThreadMarker,
type CommitCompareOctokit,
type GithubIdentityOctokit,
type GithubStatusesOctokit,
type GithubOutboxStore,
type NotificationCommentOctokit,
type OwnershipTreeOctokit,
type PullRequestFilesOctokit,
Expand All @@ -31,6 +28,10 @@ import {
import type { ClearanceState } from "../state/index.js";
import { parseOverrideCommentCommand } from "../override/index.js";
import type { ReviewPatchsetInput, ReviewPullRequestRef } from "../review/index.js";
import {
commitGithubReviewTransition,
type GithubReviewTransitionStore,
} from "./review-transition.js";

const pullRequestActions = new Set(["opened", "reopened", "synchronize", "ready_for_review"]);

Expand Down Expand Up @@ -66,14 +67,13 @@ export type GithubInstallationClientFactory = {
getInstallationOctokit(installationId: number): Promise<GithubWorkflowOctokit>;
};

export type GithubHandlerStateStore = {
export type GithubHandlerStateStore = GithubReviewTransitionStore & {
beginWebhookDelivery(input: {
action?: string;
deliveryId: string;
event: string;
payload?: unknown;
}): Promise<boolean>;
enqueueOutboxJob: GithubOutboxStore["enqueueOutboxJob"];
loadPullRequestState(
input: Pick<PullRequestWorkflowInput, "owner" | "pullNumber" | "repo">,
): Promise<ClearanceState | undefined>;
Expand All @@ -85,7 +85,6 @@ export type GithubHandlerStateStore = {
payload?: unknown;
status: "failed" | "processed" | "processing";
}): Promise<void>;
savePullRequestState(input: PullRequestWorkflowInput, state: ClearanceState): Promise<void>;
};

export type GithubHandlerReviewStore = {
Expand Down Expand Up @@ -171,7 +170,6 @@ export function registerGithubHandlers(
console.info(
{
checks: result.checks,
sideEffectFailures: result.sideEffectFailures,
pullNumber,
requestedReviewers: result.requestedReviewers,
repository,
Expand Down Expand Up @@ -253,7 +251,6 @@ export function registerGithubHandlers(
console.info(
{
checks: result.checks,
sideEffectFailures: result.sideEffectFailures,
pullNumber: payload.pull_request.number,
repository: payload.repository.full_name,
reviewer,
Expand Down Expand Up @@ -408,7 +405,6 @@ export function registerGithubHandlers(
command: command.type,
pullNumber,
repository,
sideEffectFailures: result.sideEffectFailures,
},
"processed override comment event",
);
Expand Down Expand Up @@ -617,70 +613,9 @@ function buildWorkflowDependencies(
ref: input.headSha,
repo: input.repo,
}),
requestReviewers: async (input, reviewers) => {
if (reviewers.length === 0) {
return;
}

await enqueueGithubOutboxJob(options.stateStore, {
payload: {
installationId,
owner: input.owner,
pullNumber: input.pullNumber,
repo: input.repo,
reviewers,
},
type: githubOutboxJobTypes.requestReviewers,
});
},
resolveIdentities: async (tree) => resolveGithubIdentities(octokit, tree.files),
saveState: async (input, state) => {
await options.stateStore.savePullRequestState(input, state);
},
sendNotifications: async (input, notifications) => {
if (notifications.length === 0) {
return;
}

await enqueueGithubOutboxJob(options.stateStore, {
payload: {
installationId,
notifications,
owner: input.owner,
pullNumber: input.pullNumber,
repo: input.repo,
},
type: githubOutboxJobTypes.sendNotifications,
});
},
setStatuses: async (input, decisions) => {
if (decisions.length === 0) {
return;
}

await enqueueGithubOutboxJob(options.stateStore, {
payload: {
decisions,
installationId,
owner: input.owner,
repo: input.repo,
sha: input.headSha,
},
type: githubOutboxJobTypes.setStatuses,
});
},
upsertComment: async (input, body) => {
await enqueueGithubOutboxJob(options.stateStore, {
payload: {
body,
installationId,
owner: input.owner,
pullNumber: input.pullNumber,
repo: input.repo,
},
type: githubOutboxJobTypes.upsertComment,
});
},
commitTransition: (input, transition) =>
commitGithubReviewTransition(options.stateStore, input, installationId, transition),
};
}

Expand Down
Loading
Loading