diff --git a/.github/workflows/require_issue_link.yml b/.github/workflows/require_issue_link.yml index 6ee800f24..63a9825fb 100644 --- a/.github/workflows/require_issue_link.yml +++ b/.github/workflows/require_issue_link.yml @@ -5,7 +5,7 @@ # Maintainer override: an org member can reopen the PR or remove # "missing-issue-link" — both add "bypass-issue-check" and reopen. # -# Dependency: tag-external-contributions.yml must apply the "external" label +# Dependency: tag-external-prs.yml must apply the "external" label # first. This workflow does NOT trigger on "opened" (new PRs have no labels # yet, so the gate would always skip). diff --git a/.github/workflows/tag-external-contributions.yml b/.github/workflows/tag-external-issues.yml similarity index 83% rename from .github/workflows/tag-external-contributions.yml rename to .github/workflows/tag-external-issues.yml index 00ae30d8f..fa976cb56 100644 --- a/.github/workflows/tag-external-contributions.yml +++ b/.github/workflows/tag-external-issues.yml @@ -1,10 +1,10 @@ -# Automatically tag issues and pull requests as "external" or "internal" -# based on whether the author is a member of the langchain-ai GitHub -# organization, and apply contributor tier labels to external contributors -# based on their merged PR history. +# Automatically tag issues as "external" or "internal" based on whether +# the author is a member of the langchain-ai GitHub organization, and +# apply contributor tier labels to external contributors based on their +# merged PR history. # -# NOTE: This repo does not have a separate pr_labeler.yml — this workflow -# handles both issues and PRs. +# PR labeling is handled by tag-external-prs.yml. +# PR + issue backfill lives in the backfill job below (workflow_dispatch). # # Setup Requirements: # 1. Create a GitHub App with permissions: @@ -18,13 +18,11 @@ # The GitHub App token is required to check private organization membership. # Without it, the workflow will fail. -name: Tag External Contributions +name: Tag External Issues on: issues: types: [opened] - pull_request_target: - types: [opened] workflow_dispatch: inputs: backfill_type: @@ -44,16 +42,15 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} + group: ${{ github.workflow }}-${{ github.event.issue.number || github.run_id }} cancel-in-progress: true jobs: tag-external: - if: github.event_name != 'workflow_dispatch' + if: github.event_name == 'issues' runs-on: ubuntu-latest permissions: issues: write - pull-requests: write steps: - name: Generate GitHub App token @@ -103,24 +100,16 @@ jobs: } } - # Apply tier label BEFORE the external/internal labels so that - # "trusted-contributor" is already present when the "external" labeled - # event fires and triggers require_issue_link.yml. - name: Apply contributor tier label if: steps.check-membership.outputs.is-external == 'true' uses: actions/github-script@v8 with: - # Use App token so the "labeled" event propagates to downstream - # workflows (e.g. require_issue_link.yml). - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ secrets.GITHUB_TOKEN }} script: | const { owner, repo } = context.repo; - const isPR = context.eventName === 'pull_request_target'; - const item = isPR - ? context.payload.pull_request - : context.payload.issue; - const author = item.user.login; - const issueNumber = item.number; + const issue = context.payload.issue; + const author = issue.user.login; + const issueNumber = issue.number; const TRUSTED_THRESHOLD = 5; const LABEL_COLOR = 'b76e79'; @@ -164,8 +153,8 @@ jobs: console.log(`No tier label for ${author} (${mergedCount} merged PRs)`); } - - name: Add external label to issue - if: steps.check-membership.outputs.is-external == 'true' && github.event_name == 'issues' + - name: Add external label + if: steps.check-membership.outputs.is-external == 'true' uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -177,24 +166,8 @@ jobs: }); console.log(`Added 'external' label to issue #${issue_number}`); - - name: Add external label to pull request - if: steps.check-membership.outputs.is-external == 'true' && github.event_name == 'pull_request_target' - uses: actions/github-script@v8 - with: - # Use App token so the "labeled" event propagates to downstream - # workflows (e.g. require_issue_link.yml). Events created by the - # default GITHUB_TOKEN do not trigger additional workflow runs. - github-token: ${{ steps.app-token.outputs.token }} - script: | - const { owner, repo } = context.repo; - const issue_number = context.payload.pull_request.number; - await github.rest.issues.addLabels({ - owner, repo, issue_number, labels: ['external'], - }); - console.log(`Added 'external' label to PR #${issue_number}`); - - - name: Add internal label to issue - if: steps.check-membership.outputs.is-external == 'false' && github.event_name == 'issues' + - name: Add internal label + if: steps.check-membership.outputs.is-external == 'false' uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -206,19 +179,6 @@ jobs: }); console.log(`Added 'internal' label to issue #${issue_number}`); - - name: Add internal label to pull request - if: steps.check-membership.outputs.is-external == 'false' && github.event_name == 'pull_request_target' - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { owner, repo } = context.repo; - const issue_number = context.payload.pull_request.number; - await github.rest.issues.addLabels({ - owner, repo, issue_number, labels: ['internal'], - }); - console.log(`Added 'internal' label to PR #${issue_number}`); - backfill: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest @@ -254,7 +214,7 @@ jobs: const tierLabels = ['trusted-contributor']; - // ── Helpers (inlined from pr-labeler.js) ───────────────────────── + // ── Helpers ───────────────────────────────────────────────── async function ensureLabel(name) { try { @@ -321,7 +281,7 @@ jobs: return info; } - // ── Setup ──────────────────────────────────────────────────────── + // ── Setup ──────────────────────────────────────────────────── for (const name of tierLabels) { await ensureLabel(name); @@ -332,7 +292,7 @@ jobs: let processed = 0; let failures = 0; - // ── Backfill PRs ───────────────────────────────────────────────── + // ── Backfill PRs ───────────────────────────────────────────── if (backfillType === 'prs' || backfillType === 'both') { const prs = await github.paginate(github.rest.pulls.list, { @@ -385,7 +345,7 @@ jobs: } } - // ── Backfill issues ────────────────────────────────────────────── + // ── Backfill issues ────────────────────────────────────────── if (backfillType === 'issues' || backfillType === 'both') { const issues = await github.paginate(github.rest.issues.listForRepo, { diff --git a/.github/workflows/tag-external-prs.yml b/.github/workflows/tag-external-prs.yml new file mode 100644 index 000000000..8ac61f35c --- /dev/null +++ b/.github/workflows/tag-external-prs.yml @@ -0,0 +1,173 @@ +# Automatically tag pull requests as "external" or "internal" based on +# whether the author is a member of the langchain-ai GitHub organization, +# and apply contributor tier labels to external contributors based on +# their merged PR history. +# +# Issue labeling is handled by tag-external-issues.yml. +# Backfill (workflow_dispatch) also lives in tag-external-issues.yml. +# +# Setup Requirements: +# 1. Create a GitHub App with permissions: +# - Repository: Pull requests (write) +# - Organization: Members (read) +# 2. Install the app on your organization and this repository +# 3. Add these repository secrets: +# - ORG_MEMBERSHIP_APP_ID: Your app's ID +# - ORG_MEMBERSHIP_APP_PRIVATE_KEY: Your app's private key +# +# The GitHub App token is required to check private organization membership. +# Without it, the workflow will fail. + +name: Tag External PRs + +on: + pull_request_target: + types: [opened] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + tag-external: + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }} + private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }} + + - name: Check if contributor is external + if: steps.app-token.outcome == 'success' + id: check-membership + uses: actions/github-script@v8 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const author = context.payload.sender.login; + const senderType = context.payload.sender.type; + + if (senderType === 'Bot') { + console.log(`${author} is a Bot — treating as internal`); + core.setOutput('is-external', 'false'); + return; + } + + try { + const membership = await github.rest.orgs.getMembershipForUser({ + org: 'langchain-ai', + username: author, + }); + const isExternal = membership.data.state !== 'active'; + console.log( + isExternal + ? `${author} has pending membership — treating as external` + : `${author} is an active member of langchain-ai`, + ); + core.setOutput('is-external', isExternal ? 'true' : 'false'); + } catch (e) { + if (e.status === 404) { + console.log(`${author} is not a member of langchain-ai`); + core.setOutput('is-external', 'true'); + } else { + throw new Error( + `Membership check failed for ${author} (${e.status}): ${e.message}`, + ); + } + } + + # Apply tier label BEFORE the external label so that + # "trusted-contributor" is already present when the "external" labeled + # event fires and triggers require_issue_link.yml. + - name: Apply contributor tier label + if: steps.check-membership.outputs.is-external == 'true' + uses: actions/github-script@v8 + with: + # Use App token so the "labeled" event propagates to downstream + # workflows (e.g. require_issue_link.yml). + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const author = pr.user.login; + const prNumber = pr.number; + + const TRUSTED_THRESHOLD = 5; + const LABEL_COLOR = 'b76e79'; + + let mergedCount; + try { + const result = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`, + per_page: 1, + }); + mergedCount = result?.data?.total_count; + } catch (error) { + if (error?.status !== 422) throw error; + core.warning(`Search failed for ${author}; skipping tier label.`); + return; + } + + if (mergedCount == null) { + core.warning(`Search response missing total_count for ${author}; skipping tier label.`); + return; + } + + const tierLabel = mergedCount >= TRUSTED_THRESHOLD ? 'trusted-contributor' : null; + + if (tierLabel) { + try { + await github.rest.issues.getLabel({ owner, repo, name: tierLabel }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ owner, repo, name: tierLabel, color: LABEL_COLOR }); + } catch (createErr) { + if (createErr.status !== 422) throw createErr; + } + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [tierLabel], + }); + console.log(`Applied '${tierLabel}' to PR #${prNumber} (${mergedCount} merged PRs)`); + } else { + console.log(`No tier label for ${author} (${mergedCount} merged PRs)`); + } + + - name: Add external label + if: steps.check-membership.outputs.is-external == 'true' + uses: actions/github-script@v8 + with: + # Use App token so the "labeled" event propagates to downstream + # workflows (e.g. require_issue_link.yml). Events created by the + # default GITHUB_TOKEN do not trigger additional workflow runs. + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + await github.rest.issues.addLabels({ + owner, repo, issue_number, labels: ['external'], + }); + console.log(`Added 'external' label to PR #${issue_number}`); + + - name: Add internal label + if: steps.check-membership.outputs.is-external == 'false' + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + await github.rest.issues.addLabels({ + owner, repo, issue_number, labels: ['internal'], + }); + console.log(`Added 'internal' label to PR #${issue_number}`);