Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add automation for PR rejection #66

Merged
merged 9 commits into from
Apr 15, 2024
13 changes: 13 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const LEVEL_LABEL = "level";
export const ASSIGN_IDENTIFIER = "/assign" as const;
export const CREATE_IDENTIFIER = "/oss.gg" as const;
export const UNASSIGN_IDENTIFIER = "/unassign" as const;
export const REJECT_IDENTIFIER = "/reject" as const;
export enum EVENT_TRIGGERS {
ISSUE_OPENED = "issues.opened",
INSTALLATION_CREATED = "installation.created",
Expand All @@ -24,6 +25,18 @@ export const ON_NEW_ISSUE = "Thanks for opening an issue! It's live on oss.gg!";
export const ON_REPO_NOT_REGISTERED = `This repository is not registered with oss.gg. Please register it at [oss.gg](https://oss.gg).`;
export const ON_USER_NOT_REGISTERED = `you are not registered as a member of this repository, so you can't post oss.gg issues. Please register at [oss.gg](https://oss.gg).`;
export const POINT_IS_NOT_A_NUMBER = "please provide a valid number of points to assign.";
export const REJECTION_MESSAGE_TEMPLATE = (assignee: string, message: string) => `
Hey @${assignee},

Thanks a lot for the time and effort you put into shipping this! Unfortunately, we cannot accept your contribution for the following reason:

${message}

We will open the issue up for a different contributor to work on. Feel free to stick around in the community and pick up a different issue, if you like :)

Thanks a lot!
`;

export const GITHUB_APP_ID = env.GITHUB_APP_ID as string;
export const GITHUB_APP_PRIVATE_KEY = env.GITHUB_APP_PRIVATE_KEY as string;

Expand Down
239 changes: 235 additions & 4 deletions lib/github/hooks/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
ON_USER_NOT_REGISTERED,
OSS_GG_LABEL,
POINT_IS_NOT_A_NUMBER,
REJECTION_MESSAGE_TEMPLATE,
REJECT_IDENTIFIER,
UNASSIGN_IDENTIFIER,
} from "@/lib/constants";
import { assignUserPoints } from "@/lib/points/service";
Expand Down Expand Up @@ -90,6 +92,27 @@ export const onAssignCommented = async (webhooks: Webhooks) => {
return;
}

//users who haven't linked the issue to the PR will be able to assign themselves again even if their pr was rejected, because their names won't be added to the "Attempted:user1" comment in the issue.
const allCommentsInTheIssue = await octokit.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
let { extractedUserNames } =
await extractUserNamesFromCommentsForRejectCommand(allCommentsInTheIssue);

const isUserPrRejectedBefore = extractedUserNames?.includes(context.payload.comment.user.login);
if (isUserPrRejectedBefore) {
await octokit.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: "You have already attempted this issue.We will open the issue up for a different contributor to work on. Feel free to stick around in the community and pick up a different issue.",
});
return;
}

const { data: userIssues } = await octokit.issues.listForRepo({
owner,
repo,
Expand Down Expand Up @@ -245,17 +268,19 @@ export const onUnassignCommented = async (webhooks: Webhooks) => {
});
return;
}
await octokit.issues.removeAssignees({

await octokit.issues.createComment({
owner,
repo,
issue_number: issueNumber,
assignees: [assignee],
body: "Issue unassigned.",
});
await octokit.issues.createComment({

await octokit.issues.removeAssignees({
owner,
repo,
issue_number: issueNumber,
body: "Issue unassigned.",
assignees: [assignee],
});
} catch (err) {
console.error(err);
Expand Down Expand Up @@ -351,5 +376,211 @@ export const onPullRequestOpened = async (webhooks: Webhooks) => {
const body = context.payload.pull_request.body;
const issueNumber = extractIssueNumbers(body!);
// create a comment on the issue that a PR has been opened

return;
});
};

export const onRejectCommented = async (webhooks: Webhooks) => {
webhooks.on(EVENT_TRIGGERS.ISSUE_COMMENTED, async (context) => {
try {
const issueCommentBody = context.payload.comment.body;
const prNumber = context.payload.issue.number; //this is pr number if comment made from pr,else issue number when made from issue.
const repo = context.payload.repository.name;
const owner = context.payload.repository.owner.login;
const octokit = getOctokitInstance(context.payload.installation?.id!);
const rejectRegex = new RegExp(`${REJECT_IDENTIFIER}\\s+(.*)`, "i");
const match = issueCommentBody.match(rejectRegex);
const isCommentOnPullRequest = context.payload.issue.pull_request;
let comment: string = "";

if (!match) {
return;
}

if (!isCommentOnPullRequest) {
await octokit.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: `The command ${REJECT_IDENTIFIER} only works in PRs, not on issues. Please use it in a Pull Request.`,
});
return;
}

const message = match[1];
const ossGgRepo = await getRepositoryByGithubId(context.payload.repository.id);

let usersThatCanRejectPr = ossGgRepo?.installation.memberships.map((m) => m.userId);
if (!usersThatCanRejectPr) {
throw new Error("No admins for the given repo in oss.gg!");
}
const ossGgUsers = await Promise.all(
usersThatCanRejectPr.map(async (userId) => {
const user = await getUser(userId);
return user?.githubId;
})
);
const isUserAllowedToRejectPr = ossGgUsers?.includes(context.payload.comment.user.id);
if (!isUserAllowedToRejectPr) {
comment = "You are not allowed to reject a pull request.";
await octokit.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: comment,
});
return;
} else {
const extractIssueNumbersFromPrBody = extractIssueNumbers(context.payload.issue.body || "");
const prAuthor = context.payload.issue.user.login;
const rejectionMessage = REJECTION_MESSAGE_TEMPLATE(prAuthor, message);

await octokit.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: rejectionMessage,
});

await octokit.pulls.update({
owner,
repo,
pull_number: prNumber,
state: "closed",
});

if (extractIssueNumbersFromPrBody.length === 0) {
await octokit.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: "This PR is not linked to an issue. Please update the issue status manually.",
});
return;
} else {
extractIssueNumbersFromPrBody.forEach(async (issueNumber: number) => {
//assumption: taking only first 100 comments because first rejection will happen in first 100 comments.If comments are more than 100 then such heavy discussed issue mostly would be given to a core team member.Even if it is given to a non core team member, our requirements would fulfill within 100 comments.
const allCommentsInTheIssue = await octokit.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});

const issue = await octokit.issues.get({
owner,
repo,
issue_number: issueNumber,
});

const issueAssignee = issue.data.assignees ? issue.data.assignees[0]?.login : "";

if (issueAssignee !== prAuthor) {
return;
}

const { hasCommentWithAttemptedUserNames } =
checkFirstOccurenceForAttemptedComment(allCommentsInTheIssue);

if (!hasCommentWithAttemptedUserNames) {
await octokit.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `Attempted:${issueAssignee}`,
});
} else {
const { extractedUserNames, commentId } =
await extractUserNamesFromCommentsForRejectCommand(allCommentsInTheIssue);

extractedUserNames.push(issueAssignee);

commentId &&
(await octokit.issues.updateComment({
owner,
repo,
issue_number: issueNumber,
comment_id: commentId,
body: `Attempted:${extractedUserNames}`,
}));
}

await octokit.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: "The issue is up for grabs again! Feel free to assign yourself using /assign.",
});

await octokit.issues.removeAssignees({
owner,
repo,
issue_number: issueNumber,
assignees: [issueAssignee],
});
});
}
}
} catch (err) {
console.error(err);
}
});
};
const extractUserNamesFromCommentsForRejectCommand = async (allCommentsInTheIssue) => {
const { indexCommentWithAttemptedUserNames, hasCommentWithAttemptedUserNames } =
checkFirstOccurenceForAttemptedComment(allCommentsInTheIssue);

if (indexCommentWithAttemptedUserNames !== null && hasCommentWithAttemptedUserNames) {
const commentContainingUserNamesWhosePrIsRejected =
allCommentsInTheIssue.data[indexCommentWithAttemptedUserNames];

let extractedUserNames: string[] = [];
const namesRegex = /Attempted:(.*)/i;
const match = commentContainingUserNamesWhosePrIsRejected?.body?.match(namesRegex);

if (match && match[1]) {
const namesString = match[1];

extractedUserNames = namesString.split(",");
}
const commentId = Number(commentContainingUserNamesWhosePrIsRejected?.id);

return { extractedUserNames, commentId };
} else {
return { extractedUserNames: [] as string[], commentId: null };
}
};

const checkFirstOccurence = (comments, regex: RegExp) => {
let hasFirstOccurred: boolean = false;
let indexOfFirstOccurred: number | null = null;

comments.forEach((comment, index) => {
if (hasFirstOccurred) return; // stop the loop after first occurrence is found.

const commentBody = comment.body || "";
const isMatch = commentBody.match(regex);
if (isMatch) {
hasFirstOccurred = true;
indexOfFirstOccurred = index;
}
});

return { hasFirstOccurred, indexOfFirstOccurred };
};

const checkFirstOccurenceForAttemptedComment = (allCommentsInTheIssue) => {
let hasCommentWithAttemptedUserNames: boolean = false;
let indexCommentWithAttemptedUserNames: number | null = null;

const { hasFirstOccurred, indexOfFirstOccurred } = checkFirstOccurence(
allCommentsInTheIssue.data,
/Attempted:(.*)/i
);

hasCommentWithAttemptedUserNames = hasFirstOccurred;
indexCommentWithAttemptedUserNames = indexOfFirstOccurred;

return { hasCommentWithAttemptedUserNames, indexCommentWithAttemptedUserNames };
};
2 changes: 2 additions & 0 deletions lib/github/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
onAwardPoints,
onIssueOpened,
onPullRequestOpened,
onRejectCommented,
onUnassignCommented,
} from "./hooks/issue";

Expand All @@ -25,4 +26,5 @@ export const registerHooks = async () => {
onUnassignCommented(webhooks);
onAwardPoints(webhooks);
onPullRequestOpened(webhooks);
onRejectCommented(webhooks);
};
Loading