Files
wehub-resource-sync 60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:59 +08:00

588 lines
27 KiB
YAML

# Require external PRs to reference an issue with an auto-close keyword
# (e.g. "Fixes #123") AND have the PR author assigned to that issue.
# Otherwise the PR is labeled "missing-issue-link", commented on, and
# closed. CONTRIBUTING.md requires external contributors to be assigned to
# an issue before opening a PR; this enforces that.
#
# Adapted from langchain-ai/langchain's require_issue_link.yml. Differences:
# - Self-contained: it does NOT depend on a separate labeler workflow
# applying an "external" label first, so it can run on `opened`.
# - "External" is determined authoritatively, in-script, from the PR
# author's repo collaborator permission level — NOT from the event
# payload's author_association. author_association reports MEMBER only
# for *public* org members; a maintainer whose org membership is
# private appears as CONTRIBUTOR/NONE, so gating on it would wrongly
# enforce against private-member maintainers. getCollaboratorPermission
# reflects effective write access regardless of membership visibility.
# - The enforcement path is a single github-script step (the upstream
# version is split across four, forcing the label/comment/reopen helpers
# to be duplicated per scope).
# - Issue assignment events are handled in this same workflow so assigning
# the linked issue reopens previously closed PRs automatically.
#
# Maintainer override: reopen the PR, or remove the "missing-issue-link"
# label — either applies a sticky "bypass-issue-check" label and reopens.
name: Require Issue Link
on:
pull_request_target:
# SECURITY: pull_request_target runs with repo write scope against the
# BASE repo. NEVER check out or execute PR-head code here — it would run
# with these permissions. This workflow only reads the PR payload and
# calls the API; it never checks anything out.
# ready_for_review matters because the job skips drafts: without it a
# draft opened with no issue link would never be checked when it later
# becomes reviewable.
types: [opened, edited, reopened, ready_for_review, labeled, unlabeled]
issues:
# Assignment is what makes a previously closed "not assigned" PR compliant,
# so it needs a separate event path that finds and reopens matching PRs.
types: [assigned]
# Dry run: when 'false' the check still runs and logs its verdict but makes
# NO mutations at all (no label, comment, close, reopen, or failure). Flip
# to 'true' to enforce.
env:
ENFORCE_ISSUE_LINK: "true"
permissions:
contents: read
jobs:
check-issue-link:
# Cheap pre-filters only. Maintainer detection is deliberately NOT done
# here: the job-level `if` can't call the API, and author_association is
# unreliable for private org members (see file header). The job runs,
# then the script resolves the author's real permission and exits early
# for maintainers.
#
# Gate: only run on pull_request_target events. The workflow also listens
# to `issues.assigned` (handled by reopen-on-assignment below), and without
# this guard the job would also fire there — `github.event.pull_request` is
# null on an issues event, so `...draft == false` coerces to true and the
# script then dereferences a missing PR and crashes. Beyond the event type,
# skip drafts, bots, and already-bypassed/trusted PRs, and allow the primary
# actions plus the one maintainer-override action we care about (removing
# the missing-issue-link label).
if: >-
github.event_name == 'pull_request_target' &&
github.event.pull_request.draft == false &&
!endsWith(github.actor, '[bot]') &&
!contains(github.event.pull_request.labels.*.name, 'trusted-contributor') &&
!contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') &&
(
(github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
(github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link')
)
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: require-issue-link-${{ github.event.pull_request.number }}
cancel-in-progress: false
permissions:
issues: write
pull-requests: write
steps:
- name: Enforce issue link
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const prNumber = pr.number;
const action = context.payload.action;
const enforce = process.env.ENFORCE_ISSUE_LINK === 'true';
const LABEL = 'missing-issue-link';
const MARKER = '<!-- require-issue-link -->';
// Dry-run guard: every mutating call goes through this so that
// ENFORCE_ISSUE_LINK=false means strictly read-only.
async function mutate(description, fn) {
if (!enforce) {
console.log(`[dry-run] would ${description}`);
return;
}
await fn();
}
// Authoritative maintainer check. Uses collaborator permission,
// not org membership or author_association:
// - GITHUB_TOKEN is an app token and is never an org member,
// so the org-membership endpoint always 403s.
// - author_association reports MEMBER only for *public* org
// members; a private-member maintainer shows as
// CONTRIBUTOR/NONE. Permission level is visibility-
// independent and reflects effective access.
// 404 (not a collaborator) → not a maintainer. Other errors
// (rate limit, 5xx) MUST throw: silently treating them as
// "not a maintainer" could wrongly close a maintainer's PR.
// A throw aborts the script before any close/label call, so the
// job fails red and the PR is left untouched — the safe direction.
async function hasWriteAccess(username) {
if (!username) throw new Error('No username — cannot check permissions');
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner, repo, username,
});
const ok = ['admin', 'maintain', 'write'].includes(data.permission);
console.log(`${username}: ${data.permission} — ${ok ? 'maintainer' : 'not a maintainer'}`);
return ok;
} catch (e) {
if (e.status === 404) {
console.log(`${username} is not a collaborator — not a maintainer`);
return false;
}
throw new Error(
`Permission check failed for ${username} (HTTP ${e.status ?? 'unknown'}): ${e.message}`,
);
}
}
async function addLabel() {
await mutate(`label PR #${prNumber} "${LABEL}"`, async () => {
try {
await github.rest.issues.getLabel({ owner, repo, name: LABEL });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({ owner, repo, name: LABEL, color: 'b76e79' });
} catch (createErr) {
// 422 = created by a concurrent run between GET and POST.
if (createErr.status !== 422) throw createErr;
}
}
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [LABEL],
});
});
}
async function minimizeStaleComment() {
try {
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 },
);
const stale = comments.find(c => c.body && c.body.includes(MARKER));
if (!stale) return;
await mutate(`minimize stale comment ${stale.id}`, () => github.graphql(`
mutation($id: ID!) {
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
minimizedComment { isMinimized }
}
}
`, { id: stale.node_id }));
} catch (e) {
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
}
}
// Shared "this PR passes" cleanup: drop the label, reopen, and
// retire any stale enforcement comment.
//
// For the normal pass paths we only reopen if THIS workflow had
// closed the PR — inferred from the label still being on the
// payload. The maintainer-override paths pass forceReopen: the
// `unlabeled` event payload no longer carries the just-removed
// label, so the heuristic can't see it; without forcing, the
// advertised "remove the label to bypass" gesture would leave
// the PR closed.
async function clearEnforcement(forceReopen = false) {
await mutate(`remove "${LABEL}" from PR #${prNumber}`, async () => {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: LABEL,
});
} catch (e) {
if (e.status !== 404) throw e;
}
});
const hadLabel = pr.labels.map(l => l.name).includes(LABEL);
if (pr.state === 'closed' && (forceReopen || hadLabel)) {
await mutate(`reopen PR #${prNumber}`, async () => {
await github.rest.pulls.update({
owner, repo, pull_number: prNumber, state: 'open',
});
});
}
await minimizeStaleComment();
}
async function applyBypass(reason) {
console.log(reason);
await clearEnforcement(true);
await mutate(`add sticky "bypass-issue-check" to PR #${prNumber}`, async () => {
try {
await github.rest.issues.getLabel({ owner, repo, name: 'bypass-issue-check' });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({
owner, repo, name: 'bypass-issue-check', color: '0e8a16',
});
} catch (createErr) {
if (createErr.status !== 422) throw createErr;
}
}
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: ['bypass-issue-check'],
});
});
}
// ── Maintainer-authored PRs are exempt entirely ────────────────
if (await hasWriteAccess(pr.user.login)) {
console.log(`PR author ${pr.user.login} has write access — exempt`);
await clearEnforcement();
return;
}
const sender = context.payload.sender?.login;
// ── Maintainer override: removed the "missing-issue-link" label ─
if (action === 'unlabeled') {
if (await hasWriteAccess(sender)) {
await applyBypass(`Maintainer ${sender} removed ${LABEL} from PR #${prNumber} — bypassing`);
return;
}
// Only triage/admin can manage labels, so a non-write actor
// reaching here is rare (triage role). Fall through to the
// normal check, which recomputes link + assignment and
// re-enforces with the correct message if still failing.
console.log(`Non-maintainer ${sender} removed ${LABEL} — re-checking`);
}
// ── Maintainer override: reopened a PR we had closed ───────────
if (
action === 'reopened' &&
pr.labels.map(l => l.name).includes(LABEL) &&
(await hasWriteAccess(sender))
) {
await applyBypass(`Maintainer ${sender} reopened PR #${prNumber} — bypassing`);
return;
}
// ── Race guard: re-read live labels ────────────────────────────
const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({
owner, repo, issue_number: prNumber,
});
const liveNames = liveLabels.map(l => l.name);
if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) {
console.log('PR carries trusted-contributor or bypass-issue-check — clearing any prior enforcement');
await clearEnforcement();
return;
}
// ── The actual check: an auto-close keyword + issue number ─────
const body = pr.body || '';
// Match GitHub's auto-close keywords against any reference form
// that GitHub itself honors: bare `#123`, the `owner/repo#123`
// shorthand, and the full issue URL. Scope the qualified forms to
// THIS repo — GitHub only auto-closes same-repo issues, so a
// cross-repo reference must not be resolved against our numbering.
const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(
'(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*' +
`(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`,
'gi',
);
const matches = [...body.matchAll(pattern)];
if (matches.length === 0) {
console.log('No issue link found in PR body');
await enforceFailure('no-link');
return;
}
// The author must be assigned to at least one linked issue.
// CONTRIBUTING.md requires external contributors to be assigned
// before opening a PR (so maintainers can deconflict / steer
// approach first).
const MAX_ISSUES = 5;
const allNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))];
const numbers = allNumbers.slice(0, MAX_ISSUES);
if (allNumbers.length > MAX_ISSUES) {
core.warning(`PR references ${allNumbers.length} issues — checking only the first ${MAX_ISSUES}`);
}
const prAuthor = pr.user.login.toLowerCase();
let sawRealIssue = false;
let assignedToAny = false;
for (const num of numbers) {
let issue;
try {
({ data: issue } = await github.rest.issues.get({
owner, repo, issue_number: num,
}));
} catch (e) {
if (e.status === 404) {
console.log(`#${num} does not exist — ignoring`);
continue;
}
// Same safe-direction rule as hasWriteAccess: a transient
// error must not be read as "not assigned" and close the PR.
throw new Error(`Cannot fetch issue #${num} (HTTP ${e.status ?? 'unknown'}): ${e.message}`);
}
sawRealIssue = true;
const assignees = (issue.assignees || []).map(a => a.login.toLowerCase());
if (assignees.includes(prAuthor)) {
console.log(`PR author ${pr.user.login} is assigned to #${num}`);
assignedToAny = true;
break;
}
console.log(`PR author ${pr.user.login} is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`);
}
if (!sawRealIssue) {
console.log('Referenced issue(s) do not exist');
await enforceFailure('no-link');
return;
}
if (!assignedToAny) {
await enforceFailure('not-assigned');
return;
}
console.log('Linked and assigned — clearing any prior enforcement');
await clearEnforcement();
// ── Label, comment, close, and fail ────────────────────────────
// `kind`: 'no-link' (no valid issue reference) or 'not-assigned'
// (referenced an issue, but the author isn't assigned to it).
async function enforceFailure(kind) {
await addLabel();
const intro = kind === 'no-link'
? '**This PR has been automatically closed** because its description does not reference a tracked issue.'
: '**This PR has been automatically closed** because you are not assigned to the issue it references.';
const steps = kind === 'no-link'
? [
`1. Find or [open an issue](https://github.com/${owner}/${repo}/issues/new/choose) describing the change.`,
'2. Comment on the issue to ask a maintainer to assign it to you.',
'3. Add `Fixes #<issue>`, `Closes #<issue>`, or `Resolves #<issue>` to the PR description.',
'4. Once you are assigned and the link is present, the PR reopens automatically.',
]
: [
'1. Comment on the linked issue to ask a maintainer to assign it to you.',
'2. Once a maintainer assigns you, the PR reopens automatically.',
];
const commentBody = [
MARKER,
intro,
'',
`Per [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md), an external PR must reference an issue that is assigned to its author. To proceed:`,
'',
...steps,
'',
`*Maintainers: reopen this PR or remove the \`${LABEL}\` label to bypass this check.*`,
].join('\n');
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 },
);
const existing = comments.find(c => c.body && c.body.includes(MARKER));
if (!existing) {
await mutate(`comment on PR #${prNumber}`, () => github.rest.issues.createComment({
owner, repo, issue_number: prNumber, body: commentBody,
}));
} else if (existing.body !== commentBody) {
await mutate(`update comment ${existing.id}`, () => github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body: commentBody,
}));
} else {
console.log('Requirement comment already present — skipping');
}
if (pr.state === 'open') {
await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({
owner, repo, pull_number: prNumber, state: 'closed',
}));
}
if (enforce) {
core.setFailed(
kind === 'no-link'
? 'PR must reference a tracked issue using an auto-close keyword (e.g. "Fixes #123").'
: 'PR author must be assigned to the referenced issue.',
);
}
}
reopen-on-assignment:
if: github.event_name == 'issues' && github.event.action == 'assigned' && !github.event.issue.pull_request
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: reopen-on-assignment-${{ github.event.issue.number }}-${{ github.event.assignee.login }}
cancel-in-progress: false
permissions:
actions: write
issues: write
pull-requests: write
steps:
- name: Reopen linked PRs
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const issueNumber = context.payload.issue.number;
const assignee = context.payload.assignee.login;
const enforce = process.env.ENFORCE_ISSUE_LINK === 'true';
const LABEL = 'missing-issue-link';
const MARKER = '<!-- require-issue-link -->';
// Match GitHub's auto-close keywords against any reference form
// that GitHub itself honors: bare `#123`, the `owner/repo#123`
// shorthand, and the full issue URL. Scope the qualified forms to
// THIS repo — GitHub only auto-closes same-repo issues, so a
// cross-repo reference must not be resolved against our numbering.
const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(
'(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*' +
`(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`,
'gi',
);
async function mutate(description, fn) {
if (!enforce) {
console.log(`[dry-run] would ${description}`);
return;
}
await fn();
}
console.log(`Issue #${issueNumber} assigned to ${assignee} — searching for closed PRs to reopen`);
const q = [
'is:pr',
'is:closed',
`author:${assignee}`,
`label:${LABEL}`,
`repo:${owner}/${repo}`,
].join(' ');
let search;
try {
({ data: search } = await github.rest.search.issuesAndPullRequests({
q,
per_page: 30,
}));
} catch (e) {
throw new Error(
`Failed to search closed PRs for ${assignee} after assigning #${issueNumber} ` +
`(HTTP ${e.status ?? 'unknown'}): ${e.message}`,
);
}
if (search.total_count === 0) {
console.log('No matching closed PRs found');
return;
}
console.log(`Found ${search.total_count} candidate PR(s)`);
for (const item of search.items) {
const prNumber = item.number;
let issue;
try {
({ data: issue } = await github.rest.issues.get({
owner, repo, issue_number: prNumber,
}));
} catch (e) {
throw new Error(`Cannot fetch PR #${prNumber} issue data (HTTP ${e.status ?? 'unknown'}): ${e.message}`);
}
const labels = (issue.labels || []).map(label => label.name);
if (labels.includes('bypass-issue-check')) {
console.log(`PR #${prNumber} already has bypass-issue-check — skipping`);
continue;
}
const body = issue.body || '';
const referencedIssues = [...body.matchAll(pattern)].map(match => parseInt(match[1], 10));
if (!referencedIssues.includes(issueNumber)) {
console.log(`PR #${prNumber} does not reference #${issueNumber} — skipping`);
continue;
}
try {
await mutate(`reopen PR #${prNumber}`, () => github.rest.pulls.update({
owner, repo, pull_number: prNumber, state: 'open',
}));
} catch (e) {
if (e.status === 422) {
core.warning(`Cannot reopen PR #${prNumber}: the head branch was likely deleted`);
await mutate(`comment on unreopenable PR #${prNumber}`, () => github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body:
`You have been assigned to #${issueNumber}, but this PR could not be ` +
'reopened because the head branch has been deleted. Please open a new PR ' +
'referencing the issue.',
}));
continue;
}
throw e;
}
await mutate(`remove "${LABEL}" from PR #${prNumber}`, async () => {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: LABEL,
});
} catch (e) {
if (e.status !== 404) throw e;
}
});
try {
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 },
);
const stale = comments.find(comment => comment.body && comment.body.includes(MARKER));
if (stale) {
await mutate(`minimize stale comment ${stale.id}`, () => github.graphql(`
mutation($id: ID!) {
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
minimizedComment { isMinimized }
}
}
`, { id: stale.node_id }));
}
} catch (e) {
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
}
try {
const { data: pr } = await github.rest.pulls.get({
owner, repo, pull_number: prNumber,
});
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'require-issue-link.yml',
head_sha: pr.head.sha,
status: 'failure',
per_page: 1,
});
if (runs.workflow_runs.length === 0) {
console.log(`No failed require-issue-link runs found for PR #${prNumber}`);
continue;
}
await mutate(`re-run failed require-issue-link run for PR #${prNumber}`, () =>
github.rest.actions.reRunWorkflowFailedJobs({
owner, repo, run_id: runs.workflow_runs[0].id,
}),
);
} catch (e) {
core.warning(`Could not re-run require-issue-link for PR #${prNumber}: ${e.message}`);
}
}