Skip to content

Multi-Document Extraction Bleed Fix #355

Multi-Document Extraction Bleed Fix

Multi-Document Extraction Bleed Fix #355

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}`);
}
}