import * as Cause from "effect/Cause";import * as Exit from "effect/Exit";import * as Result from "effect/Result";import * as Schema from "effect/Schema";import type { PullRequestStackMembership, PullRequestActor, PullRequestCheck, PullRequestCheckStatus, PullRequestChecksState, PullRequestComment, PullRequestCommit, PullRequestLabel, PullRequestMergeCapabilities, PullRequestMergeMethod, PullRequestOmittedFileStat, PullRequestMergeability, PullRequestReaction, PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewDecision, PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidate, PullRequestReviewerCandidateList, PullRequestReviewerKind, PullRequestLabelCandidate, PullRequestLabelCandidateList, PullRequestState, PullRequestThreadComment,} from "@t3tools/contracts";import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { dedupeChecks } from "./pullRequestChecks.ts"; /** * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh` * release that adds a conclusion or a review state must not fail the whole payload. */const RawActorSchema = Schema.Struct({ /** * Optional because a review can be requested from a team or a mannequin, which the query has * no fragment for and GraphQL answers with an empty object. A reviewer with no login names * nobody to show, and must not fail the response the conversation travels in. */ login: Schema.optional(Schema.String), /** The node id, which is how a listing's authors are resolved to avatars in one request. */ id: Schema.optional(Schema.NullOr(Schema.String)), name: Schema.optional(Schema.NullOr(Schema.String)), /** Only the GraphQL API reports one; `gh pr view --json` has no avatar to give. */ avatarUrl: Schema.optional(Schema.NullOr(Schema.String)),}); const RawLabelSchema = Schema.Struct({ name: Schema.String, color: Schema.optional(Schema.NullOr(Schema.String)),}); const RawReviewRequestSchema = Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)), slug: Schema.optional(Schema.NullOr(Schema.String)), name: Schema.optional(Schema.NullOr(Schema.String)),}); const RawCheckSchema = Schema.Struct({ __typename: Schema.optional(Schema.String), name: Schema.optional(Schema.NullOr(Schema.String)), context: Schema.optional(Schema.NullOr(Schema.String)), status: Schema.optional(Schema.NullOr(Schema.String)), conclusion: Schema.optional(Schema.NullOr(Schema.String)), state: Schema.optional(Schema.NullOr(Schema.String)), description: Schema.optional(Schema.NullOr(Schema.String)), detailsUrl: Schema.optional(Schema.NullOr(Schema.String)), targetUrl: Schema.optional(Schema.NullOr(Schema.String)), /** * What tells two same-named checks apart, and which run of one is the newest. All three ride * along with `statusCheckRollup` already — it is asked for as a whole field — so reading them * costs no request. Empty for an app-provided check run, which belongs to no workflow, and * absent entirely on a commit status, which is not a run at all. */ workflowName: Schema.optional(Schema.NullOr(Schema.String)), startedAt: Schema.optional(Schema.NullOr(Schema.String)), completedAt: Schema.optional(Schema.NullOr(Schema.String)),}); const RawListItemSchema = Schema.Struct({ number: Schema.Int, title: Schema.String, url: Schema.String, author: Schema.optional(Schema.NullOr(RawActorSchema)), headRefName: Schema.String, baseRefName: Schema.String, state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), mergeable: Schema.optional(Schema.NullOr(Schema.String)), reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), additions: Schema.optional(Schema.Int), deletions: Schema.optional(Schema.Int), createdAt: Schema.String, updatedAt: Schema.String, mergedAt: Schema.optional(Schema.NullOr(Schema.String)), reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), labels: Schema.optional(Schema.Array(RawLabelSchema)), /** * Every check of the head commit, which is the only rollup `gh pr list --json` can give: there * is no field for the one-word verdict. Measured against `pingdotgg/t3code`, asking for it costs * 0.6s -> 7.9s at a hundred rows and 0.9s -> 2.1s at thirty, for 425 KB of checks a listing * reduces to one word. The listing pays it because the alternative is a request per row; the * cross-repository search below asks GitHub for the verdict itself instead. */ statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))),}); const RawStackMembershipSchema = Schema.Struct({ stack: Schema.optional( Schema.NullOr( Schema.Struct({ number: Schema.Int, size: Schema.Int, baseRefName: Schema.String }), ), ), stackEntry: Schema.optional(Schema.NullOr(Schema.Struct({ position: Schema.Int }))),}); /** * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional * because a node that is not a pull request decodes as an empty object, which is skipped. */const RawSearchItemSchema = Schema.Struct({ ...RawStackMembershipSchema.fields, number: Schema.Int, title: Schema.String, url: Schema.String, author: Schema.optional(Schema.NullOr(RawActorSchema)), headRefName: Schema.String, baseRefName: Schema.String, state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), mergeable: Schema.optional(Schema.NullOr(Schema.String)), reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), createdAt: Schema.String, updatedAt: Schema.String, mergedAt: Schema.optional(Schema.NullOr(Schema.String)), repository: Schema.optional(Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.String }))), reviewRequests: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.optional( Schema.NullOr( Schema.Array( Schema.NullOr( Schema.Struct({ requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), }), ), ), ), ), }), ), ), labels: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawLabelSchema)))), }), ), ), /** * GraphQL answers the rollup a listing actually wants — one enum for the head commit, rather * than the whole check array `gh pr list --json` insists on. Measured at a hundred rows across * this repository: 0.8s -> 3.0s and 15 KB, against 425 KB for the same verdict over `gh`. */ commits: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.optional( Schema.NullOr( Schema.Array( Schema.NullOr( Schema.Struct({ commit: Schema.optional( Schema.NullOr( Schema.Struct({ statusCheckRollup: Schema.optional( Schema.NullOr(Schema.Struct({ state: Schema.String })), ), }), ), ), }), ), ), ), ), }), ), ),}); const RawSearchSchema = Schema.Struct({ data: Schema.Struct({ search: Schema.Struct({ pageInfo: Schema.optional(Schema.NullOr(Schema.Struct({ hasNextPage: Schema.Boolean }))), // Row by row, like the listing's own: a node that is not a pull request — or one field // GitHub changes — is skipped rather than blanking every repository at once. nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), }), }),}); /** One aliased lookup per row, so the response is keyed by the position it was asked in. */const RawStatsSchema = Schema.Struct({ data: Schema.optional( Schema.NullOr( Schema.Record( Schema.String, Schema.NullOr( Schema.Struct({ pullRequest: Schema.optional( Schema.NullOr( Schema.Struct({ additions: Schema.optional(Schema.NullOr(Schema.Int)), deletions: Schema.optional(Schema.NullOr(Schema.Int)), }), ), ), }), ), ), ), ),}); const RawStackMembershipsSchema = Schema.Struct({ data: Schema.Record( Schema.String, Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(RawStackMembershipSchema) })), ),}); /** How many of a reaction's people the hover names before it counts the rest. */const REACTORS_PER_GROUP = 10; /** * A reaction group as every reactable node reports it. `reactors` is bounded rather than paged: * a hover says who reacted, and a hundred and forty names is a count, not a sentence. */const REACTION_GROUPS_FIELDS = `reactionGroups { content viewerHasReacted reactors(first: ${REACTORS_PER_GROUP}) { totalCount nodes { ... on User { login } ... on Bot { login } ... on Organization { login } ... on Mannequin { login } } }}`; /** GitHub's reaction names, which are the same eight the contract carries under other spellings. */const REACTION_CONTENT_BY_GITHUB: Readonly<Record<string, PullRequestReactionContent>> = { THUMBS_UP: "thumbs-up", THUMBS_DOWN: "thumbs-down", LAUGH: "laugh", HOORAY: "hooray", CONFUSED: "confused", HEART: "heart", ROCKET: "rocket", EYES: "eyes",}; const GITHUB_REACTION_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = { "thumbs-up": "THUMBS_UP", "thumbs-down": "THUMBS_DOWN", laugh: "LAUGH", hooray: "HOORAY", confused: "CONFUSED", heart: "HEART", rocket: "ROCKET", eyes: "EYES",}; export function gitHubReactionContent(content: PullRequestReactionContent): string { return GITHUB_REACTION_BY_CONTENT[content];} const RawReactionGroupsSchema = Schema.optional( Schema.NullOr( Schema.Array( Schema.Struct({ content: Schema.optional(Schema.NullOr(Schema.String)), viewerHasReacted: Schema.optional(Schema.Boolean), reactors: Schema.optional( Schema.NullOr( Schema.Struct({ totalCount: Schema.optional(Schema.Int), nodes: Schema.optional( Schema.NullOr( Schema.Array( Schema.NullOr( Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) }), ), ), ), ), }), ), ), }), ), ),); type RawReactionGroups = typeof RawReactionGroupsSchema.Type; /** * The groups GitHub answered with, as the contract carries them. A group with nobody behind it is * dropped: GitHub answers with a group per content it knows, including the ones nobody chose. The * viewer's own login is left out of `actors` — the page names them "You" instead, and leaving it * in would name them twice — but `count` still counts them along with everyone else. */function toReactions( groups: RawReactionGroups, viewer: string | null,): ReadonlyArray<PullRequestReaction> { const normalizedViewer = viewer?.toLowerCase() ?? null; const reactions: PullRequestReaction[] = []; for (const group of groups ?? []) { const content = REACTION_CONTENT_BY_GITHUB[trimmed(group.content)?.toUpperCase() ?? ""]; if (content === undefined) continue; const logins = (group.reactors?.nodes ?? []).flatMap((node) => trimmed(node?.login) ?? []); const count = Math.max(group.reactors?.totalCount ?? logins.length, logins.length); if (count <= 0) continue; const actors = normalizedViewer === null ? logins : logins.filter((login) => login.toLowerCase() !== normalizedViewer); reactions.push({ content, count, actors, viewerHasReacted: group.viewerHasReacted === true }); } return reactions;} const RawCommentSchema = Schema.Struct({ id: Schema.String, author: Schema.optional(Schema.NullOr(RawActorSchema)), body: Schema.optional(Schema.String), createdAt: Schema.String, url: Schema.optional(Schema.NullOr(Schema.String)), /** Only ever present on a GraphQL read; `gh pr view --json` reports no reaction at all. */ reactionGroups: RawReactionGroupsSchema,}); const RawReviewSchema = Schema.Struct({ id: Schema.String, author: Schema.optional(Schema.NullOr(RawActorSchema)), body: Schema.optional(Schema.String), state: Schema.optional(Schema.NullOr(Schema.String)), submittedAt: Schema.optional(Schema.NullOr(Schema.String)), url: Schema.optional(Schema.NullOr(Schema.String)),}); const RawCommitSchema = Schema.Struct({ oid: Schema.String, messageHeadline: Schema.optional(Schema.String), committedDate: Schema.String, authors: Schema.optional( Schema.Array( Schema.Struct({ email: Schema.optional(Schema.NullOr(Schema.String)), id: Schema.optional(Schema.NullOr(Schema.String)), login: Schema.optional(Schema.NullOr(Schema.String)), name: Schema.optional(Schema.NullOr(Schema.String)), }), ), ),}); const RawDetailSchema = Schema.Struct({ ...RawListItemSchema.fields, /** GitHub's explicit distinction between a fork head and a branch in the base repository. */ isCrossRepository: Schema.optional(Schema.Boolean), /** Names the fork a pull request came from, which is what qualifies its head ref. */ headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), /** The exact head revision, used to find workflow runs that GitHub has not started yet. */ headRefOid: Schema.optional(Schema.NullOr(Schema.String)), body: Schema.optional(Schema.String), changedFiles: Schema.optional(Schema.Int), closedAt: Schema.optional(Schema.NullOr(Schema.String)), /** The standing instruction and strategy GitHub will use once its requirements are met. */ autoMergeRequest: Schema.optional( Schema.NullOr(Schema.Struct({ mergeMethod: Schema.optional(Schema.NullOr(Schema.String)) })), ),}); const RawWorkflowRunApprovalSchema = Schema.Struct({ databaseId: Schema.Int, workflowName: Schema.optional(Schema.NullOr(Schema.String)), url: Schema.optional(Schema.NullOr(Schema.String)),}); const RawPullRequestHeadSchema = Schema.Struct({ number: Schema.Int, headRefOid: Schema.String, isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))),}); const RawActivitySchema = Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)), comments: Schema.optional(Schema.Array(RawCommentSchema)), reviews: Schema.optional(Schema.Array(RawReviewSchema)), commits: Schema.optional(Schema.Array(RawCommitSchema)),}); /** Where a connection carries on from, which is what every paged read below follows. */const RawPageInfoSchema = Schema.Struct({ hasNextPage: Schema.optional(Schema.Boolean), endCursor: Schema.optional(Schema.NullOr(Schema.String)),}); /** * What GitHub says the viewer may do with a pull request. Both are optional so that an install * that answers without them still delivers the conversation they travel with; an absent field * reads as granted, which is what an unknown permission is. */const RawViewerFieldsSchema = Schema.Struct({ viewerCanUpdate: Schema.optional(Schema.Boolean), viewerDidAuthor: Schema.optional(Schema.Boolean),}); const RawThreadCommentsSchema = Schema.Struct({ totalCount: Schema.optional(Schema.Int), pageInfo: Schema.optional(RawPageInfoSchema), nodes: Schema.Array(RawCommentSchema),}); /** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */const RawReviewThreadsSchema = Schema.Struct({ data: Schema.Struct({ // Rides along in the same request: GitHub names who reacted but never says whether that is // the reader, so the comparison is made here rather than paid for with a request of its own. viewer: Schema.optional( Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), ), repository: Schema.Struct({ pullRequest: Schema.Struct({ reviewThreads: Schema.Struct({ totalCount: Schema.optional(Schema.Int), pageInfo: Schema.optional(RawPageInfoSchema), nodes: Schema.Array( Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)), isResolved: Schema.optional(Schema.Boolean), isOutdated: Schema.optional(Schema.Boolean), path: Schema.optional(Schema.NullOr(Schema.String)), /** Null once the thread's line has left the diff, which `isOutdated` reports. */ line: Schema.optional(Schema.NullOr(Schema.Int)), diffSide: Schema.optional(Schema.NullOr(Schema.String)), comments: RawThreadCommentsSchema, }), ), }), ...RawViewerFieldsSchema.fields, author: Schema.optional(Schema.NullOr(RawActorSchema)), reactionGroups: RawReactionGroupsSchema, comments: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)), author: Schema.optional(Schema.NullOr(RawActorSchema)), reactionGroups: RawReactionGroupsSchema, }), ), }), ), ), /** * Reviews for their reactions alone: the words and the verdict arrive with * `gh pr view --json reviews`, which reports no reaction of any kind. */ reviews: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)), reactionGroups: RawReactionGroupsSchema, }), ), }), ), ), reviewRequests: Schema.optional( Schema.NullOr( Schema.Struct({Lines 1–500 of 2592. Load more
import * as Cause from "effect/Cause"; import * as Exit from "effect/Exit"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { PullRequestStackMembership, PullRequestActor, PullRequestCheck, PullRequestCheckStatus, PullRequestChecksState, PullRequestComment, PullRequestCommit, PullRequestLabel, PullRequestMergeCapabilities, PullRequestMergeMethod, PullRequestOmittedFileStat, PullRequestMergeability, PullRequestReaction, PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewDecision, PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidate, PullRequestReviewerCandidateList, PullRequestReviewerKind, PullRequestLabelCandidate, PullRequestLabelCandidateList, PullRequestState, PullRequestThreadComment, } from "@t3tools/contracts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { dedupeChecks } from "./pullRequestChecks.ts"; /** * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh` * release that adds a conclusion or a review state must not fail the whole payload. */ const RawActorSchema = Schema.Struct({ /** * Optional because a review can be requested from a team or a mannequin, which the query has * no fragment for and GraphQL answers with an empty object. A reviewer with no login names * nobody to show, and must not fail the response the conversation travels in. */ login: Schema.optional(Schema.String), /** The node id, which is how a listing's authors are resolved to avatars in one request. */ id: Schema.optional(Schema.NullOr(Schema.String)), name: Schema.optional(Schema.NullOr(Schema.String)), /** Only the GraphQL API reports one; `gh pr view --json` has no avatar to give. */ avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), }); const RawLabelSchema = Schema.Struct({ name: Schema.String, color: Schema.optional(Schema.NullOr(Schema.String)), }); const RawReviewRequestSchema = Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)), slug: Schema.optional(Schema.NullOr(Schema.String)), name: Schema.optional(Schema.NullOr(Schema.String)), }); const RawCheckSchema = Schema.Struct({ __typename: Schema.optional(Schema.String), name: Schema.optional(Schema.NullOr(Schema.String)), context: Schema.optional(Schema.NullOr(Schema.String)), status: Schema.optional(Schema.NullOr(Schema.String)), conclusion: Schema.optional(Schema.NullOr(Schema.String)), state: Schema.optional(Schema.NullOr(Schema.String)), description: Schema.optional(Schema.NullOr(Schema.String)), detailsUrl: Schema.optional(Schema.NullOr(Schema.String)), targetUrl: Schema.optional(Schema.NullOr(Schema.String)), /** * What tells two same-named checks apart, and which run of one is the newest. All three ride * along with `statusCheckRollup` already — it is asked for as a whole field — so reading them * costs no request. Empty for an app-provided check run, which belongs to no workflow, and * absent entirely on a commit status, which is not a run at all. */ workflowName: Schema.optional(Schema.NullOr(Schema.String)), startedAt: Schema.optional(Schema.NullOr(Schema.String)), completedAt: Schema.optional(Schema.NullOr(Schema.String)), }); const RawListItemSchema = Schema.Struct({ number: Schema.Int, title: Schema.String, url: Schema.String, author: Schema.optional(Schema.NullOr(RawActorSchema)), headRefName: Schema.String, baseRefName: Schema.String, state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), mergeable: Schema.optional(Schema.NullOr(Schema.String)), reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), additions: Schema.optional(Schema.Int), deletions: Schema.optional(Schema.Int), createdAt: Schema.String, updatedAt: Schema.String, mergedAt: Schema.optional(Schema.NullOr(Schema.String)), reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), labels: Schema.optional(Schema.Array(RawLabelSchema)), /** * Every check of the head commit, which is the only rollup `gh pr list --json` can give: there * is no field for the one-word verdict. Measured against `pingdotgg/t3code`, asking for it costs * 0.6s -> 7.9s at a hundred rows and 0.9s -> 2.1s at thirty, for 425 KB of checks a listing * reduces to one word. The listing pays it because the alternative is a request per row; the * cross-repository search below asks GitHub for the verdict itself instead. */ statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), }); const RawStackMembershipSchema = Schema.Struct({ stack: Schema.optional( Schema.NullOr( Schema.Struct({ number: Schema.Int, size: Schema.Int, baseRefName: Schema.String }), ), ), stackEntry: Schema.optional(Schema.NullOr(Schema.Struct({ position: Schema.Int }))), }); /** * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional * because a node that is not a pull request decodes as an empty object, which is skipped. */ const RawSearchItemSchema = Schema.Struct({ ...RawStackMembershipSchema.fields, number: Schema.Int, title: Schema.String, url: Schema.String, author: Schema.optional(Schema.NullOr(RawActorSchema)), headRefName: Schema.String, baseRefName: Schema.String, state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), mergeable: Schema.optional(Schema.NullOr(Schema.String)), reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), createdAt: Schema.String, updatedAt: Schema.String, mergedAt: Schema.optional(Schema.NullOr(Schema.String)), repository: Schema.optional(Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.String }))), reviewRequests: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.optional( Schema.NullOr( Schema.Array( Schema.NullOr( Schema.Struct({ requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), }), ), ), ), ), }), ), ), labels: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawLabelSchema)))), }), ), ), /** * GraphQL answers the rollup a listing actually wants — one enum for the head commit, rather * than the whole check array `gh pr list --json` insists on. Measured at a hundred rows across * this repository: 0.8s -> 3.0s and 15 KB, against 425 KB for the same verdict over `gh`. */ commits: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.optional( Schema.NullOr( Schema.Array( Schema.NullOr( Schema.Struct({ commit: Schema.optional( Schema.NullOr( Schema.Struct({ statusCheckRollup: Schema.optional( Schema.NullOr(Schema.Struct({ state: Schema.String })), ), }), ), ), }), ), ), ), ), }), ), ), }); const RawSearchSchema = Schema.Struct({ data: Schema.Struct({ search: Schema.Struct({ pageInfo: Schema.optional(Schema.NullOr(Schema.Struct({ hasNextPage: Schema.Boolean }))), // Row by row, like the listing's own: a node that is not a pull request — or one field // GitHub changes — is skipped rather than blanking every repository at once. nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), }), }), }); /** One aliased lookup per row, so the response is keyed by the position it was asked in. */ const RawStatsSchema = Schema.Struct({ data: Schema.optional( Schema.NullOr( Schema.Record( Schema.String, Schema.NullOr( Schema.Struct({ pullRequest: Schema.optional( Schema.NullOr( Schema.Struct({ additions: Schema.optional(Schema.NullOr(Schema.Int)), deletions: Schema.optional(Schema.NullOr(Schema.Int)), }), ), ), }), ), ), ), ), }); const RawStackMembershipsSchema = Schema.Struct({ data: Schema.Record( Schema.String, Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(RawStackMembershipSchema) })), ), }); /** How many of a reaction's people the hover names before it counts the rest. */ const REACTORS_PER_GROUP = 10; /** * A reaction group as every reactable node reports it. `reactors` is bounded rather than paged: * a hover says who reacted, and a hundred and forty names is a count, not a sentence. */ const REACTION_GROUPS_FIELDS = `reactionGroups { content viewerHasReacted reactors(first: ${REACTORS_PER_GROUP}) { totalCount nodes { ... on User { login } ... on Bot { login } ... on Organization { login } ... on Mannequin { login } } } }`; /** GitHub's reaction names, which are the same eight the contract carries under other spellings. */ const REACTION_CONTENT_BY_GITHUB: Readonly<Record<string, PullRequestReactionContent>> = { THUMBS_UP: "thumbs-up", THUMBS_DOWN: "thumbs-down", LAUGH: "laugh", HOORAY: "hooray", CONFUSED: "confused", HEART: "heart", ROCKET: "rocket", EYES: "eyes", }; const GITHUB_REACTION_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = { "thumbs-up": "THUMBS_UP", "thumbs-down": "THUMBS_DOWN", laugh: "LAUGH", hooray: "HOORAY", confused: "CONFUSED", heart: "HEART", rocket: "ROCKET", eyes: "EYES", }; export function gitHubReactionContent(content: PullRequestReactionContent): string { return GITHUB_REACTION_BY_CONTENT[content]; } const RawReactionGroupsSchema = Schema.optional( Schema.NullOr( Schema.Array( Schema.Struct({ content: Schema.optional(Schema.NullOr(Schema.String)), viewerHasReacted: Schema.optional(Schema.Boolean), reactors: Schema.optional( Schema.NullOr( Schema.Struct({ totalCount: Schema.optional(Schema.Int), nodes: Schema.optional( Schema.NullOr( Schema.Array( Schema.NullOr( Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) }), ), ), ), ), }), ), ), }), ), ), ); type RawReactionGroups = typeof RawReactionGroupsSchema.Type; /** * The groups GitHub answered with, as the contract carries them. A group with nobody behind it is * dropped: GitHub answers with a group per content it knows, including the ones nobody chose. The * viewer's own login is left out of `actors` — the page names them "You" instead, and leaving it * in would name them twice — but `count` still counts them along with everyone else. */ function toReactions( groups: RawReactionGroups, viewer: string | null, ): ReadonlyArray<PullRequestReaction> { const normalizedViewer = viewer?.toLowerCase() ?? null; const reactions: PullRequestReaction[] = []; for (const group of groups ?? []) { const content = REACTION_CONTENT_BY_GITHUB[trimmed(group.content)?.toUpperCase() ?? ""]; if (content === undefined) continue; const logins = (group.reactors?.nodes ?? []).flatMap((node) => trimmed(node?.login) ?? []); const count = Math.max(group.reactors?.totalCount ?? logins.length, logins.length); if (count <= 0) continue; const actors = normalizedViewer === null ? logins : logins.filter((login) => login.toLowerCase() !== normalizedViewer); reactions.push({ content, count, actors, viewerHasReacted: group.viewerHasReacted === true }); } return reactions; } const RawCommentSchema = Schema.Struct({ id: Schema.String, author: Schema.optional(Schema.NullOr(RawActorSchema)), body: Schema.optional(Schema.String), createdAt: Schema.String, url: Schema.optional(Schema.NullOr(Schema.String)), /** Only ever present on a GraphQL read; `gh pr view --json` reports no reaction at all. */ reactionGroups: RawReactionGroupsSchema, }); const RawReviewSchema = Schema.Struct({ id: Schema.String, author: Schema.optional(Schema.NullOr(RawActorSchema)), body: Schema.optional(Schema.String), state: Schema.optional(Schema.NullOr(Schema.String)), submittedAt: Schema.optional(Schema.NullOr(Schema.String)), url: Schema.optional(Schema.NullOr(Schema.String)), }); const RawCommitSchema = Schema.Struct({ oid: Schema.String, messageHeadline: Schema.optional(Schema.String), committedDate: Schema.String, authors: Schema.optional( Schema.Array( Schema.Struct({ email: Schema.optional(Schema.NullOr(Schema.String)), id: Schema.optional(Schema.NullOr(Schema.String)), login: Schema.optional(Schema.NullOr(Schema.String)), name: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), }); const RawDetailSchema = Schema.Struct({ ...RawListItemSchema.fields, /** GitHub's explicit distinction between a fork head and a branch in the base repository. */ isCrossRepository: Schema.optional(Schema.Boolean), /** Names the fork a pull request came from, which is what qualifies its head ref. */ headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), /** The exact head revision, used to find workflow runs that GitHub has not started yet. */ headRefOid: Schema.optional(Schema.NullOr(Schema.String)), body: Schema.optional(Schema.String), changedFiles: Schema.optional(Schema.Int), closedAt: Schema.optional(Schema.NullOr(Schema.String)), /** The standing instruction and strategy GitHub will use once its requirements are met. */ autoMergeRequest: Schema.optional( Schema.NullOr(Schema.Struct({ mergeMethod: Schema.optional(Schema.NullOr(Schema.String)) })), ), }); const RawWorkflowRunApprovalSchema = Schema.Struct({ databaseId: Schema.Int, workflowName: Schema.optional(Schema.NullOr(Schema.String)), url: Schema.optional(Schema.NullOr(Schema.String)), }); const RawPullRequestHeadSchema = Schema.Struct({ number: Schema.Int, headRefOid: Schema.String, isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), }); const RawActivitySchema = Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)), comments: Schema.optional(Schema.Array(RawCommentSchema)), reviews: Schema.optional(Schema.Array(RawReviewSchema)), commits: Schema.optional(Schema.Array(RawCommitSchema)), }); /** Where a connection carries on from, which is what every paged read below follows. */ const RawPageInfoSchema = Schema.Struct({ hasNextPage: Schema.optional(Schema.Boolean), endCursor: Schema.optional(Schema.NullOr(Schema.String)), }); /** * What GitHub says the viewer may do with a pull request. Both are optional so that an install * that answers without them still delivers the conversation they travel with; an absent field * reads as granted, which is what an unknown permission is. */ const RawViewerFieldsSchema = Schema.Struct({ viewerCanUpdate: Schema.optional(Schema.Boolean), viewerDidAuthor: Schema.optional(Schema.Boolean), }); const RawThreadCommentsSchema = Schema.Struct({ totalCount: Schema.optional(Schema.Int), pageInfo: Schema.optional(RawPageInfoSchema), nodes: Schema.Array(RawCommentSchema), }); /** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */ const RawReviewThreadsSchema = Schema.Struct({ data: Schema.Struct({ // Rides along in the same request: GitHub names who reacted but never says whether that is // the reader, so the comparison is made here rather than paid for with a request of its own. viewer: Schema.optional( Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), ), repository: Schema.Struct({ pullRequest: Schema.Struct({ reviewThreads: Schema.Struct({ totalCount: Schema.optional(Schema.Int), pageInfo: Schema.optional(RawPageInfoSchema), nodes: Schema.Array( Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)), isResolved: Schema.optional(Schema.Boolean), isOutdated: Schema.optional(Schema.Boolean), path: Schema.optional(Schema.NullOr(Schema.String)), /** Null once the thread's line has left the diff, which `isOutdated` reports. */ line: Schema.optional(Schema.NullOr(Schema.Int)), diffSide: Schema.optional(Schema.NullOr(Schema.String)), comments: RawThreadCommentsSchema, }), ), }), ...RawViewerFieldsSchema.fields, author: Schema.optional(Schema.NullOr(RawActorSchema)), reactionGroups: RawReactionGroupsSchema, comments: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)), author: Schema.optional(Schema.NullOr(RawActorSchema)), reactionGroups: RawReactionGroupsSchema, }), ), }), ), ), /** * Reviews for their reactions alone: the words and the verdict arrive with * `gh pr view --json reviews`, which reports no reaction of any kind. */ reviews: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)), reactionGroups: RawReactionGroupsSchema, }), ), }), ), ), reviewRequests: Schema.optional( Schema.NullOr( Schema.Struct({