Skip to content

Commit

Permalink
feat: Add automation for PR rejection (#66)
Browse files Browse the repository at this point in the history
* first draft reject automation pr

* preventing users to assign themselves if rejected before

* dry

* tweaks

* fix typos

* made changes as per review

* removed the check for ossgg label in pr

* Update the error message when /reject made in issue

---------

Co-authored-by: Johannes <[email protected]>
  • Loading branch information
manish-singh-bisht and jobenjada authored Apr 15, 2024
1 parent beeb5d9 commit c920ca5
Show file tree
Hide file tree
Showing 3 changed files with 250 additions and 4 deletions.
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);
};

0 comments on commit c920ca5

Please sign in to comment.