1import {
2 MergebotDeploymentEnvironment,
3 MergebotDeployments,
4 MergebotMergedCommit,
5 MergebotPullRequestDetails,
6 PullRequestActor,
7 type MergebotPullRequestLiveStatus,
8} from "@t3tools/contracts";
9import * as Schema from "effect/Schema";
10import * as Struct from "effect/Struct";
11import { mergebotChecks } from "./pullRequestRead.ts";
12
13export const PullRequestRecord = Schema.Struct({
14 ...Struct.omit(MergebotPullRequestDetails.fields, [
15 "position",
16 "checks",
17 "author",
18 "comments",
19 "observedAt",
20 ]),
21 authorId: Schema.NullOr(Schema.String),
22 id: Schema.String,
23 repoId: Schema.String,
24});
25
26const RepoRecord = Schema.Struct({
27 ...Struct.pick(MergebotDeployments.fields, [
28 "defaultBranch",
29 "productionBranch",
30 "stagingUrl",
31 "productionUrl",
32 ]),
33 processing: Schema.Boolean,
34 triggerLabel: Schema.String,
35 stagingDeployment: Schema.NullOr(MergebotDeploymentEnvironment),
36 productionDeployment: Schema.NullOr(MergebotDeploymentEnvironment),
37});
38
39const MergedCommitRecord = Schema.Struct({
40 ...Struct.omit(MergebotMergedCommit.fields, ["authorLogin", "authorAvatarUrl"]),
41 authorId: Schema.NullOr(Schema.String),
42});
43
44export const RepoPayload = Schema.Struct({
45 entities: Schema.Struct({
46 repos: Schema.Record(Schema.String, RepoRecord),
47 users: Schema.Record(Schema.String, PullRequestActor),
48 pullRequests: Schema.Record(Schema.String, PullRequestRecord),
49 mergedCommits: Schema.Record(Schema.String, MergedCommitRecord),
50 }),
51 view: Schema.Struct({
52 repoId: Schema.String,
53 queuePrIds: Schema.Array(Schema.String),
54 mergedCommitIds: Schema.Array(Schema.String),
55 }),
56});
57export type RepoPayload = typeof RepoPayload.Type;
58type Repo = typeof RepoRecord.Type;
59type MergedCommit = typeof MergedCommitRecord.Type;
60type Deploy = NonNullable<MergebotPullRequestLiveStatus["deploy"]>;
61
62const mergedCommitsOf = (payload: RepoPayload) =>
63 payload.view.mergedCommitIds.flatMap((id) => payload.entities.mergedCommits[id] ?? []);
64
65/** Where a commit at `index` (0 is the newest merge) stands against one environment's deployment. */
66function environmentState(
67 deployment: MergebotDeploymentEnvironment,
68 commits: ReadonlyArray<MergedCommit>,
69 index: number,
70): "waiting" | "deploying" | "deployed" | "failed" {
71 const headIndex = Math.max(
72 0,
73 commits.findIndex((commit) => commit.sha === deployment.sha),
74 );
75 const deployedIndex =
76 deployment.deployedSha === null
77 ? -1
78 : commits.findIndex((commit) => commit.sha === deployment.deployedSha);
79
80 if (deployedIndex !== -1 && index >= deployedIndex) return "deployed";
81 if (index < headIndex) return "waiting";
82 if (deployment.status === "passed") return "deployed";
83 if (deployment.status === "pending") return "deploying";
84 return deployment.status === "failed" ? "failed" : "waiting";
85}
86
87/**
88 * Maps a merged PR onto the deploy timeline. A merge missing from Mergebot's window but older than
89 * all of it sits below every listed commit; a newer one has not reached the overview yet.
90 */
91export function deployState(
92 repo: Repo,
93 commits: ReadonlyArray<MergedCommit>,
94 pullRequest: { readonly number: number; readonly mergedAt?: string | null | undefined },
95): Deploy | null {
96 const listed = commits.findIndex((commit) => commit.pullRequestNumber === pullRequest.number);
97 const oldest = commits.at(-1);
98 const mergedAt = Date.parse(pullRequest.mergedAt ?? "");
99 const index =
100 listed !== -1
101 ? listed
102 : oldest && mergedAt < Date.parse(oldest.mergedAt)
103 ? commits.length
104 : null;
105 if (index === null) return null;
106
107 const productionHeadIndex = commits.findIndex((commit) => commit.isProductionHead);
108 const production = repo.productionDeployment
109 ? environmentState(repo.productionDeployment, commits, index)
110 : repo.productionBranch === null
111 ? null
112 : productionHeadIndex !== -1 && index >= productionHeadIndex
113 ? "deployed"
114 : "waiting";
115 const staging = repo.stagingDeployment
116 ? environmentState(repo.stagingDeployment, commits, index)
117 : null;
118
119 if (production === "deployed")
120 return { state: "production", environment: "production", etaAt: null };
121 if (production === "deploying")
122 return {
123 state: "deploying",
124 environment: "production",
125 etaAt: repo.productionDeployment?.etaAt ?? null,
126 };
127 if (production === "failed") return { state: "failed", environment: "production", etaAt: null };
128 if (staging === "deployed") return { state: "staging", environment: "staging", etaAt: null };
129 if (staging === "deploying")
130 return {
131 state: "deploying",
132 environment: "staging",
133 etaAt: repo.stagingDeployment?.etaAt ?? null,
134 };
135 if (staging === "failed") return { state: "failed", environment: "staging", etaAt: null };
136 if (staging === null && production === null) return null;
137 return {
138 state: "waiting",
139 environment: staging === null ? "production" : "staging",
140 etaAt: null,
141 };
142}
143
144/**
145 * Composes one PR's live status. The overview carries open and recently merged PRs; the detail
146 * snapshot covers the rest and supplies checks for the current head.
147 */
148export function pullRequestLiveStatus(
149 url: string,
150 repository: string,
151 payload: RepoPayload,
152 detail: MergebotPullRequestDetails | null | undefined,
153 number: number,
154): MergebotPullRequestLiveStatus | null {
155 const id = `${repository}#${number}`;
156 const pullRequest = payload.entities.pullRequests[id] ?? detail;
157 const repo = payload.entities.repos[repository];
158 if (!pullRequest || !repo) return null;
159
160 const position = payload.view.queuePrIds.indexOf(id) + 1;
161 const checks = detail && detail.headSha === pullRequest.headSha ? mergebotChecks(detail) : null;
162
163 return {
164 number,
165 url: `${url}/pull/${number}`,
166 title: pullRequest.title,
167 body: pullRequest.body,
168 headSha: pullRequest.headSha,
169 headBranch: pullRequest.headBranch,
170 lifecycle: pullRequest.lifecycle,
171 draft: pullRequest.draft,
172 autoMerge: pullRequest.labels.some((label) => label.name === repo.triggerLabel),
173 ciStatus: pullRequest.ciStatus,
174 checks: checks && {
175 total: checks.length,
176 completed: checks.filter((check) => check.status !== "pending").length,
177 failed: checks.filter((check) => check.status === "failure").length,
178 },
179 hasConflicts: pullRequest.hasConflicts,
180 queueStatus: pullRequest.queueStatus,
181 position: position === 0 ? null : position,
182 mergeEtaAt: pullRequest.mergeEtaAt,
183 deploy:
184 pullRequest.lifecycle === "merged"
185 ? deployState(repo, mergedCommitsOf(payload), pullRequest)
186 : null,
187 };
188}
189
190export function deployments(
191 url: string,
192 repository: string,
193 payload: RepoPayload,
194): MergebotDeployments | null {
195 const repo = payload.entities.repos[repository];
196 if (!repo) return null;
197 const users = payload.entities.users;
198
199 return {
200 repository,
201 url,
202 defaultBranch: repo.defaultBranch,
203 productionBranch: repo.productionBranch,
204 stagingUrl: repo.stagingUrl,
205 productionUrl: repo.productionUrl,
206 staging: repo.stagingDeployment,
207 production: repo.productionDeployment,
208 mergedCommits: mergedCommitsOf(payload).map(({ authorId, ...commit }) => {
209 const user = authorId === null ? undefined : users[authorId];
210 return {
211 ...commit,
212 authorLogin: user?.login ?? null,
213 authorName: user?.name ?? commit.authorName,
214 authorAvatarUrl: user?.avatarUrl ?? null,
215 };
216 }),
217 };
218}