From c920ca5f0859b0348523141309eb682a0ebed6bf Mon Sep 17 00:00:00 2001 From: Manish Singh Bisht <114493480+manish-singh-bisht@users.noreply.github.com> Date: Mon, 15 Apr 2024 20:31:00 +0530 Subject: [PATCH] feat: Add automation for PR rejection (#66) * 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 --- lib/constants.ts | 13 +++ lib/github/hooks/issue.ts | 239 +++++++++++++++++++++++++++++++++++++- lib/github/index.ts | 2 + 3 files changed, 250 insertions(+), 4 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index ad93991..4ac010d 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -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", @@ -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; diff --git a/lib/github/hooks/issue.ts b/lib/github/hooks/issue.ts index 5d4457d..8d28e73 100644 --- a/lib/github/hooks/issue.ts +++ b/lib/github/hooks/issue.ts @@ -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"; @@ -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, @@ -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); @@ -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 }; +}; diff --git a/lib/github/index.ts b/lib/github/index.ts index 6dd3e11..07b6b0b 100644 --- a/lib/github/index.ts +++ b/lib/github/index.ts @@ -7,6 +7,7 @@ import { onAwardPoints, onIssueOpened, onPullRequestOpened, + onRejectCommented, onUnassignCommented, } from "./hooks/issue"; @@ -25,4 +26,5 @@ export const registerHooks = async () => { onUnassignCommented(webhooks); onAwardPoints(webhooks); onPullRequestOpened(webhooks); + onRejectCommented(webhooks); };