From 7667ebd275f9a741d72332da7767b9b1d88b036a Mon Sep 17 00:00:00 2001 From: csehatt741 <77381875+csehatt741@users.noreply.github.com> Date: Thu, 20 Feb 2025 07:21:05 +0100 Subject: [PATCH] chore: Issue PR reminder workflow created (#763) --- .github/workflows/auto-assign.yaml | 115 ++++++++++++--- .github/workflows/issue-pr-reminder.yaml | 172 ++++++++++++++++++++++ .github/workflows/issue-unassign.yaml | 123 ++++++++++++++++ .github/workflows/pr-close.yaml | 115 +++++++++++++++ .github/workflows/pr-review-reminder.yaml | 163 ++++++++++++++++++++ 5 files changed, 669 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/issue-pr-reminder.yaml create mode 100644 .github/workflows/issue-unassign.yaml create mode 100644 .github/workflows/pr-close.yaml create mode 100644 .github/workflows/pr-review-reminder.yaml diff --git a/.github/workflows/auto-assign.yaml b/.github/workflows/auto-assign.yaml index a882dfc4e..a4268bc67 100644 --- a/.github/workflows/auto-assign.yaml +++ b/.github/workflows/auto-assign.yaml @@ -9,13 +9,79 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/github-script@v7 + env: + USER_MAX_CONCURRENT_ISSUE_COUNT: ${{ vars.USER_MAX_CONCURRENT_ISSUE_COUNT }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const comment = context.payload.comment; const issue = context.issue; - const owner = "keyshade-xyz"; - const repo = "keyshade"; + const owner = 'keyshade-xyz'; + const repo = 'keyshade'; + const userMaxConcurrentIssueCount = process.env.USER_MAX_CONCURRENT_ISSUE_COUNT || 5; + + async function listIssueEvents(issue) { + const allEvents = []; + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + const issuesResponse = await github.rest.issues.listEventsForTimeline({ + owner, + repo, + issue_number: issue.number, + per_page: 100, + page + }); + + allEvents.push(...issuesResponse.data); + + hasNextPage = issuesResponse.headers.link && issuesResponse.headers.link.includes('rel="next"'); + page++; + } + + return allEvents; + } + + async function listUserOpenIssuesWithoutActivePullRequest(user) { + const issuesWithoutActivePullRequest = []; + + const { data: userOpenIssues } = await github.rest.issues.listForRepo({ + owner, + repo, + state: 'open', + assignee: user.login, + per_page: 100 + }); + + for (const issue of userOpenIssues) { + const events = await listIssueEvents(issue); + const userPullRequestIssues = events + .filter(event => event.event === 'cross-referenced') + .map(event => event.source.issue) + .filter(issue => issue && issue.pull_request && issue.user.login === comment.user.login); + + if(userPullRequestIssues.length === 0) { + issuesWithoutActivePullRequest.push(issue); + continue; + } + + for (const pullRequestIssue of userPullRequestIssues) { + const { data: pullRequest } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullRequestIssue.number + }); + + if(pullRequest.draft) { + issuesWithoutActivePullRequest.push(issue); + break; + } + } + } + + return issuesWithoutActivePullRequest; + } async function updateProjectStatus(issueNumber) { const projectsResponse = await github.rest.projects.listForRepo({ @@ -30,7 +96,7 @@ jobs: per_page: 100, }); - const inProgressColumn = columnsResponse.data.find(column => column.name === "In Progress"); + const inProgressColumn = columnsResponse.data.find(column => column.name === 'In Progress'); if (!inProgressColumn) continue; const cardsResponse = await github.rest.projects.listCards({ @@ -38,13 +104,13 @@ jobs: per_page: 100, }); - const issueCardExists = cardsResponse.data.some(card => card.content_id === issueNumber && card.content_type === "Issue"); + const issueCardExists = cardsResponse.data.some(card => card.content_id === issueNumber && card.content_type === 'Issue'); if (!issueCardExists) { await github.rest.projects.createCard({ column_id: inProgressColumn.id, content_id: issueNumber, - content_type: "Issue", + content_type: 'Issue', }); } } @@ -52,27 +118,38 @@ jobs: if (comment.body.startsWith('/attempt')) { if (!issue.assignee) { - await github.rest.issues.addAssignees({ - owner, - repo, - issue_number: issue.number, - assignees: [comment.user.login], - }); + const userActiveIssues = await listUserOpenIssuesWithoutActivePullRequest(comment.user); - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issue.number, - body: `Assigned the issue to @${comment.user.login}!`, - }); + if(userActiveIssues.length < userMaxConcurrentIssueCount) { + await github.rest.issues.addAssignees({ + owner, + repo, + issue_number: issue.number, + assignees: [comment.user.login], + }); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `Assigned the issue to @${comment.user.login}!`, + }); - await updateProjectStatus(issue.number); + await updateProjectStatus(issue.number); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `@${comment.user.login}, cannot concurrently work on more than ${userMaxConcurrentIssueCount} issues. Tag a maintainer if you need to take over.`, + }); + } } else { await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, - body: 'This issue is already assigned. Tag a maintainer if you need to take over.', + body: `@${comment.user.login}, this issue is already assigned. Tag a maintainer if you need to take over.`, }); } } diff --git a/.github/workflows/issue-pr-reminder.yaml b/.github/workflows/issue-pr-reminder.yaml new file mode 100644 index 000000000..f05a4d95f --- /dev/null +++ b/.github/workflows/issue-pr-reminder.yaml @@ -0,0 +1,172 @@ +name: Issue PR Reminder + +on: + schedule: + - cron: '0 * * * *' # Runs every hour + +jobs: + issue-pr-reminder: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + env: + CREATE_ISSUE_PR_REMINDER_ENABLED: ${{ vars.CREATE_ISSUE_PR_REMINDER_ENABLED }} + CREATE_ISSUE_PR_REMINDER_AFTER_ISSUE_ASSIGNED_DAYS: ${{ vars.CREATE_ISSUE_PR_REMINDER_AFTER_ISSUE_ASSIGNED_DAYS }} + CREATE_ISSUE_PR_REMINDER_BEFORE_ISSUE_UNASSIGNED_DAYS: ${{ vars.CREATE_ISSUE_PR_REMINDER_BEFORE_ISSUE_UNASSIGNED_DAYS }} + UNASSIGN_ISSUE_AFTER_DAYS: ${{ vars.UNASSIGN_ISSUE_AFTER_DAYS }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const owner = 'keyshade-xyz'; + const repo = 'keyshade'; + const createPrReminderEnabled = process.env.CREATE_ISSUE_PR_REMINDER_ENABLED || false; + const createPrReminderAfterIssueAssignedDays = process.env.CREATE_ISSUE_PR_REMINDER_AFTER_ISSUE_ASSIGNED_DAYS || 2; + const createPrReminderBeforeIssueUnassignedDays = process.env.CREATE_ISSUE_PR_REMINDER_BEFORE_ISSUE_UNASSIGNED_DAYS || 2; + const unassignIssueAfterDays = process.env.UNASSIGN_ISSUE_AFTER_DAYS || 14; + const createPrReminderAfterIssueAssignedMilliseconds = createPrReminderAfterIssueAssignedDays * 24 * 60 * 60 * 1000; + const createPrReminderBeforeIssueUnassignedMilliseconds = createPrReminderBeforeIssueUnassignedDays * 24 * 60 * 60 * 1000; + const unassignIssueAfterMilliseconds = unassignIssueAfterDays * 24 * 60 * 60 * 1000; + const now = Date.now(); + + if(!createPrReminderEnabled) { + console.log('!!! Dry run, there are no changes made !!!'); + } + + async function listOpenIssues() { + const issues = []; + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + const issuesResponse = await github.rest.issues.listForRepo({ + owner, + repo, + state: 'open', + per_page: 100, + page + }); + + issues.push(...issuesResponse.data); + + hasNextPage = issuesResponse.headers.link && issuesResponse.headers.link.includes('rel="next"'); + page++; + } + + return issues; + } + + async function listIssueEvents(issue) { + const events = []; + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + const eventsResponse = await github.rest.issues.listEventsForTimeline({ + owner, + repo, + issue_number: issue.number, + per_page: 100, + page + }); + + events.push(...eventsResponse.data); + + hasNextPage = eventsResponse.headers.link && eventsResponse.headers.link.includes('rel="next"'); + page++; + } + + return events.sort((a, b) => a.id > b.id); + } + + async function listIssueComments(issue) { + const comments = []; + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + const commentsResponse = await github.rest.issues.listComments({ + owner, + repo, + issue_number: issue.number, + per_page: 100, + page + }); + + comments.push(...commentsResponse.data); + + hasNextPage = commentsResponse.headers.link && commentsResponse.headers.link.includes('rel="next"'); + page++; + } + + return comments; + } + + async function createPrReminder(assignee, issueNumber, comments, createPrReminderAfter, prReminder) { + // Check if it is time to create PR reminder + if (now < createPrReminderAfter) { + return false; + } + + // Check if PR reminder has already been created + if (comments.some(comment => comment.body === prReminder)) { + return false; + } + + // Create PR reminder + if(createPrReminderEnabled) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: prReminder + }); + } + + console.log(`Issue '${issueNumber}' PR reminder created for: ${assignee.login}`); + + return true; + } + + async function createPrReminders() { + const issues = await listOpenIssues(); + + for (const issue of issues) { + const events = await listIssueEvents(issue); + const pullRequests = events + .filter(event => event.event === 'cross-referenced') + .map(event => event.source.issue) + .filter(issue => issue && issue.pull_request); + const comments = await listIssueComments(issue); + + for (const assignee of issue.assignees) { + const assignedEvent = events.find(event => event.event === 'assigned' && event.assignee.login === assignee.login); + const issueAssignedAt = new Date(assignedEvent.created_at); + + // Check if assignee has already opened a PR + const assigneePullRequest = pullRequests.find(pullRequest => pullRequest.user.login === assignee.login); + + if(assigneePullRequest) { + continue; + } + + // Create first PR reminder + const createFirstPrReminderAfter = new Date(issueAssignedAt.getTime() + createPrReminderAfterIssueAssignedMilliseconds); + const firstPrReminder = `@${assignee.login}, please open a draft PR linking this issue!`; + + const isFirstPrReminderCreated = await createPrReminder(assignee, issue.number, comments, createFirstPrReminderAfter, firstPrReminder); + + if(isFirstPrReminderCreated) { + continue; + } + + // Create final PR reminder + const unassignIssueAfter = new Date(issueAssignedAt.getTime() + unassignIssueAfterMilliseconds); + const createFinalPrReminderAfter = new Date(issueAssignedAt.getTime() + unassignIssueAfterMilliseconds - createPrReminderBeforeIssueUnassignedMilliseconds); + const finalPrReminder = `@${assignee.login}, please open a draft PR linking this issue; otherwise you will be unassigned from this issue after ${unassignIssueAfter}!`; + + await createPrReminder(assignee, issue.number, comments, createFinalPrReminderAfter, finalPrReminder); + } + } + } + + await createPrReminders(); \ No newline at end of file diff --git a/.github/workflows/issue-unassign.yaml b/.github/workflows/issue-unassign.yaml new file mode 100644 index 000000000..2c8df30ca --- /dev/null +++ b/.github/workflows/issue-unassign.yaml @@ -0,0 +1,123 @@ +name: Issue Unassign + +on: + schedule: + - cron: '0 * * * *' # Runs every hour + +jobs: + issue-unassign: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + env: + UNASSIGN_ISSUE_ENABLED: ${{ vars.UNASSIGN_ISSUE_ENABLED }} + UNASSIGN_ISSUE_AFTER_DAYS: ${{ vars.UNASSIGN_ISSUE_AFTER_DAYS }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const owner = 'keyshade-xyz'; + const repo = 'keyshade'; + const unassignIssueEnabled = process.env.UNASSIGN_ISSUE_ENABLED || false; + const unassignIssueAfterDays = process.env.UNASSIGN_ISSUE_AFTER_DAYS || 14; + const unassignIssueAfterMilliseconds = unassignIssueAfterDays * 24 * 60 * 60 * 1000; + const now = Date.now(); + + if(!unassignIssueEnabled) { + console.log('!!! Dry run, there are no changes made !!!'); + } + + async function listOpenIssues() { + const issues = []; + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + const issuesResponse = await github.rest.issues.listForRepo({ + owner, + repo, + state: 'open', + per_page: 100, + page + }); + + issues.push(...issuesResponse.data); + + hasNextPage = issuesResponse.headers.link && issuesResponse.headers.link.includes('rel="next"'); + page++; + } + + return issues; + } + + async function listIssueEvents(issue) { + const allEvents = []; + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + const issuesResponse = await github.rest.issues.listEventsForTimeline({ + owner, + repo, + issue_number: issue.number, + per_page: 100, + page + }); + + allEvents.push(...issuesResponse.data); + + hasNextPage = issuesResponse.headers.link && issuesResponse.headers.link.includes('rel="next"'); + page++; + } + + return allEvents.sort((a, b) => a.id > b.id); + } + + async function unassignIssues() { + const issues = await listOpenIssues(); + + for (const issue of issues) { + const events = await listIssueEvents(issue); + const pullRequests = events + .filter(event => event.event === 'cross-referenced') + .map(event => event.source.issue) + .filter(issue => issue && issue.pull_request); + + for (const assignee of issue.assignees) { + // Check if assignee has already opened a PR + const assigneePullRequest = pullRequests.find(pullRequest => pullRequest.user.login === assignee.login); + + if(assigneePullRequest) { + continue; + } + + // Check if it is time to unassign issue + const assignedEvent = events.find(event => event.event === 'assigned' && event.assignee.login === assignee.login); + const issueAssignedAt = new Date(assignedEvent.created_at); + const unassignIssueAfter = new Date(issueAssignedAt.getTime() + unassignIssueAfterMilliseconds); + + if (now < unassignIssueAfter) { + continue; + } + + if(unassignIssueEnabled) { + await github.rest.issues.removeAssignees({ + owner, + repo, + issue_number: issue.number, + assignees: [assignee.login], + }); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `Unassigned the issue from @${assignee.login} due to inactivity!`, + }); + } + + console.log(`Issue '${issue.number}' user unassigned: ${assignee.login}`); + } + } + } + + await unassignIssues(); \ No newline at end of file diff --git a/.github/workflows/pr-close.yaml b/.github/workflows/pr-close.yaml new file mode 100644 index 000000000..5ecec9f82 --- /dev/null +++ b/.github/workflows/pr-close.yaml @@ -0,0 +1,115 @@ +name: PR Close + +on: + schedule: + - cron: '0 * * * *' # Runs every hour + +jobs: + pr-close: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + env: + CLOSE_PR_ENABLED: ${{ vars.CLOSE_PR_ENABLED }} + CLOSE_PR_AFTER_DAYS: ${{ vars.CLOSE_PR_AFTER_DAYS }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const owner = 'keyshade-xyz'; + const repo = 'keyshade'; + const closePrEnabled = process.env.CLOSE_PR_ENABLED || false; + const closePrAfterDays = process.env.CLOSE_PR_AFTER_DAYS || 7; + const closePrAfterMilliseconds = closePrAfterDays * 24 * 60 * 60 * 1000; + const now = Date.now(); + + if(!closePrEnabled) { + console.log('!!! Dry run, there are no changes made !!!'); + } + + async function listOpenPrs() { + const pullRequests = []; + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + const pullRequestsResponse = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + per_page: 100, + page + }); + + pullRequests.push(...pullRequestsResponse.data); + + hasNextPage = pullRequestsResponse.headers.link && pullRequestsResponse.headers.link.includes('rel="next"'); + page++; + } + + return pullRequests; + } + + async function listPrReviews(pullRequest) { + const reviews = []; + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + const reviewsResponse = await github.rest.pulls.listReviews({ + owner, + repo, + pull_number: pullRequest.number, + per_page: 100, + page + }); + + reviews.push(...reviewsResponse.data); + + hasNextPage = reviewsResponse.headers.link && reviewsResponse.headers.link.includes('rel="next"'); + page++; + } + + return reviews; + } + + async function closePrs() { + const pullRequests = await listOpenPrs(); + + for (const pullRequest of pullRequests) { + const reviews = await listPrReviews(pullRequest); + const unapprovedReviews = reviews.filter(review => review.state !== 'APPROVED'); + const pullRequestCreatedAt = new Date(pullRequest.created_at); + + // Check if PR has unapproved reviews + if(unapprovedReviews.length === 0) { + continue; + } + + // Check if it is time to close PR + const closePrAfter = new Date(pullRequestCreatedAt.getTime() + closePrAfterMilliseconds); + + if (now < closePrAfter) { + continue; + } + + if(closePrEnabled) { + await github.rest.pulls.update({ + owner, + repo, + pull_number: pullRequest.number, + state: 'closed' + }); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pullRequest.number, + body: `PR closed due to inactivity`, + }); + } + + console.log(`PR '${pullRequest.number}' closed`); + } + } + + await closePrs(); \ No newline at end of file diff --git a/.github/workflows/pr-review-reminder.yaml b/.github/workflows/pr-review-reminder.yaml new file mode 100644 index 000000000..aa466b7f4 --- /dev/null +++ b/.github/workflows/pr-review-reminder.yaml @@ -0,0 +1,163 @@ +name: PR Review Reminder + +on: + schedule: + - cron: '0 * * * *' # Runs every hour + +jobs: + pr-review-reminder: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + env: + CREATE_PR_REVIEW_REMINDER_ENABLED: ${{ vars.CREATE_PR_REVIEW_REMINDER_ENABLED }} + CREATE_PR_REVIEW_REMINDER_AFTER_PR_OPENED_DAYS: ${{ vars.CREATE_PR_REVIEW_REMINDER_AFTER_PR_OPENED_DAYS }} + CREATE_PR_REVIEW_REMINDER_BEFORE_PR_CLOSED_DAYS: ${{ vars.CREATE_PR_REVIEW_REMINDER_BEFORE_PR_CLOSED_DAYS }} + CLOSE_PR_AFTER_DAYS: ${{ vars.CLOSE_PR_AFTER_DAYS }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const owner = 'keyshade-xyz'; + const repo = 'keyshade'; + const createReviewReminderEnabled = process.env.CREATE_PR_REVIEW_REMINDER_ENABLED || false; + const createReviewReminderAfterPrOpenedDays = process.env.CREATE_PR_REVIEW_REMINDER_AFTER_PR_OPENED_DAYS || 2; + const createReviewReminderBeforePrClosedDays = process.env.CREATE_PR_REVIEW_REMINDER_BEFORE_PR_CLOSED_DAYS || 2; + const closePrAfterDays = process.env.CLOSE_PR_AFTER_DAYS || 7; + const createReviewReminderAfterPrOpenedMilliseconds = createReviewReminderAfterPrOpenedDays * 24 * 60 * 60 * 1000; + const createReviewReminderBeforePrClosedMilliseconds = createReviewReminderBeforePrClosedDays * 24 * 60 * 60 * 1000; + const closePrAfterMilliseconds = closePrAfterDays * 24 * 60 * 60 * 1000; + const now = Date.now(); + + if(!createReviewReminderEnabled) { + console.log('!!! Dry run, there are no changes made !!!'); + } + + async function listOpenPrs() { + const pullRequests = []; + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + const pullRequestsResponse = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + per_page: 100, + page + }); + + pullRequests.push(...pullRequestsResponse.data); + + hasNextPage = pullRequestsResponse.headers.link && pullRequestsResponse.headers.link.includes('rel="next"'); + page++; + } + + return pullRequests; + } + + async function listPrReviews(pullRequest) { + const reviews = []; + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + const reviewsResponse = await github.rest.pulls.listReviews({ + owner, + repo, + pull_number: pullRequest.number, + per_page: 100, + page + }); + + reviews.push(...reviewsResponse.data); + + hasNextPage = reviewsResponse.headers.link && reviewsResponse.headers.link.includes('rel="next"'); + page++; + } + + return reviews; + } + + async function listPrComments(pullRequest) { + const comments = []; + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + const issuesResponse = await github.rest.issues.listComments({ + owner, + repo, + issue_number: pullRequest.number, + per_page: 100, + page + }); + + comments.push(...issuesResponse.data); + + hasNextPage = issuesResponse.headers.link && issuesResponse.headers.link.includes('rel="next"'); + page++; + } + + return comments; + } + + async function createReviewReminder(user, pullRequestNumber, comments, createReviewReminderAfter, reviewReminder) { + // Check if it is time to create review reminder + if (now < createReviewReminderAfter) { + return false; + } + + // Check if review reminder has already been created + if (comments.some(comment => comment.body === reviewReminder)) { + return false; + } + + // Create review reminder + if(createReviewReminderEnabled) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pullRequestNumber, + body: reviewReminder + }); + } + + console.log(`PR '${pullRequestNumber}' review reminder created for: ${user.login}`); + + return true; + } + + async function createReviewReminders() { + const pullRequests = await listOpenPrs(); + + for (const pullRequest of pullRequests) { + const reviews = await listPrReviews(pullRequest); + const unapprovedReviews = reviews.filter(review => review.state !== 'APPROVED'); + const comments = await listPrComments(pullRequest); + const pullRequestCreatedAt = new Date(pullRequest.created_at); + + // Check if PR has unapproved reviews + if(unapprovedReviews.length === 0) { + continue; + } + + // Create first review reminder + const createFirstReviewReminderAfter = new Date(pullRequestCreatedAt.getTime() + createReviewReminderAfterPrOpenedMilliseconds); + const firstReviewReminder = `@${pullRequest.user.login}, please resolve all open reviews!`; + + const isFirstReviewReminderCreated = await createReviewReminder(pullRequest.user, pullRequest.number, comments, createFirstReviewReminderAfter, firstReviewReminder); + + if(isFirstReviewReminderCreated) { + continue; + } + + // Create final review reminder + const closePrAfter = new Date(pullRequestCreatedAt.getTime() + closePrAfterMilliseconds); + const createFinalReviewReminderAfter = new Date(pullRequestCreatedAt.getTime() + closePrAfterMilliseconds - createReviewReminderBeforePrClosedMilliseconds); + const finalReviewReminder = `@${pullRequest.user.login}, please resolve all open reviews; otherwise this PR will be closed after ${closePrAfter}!`; + + await createReviewReminder(pullRequest.user, pullRequest.number, comments, createFinalReviewReminderAfter, finalReviewReminder); + } + } + + await createReviewReminders(); \ No newline at end of file