feat(github): pull request tool window, timeline and review flow (#477)

Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication.

- List with search, quick filters and paging; a tab per pull request and a New Pull Request tab.
- Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen.
- Timeline editor: comments, review threads, reactions and suggested changes.
- Review in the diff viewer and, for the checked-out branch, in the editor (review mode).
- New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands.
- CSP: img-src allows https://avatars.githubusercontent.com for avatars.

Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane.

Co-authored-by: imacross <30753807+imacross@users.noreply.github.com>
This commit is contained in:
imacross
2026-10-31 07:50:43 +08:00
committed by GitHub
parent 696377acc2
commit 059fd8a69b
192 changed files with 23668 additions and 2076 deletions
+112
View File
@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fragmentsOf, withFragments } from "./document";
import { FRAGMENTS } from "./fragments";
import * as Q from "./queries";
import { GHGraphQLError, graphqlData, parseRest } from "./client";
import { timelineItems } from "./requests";
const definitions = (text: string) => [...text.matchAll(/^\s*fragment\s+(\S+)\s+on\s/gm)].map((m) => m[1]!);
const spreads = (text: string) => new Set([...text.matchAll(/^\s*\.\.\.([A-Za-z_]\w*)/gm)].map((m) => m[1]!));
describe("GraphQL documents", () => {
it("ports every IntelliJ fragment and query", () => {
expect(Object.keys(FRAGMENTS)).toHaveLength(39);
expect(Object.keys(Q)).toHaveLength(40);
for (const [name, text] of Object.entries(FRAGMENTS)) expect(definitions(text), name).toEqual([name]);
});
it.each(Object.entries(Q))("sends %s with every fragment it needs, once", (_name, doc) => {
const text = withFragments(doc);
const defined = definitions(text);
expect(new Set(defined).size).toBe(defined.length);
for (const s of spreads(text)) expect(defined, s).toContain(s);
// Only fragments that are spread somewhere are added.
for (const d of fragmentsOf(doc)) expect(spreads(text).has(d), d).toBe(true);
});
it("resolves fragments transitively, like GHGQLQueryLoader", () => {
const names = fragmentsOf(Q.issueSearch);
expect(names).toEqual(
expect.arrayContaining([
"pageInfo",
"pullRequestInfoShort",
"rates",
"nodeInfo",
"actor",
"actorInfo",
"userInfo",
"user",
"labelInfo",
"pullRequestReviewer",
"teamInfo",
"pullRequestReview",
"reactions",
"reaction",
"enterpriseUserAccountInfo",
"organizationInfo",
]),
);
expect(names).not.toContain("repository");
// Fragments go first, the operation last.
expect(withFragments(Q.issueSearch).trim()).toMatch(/^fragment[\s\S]*\nquery issueSearch[\s\S]*\}$/);
});
it("does not add fragments a document defines itself", () => {
const text = withFragments(Q.pullRequestTimeline);
expect(definitions(text).filter((d) => d === "gitRefName")).toHaveLength(1);
expect(fragmentsOf(Q.pullRequestTimeline)).not.toContain("gitRefName");
expect(fragmentsOf(Q.pullRequestTimeline)).not.toContain("issueCommentInfo");
});
it("treats inline fragments as selections, not spreads", () => {
expect(fragmentsOf(Q.findPendingReview)).toEqual(expect.arrayContaining(["pullRequestPendingReview", "nodeInfo"]));
expect(fragmentsOf(`query { node(id: "x") { ... on User { id } } }`)).toEqual([]);
});
it("fails on an unknown fragment", () => {
expect(() => withFragments(`query {\n viewer {\n ...nope\n }\n}`)).toThrow(/nope/);
});
});
describe("client", () => {
afterEach(() => vi.restoreAllMocks());
it("returns data and throws GraphQL errors", () => {
expect(graphqlData<{ a: number }>('{"data":{"a":1}}')).toEqual({ a: 1 });
expect(() => graphqlData('{"errors":[{"message":"bad"},{"message":"worse"}]}')).toThrow(GHGraphQLError);
expect(() => graphqlData('{"errors":[{"message":"bad"}]}')).toThrow("bad");
});
it("flattens slurped REST pages and maps empty bodies to null", () => {
expect(parseRest("[[1,2],[3]]", true)).toEqual([1, 2, 3]);
expect(parseRest('[{"a":1}]', false)).toEqual([{ a: 1 }]);
expect(parseRest(" ", false)).toBeNull();
});
it("sends documents with their fragments through gh", async () => {
const ipc = await import("@/platform/ipc");
const ghApi = vi
.spyOn(ipc.commands, "ghApi")
.mockResolvedValue({ status: "ok", data: '{"data":{"repository":null}}' } as never);
const { graphql } = await import("./client");
await graphql(Q.findRepository, { repoOwner: "o", repoName: "r", cursor: undefined });
const req = ghApi.mock.calls[0]![0];
expect(req).toMatchObject({ method: "POST", endpoint: "graphql", paginate: false });
const body = JSON.parse(req.body!) as { query: string; variables: Record<string, unknown> };
expect(body.variables).toEqual({ repoOwner: "o", repoName: "r" });
expect(definitions(body.query)).toEqual(expect.arrayContaining(["repository", "repositoryOwnerName", "rates"]));
});
});
describe("timeline items", () => {
it("drops IntelliJ's ignored types and marks unknown ones", () => {
const items = timelineItems([
{ __typename: "IssueComment" },
{ __typename: "MentionedEvent" },
{ __typename: "SubscribedEvent" },
{ __typename: "AutoMergeEnabledEvent" },
]);
expect(items.map((i) => i.__typename)).toEqual(["IssueComment", "Unknown"]);
expect(items[1]).toEqual({ __typename: "Unknown", type: "AutoMergeEnabledEvent" });
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* GitHub API access through gh (`commands.ghApi`, which runs `gh api` in the project root
* with gh's authentication), like IntelliJ's `GithubApiRequestExecutor`:
*
* const data = await graphql<{ repository: … }>(findRepository, { repoOwner, repoName });
* const page = await rest<GHLabelDto[]>("GET", `${repoPath()}/labels?per_page=100`);
*
* Documents are sent with the fragments they spread (`withFragments`). Requests address
* the connected repository explicitly (`repoPath()`, `repoVariables()`), not gh's
* `{owner}/{repo}` placeholders, since a project can have several GitHub remotes.
*/
import { call, commands } from "@/platform/ipc";
import { withFragments } from "./document";
import type { GHRepositoryCoordinates } from "./types";
export type GHApiMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
/** A GraphQL response with `errors` (IntelliJ's `GithubConfusingException`). */
export class GHGraphQLError extends Error {
constructor(
message: string,
readonly errors: ReadonlyArray<{ message: string; type?: string; path?: unknown[] }>,
) {
super(message);
this.name = "GHGraphQLError";
}
}
export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e));
/** `data` of a GraphQL response body; throws on `errors` (gh usually fails the call first). */
export function graphqlData<T>(text: string): T {
const json = JSON.parse(text) as { data?: T; errors?: Array<{ message: string; type?: string; path?: unknown[] }> };
if (json.errors?.length) throw new GHGraphQLError(json.errors.map((e) => e.message).join("\n"), json.errors);
if (json.data === undefined) throw new GHGraphQLError("GitHub returned no data", []);
return json.data;
}
/** Runs a document from `queries.ts` with `variables` (undefined values are dropped). */
export async function graphql<T>(doc: string, variables: Record<string, unknown> = {}): Promise<T> {
const body = JSON.stringify({ query: withFragments(doc), variables: definedOnly(variables) });
const text = await call(commands.ghApi({ method: "POST", endpoint: "graphql", body, paginate: false }));
return graphqlData<T>(text);
}
function definedOnly(v: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(v).filter(([, x]) => x !== undefined));
}
/**
* A REST call (`endpoint` relative to the API root, e.g. `repos/o/r/pulls/1/files`).
* `paginate` follows every `Link` page and concatenates array pages. Empty bodies
* (`204 No Content`) come back as `null`.
*/
export async function rest<T>(
method: GHApiMethod,
endpoint: string,
body?: unknown,
opts: { paginate?: boolean } = {},
): Promise<T> {
const text = await call(
commands.ghApi({
method,
endpoint,
body: body === undefined ? null : JSON.stringify(body),
paginate: opts.paginate ?? false,
}),
);
return parseRest<T>(text, opts.paginate ?? false);
}
/** Parses a REST body; `--slurp` pages (an array of pages) are flattened when they are arrays. */
export function parseRest<T>(text: string, paginated: boolean): T {
if (!text.trim()) return null as T;
const json = JSON.parse(text) as unknown;
if (paginated && Array.isArray(json) && json.every(Array.isArray)) return (json as unknown[][]).flat() as T;
return json as T;
}
// ---------------------------------------------------------------------------
// The connected repository
let current: (GHRepositoryCoordinates & { id: string | null }) | null = null;
/** Set by `data/repository.ts` when the tool window connects (null: disconnected). */
export function setCurrentRepository(repo: (GHRepositoryCoordinates & { id: string | null }) | null) {
current = repo;
}
/** The connected repository: `owner`, `name` and its GraphQL node `id` (once loaded). */
export function currentRepository(): GHRepositoryCoordinates & { id: string | null } {
if (!current) throw new Error("Not connected to a GitHub repository");
return current;
}
/** `repos/owner/name`, the prefix of the connected repository's REST endpoints. */
export function repoPath(repo: GHRepositoryCoordinates = currentRepository()): string {
return `repos/${repo.owner}/${repo.name}`;
}
/** The `$repoOwner` / `$repoName` variables of the repository documents. */
export function repoVariables(repo: GHRepositoryCoordinates = currentRepository()) {
return { repoOwner: repo.owner, repoName: repo.name };
}
+78
View File
@@ -0,0 +1,78 @@
/**
* GraphQL document assembly, like IntelliJ's `CachingGraphQLQueryLoader`: a document is
* sent with every fragment it spreads (`...name`, where the fragment is named after its
* file), transitively. Fragments the document defines itself are not added again.
*/
import { FRAGMENTS } from "./fragments";
interface Block {
body: string;
/** Fragments spread in the block and not defined in it. */
dependencies: Set<string>;
/** Fragments the block defines itself. */
defines: Set<string>;
}
const FRAGMENT_DEFINITION = /^fragment\s+(\S+)\s+on\s+.*\{$/;
const SPREAD = /^\.\.\.([A-Za-z_]\w*)/;
function readBlock(text: string): Block {
const dependencies = new Set<string>();
const defines = new Set<string>();
const lines: string[] = [];
for (const raw of text.split("\n")) {
const line = raw.trim();
lines.push(line);
const def = FRAGMENT_DEFINITION.exec(line);
if (def) defines.add(def[1]!);
// `... on User {` is an inline fragment, not a spread.
const spread = SPREAD.exec(line);
if (spread) dependencies.add(spread[1]!);
}
for (const d of defines) dependencies.delete(d);
return { body: lines.join("\n").trim(), dependencies, defines };
}
const fragmentBlocks = new Map<string, Block>();
const documents = new Map<string, { text: string; fragments: string[] }>();
function fragmentBlock(name: string): Block {
let block = fragmentBlocks.get(name);
if (!block) {
const text = FRAGMENTS[name];
if (text === undefined) throw new Error(`Couldn't find file for fragment ${name}`);
block = readBlock(text);
fragmentBlocks.set(name, block);
}
return block;
}
function resolve(doc: string): { text: string; fragments: string[] } {
const cached = documents.get(doc);
if (cached) return cached;
const { body, dependencies, defines } = readBlock(doc);
const found = new Map<string, Block>();
const visit = (names: Iterable<string>) => {
for (const name of names) {
if (found.has(name) || defines.has(name)) continue;
const block = fragmentBlock(name);
found.set(name, block);
visit(block.dependencies);
}
};
visit(dependencies);
const blocks = [...found.values()].reverse();
const resolved = { text: [...blocks.map((f) => f.body), body].join("\n"), fragments: [...found.keys()] };
documents.set(doc, resolved);
return resolved;
}
/** `doc` preceded by the fragments it needs, ready to send (cached per document). */
export function withFragments(doc: string): string {
return resolve(doc).text;
}
/** Names of the fragments `withFragments(doc)` adds, in discovery order. */
export function fragmentsOf(doc: string): string[] {
return resolve(doc).fragments;
}
+505
View File
@@ -0,0 +1,505 @@
/**
* GraphQL fragments of IntelliJ's GitHub plugin (`resources/graphql/fragment`, plus
* collaboration-tools' `pageInfo`), verbatim. Each one is named after its file, the
* name it is spread with (`...name`); `withFragments` adds the ones a document needs.
*/
export const FRAGMENTS: Readonly<Record<string, string>> = {
actor: `
fragment actor on Actor {
...actorInfo
... on User {
...userInfo
}
... on EnterpriseUserAccount {
...enterpriseUserAccountInfo
}
... on Organization {
...organizationInfo
}
}
`,
actorInfo: `
# Just the data that should be present for any type of Actor.
fragment actorInfo on Actor {
__typename
...nodeInfo
login
url
avatarUrl
}
`,
checkRun: `
fragment checkRun on CheckRun {
name
conclusion
isRequired(pullRequestNumber: $number) # See: findPullRequestMergeability
detailsUrl
url
}
`,
checkSuite: `
fragment checkSuite on CheckSuite {
checkRuns(first: 100) {
pageInfo {
...pageInfo
}
nodes {
...checkRun
}
}
}
`,
comment: `
fragment comment on Comment {
...nodeInfo
author {
...actor
}
body
createdAt
...reactions
}
`,
commit: `
fragment commit on Commit {
...commitShort
committer {
...gitActorInfo
}
messageBody
committedDate
parents(first: 100) {
nodes {
...commitHash
}
}
}
`,
commitHash: `
fragment commitHash on Commit {
...nodeInfo
oid
abbreviatedOid
}
`,
commitShort: `
fragment commitShort on Commit {
...commitHash
url
messageHeadline
author {
...gitActorInfo
}
}
`,
commitStatusContext: `
fragment commitStatusContext on StatusContext {
context
state
isRequired(pullRequestNumber: $number) # See: findPullRequestMergeability
targetUrl
}
`,
commitStatusRollup: `
fragment commitStatusRollup on StatusCheckRollup {
contexts(first: $pageSize, after: $cursor) {
pageInfo {
...pageInfo
}
nodes {
__typename
... on CheckRun {
checkSuite {
workflowRun {
workflow {
name
}
}
}
name
title
status
conclusion
url
}
... on StatusContext {
context
description
state
targetUrl
}
}
}
}
`,
commitStatusRollupShort: `
fragment commitStatusRollupShort on StatusCheckRollup {
state
}
`,
commitWithCheckStatuses: `
fragment commitWithCheckStatuses on Commit {
...commitHash
status {
contexts {
...commitStatusContext
}
}
checkSuites(first: 100) {
pageInfo {
...pageInfo
}
nodes {
...checkSuite
}
}
}
`,
enterpriseUserAccountInfo: `
fragment enterpriseUserAccountInfo on EnterpriseUserAccount {
name
}
`,
gitActorInfo: `
fragment gitActorInfo on GitActor {
name
email
avatarUrl
date
user {
...user
}
}
`,
gitRefName: `
fragment gitRefName on Ref {
name
}
`,
labelInfo: `
fragment labelInfo on Label {
...nodeInfo
url
name
color
}
`,
nodeInfo: `
fragment nodeInfo on Node {
id
}
`,
organizationInfo: `
fragment organizationInfo on Organization {
name
}
`,
pageInfo: `
fragment pageInfo on PageInfo {
startCursor
hasPreviousPage
endCursor
hasNextPage
}
`,
pullRequestBranchMatch: `
fragment pullRequestBranchMatch on PullRequest {
...nodeInfo
number
title
headRefName
}
`,
pullRequestCommitWithCheckStatuses: `
fragment pullRequestCommitWithCheckStatuses on PullRequestCommit {
...nodeInfo
commit {
...commitWithCheckStatuses
}
}
`,
pullRequestInfo: `
fragment pullRequestInfo on PullRequest {
...pullRequestInfoShort
body
author {
...actor
}
createdAt
reviewDecision
baseRefName
baseRefOid
baseRepository {
...repository
}
headRef {
id
}
headRefName
headRefOid
headRepository {
...repository
}
viewerCanDeleteHeadRef
}
`,
pullRequestInfoShort: `
fragment pullRequestInfoShort on PullRequest {
...nodeInfo
url
number
title
state
isDraft
author {
...actor
}
createdAt
updatedAt
isReadByViewer
assignees(first: 100) {
nodes {
...user
}
}
labels(first: 100) {
nodes {
...labelInfo
}
}
reviewRequests(first: 100) {
nodes {
... on ReviewRequest {
requestedReviewer {
...pullRequestReviewer
}
}
}
}
reviewThreads(last: 100) {
nodes {
isResolved
isOutdated
}
}
reviews(last: 100) {
nodes {
...pullRequestReview
}
}
...reactions
mergeable
viewerCanUpdate
viewerCanReact
viewerDidAuthor
}
`,
pullRequestMergeability: `
fragment pullRequestMergeability on PullRequest {
mergeable
canBeRebased
mergeStateStatus
baseRef {
refUpdateRule {
...refUpdateRule
}
}
commits(last: 1) {
pageInfo {
...pageInfo
}
nodes {
...pullRequestCommitWithCheckStatuses
}
}
}
`,
pullRequestPendingReview: `
fragment pullRequestPendingReview on PullRequestReview {
...nodeInfo
state
comments(first: 0) {
totalCount
}
}
`,
pullRequestReview: `
fragment pullRequestReview on PullRequestReview {
__typename
...nodeInfo
url
author {
...actor
}
body
state
createdAt
viewerCanUpdate
}
`,
pullRequestReviewComment: `
fragment pullRequestReviewComment on PullRequestReviewComment {
...comment
url
state
commit {
...commitHash
}
originalCommit {
...commitHash
}
diffHunk
pullRequestReview {
...nodeInfo
}
viewerCanDelete
viewerCanUpdate
viewerCanReact
}
`,
pullRequestReviewer: `
fragment pullRequestReviewer on RequestedReviewer {
... on Actor {
...actorInfo
}
... on User {
...userInfo
}
... on Team {
...teamInfo
}
# For future enthusiasts: be careful with adding ... on Bot, as it's been added only in GHE 3.11
}
`,
pullRequestReviewThread: `
fragment pullRequestReviewThread on PullRequestReviewThread {
...nodeInfo
isResolved
isOutdated
path
subjectType
diffSide
line
originalLine
startDiffSide
startLine
originalStartLine
comments(first: 100) {
nodes {
...pullRequestReviewComment
}
}
viewerCanReply
viewerCanResolve
viewerCanUnresolve
}
`,
pullRequestTemplate: `
fragment pullRequestTemplate on PullRequestTemplate {
body
}
`,
rates: `
fragment rates on RateLimit {
cost
}
`,
reaction: `
fragment reaction on Reaction {
content
user {
...user
}
}
`,
reactions: `
fragment reactions on Reactable {
reactions(first: 25) {
pageInfo {
...pageInfo
}
nodes {
...reaction
}
}
}
`,
refUpdateRule: `
fragment refUpdateRule on RefUpdateRule {
allowsDeletions
allowsForcePushes
pattern
requiredApprovingReviewCount
requiredStatusCheckContexts
requiresLinearHistory
requiresSignatures
viewerCanPush
}
`,
repository: `
fragment repository on Repository {
...nodeInfo
owner {
...repositoryOwnerName
}
nameWithOwner
viewerPermission
mergeCommitAllowed
squashMergeAllowed
rebaseMergeAllowed
defaultBranchRef {
...gitRefName
}
isFork
url
sshUrl
}
`,
repositoryOwnerName: `
fragment repositoryOwnerName on RepositoryOwner {
__typename
...nodeInfo
login
}
`,
teamInfo: `
fragment teamInfo on Team {
__typename
...nodeInfo
slug
url
teamAvatarUrl: avatarUrl
teamName: name
combinedSlug
}
`,
user: `
fragment user on User {
...actorInfo
...userInfo
}
`,
userInfo: `
fragment userInfo on User {
name
}
`,
};
+852
View File
@@ -0,0 +1,852 @@
/**
* GraphQL documents of IntelliJ's GitHub plugin (`resources/graphql/query`), verbatim and
* named after their files. They spread fragments from `fragments.ts`, which
* `withFragments` (in `document.ts`) appends before a document is sent.
*/
export const addPullRequestReviewThread = `
mutation ($body: String!, $line: Int!, $path: String!, $pullRequestReviewId: ID, $side: DiffSide, $startSide: DiffSide, $startLine: Int) {
addPullRequestReviewThread(input: {body: $body, line: $line, path: $path, pullRequestReviewId:$pullRequestReviewId, side: $side, startSide: $startSide, startLine: $startLine}) {
thread {
...pullRequestReviewThread
}
}
}
`;
export const addReaction = `
mutation ($id: ID!, $reaction: ReactionContent!) {
addReaction(input: {
subjectId: $id,
content: $reaction
}) {
reaction {
...reaction
}
}
}
`;
export const addReviewComment = `
mutation ($reviewId: ID!, $body: String!, $commit: GitObjectID, $file: String, $position: Int, $inReplyTo: ID) {
addPullRequestReviewComment(input: {pullRequestReviewId: $reviewId, body: $body, commitOID: $commit, path: $file, position: $position, inReplyTo: $inReplyTo}) {
comment {
...pullRequestReviewComment
}
}
}
`;
export const createPullRequest = `
mutation ($repositoryId: ID!, $baseRefName: String!, $headRefName: String!, $title: String!, $body: String, $draft: Boolean = false) {
createPullRequest(input: {
repositoryId: $repositoryId,
baseRefName: $baseRefName,
headRefName: $headRefName,
title: $title,
body: $body,
draft: $draft
}) {
pullRequest {
...pullRequestInfoShort
}
}
}
`;
export const createReview = `
mutation ($pullRequestId: ID!, $event: PullRequestReviewEvent, $body: String, $commitOid: GitObjectID, $threads: [DraftPullRequestReviewThread]) {
addPullRequestReview(input: {pullRequestId: $pullRequestId, event: $event, body: $body, commitOID: $commitOid, threads: $threads}) {
pullRequestReview {
...pullRequestPendingReview
}
}
}
`;
export const deleteIssueComment = `
mutation ($id: ID!) {
deleteIssueComment(input: {id: $id}) {
__typename
}
}
`;
export const deleteRef = `
mutation ($refId: ID!) {
deleteRef(input: {refId: $refId}) {
clientMutationId
}
}
`;
export const deleteReview = `
mutation ($reviewId: ID!){
deletePullRequestReview(input: {pullRequestReviewId: $reviewId}) {
__typename
}
}
`;
export const deleteReviewComment = `
mutation ($id: ID!) {
deletePullRequestReviewComment(input: {id: $id}) {
pullRequestReview {
...pullRequestPendingReview
}
}
}
`;
export const findMentionableUsers = `
query($repoOwner: String!, $repoName: String!, $pageSize: Int = 100, $cursor: String) {
repository(owner: $repoOwner, name: $repoName) {
mentionableUsers(first: $pageSize, after: $cursor) {
pageInfo {
...pageInfo
}
nodes {
...user
}
}
}
rateLimit {
...rates
}
}
`;
export const findOrganizationTeams = `
query($organization: String!, $pageSize: Int = 100, $cursor: String) {
organization(login: $organization) {
teams(first: $pageSize, after: $cursor) {
pageInfo {
...pageInfo
}
nodes {
...teamInfo
}
}
}
rateLimit {
...rates
}
}
`;
export const findPendingReview = `
query($pullRequestId: ID!) {
node(id: $pullRequestId) {
... on PullRequest {
reviews(states: [PENDING], first: 1) {
nodes {
...pullRequestPendingReview
}
}
}
}
rateLimit {
...rates
}
}
`;
export const findPullRequest = `
query($repoOwner: String!, $repoName: String!, $number: Int!) {
repository(owner: $repoOwner, name: $repoName) {
pullRequest(number: $number) {
...pullRequestInfo
}
}
rateLimit {
...rates
}
}
`;
export const findPullRequestId = `
query($repoOwner: String!, $repoName: String!, $number: Int!) {
repository(owner: $repoOwner, name: $repoName) {
pullRequest(number: $number) {
...nodeInfo
number
}
}
rateLimit {
...rates
}
}
`;
export const findPullRequestMergeability = `
query($repoOwner: String!, $repoName: String!, $number: Int!) {
repository(owner: $repoOwner, name: $repoName) {
pullRequest(number: $number) {
...pullRequestMergeability
}
}
rateLimit {
...rates
}
}
`;
export const findPullRequestsByHeadBranch = `
query findPullRequestsByHeadBranch($query: String!, $pageSize: Int = 100, $cursor: String) {
search(first: $pageSize, after: $cursor, type: ISSUE_ADVANCED, query: $query) {
pageInfo {
...pageInfo
}
nodes {
...pullRequestBranchMatch
}
}
rateLimit {
...rates
}
}
`;
export const findRepository = `
query($repoOwner: String!, $repoName: String!) {
repository(owner: $repoOwner, name: $repoName) {
...repository
}
rateLimit {
...rates
}
}
`;
export const findUser = `
query($login: String!) {
user(login: $login) {
__typename
...user
}
rateLimit {
...rates
}
}
`;
export const getProtectionRules = `
query($repoOwner: String!, $repoName: String!, $pageSize: Int = 100, $cursor: String) {
repository(owner: $repoOwner, name: $repoName) {
branchProtectionRules(first: $pageSize, after: $cursor) {
pageInfo {
...pageInfo
}
nodes {
...nodeInfo
pattern
}
}
}
rateLimit {
...rates
}
}
`;
export const getPullRequestTemplates = `
query($repoOwner: String!, $repoName: String!) {
repository(owner: $repoOwner, name: $repoName) {
pullRequestTemplates {
...pullRequestTemplate
}
}
rateLimit {
...rates
}
}
`;
export const getRepositoryCommitStatusContexts = `
query($repoOwner: String!, $repoName: String!, $oid: GitObjectID!, $pageSize: Int, $cursor: String) {
repository(owner: $repoOwner, name: $repoName) {
object(oid: $oid) {
... on Commit {
statusCheckRollup {
...commitStatusRollup
}
}
}
}
rateLimit {
...rates
}
}
`;
export const getRepositoryCommitStatusShort = `
query($repoOwner: String!, $repoName: String!, $oid: GitObjectID!) {
repository(owner: $repoOwner, name: $repoName) {
object(oid: $oid) {
... on Commit {
statusCheckRollup {
...commitStatusRollupShort
}
}
}
}
rateLimit {
...rates
}
}
`;
// `ISSUE_ADVANCED` rather than IntelliJ's `ISSUE`: only the advanced search understands
// `OR`, `AND` and parentheses in the typed query.
export const issueSearch = `
query issueSearch($query: String!, $pageSize: Int = 100, $cursor: String) {
search(first: $pageSize, after: $cursor, type: ISSUE_ADVANCED, query: $query) {
pageInfo {
...pageInfo
}
nodes {
...pullRequestInfoShort
}
}
rateLimit {
...rates
}
}
`;
export const markFileAsViewed = `
mutation ($pullRequestId: ID!, $path: String!) {
markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path }) {
__typename
}
}
`;
export const markPullRequestReadyForReview = `
mutation ($pullRequestId: ID!) {
markPullRequestReadyForReview(input: {pullRequestId: $pullRequestId}) {
__typename
}
}
`;
export const metrics = `
query metrics(
$allPRCountQuery: String!,
$openPRCountQuery: String!,
$openAuthoredPRCountQuery: String!,
$openAssigneePRCountQuery: String!,
$openReviewedPRCountQuery: String!,
$openReviewAssignedPRCountQuery: String!,
) {
allPRCount: search(type: ISSUE, query: $allPRCountQuery) {
issueCount
}
openPRCount: search(type: ISSUE, query: $openPRCountQuery) {
issueCount
}
openAuthoredPRCount: search(type: ISSUE, query: $openAuthoredPRCountQuery) {
issueCount
}
openAssigneePRCount: search(type: ISSUE, query: $openAssigneePRCountQuery) {
issueCount
}
openReviewedPRCount: search(type: ISSUE, query: $openReviewedPRCountQuery) {
issueCount
}
openReviewAssignedPRCount: search(type: ISSUE, query: $openReviewAssignedPRCountQuery) {
issueCount
}
rateLimit {
...rates
}
}
`;
export const pullRequestCommits = `
query($repoOwner: String!, $repoName: String!, $number: Int!, $pageSize: Int = 100, $cursor: String) {
repository(owner: $repoOwner, name: $repoName) {
pullRequest(number: $number) {
commits(first: $pageSize, after: $cursor) {
pageInfo {
...pageInfo
}
nodes {
...nodeInfo
commit {
...commit
}
url
}
}
}
}
rateLimit {
...rates
}
}
`;
export const pullRequestFiles = `
query($repoOwner: String!, $repoName: String!, $number: Int!, $pageSize: Int = 100, $cursor: String) {
repository(owner: $repoOwner, name: $repoName) {
pullRequest(number: $number) {
files(first: $pageSize, after: $cursor) {
pageInfo {
...pageInfo
}
nodes {
path
viewerViewedState
}
}
}
}
rateLimit {
...rates
}
}
`;
export const pullRequestParticipants = `
query($repoOwner: String!, $repoName: String!, $number: Int!, $pageSize: Int = 100, $cursor: String) {
repository(owner: $repoOwner, name: $repoName) {
pullRequest(number: $number) {
participants(first: $pageSize, after: $cursor) {
pageInfo {
...pageInfo
}
nodes {
...user
}
}
}
}
rateLimit {
...rates
}
}
`;
export const pullRequestReviewThreads = `
query($repoOwner: String!, $repoName: String!, $number: Int!, $pageSize: Int = 100, $cursor: String) {
repository(owner: $repoOwner, name: $repoName) {
pullRequest(number: $number) {
reviewThreads(first: $pageSize, after: $cursor) {
pageInfo {
...pageInfo
}
nodes {
...pullRequestReviewThread
}
}
}
}
rateLimit {
...rates
}
}
`;
export const pullRequestTimeline = `
fragment issueCommentInfo on IssueComment {
...comment
viewerCanUpdate
viewerCanDelete
viewerCanReact
}
fragment prCommitInfo on PullRequestCommit {
...nodeInfo
commit {
...commitShort
}
url
}
fragment reviewDismissedEvent on ReviewDismissedEvent {
actor {
...actor
}
createdAt
dismissalMessageHTML
review {
author {
...actor
}
}
}
fragment readyForReviewEvent on ReadyForReviewEvent {
actor {
...actor
}
createdAt
}
fragment convertToDraftEvent on ConvertToDraftEvent {
actor {
...actor
}
createdAt
}
fragment renamedEvent on RenamedTitleEvent {
actor {
...actor
}
previousTitle
currentTitle
createdAt
}
fragment closedEvent on ClosedEvent {
actor {
...actor
}
createdAt
}
fragment reopenedEvent on ReopenedEvent {
actor {
...actor
}
createdAt
}
fragment mergedEvent on MergedEvent {
actor {
...actor
}
createdAt
mergedEventCommit: commit {
...commitShort
}
mergeRefName
}
fragment assignedEvent on AssignedEvent {
actor {
...actor
}
createdAt
user {
...user
}
}
fragment unassignedEvent on UnassignedEvent {
actor {
...actor
}
createdAt
user {
...user
}
}
fragment labeledEvent on LabeledEvent {
actor {
...actor
}
createdAt
label {
...labelInfo
}
}
fragment unlabeledEvent on UnlabeledEvent {
actor {
...actor
}
createdAt
label {
...labelInfo
}
}
fragment reviewRequestedEvent on ReviewRequestedEvent {
actor {
...actor
}
createdAt
requestedReviewer {
...pullRequestReviewer
}
}
fragment reviewRequestRemovedEvent on ReviewRequestRemovedEvent {
actor {
...actor
}
createdAt
requestedReviewer {
...pullRequestReviewer
}
}
fragment gitRefName on Ref {
name
}
fragment baseRefForcePushedEvent on BaseRefForcePushedEvent {
actor {
...actor
}
createdAt
ref {
...gitRefName
}
}
fragment headRefForcePushedEvent on HeadRefForcePushedEvent {
actor {
...actor
}
createdAt
ref {
...gitRefName
}
beforeCommit {
...commitHash
}
afterCommit {
...commitHash
}
}
fragment headRefDeletedEvent on HeadRefDeletedEvent {
actor {
...actor
}
createdAt
headRefName
}
fragment headRefRestoredEvent on HeadRefRestoredEvent {
actor {
...actor
}
createdAt
}
fragment baseRefChangedEvent on BaseRefChangedEvent {
actor {
...actor
}
createdAt
}
fragment referenceSubject on ReferencedSubject {
__typename
... on PullRequest {
title
number
prState: state
isDraft
url
}
... on Issue {
title
number
issueState: state
url
}
}
fragment crossReferencedEvent on CrossReferencedEvent {
actor {
...actor
}
createdAt
source {
...referenceSubject
}
}
fragment connectedEvent on ConnectedEvent {
actor {
...actor
}
createdAt
subject {
...referenceSubject
}
}
fragment disconnectedEvent on DisconnectedEvent {
actor {
...actor
}
createdAt
subject {
...referenceSubject
}
}
query($repoOwner: String!, $repoName: String!, $number: Int!, $pageSize: Int = 100, $cursor: String, $since: DateTime) {
repository(owner: $repoOwner, name: $repoName) {
pullRequest(number: $number) {
timelineItems(first: $pageSize, after: $cursor, since: $since) {
pageInfo {
...pageInfo
}
nodes {
__typename
...prCommitInfo
...pullRequestReview
...issueCommentInfo
...renamedEvent
...closedEvent
...reopenedEvent
...mergedEvent
...assignedEvent
...unassignedEvent
...labeledEvent
...unlabeledEvent
...reviewRequestedEvent
...reviewRequestRemovedEvent
...reviewDismissedEvent
...readyForReviewEvent
...convertToDraftEvent
...baseRefChangedEvent
...baseRefForcePushedEvent
...headRefForcePushedEvent
...headRefDeletedEvent
...headRefRestoredEvent
...crossReferencedEvent
...connectedEvent
...disconnectedEvent
}
}
}
}
rateLimit {
...rates
}
}
`;
export const removeReaction = `
mutation ($id: ID!, $reaction: ReactionContent!) {
removeReaction(input: {
subjectId: $id,
content: $reaction
}) {
reaction {
...reaction
}
}
}
`;
export const resolveReviewThread = `
mutation ($threadId: ID!) {
resolveReviewThread(input: {threadId: $threadId}) {
thread {
...pullRequestReviewThread
}
}
}
`;
export const submitReview = `
mutation ($reviewId: ID!, $event: PullRequestReviewEvent!, $body: String){
submitPullRequestReview(input: {pullRequestReviewId: $reviewId, event: $event, body: $body}) {
__typename
}
}
`;
export const unmarkFileAsViewed = `
mutation ($pullRequestId: ID!, $path: String!) {
unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path }) {
__typename
}
}
`;
export const unresolveReviewThread = `
mutation ($threadId: ID!) {
unresolveReviewThread(input: {threadId: $threadId}) {
thread {
...pullRequestReviewThread
}
}
}
`;
export const updateIssueComment = `
mutation ($id: ID!, $body: String!) {
updateIssueComment(input: {id: $id, body: $body}) {
issueComment {
...comment
}
}
}
`;
export const updatePullRequest = `
mutation ($pullRequestId: ID!, $title: String, $body: String) {
updatePullRequest(input: {pullRequestId: $pullRequestId, title: $title, body: $body}) {
pullRequest {
...pullRequestInfo
}
}
}
`;
export const updateReview = `
mutation ($reviewId: ID!, $body: String!){
updatePullRequestReview(input: {pullRequestReviewId: $reviewId, body: $body}) {
pullRequestReview {
...pullRequestReview
}
}
}
`;
export const updateReviewComment = `
mutation ($id: ID!, $body: String!) {
updatePullRequestReviewComment(input: {pullRequestReviewCommentId: $id, body: $body}) {
pullRequestReviewComment {
...pullRequestReviewComment
}
}
}
`;
+619
View File
@@ -0,0 +1,619 @@
/**
* Typed GitHub requests, one per IntelliJ `GHGQLRequests` / `GithubApiRequests` call the
* plugin makes. Everything addresses the connected repository (`client.ts`) unless a
* repository is passed. Mutations only send; `data/operations.ts` refreshes what they
* change.
*/
import { graphql, repoPath, repoVariables, rest } from "./client";
import * as Q from "./queries";
import type {
GHCollaborator,
GHCommitFile,
GHCommitStatusRollupContext,
GHConnection,
GHDiffSide,
GHComment,
GHLabel,
GHPRTimelineItem,
GHPullRequest,
GHPullRequestBranchMatch,
GHPullRequestChangedFile,
GHPullRequestCommit,
GHPullRequestDraftReviewThread,
GHPullRequestMergeabilityData,
GHPullRequestPendingReview,
GHPullRequestReview,
GHPullRequestReviewComment,
GHPullRequestReviewEvent,
GHPullRequestReviewThread,
GHPullRequestShort,
GHReaction,
GHReactionContent,
GHRepository,
GHRepositoryCoordinates,
GHRepositoryPullRequestTemplate,
GHTeam,
GHUser,
} from "./types";
/** IntelliJ's `PR_PAGE_SIZE`: list rows per `issueSearch` page. */
export const PR_PAGE_SIZE = 50;
type Pr = { repository: { pullRequest: Record<string, unknown> | null } | null };
/** `repository.pullRequest.<field>` of a pull request document, or null when the PR is gone. */
async function prField<T>(doc: string, number: number, field: string, vars: Record<string, unknown> = {}) {
const data = await graphql<Pr>(doc, { ...repoVariables(), number, ...vars });
return (data.repository?.pullRequest?.[field] as T | undefined) ?? null;
}
/** Every page of a cursor-paged connection, in order. */
export async function allPages<T>(page: (cursor: string | null) => Promise<GHConnection<T> | null>): Promise<T[]> {
const out: T[] = [];
let cursor: string | null = null;
for (;;) {
const c: GHConnection<T> | null = await page(cursor);
if (!c) return out;
out.push(...c.nodes);
if (!c.pageInfo.hasNextPage || !c.pageInfo.endCursor) return out;
cursor = c.pageInfo.endCursor;
}
}
// ---------------------------------------------------------------------------
// Repository and users
export async function findRepository(repo: GHRepositoryCoordinates): Promise<GHRepository | null> {
const data = await graphql<{ repository: GHRepository | null }>(Q.findRepository, repoVariables(repo));
return data.repository;
}
/**
* A user by login (`findUser`).
* @public
*/
export async function findUser(login: string): Promise<GHUser | null> {
return (await graphql<{ user: GHUser | null }>(Q.findUser, { login })).user;
}
interface RestUser {
login: string;
node_id: string;
avatar_url: string;
html_url: string;
name?: string | null;
type?: string;
}
/** A REST user as the GraphQL `user` fragment shape. */
export function restUser(u: RestUser): GHUser {
return {
__typename: "User",
id: u.node_id,
login: u.login,
url: u.html_url,
avatarUrl: u.avatar_url,
name: u.name ?? null,
};
}
/** The signed-in user (REST `user`, IntelliJ's `CurrentUser.get`). */
export async function currentUser(): Promise<GHUser> {
return restUser(await rest<RestUser>("GET", "user"));
}
/** Page `page` (1-based) of a REST list; `next` is null on the last page. */
async function restPage<T, R>(endpoint: string, page: number, map: (r: R) => T) {
const sep = endpoint.includes("?") ? "&" : "?";
const items = (await rest<R[] | null>("GET", `${endpoint}${sep}per_page=100&page=${page}`)) ?? [];
return { items: items.map(map), next: items.length === 100 ? page + 1 : null };
}
export function collaboratorsPage(page: number) {
return restPage<GHCollaborator, RestUser & { permissions?: GHCollaborator["permissions"] }>(
`${repoPath()}/collaborators`,
page,
(u) => ({
...restUser(u),
permissions: u.permissions ?? { admin: false, maintain: false, push: false, triage: false, pull: true },
}),
);
}
export function contributorsPage(page: number) {
return restPage<GHUser, RestUser>(`${repoPath()}/contributors`, page, restUser);
}
/** Users issues and pull requests can be assigned to. */
export function assigneesPage(page: number) {
return restPage<GHUser, RestUser>(`${repoPath()}/assignees`, page, restUser);
}
interface RestLabel {
node_id: string;
url: string;
name: string;
color: string;
}
const restLabel = (l: RestLabel): GHLabel => ({ id: l.node_id, url: l.url, name: l.name, color: l.color });
export function labelsPage(page: number) {
return restPage<GHLabel, RestLabel>(`${repoPath()}/labels`, page, restLabel);
}
export async function mentionableUsersPage(cursor: string | null): Promise<GHConnection<GHUser> | null> {
const data = await graphql<{ repository: { mentionableUsers: GHConnection<GHUser> } | null }>(
Q.findMentionableUsers,
{ ...repoVariables(), cursor },
);
return data.repository?.mentionableUsers ?? null;
}
export async function organizationTeamsPage(organization: string, cursor: string | null) {
const data = await graphql<{ organization: { teams: GHConnection<GHTeam> } | null }>(Q.findOrganizationTeams, {
organization,
cursor,
});
return data.organization?.teams ?? null;
}
/**
* A page of the branch protection rules (`getProtectionRules`).
* @public
*/
export async function protectionRulesPage(cursor: string | null) {
const data = await graphql<{
repository: { branchProtectionRules: GHConnection<{ id: string; pattern: string }> } | null;
}>(Q.getProtectionRules, { ...repoVariables(), cursor });
return data.repository?.branchProtectionRules ?? null;
}
export async function pullRequestTemplates(): Promise<GHRepositoryPullRequestTemplate[]> {
const data = await graphql<{ repository: { pullRequestTemplates: GHRepositoryPullRequestTemplate[] | null } | null }>(
Q.getPullRequestTemplates,
repoVariables(),
);
return data.repository?.pullRequestTemplates ?? [];
}
/**
* A page of the commit's status check rollup (`getRepositoryCommitStatusContexts`).
* @public
*/
export async function commitStatusContextsPage(oid: string, cursor: string | null, pageSize = 100) {
const data = await graphql<{
repository: {
object: { statusCheckRollup: { contexts: GHConnection<GHCommitStatusRollupContext> } | null } | null;
};
}>(Q.getRepositoryCommitStatusContexts, { ...repoVariables(), oid, pageSize, cursor });
return data.repository.object?.statusCheckRollup?.contexts ?? null;
}
/**
* The commit's overall status check state (`getRepositoryCommitStatusShort`).
* @public
*/
export async function commitStatusShort(oid: string): Promise<string | null> {
const data = await graphql<{ repository: { object: { statusCheckRollup: { state: string } | null } | null } }>(
Q.getRepositoryCommitStatusShort,
{ ...repoVariables(), oid },
);
return data.repository.object?.statusCheckRollup?.state ?? null;
}
/**
* The counts of IntelliJ's usage metrics, by query (each a full search string).
* @public
*/
export async function metrics(queries: {
allPRCountQuery: string;
openPRCountQuery: string;
openAuthoredPRCountQuery: string;
openAssigneePRCountQuery: string;
openReviewedPRCountQuery: string;
openReviewAssignedPRCountQuery: string;
}): Promise<Record<string, number>> {
const data = await graphql<Record<string, { issueCount: number }>>(Q.metrics, queries);
return Object.fromEntries(
Object.entries(data)
.filter(([k]) => k !== "rateLimit")
.map(([k, v]) => [k, v.issueCount]),
);
}
// ---------------------------------------------------------------------------
// Pull request queries
/** One `issueSearch` page: `query` is the full search string (`type:pr repo:o/r …`). */
export async function searchPullRequests(query: string, cursor: string | null, pageSize = PR_PAGE_SIZE) {
const data = await graphql<{ search: GHConnection<GHPullRequestShort | Record<string, never>> }>(Q.issueSearch, {
query,
pageSize,
cursor,
});
// `type: ISSUE` may return issues (no fields selected for them: empty objects).
const nodes = data.search.nodes.filter((n): n is GHPullRequestShort => "number" in n);
return { pageInfo: data.search.pageInfo, nodes };
}
/**
* Open pull requests whose head matches the `head:` terms of `query` (`findPullRequestsByHeadBranch`).
* @public
*/
export async function searchPullRequestsByHeadBranches(query: string, pageSize = 100) {
const data = await graphql<{ search: GHConnection<GHPullRequestBranchMatch> }>(Q.findPullRequestsByHeadBranch, {
query,
pageSize,
});
return data.search.nodes.filter((n) => "number" in n);
}
export async function findPullRequest(number: number): Promise<GHPullRequest | null> {
const data = await graphql<{ repository: { pullRequest: GHPullRequest | null } | null }>(Q.findPullRequest, {
...repoVariables(),
number,
});
return data.repository?.pullRequest ?? null;
}
export async function findPullRequestId(number: number): Promise<string | null> {
return prField<string>(Q.findPullRequestId, number, "id");
}
export async function findPullRequestMergeability(number: number): Promise<GHPullRequestMergeabilityData | null> {
const data = await graphql<{ repository: { pullRequest: GHPullRequestMergeabilityData | null } | null }>(
Q.findPullRequestMergeability,
{ ...repoVariables(), number },
);
return data.repository?.pullRequest ?? null;
}
export function pullRequestCommitsPage(number: number, cursor: string | null) {
return prField<GHConnection<GHPullRequestCommit>>(Q.pullRequestCommits, number, "commits", { cursor });
}
export function pullRequestFilesPage(number: number, cursor: string | null) {
return prField<GHConnection<GHPullRequestChangedFile>>(Q.pullRequestFiles, number, "files", { cursor });
}
export function pullRequestParticipantsPage(number: number, cursor: string | null) {
return prField<GHConnection<GHUser>>(Q.pullRequestParticipants, number, "participants", { cursor });
}
export function pullRequestReviewThreadsPage(number: number, cursor: string | null) {
return prField<GHConnection<GHPullRequestReviewThread>>(Q.pullRequestReviewThreads, number, "reviewThreads", {
cursor,
});
}
/** Timeline item types IntelliJ drops (`GHPRTimelineItem.IGNORED_TYPES`). */
const IGNORED_TIMELINE_TYPES = new Set([
"ReferencedEvent",
"MentionedEvent",
"CommentDeletedEvent",
"LockedEvent",
"UnlockedEvent",
"SubscribedEvent",
"UnsubscribedEvent",
]);
/** Item types the timeline document selects fields for. */
const TIMELINE_TYPES = new Set([
"PullRequestCommit",
"PullRequestReview",
"IssueComment",
"RenamedTitleEvent",
"ClosedEvent",
"ReopenedEvent",
"MergedEvent",
"AssignedEvent",
"UnassignedEvent",
"LabeledEvent",
"UnlabeledEvent",
"ReviewRequestedEvent",
"ReviewRequestRemovedEvent",
"ReviewDismissedEvent",
"ReadyForReviewEvent",
"ConvertToDraftEvent",
"BaseRefChangedEvent",
"BaseRefForcePushedEvent",
"HeadRefForcePushedEvent",
"HeadRefDeletedEvent",
"HeadRefRestoredEvent",
"CrossReferencedEvent",
"ConnectedEvent",
"DisconnectedEvent",
]);
/** Timeline nodes as items: ignored types dropped, others IntelliJ can't show marked `Unknown`. */
export function timelineItems(nodes: ReadonlyArray<{ __typename: string }>): GHPRTimelineItem[] {
return nodes.flatMap((n): GHPRTimelineItem[] => {
if (IGNORED_TIMELINE_TYPES.has(n.__typename)) return [];
if (TIMELINE_TYPES.has(n.__typename)) return [n as GHPRTimelineItem];
return [{ __typename: "Unknown", type: n.__typename }];
});
}
/** One page (100) of timeline items, after `cursor`. */
export async function pullRequestTimelinePage(number: number, cursor: string | null, since?: string) {
const c = await prField<GHConnection<{ __typename: string }>>(Q.pullRequestTimeline, number, "timelineItems", {
cursor,
since,
});
return c && { pageInfo: c.pageInfo, nodes: timelineItems(c.nodes) };
}
interface PendingReviewNode {
id: string;
state: GHPullRequestPendingReview["state"];
comments: { totalCount: number };
}
const pendingReview = (r: PendingReviewNode): GHPullRequestPendingReview => ({
id: r.id,
state: r.state,
commentsCount: r.comments.totalCount,
});
/** The viewer's pending (not yet submitted) review of the pull request with node id `pullRequestId`. */
export async function findPendingReview(pullRequestId: string): Promise<GHPullRequestPendingReview | null> {
const data = await graphql<{ node: { reviews?: { nodes: PendingReviewNode[] } } | null }>(Q.findPendingReview, {
pullRequestId,
});
const r = data.node?.reviews?.nodes[0];
return r ? pendingReview(r) : null;
}
interface RestCommitFile {
sha: string | null;
filename: string;
status: GHCommitFile["status"];
additions: number;
deletions: number;
changes: number;
patch?: string;
previous_filename?: string;
}
const commitFile = (f: RestCommitFile): GHCommitFile => ({
sha: f.sha,
filename: f.filename,
status: f.status,
additions: f.additions,
deletions: f.deletions,
changes: f.changes,
patch: f.patch ?? null,
previousFilename: f.previous_filename ?? null,
});
/** The pull request's changed files (REST `pulls/{n}/files`, every page). */
export async function pullRequestChangedFiles(number: number): Promise<GHCommitFile[]> {
const files = await rest<RestCommitFile[]>("GET", `${repoPath()}/pulls/${number}/files?per_page=100`, undefined, {
paginate: true,
});
return (files ?? []).map(commitFile);
}
/** The files a commit changes (REST `commits/{sha}`). */
export async function commitChangedFiles(sha: string): Promise<GHCommitFile[]> {
const commit = await rest<{ files?: RestCommitFile[] }>("GET", `${repoPath()}/commits/${sha}`);
return (commit?.files ?? []).map(commitFile);
}
/** The files changed from the merge base of `base` and `head` to `head` (REST `compare/{base}...{head}`). */
export async function compareChangedFiles(base: string, head: string): Promise<GHCommitFile[]> {
const r = await rest<{ files?: RestCommitFile[] }>("GET", `${repoPath()}/compare/${base}...${head}`);
return (r?.files ?? []).map(commitFile);
}
/**
* Open pull requests from `head` (`owner:branch`) into `base`, REST `Repos.PullRequests.find`.
*/
export async function findOpenPullRequestsByHead(
head: string,
base?: string,
): Promise<Array<{ number: number; html_url: string }>> {
const q = new URLSearchParams({ state: "open", head, ...(base ? { base } : {}), per_page: "1" });
return (await rest<Array<{ number: number; html_url: string }>>("GET", `${repoPath()}/pulls?${q}`)) ?? [];
}
// ---------------------------------------------------------------------------
// Pull request mutations (GraphQL)
export async function createPullRequest(input: {
repositoryId: string;
baseRefName: string;
/** `owner:branch` for a branch of a fork. */
headRefName: string;
title: string;
body: string | null;
draft: boolean;
}): Promise<GHPullRequestShort> {
const data = await graphql<{ createPullRequest: { pullRequest: GHPullRequestShort } }>(Q.createPullRequest, input);
return data.createPullRequest.pullRequest;
}
export async function updatePullRequest(pullRequestId: string, patch: { title?: string; body?: string }) {
const data = await graphql<{ updatePullRequest: { pullRequest: GHPullRequest } }>(Q.updatePullRequest, {
pullRequestId,
...patch,
});
return data.updatePullRequest.pullRequest;
}
export async function markPullRequestReadyForReview(pullRequestId: string): Promise<void> {
await graphql(Q.markPullRequestReadyForReview, { pullRequestId });
}
export async function markFileAsViewed(pullRequestId: string, path: string): Promise<void> {
await graphql(Q.markFileAsViewed, { pullRequestId, path });
}
export async function unmarkFileAsViewed(pullRequestId: string, path: string): Promise<void> {
await graphql(Q.unmarkFileAsViewed, { pullRequestId, path });
}
export async function deleteRef(refId: string): Promise<void> {
await graphql(Q.deleteRef, { refId });
}
/** `addPullRequestReview`: a pending review without `event`, else a submitted one. */
export async function createReview(input: {
pullRequestId: string;
event?: GHPullRequestReviewEvent | null;
body?: string | null;
commitOid?: string | null;
threads?: GHPullRequestDraftReviewThread[] | null;
}): Promise<GHPullRequestPendingReview> {
const data = await graphql<{ addPullRequestReview: { pullRequestReview: PendingReviewNode } }>(Q.createReview, input);
return pendingReview(data.addPullRequestReview.pullRequestReview);
}
export async function submitReview(reviewId: string, event: GHPullRequestReviewEvent, body?: string | null) {
await graphql(Q.submitReview, { reviewId, event, body });
}
export async function deleteReview(reviewId: string): Promise<void> {
await graphql(Q.deleteReview, { reviewId });
}
export async function updateReview(reviewId: string, body: string): Promise<GHPullRequestReview> {
const data = await graphql<{ updatePullRequestReview: { pullRequestReview: GHPullRequestReview } }>(Q.updateReview, {
reviewId,
body,
});
return data.updatePullRequestReview.pullRequestReview;
}
/** `addPullRequestReviewComment`: a reply (`inReplyTo`), or a comment on a commit position. */
export async function addReviewComment(input: {
reviewId: string;
body: string;
commit?: string | null;
file?: string | null;
position?: number | null;
inReplyTo?: string | null;
}): Promise<GHPullRequestReviewComment> {
const data = await graphql<{ addPullRequestReviewComment: { comment: GHPullRequestReviewComment } }>(
Q.addReviewComment,
input,
);
return data.addPullRequestReviewComment.comment;
}
export async function updateReviewComment(id: string, body: string): Promise<GHPullRequestReviewComment> {
const data = await graphql<{
updatePullRequestReviewComment: { pullRequestReviewComment: GHPullRequestReviewComment };
}>(Q.updateReviewComment, { id, body });
return data.updatePullRequestReviewComment.pullRequestReviewComment;
}
/** Deletes a review comment; returns its (pending) review, when it still exists. */
export async function deleteReviewComment(id: string): Promise<GHPullRequestPendingReview | null> {
const data = await graphql<{ deletePullRequestReviewComment: { pullRequestReview: PendingReviewNode | null } }>(
Q.deleteReviewComment,
{ id },
);
const r = data.deletePullRequestReviewComment.pullRequestReview;
return r ? pendingReview(r) : null;
}
export async function addReviewThread(input: {
pullRequestReviewId: string;
body: string;
path: string;
line: number;
side: GHDiffSide;
startLine?: number | null;
startSide?: GHDiffSide | null;
}): Promise<GHPullRequestReviewThread> {
const data = await graphql<{ addPullRequestReviewThread: { thread: GHPullRequestReviewThread } }>(
Q.addPullRequestReviewThread,
input,
);
return data.addPullRequestReviewThread.thread;
}
export async function resolveReviewThread(threadId: string): Promise<GHPullRequestReviewThread> {
const data = await graphql<{ resolveReviewThread: { thread: GHPullRequestReviewThread } }>(Q.resolveReviewThread, {
threadId,
});
return data.resolveReviewThread.thread;
}
export async function unresolveReviewThread(threadId: string): Promise<GHPullRequestReviewThread> {
const data = await graphql<{ unresolveReviewThread: { thread: GHPullRequestReviewThread } }>(
Q.unresolveReviewThread,
{ threadId },
);
return data.unresolveReviewThread.thread;
}
export async function updateIssueComment(id: string, body: string): Promise<GHComment> {
const data = await graphql<{ updateIssueComment: { issueComment: GHComment } }>(Q.updateIssueComment, { id, body });
return data.updateIssueComment.issueComment;
}
export async function deleteIssueComment(id: string): Promise<void> {
await graphql(Q.deleteIssueComment, { id });
}
export async function addReaction(id: string, reaction: GHReactionContent): Promise<GHReaction> {
const data = await graphql<{ addReaction: { reaction: GHReaction } }>(Q.addReaction, { id, reaction });
return data.addReaction.reaction;
}
export async function removeReaction(id: string, reaction: GHReactionContent): Promise<GHReaction> {
const data = await graphql<{ removeReaction: { reaction: GHReaction } }>(Q.removeReaction, { id, reaction });
return data.removeReaction.reaction;
}
// ---------------------------------------------------------------------------
// Pull request mutations (REST)
/** Adds (`POST`) or removes (`DELETE`) requested reviewers: user logins and team slugs. */
export async function changeRequestedReviewers(
number: number,
method: "POST" | "DELETE",
reviewers: { logins: string[]; teamSlugs: string[] },
): Promise<void> {
await rest(method, `${repoPath()}/pulls/${number}/requested_reviewers`, {
reviewers: reviewers.logins,
team_reviewers: reviewers.teamSlugs,
});
}
/** Closes or reopens (`PullRequests.update(state)`). */
export async function updatePullRequestState(number: number, state: "open" | "closed"): Promise<void> {
await rest("PATCH", `${repoPath()}/pulls/${number}`, { state });
}
/** `PUT pulls/{n}/merge` at head `sha` (rebase takes no message). */
export async function mergePullRequest(
number: number,
method: "merge" | "squash" | "rebase",
sha: string,
message?: { title: string; body: string },
): Promise<void> {
await rest("PUT", `${repoPath()}/pulls/${number}/merge`, {
merge_method: method,
sha,
...(message ? { commit_title: message.title, commit_message: message.body } : {}),
});
}
/** Adds a conversation comment (`Issues.Comments.create`). */
export async function createIssueComment(number: number, body: string): Promise<void> {
await rest("POST", `${repoPath()}/issues/${number}/comments`, { body });
}
/** Replaces the assignees (`Issues.updateAssignees`). */
export async function updateAssignees(number: number, logins: string[]): Promise<void> {
await rest("PATCH", `${repoPath()}/issues/${number}`, { assignees: logins });
}
/** Replaces the labels (`Issues.Labels.replace`). */
export async function replaceLabels(number: number, names: string[]): Promise<void> {
await rest("PUT", `${repoPath()}/issues/${number}/labels`, { labels: names });
}
+499
View File
@@ -0,0 +1,499 @@
/**
* Shapes of the GitHub data the documents in `queries.ts` return, named after IntelliJ's
* DTOs (`GHPullRequestShort`, `GHPullRequest`, …). GraphQL connections keep their
* `nodes`; `data/model.ts` derives what the UI shows (reviewer states, unread, counters).
* REST results are mapped to the same shapes (`GHUser`, `GHLabel`) where IntelliJ does.
*/
// ---------------------------------------------------------------------------
// Common
/** `pageInfo` fragment. */
export interface GHPageInfo {
startCursor: string | null;
hasPreviousPage: boolean;
endCursor: string | null;
hasNextPage: boolean;
}
export interface GHConnection<T> {
pageInfo: GHPageInfo;
nodes: T[];
}
export interface GHNodes<T> {
nodes: T[];
}
/** `actorInfo`: what every actor has. `name` comes with users, organizations and enterprise accounts. */
export interface GHActor {
__typename: "User" | "Bot" | "Organization" | "EnterpriseUserAccount" | "Mannequin" | (string & {});
id: string;
login: string;
url: string;
avatarUrl: string;
name?: string | null;
}
/** `user` fragment. */
export interface GHUser extends GHActor {
__typename: "User";
name: string | null;
}
/** `teamInfo` fragment (aliased `teamAvatarUrl`, `teamName`). */
export interface GHTeam {
__typename: "Team";
id: string;
slug: string;
url: string;
teamAvatarUrl: string | null;
teamName: string;
/** `org/slug`. */
combinedSlug: string;
}
/** `pullRequestReviewer`: a user, a team or (GitHub Apps) another actor. */
export type GHPullRequestRequestedReviewer = GHUser | GHTeam | GHActor;
/** `labelInfo` fragment. `color` is hex without `#`. */
export interface GHLabel {
id: string;
url: string;
name: string;
color: string;
}
export type GHReactionContent =
| "THUMBS_UP"
| "THUMBS_DOWN"
| "LAUGH"
| "HOORAY"
| "CONFUSED"
| "HEART"
| "ROCKET"
| "EYES";
export interface GHReaction {
content: GHReactionContent;
user: GHUser | null;
}
/** `reactions` fragment (first 25). */
export interface GHReactable {
reactions: GHConnection<GHReaction>;
}
/** `gitActorInfo`: commit author / committer. */
export interface GHGitActor {
name: string | null;
email: string | null;
avatarUrl: string;
date: string | null;
user: GHUser | null;
}
/** `commitHash` fragment. */
export interface GHCommitHash {
id: string;
oid: string;
abbreviatedOid: string;
}
/** `commitShort` fragment. */
export interface GHCommitShort extends GHCommitHash {
url: string;
messageHeadline: string;
author: GHGitActor | null;
}
/** `commit` fragment. */
export interface GHCommit extends GHCommitShort {
committer: GHGitActor | null;
messageBody: string;
committedDate: string;
parents: GHNodes<GHCommitHash>;
}
/** `pullRequestCommits` node. */
export interface GHPullRequestCommit {
id: string;
commit: GHCommit;
url: string;
}
// ---------------------------------------------------------------------------
// Repository
export type GHRepositoryPermissionLevel = "READ" | "TRIAGE" | "WRITE" | "MAINTAIN" | "ADMIN";
/** `repository` fragment. */
export interface GHRepository {
id: string;
owner: { __typename: "User" | "Organization" | (string & {}); id: string; login: string };
nameWithOwner: string;
viewerPermission: GHRepositoryPermissionLevel | null;
mergeCommitAllowed: boolean;
squashMergeAllowed: boolean;
rebaseMergeAllowed: boolean;
defaultBranchRef: { name: string } | null;
isFork: boolean;
url: string;
sshUrl: string;
}
/** Where a request goes: `owner/name` on github.com. */
export interface GHRepositoryCoordinates {
owner: string;
name: string;
}
/** REST collaborator (`repos/{o}/{r}/collaborators`) mapped to a user, with its permissions. */
export interface GHCollaborator extends GHUser {
permissions: { admin: boolean; maintain: boolean; push: boolean; triage: boolean; pull: boolean };
}
/** `pullRequestTemplate` fragment. */
export interface GHRepositoryPullRequestTemplate {
body: string | null;
}
// ---------------------------------------------------------------------------
// Pull requests
export type GHPullRequestState = "OPEN" | "CLOSED" | "MERGED";
export type GHPullRequestMergeableState = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
export type GHPullRequestReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED";
export type GHPullRequestReviewState = "PENDING" | "COMMENTED" | "APPROVED" | "CHANGES_REQUESTED" | "DISMISSED";
export type GHPullRequestReviewEvent = "COMMENT" | "APPROVE" | "REQUEST_CHANGES" | "DISMISS";
export type GHPullRequestMergeStateStatus =
| "BEHIND"
| "BLOCKED"
| "CLEAN"
| "DIRTY"
| "DRAFT"
| "HAS_HOOKS"
| "UNKNOWN"
| "UNSTABLE";
/** `pullRequestReview` fragment. */
export interface GHPullRequestReview {
__typename: "PullRequestReview";
id: string;
url: string;
author: GHActor | null;
body: string;
state: GHPullRequestReviewState;
createdAt: string;
viewerCanUpdate: boolean;
}
/** `pullRequestInfoShort` fragment: a list row (`issueSearch`). */
export interface GHPullRequestShort extends GHReactable {
id: string;
url: string;
number: number;
title: string;
state: GHPullRequestState;
isDraft: boolean;
author: GHActor | null;
createdAt: string;
updatedAt: string;
isReadByViewer: boolean;
assignees: GHNodes<GHUser>;
labels: GHNodes<GHLabel>;
reviewRequests: GHNodes<{ requestedReviewer: GHPullRequestRequestedReviewer | null }>;
reviewThreads: GHNodes<{ isResolved: boolean; isOutdated: boolean }>;
reviews: GHNodes<GHPullRequestReview>;
mergeable: GHPullRequestMergeableState;
viewerCanUpdate: boolean;
viewerCanReact: boolean;
viewerDidAuthor: boolean;
}
/** `pullRequestInfo` fragment: the details (`findPullRequest`). */
export interface GHPullRequest extends GHPullRequestShort {
body: string;
reviewDecision: GHPullRequestReviewDecision | null;
baseRefName: string;
baseRefOid: string;
baseRepository: GHRepository | null;
/** Null once the head branch is deleted. */
headRef: { id: string } | null;
headRefName: string;
headRefOid: string;
headRepository: GHRepository | null;
viewerCanDeleteHeadRef: boolean;
}
/** `pullRequestBranchMatch` fragment. */
export interface GHPullRequestBranchMatch {
id: string;
number: number;
title: string;
headRefName: string;
}
// ---------------------------------------------------------------------------
// Mergeability (`findPullRequestMergeability`)
/** `refUpdateRule` fragment. */
export interface GHRefUpdateRule {
allowsDeletions: boolean;
allowsForcePushes: boolean;
pattern: string;
requiredApprovingReviewCount: number | null;
requiredStatusCheckContexts: Array<string | null>;
requiresLinearHistory: boolean;
requiresSignatures: boolean;
viewerCanPush: boolean;
}
export type GHCommitStatusContextState = "ERROR" | "EXPECTED" | "FAILURE" | "PENDING" | "SUCCESS";
export type GHCommitCheckSuiteConclusion =
| "ACTION_REQUIRED"
| "CANCELLED"
| "FAILURE"
| "NEUTRAL"
| "SKIPPED"
| "STALE"
| "STARTUP_FAILURE"
| "SUCCESS"
| "TIMED_OUT";
/** `commitStatusContext` fragment. */
export interface GHCommitStatusContext {
context: string;
state: GHCommitStatusContextState;
isRequired: boolean;
targetUrl: string | null;
}
/** `checkRun` fragment. */
export interface GHCheckRun {
name: string;
conclusion: GHCommitCheckSuiteConclusion | null;
isRequired: boolean;
detailsUrl: string | null;
url: string;
}
/** `commitWithCheckStatuses` fragment. */
export interface GHCommitWithCheckStatuses extends GHCommitHash {
status: { contexts: GHCommitStatusContext[] } | null;
checkSuites: GHConnection<{ checkRuns: GHConnection<GHCheckRun> | null }> | null;
}
/** `pullRequestMergeability` fragment. */
export interface GHPullRequestMergeabilityData {
mergeable: GHPullRequestMergeableState;
canBeRebased: boolean;
mergeStateStatus: GHPullRequestMergeStateStatus;
baseRef: { refUpdateRule: GHRefUpdateRule | null } | null;
commits: GHConnection<{ id: string; commit: GHCommitWithCheckStatuses }>;
}
/** `statusCheckRollup` (`getRepositoryCommitStatusContexts`). */
export type GHCommitStatusRollupContext =
| {
__typename: "CheckRun";
checkSuite: { workflowRun: { workflow: { name: string } } | null } | null;
name: string;
title: string | null;
status: string;
conclusion: GHCommitCheckSuiteConclusion | null;
url: string;
}
| {
__typename: "StatusContext";
context: string;
description: string | null;
state: GHCommitStatusContextState;
targetUrl: string | null;
};
// ---------------------------------------------------------------------------
// Files, reviews, threads
export type GHPullRequestFileViewedState = "VIEWED" | "UNVIEWED" | "DISMISSED";
/** `pullRequestFiles` node. */
export interface GHPullRequestChangedFile {
path: string;
viewerViewedState: GHPullRequestFileViewedState;
}
/** REST `pulls/{n}/files` / `commits/{sha}` entry (IntelliJ's `GHCommitFile`), camel-cased. */
export interface GHCommitFile {
sha: string | null;
filename: string;
status: "added" | "removed" | "modified" | "renamed" | "copied" | "changed" | "unchanged";
additions: number;
deletions: number;
changes: number;
/** Absent for binary and very large files. */
patch: string | null;
previousFilename: string | null;
}
/** `pullRequestPendingReview` fragment, flattened like IntelliJ's `GHPullRequestPendingReview`. */
export interface GHPullRequestPendingReview {
id: string;
state: GHPullRequestReviewState;
commentsCount: number;
}
/** `comment` fragment. */
export interface GHComment extends GHReactable {
id: string;
author: GHActor | null;
body: string;
createdAt: string;
}
export type GHPullRequestReviewCommentState = "PENDING" | "SUBMITTED";
/** `pullRequestReviewComment` fragment. */
export interface GHPullRequestReviewComment extends GHComment {
url: string;
state: GHPullRequestReviewCommentState;
commit: GHCommitHash | null;
originalCommit: GHCommitHash | null;
diffHunk: string;
pullRequestReview: { id: string } | null;
viewerCanDelete: boolean;
viewerCanUpdate: boolean;
viewerCanReact: boolean;
}
export type GHDiffSide = "LEFT" | "RIGHT";
/** `pullRequestReviewThread` fragment. Lines are 1-based; `line` is null when outdated. */
export interface GHPullRequestReviewThread {
id: string;
isResolved: boolean;
isOutdated: boolean;
path: string;
subjectType: "LINE" | "FILE";
diffSide: GHDiffSide;
line: number | null;
originalLine: number | null;
startDiffSide: GHDiffSide | null;
startLine: number | null;
originalStartLine: number | null;
comments: GHNodes<GHPullRequestReviewComment>;
viewerCanReply: boolean;
viewerCanResolve: boolean;
viewerCanUnresolve: boolean;
}
/** `DraftPullRequestReviewThread` input of `createReview`. */
export interface GHPullRequestDraftReviewThread {
body: string;
path: string;
line: number;
side: GHDiffSide;
startLine?: number | null;
startSide?: GHDiffSide | null;
}
// ---------------------------------------------------------------------------
// Timeline (`pullRequestTimeline`)
interface GHTimelineEvent {
actor: GHActor | null;
createdAt: string;
}
/** `issueCommentInfo`. */
export interface GHIssueComment extends GHComment {
__typename: "IssueComment";
viewerCanUpdate: boolean;
viewerCanDelete: boolean;
viewerCanReact: boolean;
}
/** `referenceSubject`. */
export type GHReferencedSubject =
| {
__typename: "PullRequest";
title: string;
number: number;
prState: GHPullRequestState;
isDraft: boolean;
url: string;
}
| { __typename: "Issue"; title: string; number: number; issueState: "OPEN" | "CLOSED"; url: string };
export type GHPRTimelineItem =
| { __typename: "PullRequestCommit"; id: string; commit: GHCommitShort; url: string }
| GHPullRequestReview
| GHIssueComment
| ({ __typename: "RenamedTitleEvent"; previousTitle: string; currentTitle: string } & GHTimelineEvent)
| ({ __typename: "ClosedEvent" } & GHTimelineEvent)
| ({ __typename: "ReopenedEvent" } & GHTimelineEvent)
| ({ __typename: "MergedEvent"; mergedEventCommit: GHCommitShort | null; mergeRefName: string } & GHTimelineEvent)
| ({ __typename: "AssignedEvent"; user: GHUser | null } & GHTimelineEvent)
| ({ __typename: "UnassignedEvent"; user: GHUser | null } & GHTimelineEvent)
| ({ __typename: "LabeledEvent"; label: GHLabel } & GHTimelineEvent)
| ({ __typename: "UnlabeledEvent"; label: GHLabel } & GHTimelineEvent)
| ({ __typename: "ReviewRequestedEvent"; requestedReviewer: GHPullRequestRequestedReviewer | null } & GHTimelineEvent)
| ({
__typename: "ReviewRequestRemovedEvent";
requestedReviewer: GHPullRequestRequestedReviewer | null;
} & GHTimelineEvent)
| ({
__typename: "ReviewDismissedEvent";
dismissalMessageHTML: string | null;
review: { author: GHActor | null } | null;
} & GHTimelineEvent)
| ({ __typename: "ReadyForReviewEvent" } & GHTimelineEvent)
| ({ __typename: "ConvertToDraftEvent" } & GHTimelineEvent)
| ({ __typename: "BaseRefChangedEvent" } & GHTimelineEvent)
| ({ __typename: "BaseRefForcePushedEvent"; ref: { name: string } | null } & GHTimelineEvent)
| ({
__typename: "HeadRefForcePushedEvent";
ref: { name: string } | null;
beforeCommit: GHCommitHash | null;
afterCommit: GHCommitHash | null;
} & GHTimelineEvent)
| ({ __typename: "HeadRefDeletedEvent"; headRefName: string } & GHTimelineEvent)
| ({ __typename: "HeadRefRestoredEvent" } & GHTimelineEvent)
| ({ __typename: "CrossReferencedEvent"; source: GHReferencedSubject } & GHTimelineEvent)
| ({ __typename: "ConnectedEvent"; subject: GHReferencedSubject } & GHTimelineEvent)
| ({ __typename: "DisconnectedEvent"; subject: GHReferencedSubject } & GHTimelineEvent)
/** Types the document does not select fields for (ReferencedEvent, MentionedEvent…). */
| { __typename: "Unknown"; type: string };
// ---------------------------------------------------------------------------
// Derived state (`data/model.ts`)
/** IntelliJ's `GHPRReviewerState`. */
export type GHPRReviewerState = "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "NEEDS_REVIEW" | "RE_REQUESTED";
export type CodeReviewCIJobState = "FAILED" | "PENDING" | "SKIPPED" | "SUCCESS";
/** IntelliJ's `CodeReviewCIJob`. */
export interface CodeReviewCIJob {
name: string;
status: CodeReviewCIJobState;
isRequired: boolean;
detailsUrl: string | null;
}
/** IntelliJ's `GHPRMergeabilityState`, plus the raw GitHub values it was built from. */
export interface GHPRMergeabilityState {
/** Null while GitHub is still checking (mergeable UNKNOWN). */
hasConflicts: boolean | null;
ciJobs: CodeReviewCIJob[];
canBeMerged: boolean;
canBeRebased: boolean;
/** The viewer may not push to the base branch. */
isRestricted: boolean;
/** Approvals still required by branch protection (0 when nothing blocks on reviews). */
requiredApprovingReviewsCount: number;
/** `BLOCKING_BEHIND` / `BLOCKING_FAILING` when the merge box blocks on them, else null. */
checksState: "BLOCKING_BEHIND" | "BLOCKING_FAILING" | null;
mergeable: GHPullRequestMergeableState;
mergeStateStatus: GHPullRequestMergeStateStatus;
}