google--langextract
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 行
8.4 KiB
YAML
189 行
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}`);
|
|
}
|
|
}
|