| 1 | import type { Readable } from 'node:stream'; |
| 2 | |
| 3 | import { type Database, |
| 4 | import { Inject, Injectable |
| 5 | import { ConfigService } |
| 6 | import { and, count |
| 7 | |
| 8 | import { DATABASE } |
| 9 | import { orNull } |
| 10 | import { |
| 11 | GithubConflictError, |
| 12 | GithubNotFoundError, |
| 13 | NotMergeableError, |
| 14 | UnprocessableError, |
| 15 | } from '../../../lib/github/github.errors.js'; |
| 16 | import { encodeNodeId } |
| 17 | import { |
| 18 | pullRequestRowsOf, |
| 19 | reviewsOf, |
| 20 | usersByEmailOf, |
| 21 | } from '../../../lib/github/node-lookup.js'; |
| 22 | import { githubOrigins } |
| 23 | import { |
| 24 | type PullRestDetail, |
| 25 | pageOf, |
| 26 | RestPage, |
| 27 | type RestRepositoryObject, |
| 28 | toPullRest, |
| 29 | toRepositoryRest, |
| 30 | toReviewRest, |
| 31 | toUserRest, |
| 32 | } from '../../../lib/github/rest.js'; |
| 33 | import { |
| 34 | NothingToMergeError, |
| 35 | PullRequestConflictError, |
| 36 | PullRequestDraftError, |
| 37 | PullRequestNotOpenError, |
| 38 | } from '../../../lib/pull-requests/pull-requests.errors.js'; |
| 39 | import type { IssueDTO |
| 40 | import { IssuesService } |
| 41 | import { PullRequestsService } |
| 42 | import type { |
| 43 | GithubRepositoryTarget, |
| 44 | GithubViewer, |
| 45 | } from '../../auth/github-request.js'; |
| 46 | import { RepositoryNodesService } |
| 47 | import type { RepositoryNode |
| 48 | import type { GithubPageQueryDTO |
| 49 | import { GithubIssuesService } |
| 50 | import type { |
| 51 | GithubCreatePullDTO, |
| 52 | GithubListPullsQueryDTO, |
| 53 | GithubMergePullDTO, |
| 54 | GithubUpdatePullDTO, |
| 55 | } from './dto/pull.dto.js'; |
| 56 | |
| 57 | // ponytail: a pull request's commits are listed in one page of this many, as GitHub caps the route at 250. |
| 58 | const MAX_COMMITS = 250 |
| 59 | |
| 60 | const FILE_STATUS: Record |
| 61 | A: 'added', |
| 62 | D: 'removed', |
| 63 | M: 'modified', |
| 64 | T: 'changed', |
| 65 | }; |
| 66 | |
| 67 | /** GitHub's REST pull requests over IssuesService and PullRequestsService: an issue's fields plus its pull_request row, branches and repositories. */ |
| 68 | @Injectable() |
| 69 | export class GithubPullsService |
| 70 | constructor( |
| 71 | @Inject(DATABASE) |
| 72 | private readonly github |
| 73 | private readonly issues |
| 74 | private readonly pulls |
| 75 | private readonly repositories |
| 76 | private readonly config |
| 77 | ) {} |
| 78 | |
| 79 | /** `head` is GitHub's `owner:branch`, or a bare branch from any owner. */ |
| 80 | async listPulls( |
| 81 | { owner, repo |
| 82 | query: GithubListPullsQueryDTO |
| 83 | ) { |
| 84 | const repository = |
| 85 | const page = |
| 86 | const state = |
| 87 | const colon = |
| 88 | const list = |
| 89 | { |
| 90 | ...this.github. |
| 91 | query: { |
| 92 | state: 'all', |
| 93 | sort: query.sort |
| 94 | direction: query.direction |
| 95 | limit: page.perPage |
| 96 | }, |
| 97 | }, |
| 98 | { |
| 99 | offset: page.offset |
| 100 | pullRequests: { |
| 101 | states: |
| 102 | state === 'open' |
| 103 | ? ['open'] |
| 104 | : state === ' |
| 105 | ? ['closed', ' |
| 106 | : undefined, |
| 107 | ...(query.head |
| 108 | (colon < 0 |
| 109 | ? { headRef: query |
| 110 | : { |
| 111 | headOwner: query.head |
| 112 | headRef: query.head |
| 113 | })), |
| 114 | baseRef: query.base |
| 115 | }, |
| 116 | }, |
| 117 | ); |
| 118 | return new RestPage |
| 119 | await this.rest |
| 120 | page, |
| 121 | list.total, |
| 122 | ); |
| 123 | } |
| 124 | |
| 125 | async createPull( |
| 126 | { owner, repo |
| 127 | body: GithubCreatePullDTO |
| 128 | ) { |
| 129 | const { repository, |
| 130 | owner, |
| 131 | repo, |
| 132 | viewer, |
| 133 | 'POST /repos/:owner/:repo/pulls', |
| 134 | ); |
| 135 | const forkOwner = |
| 136 | const created = |
| 137 | username: repository.ownerLogin |
| 138 | repo: repository.slug |
| 139 | requesterId: signedIn.userId |
| 140 | body: { |
| 141 | title: body.title |
| 142 | body: body.body |
| 143 | base: body.base |
| 144 | head: |
| 145 | forkOwner && !body |
| 146 | ? `${forkOwner}: |
| 147 | : body.head, |
| 148 | draft: body.draft |
| 149 | }, |
| 150 | }); |
| 151 | return this.detailed |
| 152 | } |
| 153 | |
| 154 | async getPull( |
| 155 | { owner, repo |
| 156 | number: number, |
| 157 | ) { |
| 158 | const repository = |
| 159 | return this.detailed |
| 160 | } |
| 161 | |
| 162 | /** The diff as text, or with `mailbox` one mailbox-style patch per commit, as `gh pr diff` reads it. */ |
| 163 | async diff( |
| 164 | { owner, repo |
| 165 | number: number, |
| 166 | mailbox: boolean, |
| 167 | ): Promise<Readable |
| 168 | const repository = |
| 169 | await this.issue |
| 170 | return this.pulls |
| 171 | ...this.github. |
| 172 | number, |
| 173 | mailbox, |
| 174 | }); |
| 175 | } |
| 176 | |
| 177 | async updatePull( |
| 178 | { owner, repo |
| 179 | number: number, |
| 180 | body: GithubUpdatePullDTO |
| 181 | ) { |
| 182 | const { repository, |
| 183 | owner, |
| 184 | repo, |
| 185 | viewer, |
| 186 | 'PATCH /repos/:owner/:repo/pulls/:number', |
| 187 | ); |
| 188 | const ref = |
| 189 | ...this.github. |
| 190 | number, |
| 191 | requesterId: signedIn.userId |
| 192 | }; |
| 193 | const current = |
| 194 | if (body.base |
| 195 | throw new UnprocessableError( |
| 196 | 'Ghost cannot change the base branch of a pull request', |
| 197 | ); |
| 198 | if (body.title |
| 199 | await this.pulls |
| 200 | ...ref, |
| 201 | body: { title: body |
| 202 | }); |
| 203 | // Setting the state a request already has is a no-op on GitHub; Ghost refuses it. |
| 204 | const state = |
| 205 | if (body.state |
| 206 | await this.pulls |
| 207 | if (body.state |
| 208 | await this.pulls |
| 209 | if (body.state |
| 210 | throw new UnprocessableError( |
| 211 | 'state cannot be changed. The pull request cannot be reopened.', |
| 212 | ); |
| 213 | return this.detailed |
| 214 | } |
| 215 | |
| 216 | async commits( |
| 217 | { owner, repo |
| 218 | number: number, |
| 219 | query: GithubPageQueryDTO |
| 220 | ) { |
| 221 | const repository = |
| 222 | await this.issue |
| 223 | const page = |
| 224 | const { commits, |
| 225 | ...this.github. |
| 226 | number, |
| 227 | limit: MAX_COMMITS, |
| 228 | oldestFirst: true, |
| 229 | }); |
| 230 | const users = |
| 231 | commits.flatMap((commit |
| 232 | ); |
| 233 | const userOf = |
| 234 | const apiUrl = |
| 235 | return new RestPage |
| 236 | commits.slice(page |
| 237 | sha: commit.sha |
| 238 | node_id: encodeNodeId('Commit |
| 239 | commit: { |
| 240 | author: { |
| 241 | name: commit.authorName |
| 242 | email: commit.authorEmail |
| 243 | date: commit.authoredAt |
| 244 | }, |
| 245 | committer: { |
| 246 | name: commit.committerName |
| 247 | email: commit.committerEmail |
| 248 | date: commit.committedAt |
| 249 | }, |
| 250 | message: [commit.subject |
| 251 | url: `${apiUrl} |
| 252 | comment_count: 0, |
| 253 | verification: { |
| 254 | verified: commit.verification |
| 255 | reason: commit.verification |
| 256 | ? 'valid' |
| 257 | : commit.verification?. |
| 258 | ? 'unknown_key' |
| 259 | : 'unsigned', |
| 260 | signature: null, |
| 261 | payload: null, |
| 262 | verified_at: null, |
| 263 | }, |
| 264 | }, |
| 265 | url: `${apiUrl} |
| 266 | html_url: `${repository. |
| 267 | comments_url: `${apiUrl} |
| 268 | author: userOf(commit |
| 269 | committer: userOf(commit |
| 270 | })), |
| 271 | page, |
| 272 | Math.min(total |
| 273 | ); |
| 274 | } |
| 275 | |
| 276 | async reviews( |
| 277 | { owner, repo |
| 278 | number: number, |
| 279 | query: GithubPageQueryDTO |
| 280 | ) { |
| 281 | const repository = |
| 282 | const page = |
| 283 | const reviews = |
| 284 | return new RestPage |
| 285 | reviews.slice(page |
| 286 | page, |
| 287 | reviews.length, |
| 288 | ); |
| 289 | } |
| 290 | |
| 291 | async review( |
| 292 | { owner, repo |
| 293 | number: number, |
| 294 | reviewId: number, |
| 295 | ) { |
| 296 | const repository = |
| 297 | const review = |
| 298 | (candidate) => |
| 299 | ); |
| 300 | if (!review) |
| 301 | return review; |
| 302 | } |
| 303 | |
| 304 | async files( |
| 305 | { owner, repo |
| 306 | number: number, |
| 307 | query: GithubPageQueryDTO |
| 308 | ) { |
| 309 | const repository = |
| 310 | await this.issue |
| 311 | const page = |
| 312 | const { to, |
| 313 | ...this.github. |
| 314 | number, |
| 315 | }); |
| 316 | return new RestPage |
| 317 | files.slice(page |
| 318 | filename: file.path |
| 319 | status: FILE_STATUS[file |
| 320 | additions: file.additions |
| 321 | deletions: file.deletions |
| 322 | changes: file.additions |
| 323 | blob_url: `${repository. |
| 324 | raw_url: `${repository. |
| 325 | })), |
| 326 | page, |
| 327 | files.length, |
| 328 | ); |
| 329 | } |
| 330 | |
| 331 | /** Resolves when merged; 404 otherwise, as GitHub answers. */ |
| 332 | async merged( |
| 333 | { owner, repo |
| 334 | number: number, |
| 335 | ) { |
| 336 | const repository = |
| 337 | const { pullRequest |
| 338 | ...this.github. |
| 339 | number, |
| 340 | }); |
| 341 | if (pullRequest.state |
| 342 | } |
| 343 | |
| 344 | async merge( |
| 345 | { owner, repo |
| 346 | number: number, |
| 347 | body: GithubMergePullDTO |
| 348 | ) { |
| 349 | const { repository, |
| 350 | owner, |
| 351 | repo, |
| 352 | viewer, |
| 353 | 'PUT /repos/:owner/:repo/pulls/:number/merge', |
| 354 | ); |
| 355 | const ref = |
| 356 | ...this.github. |
| 357 | number, |
| 358 | requesterId: signedIn.userId |
| 359 | }; |
| 360 | if (body.sha |
| 361 | // ponytail: checked before the merge, not under its lock; a push landing in between merges the newer head. |
| 362 | const { headSha |
| 363 | if (headSha !== |
| 364 | throw new GithubConflictError( |
| 365 | 'Head branch was modified. Review and try the merge again.', |
| 366 | ); |
| 367 | } |
| 368 | try { |
| 369 | const { mergeCommitSha |
| 370 | ...ref, |
| 371 | method: body.merge_method |
| 372 | title: body.commit_title |
| 373 | message: body.commit_message |
| 374 | }); |
| 375 | return { |
| 376 | sha: mergeCommitSha, |
| 377 | merged: true, |
| 378 | message: 'Pull Request successfully merged', |
| 379 | }; |
| 380 | } catch (error) |
| 381 | if ( |
| 382 | error instanceof PullRequestConflictError |
| 383 | error instanceof PullRequestDraftError |
| 384 | error instanceof PullRequestNotOpenError |
| 385 | error instanceof NothingToMergeError |
| 386 | ) |
| 387 | throw new NotMergeableError() |
| 388 | throw error; |
| 389 | } |
| 390 | } |
| 391 | |
| 392 | /** The issue behind a pull request number; 404 for an issue, as GitHub answers `/pulls/:number` for one. */ |
| 393 | private async issue |
| 394 | repository: RepositoryNode |
| 395 | viewer: GithubViewer |
| 396 | number: number, |
| 397 | ) { |
| 398 | const issue = |
| 399 | ...this.github. |
| 400 | number, |
| 401 | }); |
| 402 | if (!issue.isPullRequest |
| 403 | return issue; |
| 404 | } |
| 405 | |
| 406 | /** REST pull requests for issues that are all pull requests, in order. */ |
| 407 | private async rest |
| 408 | repository: RepositoryNode |
| 409 | viewer: GithubViewer |
| 410 | issues: IssueDTO |
| 411 | detail?: PullRestDetail |
| 412 | ) { |
| 413 | const ids = |
| 414 | const [rows, |
| 415 | pullRequestRowsOf(this.db |
| 416 | this.github.issueExtras |
| 417 | ]); |
| 418 | const origins = |
| 419 | const base = |
| 420 | const heads = |
| 421 | [repository.ghostId, |
| 422 | ]); |
| 423 | for (const row |
| 424 | if (row.headRepositoryId |
| 425 | const head = |
| 426 | this.repositories.load |
| 427 | ); |
| 428 | heads.set( |
| 429 | row.headRepositoryId, |
| 430 | head && toRepositoryRest( |
| 431 | ); |
| 432 | } |
| 433 | const context = |
| 434 | logins: [ |
| 435 | ...issues.flatMap( |
| 436 | issue.authorUsername, |
| 437 | ...issue.assignees, |
| 438 | ...(issue.closedByUsername |
| 439 | ]), |
| 440 | ...[...heads |
| 441 | head ? [head |
| 442 | ), |
| 443 | ], |
| 444 | labels: issues.flatMap |
| 445 | }); |
| 446 | // Requests sharing a base branch share one in-flight lookup. |
| 447 | const baseTip = |
| 448 | return Promise.all |
| 449 | issues.map(async |
| 450 | const row = |
| 451 | if (!baseTip.has |
| 452 | baseTip.set( |
| 453 | row.baseRef, |
| 454 | this.pulls.branchTip |
| 455 | ); |
| 456 | // The stored head is only refreshed on merge, so an open request reads its branch. |
| 457 | const headSha = |
| 458 | (row.state === |
| 459 | (await this.pulls |
| 460 | row.headSha; |
| 461 | return toPullRest( |
| 462 | issue, |
| 463 | { |
| 464 | numericId: extras.get |
| 465 | state: row.state |
| 466 | draft: row.draft |
| 467 | baseRef: row.baseRef |
| 468 | headRef: row.headRef |
| 469 | headSha, |
| 470 | baseSha: (await baseTip |
| 471 | mergeCommitSha: row.mergeCommitSha |
| 472 | mergedAt: row.mergedAt |
| 473 | base, |
| 474 | head: row.headRepositoryId |
| 475 | ? (heads.get |
| 476 | : null, |
| 477 | }, |
| 478 | context, |
| 479 | detail, |
| 480 | ); |
| 481 | }), |
| 482 | ); |
| 483 | } |
| 484 | |
| 485 | /** One pull request with the merge state and counts only GitHub's single-pull response carries. */ |
| 486 | private async detailed |
| 487 | repository: RepositoryNode |
| 488 | viewer: GithubViewer |
| 489 | number: number, |
| 490 | ) { |
| 491 | const issue = |
| 492 | const pull = |
| 493 | ...this.github. |
| 494 | number, |
| 495 | }); |
| 496 | const open = |
| 497 | const conflicted = |
| 498 | const [rest] |
| 499 | mergeable: !open |
| 500 | ? null |
| 501 | : conflicted |
| 502 | ? false |
| 503 | : pull.mergeBase |
| 504 | ? true |
| 505 | : null, |
| 506 | conflicted, |
| 507 | commits: pull.commitCount |
| 508 | additions: pull.additions |
| 509 | deletions: pull.deletions |
| 510 | changedFiles: pull.changedFiles |
| 511 | reviewComments: await this |
| 512 | }); |
| 513 | return rest!; |
| 514 | } |
| 515 | |
| 516 | /** A pull request's submitted reviews, oldest first. */ |
| 517 | private async reviewsOf |
| 518 | repository: RepositoryNode |
| 519 | viewer: GithubViewer |
| 520 | number: number, |
| 521 | ) { |
| 522 | await this.issue |
| 523 | const { pullRequest |
| 524 | ...this.github. |
| 525 | number, |
| 526 | }); |
| 527 | const rows = |
| 528 | const context = |
| 529 | logins: rows.map |
| 530 | labels: [], |
| 531 | }); |
| 532 | return rows.map |
| 533 | } |
| 534 | |
| 535 | /** The accounts behind commit emails, as GitHub's simple-user, keyed by lowercased email. */ |
| 536 | private async commitUsers |
| 537 | const origins = |
| 538 | const found = |
| 539 | return new Map |
| 540 | [...found].flatMap |
| 541 | row.username |
| 542 | ? [ |
| 543 | [ |
| 544 | email, |
| 545 | toUserRest( |
| 546 | { |
| 547 | ghostId: row.id |
| 548 | numericId: row.numericId |
| 549 | login: row.username |
| 550 | avatarUrl: row.image |
| 551 | }, |
| 552 | origins, |
| 553 | ), |
| 554 | ] as const, |
| 555 | ] |
| 556 | : [], |
| 557 | ), |
| 558 | ); |
| 559 | } |
| 560 | |
| 561 | /** Line comments others can see: those in a submitted review, and replies. */ |
| 562 | private async reviewCommentCount |
| 563 | const [row] |
| 564 | .select({ total: |
| 565 | .from(schema.pullRequestReviewComment |
| 566 | .leftJoin( |
| 567 | schema.pullRequestReview, |
| 568 | eq( |
| 569 | schema.pullRequestReview.id |
| 570 | schema.pullRequestReviewComment.reviewId |
| 571 | ), |
| 572 | ) |
| 573 | .where( |
| 574 | and( |
| 575 | eq(schema.pullRequestReviewComment |
| 576 | or( |
| 577 | isNull(schema.pullRequestReviewComment |
| 578 | isNotNull(schema.pullRequestReview |
| 579 | ), |
| 580 | ), |
| 581 | ); |
| 582 | return row?.total |
| 583 | } |
| 584 | } |