Skip to content

Commit c781fde

Browse files
committed
feat: automated cla flow with robust cla requirement check
1 parent 0dc09c0 commit c781fde

File tree

14 files changed

+344
-126
lines changed

14 files changed

+344
-126
lines changed

.env.sample

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ APP_ID="11"
22
GITHUB_APP_PRIVATE_KEY_BASE64="base64 encoded private key"
33
PRIVATE_KEY_PATH="very/secure/location/gh_app_key.pem"
44
WEBHOOK_SECRET="secret"
5+
WEBSITE_ADDRESS="https://github.app.home"

app.js

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import dotenv from "dotenv";
22
import fs from "fs";
33
import http from "http";
4+
import url from "url";
45
import { Octokit, App } from "octokit";
56
import { createNodeMiddleware } from "@octokit/webhooks";
6-
import { routes } from "./routes.js";
7+
import { routes } from "./src/routes.js";
8+
import { getMessage, isCLARequired } from "./src/helpers.js";
79

810
// Load environment variables from .env file
911
dotenv.config();
@@ -26,7 +28,6 @@ const privateKey =
2628
);
2729
const secret = process.env.WEBHOOK_SECRET;
2830
const enterpriseHostname = process.env.ENTERPRISE_HOSTNAME;
29-
const messageForNewPRs = fs.readFileSync("./message.md", "utf8");
3031

3132
// Create an authenticated Octokit client authenticated as a GitHub App
3233
const app = new App({
@@ -54,11 +55,28 @@ app.webhooks.on("pull_request.opened", async ({ octokit, payload }) => {
5455
`Received a pull request event for #${payload.pull_request.number}`,
5556
);
5657
try {
58+
if (!isCLARequired(payload.pull_request)) {
59+
return;
60+
}
61+
// If the user is not a member of the organization and haven't yet signed CLA,
62+
// ask them to sign the CLA
5763
await octokit.rest.issues.createComment({
5864
owner: payload.repository.owner.login,
5965
repo: payload.repository.name,
6066
issue_number: payload.pull_request.number,
61-
body: messageForNewPRs,
67+
body: getMessage("ask-to-sign-cla", {
68+
username: payload.pull_request.user.login,
69+
org: payload.repository.owner.login,
70+
repo: payload.repository.name,
71+
pr_number: payload.pull_request.number,
72+
}),
73+
});
74+
// Add a label to the PR
75+
octokit.rest.issues.addLabels({
76+
owner: payload.repository.owner.login,
77+
repo: payload.repository.name,
78+
issue_number: payload.pull_request.number,
79+
labels: ["Pending CLA"],
6280
});
6381
} catch (error) {
6482
if (error.response) {
@@ -72,7 +90,9 @@ app.webhooks.on("pull_request.opened", async ({ octokit, payload }) => {
7290
});
7391

7492
app.webhooks.on("issues.opened", async ({ octokit, payload }) => {
75-
console.log(`Received a new issue event for #${payload.issue.number}`);
93+
console.log(
94+
`Received a new issue event for #${payload.issue.number} by ${pull_request.user.type}: ${pull_request.user.login}`,
95+
);
7696
try {
7797
await octokit.rest.issues.createComment({
7898
owner: payload.repository.owner.login,
@@ -118,15 +138,20 @@ const middleware = createNodeMiddleware(app.webhooks, { path });
118138

119139
http
120140
.createServer((req, res) => {
121-
switch (req.method + " " + req.url) {
141+
const parsedUrl = url.parse(req.url);
142+
const pathWithoutQuery = parsedUrl.pathname;
143+
const queryString = parsedUrl.query;
144+
console.log(req.method + " " + pathWithoutQuery);
145+
if (queryString) console.log(queryString.substring(0, 20) + "...");
146+
switch (req.method + " " + pathWithoutQuery) {
122147
case "GET /":
123148
routes.home(req, res);
124149
break;
125-
case "GET /form":
126-
routes.form(req, res);
150+
case "GET /cla":
151+
routes.cla(req, res);
127152
break;
128-
case "POST /form":
129-
routes.submitForm(req, res);
153+
case "POST /cla":
154+
routes.submitCla(req, res, app.octokit);
130155
break;
131156
case "POST /api/webhook":
132157
middleware(req, res);

form.html

Lines changed: 0 additions & 45 deletions
This file was deleted.

helpers.js

Lines changed: 0 additions & 15 deletions
This file was deleted.

home.html

Lines changed: 0 additions & 34 deletions
This file was deleted.

message.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
2-
"name": "starter_github_app",
3-
"private": true,
4-
"version": "0.0.1",
2+
"name": "rudder_github_app",
3+
"private": false,
4+
"version": "0.0.2",
55
"type": "module",
66
"scripts": {
77
"lint": "standard",
@@ -15,5 +15,8 @@
1515
"dependencies": {
1616
"dotenv": "^16.0.3",
1717
"octokit": "^3.1.2"
18+
},
19+
"engines": {
20+
"node": ">=20"
1821
}
1922
}

src/helpers.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { storage } from "./storage.js";
2+
import { resolve, dirname } from "path";
3+
import url from "url";
4+
5+
const __currentDir = dirname(url.fileURLToPath(import.meta.url)); // Path to directory of this file
6+
export const PROJECT_ROOT_PATH = resolve(__currentDir, ".."); // Assuming this file is located one folder under root
7+
8+
export function parseUrlQueryParams(urlString) {
9+
const url = new URL(urlString);
10+
const params = new URLSearchParams(url.search);
11+
return Object.fromEntries(params.entries());
12+
}
13+
14+
export function queryStringToJson(str) {
15+
if (!str) {
16+
return {};
17+
}
18+
return str.split("&").reduce((result, item) => {
19+
const parts = item.split("=");
20+
const key = decodeURIComponent(parts[0]);
21+
const value = parts.length > 1 ? decodeURIComponent(parts[1]) : "";
22+
result[key] = value;
23+
return result;
24+
}, {});
25+
}
26+
27+
export function isCLARequired(pullRequest) {
28+
if (isABot(pullRequest.user)) {
29+
console.log("This PR is from a bot. So no CLA required.");
30+
return false;
31+
}
32+
if (!isExternalContribution(pullRequest)) {
33+
console.log("This PR is an internal contribution. So no CLA required.");
34+
return false;
35+
}
36+
if (isCLASigned(pullRequest.user.login)) {
37+
console.log("Author signed CLA already. So no CLA required.");
38+
return false;
39+
}
40+
return true;
41+
}
42+
43+
export function isExternalContribution(pullRequest) {
44+
if (
45+
pullRequest?.head?.repo?.full_name !== pullRequest?.base?.repo?.full_name
46+
) {
47+
return true;
48+
}
49+
return false;
50+
}
51+
52+
export function isABot(user) {
53+
if (user?.type === "Bot") {
54+
return true;
55+
}
56+
return false;
57+
}
58+
59+
export async function isOrgMember(octokit, org, username) {
60+
// Check if the is a member of the organization
61+
try {
62+
await octokit.rest.orgs.checkMembershipForUser({
63+
org,
64+
username,
65+
});
66+
return true;
67+
} catch (err) {
68+
console.log(err);
69+
return false;
70+
}
71+
}
72+
73+
export async function afterCLA(octokit, claSignatureInfo) {
74+
if (!claSignatureInfo || !claSignatureInfo.referrer) return;
75+
const { org, repo, prNumber } = parseUrlQueryParams(
76+
claSignatureInfo.referrer,
77+
);
78+
console.log(
79+
`PR related to the CLA - owner: ${org}, repo: ${repo}, prNumber: ${prNumber}`,
80+
);
81+
if (!org || !repo || !prNumber) {
82+
console.log("Not enough info to find the related PR.");
83+
return;
84+
}
85+
try {
86+
await octokit.rest.issues.removeLabel({
87+
owner: org,
88+
repo: repo,
89+
issue_number: prNumber,
90+
name: "Pending CLA",
91+
});
92+
console.log("Label 'Pending CLA' removed successfully.");
93+
} catch (error) {
94+
if (error.response) {
95+
console.error(
96+
`Error! Status: ${error.response.status}. Message: ${error.response.data.message}`,
97+
);
98+
} else {
99+
console.error(error);
100+
}
101+
} finally {
102+
console.log("Completed post CLA verification tasks");
103+
}
104+
}
105+
106+
export function getMessage(name, payload) {
107+
let message = "";
108+
switch (name) {
109+
case "ask-to-sign-cla":
110+
const CLA_LINK =
111+
process.env.WEBSITE_ADDRESS +
112+
"/cla" +
113+
`?org=${payload.org}&repo=${payload.repo}&prNumber=${payload.pr_number}&username=${payload.username}`;
114+
message = `Thank you for contributing this PR.
115+
Please [sign the Contributor License Agreement (CLA)](${CLA_LINK}) before merging.`;
116+
break;
117+
default:
118+
const filepath = resolve(PROJECT_ROOT_PATH, name + ".md");
119+
message = fs.readFileSync(filepath, "utf8");
120+
}
121+
return message;
122+
}
123+
124+
export function isCLASigned(username) {
125+
const userData = storage.get({ username: username, terms: "on" });
126+
if (userData?.length > 0) {
127+
return true;
128+
}
129+
return false;
130+
}

src/storage.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import fs from "fs";
2+
import { resolve } from "path";
3+
4+
import { PROJECT_ROOT_PATH } from "./helpers.js";
5+
6+
const dbPath = process.env.DB_PATH || resolve(PROJECT_ROOT_PATH, "db.json");
7+
8+
export const storage = {
9+
save(data) {
10+
const currentData = JSON.parse(fs.readFileSync(dbPath, "utf8"));
11+
currentData.push(data);
12+
fs.writeFileSync(dbPath, JSON.stringify(currentData, null, 2));
13+
},
14+
get(filters) {
15+
const currentData = JSON.parse(fs.readFileSync(dbPath, "utf8"));
16+
return currentData.filter((item) => {
17+
for (const [key, value] of Object.entries(filters)) {
18+
if (item[key] !== value) {
19+
return false;
20+
}
21+
}
22+
return true;
23+
});
24+
},
25+
};

0 commit comments

Comments
 (0)