# Mirrors an issue's triage state onto the "Auto-Triage" Projects v2 board.
#
# Reactive and decoupled from the triage bot: it watches label changes and,
# whenever a `bot:repro` (trigger) or `triage/*` (state) label is added or
# removed, recomputes the issue's canonical triage state from its CURRENT
# labels and sets the board's "Triage State" single-select field. The triage
# workflows just move labels as they always have; this never writes back to
# them.
#
# It also keeps board membership in sync with the issue's open/closed state:
# closing an issue ARCHIVES its card (it leaves the active board views but
# stays recoverable in the project's archive, since triage is done once the
# issue is closed); reopening unarchives and re-syncs it if it still carries
# triage labels.
#
# Auth: mints an emdashbot App installation token (same APP_ID / APP_PRIVATE_KEY
# secrets the other bot workflows use). The App needs organization "Projects:
# Read and write" for the project mutations.

name: Triage project sync

on:
  issues:
    types: [labeled, unlabeled, closed, reopened]
  workflow_dispatch: # one-shot backfill of issues already carrying triage labels

concurrency:
  # Serialize per issue so rapid reproducing -> reproduced transitions don't race.
  group: triage-project-sync-${{ github.event.issue.number || github.run_id }}
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  sync:
    runs-on: ubuntu-latest
    # Skip PRs (issues.labeled fires for PRs too); always run the manual backfill.
    if: github.event_name == 'workflow_dispatch' || github.event.issue.pull_request == null
    steps:
      - name: Generate app token
        id: app-token
        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
        with:
          app-id: ${{ secrets.APP_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}
          # owner + repositories: org-scoped for Projects, but limited to this
          # one repo. permission-*: least privilege, just what the sync needs
          # (org Projects write; issues read for the workflow_dispatch backfill).
          owner: ${{ github.repository_owner }}
          repositories: ${{ github.event.repository.name }}
          permission-organization-projects: write
          permission-issues: read

      - name: Sync triage state to project
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        with:
          github-token: ${{ steps.app-token.outputs.token }}
          script: |
            const PROJECT_NUMBER = 3;
            const FIELD_NAME = "Triage State";
            const owner = context.repo.owner;
            const repo = context.repo.repo;

            // Triage label -> board option name.
            const STATE_BY_LABEL = {
              "bot:repro": "Queued",
              "triage/reproducing": "Reproducing",
              "triage/reproduced": "Reproduced",
              "triage/by-design": "By design",
              "triage/awaiting-reporter": "Awaiting reporter",
              "triage/verified": "Verified",
              "triage/not-reproduced": "Not reproduced",
              "triage/failed": "Failed",
              "triage/skipped": "Skipped",
            };
            // When several are present, the most advanced/terminal one wins.
            const PRECEDENCE = [
              "triage/verified",
              "triage/awaiting-reporter",
              "triage/reproduced",
              "triage/by-design",
              "triage/not-reproduced",
              "triage/failed",
              "triage/skipped",
              "triage/reproducing",
              "bot:repro",
            ];
            const pickState = (labels) => {
              for (const l of PRECEDENCE) if (labels.includes(l)) return STATE_BY_LABEL[l];
              return null;
            };
            const labelNames = (iss) =>
              (iss.labels || []).map((l) => (typeof l === "string" ? l : l.name));

            // Resolve the project, the single-select field, and its options.
            const projData = await github.graphql(
              `query($owner:String!, $num:Int!, $field:String!) {
                organization(login:$owner) {
                  projectV2(number:$num) {
                    id
                    field(name:$field) {
                      ... on ProjectV2SingleSelectField { id options { id name } }
                    }
                  }
                }
              }`,
              { owner, num: PROJECT_NUMBER, field: FIELD_NAME },
            );
            const project = projData.organization.projectV2;
            if (!project || !project.field) {
              core.setFailed(`Project #${PROJECT_NUMBER} or field "${FIELD_NAME}" not found`);
              return;
            }
            const fieldId = project.field.id;
            const optionId = (name) => project.field.options.find((o) => o.name === name)?.id;

            // Archive an issue's card on THIS board, if present. An issue can
            // belong to several projects; match on the project NODE ID, not the
            // number -- Projects v2 numbers are per-owner, so a foreign project
            // could also be #3 and a number match would target the wrong item.
            // Archiving is reversible (unlike delete) and hides the card from
            // active board views. Idempotent -- returns false when the issue
            // has no card here, and re-archiving an archived item is a no-op.
            const archiveCard = async (number) => {
              const data = await github.graphql(
                `query($owner:String!, $repo:String!, $number:Int!) {
                  repository(owner:$owner, name:$repo) {
                    issue(number:$number) {
                      projectItems(first:100) { nodes { id project { id } } }
                    }
                  }
                }`,
                { owner, repo, number },
              );
              const nodes = data.repository?.issue?.projectItems?.nodes || [];
              const item = nodes.find((n) => n.project?.id === project.id);
              if (!item) {
                // first:100 isn't paginated; warn if we hit the cap so a silent
                // miss on an issue attached to 100+ projects is at least visible.
                if (nodes.length === 100) {
                  core.warning(`#${number}: on 100+ projects; could not confirm board membership`);
                }
                return false;
              }
              await github.graphql(
                `mutation($project:ID!, $item:ID!) {
                  archiveProjectV2Item(input:{ projectId:$project, itemId:$item }) { item { id } }
                }`,
                { project: project.id, item: item.id },
              );
              return true;
            };

            // Add the issue to the board (idempotent) and set its Triage State.
            const upsertState = async (iss) => {
              const stateName = pickState(labelNames(iss));
              if (!stateName) {
                core.info(`#${iss.number}: no triage label; skipping`);
                return;
              }
              const optId = optionId(stateName);
              if (!optId) {
                core.warning(`#${iss.number}: no board option named "${stateName}"`);
                return;
              }
              // Idempotent: returns the existing item if the issue is already added.
              const added = await github.graphql(
                `mutation($project:ID!, $content:ID!) {
                  addProjectV2ItemById(input:{projectId:$project, contentId:$content}) { item { id } }
                }`,
                { project: project.id, content: iss.node_id },
              );
              const itemId = added.addProjectV2ItemById.item.id;
              // addProjectV2ItemById returns an EXISTING item as-is -- including
              // when it was archived on close -- so unarchive to bring a
              // reopened issue's card back onto the active board. No-op for
              // items that are already active.
              await github.graphql(
                `mutation($project:ID!, $item:ID!) {
                  unarchiveProjectV2Item(input:{ projectId:$project, itemId:$item }) { item { id } }
                }`,
                { project: project.id, item: itemId },
              );
              await github.graphql(
                `mutation($project:ID!, $item:ID!, $field:ID!, $opt:String!) {
                  updateProjectV2ItemFieldValue(input:{
                    projectId:$project, itemId:$item, fieldId:$field,
                    value:{ singleSelectOptionId:$opt }
                  }) { projectV2Item { id } }
                }`,
                { project: project.id, item: itemId, field: fieldId, opt: optId },
              );
              core.info(`#${iss.number} -> ${stateName}`);
            };

            // Build the work list.
            let issues = [];
            if (context.eventName === "workflow_dispatch") {
              const all = await github.paginate(github.rest.issues.listForRepo, {
                owner,
                repo,
                state: "all",
                per_page: 100,
              });
              issues = all.filter(
                (iss) =>
                  !iss.pull_request &&
                  labelNames(iss).some((l) => l === "bot:repro" || l.startsWith("triage/")),
              );
            } else {
              const iss = context.payload.issue;
              if (!iss || iss.pull_request) return;
              issues = [iss];
            }

            // Per-issue try/catch so the bulk backfill reconciles every issue
            // even if one fails (e.g. a transient GraphQL error). Single-issue
            // event runs still fail loudly via setFailed below, since the next
            // event would otherwise be the only chance to recover.
            let failures = 0;
            for (const iss of issues) {
              try {
                // Closed issues never belong on the board: triage is finished
                // once the issue is closed (however it was closed). Handling it
                // here -- rather than only on the `closed` event -- means label
                // changes on an already-closed issue, and the dispatch backfill,
                // also reconcile, so the board self-heals to "open triaged
                // issues only". Reopening hits the else-branch with state "open"
                // and is re-added below if it still carries triage labels.
                if ((iss.state || "").toLowerCase() === "closed") {
                  const archived = await archiveCard(iss.number);
                  core.info(`#${iss.number}: closed; ${archived ? "archived" : "not on board"}`);
                  continue;
                }
                await upsertState(iss);
              } catch (e) {
                failures++;
                core.warning(`#${iss.number}: sync failed: ${e.message}`);
              }
            }
            if (failures > 0 && context.eventName !== "workflow_dispatch") {
              core.setFailed(`${failures} issue(s) failed to sync`);
            }
