@triagepilot/application 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/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/review-policy.d.ts +52 -0
- package/dist/review-policy.js +58 -0
- package/dist/reviewer-availability.d.ts +290 -0
- package/dist/reviewer-availability.js +878 -0
- package/dist/routing-recovery.d.ts +68 -0
- package/dist/routing-recovery.js +150 -0
- package/dist/routing.d.ts +96 -0
- package/dist/routing.js +221 -0
- package/package.json +37 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type ChangeRequestId, type ProviderConnectionId, type ProviderKind, type RepositoryRef, type RoutingJobPayload, type WorkspaceId } from "@triagepilot/contracts";
|
|
2
|
+
declare const routingRecoveryDecisionIdBrand: unique symbol;
|
|
3
|
+
type RoutingRecoveryDecisionId = string & {
|
|
4
|
+
readonly [routingRecoveryDecisionIdBrand]: true;
|
|
5
|
+
};
|
|
6
|
+
export type RoutingRecoveryRequest = {
|
|
7
|
+
decisionId: string;
|
|
8
|
+
} | {
|
|
9
|
+
changeRequest: {
|
|
10
|
+
repository: RepositoryRef;
|
|
11
|
+
externalId: ChangeRequestId;
|
|
12
|
+
number: number;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
type ValidatedRoutingRecoveryRequest = {
|
|
16
|
+
decisionId: RoutingRecoveryDecisionId;
|
|
17
|
+
} | Exclude<RoutingRecoveryRequest, {
|
|
18
|
+
decisionId: string;
|
|
19
|
+
}>;
|
|
20
|
+
export interface RoutingRecoveryTarget {
|
|
21
|
+
providerConnectionId: ProviderConnectionId;
|
|
22
|
+
repository: RepositoryRef;
|
|
23
|
+
changeRequestId: ChangeRequestId;
|
|
24
|
+
changeRequestNumber: number;
|
|
25
|
+
}
|
|
26
|
+
export interface RoutingRecoveryCurrentState {
|
|
27
|
+
state: string;
|
|
28
|
+
baseRevision: string;
|
|
29
|
+
headRevision: string;
|
|
30
|
+
isDraft: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface EnqueueRoutingRecoveryInput {
|
|
33
|
+
workspaceId: WorkspaceId;
|
|
34
|
+
provider: ProviderKind;
|
|
35
|
+
providerConnectionId: ProviderConnectionId;
|
|
36
|
+
payload: RoutingJobPayload;
|
|
37
|
+
idempotencyKey: string;
|
|
38
|
+
}
|
|
39
|
+
export interface RoutingRecoveryPorts {
|
|
40
|
+
findTarget(input: {
|
|
41
|
+
workspaceId: WorkspaceId;
|
|
42
|
+
request: ValidatedRoutingRecoveryRequest;
|
|
43
|
+
}): Promise<RoutingRecoveryTarget | null>;
|
|
44
|
+
fetchCurrentState(input: {
|
|
45
|
+
workspaceId: WorkspaceId;
|
|
46
|
+
} & RoutingRecoveryTarget): Promise<RoutingRecoveryCurrentState | null>;
|
|
47
|
+
enqueue(input: EnqueueRoutingRecoveryInput): Promise<{
|
|
48
|
+
jobId: string;
|
|
49
|
+
} | null>;
|
|
50
|
+
createRunId(): string;
|
|
51
|
+
}
|
|
52
|
+
export declare class RoutingRecoveryValidationError extends Error {
|
|
53
|
+
readonly code: "invalid_target";
|
|
54
|
+
}
|
|
55
|
+
export declare class RoutingRecoveryTargetUnavailableError extends Error {
|
|
56
|
+
readonly code: "not_found_or_inactive";
|
|
57
|
+
}
|
|
58
|
+
export declare class RoutingRecoveryClosedError extends Error {
|
|
59
|
+
readonly code: "change_request_closed";
|
|
60
|
+
}
|
|
61
|
+
export declare function queueRoutingRecovery(input: {
|
|
62
|
+
workspaceId: WorkspaceId;
|
|
63
|
+
request: RoutingRecoveryRequest;
|
|
64
|
+
}, ports: RoutingRecoveryPorts): Promise<{
|
|
65
|
+
jobId: string;
|
|
66
|
+
routingKey: string;
|
|
67
|
+
}>;
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { buildRoutingKey, } from "@triagepilot/contracts";
|
|
2
|
+
const PROVIDERS = new Set(["github", "gitlab", "bitbucket"]);
|
|
3
|
+
const MAX_CHANGE_REQUEST_NUMBER = 2_147_483_647;
|
|
4
|
+
const routingRecoveryDecisionIdBrand = Symbol("RoutingRecoveryDecisionId");
|
|
5
|
+
export class RoutingRecoveryValidationError extends Error {
|
|
6
|
+
code = "invalid_target";
|
|
7
|
+
}
|
|
8
|
+
export class RoutingRecoveryTargetUnavailableError extends Error {
|
|
9
|
+
code = "not_found_or_inactive";
|
|
10
|
+
}
|
|
11
|
+
export class RoutingRecoveryClosedError extends Error {
|
|
12
|
+
code = "change_request_closed";
|
|
13
|
+
}
|
|
14
|
+
export async function queueRoutingRecovery(input, ports) {
|
|
15
|
+
const request = parseRoutingRecoveryRequest(input.workspaceId, input.request);
|
|
16
|
+
const target = await ports.findTarget({ workspaceId: input.workspaceId, request });
|
|
17
|
+
if (target === null) {
|
|
18
|
+
throw new RoutingRecoveryTargetUnavailableError("Routing recovery target is unavailable in this workspace");
|
|
19
|
+
}
|
|
20
|
+
const providerState = await ports.fetchCurrentState({ workspaceId: input.workspaceId, ...target });
|
|
21
|
+
if (providerState === null) {
|
|
22
|
+
throw new RoutingRecoveryTargetUnavailableError("Routing recovery target is unavailable in this workspace");
|
|
23
|
+
}
|
|
24
|
+
const current = parseCurrentState(providerState);
|
|
25
|
+
if (current.state !== "open") {
|
|
26
|
+
throw new RoutingRecoveryClosedError("Only an open change request can be routed again");
|
|
27
|
+
}
|
|
28
|
+
const runId = parseRunId(ports.createRunId());
|
|
29
|
+
const routingKey = `${buildRoutingKey({
|
|
30
|
+
workspaceId: input.workspaceId,
|
|
31
|
+
provider: target.repository.provider,
|
|
32
|
+
repositoryId: target.repository.externalId,
|
|
33
|
+
changeRequestId: target.changeRequestId,
|
|
34
|
+
trustedConfigRevision: current.baseRevision,
|
|
35
|
+
headRevision: current.headRevision,
|
|
36
|
+
isDraft: current.isDraft,
|
|
37
|
+
})}:operator:${runId}`;
|
|
38
|
+
const payload = {
|
|
39
|
+
kind: "process_change_request",
|
|
40
|
+
deliveryId: `operator:${runId}`,
|
|
41
|
+
eventName: "operator.routing_recovery",
|
|
42
|
+
workspaceId: input.workspaceId,
|
|
43
|
+
providerConnectionId: target.providerConnectionId,
|
|
44
|
+
changeRequest: {
|
|
45
|
+
repository: target.repository,
|
|
46
|
+
externalId: target.changeRequestId,
|
|
47
|
+
number: target.changeRequestNumber,
|
|
48
|
+
baseRevision: current.baseRevision,
|
|
49
|
+
headRevision: current.headRevision,
|
|
50
|
+
},
|
|
51
|
+
isDraft: current.isDraft,
|
|
52
|
+
routingKey,
|
|
53
|
+
};
|
|
54
|
+
const queued = await ports.enqueue({
|
|
55
|
+
workspaceId: input.workspaceId,
|
|
56
|
+
provider: target.repository.provider,
|
|
57
|
+
providerConnectionId: target.providerConnectionId,
|
|
58
|
+
payload,
|
|
59
|
+
idempotencyKey: routingKey,
|
|
60
|
+
});
|
|
61
|
+
if (queued === null) {
|
|
62
|
+
throw new RoutingRecoveryTargetUnavailableError("Routing recovery target is unavailable in this workspace");
|
|
63
|
+
}
|
|
64
|
+
return { jobId: queued.jobId, routingKey };
|
|
65
|
+
}
|
|
66
|
+
function parseRoutingRecoveryRequest(workspaceId, request) {
|
|
67
|
+
if (!isNonEmptyString(workspaceId) || !isRecord(request)) {
|
|
68
|
+
throw new RoutingRecoveryValidationError("Routing recovery target is invalid");
|
|
69
|
+
}
|
|
70
|
+
const keys = Object.keys(request);
|
|
71
|
+
if (keys.length !== 1)
|
|
72
|
+
throw new RoutingRecoveryValidationError("Exactly one routing recovery target is required");
|
|
73
|
+
if (keys[0] === "decisionId") {
|
|
74
|
+
return { decisionId: parseDecisionId(request.decisionId) };
|
|
75
|
+
}
|
|
76
|
+
if (keys[0] !== "changeRequest" || !isRecord(request.changeRequest)) {
|
|
77
|
+
throw new RoutingRecoveryValidationError("Routing recovery target is invalid");
|
|
78
|
+
}
|
|
79
|
+
const changeRequest = request.changeRequest;
|
|
80
|
+
if (!hasExactKeys(changeRequest, ["repository", "externalId", "number"])
|
|
81
|
+
|| !isRecord(changeRequest.repository)
|
|
82
|
+
|| !hasExactKeys(changeRequest.repository, ["provider", "externalId", "owner", "name"])
|
|
83
|
+
|| !PROVIDERS.has(changeRequest.repository.provider)
|
|
84
|
+
|| !isNonEmptyString(changeRequest.repository.externalId)
|
|
85
|
+
|| !isNonEmptyString(changeRequest.repository.owner)
|
|
86
|
+
|| !isNonEmptyString(changeRequest.repository.name)
|
|
87
|
+
|| !isNonEmptyString(changeRequest.externalId)
|
|
88
|
+
|| typeof changeRequest.number !== "number"
|
|
89
|
+
|| !Number.isSafeInteger(changeRequest.number)
|
|
90
|
+
|| changeRequest.number < 1
|
|
91
|
+
|| changeRequest.number > MAX_CHANGE_REQUEST_NUMBER) {
|
|
92
|
+
throw new RoutingRecoveryValidationError("Routing recovery change-request reference is invalid");
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
changeRequest: {
|
|
96
|
+
repository: {
|
|
97
|
+
provider: changeRequest.repository.provider,
|
|
98
|
+
externalId: changeRequest.repository.externalId.trim(),
|
|
99
|
+
owner: changeRequest.repository.owner.trim(),
|
|
100
|
+
name: changeRequest.repository.name.trim(),
|
|
101
|
+
},
|
|
102
|
+
externalId: changeRequest.externalId.trim(),
|
|
103
|
+
number: changeRequest.number,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function parseCurrentState(state) {
|
|
108
|
+
if (!isRecord(state)
|
|
109
|
+
|| !hasExactKeys(state, ["state", "baseRevision", "headRevision", "isDraft"])
|
|
110
|
+
|| !isNonEmptyString(state.state)
|
|
111
|
+
|| !isNonEmptyString(state.baseRevision)
|
|
112
|
+
|| !isNonEmptyString(state.headRevision)
|
|
113
|
+
|| typeof state.isDraft !== "boolean") {
|
|
114
|
+
throw new RoutingRecoveryValidationError("Current provider change-request state is invalid");
|
|
115
|
+
}
|
|
116
|
+
const normalizedState = state.state.trim().toLowerCase();
|
|
117
|
+
if (normalizedState !== "open" && normalizedState !== "closed") {
|
|
118
|
+
throw new RoutingRecoveryValidationError("Current provider change-request state is invalid");
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
state: normalizedState,
|
|
122
|
+
baseRevision: state.baseRevision.trim(),
|
|
123
|
+
headRevision: state.headRevision.trim(),
|
|
124
|
+
isDraft: state.isDraft,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function parseDecisionId(value) {
|
|
128
|
+
const normalized = typeof value === "string" ? value.trim() : "";
|
|
129
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(normalized)) {
|
|
130
|
+
throw new RoutingRecoveryValidationError("Routing recovery decision ID is invalid");
|
|
131
|
+
}
|
|
132
|
+
return normalized;
|
|
133
|
+
}
|
|
134
|
+
function parseRunId(value) {
|
|
135
|
+
if (!isNonEmptyString(value) || !/^[A-Za-z0-9._-]+$/.test(value)) {
|
|
136
|
+
throw new RoutingRecoveryValidationError("Routing recovery run identity is invalid");
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
function isNonEmptyString(value) {
|
|
141
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
142
|
+
}
|
|
143
|
+
function isRecord(value) {
|
|
144
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
145
|
+
}
|
|
146
|
+
function hasExactKeys(record, keys) {
|
|
147
|
+
const actual = Object.keys(record).sort();
|
|
148
|
+
const expected = [...keys].sort();
|
|
149
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
150
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { EffectiveConfigurationProvenance, EffectiveConfigurationResult } from "@triagepilot/config";
|
|
2
|
+
import { type ActionStatus, type ChangeRequestId, type Clock, type DecisionEventV1, type ExternalActorId, type HumanReviewPolicyJobPayload, type ProviderConnectionId, type RepositoryMode, type RepositoryRef, type RoutingAction, type RoutingJobPayload, type WorkspaceId } from "@triagepilot/contracts";
|
|
3
|
+
import { type ChangedFileMetadata, type ReviewerAbsenceWindow, type RiskScoringResult } from "@triagepilot/core";
|
|
4
|
+
export interface DecisionInput {
|
|
5
|
+
workspaceId: WorkspaceId;
|
|
6
|
+
repository: RepositoryRef;
|
|
7
|
+
deliveryId: string;
|
|
8
|
+
routingKey: string;
|
|
9
|
+
changeRequestId: ChangeRequestId;
|
|
10
|
+
changeRequestNumber: number;
|
|
11
|
+
headRevision: string;
|
|
12
|
+
mode: RepositoryMode;
|
|
13
|
+
action: RoutingAction;
|
|
14
|
+
actionStatus: ActionStatus;
|
|
15
|
+
riskScore: number;
|
|
16
|
+
selectedActors?: ExternalActorId[];
|
|
17
|
+
noHumanReason?: string;
|
|
18
|
+
details: unknown;
|
|
19
|
+
organizationConfigVersion: string | null;
|
|
20
|
+
repositoryConfigPath: string | null;
|
|
21
|
+
repositoryConfigRevision: string | null;
|
|
22
|
+
effectiveConfigHash: string | null;
|
|
23
|
+
inheritanceMode: EffectiveConfigurationProvenance["inheritanceMode"];
|
|
24
|
+
configDiagnostics: unknown[];
|
|
25
|
+
configSources: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
export interface PersistedDecision {
|
|
28
|
+
decisionId: string;
|
|
29
|
+
actionStatus: ActionStatus;
|
|
30
|
+
actionError: string | null;
|
|
31
|
+
actionAppliedAt: Date | null;
|
|
32
|
+
}
|
|
33
|
+
export interface PersistedDecisionEventContext extends PersistedDecision {
|
|
34
|
+
occurredAt: Date;
|
|
35
|
+
}
|
|
36
|
+
export type DecisionEventFactory = (persisted: PersistedDecisionEventContext) => DecisionEventV1;
|
|
37
|
+
export interface RoutingApplicationPorts {
|
|
38
|
+
resolveConfiguration(job: RoutingJobPayload): Promise<EffectiveConfigurationResult>;
|
|
39
|
+
provider: {
|
|
40
|
+
fetchChangeRequestMetadata(job: RoutingJobPayload): Promise<{
|
|
41
|
+
author: ExternalActorId;
|
|
42
|
+
sourceBranch: string;
|
|
43
|
+
targetBranch: string;
|
|
44
|
+
currentHeadRevision: string;
|
|
45
|
+
}>;
|
|
46
|
+
fetchChangedFiles(job: RoutingJobPayload): Promise<ChangedFileMetadata[]>;
|
|
47
|
+
fetchCommitMessages(job: RoutingJobPayload): Promise<string[]>;
|
|
48
|
+
fetchCurrentRevisionApprovals(job: RoutingJobPayload): Promise<ExternalActorId[]>;
|
|
49
|
+
applyActions(input: {
|
|
50
|
+
workspaceId: WorkspaceId;
|
|
51
|
+
providerConnectionId: ProviderConnectionId;
|
|
52
|
+
repository: RepositoryRef;
|
|
53
|
+
changeRequestId: ChangeRequestId;
|
|
54
|
+
changeRequestNumber: number;
|
|
55
|
+
expectedHeadRevision: string;
|
|
56
|
+
decisionId: string;
|
|
57
|
+
action: RoutingAction;
|
|
58
|
+
risk: RiskScoringResult;
|
|
59
|
+
selectedActors: ExternalActorId[];
|
|
60
|
+
actorsToRequest: ExternalActorId[];
|
|
61
|
+
noHumanReason?: string;
|
|
62
|
+
}): Promise<void>;
|
|
63
|
+
};
|
|
64
|
+
reviewerLoad(input: {
|
|
65
|
+
workspaceId: WorkspaceId;
|
|
66
|
+
actors: ExternalActorId[];
|
|
67
|
+
}): Promise<Record<string, number>>;
|
|
68
|
+
availability: {
|
|
69
|
+
findActive(input: {
|
|
70
|
+
workspaceId: WorkspaceId;
|
|
71
|
+
providerConnectionId: ProviderConnectionId;
|
|
72
|
+
actors: ExternalActorId[];
|
|
73
|
+
at: Date;
|
|
74
|
+
}): Promise<ReviewerAbsenceWindow[]>;
|
|
75
|
+
};
|
|
76
|
+
decisions: {
|
|
77
|
+
persistWithEvent(input: DecisionInput, event: DecisionEventFactory): Promise<PersistedDecision>;
|
|
78
|
+
markActionSucceeded(decisionId: string, at: Date): Promise<void>;
|
|
79
|
+
markActionFailed(decisionId: string, error: string, at: Date): Promise<void>;
|
|
80
|
+
};
|
|
81
|
+
enqueueReviewPolicy(input: HumanReviewPolicyJobPayload): Promise<void>;
|
|
82
|
+
clock: Clock;
|
|
83
|
+
}
|
|
84
|
+
export type RoutingOutcome = {
|
|
85
|
+
status: "skipped";
|
|
86
|
+
reason: "draft" | "target_branch" | "source_branch" | "stale_revision";
|
|
87
|
+
} | {
|
|
88
|
+
status: "configuration_failure";
|
|
89
|
+
decisionId: string;
|
|
90
|
+
} | {
|
|
91
|
+
status: "decided";
|
|
92
|
+
decisionId: string;
|
|
93
|
+
mode: RepositoryMode;
|
|
94
|
+
actionStatus: ActionStatus;
|
|
95
|
+
};
|
|
96
|
+
export declare function processChangeRequest(job: RoutingJobPayload, ports: RoutingApplicationPorts): Promise<RoutingOutcome>;
|
package/dist/routing.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { availableActorsAt, decideRouting, isBranchExcluded, matchOwnership, scorePullRequestRisk, } from "@triagepilot/core";
|
|
2
|
+
export async function processChangeRequest(job, ports) {
|
|
3
|
+
const configuration = await ports.resolveConfiguration(job);
|
|
4
|
+
if (!configuration.ok) {
|
|
5
|
+
const persisted = await ports.decisions.persistWithEvent({
|
|
6
|
+
...decisionIdentity(job),
|
|
7
|
+
mode: "shadow",
|
|
8
|
+
action: "configuration_failure",
|
|
9
|
+
actionStatus: "not_applied",
|
|
10
|
+
riskScore: 0,
|
|
11
|
+
details: { changeRequestNumber: job.changeRequest.number, diagnostics: configuration.diagnostics },
|
|
12
|
+
...persistenceProvenance(configuration.provenance, configuration.diagnostics),
|
|
13
|
+
}, ({ decisionId, occurredAt }) => decisionEvent({
|
|
14
|
+
job,
|
|
15
|
+
decisionId,
|
|
16
|
+
mode: "shadow",
|
|
17
|
+
action: "configuration_failure",
|
|
18
|
+
riskScore: 0,
|
|
19
|
+
selectedActors: [],
|
|
20
|
+
effectiveConfigurationHash: configuration.provenance.effectiveHash ?? "invalid",
|
|
21
|
+
occurredAt,
|
|
22
|
+
}));
|
|
23
|
+
return { status: "configuration_failure", decisionId: persisted.decisionId };
|
|
24
|
+
}
|
|
25
|
+
const { config, provenance } = configuration;
|
|
26
|
+
if (job.isDraft && !config.routing.includeDraftPullRequests) {
|
|
27
|
+
return { status: "skipped", reason: "draft" };
|
|
28
|
+
}
|
|
29
|
+
const metadata = await ports.provider.fetchChangeRequestMetadata(job);
|
|
30
|
+
if (metadata.currentHeadRevision !== job.changeRequest.headRevision) {
|
|
31
|
+
return { status: "skipped", reason: "stale_revision" };
|
|
32
|
+
}
|
|
33
|
+
if (config.routing.excludeTargetBranches.includes(metadata.targetBranch)) {
|
|
34
|
+
return { status: "skipped", reason: "target_branch" };
|
|
35
|
+
}
|
|
36
|
+
if (isBranchExcluded(metadata.sourceBranch, config.routing.excludeSourceBranchPatterns)) {
|
|
37
|
+
return { status: "skipped", reason: "source_branch" };
|
|
38
|
+
}
|
|
39
|
+
const [changedFiles, commitMessages] = await Promise.all([
|
|
40
|
+
ports.provider.fetchChangedFiles(job),
|
|
41
|
+
ports.provider.fetchCommitMessages(job),
|
|
42
|
+
]);
|
|
43
|
+
const ownership = matchOwnership({
|
|
44
|
+
files: changedFiles.map((file) => file.path),
|
|
45
|
+
rules: config.ownership.rules,
|
|
46
|
+
fallbackReviewers: config.ownership.fallbackReviewers,
|
|
47
|
+
});
|
|
48
|
+
const availabilityEvaluatedAt = ports.clock.now();
|
|
49
|
+
const availabilityActors = availableActorsAt({
|
|
50
|
+
actors: [...ownership.preferredReviewers, ...ownership.eligibleReviewers],
|
|
51
|
+
absences: [],
|
|
52
|
+
now: availabilityEvaluatedAt,
|
|
53
|
+
});
|
|
54
|
+
const absences = await ports.availability.findActive({
|
|
55
|
+
workspaceId: job.workspaceId,
|
|
56
|
+
providerConnectionId: job.providerConnectionId,
|
|
57
|
+
actors: availabilityActors,
|
|
58
|
+
at: availabilityEvaluatedAt,
|
|
59
|
+
});
|
|
60
|
+
const canonicalEligibleReviewers = availableActorsAt({
|
|
61
|
+
actors: ownership.eligibleReviewers,
|
|
62
|
+
absences: [],
|
|
63
|
+
now: availabilityEvaluatedAt,
|
|
64
|
+
});
|
|
65
|
+
const availableEligibleReviewers = availableActorsAt({
|
|
66
|
+
actors: ownership.eligibleReviewers,
|
|
67
|
+
absences,
|
|
68
|
+
now: availabilityEvaluatedAt,
|
|
69
|
+
});
|
|
70
|
+
const availablePreferredReviewers = availableActorsAt({
|
|
71
|
+
actors: ownership.preferredReviewers,
|
|
72
|
+
absences,
|
|
73
|
+
now: availabilityEvaluatedAt,
|
|
74
|
+
});
|
|
75
|
+
const availableEligibleSet = new Set(availableEligibleReviewers);
|
|
76
|
+
const excludedReviewers = canonicalEligibleReviewers.filter((actor) => !availableEligibleSet.has(actor));
|
|
77
|
+
const load = await ports.reviewerLoad({
|
|
78
|
+
workspaceId: job.workspaceId,
|
|
79
|
+
actors: availableEligibleReviewers,
|
|
80
|
+
});
|
|
81
|
+
const risk = scorePullRequestRisk({
|
|
82
|
+
files: changedFiles,
|
|
83
|
+
author: metadata.author,
|
|
84
|
+
branchName: metadata.sourceBranch,
|
|
85
|
+
commitMessages,
|
|
86
|
+
config: config.risk,
|
|
87
|
+
});
|
|
88
|
+
const existingApprovedReviewers = risk.tier === "low"
|
|
89
|
+
? []
|
|
90
|
+
: await ports.provider.fetchCurrentRevisionApprovals(job);
|
|
91
|
+
const routing = decideRouting({
|
|
92
|
+
risk,
|
|
93
|
+
author: metadata.author,
|
|
94
|
+
preferredReviewers: availablePreferredReviewers,
|
|
95
|
+
eligibleReviewers: availableEligibleReviewers,
|
|
96
|
+
existingApprovedReviewers,
|
|
97
|
+
load,
|
|
98
|
+
highRiskReviewers: config.routing.highRiskReviewers,
|
|
99
|
+
selectionKey: `${job.changeRequest.repository.owner}/${job.changeRequest.repository.name}#${job.changeRequest.number}`,
|
|
100
|
+
});
|
|
101
|
+
const initialActionStatus = config.mode === "enforce" && routing.action !== "no_eligible_reviewer"
|
|
102
|
+
? "pending"
|
|
103
|
+
: "not_applied";
|
|
104
|
+
const decision = {
|
|
105
|
+
...decisionIdentity(job),
|
|
106
|
+
mode: config.mode,
|
|
107
|
+
action: routing.action,
|
|
108
|
+
actionStatus: initialActionStatus,
|
|
109
|
+
riskScore: risk.score,
|
|
110
|
+
details: {
|
|
111
|
+
changeRequestNumber: job.changeRequest.number,
|
|
112
|
+
risk,
|
|
113
|
+
ownership,
|
|
114
|
+
availability: {
|
|
115
|
+
evaluatedAt: availabilityEvaluatedAt.toISOString(),
|
|
116
|
+
excludedReviewers,
|
|
117
|
+
},
|
|
118
|
+
routing,
|
|
119
|
+
},
|
|
120
|
+
...persistenceProvenance(provenance, []),
|
|
121
|
+
};
|
|
122
|
+
if (routing.selectedReviewers.length > 0)
|
|
123
|
+
decision.selectedActors = routing.selectedReviewers;
|
|
124
|
+
if (routing.noHumanReason !== undefined)
|
|
125
|
+
decision.noHumanReason = routing.noHumanReason;
|
|
126
|
+
const persisted = await ports.decisions.persistWithEvent(decision, ({ decisionId, occurredAt }) => decisionEvent({
|
|
127
|
+
job,
|
|
128
|
+
decisionId,
|
|
129
|
+
mode: config.mode,
|
|
130
|
+
action: routing.action,
|
|
131
|
+
riskScore: risk.score,
|
|
132
|
+
selectedActors: routing.selectedReviewers,
|
|
133
|
+
effectiveConfigurationHash: provenance.effectiveHash ?? "invalid",
|
|
134
|
+
occurredAt,
|
|
135
|
+
}));
|
|
136
|
+
if (persisted.actionStatus === "succeeded") {
|
|
137
|
+
return { status: "decided", decisionId: persisted.decisionId, mode: config.mode, actionStatus: "succeeded" };
|
|
138
|
+
}
|
|
139
|
+
let actionStatus = persisted.actionStatus;
|
|
140
|
+
if (config.mode === "enforce") {
|
|
141
|
+
try {
|
|
142
|
+
await ports.provider.applyActions({
|
|
143
|
+
workspaceId: job.workspaceId,
|
|
144
|
+
providerConnectionId: job.providerConnectionId,
|
|
145
|
+
repository: job.changeRequest.repository,
|
|
146
|
+
changeRequestId: job.changeRequest.externalId,
|
|
147
|
+
changeRequestNumber: job.changeRequest.number,
|
|
148
|
+
expectedHeadRevision: job.changeRequest.headRevision,
|
|
149
|
+
decisionId: persisted.decisionId,
|
|
150
|
+
action: routing.action,
|
|
151
|
+
risk,
|
|
152
|
+
selectedActors: routing.selectedReviewers,
|
|
153
|
+
actorsToRequest: routing.reviewersToRequest,
|
|
154
|
+
...(routing.noHumanReason === undefined ? {} : { noHumanReason: routing.noHumanReason }),
|
|
155
|
+
});
|
|
156
|
+
if (routing.action === "request_human_review") {
|
|
157
|
+
await ports.enqueueReviewPolicy({
|
|
158
|
+
kind: "evaluate_human_review_policy",
|
|
159
|
+
deliveryId: `routing-policy:${job.deliveryId}`,
|
|
160
|
+
workspaceId: job.workspaceId,
|
|
161
|
+
providerConnectionId: job.providerConnectionId,
|
|
162
|
+
changeRequest: {
|
|
163
|
+
repository: job.changeRequest.repository,
|
|
164
|
+
externalId: job.changeRequest.externalId,
|
|
165
|
+
number: job.changeRequest.number,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
await ports.decisions.markActionFailed(persisted.decisionId, error instanceof Error ? error.message : "provider action failed", ports.clock.now());
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
if (persisted.actionStatus === "pending") {
|
|
175
|
+
await ports.decisions.markActionSucceeded(persisted.decisionId, ports.clock.now());
|
|
176
|
+
actionStatus = "succeeded";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { status: "decided", decisionId: persisted.decisionId, mode: config.mode, actionStatus };
|
|
180
|
+
}
|
|
181
|
+
function decisionIdentity(job) {
|
|
182
|
+
return {
|
|
183
|
+
workspaceId: job.workspaceId,
|
|
184
|
+
repository: job.changeRequest.repository,
|
|
185
|
+
deliveryId: job.deliveryId,
|
|
186
|
+
routingKey: job.routingKey,
|
|
187
|
+
changeRequestId: job.changeRequest.externalId,
|
|
188
|
+
changeRequestNumber: job.changeRequest.number,
|
|
189
|
+
headRevision: job.changeRequest.headRevision,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function persistenceProvenance(provenance, diagnostics) {
|
|
193
|
+
return {
|
|
194
|
+
organizationConfigVersion: provenance.organizationVersion,
|
|
195
|
+
repositoryConfigPath: provenance.repositoryPath,
|
|
196
|
+
repositoryConfigRevision: provenance.repositoryRevision,
|
|
197
|
+
effectiveConfigHash: provenance.effectiveHash,
|
|
198
|
+
inheritanceMode: provenance.inheritanceMode,
|
|
199
|
+
configDiagnostics: diagnostics,
|
|
200
|
+
configSources: provenance.sources,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function decisionEvent(input) {
|
|
204
|
+
return {
|
|
205
|
+
schemaVersion: 1,
|
|
206
|
+
eventType: "routing_decision",
|
|
207
|
+
eventId: `decision:${input.decisionId}:v1`,
|
|
208
|
+
occurredAt: input.occurredAt.toISOString(),
|
|
209
|
+
workspaceId: input.job.workspaceId,
|
|
210
|
+
provider: input.job.changeRequest.repository.provider,
|
|
211
|
+
decisionId: input.decisionId,
|
|
212
|
+
repositoryId: input.job.changeRequest.repository.externalId,
|
|
213
|
+
changeRequestId: input.job.changeRequest.externalId,
|
|
214
|
+
routingKey: input.job.routingKey,
|
|
215
|
+
mode: input.mode,
|
|
216
|
+
action: input.action,
|
|
217
|
+
riskScore: input.riskScore,
|
|
218
|
+
selectedActors: input.selectedActors,
|
|
219
|
+
effectiveConfigurationHash: input.effectiveConfigurationHash,
|
|
220
|
+
};
|
|
221
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@triagepilot/application",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/TriagePilot/triage-pilot.git",
|
|
8
|
+
"directory": "packages/application"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"main": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@triagepilot/config": "1.1.0",
|
|
25
|
+
"@triagepilot/contracts": "1.1.0",
|
|
26
|
+
"@triagepilot/core": "1.1.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"publishedAt": "2026-09-22T09:41:47.000Z",
|
|
32
|
+
"futureLicenseEffectiveAt": "2028-09-22T09:41:47.000Z",
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
35
|
+
"check": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.type-tests.json"
|
|
36
|
+
}
|
|
37
|
+
}
|