name: Post Web Flasher Link Comment

on:
  workflow_run:
    workflows: [CI]
    types: [completed]

permissions:
  pull-requests: write
  actions: read

jobs:
  post-flasher-link:
    if: >
      github.event.workflow_run.event == 'pull_request' &&
      github.event.workflow_run.conclusion != 'cancelled' &&
      github.repository == 'meshtastic/firmware'
    continue-on-error: true
    runs-on: ubuntu-latest
    steps:
      # Per-board manifests carry the firmware's own metadata (activelySupported,
      # displayName, ...) generated from each target's custom_meshtastic_* config.
      - name: Download board manifests
        uses: actions/download-artifact@v8
        continue-on-error: true
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          run-id: ${{ github.event.workflow_run.id }}
          pattern: manifest-*
          path: ./manifests
          merge-multiple: true

      - name: Post or update web flasher link comment
        uses: actions/github-script@v8
        with:
          script: |
            const marker = '<!-- web-flasher-link -->';
            const run = context.payload.workflow_run;
            const { owner, repo } = context.repo;

            // Resolve the PR by matching the run's head SHA against the repo's open
            // PRs. workflow_run.pull_requests is empty for fork PRs, and
            // listPullRequestsAssociatedWithCommit won't return an open fork PR by
            // its head commit — but pulls.list includes fork PRs. Matching on head
            // SHA also enforces that the run is for the PR's current commit, so stale
            // re-runs of an outdated commit won't match.
            const openPrs = await github.paginate(github.rest.pulls.list, {
              owner, repo, state: 'open', per_page: 100,
            });
            const pr = openPrs.find((p) => p.head.sha === run.head_sha);
            if (!pr) {
              core.info(`No open pull request matches commit ${run.head_sha}; skipping.`);
              return;
            }
            const prNumber = pr.number;

            // Restrict to trusted authors. NOTE: author_association is computed for
            // the GITHUB_TOKEN, which cannot see *private/concealed* org memberships —
            // those members come back as CONTRIBUTOR, not MEMBER. So gating on MEMBER
            // alone silently excludes most maintainers. We allow the trusted set the
            // token can actually identify (members, collaborators, and anyone with a
            // previously merged PR). For strict members-only you'd need an org-read
            // App/PAT token to call orgs.checkMembershipForUser.
            const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
            if (!allowedAssociations.includes(pr.author_association)) {
              core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
              return;
            }

            // Require at least one per-arch firmware artifact from gather-artifacts
            const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
              owner, repo, run_id: run.id, per_page: 100,
            });
            const archRe = /^firmware-(esp32|esp32s3|esp32c3|esp32c6|nrf52840|rp2040|rp2350|stm32)-(\d+\.\d+\.\d+\.[0-9a-f]+)$/;
            const archArtifacts = artifacts.filter((a) => archRe.test(a.name) && !a.expired);
            if (archArtifacts.length === 0) {
              core.info('No per-arch firmware artifacts found; skipping.');
              return;
            }

            const version = archRe.exec(archArtifacts[0].name)[2];
            const expiresAt = archArtifacts[0].expires_at
              ? new Date(archArtifacts[0].expires_at).toISOString().slice(0, 10)
              : null;

            // Read each built board's manifest (.mt.json). activelySupported,
            // displayName and architecture come straight from the board's
            // custom_meshtastic_* platformio config, so the list is in sync with
            // the firmware itself — no external device database needed.
            const fs = require('fs');
            let boards = [];
            try {
              boards = fs.readdirSync('./manifests')
                .filter((f) => f.endsWith('.mt.json'))
                .map((f) => {
                  try { return JSON.parse(fs.readFileSync(`./manifests/${f}`, 'utf8')); }
                  catch { return null; }
                })
                .filter((m) => m && m.activelySupported === true && m.platformioTarget)
                .map((m) => ({
                  board: m.platformioTarget,
                  platform: m.architecture || '',
                  // displayName is maintainer-authored text; escape table-breaking pipes
                  displayName: String(m.displayName || m.platformioTarget).replace(/\|/g, '\\|'),
                  image: Array.isArray(m.images) && m.images[0] ? String(m.images[0]) : '',
                }))
                .sort((a, b) => a.board.localeCompare(b.board));
            } catch (e) {
              core.warning(`Could not read board manifests: ${e.message}`);
            }

            const flasherUrl = `https://flasher.meshtastic.org/?pr=${prNumber}`;
            // Device illustrations are served by the flasher from the same image
            // names the manifest declares (custom_meshtastic_images). The flasher
            // serves its SPA shell (HTML, 200) for unknown paths, so confirm each
            // image really resolves to an image before linking it.
            const imageBase = 'https://flasher.meshtastic.org/img/devices/';
            await Promise.all(boards.map(async (b) => {
              if (!b.image) return;
              try {
                const res = await fetch(`${imageBase}${encodeURIComponent(b.image)}`);
                const type = res.headers.get('content-type') || '';
                if (!res.ok || !type.startsWith('image/')) b.image = '';
              } catch { b.image = ''; }
            }));

            const boardLines = boards
              .map((b) => {
                const img = b.image ? `<img src="${imageBase}${encodeURIComponent(b.image)}" alt="" height="34">` : '';
                return `| ${img} | ${b.displayName} | [\`${b.board}\`](${flasherUrl}&device=${encodeURIComponent(b.board)}) | ${b.platform} |`;
              })
              .join('\n');

            // Shields.io badges. Only non-user-controlled, charset-constrained values
            // (version, commit sha, counts, dates) go into badge URLs — never board
            // names or the PR title — so the rendered comment cannot be spoofed.
            const shieldText = (s) =>
              encodeURIComponent(String(s).replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_'));
            const shield = (label, message, color) =>
              `https://img.shields.io/badge/${shieldText(label)}-${shieldText(message)}-${color}`;
            const buttonUrl =
              `https://img.shields.io/badge/${shieldText('Flash this PR in the Web Flasher')}-2C2D3C?style=for-the-badge`;
            const badges = [
              `![firmware](${shield('firmware', version, '67EA94')})`,
              `![commit](${shield('commit', run.head_sha.slice(0, 7), '2C2D3C')})`,
              `![boards](${shield('boards', boards.length, '5C6BC0')})`,
            ];
            if (expiresAt) badges.push(`![expires](${shield('expires', expiresAt, '9A4E00')})`);

            // Only render the board table when there are supported boards to list
            const boardTable = boards.length > 0 ? [
              `<details><summary>Supported boards built by this PR (${boards.length})</summary>`,
              '',
              '| | Device | Board | Platform |',
              '| --- | --- | --- | --- |',
              boardLines,
              '',
              '</details>',
              '',
            ] : [];

            const body = [
              marker,
              '## ⚡ Try this PR in the Web Flasher',
              '',
              `[![Flash this PR in the Web Flasher](${buttonUrl})](${flasherUrl})`,
              '',
              badges.join(' '),
              '',
              '> [!WARNING]',
              '> This is an automated, unreviewed CI test build. Back up your device configuration',
              '> before flashing, and only flash devices you are able to recover.',
              '',
              ...boardTable,
              `*Build artifacts expire${expiresAt ? ` on ${expiresAt}` : ' after 30 days'}. Updated for \`${run.head_sha.slice(0, 7)}\`.*`,
            ].join('\n');

            // Sticky comment: update in place when the marker is found
            const comments = await github.paginate(github.rest.issues.listComments, {
              owner, repo, issue_number: prNumber, per_page: 100,
            });
            const existing = comments.find((c) => c.body?.includes(marker));
            if (existing) {
              await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
            } else {
              await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
            }
