# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 NVIDIA Corporation. All rights reserved.
#
# Sync skills from product repos into the nvidia/skills catalog.
# Reads component definitions from components.d/*.yml (one file per
# component) — teams onboard by adding their own file there, not by
# editing this workflow or any shared file.
#
# Requires a PAT (SKILLS_SYNC_PAT) with read access to all product
# repos, stored as a repository secret.

name: Sync Skills from Product Repos

on:
  schedule:
    # Twice daily: 06:00 UTC and 18:00 UTC
    - cron: "0 6,18 * * *"
  workflow_dispatch: # Allow manual trigger

permissions:
  contents: write
  pull-requests: write
  issues: write

concurrency:
  group: sync-skills
  cancel-in-progress: true

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout skills catalog
        uses: actions/checkout@v4
        with:
          ref: main

      - name: Verify yq is available
        run: yq --version

      - name: Sync skills from product repos
        env:
          GH_TOKEN: ${{ secrets.SKILLS_SYNC_PAT }}
        run: |
          set -euo pipefail
          truncate -s 0 /tmp/rsynced-components.txt
          truncate -s 0 /tmp/changed-components.txt
          truncate -s 0 /tmp/failed-components.txt
          truncate -s 0 /tmp/sync-versions.txt

          # Aggregate per-component files into a single config so the
          # iteration loop below can index by position. The components.d/
          # layout means concurrent onboarding PRs never touch a shared
          # file — see components.d/README.md for the schema.
          CONFIG=/tmp/components.aggregated.yml
          yq ea '[.] | {"components": .}' components.d/*.yml > "$CONFIG"

          # Configure git to use the token via credential helper
          # so it never appears in clone URLs or error output
          git config --global credential.helper '!f() { echo "username=x-access-token"; echo "password=${GH_TOKEN}"; }; f'

          component_count=$(yq '.components | length' "$CONFIG")

          for i in $(seq 0 $((component_count - 1))); do
            name=$(yq -r ".components[$i].name" "$CONFIG")
            repo=$(yq -r ".components[$i].repo" "$CONFIG")
            ref=$(yq -r ".components[$i].ref // \"main\"" "$CONFIG")

            echo "── $name ──"

            # Clone with sparse checkout
            tmp_dir=".tmp/$(echo "$repo" | tr '/' '-')"
            if ! git clone --depth 1 --filter=blob:none --sparse \
                   "https://github.com/${repo}.git" \
                   "$tmp_dir" -b "$ref" 2>/dev/null; then
              echo "  ⚠ Clone failed for $repo — skipping"
              echo "- **$name** ($repo): clone failed" >> /tmp/failed-components.txt
              continue
            fi

            # Capture upstream HEAD info for the README Version column
            upstream_sha=$(git -C "$tmp_dir" rev-parse HEAD)
            upstream_short_sha=$(git -C "$tmp_dir" rev-parse --short HEAD)
            upstream_date=$(git -C "$tmp_dir" show -s --format=%cs HEAD)
            slug=$(echo "$name" | tr 'A-Z ' 'a-z-')
            echo "$slug|$upstream_short_sha|$upstream_sha|$upstream_date|$repo" >> /tmp/sync-versions.txt

            # Collect all skill paths for sparse checkout
            skill_count=$(yq -r ".components[$i].skills | length" "$CONFIG")
            sparse_paths=""
            for j in $(seq 0 $((skill_count - 1))); do
              path=$(yq -r ".components[$i].skills[$j].path" "$CONFIG")
              sparse_paths="$sparse_paths $path"
            done
            (cd "$tmp_dir" && git sparse-checkout set $sparse_paths 2>/dev/null) || true

            # Copy each skill path to its catalog directory
            synced=false
            for j in $(seq 0 $((skill_count - 1))); do
              path=$(yq -r ".components[$i].skills[$j].path" "$CONFIG")
              catalog_dir=$(yq -r ".components[$i].skills[$j].catalog_dir" "$CONFIG")
              src="$tmp_dir/$path"

              if [ -d "$src" ] && [ -n "$(ls -A "$src" 2>/dev/null)" ]; then
                mkdir -p "skills/$catalog_dir"
                rsync -a --delete "$src/" "skills/$catalog_dir/"
                synced=true
                echo "  ✓ $path → skills/$catalog_dir/"
              else
                echo "  ⚠ $path empty or missing — skipping to preserve existing catalog"
                echo "- **$name** ($repo): \`$path\` empty or missing" >> /tmp/failed-components.txt
              fi
            done

            # Record successful rsync (used by the "fail if nothing synced"
            # health check) separately from actual content changes (used
            # for the PR title and body) — a component can rsync cleanly
            # yet have no diff when upstream is unchanged.
            if $synced; then
              echo "- $name" >> /tmp/rsynced-components.txt
              for j in $(seq 0 $((skill_count - 1))); do
                catalog_dir=$(yq -r ".components[$i].skills[$j].catalog_dir" "$CONFIG")
                if [ -n "$(git status --porcelain "skills/$catalog_dir" 2>/dev/null)" ]; then
                  echo "- $name" >> /tmp/changed-components.txt
                  break
                fi
              done
            fi
            echo ""
          done

          rm -rf .tmp

      - name: Fail if nothing synced
        run: |
          if [ ! -s /tmp/rsynced-components.txt ]; then
            echo "::error::All component checkouts failed — no skills were synced"
            exit 1
          fi

      - name: Enforce skill compliance (signature + card)
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        run: |
          set -euo pipefail

          # Every catalog skill (defined by SKILL.md) must ship with:
          #   - skill.oms.sig  (detached NVIDIA signature)
          #   - skill-card.md  (skill metadata card)
          #   - eval data      (Tier-3 NV-ACES dataset). Accepted in any
          #                     of these forms — semantic equivalence,
          #                     not literal-filename:
          #                       * evals.json anywhere in the skill dir
          #                         (canonical — recursive find)
          #                       * any *.json under skills/<skill>/evals/
          #                       * any *.json under skills/<skill>/eval/
          #                     This accepts multi-profile datasets like
          #                     eval/h100.json + eval/nvidia_hosted.json
          #                     (RAG Blueprint pattern), benchmark/evals.json
          #                     (cuopt-developer pattern), and the
          #                     canonical evals/evals.json layout.
          # Skills missing any of these are dropped from the catalog
          # before the PR is created — non-compliant skills do not merge.
          dropped=/tmp/dropped-skills.txt
          truncate -s 0 "$dropped"

          while IFS= read -r skill_md; do
            dir=$(dirname "$skill_md")
            missing=""
            [ -f "$dir/skill.oms.sig" ] || missing="$missing skill.oms.sig"
            [ -f "$dir/skill-card.md" ] || missing="$missing skill-card.md"
            # Eval dataset: accept canonical evals.json, OR any *.json
            # under eval/ or evals/ subdir (multi-profile datasets).
            has_eval=""
            if [ -n "$(find "$dir" -type f -name evals.json -print -quit 2>/dev/null)" ]; then
              has_eval=1
            elif [ -d "$dir/evals" ] && \
                 [ -n "$(find "$dir/evals" -maxdepth 1 -type f -name '*.json' -print -quit 2>/dev/null)" ]; then
              has_eval=1
            elif [ -d "$dir/eval" ] && \
                 [ -n "$(find "$dir/eval" -maxdepth 1 -type f -name '*.json' -print -quit 2>/dev/null)" ]; then
              has_eval=1
            fi
            [ -n "$has_eval" ] || missing="$missing evals.json"
            if [ -n "$missing" ]; then
              product=$(echo "$dir" | awk -F/ '{print $2}')
              echo "$product|$dir|$(echo "$missing" | xargs)" >> "$dropped"
              echo "  ✗ Dropping $dir — missing:$missing"
              rm -rf "$dir"
            fi
          done < <(find skills -type f -name SKILL.md)

          # Drop top-level product directories with no SKILL.md anywhere
          # inside. These show up as misleading empty/helper-only folders
          # to anyone browsing the catalog (e.g. a product placeholder
          # with just a README, or a product whose every skill was just
          # dropped above).
          for product_dir in skills/*/; do
            product_dir="${product_dir%/}"
            if [ -z "$(find "$product_dir" -type f -name SKILL.md -print -quit 2>/dev/null)" ]; then
              product=$(basename "$product_dir")
              echo "$product|$product_dir|(orphan — no skills inside)" >> "$dropped"
              echo "  ✗ Dropping orphan $product_dir — no SKILL.md inside"
              rm -rf "$product_dir"
            fi
          done

          # Mark any product with a dropped skill as "changed" so the PR
          # opens even when the only diff is a removed non-compliant skill.
          if [ -s "$dropped" ]; then
            while IFS='|' read -r product _ _; do
              if ! grep -qx "- $product" /tmp/changed-components.txt 2>/dev/null; then
                echo "- $product" >> /tmp/changed-components.txt
              fi
            done < "$dropped"
          fi

      - name: Track dropped skills
        continue-on-error: true
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        run: |
          set -euo pipefail

          dropped=/tmp/dropped-skills.txt

          gh label create missing-compliance \
            --color FFA500 \
            --description "Catalog skills dropped from sync — missing skill.oms.sig, skill-card.md, and/or evals.json" \
            --force >/dev/null

          existing=$(gh issue list --label missing-compliance --state open \
                       --json number --jq '.[0].number // empty' --limit 1)

          if [ ! -s "$dropped" ]; then
            if [ -n "$existing" ]; then
              gh issue close "$existing" \
                --comment "All catalog skills now carry \`skill.oms.sig\`, \`skill-card.md\`, and \`evals.json\`. Closing automatically."
              echo "All compliant — closed tracking issue #$existing"
            else
              echo "All compliant — nothing to track"
            fi
            exit 0
          fi

          sort -t'|' -k1,1 -k2,2 -o "$dropped" "$dropped"
          count=$(wc -l < "$dropped" | tr -d ' ')

          body=/tmp/missing-compliance-body.md
          {
            echo "Tracking catalog skills that were **dropped from sync** because they are missing one or more required artifacts next to \`SKILL.md\`:"
            echo "- \`skill.oms.sig\` (NVIDIA detached signature)"
            echo "- \`skill-card.md\` (skill metadata card)"
            echo "- \`evals.json\` (eval definitions — anywhere within the skill directory, typically under \`benchmark/\`)"
            echo ""
            echo "**Compliance is enforced** — skills missing any of these are removed from the catalog before the sync PR is opened. To restore a dropped skill, ensure all three artifacts exist in the upstream repo."
            echo ""
            echo "**Last updated:** $(date -u +%Y-%m-%d) by [sync run #${{ github.run_id }}]($RUN_URL)"
            echo ""
            echo "## Dropped skills ($count)"
            current=""
            while IFS='|' read -r product dir missing; do
              if [ "$product" != "$current" ]; then
                echo ""
                echo "### \`$product\`"
                current="$product"
              fi
              echo "- \`$dir\` — missing: $missing"
            done < "$dropped"
            echo ""
            echo "---"
            echo "Owners: ensure your upstream pipeline produces \`skill.oms.sig\`, \`skill-card.md\`, and \`evals.json\` for each skill. This issue auto-updates each sync and closes when all skills are compliant."
          } > "$body"

          title="Skills dropped from sync — missing required artifacts ($count)"
          if [ -n "$existing" ]; then
            gh issue edit "$existing" --title "$title" --body-file "$body"
            echo "Updated tracking issue #$existing"
          else
            gh issue create --title "$title" --body-file "$body" --label missing-compliance
            echo "Opened new tracking issue"
          fi

      - name: Rebuild plugin catalog
        run: |
          set -euo pipefail
          # plugins/<name>/skills/ in copy mode duplicates content from
          # the canonical skills/ tree, so any sync that touched a
          # curated source path leaves those copies stale. Re-run the
          # plugin build here so the same sync PR ships a consistent
          # tree (skills/ + plugins/) and validate-plugins.yml --check
          # stays green. Symlink-mode plugins are a no-op for the build.
          # build-plugins.py soft-fails missing/dropped curated skills
          # so a rename or compliance drop does NOT block the sync PR.
          .github/scripts/build-plugins.sh

          # Mark the plugin catalog as a changed component if anything
          # under plugins/ or either marketplace.json moved, so the PR
          # summary surfaces it and the "has_changes" gate fires even
          # when the only diff is the auto-rebuild.
          if ! git diff --quiet plugins/ \
              .claude-plugin/marketplace.json \
              .agents/plugins/marketplace.json; then
            if ! grep -qx "- plugin catalog" /tmp/changed-components.txt 2>/dev/null; then
              echo "- plugin catalog" >> /tmp/changed-components.txt
            fi
          fi

      - name: Regenerate README tables
        run: .github/scripts/regenerate-readme.sh

      - name: Build sync summary
        id: summary
        run: |
          # Surface failure count regardless of whether anything changed —
          # a quiet sync can still have a misconfigured component to flag.
          if [ -s /tmp/failed-components.txt ]; then
            echo "has_failures=true" >> "$GITHUB_OUTPUT"
          else
            echo "has_failures=false" >> "$GITHUB_OUTPUT"
          fi

          if [ ! -s /tmp/changed-components.txt ]; then
            echo "has_changes=false" >> "$GITHUB_OUTPUT"
            echo "::notice::All components synced; no upstream content changes detected"
            exit 0
          fi
          echo "has_changes=true" >> "$GITHUB_OUTPUT"

          # Dedupe before composing the title — the enforce step may have
          # re-added entries already present from the rsync loop.
          sort -u -o /tmp/changed-components.txt /tmp/changed-components.txt
          changed=$(cat /tmp/changed-components.txt | tr '\n' ',' | sed 's/- //g;s/,$//')
          echo "title=chore: sync skills ($changed)" >> "$GITHUB_OUTPUT"
          {
            echo 'body<<EOFBODY'
            echo "## Automated skills sync"
            echo ""
            echo "**Components updated:**"
            cat /tmp/changed-components.txt
            if [ -s /tmp/dropped-skills.txt ]; then
              echo ""
              echo "**Skills dropped (missing \`skill.oms.sig\`, \`skill-card.md\`, and/or \`evals.json\`):**"
              while IFS='|' read -r _ dir missing; do
                echo "- \`$dir\` — missing: $missing"
              done < /tmp/dropped-skills.txt
            fi
            if [ -s /tmp/failed-components.txt ]; then
              echo ""
              echo "**Components that failed to sync:**"
              cat /tmp/failed-components.txt
            fi
            echo ""
            echo "Triggered by: \`${{ github.event_name }}\`"
            echo 'EOFBODY'
          } >> "$GITHUB_OUTPUT"

      - name: Create pull request
        if: steps.summary.outputs.has_changes == 'true'
        uses: peter-evans/create-pull-request@v6
        with:
          commit-message: ${{ steps.summary.outputs.title }}
          branch: automated/sync-skills
          title: ${{ steps.summary.outputs.title }}
          body: ${{ steps.summary.outputs.body }}
          delete-branch: true

      - name: Update sync-failure tracking issue (rolling)
        if: always()
        continue-on-error: true
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const hasFailures = '${{ steps.summary.outputs.has_failures }}' === 'true';
            const jobFailed = '${{ job.status }}' === 'failure';
            const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;

            // Find the existing rolling tracker (if any).
            const { data: openIssues } = await github.rest.issues.listForRepo({
              owner: context.repo.owner,
              repo: context.repo.repo,
              state: 'open',
              labels: 'sync-failure',
            });
            const tracker = openIssues[0];

            if (!hasFailures && !jobFailed) {
              // Clean run — close the rolling tracker if open, then exit.
              if (tracker) {
                await github.rest.issues.createComment({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: tracker.number,
                  body: `Sync run [#${context.runId}](${runUrl}) completed cleanly — closing the rolling sync-failure tracker. A new tracker opens automatically if a future sync fails.`,
                });
                await github.rest.issues.update({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: tracker.number,
                  state: 'closed',
                });
                console.log(`Clean run — closed tracker #${tracker.number}`);
              } else {
                console.log('Clean run — no tracker to close');
              }
              return;
            }

            // There IS a failure to report. Build a single rolling body that
            // reflects the current state, not a historical run log.
            let title, bodyLines = [];
            const today = new Date().toISOString().slice(0, 10);

            if (jobFailed) {
              title = `Sync failure tracker — sync workflow run failed (${today})`;
              bodyLines.push(
                `The most recent sync workflow run failed entirely.`,
                ``,
                `**Most recent run:** [#${context.runId}](${runUrl})`,
                `**Trigger:** \`${context.eventName}\``,
                `**Last updated:** ${today}`,
                ``,
                `This issue auto-updates on every sync run and closes when sync runs cleanly again.`,
              );
            } else {
              // Partial failure — read the failed components list.
              let failed = '';
              try {
                failed = fs.readFileSync('/tmp/failed-components.txt', 'utf8').trim();
              } catch (e) {
                failed = '(failed-components.txt not available)';
              }
              const count = failed.split('\n').filter(l => l.trim()).length;
              title = `Sync failure tracker — ${count} component(s) currently failing (${today})`;
              bodyLines.push(
                `Components that failed to sync on the most recent run. This issue auto-updates each sync and closes when all components are syncing cleanly.`,
                ``,
                `**Currently failing components (${count}):**`,
                failed,
                ``,
                `**Most recent run:** [#${context.runId}](${runUrl})`,
                `**Trigger:** \`${context.eventName}\``,
                `**Last updated:** ${today}`,
                ``,
                `Failures usually mean the skill path in \`components.d/<slug>.yml\` doesn't match the actual directory in the source repo (e.g. symlink, renamed, removed, or moved branch).`,
              );
            }
            const body = bodyLines.join('\n');

            if (tracker) {
              await github.rest.issues.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: tracker.number,
                title,
                body,
              });
              console.log(`Updated rolling tracker #${tracker.number}`);
            } else {
              await github.rest.issues.create({
                owner: context.repo.owner,
                repo: context.repo.repo,
                title,
                body,
                labels: ['sync-failure'],
              });
              console.log('Opened new rolling tracker');
            }
