# VibeView: run your tagged VibeView test groups against every pull request's build.
#
# Save this file as .github/workflows/vibeview.yml. Docs, and the latest version
# of this file: https://vibeview.io/docs/ci-testing
#
# One-time setup
#   1. In VibeView, tag the groups this workflow should run (for example "smoke").
#      Each platform job runs the groups with that tag for its own platform.
#      (The CLI calls a group a suite, so the command is run-suites. --mode hybrid is Smart in the dashboard.)
#   2. Create an API token for this repository only (VibeView > Settings >
#      API token, role Developer, access CI only, with an expiry) and save it
#      as the repository secret
#      VIBEVIEW_API_TOKEN (Settings > Secrets and variables > Actions).
#   3. Add the repository variable VIBEVIEW_TAG with that tag (same page, the
#      Variables tab).
#   4. iOS is built by VibeView cloud builds from the "ios" build block
#      in your vibeview.json. Android is built in this workflow: replace the
#      "Build" step of the build-android job with your own build.
#   5. Delete the jobs of a platform you don't ship, and remove them from the
#      report job's "needs".
#
# Pull requests from forks and from Dependabot are skipped: GitHub gives them
# no secrets. Never switch this workflow to pull_request_target.

name: VibeView

on:
  pull_request:
    paths-ignore:
      - '**/*.md'
      - 'docs/**'
  workflow_dispatch:

permissions:
  contents: read

defaults:
  run:
    # bash -eo pipefail: a failing command fails the step, also inside a pipeline.
    shell: bash

concurrency:
  group: vibeview-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

env:
  # The VibeView CLI version. Pinned: bump it deliberately.
  VIBEVIEW_CLI_VERSION: 0.28.1

jobs:
  ios:
    name: VibeView (iOS)
    if: >-
      github.event_name != 'pull_request' ||
      (github.event.pull_request.head.repo.full_name == github.repository &&
       github.event.pull_request.user.login != 'dependabot[bot]')
    runs-on: ubuntu-latest
    # Above the cloud build plus the CLI's own wait, so the CLI gives up first
    # and cancels the run on VibeView.
    timeout-minutes: 150
    env:
      VV_TAG: ${{ vars.VIBEVIEW_TAG }}
      HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
      PR_NUMBER: ${{ github.event.pull_request.number }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: 22

      - name: Install the VibeView CLI
        run: npm install --global --no-audit --no-fund "vibeview@${VIBEVIEW_CLI_VERSION}"

      - name: Check the setup
        env:
          VIBEVIEW_API_TOKEN: ${{ secrets.VIBEVIEW_API_TOKEN }}
        run: |
          if [ -z "${VIBEVIEW_API_TOKEN}" ]; then
            echo "::error::The repository secret VIBEVIEW_API_TOKEN is not set."
            exit 1
          fi
          if [ -z "${VV_TAG}" ]; then
            echo "::error::The repository variable VIBEVIEW_TAG is not set."
            exit 1
          fi

      # Builds the "ios" block of vibeview.json on VibeView and adds the result
      # to your app's build history. For React Native, that build must embed
      # the JS bundle (a Release build): a Debug build needs Metro.
      - name: Build in the cloud
        id: build
        env:
          VIBEVIEW_API_TOKEN: ${{ secrets.VIBEVIEW_API_TOKEN }}
        run: |
          out="$RUNNER_TEMP/vibeview"
          mkdir -p "$out"
          status=0
          # The full build log is long: it is collapsed in the job log.
          echo "::group::Cloud build log"
          vibeview build --cloud --platform ios --json \
            | tee "$out/build.ndjson" \
            | jq --unbuffered -rj 'select(.event == "build_log") | .content' \
            || status=$?
          echo "::endgroup::"
          build_id=$(jq -rs '[.[] | select(.event == "build_finished" and .status == "succeeded")]
                             | last | .app_build_id // empty' "$out/build.ndjson")
          if [ "$status" -ne 0 ] || [ -z "$build_id" ]; then
            echo "The last 100 lines of the build log:"
            jq -rj 'select(.event == "build_log") | .content' "$out/build.ndjson" | tail -n 100
            jq -r 'select(.event == "error") | "error: \(.message)"' "$out/build.ndjson"
            echo "::error::The iOS cloud build failed. See the build log above (the full log is in the \"Cloud build log\" group)."
            exit 1
          fi
          echo "build_id=$build_id" >> "$GITHUB_OUTPUT"

      # A cancelled or timed-out job doesn't stop a cloud build by itself.
      - name: Cancel the cloud build
        if: cancelled()
        env:
          VIBEVIEW_API_TOKEN: ${{ secrets.VIBEVIEW_API_TOKEN }}
        run: |
          log="$RUNNER_TEMP/vibeview/build.ndjson"
          [ -s "$log" ] || exit 0
          for id in $(jq -r 'select(.event == "build_created") | .id' "$log"); do
            if ! jq -e --arg id "$id" 'select(.event == "build_finished" and .id == $id)' "$log" > /dev/null; then
              vibeview builds cancel "$id" || true
            fi
          done

      # exec: the CLI must be this step's own process, so a cancelled or
      # timed-out job stops the CLI and the CLI cancels the run on VibeView.
      # Exit code: 0 passed, 1 a test failed, 2 the run could not complete.
      - name: Run the tagged groups
        env:
          VIBEVIEW_API_TOKEN: ${{ secrets.VIBEVIEW_API_TOKEN }}
          BUILD_ID: ${{ steps.build.outputs.build_id }}
        run: |
          out="$RUNNER_TEMP/vibeview"
          jq -n --arg repo "$GITHUB_REPOSITORY" --arg sha "$HEAD_SHA" --arg pr "$PR_NUMBER" \
            --arg run "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
            '{github: {repository: $repo, sha: $sha, pull_request: $pr, workflow_run: $run}}' \
            > "$out/metadata.json"
          exec vibeview run-suites --tag "$VV_TAG" --platform ios --build "$BUILD_ID" \
            --mode hybrid --commit-sha "$HEAD_SHA" --metadata-file "$out/metadata.json" \
            --timeout 1800 --output json > "$out/result-$GITHUB_JOB.json"

      - name: Keep the result for the report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: vibeview-result-${{ github.job }}
          path: ${{ runner.temp }}/vibeview/result-${{ github.job }}.json
          if-no-files-found: ignore
          retention-days: 7

  # No VibeView secret in this job: the pull request's build scripts never see it.
  build-android:
    name: Build Android
    if: >-
      github.event_name != 'pull_request' ||
      (github.event.pull_request.head.repo.full_name == github.repository &&
       github.event.pull_request.user.login != 'dependabot[bot]')
    runs-on: ubuntu-latest
    timeout-minutes: 45
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: 22

      - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
        with:
          distribution: temurin
          java-version: 17

      # Replace with your own build (this one is a React Native project). The APK
      # must include the x86_64 ABI, and must run without a dev server: for
      # React Native, a release build embeds the JS bundle.
      - name: Build
        run: |
          npm ci
          cd android
          ./gradlew assembleRelease -PreactNativeArchitectures=x86_64,arm64-v8a
          cp app/build/outputs/apk/release/app-release.apk "$RUNNER_TEMP/app-android.apk"

      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: app-android
          path: ${{ runner.temp }}/app-android.apk
          retention-days: 3

  android:
    name: VibeView (Android)
    needs: build-android
    runs-on: ubuntu-latest
    # Above the CLI's own wait, so the CLI gives up first and cancels the run.
    timeout-minutes: 130
    env:
      VV_TAG: ${{ vars.VIBEVIEW_TAG }}
      HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
      PR_NUMBER: ${{ github.event.pull_request.number }}
    steps:
      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: 22

      - name: Install the VibeView CLI
        run: npm install --global --no-audit --no-fund "vibeview@${VIBEVIEW_CLI_VERSION}"

      - name: Check the setup
        env:
          VIBEVIEW_API_TOKEN: ${{ secrets.VIBEVIEW_API_TOKEN }}
        run: |
          if [ -z "${VIBEVIEW_API_TOKEN}" ]; then
            echo "::error::The repository secret VIBEVIEW_API_TOKEN is not set."
            exit 1
          fi
          if [ -z "${VV_TAG}" ]; then
            echo "::error::The repository variable VIBEVIEW_TAG is not set."
            exit 1
          fi

      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: app-android
          path: ${{ runner.temp }}/app

      - name: Upload the build
        id: build
        env:
          VIBEVIEW_API_TOKEN: ${{ secrets.VIBEVIEW_API_TOKEN }}
        run: |
          out="$RUNNER_TEMP/vibeview"
          mkdir -p "$out"
          note="$GITHUB_REPOSITORY ${PR_NUMBER:+PR #$PR_NUMBER }${HEAD_SHA:0:7}"
          vibeview upload-app "$RUNNER_TEMP/app/app-android.apk" --note "$note" \
            --output json > "$out/upload.json"
          build_id=$(jq -er '.build_id' "$out/upload.json")
          echo "build_id=$build_id" >> "$GITHUB_OUTPUT"

      # exec: see the iOS job.
      - name: Run the tagged groups
        env:
          VIBEVIEW_API_TOKEN: ${{ secrets.VIBEVIEW_API_TOKEN }}
          BUILD_ID: ${{ steps.build.outputs.build_id }}
        run: |
          out="$RUNNER_TEMP/vibeview"
          jq -n --arg repo "$GITHUB_REPOSITORY" --arg sha "$HEAD_SHA" --arg pr "$PR_NUMBER" \
            --arg run "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
            '{github: {repository: $repo, sha: $sha, pull_request: $pr, workflow_run: $run}}' \
            > "$out/metadata.json"
          exec vibeview run-suites --tag "$VV_TAG" --platform android --build "$BUILD_ID" \
            --mode hybrid --commit-sha "$HEAD_SHA" --metadata-file "$out/metadata.json" \
            --timeout 1800 --output json > "$out/result-$GITHUB_JOB.json"

      - name: Keep the result for the report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: vibeview-result-${{ github.job }}
          path: ${{ runner.temp }}/vibeview/result-${{ github.job }}.json
          if-no-files-found: ignore
          retention-days: 7

  # Writes the job summary and one comment per platform on the pull request.
  # The only job that can write to the pull request, and it has no VibeView secret.
  report:
    name: VibeView report
    needs: [ios, android]
    if: >-
      !cancelled() &&
      (github.event_name != 'pull_request' ||
       (github.event.pull_request.head.repo.full_name == github.repository &&
        github.event.pull_request.user.login != 'dependabot[bot]'))
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      pull-requests: write
    steps:
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        continue-on-error: true
        with:
          pattern: vibeview-result-*
          path: ${{ runner.temp }}/results
          # One flat folder even when only one platform left a result (a single
          # matching artifact is otherwise unpacked straight into path/).
          merge-multiple: true

      - name: Summarize and comment
        env:
          GH_TOKEN: ${{ github.token }}
          NEEDS: ${{ toJSON(needs) }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
        run: |
          cat > "$RUNNER_TEMP/render.jq" <<'JQ'
          def clip($n): tostring | if length > $n then .[0:$n] + "..." else . end;
          # Names and messages come from your tests and your app: neutralize Markdown,
          # HTML and @mentions before they reach the comment.
          def esc: tostring | gsub("[\r\n\t]+"; " ")
            | gsub("&"; "&amp;") | gsub("<"; "&lt;") | gsub(">"; "&gt;")
            | gsub("(?<c>[\\\\`*_{}\\[\\]()#+!|~$])"; "\\\(.c)")
            | gsub("@"; "@​");
          def link($text; $url):
            if ($url | type) == "string" and ($url | test("^https://[^\\s()<>\\[\\]]+$"))
            then "[\($text)](\($url))" else $text end;
          def warned: .visual_regression_status == "warn" or .visual_regression_status == "fail";
          def icon: if .status == "passed" then (if warned then "⚠️" else "✅" end)
            elif .status == "cancelled" then "⚪"
            elif (.status == "queued" or .status == "pending" or .status == "running") then "⏳"
            else "❌" end;
          def note: if .status != "passed" and .error_message then ": " + (.error_message | clip(300) | esc)
            elif .status == "passed" and warned then ": passed with visual differences from its baseline"
            else "" end;
          def suite_note: if .status != "passed" and .error_message then ": " + (.error_message | clip(200) | esc) else "" end;
          def credit: if (.cost_micro | type) == "number"
            then "\nUsage credit: $\((.cost_micro / 100 | round) / 10000)\n" else "" end;
          def suites:
            "| Group | Result | Passed | Failed |\n| --- | --- | --- | --- |\n"
            + ([.suite_runs[] | "| \(icon) \(link(.suite_name // "Group" | clip(100) | esc; .url)) | \(.status | esc)\(suite_note) | \(.passed // 0) | \(.failed // 0) |"] | join("\n"))
            + "\n"
            + ([.suite_runs[] | .case_runs[]? | "- \(icon) \(link(.case_name // "Case" | clip(100) | esc; .url))\(note)"]
               | if length > 0 then "\n<details><summary>Test cases</summary>\n\n" + join("\n") + "\n\n</details>\n" else "" end);
          def steps:
            ([.steps[]? | "- \(if .status == "passed" then "✅" else "❌" end) \(.step | clip(200) | esc)"
              + (if .status != "passed" and .error then ": " + (.error | clip(300) | esc) else "" end)] | join("\n"));
          def tested: (if $sha | test("^[0-9a-f]{7,40}$") then "Commit `\($sha[0:7])` · " else "" end)
            + "[workflow run](\($run_url))\n\n";
          def header($icon; $status): "\($marker)\n### \($icon) VibeView (\($label)): \($status | esc)\n\n" + tested;
          if . == null then
            header("❌"; "no result")
            + (if $job_result == "skipped" then "The job did not run: a job it depends on failed or was skipped. "
               else "The job ended (\($job_result | esc)) before the tests reported a result. " end)
            + "See [the workflow run](\($run_url)).\n"
          elif .event? == "error" then
            header("❌"; "could not run") + (.message | clip(500) | esc) + "\n\n"
            + (if (.result | type) == "object" and (.result.suite_runs | type) == "array" then (.result | suites) else "" end)
            + (if .url then link("Open the run"; .url) + "\n" else "" end)
          elif (.suite_runs | type) == "array" then
            header(icon; .status) + suites + credit
          elif .type == "test_run" or (.steps | type) == "array" then
            header(icon; .status) + "\(link(.case_name // "Test" | clip(100) | esc; .url))\(note)\n\n" + steps + "\n" + credit
          else
            header("❔"; "unrecognized result") + "See [the workflow run](\($run_url)).\n"
          end
          JQ

          run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
          for job in $(jq -r 'keys[]' <<< "$NEEDS"); do
            case "$job" in
              ios) label="iOS" ;; android) label="Android" ;;
              tvos) label="Apple TV" ;; androidtv) label="Android TV" ;;
              *) label="$job" ;;
            esac
            job_result=$(jq -r --arg j "$job" '.[$j].result' <<< "$NEEDS")
            marker="<!-- vibeview-ci:$job -->"
            result="$RUNNER_TEMP/results/result-$job.json"
            body="$RUNNER_TEMP/comment-$job.md"
            args=(--arg marker "$marker" --arg label "$label" --arg job_result "$job_result" --arg run_url "$run_url"
                  --arg sha "$HEAD_SHA")
            if [ -s "$result" ] && jq -e 'type == "object"' "$result" > /dev/null 2>&1; then
              jq -r "${args[@]}" -f "$RUNNER_TEMP/render.jq" "$result" > "$body"
            else
              jq -r "${args[@]}" -f "$RUNNER_TEMP/render.jq" <<< 'null' > "$body"
            fi
            cat "$body" >> "$GITHUB_STEP_SUMMARY"

            [ -n "$PR_NUMBER" ] || continue
            comments="repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments"
            id=$(gh api --paginate "$comments" \
                   | jq -rs --arg m "$marker" '[.[][] | select(.user.login == "github-actions[bot]"
                       and (.body | startswith($m))) | .id] | first // empty')
            payload=$(jq -n --rawfile body "$body" '{body: $body}')
            if [ -n "$id" ]; then
              gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" --input - <<< "$payload" > /dev/null
            else
              gh api --method POST "$comments" --input - <<< "$payload" > /dev/null
            fi
          done
