# .github/workflows/breaking-change-check.yml
#
# Analyzes every PR that touches strands_robots/ for public API breaking
# changes and posts a comment on the PR with the results.
#
# Uses griffe (https://mkdocstrings.github.io/griffe/) - the standard Python
# library for static API analysis, used by mkdocstrings, Rye, and others.
# It understands __all__, class hierarchies, decorators, multi-line signatures,
# and parameter kind changes - far beyond what git-diff heuristics can detect.
#
# This workflow is INFORMATIONAL ONLY. It never blocks a merge.
# Intended use: reviewer awareness for breaking changes that may need a
# CHANGELOG entry or a deprecation notice before removal. This matters while
# the SDK is pre-1.0 and its public surface (Robot, create_policy, the
# AgentTools, Trainer) is what customers build against.
#

name: Breaking Change Check

on:
  pull_request:
    branches: [main]
    paths:
      - 'strands_robots/**'

permissions:
  contents: read       # checkout
  pull-requests: write # post/update PR comment (read-only on fork PRs)

jobs:
  check-breaking-changes:
    name: Detect Breaking Changes
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - name: Checkout PR
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
        with:
          # Full history needed so griffe can create a worktree at the base ref.
          fetch-depth: 0
          persist-credentials: false

      - name: Set up Python
        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065  # v5.6.0
        with:
          python-version: '3.12'

      - name: Install griffe
        run: pip install griffe==2.1.0

      # Fetch the base branch so the ref is available for griffe's worktree.
      - name: Fetch base branch
        env:
          BASE_REF: ${{ github.base_ref }}
        run: git fetch origin "$BASE_REF" --depth=1

      # Run griffe using its Python API for stable, structured output.
      # The Python API is used instead of the CLI to:
      #   a) get markdown-formatted output for the PR comment
      #   b) handle errors gracefully without failing the job
      #   c) avoid relying on CLI flag stability across griffe versions
      #
      # Note: griffe analyzes the package STATICALLY (it parses the source, it
      # does not import strands_robots), so heavy optional deps (torch, mujoco,
      # lerobot) do NOT need to be installed for this check to run.
      #
      # Exit codes written to GITHUB_OUTPUT:
      #   exit_code=0  -> no breaking changes
      #   exit_code=1  -> breaking changes found
      #   exit_code=2  -> griffe itself errored (treated as a warning, not a failure)
      - name: Run griffe analysis
        id: griffe
        env:
          BASE_REF: ${{ github.base_ref }}
        run: |
          python3 - <<'PYEOF'
          import griffe, sys, os

          search   = ["."]
          base_ref = f"origin/{os.environ['BASE_REF']}"

          # Load the old API from the base branch via a temporary git worktree.
          # Load the new API from the current working tree (the PR branch).
          try:
              old = griffe.load_git("strands_robots", ref=base_ref, search_paths=search)
              new = griffe.load("strands_robots", search_paths=search)
          except griffe.GitError as e:
              print(f"::warning::griffe could not create worktree for {base_ref}: {e}")
              print(f"::warning::Skipping breaking change analysis for this PR.")
              with open("/tmp/breaking-changes.md", "w") as f:
                  f.write(
                      "## Breaking Change Analysis Skipped\n\n"
                      "griffe could not load the base branch for comparison. "
                      "This is usually a transient git issue. "
                      "A maintainer can re-run this check manually."
                  )
              with open(os.environ["GITHUB_OUTPUT"], "a") as out:
                  out.write("exit_code=2\n")
              sys.exit(0)
          except Exception as e:
              print(f"::warning::griffe analysis failed unexpectedly: {e}")
              with open("/tmp/breaking-changes.md", "w") as f:
                  f.write(
                      "## Breaking Change Analysis Skipped\n\n"
                      f"griffe encountered an unexpected error: `{e}`"
                  )
              with open(os.environ["GITHUB_OUTPUT"], "a") as out:
                  out.write("exit_code=2\n")
              sys.exit(0)

          breakages = list(griffe.find_breaking_changes(old, new))

          if not breakages:
              body = (
                  "## No Breaking Changes Detected\n\n"
                  "No public API breaking changes found in this PR."
              )
              with open("/tmp/breaking-changes.md", "w") as f:
                  f.write(body)
              with open(os.environ["GITHUB_OUTPUT"], "a") as out:
                  out.write("exit_code=0\n")
              print("No breaking changes detected.")
              sys.exit(0)

          # Format output as markdown for the PR comment.
          lines = [
              "## Breaking Change Warning",
              "",
              f"Found **{len(breakages)}** potential breaking change(s) in this PR:",
              "",
          ]
          for b in breakages:
              lines.append(b.explain())

          lines += [
              "",
              "---",
              "> **Note:** This is an automated static analysis check. "
              "Some flagged changes may be intentional.",
              "> Please confirm each item is expected and, if so, add a migration "
              "note as a `changelog.d/` fragment (see `changelog.d/README.md`) "
              "or a deprecation notice before removal.",
          ]

          body = "\n".join(lines)
          with open("/tmp/breaking-changes.md", "w") as f:
              f.write(body)

          with open(os.environ["GITHUB_OUTPUT"], "a") as out:
              out.write("exit_code=1\n")

          print(f"{len(breakages)} breaking change(s) found.")
          # Exit 0 here - the job itself always succeeds (informational).
          # The PR comment carries the signal; the job status does not block merge.
          sys.exit(0)
          PYEOF

      # Always write the report to the job summary. This needs no token
      # permissions and works on fork PRs (where GITHUB_TOKEN is read-only), so
      # the signal is never lost even if the PR comment cannot be posted.
      - name: Write job summary
        if: always()
        run: |
          if [ -f /tmp/breaking-changes.md ]; then
            cat /tmp/breaking-changes.md >> "$GITHUB_STEP_SUMMARY"
          else
            printf '%s\n' "## Breaking Change Check" "" "No report produced." >> "$GITHUB_STEP_SUMMARY"
          fi

      # Post or update a single persistent comment on the PR.
      # Uses a hidden HTML marker so subsequent pushes update the same comment
      # rather than creating a new one per commit.
      #
      # Best-effort: on pull_request events from a fork, GitHub forces
      # GITHUB_TOKEN to read-only regardless of the permissions block above, so
      # issues.createComment returns 403 "Resource not accessible by
      # integration". We swallow that (continue-on-error + try/catch -> notice)
      # because the authoritative report is the job summary written above. We do
      # NOT switch to pull_request_target, which would run untrusted PR code with
      # a write token.
      - name: Post or update PR comment (best-effort)
        if: always()
        continue-on-error: true
        uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b  # v7.1.0
        env:
          GRIFFE_EXIT_CODE: ${{ steps.griffe.outputs.exit_code }}
        with:
          script: |
            const fs = require('fs');
            let body = '';
            try {
              body = fs.readFileSync('/tmp/breaking-changes.md', 'utf8');
            } catch (e) {
              body = '## Breaking Change Check\n\nNo report produced.';
            }

            // Marker that identifies the comment as ours for future updates.
            const marker = '<!-- breaking-change-check -->';
            const fullBody = `${marker}\n${body}`;

            try {
              const { data: comments } = await github.rest.issues.listComments({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.payload.pull_request.number,
              });

              const existing = comments.find(c => c.body.includes(marker));

              if (existing) {
                await github.rest.issues.updateComment({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  comment_id: existing.id,
                  body: fullBody,
                });
                console.log(`Updated existing breaking change comment (id: ${existing.id})`);
              } else {
                await github.rest.issues.createComment({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: context.payload.pull_request.number,
                  body: fullBody,
                });
                console.log('Created new breaking change comment');
              }
            } catch (e) {
              // Expected on fork PRs: GITHUB_TOKEN is read-only, so commenting
              // returns 403. The job summary carries the report; do not fail.
              core.notice(`Could not post PR comment (${e.status || 'error'}); see the job summary for the breaking change report.`);
            }

            const exitCode = process.env.GRIFFE_EXIT_CODE;
            if (exitCode === '1') {
              core.warning('Breaking changes detected - see PR comment / job summary for details.');
            }
