| 1 | /** |
| 2 | * GitCafe's REST bodies, and their conversion into the neutral `Provider*` shapes. Every |
| 3 | * conversion takes the repository's host, so a staging repository's links stay on staging. |
| 4 | * |
| 5 | * @module source-control-gitcafe/server/gitCafePullRequestJson |
| 6 | */ |
| 7 | import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; |
| 8 | import * as DateTime from "effect/DateTime"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | import { |
| 11 | IsoDateTime, |
| 12 | NonNegativeInt, |
| 13 | PositiveInt, |
| 14 | TrimmedNonEmptyString, |
| 15 | type PullRequestActor, |
| 16 | type PullRequestCheck, |
| 17 | type PullRequestComment, |
| 18 | type PullRequestReaction, |
| 19 | type PullRequestReactionContent, |
| 20 | type PullRequestReviewThread, |
| 21 | type PullRequestViewerPermissions, |
| 22 | } from "@t3tools/contracts"; |
| 23 | import type { |
| 24 | ProviderChangeRequest, |
| 25 | ProviderChangeRequestActivity, |
| 26 | ProviderChangeRequestStack, |
| 27 | ProviderDiffSlice, |
| 28 | } from "@t3tools/source-control-core/server/PullRequestProvider"; |
| 29 | |
| 30 | const Actor = Schema.Union([ |
| 31 | Schema.Struct({ |
| 32 | kind: Schema.Literal("local"), |
| 33 | actorId: Schema.String, |
| 34 | handle: TrimmedNonEmptyString, |
| 35 | displayName: Schema.NullOr(Schema.String), |
| 36 | avatarUrl: Schema.NullOr(Schema.String), |
| 37 | }), |
| 38 | Schema.Struct({ |
| 39 | kind: Schema.Literal("github"), |
| 40 | actorId: Schema.String, |
| 41 | login: TrimmedNonEmptyString, |
| 42 | avatarUrl: Schema.NullOr(Schema.String), |
| 43 | linkedProfile: Schema.optional(Schema.NullOr(Schema.Struct({ handle: TrimmedNonEmptyString }))), |
| 44 | }), |
| 45 | Schema.Struct({ kind: Schema.Literal("unavailable"), actorId: Schema.String }), |
| 46 | ]); |
| 47 | type Actor = typeof Actor.Type; |
| 48 | |
| 49 | const PullState = Schema.Literals(["open", "closed", "merged"]); |
| 50 | |
| 51 | export const GitCafePull = Schema.Struct({ |
| 52 | id: TrimmedNonEmptyString, |
| 53 | number: PositiveInt, |
| 54 | title: TrimmedNonEmptyString, |
| 55 | state: PullState, |
| 56 | draft: Schema.Boolean, |
| 57 | sourceBranch: Schema.String, |
| 58 | targetBranch: Schema.String, |
| 59 | headOid: Schema.NullOr(Schema.String), |
| 60 | author: Actor, |
| 61 | createdAt: IsoDateTime, |
| 62 | updatedAt: IsoDateTime, |
| 63 | version: NonNegativeInt, |
| 64 | reviewers: Schema.optional(Schema.Array(Schema.Struct({ actor: Actor }))), |
| 65 | labels: Schema.optional( |
| 66 | Schema.Array( |
| 67 | Schema.Struct({ name: TrimmedNonEmptyString, color: Schema.NullOr(Schema.String) }), |
| 68 | ), |
| 69 | ), |
| 70 | }); |
| 71 | export type GitCafePull = typeof GitCafePull.Type; |
| 72 | |
| 73 | export const GitCafePulls = Schema.Struct({ |
| 74 | items: Schema.Array(GitCafePull), |
| 75 | next: Schema.NullOr(Schema.String), |
| 76 | }); |
| 77 | |
| 78 | export const GitCafePullDetail = Schema.Struct({ |
| 79 | ...GitCafePull.fields, |
| 80 | description: Schema.NullOr(Schema.String), |
| 81 | closedAt: Schema.NullOr(IsoDateTime), |
| 82 | mergedAt: Schema.NullOr(IsoDateTime), |
| 83 | sourceRepo: Schema.NullOr(Schema.Struct({ owner: Schema.String, name: Schema.String })), |
| 84 | /** The base GitCafe last compared the pull request against, which a provider merge fences on. */ |
| 85 | observedBaseOid: Schema.optional(Schema.NullOr(Schema.String)), |
| 86 | mergeRoute: Schema.optional(Schema.Literals(["native", "provider", "unsupported"])), |
| 87 | lockedAt: Schema.optional(Schema.NullOr(IsoDateTime)), |
| 88 | capabilities: Schema.Struct({ |
| 89 | comment: Schema.Boolean, |
| 90 | review: Schema.Boolean, |
| 91 | merge: Schema.Boolean, |
| 92 | edit: Schema.Boolean, |
| 93 | moderate: Schema.Boolean, |
| 94 | }), |
| 95 | }); |
| 96 | export type GitCafePullDetail = typeof GitCafePullDetail.Type; |
| 97 | |
| 98 | const CommentCapabilities = Schema.Struct({ |
| 99 | edit: Schema.Boolean, |
| 100 | hide: Schema.Boolean, |
| 101 | unhide: Schema.Boolean, |
| 102 | delete: Schema.Boolean, |
| 103 | resolve: Schema.Boolean, |
| 104 | unresolve: Schema.Boolean, |
| 105 | }); |
| 106 | |
| 107 | const GitCafeComment = Schema.Struct({ |
| 108 | id: TrimmedNonEmptyString, |
| 109 | threadId: TrimmedNonEmptyString, |
| 110 | /** What a resolution or rewrite fences on; absent from older answers. */ |
| 111 | version: Schema.optional(NonNegativeInt), |
| 112 | author: Actor, |
| 113 | body: Schema.NullOr(Schema.String), |
| 114 | createdAt: IsoDateTime, |
| 115 | path: Schema.NullOr(Schema.String), |
| 116 | // The API allows any integer here; only a positive line anchors a review thread. |
| 117 | line: Schema.NullOr(Schema.Int), |
| 118 | side: Schema.NullOr(Schema.Literals(["left", "right"])), |
| 119 | commitOid: Schema.NullOr(Schema.String), |
| 120 | resolvedAt: Schema.NullOr(IsoDateTime), |
| 121 | capabilities: Schema.optional(CommentCapabilities), |
| 122 | }); |
| 123 | type GitCafeComment = typeof GitCafeComment.Type; |
| 124 | |
| 125 | export const GitCafeComments = Schema.Struct({ |
| 126 | items: Schema.Array(GitCafeComment), |
| 127 | next: Schema.NullOr(Schema.String), |
| 128 | }); |
| 129 | |
| 130 | export const GitCafeReviews = Schema.Struct({ |
| 131 | items: Schema.Array( |
| 132 | Schema.Struct({ |
| 133 | id: TrimmedNonEmptyString, |
| 134 | author: Actor, |
| 135 | body: Schema.NullOr(Schema.String), |
| 136 | verdict: Schema.Literals(["approve", "request_changes", "comment"]), |
| 137 | dismissedAt: Schema.NullOr(IsoDateTime), |
| 138 | createdAt: IsoDateTime, |
| 139 | }), |
| 140 | ), |
| 141 | next: Schema.NullOr(Schema.String), |
| 142 | }); |
| 143 | |
| 144 | export const GitCafeCommits = Schema.Struct({ |
| 145 | items: Schema.Array( |
| 146 | Schema.Struct({ oid: TrimmedNonEmptyString, summary: Schema.String, time: Schema.Finite }), |
| 147 | ), |
| 148 | truncated: Schema.Boolean, |
| 149 | next: Schema.NullOr(Schema.String), |
| 150 | headOid: Schema.String, |
| 151 | }); |
| 152 | |
| 153 | export const GitCafeReactions = Schema.Struct({ |
| 154 | items: Schema.Array( |
| 155 | Schema.Struct({ |
| 156 | subject: Schema.Struct({ |
| 157 | kind: Schema.Literals(["issue", "issue_comment", "pull_request", "pull_request_comment"]), |
| 158 | id: TrimmedNonEmptyString, |
| 159 | }), |
| 160 | emoji: Schema.Union([ |
| 161 | Schema.Struct({ kind: Schema.Literal("unicode"), value: TrimmedNonEmptyString }), |
| 162 | Schema.Struct({ kind: Schema.Literal("custom"), id: TrimmedNonEmptyString }), |
| 163 | ]), |
| 164 | count: PositiveInt, |
| 165 | viewerReactionId: Schema.NullOr(Schema.String), |
| 166 | reactors: Schema.Array(Actor), |
| 167 | }), |
| 168 | ), |
| 169 | next: Schema.NullOr(Schema.String), |
| 170 | }); |
| 171 | |
| 172 | const GitCafeStack = Schema.Struct({ |
| 173 | id: TrimmedNonEmptyString, |
| 174 | number: PositiveInt, |
| 175 | /** What a land or restack fences on. */ |
| 176 | revision: PositiveInt, |
| 177 | landingBase: Schema.String, |
| 178 | members: Schema.Array( |
| 179 | Schema.Struct({ |
| 180 | pullRequestNumber: PositiveInt, |
| 181 | title: Schema.String, |
| 182 | state: PullState, |
| 183 | draft: Schema.Boolean, |
| 184 | sourceBranch: Schema.String, |
| 185 | headOid: Schema.optional(Schema.String), |
| 186 | position: PositiveInt, |
| 187 | }), |
| 188 | ), |
| 189 | }); |
| 190 | export type GitCafeStack = typeof GitCafeStack.Type; |
| 191 | |
| 192 | export const GitCafeStackEnvelope = Schema.Struct({ stack: Schema.NullOr(GitCafeStack) }); |
| 193 | |
| 194 | const GitCafeDiffFile = Schema.Struct({ |
| 195 | path: TrimmedNonEmptyString, |
| 196 | oldPath: Schema.optional(Schema.NullOr(Schema.String)), |
| 197 | status: Schema.optional( |
| 198 | Schema.Literals([ |
| 199 | "added", |
| 200 | "deleted", |
| 201 | "modified", |
| 202 | "typeChanged", |
| 203 | "conflicted", |
| 204 | "renamed", |
| 205 | "copied", |
| 206 | ]), |
| 207 | ), |
| 208 | additions: Schema.optional(NonNegativeInt), |
| 209 | deletions: Schema.optional(NonNegativeInt), |
| 210 | binary: Schema.optional(Schema.Boolean), |
| 211 | tooLarge: Schema.optional(Schema.Boolean), |
| 212 | hunksOmitted: Schema.optional(Schema.Boolean), |
| 213 | isSubmodule: Schema.optional(Schema.Boolean), |
| 214 | oldOid: Schema.optional(Schema.String), |
| 215 | newOid: Schema.optional(Schema.String), |
| 216 | // Structural pages (`/changes`, `/compare`) list files without hunks; the batched reads fill them. |
| 217 | hunks: Schema.optional( |
| 218 | Schema.Array( |
| 219 | Schema.Struct({ |
| 220 | oldStart: NonNegativeInt, |
| 221 | oldLines: NonNegativeInt, |
| 222 | newStart: NonNegativeInt, |
| 223 | newLines: NonNegativeInt, |
| 224 | lines: Schema.Array(Schema.Struct({ origin: Schema.String, content: Schema.String })), |
| 225 | }), |
| 226 | ), |
| 227 | ), |
| 228 | }); |
| 229 | export type GitCafeDiffFile = typeof GitCafeDiffFile.Type; |
| 230 | export const GitCafeDiffFiles = Schema.Array(GitCafeDiffFile); |
| 231 | |
| 232 | export const GitCafeDiff = Schema.Struct({ |
| 233 | items: GitCafeDiffFiles, |
| 234 | truncated: Schema.optional(Schema.Boolean), |
| 235 | }); |
| 236 | |
| 237 | export const GitCafeChecks = Schema.Struct({ |
| 238 | items: Schema.Array( |
| 239 | Schema.Struct({ |
| 240 | name: TrimmedNonEmptyString, |
| 241 | status: Schema.Literals(["queued", "in_progress", "completed"]), |
| 242 | conclusion: Schema.NullOr(Schema.String), |
| 243 | summary: Schema.NullOr(Schema.String), |
| 244 | detailsUrl: Schema.NullOr(Schema.String), |
| 245 | }), |
| 246 | ), |
| 247 | next: Schema.NullOr(Schema.String), |
| 248 | }); |
| 249 | |
| 250 | interface RepositoryOnHost { |
| 251 | readonly repository: string; |
| 252 | readonly host: string; |
| 253 | } |
| 254 | |
| 255 | function toActor(actor: Actor, host: string): PullRequestActor | null { |
| 256 | if (actor.kind === "unavailable") return null; |
| 257 | return { |
| 258 | login: actor.kind === "local" ? actor.handle : actor.login, |
| 259 | name: actor.kind === "local" ? actor.displayName : null, |
| 260 | // GitCafe serves its own avatars by path, so a relative one is read against the host. |
| 261 | avatarUrl: actor.avatarUrl === null ? null : new URL(actor.avatarUrl, `https://${host}`).href, |
| 262 | }; |
| 263 | } |
| 264 | |
| 265 | /** The reviewers GitCafe has asked, as the detail's reviewer list shows them. */ |
| 266 | export function toReviewers( |
| 267 | pull: Pick<GitCafePull, "reviewers">, |
| 268 | host: string, |
| 269 | ): ReadonlyArray<PullRequestActor> { |
| 270 | return (pull.reviewers ?? []).flatMap(({ actor }) => { |
| 271 | const reviewer = toActor(actor, host); |
| 272 | return reviewer === null ? [] : [reviewer]; |
| 273 | }); |
| 274 | } |
| 275 | |
| 276 | /** A linked GitHub reviewer answers to both logins, so either one finds the review request. */ |
| 277 | function reviewerLogins(actor: Actor, host: string): ReadonlyArray<string> { |
| 278 | const reviewer = toActor(actor, host); |
| 279 | if (reviewer === null) return []; |
| 280 | return actor.kind === "github" && actor.linkedProfile |
| 281 | ? [reviewer.login, actor.linkedProfile.handle] |
| 282 | : [reviewer.login]; |
| 283 | } |
| 284 | |
| 285 | export function toChangeRequest( |
| 286 | pull: GitCafePull, |
| 287 | target: RepositoryOnHost, |
| 288 | ): ProviderChangeRequest { |
| 289 | return { |
| 290 | number: pull.number, |
| 291 | title: pull.title, |
| 292 | url: `https://${target.host}/${target.repository}/pulls/${pull.number}`, |
| 293 | author: toActor(pull.author, target.host), |
| 294 | headBranch: pull.sourceBranch, |
| 295 | baseBranch: pull.targetBranch, |
| 296 | state: pull.state, |
| 297 | isDraft: pull.draft, |
| 298 | mergeability: "unknown", |
| 299 | additions: 0, |
| 300 | deletions: 0, |
| 301 | createdAt: pull.createdAt, |
| 302 | updatedAt: pull.updatedAt, |
| 303 | reviewRequestLogins: (pull.reviewers ?? []).flatMap(({ actor }) => |
| 304 | reviewerLogins(actor, target.host), |
| 305 | ), |
| 306 | labels: pull.labels ?? [], |
| 307 | }; |
| 308 | } |
| 309 | |
| 310 | /** What GitCafe's own capabilities let this viewer do with the pull request. */ |
| 311 | export function toViewerPermissions(detail: GitCafePullDetail): PullRequestViewerPermissions { |
| 312 | const { comment, edit, review, merge, moderate } = detail.capabilities; |
| 313 | // A locked conversation keeps its composer for moderators only. |
| 314 | const open = comment && (detail.lockedAt == null || moderate); |
| 315 | return { |
| 316 | actions: [ |
| 317 | // A merged pull request is settled; a closed one can only be reopened. |
| 318 | ...(edit && detail.state === "open" |
| 319 | ? ([detail.draft ? "ready" : "draft", "close"] as const) |
| 320 | : edit && detail.state === "closed" |
| 321 | ? (["reopen"] as const) |
| 322 | : []), |
| 323 | ...(merge && detail.state === "open" && !detail.draft ? (["merge"] as const) : []), |
| 324 | ], |
| 325 | stackRebase: merge, |
| 326 | comment: open, |
| 327 | // Each thread's root narrows this further with its own `canResolve`. |
| 328 | resolve: open, |
| 329 | verdicts: review ? ["approve", "request-changes", "comment"] : [], |
| 330 | requestReviewers: false, |
| 331 | editChangeRequest: edit, |
| 332 | // GitCafe's only branch update moves a whole stack. |
| 333 | updateMethods: [], |
| 334 | }; |
| 335 | } |
| 336 | |
| 337 | const REACTIONS: Readonly<Record<string, PullRequestReactionContent>> = { |
| 338 | "👍": "thumbs-up", |
| 339 | "👎": "thumbs-down", |
| 340 | "😄": "laugh", |
| 341 | "🎉": "hooray", |
| 342 | "😕": "confused", |
| 343 | "❤": "heart", |
| 344 | "🚀": "rocket", |
| 345 | "👀": "eyes", |
| 346 | }; |
| 347 | |
| 348 | /** The emoji GitCafe takes for one of the shared reactions. */ |
| 349 | export const REACTION_EMOJI: Readonly<Record<PullRequestReactionContent, string>> = { |
| 350 | "thumbs-up": "👍", |
| 351 | "thumbs-down": "👎", |
| 352 | laugh: "😄", |
| 353 | hooray: "🎉", |
| 354 | confused: "😕", |
| 355 | heart: "❤️", |
| 356 | rocket: "🚀", |
| 357 | eyes: "👀", |
| 358 | }; |
| 359 | |
| 360 | /** The shared reaction an emoji stands for; custom and unlisted ones stand for none. */ |
| 361 | export function reactionContent(emoji: string): PullRequestReactionContent | undefined { |
| 362 | return REACTIONS[emoji.replaceAll("\uFE0F", "")]; |
| 363 | } |
| 364 | |
| 365 | function toReactions(data: typeof GitCafeReactions.Type, host: string) { |
| 366 | const reactions: Array<PullRequestReaction> = []; |
| 367 | const byComment = new Map<string, Array<PullRequestReaction>>(); |
| 368 | for (const item of data.items) { |
| 369 | const content = item.emoji.kind === "unicode" ? reactionContent(item.emoji.value) : undefined; |
| 370 | if (content === undefined) continue; |
| 371 | const reaction: PullRequestReaction = { |
| 372 | content, |
| 373 | count: item.count, |
| 374 | actors: item.reactors.flatMap((actor) => { |
| 375 | const reactor = toActor(actor, host); |
| 376 | return reactor === null ? [] : [reactor.login]; |
| 377 | }), |
| 378 | viewerHasReacted: item.viewerReactionId !== null, |
| 379 | }; |
| 380 | if (item.subject.kind === "pull_request") reactions.push(reaction); |
| 381 | else if (item.subject.kind === "pull_request_comment") |
| 382 | byComment.set(item.subject.id, [...(byComment.get(item.subject.id) ?? []), reaction]); |
| 383 | } |
| 384 | return { reactions, byComment }; |
| 385 | } |
| 386 | |
| 387 | const REVIEW_STATES = { |
| 388 | approve: "approved", |
| 389 | request_changes: "changes_requested", |
| 390 | comment: "commented", |
| 391 | } as const; |
| 392 | |
| 393 | /** GitCafe persists a thread's identity apart from its line, so threads group by `threadId`. */ |
| 394 | export function toActivity(input: { |
| 395 | readonly comments: typeof GitCafeComments.Type; |
| 396 | readonly reviews: typeof GitCafeReviews.Type; |
| 397 | readonly commits: typeof GitCafeCommits.Type; |
| 398 | readonly reactions?: typeof GitCafeReactions.Type; |
| 399 | readonly headOid?: string; |
| 400 | readonly host: string; |
| 401 | }): ProviderChangeRequestActivity { |
| 402 | const { host } = input; |
| 403 | const reactions = input.reactions === undefined ? undefined : toReactions(input.reactions, host); |
| 404 | const remark = (comment: GitCafeComment) => ({ |
| 405 | id: comment.id, |
| 406 | author: toActor(comment.author, host), |
| 407 | body: comment.body ?? "", |
| 408 | createdAt: comment.createdAt, |
| 409 | url: null, |
| 410 | ...(comment.capabilities === undefined ? {} : { canEdit: comment.capabilities.edit }), |
| 411 | ...(reactions === undefined ? {} : { reactions: reactions.byComment.get(comment.id) ?? [] }), |
| 412 | }); |
| 413 | // Stable, so remarks written in the same instant keep GitCafe's own order. |
| 414 | const ordered = input.comments.items.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); |
| 415 | const groups = new Map<string, Array<GitCafeComment>>(); |
| 416 | for (const comment of ordered) { |
| 417 | if (!comment.path || comment.line === null || comment.line < 1 || comment.side === null) |
| 418 | continue; |
| 419 | groups.set(comment.threadId, [...(groups.get(comment.threadId) ?? []), comment]); |
| 420 | } |
| 421 | const reviewThreads = [...groups.values()].map((group): PullRequestReviewThread => { |
| 422 | const first = group[0]!; |
| 423 | const latest = group.at(-1)!; |
| 424 | const root = group.find((comment) => comment.id === comment.threadId); |
| 425 | return { |
| 426 | id: first.threadId, |
| 427 | path: first.path!, |
| 428 | line: first.line, |
| 429 | side: first.side!, |
| 430 | isResolved: root?.resolvedAt != null, |
| 431 | isOutdated: |
| 432 | input.headOid !== undefined && |
| 433 | latest.commitOid !== null && |
| 434 | latest.commitOid !== input.headOid, |
| 435 | comments: group.map(remark), |
| 436 | ...(root?.capabilities === undefined |
| 437 | ? {} |
| 438 | : { |
| 439 | canResolve: |
| 440 | root.resolvedAt === null ? root.capabilities.resolve : root.capabilities.unresolve, |
| 441 | }), |
| 442 | }; |
| 443 | }); |
| 444 | const comments: Array<PullRequestComment> = [ |
| 445 | ...ordered.map((comment) => ({ |
| 446 | ...remark(comment), |
| 447 | kind: comment.path === null ? ("issue-comment" as const) : ("review-comment" as const), |
| 448 | path: comment.path, |
| 449 | reviewState: null, |
| 450 | })), |
| 451 | ...input.reviews.items.map((review) => ({ |
| 452 | id: review.id, |
| 453 | kind: "review" as const, |
| 454 | author: toActor(review.author, host), |
| 455 | body: review.body ?? "", |
| 456 | createdAt: review.createdAt, |
| 457 | url: null, |
| 458 | path: null, |
| 459 | reviewState: review.dismissedAt !== null ? "dismissed" : REVIEW_STATES[review.verdict], |
| 460 | })), |
| 461 | ]; |
| 462 | return { |
| 463 | comments: comments.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)), |
| 464 | commentCount: input.comments.items.length + input.reviews.items.length, |
| 465 | commentsTruncated: input.comments.next !== null || input.reviews.next !== null, |
| 466 | reviewThreads, |
| 467 | commits: input.commits.items.map((commit) => ({ |
| 468 | oid: commit.oid, |
| 469 | messageHeadline: commit.summary, |
| 470 | committedDate: DateTime.formatIso(DateTime.makeUnsafe(commit.time * 1_000)), |
| 471 | })), |
| 472 | ...(reactions === undefined ? {} : { reactions: reactions.reactions }), |
| 473 | }; |
| 474 | } |
| 475 | |
| 476 | export function toStack(stack: GitCafeStack, target: RepositoryOnHost): ProviderChangeRequestStack { |
| 477 | return { |
| 478 | id: stack.id, |
| 479 | number: stack.number, |
| 480 | url: `https://${target.host}/${target.repository}/stacks/${stack.number}`, |
| 481 | base: stack.landingBase, |
| 482 | layers: stack.members |
| 483 | .toSorted((a, b) => a.position - b.position) |
| 484 | .map((member) => ({ |
| 485 | number: member.pullRequestNumber, |
| 486 | title: member.title, |
| 487 | headBranch: member.sourceBranch, |
| 488 | state: member.state, |
| 489 | isDraft: member.draft, |
| 490 | ...(member.headOid === undefined ? {} : { headSha: member.headOid }), |
| 491 | })), |
| 492 | }; |
| 493 | } |
| 494 | |
| 495 | /** Converts GitCafe's structured hunks into a unified patch, keeping omitted files' counts. */ |
| 496 | export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice { |
| 497 | const chunks: Array<string> = []; |
| 498 | const omittedFileStats: Array<{ path: string; additions: number; deletions: number }> = []; |
| 499 | for (const file of diff.items) { |
| 500 | const oldPath = file.oldPath ?? file.path; |
| 501 | const a = quoteGitPatchPath(`a/${oldPath}`); |
| 502 | const b = quoteGitPatchPath(`b/${file.path}`); |
| 503 | const before = file.status === "added" ? "/dev/null" : a; |
| 504 | const after = file.status === "deleted" ? "/dev/null" : b; |
| 505 | const mode = file.isSubmodule ? "160000" : "100644"; |
| 506 | const lines = [`diff --git ${a} ${b}`]; |
| 507 | if (file.status === "added") lines.push(`new file mode ${mode}`); |
| 508 | if (file.status === "deleted") lines.push(`deleted file mode ${mode}`); |
| 509 | if (file.status === "renamed" || file.status === "copied") { |
| 510 | const action = file.status === "renamed" ? "rename" : "copy"; |
| 511 | lines.push( |
| 512 | `${action} from ${quoteGitPatchPath(oldPath)}`, |
| 513 | `${action} to ${quoteGitPatchPath(file.path)}`, |
| 514 | ); |
| 515 | } |
| 516 | if (file.binary) { |
| 517 | lines.push(`Binary files ${before} and ${after} differ`); |
| 518 | } else if (file.tooLarge || file.hunksOmitted) { |
| 519 | omittedFileStats.push({ |
| 520 | path: file.path, |
| 521 | additions: file.additions ?? 0, |
| 522 | deletions: file.deletions ?? 0, |
| 523 | }); |
| 524 | } else if (file.isSubmodule) { |
| 525 | const hasOld = file.status !== "added" && file.oldOid !== undefined; |
| 526 | const hasNew = file.status !== "deleted" && file.newOid !== undefined; |
| 527 | lines.push(`--- ${before}`, `+++ ${after}`); |
| 528 | lines.push(`@@ -${hasOld ? "1" : "0,0"} +${hasNew ? "1" : "0,0"} @@`); |
| 529 | if (hasOld) lines.push(`-Subproject commit ${file.oldOid}`); |
| 530 | if (hasNew) lines.push(`+Subproject commit ${file.newOid}`); |
| 531 | } else { |
| 532 | const hunks = file.hunks ?? []; |
| 533 | if (hunks.length > 0) lines.push(`--- ${before}`, `+++ ${after}`); |
| 534 | for (const hunk of hunks) { |
| 535 | lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); |
| 536 | for (const line of hunk.lines) { |
| 537 | // GitCafe marks a missing final newline with libgit2's own origins. |
| 538 | if (["=", ">", "<"].includes(line.origin)) lines.push("\\ No newline at end of file"); |
| 539 | else lines.push(`${line.origin}${line.content.replace(/\n$/u, "")}`); |
| 540 | } |
| 541 | } |
| 542 | } |
| 543 | chunks.push(lines.join("\n")); |
| 544 | } |
| 545 | return { |
| 546 | patch: chunks.length === 0 ? "" : `${chunks.join("\n")}\n`, |
| 547 | truncated: diff.truncated === true || omittedFileStats.length > 0, |
| 548 | nextCursor: null, |
| 549 | ...(omittedFileStats.length === 0 ? {} : { omittedFileStats }), |
| 550 | }; |
| 551 | } |
| 552 | |
| 553 | function checkStatus(check: (typeof GitCafeChecks.Type.items)[number]): PullRequestCheck["status"] { |
| 554 | if (check.status !== "completed") return "pending"; |
| 555 | switch (check.conclusion) { |
| 556 | case "success": |
| 557 | return "success"; |
| 558 | case "neutral": |
| 559 | return "neutral"; |
| 560 | case "skipped": |
| 561 | return "skipped"; |
| 562 | case "cancelled": |
| 563 | return "cancelled"; |
| 564 | case "action_required": |
| 565 | return "action-required"; |
| 566 | default: |
| 567 | return "failure"; |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | export function toChecks(checks: typeof GitCafeChecks.Type): ReadonlyArray<PullRequestCheck> { |
| 572 | return checks.items.map((check) => ({ |
| 573 | name: check.name, |
| 574 | status: checkStatus(check), |
| 575 | description: check.summary, |
| 576 | url: check.detailsUrl, |
| 577 | })); |
| 578 | } |