Skip to content

Commit

Permalink
feat: add a pr list page (#57)
Browse files Browse the repository at this point in the history
  • Loading branch information
gitcommitshow authored Oct 18, 2024
1 parent 2739f74 commit f152d39
Show file tree
Hide file tree
Showing 6 changed files with 433 additions and 15 deletions.
13 changes: 8 additions & 5 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
APP_ID="11"
GITHUB_APP_PRIVATE_KEY_BASE64="base64 encoded private key"
PRIVATE_KEY_PATH="very/secure/location/gh_app_key.pem"
WEBHOOK_SECRET="secret"
WEBSITE_ADDRESS="https://github.app.home"
LOGIN_USER=username
LOGIN_PASSWORD=strongpassword
LOGIN_PASSWORD=strongpassword
DEFAULT_GITHUB_ORG=Git-Commit-Show
GITHUB_BOT_USERS=dependabot[bot],devops-github-rudderstack
GITHUB_ORG_MEMBERS=
APP_ID="11"
WEBHOOK_SECRET="secret"
PRIVATE_KEY_PATH="very/secure/location/gh_app_key.pem"
GITHUB_APP_PRIVATE_KEY_BASE64="base64 encoded private key"
9 changes: 9 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,15 @@ http
case "POST /cla":
routes.submitCla(req, res, app);
break;
case "GET /contributions/sync":
routes.syncPullRequests(req, res, app);
break;
case "GET /contributions":
routes.listPullRequests(req, res, app);
break;
case "GET /contributions/pr":
routes.getPullRequestDetail(req, res, app);
break;
case "POST /api/webhook":
middleware(req, res);
break;
Expand Down
226 changes: 216 additions & 10 deletions src/helpers.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { storage } from "./storage.js";
import { resolve } from "path";
import { PROJECT_ROOT_PATH } from "./config.js";
import url from "node:url";

export function parseUrlQueryParams(urlString) {
if(!urlString) return urlString;
try{
const url = new URL(urlString);
const params = new URLSearchParams(url.search);
const parsedUrl = url.parse(urlString)
const query = parsedUrl.query;
const params = new URLSearchParams(query);
return Object.fromEntries(params.entries());
} catch(err){
console.error(err);
Expand All @@ -32,7 +34,7 @@ export function isCLARequired(pullRequest) {
console.log("This PR is from a bot. So no CLA required.");
return false;
}
if (!isExternalContribution(pullRequest)) {
if (!isExternalContributionMaybe(pullRequest)) {
console.log("This PR is an internal contribution. So no CLA required.");
return false;
}
Expand All @@ -48,7 +50,7 @@ export function isMessageAfterMergeRequired(pullRequest) {
console.log("This PR is from a bot. So no message after merge required.");
return false;
}
if (!isExternalContribution(pullRequest)) {
if (!isExternalContributionMaybe(pullRequest)) {
console.log(
"This PR is an internal contribution. So no message after merge required.",
);
Expand All @@ -57,13 +59,67 @@ export function isMessageAfterMergeRequired(pullRequest) {
return true;
}

export function isExternalContribution(pullRequest) {
if (
pullRequest?.head?.repo?.full_name !== pullRequest?.base?.repo?.full_name
) {
/**
* Whether a pull request is a contribution by external user who has bot been associated with the repo
* @param {Object} pullRequest
* @returns {boolean | undefined} - boolean when confirmed, undefined when not confirmed
*/
export function isExternalContributionMaybe(pullRequest) {
const { owner, repo } = parseRepoUrl(pullRequest?.repository_url || pullRequest?.base?.repo?.html_url) || {};
const username = pullRequest?.user?.login;
if (typeof pullRequest?.author_association === "string") {
// OWNER: Author is the owner of the repository.
// MEMBER: Author is a member of the organization that owns the repository.
// COLLABORATOR: Author has been invited to collaborate on the repository.
// CONTRIBUTOR: Author has previously committed to the repository.
// FIRST_TIMER: Author has not previously committed to GitHub.
// FIRST_TIME_CONTRIBUTOR: Author has not previously committed to the repository.
// MANNEQUIN: Author is a placeholder for an unclaimed user.
// NONE: Author has no association with the repository (or doesn't want to make his association public).
switch (pullRequest.author_association.toUpperCase()) {
case "OWNER":
storage.cache.set(false, username, "contribution", "external", owner, repo);
return false;
case "MEMBER":
storage.cache.set(false, username, "contribution", "external", owner, repo);
return false;
case "COLLABORATOR":
pullRequest.isExternalContribution = false;
storage.cache.set(false, username, "contribution", "external", owner, repo);
return false;
default:
//Will need more checks to verify author relation with the repo
break;
}
}
if (pullRequest?.head?.repo?.full_name !== pullRequest?.base?.repo?.full_name) {
storage.cache.set(true, username, "contribution", "external", owner, repo);
return true;
}
return false;
// Utilize cache if possible
const isConfirmedToBeExternalContributionInPast = storage.cache.get(username, "contribution", "external", owner, repo);
if (typeof isConfirmedToBeExternalContributionInPast === "boolean") {
return isConfirmedToBeExternalContributionInPast
}
// Ambigous results after this point.
// Cannot confirm whether an external contribution or not.
// Need more reliable check.
return undefined;
}

async function isExternalContribution(octokit, pullRequest) {
const probablisticResult = isExternalContributionMaybe(pullRequest);
if (typeof probablisticResult === "boolean") {
// Boolean is returned when the probabilistic check is sufficient
return probablisticResult;
}
const username = pullRequest?.user?.login;
const { owner, repo } = parseRepoUrl(pullRequest?.repository_url || pullRequest?.base?.repo?.html_url) || {};
//TODO: Handle failure in checking permissions for the user
const deterministicPermissionCheck = await isAllowedToWriteToTheRepo(octokit, username, owner, repo);
pullRequest.isExternalContribution = deterministicPermissionCheck;
storage.cache.set(pullRequest, username, "contribution", "external", owner, repo);
return deterministicPermissionCheck;
}

export function isABot(user) {
Expand Down Expand Up @@ -229,6 +285,7 @@ export function getMessage(name, context) {
}

export function isCLASigned(username) {
if (!username) return
const userData = storage.get({ username: username, terms: "on" });
if (userData?.length > 0) {
return true;
Expand Down Expand Up @@ -261,6 +318,17 @@ export function jsonToCSV(arr) {
return csvRows.join('\n');
}

/**
* Authenticate as app installation for the org
* Authenticating as an app installation lets your app access resources that are owned by the user or organization
* that installed the app. Authenticating as an app installation is ideal for automation workflows
* that don't involve user input.
* Check out { @link https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app GitHub Docs for Authentication }
* and { @tutorial https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation GitHub Docs for Authenticating App Installation}
* @param {Object} app
* @param {string} org
* @returns
*/
export async function getOctokitForOrg(app, org) {
// Find the installation for the organization
for await (const { installation } of app.eachInstallation.iterator()) {
Expand All @@ -270,6 +338,12 @@ export async function getOctokitForOrg(app, org) {
return octokit
}
}
console.error("No GitHub App installation found for " + org);
// Fall back authentication method
const DEFAULT_GITHUB_ORG = process.env.DEFAULT_GITHUB_ORG;
if (DEFAULT_GITHUB_ORG && org !== DEFAULT_GITHUB_ORG) {
return await getOctokitForOrg(app, DEFAULT_GITHUB_ORG);
}
}

export async function verifyGitHubAppAuthenticationAndAccess(app) {
Expand Down Expand Up @@ -330,7 +404,139 @@ function parseRepoUrl(repoUrl) {

return { owner: segments[segments.length - 2], repo: segments[segments.length - 1] };
} catch (error) {
// Handle cases where URL constructor fails (e.g., SSH URLs)
//TODO: Handle cases where URL constructor fails (e.g., SSH URLs)
return null;
}
}

export async function getOpenPullRequests(octokit, owner, repo, options) {
let query = `is:pr is:open` + (repo ? ` repo:${owner + "/" + repo}` : ` org:${owner}`);
const BOT_USERS = process.env.GITHUB_BOT_USERS ? process.env.GITHUB_BOT_USERS.split(",")?.map((item) => item?.trim()) : null;
const GITHUB_ORG_MEMBERS = process.env.GITHUB_ORG_MEMBERS ? process.env.GITHUB_ORG_MEMBERS.split(",")?.map((item) => item?.trim()) : null;
// Remove results from bots or internal team members
BOT_USERS?.forEach((botUser) => query += (" -author:" + botUser));
GITHUB_ORG_MEMBERS?.forEach((orgMember) => query += (" -author:" + orgMember));
const response = await octokit.rest.search.issuesAndPullRequests({
q: query,
per_page: 100,
page: options?.page || 1,
sort: 'created',
order: 'desc'
});
console.log(response?.data?.total_count + " results found for search: " + query);
const humanPRs = response?.data?.items?.filter(pr => pr.user && pr.user.type === 'User');
return humanPRs;
}

export async function getOpenExternalPullRequests(app, owner, repo, options) {
try {
const octokit = await getOctokitForOrg(app, owner);
if (!octokit) {
throw new Error("Failed to search PR because of undefined octokit intance")
}
const openPRs = await getOpenPullRequests(octokit, owner, repo, options);
if (!Array.isArray(openPRs)) {
return;
}
// Send only the external PRs
const openExternalPRs = []
for (const pr of openPRs) {
try {
pr.isExternalContribution = await isExternalContribution(octokit, pr);
if (pr.isExternalContribution) {
openExternalPRs.push(pr);
}
} catch (err) {
// Some error occurred, so we cannot deterministically say whether it is an external contribution or not
pr.isExternalContribution = undefined;
// We are anyways going to send this in the external open PR list
openExternalPRs.push(pr);
}
}
return openExternalPRs
} catch (err) {
return
}
}

export function timeAgo(date) {
if (!date) return '';
if (typeof date === 'string') {
date = new Date(date);
}
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
let interval = Math.floor(seconds / 31536000);

if (interval > 1) {
return `${interval} years ago`;
}
interval = Math.floor(seconds / 2592000);
if (interval > 1) {
return `${interval} months ago`;
}
interval = Math.floor(seconds / 604800);
if (interval > 1) {
return `${interval} weeks ago`;
}
interval = Math.floor(seconds / 86400);
if (interval > 1) {
return `${interval} days ago`;
}
interval = Math.floor(seconds / 3600);
if (interval > 1) {
return `${interval} hours ago`;
}
interval = Math.floor(seconds / 60);
if (interval > 1) {
return `${interval} minutes ago`;
}
return `${seconds} seconds ago`;
}

/**
* Check user permissions for a repository
* The authenticating octokit instance must have "Metadata" repository permissions (read)
* @param {string} username
* @param {string} owner
* @param {string} repo
* @returns {boolean}
*/
async function isAllowedToWriteToTheRepo(octokit, username, owner, repo,) {
try {
const result = await octokit.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username,
});
if (["admin", "write"].includes(result?.permission)) {
return true
}
if (["admin", "maintain", "write"].includes(result?.role_name)) {
return true
}
return false;
} catch (err) {
// If 403 error "HttpError: Resource not accessible by integration"
// The app is not installed in that repo
// Only "metadata:repository" permission is needed for this api, which all gh apps have wherever they are installed
console.log("Failed to check if a " + username + " is allowed to write to " + owner + "/" + repo);
console.error(err);
throw new Error("Failed to check user permission for the repo")
}
}

export async function getPullRequestDetail(app, owner, repo, number) {
const octokit = await getOctokitForOrg(app, owner);
if (!octokit) {
throw new Error("Failed to search PR because of undefined octokit intance")
}
const { data } = await octokit.rest.pulls.get({
owner: owner,
repo: repo,
pull_number: number
});
if (!data) return data;
const pr = Object.assign({}, data, { isExternalContribution: isExternalContributionMaybe(data) });
return pr;
}
Loading

0 comments on commit f152d39

Please sign in to comment.