# 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.
#
# Setup Requirements:
# 1. Create a GitHub App with permissions:
#    - Repository: Issues (write), 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.
#
# Contributor tier thresholds:
#   - trusted-contributor: >= 5 merged PRs

name: Tag External Contributions

on:
  issues:
    types: [opened]
  pull_request_target:
    types: [opened]
  workflow_dispatch:
    inputs:
      backfill_type:
        description: "Backfill type (for initial run)"
        default: "both"
        type: choice
        options:
          - prs
          - issues
          - both
      max_items:
        description: "Maximum number of items to process"
        default: "100"
        type: string

permissions:
  contents: read

jobs:
  tag-external:
    if: github.event_name != 'workflow_dispatch'
    runs-on: ubuntu-latest
    permissions:
      issues: write
      pull-requests: write

    steps:
      - name: Generate GitHub App token
        id: app-token
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }}
          private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}

      - name: Check if contributor is external
        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;

            // GitHub App bots (e.g. model-profile-bot) are not org members
            // but should be treated as internal — skip the membership check.
            const senderType = context.payload.sender.type;
            if (senderType === 'Bot') {
              console.log(`Sender ${author} is a Bot — treating as internal`);
              core.setOutput('is-external', 'false');
              return;
            }

            try {
              // Check if the author is a member of the langchain-ai organization
              // This requires org:read permissions to see private memberships
              const membership = await github.rest.orgs.getMembershipForUser({
                org: 'langchain-ai',
                username: author
              });

              // Check if membership is active (not just pending invitation)
              if (membership.data.state === 'active') {
                console.log(`User ${author} is an active member of langchain-ai organization`);
                core.setOutput('is-external', 'false');
              } else {
                console.log(`User ${author} has pending membership in langchain-ai organization`);
                core.setOutput('is-external', 'true');
              }
            } catch (error) {
              if (error.status === 404) {
                console.log(`User ${author} is not a member of langchain-ai organization`);
                core.setOutput('is-external', 'true');
              } else {
                console.error('Error checking membership:', error);
                console.log('Status:', error.status);
                console.log('Message:', error.message);
                // If we can't determine membership due to API error, assume external for safety
                core.setOutput('is-external', 'true');
              }
            }

      # 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 bypass-trusted-contributor).
          github-token: ${{ steps.app-token.outputs.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 TRUSTED_THRESHOLD = 5;

            const mergedQuery = `repo:${owner}/${repo} is:pr is:merged author:"${author}"`;
            let mergedCount = 0;
            try {
              const result = await github.rest.search.issuesAndPullRequests({
                q: mergedQuery,
                per_page: 1,
              });
              mergedCount = result?.data?.total_count ?? 0;
            } catch (error) {
              if (error?.status !== 422) throw error;
              core.warning(`Search failed for ${author}; skipping tier label.`);
              return;
            }

            const label = mergedCount >= TRUSTED_THRESHOLD ? 'trusted-contributor' : null;

            if (label) {
              await github.rest.issues.addLabels({
                owner,
                repo,
                issue_number: issueNumber,
                labels: [label],
              });
              console.log(`Applied '${label}' to #${issueNumber} (${mergedCount} merged PRs)`);
            } else {
              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'
        uses: actions/github-script@v8
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const { owner, repo } = context.repo;
            const issue_number = context.payload.issue.number;

            await github.rest.issues.addLabels({
              owner,
              repo,
              issue_number,
              labels: ['external']
            });

            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 pull_number = context.payload.pull_request.number;

            await github.rest.issues.addLabels({
              owner,
              repo,
              issue_number: pull_number,
              labels: ['external']
            });

            console.log(`Added 'external' label to pull request #${pull_number}`);

      - name: Add internal label to issue
        if: steps.check-membership.outputs.is-external == 'false' && github.event_name == 'issues'
        uses: actions/github-script@v8
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const { owner, repo } = context.repo;
            const issue_number = context.payload.issue.number;

            await github.rest.issues.addLabels({
              owner,
              repo,
              issue_number,
              labels: ['internal']
            });

            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 pull_number = context.payload.pull_request.number;

            await github.rest.issues.addLabels({
              owner,
              repo,
              issue_number: pull_number,
              labels: ['internal']
            });

            console.log(`Added 'internal' label to pull request #${pull_number}`);

  backfill:
    if: github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-latest
    permissions:
      issues: write
      pull-requests: write

    steps:
      - name: Generate GitHub App token
        id: app-token
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }}
          private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}

      - name: Backfill labels
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token }}
          script: |
            const { owner, repo } = context.repo;
            const maxItems = parseInt('${{ inputs.max_items }}') || 100;
            const backfillType = '${{ inputs.backfill_type }}';

            const TRUSTED_THRESHOLD = 5;
            const LABEL_COLOR = 'b76e79';

            const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL'];
            const tierLabels = ['trusted-contributor'];

            // Ensure tier and size labels exist
            for (const name of [...tierLabels, ...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: LABEL_COLOR,
                });
                console.log(`Created label: ${name}`);
              }
            }

            // Cache: author -> { isExternal, mergedCount }
            const contributorCache = new Map();

            async function getContributorInfo(author, userType) {
              if (contributorCache.has(author)) return contributorCache.get(author);

              // Bots are always internal
              if (userType === 'Bot') {
                const info = { isExternal: false, mergedCount: 0 };
                contributorCache.set(author, info);
                return info;
              }

              let isExternal = true;
              try {
                const membership = await github.rest.orgs.getMembershipForUser({
                  org: 'langchain-ai',
                  username: author,
                });
                isExternal = membership.data.state !== 'active';
              } catch (error) {
                if (error.status !== 404) {
                  core.warning(`Membership check failed for ${author}: ${error.message}`);
                }
              }

              let mergedCount = 0;
              if (isExternal) {
                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 ?? 0;
                } catch (error) {
                  if (error?.status !== 422) throw error;
                  core.warning(`Search failed for ${author}; skipping tier.`);
                }
              }

              const info = { isExternal, mergedCount };
              contributorCache.set(author, info);
              return info;
            }

            function getTierLabel(mergedCount) {
              return mergedCount >= TRUSTED_THRESHOLD ? 'trusted-contributor' : null;
            }

            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';
            }

            async function removeStaleLabels(issueNumber, labelsToKeep, labelSets) {
              const currentLabels = await github.paginate(
                github.rest.issues.listLabelsOnIssue,
                { owner, repo, issue_number: issueNumber, per_page: 100 },
              );
              for (const label of currentLabels) {
                const name = label.name ?? '';
                const inManagedSet = labelSets.some((s) => s.includes(name));
                if (inManagedSet && !labelsToKeep.includes(name)) {
                  await github.rest.issues.removeLabel({
                    owner, repo, issue_number: issueNumber, name,
                  });
                }
              }
            }

            let processed = 0;

            // Backfill PRs
            if (backfillType === 'prs' || backfillType === 'both') {
              const prs = await github.paginate(github.rest.pulls.list, {
                owner, repo, state: 'open', per_page: 100,
              });

              for (const pr of prs) {
                if (processed >= maxItems) break;
                const author = pr.user.login;
                const info = await getContributorInfo(author, pr.user.type);

                const labels = [];
                labels.push(info.isExternal ? 'external' : 'internal');

                if (info.isExternal) {
                  const tier = getTierLabel(info.mergedCount);
                  if (tier) labels.push(tier);
                }

                // Compute size label
                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);
                labels.push(getSizeLabel(totalChangedLines));

                await removeStaleLabels(pr.number, labels, [sizeLabels, tierLabels]);
                await github.rest.issues.addLabels({
                  owner, repo, issue_number: pr.number, labels,
                });
                console.log(`PR #${pr.number} (${author}): ${labels.join(', ')}`);
                processed++;
              }
            }

            // Backfill issues
            if (backfillType === 'issues' || backfillType === 'both') {
              const issues = await github.paginate(github.rest.issues.listForRepo, {
                owner, repo, state: 'open', per_page: 100,
              });

              for (const issue of issues) {
                if (processed >= maxItems) break;
                if (issue.pull_request) continue;

                const author = issue.user.login;
                const info = await getContributorInfo(author, issue.user.type);

                const labels = [];
                labels.push(info.isExternal ? 'external' : 'internal');

                if (info.isExternal) {
                  const tier = getTierLabel(info.mergedCount);
                  if (tier) labels.push(tier);
                }

                await removeStaleLabels(issue.number, labels, [tierLabels]);
                await github.rest.issues.addLabels({
                  owner, repo, issue_number: issue.number, labels,
                });
                console.log(`Issue #${issue.number} (${author}): ${labels.join(', ')}`);
                processed++;
              }
            }

            console.log(`\nBackfill complete. Processed ${processed} items. Cache hits: ${contributorCache.size} unique authors.`);
