Skip to content

Commit

Permalink
Feature: Status checks for each pull request (#59)
Browse files Browse the repository at this point in the history
### Summary

In this PR, the status check reports have been added to be visible on
each pull request.

There are a few things to note:
- The checks may not be completely accurate if the repository is
configured with external 3rd party tools for adding checks on a pull
request
- The checks icon will show the overall status of success, failed,
pending, or if there aren't any configured
- At the moment the performance has taken a hit because the query for
the API has become more complicated, this will be addressed before this
feature is available for users

### Changes
- Updated query to fetch status checks information
- Parallelized the query to split up the complexity of the query over
multiple calls
- Added new components to render icons next to the author on the pull
request and clicking on them will navigate to the checks for the pull
request
  • Loading branch information
syj67507 committed May 19, 2024
1 parent 25f1e70 commit 3e28f96
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 47 deletions.
186 changes: 142 additions & 44 deletions src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,30 @@ export type PullRequestReviewState =
| "DISMISSED"
| null; // This will be null when the user has not given a review

export type CheckStatusRaw =
| "REQUESTED"
| "QUEUED"
| "IN_PROGRESS"
| "COMPLETED"
| "WAITING"
| "PENDING";

export type CheckConclusionRaw =
| "ACTION_REQUIRED"
| "TIMED_OUT"
| "CANCELLED"
| "FAILURE"
| "SUCCESS"
| "NEUTRAL"
| "SKIPPED"
| "STARTUP_FAILURE"
| "STALE";

interface CheckSuiteRaw {
status: CheckStatusRaw;
conclusion: CheckConclusionRaw;
}

/**
* The raw JSON response body when fetching the pull request data for a given
* repository from GitHub's GraphQL API
Expand All @@ -38,6 +62,16 @@ type PullRequestResponseRaw = Record<
viewerLatestReview: {
state: PullRequestReviewState;
} | null;
checksUrl: string;
commits: {
nodes: Array<{
commit: {
checkSuites: {
nodes: CheckSuiteRaw[];
};
};
}>;
};
}>;
};
}
Expand All @@ -54,6 +88,8 @@ interface AuthenticatedUserResponse {
};
}

export type ChecksState = "SUCCESS" | "FAILURE" | "PENDING" | "NONE";

export interface RepoData {
/** The owner of the repo */
owner: string;
Expand Down Expand Up @@ -87,6 +123,8 @@ export interface PullRequestData {
draft: boolean;
/** Indicates the type of the last review this user gave on the pull request */
viewerLatestReview: PullRequestReviewState;
checksUrl: string;
checksState: ChecksState;
}

/**
Expand Down Expand Up @@ -142,71 +180,124 @@ export default class GitHubClient {
.join("");
}

/**
* Helper method to parse all the check suites and return the overall
* status of if the checks have passed, failed, are in progress, or none were configured.
* @
*/
private static getCheckState(checkSuites: CheckSuiteRaw[]): ChecksState {
let overallStatus: ChecksState = "SUCCESS";
let hasPending = false;
checkSuites.forEach((checkSuite) => {
if (checkSuite.status === "IN_PROGRESS") {
hasPending = true;
}
if (
checkSuite.conclusion === "FAILURE" ||
checkSuite.conclusion === "TIMED_OUT" ||
checkSuite.conclusion === "CANCELLED"
) {
overallStatus = "FAILURE";
}
});

if (hasPending) {
overallStatus = "PENDING";
}

// If there aren't any checks configured on the repository
if (checkSuites.length === 0) {
overallStatus = "NONE";
}

return overallStatus;
}

/**
* Fetches the raw data for each repository that the user has configured
* @param repositories The user configured repositories in storage
*/
private async getRawRepoData(
repositories: ConfiguredRepo[]
): Promise<PullRequestResponseRaw> {
// Build each individual query for each repo
const queries = repositories.map((repository) => {
const headersList = {
Accept: "application/json",
Authorization: `token ${this.token}`,
};

// Build each individual query for each repo and call the API
const queries = repositories.map(async (repository) => {
const { url } = repository;

const parsed = url.split("/");
const owner = parsed[3];
const name = parsed[4];

const query = `
${GitHubClient.randomAlphaString()} :repository(owner: "${owner}", name: "${name}") {
owner {
login
},
name,
url,
pullRequests(states: OPEN, first: 30, orderBy: {
field:CREATED_AT,
direction:DESC
}) {
nodes {
number,
title,
body,
url,
isDraft,
author {
login
},
viewerLatestReview {
state,
query {
repository(owner: "${owner}", name: "${name}") {
owner {
login
},
name,
url,
pullRequests(states: OPEN, first: 30, orderBy: {
field:CREATED_AT,
direction:DESC
}) {
nodes {
number,
title,
body,
url,
isDraft,
author {
login
},
viewerLatestReview {
state,
},
checksUrl,
commits(last: 1) {
nodes {
commit {
checkSuites(first: 50) {
nodes {
status,
conclusion
}
}
}
}
}
}
}
}
},
}
`;

return query;
return fetch(`https://api.github.com/graphql`, {
method: "POST",
headers: headersList,
body: JSON.stringify({ query }),
});
});

// Merge it into one
const query = `
{
${queries.join("\n")}
}
`;
// Await the responses in parallel
const responses = await Promise.all(queries);
const rawDataResponse = await Promise.all(
responses.map(async (r) => r.json())
);
const rawData = rawDataResponse.map((r) => r.data);

const headersList = {
Accept: "application/json",
Authorization: `token ${this.token}`,
};

const response = await fetch(`https://api.github.com/graphql`, {
method: "POST",
headers: headersList,
body: JSON.stringify({ query }),
// Merge it all together
const result: PullRequestResponseRaw = {};
rawData.forEach((value) => {
result[GitHubClient.randomAlphaString()] = value.repository;
});
console.log(result);

return (await response.json()).data as PullRequestResponseRaw;
return result;
}

private static parseRawData(
Expand All @@ -231,7 +322,6 @@ export default class GitHubClient {
configuredRepo?.jiraTags?.forEach((jiraTag) => {
const regex = new RegExp(`${jiraTag}-\\d+`, "g");
// const regex = new RegExp(jiraTag, "g"); // For testing

const ticketsInTitle = node.title.match(regex);
const ticketsInBody = node.body.match(regex);

Expand All @@ -250,19 +340,27 @@ export default class GitHubClient {
jiraUrl = `${configuredRepo.jiraDomain}/${ticketTags[0]}`;
}

// Determine checks state and conclusion
const checkSuites = node.commits.nodes[0].commit.checkSuites.nodes;
const checksState = GitHubClient.getCheckState(checkSuites);

return {
jiraUrl,
draft: node.isDraft,
checksState,
number: node.number,
checkSuite: node.commits.nodes[0].commit.checkSuites,
title: node.title,
jiraUrl,
draft: node.isDraft,
body: node.body,
username: node.author.login,
viewerLatestReview: node.viewerLatestReview?.state ?? null,
url: node.url,
checksUrl: node.checksUrl,
};
}),
});
});

return result;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function ApprovedIcon() {
disableInteractive
>
<DoneIcon
fontSize="small"
sx={{
color: "#1f883d",
}}
Expand All @@ -28,6 +29,7 @@ export function ChangesRequestedIcon() {
disableInteractive
>
<FeedbackOutlinedIcon
fontSize="small"
sx={{
color: "red",
}}
Expand All @@ -44,6 +46,7 @@ export function CommentedIcon() {
disableInteractive
>
<ChatBubbleOutlineOutlinedIcon
fontSize="small"
sx={{
color: "#767676",
}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from "react";
import CircleIcon from "@mui/icons-material/Circle";
import ClearIcon from "@mui/icons-material/Clear";
import DoneIcon from "@mui/icons-material/Done";
import Tooltip from "@mui/material/Tooltip";

export function PendingStatusChecksIcon() {
return (
<Tooltip
title="Checks are still in rrogress"
placement="top"
disableInteractive
>
<CircleIcon
sx={{
color: "#DCAB08",
fontSize: "10px",
cursor: "pointer",
}}
/>
</Tooltip>
);
}

export function SuccessStatusChecksIcon() {
return (
<Tooltip title="All checks passed" placement="top" disableInteractive>
<DoneIcon
sx={{
color: "#1f883d",
fontSize: "16px",
cursor: "pointer",
}}
/>
</Tooltip>
);
}

export function FailedStatusChecksIcon() {
return (
<Tooltip title="Checks have failed" placement="top" disableInteractive>
<ClearIcon
sx={{
color: "red",
fontSize: "16px",
cursor: "pointer",
}}
/>
</Tooltip>
);
}
26 changes: 23 additions & 3 deletions src/popup/components/PRDisplay/RepoSection/PullRequest/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import JiraIconButton from "./JiraIconButton";
import GitHubIconButton from "./GitHubIconButton";
import type { PullRequestData } from "../../../../../data";
Expand All @@ -9,6 +10,12 @@ import {
ChangesRequestedIcon,
CommentedIcon,
} from "./ReviewIcons";
import {
FailedStatusChecksIcon,
PendingStatusChecksIcon,
SuccessStatusChecksIcon,
} from "./StatusCheckIcons";
import { createTab } from "../../../../../data/extension";

interface PullRequestProps {
pr: PullRequestData;
Expand Down Expand Up @@ -38,9 +45,22 @@ export default function PullRequest({
<GitHubIconButton pr={pr} />
{isJiraConfigured && <JiraIconButton jiraUrl={pr.jiraUrl} />}
<Stack overflow="hidden" flex={1}>
<Typography variant="caption" fontStyle="italic">
{pr.username}
</Typography>
<Stack direction="row" alignItems="center" gap={1}>
<Typography variant="caption" fontStyle="italic">
{pr.username}
</Typography>
<Box
onClick={() => {
createTab(pr.checksUrl).catch(() => {
console.error(`Failed to create tab with url ${pr.checksUrl}`);
});
}}
>
{pr.checksState === "SUCCESS" && <SuccessStatusChecksIcon />}
{pr.checksState === "FAILURE" && <FailedStatusChecksIcon />}
{pr.checksState === "PENDING" && <PendingStatusChecksIcon />}
</Box>
</Stack>
<Typography
variant="caption"
sx={{
Expand Down

0 comments on commit 3e28f96

Please sign in to comment.