Compare commits

...

7 commits

Author SHA1 Message Date
strawgate
a4488c5c2c ci: split reopen-on-issue-assignment into its own workflow file
🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 08:48:31 -05:00
Jeremiah Lowin
cb55924068
ci: reopen issue-linked PRs on assignment 2026-05-20 08:40:07 -04:00
strawgate
090e8afe49 ci: trigger on ready_for_review, reopen on label-removal bypass; trim CONTRIBUTING 2026-05-17 15:20:14 -05:00
strawgate
a082415ce7 ci: also require the linked issue be assigned to the PR author; update CONTRIBUTING 2026-05-17 15:10:35 -05:00
strawgate
03e062126a ci: consolidate require-issue-link into one step; read-only dry run; drop actions:write 2026-05-17 15:00:56 -05:00
strawgate
df80403592 ci: classify external authors by repo permission, not author_association 2026-05-17 14:50:06 -05:00
strawgate
8c3c5a54a9 ci: require external PRs to link a tracked issue 2026-05-17 14:43:17 -05:00
3 changed files with 685 additions and 2 deletions

View file

@ -0,0 +1,286 @@
# When the linked issue is assigned to a PR author, reopen any closed PR
# that this workflow's sibling, `require-issue-link.yml`, closed for the
# `not-assigned` failure mode. This is the happy-path continuation of the
# enforcement workflow: a contributor referenced a real issue but wasn't
# assigned to it; the natural maintainer response is to assign them, and
# the PR should resume without requiring a separate PR-side action.
#
# Out of scope: PRs closed for the `no-link` failure mode (no auto-close
# keyword in body). No signal connects an arbitrary issue assignment to
# such a PR. Those require a PR edit to retrigger.
#
# Coupling with `require-issue-link.yml` — keep in sync if changed there:
# • the `missing-issue-link` label name (LABEL constant)
# • the `bypass-issue-check` label name
# • the `<!-- require-issue-link -->` comment marker (MARKER constant)
# • the auto-close keyword regex
#
# SECURITY: `issues` events run with the BASE repo token. This workflow
# never checks out or executes PR-head code; it only reads the event
# payload and calls the API.
name: Reopen on Issue Assignment
on:
issues:
types: [assigned]
# Dry run: when "false" the check still runs and logs its decisions but
# makes NO mutations. Mirror of the sibling workflow's flag so the pair
# can be flipped together.
env:
ENFORCE_ISSUE_LINK: "true"
permissions:
contents: read
concurrency:
# Serialize per (issue, assignee): rapid re-assignments don't race.
# Different issues / different assignees run in parallel.
group: reopen-on-issue-assignment-${{ github.event.issue.number }}-${{ github.event.assignee.login }}
cancel-in-progress: false
jobs:
reopen:
# Skip when the assignment is on a PR (in the GitHub API a PR is an
# issue; the payload has `pull_request` set when the issue is in fact
# a PR — we only care about real issue assignments). Skip bots.
if: >-
github.event.assignee != null &&
!github.event.issue.pull_request &&
!endsWith(github.event.assignee.login, '[bot]')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
# pull-requests: write — reopen, remove label, post the
# deleted-branch comment, minimize the stale enforcement comment
# (all PR-as-issue API endpoints; require pull-requests scope).
# issues: read — GraphQL issue.timelineItems on the assigned issue.
# actions: write — re-run the original failed enforce workflow so
# the PR's red check run flips to green, instead of leaving the
# old run red alongside the new event-driven green check run.
pull-requests: write
issues: read
actions: write
steps:
- name: Reopen PRs newly compliant after issue assignment
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';
// ── Contracts shared with require-issue-link.yml ──────────────
const LABEL = 'missing-issue-link';
const BYPASS_LABEL = 'bypass-issue-check';
const MARKER = '<!-- require-issue-link -->';
// Identical regex to the enforce path: kept byte-for-byte the
// same so the two paths can't disagree about what counts as
// an auto-close link.
const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\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`
);
// ── Discover candidates via the issue's timeline ─────────────
// CROSS_REFERENCED_EVENT + body regex re-check is preferred
// over the Search API: no indexing lag (Search can take ~30s
// to reflect a freshly-closed PR), and the response gives
// state + body + labels + author in a single round trip.
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
timelineItems(itemTypes: [CROSS_REFERENCED_EVENT], first: 50) {
nodes {
... on CrossReferencedEvent {
source {
__typename
... on PullRequest {
number
state
body
author { login }
labels(first: 30) { nodes { name } }
}
}
}
}
}
}
}
}
`;
let timeline;
try {
const result = await github.graphql(query, {
owner, repo, number: issueNumber,
});
timeline = result?.repository?.issue?.timelineItems?.nodes ?? [];
} catch (e) {
throw new Error(
`Cannot fetch timeline for #${issueNumber} ` +
`(HTTP ${e.status ?? 'unknown'}): ${e.message}`
);
}
// Dedupe + filter to closed (not merged) PRs the sibling
// workflow closed for *this specific* issue, authored by the
// newly-assigned user, and not already bypassed.
const assigneeLower = assignee.toLowerCase();
const seen = new Set();
const candidates = [];
for (const node of timeline) {
const src = node?.source;
if (!src || src.__typename !== 'PullRequest') continue;
// state is OPEN | CLOSED | MERGED — only act on CLOSED
// (never resurrect a merged PR).
if (src.state !== 'CLOSED') continue;
if (seen.has(src.number)) continue;
seen.add(src.number);
const labels = (src.labels?.nodes ?? []).map(l => l.name);
if (!labels.includes(LABEL)) continue;
if (labels.includes(BYPASS_LABEL)) {
console.log(`PR #${src.number} already bypassed — skipping`);
continue;
}
const authorLogin = src.author?.login?.toLowerCase();
if (!authorLogin || authorLogin !== assigneeLower) continue;
// Re-apply the enforce path's regex against the PR body to
// confirm an auto-close link to *this* issue (rather than a
// cross-reference from a comment or commit mention).
const referenced = [...(src.body ?? '').matchAll(pattern)]
.map(m => parseInt(m[1], 10));
if (!referenced.includes(issueNumber)) continue;
candidates.push(src.number);
}
if (candidates.length === 0) {
console.log('No matching closed PRs found');
return;
}
console.log(`Reopening PR(s): ${candidates.join(', ')}`);
for (const prNumber of candidates) {
// ── Reopen the PR (handle deleted head branch) ──────────────
let reopened = false;
try {
await mutate(`reopen PR #${prNumber}`, () =>
github.rest.pulls.update({
owner, repo, pull_number: prNumber, state: 'open',
}),
);
reopened = true;
} 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;
}
// ── Remove the enforcement label ────────────────────────────
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;
}
});
// ── Minimize the stale enforcement comment ──────────────────
// listComments paginated: a long-running PR can accumulate
// many comments and per_page caps at 100.
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) {
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}`
);
}
// ── Re-run the failed enforce run so the red check turns ───
// green on this same head SHA, instead of leaving the
// original failure visible alongside the new event-driven
// run that the reopen itself triggers.
if (!reopened) continue;
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}`
);
}
}

397
.github/workflows/require-issue-link.yml vendored Normal file
View file

@ -0,0 +1,397 @@
# 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).
# - The issue-assignment side (reopening previously closed PRs once the
# linked issue is assigned to the author) lives in the sibling workflow
# `reopen-on-issue-assignment.yml` to keep this file focused on
# enforcement. The two files share the `missing-issue-link` /
# `bypass-issue-check` label names, the `<!-- require-issue-link -->`
# comment marker, and the auto-close keyword regex — keep them in sync.
#
# 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]
# 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: skip drafts, bots, and already-bypassed/trusted PRs. Allow the
# primary actions plus the one maintainer-override action we care about
# (removing the missing-issue-link label).
if: >-
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 || '';
const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\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.',
);
}
}

View file

@ -20,7 +20,7 @@ We encourage you to use LLMs to help identify bugs, write MREs, and prepare cont
## When to open a pull request
An open issue is not an invitation to submit a PR. Issues track problems; whether and how to solve them is a separate decision. If you want to work on something, propose your approach in the issue first — especially for anything beyond a trivial fix.
An open issue is not an invitation to submit a PR. Issues track problems; whether and how to solve them is a separate decision. If you want to work on something, propose your approach in the issue first and ask a maintainer to assign it to you — especially for anything beyond a trivial fix. External PRs that reference an issue not assigned to their author are closed automatically (see [PR guidelines](#pr-guidelines)).
**Bug fixes** — PRs are welcome for simple, well-scoped bug fixes where the problem and solution are both straightforward. "The function raises `TypeError` when passed `None` because of a missing guard" is a good candidate. If the fix requires design decisions or touches multiple subsystems, open an issue with a design proposal instead.
@ -34,7 +34,7 @@ An open issue is not an invitation to submit a PR. Issues track problems; whethe
If you do open a PR:
- **Reference an issue.** Every PR should address a tracked issue. If there isn't one, open an issue first. This isn't a permission step — you don't need to wait for a response. But the issue gives us context on the problem, and if a maintainer is already working on it, we can let you know before you invest time in code.
- **Reference an issue you're assigned to.** Every PR must reference a tracked issue using an auto-close keyword (`Fixes #123`, `Closes #123`, or `Resolves #123`), and the referenced issue must be assigned to you. If there isn't an issue, open one; then comment to ask a maintainer to assign it to you. This lets us deconflict effort and steer the approach before you invest time in code. External PRs that don't meet both conditions are automatically labeled `missing-issue-link` and closed; they reopen automatically once the link is present and you're assigned.
- **Keep it focused.** One logical change per PR. Don't bundle unrelated fixes or refactors.
- **Match existing patterns.** Follow the code style, type annotation conventions, and test patterns you see in the codebase. Run `uv run prek run --all-files` before submitting.
- **Write tests.** Bug fixes should include a test that fails without the fix. Enhancements should include tests for the new behavior.