| 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 | PullRequestCheck, |
| 8 | PullRequestCheckStatus, |
| 9 | PullRequestComment, |
| 10 | PullRequestCommit, |
| 11 | PullRequestLabel, |
| 12 | PullRequestMergeability, |
| 13 | PullRequestMergeCapabilities, |
| 14 | PullRequestMergeMethod, |
| 15 | PullRequestReaction, |
| 16 | PullRequestReactionContent, |
| 17 | PullRequestReviewThread, |
| 18 | PullRequestReviewerCandidate, |
| 19 | PullRequestState, |
| 20 | } from "@t3tools/contracts"; |
| 21 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; |
| 22 | import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; |
| 23 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 24 | |
| 25 | /** |
| 26 | * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that |
| 27 | * adds a pipeline status or a merge status must not fail the whole payload. |
| 28 | */ |
| 29 | const RawUserSchema = Schema.Struct({ |
| 30 | /** |
| 31 | * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the |
| 32 | * id is carried alongside the handle rather than looked up again when a review is asked for. |
| 33 | */ |
| 34 | id: Schema.optional(Schema.Int), |
| 35 | username: Schema.String, |
| 36 | name: Schema.optional(Schema.NullOr(Schema.String)), |
| 37 | avatar_url: Schema.optional(Schema.NullOr(Schema.String)), |
| 38 | }); |
| 39 | |
| 40 | const RawPipelineSchema = Schema.Struct({ |
| 41 | status: Schema.optional(Schema.NullOr(Schema.String)), |
| 42 | web_url: Schema.optional(Schema.NullOr(Schema.String)), |
| 43 | source: Schema.optional(Schema.NullOr(Schema.String)), |
| 44 | }); |
| 45 | |
| 46 | const RawMergeRequestSchema = Schema.Struct({ |
| 47 | iid: Schema.Int, |
| 48 | title: Schema.String, |
| 49 | web_url: Schema.String, |
| 50 | description: Schema.optional(Schema.NullOr(Schema.String)), |
| 51 | author: Schema.optional(Schema.NullOr(RawUserSchema)), |
| 52 | source_branch: Schema.String, |
| 53 | target_branch: Schema.String, |
| 54 | /** The source branch's head commit. */ |
| 55 | sha: Schema.optional(Schema.NullOr(Schema.String)), |
| 56 | state: Schema.optional(Schema.NullOr(Schema.String)), |
| 57 | draft: Schema.optional(Schema.Boolean), |
| 58 | work_in_progress: Schema.optional(Schema.Boolean), |
| 59 | merge_status: Schema.optional(Schema.NullOr(Schema.String)), |
| 60 | has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 61 | created_at: Schema.String, |
| 62 | updated_at: Schema.String, |
| 63 | merged_at: Schema.optional(Schema.NullOr(Schema.String)), |
| 64 | closed_at: Schema.optional(Schema.NullOr(Schema.String)), |
| 65 | reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), |
| 66 | labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), |
| 67 | // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded. |
| 68 | changes_count: Schema.optional(Schema.NullOr(Schema.String)), |
| 69 | head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)), |
| 70 | /** |
| 71 | * What the requesting account may do, which only the single-merge-request endpoint carries. |
| 72 | * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts |
| 73 | * for the role, the approval rules and a protected target branch — none of which a project's |
| 74 | * access level on its own would tell apart. |
| 75 | */ |
| 76 | user: Schema.optional( |
| 77 | Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })), |
| 78 | ), |
| 79 | /** |
| 80 | * Whether GitLab is holding this merge request to merge it once its pipeline goes green. |
| 81 | * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also |
| 82 | * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so |
| 83 | * either one saying yes is a yes. |
| 84 | */ |
| 85 | merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 86 | auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 87 | /** The merge request's stored squash choice, including project-policy overrides. */ |
| 88 | squash_on_merge: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 89 | squash: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 90 | /** |
| 91 | * How far the target branch has moved on since this one left it, which is the same number |
| 92 | * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab |
| 93 | * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a |
| 94 | * single merge request — a list never carries it, however it is asked for. |
| 95 | */ |
| 96 | diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)), |
| 97 | }); |
| 98 | |
| 99 | const RawNoteSchema = Schema.Struct({ |
| 100 | id: Schema.Int, |
| 101 | body: Schema.optional(Schema.NullOr(Schema.String)), |
| 102 | author: Schema.optional(Schema.NullOr(RawUserSchema)), |
| 103 | created_at: Schema.String, |
| 104 | /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */ |
| 105 | system: Schema.optional(Schema.Boolean), |
| 106 | type: Schema.optional(Schema.NullOr(Schema.String)), |
| 107 | position: Schema.optional( |
| 108 | Schema.NullOr( |
| 109 | Schema.Struct({ |
| 110 | new_path: Schema.optional(Schema.NullOr(Schema.String)), |
| 111 | old_path: Schema.optional(Schema.NullOr(Schema.String)), |
| 112 | }), |
| 113 | ), |
| 114 | ), |
| 115 | }); |
| 116 | |
| 117 | /** |
| 118 | * A discussion note carrying its place in the diff, which is the shape the whole thread view |
| 119 | * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a |
| 120 | * discussion resolved once every resolvable note in it is. |
| 121 | */ |
| 122 | const RawDiscussionNoteSchema = Schema.Struct({ |
| 123 | id: Schema.Int, |
| 124 | body: Schema.optional(Schema.NullOr(Schema.String)), |
| 125 | author: Schema.optional(Schema.NullOr(RawUserSchema)), |
| 126 | created_at: Schema.String, |
| 127 | system: Schema.optional(Schema.Boolean), |
| 128 | resolvable: Schema.optional(Schema.Boolean), |
| 129 | resolved: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 130 | position: Schema.optional( |
| 131 | Schema.NullOr( |
| 132 | Schema.Struct({ |
| 133 | position_type: Schema.optional(Schema.NullOr(Schema.String)), |
| 134 | new_path: Schema.optional(Schema.NullOr(Schema.String)), |
| 135 | old_path: Schema.optional(Schema.NullOr(Schema.String)), |
| 136 | new_line: Schema.optional(Schema.NullOr(Schema.Int)), |
| 137 | old_line: Schema.optional(Schema.NullOr(Schema.Int)), |
| 138 | }), |
| 139 | ), |
| 140 | ), |
| 141 | }); |
| 142 | |
| 143 | const RawDiscussionSchema = Schema.Struct({ |
| 144 | id: Schema.String, |
| 145 | notes: Schema.optional(Schema.NullOr(Schema.Array(RawDiscussionNoteSchema))), |
| 146 | }); |
| 147 | |
| 148 | const RawDiffRefsSchema = Schema.Struct({ |
| 149 | diff_refs: Schema.optional( |
| 150 | Schema.NullOr( |
| 151 | Schema.Struct({ |
| 152 | base_sha: Schema.String, |
| 153 | head_sha: Schema.String, |
| 154 | start_sha: Schema.String, |
| 155 | }), |
| 156 | ), |
| 157 | ), |
| 158 | }); |
| 159 | |
| 160 | const RawCommitSchema = Schema.Struct({ |
| 161 | id: TrimmedNonEmptyString, |
| 162 | title: Schema.optional(Schema.NullOr(Schema.String)), |
| 163 | committed_date: Schema.optional(Schema.NullOr(Schema.String)), |
| 164 | created_at: Schema.optional(Schema.NullOr(Schema.String)), |
| 165 | parent_ids: Schema.optional(Schema.Array(Schema.String)), |
| 166 | author_name: Schema.optional(Schema.NullOr(Schema.String)), |
| 167 | author_email: Schema.optional(Schema.NullOr(Schema.String)), |
| 168 | stats: Schema.optional( |
| 169 | Schema.NullOr( |
| 170 | Schema.Struct({ |
| 171 | additions: Schema.optional(Schema.Int), |
| 172 | deletions: Schema.optional(Schema.Int), |
| 173 | }), |
| 174 | ), |
| 175 | ), |
| 176 | }); |
| 177 | |
| 178 | const RawDiffSchema = Schema.Struct({ |
| 179 | old_path: Schema.String, |
| 180 | new_path: Schema.String, |
| 181 | a_mode: Schema.optional(Schema.NullOr(Schema.String)), |
| 182 | b_mode: Schema.optional(Schema.NullOr(Schema.String)), |
| 183 | new_file: Schema.optional(Schema.Boolean), |
| 184 | renamed_file: Schema.optional(Schema.Boolean), |
| 185 | deleted_file: Schema.optional(Schema.Boolean), |
| 186 | diff: Schema.optional(Schema.NullOr(Schema.String)), |
| 187 | /** GitLab omits the hunks for a file it considers too large to inline. */ |
| 188 | too_large: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 189 | /** And for one it collapsed, which withholds them the same way. */ |
| 190 | collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 191 | }); |
| 192 | |
| 193 | const RawViewerSchema = Schema.Struct({ |
| 194 | username: Schema.optional(Schema.NullOr(Schema.String)), |
| 195 | }); |
| 196 | |
| 197 | /** A GitLab project settles on one merge strategy plus an optional squash. */ |
| 198 | const RawProjectMergeSettingsSchema = Schema.Struct({ |
| 199 | merge_method: Schema.optional(Schema.NullOr(Schema.String)), |
| 200 | squash_option: Schema.optional(Schema.NullOr(Schema.String)), |
| 201 | }); |
| 202 | |
| 203 | export interface GitLabMergeRequestListItem { |
| 204 | readonly number: number; |
| 205 | readonly title: string; |
| 206 | readonly url: string; |
| 207 | readonly author: PullRequestActor | null; |
| 208 | readonly headBranch: string; |
| 209 | readonly baseBranch: string; |
| 210 | readonly state: PullRequestState; |
| 211 | readonly isDraft: boolean; |
| 212 | readonly mergeability: PullRequestMergeability; |
| 213 | /** |
| 214 | * GitLab reports neither added nor removed lines on a merge request, so both stay zero and |
| 215 | * the surface omits the stat. The Code tab counts them from the patch it already fetched. |
| 216 | */ |
| 217 | readonly additions: number; |
| 218 | readonly deletions: number; |
| 219 | readonly createdAt: string; |
| 220 | readonly updatedAt: string; |
| 221 | readonly reviewRequestLogins: ReadonlyArray<string>; |
| 222 | readonly labels: ReadonlyArray<PullRequestLabel>; |
| 223 | } |
| 224 | |
| 225 | export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { |
| 226 | readonly body: string; |
| 227 | readonly changedFiles: number; |
| 228 | readonly mergedAt: string | null; |
| 229 | readonly closedAt: string | null; |
| 230 | readonly reviewers: ReadonlyArray<PullRequestActor>; |
| 231 | readonly checks: ReadonlyArray<PullRequestCheck>; |
| 232 | /** False only where GitLab said so; an answer without the field leaves merging permitted. */ |
| 233 | readonly viewerCanMerge: boolean; |
| 234 | /** The reviewers as GitLab addresses them, which is what writing the set back takes. */ |
| 235 | readonly reviewerIds: ReadonlyArray<number>; |
| 236 | /** Absent where GitLab named neither auto-merge field, which is not the same as off. */ |
| 237 | readonly autoMergeEnabled?: boolean; |
| 238 | /** GitLab only exposes the stored strategy separately when that strategy is squash. */ |
| 239 | readonly autoMergeMethod?: PullRequestMergeMethod; |
| 240 | /** |
| 241 | * Absent where GitLab did not count, which is not the same as a branch that has nothing behind |
| 242 | * it: an install too old to answer must not be read as saying the branch is current. |
| 243 | */ |
| 244 | readonly divergedCommits?: number; |
| 245 | /** Absent where GitLab did not report the head commit. */ |
| 246 | readonly headSha?: string; |
| 247 | } |
| 248 | |
| 249 | function trimmed(value: string | null | undefined): string | null { |
| 250 | const text = value?.trim() ?? ""; |
| 251 | return text.length > 0 ? text : null; |
| 252 | } |
| 253 | |
| 254 | function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) { |
| 255 | const login = trimmed(raw?.username); |
| 256 | return login === null |
| 257 | ? null |
| 258 | : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) }; |
| 259 | } |
| 260 | |
| 261 | function toState(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): PullRequestState { |
| 262 | if (trimmed(raw.merged_at) !== null) return "merged"; |
| 263 | switch (raw.state?.trim().toLowerCase()) { |
| 264 | case "merged": |
| 265 | return "merged"; |
| 266 | case "closed": |
| 267 | return "closed"; |
| 268 | default: |
| 269 | // `locked` is an open merge request whose discussion is locked. |
| 270 | return "open"; |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | function toMergeability( |
| 275 | raw: Schema.Schema.Type<typeof RawMergeRequestSchema>, |
| 276 | ): PullRequestMergeability { |
| 277 | if (raw.has_conflicts === true) return "conflicting"; |
| 278 | switch (raw.merge_status?.trim().toLowerCase()) { |
| 279 | case "can_be_merged": |
| 280 | return "mergeable"; |
| 281 | case "cannot_be_merged": |
| 282 | return "conflicting"; |
| 283 | default: |
| 284 | // `unchecked` and `checking` mean GitLab has not finished the merge check yet. |
| 285 | return "unknown"; |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | function toLabels(raw: ReadonlyArray<string> | null | undefined): ReadonlyArray<PullRequestLabel> { |
| 290 | // GitLab returns label names only, so there is no colour to carry. |
| 291 | return (raw ?? []).flatMap((label) => { |
| 292 | const name = trimmed(label); |
| 293 | return name === null ? [] : [{ name, color: null }]; |
| 294 | }); |
| 295 | } |
| 296 | |
| 297 | /** |
| 298 | * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is |
| 299 | * the floor either way, which reads better than dropping an uncounted change set to nothing. |
| 300 | */ |
| 301 | function toChangedFiles(value: string | null | undefined): number { |
| 302 | const parsed = Number.parseInt(value?.trim() ?? "", 10); |
| 303 | return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; |
| 304 | } |
| 305 | |
| 306 | function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus { |
| 307 | switch (value?.trim().toLowerCase()) { |
| 308 | case "success": |
| 309 | return "success"; |
| 310 | case "failed": |
| 311 | return "failure"; |
| 312 | case "canceled": |
| 313 | case "cancelling": |
| 314 | return "cancelled"; |
| 315 | case "skipped": |
| 316 | return "skipped"; |
| 317 | // A pipeline waiting on a person is not progress, and it is not a failure either. |
| 318 | case "manual": |
| 319 | case "scheduled": |
| 320 | return "neutral"; |
| 321 | default: |
| 322 | return "pending"; |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | /** |
| 327 | * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one |
| 328 | * check. The jobs behind it stay one click away through the pipeline URL. |
| 329 | */ |
| 330 | function toChecks( |
| 331 | raw: Schema.Schema.Type<typeof RawMergeRequestSchema>, |
| 332 | ): ReadonlyArray<PullRequestCheck> { |
| 333 | const pipeline = raw.head_pipeline; |
| 334 | if (!pipeline) return []; |
| 335 | return [ |
| 336 | { |
| 337 | name: "Pipeline", |
| 338 | status: toPipelineStatus(pipeline.status), |
| 339 | description: trimmed(pipeline.source), |
| 340 | url: trimmed(pipeline.web_url), |
| 341 | }, |
| 342 | ]; |
| 343 | } |
| 344 | |
| 345 | function toListItem( |
| 346 | raw: Schema.Schema.Type<typeof RawMergeRequestSchema>, |
| 347 | ): GitLabMergeRequestListItem { |
| 348 | return { |
| 349 | number: raw.iid, |
| 350 | title: raw.title, |
| 351 | url: raw.web_url, |
| 352 | author: toActor(raw.author), |
| 353 | headBranch: raw.source_branch, |
| 354 | baseBranch: raw.target_branch, |
| 355 | state: toState(raw), |
| 356 | isDraft: raw.draft ?? raw.work_in_progress ?? false, |
| 357 | mergeability: toMergeability(raw), |
| 358 | additions: 0, |
| 359 | deletions: 0, |
| 360 | createdAt: raw.created_at, |
| 361 | updatedAt: raw.updated_at, |
| 362 | reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => { |
| 363 | const login = trimmed(reviewer.username); |
| 364 | return login === null ? [] : [login]; |
| 365 | }), |
| 366 | labels: toLabels(raw.labels), |
| 367 | }; |
| 368 | } |
| 369 | |
| 370 | function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLabMergeRequestDetail { |
| 371 | const listItem = toListItem(raw); |
| 372 | const headSha = trimmed(raw.sha); |
| 373 | const autoMerge = |
| 374 | raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null |
| 375 | ? undefined |
| 376 | : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true; |
| 377 | return { |
| 378 | ...listItem, |
| 379 | body: raw.description ?? "", |
| 380 | changedFiles: toChangedFiles(raw.changes_count), |
| 381 | mergedAt: trimmed(raw.merged_at), |
| 382 | closedAt: trimmed(raw.closed_at), |
| 383 | // Built from the reviewers themselves rather than from their logins, so the avatars survive. |
| 384 | reviewers: (raw.reviewers ?? []).flatMap((reviewer) => { |
| 385 | const actor = toActor(reviewer); |
| 386 | return actor === null ? [] : [actor]; |
| 387 | }), |
| 388 | checks: toChecks(raw), |
| 389 | viewerCanMerge: raw.user?.can_merge !== false, |
| 390 | reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => |
| 391 | reviewer.id === undefined ? [] : [reviewer.id], |
| 392 | ), |
| 393 | ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }), |
| 394 | ...(autoMerge === true && raw.squash_on_merge === true |
| 395 | ? { autoMergeMethod: "squash" as const } |
| 396 | : {}), |
| 397 | ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }), |
| 398 | ...(headSha === null ? {} : { headSha }), |
| 399 | }; |
| 400 | } |
| 401 | |
| 402 | const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); |
| 403 | const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema); |
| 404 | const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema); |
| 405 | const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema); |
| 406 | const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema); |
| 407 | const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); |
| 408 | const decodeCommit = decodeJsonResult(RawCommitSchema); |
| 409 | const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema); |
| 410 | const decodeDiscussionEntry = Schema.decodeUnknownExit(RawDiscussionSchema); |
| 411 | const decodeDiffRefs = decodeJsonResult(RawDiffRefsSchema); |
| 412 | const decodeViewer = decodeJsonResult(RawViewerSchema); |
| 413 | const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema); |
| 414 | |
| 415 | type DecodeFailure = Cause.Cause<Schema.SchemaError>; |
| 416 | |
| 417 | export interface GitLabProjectUsers { |
| 418 | readonly candidates: ReadonlyArray<PullRequestReviewerCandidate>; |
| 419 | /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ |
| 420 | readonly rawCount: number; |
| 421 | } |
| 422 | |
| 423 | export interface GitLabMergeRequestListBatch { |
| 424 | readonly items: ReadonlyArray<GitLabMergeRequestListItem>; |
| 425 | /** Zero-based positions of the decoded items in GitLab's raw page. */ |
| 426 | readonly rawIndexes: ReadonlyArray<number>; |
| 427 | /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ |
| 428 | readonly rawCount: number; |
| 429 | } |
| 430 | |
| 431 | /** Malformed entries are skipped rather than failing the batch: one unexpected merge request |
| 432 | * must not blank the whole list. */ |
| 433 | export function decodeMergeRequestListJson( |
| 434 | raw: string, |
| 435 | ): Result.Result<GitLabMergeRequestListBatch, DecodeFailure> { |
| 436 | const decoded = decodeUnknownList(raw); |
| 437 | if (!Result.isSuccess(decoded)) { |
| 438 | return Result.fail(decoded.failure); |
| 439 | } |
| 440 | const items: GitLabMergeRequestListItem[] = []; |
| 441 | const rawIndexes: number[] = []; |
| 442 | for (const [rawIndex, entry] of decoded.success.entries()) { |
| 443 | const item = decodeMergeRequestEntry(entry); |
| 444 | if (Exit.isSuccess(item)) { |
| 445 | items.push(toListItem(item.value)); |
| 446 | rawIndexes.push(rawIndex); |
| 447 | } |
| 448 | } |
| 449 | return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); |
| 450 | } |
| 451 | |
| 452 | export function decodeMergeRequestDetailJson( |
| 453 | raw: string, |
| 454 | ): Result.Result<GitLabMergeRequestDetail, DecodeFailure> { |
| 455 | const decoded = decodeMergeRequest(raw); |
| 456 | return Result.isSuccess(decoded) |
| 457 | ? Result.succeed(toDetail(decoded.success)) |
| 458 | : Result.fail(decoded.failure); |
| 459 | } |
| 460 | |
| 461 | export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> { |
| 462 | const decoded = decodeViewer(raw); |
| 463 | return Result.isSuccess(decoded) |
| 464 | ? Result.succeed(trimmed(decoded.success.username)) |
| 465 | : Result.fail(decoded.failure); |
| 466 | } |
| 467 | |
| 468 | /** |
| 469 | * The people with access to the project, which `GET /projects/:id/users` answers with — the same |
| 470 | * list GitLab's own reviewer field is filled from, including the members a group above the project |
| 471 | * lends it. A malformed row is skipped rather than failing the menu it belongs to. |
| 472 | * |
| 473 | * Nobody is marked requested here: who has been asked lives on the merge request, and only the |
| 474 | * caller holds both. |
| 475 | */ |
| 476 | export function decodeProjectUsersJson( |
| 477 | raw: string, |
| 478 | ): Result.Result<GitLabProjectUsers, DecodeFailure> { |
| 479 | const decoded = decodeUnknownList(raw); |
| 480 | if (!Result.isSuccess(decoded)) { |
| 481 | return Result.fail(decoded.failure); |
| 482 | } |
| 483 | const candidates: PullRequestReviewerCandidate[] = []; |
| 484 | for (const entry of decoded.success) { |
| 485 | const user = decodeUserEntry(entry); |
| 486 | if (Exit.isFailure(user) || user.value.id === undefined) continue; |
| 487 | const actor = toActor(user.value); |
| 488 | if (actor === null) continue; |
| 489 | candidates.push({ |
| 490 | ...actor, |
| 491 | id: String(user.value.id), |
| 492 | kind: "user", |
| 493 | isRequested: false, |
| 494 | }); |
| 495 | } |
| 496 | return Result.succeed({ candidates, rawCount: decoded.success.length }); |
| 497 | } |
| 498 | |
| 499 | /** |
| 500 | * GitLab settles the strategy per project rather than offering all three per merge request: |
| 501 | * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a |
| 502 | * separate switch. An unrecognized setting offers nothing rather than offering a strategy the |
| 503 | * project forbids. |
| 504 | */ |
| 505 | export function decodeProjectMergeCapabilitiesJson( |
| 506 | raw: string, |
| 507 | ): Result.Result<PullRequestMergeCapabilities, DecodeFailure> { |
| 508 | const decoded = decodeProjectMergeSettings(raw); |
| 509 | if (!Result.isSuccess(decoded)) { |
| 510 | return Result.fail(decoded.failure); |
| 511 | } |
| 512 | const mergeMethod = decoded.success.merge_method?.trim().toLowerCase(); |
| 513 | const squashOption = decoded.success.squash_option?.trim().toLowerCase(); |
| 514 | return Result.succeed({ |
| 515 | merge: mergeMethod === "merge", |
| 516 | // Both semi-linear and fast-forward histories are reached by rebasing onto the target. |
| 517 | rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff", |
| 518 | // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing, |
| 519 | // rather than offering a squash the project may forbid. |
| 520 | squash: |
| 521 | squashOption === "always" || squashOption === "default_on" || squashOption === "default_off", |
| 522 | }); |
| 523 | } |
| 524 | |
| 525 | /** |
| 526 | * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the |
| 527 | * root of a line-level discussion, which is what the review-comment kind means. |
| 528 | * |
| 529 | * The raw note count comes back alongside, because dropping notes hides whether the page was |
| 530 | * full: a caller cannot tell "no more notes" from "a page of activity entries" without it. |
| 531 | */ |
| 532 | /** The three revisions a positioned comment is written against. */ |
| 533 | export interface GitLabDiffRefs { |
| 534 | readonly baseSha: string; |
| 535 | readonly headSha: string; |
| 536 | readonly startSha: string; |
| 537 | } |
| 538 | |
| 539 | export interface GitLabDiscussions { |
| 540 | readonly threads: ReadonlyArray<PullRequestReviewThread>; |
| 541 | /** Discussions GitLab returned, counted before decoding, so a skipped one still counts. */ |
| 542 | readonly rawCount: number; |
| 543 | } |
| 544 | |
| 545 | /** |
| 546 | * Positioned discussions only. GitLab returns the merge request's whole conversation here, |
| 547 | * including the plain notes the timeline already shows, and only a positioned one belongs |
| 548 | * against a line of the diff. |
| 549 | */ |
| 550 | export function decodeDiscussionsJson( |
| 551 | raw: string, |
| 552 | ): Result.Result<GitLabDiscussions, DecodeFailure> { |
| 553 | const decoded = decodeUnknownList(raw); |
| 554 | if (!Result.isSuccess(decoded)) { |
| 555 | return Result.fail(decoded.failure); |
| 556 | } |
| 557 | const threads: PullRequestReviewThread[] = []; |
| 558 | for (const entry of decoded.success) { |
| 559 | const discussion = decodeDiscussionEntry(entry); |
| 560 | if (!Exit.isSuccess(discussion)) continue; |
| 561 | const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true); |
| 562 | const root = notes[0]; |
| 563 | const position = root?.position; |
| 564 | if (root === undefined || !position || position.position_type !== "text") continue; |
| 565 | // A comment on an added or context line carries `new_line`; one on a removed line carries |
| 566 | // only `old_line`, and belongs against the file as it was. |
| 567 | const side = position.new_line === null || position.new_line === undefined ? "left" : "right"; |
| 568 | const path = trimmed(side === "left" ? position.old_path : position.new_path); |
| 569 | const line = side === "left" ? position.old_line : position.new_line; |
| 570 | if (path === null) continue; |
| 571 | threads.push({ |
| 572 | id: discussion.value.id, |
| 573 | path, |
| 574 | line: typeof line === "number" && line > 0 ? line : null, |
| 575 | side, |
| 576 | isResolved: root.resolved === true, |
| 577 | // GitLab reports no equivalent of "written against a line that has since moved", so a |
| 578 | // thread the diff cannot place is worked out from the diff itself rather than claimed |
| 579 | // here. |
| 580 | isOutdated: false, |
| 581 | comments: notes.map((note) => ({ |
| 582 | id: String(note.id), |
| 583 | author: toActor(note.author), |
| 584 | body: note.body ?? "", |
| 585 | createdAt: note.created_at, |
| 586 | url: null, |
| 587 | })), |
| 588 | }); |
| 589 | } |
| 590 | return Result.succeed({ threads, rawCount: decoded.success.length }); |
| 591 | } |
| 592 | |
| 593 | export function decodeDiffRefsJson( |
| 594 | raw: string, |
| 595 | ): Result.Result<GitLabDiffRefs | null, DecodeFailure> { |
| 596 | const decoded = decodeDiffRefs(raw); |
| 597 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); |
| 598 | const refs = decoded.success.diff_refs; |
| 599 | return Result.succeed( |
| 600 | refs ? { baseSha: refs.base_sha, headSha: refs.head_sha, startSha: refs.start_sha } : null, |
| 601 | ); |
| 602 | } |
| 603 | |
| 604 | export function decodeNotesJson( |
| 605 | raw: string, |
| 606 | ): Result.Result< |
| 607 | { readonly comments: ReadonlyArray<PullRequestComment>; readonly rawCount: number }, |
| 608 | DecodeFailure |
| 609 | > { |
| 610 | const decoded = decodeUnknownList(raw); |
| 611 | if (!Result.isSuccess(decoded)) { |
| 612 | return Result.fail(decoded.failure); |
| 613 | } |
| 614 | const comments: PullRequestComment[] = []; |
| 615 | for (const entry of decoded.success) { |
| 616 | const note = decodeNoteEntry(entry); |
| 617 | if (Exit.isFailure(note)) continue; |
| 618 | const value = note.value; |
| 619 | if (value.system === true) continue; |
| 620 | const body = value.body ?? ""; |
| 621 | if (body.trim().length === 0) continue; |
| 622 | const isDiffNote = value.type?.trim() === "DiffNote"; |
| 623 | comments.push({ |
| 624 | id: String(value.id), |
| 625 | kind: isDiffNote ? "review-comment" : "issue-comment", |
| 626 | author: toActor(value.author), |
| 627 | body, |
| 628 | createdAt: value.created_at, |
| 629 | url: null, |
| 630 | path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path), |
| 631 | reviewState: null, |
| 632 | }); |
| 633 | } |
| 634 | return Result.succeed({ comments, rawCount: decoded.success.length }); |
| 635 | } |
| 636 | |
| 637 | export function decodeCommitsJson( |
| 638 | raw: string, |
| 639 | ): Result.Result<ReadonlyArray<PullRequestCommit>, DecodeFailure> { |
| 640 | const decoded = decodeUnknownList(raw); |
| 641 | if (!Result.isSuccess(decoded)) { |
| 642 | return Result.fail(decoded.failure); |
| 643 | } |
| 644 | const commits: PullRequestCommit[] = []; |
| 645 | for (const entry of decoded.success) { |
| 646 | const commit = decodeCommitEntry(entry); |
| 647 | if (Exit.isFailure(commit)) continue; |
| 648 | const committedDate = trimmed(commit.value.committed_date) ?? trimmed(commit.value.created_at); |
| 649 | if (committedDate === null) continue; |
| 650 | commits.push({ |
| 651 | oid: commit.value.id, |
| 652 | messageHeadline: commit.value.title ?? "", |
| 653 | committedDate, |
| 654 | ...(commit.value.stats === null || commit.value.stats === undefined |
| 655 | ? {} |
| 656 | : { |
| 657 | additions: Math.max(0, commit.value.stats.additions ?? 0), |
| 658 | deletions: Math.max(0, commit.value.stats.deletions ?? 0), |
| 659 | }), |
| 660 | authors: (() => { |
| 661 | const login = trimmed(commit.value.author_name) ?? trimmed(commit.value.author_email); |
| 662 | return login === null |
| 663 | ? [] |
| 664 | : [{ login, name: trimmed(commit.value.author_name), avatarUrl: null }]; |
| 665 | })(), |
| 666 | }); |
| 667 | } |
| 668 | // GitLab lists a merge request's commits newest first; the timeline reads oldest first. |
| 669 | return Result.succeed(commits.toReversed()); |
| 670 | } |
| 671 | |
| 672 | /** The exact comparison GitLab uses for a commit-scoped diff. */ |
| 673 | export function decodeCommitDiffRefsJson( |
| 674 | raw: string, |
| 675 | ): Result.Result<GitLabDiffRefs | null, DecodeFailure> { |
| 676 | const decoded = decodeCommit(raw); |
| 677 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); |
| 678 | const baseSha = trimmed(decoded.success.parent_ids?.[0]); |
| 679 | const headSha = trimmed(decoded.success.id); |
| 680 | return Result.succeed( |
| 681 | baseSha === null || headSha === null ? null : { baseSha, headSha, startSha: baseSha }, |
| 682 | ); |
| 683 | } |
| 684 | |
| 685 | function diffHeaderPaths(raw: Schema.Schema.Type<typeof RawDiffSchema>): { |
| 686 | readonly from: string; |
| 687 | readonly to: string; |
| 688 | } { |
| 689 | return { |
| 690 | from: raw.new_file === true ? "/dev/null" : quoteGitPatchPath(`a/${raw.old_path}`), |
| 691 | to: raw.deleted_file === true ? "/dev/null" : quoteGitPatchPath(`b/${raw.new_path}`), |
| 692 | }; |
| 693 | } |
| 694 | |
| 695 | export interface GitLabMergeRequestPatch { |
| 696 | readonly patch: string; |
| 697 | /** At least one file's hunks were withheld by GitLab as too large to inline. */ |
| 698 | readonly truncated: boolean; |
| 699 | /** Files GitLab returned, counted before decoding, so the caller can page. */ |
| 700 | readonly rawCount: number; |
| 701 | } |
| 702 | |
| 703 | /** |
| 704 | * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff |
| 705 | * viewer expects is assembled here. This decodes one page; walking pages is the caller's job, |
| 706 | * which is why the raw file count comes back with the patch. |
| 707 | */ |
| 708 | export function decodeMergeRequestDiffsJson( |
| 709 | raw: string, |
| 710 | ): Result.Result<GitLabMergeRequestPatch, DecodeFailure> { |
| 711 | const decoded = decodeUnknownList(raw); |
| 712 | if (!Result.isSuccess(decoded)) { |
| 713 | return Result.fail(decoded.failure); |
| 714 | } |
| 715 | const sections: string[] = []; |
| 716 | let truncated = false; |
| 717 | for (const entry of decoded.success) { |
| 718 | const file = decodeDiffEntry(entry); |
| 719 | if (Exit.isFailure(file)) continue; |
| 720 | const value = file.value; |
| 721 | const hunks = value.diff ?? ""; |
| 722 | if (hunks.length === 0) { |
| 723 | // A file GitLab declined to inline still belongs in the file list, header only. |
| 724 | truncated = truncated || value.too_large === true || value.collapsed === true; |
| 725 | } |
| 726 | const { from, to } = diffHeaderPaths(value); |
| 727 | const header = [ |
| 728 | `diff --git ${quoteGitPatchPath(`a/${value.old_path}`)} ${quoteGitPatchPath(`b/${value.new_path}`)}`, |
| 729 | ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []), |
| 730 | ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []), |
| 731 | ...(value.renamed_file === true |
| 732 | ? [ |
| 733 | `rename from ${quoteGitPatchPath(value.old_path)}`, |
| 734 | `rename to ${quoteGitPatchPath(value.new_path)}`, |
| 735 | ] |
| 736 | : []), |
| 737 | `--- ${from}`, |
| 738 | `+++ ${to}`, |
| 739 | ].join("\n"); |
| 740 | sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`); |
| 741 | } |
| 742 | return Result.succeed({ |
| 743 | patch: sections.join("\n"), |
| 744 | truncated, |
| 745 | rawCount: decoded.success.length, |
| 746 | }); |
| 747 | } |
| 748 | |
| 749 | /** GitLab's award names for the eight reactions the contract carries. */ |
| 750 | const GITLAB_AWARD_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = { |
| 751 | "thumbs-up": "thumbsup", |
| 752 | "thumbs-down": "thumbsdown", |
| 753 | laugh: "laughing", |
| 754 | hooray: "tada", |
| 755 | confused: "confused", |
| 756 | heart: "heart", |
| 757 | rocket: "rocket", |
| 758 | eyes: "eyes", |
| 759 | }; |
| 760 | |
| 761 | const CONTENT_BY_GITLAB_AWARD: Readonly<Record<string, PullRequestReactionContent>> = |
| 762 | Object.fromEntries( |
| 763 | Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]), |
| 764 | ) as Readonly<Record<string, PullRequestReactionContent>>; |
| 765 | |
| 766 | export function gitLabAwardName(content: PullRequestReactionContent): string { |
| 767 | return GITLAB_AWARD_BY_CONTENT[content]; |
| 768 | } |
| 769 | |
| 770 | /** |
| 771 | * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the |
| 772 | * conversation comes from carries no award at all, and asking per note would be a request each. |
| 773 | * |
| 774 | * `currentUser` rides along because GitLab names who awarded but never says whether that is the |
| 775 | * reader — so the comparison is made here rather than paid for with a request of its own. |
| 776 | */ |
| 777 | export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) { |
| 778 | currentUser { username } |
| 779 | project(fullPath: $fullPath) { |
| 780 | mergeRequest(iid: $iid) { |
| 781 | awardEmoji { nodes { name user { username } } } |
| 782 | notes(first: 100, after: $cursor) { |
| 783 | pageInfo { hasNextPage endCursor } |
| 784 | nodes { id awardEmoji { nodes { name user { username } } } } |
| 785 | } |
| 786 | } |
| 787 | } |
| 788 | }`; |
| 789 | |
| 790 | const RawAwardEmojiNodesSchema = Schema.optional( |
| 791 | Schema.NullOr( |
| 792 | Schema.Struct({ |
| 793 | nodes: Schema.optional( |
| 794 | Schema.NullOr( |
| 795 | Schema.Array( |
| 796 | Schema.NullOr( |
| 797 | Schema.Struct({ |
| 798 | name: Schema.optional(Schema.NullOr(Schema.String)), |
| 799 | user: Schema.optional( |
| 800 | Schema.NullOr( |
| 801 | Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) }), |
| 802 | ), |
| 803 | ), |
| 804 | }), |
| 805 | ), |
| 806 | ), |
| 807 | ), |
| 808 | ), |
| 809 | }), |
| 810 | ), |
| 811 | ); |
| 812 | |
| 813 | const RawAwardEmojiPageSchema = Schema.Struct({ |
| 814 | data: Schema.Struct({ |
| 815 | currentUser: Schema.optional( |
| 816 | Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), |
| 817 | ), |
| 818 | project: Schema.NullOr( |
| 819 | Schema.Struct({ |
| 820 | mergeRequest: Schema.NullOr( |
| 821 | Schema.Struct({ |
| 822 | awardEmoji: RawAwardEmojiNodesSchema, |
| 823 | notes: Schema.optional( |
| 824 | Schema.NullOr( |
| 825 | Schema.Struct({ |
| 826 | pageInfo: Schema.optional( |
| 827 | Schema.Struct({ |
| 828 | hasNextPage: Schema.optional(Schema.Boolean), |
| 829 | endCursor: Schema.optional(Schema.NullOr(Schema.String)), |
| 830 | }), |
| 831 | ), |
| 832 | nodes: Schema.Array( |
| 833 | Schema.NullOr( |
| 834 | Schema.Struct({ |
| 835 | id: Schema.optional(Schema.NullOr(Schema.String)), |
| 836 | awardEmoji: RawAwardEmojiNodesSchema, |
| 837 | }), |
| 838 | ), |
| 839 | ), |
| 840 | }), |
| 841 | ), |
| 842 | ), |
| 843 | }), |
| 844 | ), |
| 845 | }), |
| 846 | ), |
| 847 | }), |
| 848 | }); |
| 849 | |
| 850 | const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema); |
| 851 | |
| 852 | /** |
| 853 | * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username |
| 854 | * is left out of `actors` — the page names them "You" instead, and leaving it in would name them |
| 855 | * twice — but `count` still counts them along with everyone else. |
| 856 | */ |
| 857 | function toReactions( |
| 858 | nodes: Schema.Schema.Type<typeof RawAwardEmojiNodesSchema>, |
| 859 | viewer: string | null, |
| 860 | ): ReadonlyArray<PullRequestReaction> { |
| 861 | const normalizedViewer = viewer?.toLowerCase() ?? null; |
| 862 | const groups = new Map< |
| 863 | PullRequestReactionContent, |
| 864 | { count: number; actors: string[]; viewer: boolean } |
| 865 | >(); |
| 866 | for (const node of nodes?.nodes ?? []) { |
| 867 | // An award outside the eight is left out rather than shown under a name the picker has no |
| 868 | // way to take back: GitLab accepts any emoji, and the other hosts accept none of them. |
| 869 | const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""]; |
| 870 | if (content === undefined) continue; |
| 871 | const username = trimmed(node?.user?.username); |
| 872 | if (username === null) continue; |
| 873 | const group = groups.get(content) ?? { count: 0, actors: [], viewer: false }; |
| 874 | group.count++; |
| 875 | if (normalizedViewer !== null && username.toLowerCase() === normalizedViewer) { |
| 876 | group.viewer = true; |
| 877 | } else { |
| 878 | group.actors.push(username); |
| 879 | } |
| 880 | groups.set(content, group); |
| 881 | } |
| 882 | return [...groups].flatMap(([content, group]) => |
| 883 | group.count === 0 |
| 884 | ? [] |
| 885 | : [{ content, count: group.count, actors: group.actors, viewerHasReacted: group.viewer }], |
| 886 | ); |
| 887 | } |
| 888 | |
| 889 | /** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */ |
| 890 | function noteIdOf(gid: string | null | undefined): string | null { |
| 891 | const id = trimmed(gid)?.split("/").at(-1); |
| 892 | return id !== undefined && /^\d+$/.test(id) ? id : null; |
| 893 | } |
| 894 | |
| 895 | export interface GitLabAwardEmojiPage { |
| 896 | /** The merge request's own awards, which are the ones on its description. */ |
| 897 | readonly reactions: ReadonlyArray<PullRequestReaction>; |
| 898 | readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; |
| 899 | readonly nextCursor: string | null; |
| 900 | } |
| 901 | |
| 902 | export function decodeAwardEmojiJson( |
| 903 | raw: string, |
| 904 | ): Result.Result<GitLabAwardEmojiPage, DecodeFailure> { |
| 905 | const decoded = decodeAwardEmojiPage(raw); |
| 906 | if (!Result.isSuccess(decoded)) { |
| 907 | return Result.fail(decoded.failure); |
| 908 | } |
| 909 | const data = decoded.success.data; |
| 910 | const viewer = trimmed(data.currentUser?.username); |
| 911 | const mergeRequest = data.project?.mergeRequest; |
| 912 | const reactionsByNoteId = new Map<string, ReadonlyArray<PullRequestReaction>>(); |
| 913 | for (const node of mergeRequest?.notes?.nodes ?? []) { |
| 914 | const id = noteIdOf(node?.id); |
| 915 | if (id === null) continue; |
| 916 | const reactions = toReactions(node?.awardEmoji, viewer); |
| 917 | if (reactions.length > 0) reactionsByNoteId.set(id, reactions); |
| 918 | } |
| 919 | const pageInfo = mergeRequest?.notes?.pageInfo; |
| 920 | return Result.succeed({ |
| 921 | reactions: toReactions(mergeRequest?.awardEmoji, viewer), |
| 922 | reactionsByNoteId, |
| 923 | nextCursor: pageInfo?.hasNextPage === true ? (trimmed(pageInfo.endCursor) ?? null) : null, |
| 924 | }); |
| 925 | } |
| 926 | |
| 927 | const RawAwardSchema = Schema.Struct({ |
| 928 | id: Schema.Int, |
| 929 | name: Schema.optional(Schema.NullOr(Schema.String)), |
| 930 | user: Schema.optional( |
| 931 | Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), |
| 932 | ), |
| 933 | }); |
| 934 | |
| 935 | const decodeAward = Schema.decodeUnknownExit(RawAwardSchema); |
| 936 | |
| 937 | /** |
| 938 | * The reader's own award of one name on a subject, which is what taking a reaction back is |
| 939 | * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji. |
| 940 | */ |
| 941 | export function decodeOwnAwardIdJson( |
| 942 | raw: string, |
| 943 | input: { readonly content: PullRequestReactionContent; readonly viewer: string }, |
| 944 | ): Result.Result<number | null, DecodeFailure> { |
| 945 | const decoded = decodeUnknownList(raw); |
| 946 | if (!Result.isSuccess(decoded)) { |
| 947 | return Result.fail(decoded.failure); |
| 948 | } |
| 949 | const name = gitLabAwardName(input.content); |
| 950 | for (const entry of decoded.success) { |
| 951 | const award = decodeAward(entry); |
| 952 | if (Exit.isFailure(award)) continue; |
| 953 | const value = award.value; |
| 954 | if (trimmed(value.name)?.toLowerCase() !== name) continue; |
| 955 | if (trimmed(value.user?.username) !== input.viewer) continue; |
| 956 | return Result.succeed(value.id); |
| 957 | } |
| 958 | return Result.succeed(null); |
| 959 | } |
| 960 | |
| 961 | /** |
| 962 | * What the given paths are at one revision, as blob ids. Asked for by path rather than by walking |
| 963 | * the tree, since GitLab charges this query by how many paths it is given. A path the revision |
| 964 | * does not have comes back missing rather than as an error, which is the answer for a deleted file. |
| 965 | */ |
| 966 | export const REPOSITORY_BLOBS_GRAPHQL_QUERY = `query($fullPath: ID!, $ref: String!, $paths: [String!]!) { |
| 967 | project(fullPath: $fullPath) { |
| 968 | repository { |
| 969 | blobs(ref: $ref, paths: $paths) { |
| 970 | nodes { path oid } |
| 971 | } |
| 972 | } |
| 973 | } |
| 974 | }`; |
| 975 | |
| 976 | const RawRepositoryBlobsSchema = Schema.Struct({ |
| 977 | data: Schema.Struct({ |
| 978 | project: Schema.NullOr( |
| 979 | Schema.Struct({ |
| 980 | repository: Schema.optional( |
| 981 | Schema.NullOr( |
| 982 | Schema.Struct({ |
| 983 | blobs: Schema.optional( |
| 984 | Schema.NullOr( |
| 985 | Schema.Struct({ |
| 986 | nodes: Schema.optional( |
| 987 | Schema.NullOr( |
| 988 | Schema.Array( |
| 989 | Schema.NullOr( |
| 990 | Schema.Struct({ |
| 991 | path: Schema.optional(Schema.NullOr(Schema.String)), |
| 992 | oid: Schema.optional(Schema.NullOr(Schema.String)), |
| 993 | }), |
| 994 | ), |
| 995 | ), |
| 996 | ), |
| 997 | ), |
| 998 | }), |
| 999 | ), |
| 1000 | ), |
| 1001 | }), |
| 1002 | ), |
| 1003 | ), |
| 1004 | }), |
| 1005 | ), |
| 1006 | }), |
| 1007 | }); |
| 1008 | |
| 1009 | const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema); |
| 1010 | |
| 1011 | /** |
| 1012 | * Blob ids by path, or null where GitLab did not answer the query at all (a project the token |
| 1013 | * cannot see, or a repository with no blobs connection). That case must be told apart from an |
| 1014 | * empty answer: read as "the revision has none of these files", it would report every cleared |
| 1015 | * file as changed again. A node missing either half is left out, since the caller treats an |
| 1016 | * absent path as one the revision does not carry. |
| 1017 | */ |
| 1018 | export function decodeRepositoryBlobsJson( |
| 1019 | raw: string, |
| 1020 | ): Result.Result<ReadonlyMap<string, string> | null, DecodeFailure> { |
| 1021 | const decoded = decodeRepositoryBlobs(raw); |
| 1022 | if (!Result.isSuccess(decoded)) { |
| 1023 | return Result.fail(decoded.failure); |
| 1024 | } |
| 1025 | const nodes = decoded.success.data.project?.repository?.blobs?.nodes; |
| 1026 | if (nodes === undefined || nodes === null) return Result.succeed(null); |
| 1027 | const blobs = new Map<string, string>(); |
| 1028 | for (const node of nodes) { |
| 1029 | // Not trimmed, unlike everything else read out of this payload: a leading or trailing space |
| 1030 | // is a legal part of a file's name, and trimming it would key this map under a name the |
| 1031 | // caller's asked-for path never matches. |
| 1032 | const path = node?.path; |
| 1033 | const oid = trimmed(node?.oid); |
| 1034 | if (path === undefined || path === null || path.length === 0 || oid === null) continue; |
| 1035 | blobs.set(path, oid); |
| 1036 | } |
| 1037 | return Result.succeed(blobs); |
| 1038 | } |