ci(infra): require issue link for external PRs (#35690)

Enforce that all external PRs reference an approved issue via GitHub
auto-close keywords (`Fixes #NNN`, `Closes #NNN`, `Resolves #NNN`). This
replaces the previous AI-disclaimer policy in the PR template with a
stricter requirement: external contributors must link to a
maintainer-approved issue before their PR can merge.

## Changes

- Add `require_issue_link.yml` workflow that chains off the `external`
label applied by `tag-external-contributions.yml` — listens for
`labeled`, `edited`, and `reopened` events to avoid duplicating the org
membership API call
- Scan PR body with a case-insensitive regex matching all conjugations
of `close/fix/resolve` + `#NNN`; fail the check and post a deduplicated
comment (via `<!-- require-issue-link -->` HTML marker) when no link is
found
- Apply a `missing-issue-link` label on failure, remove it on pass —
enables bulk cleanup via label filter
- Add `workflow_dispatch` backfill job to `pr_size_labeler.yml` for
retroactively applying size labels to open PRs
- Quote `author` in GitHub search queries in
`tag-external-contributions.yml` to prevent mismatches on usernames with
special characters
- Update `PULL_REQUEST_TEMPLATE.md` to replace the AI-disclaimer
guideline with the new issue-link requirement

> [!NOTE]
> `require_issue_link.yml` depends on `tag-external-contributions.yml`
running first to apply the `external` label. Deploy as a non-required
check initially, then promote to required after validation.
This commit is contained in:
Mason Daugherty
2026-03-09 11:12:33 -04:00
committed by GitHub
parent 360e0165ab
commit 225bb5b253
4 changed files with 215 additions and 3 deletions

View File

@@ -31,7 +31,7 @@ Thank you for contributing to LangChain! Follow these steps to have your pull re
Additional guidelines:
- We ask that if you use generative AI for your contribution, you include a disclaimer.
- All external PRs must link to an issue or discussion where a solution has been approved by a maintainer. PRs without prior approval will be closed.
- PRs should not touch more than one package unless absolutely necessary.
- Do not update the `uv.lock` files or add dependencies to `pyproject.toml` files (even optional ones) unless you have explicit permission to do so by a maintainer.

View File

@@ -8,12 +8,19 @@ name: "📏 PR Size Labeler"
on:
pull_request_target:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
max_items:
description: "Maximum number of open PRs to process"
default: "100"
type: string
permissions:
contents: read
jobs:
size-label:
if: github.event_name != 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
pull-requests: write
@@ -80,3 +87,88 @@ jobs:
});
console.log(`PR #${pullRequest.number}: ${totalChangedLines} changed lines → ${targetSizeLabel}`);
backfill:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Backfill size labels on open PRs
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { owner, repo } = context.repo;
const maxItems = parseInt('${{ inputs.max_items }}') || 100;
const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL'];
const labelColor = 'b76e79';
// Ensure labels exist
for (const name of sizeLabels) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
} catch (error) {
if (error?.status !== 404) throw error;
await github.rest.issues.createLabel({
owner, repo, name, color: labelColor,
});
console.log(`Created label: ${name}`);
}
}
function getSizeLabel(totalChangedLines) {
if (totalChangedLines < 50) return 'size: XS';
if (totalChangedLines < 200) return 'size: S';
if (totalChangedLines < 500) return 'size: M';
if (totalChangedLines < 1000) return 'size: L';
return 'size: XL';
}
const prs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
let processed = 0;
for (const pr of prs) {
if (processed >= maxItems) break;
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: pr.number, per_page: 100,
});
const excludedFiles = new Set(['poetry.lock', 'uv.lock']);
const totalChangedLines = files.reduce((total, file) => {
const path = file.filename ?? '';
if (path.startsWith('docs/') || excludedFiles.has(path)) return total;
return total + (file.additions ?? 0) + (file.deletions ?? 0);
}, 0);
const targetSizeLabel = getSizeLabel(totalChangedLines);
// Remove stale size labels
const currentLabels = await github.paginate(
github.rest.issues.listLabelsOnIssue,
{ owner, repo, issue_number: pr.number, per_page: 100 },
);
for (const label of currentLabels) {
const name = label.name ?? '';
if (sizeLabels.includes(name) && name !== targetSizeLabel) {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr.number, name,
});
}
}
await github.rest.issues.addLabels({
owner, repo, issue_number: pr.number, labels: [targetSizeLabel],
});
console.log(`PR #${pr.number}: ${totalChangedLines} changed lines → ${targetSizeLabel}`);
processed++;
}
console.log(`\nBackfill complete. Processed ${processed} PRs.`);

120
.github/workflows/require_issue_link.yml vendored Normal file
View File

@@ -0,0 +1,120 @@
# Require external PRs to link to an approved issue or discussion using
# GitHub auto-close keywords (Fixes #NNN, Closes #NNN, Resolves #NNN).
#
# - Reacts to the "external" label applied by tag-external-contributions.yml,
# avoiding a duplicate org membership check.
# - Also re-checks on PR edits/reopens for PRs that already have the label.
# - Adds a "missing-issue-link" label on failure; removes it on pass.
# - Posts a comment explaining the requirement on failure.
# - Deduplicates comments via an HTML marker so re-runs don't spam.
#
# Dependency: tag-external-contributions.yml must run first to apply the
# "external" label on new PRs. Both workflows trigger on pull_request_target
# opened events; this workflow additionally listens for the "labeled" event
# to chain off the external classification.
name: Require Issue Link
on:
pull_request_target:
types: [opened, edited, reopened, labeled]
permissions:
contents: read
jobs:
check-issue-link:
# Run when the "external" label is added, or on edit/reopen if already labeled
if: >-
(github.event.action == 'labeled' && github.event.label.name == 'external') ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'external'))
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Check for issue link
id: check-link
uses: actions/github-script@v8
with:
script: |
const body = context.payload.pull_request.body || '';
const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi;
const matches = [...body.matchAll(pattern)];
if (matches.length > 0) {
const issues = matches.map(m => `#${m[1]}`).join(', ');
console.log(`Found issue link(s): ${issues}`);
core.setOutput('has-link', 'true');
} else {
console.log('No issue link found in PR body');
core.setOutput('has-link', 'false');
}
- name: Add missing-issue-link label
if: steps.check-link.outputs.has-link != 'true'
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: ['missing-issue-link'],
});
- name: Remove missing-issue-link label
if: steps.check-link.outputs.has-link == 'true'
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: 'missing-issue-link',
});
} catch (error) {
if (error.status !== 404) throw error;
}
- name: Post comment and fail
if: steps.check-link.outputs.has-link != 'true'
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const marker = '<!-- require-issue-link -->';
const body = [
marker,
'**This PR is missing a linked issue.** All external contributions must reference an approved issue or discussion.',
'',
'Please add one of the following to your PR description:',
'- `Fixes #<issue_number>`',
'- `Closes #<issue_number>`',
'- `Resolves #<issue_number>`',
'',
'If no issue exists yet, [open one](https://github.com/' + owner + '/' + repo + '/issues/new/choose) and wait for maintainer approval before proceeding.',
].join('\n');
// Deduplicate: check for existing comment with the marker
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 github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
console.log('Posted issue-link requirement comment');
} else {
console.log('Comment already exists — skipping');
}
core.setFailed('PR must reference an issue using auto-close keywords (e.g., "Fixes #123").');

View File

@@ -187,7 +187,7 @@ jobs:
const TRUSTED_THRESHOLD = 4;
const EXPERIENCED_THRESHOLD = 10;
const mergedQuery = `repo:${owner}/${repo} is:pr is:merged author:${author}`;
const mergedQuery = `repo:${owner}/${repo} is:pr is:merged author:"${author}"`;
let mergedCount = 0;
try {
const result = await github.rest.search.issuesAndPullRequests({
@@ -287,7 +287,7 @@ jobs:
if (isExternal) {
try {
const result = await github.rest.search.issuesAndPullRequests({
q: `repo:${owner}/${repo} is:pr is:merged author:${author}`,
q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`,
per_page: 1,
});
mergedCount = result?.data?.total_count ?? 0;