diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 102bc794fae..d809e8729a0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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. diff --git a/.github/workflows/pr_size_labeler.yml b/.github/workflows/pr_size_labeler.yml index c76314f67b5..9dc4b6a4adc 100644 --- a/.github/workflows/pr_size_labeler.yml +++ b/.github/workflows/pr_size_labeler.yml @@ -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.`); diff --git a/.github/workflows/require_issue_link.yml b/.github/workflows/require_issue_link.yml new file mode 100644 index 00000000000..c3181d5b2a6 --- /dev/null +++ b/.github/workflows/require_issue_link.yml @@ -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 = ''; + + 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 #`', + '- `Closes #`', + '- `Resolves #`', + '', + '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").'); diff --git a/.github/workflows/tag-external-contributions.yml b/.github/workflows/tag-external-contributions.yml index 8d6852cae67..0cb941f6fa2 100644 --- a/.github/workflows/tag-external-contributions.yml +++ b/.github/workflows/tag-external-contributions.yml @@ -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;