From 39095d314bb2a03506e58ab486ef3db895c574df Mon Sep 17 00:00:00 2001 From: manan Date: Mon, 12 Jan 2026 03:51:35 +0530 Subject: [PATCH 1/3] feat: add issue approval workflow --- .github/workflows/global-replicator.yml | 2 +- .github/workflows/issue-approval-check.yml | 75 +++++++++ .github/workflows/issue-approval-commands.yml | 145 ++++++++++++++++++ .github/workflows/issue-needs-approval.yml | 63 ++++++++ .github/workflows/pr-approval-check.yml | 112 ++++++++++++++ 5 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/issue-approval-check.yml create mode 100644 .github/workflows/issue-approval-commands.yml create mode 100644 .github/workflows/issue-needs-approval.yml create mode 100644 .github/workflows/pr-approval-check.yml diff --git a/.github/workflows/global-replicator.yml b/.github/workflows/global-replicator.yml index 3439f25c..60f5f9dd 100644 --- a/.github/workflows/global-replicator.yml +++ b/.github/workflows/global-replicator.yml @@ -159,7 +159,7 @@ jobs: uses: derberg/manage-files-in-multiple-repositories@beecbe897cf5ed7f3de5a791a3f2d70102fe7c25 with: github_token: ${{ secrets.GH_TOKEN }} - patterns_to_include: .github/workflows/scripts,.github/workflows/automerge-for-humans-add-ready-to-merge-or-do-not-merge-label.yml,.github/workflows/add-good-first-issue-labels.yml,.github/workflows/automerge-for-humans-merging.yml,.github/workflows/automerge-for-humans-remove-ready-to-merge-label-on-edit.yml,.github/workflows/automerge-orphans.yml,.github/workflows/automerge.yml,.github/workflows/autoupdate.yml,.github/workflows/help-command.yml,.github/workflows/issues-prs-notifications.yml,.github/workflows/lint-pr-title.yml,.github/workflows/notify-tsc-members-mention.yml,.github/workflows/stale-issues-prs.yml,.github/workflows/welcome-first-time-contrib.yml,.github/workflows/release-announcements.yml,.github/workflows/bounty-program-commands.yml,.github/workflows/please-take-a-look-command.yml,.github/workflows/update-pr.yml,.github/workflows/transfer-issue.yml + patterns_to_include: .github/workflows/scripts,.github/workflows/automerge-for-humans-add-ready-to-merge-or-do-not-merge-label.yml,.github/workflows/add-good-first-issue-labels.yml,.github/workflows/automerge-for-humans-merging.yml,.github/workflows/automerge-for-humans-remove-ready-to-merge-label-on-edit.yml,.github/workflows/automerge-orphans.yml,.github/workflows/automerge.yml,.github/workflows/autoupdate.yml,.github/workflows/help-command.yml,.github/workflows/issues-prs-notifications.yml,.github/workflows/lint-pr-title.yml,.github/workflows/notify-tsc-members-mention.yml,.github/workflows/stale-issues-prs.yml,.github/workflows/welcome-first-time-contrib.yml,.github/workflows/release-announcements.yml,.github/workflows/bounty-program-commands.yml,.github/workflows/please-take-a-look-command.yml,.github/workflows/update-pr.yml,.github/workflows/transfer-issue.yml,.github/workflows/issue-needs-approval.yml,.github/workflows/issue-approval-check.yml,.github/workflows/issue-approval-commands.yml,.github/workflows/pr-approval-check.yml committer_username: asyncapi-bot committer_email: info@asyncapi.io commit_message: "ci: update of files from global .github repo" diff --git a/.github/workflows/issue-approval-check.yml b/.github/workflows/issue-approval-check.yml new file mode 100644 index 00000000..35099d2d --- /dev/null +++ b/.github/workflows/issue-approval-check.yml @@ -0,0 +1,75 @@ +name: Issue Approval Check + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +jobs: + check-unapproved-issues: + if: startsWith(github.repository, 'asyncapi/') + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const now = new Date(); + const sevenDaysAgo = new Date(now - 7 * 24 * 60 * 60 * 1000); + const twentyOneDaysAgo = new Date(now - 21 * 24 * 60 * 60 * 1000); + + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'needs-approval', + per_page: 100 + }); + + for (const issue of issues) { + if (issue.pull_request) continue; + + const createdAt = new Date(issue.created_at); + const issueNumber = issue.number; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100 + }); + + const hasReminder = comments.some(c => + c.body.includes('Reminder: This issue is still awaiting approval') + ); + + if (createdAt < twentyOneDaysAgo) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: `This issue has been automatically closed because it was not approved by a maintainer within 21 days.\n\nThis doesn't mean your issue isn't valid! It may simply mean maintainers haven't had time to review it. Feel free to:\n- Reopen this issue with more context\n- Reach out on [Slack](https://asyncapi.com/slack-invite) for discussion\n\nThank you for your understanding! ❤️` + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + state: 'closed', + state_reason: 'not_planned' + }); + + console.log(`Closed issue #${issueNumber} (no approval after 21 days)`); + } + else if (createdAt < sevenDaysAgo && !hasReminder) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: `**Reminder:** This issue is still awaiting approval from a maintainer.\n\nIf a maintainer doesn't approve this issue within the next **14 days**, it will be automatically closed.\n\nMaintainers: Please comment \`/approve\` to approve this issue or \`/reject\` to reject it.` + }); + + console.log(`Posted reminder on issue #${issueNumber}`); + } + } + diff --git a/.github/workflows/issue-approval-commands.yml b/.github/workflows/issue-approval-commands.yml new file mode 100644 index 00000000..ef9c8cd9 --- /dev/null +++ b/.github/workflows/issue-approval-commands.yml @@ -0,0 +1,145 @@ +name: Issue Approval Commands + +on: + issue_comment: + types: [created] + +jobs: + handle-approval-command: + if: > + startsWith(github.repository, 'asyncapi/') && + !github.event.issue.pull_request && + github.event.issue.state != 'closed' && + ( + contains(github.event.comment.body, '/approve') || + contains(github.event.comment.body, '/reject') + ) + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const commentBody = context.payload.comment.body.trim(); + const commenter = context.payload.comment.user.login; + const issueNumber = context.payload.issue.number; + + const botsList = ['asyncapi-bot', 'dependabot[bot]', 'dependabot-preview[bot]', 'allcontributors[bot]', 'github-actions[bot]']; + if (botsList.includes(commenter)) { + console.log(`Skipping: ${commenter} is a bot`); + return; + } + + const isApprove = commentBody === '/approve' || commentBody.startsWith('/approve '); + const isReject = commentBody === '/reject' || commentBody.startsWith('/reject '); + + if (!isApprove && !isReject) { + return; + } + + let permissionLevel = 'none'; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: commenter + }); + permissionLevel = data.permission; + } catch (error) { + console.log(`Could not get permission level for ${commenter}: ${error.message}`); + } + + if (!['admin', 'write'].includes(permissionLevel)) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: `@${commenter} Only maintainers with write or admin permissions can use the \`/approve\` and \`/reject\` commands.` + }); + return; + } + + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + + const labels = issue.labels.map(l => l.name); + + if (isApprove) { + if (labels.includes('approved')) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: `@${commenter} This issue is already approved. ✅` + }); + return; + } + + if (labels.includes('needs-approval')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: 'needs-approval' + }); + } + + if (!labels.includes('approved')) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['approved'] + }); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: `This issue has been approved by @${commenter}! 🎉\n\nContributors are welcome to work on this issue. If you'd like to work on it, please comment below to get assigned.` + }); + + console.log(`Issue #${issueNumber} approved by ${commenter}`); + } + + if (isReject) { + if (labels.includes('needs-approval')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: 'needs-approval' + }); + } + + if (labels.includes('approved')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: 'approved' + }); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: `This issue has been rejected by @${commenter}.\n\nThis issue doesn't align with current project priorities or guidelines. If you believe this was closed in error, feel free to reopen it with additional context.` + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + state: 'closed', + state_reason: 'not_planned' + }); + + console.log(`Issue #${issueNumber} rejected and closed by ${commenter}`); + } + diff --git a/.github/workflows/issue-needs-approval.yml b/.github/workflows/issue-needs-approval.yml new file mode 100644 index 00000000..6a908589 --- /dev/null +++ b/.github/workflows/issue-needs-approval.yml @@ -0,0 +1,63 @@ +name: Issue Needs Approval + +on: + issues: + types: [opened, reopened] + +jobs: + add-needs-approval: + if: startsWith(github.repository, 'asyncapi/') + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const botsList = ['asyncapi-bot', 'dependabot[bot]', 'dependabot-preview[bot]', 'allcontributors[bot]', 'github-actions[bot]']; + const issueAuthor = context.payload.issue.user.login; + + if (botsList.includes(issueAuthor)) { + console.log(`Skipping: ${issueAuthor} is a bot`); + return; + } + + let permissionLevel = 'none'; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: issueAuthor + }); + permissionLevel = data.permission; + } catch (error) { + console.log(`Could not get permission level for ${issueAuthor}: ${error.message}`); + } + + if (['admin', 'write'].includes(permissionLevel)) { + console.log(`Skipping: ${issueAuthor} is a maintainer (${permissionLevel})`); + return; + } + + const issueNumber = context.payload.issue.number; + const action = context.payload.action; + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['needs-approval'] + }); + + const message = action === 'reopened' + ? `This issue has been reopened and requires maintainer approval again.\n\nMaintainers can approve this issue by commenting \`/approve\` or reject it by commenting \`/reject\`.\n\nIf no approval is given within **21 days**, this issue will be automatically closed.\n\nThank you for your patience! ❤️` + : `Thank you for opening this issue! 🎉\n\nThis issue requires approval from a maintainer before work can begin. Maintainers can approve this issue by commenting \`/approve\` or reject it by commenting \`/reject\`.\n\n**Timeline:**\n- If not approved within **7 days**, a reminder will be posted\n- If not approved within **21 days**, this issue will be automatically closed\n\nPlease make sure your issue follows our [Contributing Guidelines](https://github.com/asyncapi/.github/blob/master/CONTRIBUTING.md).\n\nThank you for your patience! ❤️`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: message + }); + + console.log(`Added needs-approval label and comment to issue #${issueNumber}`); + diff --git a/.github/workflows/pr-approval-check.yml b/.github/workflows/pr-approval-check.yml new file mode 100644 index 00000000..7d1f7ede --- /dev/null +++ b/.github/workflows/pr-approval-check.yml @@ -0,0 +1,112 @@ +name: PR Approval Check + +on: + pull_request_target: + types: [opened] + +jobs: + check-linked-issue: + if: startsWith(github.repository, 'asyncapi/') + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const botsList = ['asyncapi-bot', 'dependabot[bot]', 'dependabot-preview[bot]', 'allcontributors[bot]', 'github-actions[bot]']; + const prAuthor = context.payload.pull_request.user.login; + + if (botsList.includes(prAuthor)) { + console.log(`Skipping: ${prAuthor} is a bot`); + return; + } + + let permissionLevel = 'none'; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: prAuthor + }); + permissionLevel = data.permission; + } catch (error) { + console.log(`Could not get permission level for ${prAuthor}: ${error.message}`); + } + + if (['admin', 'write'].includes(permissionLevel)) { + console.log(`Skipping: ${prAuthor} is a maintainer (${permissionLevel})`); + return; + } + + const prNumber = context.payload.pull_request.number; + const prBody = context.payload.pull_request.body || ''; + + const issuePattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)/gi; + const matches = [...prBody.matchAll(issuePattern)]; + const linkedIssues = matches.map(m => parseInt(m[1])); + + if (linkedIssues.length === 0) { + console.log(`PR #${prNumber} has no linked issues - allowing standalone PR`); + return; + } + + for (const issueNumber of linkedIssues) { + let issue; + try { + const { data } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + issue = data; + } catch (error) { + console.log(`Could not fetch issue #${issueNumber}: ${error.message}`); + continue; + } + + const labels = issue.labels.map(l => l.name); + const isApproved = labels.includes('approved'); + const needsApproval = labels.includes('needs-approval'); + const isClosed = issue.state === 'closed'; + + if (isClosed && !isApproved) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `This PR references issue #${issueNumber} which was closed without approval.\n\nPRs should only address approved issues. Please ensure the related issue is approved before submitting a PR.\n\nThis PR will be closed automatically. If you believe this is a mistake, please reach out to a maintainer.` + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + state: 'closed' + }); + + console.log(`Closed PR #${prNumber} - linked issue #${issueNumber} was closed without approval`); + return; + } + + if (needsApproval) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `This PR references issue #${issueNumber} which has not been approved yet.\n\nPlease wait for a maintainer to approve the issue before submitting a PR. This ensures your effort aligns with project goals.\n\nThis PR will be closed automatically. Once the issue is approved, feel free to reopen this PR or create a new one.` + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + state: 'closed' + }); + + console.log(`Closed PR #${prNumber} - linked issue #${issueNumber} needs approval`); + return; + } + } + + console.log(`PR #${prNumber} passed approval check`); + From b00d05b395784af318b2f3cb319350b56980c168 Mon Sep 17 00:00:00 2001 From: Manancode Date: Wed, 28 Jan 2026 21:05:59 +0530 Subject: [PATCH 2/3] Adding new workflows --- .github/workflows/issue-closure-label.yml | 54 ++++ .github/workflows/issue-triage-on-comment.yml | 87 +++++++ .github/workflows/issue-triage-on-open.yml | 100 ++++++++ .github/workflows/issue-triage-reminders.yml | 119 +++++++++ .github/workflows/pr-closed-without-merge.yml | 57 +++++ .github/workflows/pr-commit-detection.yml | 45 ++++ .github/workflows/pr-merged-cleanup.yml | 68 +++++ .github/workflows/pr-reminders.yml | 143 +++++++++++ .github/workflows/pr-reopened.yml | 177 +++++++++++++ .github/workflows/pr-triage-check.yml | 234 ++++++++++++++++++ 10 files changed, 1084 insertions(+) create mode 100644 .github/workflows/issue-closure-label.yml create mode 100644 .github/workflows/issue-triage-on-comment.yml create mode 100644 .github/workflows/issue-triage-on-open.yml create mode 100644 .github/workflows/issue-triage-reminders.yml create mode 100644 .github/workflows/pr-closed-without-merge.yml create mode 100644 .github/workflows/pr-commit-detection.yml create mode 100644 .github/workflows/pr-merged-cleanup.yml create mode 100644 .github/workflows/pr-reminders.yml create mode 100644 .github/workflows/pr-reopened.yml create mode 100644 .github/workflows/pr-triage-check.yml diff --git a/.github/workflows/issue-closure-label.yml b/.github/workflows/issue-closure-label.yml new file mode 100644 index 00000000..ab826d12 --- /dev/null +++ b/.github/workflows/issue-closure-label.yml @@ -0,0 +1,54 @@ +name: Issue Closure Label + +on: + issues: + types: [closed] + +jobs: + add-closure-label: + if: startsWith(github.repository, 'asyncapi/') + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const issueNumber = context.payload.issue.number; + + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + + if (issue.state !== 'closed') { + console.log(`Issue #${issueNumber} is now open (was reopened) - skipping closure label`); + return; + } + + const labels = issue.labels.map(l => l.name); + + if (labels.includes('status/done')) { + console.log(`Issue #${issueNumber} already marked as status/done - skipping`); + return; + } + + for (const name of ['status/to-be-triaged', 'status/triaged', 'status/in-progress']) { + if (labels.includes(name)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name + }).catch(() => {}); + } + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['status/not-needed'] + }); + + console.log(`Added status/not-needed label to closed issue #${issueNumber}`); diff --git a/.github/workflows/issue-triage-on-comment.yml b/.github/workflows/issue-triage-on-comment.yml new file mode 100644 index 00000000..b096ee62 --- /dev/null +++ b/.github/workflows/issue-triage-on-comment.yml @@ -0,0 +1,87 @@ +name: Issue Triage on Comment + +on: + issue_comment: + types: [created] + +jobs: + auto-triage: + if: > + startsWith(github.repository, 'asyncapi/') && + !github.event.issue.pull_request && + github.event.issue.state == 'open' + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const commenter = context.payload.comment.user.login; + const issueNumber = context.payload.issue.number; + + const botsList = ['asyncapi-bot', 'asyncapi-bot-eve', 'dependabot[bot]', 'dependabot-preview[bot]', 'allcontributors[bot]', 'github-actions[bot]']; + if (botsList.includes(commenter)) { + console.log(`Skipping: ${commenter} is a bot`); + return; + } + + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + + const labels = issue.labels.map(l => l.name); + + if (!labels.includes('status/to-be-triaged')) { + console.log(`Issue #${issueNumber} does not have status/to-be-triaged label - skipping`); + return; + } + + let isCodeowner = false; + try { + const { data: codeownersFile } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: 'CODEOWNERS' + }); + + const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf-8'); + const codeownerPattern = /@([\w-]+)/g; + const codeowners = [...codeownersContent.matchAll(codeownerPattern)].map(m => m[1]); + + isCodeowner = codeowners.includes(commenter); + } catch (error) { + console.log(`Could not read CODEOWNERS: ${error.message}`); + return; + } + + if (!isCodeowner) { + console.log(`${commenter} is not a CODEOWNER - skipping auto-triage`); + return; + } + + if (labels.includes('status/triaged')) { + console.log(`Issue #${issueNumber} already has status/triaged label - skipping to avoid conflicts`); + return; + } + + for (const name of ['status/to-be-triaged', 'status/not-needed']) { + if (labels.includes(name)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name + }).catch(() => {}); + } + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['status/triaged'] + }); + + console.log(`Issue #${issueNumber} auto-triaged by CODEOWNER ${commenter}`); diff --git a/.github/workflows/issue-triage-on-open.yml b/.github/workflows/issue-triage-on-open.yml new file mode 100644 index 00000000..120d4ef6 --- /dev/null +++ b/.github/workflows/issue-triage-on-open.yml @@ -0,0 +1,100 @@ +name: Issue Triage on Open + +on: + issues: + types: [opened, reopened] + +jobs: + triage-issue: + if: startsWith(github.repository, 'asyncapi/') + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const botsList = ['asyncapi-bot', 'asyncapi-bot-eve', 'dependabot[bot]', 'dependabot-preview[bot]', 'allcontributors[bot]', 'github-actions[bot]']; + const issueAuthor = context.payload.issue.user.login; + + if (botsList.includes(issueAuthor)) { + console.log(`Skipping: ${issueAuthor} is a bot`); + return; + } + + let isCodeowner = false; + try { + const { data: codeownersFile } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: 'CODEOWNERS' + }); + + const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf-8'); + const codeownerPattern = /@([\w-]+)/g; + const codeowners = [...codeownersContent.matchAll(codeownerPattern)].map(m => m[1]); + + isCodeowner = codeowners.includes(issueAuthor); + console.log(`${issueAuthor} is ${isCodeowner ? '' : 'not '}a CODEOWNER`); + } catch (error) { + console.log(`Could not read CODEOWNERS: ${error.message}`); + } + + const issueNumber = context.payload.issue.number; + const action = context.payload.action; + + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + const labels = issue.labels.map(l => l.name); + + const removeIfPresent = async (name) => { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name + }).catch(() => {}); + }; + + if (isCodeowner) { + await removeIfPresent('status/not-needed'); + await removeIfPresent('status/to-be-triaged'); + await removeIfPresent('status/done'); + await removeIfPresent('status/in-progress'); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['status/triaged'] + }); + console.log(`Issue #${issueNumber} created by CODEOWNER - marked as triaged`); + } else { + if (labels.includes('status/triaged')) { + console.log(`Issue #${issueNumber} already has status/triaged - skipping`); + return; + } + await removeIfPresent('status/not-needed'); + await removeIfPresent('status/done'); + await removeIfPresent('status/in-progress'); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['status/to-be-triaged'] + }); + + const message = action === 'reopened' + ? `This issue has been reopened and requires maintainer triage.\n\nA maintainer will review this issue and triage it accordingly. Thank you for your patience! ❤️` + : `Thank you for opening this issue! 🎉\n\nBefore proceeding, please wait for a maintainer to triage/approve this issue.\n\nThank you for your patience! ❤️`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: message + }); + + console.log(`Issue #${issueNumber} created by contributor - added status/to-be-triaged label`); + } diff --git a/.github/workflows/issue-triage-reminders.yml b/.github/workflows/issue-triage-reminders.yml new file mode 100644 index 00000000..5e88319d --- /dev/null +++ b/.github/workflows/issue-triage-reminders.yml @@ -0,0 +1,119 @@ +name: Issue Triage Reminders + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +jobs: + check-untriaged-issues: + if: startsWith(github.repository, 'asyncapi/') + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const now = new Date(); + const msPerDay = 24 * 60 * 60 * 1000; + const twoWeeksAgo = new Date(now - 14 * msPerDay); + const threeWeeksAgo = new Date(now - 21 * msPerDay); + const fiveWeeksAgo = new Date(now - 35 * msPerDay); + const sixWeeksAgo = new Date(now - 42 * msPerDay); + const sixtyDaysAgo = new Date(now - 60 * msPerDay); + + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'status/to-be-triaged', + per_page: 100 + }); + + for (const issue of issues) { + if (issue.pull_request) continue; + + const issueNumber = issue.number; + const labels = issue.labels.map(l => l.name); + + if (labels.includes('keep-open')) { + console.log(`Issue #${issueNumber} has keep-open label - skipping`); + continue; + } + + const labelEvents = await github.rest.issues.listEvents({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100 + }); + + const triageLabelAdded = labelEvents.data.find( + e => e.event === 'labeled' && e.label.name === 'status/to-be-triaged' + ); + + let labelAddedAt; + if (!triageLabelAdded) { + console.log(`Issue #${issueNumber} has no label event history - using issue creation date`); + labelAddedAt = new Date(issue.created_at); + } else { + labelAddedAt = new Date(triageLabelAdded.created_at); + } + + console.log(`Issue #${issueNumber} - label added: ${labelAddedAt.toISOString()}`); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100 + }); + + const hasFirstReminder = comments.some(c => + c.body.includes('Reminder: This issue is awaiting triage') + ); + + const hasSecondReminder = comments.some(c => + c.body.includes('Second reminder: This issue is still awaiting triage') + ); + + const hasClosureProposal = labels.includes('bot/to-be-closed'); + + if (labelAddedAt < sixtyDaysAgo && !hasClosureProposal) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['bot/to-be-closed'] + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: `This issue has not been triaged within **60 days** and is being proposed for closure.\n\n@asyncapi/maintainers - Please review and decide: triage, close, or add \`keep-open\`.\n\nThank you! ❤️` + }); + + console.log(`Issue #${issueNumber} proposed for closure (60+ days untriaged)`); + } + else if (labelAddedAt < fiveWeeksAgo && labelAddedAt >= sixWeeksAgo && !hasSecondReminder) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: `**Second reminder:** This issue is still awaiting triage from a maintainer.\n\nIt has been **5-6 weeks** since triage. If no triage occurs, it may be proposed for closure.\n\nThank you for your patience! ❤️` + }); + + console.log(`Posted second reminder on issue #${issueNumber}`); + } + else if (labelAddedAt < twoWeeksAgo && labelAddedAt >= threeWeeksAgo && !hasFirstReminder) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: `**Reminder:** This issue is awaiting triage from a maintainer.\n\nIt has been **2-3 weeks** since triage. A maintainer will review and triage this issue soon.\n\nThank you for your patience! ❤️` + }); + + console.log(`Posted first reminder on issue #${issueNumber}`); + } + } diff --git a/.github/workflows/pr-closed-without-merge.yml b/.github/workflows/pr-closed-without-merge.yml new file mode 100644 index 00000000..04ef3be0 --- /dev/null +++ b/.github/workflows/pr-closed-without-merge.yml @@ -0,0 +1,57 @@ +name: PR Closed Without Merge + +on: + pull_request: + types: [closed] + +jobs: + mark-not-needed: + if: > + startsWith(github.repository, 'asyncapi/') && + github.event.pull_request.merged == false + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + if (pr.state !== 'closed') { + console.log(`PR #${prNumber} is now open (was reopened) - skipping closure label`); + return; + } + + const labels = pr.labels.map(l => l.name); + + if (labels.includes('status/done')) { + console.log(`PR #${prNumber} already marked as status/done - skipping`); + return; + } + + const toRemove = ['status/in-review', 'no-linked-issue']; + for (const name of toRemove) { + if (labels.includes(name)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name + }).catch(() => {}); + } + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['status/not-needed'] + }); + + console.log(`Added status/not-needed to closed (unmerged) PR #${prNumber}`); diff --git a/.github/workflows/pr-commit-detection.yml b/.github/workflows/pr-commit-detection.yml new file mode 100644 index 00000000..af939ec7 --- /dev/null +++ b/.github/workflows/pr-commit-detection.yml @@ -0,0 +1,45 @@ +name: PR Commit Detection + +on: + pull_request_target: + types: [synchronize] + +jobs: + detect-commits: + if: startsWith(github.repository, 'asyncapi/') + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + const labels = pr.labels.map(l => l.name); + + if (!labels.includes('status/waiting-response')) { + console.log(`PR #${prNumber} does not have status/waiting-response - skipping`); + return; + } + + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'status/waiting-response' + }).catch(err => console.log('Could not remove label:', err.message)); + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['status/ready-for-rereview'] + }); + + console.log(`PR #${prNumber} has new commits - changed status/waiting-response to status/ready-for-rereview`); diff --git a/.github/workflows/pr-merged-cleanup.yml b/.github/workflows/pr-merged-cleanup.yml new file mode 100644 index 00000000..4d61ac02 --- /dev/null +++ b/.github/workflows/pr-merged-cleanup.yml @@ -0,0 +1,68 @@ +name: PR Merged Cleanup + +on: + pull_request: + types: [closed] + +jobs: + mark-done: + if: > + startsWith(github.repository, 'asyncapi/') && + github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + const prBody = context.payload.pull_request.body || ''; + + const removeFromIssue = async (issueNum, toRemove) => { + const { data: i } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNum + }); + const names = i.labels.map(l => l.name); + for (const name of toRemove) { + if (!names.includes(name)) continue; + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNum, + name + }).catch(() => {}); + } + }; + + const obsoletePr = ['status/in-review', 'no-linked-issue']; + const obsoleteIssue = ['status/in-progress', 'status/to-be-triaged', 'status/not-needed', 'status/triaged']; + + await removeFromIssue(prNumber, obsoletePr); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['status/done'] + }); + console.log(`Marked PR #${prNumber} as status/done`); + + const issuePattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)/gi; + const matches = [...prBody.matchAll(issuePattern)]; + const linkedIssues = [...new Set(matches.map(m => parseInt(m[1], 10)).filter(n => !isNaN(n)))]; + + for (const issueNumber of linkedIssues) { + try { + await removeFromIssue(issueNumber, obsoleteIssue); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['status/done'] + }); + console.log(`Marked linked issue #${issueNumber} as status/done`); + } catch (error) { + console.log(`Could not mark issue #${issueNumber} as done: ${error.message}`); + } + } diff --git a/.github/workflows/pr-reminders.yml b/.github/workflows/pr-reminders.yml new file mode 100644 index 00000000..c9295a9f --- /dev/null +++ b/.github/workflows/pr-reminders.yml @@ -0,0 +1,143 @@ +name: PR Reminders + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +jobs: + check-stale-prs: + if: startsWith(github.repository, 'asyncapi/') + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const now = new Date(); + const msPerDay = 24 * 60 * 60 * 1000; + const oneWeekAgo = new Date(now - 7 * msPerDay); + const twoWeeksAgo = new Date(now - 14 * msPerDay); + const threeWeeksAgo = new Date(now - 21 * msPerDay); + const fourWeeksAgo = new Date(now - 28 * msPerDay); + + const { data: waitingPRs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + + for (const pr of waitingPRs) { + const prNumber = pr.number; + const labels = pr.labels.map(l => l.name); + + if (labels.includes('status/waiting-response')) { + const { data: commits } = await github.rest.pulls.listCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + if (commits.length === 0) continue; + + const lastCommitDate = new Date(commits[commits.length - 1].commit.committer.date); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + const hasFirstReminder = comments.some(c => + c.body.includes('Any update on the requested changes?') + ); + + const hasClosureProposal = labels.includes('bot/to-be-closed'); + + if (lastCommitDate < twoWeeksAgo && lastCommitDate >= threeWeeksAgo && !hasClosureProposal) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['bot/to-be-closed'] + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `This PR has been waiting for contributor response for **2-3 weeks** with no activity.\n\n@asyncapi/maintainers - Please review and decide whether to close this PR.\n\nThank you! ❤️` + }); + + console.log(`PR #${prNumber} proposed for closure (waiting-response, 2-3 weeks)`); + } + else if (lastCommitDate < oneWeekAgo && lastCommitDate >= twoWeeksAgo && !hasFirstReminder) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `👋 **Ping:** Any update on the requested changes? (1-2 weeks)\n\nThank you! ❤️` + }); + + console.log(`Posted reminder on PR #${prNumber} (waiting-response, 1-2 weeks)`); + } + } + + if (labels.includes('no-linked-issue')) { + const prCreatedAt = new Date(pr.created_at); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + const botComments = comments.filter(c => + c.user.login === 'github-actions[bot]' && + c.body.includes("this PR isn't linked to an approved issue") + ); + + const hasFirstReminder = botComments.length >= 2; + const hasSecondReminder = botComments.length >= 3; + const hasClosureProposal = labels.includes('bot/to-be-closed'); + + if (prCreatedAt < threeWeeksAgo && prCreatedAt >= fourWeeksAgo && !hasClosureProposal) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['bot/to-be-closed'] + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `This PR has been open for **3-4 weeks** without a linked approved issue.\n\n@asyncapi/maintainers - Please review and decide.\n\nThank you! ❤️` + }); + + console.log(`PR #${prNumber} proposed for closure (no-linked-issue, 3-4 weeks)`); + } + else if (prCreatedAt < twoWeeksAgo && prCreatedAt >= threeWeeksAgo && !hasSecondReminder) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `**Second reminder (2-3 weeks):** This PR still isn't linked to an approved issue. Please link one in the description.\n\nThank you! ❤️` + }); + + console.log(`Posted second reminder on PR #${prNumber} (no-linked-issue)`); + } + else if (prCreatedAt < oneWeekAgo && prCreatedAt >= twoWeeksAgo && !hasFirstReminder) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `**Reminder (1-2 weeks):** This PR still isn't linked to an approved issue. Please link one in the description.\n\nThank you! ❤️` + }); + + console.log(`Posted first reminder on PR #${prNumber} (no-linked-issue)`); + } + } + } diff --git a/.github/workflows/pr-reopened.yml b/.github/workflows/pr-reopened.yml new file mode 100644 index 00000000..a9ecf9f9 --- /dev/null +++ b/.github/workflows/pr-reopened.yml @@ -0,0 +1,177 @@ +name: PR Reopened + +on: + pull_request: + types: [reopened] + +jobs: + cleanup-and-retriage: + if: startsWith(github.repository, 'asyncapi/') + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + const labels = pr.labels.map(l => l.name); + + const toRemove = ['status/not-needed', 'status/done']; + for (const name of toRemove) { + if (labels.includes(name)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name + }).catch(() => {}); + } + } + + console.log(`Cleaned up closure labels from reopened PR #${prNumber}`); + + const prBody = pr.body || ''; + + const graphqlQuery = ` + query($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + closingIssuesReferences(first: 10) { + nodes { + number + repository { + owner { login } + name + } + } + } + } + } + } + `; + + let linkedIssues = []; + try { + const result = await github.graphql(graphqlQuery, { + owner: context.repo.owner, + repo: context.repo.repo, + prNumber: prNumber + }); + + const references = result.repository.pullRequest.closingIssuesReferences.nodes || []; + linkedIssues = references + .filter(ref => + ref.repository.owner.login === context.repo.owner && + ref.repository.name === context.repo.repo + ) + .map(ref => ({ number: ref.number, source: 'graphql' })); + } catch (error) { + console.log(`GraphQL query failed: ${error.message}`); + } + + if (linkedIssues.length === 0) { + const regex = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*(?:issue\s+)?:?\s*#\s*(\d+)/gi; + const matches = [...prBody.matchAll(regex)]; + linkedIssues = [...new Set(matches.map(m => parseInt(m[1], 10)).filter(n => !isNaN(n)))] + .map(num => ({ number: num, source: 'regex' })); + } + + console.log(`PR #${prNumber} reopened - found ${linkedIssues.length} linked issues: [${linkedIssues.map(i => `#${i.number} (${i.source})`).join(', ')}]`); + + if (linkedIssues.length === 0) { + if (!labels.includes('no-linked-issue')) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['no-linked-issue'] + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `This PR has been reopened but does not appear to link to an issue.\n\nPlease link this PR to an issue using keywords like "fixes #123" or "closes #456" in the description.\n\nThank you! ❤️` + }); + + console.log(`Added no-linked-issue label to reopened PR #${prNumber}`); + } + return; + } + + if (labels.includes('no-linked-issue')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'no-linked-issue' + }).catch(() => {}); + } + + for (const linkedIssue of linkedIssues) { + const issueNumber = linkedIssue.number; + let issue; + try { + const { data } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + issue = data; + } catch (error) { + console.log(`Could not fetch issue #${issueNumber}: ${error.message}`); + continue; + } + + const issueLabels = issue.labels.map(l => l.name); + const isTriaged = issueLabels.includes('status/triaged'); + const needsTriage = issueLabels.includes('status/to-be-triaged'); + const isClosed = issue.state === 'closed'; + + if (needsTriage && !isClosed) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `This PR has been reopened and is linked to issue #${issueNumber} that hasn't been approved/triaged by maintainers yet.\n\nPlease pause work and wait for maintainer approval on the issue first.\n\nThank you for your understanding! ❤️` + }); + console.log(`PR #${prNumber} links to untriaged issue #${issueNumber} - added comment`); + return; + } + + if (isTriaged) { + const issueToRemove = ['status/not-needed', 'status/to-be-triaged']; + for (const name of issueToRemove) { + if (issueLabels.includes(name)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name + }).catch(() => {}); + } + } + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['status/in-progress'] + }).catch(() => {}); + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['status/in-review'] + }).catch(() => {}); + + console.log(`Reopened PR #${prNumber} links to triaged issue #${issueNumber} - updated labels`); + } + } diff --git a/.github/workflows/pr-triage-check.yml b/.github/workflows/pr-triage-check.yml new file mode 100644 index 00000000..6aea19f8 --- /dev/null +++ b/.github/workflows/pr-triage-check.yml @@ -0,0 +1,234 @@ +name: PR Triage Check + +on: + pull_request_target: + types: [opened, edited, synchronize] + +jobs: + check-pr-triage: + if: startsWith(github.repository, 'asyncapi/') + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const botsList = ['asyncapi-bot', 'asyncapi-bot-eve', 'dependabot[bot]', 'dependabot-preview[bot]', 'allcontributors[bot]', 'github-actions[bot]']; + const prAuthor = context.payload.pull_request.user.login; + + if (botsList.includes(prAuthor)) { + console.log(`Skipping: ${prAuthor} is a bot`); + return; + } + + let isCodeowner = false; + try { + const { data: codeownersFile } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: 'CODEOWNERS' + }); + + const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf-8'); + const codeownerPattern = /@([\w-]+)/g; + const codeowners = [...codeownersContent.matchAll(codeownerPattern)].map(m => m[1]); + + isCodeowner = codeowners.includes(prAuthor); + } catch (error) { + console.log(`Could not read CODEOWNERS: ${error.message}`); + } + + if (isCodeowner) { + console.log(`Skipping: ${prAuthor} is a CODEOWNER`); + return; + } + + const prNumber = context.payload.pull_request.number; + const { data: prData } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + const prBody = prData.body || ''; + + let linkedIssues = []; + let source = 'none'; + + try { + const gql = ` + query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + closingIssuesReferences(first: 100) { + nodes { + number + repository { name owner { login } } + } + } + } + } + } + `; + const { repository } = await github.graphql(gql, { + owner: context.repo.owner, + repo: context.repo.repo, + pr: prNumber + }); + const nodes = repository?.pullRequest?.closingIssuesReferences?.nodes ?? []; + const sameRepo = nodes.filter( + n => n?.repository && n.repository.owner?.login === context.repo.owner && n.repository.name === context.repo.repo + ); + linkedIssues = [...new Set(sameRepo.map(n => n.number).filter(Boolean))]; + if (linkedIssues.length > 0) source = 'graphql'; + } catch (e) { + console.log(`GraphQL closingIssuesReferences failed: ${e.message}`); + } + + if (linkedIssues.length === 0) { + const kw = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)/gi; + const ms = [...prBody.matchAll(kw)]; + const same = ms.map(m => parseInt(m[1], 10)).filter(n => !isNaN(n)); + const cross = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+([\w.-]+)\/([\w.-]+)#(\d+)/gi; + const m2 = [...prBody.matchAll(cross)]; + for (const m of m2) { + if (m[1] === context.repo.owner && m[2] === context.repo.repo) same.push(parseInt(m[3], 10)); + } + linkedIssues = [...new Set(same)]; + if (linkedIssues.length > 0) source = 'regex'; + } + + console.log(`PR #${prNumber} linked issues: [${linkedIssues.join(', ')}] (${source})`); + + if (linkedIssues.length > 0) { + if (prData.labels.some(l => l.name === 'no-linked-issue')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'no-linked-issue' + }); + console.log(`PR #${prNumber} now has linked issue(s) - removed no-linked-issue label`); + } + } + + if (linkedIssues.length === 0) { + const prLabels = prData.labels.map(l => l.name); + + if (!prLabels.includes('no-linked-issue')) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['no-linked-issue'] + }); + } + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + const hasComment = comments.some(c => + c.user.login === 'github-actions[bot]' && + c.body.includes("this PR isn't linked to an approved issue") + ); + + if (!hasComment) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `Thanks for the PR! 🎉\n\nIt looks like this PR isn't linked to an approved issue. Please link the issue in the PR description (or create an issue first, get maintainer approval, then link it here).\n\nWe'll wait for you to link it. If it's still not linked after a few weeks, maintainers may close it.\n\nThank you! ❤️` + }); + + console.log(`PR #${prNumber} has no linked issue - added comment and label`); + } + + return; + } + + for (const issueNumber of linkedIssues) { + let issue; + try { + const { data } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + issue = data; + } catch (error) { + console.log(`Could not fetch issue #${issueNumber}: ${error.message}`); + continue; + } + + const labels = issue.labels.map(l => l.name); + const isTriaged = labels.includes('status/triaged'); + const needsTriage = labels.includes('status/to-be-triaged'); + const isClosed = issue.state === 'closed'; + + if (needsTriage && !isClosed) { + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + const hasComment = comments.some(c => + c.user.login === 'github-actions[bot]' && + c.body.includes(`issue #${issueNumber}`) && + c.body.includes("hasn't been approved/triaged") + ); + + if (!hasComment) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `Thanks for the PR! 🎉\n\nThis PR is linked to issue #${issueNumber} that hasn't been approved/triaged by maintainers yet.\n\nPlease pause work and wait for maintainer approval on the issue first.\n\nIf it stays open, it may be marked for closure after a grace period.\n\nThank you for your understanding! ❤️` + }); + + console.log(`PR #${prNumber} links to untriaged issue #${issueNumber} - added comment`); + } + + return; + } + + if (isTriaged) { + for (const name of ['status/not-needed', 'status/to-be-triaged']) { + if (labels.includes(name)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name + }).catch(() => {}); + } + } + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['status/in-progress'] + }).catch(err => console.log('Could not add status/in-progress to issue:', err.message)); + + if (prData.labels.some(l => l.name === 'no-linked-issue')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'no-linked-issue' + }).catch(() => {}); + } + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['status/in-review'] + }).catch(err => console.log('Could not add status/in-review to PR:', err.message)); + + console.log(`PR #${prNumber} links to triaged issue #${issueNumber} - updated labels`); + } + } + + console.log(`PR #${prNumber} passed triage check`); From 61c4d17b05f4b63ba162b2eb4398fa466720644a Mon Sep 17 00:00:00 2001 From: Manancode Date: Thu, 29 Jan 2026 03:23:10 +0530 Subject: [PATCH 3/3] minimal comments --- .github/workflows/pr-triage-check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-triage-check.yml b/.github/workflows/pr-triage-check.yml index 6aea19f8..65c4e056 100644 --- a/.github/workflows/pr-triage-check.yml +++ b/.github/workflows/pr-triage-check.yml @@ -139,7 +139,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - body: `Thanks for the PR! 🎉\n\nIt looks like this PR isn't linked to an approved issue. Please link the issue in the PR description (or create an issue first, get maintainer approval, then link it here).\n\nWe'll wait for you to link it. If it's still not linked after a few weeks, maintainers may close it.\n\nThank you! ❤️` + body: `Thanks for the PR! 🎉\n\nIt looks like this PR isn't linked to an approved issue. Please link the issue in the PR description (or create an issue first, get maintainer approval, then link it here).\n\nThank you! ❤️` }); console.log(`PR #${prNumber} has no linked issue - added comment and label`); @@ -185,7 +185,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - body: `Thanks for the PR! 🎉\n\nThis PR is linked to issue #${issueNumber} that hasn't been approved/triaged by maintainers yet.\n\nPlease pause work and wait for maintainer approval on the issue first.\n\nIf it stays open, it may be marked for closure after a grace period.\n\nThank you for your understanding! ❤️` + body: `Thanks for the PR! 🎉\n\nThis PR is linked to issue #${issueNumber} that hasn't been approved/triaged by maintainers yet.\n\nPlease pause work and wait for maintainer approval on the issue first.\n\nThank you for your understanding! ❤️` }); console.log(`PR #${prNumber} links to untriaged issue #${issueNumber} - added comment`);