Skip to content
Source
Sign inSign up

azureDevOpsPullRequests.ts

217 lines7.3 KB

1import {
2 trimOptionalString,
3 normalizeRefName,
4} from "@t3tools/source-control-core/server/pullRequestUtils";
5import * as Cause from "effect/Cause";
6import * as DateTime from "effect/DateTime";
7import * as Exit from "effect/Exit";
8import * as Option from "effect/Option";
9import * as Result from "effect/Result";
10import * as Schema from "effect/Schema";
11import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
12import { decodeJsonResult } from "@t3tools/shared/schemaJson";
13​
14export interface NormalizedAzureDevOpsPullRequestRecord {
15 readonly number: number;
16 readonly title: string;
17 readonly url: string;
18 readonly baseRefName: string;
19 readonly headRefName: string;
20 readonly headSha?: string;
21 readonly state: "open" | "closed" | "merged";
22 readonly isDraft?: boolean;
23 readonly isCrossRepository: boolean;
24 readonly closedAt?: string | null;
25 readonly mergedAt?: string | null;
26 readonly updatedAt: Option.Option<DateTime.Utc>;
28​
29const AzureDevOpsPullRequestSchema = Schema.Struct({
30 pullRequestId: PositiveInt,
31 title: TrimmedNonEmptyString,
32 url: Schema.optional(Schema.String),
33 repository: Schema.optional(
34 Schema.Struct({
35 name: Schema.optional(Schema.String),
36 webUrl: Schema.optional(Schema.String),
37 project: Schema.optional(
38 Schema.Struct({
39 name: Schema.optional(Schema.String),
40 }),
41 ),
42 }),
43 ),
44 sourceRefName: TrimmedNonEmptyString,
45 targetRefName: TrimmedNonEmptyString,
46 /** The source branch's tip as of Azure's last merge evaluation: the pull request's head. */
47 lastMergeSourceCommit: Schema.optional(
48 Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })),
49 ),
50 status: Schema.String,
51 isDraft: Schema.optional(Schema.Boolean),
52 /** `az` prints `null` for a pull request from the same repo. */
53 forkSource: Schema.optional(Schema.Unknown),
54 creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
55 closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
56 _links: Schema.optional(
57 Schema.Struct({
58 web: Schema.optional(
59 Schema.Struct({
60 href: Schema.String,
61 }),
62 ),
63 }),
64 ),
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 }
77​
78function encodeAzureDevOpsPathSegment(segment: string): string {
79 return encodeURIComponent(segment);
81​
82function azureDevOpsOrganizationBaseFromRestApiUrl(
83 value: string | null | undefined,
84): string | null {
85 const rawUrl = trimOptionalString(value);
86 if (!rawUrl) {
87 return null;
88 }
89​
90 try {
91 const url = new URL(rawUrl);
92 const hostname = url.hostname.toLowerCase();
93 const pathSegments = url.pathname.split("/").filter((segment) => segment.length > 0);
94 const isAzureRestUrl = pathSegments.some((segment) => segment.toLowerCase() === "_apis");
95 if (!isAzureRestUrl) {
96 return null;
97 }
98​
99 if (hostname === "dev.azure.com") {
100 const organization = pathSegments[0];
101 return organization ? `${url.origin}/${organization}` : null;
102 }
103​
104 if (hostname.endsWith(".visualstudio.com")) {
105 return url.origin;
106 }
107​
108 return null;
109 } catch {
110 return null;
111 }
113​
114export function azureDevOpsPullRequestWebUrl(input: {
115 readonly pullRequestId: number;
116 readonly webLink?: string | null | undefined;
117 readonly repositoryWebUrl?: string | null | undefined;
118 readonly restApiUrl?: string | null | undefined;
119 readonly projectName?: string | null | undefined;
120 readonly repositoryName?: string | null | undefined;
121}): string {
122 const webLink = trimOptionalString(input.webLink);
123 if (webLink) {
124 return webLink;
125 }
126​
127 const repositoryWebUrl = trimOptionalString(input.repositoryWebUrl);
128 if (repositoryWebUrl) {
129 return `${repositoryWebUrl.replace(/\/+$/, "")}/pullrequest/${input.pullRequestId}`;
130 }
131​
132 const organizationBase = azureDevOpsOrganizationBaseFromRestApiUrl(input.restApiUrl);
133 const projectName = trimOptionalString(input.projectName);
134 const repositoryName = trimOptionalString(input.repositoryName);
135 if (organizationBase && projectName && repositoryName) {
136 const encodedProjectName = encodeAzureDevOpsPathSegment(projectName);
137 const encodedRepositoryName = encodeAzureDevOpsPathSegment(repositoryName);
138 return `${organizationBase}/${encodedProjectName}/_git/${encodedRepositoryName}/pullrequest/${input.pullRequestId}`;
139 }
140​
141 return trimOptionalString(input.restApiUrl) ?? "";
143​
144function normalizeAzureDevOpsPullRequestUrl(
145 raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,
146): string {
147 return azureDevOpsPullRequestWebUrl({
148 pullRequestId: raw.pullRequestId,
149 webLink: raw._links?.web?.href,
150 repositoryWebUrl: raw.repository?.webUrl,
151 restApiUrl: raw.url,
152 projectName: raw.repository?.project?.name,
153 repositoryName: raw.repository?.name,
154 });
156​
157function normalizeAzureDevOpsPullRequestRecord(
158 raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,
159): NormalizedAzureDevOpsPullRequestRecord {
160 const state = normalizeAzureDevOpsPullRequestState(raw.status);
161 const headSha = trimOptionalString(raw.lastMergeSourceCommit?.commitId);
162 const terminalAt = Option.match(raw.closedDate ?? Option.none(), {
163 onNone: () => null,
164 onSome: DateTime.formatIso,
165 });
166 return {
167 number: raw.pullRequestId,
168 title: raw.title,
169 url: normalizeAzureDevOpsPullRequestUrl(raw),
170 baseRefName: normalizeRefName(raw.targetRefName),
171 headRefName: normalizeRefName(raw.sourceRefName),
172 ...(headSha ? { headSha } : {}),
173 state,
174 ...(raw.isDraft === true ? { isDraft: true } : {}),
175 isCrossRepository: raw.forkSource != null,
176 closedAt: state === "closed" ? terminalAt : null,
177 mergedAt: state === "merged" ? terminalAt : null,
178 updatedAt: (raw.closedDate ?? Option.none()).pipe(
179 Option.orElse(() => raw.creationDate ?? Option.none()),
180 ),
181 };
183​
184const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
185const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema);
186const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema);
187​
188export function decodeAzureDevOpsPullRequestListJson(
189 raw: string,
190): Result.Result<
191 ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>,
192 Cause.Cause<Schema.SchemaError>
193> {
194 const result = decodeAzureDevOpsPullRequestList(raw);
195 if (Result.isSuccess(result)) {
196 const pullRequests: NormalizedAzureDevOpsPullRequestRecord[] = [];
197 for (const entry of result.success) {
198 const decodedEntry = decodeAzureDevOpsPullRequestEntry(entry);
199 if (Exit.isFailure(decodedEntry)) {
200 continue;
201 }
202 pullRequests.push(normalizeAzureDevOpsPullRequestRecord(decodedEntry.value));
203 }
204 return Result.succeed(pullRequests);
205 }
206 return Result.fail(result.failure);
208​
209export function decodeAzureDevOpsPullRequestJson(
210 raw: string,
211): Result.Result<NormalizedAzureDevOpsPullRequestRecord, Cause.Cause<Schema.SchemaError>> {
212 const result = decodeAzureDevOpsPullRequest(raw);
213 if (Result.isSuccess(result)) {
214 return Result.succeed(normalizeAzureDevOpsPullRequestRecord(result.success));
215 }
216 return Result.fail(result.failure);