# Require external PRs to link to an approved issue or discussion using
# GitHub auto-close keywords (Fixes #NNN, Closes #NNN, Resolves #NNN),
# AND require that the PR author is assigned to the linked issue.
#
# - 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.
# - Bypasses the check for PRs with the "trusted-contributor" label, and
#   automatically reopens/cleans up PRs that receive it after enforcement.
# - Validates the PR author is an assignee on at least one linked issue.
# - Adds a "missing-issue-link" label on failure; removes it on pass.
# - Automatically reopens PRs that were closed by this workflow once the
#   check passes (e.g. author edits the body to add a valid issue link).
# - 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.
    # Skip entirely when the PR already carries "trusted-contributor".
    if: >-
      !contains(github.event.pull_request.labels.*.name, 'trusted-contributor') &&
      (
        (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 and assignee
        id: check-link
        uses: actions/github-script@v8
        with:
          script: |
            const { owner, repo } = context.repo;
            const prNumber = context.payload.pull_request.number;

            // Fetch live labels to handle the race where "external" fires
            // before "trusted-contributor" appears in the event payload.
            const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({
              owner, repo, issue_number: prNumber,
            });
            if (liveLabels.some(l => l.name === 'trusted-contributor')) {
              console.log('PR has trusted-contributor label — bypassing issue link check');
              core.setOutput('has-link', 'true');
              core.setOutput('is-assigned', 'true');
              return;
            }

            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) {
              console.log('No issue link found in PR body');
              core.setOutput('has-link', 'false');
              core.setOutput('is-assigned', 'false');
              return;
            }

            const issues = matches.map(m => `#${m[1]}`).join(', ');
            console.log(`Found issue link(s): ${issues}`);
            core.setOutput('has-link', 'true');

            // Check whether the PR author is assigned to at least one linked issue
            const prAuthor = context.payload.pull_request.user.login;
            const issueNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))];

            let assignedToAny = false;
            for (const num of issueNumbers) {
              try {
                const { data: issue } = await github.rest.issues.get({
                  owner, repo, issue_number: num,
                });
                const assignees = issue.assignees.map(a => a.login.toLowerCase());
                if (assignees.includes(prAuthor.toLowerCase())) {
                  console.log(`PR author "${prAuthor}" is assigned to #${num}`);
                  assignedToAny = true;
                  break;
                } else {
                  console.log(`PR author "${prAuthor}" is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`);
                }
              } catch (error) {
                console.log(`Could not fetch issue #${num}: ${error.message}`);
              }
            }

            core.setOutput('is-assigned', assignedToAny ? 'true' : 'false');

      - name: Add missing-issue-link label
        if: steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != '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 and reopen PR
        if: steps.check-link.outputs.has-link == 'true' && steps.check-link.outputs.is-assigned == '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;
            }

            // Reopen PR only if it was previously closed by this workflow
            const labels = context.payload.pull_request.labels.map(l => l.name);
            if (context.payload.pull_request.state === 'closed' && labels.includes('missing-issue-link')) {
              await github.rest.pulls.update({
                owner,
                repo,
                pull_number: prNumber,
                state: 'open',
              });
              console.log(`Reopened PR #${prNumber}`);
            }

      - name: Post comment, close PR, and fail
        if: steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true'
        uses: actions/github-script@v8
        with:
          script: |
            const { owner, repo } = context.repo;
            const prNumber = context.payload.pull_request.number;
            const hasLink = '${{ steps.check-link.outputs.has-link }}' === 'true';
            const isAssigned = '${{ steps.check-link.outputs.is-assigned }}' === 'true';
            const marker = '<!-- require-issue-link -->';

            let lines;
            if (!hasLink) {
              lines = [
                marker,
                '**This PR has been automatically closed** because it does not link to an approved issue.',
                '',
                'All external contributions must reference an approved issue or discussion. Please:',
                '1. Find or [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new/choose) describing the change',
                '2. Wait for a maintainer to approve and assign you',
                '3. Add `Fixes #<issue_number>`, `Closes #<issue_number>`, or `Resolves #<issue_number>` to your PR description and the PR will be reopened automatically',
              ];
            } else {
              lines = [
                marker,
                '**This PR has been automatically closed** because you are not assigned to the linked issue.',
                '',
                'External contributors must be assigned to an issue before opening a PR for it. Please:',
                '1. Comment on the linked issue to request assignment from a maintainer',
                '2. Once assigned, edit your PR description and the PR will be reopened automatically',
              ];
            }

            const body = lines.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 requirement comment');
            } else if (existing.body !== body) {
              await github.rest.issues.updateComment({
                owner,
                repo,
                comment_id: existing.id,
                body,
              });
              console.log('Updated existing comment with new message');
            } else {
              console.log('Comment already exists — skipping');
            }

            // Close the PR
            if (context.payload.pull_request.state === 'open') {
              await github.rest.pulls.update({
                owner,
                repo,
                pull_number: prNumber,
                state: 'closed',
              });
              console.log(`Closed PR #${prNumber}`);
            }

            const reason = !hasLink
              ? 'PR must reference an issue using auto-close keywords (e.g., "Fixes #123").'
              : 'PR author must be assigned to the linked issue.';
            core.setFailed(reason);

  # When a trusted-contributor label is added to a PR that was previously
  # closed by check-issue-link, clean up and reopen it.
  bypass-trusted-contributor:
    if: github.event.action == 'labeled' && github.event.label.name == 'trusted-contributor'
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write

    steps:
      - name: Remove missing-issue-link label and reopen PR
        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',
              });
              console.log('Removed missing-issue-link label');
            } catch (error) {
              if (error.status !== 404) throw error;
            }

            if (context.payload.pull_request.state === 'closed') {
              await github.rest.pulls.update({
                owner, repo, pull_number: prNumber, state: 'open',
              });
              console.log(`Reopened PR #${prNumber}`);
            }
