@triagepilot/db 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +105 -0
  2. package/dist/availability.d.ts +164 -0
  3. package/dist/availability.js +974 -0
  4. package/dist/database.d.ts +3 -0
  5. package/dist/database.js +9 -0
  6. package/dist/decisions.d.ts +107 -0
  7. package/dist/decisions.js +435 -0
  8. package/dist/deliveries.d.ts +30 -0
  9. package/dist/deliveries.js +82 -0
  10. package/dist/heartbeat.d.ts +11 -0
  11. package/dist/heartbeat.js +11 -0
  12. package/dist/index.d.ts +14 -0
  13. package/dist/index.js +13 -0
  14. package/dist/jobs.d.ts +86 -0
  15. package/dist/jobs.js +641 -0
  16. package/dist/kysely.d.ts +202 -0
  17. package/dist/kysely.js +1 -0
  18. package/dist/migrate.d.ts +1 -0
  19. package/dist/migrate.js +45 -0
  20. package/dist/operations.d.ts +77 -0
  21. package/dist/operations.js +256 -0
  22. package/dist/outbox.d.ts +61 -0
  23. package/dist/outbox.js +230 -0
  24. package/dist/provider-connections.d.ts +34 -0
  25. package/dist/provider-connections.js +302 -0
  26. package/dist/retention.d.ts +6 -0
  27. package/dist/retention.js +44 -0
  28. package/dist/routing-recovery.d.ts +55 -0
  29. package/dist/routing-recovery.js +175 -0
  30. package/dist/workspaces.d.ts +50 -0
  31. package/dist/workspaces.js +43 -0
  32. package/migrations/0001_initial.sql +79 -0
  33. package/migrations/0002_selected_reviewers.sql +10 -0
  34. package/migrations/0003_human_review_policy.sql +10 -0
  35. package/migrations/0004_semantic_routing_deduplication.sql +15 -0
  36. package/migrations/0005_reviewer_availability.sql +58 -0
  37. package/migrations/0005_workspace_scope.sql +198 -0
  38. package/migrations/0006_decision_outbox.sql +24 -0
  39. package/migrations/0007_workspace_reviewer_availability.sql +192 -0
  40. package/migrations/0008_reviewer_mutation_intents.sql +160 -0
  41. package/migrations/0009_provider_connection_revocations.sql +18 -0
  42. package/migrations/0010_provider_connection_preemptive_revocations.sql +12 -0
  43. package/package.json +42 -0
@@ -0,0 +1,3 @@
1
+ import { Kysely } from "kysely";
2
+ import type { Database } from "./kysely.js";
3
+ export declare function createDatabase(databaseUrl: string): Kysely<Database>;
@@ -0,0 +1,9 @@
1
+ import { Kysely, PostgresDialect } from "kysely";
2
+ import pg from "pg";
3
+ export function createDatabase(databaseUrl) {
4
+ return new Kysely({
5
+ dialect: new PostgresDialect({
6
+ pool: new pg.Pool({ connectionString: databaseUrl }),
7
+ }),
8
+ });
9
+ }
@@ -0,0 +1,107 @@
1
+ import { type Kysely, type Transaction } from "kysely";
2
+ import { type ActionStatus, type DecisionEventV1, type ProviderConnectionId, type ProviderKind, type RepositoryMode, type RoutingAction, type WorkspaceId } from "@triagepilot/contracts";
3
+ import type { Database } from "./kysely.js";
4
+ export interface DecisionInput {
5
+ repositoryId: string;
6
+ deliveryId: string;
7
+ changeRequestId: string;
8
+ routingKey?: string;
9
+ pullNumber: number;
10
+ headSha: string;
11
+ mode: RepositoryMode;
12
+ action: string;
13
+ actionStatus: ActionStatus;
14
+ riskScore: number;
15
+ selectedReviewers?: string[];
16
+ noHumanReason?: string;
17
+ details: unknown;
18
+ organizationConfigVersion?: string | null;
19
+ repositoryConfigPath?: string | null;
20
+ repositoryConfigRevision?: string | null;
21
+ effectiveConfigHash?: string;
22
+ inheritanceMode?: "legacy" | "defaults" | "organization" | "replace" | "inherit";
23
+ configDiagnostics?: unknown[];
24
+ configSources?: Record<string, unknown>;
25
+ }
26
+ export interface PersistedDecision {
27
+ decisionId: string;
28
+ actionStatus: ActionStatus;
29
+ actionError: string | null;
30
+ actionAppliedAt: Date | null;
31
+ }
32
+ export interface PersistedDecisionEventContext extends PersistedDecision {
33
+ occurredAt: Date;
34
+ }
35
+ export declare class DecisionValidationError extends Error {
36
+ }
37
+ export interface HumanReviewPolicyDecision {
38
+ decisionId: string;
39
+ owner: string;
40
+ repo: string;
41
+ pullNumber: number;
42
+ headSha: string;
43
+ mode: RepositoryMode;
44
+ action: RoutingAction;
45
+ selectedReviewers: string[];
46
+ requiredApprovalCount?: number;
47
+ policyCheckRunId: string | null;
48
+ policyCheckState: "not_started" | "in_progress" | "success" | "failure";
49
+ }
50
+ export interface ReviewerReplacementCandidateDecision {
51
+ decisionId: string;
52
+ provider: ProviderKind;
53
+ providerConnectionId: ProviderConnectionId;
54
+ repositoryRecordId: string;
55
+ repositoryId: string;
56
+ owner: string;
57
+ repositoryName: string;
58
+ changeRequestId: string;
59
+ changeRequestNumber: number;
60
+ routedHeadRevision: string;
61
+ mode: RepositoryMode;
62
+ selectedActors: string[];
63
+ originalPreferredActors: string[];
64
+ originalEligibleActors: string[];
65
+ requestedReviewerCount: 1 | 2;
66
+ policyCheckRunId: string | null;
67
+ policyCheckState: HumanReviewPolicyDecision["policyCheckState"];
68
+ }
69
+ type PolicyCheckState = Exclude<HumanReviewPolicyDecision["policyCheckState"], "not_started">;
70
+ type DatabaseExecutor = Kysely<Database> | Transaction<Database>;
71
+ export declare function persistDecision(db: Kysely<Database>, workspaceId: WorkspaceId, input: DecisionInput): Promise<PersistedDecision>;
72
+ export declare function persistDecisionWithEvent(db: Kysely<Database>, workspaceId: WorkspaceId, input: {
73
+ decision: DecisionInput;
74
+ event(persisted: PersistedDecisionEventContext): DecisionEventV1;
75
+ }): Promise<PersistedDecision>;
76
+ export declare function recordPolicyCheck(db: Kysely<Database>, workspaceId: WorkspaceId, input: {
77
+ decisionId: string;
78
+ checkRunId: string;
79
+ state: PolicyCheckState;
80
+ }): Promise<void>;
81
+ export declare function findLatestHumanReviewPolicyDecision(db: Kysely<Database>, workspaceId: WorkspaceId, input: {
82
+ repositoryId: string;
83
+ pullNumber: number;
84
+ }): Promise<HumanReviewPolicyDecision | null>;
85
+ export declare function findReviewerReplacementCandidates(db: DatabaseExecutor, workspaceId: WorkspaceId, input: {
86
+ provider: ProviderKind;
87
+ providerConnectionId: ProviderConnectionId;
88
+ unavailableActorId: string;
89
+ recordedFor: {
90
+ absenceId: string;
91
+ absenceRevision: number;
92
+ };
93
+ }): Promise<ReviewerReplacementCandidateDecision[]>;
94
+ export declare function updatePolicyCheckState(db: Kysely<Database>, workspaceId: WorkspaceId, input: {
95
+ decisionId: string;
96
+ state: PolicyCheckState;
97
+ }): Promise<void>;
98
+ export declare function parseStrictActorList(value: unknown): string[] | null;
99
+ export declare function parseOriginalReviewerPool(details: unknown): {
100
+ eligibleActors: string[];
101
+ preferredActors: string[];
102
+ requestedReviewerCount: 1 | 2;
103
+ } | null;
104
+ export declare function parseExternalActorId(value: string): string | null;
105
+ export declare function markActionSucceeded(db: Kysely<Database>, workspaceId: WorkspaceId, decisionId: string, at: Date): Promise<void>;
106
+ export declare function markActionFailed(db: Kysely<Database>, workspaceId: WorkspaceId, decisionId: string, error: string, at: Date): Promise<void>;
107
+ export {};
@@ -0,0 +1,435 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import { sql } from "kysely";
4
+ import { legacyRoutingKey, } from "@triagepilot/contracts";
5
+ import { stagePlatformEvent } from "./outbox.js";
6
+ export class DecisionValidationError extends Error {
7
+ }
8
+ export async function persistDecision(db, workspaceId, input) {
9
+ return await persistDecisionRecord(db, workspaceId, input);
10
+ }
11
+ export async function persistDecisionWithEvent(db, workspaceId, input) {
12
+ return await db.transaction().execute(async (trx) => {
13
+ const persisted = await persistDecisionRecord(trx, workspaceId, input.decision);
14
+ const effective = await trx
15
+ .selectFrom("routing_decisions")
16
+ .innerJoin("repositories", (join) => join
17
+ .onRef("repositories.workspace_id", "=", "routing_decisions.workspace_id")
18
+ .onRef("repositories.id", "=", "routing_decisions.repository_id"))
19
+ .select([
20
+ "routing_decisions.id",
21
+ "routing_decisions.workspace_id",
22
+ "routing_decisions.change_request_id",
23
+ "routing_decisions.routing_key",
24
+ "routing_decisions.mode",
25
+ "routing_decisions.action",
26
+ "routing_decisions.risk_score",
27
+ "routing_decisions.selected_reviewers",
28
+ "routing_decisions.effective_config_hash",
29
+ "routing_decisions.created_at",
30
+ "repositories.provider",
31
+ "repositories.external_repository_id",
32
+ ])
33
+ .where("routing_decisions.workspace_id", "=", workspaceId)
34
+ .where("routing_decisions.id", "=", persisted.decisionId)
35
+ .forUpdate("routing_decisions")
36
+ .executeTakeFirstOrThrow();
37
+ const resolvedEvent = await resolveDecisionEvent(trx, effective);
38
+ const event = input.event({ ...persisted, occurredAt: resolvedEvent.occurredAt });
39
+ assertDecisionEventMatches(effective, event, resolvedEvent.occurredAt);
40
+ if (resolvedEvent.existingEvent !== null && !isDeepStrictEqual(event, resolvedEvent.existingEvent)) {
41
+ throw new DecisionValidationError("persisted routing event does not match callback event");
42
+ }
43
+ await stagePlatformEvent(trx, workspaceId, persisted.decisionId, event);
44
+ return persisted;
45
+ });
46
+ }
47
+ async function resolveDecisionEvent(db, decision) {
48
+ const existing = await db
49
+ .selectFrom("decision_outbox")
50
+ .select([
51
+ "decision_id",
52
+ "reviewer_replacement_id",
53
+ "event_id",
54
+ "event_type",
55
+ "schema_version",
56
+ "payload",
57
+ "occurred_at",
58
+ ])
59
+ .where("workspace_id", "=", decision.workspace_id)
60
+ .where("decision_id", "=", decision.id)
61
+ .forUpdate()
62
+ .execute();
63
+ if (existing.length === 0)
64
+ return { occurredAt: decision.created_at, existingEvent: null };
65
+ if (existing.length !== 1) {
66
+ throw new DecisionValidationError("ambiguous persisted routing events for decision source");
67
+ }
68
+ const stored = existing[0];
69
+ const payload = stored?.payload;
70
+ if (stored === undefined
71
+ || payload === null
72
+ || typeof payload !== "object"
73
+ || stored.decision_id !== decision.id
74
+ || stored.reviewer_replacement_id !== null
75
+ || stored.event_type !== "routing_decision"
76
+ || stored.schema_version !== 1
77
+ || payload.eventId !== stored.event_id
78
+ || payload.eventType !== stored.event_type
79
+ || payload.schemaVersion !== stored.schema_version) {
80
+ throw new DecisionValidationError("persisted routing event does not match decision source");
81
+ }
82
+ const existingEvent = payload;
83
+ assertDecisionEventMatches(decision, existingEvent, stored.occurred_at);
84
+ return { occurredAt: stored.occurred_at, existingEvent };
85
+ }
86
+ async function persistDecisionRecord(db, workspaceId, input) {
87
+ validateChangeRequestId(input.changeRequestId);
88
+ const selectedReviewers = [...new Set(input.selectedReviewers ?? [])].slice(0, 2);
89
+ const selectedReviewersJson = JSON.stringify(selectedReviewers);
90
+ const configDiagnosticsJson = JSON.stringify(input.configDiagnostics ?? []);
91
+ const routingKey = input.routingKey ?? legacyRoutingKey(input.deliveryId);
92
+ const decision = await db
93
+ .insertInto("routing_decisions")
94
+ .values({
95
+ workspace_id: workspaceId,
96
+ repository_id: input.repositoryId,
97
+ delivery_id: input.deliveryId,
98
+ routing_key: routingKey,
99
+ change_request_id: input.changeRequestId,
100
+ pull_number: input.pullNumber,
101
+ head_sha: input.headSha,
102
+ mode: input.mode,
103
+ action: input.action,
104
+ action_status: input.actionStatus,
105
+ action_error: null,
106
+ action_applied_at: null,
107
+ action_failed_at: null,
108
+ risk_score: input.riskScore,
109
+ selected_reviewer: selectedReviewers[0] ?? null,
110
+ selected_reviewers: selectedReviewersJson,
111
+ no_human_reason: input.noHumanReason ?? null,
112
+ details: input.details,
113
+ organization_config_version: input.organizationConfigVersion ?? null,
114
+ repository_config_path: input.repositoryConfigPath ?? null,
115
+ repository_config_revision: input.repositoryConfigRevision ?? null,
116
+ effective_config_hash: input.effectiveConfigHash ?? legacyConfigHash(input.details),
117
+ inheritance_mode: input.inheritanceMode ?? "legacy",
118
+ config_diagnostics: configDiagnosticsJson,
119
+ config_sources: input.configSources ?? {},
120
+ })
121
+ .onConflict((conflict) => conflict.columns(["workspace_id", "routing_key"]).doUpdateSet((eb) => ({
122
+ mode: preserveAfterSuccess("mode", input.mode),
123
+ action: preserveAfterSuccess("action", input.action),
124
+ risk_score: preserveAfterSuccess("risk_score", input.riskScore),
125
+ pull_number: preserveAfterSuccess("pull_number", input.pullNumber),
126
+ change_request_id: preserveAfterSuccess("change_request_id", input.changeRequestId),
127
+ head_sha: preserveAfterSuccess("head_sha", input.headSha),
128
+ selected_reviewer: preserveAfterSuccess("selected_reviewer", selectedReviewers[0] ?? null),
129
+ selected_reviewers: preserveAfterSuccess("selected_reviewers", selectedReviewersJson),
130
+ no_human_reason: preserveAfterSuccess("no_human_reason", input.noHumanReason ?? null),
131
+ details: preserveAfterSuccess("details", input.details),
132
+ organization_config_version: preserveAfterSuccess("organization_config_version", input.organizationConfigVersion ?? null),
133
+ repository_config_path: preserveAfterSuccess("repository_config_path", input.repositoryConfigPath ?? null),
134
+ repository_config_revision: preserveAfterSuccess("repository_config_revision", input.repositoryConfigRevision ?? null),
135
+ effective_config_hash: preserveAfterSuccess("effective_config_hash", input.effectiveConfigHash ?? legacyConfigHash(input.details)),
136
+ inheritance_mode: preserveAfterSuccess("inheritance_mode", input.inheritanceMode ?? "legacy"),
137
+ config_diagnostics: preserveAfterSuccess("config_diagnostics", configDiagnosticsJson),
138
+ config_sources: preserveAfterSuccess("config_sources", input.configSources ?? {}),
139
+ action_status: eb
140
+ .case()
141
+ .when("routing_decisions.action_status", "=", "succeeded")
142
+ .then("succeeded")
143
+ .else(input.actionStatus)
144
+ .end(),
145
+ })))
146
+ .returning(["id", "action_status", "action_error", "action_applied_at"])
147
+ .executeTakeFirstOrThrow();
148
+ return {
149
+ decisionId: decision.id,
150
+ actionStatus: decision.action_status,
151
+ actionError: decision.action_error,
152
+ actionAppliedAt: decision.action_applied_at,
153
+ };
154
+ }
155
+ function validateChangeRequestId(value) {
156
+ if (typeof value !== "string" || value.trim() === "") {
157
+ throw new DecisionValidationError("changeRequestId must be a non-empty provider identifier");
158
+ }
159
+ }
160
+ function assertDecisionEventMatches(decision, event, occurredAt) {
161
+ const selectedActors = parseStrictActorList(decision.selected_reviewers);
162
+ if (event.schemaVersion !== 1
163
+ || event.eventType !== "routing_decision"
164
+ || event.decisionId !== decision.id
165
+ || event.workspaceId !== decision.workspace_id
166
+ || event.provider !== decision.provider
167
+ || event.repositoryId !== decision.external_repository_id
168
+ || event.changeRequestId !== decision.change_request_id
169
+ || event.routingKey !== decision.routing_key
170
+ || event.mode !== decision.mode
171
+ || event.action !== decision.action
172
+ || event.riskScore !== decision.risk_score
173
+ || selectedActors === null
174
+ || !isDeepStrictEqual(event.selectedActors, selectedActors)
175
+ || event.effectiveConfigurationHash !== decision.effective_config_hash
176
+ || event.occurredAt !== occurredAt.toISOString())
177
+ throw new DecisionValidationError("routing decision event does not match persisted decision");
178
+ }
179
+ export async function recordPolicyCheck(db, workspaceId, input) {
180
+ await db
181
+ .updateTable("routing_decisions")
182
+ .set({ policy_check_run_id: input.checkRunId, policy_check_state: input.state })
183
+ .where((eb) => eb.and([
184
+ eb("workspace_id", "=", workspaceId),
185
+ eb("id", "=", input.decisionId),
186
+ eb("policy_check_state", "!=", "failure"),
187
+ ]))
188
+ .execute();
189
+ }
190
+ export async function findLatestHumanReviewPolicyDecision(db, workspaceId, input) {
191
+ const decision = await db
192
+ .selectFrom("routing_decisions")
193
+ .innerJoin("repositories", (join) => join
194
+ .onRef("repositories.id", "=", "routing_decisions.repository_id")
195
+ .onRef("repositories.workspace_id", "=", "routing_decisions.workspace_id"))
196
+ .select([
197
+ "routing_decisions.id as decisionId",
198
+ "repositories.owner",
199
+ "repositories.name as repo",
200
+ "routing_decisions.pull_number as pullNumber",
201
+ "routing_decisions.head_sha as headSha",
202
+ "routing_decisions.mode",
203
+ "routing_decisions.action",
204
+ "routing_decisions.selected_reviewers as selectedReviewers",
205
+ "routing_decisions.details as details",
206
+ "routing_decisions.policy_check_run_id as policyCheckRunId",
207
+ "routing_decisions.policy_check_state as policyCheckState",
208
+ ])
209
+ .where("routing_decisions.workspace_id", "=", workspaceId)
210
+ .where((eb) => eb.and([
211
+ eb("routing_decisions.repository_id", "=", input.repositoryId),
212
+ eb("routing_decisions.pull_number", "=", input.pullNumber),
213
+ ]))
214
+ .orderBy("routing_decisions.created_at", "desc")
215
+ .executeTakeFirst();
216
+ if (!decision ||
217
+ decision.pullNumber === null ||
218
+ decision.headSha === null ||
219
+ decision.mode !== "enforce")
220
+ return null;
221
+ return {
222
+ decisionId: decision.decisionId,
223
+ owner: decision.owner,
224
+ repo: decision.repo,
225
+ pullNumber: decision.pullNumber,
226
+ headSha: decision.headSha,
227
+ mode: decision.mode,
228
+ action: decision.action,
229
+ selectedReviewers: parseSelectedReviewers(decision.selectedReviewers),
230
+ requiredApprovalCount: parseRequiredApprovalCount(decision.details, parseSelectedReviewers(decision.selectedReviewers)),
231
+ policyCheckRunId: decision.policyCheckRunId,
232
+ policyCheckState: decision.policyCheckState,
233
+ };
234
+ }
235
+ export async function findReviewerReplacementCandidates(db, workspaceId, input) {
236
+ const unavailableActorId = parseExternalActorId(input.unavailableActorId);
237
+ if (unavailableActorId === null)
238
+ return [];
239
+ const latestDecisions = db
240
+ .selectFrom("routing_decisions")
241
+ .selectAll()
242
+ .where("workspace_id", "=", workspaceId)
243
+ .where("repository_id", "is not", null)
244
+ .distinctOn(["repository_id", "pull_number"])
245
+ .orderBy("repository_id")
246
+ .orderBy("pull_number")
247
+ .orderBy("created_at", "desc")
248
+ .orderBy("id", "desc");
249
+ let query = db
250
+ .with("latest_decisions", () => latestDecisions)
251
+ .selectFrom("latest_decisions")
252
+ .innerJoin("repositories", (join) => join
253
+ .onRef("repositories.workspace_id", "=", "latest_decisions.workspace_id")
254
+ .onRef("repositories.id", "=", "latest_decisions.repository_id"))
255
+ .innerJoin("provider_connections", (join) => join
256
+ .onRef("provider_connections.workspace_id", "=", "repositories.workspace_id")
257
+ .onRef("provider_connections.provider", "=", "repositories.provider")
258
+ .onRef("provider_connections.id", "=", "repositories.provider_connection_id"))
259
+ .select([
260
+ "latest_decisions.id as decisionId",
261
+ "repositories.provider",
262
+ "repositories.provider_connection_id as providerConnectionId",
263
+ "repositories.id as repositoryRecordId",
264
+ "repositories.external_repository_id as repositoryId",
265
+ "repositories.owner",
266
+ "repositories.name as repositoryName",
267
+ "latest_decisions.change_request_id as changeRequestId",
268
+ "latest_decisions.pull_number as changeRequestNumber",
269
+ "latest_decisions.head_sha as routedHeadRevision",
270
+ "latest_decisions.mode",
271
+ "latest_decisions.selected_reviewers as selectedActors",
272
+ "latest_decisions.details",
273
+ "latest_decisions.policy_check_run_id as policyCheckRunId",
274
+ "latest_decisions.policy_check_state as policyCheckState",
275
+ ])
276
+ .where("latest_decisions.workspace_id", "=", workspaceId)
277
+ .where("repositories.provider", "=", input.provider)
278
+ .where("repositories.provider_connection_id", "=", input.providerConnectionId)
279
+ .where("provider_connections.status", "=", "active")
280
+ .where("latest_decisions.action", "=", "request_human_review")
281
+ .where("latest_decisions.head_sha", "is not", null);
282
+ query = query.where(({ exists, not, selectFrom }) => not(exists(selectFrom("reviewer_replacements")
283
+ .select("reviewer_replacements.id")
284
+ .whereRef("reviewer_replacements.workspace_id", "=", "latest_decisions.workspace_id")
285
+ .where("reviewer_replacements.provider", "=", input.provider)
286
+ .where("reviewer_replacements.provider_connection_id", "=", input.providerConnectionId)
287
+ .where("reviewer_replacements.absence_id", "=", input.recordedFor.absenceId)
288
+ .where("reviewer_replacements.absence_revision", "=", input.recordedFor.absenceRevision)
289
+ .whereRef("reviewer_replacements.decision_id", "=", "latest_decisions.id"))));
290
+ const rows = await query
291
+ .orderBy("repositories.external_repository_id", "asc")
292
+ .orderBy("latest_decisions.pull_number", "asc")
293
+ .orderBy("latest_decisions.id", "asc")
294
+ .execute();
295
+ return rows.flatMap((row) => {
296
+ const selectedActors = parseStrictActorList(row.selectedActors);
297
+ const original = parseOriginalReviewerPool(row.details);
298
+ if (row.changeRequestNumber === null
299
+ || row.changeRequestId === null
300
+ || row.routedHeadRevision === null
301
+ || selectedActors === null
302
+ || !selectedActors.includes(unavailableActorId)
303
+ || original === null)
304
+ return [];
305
+ return [{
306
+ decisionId: row.decisionId,
307
+ provider: row.provider,
308
+ providerConnectionId: row.providerConnectionId,
309
+ repositoryRecordId: row.repositoryRecordId,
310
+ repositoryId: row.repositoryId,
311
+ owner: row.owner,
312
+ repositoryName: row.repositoryName,
313
+ changeRequestId: row.changeRequestId,
314
+ changeRequestNumber: row.changeRequestNumber,
315
+ routedHeadRevision: row.routedHeadRevision,
316
+ mode: row.mode,
317
+ selectedActors,
318
+ originalPreferredActors: original.preferredActors,
319
+ originalEligibleActors: original.eligibleActors,
320
+ requestedReviewerCount: original.requestedReviewerCount,
321
+ policyCheckRunId: row.policyCheckRunId,
322
+ policyCheckState: row.policyCheckState,
323
+ }];
324
+ });
325
+ }
326
+ function parseRequiredApprovalCount(details, selectedReviewers) {
327
+ if (typeof details === "object" &&
328
+ details !== null &&
329
+ "routing" in details &&
330
+ typeof details.routing === "object" &&
331
+ details.routing !== null &&
332
+ "requestedReviewerCount" in details.routing &&
333
+ (details.routing.requestedReviewerCount === 1 || details.routing.requestedReviewerCount === 2))
334
+ return details.routing.requestedReviewerCount;
335
+ return selectedReviewers.length;
336
+ }
337
+ export async function updatePolicyCheckState(db, workspaceId, input) {
338
+ await db
339
+ .updateTable("routing_decisions")
340
+ .set({ policy_check_state: input.state })
341
+ .where((eb) => eb.and([
342
+ eb("workspace_id", "=", workspaceId),
343
+ eb("id", "=", input.decisionId),
344
+ eb("policy_check_state", "!=", "failure"),
345
+ ]))
346
+ .execute();
347
+ }
348
+ function parseSelectedReviewers(value) {
349
+ const reviewers = typeof value === "string" ? parseJsonArray(value) : value;
350
+ return Array.isArray(reviewers) ? reviewers.filter((reviewer) => typeof reviewer === "string") : [];
351
+ }
352
+ export function parseStrictActorList(value) {
353
+ const actors = typeof value === "string" ? parseJsonArray(value) : value;
354
+ if (!Array.isArray(actors) || !actors.every((actor) => typeof actor === "string"))
355
+ return null;
356
+ const parsed = actors.map(parseExternalActorId);
357
+ if (parsed.some((actor) => actor === null))
358
+ return null;
359
+ return [...new Set(parsed)];
360
+ }
361
+ export function parseOriginalReviewerPool(details) {
362
+ if (typeof details !== "object"
363
+ || details === null
364
+ || !("ownership" in details)
365
+ || typeof details.ownership !== "object"
366
+ || details.ownership === null
367
+ || !("eligibleReviewers" in details.ownership)
368
+ || !("routing" in details)
369
+ || typeof details.routing !== "object"
370
+ || details.routing === null
371
+ || !("requestedReviewerCount" in details.routing))
372
+ return null;
373
+ const eligibleActors = parseStrictActorList(details.ownership.eligibleReviewers);
374
+ const preferredActors = "preferredReviewers" in details.ownership
375
+ ? parseStrictActorList(details.ownership.preferredReviewers)
376
+ : eligibleActors;
377
+ const requestedReviewerCount = details.routing.requestedReviewerCount;
378
+ if (eligibleActors === null
379
+ || preferredActors === null
380
+ || (requestedReviewerCount !== 1 && requestedReviewerCount !== 2))
381
+ return null;
382
+ return { eligibleActors, preferredActors, requestedReviewerCount };
383
+ }
384
+ export function parseExternalActorId(value) {
385
+ return value.trim() === "" ? null : value;
386
+ }
387
+ function parseJsonArray(value) {
388
+ try {
389
+ return JSON.parse(value);
390
+ }
391
+ catch {
392
+ return undefined;
393
+ }
394
+ }
395
+ function preserveAfterSuccess(column, nextValue) {
396
+ return sql `case
397
+ when routing_decisions.action_status = 'succeeded' then ${sql.ref(`routing_decisions.${column}`)}
398
+ else ${nextValue}
399
+ end`;
400
+ }
401
+ export async function markActionSucceeded(db, workspaceId, decisionId, at) {
402
+ await db
403
+ .updateTable("routing_decisions")
404
+ .set({
405
+ action_status: "succeeded",
406
+ action_error: null,
407
+ action_applied_at: at,
408
+ action_failed_at: null,
409
+ })
410
+ .where((eb) => eb.and([
411
+ eb("workspace_id", "=", workspaceId),
412
+ eb("id", "=", decisionId),
413
+ eb("action_status", "!=", "succeeded"),
414
+ ]))
415
+ .execute();
416
+ }
417
+ export async function markActionFailed(db, workspaceId, decisionId, error, at) {
418
+ await db
419
+ .updateTable("routing_decisions")
420
+ .set({
421
+ action_status: "failed",
422
+ action_error: error,
423
+ action_applied_at: null,
424
+ action_failed_at: at,
425
+ })
426
+ .where((eb) => eb.and([
427
+ eb("workspace_id", "=", workspaceId),
428
+ eb("id", "=", decisionId),
429
+ eb("action_status", "!=", "succeeded"),
430
+ ]))
431
+ .execute();
432
+ }
433
+ function legacyConfigHash(details) {
434
+ return createHash("sha256").update(JSON.stringify(details)).digest("hex");
435
+ }
@@ -0,0 +1,30 @@
1
+ import type { Kysely } from "kysely";
2
+ import { type HumanReviewPolicyJobPayload, type RoutingJobPayload, type WorkspaceId } from "@triagepilot/contracts";
3
+ import { type ProviderConnectionMetadata, type ProviderRepositoryMetadata } from "./provider-connections.js";
4
+ import type { Database } from "./kysely.js";
5
+ export interface RoutingDeliveryInput {
6
+ deliveryId: string;
7
+ eventName: string;
8
+ eventAction: string;
9
+ hookId: string | null;
10
+ connection: ProviderConnectionMetadata;
11
+ repository: ProviderRepositoryMetadata;
12
+ payload: Omit<RoutingJobPayload, "workspaceId" | "providerConnectionId">;
13
+ }
14
+ export interface HumanReviewPolicyDeliveryInput {
15
+ deliveryId: string;
16
+ eventName: string;
17
+ eventAction?: string;
18
+ hookId?: string | null;
19
+ connection: ProviderConnectionMetadata;
20
+ repository: ProviderRepositoryMetadata;
21
+ payload: Omit<HumanReviewPolicyJobPayload, "workspaceId" | "providerConnectionId">;
22
+ }
23
+ export declare function acceptRoutingDelivery(db: Kysely<Database>, workspaceId: WorkspaceId, input: RoutingDeliveryInput): Promise<{
24
+ inserted: boolean;
25
+ jobId: string | null;
26
+ }>;
27
+ export declare function acceptHumanReviewPolicyDelivery(db: Kysely<Database>, workspaceId: WorkspaceId, input: HumanReviewPolicyDeliveryInput): Promise<{
28
+ inserted: boolean;
29
+ jobId: string | null;
30
+ }>;
@@ -0,0 +1,82 @@
1
+ import { legacyRoutingKey } from "@triagepilot/contracts";
2
+ import { upsertDeliveryRepository } from "./provider-connections.js";
3
+ export async function acceptRoutingDelivery(db, workspaceId, input) {
4
+ return await db.transaction().execute(async (trx) => {
5
+ const projection = await upsertDeliveryRepository(trx, workspaceId, input.connection, input.repository);
6
+ if (projection === null)
7
+ return { inserted: false, jobId: null };
8
+ const { providerConnectionId, repositoryId } = projection;
9
+ const receipt = await trx
10
+ .insertInto("webhook_receipts")
11
+ .values({
12
+ workspace_id: workspaceId,
13
+ provider: input.connection.provider,
14
+ delivery_id: input.deliveryId,
15
+ event_name: input.eventName,
16
+ event_action: input.eventAction,
17
+ hook_id: input.hookId,
18
+ external_connection_id: input.connection.externalConnectionId,
19
+ payload_summary: { repositoryId },
20
+ })
21
+ .onConflict((conflict) => conflict.columns(["workspace_id", "provider", "delivery_id"]).doNothing())
22
+ .returning("delivery_id")
23
+ .executeTakeFirst();
24
+ if (!receipt)
25
+ return { inserted: false, jobId: null };
26
+ const job = await trx
27
+ .insertInto("jobs")
28
+ .values({
29
+ workspace_id: workspaceId,
30
+ provider: input.repository.provider,
31
+ provider_connection_id: providerConnectionId,
32
+ kind: "process_pull_request",
33
+ payload: { ...input.payload, workspaceId, providerConnectionId },
34
+ idempotency_key: input.payload.routingKey ?? legacyRoutingKey(input.deliveryId),
35
+ })
36
+ .onConflict((conflict) => conflict.columns(["workspace_id", "idempotency_key"]).doNothing())
37
+ .returning("id")
38
+ .executeTakeFirst();
39
+ return { inserted: true, jobId: job?.id ?? null };
40
+ });
41
+ }
42
+ export async function acceptHumanReviewPolicyDelivery(db, workspaceId, input) {
43
+ return await db.transaction().execute(async (trx) => {
44
+ const projection = await upsertDeliveryRepository(trx, workspaceId, input.connection, input.repository);
45
+ if (projection === null)
46
+ return { inserted: false, jobId: null };
47
+ const { providerConnectionId, repositoryId } = projection;
48
+ const receipt = await trx
49
+ .insertInto("webhook_receipts")
50
+ .values({
51
+ workspace_id: workspaceId,
52
+ provider: input.connection.provider,
53
+ delivery_id: input.deliveryId,
54
+ event_name: input.eventName,
55
+ event_action: input.eventAction ?? null,
56
+ hook_id: input.hookId ?? null,
57
+ external_connection_id: input.connection.externalConnectionId,
58
+ payload_summary: { repositoryId },
59
+ })
60
+ .onConflict((conflict) => conflict.columns(["workspace_id", "provider", "delivery_id"]).doNothing())
61
+ .returning("delivery_id")
62
+ .executeTakeFirst();
63
+ if (!receipt)
64
+ return { inserted: false, jobId: null };
65
+ const job = await trx
66
+ .insertInto("jobs")
67
+ .values({
68
+ workspace_id: workspaceId,
69
+ provider: input.repository.provider,
70
+ provider_connection_id: providerConnectionId,
71
+ kind: "evaluate_human_review_policy",
72
+ payload: { ...input.payload, workspaceId, providerConnectionId },
73
+ idempotency_key: `review-policy:${input.deliveryId}`,
74
+ })
75
+ .onConflict((conflict) => conflict.columns(["workspace_id", "idempotency_key"]).doNothing())
76
+ .returning("id")
77
+ .executeTakeFirst();
78
+ if (!job)
79
+ throw new Error("review policy receipt inserted without a policy-evaluation job");
80
+ return { inserted: true, jobId: job.id };
81
+ });
82
+ }