76d991c447
Auto Update PR / update-prs (push) Has been cancelled
CI / format-check (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / live-api-tests (push) Has been cancelled
CI / plugin-integration-test (push) Has been cancelled
CI / ollama-integration-test (push) Has been cancelled
CI / test-fork-pr (push) Has been cancelled
189 lines
8.4 KiB
YAML
189 lines
8.4 KiB
YAML
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@v9
|
||
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}`);
|
||
}
|
||
}
|