Skip to content

Commit f152d39

Browse files
feat: add a pr list page (#57)
1 parent 2739f74 commit f152d39

File tree

6 files changed

+433
-15
lines changed

6 files changed

+433
-15
lines changed

.env.sample

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
APP_ID="11"
2-
GITHUB_APP_PRIVATE_KEY_BASE64="base64 encoded private key"
3-
PRIVATE_KEY_PATH="very/secure/location/gh_app_key.pem"
4-
WEBHOOK_SECRET="secret"
51
WEBSITE_ADDRESS="https://github.app.home"
62
LOGIN_USER=username
7-
LOGIN_PASSWORD=strongpassword
3+
LOGIN_PASSWORD=strongpassword
4+
DEFAULT_GITHUB_ORG=Git-Commit-Show
5+
GITHUB_BOT_USERS=dependabot[bot],devops-github-rudderstack
6+
GITHUB_ORG_MEMBERS=
7+
APP_ID="11"
8+
WEBHOOK_SECRET="secret"
9+
PRIVATE_KEY_PATH="very/secure/location/gh_app_key.pem"
10+
GITHUB_APP_PRIVATE_KEY_BASE64="base64 encoded private key"

app.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,15 @@ http
233233
case "POST /cla":
234234
routes.submitCla(req, res, app);
235235
break;
236+
case "GET /contributions/sync":
237+
routes.syncPullRequests(req, res, app);
238+
break;
239+
case "GET /contributions":
240+
routes.listPullRequests(req, res, app);
241+
break;
242+
case "GET /contributions/pr":
243+
routes.getPullRequestDetail(req, res, app);
244+
break;
236245
case "POST /api/webhook":
237246
middleware(req, res);
238247
break;

src/helpers.js

Lines changed: 216 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { storage } from "./storage.js";
22
import { resolve } from "path";
33
import { PROJECT_ROOT_PATH } from "./config.js";
4+
import url from "node:url";
45

56
export function parseUrlQueryParams(urlString) {
67
if(!urlString) return urlString;
78
try{
8-
const url = new URL(urlString);
9-
const params = new URLSearchParams(url.search);
9+
const parsedUrl = url.parse(urlString)
10+
const query = parsedUrl.query;
11+
const params = new URLSearchParams(query);
1012
return Object.fromEntries(params.entries());
1113
} catch(err){
1214
console.error(err);
@@ -32,7 +34,7 @@ export function isCLARequired(pullRequest) {
3234
console.log("This PR is from a bot. So no CLA required.");
3335
return false;
3436
}
35-
if (!isExternalContribution(pullRequest)) {
37+
if (!isExternalContributionMaybe(pullRequest)) {
3638
console.log("This PR is an internal contribution. So no CLA required.");
3739
return false;
3840
}
@@ -48,7 +50,7 @@ export function isMessageAfterMergeRequired(pullRequest) {
4850
console.log("This PR is from a bot. So no message after merge required.");
4951
return false;
5052
}
51-
if (!isExternalContribution(pullRequest)) {
53+
if (!isExternalContributionMaybe(pullRequest)) {
5254
console.log(
5355
"This PR is an internal contribution. So no message after merge required.",
5456
);
@@ -57,13 +59,67 @@ export function isMessageAfterMergeRequired(pullRequest) {
5759
return true;
5860
}
5961

60-
export function isExternalContribution(pullRequest) {
61-
if (
62-
pullRequest?.head?.repo?.full_name !== pullRequest?.base?.repo?.full_name
63-
) {
62+
/**
63+
* Whether a pull request is a contribution by external user who has bot been associated with the repo
64+
* @param {Object} pullRequest
65+
* @returns {boolean | undefined} - boolean when confirmed, undefined when not confirmed
66+
*/
67+
export function isExternalContributionMaybe(pullRequest) {
68+
const { owner, repo } = parseRepoUrl(pullRequest?.repository_url || pullRequest?.base?.repo?.html_url) || {};
69+
const username = pullRequest?.user?.login;
70+
if (typeof pullRequest?.author_association === "string") {
71+
// OWNER: Author is the owner of the repository.
72+
// MEMBER: Author is a member of the organization that owns the repository.
73+
// COLLABORATOR: Author has been invited to collaborate on the repository.
74+
// CONTRIBUTOR: Author has previously committed to the repository.
75+
// FIRST_TIMER: Author has not previously committed to GitHub.
76+
// FIRST_TIME_CONTRIBUTOR: Author has not previously committed to the repository.
77+
// MANNEQUIN: Author is a placeholder for an unclaimed user.
78+
// NONE: Author has no association with the repository (or doesn't want to make his association public).
79+
switch (pullRequest.author_association.toUpperCase()) {
80+
case "OWNER":
81+
storage.cache.set(false, username, "contribution", "external", owner, repo);
82+
return false;
83+
case "MEMBER":
84+
storage.cache.set(false, username, "contribution", "external", owner, repo);
85+
return false;
86+
case "COLLABORATOR":
87+
pullRequest.isExternalContribution = false;
88+
storage.cache.set(false, username, "contribution", "external", owner, repo);
89+
return false;
90+
default:
91+
//Will need more checks to verify author relation with the repo
92+
break;
93+
}
94+
}
95+
if (pullRequest?.head?.repo?.full_name !== pullRequest?.base?.repo?.full_name) {
96+
storage.cache.set(true, username, "contribution", "external", owner, repo);
6497
return true;
6598
}
66-
return false;
99+
// Utilize cache if possible
100+
const isConfirmedToBeExternalContributionInPast = storage.cache.get(username, "contribution", "external", owner, repo);
101+
if (typeof isConfirmedToBeExternalContributionInPast === "boolean") {
102+
return isConfirmedToBeExternalContributionInPast
103+
}
104+
// Ambigous results after this point.
105+
// Cannot confirm whether an external contribution or not.
106+
// Need more reliable check.
107+
return undefined;
108+
}
109+
110+
async function isExternalContribution(octokit, pullRequest) {
111+
const probablisticResult = isExternalContributionMaybe(pullRequest);
112+
if (typeof probablisticResult === "boolean") {
113+
// Boolean is returned when the probabilistic check is sufficient
114+
return probablisticResult;
115+
}
116+
const username = pullRequest?.user?.login;
117+
const { owner, repo } = parseRepoUrl(pullRequest?.repository_url || pullRequest?.base?.repo?.html_url) || {};
118+
//TODO: Handle failure in checking permissions for the user
119+
const deterministicPermissionCheck = await isAllowedToWriteToTheRepo(octokit, username, owner, repo);
120+
pullRequest.isExternalContribution = deterministicPermissionCheck;
121+
storage.cache.set(pullRequest, username, "contribution", "external", owner, repo);
122+
return deterministicPermissionCheck;
67123
}
68124

69125
export function isABot(user) {
@@ -229,6 +285,7 @@ export function getMessage(name, context) {
229285
}
230286

231287
export function isCLASigned(username) {
288+
if (!username) return
232289
const userData = storage.get({ username: username, terms: "on" });
233290
if (userData?.length > 0) {
234291
return true;
@@ -261,6 +318,17 @@ export function jsonToCSV(arr) {
261318
return csvRows.join('\n');
262319
}
263320

321+
/**
322+
* Authenticate as app installation for the org
323+
* Authenticating as an app installation lets your app access resources that are owned by the user or organization
324+
* that installed the app. Authenticating as an app installation is ideal for automation workflows
325+
* that don't involve user input.
326+
* 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 }
327+
* 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}
328+
* @param {Object} app
329+
* @param {string} org
330+
* @returns
331+
*/
264332
export async function getOctokitForOrg(app, org) {
265333
// Find the installation for the organization
266334
for await (const { installation } of app.eachInstallation.iterator()) {
@@ -270,6 +338,12 @@ export async function getOctokitForOrg(app, org) {
270338
return octokit
271339
}
272340
}
341+
console.error("No GitHub App installation found for " + org);
342+
// Fall back authentication method
343+
const DEFAULT_GITHUB_ORG = process.env.DEFAULT_GITHUB_ORG;
344+
if (DEFAULT_GITHUB_ORG && org !== DEFAULT_GITHUB_ORG) {
345+
return await getOctokitForOrg(app, DEFAULT_GITHUB_ORG);
346+
}
273347
}
274348

275349
export async function verifyGitHubAppAuthenticationAndAccess(app) {
@@ -330,7 +404,139 @@ function parseRepoUrl(repoUrl) {
330404

331405
return { owner: segments[segments.length - 2], repo: segments[segments.length - 1] };
332406
} catch (error) {
333-
// Handle cases where URL constructor fails (e.g., SSH URLs)
407+
//TODO: Handle cases where URL constructor fails (e.g., SSH URLs)
334408
return null;
335409
}
410+
}
411+
412+
export async function getOpenPullRequests(octokit, owner, repo, options) {
413+
let query = `is:pr is:open` + (repo ? ` repo:${owner + "/" + repo}` : ` org:${owner}`);
414+
const BOT_USERS = process.env.GITHUB_BOT_USERS ? process.env.GITHUB_BOT_USERS.split(",")?.map((item) => item?.trim()) : null;
415+
const GITHUB_ORG_MEMBERS = process.env.GITHUB_ORG_MEMBERS ? process.env.GITHUB_ORG_MEMBERS.split(",")?.map((item) => item?.trim()) : null;
416+
// Remove results from bots or internal team members
417+
BOT_USERS?.forEach((botUser) => query += (" -author:" + botUser));
418+
GITHUB_ORG_MEMBERS?.forEach((orgMember) => query += (" -author:" + orgMember));
419+
const response = await octokit.rest.search.issuesAndPullRequests({
420+
q: query,
421+
per_page: 100,
422+
page: options?.page || 1,
423+
sort: 'created',
424+
order: 'desc'
425+
});
426+
console.log(response?.data?.total_count + " results found for search: " + query);
427+
const humanPRs = response?.data?.items?.filter(pr => pr.user && pr.user.type === 'User');
428+
return humanPRs;
429+
}
430+
431+
export async function getOpenExternalPullRequests(app, owner, repo, options) {
432+
try {
433+
const octokit = await getOctokitForOrg(app, owner);
434+
if (!octokit) {
435+
throw new Error("Failed to search PR because of undefined octokit intance")
436+
}
437+
const openPRs = await getOpenPullRequests(octokit, owner, repo, options);
438+
if (!Array.isArray(openPRs)) {
439+
return;
440+
}
441+
// Send only the external PRs
442+
const openExternalPRs = []
443+
for (const pr of openPRs) {
444+
try {
445+
pr.isExternalContribution = await isExternalContribution(octokit, pr);
446+
if (pr.isExternalContribution) {
447+
openExternalPRs.push(pr);
448+
}
449+
} catch (err) {
450+
// Some error occurred, so we cannot deterministically say whether it is an external contribution or not
451+
pr.isExternalContribution = undefined;
452+
// We are anyways going to send this in the external open PR list
453+
openExternalPRs.push(pr);
454+
}
455+
}
456+
return openExternalPRs
457+
} catch (err) {
458+
return
459+
}
460+
}
461+
462+
export function timeAgo(date) {
463+
if (!date) return '';
464+
if (typeof date === 'string') {
465+
date = new Date(date);
466+
}
467+
const now = new Date();
468+
const seconds = Math.floor((now - date) / 1000);
469+
let interval = Math.floor(seconds / 31536000);
470+
471+
if (interval > 1) {
472+
return `${interval} years ago`;
473+
}
474+
interval = Math.floor(seconds / 2592000);
475+
if (interval > 1) {
476+
return `${interval} months ago`;
477+
}
478+
interval = Math.floor(seconds / 604800);
479+
if (interval > 1) {
480+
return `${interval} weeks ago`;
481+
}
482+
interval = Math.floor(seconds / 86400);
483+
if (interval > 1) {
484+
return `${interval} days ago`;
485+
}
486+
interval = Math.floor(seconds / 3600);
487+
if (interval > 1) {
488+
return `${interval} hours ago`;
489+
}
490+
interval = Math.floor(seconds / 60);
491+
if (interval > 1) {
492+
return `${interval} minutes ago`;
493+
}
494+
return `${seconds} seconds ago`;
495+
}
496+
497+
/**
498+
* Check user permissions for a repository
499+
* The authenticating octokit instance must have "Metadata" repository permissions (read)
500+
* @param {string} username
501+
* @param {string} owner
502+
* @param {string} repo
503+
* @returns {boolean}
504+
*/
505+
async function isAllowedToWriteToTheRepo(octokit, username, owner, repo,) {
506+
try {
507+
const result = await octokit.rest.repos.getCollaboratorPermissionLevel({
508+
owner,
509+
repo,
510+
username,
511+
});
512+
if (["admin", "write"].includes(result?.permission)) {
513+
return true
514+
}
515+
if (["admin", "maintain", "write"].includes(result?.role_name)) {
516+
return true
517+
}
518+
return false;
519+
} catch (err) {
520+
// If 403 error "HttpError: Resource not accessible by integration"
521+
// The app is not installed in that repo
522+
// Only "metadata:repository" permission is needed for this api, which all gh apps have wherever they are installed
523+
console.log("Failed to check if a " + username + " is allowed to write to " + owner + "/" + repo);
524+
console.error(err);
525+
throw new Error("Failed to check user permission for the repo")
526+
}
527+
}
528+
529+
export async function getPullRequestDetail(app, owner, repo, number) {
530+
const octokit = await getOctokitForOrg(app, owner);
531+
if (!octokit) {
532+
throw new Error("Failed to search PR because of undefined octokit intance")
533+
}
534+
const { data } = await octokit.rest.pulls.get({
535+
owner: owner,
536+
repo: repo,
537+
pull_number: number
538+
});
539+
if (!data) return data;
540+
const pr = Object.assign({}, data, { isExternalContribution: isExternalContributionMaybe(data) });
541+
return pr;
336542
}

0 commit comments

Comments
 (0)