diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..f0b82d2 --- /dev/null +++ b/CONTEXT.md @@ -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. diff --git a/docs/testing.md b/docs/testing.md index 5797ca9..d8c3e22 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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. @@ -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 @@ -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. diff --git a/package-lock.json b/package-lock.json index 66489d8..0eacbfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,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", @@ -950,6 +951,13 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz", + "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==", + "devOptional": true, + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", diff --git a/package.json b/package.json index e653cec..1daf2b4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/db/store.ts b/src/db/store.ts index 38ddc2b..49c1a3b 100644 --- a/src/db/store.ts +++ b/src/db/store.ts @@ -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, @@ -58,7 +59,7 @@ export type OutboxJobFailureInput = { }; export class DrizzleClearanceStore { - constructor(private readonly db: ClearanceDatabase) {} + constructor(private readonly db: PgDatabase) {} async listTrackedRepositories(): Promise { return this.db @@ -88,7 +89,11 @@ export class DrizzleClearanceStore { return rows[0]?.state; } - async savePullRequestState(input: PullRequestStateRef, state: ClearanceState): Promise { + async savePullRequestTransition( + input: PullRequestStateRef, + state: ClearanceState, + jobs: OutboxJobInput[], + ): Promise { await this.db.transaction(async (tx) => { const rows = await tx .insert(pullRequests) @@ -208,6 +213,10 @@ export class DrizzleClearanceStore { })), ); } + + if (jobs.length > 0) { + await tx.insert(outboxJobs).values(jobs); + } }); } @@ -278,14 +287,6 @@ export class DrizzleClearanceStore { return true; } - async enqueueOutboxJob(input: OutboxJobInput): Promise { - await this.db.insert(outboxJobs).values({ - availableAt: input.availableAt, - payload: input.payload, - type: input.type, - }); - } - async claimOutboxJobs(limit: number): Promise { if (limit <= 0) { return []; diff --git a/src/github/escalation-runner.ts b/src/github/escalation-runner.ts index 3ab3104..a08614c 100644 --- a/src/github/escalation-runner.ts +++ b/src/github/escalation-runner.ts @@ -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, @@ -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; - savePullRequestState( - input: { - author: string; - headSha: string; - labels: string[]; - now: string; - owner: string; - pullNumber: number; - repo: string; - }, - state: ClearanceState, - ): Promise; }; export type GithubEscalationRunnerOptions = { @@ -44,7 +34,6 @@ export type EscalationPullResult = | { actions: number; pullNumber: number; - sideEffectFailures: number; status: "processed"; } | { @@ -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, @@ -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", }; } diff --git a/src/github/handlers.ts b/src/github/handlers.ts index 8cecefb..ee00dbb 100644 --- a/src/github/handlers.ts +++ b/src/github/handlers.ts @@ -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, @@ -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"]); @@ -66,14 +67,13 @@ export type GithubInstallationClientFactory = { getInstallationOctokit(installationId: number): Promise; }; -export type GithubHandlerStateStore = { +export type GithubHandlerStateStore = GithubReviewTransitionStore & { beginWebhookDelivery(input: { action?: string; deliveryId: string; event: string; payload?: unknown; }): Promise; - enqueueOutboxJob: GithubOutboxStore["enqueueOutboxJob"]; loadPullRequestState( input: Pick, ): Promise; @@ -85,7 +85,6 @@ export type GithubHandlerStateStore = { payload?: unknown; status: "failed" | "processed" | "processing"; }): Promise; - savePullRequestState(input: PullRequestWorkflowInput, state: ClearanceState): Promise; }; export type GithubHandlerReviewStore = { @@ -171,7 +170,6 @@ export function registerGithubHandlers( console.info( { checks: result.checks, - sideEffectFailures: result.sideEffectFailures, pullNumber, requestedReviewers: result.requestedReviewers, repository, @@ -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, @@ -408,7 +405,6 @@ export function registerGithubHandlers( command: command.type, pullNumber, repository, - sideEffectFailures: result.sideEffectFailures, }, "processed override comment event", ); @@ -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), }; } diff --git a/src/github/index.ts b/src/github/index.ts index 69dafbb..5d044b6 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -51,14 +51,12 @@ export { type ReviewerSignalsRef, } from "./reviewer-signals.js"; export { - enqueueGithubOutboxJob, executeGithubOutboxJob, githubOutboxJobTypes, type GithubOutboxInstallationClientFactory, type GithubOutboxJobInput, type GithubOutboxJobType, type GithubOutboxOctokit, - type GithubOutboxStore, } from "./outbox.js"; export { runGithubOutboxOnce, diff --git a/src/github/outbox.ts b/src/github/outbox.ts index 56bc0e1..6c2953c 100644 --- a/src/github/outbox.ts +++ b/src/github/outbox.ts @@ -1,5 +1,5 @@ import type { CheckDecision } from "../checks/index.js"; -import type { OutboxJobInput, OutboxJobRecord } from "../db/index.js"; +import type { OutboxJobRecord } from "../db/index.js"; import type { NotificationRecord } from "../resolution/index.js"; import { sendPullRequestNotifications, type NotificationCommentOctokit } from "./notifications.js"; import { requestPullRequestReviewers, type PullRequestReviewersOctokit } from "./reviewers.js"; @@ -21,10 +21,6 @@ export type GithubOutboxOctokit = GithubStatusesOctokit & PullRequestReviewersOctokit & StickyCommentOctokit; -export type GithubOutboxStore = { - enqueueOutboxJob(input: OutboxJobInput): Promise; -}; - export type GithubOutboxInstallationClientFactory = { getInstallationOctokit(installationId: number): Promise; }; @@ -82,16 +78,6 @@ export type GithubOutboxJobInput = type: typeof githubOutboxJobTypes.sendNotifications; }; -export async function enqueueGithubOutboxJob( - store: GithubOutboxStore, - job: GithubOutboxJobInput, -): Promise { - await store.enqueueOutboxJob({ - payload: job.payload, - type: job.type, - }); -} - export async function executeGithubOutboxJob( job: OutboxJobRecord, installationClientFactory: GithubOutboxInstallationClientFactory, diff --git a/src/github/review-transition.ts b/src/github/review-transition.ts new file mode 100644 index 0000000..de69945 --- /dev/null +++ b/src/github/review-transition.ts @@ -0,0 +1,43 @@ +import type { DrizzleClearanceStore, PullRequestStateRef } from "../db/index.js"; +import type { ReviewTransition } from "../workflow/transition.js"; +import { githubOutboxJobTypes, type GithubOutboxJobInput } from "./outbox.js"; + +export type GithubReviewTransitionStore = Pick; + +/** Accept the state and GitHub work together; remote execution belongs to the outbox runner. */ +export async function commitGithubReviewTransition( + store: GithubReviewTransitionStore, + input: PullRequestStateRef, + installationId: number, + transition: ReviewTransition, +): Promise { + const base = { installationId, owner: input.owner, repo: input.repo }; + const pull = { ...base, pullNumber: input.pullNumber }; + const jobs = transition.effects.map((effect): GithubOutboxJobInput => { + switch (effect.type) { + case "upsert-comment": + return { + type: githubOutboxJobTypes.upsertComment, + payload: { ...pull, body: effect.body }, + }; + case "post-comment": + return { type: githubOutboxJobTypes.postComment, payload: { ...pull, body: effect.body } }; + case "request-reviewers": + return { + type: githubOutboxJobTypes.requestReviewers, + payload: { ...pull, reviewers: effect.reviewers }, + }; + case "set-statuses": + return { + type: githubOutboxJobTypes.setStatuses, + payload: { ...base, sha: input.headSha, decisions: effect.decisions }, + }; + case "send-notifications": + return { + type: githubOutboxJobTypes.sendNotifications, + payload: { ...pull, notifications: effect.notifications }, + }; + } + }); + await store.savePullRequestTransition(input, transition.state, jobs); +} diff --git a/src/workflow/escalation.ts b/src/workflow/escalation.ts index 0e9efe7..549bda0 100644 --- a/src/workflow/escalation.ts +++ b/src/workflow/escalation.ts @@ -9,6 +9,7 @@ import { type ClearanceStateRequirement, type StateEvent, } from "../state/index.js"; +import type { ReviewTransition, ReviewTransitionEffect } from "./transition.js"; export type EscalationWorkflowInput = { now: string; @@ -17,20 +18,11 @@ export type EscalationWorkflowInput = { }; export type EscalationWorkflowDependencies = { - postComment(body: string): Promise; - requestReviewers(reviewers: string[]): Promise; - saveState(state: ClearanceState): Promise; - upsertComment(body: string): Promise; -}; - -export type EscalationSideEffectFailure = { - message: string; - operation: "post-comment" | "request-reviewers" | "upsert-comment"; + commitTransition(transition: ReviewTransition): Promise; }; export type EscalationWorkflowResult = { actions: EscalationAction[]; - sideEffectFailures: EscalationSideEffectFailure[]; state: ClearanceState; }; @@ -49,25 +41,19 @@ export async function processEscalationRun( }); const nextState = applyEscalationActions(state, actions, input.now); - await dependencies.saveState(nextState); - - const sideEffectFailures = await runEscalationSideEffects([ - { - execute: () => dependencies.upsertComment(renderClearanceComment(nextState)), - operation: "upsert-comment", - }, - ...actions.map((action) => ({ - execute: () => applyEscalationSideEffect(action, dependencies), - operation: + const effects: ReviewTransitionEffect[] = [ + { type: "upsert-comment", body: renderClearanceComment(nextState) }, + ...actions.map( + (action): ReviewTransitionEffect => action.type === "request_reviewer" - ? ("request-reviewers" as const) - : ("post-comment" as const), - })), - ]); + ? { type: "request-reviewers", reviewers: [action.reviewer] } + : { type: "post-comment", body: renderActionComment(action) }, + ), + ]; + await dependencies.commitTransition({ state: nextState, effects }); return { actions, - sideEffectFailures, state: nextState, }; } @@ -125,41 +111,6 @@ function applyEscalationActions( }; } -async function applyEscalationSideEffect( - action: EscalationAction, - dependencies: EscalationWorkflowDependencies, -): Promise { - if (action.type === "request_reviewer") { - await dependencies.requestReviewers([action.reviewer]); - return; - } - - await dependencies.postComment(renderActionComment(action)); -} - -async function runEscalationSideEffects( - effects: Array<{ - execute(): Promise; - operation: EscalationSideEffectFailure["operation"]; - }>, -): Promise { - const results = await Promise.all( - effects.map(async (effect): Promise => { - try { - await effect.execute(); - return undefined; - } catch (error) { - return { - message: getErrorMessage(error, "unknown error"), - operation: effect.operation, - }; - } - }), - ); - - return results.filter((failure): failure is EscalationSideEffectFailure => failure !== undefined); -} - function getAssignedReviewers( state: ClearanceState, requirement: ClearanceStateRequirement, @@ -181,10 +132,6 @@ function getAssignmentTime( )?.assignedAt; } -function getErrorMessage(error: unknown, fallback: string): string { - return error instanceof Error ? error.message : fallback; -} - function createStateEvent(action: EscalationAction, now: string): StateEvent { if (action.type === "warn") { return { diff --git a/src/workflow/index.ts b/src/workflow/index.ts index 38f7147..8548d8e 100644 --- a/src/workflow/index.ts +++ b/src/workflow/index.ts @@ -9,8 +9,8 @@ export { export { buildEscalationRequirementsFromState, processEscalationRun, - type EscalationSideEffectFailure, type EscalationWorkflowDependencies, type EscalationWorkflowInput, type EscalationWorkflowResult, } from "./escalation.js"; +export type { ReviewTransition, ReviewTransitionEffect } from "./transition.js"; diff --git a/src/workflow/pull-request.ts b/src/workflow/pull-request.ts index 34847f9..129d9a8 100644 --- a/src/workflow/pull-request.ts +++ b/src/workflow/pull-request.ts @@ -29,6 +29,7 @@ import { type ReviewRequirementDefinition, } from "../state/index.js"; import type { GithubIdentityResolution } from "../github/index.js"; +import type { ReviewTransition, ReviewTransitionEffect } from "./transition.js"; export type PullRequestWorkflowInput = { author: string; @@ -57,34 +58,17 @@ export type PullRequestWorkflowDependencies = { ): Promise; loadState(input: PullRequestWorkflowInput): Promise; loadOwnershipTree(input: PullRequestWorkflowInput): Promise; - requestReviewers(input: PullRequestWorkflowInput, reviewers: string[]): Promise; resolveIdentities(tree: OwnershipTree): Promise; - saveState(input: PullRequestWorkflowInput, state: ClearanceState): Promise; - sendNotifications( - input: PullRequestWorkflowInput, - notifications: NotificationRecord[], - ): Promise; - setStatuses(input: PullRequestWorkflowInput, decisions: CheckDecision[]): Promise; - upsertComment(input: PullRequestWorkflowInput, body: string): Promise; + /** Resolves only after state and every outgoing effect are durably accepted together. */ + commitTransition(input: PullRequestWorkflowInput, transition: ReviewTransition): Promise; }; export type ReviewerSignal = Pick & Partial>; -export type WorkflowSideEffectFailure = { - message: string; - operation: "request-reviewers" | "send-notifications" | "set-statuses" | "upsert-comment"; -}; - -type WorkflowSideEffect = { - execute(): Promise; - operation: WorkflowSideEffectFailure["operation"]; -}; - export type PullRequestWorkflowResult = { changedFiles: string[]; checks: CheckDecision[]; - sideEffectFailures: WorkflowSideEffectFailure[]; requestedReviewers: string[]; state: ClearanceState; }; @@ -163,28 +147,23 @@ export async function processPullRequestChange( ].toSorted(compareStrings), }; - await dependencies.saveState(input, finalStateWithNotifications); - - const sideEffectFailures = await runWorkflowSideEffects([ - { - execute: () => - dependencies.upsertComment(input, renderClearanceComment(finalStateWithNotifications)), - operation: "upsert-comment", - }, - ...getEnforcementSideEffects( - input, - dependencies, - checks, - requestedReviewers, - pendingNotifications, - dryRun, - ), - ]); + const effects: ReviewTransitionEffect[] = [ + { type: "upsert-comment", body: renderClearanceComment(finalStateWithNotifications) }, + ]; + if (!dryRun) { + effects.push({ type: "set-statuses", decisions: checks }); + if (requestedReviewers.length > 0) { + effects.push({ type: "request-reviewers", reviewers: requestedReviewers }); + } + if (pendingNotifications.length > 0) { + effects.push({ type: "send-notifications", notifications: pendingNotifications }); + } + } + await dependencies.commitTransition(input, { state: finalStateWithNotifications, effects }); return { changedFiles: context.changedFiles, checks, - sideEffectFailures, requestedReviewers, state: finalStateWithNotifications, }; @@ -211,20 +190,17 @@ export async function processSubmittedReview( reconciledState.override !== undefined, ); - await dependencies.saveState(input, reconciledState); - - const sideEffectFailures = await runWorkflowSideEffects([ - { - execute: () => dependencies.upsertComment(input, renderClearanceComment(reconciledState)), - operation: "upsert-comment", - }, - ...getSubmittedReviewEnforcementSideEffects(input, dependencies, checks, dryRun), - ]); + const effects: ReviewTransitionEffect[] = [ + { type: "upsert-comment", body: renderClearanceComment(reconciledState) }, + ]; + if (!dryRun) { + effects.push({ type: "set-statuses", decisions: checks }); + } + await dependencies.commitTransition(input, { state: reconciledState, effects }); return { changedFiles: context.changedFiles, checks, - sideEffectFailures, requestedReviewers: [], state: reconciledState, }; @@ -243,52 +219,6 @@ function applyDryRunState(state: ClearanceState, dryRun: boolean): ClearanceStat return nextState; } -function getEnforcementSideEffects( - input: PullRequestWorkflowInput, - dependencies: PullRequestWorkflowDependencies, - checks: CheckDecision[], - requestedReviewers: string[], - pendingNotifications: NotificationRecord[], - dryRun: boolean, -): WorkflowSideEffect[] { - if (dryRun) { - return []; - } - - return [ - { - execute: () => dependencies.setStatuses(input, checks), - operation: "set-statuses", - }, - { - execute: () => dependencies.requestReviewers(input, requestedReviewers), - operation: "request-reviewers", - }, - { - execute: () => dependencies.sendNotifications(input, pendingNotifications), - operation: "send-notifications", - }, - ]; -} - -function getSubmittedReviewEnforcementSideEffects( - input: PullRequestWorkflowInput, - dependencies: PullRequestWorkflowDependencies, - checks: CheckDecision[], - dryRun: boolean, -): WorkflowSideEffect[] { - if (dryRun) { - return []; - } - - return [ - { - execute: () => dependencies.setStatuses(input, checks), - operation: "set-statuses", - }, - ]; -} - async function loadPreviousState( input: PullRequestWorkflowInput, dependencies: PullRequestWorkflowDependencies, @@ -707,30 +637,6 @@ function createWorkflowChecks( ]; } -async function runWorkflowSideEffects( - effects: WorkflowSideEffect[], -): Promise { - const results = await Promise.all( - effects.map(async (effect): Promise => { - try { - await effect.execute(); - return undefined; - } catch (error) { - return { - message: getErrorMessage(error, "unknown error"), - operation: effect.operation, - }; - } - }), - ); - - return results.filter((failure): failure is WorkflowSideEffectFailure => failure !== undefined); -} - -function getErrorMessage(error: unknown, fallback: string): string { - return error instanceof Error ? error.message : fallback; -} - function compareStrings(left: string, right: string): number { if (left < right) { return -1; diff --git a/src/workflow/transition.ts b/src/workflow/transition.ts new file mode 100644 index 0000000..55bd898 --- /dev/null +++ b/src/workflow/transition.ts @@ -0,0 +1,16 @@ +import type { CheckDecision } from "../checks/index.js"; +import type { NotificationRecord } from "../resolution/index.js"; +import type { ClearanceState } from "../state/index.js"; + +/** GitHub work accepted with a review-policy state change, executed asynchronously. */ +export type ReviewTransitionEffect = + | { type: "upsert-comment"; body: string } + | { type: "post-comment"; body: string } + | { type: "request-reviewers"; reviewers: string[] } + | { type: "set-statuses"; decisions: CheckDecision[] } + | { type: "send-notifications"; notifications: NotificationRecord[] }; + +export type ReviewTransition = { + state: ClearanceState; + effects: ReviewTransitionEffect[]; +}; diff --git a/test/github/escalation-runner.test.ts b/test/github/escalation-runner.test.ts index 2f7c52e..1c3dbfc 100644 --- a/test/github/escalation-runner.test.ts +++ b/test/github/escalation-runner.test.ts @@ -53,13 +53,12 @@ describe("runGithubEscalationSweep", () => { { actions: 2, pullNumber: 42, - sideEffectFailures: 0, status: "processed", }, ], repository: "acme/clearance", }); - expect(stateStore.enqueueOutboxJob).toHaveBeenCalledWith( + expect(stateStore.jobs).toContainEqual( expect.objectContaining({ payload: expect.objectContaining({ body: "Warn assigned reviewers for and:platform: @alice", @@ -68,7 +67,7 @@ describe("runGithubEscalationSweep", () => { type: "github.post-comment", }), ); - expect(stateStore.enqueueOutboxJob).toHaveBeenCalledWith( + expect(stateStore.jobs).toContainEqual( expect.objectContaining({ payload: expect.objectContaining({ reviewers: ["bob"], @@ -76,7 +75,7 @@ describe("runGithubEscalationSweep", () => { type: "github.request-reviewers", }), ); - expect(stateStore.enqueueOutboxJob).toHaveBeenCalledWith( + expect(stateStore.jobs).toContainEqual( expect.objectContaining({ payload: expect.objectContaining({ body: expect.stringContaining("