| 1 | import type { schema } from '@ghost/db'; |
| 2 | |
| 3 | import { |
| 4 | CommentAuthorAssociation, |
| 5 | IssueState, |
| 6 | IssueStateReason, |
| 7 | PullRequestReviewState, |
| 8 | PullRequestState, |
| 9 | RepositoryPermission, |
| 10 | RepositoryVisibility, |
| 11 | } from '../../github/graphql/enums.js'; |
| 12 | import { IssueNode } |
| 13 | import { IssueCommentNode } |
| 14 | import type { IssueOrPullRequestFields |
| 15 | import { LabelNode } |
| 16 | import { OrganizationNode } |
| 17 | import { |
| 18 | CommitNode, |
| 19 | PullRequestNode, |
| 20 | PullRequestReviewNode, |
| 21 | } from '../../github/graphql/types/pull-request.type.js'; |
| 22 | import { ReleaseNode } |
| 23 | import { |
| 24 | RefNode, |
| 25 | RepositoryNode, |
| 26 | } from '../../github/graphql/types/repository.type.js'; |
| 27 | import { UserNode } |
| 28 | import type { IssueDTO |
| 29 | import type { LabelDTO |
| 30 | import type { ReleaseDTO |
| 31 | import type { Commit |
| 32 | import type { AuthorizedRepository |
| 33 | import type { ReviewRow |
| 34 | import { encodeNodeId } |
| 35 | import type { PullRequestRow |
| 36 | import type { GithubOrigins |
| 37 | import { repositoryPermissionOf } |
| 38 | import { tagPath } |
| 39 | |
| 40 | export type UserRow |
| 41 | export type OrganizationRow |
| 42 | |
| 43 | // Mapper: loaders hand back whole rows shared by several GraphQL types, so the GitHub shape cannot be selected in the query. |
| 44 | export function toUserNode(row |
| 45 | const login = |
| 46 | return Object.assign |
| 47 | ghostId: row.id |
| 48 | id: encodeNodeId('User |
| 49 | login, |
| 50 | name: row.name |
| 51 | avatarUrl: row.image |
| 52 | resourcePath: `/${login |
| 53 | url: `${web} |
| 54 | databaseId: row.numericId |
| 55 | createdAt: row.createdAt |
| 56 | }); |
| 57 | } |
| 58 | |
| 59 | // Mapper: same reason as toUserNode. |
| 60 | export function toOrganizationNode( |
| 61 | row: OrganizationRow |
| 62 | { web }: |
| 63 | ) { |
| 64 | return Object.assign |
| 65 | ghostId: row.id |
| 66 | id: encodeNodeId('Organization |
| 67 | login: row.slug |
| 68 | name: row.name |
| 69 | avatarUrl: row.logo |
| 70 | resourcePath: `/${row |
| 71 | url: `${web} |
| 72 | databaseId: row.numericId |
| 73 | }); |
| 74 | } |
| 75 | |
| 76 | // Mapper: the access service returns the row the permission check already loaded; re-querying it in GitHub's shape would read it twice. |
| 77 | export function toRepositoryNode( |
| 78 | row: AuthorizedRepository |
| 79 | ownerLogin: string, |
| 80 | { web, sshHost |
| 81 | ) { |
| 82 | const permission = |
| 83 | return Object.assign |
| 84 | ghostId: row.id |
| 85 | ownerGhostId: row.ownerId |
| 86 | organizationGhostId: row.organizationId |
| 87 | parentGhostId: row.parentRepositoryId |
| 88 | ownerLogin, |
| 89 | slug: row.slug |
| 90 | defaultBranch: row.defaultBranch |
| 91 | viewerRole: row.viewerRole |
| 92 | id: encodeNodeId('Repository |
| 93 | databaseId: row.numericId |
| 94 | name: row.slug |
| 95 | nameWithOwner: `${ownerLogin} |
| 96 | description: row.description |
| 97 | isPrivate: row.visibility |
| 98 | isFork: row.parentRepositoryId |
| 99 | isArchived: false, |
| 100 | isEmpty: false, |
| 101 | visibility: |
| 102 | row.visibility === |
| 103 | ? RepositoryVisibility.PRIVATE |
| 104 | : RepositoryVisibility.PUBLIC, |
| 105 | hasIssuesEnabled: true, |
| 106 | hasWikiEnabled: false, |
| 107 | hasProjectsEnabled: false, |
| 108 | // ponytail: Ghost allows every merge method on every repository; read per-repository settings here once they exist. |
| 109 | mergeCommitAllowed: true, |
| 110 | rebaseMergeAllowed: true, |
| 111 | squashMergeAllowed: true, |
| 112 | sshUrl: sshHost ? |
| 113 | url: `${web} |
| 114 | resourcePath: `/${ownerLogin |
| 115 | createdAt: row.createdAt |
| 116 | updatedAt: row.updatedAt |
| 117 | pushedAt: row.lastPushedAt |
| 118 | viewerPermission: permission ? |
| 119 | }); |
| 120 | } |
| 121 | |
| 122 | // Mapper: labels arrive from IssuesService already selected for Ghost's API; selecting them again in GitHub's shape would double the query. |
| 123 | export function toLabelNode( |
| 124 | label: LabelDTO |
| 125 | repository: { |
| 126 | ) { |
| 127 | const path = |
| 128 | return Object.assign |
| 129 | ghostId: label.id |
| 130 | id: encodeNodeId('Label |
| 131 | name: label.name |
| 132 | color: label.color |
| 133 | description: label.description |
| 134 | isDefault: false, |
| 135 | url: `${repository. |
| 136 | resourcePath: `${repository. |
| 137 | createdAt: new Date |
| 138 | updatedAt: new Date |
| 139 | }); |
| 140 | } |
| 141 | |
| 142 | type RepositoryRefs |
| 143 | RepositoryNode, |
| 144 | 'ghostId' | |
| 145 | >; |
| 146 | |
| 147 | /** What toIssueNode and toPullRequestNode share; `path` is `issues` or `pull`, as GitHub's URLs have it. */ |
| 148 | function issueOrPullRequestFields( |
| 149 | issue: IssueDTO |
| 150 | repository: RepositoryRefs |
| 151 | path: 'issues |
| 152 | ) { |
| 153 | return { |
| 154 | ghostId: issue.id |
| 155 | ownerLogin: repository.ownerLogin |
| 156 | repoSlug: repository.slug |
| 157 | repositoryGhostId: repository.ghostId |
| 158 | authorLogin: issue.authorUsername |
| 159 | assigneeLogins: issue.assignees |
| 160 | labelDtos: issue.labels |
| 161 | repositoryUrl: repository.url |
| 162 | repositoryResourcePath: repository.resourcePath |
| 163 | number: issue.number |
| 164 | title: issue.title |
| 165 | body: issue.body |
| 166 | bodyHTML: '', |
| 167 | bodyText: issue.body |
| 168 | closed: issue.state |
| 169 | closedAt: issue.closedAt |
| 170 | createdAt: new Date |
| 171 | updatedAt: new Date |
| 172 | locked: false, |
| 173 | includesCreatedEdit: issue.updatedAt |
| 174 | viewerCanUpdate: issue.viewerCanEdit |
| 175 | url: `${repository. |
| 176 | resourcePath: `${repository. |
| 177 | }; |
| 178 | } |
| 179 | |
| 180 | // Mapper: IssuesService.getIssues/getIssue already expand authors, labels and assignees for Ghost's API; the GitHub shape renames and re-cases those fields. |
| 181 | export function toIssueNode(issue |
| 182 | const closed = |
| 183 | return Object.assign |
| 184 | new IssueNode(), |
| 185 | issueOrPullRequestFields(issue, repository |
| 186 | { |
| 187 | id: encodeNodeId('Issue |
| 188 | state: closed ? |
| 189 | // ponytail: Ghost records no close reason; every close reads as COMPLETED until it does. |
| 190 | stateReason: closed ? |
| 191 | isPinned: false, |
| 192 | }, |
| 193 | ); |
| 194 | } |
| 195 | |
| 196 | // Mapper: same source as toIssueNode, plus the pull_request row; IssueDTO carries the labels, assignees and close time a pull request shares with an issue. |
| 197 | export function toPullRequestNode( |
| 198 | issue: IssueDTO |
| 199 | repository: RepositoryRefs |
| 200 | pull: PullRequestRow |
| 201 | ) { |
| 202 | return Object.assign |
| 203 | new PullRequestNode(), |
| 204 | issueOrPullRequestFields(issue, repository |
| 205 | { |
| 206 | id: encodeNodeId('PullRequest |
| 207 | pullRequestGhostId: pull.id |
| 208 | baseRepositoryGhostId: pull.baseRepositoryId |
| 209 | headRepositoryGhostId: pull.headRepositoryId |
| 210 | storedHeadSha: pull.headSha |
| 211 | mergeCommitSha: pull.mergeCommitSha |
| 212 | state: |
| 213 | pull.state === |
| 214 | ? PullRequestState.MERGED |
| 215 | : pull.state |
| 216 | ? PullRequestState.CLOSED |
| 217 | : PullRequestState.OPEN, |
| 218 | isDraft: pull.draft |
| 219 | merged: pull.state |
| 220 | mergedAt: pull.mergedAt |
| 221 | baseRefName: pull.baseRef |
| 222 | headRefName: pull.headRef |
| 223 | isCrossRepository: pull.headRepositoryId |
| 224 | // ponytail: Ghost has no "allow edits by maintainers"; a fork's branch is never writable from the base, so false. |
| 225 | maintainerCanModify: false, |
| 226 | }, |
| 227 | ); |
| 228 | } |
| 229 | |
| 230 | /** A branch as GitHub's Ref; the id is opaque and only needs to be stable. */ |
| 231 | export function toRefNode(repositoryGhostId |
| 232 | return Object.assign |
| 233 | id: encodeNodeId('Repository |
| 234 | name, |
| 235 | prefix: 'refs/heads/', |
| 236 | branchProtectionRule: null, |
| 237 | }); |
| 238 | } |
| 239 | |
| 240 | export type CommentRow |
| 241 | id: string |
| 242 | body: string |
| 243 | createdAt: string |
| 244 | updatedAt: string |
| 245 | authorUsername: string |
| 246 | }; |
| 247 | |
| 248 | // Mapper: IssuesService.getComments selects Ghost's comment columns for its own API. |
| 249 | export function toIssueCommentNode( |
| 250 | comment: CommentRow |
| 251 | issue: Pick |
| 252 | viewerLogin: string |
| 253 | ) { |
| 254 | return Object.assign |
| 255 | ghostId: comment.id |
| 256 | authorLogin: comment.authorUsername |
| 257 | id: encodeNodeId('IssueComment |
| 258 | body: comment.body |
| 259 | createdAt: new Date |
| 260 | updatedAt: new Date |
| 261 | includesCreatedEdit: comment.updatedAt |
| 262 | isMinimized: false, |
| 263 | minimizedReason: null, |
| 264 | // ponytail: OWNER or NONE only; MEMBER and COLLABORATOR need a role lookup per author. |
| 265 | authorAssociation: |
| 266 | comment.authorUsername === |
| 267 | ? CommentAuthorAssociation.OWNER |
| 268 | : CommentAuthorAssociation.NONE, |
| 269 | viewerDidAuthor: comment.authorUsername |
| 270 | url: `${issue. |
| 271 | resourcePath: `${issue. |
| 272 | }); |
| 273 | } |
| 274 | |
| 275 | // Mapper: reviews come from the per-request loader, whose rows other fields (reviewDecision) read too. |
| 276 | export function toPullRequestReviewNode( |
| 277 | review: ReviewRow |
| 278 | pull: Pick |
| 279 | PullRequestNode, |
| 280 | | 'ownerLogin' |
| 281 | | 'repoSlug' |
| 282 | | 'number' |
| 283 | | 'url' |
| 284 | | 'resourcePath' |
| 285 | | 'repositoryGhostId' |
| 286 | | 'repositoryUrl' |
| 287 | | 'repositoryResourcePath' |
| 288 | >, |
| 289 | ) { |
| 290 | const anchor = |
| 291 | return Object.assign |
| 292 | authorLogin: review.authorLogin |
| 293 | commitSha: review.commitSha |
| 294 | pullRequest: pull, |
| 295 | id: encodeNodeId('PullRequestReview |
| 296 | databaseId: review.numericId |
| 297 | body: review.body |
| 298 | state: review.dismissed |
| 299 | ? PullRequestReviewState.DISMISSED |
| 300 | : review.state |
| 301 | ? PullRequestReviewState.APPROVED |
| 302 | : review.state |
| 303 | ? PullRequestReviewState.CHANGES_REQUESTED |
| 304 | : PullRequestReviewState.COMMENTED, |
| 305 | // ponytail: OWNER or NONE only, as for comments. |
| 306 | authorAssociation: |
| 307 | review.authorLogin === |
| 308 | ? CommentAuthorAssociation.OWNER |
| 309 | : CommentAuthorAssociation.NONE, |
| 310 | createdAt: review.createdAt |
| 311 | submittedAt: review.submittedAt |
| 312 | includesCreatedEdit: false, |
| 313 | url: `${pull. |
| 314 | resourcePath: `${pull. |
| 315 | }); |
| 316 | } |
| 317 | |
| 318 | // Mapper: commits come from git log, in Ghost's shape; GitActor.user resolves each email to an account. |
| 319 | export function toCommitNode( |
| 320 | commit: Commit |
| 321 | repository: { |
| 322 | ) { |
| 323 | return Object.assign |
| 324 | author: { name: commit |
| 325 | committer: { name: commit |
| 326 | id: encodeNodeId('Commit |
| 327 | oid: commit.sha |
| 328 | abbreviatedOid: commit.sha |
| 329 | message: [commit.subject |
| 330 | messageHeadline: commit.subject |
| 331 | messageBody: commit.body |
| 332 | authoredDate: new Date |
| 333 | committedDate: new Date |
| 334 | url: `${repository. |
| 335 | resourcePath: `${repository. |
| 336 | }); |
| 337 | } |
| 338 | |
| 339 | // Mapper: releases come from ReleasesService in Ghost's shape; the node adds the integer id releaseNumericIdsOf loads and the web URLs. |
| 340 | export function toReleaseNode( |
| 341 | release: ReleaseDTO |
| 342 | numericId: number |
| 343 | repository: { |
| 344 | ) { |
| 345 | const path = |
| 346 | return Object.assign |
| 347 | id: encodeNodeId('Release |
| 348 | databaseId: numericId, |
| 349 | name: release.name |
| 350 | tagName: release.tagName |
| 351 | description: release.body |
| 352 | isDraft: release.isDraft |
| 353 | isLatest: release.isLatest |
| 354 | isPrerelease: release.isPrerelease |
| 355 | createdAt: new Date |
| 356 | publishedAt: release.publishedAt |
| 357 | updatedAt: new Date |
| 358 | url: `${repository. |
| 359 | resourcePath: `${repository. |
| 360 | }); |
| 361 | } |