Multi-Document Extraction Bleed Fix #355
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
name: Require linked issue with community support | |
on: | |
pull_request_target: | |
types: [opened, edited, synchronize, reopened, ready_for_review] | |
permissions: | |
contents: read | |
issues: write | |
pull-requests: write | |
concurrency: | |
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
cancel-in-progress: true | |
jobs: | |
enforce: | |
if: github.event_name == 'pull_request_target' && !github.event.pull_request.draft | |
runs-on: ubuntu-latest | |
steps: | |
- name: Check linked issue and community support | |
uses: actions/github-script@v7 | |
with: | |
github-token: ${{ secrets.GITHUB_TOKEN }} | |
script: | | |
// Strip code blocks and inline code to avoid false matches | |
const stripCode = txt => | |
txt.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, ''); | |
// Combine title + body for comprehensive search | |
const prText = stripCode(`${context.payload.pull_request.title || ''}\n${context.payload.pull_request.body || ''}`); | |
// Issue reference pattern: #123, org/repo#123, or full URL (with http/https and optional www) | |
const issueRef = String.raw`(?:#(?<num>\d+)|(?<o1>[\w.-]+)\/(?<r1>[\w.-]+)#(?<n1>\d+)|https?:\/\/(?:www\.)?github\.com\/(?<o2>[\w.-]+)\/(?<r2>[\w.-]+)\/issues\/(?<n2>\d+))`; | |
// Keywords - supporting common variants | |
const closingRe = new RegExp(String.raw`\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s*:?\s+${issueRef}`, 'gi'); | |
const referenceRe = new RegExp(String.raw`\b(?:related\s+to|relates\s+to|refs?|part\s+of|addresses|see(?:\s+also)?|depends\s+on|blocked\s+by|supersedes)\b\s*:?\s+${issueRef}`, 'gi'); | |
// Gather all matches | |
const closings = [...prText.matchAll(closingRe)]; | |
const references = [...prText.matchAll(referenceRe)]; | |
const first = closings[0] || references[0]; | |
// Check for draft PRs and bots | |
const pr = context.payload.pull_request; | |
const isDraft = !!pr.draft; | |
const login = pr.user.login; | |
const isBot = pr.user.type === 'Bot' || /\[bot\]$/.test(login); | |
if (isDraft || isBot) { | |
core.info('Draft or bot PR – skipping enforcement'); | |
return; | |
} | |
// Check if PR author is a maintainer | |
let authorPerm = 'none'; | |
try { | |
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
username: pr.user.login, | |
}); | |
authorPerm = data.permission || 'none'; | |
} catch (_) { | |
// User might not have any permissions | |
} | |
core.info(`Author permission: ${authorPerm}`); | |
const isMaintainer = ['admin', 'maintain'].includes(authorPerm); // Removed 'write' for stricter maintainer definition | |
// Maintainers bypass entirely | |
if (isMaintainer) { | |
core.info(`Maintainer ${pr.user.login} - bypassing linked issue requirement`); | |
return; | |
} | |
if (!first) { | |
// Check for existing comment to avoid duplicates | |
const MARKER = '<!-- linkcheck:missing-issue -->'; | |
const existing = await github.paginate(github.rest.issues.listComments, { | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: context.payload.pull_request.number, | |
per_page: 100, | |
}); | |
const alreadyLeft = existing.some(c => c.body && c.body.includes(MARKER)); | |
if (!alreadyLeft) { | |
const contribUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md#pull-request-guidelines`; | |
const commentBody = [ | |
'No linked issues found. Please link an issue in your pull request description or title.', | |
'', | |
`Per our [Contributing Guidelines](${contribUrl}), all PRs must:`, | |
'- Reference an issue with one of:', | |
' - **Closing keywords**: `Fixes #123`, `Closes #123`, `Resolves #123` (auto-closes on merge in the same repository)', | |
' - **Reference keywords**: `Related to #123`, `Refs #123`, `Part of #123`, `See #123` (links without closing)', | |
'- The linked issue should have 5+ 👍 reactions from unique users (excluding bots and the PR author)', | |
'- Include discussion demonstrating the importance of the change', | |
'', | |
'You can also use cross-repo references like `owner/repo#123` or full URLs.', | |
'', | |
MARKER | |
].join('\n'); | |
await github.rest.issues.createComment({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: context.payload.pull_request.number, | |
body: commentBody | |
}); | |
} | |
core.setFailed('No linked issue found. Use "Fixes #123" to close an issue or "Related to #123" to reference it.'); | |
return; | |
} | |
// Resolve owner/repo/number, defaulting to the current repo | |
const groups = first.groups || {}; | |
const owner = groups.o1 || groups.o2 || context.repo.owner; | |
const repo = groups.r1 || groups.r2 || context.repo.repo; | |
const issue_number = Number(groups.num || groups.n1 || groups.n2); | |
// Validate issue number | |
if (!Number.isInteger(issue_number) || issue_number <= 0) { | |
core.setFailed( | |
'Found a potential issue link but no valid number. ' + | |
'Use "Fixes #123" or "Related to owner/repo#123".' | |
); | |
return; | |
} | |
core.info(`Found linked issue: ${owner}/${repo}#${issue_number}`); | |
// Count unique users who reacted with 👍 on the linked issue (excluding bots and PR author) | |
try { | |
const reactions = await github.paginate(github.rest.reactions.listForIssue, { | |
owner, | |
repo, | |
issue_number, | |
per_page: 100, | |
}); | |
const prAuthorId = pr.user.id; | |
const uniqueThumbs = new Set( | |
reactions | |
.filter(r => | |
r.content === '+1' && | |
r.user && | |
r.user.id !== prAuthorId && | |
r.user.type !== 'Bot' && | |
!String(r.user.login || '').endsWith('[bot]') | |
) | |
.map(r => r.user.id) | |
).size; | |
core.info(`Issue ${owner}/${repo}#${issue_number} has ${uniqueThumbs} unique 👍 reactions`); | |
const REQUIRED_THUMBS_UP = 5; | |
if (uniqueThumbs < REQUIRED_THUMBS_UP) { | |
core.setFailed(`Linked issue ${owner}/${repo}#${issue_number} has only ${uniqueThumbs} 👍 (need ${REQUIRED_THUMBS_UP}).`); | |
return; | |
} | |
} catch (error) { | |
const isSameRepo = owner === context.repo.owner && repo === context.repo.repo; | |
if (error.status === 404 || error.status === 403) { | |
if (!isSameRepo) { | |
core.setFailed( | |
`Linked issue ${owner}/${repo}#${issue_number} is not accessible. ` + | |
`Please link to an issue in ${context.repo.owner}/${context.repo.repo} or a public repo.` | |
); | |
} else { | |
core.info(`Cannot access reactions for ${owner}/${repo}#${issue_number}; skipping enforcement for same-repo issue.`); | |
} | |
return; | |
} | |
// Any other error should fail to prevent accidental bypass | |
const msg = (error && error.message) ? String(error.message).toLowerCase() : ''; | |
const isRateLimit = msg.includes('rate limit') || error?.headers?.['x-ratelimit-remaining'] === '0'; | |
if (isRateLimit) { | |
core.setFailed(`Rate limit while checking reactions for ${owner}/${repo}#${issue_number}. Please retry the workflow.`); | |
} else { | |
core.setFailed(`Unexpected error checking reactions for ${owner}/${repo}#${issue_number}: ${error?.message || error}`); | |
} | |
} |