@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.
- package/LICENSE +105 -0
- package/dist/availability.d.ts +164 -0
- package/dist/availability.js +974 -0
- package/dist/database.d.ts +3 -0
- package/dist/database.js +9 -0
- package/dist/decisions.d.ts +107 -0
- package/dist/decisions.js +435 -0
- package/dist/deliveries.d.ts +30 -0
- package/dist/deliveries.js +82 -0
- package/dist/heartbeat.d.ts +11 -0
- package/dist/heartbeat.js +11 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -0
- package/dist/jobs.d.ts +86 -0
- package/dist/jobs.js +641 -0
- package/dist/kysely.d.ts +202 -0
- package/dist/kysely.js +1 -0
- package/dist/migrate.d.ts +1 -0
- package/dist/migrate.js +45 -0
- package/dist/operations.d.ts +77 -0
- package/dist/operations.js +256 -0
- package/dist/outbox.d.ts +61 -0
- package/dist/outbox.js +230 -0
- package/dist/provider-connections.d.ts +34 -0
- package/dist/provider-connections.js +302 -0
- package/dist/retention.d.ts +6 -0
- package/dist/retention.js +44 -0
- package/dist/routing-recovery.d.ts +55 -0
- package/dist/routing-recovery.js +175 -0
- package/dist/workspaces.d.ts +50 -0
- package/dist/workspaces.js +43 -0
- package/migrations/0001_initial.sql +79 -0
- package/migrations/0002_selected_reviewers.sql +10 -0
- package/migrations/0003_human_review_policy.sql +10 -0
- package/migrations/0004_semantic_routing_deduplication.sql +15 -0
- package/migrations/0005_reviewer_availability.sql +58 -0
- package/migrations/0005_workspace_scope.sql +198 -0
- package/migrations/0006_decision_outbox.sql +24 -0
- package/migrations/0007_workspace_reviewer_availability.sql +192 -0
- package/migrations/0008_reviewer_mutation_intents.sql +160 -0
- package/migrations/0009_provider_connection_revocations.sql +18 -0
- package/migrations/0010_provider_connection_preemptive_revocations.sql +12 -0
- package/package.json +42 -0
package/dist/database.js
ADDED
|
@@ -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
|
+
}
|