# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

This repo is a **reusable, Docker-based GitHub Action** that nudges authors of stale PRs in the `theorchard/terraform-infra` repository. The action is packaged via `action.yml` (`using: docker`, `image: Dockerfile`) and run on a schedule by a workflow in the *consuming* repo — there is **no workflow file in this repo**. The target repo (`OWNER`/`REPO`) is hardcoded in `stale_pr_review/core.py`, not configurable via input.

Requires Python 3.14 (`requires-python = ">=3.14"`; the Dockerfile installs 3.14 via uv).

**Dockerfile is multi-target.** `base` → `builder` → `runtime` (the Action image) plus `base-dev` → `unit-lint` (CI). `action.yml` (`image: Dockerfile`) always builds the **final** stage and can't pass `--target`, so **`runtime` must stay last in the file** or the Action would ship the test image. CI builds the `unit-lint` stage explicitly via `docker compose ... --target unit-lint` (see `make ci_unit_lint`).

## Commands

```bash
# Set up environment (first time, includes dev dependencies)
uv sync --dev

# Run tests
GH_TOKEN=dummy uv run pytest tests/ -v

# Run a single test
GH_TOKEN=dummy uv run pytest tests/ -v -k "test_name"

# Run the script
uv run python -m stale_pr_review

# Lint and format
uv run ruff check --fix .
uv run ruff format .

# Type check (ty), dead-code (vulture), dependency CVE audit (pip-audit)
uv run ty check stale_pr_review/
uv run vulture stale_pr_review
uv export --no-dev --no-emit-project -o /tmp/reqs.txt && uv run pip-audit --disable-pip -r /tmp/reqs.txt

# Run every quality gate at once (lint, format, type, dead-code, audit; tests on push)
uv run pre-commit run --all-files                     # commit-stage hooks
uv run pre-commit run --all-files --hook-stage pre-push  # adds pytest

# Install pre-commit hooks (one-time, run from this directory).
# Installs both pre-commit (lint/type/audit) and pre-push (tests) hooks.
uv run pre-commit install

# Build and run as the packaged Docker action
docker build -t stale-pr-review .
docker run --rm --env-file .env stale-pr-review -m stale_pr_review

# Run the CI quality gates the way Jenkins does (in Docker, no host uv needed)
make ci_unit_lint        # build unit-lint image, run ruff/ty/vulture/pytest, emit build/pyunit.xml
make ci_unit_lint_clean  # remove build/ artifacts

# Refresh uv.lock to latest allowed versions, e.g. to clear a SAST CVE finding
make lock                # runs `uv lock --upgrade` in Docker; writes uv.lock back to the tree
```

The host `uv`/`pre-commit` commands above are for local development. **CI runs everything in Docker** instead: Jenkins agents have neither `uv` nor a pinned Python, so `make ci_unit_lint` builds the `unit-lint` Dockerfile target (public `ghcr.io/astral-sh/uv` base — *not* the org's ECR parent, because the GitHub Action runtime image has no ECR access) and runs `scripts/unit-lint.sh` inside it via `docker-compose.yml`. JUnit XML lands in the mounted `build/` for the Jenkins `xunit` publisher.

## Quality gates

`pre-commit` runs these from the repo root (each hook cd's into this directory):

| Hook | Tool | Catches |
|---|---|---|
| ruff | lint (incl. `S` bandit rules) + format | bugs, smells, insecure patterns, naive datetimes, missing timeouts |
| ty | static type check | wrong/missing fields, type mismatches |
| vulture | dead-code | unused functions/variables |
| pip-audit | dependency CVE scan | known vulnerabilities in resolved deps |
| pytest | test suite (pre-push only) | behavioral regressions |

Ruff rule sets and per-tool config live in `pyproject.toml`.

## Local Development

Copy `.env.shadow` to `.env` and fill in your values. `.env` is gitignored and auto-loaded when present.

```bash
cp .env.shadow .env
```

Key env vars:

| Variable | Required | Default | Description |
|---|---|---|---|
| `GH_TOKEN` / `GITHUB_TOKEN` | yes | — | GitHub API token |
| `SLACK_BOT_TOKEN` | no | — | Slack bot token for DMs |
| `ORG_MEMBERS_B64GZ` | no | — | Base64-gzipped JSON of org members for Slack email lookup |
| `STALE_DAYS` | no | 7 | Days without activity before PR is stale |
| `DRY_RUN` | no | false | Print actions without executing them |
| `TEST_PR_NUMBER` | no | — | Run against a single PR only, bypassing staleness/lease checks |

**Modes:**
- `DRY_RUN=true` — no GitHub comments or Slack DMs are sent; all actions are printed
- `TEST_PR_NUMBER=<n>` — processes only that PR regardless of staleness/lease (still respects opt-out labels); useful for testing against a PR you author
- Both together — see exactly what would happen on a test PR without side effects

## Scripts

### `stale_pr_review/` — Stale PR Manager

`core.py` is the whole program: `__main__.py` calls `core.main()`, which lists open PRs and delegates each to `process_pr()`. Each PR is processed independently inside a try/except so one bad PR never aborts the run.

**Module-level config loaded at import time.** Env vars (`GITHUB_TOKEN`, `STALE_DAYS`, `DRY_RUN`, `TEST_PR_NUMBER`, etc.) are read into module constants when `core.py` is imported, and the import **`sys.exit(1)`s if no GitHub token is set**. This is why tests must run with `GH_TOKEN=dummy` — importing the module without a token kills the process before any test runs.

Scans all open PRs and sends each stale one a single polite reminder to merge or close it. Runs daily via a GitHub Actions workflow in the consuming repo.

**Decision logic** (see `decision-tree.md` for the flowchart):
1. Skip if the PR has a `Dont Merge` or `do not apply` label (intentional opt-out).
2. Skip if the PR was created within `STALE_DAYS`.
3. Skip if the bot already commented within `COMMENT_LEASE_DAYS` (hardcoded 3 days; spam guard).
4. Otherwise post one polite "open for N days, please merge or close" comment (+ best-effort Slack DM).

Staleness keys off **`created_at`, not `updated_at`** — deliberate: GitHub bumps `updated_at` on any activity, including the bot's own comment, which would reset the clock every run and the PR would never read as stale. `TEST_PR_NUMBER` bypasses both the staleness and the comment-lease checks (but still respects opt-out labels).

The bot deliberately does **not** inspect approval, Atlantis apply history, or `mergeable_state` — that logic was the source of most bugs and the auto-close idea was dropped in favor of simply reminding the author.

**Bot comment identification:** Uses the `BOT_COMMENT_MARKER` (`[stale‑check]`) constant. The hyphen is **U+2011 (non-breaking hyphen), not ASCII `-`** — it must stay byte-for-byte identical or `last_bot_comment()` stops recognizing existing comments in production (the marker carries a `# noqa: RUF001`). The `COMMENT_LEASE_DAYS` lease prevents notification spam.

**Slack DMs:** Requires `SLACK_BOT_TOKEN`. Email addresses are resolved from the `ORG_MEMBERS_B64GZ` env var (base64-gzipped JSON of org members, provisioned as a GitHub secret). Failures are best-effort and never block the GitHub comment.

## Dependencies

Managed with `uv`. Runtime dependency declared in `pyproject.toml`: `requests`. Dev dependencies: `ruff`, `ty`, `vulture`, `pip-audit`, `pytest`, `pre-commit`.

## Linting & quality gates

`pre-commit` runs the full gate suite (see the "Quality gates" table above): `ruff` (lint incl. bandit-style `S` rules + format), `ty` (type check), `vulture` (dead code), and `pip-audit` (dependency CVEs) on commit, plus `pytest` on push. Tool config lives in `pyproject.toml` (`[tool.ruff]`, `[tool.ty.rules]`, `[tool.vulture]`).

Install hooks once with `uv run pre-commit install` from this directory.

## Tests

Tests use `unittest.mock.patch` to mock all GitHub API and Slack API calls — no live calls are made. The `DRY_RUN` and `TEST_PR_NUMBER` flags default to off, so all existing test paths are unaffected by those modes.
