From 3427348ead3bf29ae690c017fa64cd9b42128844 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 25 Mar 2026 11:50:38 +0100 Subject: [PATCH] Auto-close duplicate effort PRs --- .../workflows/close-duplicate-effort-prs.yml | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/close-duplicate-effort-prs.yml diff --git a/.github/workflows/close-duplicate-effort-prs.yml b/.github/workflows/close-duplicate-effort-prs.yml new file mode 100644 index 0000000000..6c2c25634a --- /dev/null +++ b/.github/workflows/close-duplicate-effort-prs.yml @@ -0,0 +1,100 @@ +name: Close Duplicate Effort PRs + +on: + pull_request_target: + types: [opened] + +jobs: + close-if-issue-assigned: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Check org membership and referenced issues + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const prAuthor = context.payload.pull_request.user.login; + const prNumber = context.payload.pull_request.number; + const owner = context.repo.owner; + const repo = context.repo.repo; + + // Check if PR author is a member of the getsentry org + try { + await github.rest.orgs.checkMembershipForUser({ + org: 'getsentry', + username: prAuthor, + }); + console.log(`${prAuthor} is a member of getsentry, skipping.`); + return; + } catch (error) { + // 404 means not a member, which is what we want to continue with + if (error.status !== 404) { + throw error; + } + console.log(`${prAuthor} is not a member of getsentry.`); + } + + // Extract issue references from PR title and body + const prTitle = context.payload.pull_request.title || ''; + const prBody = context.payload.pull_request.body || ''; + const text = `${prTitle}\n${prBody}`; + + // Match patterns like #123, fixes #123, closes #123, resolves #123, + // as well as full URL references like github.com/owner/repo/issues/123 + const issuePattern = /(?:(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)\s*:?\s*)?(?:https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/(\d+)|#(\d+))/gi; + + const issueNumbers = new Set(); + let match; + while ((match = issuePattern.exec(text)) !== null) { + const num = match[1] || match[2]; + issueNumbers.add(parseInt(num, 10)); + } + + if (issueNumbers.size === 0) { + console.log('No issue references found in PR, skipping.'); + return; + } + + console.log(`Found referenced issues: ${[...issueNumbers].join(', ')}`); + + // Check if any referenced issue has an assignee + for (const issueNumber of issueNumbers) { + let issue; + try { + const response = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + issue = response.data; + } catch (error) { + console.log(`Could not fetch issue #${issueNumber}: ${error.message}`); + continue; + } + + if (issue.assignees && issue.assignees.length > 0) { + const assignees = issue.assignees.map(a => a.login).join(', '); + console.log(`Issue #${issueNumber} is assigned to: ${assignees}. Closing PR.`); + + // Add a comment explaining why the PR is being closed + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: `Hey @${prAuthor}, thanks for contributing!\n\nIt looks like the issue (#${issueNumber}) you're addressing in this pull request already has someone assigned to it. Please check on the issue whether help is wanted before opening a pull request.\n\nIf you believe your contribution is still valuable, please leave a comment on the issue to coordinate with the assignee.`, + }); + + // Close the PR + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: 'closed', + }); + + return; + } + } + + console.log('No referenced issues have assignees, PR is fine.');