name: Dependabot Weekly Summary

on:
  schedule:
    - cron: "0 8 * * 1" # Mon 08:00 UTC
  workflow_dispatch:

# Single-purpose monitoring workflow; serialise on workflow name only - we never
# want two concurrent summary runs racing to post the same digest.
concurrency:
  group: ${{ github.workflow }}
  cancel-in-progress: false

permissions:
  contents: read # gh CLI baseline
  pull-requests: read # gh pr list (open dependabot PRs)
  actions: read # gh run list / view (parse latest dependabot run logs)

jobs:
  summary:
    name: Post weekly Dependabot summary
    # Set the ENABLE_DEPENDABOT_ALERTS repository variable to 'false' to turn off
    # the Dependabot alert/summary notifiers — e.g. forks/mirrors that lack the
    # DEPENDABOT_ALERTS_TOKEN / SLACK_BOT_TOKEN secrets. Defaults to enabled.
    if: ${{ vars.ENABLE_DEPENDABOT_ALERTS != 'false' }}
    runs-on: warp-ubuntu-latest-x64-2x
    environment: dependabot-summary
    env:
      # Severities surface in the actions list when their remaining TTR drops
      # below this many days. Override via repo/env var ACTION_THRESHOLD_DAYS.
      THRESHOLD_DAYS: ${{ vars.ACTION_THRESHOLD_DAYS || '7' }}
    steps:
      - name: Fetch alerts and compute summaries
        id: alerts
        env:
          GH_TOKEN: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}
          REPO: ${{ github.repository }}
        run: |
          if ! gh api -X GET "/repos/$REPO/dependabot/alerts" --paginate > pages.json 2> err.txt; then
            echo "total=?" >> "$GITHUB_OUTPUT"
            ERR=$(head -c 200 err.txt | tr '\n' ' ')
            echo "by_severity=:x: _failed to fetch alerts: ${ERR}_" >> "$GITHUB_OUTPUT"
            echo "actions=:x: _alerts unavailable_" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          jq -s '[.[][] | select(.state == "open")]' pages.json > open.json

          TOTAL=$(jq 'length' open.json)
          echo "total=$TOTAL" >> "$GITHUB_OUTPUT"

          if [ "$TOTAL" = "0" ]; then
            echo "by_severity=:white_check_mark: No open alerts." >> "$GITHUB_OUTPUT"
            echo "actions=_None_" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          # Severity breakdown - real newlines so jq --arg in the payload
          # builder encodes them as proper \n in JSON (Slack renders as breaks).
          BY_SEV=$(jq -r '
            group_by(.security_advisory.severity)
            | map({sev: .[0].security_advisory.severity,
                   count: length,
                   weight: ({"critical":0,"high":1,"medium":2,"low":3}[.[0].security_advisory.severity])})
            | sort_by(.weight)
            | map("• *\(.count)* \(.sev)")
            | join("\n")
          ' open.json)
          {
            echo "by_severity<<EOF"
            echo "$BY_SEV"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

          # Actions: alerts within THRESHOLD_DAYS of their TTR (P0=7d, P1=30d, P2=90d, P3=no deadline)
          # Grouped by (package, severity); shows earliest deadline per group.
          ACTIONS=$(jq -r --argjson threshold "$THRESHOLD_DAYS" '
            [.[]
              | (.security_advisory.severity) as $sev
              | ({"critical":7,"high":30,"medium":90,"low":null}[$sev]) as $ttr
              | select($ttr != null)
              | ((now - (.created_at | fromdateiso8601)) / 86400 | floor) as $age
              | {pkg: .dependency.package.name, sev: $sev, remaining: ($ttr - $age)}
            ]
            | group_by([.pkg, .sev])
            | map({pkg: .[0].pkg, sev: .[0].sev, count: length, min_remaining: ([.[].remaining] | min)})
            | map(select(.min_remaining < $threshold))
            | sort_by(.min_remaining)
            | if length == 0 then "_None_"
              else (map(
                "• *\(.pkg)* (\(.sev))" +
                (if .count > 1 then " ×\(.count)" else "" end) + " - " +
                (if .min_remaining < 0 then "*OVERDUE* by \(-.min_remaining)d"
                 else "\(.min_remaining)d remaining" end)
              ) | join("\n"))
              end
          ' open.json)
          {
            echo "actions<<EOF"
            echo "$ACTIONS"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Fetch open dependabot PRs
        id: prs
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPO: ${{ github.repository }}
          REPO_URL: https://github.com/${{ github.repository }}
        run: |
          if ! PR_JSON=$(gh pr list --repo "$REPO" --state open --author "app/dependabot" --json number,title 2> err.txt); then
            ERR=$(head -c 200 err.txt | tr '\n' ' ')
            echo "list=:x: _failed to fetch PRs: ${ERR}_" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          LIST=$(echo "$PR_JSON" | jq -r --arg url "$REPO_URL" '
            if length == 0 then "_None_"
            else (map("• <\($url)/pull/\(.number)|#\(.number)> \(.title)") | join("\n"))
            end
          ')
          {
            echo "list<<EOF"
            echo "$LIST"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Find latest npm dependabot run
        id: latest
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPO: ${{ github.repository }}
        run: |
          # Repos without a dependabot.yml have no "Dependabot Updates" workflow;
          # treat the lookup failure as "no recent run found" rather than failing.
          if ! RUN_ID=$(gh run list --repo "$REPO" --workflow "Dependabot Updates" --status success --limit 30 --json databaseId,name --jq 'first(.[] | select(.name | startswith("npm_and_yarn")) | .databaseId) // empty' 2>/dev/null); then
            RUN_ID=""
          fi
          echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"

      - name: Extract stuck deps (only if actions pending)
        id: stuck
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPO: ${{ github.repository }}
          RUN_ID: ${{ steps.latest.outputs.run_id }}
          ACTIONS: ${{ steps.alerts.outputs.actions }}
        run: |
          # Skip the stuck section entirely when nothing in the actions list
          # - keeps the digest tidy when there's nothing to actually act on.
          if [ "$ACTIONS" = "_None_" ]; then
            echo "section=" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          HEADER=$'\n\n*Couldn\'t auto-fix (need manual `pnpm.overrides`):*\n'
          if [ -z "$RUN_ID" ]; then
            {
              echo "section<<EOF"
              echo "${HEADER}_(no recent npm run found)_"
              echo "EOF"
            } >> "$GITHUB_OUTPUT"
            exit 0
          fi
          gh run view "$RUN_ID" --repo "$REPO" --log > log.txt 2>&1 || true
          STUCK=$(grep -oE "No update possible for [^[:space:]]+ [0-9][^[:space:]]*" log.txt | sed 's/No update possible for //' | sort -u || true)
          if [ -z "$STUCK" ]; then
            {
              echo "section<<EOF"
              echo "${HEADER}_None_"
              echo "EOF"
            } >> "$GITHUB_OUTPUT"
            exit 0
          fi
          LIST=$(echo "$STUCK" | awk 'NR>1{printf "\n"} {printf "• *%s* %s", $1, $2}')
          {
            echo "section<<EOF"
            echo "${HEADER}${LIST}"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Build Slack payload
        env:
          REPO: ${{ github.repository }}
          CHANNEL: ${{ vars.SLACK_CHANNEL_ID }}
          TOTAL: ${{ steps.alerts.outputs.total }}
          BY_SEVERITY: ${{ steps.alerts.outputs.by_severity }}
          PRS_LIST: ${{ steps.prs.outputs.list }}
          ACTIONS: ${{ steps.alerts.outputs.actions }}
          STUCK: ${{ steps.stuck.outputs.section }}
        run: |
          # Build payload via jq so PR titles or error strings containing
          # quotes/backslashes/newlines can't break the JSON.
          jq -n \
            --arg channel "$CHANNEL" \
            --arg repo "$REPO" \
            --arg total "$TOTAL" \
            --arg by_severity "$BY_SEVERITY" \
            --arg prs_list "$PRS_LIST" \
            --arg actions "$ACTIONS" \
            --arg stuck "$STUCK" \
            --arg threshold "$THRESHOLD_DAYS" \
            '{
              channel: $channel,
              text: ":calendar: *Weekly Dependabot summary* - `\($repo)`\n\n*Open alerts (\($total)):*\n\($by_severity)\n\n*Open Dependabot PRs:*\n\($prs_list)\n\n*Actions needed (<\($threshold)d remaining):*\n\($actions)\($stuck)\n\n<https://github.com/\($repo)/security/dependabot|Dependabot alerts>"
            }' > payload.json

      - name: Post Slack summary
        uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
        with:
          method: chat.postMessage
          token: ${{ secrets.SLACK_BOT_TOKEN }}
          payload-file-path: payload.json
