1import * as Schema from "effect/Schema";
2import { PullRequestActor, PullRequestComment, PullRequestLabel } from "./pullRequest.ts";
3import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
4
5export const MergebotConfiguration = Schema.Struct({
6 origin: TrimmedNonEmptyString.check(Schema.isPattern(/^https:\/\/[a-zA-Z0-9.-]+(?::[0-9]+)?$/)),
7 repositories: Schema.Array(
8 TrimmedNonEmptyString.check(Schema.isPattern(/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/)),
9 ),
10});
11export type MergebotConfiguration = typeof MergebotConfiguration.Type;
12
13export function isMergebotRepository(
14 configuration: MergebotConfiguration | undefined,
15 repository: string,
16): boolean {
17 return (
18 configuration?.repositories.some((repo) => repo.toLowerCase() === repository.toLowerCase()) ??
19 false
20 );
21}
22
23export const MergebotQueueInput = Schema.Struct({ repository: TrimmedNonEmptyString });
24export type MergebotQueueInput = typeof MergebotQueueInput.Type;
25
26export const MergebotPullRequestStatus = Schema.Struct({
27 number: PositiveInt,
28 headSha: TrimmedNonEmptyString,
29 lifecycle: Schema.Literals(["open", "closed", "merged"]),
30 queueStatus: Schema.NullOr(
31 Schema.Literals([
32 "not_queued",
33 "queued",
34 "updating_branch",
35 "waiting_ci",
36 "merging",
37 "ci_failed",
38 ]),
39 ),
40 position: Schema.NullOr(PositiveInt),
41 mergeEtaAt: Schema.NullOr(Schema.String),
42 url: Schema.String,
43});
44export type MergebotPullRequestStatus = typeof MergebotPullRequestStatus.Type;
45
46export const MergebotRepoQueue = Schema.Struct({
47 repo: TrimmedNonEmptyString,
48 url: Schema.String,
49 status: Schema.Literals(["loading", "ready"]),
50 processing: Schema.Boolean,
51 pullRequests: Schema.NullOr(Schema.Array(MergebotPullRequestStatus)),
52});
53export type MergebotRepoQueue = typeof MergebotRepoQueue.Type;
54
55export function mergebotPullRequestStatus(
56 queue: MergebotRepoQueue | null,
57 number: number,
58 headSha?: string,
59): MergebotPullRequestStatus | null {
60 if (queue?.status !== "ready") return null;
61 const entry = queue.pullRequests?.find((pullRequest) => pullRequest.number === number);
62 if (!entry || entry.queueStatus === null || (headSha !== undefined && entry.headSha !== headSha))
63 return null;
64 return entry;
65}
66
67export const MergebotPullRequestInput = Schema.Struct({
68 repository: TrimmedNonEmptyString,
69 number: PositiveInt,
70});
71export type MergebotPullRequestInput = typeof MergebotPullRequestInput.Type;
72
73export const MergebotPullRequestCheck = Schema.Struct({
74 id: TrimmedNonEmptyString,
75 name: Schema.String,
76 workflowName: Schema.NullOr(Schema.String),
77 status: Schema.String,
78 conclusion: Schema.NullOr(Schema.String),
79 url: Schema.NullOr(Schema.String),
80 startedAt: Schema.NullOr(Schema.String),
81 completedAt: Schema.NullOr(Schema.String),
82});
83export type MergebotPullRequestCheck = typeof MergebotPullRequestCheck.Type;
84
85export const MergebotPullRequestDetails = Schema.Struct({
86 ...MergebotPullRequestStatus.fields,
87 title: Schema.String,
88 createdAt: Schema.String,
89 author: Schema.NullOr(PullRequestActor),
90 additions: Schema.NullOr(Schema.Number),
91 deletions: Schema.NullOr(Schema.Number),
92 changedFilesCount: Schema.NullOr(Schema.Number),
93 labels: Schema.Array(PullRequestLabel),
94 comments: Schema.Array(PullRequestComment),
95 observedAt: Schema.Number,
96 updatedAt: Schema.String,
97 mergedAt: Schema.optionalKey(Schema.NullOr(Schema.String)),
98 closedAt: Schema.optionalKey(Schema.NullOr(Schema.String)),
99 body: Schema.NullOr(Schema.String),
100 draft: Schema.Boolean,
101 baseBranch: Schema.String,
102 headBranch: Schema.String,
103 ciStatus: Schema.NullOr(Schema.Literals(["passed", "pending", "failed", "unknown"])),
104 hasConflicts: Schema.NullOr(Schema.Boolean),
105 checks: Schema.Array(MergebotPullRequestCheck),
106});
107export type MergebotPullRequestDetails = typeof MergebotPullRequestDetails.Type;
108
109export const MERGEBOT_BROWSER_THREAD_ID = "mergebot-browser";
110
111export function mergebotPullRequestBrowserThreadId(reference: {
112 readonly repository: string;
113 readonly number: number;
114}): string {
115 return `${MERGEBOT_BROWSER_THREAD_ID}:${reference.repository.toLowerCase()}:${reference.number}`;
116}
117
118export function mergebotPullRequestUrl(
119 configuration: MergebotConfiguration | undefined,
120 reference: {
121 readonly host?: string | undefined;
122 readonly repository: string;
123 readonly number: number;
124 },
125): string | null {
126 if (!configuration || (reference.host ?? "github.com").toLowerCase() !== "github.com")
127 return null;
128 const repository = configuration.repositories.find(
129 (candidate) => candidate.toLowerCase() === reference.repository.toLowerCase(),
130 );
131 return repository && Number.isSafeInteger(reference.number) && reference.number > 0
132 ? `${configuration.origin}/${repository}/pull/${reference.number}`
133 : null;
134}
135
136export function parseMergebotPullRequestUrl(
137 configuration: MergebotConfiguration | undefined,
138 targetUrl: string,
139): { host: "github.com"; repository: string; number: number } | null {
140 if (!configuration) return null;
141 let url: URL;
142 try {
143 url = new URL(targetUrl);
144 } catch {
145 return null;
146 }
147 if (url.origin !== configuration.origin || url.username || url.password) return null;
148 const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname);
149 const repository = configuration.repositories.find(
150 (candidate) => candidate.toLowerCase() === match?.[1]?.toLowerCase(),
151 );
152 const number = Number(match?.[2]);
153 return repository && Number.isSafeInteger(number) && number > 0
154 ? { host: "github.com", repository, number }
155 : null;
156}
157
158export const MergebotOpenPullRequestInput = Schema.Struct({
159 repository: TrimmedNonEmptyString,
160 head: TrimmedNonEmptyString,
161 base: Schema.optionalKey(TrimmedNonEmptyString),
162 title: TrimmedNonEmptyString,
163 body: Schema.String,
164 draft: Schema.Boolean,
165 autoMerge: Schema.Boolean,
166});
167export type MergebotOpenPullRequestInput = typeof MergebotOpenPullRequestInput.Type;
168
169export const MergebotOpenPullRequestResult = Schema.Struct({
170 number: PositiveInt,
171 url: Schema.String,
172});
173export type MergebotOpenPullRequestResult = typeof MergebotOpenPullRequestResult.Type;
174
175export const MergebotSetAutoMergeInput = Schema.Struct({
176 repository: TrimmedNonEmptyString,
177 number: PositiveInt,
178 enabled: Schema.Boolean,
179});
180export type MergebotSetAutoMergeInput = typeof MergebotSetAutoMergeInput.Type;
181
182export const MergebotUpdatePullRequestInput = Schema.Struct({
183 repository: TrimmedNonEmptyString,
184 number: PositiveInt,
185 title: Schema.optionalKey(TrimmedNonEmptyString),
186 body: Schema.optionalKey(Schema.String),
187 draft: Schema.optionalKey(Schema.Boolean),
188});
189export type MergebotUpdatePullRequestInput = typeof MergebotUpdatePullRequestInput.Type;
190
191export class MergebotOperationError extends Schema.TaggedError<MergebotOperationError>()(
192 "MergebotOperationError",
193 { operation: Schema.String, status: Schema.NullOr(Schema.Number), detail: Schema.String },
194) {
195 override get message(): string {
196 return `Mergebot could not ${this.operation}: ${this.detail}`;
197 }
198}
199
200/** Everything a status pill needs for one PR, composed from Mergebot's overview, detail, and deploy data. */
201export const MergebotPullRequestLiveStatus = Schema.Struct({
202 number: PositiveInt,
203 url: Schema.String,
204 title: Schema.String,
205 body: Schema.NullOr(Schema.String),
206 headSha: Schema.String,
207 headBranch: Schema.String,
208 lifecycle: Schema.Literals(["open", "closed", "merged"]),
209 draft: Schema.Boolean,
210 autoMerge: Schema.Boolean,
211 ciStatus: Schema.NullOr(Schema.Literals(["passed", "pending", "failed", "unknown"])),
212 /** From the detail channel; null until it loads. */
213 checks: Schema.NullOr(
214 Schema.Struct({ completed: Schema.Number, total: Schema.Number, failed: Schema.Number }),
215 ),
216 hasConflicts: Schema.NullOr(Schema.Boolean),
217 queueStatus: MergebotPullRequestStatus.fields.queueStatus,
218 position: Schema.NullOr(PositiveInt),
219 mergeEtaAt: Schema.NullOr(Schema.String),
220 deploy: Schema.NullOr(
221 Schema.Struct({
222 state: Schema.Literals(["waiting", "deploying", "staging", "production", "failed"]),
223 environment: Schema.NullOr(Schema.Literals(["staging", "production"])),
224 etaAt: Schema.NullOr(Schema.String),
225 }),
226 ),
227});
228export type MergebotPullRequestLiveStatus = typeof MergebotPullRequestLiveStatus.Type;
229
230export const MergebotDeploymentEnvironment = Schema.Struct({
231 sha: Schema.String,
232 deployedSha: Schema.NullOr(Schema.String),
233 status: Schema.Literals(["passed", "pending", "failed", "unknown"]),
234 currentStep: Schema.NullOr(Schema.String),
235 etaAt: Schema.NullOr(Schema.String),
236 checksUrl: Schema.String,
237});
238export type MergebotDeploymentEnvironment = typeof MergebotDeploymentEnvironment.Type;
239
240export const MergebotMergedCommit = Schema.Struct({
241 sha: Schema.String,
242 pullRequestNumber: Schema.NullOr(PositiveInt),
243 authorLogin: Schema.NullOr(Schema.String),
244 authorName: Schema.NullOr(Schema.String),
245 authorAvatarUrl: Schema.NullOr(Schema.String),
246 message: Schema.String,
247 mergedAt: Schema.String,
248 url: Schema.String,
249 labels: Schema.Array(PullRequestLabel),
250 status: Schema.Literals(["default", "recent", "hotfix"]),
251 isProductionHead: Schema.Boolean,
252});
253export type MergebotMergedCommit = typeof MergebotMergedCommit.Type;
254
255export const MergebotDeployments = Schema.Struct({
256 repository: TrimmedNonEmptyString,
257 url: Schema.String,
258 defaultBranch: Schema.String,
259 productionBranch: Schema.NullOr(Schema.String),
260 stagingUrl: Schema.NullOr(Schema.String),
261 productionUrl: Schema.NullOr(Schema.String),
262 staging: Schema.NullOr(MergebotDeploymentEnvironment),
263 production: Schema.NullOr(MergebotDeploymentEnvironment),
264 /** Ordered by default-branch history, newest first. */
265 mergedCommits: Schema.Array(MergebotMergedCommit),
266});
267export type MergebotDeployments = typeof MergebotDeployments.Type;
268
269export const MergebotDeployProductionInput = Schema.Struct({
270 repository: TrimmedNonEmptyString,
271 sha: TrimmedNonEmptyString,
272});
273export type MergebotDeployProductionInput = typeof MergebotDeployProductionInput.Type;