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 { PullRequestActor, PullRequestCheck, PullRequestCheckStatus, PullRequestComment, PullRequestCommit, PullRequestLabel, PullRequestMergeCapabilities, PullRequestMergeability, PullRequestReviewCommentDraft, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidate, PullRequestReviewerCandidateList, PullRequestReviewerKind, PullRequestState, PullRequestThreadComment,} from "@t3tools/contracts";import { decodeJsonResult } from "@t3tools/shared/schemaJson"; /** * 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 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)), 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)),}); /** * 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({ 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)), 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)))), }), ), ),}); 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 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)),}); 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)),}); 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, body: Schema.optional(Schema.String), changedFiles: Schema.optional(Schema.Int), closedAt: Schema.optional(Schema.NullOr(Schema.String)), statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))),}); 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({ 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)), comments: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)) }), ), }), ), ), reviewRequests: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ // Null for a team, which is a request nobody in particular owns. requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), }), ), }), ), ), latestReviews: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)) }), ), }), ), ), commits: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ commit: Schema.Struct({ oid: Schema.String, messageHeadline: Schema.optional(Schema.NullOr(Schema.String)), committedDate: Schema.optional(Schema.NullOr(Schema.String)), additions: Schema.optional(Schema.Int), deletions: Schema.optional(Schema.Int), authors: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)), avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), user: Schema.optional( Schema.NullOr( Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), }), ), }), ), ), }), }), ), }), ), ), }), }), }),}); /** Requested together, so a response missing any of them fails rather than defaulting open: * guessing `true` would offer a merge method the repository forbids. */const RawRepositoryAccessSchema = Schema.Struct({ mergeCommitAllowed: Schema.Boolean, squashMergeAllowed: Schema.Boolean, rebaseMergeAllowed: Schema.Boolean, /** * ADMIN, MAINTAIN, WRITE, TRIAGE, READ or NONE. Optional rather than required, unlike the * three above: an install that does not report it leaves the viewer's standing unknown, which * is answered by granting rather than by failing the whole detail read. */ viewerPermission: Schema.optional(Schema.NullOr(Schema.String)),}); const RawPullRequestFileSchema = Schema.Struct({ filename: Schema.String, status: Schema.optional(Schema.NullOr(Schema.String)), /** Only on a rename, where it names the file the hunks are counted against. */ previous_filename: Schema.optional(Schema.NullOr(Schema.String)), /** Absent for a binary file, and for one whose diff GitHub considers too large. */ patch: Schema.optional(Schema.NullOr(Schema.String)), /** Whether anything was withheld is the difference between a binary file and a pure rename. */ additions: Schema.optional(Schema.NullOr(Schema.Int)), deletions: Schema.optional(Schema.NullOr(Schema.Int)),}); /** Resolves a listing's authors to avatars, which no `gh` JSON field carries. */export const ACTOR_AVATARS_GRAPHQL_QUERY = `query($ids: [ID!]!) { nodes(ids: $ids) { ... on User { login avatarUrl } ... on Bot { login avatarUrl } }}`; const RawActorAvatarsSchema = Schema.Struct({ data: Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawActorSchema)), }),}); const decodeActorAvatars = decodeJsonResult(RawActorAvatarsSchema); export function decodeActorAvatarsJson( raw: string,): Result.Result<ReadonlyMap<string, string>, DecodeFailure> { const decoded = decodeActorAvatars(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const avatarsByLogin = new Map<string, string>(); for (const node of decoded.success.data.nodes) { const login = trimmed(node?.login); const avatarUrl = trimmed(node?.avatarUrl); if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); } return Result.succeed(avatarsByLogin);} export const PULL_REQUEST_LIST_JSON_FIELDS = "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels"; export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,statusCheckRollup`;export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */const GRAPHQL_PAGE_SIZE = 100; /** * The ceiling on `search`, which refuses anything larger with EXCESSIVE_PAGINATION (measured: * `first: 101` is an error, `first: 100` is not). */export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; /** * Every repository of a host in one read, which is what makes a listing one request rather than * one process per repository. * * `additions` and `deletions` are deliberately absent: measured over twelve repositories at a * hundred rows, this query answers in ~4.0s with them left out and ~7.1s with them in, for two * numbers at the end of a row. They are read afterwards, by `buildPullRequestStatsGraphQlQuery`. * * The row count is written into the document rather than sent as a variable because every * variable here travels as a string — and it is this module's own number, clamped by the caller, * never a reader's. * * `first` on the two inner connections is a bound rather than a page: a pull request with more * than twenty labels shows twenty, and one that has asked more than twenty people for a review * is already past what a row can say. */export function pullRequestSearchGraphQlQuery(rows: number): string { return `query($q: String!) { search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) { pageInfo { hasNextPage } nodes { ... on PullRequest { number title url author { login avatarUrl ... on User { name } } headRefName baseRefName state isDraft mergeable createdAt updatedAt mergedAt repository { nameWithOwner } reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } } } } labels(first: 20) { nodes { name color } } } } }}`;} /** * One page of review threads with their comments, and the people on the review. `$cursor` is * null for the first page and the last page's `endCursor` after that, so a pull request with * more threads than one page holds is walked rather than cut off at the first fifty. * * Reviewers come from here rather than from `gh pr view --json reviewRequests` for two reasons: * that field holds only requests still outstanding, so anyone who has already reviewed drops off * it, and neither it nor any other `gh` JSON field carries an avatar. A reviewer can be a person * or an app, and both are asked for by name because they are different GraphQL types. * * `viewerCanUpdate` and `viewerDidAuthor` ride along here for the same reason: they belong to the * pull request this query is already standing on, so what the reader may do with it arrives with * the conversation rather than costing a request of its own. * * Commits are asked for with `last` rather than `first`: `gh pr view --json commits` pages from * the start, so a pull request with more than a hundred commits loses the newest ones from its * view entirely. This query gives back the newest hundred, which is what a reader scoping a diff * wants, and stands in for the `gh` list wherever it came back non-empty. */export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewThreads(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { totalCount pageInfo { hasNextPage endCursor } nodes { id isResolved isOutdated path line diffSide comments(first: ${GRAPHQL_PAGE_SIZE}) { totalCount pageInfo { hasNextPage endCursor } nodes { id author { login avatarUrl } body createdAt url } } } } viewerCanUpdate viewerDidAuthor author { login avatarUrl } comments(first: ${GRAPHQL_PAGE_SIZE}) { nodes { author { login avatarUrl } } } reviewRequests(first: 50) { nodes { requestedReviewer { ... on User { login name avatarUrl } ... on Bot { login avatarUrl } } } } latestReviews(first: 50) { nodes { author { login avatarUrl } } } commits(last: ${GRAPHQL_PAGE_SIZE}) { nodes { commit {Lines 1–500 of 1590. 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 { PullRequestActor, PullRequestCheck, PullRequestCheckStatus, PullRequestComment, PullRequestCommit, PullRequestLabel, PullRequestMergeCapabilities, PullRequestMergeability, PullRequestReviewCommentDraft, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidate, PullRequestReviewerCandidateList, PullRequestReviewerKind, PullRequestState, PullRequestThreadComment, } from "@t3tools/contracts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; /** * 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 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)), 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)), }); /** * 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({ 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)), 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)))), }), ), ), }); 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 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)), }); 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)), }); 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, body: Schema.optional(Schema.String), changedFiles: Schema.optional(Schema.Int), closedAt: Schema.optional(Schema.NullOr(Schema.String)), statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), }); 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({ 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)), comments: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)) }), ), }), ), ), reviewRequests: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ // Null for a team, which is a request nobody in particular owns. requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), }), ), }), ), ), latestReviews: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)) }), ), }), ), ), commits: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ commit: Schema.Struct({ oid: Schema.String, messageHeadline: Schema.optional(Schema.NullOr(Schema.String)), committedDate: Schema.optional(Schema.NullOr(Schema.String)), additions: Schema.optional(Schema.Int), deletions: Schema.optional(Schema.Int), authors: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)), avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), user: Schema.optional( Schema.NullOr( Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), }), ), }), ), ), }), }), ), }), ), ), }), }), }), }); /** Requested together, so a response missing any of them fails rather than defaulting open: * guessing `true` would offer a merge method the repository forbids. */ const RawRepositoryAccessSchema = Schema.Struct({ mergeCommitAllowed: Schema.Boolean, squashMergeAllowed: Schema.Boolean, rebaseMergeAllowed: Schema.Boolean, /** * ADMIN, MAINTAIN, WRITE, TRIAGE, READ or NONE. Optional rather than required, unlike the * three above: an install that does not report it leaves the viewer's standing unknown, which * is answered by granting rather than by failing the whole detail read. */ viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), }); const RawPullRequestFileSchema = Schema.Struct({ filename: Schema.String, status: Schema.optional(Schema.NullOr(Schema.String)), /** Only on a rename, where it names the file the hunks are counted against. */ previous_filename: Schema.optional(Schema.NullOr(Schema.String)), /** Absent for a binary file, and for one whose diff GitHub considers too large. */ patch: Schema.optional(Schema.NullOr(Schema.String)), /** Whether anything was withheld is the difference between a binary file and a pure rename. */ additions: Schema.optional(Schema.NullOr(Schema.Int)), deletions: Schema.optional(Schema.NullOr(Schema.Int)), }); /** Resolves a listing's authors to avatars, which no `gh` JSON field carries. */ export const ACTOR_AVATARS_GRAPHQL_QUERY = `query($ids: [ID!]!) { nodes(ids: $ids) { ... on User { login avatarUrl } ... on Bot { login avatarUrl } } }`; const RawActorAvatarsSchema = Schema.Struct({ data: Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawActorSchema)), }), }); const decodeActorAvatars = decodeJsonResult(RawActorAvatarsSchema); export function decodeActorAvatarsJson( raw: string, ): Result.Result<ReadonlyMap<string, string>, DecodeFailure> { const decoded = decodeActorAvatars(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const avatarsByLogin = new Map<string, string>(); for (const node of decoded.success.data.nodes) { const login = trimmed(node?.login); const avatarUrl = trimmed(node?.avatarUrl); if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); } return Result.succeed(avatarsByLogin); } export const PULL_REQUEST_LIST_JSON_FIELDS = "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels"; export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,statusCheckRollup`; export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ const GRAPHQL_PAGE_SIZE = 100; /** * The ceiling on `search`, which refuses anything larger with EXCESSIVE_PAGINATION (measured: * `first: 101` is an error, `first: 100` is not). */ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; /** * Every repository of a host in one read, which is what makes a listing one request rather than * one process per repository. * * `additions` and `deletions` are deliberately absent: measured over twelve repositories at a * hundred rows, this query answers in ~4.0s with them left out and ~7.1s with them in, for two * numbers at the end of a row. They are read afterwards, by `buildPullRequestStatsGraphQlQuery`. * * The row count is written into the document rather than sent as a variable because every * variable here travels as a string — and it is this module's own number, clamped by the caller, * never a reader's. * * `first` on the two inner connections is a bound rather than a page: a pull request with more * than twenty labels shows twenty, and one that has asked more than twenty people for a review * is already past what a row can say. */ export function pullRequestSearchGraphQlQuery(rows: number): string { return `query($q: String!) { search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) { pageInfo { hasNextPage } nodes { ... on PullRequest { number title url author { login avatarUrl ... on User { name } } headRefName baseRefName state isDraft mergeable createdAt updatedAt mergedAt repository { nameWithOwner } reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } } } } labels(first: 20) { nodes { name color } } } } } }`; } /** * One page of review threads with their comments, and the people on the review. `$cursor` is * null for the first page and the last page's `endCursor` after that, so a pull request with * more threads than one page holds is walked rather than cut off at the first fifty. * * Reviewers come from here rather than from `gh pr view --json reviewRequests` for two reasons: * that field holds only requests still outstanding, so anyone who has already reviewed drops off * it, and neither it nor any other `gh` JSON field carries an avatar. A reviewer can be a person * or an app, and both are asked for by name because they are different GraphQL types. * * `viewerCanUpdate` and `viewerDidAuthor` ride along here for the same reason: they belong to the * pull request this query is already standing on, so what the reader may do with it arrives with * the conversation rather than costing a request of its own. * * Commits are asked for with `last` rather than `first`: `gh pr view --json commits` pages from * the start, so a pull request with more than a hundred commits loses the newest ones from its * view entirely. This query gives back the newest hundred, which is what a reader scoping a diff * wants, and stands in for the `gh` list wherever it came back non-empty. */ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewThreads(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { totalCount pageInfo { hasNextPage endCursor } nodes { id isResolved isOutdated path line diffSide comments(first: ${GRAPHQL_PAGE_SIZE}) { totalCount pageInfo { hasNextPage endCursor } nodes { id author { login avatarUrl } body createdAt url } } } } viewerCanUpdate viewerDidAuthor author { login avatarUrl } comments(first: ${GRAPHQL_PAGE_SIZE}) { nodes { author { login avatarUrl } } } reviewRequests(first: 50) { nodes { requestedReviewer { ... on User { login name avatarUrl } ... on Bot { login avatarUrl } } } } latestReviews(first: 50) { nodes { author { login avatarUrl } } } commits(last: ${GRAPHQL_PAGE_SIZE}) { nodes { commit {