Files
langchain/.github/workflows/pr_size_labeler.yml
Mason Daugherty 225bb5b253 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.
2026-03-09 11:12:33 -04:00

175 lines
6.3 KiB
YAML

# Label PRs by size (changed lines, excluding lockfiles and docs).
#
# Size thresholds:
# XS: < 50, S: < 200, M: < 500, L: < 1000, XL: >= 1000
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
issues: write
steps:
- name: Apply PR size label
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { owner, repo } = context.repo;
const pullRequest = context.payload.pull_request;
if (!pullRequest) return;
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,
});
}
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: pullRequest.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);
let targetSizeLabel = 'size: XL';
if (totalChangedLines < 50) targetSizeLabel = 'size: XS';
else if (totalChangedLines < 200) targetSizeLabel = 'size: S';
else if (totalChangedLines < 500) targetSizeLabel = 'size: M';
else if (totalChangedLines < 1000) targetSizeLabel = 'size: L';
// Remove stale size labels
const currentLabels = await github.paginate(
github.rest.issues.listLabelsOnIssue,
{ owner, repo, issue_number: pullRequest.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: pullRequest.number, name,
});
}
}
await github.rest.issues.addLabels({
owner, repo, issue_number: pullRequest.number, labels: [targetSizeLabel],
});
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.`);