# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

name: PR assignment
on:
  pull_request_target:
    types: [opened, edited, closed]

permissions:
  issues: write
  pull-requests: write

jobs:
  # All three behaviors live as steps under one job so the PR Checks
  # tab shows a single entry per event instead of two-or-three skipped
  # siblings. Step-level if-guards keep the actual work scoped.
  pr-assignment:
    runs-on: ubuntu-latest
    steps:
      - name: Self-assign PR author to the PR
        if: >-
          github.event.action == 'opened'
          && github.event.pull_request.user.type != 'Bot'
          && github.event.pull_request.assignees[0] == null
        uses: actions/github-script@v8
        with:
          script: |
            const pr = context.payload.pull_request;
            core.info(`PR #${pr.number} opened by ${pr.user.login}; self-assigning.`);
            try {
              await github.rest.issues.addAssignees({
                ...context.repo,
                issue_number: pr.number,
                assignees: [pr.user.login],
              });
              core.info(`Assigned ${pr.user.login} to PR #${pr.number}`);
            } catch (e) {
              core.warning(`Self-assign on PR #${pr.number} failed: ${e.message}`);
            }

      - name: Sync PR opener as assignee on linked issues
        # Mirror the PR opener as an assignee on each same-repo issue listed
        # in closingIssuesReferences. On body edits, also drop the opener
        # from issues whose closing keyword was removed. Other manual
        # assignees are never touched here, so this never fights with the
        # merge-time credit step or human triage decisions.
        if: >-
          contains(fromJSON('["opened","edited"]'), github.event.action)
          && github.event.pull_request.state == 'open'
          && github.event.pull_request.user.type != 'Bot'
        uses: actions/github-script@v8
        with:
          script: |
            const { owner, repo } = context.repo;
            const pr = context.payload.pull_request;
            const opener = pr.user.login;
            core.info(`Event ${context.payload.action} on PR #${pr.number} by ${opener}; syncing closing-issue assignees.`);

            const { repository: { pullRequest: prq } } = await github.graphql(`
              query($owner: String!, $repo: String!, $pr: Int!) {
                repository(owner: $owner, name: $repo) {
                  pullRequest(number: $pr) {
                    closingIssuesReferences(first: 50) {
                      nodes { number repository { nameWithOwner } }
                    }
                  }
                }
              }`, { owner, repo, pr: pr.number });

            const sameRepo = `${owner}/${repo}`;
            const allRefs = prq.closingIssuesReferences.nodes;
            const linked = allRefs
              .filter((n) => n.repository.nameWithOwner === sameRepo)
              .map((n) => n.number);
            const crossRepo = allRefs.filter((n) => n.repository.nameWithOwner !== sameRepo);
            core.info(`Found ${linked.length} same-repo closing reference(s): ${linked.join(', ') || '(none)'}`);
            if (crossRepo.length) {
              core.info(`Skipping ${crossRepo.length} cross-repo reference(s): ${crossRepo.map((n) => `${n.repository.nameWithOwner}#${n.number}`).join(', ')}`);
            }

            for (const issue_number of linked) {
              try {
                await github.rest.issues.addAssignees({
                  owner, repo, issue_number, assignees: [opener],
                });
                core.info(`Assigned ${opener} to issue #${issue_number}`);
              } catch (e) {
                core.warning(`addAssignees on #${issue_number} failed: ${e.message}`);
              }
            }

            // On body edit, find closing refs that disappeared from the body
            // and remove the opener from those issues. closingIssuesReferences
            // is a snapshot of the *new* state, so we need text-diff to detect
            // removals. Cross-repo refs are intentionally skipped.
            if (
              context.payload.action === 'edited' &&
              context.payload.changes &&
              context.payload.changes.body &&
              typeof context.payload.changes.body.from === 'string'
            ) {
              // GitHub also recognizes the colon form ("Closes: #123"), so
              // allow an optional ":" between the keyword and the issue
              // ref. Multiple refs on one line still need their own
              // keyword each ("Closes #1, closes #2"), matching the
              // `closingIssuesReferences` semantics — `Closes #1, #2`
              // links only #1, so the diff treats only #1 as removed.
              const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+#(\d+)/gi;
              const oldBody = context.payload.changes.body.from || '';
              const newBody = pr.body || '';
              const oldRefs = new Set([...oldBody.matchAll(re)].map((m) => Number(m[1])));
              const newRefs = new Set([...newBody.matchAll(re)].map((m) => Number(m[1])));
              const removed = [...oldRefs].filter((n) => !newRefs.has(n));
              core.info(`Body-diff: oldRefs=[${[...oldRefs].join(',')}] newRefs=[${[...newRefs].join(',')}] removed=[${removed.join(',')}]`);
              for (const issue_number of removed) {
                try {
                  await github.rest.issues.removeAssignees({
                    owner, repo, issue_number, assignees: [opener],
                  });
                  core.info(`Unassigned ${opener} from issue #${issue_number}`);
                } catch (e) {
                  core.warning(`removeAssignees on #${issue_number} failed: ${e.message}`);
                }
              }
            } else if (context.payload.action === 'edited') {
              core.info(`Body unchanged on edit; skipping removal-detection.`);
            }

      - name: Unassign PR opener from linked issues on PR close without merge
        # When a PR is closed without merging, the opener was added to
        # linked issues by the "Sync PR opener" step on open/edit. Mirror
        # the close: remove them so abandoned PRs do not leave stale
        # assignees. With no other assignee the issue then drops back into
        # the `is:open no:assignee` triage filter automatically. Cross-repo
        # refs are skipped, consistent with the assign side.
        if: >-
          github.event.action == 'closed'
          && github.event.pull_request.merged == false
          && github.event.pull_request.user.type != 'Bot'
        uses: actions/github-script@v8
        with:
          script: |
            const { owner, repo } = context.repo;
            const pr = context.payload.pull_request;
            const opener = pr.user.login;
            core.info(`PR #${pr.number} closed without merge; unassigning ${opener} from linked issues.`);

            const { repository: { pullRequest: prq } } = await github.graphql(`
              query($owner: String!, $repo: String!, $pr: Int!) {
                repository(owner: $owner, name: $repo) {
                  pullRequest(number: $pr) {
                    closingIssuesReferences(first: 50) {
                      nodes { number repository { nameWithOwner } }
                    }
                  }
                }
              }`, { owner, repo, pr: pr.number });

            const sameRepo = `${owner}/${repo}`;
            const linked = prq.closingIssuesReferences.nodes
              .filter((n) => n.repository.nameWithOwner === sameRepo)
              .map((n) => n.number);
            core.info(`Found ${linked.length} same-repo closing reference(s): ${linked.join(', ') || '(none)'}`);

            for (const issue_number of linked) {
              try {
                await github.rest.issues.removeAssignees({
                  owner, repo, issue_number, assignees: [opener],
                });
                core.info(`Unassigned ${opener} from issue #${issue_number}`);
              } catch (e) {
                core.warning(`removeAssignees on #${issue_number} failed: ${e.message}`);
              }
            }

      - name: Credit issue assignees on PR merge
        if: github.event.action == 'closed' && github.event.pull_request.merged
        uses: actions/github-script@v8
        with:
          script: |
            const { owner, repo } = context.repo;
            const pr = context.payload.pull_request;
            const opener = pr.user;
            const isHuman = (l) => l && !l.endsWith('[bot]');
            core.info(`PR #${pr.number} merged by opener=${opener.login}; computing credited authors.`);

            const { repository: { pullRequest: prq } } = await github.graphql(`
              query($owner: String!, $repo: String!, $pr: Int!) {
                repository(owner: $owner, name: $repo) {
                  pullRequest(number: $pr) {
                    closingIssuesReferences(first: 50) {
                      nodes {
                        number
                        repository { nameWithOwner }
                        assignees(first: 20) { nodes { login } }
                      }
                    }
                    commits(first: 250) {
                      nodes { commit {
                        parents { totalCount }
                        authors(first: 10) { nodes { user { login } } }
                      } }
                    }
                  }
                }
              }`, { owner, repo, pr: pr.number });

            const authors = new Set();
            if (opener.type !== 'Bot' && isHuman(opener.login)) authors.add(opener.login);
            for (const { commit } of prq.commits.nodes) {
              if (commit.parents.totalCount > 1) continue;
              for (const a of commit.authors.nodes) {
                if (isHuman(a.user?.login)) authors.add(a.user.login);
              }
            }
            const credited = [...authors].slice(0, 10);
            const creditedSet = new Set(credited);
            core.info(`Credited authors (max 10, [bot] filtered): [${credited.join(', ') || '(none)'}]`);
            if (!credited.length) {
              core.info(`No human authors to credit; skipping all linked issues.`);
              return;
            }

            const sameRepoIssues = prq.closingIssuesReferences.nodes.filter((n) => n.repository.nameWithOwner === `${owner}/${repo}`);
            core.info(`Linked same-repo issues to credit: [${sameRepoIssues.map((i) => `#${i.number}`).join(', ') || '(none)'}]`);

            for (const issue of sameRepoIssues) {
              const current = issue.assignees.nodes.map(n => n.login);
              const toRemove = current.filter(l => !creditedSet.has(l));
              const toAdd = credited.filter(l => !current.includes(l));
              const args = { owner, repo, issue_number: issue.number };
              core.info(`Issue #${issue.number}: current=[${current.join(',')}] credited=[${credited.join(',')}] toRemove=[${toRemove.join(',')}] toAdd=[${toAdd.join(',')}]`);
              try {
                if (toRemove.length) {
                  await github.rest.issues.removeAssignees({ ...args, assignees: toRemove });
                  core.info(`Removed [${toRemove.join(', ')}] from issue #${issue.number}`);
                }
                if (toAdd.length) {
                  await github.rest.issues.addAssignees({ ...args, assignees: toAdd });
                  core.info(`Added [${toAdd.join(', ')}] to issue #${issue.number}`);
                }
              } catch (e) {
                core.warning(`Updating assignees on #${issue.number} failed: ${e.message}`);
              }
            }
