1590 lines · 59.4 KB · TypeScript
RawDownload
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.