1import * as Cause from "effect/Cause";
2import * as DateTime from "effect/DateTime";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
8import { decodeJsonResult } from "@t3tools/shared/schemaJson";
9
10export interface NormalizedAzureDevOpsPullRequestRecord {
11 readonly number: number;
12 readonly title: string;
13 readonly url: string;
14 readonly baseRefName: string;
15 readonly headRefName: string;
16 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;
18 readonly isCrossRepository: boolean;
19 readonly closedAt?: string | null;
20 readonly mergedAt?: string | null;
21 readonly updatedAt: Option.Option<DateTime.Utc>;
22}
23
24const AzureDevOpsPullRequestSchema = Schema.Struct({
25 pullRequestId: PositiveInt,
26 title: TrimmedNonEmptyString,
27 url: Schema.optional(Schema.String),
28 repository: Schema.optional(
29 Schema.Struct({
30 name: Schema.optional(Schema.String),
31 webUrl: Schema.optional(Schema.String),
32 project: Schema.optional(
33 Schema.Struct({
34 name: Schema.optional(Schema.String),
35 }),
36 ),
37 }),
38 ),
39 sourceRefName: TrimmedNonEmptyString,
40 targetRefName: TrimmedNonEmptyString,
41 status: Schema.String,
42 isDraft: Schema.optional(Schema.Boolean),
43 /** The fork the head branch lives in. `az` prints `null` for a pull request from the same repo. */
44 forkSource: Schema.optional(Schema.Unknown),
45 creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
46 closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
47 _links: Schema.optional(
48 Schema.Struct({
49 web: Schema.optional(
50 Schema.Struct({
51 href: Schema.String,
52 }),
53 ),
54 }),
55 ),
56});
57
58function trimOptionalString(value: string | null | undefined): string | null {
59 const trimmed = value?.trim() ?? "";
60 return trimmed.length > 0 ? trimmed : null;
61}
62
63function normalizeRefName(refName: string): string {
64 return refName.trim().replace(/^refs\/heads\//, "");
65}
66
67function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" | "merged" {
68 switch (status.trim().toLowerCase()) {
69 case "completed":
70 return "merged";
71 case "abandoned":
72 return "closed";
73 default:
74 return "open";
75 }
76}
77
78function encodeAzureDevOpsPathSegment(segment: string): string {
79 return encodeURIComponent(segment);
80}
81
82/**
83 * The organization root a REST url belongs to, which is where a browser url has to be hung when
84 * Azure answered with neither a web link nor a repository url. Read from what Azure returned
85 * rather than from the local remote, whose shape varies.
86 */
87function azureDevOpsOrganizationBaseFromRestApiUrl(
88 value: string | null | undefined,
89): string | null {
90 const rawUrl = trimOptionalString(value);
91 if (!rawUrl) {
92 return null;
93 }
94
95 try {
96 const url = new URL(rawUrl);
97 const hostname = url.hostname.toLowerCase();
98 const pathSegments = url.pathname.split("/").filter((segment) => segment.length > 0);
99 const isAzureRestUrl = pathSegments.some((segment) => segment.toLowerCase() === "_apis");
100 if (!isAzureRestUrl) {
101 return null;
102 }
103
104 if (hostname === "dev.azure.com") {
105 const organization = pathSegments[0];
106 return organization ? `${url.origin}/${organization}` : null;
107 }
108
109 if (hostname.endsWith(".visualstudio.com")) {
110 return url.origin;
111 }
112
113 return null;
114 } catch {
115 return null;
116 }
117}
118
119/**
120 * Where a pull request lives in a browser. Azure answers with a web link when asked for one and
121 * otherwise leaves it to be assembled, so all three routes are tried in the order they can be
122 * trusted. Takes plain fields so both the source control provider and the pull requests page
123 * can share it.
124 */
125export function azureDevOpsPullRequestWebUrl(input: {
126 readonly pullRequestId: number;
127 readonly webLink?: string | null | undefined;
128 readonly repositoryWebUrl?: string | null | undefined;
129 readonly restApiUrl?: string | null | undefined;
130 readonly projectName?: string | null | undefined;
131 readonly repositoryName?: string | null | undefined;
132}): string {
133 const webLink = trimOptionalString(input.webLink);
134 if (webLink) {
135 return webLink;
136 }
137
138 const repositoryWebUrl = trimOptionalString(input.repositoryWebUrl);
139 if (repositoryWebUrl) {
140 return `${repositoryWebUrl.replace(/\/+$/, "")}/pullrequest/${input.pullRequestId}`;
141 }
142
143 const organizationBase = azureDevOpsOrganizationBaseFromRestApiUrl(input.restApiUrl);
144 const projectName = trimOptionalString(input.projectName);
145 const repositoryName = trimOptionalString(input.repositoryName);
146 if (organizationBase && projectName && repositoryName) {
147 const encodedProjectName = encodeAzureDevOpsPathSegment(projectName);
148 const encodedRepositoryName = encodeAzureDevOpsPathSegment(repositoryName);
149 return `${organizationBase}/${encodedProjectName}/_git/${encodedRepositoryName}/pullrequest/${input.pullRequestId}`;
150 }
151
152 return trimOptionalString(input.restApiUrl) ?? "";
153}
154
155function normalizeAzureDevOpsPullRequestUrl(
156 raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,
157): string {
158 return azureDevOpsPullRequestWebUrl({
159 pullRequestId: raw.pullRequestId,
160 webLink: raw._links?.web?.href,
161 repositoryWebUrl: raw.repository?.webUrl,
162 restApiUrl: raw.url,
163 projectName: raw.repository?.project?.name,
164 repositoryName: raw.repository?.name,
165 });
166}
167
168function normalizeAzureDevOpsPullRequestRecord(
169 raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,
170): NormalizedAzureDevOpsPullRequestRecord {
171 const state = normalizeAzureDevOpsPullRequestState(raw.status);
172 const terminalAt = Option.match(raw.closedDate ?? Option.none(), {
173 onNone: () => null,
174 onSome: DateTime.formatIso,
175 });
176 return {
177 number: raw.pullRequestId,
178 title: raw.title,
179 url: normalizeAzureDevOpsPullRequestUrl(raw),
180 baseRefName: normalizeRefName(raw.targetRefName),
181 headRefName: normalizeRefName(raw.sourceRefName),
182 state,
183 ...(raw.isDraft === true ? { isDraft: true } : {}),
184 isCrossRepository: raw.forkSource != null,
185 closedAt: state === "closed" ? terminalAt : null,
186 mergedAt: state === "merged" ? terminalAt : null,
187 updatedAt: (raw.closedDate ?? Option.none()).pipe(
188 Option.orElse(() => raw.creationDate ?? Option.none()),
189 ),
190 };
191}
192
193const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
194const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema);
195const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema);
196
197export function decodeAzureDevOpsPullRequestListJson(
198 raw: string,
199): Result.Result<
200 ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>,
201 Cause.Cause<Schema.SchemaError>
202> {
203 const result = decodeAzureDevOpsPullRequestList(raw);
204 if (Result.isSuccess(result)) {
205 const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = [];
206 for (const entry of result.success) {
207 const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry);
208 if (Exit.isFailure(decodedEntry)) {
209 continue;
210 }
211 pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value));
212 }
213 return Result.succeed(pullRequests);
214 }
215 return Result.fail(result.failure);
216}
217
218export function decodeAzureDevOpsPullRequestJson(
219 raw: string,
220): Result.Result<NormalizedAzureDevOpsPullRequestRecord, Cause.Cause<Schema.SchemaError>> {
221 const result = decodeAzureDevOpsPullRequest(raw);
222 if (Result.isSuccess(result)) {
223 return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success));
224 }
225 return Result.fail(result.failure);
226}