| 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as Exit from "effect/Exit"; |
| 3 | import * as Result from "effect/Result"; |
| 4 | import * as Schema from "effect/Schema"; |
| 5 | import type { |
| 6 | PullRequestActor, |
| 7 | PullRequestComment, |
| 8 | PullRequestMergeMethod, |
| 9 | PullRequestMergeability, |
| 10 | PullRequestState, |
| 11 | } from "@t3tools/contracts"; |
| 12 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; |
| 13 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 14 | |
| 15 | import { azureDevOpsPullRequestWebUrl } from "./azureDevOpsPullRequests.ts"; |
| 16 | |
| 17 | /** |
| 18 | * Azure's enums are decoded as plain strings and normalized here, in the same tolerant style as |
| 19 | * the other hosts: a new merge status must not fail a whole payload. Every field beyond the |
| 20 | * identity is optional, because `az repos pr` returns rather more or less of the REST object |
| 21 | * depending on the command. |
| 22 | */ |
| 23 | const RawIdentitySchema = Schema.Struct({ |
| 24 | displayName: Schema.optional(Schema.NullOr(Schema.String)), |
| 25 | /** An email or UPN, which is what `az account show` reports for the signed-in user. */ |
| 26 | uniqueName: Schema.optional(Schema.NullOr(Schema.String)), |
| 27 | imageUrl: Schema.optional(Schema.NullOr(Schema.String)), |
| 28 | }); |
| 29 | |
| 30 | const RawPullRequestSchema = Schema.Struct({ |
| 31 | pullRequestId: Schema.Int, |
| 32 | title: Schema.String, |
| 33 | description: Schema.optional(Schema.NullOr(Schema.String)), |
| 34 | status: Schema.optional(Schema.NullOr(Schema.String)), |
| 35 | isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 36 | /** |
| 37 | * Who armed auto-complete, which is the only thing Azure says about it: the field carries an |
| 38 | * identity while the pull request is set to complete on its own, and Azure leaves it out |
| 39 | * entirely once nobody has. So its presence is the answer, and there is no third state. |
| 40 | */ |
| 41 | autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), |
| 42 | completionOptions: Schema.optional( |
| 43 | Schema.NullOr( |
| 44 | Schema.Struct({ |
| 45 | mergeStrategy: Schema.optional(Schema.NullOr(Schema.String)), |
| 46 | squashMerge: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 47 | }), |
| 48 | ), |
| 49 | ), |
| 50 | mergeStatus: Schema.optional(Schema.NullOr(Schema.String)), |
| 51 | createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), |
| 52 | reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))), |
| 53 | // Required, and required to be non-empty: the wire contract will not carry a change request |
| 54 | // without a branch or a created time, so a row missing one is skipped rather than breaking the |
| 55 | // response it travels in. |
| 56 | sourceRefName: TrimmedNonEmptyString, |
| 57 | targetRefName: TrimmedNonEmptyString, |
| 58 | creationDate: TrimmedNonEmptyString, |
| 59 | closedDate: Schema.optional(Schema.NullOr(Schema.String)), |
| 60 | url: Schema.optional(Schema.NullOr(Schema.String)), |
| 61 | repository: Schema.optional( |
| 62 | Schema.NullOr( |
| 63 | Schema.Struct({ |
| 64 | name: Schema.optional(Schema.NullOr(Schema.String)), |
| 65 | webUrl: Schema.optional(Schema.NullOr(Schema.String)), |
| 66 | project: Schema.optional( |
| 67 | Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), |
| 68 | ), |
| 69 | }), |
| 70 | ), |
| 71 | ), |
| 72 | _links: Schema.optional( |
| 73 | Schema.NullOr( |
| 74 | Schema.Struct({ |
| 75 | web: Schema.optional( |
| 76 | Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), |
| 77 | ), |
| 78 | }), |
| 79 | ), |
| 80 | ), |
| 81 | }); |
| 82 | |
| 83 | /** A pull request thread, which is how Azure keeps its conversation. */ |
| 84 | const RawThreadSchema = Schema.Struct({ |
| 85 | id: Schema.Int, |
| 86 | isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 87 | threadContext: Schema.optional( |
| 88 | Schema.NullOr(Schema.Struct({ filePath: Schema.optional(Schema.NullOr(Schema.String)) })), |
| 89 | ), |
| 90 | comments: Schema.optional( |
| 91 | Schema.NullOr( |
| 92 | Schema.Array( |
| 93 | Schema.Struct({ |
| 94 | id: Schema.optional(Schema.NullOr(Schema.Int)), |
| 95 | content: Schema.optional(Schema.NullOr(Schema.String)), |
| 96 | author: Schema.optional(Schema.NullOr(RawIdentitySchema)), |
| 97 | publishedDate: Schema.optional(Schema.NullOr(Schema.String)), |
| 98 | isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 99 | /** `system` marks the notes Azure writes itself, which are events, not comments. */ |
| 100 | commentType: Schema.optional(Schema.NullOr(Schema.String)), |
| 101 | }), |
| 102 | ), |
| 103 | ), |
| 104 | ), |
| 105 | }); |
| 106 | |
| 107 | const RawThreadPageSchema = Schema.Struct({ |
| 108 | value: Schema.Array(Schema.Unknown), |
| 109 | }); |
| 110 | |
| 111 | const RawViewerSchema = Schema.Struct({ |
| 112 | user: Schema.optional( |
| 113 | Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), |
| 114 | ), |
| 115 | }); |
| 116 | |
| 117 | /** |
| 118 | * Where a repository lives, in the terms Azure's REST routes address it by. They take the project |
| 119 | * and the repository as separate route parameters rather than as one path, so the pair travels |
| 120 | * together rather than as a URL that would have to be taken apart again to use. |
| 121 | */ |
| 122 | export interface AzureDevOpsRepositoryLocation { |
| 123 | readonly project: string; |
| 124 | readonly repository: string; |
| 125 | } |
| 126 | |
| 127 | export interface AzureDevOpsPullRequest { |
| 128 | readonly number: number; |
| 129 | readonly title: string; |
| 130 | readonly url: string; |
| 131 | readonly author: PullRequestActor | null; |
| 132 | readonly headBranch: string; |
| 133 | readonly baseBranch: string; |
| 134 | readonly state: PullRequestState; |
| 135 | readonly isDraft: boolean; |
| 136 | readonly mergeability: PullRequestMergeability; |
| 137 | readonly createdAt: string; |
| 138 | /** |
| 139 | * Azure records no last-touched time on a pull request, so the closing time stands in where |
| 140 | * there is one and the creation time otherwise. The same fallback the rest of the app uses. |
| 141 | */ |
| 142 | readonly updatedAt: string; |
| 143 | readonly closedAt: string | null; |
| 144 | readonly body: string; |
| 145 | readonly reviewRequestLogins: ReadonlyArray<string>; |
| 146 | readonly reviewers: ReadonlyArray<PullRequestActor>; |
| 147 | /** Where this pull request lives, when Azure said enough to work it out. */ |
| 148 | readonly location: AzureDevOpsRepositoryLocation | null; |
| 149 | /** Whether Azure is set to complete this on its own once its policies pass. */ |
| 150 | readonly autoMergeEnabled: boolean; |
| 151 | /** The completion strategy Azure stored with auto-complete, where it reported one. */ |
| 152 | readonly autoMergeMethod?: PullRequestMergeMethod; |
| 153 | } |
| 154 | |
| 155 | function trimmed(value: string | null | undefined): string | null { |
| 156 | const text = value?.trim() ?? ""; |
| 157 | return text.length > 0 ? text : null; |
| 158 | } |
| 159 | |
| 160 | function normalizeRefName(refName: string): string { |
| 161 | return refName.trim().replace(/^refs\/heads\//, ""); |
| 162 | } |
| 163 | |
| 164 | /** A login has to compare against `az account show`, which reports an email. */ |
| 165 | function toActor(raw: Schema.Schema.Type<typeof RawIdentitySchema> | null | undefined) { |
| 166 | const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName); |
| 167 | return login === null |
| 168 | ? null |
| 169 | : { login, name: trimmed(raw?.displayName), avatarUrl: trimmed(raw?.imageUrl) }; |
| 170 | } |
| 171 | |
| 172 | function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState { |
| 173 | switch (raw.status?.trim().toLowerCase()) { |
| 174 | case "completed": |
| 175 | return "merged"; |
| 176 | case "abandoned": |
| 177 | return "closed"; |
| 178 | default: |
| 179 | return "open"; |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | function toMergeability(value: string | null | undefined): PullRequestMergeability { |
| 184 | switch (value?.trim().toLowerCase()) { |
| 185 | case "succeeded": |
| 186 | return "mergeable"; |
| 187 | case "conflicts": |
| 188 | case "failure": |
| 189 | case "rejectedbypolicy": |
| 190 | return "conflicting"; |
| 191 | default: |
| 192 | // `queued` and `notSet` mean Azure has not finished checking. |
| 193 | return "unknown"; |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | /** |
| 198 | * Where a pull request's own repository sits. Taken from what Azure returned rather than from the |
| 199 | * local remote, whose shape differs between the modern, legacy and SSH forms. |
| 200 | */ |
| 201 | function toLocation( |
| 202 | raw: Schema.Schema.Type<typeof RawPullRequestSchema>, |
| 203 | ): AzureDevOpsRepositoryLocation | null { |
| 204 | const project = trimmed(raw.repository?.project?.name); |
| 205 | const repository = trimmed(raw.repository?.name); |
| 206 | if (project === null || repository === null) return null; |
| 207 | return { project, repository }; |
| 208 | } |
| 209 | |
| 210 | function toAutoMergeMethod( |
| 211 | raw: Schema.Schema.Type<typeof RawPullRequestSchema>, |
| 212 | ): PullRequestMergeMethod | undefined { |
| 213 | if (raw.autoCompleteSetBy == null) return undefined; |
| 214 | switch (raw.completionOptions?.mergeStrategy?.trim().toLowerCase()) { |
| 215 | case "squash": |
| 216 | return "squash"; |
| 217 | case "rebase": |
| 218 | case "rebasemerge": |
| 219 | return "rebase"; |
| 220 | case "nofastforward": |
| 221 | return "merge"; |
| 222 | default: |
| 223 | return raw.completionOptions?.squashMerge === true ? "squash" : undefined; |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | /** |
| 228 | * Null when Azure said too little to place the pull request: a row with no browser url and no |
| 229 | * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract |
| 230 | * refuses to carry it either. |
| 231 | */ |
| 232 | function toPullRequest( |
| 233 | raw: Schema.Schema.Type<typeof RawPullRequestSchema>, |
| 234 | ): AzureDevOpsPullRequest | null { |
| 235 | const autoMergeMethod = toAutoMergeMethod(raw); |
| 236 | const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { |
| 237 | const actor = toActor(reviewer); |
| 238 | return actor === null ? [] : [actor]; |
| 239 | }); |
| 240 | const closedAt = trimmed(raw.closedDate); |
| 241 | const url = trimmed( |
| 242 | azureDevOpsPullRequestWebUrl({ |
| 243 | pullRequestId: raw.pullRequestId, |
| 244 | webLink: raw._links?.web?.href, |
| 245 | repositoryWebUrl: raw.repository?.webUrl, |
| 246 | restApiUrl: raw.url, |
| 247 | projectName: raw.repository?.project?.name, |
| 248 | repositoryName: raw.repository?.name, |
| 249 | }), |
| 250 | ); |
| 251 | const headBranch = trimmed(normalizeRefName(raw.sourceRefName)); |
| 252 | const baseBranch = trimmed(normalizeRefName(raw.targetRefName)); |
| 253 | if (url === null || headBranch === null || baseBranch === null) return null; |
| 254 | return { |
| 255 | number: raw.pullRequestId, |
| 256 | title: raw.title, |
| 257 | url, |
| 258 | author: toActor(raw.createdBy), |
| 259 | headBranch, |
| 260 | baseBranch, |
| 261 | state: toState(raw), |
| 262 | isDraft: raw.isDraft ?? false, |
| 263 | mergeability: toMergeability(raw.mergeStatus), |
| 264 | createdAt: raw.creationDate, |
| 265 | updatedAt: closedAt ?? raw.creationDate, |
| 266 | closedAt, |
| 267 | body: raw.description ?? "", |
| 268 | reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), |
| 269 | reviewers, |
| 270 | location: toLocation(raw), |
| 271 | autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, |
| 272 | ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), |
| 273 | }; |
| 274 | } |
| 275 | |
| 276 | const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); |
| 277 | const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); |
| 278 | const decodePullRequest = decodeJsonResult(RawPullRequestSchema); |
| 279 | const decodeThreadPage = decodeJsonResult(RawThreadPageSchema); |
| 280 | const decodeThreadEntry = Schema.decodeUnknownExit(RawThreadSchema); |
| 281 | const decodeViewer = decodeJsonResult(RawViewerSchema); |
| 282 | |
| 283 | type DecodeFailure = Cause.Cause<Schema.SchemaError>; |
| 284 | |
| 285 | export interface AzureDevOpsPullRequestBatch { |
| 286 | readonly items: ReadonlyArray<AzureDevOpsPullRequest>; |
| 287 | /** Zero-based positions of the decoded items in Azure's raw page. */ |
| 288 | readonly rawIndexes: ReadonlyArray<number>; |
| 289 | /** Rows Azure returned, counted before decoding, so a skipped row cannot hide a next page. */ |
| 290 | readonly rawCount: number; |
| 291 | } |
| 292 | |
| 293 | /** Malformed entries are skipped rather than failing the batch, as on the other hosts. */ |
| 294 | export function decodePullRequestListJson( |
| 295 | raw: string, |
| 296 | ): Result.Result<AzureDevOpsPullRequestBatch, DecodeFailure> { |
| 297 | const decoded = decodeUnknownList(raw); |
| 298 | if (!Result.isSuccess(decoded)) { |
| 299 | return Result.fail(decoded.failure); |
| 300 | } |
| 301 | const items: AzureDevOpsPullRequest[] = []; |
| 302 | const rawIndexes: number[] = []; |
| 303 | for (const [rawIndex, entry] of decoded.success.entries()) { |
| 304 | const item = decodePullRequestEntry(entry); |
| 305 | if (Exit.isFailure(item)) continue; |
| 306 | const pullRequest = toPullRequest(item.value); |
| 307 | if (pullRequest !== null) { |
| 308 | items.push(pullRequest); |
| 309 | rawIndexes.push(rawIndex); |
| 310 | } |
| 311 | } |
| 312 | return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); |
| 313 | } |
| 314 | |
| 315 | /** Null carries "Azure answered, but with too little to use", which the caller reports. */ |
| 316 | export function decodePullRequestJson( |
| 317 | raw: string, |
| 318 | ): Result.Result<AzureDevOpsPullRequest | null, DecodeFailure> { |
| 319 | const decoded = decodePullRequest(raw); |
| 320 | return Result.isSuccess(decoded) |
| 321 | ? Result.succeed(toPullRequest(decoded.success)) |
| 322 | : Result.fail(decoded.failure); |
| 323 | } |
| 324 | |
| 325 | /** `az account show --query user` reports the signed-in account, whose name is an email. */ |
| 326 | export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> { |
| 327 | const decoded = decodeViewer(raw); |
| 328 | return Result.isSuccess(decoded) |
| 329 | ? Result.succeed(trimmed(decoded.success.user?.name)) |
| 330 | : Result.fail(decoded.failure); |
| 331 | } |
| 332 | |
| 333 | /** |
| 334 | * Azure keeps its conversation as threads of comments, and every one of them is a remark |
| 335 | * somebody wrote: a reply under a thread is as much of the conversation as the line that opened |
| 336 | * it. A thread pinned to a file is a line-level review comment. |
| 337 | * |
| 338 | * Azure answers the whole thread collection in one response, with no cursor and no page to |
| 339 | * follow, so what this returns is everything the host has. |
| 340 | */ |
| 341 | export function decodeThreadsJson( |
| 342 | raw: string, |
| 343 | ): Result.Result<ReadonlyArray<PullRequestComment>, DecodeFailure> { |
| 344 | const decoded = decodeThreadPage(raw); |
| 345 | if (!Result.isSuccess(decoded)) { |
| 346 | return Result.fail(decoded.failure); |
| 347 | } |
| 348 | const comments: PullRequestComment[] = []; |
| 349 | for (const entry of decoded.success.value) { |
| 350 | const decodedThread = decodeThreadEntry(entry); |
| 351 | if (Exit.isFailure(decodedThread)) continue; |
| 352 | const thread = decodedThread.value; |
| 353 | if (thread.isDeleted === true) continue; |
| 354 | const path = trimmed(thread.threadContext?.filePath); |
| 355 | for (const comment of thread.comments ?? []) { |
| 356 | const publishedDate = trimmed(comment.publishedDate); |
| 357 | if ( |
| 358 | comment.isDeleted === true || |
| 359 | comment.commentType?.trim().toLowerCase() === "system" || |
| 360 | (comment.content ?? "").trim().length === 0 || |
| 361 | publishedDate === null |
| 362 | ) { |
| 363 | continue; |
| 364 | } |
| 365 | comments.push({ |
| 366 | id: `${thread.id}:${comment.id ?? 0}`, |
| 367 | kind: path === null ? "issue-comment" : "review-comment", |
| 368 | author: toActor(comment.author), |
| 369 | body: comment.content ?? "", |
| 370 | createdAt: publishedDate, |
| 371 | url: null, |
| 372 | path, |
| 373 | reviewState: null, |
| 374 | }); |
| 375 | } |
| 376 | } |
| 377 | return Result.succeed( |
| 378 | comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), |
| 379 | ); |
| 380 | } |
| 381 | |
| 382 | /** |
| 383 | * One push's worth of a pull request. Azure records every push as an iteration and keys the whole |
| 384 | * review off them: the changed files, and the marks a reader leaves on those files, both hang |
| 385 | * from an iteration rather than from the pull request. |
| 386 | */ |
| 387 | const RawIterationSchema = Schema.Struct({ |
| 388 | id: Schema.Int, |
| 389 | sourceRefCommit: Schema.optional( |
| 390 | Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), |
| 391 | ), |
| 392 | commonRefCommit: Schema.optional( |
| 393 | Schema.NullOr(Schema.Struct({ commitId: Schema.optional(Schema.NullOr(Schema.String)) })), |
| 394 | ), |
| 395 | }); |
| 396 | |
| 397 | const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) }); |
| 398 | |
| 399 | const RawChangeEntrySchema = Schema.Struct({ |
| 400 | changeType: Schema.optional(Schema.NullOr(Schema.String)), |
| 401 | sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)), |
| 402 | /** Where a renamed file came from. Azure states it here on an iteration's changes. */ |
| 403 | originalPath: Schema.optional(Schema.NullOr(Schema.String)), |
| 404 | item: Schema.optional( |
| 405 | Schema.NullOr( |
| 406 | Schema.Struct({ |
| 407 | path: Schema.optional(Schema.NullOr(Schema.String)), |
| 408 | objectId: Schema.optional(Schema.NullOr(Schema.String)), |
| 409 | originalObjectId: Schema.optional(Schema.NullOr(Schema.String)), |
| 410 | /** Azure marks a directory this way; a review has nothing to show for one. */ |
| 411 | isFolder: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 412 | gitObjectType: Schema.optional(Schema.NullOr(Schema.String)), |
| 413 | }), |
| 414 | ), |
| 415 | ), |
| 416 | }); |
| 417 | |
| 418 | const RawChangePageSchema = Schema.Struct({ |
| 419 | changeEntries: Schema.Array(Schema.Unknown), |
| 420 | /** Where the page after this one starts. Azure leaves it out on the last page. */ |
| 421 | nextSkip: Schema.optional(Schema.NullOr(Schema.Number)), |
| 422 | }); |
| 423 | |
| 424 | const RawItemContentSchema = Schema.Struct({ |
| 425 | content: Schema.optional(Schema.NullOr(Schema.String)), |
| 426 | /** What Azure makes of the file it is handing over, which is where it says it is not text. */ |
| 427 | contentMetadata: Schema.optional( |
| 428 | Schema.NullOr(Schema.Struct({ isBinary: Schema.optional(Schema.NullOr(Schema.Boolean)) })), |
| 429 | ), |
| 430 | }); |
| 431 | |
| 432 | /** The head and the merge base of one iteration, which is the range its patch is taken over. */ |
| 433 | export interface AzureDevOpsIteration { |
| 434 | readonly id: number; |
| 435 | readonly headCommit: string; |
| 436 | readonly mergeBaseCommit: string; |
| 437 | } |
| 438 | |
| 439 | /** |
| 440 | * What one file did across an iteration. `oldPath` differs from `path` only for a rename, which |
| 441 | * Azure reports by naming the file's previous home rather than as a delete and an add. |
| 442 | */ |
| 443 | export interface AzureDevOpsChangeEntry { |
| 444 | readonly path: string; |
| 445 | readonly oldPath: string; |
| 446 | readonly changeKind: "new" | "deleted" | "change" | "rename-pure" | "rename-changed"; |
| 447 | readonly objectId: string | null; |
| 448 | readonly originalObjectId: string | null; |
| 449 | } |
| 450 | |
| 451 | /** |
| 452 | * One page of what an iteration changed, and where the next one starts. Azure pages this route |
| 453 | * rather than answering with the whole change, so a review large enough to be paged is followed |
| 454 | * to its end instead of being cut off at the first page's worth. |
| 455 | */ |
| 456 | export interface AzureDevOpsChangePage { |
| 457 | readonly changes: ReadonlyArray<AzureDevOpsChangeEntry>; |
| 458 | readonly nextSkip: number | null; |
| 459 | } |
| 460 | |
| 461 | /** One file's text at one commit, and whether Azure says the text is text at all. */ |
| 462 | export interface AzureDevOpsItemContent { |
| 463 | readonly contents: string; |
| 464 | readonly isBinary: boolean; |
| 465 | } |
| 466 | |
| 467 | const decodeIterationPage = decodeJsonResult(RawIterationPageSchema); |
| 468 | const decodeIterationEntry = Schema.decodeUnknownExit(RawIterationSchema); |
| 469 | const decodeChangePage = decodeJsonResult(RawChangePageSchema); |
| 470 | const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema); |
| 471 | const decodeItemContent = decodeJsonResult(RawItemContentSchema); |
| 472 | |
| 473 | /** |
| 474 | * Azure leads a path with a slash that every other host and every patch omits. Not trimmed like |
| 475 | * the rest of this payload, since a leading/trailing space is a legal part of a file's name and |
| 476 | * the patch and viewed mark are keyed by the untrimmed path. |
| 477 | */ |
| 478 | function toRepositoryPath(value: string | null | undefined): string | null { |
| 479 | if (value === undefined || value === null) return null; |
| 480 | const path = value.replace(/^\/+/, ""); |
| 481 | return path.length === 0 ? null : path; |
| 482 | } |
| 483 | |
| 484 | /** |
| 485 | * Azure names a change with one word or two, and a rename arrives either alone or alongside the |
| 486 | * edit that came with it. Anything it has added since reads as a plain change, which shows the |
| 487 | * file rather than dropping it from the review. |
| 488 | */ |
| 489 | function toChangeKind( |
| 490 | raw: string | null | undefined, |
| 491 | renamed: boolean, |
| 492 | ): AzureDevOpsChangeEntry["changeKind"] { |
| 493 | const parts = new Set( |
| 494 | (raw ?? "") |
| 495 | .toLowerCase() |
| 496 | .split(",") |
| 497 | .map((part) => part.trim()) |
| 498 | .filter((part) => part.length > 0), |
| 499 | ); |
| 500 | if (parts.has("delete")) return "deleted"; |
| 501 | if (renamed || parts.has("rename")) return parts.has("edit") ? "rename-changed" : "rename-pure"; |
| 502 | if (parts.has("add")) return "new"; |
| 503 | return "change"; |
| 504 | } |
| 505 | |
| 506 | export function decodeIterationsJson( |
| 507 | raw: string, |
| 508 | ): Result.Result<ReadonlyArray<AzureDevOpsIteration>, DecodeFailure> { |
| 509 | const decoded = decodeIterationPage(raw); |
| 510 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); |
| 511 | const iterations: AzureDevOpsIteration[] = []; |
| 512 | for (const entry of decoded.success.value) { |
| 513 | const decodedIteration = decodeIterationEntry(entry); |
| 514 | if (Exit.isFailure(decodedIteration)) continue; |
| 515 | const iteration = decodedIteration.value; |
| 516 | const headCommit = trimmed(iteration.sourceRefCommit?.commitId); |
| 517 | const mergeBaseCommit = trimmed(iteration.commonRefCommit?.commitId); |
| 518 | // An iteration Azure cannot place both ends of names no range, and a patch needs both. |
| 519 | if (headCommit === null || mergeBaseCommit === null) continue; |
| 520 | iterations.push({ id: iteration.id, headCommit, mergeBaseCommit }); |
| 521 | } |
| 522 | return Result.succeed(iterations.toSorted((left, right) => left.id - right.id)); |
| 523 | } |
| 524 | |
| 525 | export function decodeIterationChangesJson( |
| 526 | raw: string, |
| 527 | ): Result.Result<AzureDevOpsChangePage, DecodeFailure> { |
| 528 | const decoded = decodeChangePage(raw); |
| 529 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); |
| 530 | const changes: AzureDevOpsChangeEntry[] = []; |
| 531 | for (const entry of decoded.success.changeEntries) { |
| 532 | const decodedChange = decodeChangeEntry(entry); |
| 533 | if (Exit.isFailure(decodedChange)) continue; |
| 534 | const change = decodedChange.value; |
| 535 | const path = toRepositoryPath(change.item?.path); |
| 536 | if (path === null) continue; |
| 537 | // Azure lists the folders a change touched alongside the files themselves. A review shows |
| 538 | // files, and a folder has no content to show for either side of one. |
| 539 | if (change.item?.isFolder === true) continue; |
| 540 | if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue; |
| 541 | // Azure names where a renamed file came from in either of two places depending on the route |
| 542 | // and the version, so both are read and the current path stands in when neither is there. |
| 543 | const oldPath = |
| 544 | toRepositoryPath(change.sourceServerItem) ?? toRepositoryPath(change.originalPath) ?? path; |
| 545 | changes.push({ |
| 546 | path, |
| 547 | oldPath, |
| 548 | changeKind: toChangeKind(change.changeType, oldPath !== path), |
| 549 | objectId: trimmed(change.item?.objectId), |
| 550 | originalObjectId: trimmed(change.item?.originalObjectId), |
| 551 | }); |
| 552 | } |
| 553 | const nextSkip = decoded.success.nextSkip ?? null; |
| 554 | return Result.succeed({ |
| 555 | changes, |
| 556 | nextSkip: nextSkip !== null && Number.isSafeInteger(nextSkip) && nextSkip > 0 ? nextSkip : null, |
| 557 | }); |
| 558 | } |
| 559 | |
| 560 | /** |
| 561 | * Azure answers an absent file with an empty body rather than an error, which reads as empty. |
| 562 | * Whether the bytes are text is Azure's own call, not this decoder's to guess from content. |
| 563 | */ |
| 564 | export function decodeItemContentJson( |
| 565 | raw: string, |
| 566 | ): Result.Result<AzureDevOpsItemContent, DecodeFailure> { |
| 567 | const decoded = decodeItemContent(raw); |
| 568 | return Result.isSuccess(decoded) |
| 569 | ? Result.succeed({ |
| 570 | contents: decoded.success.content ?? "", |
| 571 | isBinary: decoded.success.contentMetadata?.isBinary === true, |
| 572 | }) |
| 573 | : Result.fail(decoded.failure); |
| 574 | } |