| 1 | import { type Database, schema } from '@ghost/db'; |
| 2 | import { Inject, Injectable |
| 3 | import { ConfigService } |
| 4 | import { |
| 5 | and, |
| 6 | asc, |
| 7 | count, |
| 8 | eq, |
| 9 | inArray, |
| 10 | isNotNull, |
| 11 | type SQL, |
| 12 | sql, |
| 13 | } from 'drizzle-orm'; |
| 14 | |
| 15 | import { DATABASE } |
| 16 | import { isoTimestamp } |
| 17 | import { |
| 18 | GithubNotFoundError, |
| 19 | GithubValidationError, |
| 20 | RequiresAuthenticationError, |
| 21 | } from '../../../lib/github/github.errors.js'; |
| 22 | import { githubOrigins } |
| 23 | import { |
| 24 | type IssueExtras, |
| 25 | labelNamesOf, |
| 26 | pageOf, |
| 27 | type RestCommentSource, |
| 28 | type RestContext, |
| 29 | RestPage, |
| 30 | toCommentRest, |
| 31 | toIssueRest, |
| 32 | toLabelRest, |
| 33 | toUserRest, |
| 34 | } from '../../../lib/github/rest.js'; |
| 35 | import { UserNotFoundError } |
| 36 | import type { IssueDTO |
| 37 | import type { LabelDTO |
| 38 | import { IssuesService } |
| 39 | import type { |
| 40 | GithubRepositoryTarget, |
| 41 | GithubViewer, |
| 42 | } from '../../auth/github-request.js'; |
| 43 | import { requireWriteScope } |
| 44 | import { RepositoryPermission } |
| 45 | import { RepositoryNodesService } |
| 46 | import type { RepositoryNode |
| 47 | import type { |
| 48 | GithubCreateIssueDTO, |
| 49 | GithubListIssuesQueryDTO, |
| 50 | GithubPageQueryDTO, |
| 51 | GithubUpdateIssueDTO, |
| 52 | } from './dto/issue.dto.js'; |
| 53 | |
| 54 | const TRIAGE_OR_ABOVE: ( |
| 55 | RepositoryPermission.ADMIN, |
| 56 | RepositoryPermission.MAINTAIN, |
| 57 | RepositoryPermission.WRITE, |
| 58 | RepositoryPermission.TRIAGE, |
| 59 | ]; |
| 60 | |
| 61 | const WRITE_OR_ABOVE: ( |
| 62 | RepositoryPermission.ADMIN, |
| 63 | RepositoryPermission.MAINTAIN, |
| 64 | RepositoryPermission.WRITE, |
| 65 | ]; |
| 66 | |
| 67 | const restUserColumns = { |
| 68 | ghostId: schema.user |
| 69 | numericId: schema.user |
| 70 | login: sql<string |
| 71 | avatarUrl: schema.user |
| 72 | }; |
| 73 | |
| 74 | /** What the REST issue, comment and label routes share: the ids and users their responses embed, the lookups GitHub's paths need, and the label and assignee rules GitHub has and Ghost does not. */ |
| 75 | @Injectable() |
| 76 | export class GithubIssuesService |
| 77 | constructor( |
| 78 | @Inject(DATABASE) |
| 79 | private readonly issueService |
| 80 | private readonly repositories |
| 81 | private readonly config |
| 82 | ) {} |
| 83 | |
| 84 | async listIssues( |
| 85 | { owner, repo |
| 86 | query: GithubListIssuesQueryDTO |
| 87 | ) { |
| 88 | const repository = |
| 89 | const page = |
| 90 | const state = |
| 91 | const anyOrNone = |
| 92 | const list = |
| 93 | { |
| 94 | ...this.ref( |
| 95 | query: { |
| 96 | state, |
| 97 | labels: query.labels |
| 98 | author: query.creator |
| 99 | assignee: anyOrNone ? |
| 100 | sort: query.sort |
| 101 | direction: query.direction |
| 102 | limit: page.perPage |
| 103 | }, |
| 104 | }, |
| 105 | { |
| 106 | offset: page.offset |
| 107 | includePullRequests: true, |
| 108 | since: query.since |
| 109 | assigned: anyOrNone ? |
| 110 | }, |
| 111 | ); |
| 112 | return new RestPage |
| 113 | await this.issues |
| 114 | page, |
| 115 | state === 'open' |
| 116 | ? list.openCount |
| 117 | : state === ' |
| 118 | ? list.closedCount |
| 119 | : list.total, |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | /** Labels and assignees from someone who cannot triage are dropped, as on GitHub. */ |
| 124 | async createIssue( |
| 125 | { owner, repo |
| 126 | body: GithubCreateIssueDTO |
| 127 | ) { |
| 128 | const { repository, |
| 129 | owner, |
| 130 | repo, |
| 131 | viewer, |
| 132 | 'POST /repos/:owner/:repo/issues', |
| 133 | ); |
| 134 | if (body.milestone |
| 135 | throw new GithubValidationError([ |
| 136 | { resource: 'Issue', |
| 137 | ]); |
| 138 | const triage = |
| 139 | const labels = |
| 140 | triage && body. |
| 141 | await this.ensureLabels |
| 142 | const created = |
| 143 | this.issueService.createIssue |
| 144 | username: repository.ownerLogin |
| 145 | repo: repository.slug |
| 146 | requesterId: signedIn.userId |
| 147 | body: { |
| 148 | title: body.title |
| 149 | body: body.body |
| 150 | labels, |
| 151 | assignees: triage |
| 152 | ? [ |
| 153 | ...(body.assignees |
| 154 | ...(body.assignee |
| 155 | ] |
| 156 | : [], |
| 157 | }, |
| 158 | }), |
| 159 | ); |
| 160 | return this.issue |
| 161 | } |
| 162 | |
| 163 | async getIssue( |
| 164 | { owner, repo |
| 165 | number: number, |
| 166 | ) { |
| 167 | const repository = |
| 168 | return this.issue |
| 169 | repository, |
| 170 | await this.issueService |
| 171 | ...this.ref( |
| 172 | number, |
| 173 | }), |
| 174 | ); |
| 175 | } |
| 176 | |
| 177 | async updateIssue( |
| 178 | { owner, repo |
| 179 | number: number, |
| 180 | body: GithubUpdateIssueDTO |
| 181 | ) { |
| 182 | const { repository, |
| 183 | owner, |
| 184 | repo, |
| 185 | viewer, |
| 186 | 'PATCH /repos/:owner/:repo/issues/:number', |
| 187 | ); |
| 188 | if (body.milestone |
| 189 | throw new GithubValidationError([ |
| 190 | { resource: 'Issue', |
| 191 | ]); |
| 192 | const ref = |
| 193 | ...this.ref( |
| 194 | number, |
| 195 | requesterId: signedIn.userId |
| 196 | }; |
| 197 | const current = |
| 198 | const triage = |
| 199 | const labels = |
| 200 | triage && body. |
| 201 | if (body.title |
| 202 | await this.issueService |
| 203 | ...ref, |
| 204 | body: { title: body |
| 205 | }); |
| 206 | // Closing a closed issue is a no-op on GitHub; Ghost refuses it. |
| 207 | if (body.state |
| 208 | await this.issueService |
| 209 | if (body.state |
| 210 | await this.issueService |
| 211 | if (labels) { |
| 212 | await this.ensureLabels |
| 213 | await this.issueService |
| 214 | } |
| 215 | if (triage && |
| 216 | await this.assigning |
| 217 | this.issueService.setIssueAssignees |
| 218 | ...ref, |
| 219 | usernames: [ |
| 220 | ...(body.assignees |
| 221 | ...(body.assignee |
| 222 | ], |
| 223 | }), |
| 224 | ); |
| 225 | return this.issue |
| 226 | } |
| 227 | |
| 228 | addAssignees( |
| 229 | target: GithubRepositoryTarget |
| 230 | number: number, |
| 231 | logins: string[] |
| 232 | ) { |
| 233 | return this.changeAssignees |
| 234 | target, |
| 235 | number, |
| 236 | 'POST /repos/:owner/:repo/issues/:number/assignees', |
| 237 | (current) => |
| 238 | ); |
| 239 | } |
| 240 | |
| 241 | removeAssignees( |
| 242 | target: GithubRepositoryTarget |
| 243 | number: number, |
| 244 | logins: string[] |
| 245 | ) { |
| 246 | return this.changeAssignees |
| 247 | target, |
| 248 | number, |
| 249 | 'DELETE /repos/:owner/:repo/issues/:number/assignees', |
| 250 | (current) => |
| 251 | ); |
| 252 | } |
| 253 | |
| 254 | /** Add and remove are a read, then a full replacement through IssuesService. */ |
| 255 | private async changeAssignees |
| 256 | { owner, repo |
| 257 | number: number, |
| 258 | route: string, |
| 259 | next: (current |
| 260 | ) { |
| 261 | const { repository, |
| 262 | owner, |
| 263 | repo, |
| 264 | viewer, |
| 265 | route, |
| 266 | ); |
| 267 | const ref = |
| 268 | ...this.ref( |
| 269 | number, |
| 270 | requesterId: signedIn.userId |
| 271 | }; |
| 272 | const current = |
| 273 | // ponytail: read then replace; two concurrent adds can lose one. Add IssuesService.addIssueAssignees if that bites. |
| 274 | if (this.canTriage |
| 275 | await this.assigning |
| 276 | this.issueService.setIssueAssignees |
| 277 | ...ref, |
| 278 | usernames: next(current |
| 279 | }), |
| 280 | ); |
| 281 | return this.issue |
| 282 | } |
| 283 | |
| 284 | async canBeAssignedToIssue( |
| 285 | { owner, repo |
| 286 | number: number, |
| 287 | assignee: string, |
| 288 | ) { |
| 289 | const repository = |
| 290 | await this.issueService |
| 291 | ...this.ref( |
| 292 | number, |
| 293 | }); |
| 294 | await this.requireUser |
| 295 | } |
| 296 | |
| 297 | async canBeAssigned( |
| 298 | { owner, repo |
| 299 | assignee: string, |
| 300 | ) { |
| 301 | await this.readable |
| 302 | await this.requireUser |
| 303 | } |
| 304 | |
| 305 | /** People with access to the repository: its owner, accepted collaborators and organization members. */ |
| 306 | async listAssignees( |
| 307 | { owner, repo |
| 308 | query: GithubPageQueryDTO |
| 309 | ) { |
| 310 | const repository = |
| 311 | const page = |
| 312 | const where = |
| 313 | isNotNull(schema.user |
| 314 | sql`${schema.user |
| 315 | select ${repository.ownerGhostId}::text |
| 316 | union select user_id from repository_collaborator where repository_id = ${repository.ghostId} and accepted_at is not null |
| 317 | union select user_id from member where organization_id = ${repository.organizationGhostId} |
| 318 | )`, |
| 319 | ); |
| 320 | const [users, |
| 321 | this.db |
| 322 | .select(restUserColumns) |
| 323 | .from(schema.user |
| 324 | .where(where) |
| 325 | .orderBy(asc(schema |
| 326 | .limit(page.perPage |
| 327 | .offset(page.offset |
| 328 | this.db.select |
| 329 | ]); |
| 330 | const origins = |
| 331 | return new RestPage |
| 332 | users.map((user |
| 333 | page, |
| 334 | total?.total ?? |
| 335 | ); |
| 336 | } |
| 337 | |
| 338 | /** The repository behind a REST path, as the viewer may see it: 404 when private and unreadable, or when the token lacks `repo`. */ |
| 339 | readable(owner: |
| 340 | return this.repositories |
| 341 | } |
| 342 | |
| 343 | /** The repository behind a REST path a write is aimed at, refusing anonymous callers and tokens without the scope the write needs. */ |
| 344 | async writable( |
| 345 | owner: string, |
| 346 | repo: string, |
| 347 | viewer: GithubViewer |
| 348 | route: string, |
| 349 | ) { |
| 350 | if (!viewer) |
| 351 | const repository = |
| 352 | requireWriteScope(viewer, route |
| 353 | return { repository, |
| 354 | } |
| 355 | |
| 356 | ref(repository: |
| 357 | return { |
| 358 | username: repository.ownerLogin |
| 359 | repo: repository.slug |
| 360 | requesterId: viewer?.userId |
| 361 | }; |
| 362 | } |
| 363 | |
| 364 | /** GitHub drops labels and assignees sent by someone who cannot triage, where Ghost would refuse the whole write. */ |
| 365 | canTriage(repository: |
| 366 | return TRIAGE_OR_ABOVE.includes |
| 367 | } |
| 368 | |
| 369 | async issues(repository |
| 370 | const [context, |
| 371 | this.context(repository |
| 372 | logins: issues.flatMap |
| 373 | issue.authorUsername, |
| 374 | ...issue.assignees, |
| 375 | ...(issue.closedByUsername |
| 376 | ]), |
| 377 | labels: issues.flatMap |
| 378 | }), |
| 379 | this.issueExtras(issues |
| 380 | ]); |
| 381 | return issues.map |
| 382 | toIssueRest(issue, extras |
| 383 | ); |
| 384 | } |
| 385 | |
| 386 | async issue(repository |
| 387 | const [rest] |
| 388 | return rest; |
| 389 | } |
| 390 | |
| 391 | async labels(repository |
| 392 | const context = |
| 393 | return labels.map |
| 394 | } |
| 395 | |
| 396 | async comments(repository |
| 397 | const context = |
| 398 | logins: comments.map |
| 399 | labels: [], |
| 400 | }); |
| 401 | return comments.map |
| 402 | } |
| 403 | |
| 404 | /** A comment by its GitHub id, with the issue it belongs to; 404 when it is in another repository. */ |
| 405 | async commentByNumericId(repository |
| 406 | return this.commentWhere |
| 407 | repository, |
| 408 | eq(schema.issueComment |
| 409 | ); |
| 410 | } |
| 411 | |
| 412 | async commentById(repository |
| 413 | return this.commentWhere |
| 414 | } |
| 415 | |
| 416 | private async commentWhere |
| 417 | const [comment]: |
| 418 | .select({ |
| 419 | id: schema.issueComment |
| 420 | numericId: schema.issueComment |
| 421 | body: schema.issueComment |
| 422 | createdAt: isoTimestamp(schema |
| 423 | updatedAt: isoTimestamp(schema |
| 424 | authorUsername: sql<string |
| 425 | issueNumber: schema.issue |
| 426 | isPullRequest: schema.issue |
| 427 | }) |
| 428 | .from(schema.issueComment |
| 429 | .innerJoin(schema.issue |
| 430 | .innerJoin(schema.user |
| 431 | .where(and(condition |
| 432 | if (!comment) |
| 433 | return comment; |
| 434 | } |
| 435 | |
| 436 | /** 404 unless `login` names a user; Ghost lets any user be assigned. */ |
| 437 | async requireUser(login |
| 438 | const [user] |
| 439 | .select({ id: |
| 440 | .from(schema.user |
| 441 | .where(eq(schema |
| 442 | if (!user) |
| 443 | } |
| 444 | |
| 445 | async labelByName( |
| 446 | repository: RepositoryNode |
| 447 | viewer: GithubViewer |
| 448 | name: string, |
| 449 | ) { |
| 450 | const { labels |
| 451 | this.ref(repository |
| 452 | ); |
| 453 | const label = |
| 454 | if (!label) |
| 455 | return label; |
| 456 | } |
| 457 | |
| 458 | /** Creates the labels in `names` the repository does not have yet, as GitHub does when an issue is given one; only someone who can write may. */ |
| 459 | async ensureLabels( |
| 460 | repository: RepositoryNode |
| 461 | viewer: GithubViewer |
| 462 | names: string[] |
| 463 | ) { |
| 464 | if (names.length |
| 465 | const { labels |
| 466 | this.ref(repository |
| 467 | ); |
| 468 | const existing = |
| 469 | const missing = |
| 470 | ...new Set |
| 471 | ].filter((name |
| 472 | if (missing.length |
| 473 | if (!WRITE_OR_ABOVE.includes |
| 474 | throw new GithubValidationError([ |
| 475 | { resource: 'Label', |
| 476 | ]); |
| 477 | for (const name |
| 478 | await this.issueService |
| 479 | username: repository.ownerLogin |
| 480 | repo: repository.slug |
| 481 | requesterId: viewer.userId |
| 482 | body: { name, |
| 483 | }); |
| 484 | } |
| 485 | |
| 486 | /** Runs an assignee write, answering an unknown login as GitHub does: 422 on the `assignees` field. */ |
| 487 | async assigning<T |
| 488 | try { |
| 489 | return await write |
| 490 | } catch (error) |
| 491 | if (error instanceof |
| 492 | throw new GithubValidationError([ |
| 493 | { resource: 'Issue', |
| 494 | ]); |
| 495 | throw error; |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | /** The users and label ids the responses for `related` embed, loaded once. */ |
| 500 | async context( |
| 501 | repository: RepositoryNode |
| 502 | related: { |
| 503 | ): Promise<RestContext |
| 504 | const logins = |
| 505 | const labelIds = |
| 506 | const [users, |
| 507 | logins.length > 0 |
| 508 | ? this.db |
| 509 | .select(restUserColumns) |
| 510 | .from(schema.user |
| 511 | .where(inArray(schema |
| 512 | : [], |
| 513 | labelIds.length > 0 |
| 514 | ? this.db |
| 515 | .select({ id: |
| 516 | .from(schema.label |
| 517 | .where(inArray(schema |
| 518 | : [], |
| 519 | ]); |
| 520 | const origins = |
| 521 | return { |
| 522 | repository: { |
| 523 | ownerLogin: repository.ownerLogin |
| 524 | apiUrl: `${origins. |
| 525 | webUrl: repository.url |
| 526 | }, |
| 527 | users: new Map |
| 528 | users.map((user |
| 529 | ), |
| 530 | labelIds: new Map |
| 531 | }; |
| 532 | } |
| 533 | |
| 534 | async issueExtras(ids |
| 535 | const rows = |
| 536 | ids.length > 0 |
| 537 | ? await this. |
| 538 | .select({ |
| 539 | id: schema.issue |
| 540 | numericId: schema.issue |
| 541 | draft: schema.pullRequest |
| 542 | mergedAt: schema.pullRequest |
| 543 | }) |
| 544 | .from(schema.issue |
| 545 | .leftJoin( |
| 546 | schema.pullRequest, |
| 547 | eq(schema.pullRequest |
| 548 | ) |
| 549 | .where(inArray(schema |
| 550 | : []; |
| 551 | return new Map |
| 552 | } |
| 553 | } |