Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:37:14 +08:00

189 lines
8.4 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`);
}
}