mirror of
https://github.com/hwchase17/langchain.git
synced 2026-03-18 02:53:16 +00:00
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:
92
.github/workflows/pr_size_labeler.yml
vendored
92
.github/workflows/pr_size_labeler.yml
vendored
@@ -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
120
.github/workflows/require_issue_link.yml
vendored
Normal 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").');
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user