From 09c77c26ee092ceefb2bfdc4584758deb9208dc6 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 8 Jul 2026 10:36:42 +0300 Subject: [PATCH 1/3] chore(planning): swap index.py/templates and flatten change folders for convention 2.0.0 Apply the lesnik512/planning-convention APPLY.md tooling swap: replace index.py and the five canonical templates (adds glossary.md, drops the plan.md template), flatten all planning/changes/*/ folders to flat *.md files, and drop the committed plan.md task-lists per 2.0.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- planning/_templates/change.md | 4 +- planning/_templates/design.md | 34 +- planning/_templates/glossary.md | 15 + planning/_templates/plan.md | 46 - ...perpowers-migration-and-httpx2-wrapper.md} | 0 .../plan.md | 1374 ------------- ...design.md => 2026-05-31.02-drop-doctor.md} | 0 .../changes/2026-05-31.02-drop-doctor/plan.md | 594 ------ ...=> 2026-05-31.03-settings-aliaschoices.md} | 0 .../plan.md | 516 ----- ...n.md => 2026-05-31.04-usecase-callable.md} | 0 .../2026-05-31.04-usecase-callable/plan.md | 707 ------- ...05-31.05-ioc-idiomatic-modern-di-typer.md} | 0 .../plan.md | 794 -------- ...26-05-31.06-cli-overlay-simplification.md} | 0 .../plan.md | 383 ---- ...2026-05-31.07-strategy-no-bump-cleanup.md} | 0 .../plan.md | 451 ----- ...d => 2026-05-31.08-v0-1-0-release-prep.md} | 0 .../2026-05-31.08-v0-1-0-release-prep/plan.md | 669 ------- ...md => 2026-06-07.01-httpware-migration.md} | 0 .../2026-06-07.01-httpware-migration/plan.md | 877 --------- ...026-06-08.01-httpware-decoder-adoption.md} | 0 .../plan.md | 409 ---- ...gn.md => 2026-06-08.02-github-provider.md} | 0 .../2026-06-08.02-github-provider/plan.md | 1702 ----------------- ...-06-08.03-action-yml-composite-wrapper.md} | 0 .../plan.md | 820 -------- ...=> 2026-06-09.01-mkdocs-github-actions.md} | 0 .../plan.md | 434 ----- ...esign.md => 2026-06-09.02-dry-run-flag.md} | 0 .../2026-06-09.02-dry-run-flag/plan.md | 510 ----- ...md => 2026-06-09.03-action-yml-dry-run.md} | 0 .../2026-06-09.03-action-yml-dry-run/plan.md | 460 ----- ...-06-13.01-portable-planning-convention.md} | 0 .../plan.md | 871 --------- ...-16.01-httpware-0.12-get-with-response.md} | 0 ...16.02-branch-prefix-patch-on-non-merge.md} | 0 .../plan.md | 220 --- ...06-16.03-httpware-max-error-body-bytes.md} | 0 .../plan.md | 281 --- ... 2026-06-24.01-default-branch-override.md} | 0 .../plan.md | 44 - ...d => 2026-06-24.02-closed-outcome-type.md} | 0 .../2026-06-24.02-closed-outcome-type/plan.md | 70 - ...md => 2026-06-25.01-tag-driven-release.md} | 0 .../2026-06-25.01-tag-driven-release/plan.md | 386 ---- ...> 2026-06-26.01-load-settings-pipeline.md} | 0 .../plan.md | 294 --- ...gn.md => 2026-06-26.02-provider-target.md} | 0 .../2026-06-26.02-provider-target/plan.md | 270 --- ... => 2026-06-26.03-semver-tag-selection.md} | 0 .../plan.md | 238 --- planning/index.py | 147 +- 54 files changed, 99 insertions(+), 13521 deletions(-) create mode 100644 planning/_templates/glossary.md delete mode 100644 planning/_templates/plan.md rename planning/changes/{2026-05-31.01-bmad-to-superpowers-migration-and-httpx2-wrapper/design.md => 2026-05-31.01-bmad-to-superpowers-migration-and-httpx2-wrapper.md} (100%) delete mode 100644 planning/changes/2026-05-31.01-bmad-to-superpowers-migration-and-httpx2-wrapper/plan.md rename planning/changes/{2026-05-31.02-drop-doctor/design.md => 2026-05-31.02-drop-doctor.md} (100%) delete mode 100644 planning/changes/2026-05-31.02-drop-doctor/plan.md rename planning/changes/{2026-05-31.03-settings-aliaschoices/design.md => 2026-05-31.03-settings-aliaschoices.md} (100%) delete mode 100644 planning/changes/2026-05-31.03-settings-aliaschoices/plan.md rename planning/changes/{2026-05-31.04-usecase-callable/design.md => 2026-05-31.04-usecase-callable.md} (100%) delete mode 100644 planning/changes/2026-05-31.04-usecase-callable/plan.md rename planning/changes/{2026-05-31.05-ioc-idiomatic-modern-di-typer/design.md => 2026-05-31.05-ioc-idiomatic-modern-di-typer.md} (100%) delete mode 100644 planning/changes/2026-05-31.05-ioc-idiomatic-modern-di-typer/plan.md rename planning/changes/{2026-05-31.06-cli-overlay-simplification/design.md => 2026-05-31.06-cli-overlay-simplification.md} (100%) delete mode 100644 planning/changes/2026-05-31.06-cli-overlay-simplification/plan.md rename planning/changes/{2026-05-31.07-strategy-no-bump-cleanup/design.md => 2026-05-31.07-strategy-no-bump-cleanup.md} (100%) delete mode 100644 planning/changes/2026-05-31.07-strategy-no-bump-cleanup/plan.md rename planning/changes/{2026-05-31.08-v0-1-0-release-prep/design.md => 2026-05-31.08-v0-1-0-release-prep.md} (100%) delete mode 100644 planning/changes/2026-05-31.08-v0-1-0-release-prep/plan.md rename planning/changes/{2026-06-07.01-httpware-migration/design.md => 2026-06-07.01-httpware-migration.md} (100%) delete mode 100644 planning/changes/2026-06-07.01-httpware-migration/plan.md rename planning/changes/{2026-06-08.01-httpware-decoder-adoption/design.md => 2026-06-08.01-httpware-decoder-adoption.md} (100%) delete mode 100644 planning/changes/2026-06-08.01-httpware-decoder-adoption/plan.md rename planning/changes/{2026-06-08.02-github-provider/design.md => 2026-06-08.02-github-provider.md} (100%) delete mode 100644 planning/changes/2026-06-08.02-github-provider/plan.md rename planning/changes/{2026-06-08.03-action-yml-composite-wrapper/design.md => 2026-06-08.03-action-yml-composite-wrapper.md} (100%) delete mode 100644 planning/changes/2026-06-08.03-action-yml-composite-wrapper/plan.md rename planning/changes/{2026-06-09.01-mkdocs-github-actions/design.md => 2026-06-09.01-mkdocs-github-actions.md} (100%) delete mode 100644 planning/changes/2026-06-09.01-mkdocs-github-actions/plan.md rename planning/changes/{2026-06-09.02-dry-run-flag/design.md => 2026-06-09.02-dry-run-flag.md} (100%) delete mode 100644 planning/changes/2026-06-09.02-dry-run-flag/plan.md rename planning/changes/{2026-06-09.03-action-yml-dry-run/design.md => 2026-06-09.03-action-yml-dry-run.md} (100%) delete mode 100644 planning/changes/2026-06-09.03-action-yml-dry-run/plan.md rename planning/changes/{2026-06-13.01-portable-planning-convention/design.md => 2026-06-13.01-portable-planning-convention.md} (100%) delete mode 100644 planning/changes/2026-06-13.01-portable-planning-convention/plan.md rename planning/changes/{2026-06-16.01-httpware-0.12-get-with-response/change.md => 2026-06-16.01-httpware-0.12-get-with-response.md} (100%) rename planning/changes/{2026-06-16.02-branch-prefix-patch-on-non-merge/design.md => 2026-06-16.02-branch-prefix-patch-on-non-merge.md} (100%) delete mode 100644 planning/changes/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md rename planning/changes/{2026-06-16.03-httpware-max-error-body-bytes/design.md => 2026-06-16.03-httpware-max-error-body-bytes.md} (100%) delete mode 100644 planning/changes/2026-06-16.03-httpware-max-error-body-bytes/plan.md rename planning/changes/{2026-06-24.01-default-branch-override/design.md => 2026-06-24.01-default-branch-override.md} (100%) delete mode 100644 planning/changes/2026-06-24.01-default-branch-override/plan.md rename planning/changes/{2026-06-24.02-closed-outcome-type/design.md => 2026-06-24.02-closed-outcome-type.md} (100%) delete mode 100644 planning/changes/2026-06-24.02-closed-outcome-type/plan.md rename planning/changes/{2026-06-25.01-tag-driven-release/design.md => 2026-06-25.01-tag-driven-release.md} (100%) delete mode 100644 planning/changes/2026-06-25.01-tag-driven-release/plan.md rename planning/changes/{2026-06-26.01-load-settings-pipeline/design.md => 2026-06-26.01-load-settings-pipeline.md} (100%) delete mode 100644 planning/changes/2026-06-26.01-load-settings-pipeline/plan.md rename planning/changes/{2026-06-26.02-provider-target/design.md => 2026-06-26.02-provider-target.md} (100%) delete mode 100644 planning/changes/2026-06-26.02-provider-target/plan.md rename planning/changes/{2026-06-26.03-semver-tag-selection/design.md => 2026-06-26.03-semver-tag-selection.md} (100%) delete mode 100644 planning/changes/2026-06-26.03-semver-tag-selection/plan.md diff --git a/planning/_templates/change.md b/planning/_templates/change.md index d4c8962..5aa7e81 100644 --- a/planning/_templates/change.md +++ b/planning/_templates/change.md @@ -5,8 +5,8 @@ summary: One line — shown in the generated index. Written at creation; finaliz # Change: One-line capitalized title **Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API -change, a single straightforward test. If it outgrows this, split into -`design.md` + `plan.md`. +change, a single straightforward test. If it outgrows this, rewrite it from +the design template. ## Goal diff --git a/planning/_templates/design.md b/planning/_templates/design.md index d63e22d..17dbee1 100644 --- a/planning/_templates/design.md +++ b/planning/_templates/design.md @@ -4,6 +4,10 @@ summary: One line — shown in the generated index. Written at creation; finaliz # Design: One-line capitalized title + + ## Summary One paragraph. What changes, at the level a reader needs to decide if this @@ -12,37 +16,23 @@ spec is worth reading in full. ## Motivation Why now. What is broken or missing. Concrete observations / numbers, not -abstract complaints. Link to memory entries or earlier specs when relevant. - -## Non-goals - -What is deliberately out of scope and (when nontrivial) why. Each item is -a sentence; one line each. +abstract complaints. ## Design -### 1. - What changes, in enough detail that a reader who has not seen the codebase -can follow. Code samples / diagrams welcome. +can follow. Sketches and interface fragments welcome; never the full +diff-to-be. Reference rejected alternatives in `decisions/` instead of +retelling them. -### 2. - -... - -## Operations - -Out-of-repo steps (DNS, infra, external account changes). Omit if none. - -## Out of scope +## Non-goals -Already covered above under Non-goals if appropriate. Repeat-list of -explicitly-excluded follow-ups belongs here when the list is long. +What is deliberately out of scope and (when nontrivial) why. One line each. ## Testing -How we know it landed correctly. New pytest? Smoke check on live URL? -Lint pass? Be specific. +How we know it landed correctly. Be specific: the command and the expected +signal. ## Risk diff --git a/planning/_templates/glossary.md b/planning/_templates/glossary.md new file mode 100644 index 0000000..82385c3 --- /dev/null +++ b/planning/_templates/glossary.md @@ -0,0 +1,15 @@ +# Glossary + +The project's ubiquitous language — the domain terms that code, specs, and +capability pages share. Living prose, no frontmatter, dated by git. Each entry is +a term, what it *is* (not what it does), and the synonyms to avoid. No +implementation detail; this is a glossary, not a spec. + +**Term**: +A one-or-two-sentence definition of what it is. +_Avoid_: rejected-synonym, another-one + +**Another term**: +Define what it is, tightly. Group related terms under `##` subheadings when +natural clusters emerge; a flat list is fine when they don't. +_Avoid_: … diff --git a/planning/_templates/plan.md b/planning/_templates/plan.md deleted file mode 100644 index 132d720..0000000 --- a/planning/_templates/plan.md +++ /dev/null @@ -1,46 +0,0 @@ -# — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** One sentence — what shipping this plan achieves. No design -rationale; link to the spec for that. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `feat/my-change` (or `fix/`, `chore/`, etc.) - -**Commit strategy:** Per-task commits / single commit / squash on merge. -Whichever fits. - ---- - -### Task 1: - -**Files:** -- Modify: `path/to/file.py` -- Create: `path/to/new.py` - -One sentence on what this task accomplishes. No deeper reasoning — that's -in the spec. - -- [ ] **Step 1: ** - - Run / edit / verify command. Expected output. - -- [ ] **Step 2: ** - - ... - -- [ ] **Step 3: Commit** - - ```bash - git add path/to/file.py - git commit -m ": " - ``` - ---- - -### Task 2: ... diff --git a/planning/changes/2026-05-31.01-bmad-to-superpowers-migration-and-httpx2-wrapper/design.md b/planning/changes/2026-05-31.01-bmad-to-superpowers-migration-and-httpx2-wrapper.md similarity index 100% rename from planning/changes/2026-05-31.01-bmad-to-superpowers-migration-and-httpx2-wrapper/design.md rename to planning/changes/2026-05-31.01-bmad-to-superpowers-migration-and-httpx2-wrapper.md diff --git a/planning/changes/2026-05-31.01-bmad-to-superpowers-migration-and-httpx2-wrapper/plan.md b/planning/changes/2026-05-31.01-bmad-to-superpowers-migration-and-httpx2-wrapper/plan.md deleted file mode 100644 index 723fcb7..0000000 --- a/planning/changes/2026-05-31.01-bmad-to-superpowers-migration-and-httpx2-wrapper/plan.md +++ /dev/null @@ -1,1374 +0,0 @@ -# BMad → Superpowers Migration + httpx2 Wrapper Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Cut over from BMad to Superpowers workflow, then prove the new workflow by refactoring `semvertag/providers/gitlab.py` to use a schema-based `HttpClient` wrapper that erases its 6-step defensive dance per method. - -**Architecture:** Two phases. **Phase 1** is mechanical workspace housekeeping on `main`: `git mv _bmad _archive/bmad`, update tool excludes, add a project `CLAUDE.md`. **Phase 2** is the wrapper pilot in a worktree under full TDD: build a generic `HttpClient` (taking a Pydantic schema per call, returning typed instances), then rewrite every method in `gitlab.py` to use it, then rewire DI. Existing integration tests are the parity guarantee — they pass before and after, with only error-message match patterns updated where Pydantic produces richer messages. - -**Tech Stack:** Python 3.10+, `httpx2`, `pydantic` (already transitive via `pydantic-settings`), `pytest`, `modern-di`, `ty` (type checker), `ruff`, `uv`. - -**Spec:** `planning/specs/2026-05-31-bmad-to-superpowers-migration-and-httpx2-wrapper-design.md` - ---- - -## Phase 1 — Migration scaffolding (direct-to-`main`, no worktree) - -### Task 1: Archive `_bmad/` and update tool excludes - -**Files:** -- Move: `_bmad/` → `_archive/bmad/` (via `git mv`) -- Modify: `pyproject.toml:61` (`tool.ruff.extend-exclude`) -- Modify: `pyproject.toml:90` (`tool.coverage.run.omit`) -- Modify: `pyproject.toml:93` (`tool.ty.src.exclude`) - -- [ ] **Step 1: Verify pre-move state** - -Run: `git status` — must be clean. Run: `ls _bmad/ | head -5` — must show BMad story files. Run: `ls _archive/ 2>/dev/null || echo "missing"` — `_archive/` may or may not exist. - -- [ ] **Step 2: Create `_archive/` parent if missing and move** - -```bash -mkdir -p _archive -git mv _bmad _archive/bmad -``` - -Run: `ls _archive/bmad/ | head -5` — must show the moved BMad files. Run: `ls _bmad 2>/dev/null && echo "STILL EXISTS"` — must print nothing (no `STILL EXISTS`). - -- [ ] **Step 3: Update `pyproject.toml` excludes** - -Three string replacements, all `_bmad` → `_archive/bmad`: - -```toml -# Line ~61 -extend-exclude = ["docs", "_autosemver_reference", "_archive/bmad"] - -# Line ~90 -omit = ["_autosemver_reference/*", "_archive/bmad/*", "tests/*"] - -# Line ~93 -exclude = ["_autosemver_reference", "_archive/bmad", "docs"] -``` - -- [ ] **Step 4: Verify tools accept the new excludes** - -Run: `just lint-ci` -Expected: passes (linters honor the new exclude paths, no errors from inside `_archive/bmad/`). - -Run: `uv run pytest -x -q` -Expected: 425+ tests pass (no coverage failures from `_archive/bmad/`). - -- [ ] **Step 5: Commit** - -```bash -git add _archive/bmad _bmad pyproject.toml -git commit -m "chore: archive _bmad/ as read-only reference - -Move _bmad/ to _archive/bmad/ to retire the BMad workflow. -Update ruff, coverage, and ty excludes accordingly. -Nothing is deleted; the archive stays on disk for historical reference." -``` - -Run: `git log --oneline -1` -Expected: shows the new commit; `_bmad/` no longer appears in `git ls-files`. - ---- - -### Task 2: Add project `CLAUDE.md` - -**Files:** -- Create: `CLAUDE.md` (repo root) - -`planning/plans/.gitkeep` already exists (committed during the brainstorm in `37aeb6b`); no work needed there. - -- [ ] **Step 1: Write `CLAUDE.md`** - -Create `/Users/kevinsmith/src/pypi/autosemver/CLAUDE.md` with this exact content: - -```markdown -# semvertag — Claude project guide - -## Workflow - -This project uses **Superpowers** (brainstorm → plan → TDD → review). - -- Brainstorm specs live in `planning/specs/YYYY-MM-DD--design.md`. -- Implementation plans live in `planning/plans/YYYY-MM-DD-.md`. -- Use TDD by default: red, green, refactor. Tests before implementation. -- Use git worktrees for feature isolation (`superpowers:using-git-worktrees`). -- Use the verification gate before claiming work complete - (`superpowers:verification-before-completion`). -- Request code review via a subagent before landing - (`superpowers:requesting-code-review`). - -## Commit messages - -Imperative present-tense, scoped where helpful: - -- `providers: add HttpClient wrapper` -- `docs: update README hero section` -- `fix: handle empty default branch in GitLab provider` - -No story-numbered prefixes (`land story X.Y`, `contextualise story X.Y`). Those -belong to the retired BMad workflow. - -## Reference directories (do not edit) - -- `_archive/bmad/` — retired BMad workspace. Historical specs, PRD, - architecture, retrospectives, and 14 dense story files. Read for context; - do not extend, do not delete. -- `_autosemver_reference/` — the original Raiffeisen-internal `autosemver` - package. Behavioral reference only — port logic and test shapes from it - but never `git mv` files in or take it as a starter. - -## Test stack and lint - -See `Justfile` for the canonical commands. Quick reference: - -- `just lint-ci` — eof-fixer, ruff format check, ruff check, ty check -- `just test` — pytest with coverage -- `just test-branch` — pytest with branch coverage -- `just test-branch-strategies` / `just test-cc-strategies` / `just test-doctor` - — 100% branch coverage gates on specific modules -- `mkdocs build --strict` — docs build gate - -## What the codebase ships - -`semvertag` is a public-OSS auto-tagger for GitLab/GitHub/Bitbucket -repositories. Two strategies (`branch-prefix`, `conventional-commits`), one -provider implemented today (GitLab), distributed as a Python CLI plus a -GitHub Actions wrapper (`action.yml`) and a GitLab CI Catalog component -(`templates/semvertag.yml`). -``` - -- [ ] **Step 2: Commit** - -```bash -git add CLAUDE.md -git commit -m "docs: add project CLAUDE.md for Superpowers workflow - -Point at spec/plan locations, commit-message convention, reference -directories (_archive/bmad/, _autosemver_reference/), and the canonical -lint/test commands." -``` - -Run: `git log --oneline -1` -Expected: shows the new commit. - ---- - -### Phase 1 Gate - -- [ ] **Step 1: Run full verification** - -Run: `just lint-ci` -Expected: PASS. - -Run: `uv run pytest` -Expected: 425+ tests PASS. - -Run: `uv run mkdocs build --strict` -Expected: builds successfully in ~1s. - -If any of these fail, do **not** proceed to Phase 2 — fix the regression first. - ---- - -## Phase 2 — Wrapper pilot (worktree, TDD) - -### Task 3: Spawn a worktree for the wrapper work - -**Files:** none modified in the main checkout yet. - -- [ ] **Step 1: Invoke the worktree skill** - -Use the `superpowers:using-git-worktrees` skill to create an isolated worktree off `main`. Suggested branch name: `feat/http-client-wrapper`. Suggested worktree path: per the skill's default (typically `../-` or a configured location). - -- [ ] **Step 2: Verify worktree state** - -Run (inside the new worktree): `git status` — must be clean, on the new branch. Run: `git log --oneline -1` — must match the latest commit on `main` from Phase 1. - -All subsequent Phase 2 work happens inside this worktree. - ---- - -### Task 4: Stub `HttpClient` and write the happy-path `request` test - -**Files:** -- Create: `semvertag/providers/_http.py` -- Create: `tests/unit/test_http_client.py` - -- [ ] **Step 1: Write the failing happy-path test** - -Create `tests/unit/test_http_client.py`: - -```python -import typing - -import httpx2 -import pydantic -import pytest - -from semvertag.providers._http import HttpClient - - -_BASE_URL: typing.Final = "https://example.test" - - -class _SampleResponse(pydantic.BaseModel): - name: str - count: int - - -def _build_client(handler: typing.Callable[[httpx2.Request], httpx2.Response]) -> HttpClient: - transport: typing.Final = httpx2.MockTransport(handler) - inner: typing.Final = httpx2.Client(transport=transport, base_url=_BASE_URL) - return HttpClient( - client=inner, - auth_headers=lambda: {"X-Test-Auth": "token-xyz"}, - status_translator=lambda _status: None, - ) - - -def test_request_returns_validated_schema_instance_on_happy_path() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json={"name": "alice", "count": 7}) - - http: typing.Final = _build_client(handler) - result: typing.Final = http.request("GET", "/things/1", schema=_SampleResponse) - assert isinstance(result, _SampleResponse) - assert result.name == "alice" - assert result.count == 7 -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/unit/test_http_client.py::test_request_returns_validated_schema_instance_on_happy_path -v` -Expected: FAIL with `ModuleNotFoundError: No module named 'semvertag.providers._http'`. - -- [ ] **Step 3: Write minimal `HttpClient` to make the test pass** - -Create `semvertag/providers/_http.py`: - -```python -import collections.abc -import dataclasses -import typing - -import httpx2 -import pydantic - - -T = typing.TypeVar("T", bound=pydantic.BaseModel) - -AuthHeaders: typing.TypeAlias = collections.abc.Callable[[], dict[str, str]] -StatusTranslator: typing.TypeAlias = collections.abc.Callable[[int], None] - - -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) -class HttpClient: - client: httpx2.Client - auth_headers: AuthHeaders - status_translator: StatusTranslator - - def request(self, method: str, url: str, *, schema: type[T], **kwargs: typing.Any) -> T: - response = self.client.request(method, url, headers=self.auth_headers(), **kwargs) - payload = response.json() - return schema.model_validate(payload) - - -__all__: typing.Final = ("AuthHeaders", "HttpClient", "StatusTranslator") -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run pytest tests/unit/test_http_client.py -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add semvertag/providers/_http.py tests/unit/test_http_client.py -git commit -m "providers: add HttpClient stub with happy-path request - -Schema-based wrapper around httpx2.Client. First test only covers the -happy path; error paths follow in subsequent commits." -``` - ---- - -### Task 5: Add `RequestError` and malformed-JSON failure tests - -**Files:** -- Modify: `tests/unit/test_http_client.py` -- Modify: `semvertag/providers/_http.py` - -- [ ] **Step 1: Add two failing tests** - -Append to `tests/unit/test_http_client.py`: - -```python -from semvertag._errors import ProviderAPIError - - -def test_request_translates_request_error_to_provider_api_error() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - raise httpx2.ConnectError("connection refused") - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="request failed"): - http.request("GET", "/things/1", schema=_SampleResponse) - - -def test_request_translates_malformed_json_to_provider_api_error() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, text="this is not json") - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="malformed JSON"): - http.request("GET", "/things/1", schema=_SampleResponse) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/unit/test_http_client.py -v` -Expected: 2 FAILs (no exception translation yet — the `httpx2.ConnectError` leaks out, and `response.json()` raises `ValueError`/`DecodingError`). - -- [ ] **Step 3: Implement error translation** - -Replace the `request` method body in `semvertag/providers/_http.py`: - -```python - def request(self, method: str, url: str, *, schema: type[T], **kwargs: typing.Any) -> T: - try: - response = self.client.request(method, url, headers=self.auth_headers(), **kwargs) - except httpx2.RequestError as exc: - msg = f"request failed: {type(exc).__name__}" - raise ProviderAPIError(msg) from exc - try: - payload = response.json() - except (ValueError, httpx2.DecodingError) as exc: - msg = "malformed JSON in response body" - raise ProviderAPIError(msg) from exc - return schema.model_validate(payload) -``` - -Add the import at the top of `_http.py`: - -```python -from semvertag._errors import ProviderAPIError -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/unit/test_http_client.py -v` -Expected: 3 PASS (original happy-path + the two new error cases). - -- [ ] **Step 5: Commit** - -```bash -git add semvertag/providers/_http.py tests/unit/test_http_client.py -git commit -m "providers: translate RequestError and JSON decode errors - -HttpClient.request now wraps httpx2.RequestError -> ProviderAPIError and -catches non-JSON response bodies." -``` - ---- - -### Task 6: Add schema-validation failure tests (missing field, wrong type) - -**Files:** -- Modify: `tests/unit/test_http_client.py` -- Modify: `semvertag/providers/_http.py` - -- [ ] **Step 1: Add two failing tests** - -Append to `tests/unit/test_http_client.py`: - -```python -def test_request_translates_missing_field_to_provider_api_error() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json={"name": "alice"}) # missing 'count' - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="response shape"): - http.request("GET", "/things/1", schema=_SampleResponse) - - -def test_request_translates_wrong_field_type_to_provider_api_error() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json={"name": "alice", "count": "seven"}) - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="response shape"): - http.request("GET", "/things/1", schema=_SampleResponse) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/unit/test_http_client.py -v` -Expected: 2 FAILs (the pydantic `ValidationError` leaks out instead of becoming `ProviderAPIError`). - -- [ ] **Step 3: Wrap pydantic validation** - -Update the `request` method's final line in `semvertag/providers/_http.py`: - -```python - try: - return schema.model_validate(payload) - except pydantic.ValidationError as exc: - msg = f"response shape invalid: {exc}" - raise ProviderAPIError(msg) from exc -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/unit/test_http_client.py -v` -Expected: 5 PASS. - -- [ ] **Step 5: Commit** - -```bash -git add semvertag/providers/_http.py tests/unit/test_http_client.py -git commit -m "providers: translate pydantic ValidationError -> ProviderAPIError - -The full pydantic error (with field path and type) is preserved in the -message so failures stay diagnosable." -``` - ---- - -### Task 7: Add `status_translator` hook test (runs before validation) - -**Files:** -- Modify: `tests/unit/test_http_client.py` -- Modify: `semvertag/providers/_http.py` - -- [ ] **Step 1: Add the failing test** - -Append to `tests/unit/test_http_client.py`: - -```python -from semvertag._errors import AuthError - - -def test_status_translator_runs_before_json_decode_and_validation() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - # Body is HTML, not JSON — would fail both decode and schema validation. - return httpx2.Response(401, text="Unauthorized") - - def translator(status: int) -> None: - if status == 401: - msg = "token rejected" - raise AuthError(msg) - - http: typing.Final = HttpClient( - client=httpx2.Client(transport=httpx2.MockTransport(handler), base_url=_BASE_URL), - auth_headers=lambda: {}, - status_translator=translator, - ) - - with pytest.raises(AuthError, match="token rejected"): - http.request("GET", "/things/1", schema=_SampleResponse) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/unit/test_http_client.py::test_status_translator_runs_before_json_decode_and_validation -v` -Expected: FAIL — the translator is not invoked; instead `ProviderAPIError` raises from `response.json()`. - -- [ ] **Step 3: Invoke the translator after the request** - -Update the `request` method in `_http.py` to call `status_translator(response.status_code)` between the request and the JSON decode: - -```python - def request(self, method: str, url: str, *, schema: type[T], **kwargs: typing.Any) -> T: - try: - response = self.client.request(method, url, headers=self.auth_headers(), **kwargs) - except httpx2.RequestError as exc: - msg = f"request failed: {type(exc).__name__}" - raise ProviderAPIError(msg) from exc - self.status_translator(response.status_code) - try: - payload = response.json() - except (ValueError, httpx2.DecodingError) as exc: - msg = "malformed JSON in response body" - raise ProviderAPIError(msg) from exc - try: - return schema.model_validate(payload) - except pydantic.ValidationError as exc: - msg = f"response shape invalid: {exc}" - raise ProviderAPIError(msg) from exc -``` - -- [ ] **Step 4: Run all tests to verify** - -Run: `uv run pytest tests/unit/test_http_client.py -v` -Expected: 6 PASS. - -- [ ] **Step 5: Commit** - -```bash -git add semvertag/providers/_http.py tests/unit/test_http_client.py -git commit -m "providers: invoke status_translator before JSON decode - -This ensures a 401 raises AuthError rather than trying (and failing) to -validate the error-body shape against the success schema." -``` - ---- - -### Task 8: Add `request_many` (list response) - -**Files:** -- Modify: `tests/unit/test_http_client.py` -- Modify: `semvertag/providers/_http.py` - -- [ ] **Step 1: Add failing tests** - -Append to `tests/unit/test_http_client.py`: - -```python -def test_request_many_returns_list_of_validated_instances() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json=[{"name": "a", "count": 1}, {"name": "b", "count": 2}]) - - http: typing.Final = _build_client(handler) - result: typing.Final = http.request_many("GET", "/things", schema=_SampleResponse) - assert len(result) == 2 - assert all(isinstance(item, _SampleResponse) for item in result) - assert result[0].name == "a" - assert result[1].count == 2 - - -def test_request_many_translates_dict_payload_to_provider_api_error() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json={"not": "a list"}) - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="expected list"): - http.request_many("GET", "/things", schema=_SampleResponse) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/unit/test_http_client.py -v -k request_many` -Expected: 2 FAILs (`request_many` doesn't exist). - -- [ ] **Step 3: Implement `request_many`** - -Add to `_http.py` (inside the `HttpClient` class, after `request`): - -```python - def request_many(self, method: str, url: str, *, schema: type[T], **kwargs: typing.Any) -> list[T]: - try: - response = self.client.request(method, url, headers=self.auth_headers(), **kwargs) - except httpx2.RequestError as exc: - msg = f"request failed: {type(exc).__name__}" - raise ProviderAPIError(msg) from exc - self.status_translator(response.status_code) - try: - payload = response.json() - except (ValueError, httpx2.DecodingError) as exc: - msg = "malformed JSON in response body" - raise ProviderAPIError(msg) from exc - if not isinstance(payload, list): - msg = f"response shape invalid: expected list, got {type(payload).__name__}" - raise ProviderAPIError(msg) - try: - return [schema.model_validate(item) for item in payload] - except pydantic.ValidationError as exc: - msg = f"response shape invalid: {exc}" - raise ProviderAPIError(msg) from exc -``` - -(Yes, there's duplication with `request`. We'll refactor in Task 10 once the API is settled — premature DRY now would couple us to one implementation shape.) - -- [ ] **Step 4: Run tests** - -Run: `uv run pytest tests/unit/test_http_client.py -v` -Expected: 8 PASS. - -- [ ] **Step 5: Commit** - -```bash -git add semvertag/providers/_http.py tests/unit/test_http_client.py -git commit -m "providers: add HttpClient.request_many for list responses - -Returns list[T] from a JSON-array body. Dict payloads raise -ProviderAPIError with an explicit 'expected list' message." -``` - ---- - -### Task 9: Add `request_raw` (escape hatch for create_tag and pagination) - -**Files:** -- Modify: `tests/unit/test_http_client.py` -- Modify: `semvertag/providers/_http.py` - -- [ ] **Step 1: Add failing tests** - -Append to `tests/unit/test_http_client.py`: - -```python -def test_request_raw_returns_response_with_auth_headers_applied() -> None: - captured_headers: dict[str, str] = {} - - def handler(request: httpx2.Request) -> httpx2.Response: - captured_headers.update(request.headers) - return httpx2.Response(418, text="teapot") - - http: typing.Final = _build_client(handler) - response: typing.Final = http.request_raw("GET", "/teapot") - assert response.status_code == 418 - assert response.text == "teapot" - assert captured_headers.get("x-test-auth") == "token-xyz" - - -def test_request_raw_does_not_call_status_translator() -> None: - call_count = {"n": 0} - - def translator(_status: int) -> None: - call_count["n"] += 1 - - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(500, text="server error") - - http: typing.Final = HttpClient( - client=httpx2.Client(transport=httpx2.MockTransport(handler), base_url=_BASE_URL), - auth_headers=lambda: {}, - status_translator=translator, - ) - response: typing.Final = http.request_raw("GET", "/whatever") - assert response.status_code == 500 - assert call_count["n"] == 0 - - -def test_request_raw_translates_request_error_to_provider_api_error() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - raise httpx2.ReadTimeout("timed out") - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="request failed"): - http.request_raw("GET", "/whatever") -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/unit/test_http_client.py -v -k request_raw` -Expected: 3 FAILs (`request_raw` doesn't exist). - -- [ ] **Step 3: Implement `request_raw`** - -Add to `_http.py`: - -```python - def request_raw(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Response: - try: - return self.client.request(method, url, headers=self.auth_headers(), **kwargs) - except httpx2.RequestError as exc: - msg = f"request failed: {type(exc).__name__}" - raise ProviderAPIError(msg) from exc -``` - -- [ ] **Step 4: Run tests** - -Run: `uv run pytest tests/unit/test_http_client.py -v` -Expected: 11 PASS. - -- [ ] **Step 5: Commit** - -```bash -git add semvertag/providers/_http.py tests/unit/test_http_client.py -git commit -m "providers: add HttpClient.request_raw escape hatch - -Applies auth headers and translates RequestError but does NOT call -status_translator or decode the body. For callers (create_tag, pagination) -that need to inspect status + body together." -``` - ---- - -### Task 10: Extract shared request prelude in `HttpClient` - -**Files:** -- Modify: `semvertag/providers/_http.py` - -Now that the three methods exist and their tests pin behavior, collapse the duplicated "request + RequestError translation + status_translator" prelude into one private helper. - -- [ ] **Step 1: Refactor without changing tests** - -Replace the `HttpClient` class body in `_http.py` with: - -```python -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) -class HttpClient: - client: httpx2.Client - auth_headers: AuthHeaders - status_translator: StatusTranslator - - def request(self, method: str, url: str, *, schema: type[T], **kwargs: typing.Any) -> T: - response = self._request_translated(method, url, **kwargs) - payload = self._decode_json(response) - try: - return schema.model_validate(payload) - except pydantic.ValidationError as exc: - msg = f"response shape invalid: {exc}" - raise ProviderAPIError(msg) from exc - - def request_many(self, method: str, url: str, *, schema: type[T], **kwargs: typing.Any) -> list[T]: - response = self._request_translated(method, url, **kwargs) - payload = self._decode_json(response) - if not isinstance(payload, list): - msg = f"response shape invalid: expected list, got {type(payload).__name__}" - raise ProviderAPIError(msg) - try: - return [schema.model_validate(item) for item in payload] - except pydantic.ValidationError as exc: - msg = f"response shape invalid: {exc}" - raise ProviderAPIError(msg) from exc - - def request_raw(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Response: - return self._request_raw(method, url, **kwargs) - - def _request_translated(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Response: - response = self._request_raw(method, url, **kwargs) - self.status_translator(response.status_code) - return response - - def _request_raw(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Response: - try: - return self.client.request(method, url, headers=self.auth_headers(), **kwargs) - except httpx2.RequestError as exc: - msg = f"request failed: {type(exc).__name__}" - raise ProviderAPIError(msg) from exc - - @staticmethod - def _decode_json(response: httpx2.Response) -> typing.Any: - try: - return response.json() - except (ValueError, httpx2.DecodingError) as exc: - msg = "malformed JSON in response body" - raise ProviderAPIError(msg) from exc -``` - -- [ ] **Step 2: Run all `HttpClient` tests** - -Run: `uv run pytest tests/unit/test_http_client.py -v` -Expected: 11 PASS — no behavior changes. - -- [ ] **Step 3: Lint** - -Run: `just lint-ci` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add semvertag/providers/_http.py -git commit -m "providers: refactor HttpClient internals to share request prelude - -Extract _request_raw / _request_translated / _decode_json helpers. No -behavior change; existing tests still pass." -``` - ---- - -### Task 11: Refactor `get_default_branch` to use `HttpClient` - -**Files:** -- Modify: `semvertag/providers/gitlab.py` -- Modify: `tests/conftest.py` (update `gitlab_provider` fixture signature) -- Modify: `tests/integration/test_gitlab_provider.py` (update `_make_provider` helper + adjust 3-5 error-message match patterns) - -> **Note on the migration shape:** this is the **first** method refactor and it pulls in three structural changes that the next refactor tasks will not need to repeat: -> 1. Adding the `http: HttpClient` field to `GitLabProvider` (replacing `client: httpx2.Client`). -> 2. Updating the fixture in `tests/conftest.py` to construct an `HttpClient`. -> 3. Updating the `_make_provider` helper in `tests/integration/test_gitlab_provider.py`. -> -> After this task, subsequent method refactors are pure provider-body rewrites. - -- [ ] **Step 1: Add the first schema and refactor `get_default_branch`** - -In `semvertag/providers/gitlab.py`: - -1. Add `import pydantic` to the import block. -2. Add `from semvertag.providers._http import HttpClient` to the imports. -3. Inside the file, define the first schema near the top (after the constants): - -```python -class _ProjectResponse(pydantic.BaseModel): - default_branch: str | None = None -``` - -4. Change the `GitLabProvider` dataclass field from `client: httpx2.Client` to `http: HttpClient`. -5. Replace `get_default_branch` body with: - -```python - def get_default_branch(self) -> str: - project = self.http.request( - "GET", - self._url(f"{_API_PREFIX}/{self.project_id}"), - schema=_ProjectResponse, - ) - if not project.default_branch: - msg = "Default branch missing from GitLab response. Verify the project has a default branch configured." - raise ConfigError(msg) - return project.default_branch -``` - -Leave the other provider methods unchanged for now — they'll still reference `self.client`, which no longer exists. The tests for those methods will fail until refactored. That's OK; we'll run only the `get_default_branch` tests this task. - -- [ ] **Step 2: Update `tests/conftest.py` fixture** - -Replace the `gitlab_provider` fixture with: - -```python -@pytest.fixture -def gitlab_http(gitlab_client: httpx2.Client) -> "HttpClient": - from semvertag.providers._http import HttpClient - - config: typing.Final = GitLabConfig(endpoint=GITLAB_ENDPOINT, token=pydantic.SecretStr(GITLAB_TOKEN)) - return HttpClient( - client=gitlab_client, - auth_headers=lambda: {"PRIVATE-TOKEN": config.token.get_secret_value()}, - status_translator=_make_status_translator(GITLAB_PROJECT_ID), - ) - - -@pytest.fixture -def gitlab_provider(gitlab_http: "HttpClient") -> GitLabProvider: - config: typing.Final = GitLabConfig(endpoint=GITLAB_ENDPOINT, token=pydantic.SecretStr(GITLAB_TOKEN)) - return GitLabProvider(config=config, project_id=GITLAB_PROJECT_ID, http=gitlab_http) -``` - -Add this helper at module level in `conftest.py` (above the fixtures): - -```python -def _make_status_translator(project_id: int) -> typing.Callable[[int], None]: - from semvertag.providers.gitlab import _translate_status - - def translator(status: int) -> None: - _translate_status(status, project_id) - - return translator -``` - -Add the `from semvertag.providers._http import HttpClient` import at the top (or use the inline imports as shown — either works; pick whichever the linter prefers). - -- [ ] **Step 3: Update `tests/integration/test_gitlab_provider.py` `_make_provider` helper** - -Replace `_make_provider` (around line 42): - -```python -def _make_provider(handler: HandlerCallable) -> tuple[GitLabProvider, httpx2.Client]: - from semvertag.providers._http import HttpClient - from semvertag.providers.gitlab import _translate_status - - transport: typing.Final = httpx2.MockTransport(handler) - client: typing.Final = httpx2.Client(transport=transport, base_url=GITLAB_ENDPOINT) - config: typing.Final = GitLabConfig(endpoint=GITLAB_ENDPOINT, token=pydantic.SecretStr(GITLAB_TOKEN)) - http: typing.Final = HttpClient( - client=client, - auth_headers=lambda: {"PRIVATE-TOKEN": config.token.get_secret_value()}, - status_translator=lambda status: _translate_status(status, GITLAB_PROJECT_ID), - ) - provider: typing.Final = GitLabProvider(config=config, project_id=GITLAB_PROJECT_ID, http=http) - return provider, client -``` - -Do the same for `_make_provider_with_retrying_transport`. - -- [ ] **Step 4: Update `get_default_branch` integration-test message patterns** - -In `tests/integration/test_gitlab_provider.py`, find the three tests for `get_default_branch` error paths (around lines 99-114) and update the match patterns: - -```python -# test_raises_provider_api_error_when_default_branch_response_malformed -with client, pytest.raises(ProviderAPIError, match="response shape"): - provider.get_default_branch() - -# test_raises_provider_api_error_when_default_branch_body_is_not_json -with client, pytest.raises(ProviderAPIError, match="malformed JSON"): - provider.get_default_branch() -``` - -(The Pydantic-based wrapper produces different message text than the old hand-coded checks. The new messages are more informative — they include the field path.) - -- [ ] **Step 5: Run the `get_default_branch` tests** - -Run: `uv run pytest tests/integration/test_gitlab_provider.py -v -k "default_branch or protocol"` -Expected: PASS. Other tests may fail — that's expected; we haven't refactored them yet. - -- [ ] **Step 6: Commit** - -```bash -git add semvertag/providers/gitlab.py semvertag/providers/_http.py tests/conftest.py tests/integration/test_gitlab_provider.py -git commit -m "providers: refactor get_default_branch via HttpClient - -GitLabProvider now takes http: HttpClient instead of client: httpx2.Client. -Other provider methods are temporarily broken (will be refactored in -subsequent commits)." -``` - ---- - -### Task 12: Refactor `get_latest_commit_on_default_branch` - -**Files:** -- Modify: `semvertag/providers/gitlab.py` -- Modify: `tests/integration/test_gitlab_provider.py` (3 error-message match updates) - -- [ ] **Step 1: Add schema and refactor method** - -In `gitlab.py`, near the other schemas: - -```python -class _CommitItem(pydantic.BaseModel): - id: str - message: str -``` - -Replace `get_latest_commit_on_default_branch` body: - -```python - def get_latest_commit_on_default_branch(self) -> Commit: - default_branch: typing.Final = self.get_default_branch() - items = self.http.request_many( - "GET", - self._url(f"{_API_PREFIX}/{self.project_id}/repository/commits"), - schema=_CommitItem, - params={"ref_name": default_branch, "per_page": 1}, - ) - if not items: - msg = f"No commits on default branch '{default_branch}'. The branch appears empty." - raise ProviderAPIError(msg) - head = items[0] - return Commit(sha=head.id, message=head.message) -``` - -- [ ] **Step 2: Update integration-test match patterns** - -In `tests/integration/test_gitlab_provider.py`, update the `get_latest_commit` error tests: - -```python -# test_get_latest_commit_raises_provider_api_error_when_body_not_json -match="malformed JSON" - -# test_get_latest_commit_raises_provider_api_error_when_body_not_list -match="expected list" - -# test_get_latest_commit_raises_provider_api_error_when_commit_object_missing_keys -match="response shape" -``` - -- [ ] **Step 3: Run the relevant tests** - -Run: `uv run pytest tests/integration/test_gitlab_provider.py -v -k "latest_commit or default_branch or protocol"` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add semvertag/providers/gitlab.py tests/integration/test_gitlab_provider.py -git commit -m "providers: refactor get_latest_commit_on_default_branch via HttpClient" -``` - ---- - -### Task 13: Refactor `list_tags` (request_raw + module-level validation helper) - -**Files:** -- Modify: `semvertag/providers/gitlab.py` -- Modify: `tests/integration/test_gitlab_provider.py` (match-pattern updates) - -`list_tags` is the trickiest method because it paginates via the GitLab `Link` header. Pagination needs simultaneous access to body + headers, which `request_many` doesn't expose. So all pages go through `request_raw` with explicit schema validation via a module-level helper. - -- [ ] **Step 1: Add schemas** - -In `gitlab.py`, near the other schemas: - -```python -class _TagCommit(pydantic.BaseModel): - id: str - - -class _TagItem(pydantic.BaseModel): - name: str - commit: _TagCommit -``` - -- [ ] **Step 2: Use `request_raw` uniformly across pages** - -Pagination needs simultaneous access to the body AND the `Link` header. `request_many` doesn't expose headers, so all pages go through `request_raw` with explicit schema validation via a module-level helper. This keeps the pagination loop uniform. - -Replace `list_tags` body: - -```python - def list_tags(self) -> list[Tag]: - tags: list[Tag] = [] - base_url: typing.Final = self._url(f"{_API_PREFIX}/{self.project_id}/repository/tags") - url: str = base_url - params: dict[str, typing.Any] | None = {"per_page": _TAGS_PER_PAGE, "page": 1} - for _ in range(_MAX_TAG_PAGES): - response = self.http.request_raw("GET", url, params=params) - _translate_status(response.status_code, self.project_id) - items = _validate_tag_list(response) - tags.extend(Tag(name=item.name, commit_sha=item.commit.id) for item in items) - next_url = _next_page_url(response, current_url=url) - if next_url is None: - return tags - if not _same_origin(next_url, self.config.endpoint): - msg = ( - "GitLab pagination Link header points to a different host than SEMVERTAG_GITLAB__ENDPOINT. " - "Refusing to follow to protect credentials." - ) - raise ProviderAPIError(msg) - url, params = next_url, None - msg = ( - f"Tag pagination exceeded {_MAX_TAG_PAGES} pages. " - "The project has an unexpected number of tags; please file an issue." - ) - raise ProviderAPIError(msg) -``` - -Add the module-level helper near the other `_*` helpers in `gitlab.py`: - -```python -def _validate_tag_list(response: httpx2.Response) -> list[_TagItem]: - try: - payload = response.json() - except (ValueError, httpx2.DecodingError) as exc: - msg = "GitLab tags response malformed JSON." - raise ProviderAPIError(msg) from exc - if not isinstance(payload, list): - msg = "GitLab tags response shape invalid: expected list." - raise ProviderAPIError(msg) - try: - return [_TagItem.model_validate(item) for item in payload] - except pydantic.ValidationError as exc: - msg = f"GitLab tags response shape invalid: {exc}" - raise ProviderAPIError(msg) from exc -``` - -> This explicit pagination loop is messier than the other methods because it -> needs simultaneous access to body + Link header. That's by design — the -> spec called out pagination as a case where `request_raw` is the right tool -> and per-call validation lives in the provider. If this pattern recurs in -> GitHub later, we'll add a `request_paginated` helper to HttpClient then. - -- [ ] **Step 3: Update integration-test match patterns for list_tags** - -Run `grep -n "list_tags\|tags response" tests/integration/test_gitlab_provider.py` to find the affected tests. Update each `match=` pattern that asserts on tag-list error messages to one of the new wordings: `"malformed JSON"`, `"shape invalid"`, or `"expected list"` — whichever fits the failure mode being tested. The pagination Link-header tests (`"different host"`, `"exceeded ... pages"`) should NOT need changes — those messages are unchanged. - -If a test you can't immediately match comes up, run it first (`uv run pytest tests/integration/test_gitlab_provider.py:: -v`) — the pytest output shows the actual exception message, which tells you which new fragment to assert on. - -- [ ] **Step 4: Run list_tags tests** - -Run: `uv run pytest tests/integration/test_gitlab_provider.py -v -k "list_tags or tags"` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add semvertag/providers/gitlab.py tests/integration/test_gitlab_provider.py -git commit -m "providers: refactor list_tags via HttpClient with explicit pagination - -Pagination requires raw response access (Link header), so list_tags uses -request_raw uniformly across pages with a module-level _validate_tag_list -helper for body validation." -``` - ---- - -### Task 14: Refactor `create_tag` - -**Files:** -- Modify: `semvertag/providers/gitlab.py` -- Modify: `tests/integration/test_gitlab_provider.py` (likely no match-pattern changes — `create_tag`'s error messages are mostly provider-built, not wrapper-built) - -- [ ] **Step 1: Refactor `create_tag` body** - -Replace: - -```python - def create_tag(self, name: str, commit_sha: str) -> None: - response = self.http.request_raw( - "POST", - self._url(f"{_API_PREFIX}/{self.project_id}/repository/tags"), - json={"tag_name": name, "ref": commit_sha}, - ) - if response.status_code == _HTTP_CREATED: - return - if response.status_code == _HTTP_BAD_REQUEST: - body_message = "" - try: - payload = response.json() - body_message = str(payload.get("message", "")) if isinstance(payload, dict) else "" - except (ValueError, httpx2.DecodingError): - pass - if _TAG_EXISTS_FRAGMENT in body_message.lower(): - msg = f"Tag already exists: '{name}'. The tag was created by a concurrent run or previous invocation." - raise ConfigError(msg) - msg = "Request rejected by GitLab: 400. Check tag name format and that the referenced commit exists." - raise ConfigError(msg) - _translate_status(response.status_code, self.project_id) -``` - -- [ ] **Step 2: Run create_tag tests** - -Run: `uv run pytest tests/integration/test_gitlab_provider.py -v -k create_tag` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add semvertag/providers/gitlab.py -git commit -m "providers: refactor create_tag via HttpClient.request_raw - -create_tag still needs raw response access for the 'tag already exists' -400 branch. The wrapper kills the RequestError handling boilerplate." -``` - ---- - -### Task 15: Refactor `check_token`, `check_scopes`, `check_project_access`, `check_protected_tags` - -**Files:** -- Modify: `semvertag/providers/gitlab.py` - -The `check_*` methods don't raise — they return `CheckResult` with `status="failed"` and a human-readable cause. They need `request_raw` so they can convert `ProviderAPIError` (from `RequestError`) into a `CheckResult` rather than propagating. - -- [ ] **Step 1: Refactor `_safe_get` to use `HttpClient.request_raw`** - -Replace `_safe_get` in `gitlab.py`: - -```python - def _safe_get(self, url: str) -> tuple[httpx2.Response | None, str | None]: - try: - return self.http.request_raw("GET", url), None - except ProviderAPIError as exc: - cause = exc.__cause__ - error_kind = type(cause).__name__ if isinstance(cause, httpx2.RequestError) else "RequestError" - return None, error_kind -``` - -The `check_*` method bodies stay structurally the same — they already use `_safe_get`. The only change is that `_safe_get` now goes through `HttpClient`, which applies auth headers and translates `RequestError` to `ProviderAPIError`. We recover the original exception class name via `exc.__cause__` (which `raise ... from exc` populates), preserving the `error_type` strings the existing tests expect (e.g. `"ReadTimeout"`, `"ConnectError"`). - -- [ ] **Step 2: Run all check_* tests** - -Run: `uv run pytest tests/integration/test_gitlab_provider.py -v -k "check_"` -Expected: PASS. If any fail because of message-text expectations, adjust the test or restore the original `ProviderAPIError` message format in `_http.py` to include the exception class name verbatim. - -- [ ] **Step 3: Commit** - -```bash -git add semvertag/providers/gitlab.py tests/integration/test_gitlab_provider.py -git commit -m "providers: route check_* methods through HttpClient.request_raw - -_safe_get now wraps HttpClient.request_raw and converts ProviderAPIError -back into the (response | None, error_kind | None) shape that the check_* -methods consume. The check_* method bodies are otherwise unchanged." -``` - ---- - -### Task 16: Update DI wiring in `ioc.py` - -**Files:** -- Modify: `semvertag/ioc.py` -- Modify: `tests/unit/test_ioc.py` if it asserts on the provider's `client` field - -- [ ] **Step 1: Update `_construct_gitlab_provider`** - -Replace it in `semvertag/ioc.py`: - -```python -def _construct_gitlab_provider( - settings: Settings, - transport: httpx2.BaseTransport, -) -> "GitLabProvider": - from semvertag.providers._http import HttpClient # noqa: PLC0415 - from semvertag.providers.gitlab import GitLabProvider, _translate_status # noqa: PLC0415 - - if settings.project_id is None: - msg = "Project id missing. Set CI_PROJECT_ID or pass --project-id." - raise ConfigError(msg) - client: typing.Final = httpx2.Client( - transport=transport, - base_url=settings.gitlab.endpoint, - timeout=settings.request_timeout, - ) - project_id: typing.Final = settings.project_id - http: typing.Final = HttpClient( - client=client, - auth_headers=lambda: {"PRIVATE-TOKEN": settings.gitlab.token.get_secret_value()}, - status_translator=lambda status: _translate_status(status, project_id), - ) - return GitLabProvider( - config=settings.gitlab, - project_id=project_id, - http=http, - ) -``` - -- [ ] **Step 2: Update `_close_provider_client` finalizer** - -The finalizer needs to close the underlying httpx2 client, which now lives at `provider.http.client`: - -```python -def _close_provider_client(provider: "GitLabProvider") -> None: - provider.http.client.close() -``` - -- [ ] **Step 3: Run the full test suite** - -Run: `uv run pytest -x -q` -Expected: 425+ PASS. Any failures here mean something missed in the refactor. - -- [ ] **Step 4: Commit** - -```bash -git add semvertag/ioc.py tests/unit/test_ioc.py -git commit -m "ioc: wire HttpClient into GitLabProvider construction - -_construct_gitlab_provider now builds the HttpClient with the per-provider -auth-header and status-translator callables. The finalizer closes -provider.http.client (the underlying httpx2 client)." -``` - ---- - -### Task 17: Cleanup dead code in `gitlab.py` - -**Files:** -- Modify: `semvertag/providers/gitlab.py` - -- [ ] **Step 1: Scan for dead imports and helpers** - -Run: `uv run ruff check semvertag/providers/gitlab.py` -Expected: warnings for any now-unused imports (e.g. possibly `httpx2` if no method references it directly any more) and unused helpers. - -- [ ] **Step 2: Delete what's unused** - -Delete any import or helper flagged by `ruff` as unused. Likely candidates after the refactor: -- `_safe_get` if no longer referenced (it might still be — the refactor kept it as a one-liner). -- `_request_failed_message` (subsumed by `HttpClient`). -- Possibly direct `httpx2` imports if no symbol from it is still referenced. - -Do NOT delete: -- `_translate_status` — still called by `list_tags`, `create_tag`, and the wrapper-injected translator. -- Pagination helpers (`_next_page_url`, `_same_origin`, `_LINK_ENTRY_RE`, `_parse_rel_values`). -- HTTP status constants — still used by `create_tag` and `_translate_status`. - -- [ ] **Step 3: Verify** - -Run: `uv run ruff check semvertag/providers/gitlab.py` -Expected: no warnings. - -Run: `uv run pytest -x -q` -Expected: 425+ PASS. - -- [ ] **Step 4: Commit** - -```bash -git add semvertag/providers/gitlab.py -git commit -m "providers: drop dead helpers superseded by HttpClient" -``` - ---- - -### Task 18: Pre-review verification gate - -**Files:** none modified. - -This is the `superpowers:verification-before-completion` gate. Run each command and capture its full output before claiming work complete. - -- [ ] **Step 1: Lint** - -Run: `just lint-ci` -Expected: all four checks (`eof-fixer`, `ruff format --check`, `ruff check --no-fix`, `ty check`) PASS. - -- [ ] **Step 2: Full test suite** - -Run: `uv run pytest` -Expected: 425+ tests PASS, no failures, no errors. - -- [ ] **Step 3: Branch-coverage gates** - -Run: `just test-branch-strategies` -Expected: 100% branch coverage on `semvertag.strategies.branch_prefix`. - -Run: `just test-cc-strategies` -Expected: 100% branch coverage on `semvertag.strategies.conventional_commits`. - -Run: `just test-doctor` -Expected: 100% branch coverage on `semvertag.doctor`. - -- [ ] **Step 4: Provider branch coverage check (informal)** - -Run: `uv run pytest -o "addopts=" --cov=semvertag.providers --cov-branch --cov-report=term-missing tests/` -Expected: 100% branch coverage on `semvertag.providers` (no Missing lines reported). If anything is missing, add a unit or integration test before proceeding. - -- [ ] **Step 5: Docs build** - -Run: `uv run mkdocs build --strict` -Expected: builds clean (~1s). - -- [ ] **Step 6: LOC delta sanity check** - -Run: `git diff main --stat -- semvertag/providers/gitlab.py` -Expected: net negative LOC of roughly ~150-200 lines on `gitlab.py`. If it's positive or near-zero, the wrapper isn't pulling weight — pause and review before requesting code review. - ---- - -### Task 19: Code review via subagent - -**Files:** none modified. - -- [ ] **Step 1: Invoke `superpowers:requesting-code-review`** - -Use the skill to dispatch a code-review subagent against the worktree branch. The subagent reviews the diff between `main` and `HEAD` of the worktree. - -- [ ] **Step 2: Receive review via `superpowers:receiving-code-review`** - -Use the receiving-code-review skill to process findings. Verify each finding rather than blindly accepting; address legitimate issues by adding new commits in the worktree. - -- [ ] **Step 3: Re-run the verification gate (Task 18) if any code changed** - -If the review prompted any code changes, re-run all of Task 18 before proceeding to land. - ---- - -### Task 20: Land the worktree - -**Files:** none modified by hand; merge/rebase + cleanup. - -- [ ] **Step 1: Invoke `superpowers:finishing-a-development-branch`** - -Use the skill to pick the right landing path (PR or fast-forward merge) and clean up the worktree afterward. - -- [ ] **Step 2: Verify the wrapper work is on `main`** - -Run (in the main checkout): `git log --oneline main..HEAD || git log --oneline -10` -Expected: the wrapper commits from the worktree are now in `main`'s history. - -Run: `just lint-ci && uv run pytest` -Expected: all green on `main`. - ---- - -## Success criteria (recap from spec) - -When all tasks above are done: - -- `_bmad/` is gone from the working tree (lives at `_archive/bmad/`). -- `planning/specs/` contains the migration spec. -- `planning/plans/` contains this plan (and `.gitkeep`). -- Repo-root `CLAUDE.md` exists and points at the Superpowers flow. -- `semvertag/providers/_http.py` contains `HttpClient`, used by every method in `gitlab.py` directly or via `_safe_get`. -- All existing tests pass; no behavior changes to the GitLab provider's external contract. -- `semvertag/providers/gitlab.py` is meaningfully shorter (target ~40–50% LOC reduction). -- Branch coverage on `semvertag.providers` remains at 100%. diff --git a/planning/changes/2026-05-31.02-drop-doctor/design.md b/planning/changes/2026-05-31.02-drop-doctor.md similarity index 100% rename from planning/changes/2026-05-31.02-drop-doctor/design.md rename to planning/changes/2026-05-31.02-drop-doctor.md diff --git a/planning/changes/2026-05-31.02-drop-doctor/plan.md b/planning/changes/2026-05-31.02-drop-doctor/plan.md deleted file mode 100644 index 4d5ff29..0000000 --- a/planning/changes/2026-05-31.02-drop-doctor/plan.md +++ /dev/null @@ -1,594 +0,0 @@ -# Drop `semvertag doctor` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Delete the `semvertag doctor` subcommand and everything that exists only to serve it — the `semvertag/doctor/` package, the four `check_*` methods on the `Provider` Protocol, and the `_provenance` tracking in `Settings` — relying on the main command's existing typed errors (`AuthError`/`ConfigError`/`ProviderAPIError`) for diagnosis. - -**Architecture:** Pure deletion, no new code. Three waves on one worktree, each leaving tests green. Wave A drops the doctor package + CLI command + doctor's own tests. Wave B drops the provider `check_*` surface (Protocol + methods + tests). Wave C drops `_provenance` machinery + Justfile recipe + doc references. - -**Tech Stack:** Python 3.10+, `pytest`, `ty`, `ruff`, `uv`, `just`. Nothing new. - -**Spec:** `planning/specs/2026-05-31-drop-doctor-design.md` - ---- - -## Task 1: Spawn worktree and verify baseline - -**Files:** none in main checkout (worktree creation). - -- [ ] **Step 1: Spawn the worktree** - -Use the `superpowers:using-git-worktrees` skill. Suggested branch name: `feat/drop-doctor`. Suggested path: `.worktrees/feat-drop-doctor` (matches the existing project-local pattern from the previous feature). - -- [ ] **Step 2: Verify clean baseline inside the worktree** - -Run (in the worktree): `git status` — must be clean, on branch `feat/drop-doctor`. - -Run: `just lint-ci` -Expected: PASS (eof-fixer, ruff format, ruff check, ty check all green). - -Run: `uv run pytest -q` -Expected: 438 passed, 1 skipped. - -Run: `uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -If any baseline check fails, stop and report — the merged main is not in the expected state and the plan can't proceed. - ---- - -## Task 2: Wave A — Drop the doctor subsystem - -**Files:** -- Delete: `semvertag/doctor/__init__.py` -- Delete: `semvertag/doctor/_checks.py` -- Delete: `semvertag/doctor/_render.py` -- Modify: `semvertag/__main__.py` (remove the `_doctor_command`, `_collect_doctor_overrides`, and the two `from semvertag.doctor …` imports) -- Delete: `tests/integration/test_cli_doctor.py` -- Delete: `tests/unit/test_doctor_checks.py` -- Delete: `tests/unit/test_doctor_render.py` - -- [ ] **Step 1: Delete the doctor package** - -```bash -git rm -r semvertag/doctor -``` - -- [ ] **Step 2: Delete the doctor test files** - -```bash -git rm tests/integration/test_cli_doctor.py tests/unit/test_doctor_checks.py tests/unit/test_doctor_render.py -``` - -- [ ] **Step 3: Remove doctor imports from `semvertag/__main__.py`** - -In `semvertag/__main__.py`, delete these two import lines (currently at lines 14-15): - -```python -from semvertag.doctor._checks import resolve_exit_code, run_checks -from semvertag.doctor._render import build_doctor_result, render_doctor_human, render_doctor_json -``` - -- [ ] **Step 4: Remove `_doctor_command` from `semvertag/__main__.py`** - -Delete the entire `_doctor_command` function (currently `@MAIN_APP.command("doctor")` decorator at line 200, function body spans roughly lines 200-283). The function starts: - -```python -@MAIN_APP.command("doctor") -def _doctor_command( # noqa: PLR0913 - project_id: typing.Annotated[ - ... -``` - -and ends with: - -```python - except OSError as exc: - if exc.errno == errno.EPIPE: - raise typer.Exit(code=0) from exc - raise -``` - -Delete everything from the `@MAIN_APP.command("doctor")` decorator through the trailing `raise` (inclusive). - -- [ ] **Step 5: Remove `_collect_doctor_overrides` from `semvertag/__main__.py`** - -Delete the `_collect_doctor_overrides` function (currently around lines 72-94). The whole function: - -```python -def _collect_doctor_overrides( # noqa: PLR0913 - *, - project_id: int | None, - provider: str | None, - token: str | None, - default_branch: str | None, - gitlab_endpoint: str | None, - request_timeout: float | None, -) -> dict[str, tuple[typing.Any, str]]: - overrides: dict[str, tuple[typing.Any, str]] = {} - if project_id is not None: - overrides["project_id"] = (project_id, "--project-id") - if provider is not None: - overrides["provider"] = (provider, "--provider") - if token is not None: - overrides["gitlab.token"] = (pydantic.SecretStr(token), "--token") - if default_branch is not None: - overrides["default_branch"] = (default_branch, "--default-branch") - if gitlab_endpoint is not None: - overrides["gitlab.endpoint"] = (gitlab_endpoint, "--gitlab-endpoint") - if request_timeout is not None: - overrides["request_timeout"] = (request_timeout, "--request-timeout") - return overrides -``` - -- [ ] **Step 6: Verify lint and tests** - -Run: `just lint-ci` -Expected: PASS. If ruff flags any now-unused imports in `__main__.py` (e.g. anything that was only used by the doctor command), let `ruff check --fix` remove them by running `just lint` (which uses `--fix`), then re-run `just lint-ci`. - -Run: `uv run pytest -q` -Expected: PASS. Test count drops from 438 by however many doctor tests were deleted (likely ~50-70 tests). No failures. - -- [ ] **Step 7: Verify `doctor` subcommand is gone** - -Run: `uv run semvertag --help` -Expected output: no `doctor` subcommand listed. Only the main callback options. - -- [ ] **Step 8: Commit** - -```bash -git add -A -git commit -m "feat: drop doctor subcommand and its package - -The semvertag doctor subcommand and the entire semvertag/doctor/ package -are removed. The main semvertag command's existing typed errors -(AuthError/ConfigError/ProviderAPIError) already produce the same exit -codes for the same failure modes that doctor diagnosed, so no -replacement is needed. - -Provider check_* methods and Settings._provenance machinery remain in -place temporarily and will be removed in subsequent commits." -``` - ---- - -## Task 3: Wave B — Drop the provider `check_*` surface - -**Files:** -- Modify: `semvertag/providers/_base.py` (drop 4 `check_*` from Protocol) -- Modify: `semvertag/providers/gitlab.py` (drop 4 methods + `_safe_get` + `_evaluate_scopes_payload` + dead constants) -- Modify: `tests/integration/test_gitlab_provider.py` (drop check_* section + shrink protocol-conformance list) - -- [ ] **Step 1: Shrink the `Provider` Protocol** - -In `semvertag/providers/_base.py`, delete the four `check_*` method declarations (currently lines 14-17): - -```python - def check_token(self) -> CheckResult: ... - def check_scopes(self) -> CheckResult: ... - def check_project_access(self) -> CheckResult: ... - def check_protected_tags(self) -> CheckResult: ... -``` - -After deletion, the `Provider` Protocol body should have 5 members: `name`, `get_default_branch`, `get_latest_commit_on_default_branch`, `list_tags`, `create_tag`. - -Check the imports at the top of `_base.py`. If `CheckResult` is no longer referenced (it was likely the only doctor-related symbol imported there), remove it from the `from semvertag._types import …` line. The post-edit imports should be: - -```python -from semvertag._types import Commit, Tag -``` - -- [ ] **Step 2: Drop the four `check_*` methods from `gitlab.py`** - -In `semvertag/providers/gitlab.py`: - -1. Delete `check_token` (currently starts around line 141, ends just before `check_scopes`). -2. Delete `check_scopes` (currently starts around line 170, ends just before `check_project_access`). -3. Delete `check_project_access` (currently starts around line 185, ends just before `check_protected_tags`). -4. Delete `check_protected_tags` (currently starts around line 224, ends just before `_auth_headers` or `_url`). - -- [ ] **Step 3: Drop `_safe_get` from `gitlab.py`** - -In the same file, delete the `_safe_get` method (currently around line 260-269). It looks like: - -```python - def _safe_get(self, url: str) -> tuple[httpx2.Response | None, str | None]: - try: - return self.http.request_raw("GET", url), None - except ProviderAPIError as exc: - cause = exc.__cause__ - error_kind = type(cause).__name__ if isinstance(cause, httpx2.RequestError) else "RequestError" - return None, error_kind -``` - -- [ ] **Step 4: Drop `_evaluate_scopes_payload` from `gitlab.py`** - -In the same file, delete the module-level `_evaluate_scopes_payload` function (currently around line 330). It looks like: - -```python -def _evaluate_scopes_payload(response: httpx2.Response) -> CheckResult: - try: - payload = response.json() - except (ValueError, httpx2.DecodingError): - payload = None - if not isinstance(payload, dict): - return CheckResult( - name="scopes", - ... -``` - -Delete the entire function through its `return CheckResult(...)` tail. - -- [ ] **Step 5: Drop unused constants from `gitlab.py`** - -After Steps 2-4, several module-level constants no longer have any caller. Delete: - -```python -_USER_PATH: typing.Final = "/api/v4/user" # line ~16 -_TOKEN_INTROSPECTION_PATH: typing.Final = "/api/v4/personal_access_tokens/self" # line ~17 -_API_SCOPE: typing.Final = "api" # line ~34 -``` - -Do NOT preemptively delete other constants. Run `ruff check` after this step (see Step 6) and let it tell you about any additional unused symbols. - -- [ ] **Step 6: Check for unused imports/symbols and update imports** - -Run: `uv run ruff check semvertag/providers/gitlab.py` -Expected: may flag unused imports (e.g. `CheckResult` import from `_types` if it was only used by check_* methods). - -If `CheckResult` is no longer referenced in `gitlab.py`, remove it from the import statement: - -```python -# Before: -from semvertag._types import CheckResult, Commit, Tag -# After: -from semvertag._types import Commit, Tag -``` - -Run `just lint` to auto-fix anything else ruff catches. Then verify with `just lint-ci`. - -- [ ] **Step 7: Drop the check_* test section from `tests/integration/test_gitlab_provider.py`** - -The doctor test section starts with the section marker: - -```python -# AC8 -- Doctor methods -``` - -at line 581 of the current file (line numbers may shift slightly after Wave A; search for the marker text). Delete everything from this line through the end of the file. The file currently ends at line 751 with the last `check_*` test (`test_check_token_returns_failed_on_403`). - -- [ ] **Step 8: Shrink the protocol-conformance test in the same file** - -In `tests/integration/test_gitlab_provider.py`, find `test_gitlab_provider_exposes_every_member_required_by_protocol` (around line 75). Update the `expected_members` tuple to drop the four `check_*` names: - -```python -def test_gitlab_provider_exposes_every_member_required_by_protocol() -> None: - expected_members: typing.Final = ( - "name", - "get_default_branch", - "get_latest_commit_on_default_branch", - "list_tags", - "create_tag", - ) - for member in expected_members: - assert hasattr(GitLabProvider, member), f"GitLabProvider is missing Provider member: {member!r}" - assert Provider.__name__ == "Provider" -``` - -- [ ] **Step 9: Verify lint and tests** - -Run: `just lint-ci` -Expected: PASS. - -Run: `uv run pytest -q` -Expected: PASS. Test count drops further by however many check_* tests were in the section (likely ~17 tests per the grep). No failures. - -- [ ] **Step 10: Commit** - -```bash -git add -A -git commit -m "providers: drop check_* methods after doctor removal - -The Provider Protocol now has 5 members instead of 9. GitLabProvider's -four check_* methods, the _safe_get helper, _evaluate_scopes_payload, -and the constants only they consumed (_USER_PATH, -_TOKEN_INTROSPECTION_PATH, _API_SCOPE) are removed." -``` - ---- - -## Task 4: Wave C — Drop `_provenance` and remaining references - -**Files:** -- Modify: `semvertag/_settings.py` (drop `_provenance` machinery) -- Delete: `tests/unit/test_provenance.py` -- Modify: `Justfile` (drop `test-doctor` recipe) -- Modify: `CLAUDE.md` (drop `test-doctor` bullet) -- Modify: `docs/providers/gitlab.md` (drop doctor mentions on lines 75 and 160) - -- [ ] **Step 1: Drop `_provenance` field from `Settings`** - -In `semvertag/_settings.py`, delete the `_provenance` `PrivateAttr` line in the `Settings` class (currently line 75): - -```python - _provenance: dict[str, ConfigSource] = pydantic.PrivateAttr(default_factory=dict) -``` - -- [ ] **Step 2: Drop `_record_env_provenance` from the `Settings` class** - -In the same file, delete the model_validator method (currently lines 120-123): - -```python - @pydantic.model_validator(mode="after") - def _record_env_provenance(self) -> "Settings": - _scan_model(self, "", self._provenance) - return self -``` - -- [ ] **Step 3: Drop the provenance-only module helpers** - -In the same file, delete these three module-level helpers (currently lines 146-171): - -```python -def _scan_model(model: pydantic.BaseModel, prefix: str, provenance: dict[str, ConfigSource]) -> None: - for field_name in type(model).model_fields: - full_key = f"{prefix}{field_name}" - value = getattr(model, field_name) - if isinstance(value, pydantic.BaseModel): - _scan_model(value, f"{full_key}.", provenance) - continue - provenance[full_key] = _resolve_source(full_key) - - -def _resolve_source(full_key: str) -> ConfigSource: - candidates: typing.Final = _candidate_env_names(full_key) - found: typing.Final = _find_aliased_env(candidates) - if found is None: - return ConfigSource(layer="default", detail="default") - matched_alias, _value = found - return ConfigSource(layer="env", detail=matched_alias) - - -def _candidate_env_names(full_key: str) -> tuple[str, ...]: - if full_key in _TOKEN_ALIASES_BY_PATH: - return _TOKEN_ALIASES_BY_PATH[full_key] - if full_key in _TOP_LEVEL_FIELD_ALIASES: - return _TOP_LEVEL_FIELD_ALIASES[full_key] - default_env_name: typing.Final = _ENV_PREFIX + full_key.upper().replace(".", _ENV_NESTED_DELIMITER) - return (default_env_name,) -``` - -- [ ] **Step 4: Trim the provenance-tracking block in `apply_cli_overlay`** - -In the same file, find `apply_cli_overlay` (currently around line 190). The function currently ends with: - -```python -def apply_cli_overlay( - settings: Settings, - overrides: dict[str, tuple[typing.Any, str]], -) -> Settings: - update_top, nested_updates = _split_overrides(settings, overrides) - for head, leaf_updates in nested_updates.items(): - update_top[head] = _revalidate_nested(settings, head, leaf_updates) - - copied: typing.Final = settings.model_copy(update=update_top, deep=True) - raw_data: typing.Final = {name: getattr(copied, name) for name in type(copied).model_fields} - new_settings: typing.Final = type(copied).model_validate(raw_data) - - new_provenance: typing.Final = dict(settings._provenance) # noqa: SLF001 - for dotted_key, (_value, flag_detail) in overrides.items(): - new_provenance[dotted_key] = ConfigSource(layer="cli", detail=flag_detail) - new_settings._provenance = new_provenance # noqa: SLF001 - return new_settings -``` - -Delete the last block (the four lines starting `new_provenance: typing.Final = dict(...)` through `new_settings._provenance = new_provenance # noqa: SLF001`). The function's new ending is: - -```python - copied: typing.Final = settings.model_copy(update=update_top, deep=True) - raw_data: typing.Final = {name: getattr(copied, name) for name in type(copied).model_fields} - new_settings: typing.Final = type(copied).model_validate(raw_data) - return new_settings -``` - -- [ ] **Step 5: Remove now-unused imports from `_settings.py`** - -The `from semvertag._types import ConfigSource` import (currently line 8) may now be unused — `ConfigSource` was only referenced by the provenance machinery. Verify by running `uv run ruff check semvertag/_settings.py` after the previous steps; if ruff flags it, remove that import line. - -If `_logger` (line 13) is still used by `_clamp_request_timeout`, leave it. (It is — the clamp logs a warning. Don't delete.) - -- [ ] **Step 6: Delete the provenance test file** - -```bash -git rm tests/unit/test_provenance.py -``` - -- [ ] **Step 7: Drop the `test-doctor` recipe from `Justfile`** - -Delete the last 3 lines of `Justfile`: - -``` -test-doctor: - uv run --no-sync pytest -o "addopts=" --cov=semvertag.doctor --cov-branch --cov-fail-under=100 --cov-report=term-missing tests/unit/test_doctor_checks.py tests/unit/test_doctor_render.py -``` - -(Plus the blank line above it.) - -- [ ] **Step 8: Drop the `test-doctor` bullet from `CLAUDE.md`** - -In `CLAUDE.md`, find this bullet around line 43: - -```markdown -- `just test-branch-strategies` / `just test-cc-strategies` / `just test-doctor` - — 100% branch coverage gates on specific modules -``` - -Edit to: - -```markdown -- `just test-branch-strategies` / `just test-cc-strategies` - — 100% branch coverage gates on specific modules -``` - -- [ ] **Step 9: Drop doctor mentions from `docs/providers/gitlab.md`** - -In `docs/providers/gitlab.md`, two doctor mentions need editing: - -Around line 75, the text currently says: - -```markdown -The component pushes a tag, so the token it uses MUST carry write -access to the repository. semvertag reads the token from these env -vars in order: `SEMVERTAG_GITLAB__TOKEN`, `SEMVERTAG_TOKEN`, -`CI_JOB_TOKEN`, `GITLAB_TOKEN`. The first set value wins. - -Run `uvx semvertag doctor` locally (or as a CI pre-flight) to confirm -the token scope is correct. The diagnostic reports the GitLab API's -response to a scope-probe call and names which scopes are missing. -``` - -Delete the entire paragraph that starts `Run \`uvx semvertag doctor\` locally` (lines 75-77 in the current file, including the leading blank line above it if it creates a double-blank). - -Around line 160, the text currently says: - -```markdown -- **`Token missing scope or insufficient permission: 403`** — the - token does not have `api` + `write_repository` scope, or the - project's protected-tag rules disallow the bot from creating tags. - Verify the `SEMVERTAG_TOKEN` scopes in GitLab UI (Settings → Access - Tokens), or run `uvx semvertag doctor` locally for a named - diagnosis. -``` - -Edit to remove the doctor reference: - -```markdown -- **`Token missing scope or insufficient permission: 403`** — the - token does not have `api` + `write_repository` scope, or the - project's protected-tag rules disallow the bot from creating tags. - Verify the `SEMVERTAG_TOKEN` scopes in GitLab UI (Settings → Access - Tokens). -``` - -- [ ] **Step 10: Verify lint, tests, and docs build** - -Run: `just lint-ci` -Expected: PASS. - -Run: `uv run pytest -q` -Expected: PASS. Test count drops by the number of provenance tests (~14 per the file size of `test_provenance.py`). - -Run: `uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -- [ ] **Step 11: Commit** - -```bash -git add -A -git commit -m "chore: drop _provenance tracking and remaining doctor references - -Settings no longer tracks per-field provenance (the only consumer was -doctor's render). Justfile drops the test-doctor recipe. CLAUDE.md and -docs/providers/gitlab.md drop doctor references." -``` - ---- - -## Task 5: Pre-merge verification gate - -**Files:** none modified. - -- [ ] **Step 1: Lint** - -Run: `just lint-ci` -Expected: PASS (all four checks). - -- [ ] **Step 2: Full test suite** - -Run: `uv run pytest` -Expected: PASS. Final count significantly lower than 438 (likely ~350-360) reflecting deleted tests, not regressions. No failures, no errors. - -- [ ] **Step 3: Branch-coverage gates that should be unaffected** - -Run: `just test-branch-strategies` -Expected: 100% on `semvertag.strategies.branch_prefix`. - -Run: `just test-cc-strategies` -Expected: 100% on `semvertag.strategies.conventional_commits`. - -(`just test-doctor` should NOT exist anymore — verify with `just --list | grep test-doctor` returning nothing.) - -- [ ] **Step 4: Docs build** - -Run: `uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -- [ ] **Step 5: LOC delta sanity check** - -Run: `git diff main --stat` -Expected: a clear net negative across the source tree (target: ~400+ LOC removed, ~38 KB of test files removed). If the delta is positive or near zero, something went wrong — review before proceeding. - -Run: `git log --oneline main..HEAD` -Expected: exactly 3 commits (one per wave). - ---- - -## Task 6: Code review via subagent - -**Files:** none modified directly; review may prompt fixup commits. - -- [ ] **Step 1: Invoke `superpowers:requesting-code-review`** - -Dispatch a code-review subagent against the worktree branch. Review scope: the full diff between `main` and `HEAD` of the worktree (3 commits). - -Brief the reviewer on: -- The work is purely deletion; expect no new code. -- The spec is at `planning/specs/2026-05-31-drop-doctor-design.md`. -- The Section 2 table of "doctor failure mode → main command equivalent" is the load-bearing assumption; ask them to verify the main command's typed errors really do produce the listed exit codes. -- Don't re-litigate the decision to remove doctor (already agreed). - -- [ ] **Step 2: Process findings with `superpowers:receiving-code-review`** - -Use the receiving-code-review skill to evaluate findings. Address legitimate Important/Critical issues with fixup commits in the worktree; note Minor issues for follow-up. - -- [ ] **Step 3: Re-run Task 5 if any code changed** - -If review prompted any code changes, re-run the full verification gate (Task 5) before proceeding. - ---- - -## Task 7: Land the worktree - -**Files:** none modified by hand; merge + cleanup. - -- [ ] **Step 1: Invoke `superpowers:finishing-a-development-branch`** - -Use the skill to merge to `main` (fast-forward expected — main hasn't moved during this work) and clean up the worktree. - -- [ ] **Step 2: Verify the work is on `main`** - -Run (in the main checkout): `git log --oneline -5` -Expected: the three drop-doctor commits are at HEAD. - -Run: `just lint-ci && uv run pytest -q` -Expected: green on `main`. - -Run: `ls semvertag/doctor 2>&1` -Expected: "No such file or directory". - ---- - -## Success criteria - -When all tasks above are done: - -- `semvertag/doctor/` no longer exists in the working tree. -- `Provider` Protocol has 5 methods (was 9). -- `Settings` class has no `_provenance` field; `_scan_model`, `_resolve_source`, `_candidate_env_names`, and `_record_env_provenance` are all gone. -- `gitlab.py` is meaningfully shorter (target: ~120 LOC reduction from the current 372). -- `_settings.py` is meaningfully shorter (target: ~50 LOC reduction). -- `__main__.py` is meaningfully shorter (target: ~100 LOC reduction). -- `Justfile` has no `test-doctor` recipe. -- `CLAUDE.md` and `docs/providers/gitlab.md` have no doctor references. -- The main `semvertag` command still raises `AuthError`/`ConfigError`/`ProviderAPIError` with exit codes 3/2/4 for the failure modes listed in the spec's Section 2 table. -- `just lint-ci`, `uv run pytest`, and `mkdocs build --strict` all green. diff --git a/planning/changes/2026-05-31.03-settings-aliaschoices/design.md b/planning/changes/2026-05-31.03-settings-aliaschoices.md similarity index 100% rename from planning/changes/2026-05-31.03-settings-aliaschoices/design.md rename to planning/changes/2026-05-31.03-settings-aliaschoices.md diff --git a/planning/changes/2026-05-31.03-settings-aliaschoices/plan.md b/planning/changes/2026-05-31.03-settings-aliaschoices/plan.md deleted file mode 100644 index abae15c..0000000 --- a/planning/changes/2026-05-31.03-settings-aliaschoices/plan.md +++ /dev/null @@ -1,516 +0,0 @@ -# Settings `AliasChoices` Adoption Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace hand-coded env-alias machinery in `semvertag/_settings.py` with Pydantic's native `AliasChoices` by promoting `GitLabConfig` and `GitHubConfig` from `BaseModel` to `BaseSettings` and adding `validation_alias` to their `token` fields and to `Settings.project_id`. - -**Architecture:** Single file (`semvertag/_settings.py`) edited in two commits on one worktree. No tests added or modified — `tests/unit/test_settings.py` pins the alias precedence and should pass unchanged. Net deletion ~75-85 LOC. - -**Tech Stack:** `pydantic`, `pydantic-settings`. Both already project deps; no new packages. - -**Spec:** `planning/specs/2026-05-31-settings-aliaschoices-design.md` - ---- - -## Task 1: Spawn worktree and verify baseline - -**Files:** none in main checkout (worktree creation). - -- [ ] **Step 1: Spawn the worktree** - -Use the `superpowers:using-git-worktrees` skill. Suggested branch: `feat/settings-aliaschoices`. Suggested path: `.worktrees/feat-settings-aliaschoices`. - -- [ ] **Step 2: Verify clean baseline inside the worktree** - -Run (inside the worktree, after `cd` and `uv sync --all-extras --group lint`): - -```bash -pwd -git branch --show-current -git status -``` - -Expected: cwd is the worktree path, branch is `feat/settings-aliaschoices`, status clean. - -Run: `just lint-ci` -Expected: PASS. - -Run: `uv run pytest -q` -Expected: 334 passed, 1 skipped. - -Run: `uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -If any baseline check fails, stop and report — main is not in the expected post-drop-doctor state. - ---- - -## Task 2: Wave A — Adopt `AliasChoices` on nested configs and `project_id` - -**Files:** -- Modify: `semvertag/_settings.py` (lines 46-52 for `GitLabConfig` and `GitHubConfig`; line 67 for `project_id`; lines 74-102 for the two model_validators) - -- [ ] **Step 1: Replace `GitLabConfig` body** - -In `semvertag/_settings.py`, find the current `GitLabConfig` definition (currently lines 46-49): - -```python -class GitLabConfig(pydantic.BaseModel): - endpoint: str = "https://gitlab.com" - token: pydantic.SecretStr = pydantic.Field(default=pydantic.SecretStr("")) -``` - -Replace it with: - -```python -class GitLabConfig(pydantic_settings.BaseSettings): - model_config = pydantic_settings.SettingsConfigDict( - env_prefix="SEMVERTAG_GITLAB__", - case_sensitive=False, - extra="ignore", - ) - - endpoint: str = "https://gitlab.com" - token: pydantic.SecretStr = pydantic.Field( - default=pydantic.SecretStr(""), - validation_alias=pydantic.AliasChoices( - "SEMVERTAG_GITLAB__TOKEN", - "SEMVERTAG_TOKEN", - "CI_JOB_TOKEN", - "GITLAB_TOKEN", - ), - ) -``` - -- [ ] **Step 2: Replace `GitHubConfig` body** - -Find the current `GitHubConfig` definition (currently lines 51-52): - -```python -class GitHubConfig(pydantic.BaseModel): - token: pydantic.SecretStr = pydantic.Field(default=pydantic.SecretStr("")) -``` - -Replace it with: - -```python -class GitHubConfig(pydantic_settings.BaseSettings): - model_config = pydantic_settings.SettingsConfigDict( - env_prefix="SEMVERTAG_GITHUB__", - case_sensitive=False, - extra="ignore", - ) - - token: pydantic.SecretStr = pydantic.Field( - default=pydantic.SecretStr(""), - validation_alias=pydantic.AliasChoices( - "SEMVERTAG_GITHUB__TOKEN", - "SEMVERTAG_TOKEN", - "GITHUB_TOKEN", - ), - ) -``` - -- [ ] **Step 3: Add `validation_alias` to `Settings.project_id`** - -In the `Settings` class, find the `project_id` field declaration (currently line 67): - -```python - project_id: int | None = pydantic.Field(default=None) -``` - -Replace it with: - -```python - project_id: int | None = pydantic.Field( - default=None, - validation_alias=pydantic.AliasChoices("SEMVERTAG_PROJECT_ID", "CI_PROJECT_ID"), - ) -``` - -- [ ] **Step 4: Delete `_inject_token_aliases` model_validator** - -In the `Settings` class, find this method (currently lines 74-87): - -```python - @pydantic.model_validator(mode="before") - @classmethod - def _inject_token_aliases(cls, data: typing.Any) -> typing.Any: # noqa: ANN401 - if not isinstance(data, dict): - return data - provider: typing.Final = _resolve_active_provider(data) - nested_key: typing.Final = _PROVIDER_TO_NESTED_KEY.get(provider) - if nested_key is None: - return data - aliases: typing.Final = _TOKEN_ALIASES_BY_PATH.get(f"{nested_key}.token") - if aliases is None: - return data - _inject_token(data, nested_key, aliases) - return data -``` - -Delete the entire method including the two decorator lines and the blank line that follows it (don't leave a double-blank). - -- [ ] **Step 5: Delete `_inject_top_level_aliases` model_validator** - -In the same class, find this method (currently lines 89-102): - -```python - @pydantic.model_validator(mode="before") - @classmethod - def _inject_top_level_aliases(cls, data: typing.Any) -> typing.Any: # noqa: ANN401 - if not isinstance(data, dict): - return data - for field_name, aliases in _TOP_LEVEL_FIELD_ALIASES.items(): - if field_name in data and data[field_name] is not None: - continue - found = _find_aliased_env(aliases) - if found is None: - continue - _matched_alias, value = found - data[field_name] = value - return data -``` - -Delete the entire method including the two decorator lines and the blank line that follows it. - -- [ ] **Step 6: Run tests** - -DO NOT touch the constants (`_GITLAB_TOKEN_ALIASES`, `_GITHUB_TOKEN_ALIASES`, etc.) or the helpers (`_resolve_active_provider`, `_inject_token`, `_find_aliased_env`, `_find_env_value`) yet. They become unused after this wave but stay until Wave B for a cleaner diff. - -Run: `uv run pytest tests/unit/test_settings.py -v` -Expected: ALL 13 tests PASS, no failures. - -If any test fails: read the failure carefully. The most common failure mode would be the `case_sensitive=False` interaction with `validation_alias` (env var names with mixed case vs the all-caps alias strings). If you see a token-resolution test failing on a case-mismatch, surface it — don't silently tweak the spec. - -Run: `uv run pytest -q` -Expected: 334 passed, 1 skipped (same as baseline). - -- [ ] **Step 7: Lint check** - -Run: `just lint-ci` -Expected: PASS. - -Note on why this should pass cleanly even though the alias machinery is now dead: the helpers and constants reference each other (`_inject_token` calls `_find_aliased_env`; `_TOKEN_ALIASES_BY_PATH` references `_GITLAB_TOKEN_ALIASES`; etc.) and `import os` is still consumed by `_find_aliased_env`. Ruff sees an internally-consistent cluster of private symbols and doesn't flag them. The cluster gets deleted wholesale in Wave B. - -If lint fails for an unexpected reason (e.g., a stray import order issue caused by the edits), fix the actual issue. Do NOT add `# noqa` suppressions to paper over anything — surface it instead. - -- [ ] **Step 8: Verify cwd before committing** - -```bash -pwd -git branch --show-current -``` - -The output MUST show `/Users/kevinsmith/src/pypi/autosemver/.worktrees/feat-settings-aliaschoices` (or whatever path the worktree was created at — definitely under `.worktrees/`) and branch `feat/settings-aliaschoices`. If `pwd` shows the main repo root or branch shows `main`, STOP — do not commit. The Wave A drop-doctor implementer accidentally committed to main because cwd was wrong; do not repeat that. - -- [ ] **Step 9: Commit** - -```bash -git add semvertag/_settings.py -git commit -m "settings: route nested token aliases through pydantic AliasChoices - -GitLabConfig and GitHubConfig are now pydantic_settings.BaseSettings -subclasses with their own env_prefix. Their token fields use -AliasChoices to express the same precedence chain that -_inject_token_aliases previously implemented manually. Settings.project_id -also gets a validation_alias for SEMVERTAG_PROJECT_ID and CI_PROJECT_ID. - -The two model_validators (_inject_token_aliases and -_inject_top_level_aliases) are removed. The alias-map constants and -helper functions are now unused; they will be deleted in the next -commit for a clean per-commit diff." -``` - ---- - -## Task 3: Wave B — Drop the dead alias machinery - -**Files:** -- Modify: `semvertag/_settings.py` (delete `import os` + 7 alias constants + 4 helper functions + any temporary `# noqa` comments added in Wave A) - -- [ ] **Step 1: Delete `import os`** - -In `semvertag/_settings.py`, find the import (currently line 2): - -```python -import os -``` - -Delete it. After this and the Wave A changes, `os` is no longer referenced anywhere in the file. - -- [ ] **Step 2: Delete the 7 alias-map module constants** - -Delete the entire block (currently lines 14-43, between `_logger` and `_REQUEST_TIMEOUT_CEILING`): - -```python -_GITLAB_TOKEN_ALIASES: typing.Final[tuple[str, ...]] = ( - "SEMVERTAG_GITLAB__TOKEN", - "SEMVERTAG_TOKEN", - "CI_JOB_TOKEN", - "GITLAB_TOKEN", -) -_GITHUB_TOKEN_ALIASES: typing.Final[tuple[str, ...]] = ( - "SEMVERTAG_GITHUB__TOKEN", - "SEMVERTAG_TOKEN", - "GITHUB_TOKEN", -) -_PROJECT_ID_ALIASES: typing.Final[tuple[str, ...]] = ( - "SEMVERTAG_PROJECT_ID", - "CI_PROJECT_ID", -) -_TOKEN_ALIASES_BY_PATH: typing.Final[dict[str, tuple[str, ...]]] = { - "gitlab.token": _GITLAB_TOKEN_ALIASES, - "github.token": _GITHUB_TOKEN_ALIASES, -} -_TOP_LEVEL_FIELD_ALIASES: typing.Final[dict[str, tuple[str, ...]]] = { - "project_id": _PROJECT_ID_ALIASES, -} -_PROVIDER_TO_NESTED_KEY: typing.Final[dict[str, str]] = { - "gitlab": "gitlab", - "github": "github", -} -``` - -Also delete `_PROVIDER_ENV_VAR` (currently line 43): - -```python -_PROVIDER_ENV_VAR: typing.Final = _ENV_PREFIX + "PROVIDER" -``` - -**Keep** `_REQUEST_TIMEOUT_CEILING`, `_ENV_PREFIX`, `_ENV_NESTED_DELIMITER` — they're still in use. - -- [ ] **Step 3: Delete `_resolve_active_provider`** - -Currently lines 118-124: - -```python -def _resolve_active_provider(data: dict[str, typing.Any]) -> str: - raw = data.get("provider") - if raw is None: - raw = _find_env_value((_PROVIDER_ENV_VAR,)) - if raw is None: - raw = "gitlab" - return str(raw).lower() -``` - -Delete the entire function. - -- [ ] **Step 4: Delete `_inject_token`** - -Currently lines 127-135: - -```python -def _inject_token(data: dict[str, typing.Any], nested_key: str, aliases: tuple[str, ...]) -> None: - nested: typing.Final = data.setdefault(nested_key, {}) - if not isinstance(nested, dict) or "token" in nested: - return - found: typing.Final = _find_aliased_env(aliases) - if found is None: - return - _matched_alias, value = found - nested["token"] = value -``` - -Delete the entire function. - -- [ ] **Step 5: Delete `_find_aliased_env`** - -Currently lines 138-144: - -```python -def _find_aliased_env(candidates: tuple[str, ...]) -> tuple[str, str] | None: - env_lower_to_value: typing.Final = {key.lower(): value for key, value in os.environ.items()} - for alias in candidates: - value = env_lower_to_value.get(alias.lower()) - if value: - return alias, value - return None -``` - -Delete the entire function. - -- [ ] **Step 6: Delete `_find_env_value`** - -Currently lines 147-151: - -```python -def _find_env_value(candidates: tuple[str, ...]) -> str | None: - found: typing.Final = _find_aliased_env(candidates) - if found is None: - return None - return found[1] -``` - -Delete the entire function. - -- [ ] **Step 7: Verify no stray `# noqa` comments** - -Run: `grep -n "noqa" semvertag/_settings.py` - -Expected: no matches. The pre-Wave-A file had two `# noqa: ANN401` comments on the two model_validators (`_inject_token_aliases` and `_inject_top_level_aliases`); Wave A deleted both methods along with their noqa annotations. If you see any matches, investigate — it may indicate a temporary suppression added during Wave A that should be cleaned up alongside the symbol it was suppressing. - -- [ ] **Step 8: Run lint and tests** - -Run: `just lint-ci` -Expected: ALL CHECKS PASS (eof-fixer, ruff format, ruff check, ty check). The unused-symbol warnings from Wave A are now resolved by deletion. - -Run: `uv run pytest -q` -Expected: 334 passed, 1 skipped. - -Run: `uv run pytest tests/unit/test_settings.py -v` -Expected: ALL 13 tests PASS. - -Run: `uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -- [ ] **Step 9: Verify file shape sanity** - -Run: `wc -l semvertag/_settings.py` -Expected: ~130-140 LOC (was 211 before Wave A; target ~75-85 LOC reduction). If the file is meaningfully larger or smaller than expected, surface the discrepancy — it could indicate something the plan missed. - -Run: `grep -nE "_GITLAB_TOKEN_ALIASES|_GITHUB_TOKEN_ALIASES|_PROJECT_ID_ALIASES|_TOKEN_ALIASES_BY_PATH|_TOP_LEVEL_FIELD_ALIASES|_PROVIDER_TO_NESTED_KEY|_PROVIDER_ENV_VAR|_resolve_active_provider|_inject_token|_find_aliased_env|_find_env_value|_inject_token_aliases|_inject_top_level_aliases" semvertag/_settings.py` -Expected: NO matches. All the deleted symbols are gone. - -Run: `grep -nE "^import os$" semvertag/_settings.py` -Expected: NO matches. - -- [ ] **Step 10: Verify cwd before committing** - -```bash -pwd -git branch --show-current -``` - -The output MUST show the worktree path and branch `feat/settings-aliaschoices`. If not, STOP. - -- [ ] **Step 11: Commit** - -```bash -git add semvertag/_settings.py -git commit -m "settings: drop hand-coded env-alias machinery superseded by AliasChoices - -Delete the 7 alias-map module constants, the 4 helper functions -(_resolve_active_provider, _inject_token, _find_aliased_env, -_find_env_value), and the now-unused 'import os'. The validation -behavior is unchanged; pydantic AliasChoices on the nested -BaseSettings subclasses produces the same precedence." -``` - ---- - -## Task 4: Pre-merge verification gate - -**Files:** none modified. - -- [ ] **Step 1: Lint** - -Run: `just lint-ci` -Expected: PASS. - -- [ ] **Step 2: Full test suite** - -Run: `uv run pytest` -Expected: 334 passed, 1 skipped (no change from baseline; no tests added or removed). - -- [ ] **Step 3: Branch-coverage gates (unaffected by this work)** - -Run: `just test-branch-strategies` -Expected: 100% on `semvertag.strategies.branch_prefix`. - -Run: `just test-cc-strategies` -Expected: 100% on `semvertag.strategies.conventional_commits`. - -- [ ] **Step 4: Docs build** - -Run: `uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -- [ ] **Step 5: LOC + commit sanity check** - -Run: `git diff main --stat -- semvertag/_settings.py` -Expected: net negative ~75-85 LOC (was 211; target ~130-140). - -Run: `git log --oneline main..HEAD` -Expected: exactly 2 commits (`settings: route nested token aliases…` and `settings: drop hand-coded env-alias machinery…`). - -- [ ] **Step 6: Smoke-test the public API by hand** - -```bash -SEMVERTAG_GITLAB__TOKEN=nested SEMVERTAG_TOKEN=flat CI_JOB_TOKEN=ci \ - uv run python -c "from semvertag._settings import Settings; s = Settings(); print('gitlab.token =', s.gitlab.token.get_secret_value())" -``` -Expected output: `gitlab.token = nested` - -```bash -CI_JOB_TOKEN=ci uv run python -c "from semvertag._settings import Settings; s = Settings(); print('gitlab.token =', s.gitlab.token.get_secret_value())" -``` -Expected output: `gitlab.token = ci` - -```bash -CI_PROJECT_ID=777 uv run python -c "from semvertag._settings import Settings; s = Settings(); print('project_id =', s.project_id)" -``` -Expected output: `project_id = 777` - -If any of these produces wrong output, the AliasChoices wiring is not working as expected. Surface it; do not silently adjust. - ---- - -## Task 5: Code review via subagent - -**Files:** none modified directly; review may prompt fixup commits. - -- [ ] **Step 1: Invoke `superpowers:requesting-code-review`** - -Dispatch a code-review subagent against the worktree branch (2 commits between `main` and HEAD). - -Brief the reviewer on: -- The work is a refactor: machinery replacement, no behavior change. -- The spec is at `planning/specs/2026-05-31-settings-aliaschoices-design.md`. The "Semantic shift to be aware of" section is the one place behavior changes (both providers' tokens populated even when only one is active); confirm the reviewer accepts this is the intended trade-off. -- Existing tests in `tests/unit/test_settings.py` should pass unchanged. If they had to be modified, that's a regression. -- The 6 smoke-test outputs from Task 4 Step 6 are the practical contract. - -- [ ] **Step 2: Process findings with `superpowers:receiving-code-review`** - -Use the receiving-code-review skill to evaluate findings. Address legitimate Important/Critical issues with fixup commits in the worktree. - -- [ ] **Step 3: Re-run Task 4 if any code changed** - -If review prompted any code changes, re-run the full verification gate (Task 4) before proceeding. - ---- - -## Task 6: Land the worktree - -**Files:** none modified by hand. - -- [ ] **Step 1: Invoke `superpowers:finishing-a-development-branch`** - -Use the skill to merge to `main` (fast-forward expected — main shouldn't have moved during this work) and clean up the worktree. - -- [ ] **Step 2: Verify the work is on `main`** - -Run (in the main checkout): `git log --oneline -3` -Expected: the two settings commits are at HEAD (or two commits below a merge commit if a merge commit was used). - -Run: `just lint-ci && uv run pytest -q` -Expected: green on `main`. - -Run: `wc -l semvertag/_settings.py` -Expected: ~130-140 LOC (down from 211). - ---- - -## Success criteria - -When all tasks above are done: - -- `semvertag/_settings.py` no longer contains `_inject_token_aliases`, `_inject_top_level_aliases`, `_resolve_active_provider`, `_inject_token`, `_find_aliased_env`, `_find_env_value`. -- `_settings.py` no longer contains any of the 7 alias-map constants (`_GITLAB_TOKEN_ALIASES`, `_GITHUB_TOKEN_ALIASES`, `_PROJECT_ID_ALIASES`, `_TOKEN_ALIASES_BY_PATH`, `_TOP_LEVEL_FIELD_ALIASES`, `_PROVIDER_TO_NESTED_KEY`, `_PROVIDER_ENV_VAR`). -- `_settings.py` no longer imports `os`. -- `GitLabConfig` and `GitHubConfig` are `pydantic_settings.BaseSettings` subclasses with their own `env_prefix` and `validation_alias=AliasChoices(...)` on their `token` field. -- `Settings.project_id` has `validation_alias=pydantic.AliasChoices("SEMVERTAG_PROJECT_ID", "CI_PROJECT_ID")`. -- All 13 tests in `tests/unit/test_settings.py` pass with **no test changes**. -- `_settings.py` is ~75-85 LOC shorter (from 211 → ~130-140). -- The 6 smoke-test outputs from Task 4 Step 6 all match expected values. -- `just lint-ci`, `uv run pytest`, `mkdocs build --strict` all green. diff --git a/planning/changes/2026-05-31.04-usecase-callable/design.md b/planning/changes/2026-05-31.04-usecase-callable.md similarity index 100% rename from planning/changes/2026-05-31.04-usecase-callable/design.md rename to planning/changes/2026-05-31.04-usecase-callable.md diff --git a/planning/changes/2026-05-31.04-usecase-callable/plan.md b/planning/changes/2026-05-31.04-usecase-callable/plan.md deleted file mode 100644 index 1b27e9a..0000000 --- a/planning/changes/2026-05-31.04-usecase-callable/plan.md +++ /dev/null @@ -1,707 +0,0 @@ -# `SemvertagUseCase` Callable Refactor Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make `SemvertagUseCase` a callable whose dependencies (`provider`, `strategy`) are bound at `__init__` and whose per-invocation state (`output`) arrives via `__call__(*, output: Output)`, eliminating the `_run_with_output_override` hack in `__main__.py` and `OutputsGroup` in `ioc.py`. - -**Architecture:** Single atomic commit on one worktree. Use case shape change and DI wiring change are tightly coupled — they have to land together to keep the build green. ~6 file edits, ~-35 LOC net, no behavior change to the CLI surface. - -**Tech Stack:** Existing — `pydantic`, `pydantic-settings`, `modern-di`, `typer`, `httpx2`, `pytest`. No new deps. - -**Spec:** `planning/specs/2026-05-31-usecase-callable-design.md` - ---- - -## Task 1: Spawn worktree and verify baseline - -**Files:** none in main checkout. - -- [ ] **Step 1: Spawn the worktree** - -Use the `superpowers:using-git-worktrees` skill. Suggested branch: `feat/usecase-callable`. Suggested path: `.worktrees/feat-usecase-callable`. - -- [ ] **Step 2: Verify clean baseline inside the worktree** - -Run (inside the worktree, after `cd` and `uv sync --all-extras --group lint`): - -```bash -pwd -git branch --show-current -git status -``` - -Expected: cwd is `/Users/kevinsmith/src/pypi/autosemver/.worktrees/feat-usecase-callable`, branch is `feat/usecase-callable`, status clean. - -Run: `just lint-ci` -Expected: PASS. - -Run: `uv run pytest -q` -Expected: 334 passed, 1 skipped. - -Run: `UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. (`UV_OFFLINE=1` because PyPI may time out fetching mkdocs-material; the cached version works fine.) - -If any baseline check fails, stop and report — main is not in the expected post-settings-aliaschoices state. - ---- - -## Task 2: Atomic refactor in one commit - -The use case shape change (drop `output` field, rename `run` → `__call__`, take `output` as param) and the DI wiring change (drop `OutputsGroup`, drop `json=` from `build_container`) are tightly coupled. They land in one commit because partial state would break the build. - -**Files:** -- Modify: `semvertag/_use_case.py` -- Modify: `semvertag/ioc.py` -- Modify: `semvertag/__main__.py` -- Modify: `tests/unit/test_use_case.py` -- Modify: `tests/integration/conftest.py` -- Modify: `tests/integration/test_cli_quiet_json_matrix.py` - -### CRITICAL: verify cwd before any git operation - -Before EVERY `git rm`, `git add`, `git commit`, run: - -```bash -pwd -git branch --show-current -``` - -The output MUST show: -- pwd: `/Users/kevinsmith/src/pypi/autosemver/.worktrees/feat-usecase-callable` -- branch: `feat/usecase-callable` - -If either is wrong, STOP. Two previous plans had implementers accidentally commit to main; do not repeat that. - -### Step 1: Refactor `semvertag/_use_case.py` - -Edit `semvertag/_use_case.py`. The current `SemvertagUseCase` class body (around lines 18-91) needs three coordinated changes: - -1. **Drop the `output: Output` field** (currently line 22): - ```python - # DELETE this line: - output: Output - ``` - -2. **Rename `def run(self) -> RunResult:` (line 24) to `def __call__(self, *, output: Output) -> RunResult:`**. - -3. **Replace `self.output.*` with `output.*` throughout the method body**. There are 4 call sites in `run()`: - - Line 25: `self.output.progress(f"Detected strategy: {self.strategy.name}")` → `output.progress(...)` - - Line 26: `self.output.progress("Fetching latest commit on default branch...")` → `output.progress(...)` - - Line 29: `self.output.progress("Fetching tag history...")` → `output.progress(...)` - - Line 51: `self.output.progress("Computing bump...")` → `output.progress(...)` - - Line 63: `self.output.progress(f"Creating tag {new_version}...")` → `output.progress(...)` - -4. **Update `_emit` helper to take `output` as a parameter** (currently lines 73-91). The current signature: - - ```python - def _emit( - self, - *, - bump: Bump, - status: str, - tag: str | None, - commit: str | None, - reason: str | None, - ) -> RunResult: - result: typing.Final = RunResult( - strategy=self.strategy.name, - bump=bump.value, - status=status, - tag=tag, - commit=commit, - reason=reason, - ) - self.output.emit(result) - return result - ``` - - Change to: - - ```python - def _emit( - self, - *, - output: Output, - bump: Bump, - status: str, - tag: str | None, - commit: str | None, - reason: str | None, - ) -> RunResult: - result: typing.Final = RunResult( - strategy=self.strategy.name, - bump=bump.value, - status=status, - tag=tag, - commit=commit, - reason=reason, - ) - output.emit(result) - return result - ``` - -5. **Update the 5 `self._emit(...)` call sites in `__call__`** to pass `output=output`. Each call currently looks like: - - ```python - return self._emit( - bump=Bump.NONE, - status="no_tags", - tag=None, - commit=commit.sha, - reason=_NO_TAGS_REASON, - ) - ``` - - becomes: - - ```python - return self._emit( - output=output, - bump=Bump.NONE, - status="no_tags", - tag=None, - commit=commit.sha, - reason=_NO_TAGS_REASON, - ) - ``` - - All 5 `self._emit(...)` calls in the body need this. - -The final shape of `SemvertagUseCase` should look like: - -```python -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) -class SemvertagUseCase: - provider: Provider - strategy: BumpStrategy - - def __call__(self, *, output: Output) -> RunResult: - output.progress(f"Detected strategy: {self.strategy.name}") - output.progress("Fetching latest commit on default branch...") - commit: typing.Final = self.provider.get_latest_commit_on_default_branch() - - output.progress("Fetching tag history...") - tags: typing.Final = self.provider.list_tags() - latest_semver_tag: typing.Final = _pick_latest_semver_tag(tags) - - if latest_semver_tag is None: - return self._emit( - output=output, - bump=Bump.NONE, - status="no_tags", - tag=None, - commit=commit.sha, - reason=_NO_TAGS_REASON, - ) - - if latest_semver_tag.commit_sha == commit.sha: - return self._emit( - output=output, - bump=Bump.NONE, - status="already_tagged", - tag=latest_semver_tag.name, - commit=commit.sha, - reason=_ALREADY_TAGGED_REASON, - ) - - output.progress("Computing bump...") - bump: typing.Final = self.strategy.decide(commit) - if bump is Bump.NONE: - return self._emit( - output=output, - bump=Bump.NONE, - status=_status_for_no_bump(self.strategy.name), - tag=None, - commit=commit.sha, - reason=_reason_for_no_bump(self.strategy.name), - ) - - new_version: typing.Final = _compute_new_version(latest_semver_tag, bump) - output.progress(f"Creating tag {new_version}...") - self.provider.create_tag(name=new_version, commit_sha=commit.sha) - return self._emit( - output=output, - bump=bump, - status="created", - tag=new_version, - commit=commit.sha, - reason=None, - ) - - def _emit( - self, - *, - output: Output, - bump: Bump, - status: str, - tag: str | None, - commit: str | None, - reason: str | None, - ) -> RunResult: - result: typing.Final = RunResult( - strategy=self.strategy.name, - bump=bump.value, - status=status, - tag=tag, - commit=commit, - reason=reason, - ) - output.emit(result) - return result -``` - -Imports stay (`from semvertag._output import Output` still needed for the parameter annotation). Module-level helpers (`_pick_latest_semver_tag`, `_parse_semver_tags`, `_try_parse_semver`, `_compute_new_version`, `_status_for_no_bump`, `_reason_for_no_bump`) and constants (`_NO_MERGE_REASON`, `_NO_CONFORMING_REASON`, `_NO_TAGS_REASON`, `_ALREADY_TAGGED_REASON`) stay unchanged. - -### Step 2: Refactor `semvertag/ioc.py` - -Five coordinated edits to `semvertag/ioc.py`: - -1. **Drop the 4 output-related imports** from the import at the top: - - ```python - # Before: - from semvertag._output import JsonOutput, RichOutput, build_json_output, build_rich_output - # Delete this line entirely. - ``` - -2. **Delete `_build_rich_output` and `_build_json_output` factory functions** (currently lines 21-26): - - ```python - def _build_rich_output(settings: Settings) -> RichOutput: - return build_rich_output(quiet=settings.quiet) - - - def _build_json_output(settings: Settings) -> JsonOutput: - return build_json_output(quiet=settings.quiet) - ``` - -3. **Delete the entire `OutputsGroup` class** (currently lines 87-101): - - ```python - class OutputsGroup(modern_di.Group): - rich_output = providers.Factory( - scope=Scope.APP, - creator=_build_rich_output, - kwargs={"settings": SettingsGroup.settings}, - skip_creator_parsing=True, - bound_type=None, - ) - json_output = providers.Factory( - scope=Scope.APP, - creator=_build_json_output, - kwargs={"settings": SettingsGroup.settings}, - skip_creator_parsing=True, - bound_type=None, - ) - ``` - -4. **Drop `"output": OutputsGroup.rich_output` from `UseCasesGroup.semvertag_use_case` kwargs**. The current `kwargs` dict (around lines 143-147): - - ```python - kwargs={ - "provider": ProvidersGroup.gitlab_provider, - "strategy": StrategiesGroup.current_strategy, - "output": OutputsGroup.rich_output, - }, - ``` - - becomes: - - ```python - kwargs={ - "provider": ProvidersGroup.gitlab_provider, - "strategy": StrategiesGroup.current_strategy, - }, - ``` - -5. **Remove `OutputsGroup` from `ALL_GROUPS`** (currently lines 153-159): - - ```python - # Before: - ALL_GROUPS: typing.Final[list[type[modern_di.Group]]] = [ - SettingsGroup, - OutputsGroup, - ProvidersGroup, - StrategiesGroup, - UseCasesGroup, - ] - # After: - ALL_GROUPS: typing.Final[list[type[modern_di.Group]]] = [ - SettingsGroup, - ProvidersGroup, - StrategiesGroup, - UseCasesGroup, - ] - ``` - -6. **Remove `OutputsGroup` from `__all__`** (currently lines 184-192): - - ```python - # Before: - __all__: typing.Final = ( - "ALL_GROUPS", - "OutputsGroup", - "ProvidersGroup", - "SettingsGroup", - "StrategiesGroup", - "UseCasesGroup", - "build_container", - ) - # After: - __all__: typing.Final = ( - "ALL_GROUPS", - "ProvidersGroup", - "SettingsGroup", - "StrategiesGroup", - "UseCasesGroup", - "build_container", - ) - ``` - -7. **Drop the `json` parameter and the override block from `build_container`** (currently lines 162-181). The current function: - - ```python - def build_container( - settings: Settings, - *, - json: bool = False, - inner_transport: httpx2.BaseTransport | None = None, - ) -> modern_di.Container: - if settings.provider != "gitlab": - msg = f"Provider {settings.provider!r} not yet supported; v1.0 supports gitlab only." - raise ConfigError(msg) - container: typing.Final = modern_di.Container( - groups=ALL_GROUPS, - context={Settings: settings}, - ) - if inner_transport is not None: - provider_instance: typing.Final = _construct_gitlab_provider(settings, inner_transport) - container.override(ProvidersGroup.gitlab_provider, provider_instance) - if json: - json_instance: typing.Final = _build_json_output(settings) - container.override(OutputsGroup.rich_output, json_instance) - return container - ``` - - becomes: - - ```python - def build_container( - settings: Settings, - *, - inner_transport: httpx2.BaseTransport | None = None, - ) -> modern_di.Container: - if settings.provider != "gitlab": - msg = f"Provider {settings.provider!r} not yet supported; v1.0 supports gitlab only." - raise ConfigError(msg) - container: typing.Final = modern_di.Container( - groups=ALL_GROUPS, - context={Settings: settings}, - ) - if inner_transport is not None: - provider_instance: typing.Final = _construct_gitlab_provider(settings, inner_transport) - container.override(ProvidersGroup.gitlab_provider, provider_instance) - return container - ``` - -### Step 3: Refactor `semvertag/__main__.py` - -Three coordinated edits: - -1. **Delete `_run_with_output_override`** (currently around lines 286-287): - - ```python - def _run_with_output_override(use_case: SemvertagUseCase, output: Output) -> None: - dataclasses.replace(use_case, output=output).run() - ``` - - Delete the entire function plus any blank line around it that would otherwise create a double-blank. - -2. **Replace the single call site** (currently line 153 in `_main_callback`): - - ```python - # Before: - _run_with_output_override(use_case, output) - # After: - use_case(output=output) - ``` - -3. **Drop `json=json_flag` from the `build_container` call** (currently line 150 in `_main_callback`): - - ```python - # Before: - container = ioc.build_container(settings, json=json_flag) - # After: - container = ioc.build_container(settings) - ``` - -4. **Check for unused imports**. After deleting `_run_with_output_override`, run: - - ```bash - grep -n "SemvertagUseCase\|^from semvertag._use_case\|^import dataclasses" semvertag/__main__.py - ``` - - - If `SemvertagUseCase` is no longer referenced anywhere in `__main__.py` (it was only used by `_run_with_output_override`'s type hint), drop the `from semvertag._use_case import SemvertagUseCase` line. - - `dataclasses` was used by `_run_with_output_override` via `dataclasses.replace`. If `dataclasses` is referenced nowhere else in `__main__.py`, drop the `import dataclasses` line. Verify with the grep first. - -### Step 4: Refactor `tests/unit/test_use_case.py` - -Two edits to the test factory + a sweep of `use_case.run()` call sites: - -1. **Update `_make_use_case` factory** (currently lines 81-100). Drop `output` from the `SemvertagUseCase(...)` construction: - - ```python - # Before: - use_case: typing.Final = SemvertagUseCase( - provider=typing.cast("typing.Any", provider), - strategy=typing.cast("typing.Any", strategy), - output=typing.cast("Output", output), - ) - return use_case, provider, output - # After: - use_case: typing.Final = SemvertagUseCase( - provider=typing.cast("typing.Any", provider), - strategy=typing.cast("typing.Any", strategy), - ) - return use_case, provider, output - ``` - - (Keep the `output` local variable + the return tuple — tests still use it; it just gets passed at call time now.) - -2. **Update all `use_case.run()` call sites** to `use_case(output=output)`. There are multiple — find them with: - - ```bash - grep -n "use_case\.run()" tests/unit/test_use_case.py - ``` - - For each match, the call pattern is one of: - - ```python - # Pattern A — output captured from factory: - use_case, provider, output = _make_use_case(...) - result: typing.Final = use_case.run() - # Change to: - result: typing.Final = use_case(output=output) - ``` - - ```python - # Pattern B — output discarded from factory (_output): - use_case, _provider, _output = _make_use_case(...) - result: typing.Final = use_case.run() - # Change to (rename _output → output so it can be passed in): - use_case, _provider, output = _make_use_case(...) - result: typing.Final = use_case(output=output) - ``` - - Inspect each test to determine which pattern applies. Some tests deliberately discard `output` because they don't assert on it — for those, the `_output` → `output` rename is enough; the assertion sections don't need to change. - -### Step 5: Update `tests/integration/conftest.py` - -The `install_mock_transport` fixture (lines 55-69) calls `real_build_container(settings, json=json, inner_transport=transport)` and exposes a `json` parameter on its `patched` shim. After dropping `json=` from `build_container`, this fixture must change: - -Replace lines 55-69 with: - -```python -@pytest.fixture -def install_mock_transport( - monkeypatch: pytest.MonkeyPatch, -) -> collections.abc.Callable[[HandlerCallable], None]: - real_build_container: typing.Final = ioc.build_container - - def install(handler: HandlerCallable) -> None: - transport: typing.Final = httpx2.MockTransport(handler) - - def patched(settings: Settings) -> typing.Any: # noqa: ANN401 - return real_build_container(settings, inner_transport=transport) - - monkeypatch.setattr(ioc, "build_container", patched) - - return install -``` - -Changes: -- `patched` signature drops the `json: bool = False` parameter -- `real_build_container` call drops `json=json` - -### Step 6: Update `tests/integration/test_cli_quiet_json_matrix.py` - -The test at line 107-124 monkey-patches `SemvertagUseCase.run` to simulate failure. After renaming `run` → `__call__`, this needs updating. - -Find the current code (around lines 115-119): - -```python - def raising_run(self: SemvertagUseCase) -> typing.Any: # noqa: ANN401, ARG001 - msg = "synthetic generic failure for AC9." - raise SemvertagError(msg) - - monkeypatch.setattr(SemvertagUseCase, "run", raising_run) -``` - -Replace with: - -```python - def raising_call(self: SemvertagUseCase, *, output: Output) -> typing.Any: # noqa: ANN401, ARG001 - msg = "synthetic generic failure for AC9." - raise SemvertagError(msg) - - monkeypatch.setattr(SemvertagUseCase, "__call__", raising_call) -``` - -If `Output` is not already imported at the top of the file, add `from semvertag._output import Output` to the imports. Check with: - -```bash -grep -n "from semvertag._output\|^import.*Output\|Output," tests/integration/test_cli_quiet_json_matrix.py -``` - -### Step 7: Run the full test suite - -Run: `uv run pytest -q` -Expected: 334 passed, 1 skipped. **No tests added or removed.** If the count changed, something else broke. - -If any test fails: READ the failure. Common patterns to expect: -- `TypeError: SemvertagUseCase.__init__() got an unexpected keyword argument 'output'` — missed a `_make_use_case` factory call or an `ioc.py` kwargs entry -- `AttributeError: 'SemvertagUseCase' object has no attribute 'run'` — missed a `use_case.run()` call site -- `TypeError: __call__() missing 1 required keyword-only argument: 'output'` — missed adding `output=output` at a call site - -DO NOT silently adjust tests to mask bugs. If a real behavior change snuck in, surface it. - -### Step 8: Targeted test verification - -Run: `uv run pytest tests/unit/test_use_case.py -v` -Expected: ALL tests PASS (the file with the most-affected surface). - -Run: `uv run pytest tests/integration/test_cli_quiet_json_matrix.py -v` -Expected: ALL tests PASS (the monkeypatch target changed). - -Run: `uv run pytest tests/integration/test_cli_main_verb.py tests/integration/test_strategy_switching.py -v` -Expected: ALL tests PASS (they go through the full CLI; should be unaffected if the wiring is correct). - -### Step 9: Lint check - -Run: `just lint-ci` -Expected: PASS (eof-fixer, ruff format check, ruff check, ty check). If ruff flags any unused imports we missed (e.g. `dataclasses` or `SemvertagUseCase` in `__main__.py`), run `just lint` (auto-fix) and re-run `just lint-ci`. - -### Step 10: Docs build - -Run: `UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -### Step 11: Smoke test the CLI surface - -Run: `uv run semvertag --help` -Expected: same `--help` output as `main` (the CLI surface didn't change). Spot-check that `--json` and `--quiet` flags are still listed. - -### Step 12: Verify cwd before committing - -```bash -pwd -git branch --show-current -``` - -The output MUST show the worktree path and branch `feat/usecase-callable`. If anything is wrong, STOP. - -### Step 13: Commit - -```bash -git add semvertag/_use_case.py semvertag/ioc.py semvertag/__main__.py tests/unit/test_use_case.py tests/integration/conftest.py tests/integration/test_cli_quiet_json_matrix.py -git commit -m "use_case: separate init from invocation; pass output to __call__ - -SemvertagUseCase becomes a callable whose dependencies (provider, -strategy) are bound at init and whose per-invocation state (output) -arrives via __call__(*, output: Output). The renamed __call__ replaces -the previous .run() method. - -This eliminates _run_with_output_override in __main__.py (the -dataclasses.replace hack that swapped the use case's output post- -construction) and OutputsGroup in ioc.py (output is no longer a DI'd -dependency — it's a per-call helper built by the CLI from --json / ---quiet flags). - -Sets up sub-project B (idiomatic modern-di-typer wiring) by removing -the per-invocation override that prevented adopting setup_di / @inject -/ FromDI." -``` - ---- - -## Task 3: Pre-merge verification gate - -**Files:** none modified. - -- [ ] **Step 1: Lint** - -Run: `just lint-ci` -Expected: PASS. - -- [ ] **Step 2: Full test suite** - -Run: `uv run pytest` -Expected: 334 passed, 1 skipped. - -- [ ] **Step 3: Branch-coverage gates** - -Run: `just test-branch-strategies` -Expected: 100% on `semvertag.strategies.branch_prefix`. - -Run: `just test-cc-strategies` -Expected: 100% on `semvertag.strategies.conventional_commits`. - -- [ ] **Step 4: Docs build** - -Run: `UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -- [ ] **Step 5: LOC + commit sanity check** - -Run: `git diff main --stat` -Expected: net negative ~30-40 LOC across the 6 touched files. - -Run: `git log --oneline main..HEAD` -Expected: exactly 1 commit (`use_case: separate init from invocation…`). - -- [ ] **Step 6: Smoke-test the CLI behavior** - -Run: `uv run semvertag --help` -Expected: same `--help` output as main. `--json` and `--quiet` still listed. - -Run: `uv run semvertag --version 2>&1 | head -1` -Expected: a version string is printed (regardless of value). - ---- - -## Task 4: Land the worktree - -**Files:** none modified by hand. - -- [ ] **Step 1: Invoke `superpowers:finishing-a-development-branch`** - -Use the skill to merge to `main` (fast-forward expected — main shouldn't have moved during this work) and clean up the worktree. - -- [ ] **Step 2: Verify the work is on `main`** - -Run (in the main checkout): `git log --oneline -2` -Expected: the `use_case: separate init from invocation…` commit is at HEAD. - -Run: `just lint-ci && uv run pytest -q` -Expected: green on `main`. - -Run: `grep -n "_run_with_output_override\|OutputsGroup" semvertag/` -Expected: NO matches (modulo `_archive/`). - -Run: `grep -n "use_case\.run\b" semvertag/ tests/` -Expected: NO matches (modulo `_archive/`). - ---- - -## Success criteria - -When all tasks above are done: - -- `SemvertagUseCase` has no `output` field; its only callable is `__call__(*, output: Output) -> RunResult` (no `.run()` method remains). -- `_run_with_output_override` is deleted from `semvertag/__main__.py`. -- `OutputsGroup` is deleted from `semvertag/ioc.py`; `ALL_GROUPS` has 4 groups (Settings, Providers, Strategies, UseCases); `__all__` no longer exports `OutputsGroup`. -- `build_container` no longer accepts a `json=` parameter. -- All 334 tests pass; the smoke test confirms CLI surface is unchanged. -- `_use_case.py`, `__main__.py`, and `ioc.py` are all slightly shorter and cleaner. -- `tests/integration/conftest.py`'s `install_mock_transport` fixture no longer takes or passes a `json` parameter. -- `tests/integration/test_cli_quiet_json_matrix.py` patches `SemvertagUseCase.__call__` (not `.run`) with an output-accepting signature. diff --git a/planning/changes/2026-05-31.05-ioc-idiomatic-modern-di-typer/design.md b/planning/changes/2026-05-31.05-ioc-idiomatic-modern-di-typer.md similarity index 100% rename from planning/changes/2026-05-31.05-ioc-idiomatic-modern-di-typer/design.md rename to planning/changes/2026-05-31.05-ioc-idiomatic-modern-di-typer.md diff --git a/planning/changes/2026-05-31.05-ioc-idiomatic-modern-di-typer/plan.md b/planning/changes/2026-05-31.05-ioc-idiomatic-modern-di-typer/plan.md deleted file mode 100644 index d779f71..0000000 --- a/planning/changes/2026-05-31.05-ioc-idiomatic-modern-di-typer/plan.md +++ /dev/null @@ -1,794 +0,0 @@ -# Idiomatic `modern-di-typer` Wiring Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Refactor `ioc.py` + `__main__.py` to the canonical `modern-di-typer` pattern: module-level container, `setup_di`, an explicit `semvertag tag` subcommand decorated with `@modern_di_typer.inject`, and `FromDI`-based dependency resolution — with creator-parsing auto-wiring replacing the manual `kwargs={...}` / `skip_creator_parsing=True` / `bound_type=None` workarounds. - -**Architecture:** Single atomic commit on one worktree. Container shape, callback shape, command shape, and Settings shape all change together. The breakthrough mechanic is `container.set_context(Settings, settings)` from inside the callback after `with ioc.container:` opens APP scope — late-binds CLI-derived Settings into the context so Factories with type-hinted parameters auto-resolve when `@inject` fires. - -**Tech Stack:** `modern-di`, `modern-di-typer`, `typer`, `pydantic-settings`, `pytest`, `httpx2`. No new deps. - -**Spec:** `planning/specs/2026-05-31-ioc-idiomatic-modern-di-typer-design.md` - ---- - -## Task 1: Spawn worktree and verify baseline - -**Files:** none in main checkout. - -- [ ] **Step 1: Spawn the worktree** - -Use the `superpowers:using-git-worktrees` skill. Suggested branch: `feat/ioc-idiomatic-modern-di-typer`. Suggested path: `.worktrees/feat-ioc-idiomatic-modern-di-typer`. - -- [ ] **Step 2: Verify clean baseline inside the worktree** - -Run (inside the worktree, after `cd` and `uv sync --all-extras --group lint`): - -```bash -pwd -git branch --show-current -git status -``` - -Expected: cwd is `/Users/kevinsmith/src/pypi/autosemver/.worktrees/feat-ioc-idiomatic-modern-di-typer`, branch is `feat/ioc-idiomatic-modern-di-typer`, status clean. - -Run: `just lint-ci` -Expected: PASS. - -Run: `uv run pytest -q` -Expected: 334 passed, 1 skipped. - -Run: `UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -If any baseline check fails, stop and report. - ---- - -## Task 2: Atomic refactor in one commit - -The container shape, callback shape, tag-command introduction, and Settings shape are tightly coupled. They land in **one commit** because partial state breaks the build. - -**Files:** -- Modify: `semvertag/_settings.py` (drop `quiet` field) -- Modify: `semvertag/ioc.py` (major restructure) -- Modify: `semvertag/__main__.py` (callback shrinks, tag command added, `setup_di` at module level, `main()` enters container) -- Modify: `tests/integration/conftest.py` (`install_mock_transport` migrates to `container.override`) -- Modify: `tests/integration/test_cli_main_verb.py` (5 invocations) -- Modify: `tests/integration/test_cli_quiet_json_matrix.py` (10 invocations) -- Modify: `tests/integration/test_strategy_switching.py` (7 invocations) -- Modify: `tests/unit/test_settings.py` (drop quiet env-var test) -- Modify: `tests/unit/test_ioc.py` (rewrite to use module-level container) -- Modify: `action.yml` (`uvx semvertag` → `uvx semvertag tag`) -- Modify: `templates/semvertag.yml` (same) -- Modify: `docs/providers/github.md` (3 invocation references) -- Modify: `docs/providers/gitlab.md` (3 invocation references) - -### CRITICAL: Verify cwd before any git operation - -Before EVERY `git add` and `git commit`, run: - -```bash -pwd -git branch --show-current -``` - -The output MUST show: -- pwd: `/Users/kevinsmith/src/pypi/autosemver/.worktrees/feat-ioc-idiomatic-modern-di-typer` -- branch: `feat/ioc-idiomatic-modern-di-typer` - -If either is wrong, STOP. Use absolute paths (starting with the worktree path) for Edit/Write operations. - -### Step 1: Drop `quiet` field from `Settings` - -In `semvertag/_settings.py`, find the `quiet` field declaration in the `Settings` class (currently line 68): - -```python - quiet: bool = pydantic.Field(default=False) -``` - -Delete this line. The `Settings` class loses the `quiet` field entirely. - -### Step 2: Restructure `semvertag/ioc.py` - -Replace the entire contents of `semvertag/ioc.py` with: - -```python -import typing - -import httpx2 -import modern_di -from modern_di import Scope, providers - -from semvertag._errors import ConfigError -from semvertag._settings import Settings -from semvertag._transport import RetryingTransport -from semvertag._use_case import SemvertagUseCase -from semvertag.providers._http import HttpClient -from semvertag.providers.gitlab import GitLabProvider, _translate_status, gitlab_auth_headers -from semvertag.strategies._base import BumpStrategy -from semvertag.strategies.branch_prefix import BranchPrefixStrategy -from semvertag.strategies.conventional_commits import ConventionalCommitsStrategy - - -def _build_gitlab_provider(settings: Settings, transport: httpx2.BaseTransport) -> GitLabProvider: - if settings.project_id is None: - msg = "Project id missing. Set CI_PROJECT_ID or pass --project-id." - raise ConfigError(msg) - project_id: typing.Final = settings.project_id - client: typing.Final = httpx2.Client( - transport=transport, - base_url=settings.gitlab.endpoint, - timeout=settings.request_timeout, - ) - http: typing.Final = HttpClient( - client=client, - auth_headers=lambda: gitlab_auth_headers(settings.gitlab.token), - status_translator=lambda status: _translate_status(status, project_id), - ) - return GitLabProvider( - config=settings.gitlab, - project_id=project_id, - http=http, - ) - - -def _build_branch_prefix_strategy(settings: Settings) -> BranchPrefixStrategy: - return BranchPrefixStrategy(config=settings.branch_prefix) - - -def _build_conventional_commits_strategy(settings: Settings) -> ConventionalCommitsStrategy: - return ConventionalCommitsStrategy(config=settings.conventional_commits) - - -def _build_current_strategy(settings: Settings) -> BumpStrategy: - if settings.strategy == "conventional-commits": - return _build_conventional_commits_strategy(settings) - return _build_branch_prefix_strategy(settings) - - -def _close_provider_client(provider: GitLabProvider) -> None: - provider.http.client.close() - - -class SettingsGroup(modern_di.Group): - settings = providers.ContextProvider(scope=Scope.APP, context_type=Settings) - - -class TransportsGroup(modern_di.Group): - transport = providers.Factory(scope=Scope.APP, creator=RetryingTransport) - - -class ProvidersGroup(modern_di.Group): - gitlab_provider = providers.Factory( - scope=Scope.APP, - creator=_build_gitlab_provider, - cache_settings=providers.CacheSettings(finalizer=_close_provider_client), - ) - - -class StrategiesGroup(modern_di.Group): - branch_prefix_strategy = providers.Factory(scope=Scope.APP, creator=_build_branch_prefix_strategy) - conventional_commits_strategy = providers.Factory(scope=Scope.APP, creator=_build_conventional_commits_strategy) - current_strategy = providers.Factory(scope=Scope.APP, creator=_build_current_strategy) - - -class UseCasesGroup(modern_di.Group): - semvertag_use_case = providers.Factory( - scope=Scope.APP, - creator=SemvertagUseCase, - kwargs={ - "provider": ProvidersGroup.gitlab_provider, - "strategy": StrategiesGroup.current_strategy, - }, - ) - - -ALL_GROUPS: typing.Final[list[type[modern_di.Group]]] = [ - SettingsGroup, - TransportsGroup, - ProvidersGroup, - StrategiesGroup, - UseCasesGroup, -] - - -container: typing.Final = modern_di.Container(groups=ALL_GROUPS) - - -__all__: typing.Final = ( - "ALL_GROUPS", - "ProvidersGroup", - "SettingsGroup", - "StrategiesGroup", - "TransportsGroup", - "UseCasesGroup", - "container", -) -``` - -Key differences from the old `ioc.py`: -- `build_container()` function deleted -- `_construct_gitlab_provider` collapsed into `_build_gitlab_provider(settings, transport)` -- `TransportsGroup` added (wraps `RetryingTransport` as a Factory) -- `OutputsGroup` stays gone (sub-project A removed it) -- `skip_creator_parsing=True`, `bound_type=None`, `kwargs={"settings": SettingsGroup.settings}` all removed -- `kwargs` kept only on `semvertag_use_case` (for Protocol disambiguation) -- Module-level `container = modern_di.Container(groups=ALL_GROUPS)` singleton -- Inline imports promoted to top-level (verify they work; if `from semvertag.providers._http import HttpClient` causes a circular import, restore the inline import with `# noqa: PLC0415`) -- TYPE_CHECKING-only imports dropped because everything is now imported at top level -- `if settings.provider != "gitlab"` runtime check removed from `build_container` (handled inside `_build_gitlab_provider` only if it's still needed — verify whether non-gitlab providers get rejected naturally) - -### Step 3: Add provider-rejection check back to the callback - -The old `build_container` raised `ConfigError(f"Provider {settings.provider!r} not yet supported; v1.0 supports gitlab only.")` when `settings.provider != "gitlab"`. This check must move somewhere. The cleanest place is the callback (after Settings is built, before pushing context): - -In `semvertag/__main__.py`, this will be added inside the callback after `settings = apply_cli_overlay(...)` and before the `set_context` call. See Step 4 for exact placement. - -### Step 4: Restructure `semvertag/__main__.py` - -Replace the entire contents of `semvertag/__main__.py` with: - -```python -import errno -import importlib.metadata -import typing - -import modern_di_typer -import pydantic -import typer - -from semvertag import ioc -from semvertag._errors import ConfigError, SemvertagError -from semvertag._output import Output, build_json_output, build_rich_output -from semvertag._settings import Settings, apply_cli_overlay -from semvertag._use_case import SemvertagUseCase - - -_PACKAGE_NAME: typing.Final = "semvertag" -_CONFIG_ERROR_EXIT_CODE: typing.Final = 2 - - -MAIN_APP: typing.Final = typer.Typer( - name="semvertag", - help=("Auto-tag GitLab/GitHub/Bitbucket repos with semantic version tags — one tool, two strategies."), - no_args_is_help=True, - add_completion=True, -) - -modern_di_typer.setup_di(MAIN_APP, ioc.container) - - -def _version_callback(value: bool) -> None: - if not value: - return - try: - version = importlib.metadata.version(_PACKAGE_NAME) - except importlib.metadata.PackageNotFoundError: - version = "0" - typer.echo(version) - raise typer.Exit - - -def _collect_overrides( # noqa: PLR0913 - *, - project_id: int | None, - strategy: str | None, - provider: str | None, - token: str | None, - default_branch: str | None, - gitlab_endpoint: str | None, - request_timeout: float | None, -) -> dict[str, tuple[typing.Any, str]]: - overrides: dict[str, tuple[typing.Any, str]] = {} - if project_id is not None: - overrides["project_id"] = (project_id, "--project-id") - if strategy is not None: - overrides["strategy"] = (strategy, "--strategy") - if provider is not None: - overrides["provider"] = (provider, "--provider") - if token is not None: - overrides["gitlab.token"] = (pydantic.SecretStr(token), "--token") - if default_branch is not None: - overrides["default_branch"] = (default_branch, "--default-branch") - if gitlab_endpoint is not None: - overrides["gitlab.endpoint"] = (gitlab_endpoint, "--gitlab-endpoint") - if request_timeout is not None: - overrides["request_timeout"] = (request_timeout, "--request-timeout") - return overrides - - -def _config_error_from_validation(exc: pydantic.ValidationError) -> ConfigError: - first: typing.Final = exc.errors()[0] - loc: typing.Final = ".".join(str(part) for part in first.get("loc", ())) - detail: typing.Final = first.get("msg", "invalid value") - msg: typing.Final = f"Configuration error at '{loc}': {detail}. Check environment variables and command-line flags." - return ConfigError(msg) - - -@MAIN_APP.callback() -def _main_callback( # noqa: PLR0913 - ctx: typer.Context, - project_id: typing.Annotated[ - int | None, - typer.Option("--project-id", help="GitLab project id (or set CI_PROJECT_ID)."), - ] = None, - strategy: typing.Annotated[ - str | None, - typer.Option("--strategy", help="Bump strategy: branch-prefix | conventional-commits."), - ] = None, - provider: typing.Annotated[ - str | None, - typer.Option("--provider", help="Provider: gitlab | github | bitbucket."), - ] = None, - token: typing.Annotated[ - str | None, - typer.Option("--token", help="API token (overrides SEMVERTAG_TOKEN)."), - ] = None, - default_branch: typing.Annotated[ - str | None, - typer.Option("--default-branch", help="Default branch name override."), - ] = None, - gitlab_endpoint: typing.Annotated[ - str | None, - typer.Option("--gitlab-endpoint", help="GitLab API endpoint URL."), - ] = None, - request_timeout: typing.Annotated[ - float | None, - typer.Option("--request-timeout", help="Per-request timeout in seconds (clamped to 10)."), - ] = None, - _version: typing.Annotated[ - bool | None, - typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version and exit."), - ] = None, -) -> None: - try: - settings = Settings() - try: - overrides = _collect_overrides( - project_id=project_id, - strategy=strategy, - provider=provider, - token=token, - default_branch=default_branch, - gitlab_endpoint=gitlab_endpoint, - request_timeout=request_timeout, - ) - settings = apply_cli_overlay(settings, overrides) - except ValueError as exc: - raise ConfigError(str(exc)) from exc - if settings.provider != "gitlab": - msg = f"Provider {settings.provider!r} not yet supported; v1.0 supports gitlab only." - raise ConfigError(msg) - except pydantic.ValidationError as exc: - err = _config_error_from_validation(exc) - typer.echo(f"Error: {err}", err=True) - raise typer.Exit(code=err.exit_code) from err - except ConfigError as err: - typer.echo(f"Error: {err}", err=True) - raise typer.Exit(code=err.exit_code) from err - - app_container = modern_di_typer.fetch_di_container(ctx) - app_container.set_context(Settings, settings) - - -@MAIN_APP.command("tag") -@modern_di_typer.inject -def _tag_command( - use_case: typing.Annotated[SemvertagUseCase, modern_di_typer.FromDI(SemvertagUseCase)], - quiet: typing.Annotated[ - bool, - typer.Option("--quiet", help="Suppress progress narrative; final result still emits."), - ] = False, - json_flag: typing.Annotated[ - bool, - typer.Option("--json", help="Emit a JSON envelope on stdout instead of human-readable output."), - ] = False, -) -> None: - output: Output = build_json_output(quiet=quiet) if json_flag else build_rich_output(quiet=quiet) - try: - use_case(output=output) - except ImportError as exc: - err = ConfigError(f"Required module unavailable: {exc}.") - output.error(str(err)) - raise typer.Exit(code=err.exit_code) from err - except SemvertagError as err: - output.error(str(err)) - raise typer.Exit(code=err.exit_code) from err - except BrokenPipeError as exc: - raise typer.Exit(code=0) from exc - except OSError as exc: - if exc.errno == errno.EPIPE: - raise typer.Exit(code=0) from exc - raise - - -def main() -> None: - with ioc.container: - MAIN_APP() - - -if __name__ == "__main__": # pragma: no cover - main() -``` - -Key differences from the old `__main__.py`: -- `modern_di_typer.setup_di(MAIN_APP, ioc.container)` at module level -- `MAIN_APP` config: `no_args_is_help=True` (show help when no subcommand) instead of `invoke_without_command=True` (run callback as default action) -- Callback: removes `--quiet`/`--json` Options; removes `quiet=quiet` from `_collect_overrides` call; removes Output construction; replaces container-building + use-case-resolving with `set_context(Settings, settings)`; uses `typer.echo` for setup errors -- `_collect_overrides` loses its `quiet` parameter -- Provider-rejection check (`settings.provider != "gitlab"`) moves from `build_container` to the callback -- New `@MAIN_APP.command("tag")` decorated with `@modern_di_typer.inject`, takes `use_case` via `FromDI` and `--quiet`/`--json` as typer Options -- `_build_output_for_flags` helper deleted (tag command builds output inline) -- `main()` wraps the app in `with ioc.container:` to enter APP scope -- `import dataclasses` removed (was unused after sub-project A; this confirms) - -### Step 5: Migrate `tests/integration/conftest.py` `install_mock_transport` fixture - -In `tests/integration/conftest.py`, replace the `install_mock_transport` fixture (currently lines 55-69) with: - -```python -@pytest.fixture -def install_mock_transport() -> typing.Iterator[collections.abc.Callable[[HandlerCallable], None]]: - overridden: list[bool] = [False] - - def install(handler: HandlerCallable) -> None: - mock_transport: typing.Final = httpx2.MockTransport(handler) - ioc.container.override(ioc.TransportsGroup.transport, mock_transport) - overridden[0] = True - - yield install - - if overridden[0]: - ioc.container.reset_override(ioc.TransportsGroup.transport) -``` - -The fixture's monkeypatch parameter is no longer needed; the new shape is a generator-style fixture with explicit setup/teardown. If the fixture signature change requires updating the import list at the top of the file (`from semvertag import ioc` may need to stay; `import collections.abc` may need to be added), do so. - -### Step 6: Update CLI invocations in `tests/integration/test_cli_main_verb.py` - -5 invocations need updating (lines 42, 63, 76, 89, 102 per pre-edit numbering — may shift slightly). Pattern: - -- `cli_runner.invoke(MAIN_APP, [])` → `cli_runner.invoke(MAIN_APP, ["tag"])` -- `cli_runner.invoke(MAIN_APP, ["--json"])` → `cli_runner.invoke(MAIN_APP, ["tag", "--json"])` - -Note: `--json` and `--quiet` are now flags on the tag subcommand, not the callback. They must appear AFTER `"tag"` in the args list. - -### Step 7: Update CLI invocations in `tests/integration/test_cli_quiet_json_matrix.py` - -10 invocations need updating (lines 46, 62, 78, 97, 122, 140, 159, 179, 193, 206). Same pattern as Step 6. - -Also check the `raising_run` / `raising_call` monkeypatched test (from sub-project A) — verify it still works with the new command structure. The `monkeypatch.setattr(SemvertagUseCase, "__call__", raising_call)` should still fire when `use_case(output=output)` runs inside the tag command body. - -### Step 8: Update CLI invocations in `tests/integration/test_strategy_switching.py` - -7 invocations need updating (lines 22, 37, 52, 67, 83, 88, 102). Same pattern as Step 6. - -### Step 9: Drop the quiet env-var test from `tests/unit/test_settings.py` - -In `tests/unit/test_settings.py`, find `test_quiet_picks_up_semvertag_quiet_env_var` (around line 152). Delete the entire test function (plus any constants only it uses). - -Also check line 34 — there's `assert settings.quiet is False` in some other test. That test was checking the default for `quiet`. Either: -- Delete the assertion (Settings no longer has `quiet`, so the assertion would fail) -- Delete the entire test if `quiet` was its only purpose - -Read the test context around line 34 and decide. Likely the assertion is one of several in `test_uses_defaults_when_no_env_set` — remove just that line, keep the test. - -### Step 10: Rewrite `tests/unit/test_ioc.py` for module-level container - -Replace the entire contents of `tests/unit/test_ioc.py` with: - -```python -import typing - -import pytest - -from semvertag import ioc -from semvertag._errors import ConfigError -from semvertag._settings import Settings -from semvertag.strategies.branch_prefix import BranchPrefixStrategy -from semvertag.strategies.conventional_commits import ConventionalCommitsStrategy - - -_StrategyName = typing.Literal["branch-prefix", "conventional-commits"] -_ProviderName = typing.Literal["gitlab", "github", "bitbucket"] - - -def _settings( - *, - strategy: _StrategyName = "branch-prefix", - provider: _ProviderName = "gitlab", -) -> Settings: - return Settings(project_id=999, strategy=strategy, provider=provider) - - -def test_container_resolves_branch_prefix_strategy_by_default() -> None: - settings: typing.Final = _settings(strategy="branch-prefix") - with ioc.container: - ioc.container.set_context(Settings, settings) - try: - strategy = ioc.container.resolve_provider(ioc.StrategiesGroup.current_strategy) - assert isinstance(strategy, BranchPrefixStrategy) - assert strategy.name == "branch-prefix" - finally: - pass - - -def test_container_resolves_conventional_commits_strategy_when_settings_strategy_is_cc() -> None: - settings: typing.Final = _settings(strategy="conventional-commits") - with ioc.container: - ioc.container.set_context(Settings, settings) - try: - strategy = ioc.container.resolve_provider(ioc.StrategiesGroup.current_strategy) - assert isinstance(strategy, ConventionalCommitsStrategy) - assert strategy.name == "conventional-commits" - finally: - pass - - -def test_named_strategy_factories_resolve_to_their_concrete_types_regardless_of_settings() -> None: - settings: typing.Final = _settings(strategy="conventional-commits") - with ioc.container: - ioc.container.set_context(Settings, settings) - bp = ioc.container.resolve_provider(ioc.StrategiesGroup.branch_prefix_strategy) - cc = ioc.container.resolve_provider(ioc.StrategiesGroup.conventional_commits_strategy) - assert isinstance(bp, BranchPrefixStrategy) - assert isinstance(cc, ConventionalCommitsStrategy) - - -def test_provider_check_moved_out_of_ioc_lives_in_callback_now() -> None: - """Sanity reminder: the 'Provider not yet supported' check is now in __main__.py's callback, - not in ioc.py. There's no longer a build_container() to test this in isolation. - Integration tests in test_cli_*.py exercise the callback's behavior end-to-end.""" - # Intentionally empty body. Test passes by existing as documentation. -``` - -Note the `with ioc.container:` + `set_context` pattern at each test — this mirrors how production code uses the container. The previous test for "provider rejection" is removed because that logic moved to `__main__.py`; CLI tests cover it. - -### Step 11: Update `action.yml` - -In `action.yml`, find the line `run: uvx semvertag` (currently near the end of the file in the `runs.steps` section). Change to: - -```yaml - run: uvx semvertag tag -``` - -### Step 12: Update `templates/semvertag.yml` - -In `templates/semvertag.yml`, find the `script:` section near the end. Change: - -```yaml - script: - - uvx 'semvertag>=1,<2' -``` - -to: - -```yaml - script: - - uvx 'semvertag>=1,<2' tag -``` - -### Step 13: Update `docs/providers/github.md` - -Find and update three references to `uvx semvertag` / `semvertag` (currently lines 7, 15-16, 142, 149): - -- Line 7: `uvx semvertag` → `uvx semvertag tag` -- Line 15-16: `runs \`uvx semvertag\` with the workflow-issued...` → `runs \`uvx semvertag tag\` with the workflow-issued...` -- Line 142: `the composite wraps \`uvx semvertag\` correctly...` → `the composite wraps \`uvx semvertag tag\` correctly...` -- Line 149: `status: no_tags\` and exits 0 without bumping (\`semvertag/_use_case.py\`'s` — this is a source file reference, NOT a CLI invocation. Do NOT change. - -### Step 14: Update `docs/providers/gitlab.md` - -Find and update three references to `uvx semvertag` / `semvertag` (currently lines 4-6, 12, 46): - -- Line 4-6: `component is a thin GitLab CI job template around the \`semvertag\` CLI` and `uvx semvertag`. The first sentence describes the CLI (no change needed if it's referring to the package); update the second invocation `uvx semvertag` → `uvx semvertag tag`. -- Line 12: `component itself contributes a single \`semvertag\` job` — this refers to the GitLab CI job name, NOT the CLI invocation. Do NOT change. -- Line 46: `so GitLab serializes concurrent \`semvertag\` jobs across pipelines` — same, refers to GitLab CI job name. Do NOT change. - -Read the file carefully and update only the CLI-invocation lines, not job-name or package-name references. - -### Step 15: Verify CLAUDE.md and README.md don't need updates - -Run: `grep -n "uvx semvertag\|\\\`semvertag\\\b" CLAUDE.md README.md 2>/dev/null` - -If any CLI invocation lines turn up, update `semvertag` → `semvertag tag`. If only package-name or repository-name references show, leave alone. - -### Step 16: Run the full test suite - -```bash -uv run pytest -q -``` -Expected: **333 passed, 1 skipped** (drops by 1 vs baseline because the quiet env-var test was deleted). - -If the count is different than 333 or any test fails: READ the failure. Common issues: -- `AttributeError: 'Settings' object has no attribute 'quiet'` — missed a reference -- `TypeError: _collect_overrides() got an unexpected keyword argument 'quiet'` — missed the call-site update in callback -- `ContainerError` or `ProviderError` from modern-di — Factory wiring problem (auto-wiring isn't finding Settings in context, or Protocol disambiguation broke) -- `TypeError: __init__() takes 1 positional argument but 2 were given` — `build_container` is called somewhere it shouldn't be - -Do NOT silently adjust tests to mask bugs. - -### Step 17: Lint check - -```bash -just lint-ci -``` -Expected: PASS. If ruff/ty flag unused imports we missed (e.g., `Output` if no longer referenced in `__main__.py` — but it IS referenced as a type annotation for `output: Output`, so it stays), fix them. - -### Step 18: Docs build - -```bash -UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict -``` -Expected: builds clean. - -### Step 19: Smoke tests - -Run each and verify expected output: - -```bash -uv run semvertag --help -``` -Expected: top-level help showing `tag` as a subcommand. The `--project-id`/`--strategy`/etc. options are listed at the top level (on the callback). The `--quiet`/`--json` options are NOT at the top level (moved to tag). - -```bash -uv run semvertag tag --help -``` -Expected: tag-subcommand help. Lists `--quiet` and `--json` only (the callback-level options are accessed via `semvertag --help`). - -```bash -uv run semvertag -``` -Expected: prints help (no_args_is_help=True). Exit code 0. - -```bash -uv run semvertag tag -``` -Expected: fails with a ConfigError because no `project_id` is set (no env, no flag). Exit code 2. Error message starts with "Configuration error" or "Project id missing". - -```bash -SEMVERTAG_PROJECT_ID=999 uv run semvertag tag -``` -Expected: fails with a network error or AuthError (no real GitLab token). Exit code 3 or 4. Output should be valid (not a stack trace). - -```bash -uv run semvertag --version -``` -Expected: prints the package version. Exit code 0. - -### Step 20: Verify cwd before committing - -```bash -pwd -git branch --show-current -``` -MUST show worktree path and `feat/ioc-idiomatic-modern-di-typer`. If wrong, STOP. - -### Step 21: Commit - -```bash -git add semvertag/_settings.py semvertag/ioc.py semvertag/__main__.py tests/integration/conftest.py tests/integration/test_cli_main_verb.py tests/integration/test_cli_quiet_json_matrix.py tests/integration/test_strategy_switching.py tests/unit/test_settings.py tests/unit/test_ioc.py action.yml templates/semvertag.yml docs/providers/github.md docs/providers/gitlab.md -git commit -m "ioc: adopt idiomatic modern-di-typer wiring with setup_di + @inject + FromDI - -Refactor ioc.py from manual container construction per CLI invocation -to module-level container + modern_di_typer.setup_di integration. -Settings flows in via container.set_context(Settings, settings) from -the callback after APP scope is entered. The new semvertag tag -subcommand is decorated with @modern_di_typer.inject and resolves the -use case via FromDI(SemvertagUseCase). All Factories drop -skip_creator_parsing=True, bound_type=None, and the redundant -kwargs={'settings': ...} entries — creator parsing auto-wires from type -hints. - -Quiet and json move from Settings/callback flags to the tag subcommand -(per-call display preferences, not config). Settings.quiet field -removed. - -Test transport injection migrates from the inner_transport= parameter -on build_container to container.override(TransportsGroup.transport, -mock_transport) — the documented test pattern; override is test-only. - -CLI surface: semvertag tag is the invocation. Bare semvertag prints -help. action.yml, templates/semvertag.yml, and provider docs updated -to use the new subcommand. - -Closes sub-project B of the ioc.py showcase brainstorm." -``` - -Verify with: `git log --oneline -1`. - ---- - -## Task 3: Pre-merge verification gate - -**Files:** none modified. - -- [ ] **Step 1: Lint** - -Run: `just lint-ci` -Expected: PASS. - -- [ ] **Step 2: Full test suite** - -Run: `uv run pytest` -Expected: 333 passed, 1 skipped. - -- [ ] **Step 3: Branch-coverage gates (unaffected by this work)** - -Run: `just test-branch-strategies` -Expected: 100% on `semvertag.strategies.branch_prefix`. - -Run: `just test-cc-strategies` -Expected: 100% on `semvertag.strategies.conventional_commits`. - -- [ ] **Step 4: Docs build** - -Run: `UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -- [ ] **Step 5: LOC + commit sanity check** - -Run: `git diff main --shortstat` -Expected: net change near zero (the refactor is roughly flat in LOC; value is structural). - -Run: `git log --oneline main..HEAD` -Expected: exactly 1 commit (`ioc: adopt idiomatic modern-di-typer wiring…`). - -- [ ] **Step 6: CLI surface smoke tests** - -Run each from Step 19 of Task 2: - -```bash -uv run semvertag --help # shows 'tag' subcommand -uv run semvertag tag --help # shows --quiet and --json -uv run semvertag # prints help (no_args_is_help=True), exit 0 -uv run semvertag tag # fails with project_id missing, exit 2 -uv run semvertag --version # prints version, exit 0 -``` - -All outputs must be as expected. Any deviation indicates a wiring issue. - ---- - -## Task 4: Land the worktree - -**Files:** none modified by hand. - -- [ ] **Step 1: Invoke `superpowers:finishing-a-development-branch`** - -Use the skill to merge to `main` (fast-forward expected — main shouldn't have moved during this work) and clean up the worktree. - -- [ ] **Step 2: Verify the work is on `main`** - -Run (in the main checkout): `git log --oneline -2` -Expected: the `ioc: adopt idiomatic modern-di-typer wiring…` commit is at HEAD. - -Run: `just lint-ci && uv run pytest -q` -Expected: green on `main`; 333 passed, 1 skipped. - -Run: `grep -n "build_container\|skip_creator_parsing\|bound_type=None" semvertag/` -Expected: NO matches (modulo `_archive/`). - -Run: `grep -n "modern_di_typer\.setup_di\|FromDI" semvertag/__main__.py` -Expected: BOTH found — `setup_di(MAIN_APP, ioc.container)` and `FromDI(SemvertagUseCase)`. - -Run: `uv run semvertag --help | head -20` -Expected: `tag` subcommand listed. - ---- - -## Success criteria - -When all tasks above are done: - -- `semvertag/ioc.py` has module-level `container = modern_di.Container(groups=ALL_GROUPS)`; no `build_container()` function; no `skip_creator_parsing=True`; no `bound_type=None`; `kwargs={"settings": ...}` only present on `UseCasesGroup.semvertag_use_case` for Protocol disambiguation. -- `TransportsGroup` exists in `ioc.py` (wraps `RetryingTransport`). -- `semvertag/__main__.py` has `modern_di_typer.setup_di(MAIN_APP, ioc.container)` at module level; an `@MAIN_APP.command("tag")` decorated with `@modern_di_typer.inject`; `with ioc.container: MAIN_APP()` in `main()`; `--quiet` and `--json` flags on the tag command (not the callback). -- `Settings` no longer has a `quiet` field. -- All CLI tests invoke via `["tag", ...]` (22 sites updated). -- `install_mock_transport` uses `ioc.container.override(ioc.TransportsGroup.transport, ...)` and resets on teardown. -- `action.yml`, `templates/semvertag.yml`, and provider docs invoke `semvertag tag`. -- `just lint-ci`, `uv run pytest`, `mkdocs build --strict` all green. -- 333 tests pass (vs 334 baseline; one quiet-env-var test deleted). -- The 6 smoke tests from Task 3 Step 6 all produce expected output. diff --git a/planning/changes/2026-05-31.06-cli-overlay-simplification/design.md b/planning/changes/2026-05-31.06-cli-overlay-simplification.md similarity index 100% rename from planning/changes/2026-05-31.06-cli-overlay-simplification/design.md rename to planning/changes/2026-05-31.06-cli-overlay-simplification.md diff --git a/planning/changes/2026-05-31.06-cli-overlay-simplification/plan.md b/planning/changes/2026-05-31.06-cli-overlay-simplification/plan.md deleted file mode 100644 index ff51e43..0000000 --- a/planning/changes/2026-05-31.06-cli-overlay-simplification/plan.md +++ /dev/null @@ -1,383 +0,0 @@ -# `apply_cli_overlay` Simplification Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace `apply_cli_overlay` + `_split_overrides` + `_revalidate_nested` (~57 LOC) in `semvertag/_settings.py` with a single 14-line `apply_cli_overlay` that uses `pydantic.BaseModel.model_copy(update=...)` for nested partial updates. Drop the `(value, flag_detail)` tuple shape in `_collect_overrides` in `semvertag/__main__.py`. No behavior change; no test changes. - -**Architecture:** Single atomic commit on one worktree. Both file changes are tightly coupled (the override-values type changes simultaneously in producer and consumer) — must land together. Net deletion ~-50 LOC. - -**Tech Stack:** `pydantic`, `pydantic-settings`. No new deps. - -**Spec:** `planning/specs/2026-05-31-cli-overlay-simplification-design.md` - ---- - -## Task 1: Spawn worktree and verify baseline - -**Files:** none in main checkout. - -- [ ] **Step 1: Spawn the worktree** - -Use the `superpowers:using-git-worktrees` skill. Suggested branch: `feat/cli-overlay-simplification`. Suggested path: `.worktrees/feat-cli-overlay-simplification`. - -- [ ] **Step 2: Verify clean baseline inside the worktree** - -Run (inside the worktree, after `cd` and `uv sync --all-extras --group lint`): - -```bash -pwd -git branch --show-current -git status -``` - -Expected: cwd is `/Users/kevinsmith/src/pypi/autosemver/.worktrees/feat-cli-overlay-simplification`, branch is `feat/cli-overlay-simplification`, status clean. - -Run: `just lint-ci` -Expected: PASS. - -Run: `uv run pytest -q` -Expected: 330 passed, 1 skipped. - -Run: `UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -If any baseline check fails, stop and report. - ---- - -## Task 2: Atomic refactor in one commit - -The override-values type changes simultaneously in both producer (`_collect_overrides`) and consumer (`apply_cli_overlay`). They MUST land in one commit. - -**Files:** -- Modify: `semvertag/_settings.py` (replace 3 functions with 1) -- Modify: `semvertag/__main__.py` (simplify `_collect_overrides`) - -### CRITICAL: Verify cwd before any git operation - -Before EVERY `git add` and `git commit`, run: - -```bash -pwd -git branch --show-current -``` - -The output MUST show: -- pwd: `/Users/kevinsmith/src/pypi/autosemver/.worktrees/feat-cli-overlay-simplification` -- branch: `feat/cli-overlay-simplification` - -If either is wrong, STOP. Use absolute paths (starting with the worktree path) for Edit operations. Previous plan executions had implementers accidentally edit files on main. - -### Step 1: Replace `apply_cli_overlay` body and delete helpers in `semvertag/_settings.py` - -Find the existing `apply_cli_overlay` function (currently lines 91-102), `_split_overrides` (lines 105-128), and `_revalidate_nested` (lines 131-147). Replace all three with the single function below. - -Use a single Edit operation that replaces the block from `def apply_cli_overlay(` through the end of `_revalidate_nested`. The exact text to find: - -```python -def apply_cli_overlay( - settings: Settings, - overrides: dict[str, tuple[typing.Any, str]], -) -> Settings: - update_top, nested_updates = _split_overrides(settings, overrides) - for head, leaf_updates in nested_updates.items(): - update_top[head] = _revalidate_nested(settings, head, leaf_updates) - - copied: typing.Final = settings.model_copy(update=update_top, deep=True) - raw_data: typing.Final = {name: getattr(copied, name) for name in type(copied).model_fields} - new_settings: typing.Final = type(copied).model_validate(raw_data) - return new_settings - - -def _split_overrides( - settings: Settings, - overrides: dict[str, tuple[typing.Any, str]], -) -> tuple[dict[str, typing.Any], dict[str, dict[str, typing.Any]]]: - update_top: dict[str, typing.Any] = {} - nested_updates: dict[str, dict[str, typing.Any]] = {} - settings_fields: typing.Final = type(settings).model_fields - - for dotted_key, (value, _flag_detail) in overrides.items(): - if "." in dotted_key: - head, _, leaf = dotted_key.partition(".") - if "." in leaf: - msg = f"CLI overlay key '{dotted_key}' exceeds nesting depth 2." - raise ValueError(msg) - if head not in settings_fields: - msg = f"Unknown CLI overlay target: {head!r}." - raise ValueError(msg) - nested_updates.setdefault(head, {})[leaf] = value - else: - if dotted_key not in settings_fields: - msg = f"Unknown CLI overlay target: {dotted_key!r}." - raise ValueError(msg) - update_top[dotted_key] = value - return update_top, nested_updates - - -def _revalidate_nested( - settings: Settings, - head: str, - leaf_updates: dict[str, typing.Any], -) -> pydantic.BaseModel: - nested: typing.Final = getattr(settings, head) - if not isinstance(nested, pydantic.BaseModel): - msg = f"CLI overlay target '{head}' is not a pydantic BaseModel." - raise TypeError(msg) - nested_fields: typing.Final = type(nested).model_fields - for leaf in leaf_updates: - if leaf not in nested_fields: - msg = f"Unknown CLI overlay target: {head}.{leaf!r}." - raise ValueError(msg) - nested_data: typing.Final = {name: getattr(nested, name) for name in nested_fields} - nested_data.update(leaf_updates) - return type(nested).model_validate(nested_data) -``` - -Replace with: - -```python -def apply_cli_overlay(settings: Settings, overrides: dict[str, typing.Any]) -> Settings: - top_updates: dict[str, typing.Any] = {} - nested_updates: dict[str, dict[str, typing.Any]] = {} - for dotted_key, value in overrides.items(): - head, _, leaf = dotted_key.partition(".") - if "." in leaf: - msg = f"CLI overlay key '{dotted_key}' exceeds nesting depth 2." - raise ValueError(msg) - if leaf: - nested_updates.setdefault(head, {})[leaf] = value - else: - top_updates[head] = value - for head, leaves in nested_updates.items(): - top_updates[head] = getattr(settings, head).model_copy(update=leaves) - copied = settings.model_copy(update=top_updates) - # Re-validate to trigger field validators (e.g. _clamp_request_timeout). - # getattr (not model_dump) preserves live SecretStr values. - return type(settings).model_validate({name: getattr(copied, name) for name in type(copied).model_fields}) -``` - -### Step 2: Verify imports in `_settings.py` are still all used - -Run: `uv run ruff check semvertag/_settings.py` - -Expected: PASS (or only style issues, not unused-import errors). The new `apply_cli_overlay` still uses `typing`, `pydantic` (via model_validate / model_copy on BaseModel subclasses). No imports become orphans. - -If `pydantic` is flagged unused: don't act — pydantic is still imported because `Settings` uses `pydantic.Field` / `pydantic.SecretStr` / `pydantic.AliasChoices`. The lint check shouldn't fire; if it does, investigate before changing anything. - -### Step 3: Simplify `_collect_overrides` in `semvertag/__main__.py` - -Find `_collect_overrides` (currently lines 40-65). The current body wraps each value in a `(value, "--flag-name")` tuple. Replace the entire function with: - -```python -def _collect_overrides( # noqa: PLR0913 - *, - project_id: int | None, - strategy: str | None, - provider: str | None, - token: str | None, - default_branch: str | None, - gitlab_endpoint: str | None, - request_timeout: float | None, -) -> dict[str, typing.Any]: - overrides: dict[str, typing.Any] = {} - if project_id is not None: - overrides["project_id"] = project_id - if strategy is not None: - overrides["strategy"] = strategy - if provider is not None: - overrides["provider"] = provider - if token is not None: - overrides["gitlab.token"] = pydantic.SecretStr(token) - if default_branch is not None: - overrides["default_branch"] = default_branch - if gitlab_endpoint is not None: - overrides["gitlab.endpoint"] = gitlab_endpoint - if request_timeout is not None: - overrides["request_timeout"] = request_timeout - return overrides -``` - -Two changes from the current version: -- Return type annotation: `dict[str, tuple[typing.Any, str]]` → `dict[str, typing.Any]` -- Each assignment drops the `(value, "--flag-name")` tuple wrapper - -### Step 4: Run the full test suite - -```bash -uv run pytest -q -``` - -Expected: **330 passed, 1 skipped** (unchanged from baseline; no test count change). - -If any test fails, READ the failure. Common pitfalls: -- `TypeError: cannot unpack non-iterable` in `_collect_overrides` callers — but there's only one caller (`_main_callback`), so this shouldn't happen unless the caller wasn't updated. The caller passes the dict directly to `apply_cli_overlay`, no unpacking; no change needed there. -- `AttributeError` on a model_copy result — `model_copy(update=...)` on a `BaseSettings` subclass should return the same subclass; verify by running `tests/integration/test_cli_main_verb.py` specifically. -- `ValidationError` complaints about `SecretStr` masking — the `model_validate({name: getattr(copied, name) ...})` round-trip preserves live SecretStr objects; verify token still flows correctly via the CLI smoke test. - -Do NOT silently mask bugs. - -### Step 5: Lint check - -```bash -just lint-ci -``` - -Expected: PASS. If ruff/ty flag unused imports we missed, fix them. - -### Step 6: Targeted regression smoke tests - -```bash -uv run semvertag --version -``` -Expected: prints version. Exit 0. - -```bash -uv run semvertag tag -``` -Expected: fails with `Error: Project id missing. Set CI_PROJECT_ID or pass --project-id.` Exit code 2. - -```bash -SEMVERTAG_PROJECT_ID=999 SEMVERTAG_TOKEN=fake-token uv run semvertag --project-id 1234 tag -``` -Expected: fails with a network error or auth error (no real GitLab access). The `--project-id 1234` should override the env `999` — verify the error message references `1234`, not `999`, to confirm CLI overlay still works. - -```bash -SEMVERTAG_PROJECT_ID=999 uv run semvertag --strategy conventional-commits tag -``` -Expected: fails (no token), but the failure path should reach `_build_current_strategy` (which checks `settings.strategy == "conventional-commits"`). If the CLI overlay broke, the strategy would fall back to `branch-prefix`. Hard to verify without setting up full mocks, but the call should at least not throw a `ValidationError`. - -### Step 7: Docs build - -```bash -UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict -``` -Expected: builds clean. - -### Step 8: Verify cwd before committing - -```bash -pwd -git branch --show-current -``` - -MUST show worktree path and `feat/cli-overlay-simplification`. If wrong, STOP. - -### Step 9: Verify LOC delta - -```bash -wc -l semvertag/_settings.py -``` -Expected: ~106 LOC (down from 147). - -```bash -git diff main --shortstat -``` -Expected: ~50 LOC net deletion, 2 files changed. - -### Step 10: Commit - -```bash -git add semvertag/_settings.py semvertag/__main__.py -git commit -m "settings: collapse apply_cli_overlay into 14 LOC via pydantic model_copy - -Replace apply_cli_overlay + _split_overrides + _revalidate_nested -(~57 LOC) with a single 14-line apply_cli_overlay that uses -pydantic.BaseModel.model_copy(update=...) for nested partial updates. - -Drop the (value, flag_detail) tuple shape from overrides — flag_detail -was scaffolding for the _provenance tracking that drop-doctor removed. -Pyright was already flagging it as unused. - -Drop the unknown-key validation; _collect_overrides hard-codes all -override keys, so misspellings are implementer bugs caught by -lint/tests, not user-facing concerns. Pydantic ValidationError covers -any drift. - -_collect_overrides in __main__.py simplifies correspondingly: returns -dict[str, Any] instead of dict[str, tuple[Any, str]]; 8 if-blocks -drop the (value, '--flag') tuple wrapping. - -Net: ~-50 LOC across two files. No behavior change; no test changes -(no test references the simplified internals; CLI integration tests -verify end-to-end behavior unchanged)." -``` - ---- - -## Task 3: Pre-merge verification gate - -**Files:** none modified. - -- [ ] **Step 1: Lint** - -Run: `just lint-ci` -Expected: PASS. - -- [ ] **Step 2: Full test suite** - -Run: `uv run pytest` -Expected: 330 passed, 1 skipped (unchanged). - -- [ ] **Step 3: Branch-coverage gates (unaffected by this work)** - -Run: `just test-branch-strategies` -Expected: 100% on `semvertag.strategies.branch_prefix`. - -Run: `just test-cc-strategies` -Expected: 100% on `semvertag.strategies.conventional_commits`. - -- [ ] **Step 4: Docs build** - -Run: `UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -- [ ] **Step 5: LOC + commit sanity check** - -Run: `git diff main --shortstat` -Expected: 2 files changed, ~50 LOC net deletion. - -Run: `git log --oneline main..HEAD` -Expected: exactly 1 commit (`settings: collapse apply_cli_overlay...`). - ---- - -## Task 4: Land the worktree - -**Files:** none modified by hand. - -- [ ] **Step 1: Invoke `superpowers:finishing-a-development-branch`** - -Use the skill to merge to `main` (fast-forward expected — main shouldn't have moved during this work) and clean up the worktree. - -- [ ] **Step 2: Verify the work is on `main`** - -Run (in the main checkout): `git log --oneline -3` -Expected: the `settings: collapse apply_cli_overlay…` commit is at HEAD. - -Run: `just lint-ci && uv run pytest -q` -Expected: green on `main`; 330 passed, 1 skipped. - -Run: `grep -n "_split_overrides\|_revalidate_nested" semvertag/` -Expected: NO matches (both helpers deleted). - -Run: `grep -n "flag_detail\|tuple\[typing.Any, str\]" semvertag/` -Expected: NO matches. - -Run: `wc -l semvertag/_settings.py` -Expected: ~106 LOC. - ---- - -## Success criteria - -When all tasks above are done: - -- `semvertag/_settings.py` no longer contains `_split_overrides` or `_revalidate_nested` (deleted). -- `apply_cli_overlay` body is ~14 LOC and uses `model_copy(update=...)` on nested configs. -- `apply_cli_overlay` signature accepts `dict[str, typing.Any]` (no tuple wrapping). -- `_collect_overrides` in `semvertag/__main__.py` returns `dict[str, typing.Any]`; 7 if-blocks drop the `(value, "--flag")` tuple wrapping. -- All 330 tests pass; no test changes. -- `_settings.py` is meaningfully shorter (~106 LOC; was 147). -- 4 smoke tests produce expected output. -- `just lint-ci`, `uv run pytest`, `mkdocs build --strict` all green. diff --git a/planning/changes/2026-05-31.07-strategy-no-bump-cleanup/design.md b/planning/changes/2026-05-31.07-strategy-no-bump-cleanup.md similarity index 100% rename from planning/changes/2026-05-31.07-strategy-no-bump-cleanup/design.md rename to planning/changes/2026-05-31.07-strategy-no-bump-cleanup.md diff --git a/planning/changes/2026-05-31.07-strategy-no-bump-cleanup/plan.md b/planning/changes/2026-05-31.07-strategy-no-bump-cleanup/plan.md deleted file mode 100644 index 96443af..0000000 --- a/planning/changes/2026-05-31.07-strategy-no-bump-cleanup/plan.md +++ /dev/null @@ -1,451 +0,0 @@ -# Strategy No-Bump Cleanup Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Move strategy-specific "why I returned `Bump.NONE`" text from `_status_for_no_bump` / `_reason_for_no_bump` helpers in `_use_case.py` onto each strategy class as `ClassVar[str]`, paralleling the existing `name: ClassVar[str]` pattern. Each strategy that can return `Bump.NONE` declares its own status + reason. - -**Architecture:** Single atomic commit on one worktree. The `BumpStrategy` Protocol gains 2 fields, both concrete strategies declare them as `ClassVar`, `_use_case.py` reads them directly (drops 2 helpers + 2 constants), and the test stub gains matching defaults. 5 files; the Protocol change must land with all conformers + the consumer + the test stub at once. - -**Tech Stack:** Existing — `dataclasses`, `typing.Protocol`, `typing.ClassVar`. No new deps. - -**Spec:** `planning/specs/2026-05-31-strategy-no-bump-cleanup-design.md` - ---- - -## Task 1: Spawn worktree and verify baseline - -**Files:** none in main checkout. - -- [ ] **Step 1: Spawn the worktree** - -Use the `superpowers:using-git-worktrees` skill. Suggested branch: `feat/strategy-no-bump-cleanup`. Suggested path: `.worktrees/feat-strategy-no-bump-cleanup`. - -- [ ] **Step 2: Verify clean baseline inside the worktree** - -Run (inside the worktree, after `cd` and `uv sync --all-extras --group lint`): - -```bash -pwd -git branch --show-current -git status -``` - -Expected: cwd is `/Users/kevinsmith/src/pypi/autosemver/.worktrees/feat-strategy-no-bump-cleanup`, branch is `feat/strategy-no-bump-cleanup`, status clean. - -Run: `just lint-ci` -Expected: PASS. - -Run: `uv run pytest -q` -Expected: 330 passed, 1 skipped. - -If any baseline check fails, stop and report. - ---- - -## Task 2: Atomic refactor in one commit - -The Protocol change must land with all conformers (both strategy classes + the test stub) and the consumer (`_use_case.py`) at once. - -**Files:** -- Modify: `semvertag/strategies/_base.py` (add 2 Protocol fields) -- Modify: `semvertag/strategies/branch_prefix.py` (add 2 ClassVar declarations) -- Modify: `semvertag/strategies/conventional_commits.py` (add 2 ClassVar declarations) -- Modify: `semvertag/_use_case.py` (delete 2 helpers + 2 constants; update one branch) -- Modify: `tests/unit/test_use_case.py` (add 2 fields to `_StubStrategy`) - -### CRITICAL: Verify cwd before any git operation - -Before EVERY `git add` and `git commit`, run: - -```bash -pwd -git branch --show-current -``` - -The output MUST show: -- pwd: `/Users/kevinsmith/src/pypi/autosemver/.worktrees/feat-strategy-no-bump-cleanup` -- branch: `feat/strategy-no-bump-cleanup` - -If either is wrong, STOP. Use absolute paths (starting with the worktree path) for Edit operations. - -### Step 1: Add 2 fields to the `BumpStrategy` Protocol - -In `semvertag/strategies/_base.py`, find the current Protocol (currently 5 lines after imports): - -```python -class BumpStrategy(typing.Protocol): - name: str - - def decide(self, commit: Commit) -> Bump: ... -``` - -Replace with: - -```python -class BumpStrategy(typing.Protocol): - name: str - no_bump_status: str - no_bump_reason: str - - def decide(self, commit: Commit) -> Bump: ... -``` - -### Step 2: Add ClassVars to `BranchPrefixStrategy` - -In `semvertag/strategies/branch_prefix.py`, find the `BranchPrefixStrategy` class (currently lines 21-24, before the `decide` method): - -```python -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) -class BranchPrefixStrategy: - name: typing.ClassVar[str] = "branch-prefix" - config: BranchPrefixConfig -``` - -Replace with: - -```python -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) -class BranchPrefixStrategy: - name: typing.ClassVar[str] = "branch-prefix" - no_bump_status: typing.ClassVar[str] = "no_merge_commit" - no_bump_reason: typing.ClassVar[str] = "Latest commit on default branch is not a merge commit." - config: BranchPrefixConfig -``` - -Don't touch the `decide` method or the rest of the file. - -### Step 3: Add ClassVars to `ConventionalCommitsStrategy` - -In `semvertag/strategies/conventional_commits.py`, find the `ConventionalCommitsStrategy` class (currently lines 32-35, before the `decide` method): - -```python -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) -class ConventionalCommitsStrategy: - name: typing.ClassVar[str] = "conventional-commits" - config: ConventionalCommitsConfig -``` - -Replace with: - -```python -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) -class ConventionalCommitsStrategy: - name: typing.ClassVar[str] = "conventional-commits" - no_bump_status: typing.ClassVar[str] = "no_conforming_commit" - no_bump_reason: typing.ClassVar[str] = "No conforming Conventional Commits type found in commit message." - config: ConventionalCommitsConfig -``` - -### Step 4: Update `_use_case.py` to read from the strategy + delete dead helpers/constants - -In `semvertag/_use_case.py`: - -**4a. Delete the 2 strategy-specific constants** (currently lines 12-13): - -```python -_NO_MERGE_REASON: typing.Final = "Latest commit on default branch is not a merge commit." -_NO_CONFORMING_REASON: typing.Final = "No conforming Conventional Commits type found in commit message." -``` - -Keep `_NO_TAGS_REASON` and `_ALREADY_TAGGED_REASON` (they're strategy-independent — about repo state). - -**4b. Update the `if bump is Bump.NONE:` branch** (currently lines 54-62 inside `__call__`): - -```python -if bump is Bump.NONE: - return self._emit( - output=output, - bump=Bump.NONE, - status=_status_for_no_bump(self.strategy.name), - tag=None, - commit=commit.sha, - reason=_reason_for_no_bump(self.strategy.name), - ) -``` - -Replace with: - -```python -if bump is Bump.NONE: - return self._emit( - output=output, - bump=Bump.NONE, - status=self.strategy.no_bump_status, - tag=None, - commit=commit.sha, - reason=self.strategy.no_bump_reason, - ) -``` - -**4c. Delete the 2 helper functions** (currently lines 132-141, at module level near the end): - -```python -def _status_for_no_bump(strategy_name: str) -> str: - if strategy_name == "branch-prefix": - return "no_merge_commit" - return "no_conforming_commit" - - -def _reason_for_no_bump(strategy_name: str) -> str: - if strategy_name == "branch-prefix": - return _NO_MERGE_REASON - return _NO_CONFORMING_REASON -``` - -Delete both functions entirely. - -### Step 5: Add defaults to `_StubStrategy` in `tests/unit/test_use_case.py` - -In `tests/unit/test_use_case.py`, find `_StubStrategy` (currently lines 68-74): - -```python -@dataclasses.dataclass(slots=True, kw_only=True) -class _StubStrategy: - name: str - bump_to_return: Bump - - def decide(self, commit: Commit) -> Bump: # noqa: ARG002 - return self.bump_to_return -``` - -Replace with: - -```python -@dataclasses.dataclass(slots=True, kw_only=True) -class _StubStrategy: - name: str - no_bump_status: str = "no_merge_commit" - no_bump_reason: str = "Latest commit on default branch is not a merge commit." - bump_to_return: Bump - - def decide(self, commit: Commit) -> Bump: # noqa: ARG002 - return self.bump_to_return -``` - -Both new fields default to branch-prefix values (most existing tests use branch-prefix-shaped stubs). - -### Step 6: Update the conventional-commits no-bump test to override the defaults - -In the same file, find `test_skips_with_no_conforming_commit_under_conventional_commits_when_bump_is_none` (currently lines 141-151). The test currently uses `_make_use_case(..., strategy_name=_CONVENTIONAL_STRATEGY)` which creates a `_StubStrategy(name="conventional-commits", bump_to_return=Bump.NONE)` — with the new default fields, this stub would report `no_bump_status="no_merge_commit"` (wrong for conventional-commits). - -The test asserts `result.status == "no_conforming_commit"`, which now requires the stub to have `no_bump_status="no_conforming_commit"`. - -**Two ways to fix this:** - -**Option A (recommended): make `_make_use_case` factory derive the no-bump fields from `strategy_name`** — preserves all test call sites unchanged. Edit `_make_use_case` (currently lines 77-95) to look up the no-bump values per strategy name: - -```python -_NO_BUMP_STATUS_BY_STRATEGY: typing.Final = { - _BRANCH_PREFIX_STRATEGY: "no_merge_commit", - _CONVENTIONAL_STRATEGY: "no_conforming_commit", -} -_NO_BUMP_REASON_BY_STRATEGY: typing.Final = { - _BRANCH_PREFIX_STRATEGY: "Latest commit on default branch is not a merge commit.", - _CONVENTIONAL_STRATEGY: "No conforming Conventional Commits type found in commit message.", -} - - -def _make_use_case( - *, - commit_message: str = _MERGE_MESSAGE, - commit_sha: str = _LATEST_SHA, - tags: list[Tag] | None = None, - bump: Bump = Bump.MINOR, - strategy_name: str = _BRANCH_PREFIX_STRATEGY, -) -> tuple[SemvertagUseCase, _StubProvider, _RecordingOutput]: - provider: typing.Final = _StubProvider( - commit=Commit(sha=commit_sha, message=commit_message), - tags=tags if tags is not None else [Tag(name=_LATEST_TAG_NAME, commit_sha=_PRIOR_SHA)], - ) - strategy: typing.Final = _StubStrategy( - name=strategy_name, - no_bump_status=_NO_BUMP_STATUS_BY_STRATEGY[strategy_name], - no_bump_reason=_NO_BUMP_REASON_BY_STRATEGY[strategy_name], - bump_to_return=bump, - ) - output: typing.Final = _RecordingOutput() - use_case: typing.Final = SemvertagUseCase( - provider=typing.cast("typing.Any", provider), - strategy=typing.cast("typing.Any", strategy), - ) - return use_case, provider, output -``` - -Place the two constants near the other module-level test constants (above `_make_use_case`). The factory now derives the stub's `no_bump_status` / `no_bump_reason` from `strategy_name`, so every existing test call site works unchanged. The defaults on `_StubStrategy` itself (set in Step 5) remain as safety nets but aren't exercised by `_make_use_case`. - -This makes the test plumbing mirror the dispatch shape that the production code USED to have — but only in tests, where it's stipulation, not logic. Production code is free of the string-identity branch. - -**Option B (alternative, more verbose): update only the CC test to pass the values explicitly.** Reject this if you can; option A is cleaner for the existing 5+ tests that go through the factory. - -### Step 7: Run pytest - -```bash -uv run pytest -q -``` - -Expected: **330 passed, 1 skipped** (unchanged from baseline; no tests added or removed). - -If any test fails, READ the failure. Common pitfalls: -- `AttributeError: '_StubStrategy' object has no attribute 'no_bump_status'` — Step 5 wasn't applied -- `KeyError: 'unexpected-strategy-name'` — a test uses a `strategy_name` value not in `_NO_BUMP_STATUS_BY_STRATEGY`; add it to the map, OR (if the test only sets `name` for the `Detected strategy:` progress message and never triggers the Bump.NONE path) call `_make_use_case` differently -- `AssertionError: result.reason != ...` — the constant text values must match exactly between the strategy ClassVar and what tests assert on. Re-check the text in Step 2 / Step 3. - -Do NOT silently mask bugs. - -### Step 8: Lint check - -```bash -just lint-ci -``` - -Expected: PASS. If ruff/ty flag unused imports we missed (e.g., the now-unused `_NO_MERGE_REASON`/`_NO_CONFORMING_REASON` references — but those should be gone from `_use_case.py` per Step 4a), fix them. - -### Step 9: Branch-coverage gates (the strategy modules now have 2 more declared attributes) - -```bash -just test-branch-strategies -just test-cc-strategies -``` -Expected: both still 100% on their respective strategy modules. The new ClassVar declarations are statements that need execution at class-definition time, which happens during import — coverage should naturally hit them. - -### Step 10: Smoke test the CLI - -```bash -uv run semvertag tag -``` -Expected: fails with `Error: Project id missing. Set CI_PROJECT_ID or pass --project-id.` Exit 2. - -```bash -uv run semvertag --version -``` -Expected: prints version (e.g., "0"). Exit 0. - -If anything diverges from main, surface it. - -### Step 11: Docs build - -```bash -UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict -``` -Expected: builds clean. - -### Step 12: Verify file LOC + cwd - -```bash -wc -l semvertag/_use_case.py -``` -Expected: ~127 LOC (down from 142; ~-15 LOC from deleting 2 helpers + 2 constants). - -```bash -pwd -git branch --show-current -``` -MUST show worktree path and `feat/strategy-no-bump-cleanup`. If wrong, STOP. - -### Step 13: Commit - -```bash -git add semvertag/strategies/_base.py semvertag/strategies/branch_prefix.py semvertag/strategies/conventional_commits.py semvertag/_use_case.py tests/unit/test_use_case.py -git commit -m "strategies: hoist no-bump status/reason to strategy ClassVars - -Move strategy-specific 'why I returned Bump.NONE' text from -_status_for_no_bump / _reason_for_no_bump helpers in _use_case.py -onto each strategy class as ClassVar[str], paralleling the existing -name: ClassVar[str] pattern. The BumpStrategy Protocol gains -no_bump_status and no_bump_reason fields. - -_use_case.py drops the two helpers, the _NO_MERGE_REASON and -_NO_CONFORMING_REASON constants, and the string-identity branching. -The use case body now reads self.strategy.no_bump_status and -self.strategy.no_bump_reason directly. - -_StubStrategy in test_use_case.py gains matching default fields. -The _make_use_case factory derives the stub's no-bump fields from -strategy_name via a small lookup map, keeping every existing test -call site unchanged. - -Structural value: adding a third strategy in the future requires no -edit to _use_case.py." -``` - ---- - -## Task 3: Pre-merge verification gate - -**Files:** none modified. - -- [ ] **Step 1: Lint** - -Run: `just lint-ci` -Expected: PASS. - -- [ ] **Step 2: Full test suite** - -Run: `uv run pytest` -Expected: 330 passed, 1 skipped (unchanged). - -- [ ] **Step 3: Branch-coverage gates** - -Run: `just test-branch-strategies` -Expected: 100% on `semvertag.strategies.branch_prefix`. - -Run: `just test-cc-strategies` -Expected: 100% on `semvertag.strategies.conventional_commits`. - -- [ ] **Step 4: Docs build** - -Run: `UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -- [ ] **Step 5: LOC + commit sanity check** - -Run: `git diff main --shortstat` -Expected: 5 files changed; ~5-10 LOC net change (small positive in strategies/test, larger negative in _use_case.py). - -Run: `git log --oneline main..HEAD` -Expected: exactly 1 commit (`strategies: hoist no-bump status/reason...`). - -Run: `grep -n "_status_for_no_bump\|_reason_for_no_bump\|_NO_MERGE_REASON\|_NO_CONFORMING_REASON" semvertag/` -Expected: NO matches. - -Run: `grep -n "no_bump_status\|no_bump_reason" semvertag/strategies/` -Expected: 4 matches (2 per concrete strategy file: one ClassVar declaration, one in `BumpStrategy` Protocol). - ---- - -## Task 4: Land the worktree - -**Files:** none modified by hand. - -- [ ] **Step 1: Invoke `superpowers:finishing-a-development-branch`** - -Use the skill to merge to `main` (fast-forward expected — main shouldn't have moved during this work) and clean up the worktree. - -- [ ] **Step 2: Verify the work is on `main`** - -Run (in the main checkout): `git log --oneline -3` -Expected: the `strategies: hoist no-bump status/reason...` commit is at HEAD. - -Run: `just lint-ci && uv run pytest -q` -Expected: green on `main`; 330 passed, 1 skipped. - -Run: `grep -n "_status_for_no_bump\|_reason_for_no_bump" semvertag/` -Expected: NO matches (modulo `_archive/`). - -Run: `wc -l semvertag/_use_case.py` -Expected: ~127 LOC. - ---- - -## Success criteria - -When all tasks above are done: - -- `BumpStrategy` Protocol in `semvertag/strategies/_base.py` has `no_bump_status: str` and `no_bump_reason: str` fields. -- `BranchPrefixStrategy` and `ConventionalCommitsStrategy` each declare `no_bump_status` and `no_bump_reason` as `ClassVar[str]` with the values that previously lived in `_use_case.py`. -- `semvertag/_use_case.py` no longer contains `_status_for_no_bump`, `_reason_for_no_bump`, `_NO_MERGE_REASON`, or `_NO_CONFORMING_REASON`. -- `_use_case.py`'s `if bump is Bump.NONE:` branch reads `self.strategy.no_bump_status` and `self.strategy.no_bump_reason` directly. -- `_StubStrategy` in `tests/unit/test_use_case.py` has matching default fields; `_make_use_case` derives them from `strategy_name` via a lookup map. -- All 330 tests pass; CLI behavior unchanged (smoke tests confirm). -- `_use_case.py` is meaningfully shorter (~127 LOC; was 142). -- `just lint-ci`, `uv run pytest`, `mkdocs build --strict` all green. diff --git a/planning/changes/2026-05-31.08-v0-1-0-release-prep/design.md b/planning/changes/2026-05-31.08-v0-1-0-release-prep.md similarity index 100% rename from planning/changes/2026-05-31.08-v0-1-0-release-prep/design.md rename to planning/changes/2026-05-31.08-v0-1-0-release-prep.md diff --git a/planning/changes/2026-05-31.08-v0-1-0-release-prep/plan.md b/planning/changes/2026-05-31.08-v0-1-0-release-prep/plan.md deleted file mode 100644 index 0da9ada..0000000 --- a/planning/changes/2026-05-31.08-v0-1-0-release-prep/plan.md +++ /dev/null @@ -1,669 +0,0 @@ -# v0.1.0 Release Prep Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Land all v0.1.0 release blockers in one atomic commit on main, then tag `v0.1.0` and let the publish workflow handle PyPI upload. - -**Architecture:** Single bundled commit touching ~12 files. Source-side changes (drop `--provider` + `Settings.provider`), test updates, file deletions (`action.yml`, `docs/providers/github.md`), `mkdocs.yml` nav cleanup, `pyproject.toml` metadata, `publish.yml` switch to `uv version $TAG` pattern, real README, real `docs/index.md`, `` → `modern-python` sweep across docs. - -**Tech Stack:** Existing — pydantic-settings, typer, modern-di-typer, mkdocs, uv. No new deps. - -**Spec:** `planning/specs/2026-05-31-v0-1-0-release-prep-design.md` - ---- - -## Task 1: Spawn worktree and verify baseline - -**Files:** none in main checkout. - -- [ ] **Step 1: Spawn the worktree** - -Use the `superpowers:using-git-worktrees` skill. Suggested branch: `feat/v0-1-0-release-prep`. Suggested path: `.worktrees/feat-v0-1-0-release-prep`. - -- [ ] **Step 2: Verify clean baseline inside the worktree** - -Run (inside the worktree, after `cd` and `uv sync --all-extras --group lint`): - -```bash -pwd -git branch --show-current -git status -``` - -Expected: cwd is `/Users/kevinsmith/src/pypi/autosemver/.worktrees/feat-v0-1-0-release-prep`, branch is `feat/v0-1-0-release-prep`, status clean. - -Run: `just lint-ci` -Expected: PASS. - -Run: `uv run pytest -q` -Expected: 330 passed, 1 skipped. - -Run: `UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -If any baseline check fails, stop and report. - ---- - -## Task 2: Atomic release-prep commit - -The 12-ish file changes are independent in their effects but bundled for diff coherence as a "release prep" commit. - -### CRITICAL: Verify cwd before any git operation - -Before EVERY `git rm`, `git add`, and `git commit`, run: - -```bash -pwd -git branch --show-current -``` - -The output MUST show: -- pwd: `/Users/kevinsmith/src/pypi/autosemver/.worktrees/feat-v0-1-0-release-prep` -- branch: `feat/v0-1-0-release-prep` - -If either is wrong, STOP. Use absolute paths (starting with the worktree path) for Edit operations. - -### Step 1: Drop `provider` field from `Settings` in `semvertag/_settings.py` - -Find the `provider` field (currently line 65 in the `Settings` class): - -```python - provider: typing.Literal["gitlab", "github", "bitbucket"] = "gitlab" -``` - -Delete this line. The `Settings` class loses the `provider` field entirely. - -### Step 2: Drop `--provider` flag + related plumbing in `semvertag/__main__.py` - -**2a.** In `_collect_overrides` (around lines 40-65), remove the `provider` keyword from the signature and the corresponding if-block. The new signature: - -```python -def _collect_overrides( # noqa: PLR0913 - *, - project_id: int | None, - strategy: str | None, - token: str | None, - default_branch: str | None, - gitlab_endpoint: str | None, - request_timeout: float | None, -) -> dict[str, typing.Any]: -``` - -And remove the body block: - -```python - if provider is not None: - overrides["provider"] = provider -``` - -If the function is now under 6 keyword args (currently 7 → 6), check whether `# noqa: PLR0913` (>5 args) is still needed. Ruff's PLR0913 default is 5; with 6 args it still applies. Keep the noqa. - -**2b.** In `_main_callback` (around lines 83-150), remove the `--provider` typer Option from the parameter list. Currently: - -```python - provider: typing.Annotated[ - str | None, - typer.Option("--provider", help="Provider: gitlab | github | bitbucket."), - ] = None, -``` - -Delete those four lines. - -**2c.** In `_main_callback`'s body, remove the `provider=provider,` argument from the `_collect_overrides(...)` call. - -**2d.** In `_main_callback`'s body, remove the provider-not-supported check: - -```python - if settings.provider != "gitlab": - msg = f"Provider {settings.provider!r} not yet supported; v1.0 supports gitlab only." - raise ConfigError(msg) -``` - -There IS no other provider in v0.1.0, so the check is dead. - -### Step 3: Delete provider-related test fixtures and assertions - -**3a.** In `tests/integration/conftest.py`, find `_CLEAN_ENV_KEYS` (around line 28-39). Remove the `"SEMVERTAG_PROVIDER",` entry. - -**3b.** In `tests/unit/test_settings.py`, find `assert settings.provider == "gitlab"` (line 29). Delete this assertion. The surrounding test (`test_uses_defaults_when_no_env_set`) keeps its other assertions; just drop this one line. - -**3c.** In `tests/unit/test_settings.py`, find the function `test_prefers_semvertag_token_over_provider_native` (line 49). Read the whole function — if it relies on `provider=...` in Settings construction, update or delete. If it only relies on env-var precedence (likely), no change needed. Run the test before deciding. - -**3d.** In `tests/unit/test_ioc.py`, find `_ProviderName` (line 13) and `_settings(... provider=...)` (lines 16-18). Drop both the `_ProviderName` alias and the `provider` parameter. The new helper: - -```python -def _settings( - *, - strategy: _StrategyName = "branch-prefix", -) -> Settings: - return Settings(project_id=999, strategy=strategy) -``` - -If any test in `test_ioc.py` calls `_settings(provider=...)`, drop the `provider=` argument from those calls. - -**3e.** Verify no CLI integration test passes `--provider` to `cli_runner.invoke(MAIN_APP, [...])`: - -```bash -grep -n "provider" tests/integration/test_cli_*.py -``` - -If any matches with `--provider`, remove the flag from the args list. - -### Step 4: Delete the GitHub-specific files - -```bash -git rm action.yml docs/providers/github.md -``` - -Both files are deleted entirely. - -### Step 5: Update `mkdocs.yml` — drop GitHub provider nav entry + replace `` - -In `mkdocs.yml`: - -**5a.** Update `repo_url` (line 2): - -```yaml -repo_url: https://github.com/modern-python/semvertag -``` - -(was `https://github.com//semvertag`) - -**5b.** Remove the GitHub Actions entry from the `Providers` nav (line 10): - -```yaml -# Before: - - Providers: - - GitHub Actions: providers/github.md - - GitLab CI: providers/gitlab.md -# After: - - Providers: - - GitLab CI: providers/gitlab.md -``` - -**5c.** Update the social GitHub link (line 71): - -```yaml -extra: - social: - - icon: fontawesome/brands/github - link: https://github.com/modern-python/semvertag - name: GitHub -``` - -(was `link: https://github.com//semvertag`) - -### Step 6: Update `pyproject.toml` — `` + description - -**6a.** Replace `` (line 32): - -```toml -[project.urls] -repository = "https://github.com/modern-python/semvertag" -docs = "https://semvertag.readthedocs.io" -``` - -**6b.** Tighten the description (line 3): - -```toml -description = "Auto-tag GitLab repos with semantic version tags — one tool, two strategies." -``` - -(was `"Auto-tag GitLab/GitHub/Bitbucket repos with semantic version tags — one tool, two strategies."`) - -Keywords stay as-is (`["semver", "gitlab", "ci", "auto-tag", "conventional-commits"]` — no github/bitbucket to remove). - -### Step 7: Update `.github/workflows/publish.yml` — drop tag-guard, add `uv version` step - -In `.github/workflows/publish.yml`, find the `tag_guard` step (lines 80-121). The step currently does: -1. Compute `EFFECTIVE_TAG` from `release.tag_name` or `inputs.tag` -2. Strip leading `v` -3. Validate SemVer 2.0 regex -4. Read `[project.version]` from pyproject.toml -5. Assert `EFFECTIVE_TAG == PROJECT_VERSION` -6. Set `effective_tag` step output - -Replace the entire step block with this simpler version that keeps the tag computation + SemVer validation, drops the assertion, and writes the step output (still needed for the artifact-upload step): - -```yaml - - id: tag_guard - name: Resolve and validate release tag - run: | - # Resolve effective tag from the triggering event. - if [ "${{ github.event_name }}" = "release" ]; then - EFFECTIVE_TAG="${{ github.event.release.tag_name }}" - elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - EFFECTIVE_TAG="${{ inputs.tag }}" - else - echo "::error::Unexpected event_name ${{ github.event_name }} — publish.yml expects 'release' or 'workflow_dispatch'." - exit 1 - fi - - # Strip a single leading 'v' (the SemVer-style 'v1.0.0' tag prefix). - EFFECTIVE_TAG="${EFFECTIVE_TAG#v}" - - # Re-assert strict SemVer 2.0 form post-strip. Catches typos like - # 'v1.0' or empty pre-release identifiers before the tag reaches - # PyPI. See SemVer 2.0 §2 (no leading zeros), §9 (pre-release - # identifier rules), §10 (build-metadata identifier rules). - if ! echo "$EFFECTIVE_TAG" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'; then - echo "::error::Effective tag '${EFFECTIVE_TAG}' is not strict SemVer 2.0 (expected MAJOR.MINOR.PATCH with optional dot-separated -prerelease and +build identifiers; no leading zeros in numeric identifiers; no empty identifiers)." - exit 1 - fi - echo "effective_tag=${EFFECTIVE_TAG}" >> "$GITHUB_OUTPUT" - - - name: Inject version from tag - if: success() - # pyproject.toml ships with version = "0" as a placeholder - # (matches the modern-di convention). The publish workflow sets - # the real version from the tag at build time, so main never - # carries a version that requires bumping per release. - run: uv version "${{ steps.tag_guard.outputs.effective_tag }}" -``` - -Keep everything else in `publish.yml` unchanged (concurrency, release-from-main check, setup-uv, build, upload artifact, publish). - -### Step 8: Rewrite `README.md` - -Replace the entire contents of `README.md` with: - -````markdown -# semvertag - -[![CI](https://github.com/modern-python/semvertag/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/semvertag/actions/workflows/ci.yml) -[![codecov](https://codecov.io/gh/modern-python/semvertag/branch/main/graph/badge.svg)](https://codecov.io/gh/modern-python/semvertag) - -Auto-tag your GitLab repository with semantic version tags from CI — one tool, two strategies. - -## Install - -```sh -uvx semvertag tag -``` - -## Use it in GitLab CI - -Include the Catalog component in your `.gitlab-ci.yml`: - -```yaml -include: - - component: gitlab.com/modern-python/semvertag/semvertag@v0.1.0 - inputs: - strategy: branch-prefix # or: conventional-commits -``` - -The component runs `uvx semvertag tag` against your repo on the -default branch. semvertag inspects the latest commit + tag history, -decides the appropriate semver bump, and creates the new tag via the -GitLab API. - -## Strategies - -- **branch-prefix** (default): the latest commit on the default branch - must be a merge commit whose source branch starts with `feature/` - (minor), `bugfix/`, or `hotfix/` (patch). -- **conventional-commits**: parses the latest commit's - [Conventional Commits](https://www.conventionalcommits.org/) - header (`feat:` minor, `fix:`/`perf:` patch, `!` or `BREAKING - CHANGE:` major). - -Both are configurable via env vars. See [docs](https://semvertag.readthedocs.io) -for the full configuration surface. - -## License - -MIT -```` - -### Step 9: Rewrite `docs/index.md` - -Replace the entire contents of `docs/index.md` (currently `# semvertag — coming soon`) with: - -```markdown -# semvertag - -Auto-tag your GitLab repository with semantic version tags from CI — -one tool, two strategies. - -semvertag reads the latest commit and tag history from your GitLab -project via the API, decides the appropriate semver bump based on the -strategy you've configured, and creates the new git tag — all from a -single command in your CI pipeline. - -## Quick start - -The recommended way to use semvertag in CI is via the -[GitLab CI Catalog component](providers/gitlab.md): - -```yaml -include: - - component: gitlab.com/modern-python/semvertag/semvertag@v0.1.0 - inputs: - strategy: branch-prefix # or: conventional-commits -``` - -For local testing or one-off invocations: - -```sh -SEMVERTAG_TOKEN= \ -SEMVERTAG_PROJECT_ID= \ - uvx semvertag tag -``` - -## Strategies - -semvertag ships with two bump-decision strategies: - -- [**branch-prefix**](strategies/branch-prefix.md) — bump based on the - source branch of the latest merge commit (`feature/` → minor, - `bugfix/` / `hotfix/` → patch). The default. -- [**conventional-commits**](strategies/conventional-commits.md) — - bump based on the latest commit's Conventional Commits header - (`feat:` → minor, `fix:` / `perf:` → patch, `!` or - `BREAKING CHANGE:` → major). - -Both strategies are configurable via environment variables — see the -strategy pages for the full configuration surface. - -## Contributing - -- [Release runbook](contributing/release.md) — for maintainers cutting - a new release of semvertag itself. -``` - -### Step 10: Update `docs/providers/gitlab.md` — replace `` and update version pin - -In `docs/providers/gitlab.md`, replace every occurrence of `` with `modern-python`. Use the editor's find/replace or: - -```bash -sed -i '' 's//modern-python/g' docs/providers/gitlab.md -``` - -(On Linux: `sed -i 's//modern-python/g' docs/providers/gitlab.md` — drop the `''`.) - -Verify with: - -```bash -grep -n "" docs/providers/gitlab.md -``` -Expected: no matches. - -Also update the version pin in the Catalog component examples. The file currently uses `@v1`; update to `@v0.1.0` to match the first release. Find with: - -```bash -grep -n "@v1" docs/providers/gitlab.md -``` - -There are typically 3 occurrences. Replace each `@v1` with `@v0.1.0`. - -### Step 11: Update `docs/contributing/release.md` — replace `` - -```bash -sed -i '' 's//modern-python/g' docs/contributing/release.md -``` - -Verify with `grep -n "" docs/contributing/release.md` (expected: no matches). - -The release runbook also has 2-3 NOTE blocks that explain `` is a placeholder. After the find/replace those notes become stale ("Replace `modern-python` below with the actual..."). Read the file and delete or rewrite those NOTE blocks; they served only to flag the placeholder. - -### Step 12: Update `templates/semvertag.yml` — version pin - -Open `templates/semvertag.yml`. Find the `script:` section. The current script line is: - -```yaml - script: - - uvx 'semvertag>=1,<2' tag -``` - -Update the version pin to allow v0.x as the first published release: - -```yaml - script: - - uvx 'semvertag>=0.1,<1' tag -``` - -(`>=0.1,<1` permits any 0.1.x or 0.x release; tighten to `>=0.1,<0.2` if you want to pin more strictly. The wider range is more forgiving for early adopters.) - -### Step 13: Run the full test suite - -```bash -uv run pytest -q -``` - -Expected: a count near 330, possibly slightly lower because: -- The `provider == "gitlab"` assertion in `test_uses_defaults_when_no_env_set` is removed (1 fewer assertion, same test count) -- The `test_prefers_semvertag_token_over_provider_native` test might still pass without modification -- `test_ioc.py`'s parametrize-on-provider tests might lose variants if they tested provider rejection - -If a test fails, READ the failure: -- `AttributeError: 'Settings' object has no attribute 'provider'` — Step 1 incomplete, OR Step 3a/3b/3d missed a reference -- `TypeError: _collect_overrides() got an unexpected keyword argument 'provider'` — Step 2c missed a caller -- `TypeError: Settings.__init__() got an unexpected keyword argument 'provider'` — Step 3d missed a test fixture - -Do NOT silently mask bugs. - -### Step 14: Lint check - -```bash -just lint-ci -``` - -Expected: PASS. If ruff/ty flag unused imports (e.g., a `typing.Literal` import that became unused), fix them. - -### Step 15: Branch-coverage gates - -```bash -just test-branch-strategies -just test-cc-strategies -``` - -Expected: both still 100% on their respective strategy modules (unaffected by this work). - -### Step 16: Docs build - -```bash -UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict -``` - -Expected: builds clean. If `--strict` complains about a missing nav entry, verify `mkdocs.yml` correctly dropped the GitHub Actions line (Step 5b). - -### Step 17: Sdist verification - -```bash -uv build -tar -tzf dist/semvertag-0.tar.gz | sort -``` - -Expected: 27-ish files, all under `semvertag-0/semvertag/` or top-level (`README.md`, `pyproject.toml`, `PKG-INFO`). NO `action.yml`, NO `docs/`, NO `tests/`. - -### Step 18: CLI smoke tests - -```bash -uv run semvertag --help -``` -Expected: top-level help shows the `tag` subcommand. The `--provider` flag is NOT listed. - -```bash -uv run semvertag tag --help -``` -Expected: tag subcommand help. Shows `--quiet` and `--json` only. - -```bash -uv run semvertag tag -``` -Expected: fails with `Error: Project id missing. Set CI_PROJECT_ID or pass --project-id.` Exit code 2. - -```bash -uv run semvertag --version -``` -Expected: prints version (e.g. "0", because pyproject still has version = "0"; the real version is set only at publish-workflow time). Exit 0. - -### Step 19: `` sweep - -```bash -grep -rn "" . 2>&1 | grep -vE "_archive/|\.worktrees/|_autosemver_reference/|\.git/" -``` - -Expected: NO matches. If any line is still there, address it. - -### Step 20: Verify cwd before committing - -```bash -pwd -git branch --show-current -``` - -MUST show worktree path and `feat/v0-1-0-release-prep`. If wrong, STOP. - -### Step 21: Commit - -```bash -git add -A -git commit -m "release: prep for v0.1.0 - -- Drop --provider CLI flag + Settings.provider field (only gitlab works) -- Delete action.yml + docs/providers/github.md (github provider not - implemented; ship clean v0.1.0; restore both when github provider lands) -- publish.yml: adopt modern-di pattern of \`uv version \$TAG\` at build time; - drop the strict tag↔[project.version] guard -- README.md + docs/index.md: replace stubs with real content -- → modern-python everywhere (pyproject URL, doc examples, mkdocs - repo_url + social link, GitLab template version pin) -- mkdocs.yml: drop GitHub provider nav entry -- templates/semvertag.yml: update version pin to >=0.1,<1 - -[project.version] stays \"0\" as a placeholder per the modern-di convention; -the publish workflow injects the real version from the git tag." -``` - ---- - -## Task 3: Pre-merge verification gate - -**Files:** none modified. - -- [ ] **Step 1: Lint** - -Run: `just lint-ci` -Expected: PASS. - -- [ ] **Step 2: Full test suite** - -Run: `uv run pytest` -Expected: count near 330 (small reduction from deleted provider tests, see Step 13 for likely diff). - -- [ ] **Step 3: Branch-coverage gates** - -Run: `just test-branch-strategies && just test-cc-strategies` -Expected: both still 100% on strategy modules. - -- [ ] **Step 4: Docs build** - -Run: `UV_OFFLINE=1 uv run --with-requirements docs/requirements.txt mkdocs build --strict` -Expected: builds clean. - -- [ ] **Step 5: Sdist sanity check** - -Run: `uv build && tar -tzf dist/semvertag-0.tar.gz` -Expected: 27 entries, all `semvertag/` source or top-level (README, pyproject, PKG-INFO). - -- [ ] **Step 6: LOC + commit sanity check** - -Run: `git diff main --shortstat` -Expected: 12+ files changed, ~-200 to -300 LOC net deletion. - -Run: `git log --oneline main..HEAD` -Expected: exactly 1 commit (`release: prep for v0.1.0`). - -- [ ] **Step 7: `` sweep on main + worktree files** - -Run: `grep -rn "" . 2>&1 | grep -vE "_archive/|\.worktrees/|_autosemver_reference/|\.git/"` -Expected: NO matches. - ---- - -## Task 4: Land the worktree + tag the release - -**Files:** none modified by hand. - -- [ ] **Step 1: Invoke `superpowers:finishing-a-development-branch`** - -Use the skill to merge to `main` (fast-forward expected — main shouldn't have moved during this work) and clean up the worktree. - -- [ ] **Step 2: Verify the work is on `main`** - -Run (in the main checkout): `git log --oneline -3` -Expected: the `release: prep for v0.1.0` commit is at HEAD. - -Run: `just lint-ci && uv run pytest -q` -Expected: green on `main`. - -Run: `ls action.yml docs/providers/github.md 2>&1 | head -2` -Expected: both files report "No such file or directory". - -- [ ] **Step 3: Tag v0.1.0** - -```bash -git tag v0.1.0 -``` - -Note: do NOT push the tag yet. The tag must align with the GitHub Release creation, which fires the publish workflow. - -- [ ] **Step 4: Push the tag** - -```bash -git push origin v0.1.0 -``` - -- [ ] **Step 5: Create the GitHub Release** - -This is a manual step in the GitHub UI: -1. Navigate to `https://github.com/modern-python/semvertag/releases/new` -2. Select tag: `v0.1.0` -3. Title: `v0.1.0 — initial release` -4. Body: free-form release notes (this IS the changelog; mention the strategies, that gitlab is the only supported provider in this release) -5. Click "Publish release" - -This fires the `release: published` event, which triggers `.github/workflows/publish.yml`. The workflow validates the tag, runs `uv version 0.1.0`, builds the sdist + wheel with the injected version, and publishes to PyPI via trusted publishing. - -- [ ] **Step 6: Verify the publish workflow ran** - -Watch the workflow run at `https://github.com/modern-python/semvertag/actions/workflows/publish.yml`. Expected: green run on `v0.1.0`. - -If the workflow fails (e.g., trusted-publishing not configured on PyPI, or a SemVer validation issue), use the `workflow_dispatch` retry path: - -``` -GitHub Actions → publish workflow → Run workflow → tag: v0.1.0 → Run -``` - -- [ ] **Step 7: Verify the package on PyPI** - -```bash -uvx --refresh semvertag==0.1.0 tag --help -``` - -Expected: downloads from PyPI, prints the tag subcommand help. - -Or visit `https://pypi.org/project/semvertag/0.1.0/` to confirm the release page. - ---- - -## Success criteria - -When all tasks above are done: - -- `semvertag/_settings.py` has no `provider` field -- `semvertag/__main__.py` has no `--provider` typer Option; `_collect_overrides` has no `provider` kwarg; the `if settings.provider != "gitlab"` check is gone -- `action.yml` and `docs/providers/github.md` are deleted -- `mkdocs.yml` nav has no GitHub provider entry; `repo_url` and social link use `modern-python` -- `pyproject.toml` `[project.urls].repository` is `https://github.com/modern-python/semvertag`; description mentions gitlab only -- `publish.yml` uses `uv version "${EFFECTIVE_TAG}"` instead of the tag-guard shell script -- `README.md` has install + usage + strategies + license sections -- `docs/index.md` is a real landing page (not "coming soon") -- `templates/semvertag.yml` uses `>=0.1,<1` version pin -- `grep -rn "" .` (excluding `_archive/`, `.worktrees/`, `_autosemver_reference/`, `.git/`) returns no matches -- All tests pass (count near 330) -- `just lint-ci`, `uv run pytest`, `mkdocs build --strict`, `uv build` all green -- Sdist contents are clean (only `semvertag/` source + README + pyproject + PKG-INFO) -- v0.1.0 is published on PyPI: `https://pypi.org/project/semvertag/0.1.0/` diff --git a/planning/changes/2026-06-07.01-httpware-migration/design.md b/planning/changes/2026-06-07.01-httpware-migration.md similarity index 100% rename from planning/changes/2026-06-07.01-httpware-migration/design.md rename to planning/changes/2026-06-07.01-httpware-migration.md diff --git a/planning/changes/2026-06-07.01-httpware-migration/plan.md b/planning/changes/2026-06-07.01-httpware-migration/plan.md deleted file mode 100644 index e034dd0..0000000 --- a/planning/changes/2026-06-07.01-httpware-migration/plan.md +++ /dev/null @@ -1,877 +0,0 @@ -# httpware migration — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Port semvertag's provider HTTP stack from a hand-rolled `RetryingTransport` + `HttpClient` wrapper onto `httpware` 0.8+. Delete ~600 lines of in-tree HTTP plumbing; preserve operator-action exit-code semantics (`ConfigError`/2, `AuthError`/3, `ProviderAPIError`/4) via a small per-provider translation module. - -**Architecture:** `GitLabProvider` holds a `httpware.Client` directly (no wrapper). One `try/except httpware.ClientError` per method calls `providers._errors.translate_gitlab(...)` to convert httpware's status- and transport-error tree into semvertag's domain errors. `httpware.Retry` middleware (configured to add HTTP 500 to its default retry set) replaces `RetryingTransport`. Wiring in `ioc.py` builds the client directly; `TransportsGroup` deletes. - -**Tech Stack:** Python 3.10+, `httpware[pydantic]` 0.8+, `httpx2`, `pydantic`, `modern-di-typer`, `pytest`. Tests use `httpx2.MockTransport` injected via `httpware.Client(httpx2_client=httpx2.Client(transport=mock))`. - -**Reference spec:** `planning/specs/2026-06-07-httpware-migration-design.md` - ---- - -## File structure - -**Create:** -- `semvertag/providers/_errors.py` — `translate_gitlab(exc, *, project_id)` + `translate_create_tag_bad_request(exc, *, tag_name)`. Handles `httpware.StatusError` subclasses *and* `httpware.NetworkError` / `httpware.TimeoutError` / `httpware.RetryBudgetExhaustedError`. Returns a `SemvertagError` subclass for the caller to `raise … from exc`. -- `tests/unit/test_providers_errors.py` — exhaustive translation tests (one per status code + one per transport error type + the BadRequest body-fragment branches). - -**Modify:** -- `pyproject.toml` — add `httpware[pydantic]` to `[project] dependencies`. -- `semvertag/providers/gitlab.py` — `GitLabProvider.http: httpware.Client`. Drop `_url()`, `_translate_status()`, `gitlab_auth_headers()`, and all `_HTTP_*` constants. Keep `_LINK_ENTRY_RE`, `_next_page_url`, `_parse_rel_values`, `_same_origin`, `_validate_tag_list` (still pagination-internal). Each public method wraps its httpware call in `try/except httpware.ClientError` → `_errors.translate_gitlab(...)`. `create_tag` adds a leading `except httpware.BadRequestError` for the "already exists" body-string special case. -- `semvertag/ioc.py` — delete `TransportsGroup`; remove from `ALL_GROUPS`. Split provider construction into `_build_gitlab_client(settings) -> httpware.Client` (the test-overridable seam) + `_build_gitlab_provider(settings, client) -> GitLabProvider`. `_close_provider_client` calls `provider.http.close()`. Drop the `gitlab_auth_headers` import (helper deleted). -- `tests/integration/test_gitlab_provider.py` — rewrite `_make_provider` to inject via `httpware.Client(httpx2_client=httpx2.Client(transport=mock, base_url=...))`. Delete `_make_provider_with_retrying_transport` (transport-level retry composition is upstream now). Update imports (drop `_translate_status`, `gitlab_auth_headers`, `RetryingTransport`, `HttpClient`). Adjust the specific tests that pinned wrapper-message wording (`"request failed: ..."`) or that exercised POST retry-on-429 behavior (now: immediate `RateLimitedError` → `ProviderAPIError`). - -**Delete:** -- `semvertag/_transport.py` (whole file) -- `semvertag/providers/_http.py` (whole file) -- `tests/unit/test_transport_retry.py` (403 lines — behavior now lives in httpware) -- `tests/unit/test_http_client.py` (186 lines — `HttpClient` is gone; translation tests replace it) - -**Test inventory after the change:** -- `tests/unit/test_providers_errors.py` — NEW -- `tests/integration/test_gitlab_provider.py` — modified seam, mostly preserved -- `tests/unit/test_ioc.py` — unchanged (it tests strategy resolution, not provider wiring) -- All other test files — untouched - ---- - -## Task 1: Add httpware dependency - -**Files:** -- Modify: `pyproject.toml` — `dependencies` array, `requires-python`, `classifiers` - -**Background:** httpware 0.8.0 ships with `requires-python = ">=3.11,<4"`. semvertag currently declares `>=3.10,<4` and lists Python 3.10 in `classifiers`. Adopting httpware forces semvertag to drop 3.10 support. This is a deliberate pre-1.0 breaking change, explicitly approved at plan-execution time. - -- [ ] **Step 1: Update `pyproject.toml`** - -Three coordinated edits: - -1. Bump `requires-python` from `">=3.10,<4"` to `">=3.11,<4"`. -2. Remove `"Programming Language :: Python :: 3.10"` from `classifiers`. -3. Add `"httpware[pydantic]"` as the last entry of `dependencies`. - -Resulting `dependencies` array: - -```toml -dependencies = [ - "typer", - "rich", - "semver", - "pydantic-settings", - "modern-di-typer", - "httpx2", - "httpware[pydantic]", -] -``` - -- [ ] **Step 2: Resolve the lockfile** - -Run: `uv lock --upgrade-package httpware` -Expected: `httpware` and its transitive deps appear in `uv.lock`; no errors. - -- [ ] **Step 3: Install** - -Run: `just install` (alias for `uv lock --upgrade && uv sync --all-extras --frozen --group lint`) -Expected: completes without errors. - -- [ ] **Step 4: Smoke-test the import** - -Run: `uv run python -c "import httpware; print(httpware.Client, httpware.Retry, httpware.StatusError, httpware.NetworkError)"` -Expected: prints four class repr lines, no `ImportError`. - -- [ ] **Step 5: Run the existing test suite to confirm no regressions from the dep addition** - -Run: `just test` -Expected: all tests pass, coverage stays at 100%. - -- [ ] **Step 6: Commit** - -```bash -git add pyproject.toml uv.lock -git commit -m "chore: add httpware[pydantic] dependency" -``` - ---- - -## Task 2: Create the translation module (TDD) - -**Files:** -- Create: `semvertag/providers/_errors.py` -- Create: `tests/unit/test_providers_errors.py` - -The translation module is the foundation — `gitlab.py` will depend on it in Task 3. We TDD it in isolation first. - -- [ ] **Step 1: Write the failing test file** - -Create `tests/unit/test_providers_errors.py`: - -```python -import httpx2 -import pytest - -import httpware - -from semvertag._errors import AuthError, ConfigError, ProviderAPIError -from semvertag.providers._errors import translate_create_tag_bad_request, translate_gitlab - - -_PROJECT_ID = 4242 - - -def _response(status: int, *, body: bytes = b"") -> httpx2.Response: - return httpx2.Response(status_code=status, content=body) - - -def _status_error(cls: type[httpware.StatusError], status: int, body: bytes = b"") -> httpware.StatusError: - return cls(_response(status, body=body)) - - -# translate_gitlab — status errors - -def test_translate_gitlab_401_becomes_auth_error_with_token_guidance() -> None: - result = translate_gitlab(_status_error(httpware.UnauthorizedError, 401), project_id=_PROJECT_ID) - assert isinstance(result, AuthError) - assert "Token rejected" in str(result) - assert "SEMVERTAG_TOKEN" in str(result) - - -def test_translate_gitlab_403_becomes_auth_error_with_scope_guidance() -> None: - result = translate_gitlab(_status_error(httpware.ForbiddenError, 403), project_id=_PROJECT_ID) - assert isinstance(result, AuthError) - assert "403" in str(result) - assert "api" in str(result) or "write_repository" in str(result) - - -def test_translate_gitlab_404_becomes_config_error_with_project_id() -> None: - result = translate_gitlab(_status_error(httpware.NotFoundError, 404), project_id=_PROJECT_ID) - assert isinstance(result, ConfigError) - assert f"project_id={_PROJECT_ID}" in str(result) - - -def test_translate_gitlab_422_becomes_config_error() -> None: - result = translate_gitlab(_status_error(httpware.UnprocessableEntityError, 422), project_id=_PROJECT_ID) - assert isinstance(result, ConfigError) - assert "422" in str(result) - - -def test_translate_gitlab_429_becomes_provider_api_error() -> None: - result = translate_gitlab(_status_error(httpware.RateLimitedError, 429), project_id=_PROJECT_ID) - assert isinstance(result, ProviderAPIError) - assert "rate limit" in str(result).lower() - - -def test_translate_gitlab_500_becomes_provider_api_error() -> None: - result = translate_gitlab(_status_error(httpware.InternalServerError, 500), project_id=_PROJECT_ID) - assert isinstance(result, ProviderAPIError) - assert "500" in str(result) - - -def test_translate_gitlab_503_becomes_provider_api_error() -> None: - result = translate_gitlab(_status_error(httpware.ServiceUnavailableError, 503), project_id=_PROJECT_ID) - assert isinstance(result, ProviderAPIError) - - -def test_translate_gitlab_unknown_4xx_falls_back_to_provider_api_error() -> None: - # 418 is not specially mapped; ClientStatusError is the fallback for unknown 4xx - result = translate_gitlab(_status_error(httpware.ClientStatusError, 418), project_id=_PROJECT_ID) - assert isinstance(result, ProviderAPIError) - assert "418" in str(result) - - -# translate_gitlab — transport errors - -def test_translate_gitlab_timeout_becomes_provider_api_error() -> None: - exc = httpware.TimeoutError("read timed out") - result = translate_gitlab(exc, project_id=_PROJECT_ID) - assert isinstance(result, ProviderAPIError) - assert "timed out" in str(result).lower() or "timeout" in str(result).lower() - - -def test_translate_gitlab_network_error_becomes_provider_api_error() -> None: - exc = httpware.NetworkError("connection refused") - result = translate_gitlab(exc, project_id=_PROJECT_ID) - assert isinstance(result, ProviderAPIError) - - -def test_translate_gitlab_retry_budget_exhausted_becomes_provider_api_error() -> None: - exc = httpware.RetryBudgetExhaustedError(last_response=None, last_exception=None, attempts=3) - result = translate_gitlab(exc, project_id=_PROJECT_ID) - assert isinstance(result, ProviderAPIError) - assert "retr" in str(result).lower() - - -# translate_create_tag_bad_request - -def test_translate_create_tag_bad_request_already_exists_becomes_config_error() -> None: - exc = _status_error(httpware.BadRequestError, 400, body=b'{"message":"Tag already exists"}') - result = translate_create_tag_bad_request(exc, tag_name="v1.2.3") - assert isinstance(result, ConfigError) - assert "v1.2.3" in str(result) - assert "already exists" in str(result).lower() - - -def test_translate_create_tag_bad_request_already_exists_is_case_insensitive() -> None: - exc = _status_error(httpware.BadRequestError, 400, body=b'{"message":"Tag ALREADY EXISTS"}') - result = translate_create_tag_bad_request(exc, tag_name="v1.2.3") - assert isinstance(result, ConfigError) - assert "already exists" in str(result).lower() - - -def test_translate_create_tag_bad_request_other_400_becomes_generic_config_error() -> None: - exc = _status_error(httpware.BadRequestError, 400, body=b'{"message":"bad ref format"}') - result = translate_create_tag_bad_request(exc, tag_name="v1.2.3") - assert isinstance(result, ConfigError) - assert "v1.2.3" not in str(result) - assert "400" in str(result) -``` - -- [ ] **Step 2: Run the test to verify it fails (module does not exist yet)** - -Run: `uv run pytest tests/unit/test_providers_errors.py -v` -Expected: `ModuleNotFoundError: No module named 'semvertag.providers._errors'`. - -- [ ] **Step 3: Write the translation module** - -Create `semvertag/providers/_errors.py`: - -```python -import httpware - -from semvertag._errors import AuthError, ConfigError, ProviderAPIError - - -_TAG_EXISTS_FRAGMENT = "already exists" - - -def translate_gitlab(exc: httpware.ClientError, *, project_id: int) -> Exception: - """Translate an httpware ClientError into the semvertag domain error for GitLab. - - Handles both status errors (4xx/5xx) and transport-layer failures - (network, timeout, retry budget exhaustion). - """ - if isinstance(exc, httpware.UnauthorizedError): - return AuthError("Token rejected: 401. Verify SEMVERTAG_TOKEN is valid and has 'api' scope.") - if isinstance(exc, httpware.ForbiddenError): - return AuthError( - "Token missing scope or insufficient permission: 403. " - "Add 'api' or 'write_repository' to the SEMVERTAG_TOKEN scopes on GitLab." - ) - if isinstance(exc, httpware.NotFoundError): - return ConfigError( - f"GitLab project not found: project_id={project_id}. Verify CI_PROJECT_ID or --project-id." - ) - if isinstance(exc, httpware.UnprocessableEntityError): - return ConfigError( - "Request rejected by GitLab: 422. Check tag name format and that the referenced commit exists." - ) - if isinstance(exc, httpware.RateLimitedError): - return ProviderAPIError("GitLab rate limit: 429. Retries exhausted after 3 attempts; try again later.") - if isinstance(exc, httpware.ServerStatusError): - return ProviderAPIError( - f"GitLab API failure: {exc.response.status_code}. " - "Retries exhausted after 3 attempts. Try again or check GitLab status." - ) - if isinstance(exc, httpware.StatusError): - return ProviderAPIError(f"Unexpected GitLab response: {exc.response.status_code}. Please file an issue.") - if isinstance(exc, httpware.TimeoutError): - return ProviderAPIError("GitLab request timed out. Try again or increase SEMVERTAG_REQUEST_TIMEOUT.") - if isinstance(exc, httpware.RetryBudgetExhaustedError): - return ProviderAPIError(f"GitLab retries exhausted after {exc.attempts} attempts. Try again later.") - if isinstance(exc, httpware.NetworkError): - return ProviderAPIError("GitLab unreachable. Check network connectivity.") - return ProviderAPIError(f"GitLab request failed: {type(exc).__name__}") - - -def translate_create_tag_bad_request(exc: httpware.BadRequestError, *, tag_name: str) -> Exception: - """create_tag's 400 has an 'already exists' special case; everything else is a generic 400.""" - body = exc.response.text - if _TAG_EXISTS_FRAGMENT in body.lower(): - return ConfigError( - f"Tag already exists: '{tag_name}'. " - "The tag was created by a concurrent run or previous invocation." - ) - return ConfigError("Request rejected by GitLab: 400. Check tag name format and that the referenced commit exists.") -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `uv run pytest tests/unit/test_providers_errors.py -v` -Expected: all 14 tests pass. - -- [ ] **Step 5: Lint** - -Run: `just lint` -Expected: clean (no ruff or ty findings). - -- [ ] **Step 6: Commit** - -```bash -git add semvertag/providers/_errors.py tests/unit/test_providers_errors.py -git commit -m "providers: add httpware-to-semvertag error translation module" -``` - ---- - -## Task 3: Port `GitLabProvider` to `httpware.Client` - -**Files:** -- Modify: `semvertag/providers/gitlab.py` (rewrite the class + drop helpers; keep pagination internals) -- Modify: `tests/integration/test_gitlab_provider.py` (rewrite `_make_provider` seam; delete `_make_provider_with_retrying_transport`; fix imports; adjust the few tests that pinned removed behavior) - -This is a port-style task, not pure TDD: the integration tests already describe the behavior contract. We update the seam + the production code together so tests stay meaningful throughout. - -**Important:** This task leaves `_http.py`, `_transport.py`, `test_http_client.py`, and `test_transport_retry.py` in place — they still pass independently. Task 4 switches ioc.py wiring; Task 5 deletes them. - -- [ ] **Step 1: Read the current state end-to-end (no edits)** - -Read the full bodies of: -- `semvertag/providers/gitlab.py` (220 lines) — note `_url`, `_translate_status`, `gitlab_auth_headers`, the `_HTTP_*` constants (all going away), and `_LINK_ENTRY_RE` / `_next_page_url` / `_parse_rel_values` / `_same_origin` / `_validate_tag_list` (staying). -- `tests/integration/test_gitlab_provider.py` (615 lines) — note `_make_provider` (lines 46–56), `_make_provider_with_retrying_transport` (59–72, gets deleted), the imports (1–28), and search for tests that: - - Assert on `"request failed:"` wrapper messages (those messages go away). - - Exercise POST retry behavior (POST is no longer retried). - - Use `RetryingTransport` or `_translate_status` or `gitlab_auth_headers` directly. - -- [ ] **Step 2: Rewrite `semvertag/providers/gitlab.py`** - -Replace the entire file contents with: - -```python -import dataclasses -import re -import typing -import urllib.parse - -import httpx2 -import pydantic - -import httpware - -from semvertag._errors import ConfigError, ProviderAPIError -from semvertag._settings import GitLabConfig -from semvertag._types import Commit, Tag -from semvertag.providers import _errors - - -_API_PREFIX: typing.Final = "/api/v4/projects" -_TAGS_PER_PAGE: typing.Final = 100 -_MAX_TAG_PAGES: typing.Final = 100 - - -class _ProjectResponse(pydantic.BaseModel): - default_branch: str | None - - -class _CommitItem(pydantic.BaseModel): - id: str - message: str - - -class _TagCommit(pydantic.BaseModel): - id: str - - -class _TagItem(pydantic.BaseModel): - name: str - commit: _TagCommit - - -# RFC 8288 Link header: ;param=value;param="value";... -_LINK_ENTRY_RE: typing.Final = re.compile( - r"<\s*(?P[^>]*?)\s*>(?P(?:\s*;\s*[^,;]+)*)", -) - - -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) -class GitLabProvider: - name: typing.ClassVar[str] = "gitlab" - config: GitLabConfig - project_id: int - http: httpware.Client - - def get_default_branch(self) -> str: - try: - project = self.http.get( - f"{_API_PREFIX}/{self.project_id}", - response_model=_ProjectResponse, - ) - except httpware.ClientError as exc: - raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc - if not project.default_branch: - msg = "Default branch missing from GitLab response. Verify the project has a default branch configured." - raise ConfigError(msg) - return project.default_branch - - def get_latest_commit_on_default_branch(self) -> Commit: - default_branch: typing.Final = self.get_default_branch() - try: - response = self.http.send(self.http.build_request( - "GET", - f"{_API_PREFIX}/{self.project_id}/repository/commits", - params={"ref_name": default_branch, "per_page": 1}, - )) - except httpware.ClientError as exc: - raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc - items = _validate_commit_list(response) - if not items: - msg = f"No commits on default branch '{default_branch}'. The branch appears empty." - raise ProviderAPIError(msg) - head = items[0] - return Commit(sha=head.id, message=head.message) - - def list_tags(self) -> list[Tag]: - tags: list[Tag] = [] - url: str = f"{_API_PREFIX}/{self.project_id}/repository/tags" - params: dict[str, typing.Any] | None = {"per_page": _TAGS_PER_PAGE, "page": 1} - for _ in range(_MAX_TAG_PAGES): - try: - response = self.http.send(self.http.build_request("GET", url, params=params)) - except httpware.ClientError as exc: - raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc - items = _validate_tag_list(response) - tags.extend(Tag(name=item.name, commit_sha=item.commit.id) for item in items) - next_url = _next_page_url(response, current_url=str(response.request.url)) - if next_url is None: - return tags - if not _same_origin(next_url, self.config.endpoint): - msg = ( - "GitLab pagination Link header points to a different host than SEMVERTAG_GITLAB__ENDPOINT. " - "Refusing to follow to protect credentials." - ) - raise ProviderAPIError(msg) - url, params = next_url, None - msg = ( - f"Tag pagination exceeded {_MAX_TAG_PAGES} pages. " - "The project has an unexpected number of tags; please file an issue." - ) - raise ProviderAPIError(msg) - - def create_tag(self, name: str, commit_sha: str) -> None: - try: - self.http.send(self.http.build_request( - "POST", - f"{_API_PREFIX}/{self.project_id}/repository/tags", - json={"tag_name": name, "ref": commit_sha}, - )) - except httpware.BadRequestError as exc: - raise _errors.translate_create_tag_bad_request(exc, tag_name=name) from exc - except httpware.ClientError as exc: - raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc - - -def _next_page_url(response: httpx2.Response, current_url: str) -> str | None: - link_header: typing.Final = response.headers.get("link") - if not link_header: - return None - for match in _LINK_ENTRY_RE.finditer(link_header): - url_part = match.group("url").strip() - if not url_part: - continue - if "next" in _parse_rel_values(match.group("params")): - return urllib.parse.urljoin(current_url, url_part) - return None - - -def _validate_tag_list(response: httpx2.Response) -> list[_TagItem]: - return _validate_list(response, _TagItem, label="tags") - - -def _validate_commit_list(response: httpx2.Response) -> list[_CommitItem]: - return _validate_list(response, _CommitItem, label="commits") - - -_TModel = typing.TypeVar("_TModel", bound=pydantic.BaseModel) - - -def _validate_list(response: httpx2.Response, model: type[_TModel], *, label: str) -> list[_TModel]: - try: - payload = response.json() - except (ValueError, httpx2.DecodingError) as exc: - msg = f"GitLab {label} response malformed JSON." - raise ProviderAPIError(msg) from exc - if not isinstance(payload, list): - msg = f"GitLab {label} response shape invalid: expected list." - raise ProviderAPIError(msg) - try: - return [model.model_validate(item) for item in payload] - except pydantic.ValidationError as exc: - msg = f"GitLab {label} response shape invalid: {exc}" - raise ProviderAPIError(msg) from exc - - -def _parse_rel_values(params_blob: str) -> set[str]: - for raw_param in params_blob.split(";"): - param = raw_param.strip() - if not param: - continue - name, _, value = param.partition("=") - if name.strip().lower() != "rel": - continue - cleaned = value.strip().strip('"').strip("'").lower() - return set(cleaned.split()) - return set() - - -def _same_origin(url: str, endpoint: str) -> bool: - parsed: typing.Final = urllib.parse.urlsplit(url) - expected: typing.Final = urllib.parse.urlsplit(endpoint) - return parsed.scheme == expected.scheme and parsed.netloc == expected.netloc -``` - -**Notes on the rewrite:** -- `_url(self, path)` is gone — `httpware.Client(base_url=...)` handles URL joining. -- All `_HTTP_*` constants gone — `isinstance` checks against `httpware.X` replace them. -- `gitlab_auth_headers` and `_translate_status` gone — moved into the client construction (Task 4) and `_errors.translate_gitlab` (Task 2) respectively. -- `get_latest_commit_on_default_branch` uses raw `send()` + `_validate_commit_list` rather than `response_model=list[_CommitItem]` — generic aliases like `list[X]` satisfy `type[T]` at runtime (via `TypeAdapter`) but trip static type checkers; keeping the validator approach matches `list_tags` and stays clean under `ty`. -- `list_tags` uses `self.http.send(self.http.build_request(...))` for raw response access. `response.request.url` gives us the absolute URL for `urljoin` (replacing the manual `current_url` tracking). - -- [ ] **Step 3: Update integration test imports and helpers** - -In `tests/integration/test_gitlab_provider.py`: - -Replace the import block (lines 1–28): - -```python -import typing - -import httpx2 -import pydantic -import pytest - -import httpware - -from semvertag._errors import AuthError, ConfigError, ProviderAPIError -from semvertag._settings import GitLabConfig -from semvertag._types import Commit, Tag -from semvertag.providers._base import Provider -from semvertag.providers.gitlab import ( - GitLabProvider, - _next_page_url, - _parse_rel_values, -) -from tests.conftest import ( - GITLAB_ENDPOINT, - GITLAB_PROJECT_ID, - GITLAB_TOKEN, - HandlerCallable, - compose_handler, - default_handler, -) -``` - -(Dropped: `from semvertag import _transport`, `from semvertag._transport import RetryingTransport`, `from semvertag.providers._http import HttpClient`, `_translate_status`, `gitlab_auth_headers`.) - -Replace `_make_provider` (lines 46–56): - -```python -_TOKEN_HEADER: typing.Final = "PRIVATE-TOKEN" - - -def _make_provider(handler: HandlerCallable) -> tuple[GitLabProvider, httpware.Client]: - transport: typing.Final = httpx2.MockTransport(handler) - config: typing.Final = GitLabConfig(endpoint=GITLAB_ENDPOINT, token=pydantic.SecretStr(GITLAB_TOKEN)) - client: typing.Final = httpware.Client( - httpx2_client=httpx2.Client(transport=transport, base_url=GITLAB_ENDPOINT), - headers={_TOKEN_HEADER: config.token.get_secret_value()}, - ) - provider: typing.Final = GitLabProvider(config=config, project_id=GITLAB_PROJECT_ID, http=client) - return provider, client -``` - -**Note:** `httpware.Client(httpx2_client=...)` forbids combining `httpx2_client=` with `base_url=`/`timeout=`/etc. (per `client.py:93–104` — it'll raise `TypeError` if mixed). Configure the underlying `httpx2.Client` with `base_url`; only `headers=` and `middleware=` are safe on the outer `httpware.Client(...)` call in test paths. Production (Task 4) uses the all-kwargs form instead. - -Delete `_make_provider_with_retrying_transport` entirely (lines 59–72). - -- [ ] **Step 4: Run integration tests to surface remaining failures** - -Run: `uv run pytest tests/integration/test_gitlab_provider.py -v` -Expected: many failures. Sort them into three buckets: - 1. **Import errors** in tests that used `_translate_status` or `gitlab_auth_headers` directly — those tests test deleted functions; **delete the test functions** (they belong to the deleted helper layer). - 2. **Assertion failures on exception message wording** — anywhere a test pinned `"request failed:"` (the old `_http.request_raw` wrapper) or specific text from the deleted `_translate_status`. Update the assertion to the new message (from `_errors.translate_gitlab`). - 3. **Behavioral failures from POST not retrying** — any test that called `create_tag` against a 429-then-201 handler expecting success. With POST no longer retried, the test should expect `ProviderAPIError` (from `translate_gitlab` mapping `RateLimitedError`). Update those tests to assert the new behavior. - -- [ ] **Step 5: Fix the failures bucket by bucket** - -For each failure in bucket (1): delete the test function. Note in commit message which ones were removed. - -For each failure in bucket (2): update the `assert "..." in str(exc.value)` to match `_errors.translate_gitlab`'s output for that status code. Cross-reference the message strings in `semvertag/providers/_errors.py`. - -For each failure in bucket (3): change `assert_success`-style assertions to `pytest.raises(ProviderAPIError)` and verify `"rate limit"` appears in the message. - -Also check `tests/integration/test_gitlab_provider.py` for any tests calling `_next_page_url` with the old `current_url` parameter shape — the parameter is unchanged in the rewrite, so these should still pass; if any break, the rewrite of `_next_page_url` is at fault, not the test. - -- [ ] **Step 6: Re-run the integration tests** - -Run: `uv run pytest tests/integration/test_gitlab_provider.py -v` -Expected: all tests pass. - -- [ ] **Step 7: Run the full test suite to catch cross-file fallout** - -Run: `just test` -Expected: `test_transport_retry.py` and `test_http_client.py` still pass (they test the legacy modules, which still exist). Coverage should still be 100%. - -If coverage dropped below 100% on `semvertag/providers/gitlab.py`, identify which branches lost coverage and either: -- Add the missing branch tests to `test_gitlab_provider.py`, or -- Confirm the lost branches were unreachable post-refactor and add `# pragma: no cover` with a one-line justification. - -- [ ] **Step 8: Lint** - -Run: `just lint` -Expected: clean. - -- [ ] **Step 9: Commit** - -```bash -git add semvertag/providers/gitlab.py tests/integration/test_gitlab_provider.py -git commit -m "providers/gitlab: port to httpware.Client; remove transport-layer concerns" -``` - ---- - -## Task 4: Switch `ioc.py` wiring to `httpware.Client` - -**Files:** -- Modify: `semvertag/ioc.py` - -After this task, `_transport.py` and `_http.py` have no remaining importers — Task 5 will delete them. - -- [ ] **Step 1: Rewrite `semvertag/ioc.py`** - -Replace the entire file contents with: - -```python -import typing - -import modern_di -from modern_di import Scope, providers - -import httpware - -from semvertag._errors import ConfigError -from semvertag._settings import Settings -from semvertag._use_case import SemvertagUseCase -from semvertag.providers.gitlab import GitLabProvider -from semvertag.strategies._base import BumpStrategy -from semvertag.strategies.branch_prefix import BranchPrefixStrategy -from semvertag.strategies.conventional_commits import ConventionalCommitsStrategy - - -_TOKEN_HEADER: typing.Final = "PRIVATE-TOKEN" -_RETRY_STATUS_CODES: typing.Final = frozenset({408, 429, 500, 502, 503, 504}) - - -def _build_gitlab_client(settings: Settings) -> httpware.Client: - return httpware.Client( - base_url=settings.gitlab.endpoint, - timeout=settings.request_timeout, - headers={_TOKEN_HEADER: settings.gitlab.token.get_secret_value()}, - middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], - ) - - -def _build_gitlab_provider(settings: Settings, client: httpware.Client) -> GitLabProvider: - if settings.project_id is None: - msg = "Project id missing. Set CI_PROJECT_ID or pass --project-id." - raise ConfigError(msg) - return GitLabProvider( - config=settings.gitlab, - project_id=settings.project_id, - http=client, - ) - - -def _build_branch_prefix_strategy(settings: Settings) -> BranchPrefixStrategy: - return BranchPrefixStrategy(config=settings.branch_prefix) - - -def _build_conventional_commits_strategy(settings: Settings) -> ConventionalCommitsStrategy: - return ConventionalCommitsStrategy(config=settings.conventional_commits) - - -def _build_current_strategy(settings: Settings) -> BumpStrategy: - if settings.strategy == "conventional-commits": - return _build_conventional_commits_strategy(settings) - return _build_branch_prefix_strategy(settings) - - -def _close_provider_client(provider: GitLabProvider) -> None: - provider.http.close() - - -class SettingsGroup(modern_di.Group): - settings = providers.ContextProvider(scope=Scope.APP, context_type=Settings) - - -class ProvidersGroup(modern_di.Group): - gitlab_client = providers.Factory(scope=Scope.APP, creator=_build_gitlab_client) - gitlab_provider = providers.Factory( - scope=Scope.APP, - creator=_build_gitlab_provider, - kwargs={"client": gitlab_client}, - cache_settings=providers.CacheSettings(finalizer=_close_provider_client), - ) - - -class StrategiesGroup(modern_di.Group): - branch_prefix_strategy = providers.Factory(scope=Scope.APP, creator=_build_branch_prefix_strategy) - conventional_commits_strategy = providers.Factory(scope=Scope.APP, creator=_build_conventional_commits_strategy) - current_strategy = providers.Factory(scope=Scope.APP, creator=_build_current_strategy) - - -class UseCasesGroup(modern_di.Group): - semvertag_use_case = providers.Factory( - scope=Scope.APP, - creator=SemvertagUseCase, - kwargs={ - "provider": ProvidersGroup.gitlab_provider, - "strategy": StrategiesGroup.current_strategy, - }, - ) - - -ALL_GROUPS: typing.Final[list[type[modern_di.Group]]] = [ - SettingsGroup, - ProvidersGroup, - StrategiesGroup, - UseCasesGroup, -] - - -container: typing.Final = modern_di.Container(groups=ALL_GROUPS) -``` - -**Notes:** -- `TransportsGroup` deletes; `ALL_GROUPS` shrinks by one entry. -- Imports of `httpx2`, `_transport.RetryingTransport`, `_http.HttpClient`, `gitlab.gitlab_auth_headers`, and `gitlab._translate_status` are gone. -- The provider factory is split: `gitlab_client` is the new test-overridable seam (Task 6 sketches the override pattern). -- `_close_provider_client` calls `httpware.Client.close()` (the sync close method); `httpware.Client` is itself a context manager but `close()` works for finalizer use. - -- [ ] **Step 2: Run the full test suite** - -Run: `just test` -Expected: all tests pass; coverage stays at 100%. - -If `test_ioc.py` references `TransportsGroup`, fix that import (the file as of writing does not). If any test passes the old `_build_gitlab_provider(settings, transport=...)` signature, update it to the new two-step `_build_gitlab_client(settings)` + `_build_gitlab_provider(settings, client)` shape. - -- [ ] **Step 3: Lint** - -Run: `just lint` -Expected: clean. - -- [ ] **Step 4: Commit** - -```bash -git add semvertag/ioc.py -git commit -m "ioc: build httpware.Client directly; drop TransportsGroup" -``` - ---- - -## Task 5: Delete legacy modules and their tests - -**Files:** -- Delete: `semvertag/_transport.py` -- Delete: `semvertag/providers/_http.py` -- Delete: `tests/unit/test_transport_retry.py` -- Delete: `tests/unit/test_http_client.py` - -After Tasks 3 and 4, nothing imports these. Verify, then delete. - -- [ ] **Step 1: Verify no remaining importers** - -Run: `grep -rn "from semvertag._transport\|from semvertag.providers._http\|RetryingTransport\|HttpClient" semvertag/ tests/ --include='*.py'` -Expected output: matches only inside `_transport.py`, `_http.py`, `test_transport_retry.py`, and `test_http_client.py` themselves. - -If any other file matches, stop and resolve before deleting. Likely missed update in Task 3. - -- [ ] **Step 2: Delete the four files** - -Run: -```bash -git rm semvertag/_transport.py semvertag/providers/_http.py -git rm tests/unit/test_transport_retry.py tests/unit/test_http_client.py -``` - -- [ ] **Step 3: Run the full test suite** - -Run: `just test` -Expected: all tests pass; coverage stays at 100%. (Coverage scope auto-shrinks — `--cov=semvertag` collects whatever modules exist.) - -- [ ] **Step 4: Check the `pyproject.toml` coverage `fail_under` and the per-module branch-coverage gates** - -Run: `grep -n "fail_under\|test-branch" /Users/kevinsmith/src/pypi/autosemver/pyproject.toml /Users/kevinsmith/src/pypi/autosemver/Justfile` - -If any `just test-branch-*` recipe targets a module we deleted (`_transport` or `_http`), remove that recipe from `Justfile`. If `pyproject.toml` references either module in coverage configuration, update it. - -- [ ] **Step 5: Lint** - -Run: `just lint` -Expected: clean. - -- [ ] **Step 6: Commit** - -```bash -git commit -m "providers: delete legacy _transport and _http modules" -``` - ---- - -## Task 6: Final validation - -**Files:** none modified — this is the verification gate. - -- [ ] **Step 1: Full lint sweep** - -Run: `just lint-ci` -Expected: clean. - -- [ ] **Step 2: Full test sweep with branch coverage** - -Run: `just test-branch` -Expected: all tests pass; coverage stays at 100% statement + branch. - -- [ ] **Step 3: Docs build (the project ships docs to ReadTheDocs)** - -Run: `mkdocs build --strict` -Expected: clean build. If a doc page references `RetryingTransport` or `HttpClient` by name, update the prose to reference `httpware.Retry` / `httpware.Client`. - -- [ ] **Step 4: End-to-end smoke against a real (or recorded) GitLab endpoint** *(optional but recommended)* - -If you have a sandbox GitLab project: set `SEMVERTAG_GITLAB__ENDPOINT`, `SEMVERTAG_TOKEN`, `CI_PROJECT_ID`, and run `uv run semvertag --dry-run` (or whatever the project's read-only CLI invocation is). Confirm: - - Default branch is fetched (200 → success). - - Latest commit is fetched. - - Tags are listed and paginated correctly. - - No `_http` or `_transport` import-time errors. - -If no sandbox is available, skip this step. The integration tests with `httpx2.MockTransport` cover the same paths. - -- [ ] **Step 5: Skim `git log --oneline` to confirm the commit history reads cleanly** - -Expected sequence (or similar): -``` -chore: add httpware[pydantic] dependency -providers: add httpware-to-semvertag error translation module -providers/gitlab: port to httpware.Client; remove transport-layer concerns -ioc: build httpware.Client directly; drop TransportsGroup -providers: delete legacy _transport and _http modules -``` - -- [ ] **Step 6 — Optional: invoke `superpowers:requesting-code-review`** - -Per CLAUDE.md the project's workflow is brainstorm → plan → TDD → review. Run a review subagent against the diff before merging. - ---- - -## Self-review notes - -- **Spec coverage:** Every spec section (`Target shape`, `Retry config`, `Error translation`, `Provider call-site shape`, `ioc.py wiring`, `Dependency changes`, `Test impact`, the four `Open items`) is implemented by at least one task. The four open items from the spec are all resolved in the plan: (1) DI split → Task 4 step 1, (2) `request_timeout` is a `float` (verified: `_settings.py:66`) → Task 4's `timeout=settings.request_timeout` works directly, (3) `__main__.py` catches `SemvertagError` already (verified: `__main__.py:160`) → no change needed because `translate_gitlab` produces semvertag domain errors that bubble through unchanged, (4) pagination uses `self.http.send(self.http.build_request(...))` returning the raw response → Task 3 step 2. -- **Placeholder scan:** No `TBD`/`TODO`/`...`/"appropriate error handling"/"similar to" patterns in any task step. The `# pragma: no cover` mention in Task 3 step 7 is a contingency directive, not a placeholder. -- **Type consistency:** `GitLabProvider.http: httpware.Client` used uniformly across Tasks 3 (production), 3 (tests via `_make_provider`), 4 (`_build_gitlab_provider` signature), 4 (`_close_provider_client`). `translate_gitlab(exc, *, project_id)` signature consistent across Task 2 (definition), Task 3 (call sites). `_build_gitlab_client` / `_build_gitlab_provider` split shape consistent between Task 4 step 1 and `ProvidersGroup` factory wiring within the same task. - ---- - -## Execution handoff - -(Filled in by the launching session — see `superpowers:writing-plans` skill.) diff --git a/planning/changes/2026-06-08.01-httpware-decoder-adoption/design.md b/planning/changes/2026-06-08.01-httpware-decoder-adoption.md similarity index 100% rename from planning/changes/2026-06-08.01-httpware-decoder-adoption/design.md rename to planning/changes/2026-06-08.01-httpware-decoder-adoption.md diff --git a/planning/changes/2026-06-08.01-httpware-decoder-adoption/plan.md b/planning/changes/2026-06-08.01-httpware-decoder-adoption/plan.md deleted file mode 100644 index 1df45c0..0000000 --- a/planning/changes/2026-06-08.01-httpware-decoder-adoption/plan.md +++ /dev/null @@ -1,409 +0,0 @@ -# httpware decoder adoption — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Switch `GitLabProvider`'s three GET methods to use `httpware`'s `response_model=` / `send_with_response` decoder paths, extend `translate_gitlab` with a `DecodeError` branch, delete the six in-tree `_validate_*` helpers. After this lands, semvertag actually uses `PydanticDecoder` instead of installing it and routing around it. - -**Architecture:** Two single-object GETs adopt `client.get(..., response_model=BaseModel)` directly. The list endpoints use `pydantic.RootModel[list[X]]` wrappers (because generic aliases trip `ty`); `get_latest_commit_on_default_branch` calls `client.get(..., response_model=_CommitList)` then reads `.root`, and `list_tags` calls `client.send_with_response(req, response_model=_TagList)` (needs the response back too for the `Link` header). `create_tag` is unchanged. `_errors.translate_gitlab` gains one `isinstance(exc, httpware.DecodeError)` branch. - -**Tech Stack:** Python 3.11+, `httpware[pydantic]>=0.8.2`, `pydantic 2.13+` (RootModel), `httpx2`, `pytest`. Tests use `httpx2.MockTransport` injected via `httpware.Client(httpx2_client=...)`. - -**Reference spec:** `planning/specs/2026-06-08-httpware-decoder-adoption-design.md` - ---- - -## File structure - -**Modify:** -- `pyproject.toml` — floor `httpware[pydantic]` at `>=0.8.2`. -- `semvertag/providers/_errors.py` — add `DecodeError` branch at the top of `_translate_gitlab_transport`. -- `semvertag/providers/gitlab.py` — add `_CommitList`, `_TagList` RootModel wrappers; rewrite the three GET methods to use decoder paths; delete `_validate_obj`, `_validate_project_response`, `_validate_commit_list`, `_validate_tag_list`, `_validate_list`, `_TModel`; drop unused `pydantic.ValidationError` + `httpx2.DecodingError` references. -- `tests/unit/test_providers_errors.py` — add one test for the new `DecodeError → ProviderAPIError` branch. -- `tests/integration/test_gitlab_provider.py` — update 9 `pytest.raises(..., match=...)` strings to match the new translated DecodeError wording. - -**No new files. No deletions of whole files.** - -After this lands, `gitlab.py` contains only domain types + RootModel wrappers + the four public provider methods + the pagination utilities (`_LINK_ENTRY_RE`, `_next_page_url`, `_parse_rel_values`, `_same_origin`). All generic JSON-validation plumbing is gone. - ---- - -## Task 1: Bump httpware dependency to 0.8.2 - -**Files:** -- Modify: `pyproject.toml` (`dependencies` array, the `"httpware[pydantic]"` line) - -- [ ] **Step 1: Edit `pyproject.toml`** - -Find the dependencies block and change: - -```toml - "httpware[pydantic]", -``` - -to: - -```toml - "httpware[pydantic]>=0.8.2", -``` - -Leave the other entries (`typer`, `rich`, `semver`, `pydantic-settings`, `modern-di-typer`, `httpx2`) alone. - -- [ ] **Step 2: Resolve the lockfile** - -Run: `uv lock --upgrade-package httpware` -Expected: `Added httpware v0.8.2` (replacing the prior pin). No errors. - -- [ ] **Step 3: Install** - -Run: `just install` -Expected: completes without errors. - -- [ ] **Step 4: Smoke-test the new symbols** - -Run: `uv run python -c "import httpware; print(httpware.DecodeError, httpware.Client.send_with_response)"` -Expected: prints ` `. No `AttributeError`. - -- [ ] **Step 5: Full suite (baseline check)** - -Run: `just test` -Expected: 332 tests pass, 100% coverage. The dep bump alone should not change behavior. - -- [ ] **Step 6: Commit** - -```bash -git add pyproject.toml -git commit -m "chore: floor httpware[pydantic] at >=0.8.2 (DecodeError, send_with_response)" -``` - -`uv.lock` is gitignored in this project; do not add it. - ---- - -## Task 2: Extend `translate_gitlab` with `DecodeError` branch (TDD) - -**Files:** -- Modify: `semvertag/providers/_errors.py` -- Modify: `tests/unit/test_providers_errors.py` - -The translation extension is small (one new isinstance branch) but it gates the decoder adoption in Task 3 — without it, `httpware.DecodeError` would fall through to the generic `f"GitLab request failed: {type(exc).__name__}"` fallback with the less informative wording. We TDD it now so Task 3 can rely on it. - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_providers_errors.py` (after the existing `test_translate_gitlab_network_error_becomes_provider_api_error` and friends): - -```python -def test_translate_gitlab_decode_error_becomes_provider_api_error() -> None: - underlying = ValueError("input should be a valid dictionary") - exc = httpware.DecodeError( - response=_response(200, body=b"null"), - model=type("FakeModel", (), {}), - original=underlying, - ) - result = translate_gitlab(exc, project_id=_PROJECT_ID) - assert isinstance(result, ProviderAPIError) - assert "FakeModel" in str(result) - assert "valid dictionary" in str(result).lower() -``` - -The synthetic `type("FakeModel", (), {})` keeps the test decoupled from `gitlab.py`'s internal models. The `_response` helper already exists in this file from the prior migration. - -- [ ] **Step 2: Run the test to verify it fails (no DecodeError branch yet)** - -Run: `uv run pytest tests/unit/test_providers_errors.py::test_translate_gitlab_decode_error_becomes_provider_api_error -v` -Expected: FAIL. The assertion `"FakeModel" in str(result)` fails because the current code falls through to `f"GitLab request failed: {type(exc).__name__}"`, which produces `"GitLab request failed: DecodeError"` — no `FakeModel` substring. - -- [ ] **Step 3: Add the `DecodeError` branch to `_translate_gitlab_transport`** - -Edit `semvertag/providers/_errors.py`. Find `_translate_gitlab_transport`: - -```python -def _translate_gitlab_transport(exc: httpware.ClientError) -> Exception: - if isinstance(exc, httpware.TimeoutError): - return ProviderAPIError("GitLab request timed out. Try again or increase SEMVERTAG_REQUEST_TIMEOUT.") - if isinstance(exc, httpware.RetryBudgetExhaustedError): - return ProviderAPIError(f"GitLab retries exhausted after {exc.attempts} attempts. Try again later.") - if isinstance(exc, httpware.NetworkError): - return ProviderAPIError("GitLab unreachable. Check network connectivity.") - return ProviderAPIError(f"GitLab request failed: {type(exc).__name__}") -``` - -Replace with: - -```python -def _translate_gitlab_transport(exc: httpware.ClientError) -> Exception: - if isinstance(exc, httpware.DecodeError): - return ProviderAPIError( - f"GitLab {exc.model.__name__} response could not be decoded: {exc.original}" - ) - if isinstance(exc, httpware.TimeoutError): - return ProviderAPIError("GitLab request timed out. Try again or increase SEMVERTAG_REQUEST_TIMEOUT.") - if isinstance(exc, httpware.RetryBudgetExhaustedError): - return ProviderAPIError(f"GitLab retries exhausted after {exc.attempts} attempts. Try again later.") - if isinstance(exc, httpware.NetworkError): - return ProviderAPIError("GitLab unreachable. Check network connectivity.") - return ProviderAPIError(f"GitLab request failed: {type(exc).__name__}") -``` - -(Only the first `if` block is added; everything else is unchanged.) - -- [ ] **Step 4: Run the new test to verify it passes** - -Run: `uv run pytest tests/unit/test_providers_errors.py::test_translate_gitlab_decode_error_becomes_provider_api_error -v` -Expected: PASS. - -- [ ] **Step 5: Run the full `_errors` test file to make sure nothing regressed** - -Run: `uv run pytest tests/unit/test_providers_errors.py -v` -Expected: 16 tests pass (was 15; we added one). - -- [ ] **Step 6: Full suite + coverage** - -Run: `just test` -Expected: 333 tests pass (was 332; the new test adds one), 100% coverage. - -- [ ] **Step 7: Lint** - -Run: `just lint-ci` -Expected: clean. - -- [ ] **Step 8: Commit** - -```bash -git add semvertag/providers/_errors.py tests/unit/test_providers_errors.py -git commit -m "providers/_errors: translate httpware.DecodeError to ProviderAPIError" -``` - ---- - -## Task 3: Port the three GET methods to decoder paths - -**Files:** -- Modify: `semvertag/providers/gitlab.py` (rewrite the three GET methods + class-level model definitions; delete validator helpers) -- Modify: `tests/integration/test_gitlab_provider.py` (update 9 `match=` strings) - -This is the largest task. The translator branch from Task 2 is in place, so `httpware.DecodeError` raised during the new decoder calls flows uniformly through `except httpware.ClientError` → `_errors.translate_gitlab` → `ProviderAPIError` with the new wording. - -- [ ] **Step 1: Read the current state end-to-end (no edits)** - -Read: -- `semvertag/providers/gitlab.py` (220-ish lines). Note the existing models (`_ProjectResponse`, `_CommitItem`, `_TagCommit`, `_TagItem`), the three GET methods you're rewriting, and the validator helpers (`_validate_obj`, `_validate_project_response`, `_validate_commit_list`, `_validate_tag_list`, `_validate_list`, `_TModel`) that will be deleted. -- `tests/integration/test_gitlab_provider.py` lines 95-300 — these contain the 9 `pytest.raises(..., match=...)` assertions you'll need to update. - -- [ ] **Step 2: Add the two RootModel wrappers in `gitlab.py`** - -After the existing `_TagItem` class (around line 36), insert: - -```python -class _CommitList(pydantic.RootModel[list[_CommitItem]]): - pass - - -class _TagList(pydantic.RootModel[list[_TagItem]]): - pass -``` - -These will be referenced as `response_model=_CommitList` / `response_model=_TagList`. They wrap the list under `.root`. - -- [ ] **Step 3: Rewrite `get_default_branch`** - -Find the current method body and replace with: - -```python - def get_default_branch(self) -> str: - try: - project = self.http.get( - f"{_API_PREFIX}/{self.project_id}", - response_model=_ProjectResponse, - ) - except httpware.ClientError as exc: - raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc - if not project.default_branch: - msg = "Default branch missing from GitLab response. Verify the project has a default branch configured." - raise ConfigError(msg) - return project.default_branch -``` - -The diff: `self.http.send(self.http.build_request(...))` + `_validate_project_response(response)` collapses to `self.http.get(..., response_model=_ProjectResponse)`. Error handling shape unchanged. - -- [ ] **Step 4: Rewrite `get_latest_commit_on_default_branch`** - -Find the current method and replace with: - -```python - def get_latest_commit_on_default_branch(self) -> Commit: - default_branch: typing.Final = self.get_default_branch() - try: - page = self.http.get( - f"{_API_PREFIX}/{self.project_id}/repository/commits", - params={"ref_name": default_branch, "per_page": 1}, - response_model=_CommitList, - ) - except httpware.ClientError as exc: - raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc - if not page.root: - msg = f"No commits on default branch '{default_branch}'. The branch appears empty." - raise ProviderAPIError(msg) - head = page.root[0] - return Commit(sha=head.id, message=head.message) -``` - -Two changes from current: (1) `self.http.send(self.http.build_request("GET", url, params=...))` + `_validate_commit_list(response)` collapses to `self.http.get(url, params=..., response_model=_CommitList)`; (2) `items[0]` becomes `page.root[0]` because the RootModel wraps the list. - -- [ ] **Step 5: Rewrite `list_tags`** - -Find the current method and replace with: - -```python - def list_tags(self) -> list[Tag]: - tags: list[Tag] = [] - url: str = f"{_API_PREFIX}/{self.project_id}/repository/tags" - params: dict[str, typing.Any] | None = {"per_page": _TAGS_PER_PAGE, "page": 1} - for _ in range(_MAX_TAG_PAGES): - try: - response, page = self.http.send_with_response( - self.http.build_request("GET", url, params=params), - response_model=_TagList, - ) - except httpware.ClientError as exc: - raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc - tags.extend(Tag(name=item.name, commit_sha=item.commit.id) for item in page.root) - next_url = _next_page_url(response, current_url=str(response.request.url)) - if next_url is None: - return tags - if not _same_origin(next_url, self.config.endpoint): - msg = ( - "GitLab pagination Link header points to a different host than SEMVERTAG_GITLAB__ENDPOINT. " - "Refusing to follow to protect credentials." - ) - raise ProviderAPIError(msg) - url, params = next_url, None - msg = ( - f"Tag pagination exceeded {_MAX_TAG_PAGES} pages. " - "The project has an unexpected number of tags; please file an issue." - ) - raise ProviderAPIError(msg) -``` - -Two changes: (1) `self.http.send(self.http.build_request(...))` + `_validate_tag_list(response)` becomes `self.http.send_with_response(..., response_model=_TagList)` which returns `(response, page)`; (2) `for item in items` becomes `for item in page.root`. Everything else (pagination loop, Link-header walk, same-origin check, page-cap) is unchanged. - -- [ ] **Step 6: Delete the validator helpers** - -In `gitlab.py`, delete these definitions and any blank lines between them: -- `def _validate_project_response(response: httpx2.Response) -> _ProjectResponse:` and its body -- `def _validate_tag_list(response: httpx2.Response) -> list[_TagItem]:` and its body -- `def _validate_commit_list(response: httpx2.Response) -> list[_CommitItem]:` and its body -- `_TModel = typing.TypeVar("_TModel", bound=pydantic.BaseModel)` line -- `def _validate_obj(response: httpx2.Response, model: type[_TModel], *, label: str) -> _TModel:` and its body -- `def _validate_list(response: httpx2.Response, model: type[_TModel], *, label: str) -> list[_TModel]:` and its body - -After this step `gitlab.py` should contain: imports → domain constants → BaseModel classes → RootModel wrappers → `_LINK_ENTRY_RE` → `GitLabProvider` class → `_next_page_url` → `_parse_rel_values` → `_same_origin`. No `_validate_*`, no `_TModel`. - -- [ ] **Step 7: Verify unused imports** - -The validator helpers were the only consumers of `pydantic.ValidationError` and `httpx2.DecodingError`. Ruff's `F401` will flag them after Step 6. - -Run: `uv run ruff check semvertag/providers/gitlab.py` -Expected: ruff reports unused `pydantic.ValidationError` and/or `httpx2.DecodingError` references. (Note: only the *names* might be flagged depending on whether `import pydantic` / `import httpx2` are also unused — likely they're still used for `pydantic.BaseModel`/`pydantic.RootModel` and `httpx2.Response` typing, so the module imports stay.) - -If ruff flags any imports, remove them. With `fix = true` in `[tool.ruff]`, running `uv run ruff check . --fix` will auto-remove unused names. - -- [ ] **Step 8: Update integration tests — substring match strings** - -In `tests/integration/test_gitlab_provider.py`, replace 9 `match=` strings. The new translated message shape is `f"GitLab {exc.model.__name__} response could not be decoded: {exc.original}"` where `{exc.model.__name__}` is `_ProjectResponse`, `_CommitList`, or `_TagList`. - -Use these exact replacements: - -| Line | Old `match=` | New `match=` | -|---|---|---| -| 99 | `"response shape"` | `"_ProjectResponse response could not be decoded"` | -| 108 | `"malformed JSON"` | `"_ProjectResponse response could not be decoded"` | -| 117 | `"expected object"` | `"_ProjectResponse response could not be decoded"` | -| 161 | `"malformed JSON"` | `"_CommitList response could not be decoded"` | -| 170 | `"expected list"` | `"_CommitList response could not be decoded"` | -| 179 | `"response shape"` | `"_CommitList response could not be decoded"` | -| 271 | `"malformed JSON"` | `"_TagList response could not be decoded"` | -| 280 | `"expected list"` | `"_TagList response could not be decoded"` | -| 289 | `"shape invalid"` | `"_TagList response could not be decoded"` | - -(Line numbers are pre-edit references; if line numbers shift during your editing, identify each occurrence by the old match string.) - -The test names themselves (`test_raises_provider_api_error_when_default_branch_body_is_not_json`, etc.) stay — the test still verifies that a malformed JSON response produces a `ProviderAPIError` via the new decode path. - -- [ ] **Step 9: Run integration tests to confirm the new wording matches** - -Run: `uv run pytest tests/integration/test_gitlab_provider.py -v 2>&1 | tail -30` -Expected: all tests pass. If any of the 9 updated tests still fail with `regex did not match: actual exception text was '...'`, the actual text is in the failure message — adjust the `match=` regex accordingly (most likely the pydantic-specific underlying error wording differs from what you expected; in that case keep the model-name prefix and drop the literal `"could not be decoded"` to make the regex more permissive). - -- [ ] **Step 10: Full suite with branch coverage** - -Run: `just test` -Expected: all tests pass; coverage stays at 100%. The deleted validator branches no longer need coverage; the new call sites are covered by the same integration tests that previously exercised the validator code paths. - -If coverage dropped on `semvertag/providers/gitlab.py`, identify which lines are uncovered. The most likely culprit is a RootModel-related code path; add a targeted integration test or use `# pragma: no cover` with a one-line justification. - -- [ ] **Step 11: Lint** - -Run: `just lint-ci` -Expected: clean. - -- [ ] **Step 12: Commit** - -```bash -git add semvertag/providers/gitlab.py tests/integration/test_gitlab_provider.py -git commit -m "providers/gitlab: adopt response_model=/send_with_response; delete in-tree validators" -``` - ---- - -## Task 4: Final validation - -**Files:** none modified — this is the verification gate. - -- [ ] **Step 1: Full lint sweep** - -Run: `just lint-ci` -Expected: clean. - -- [ ] **Step 2: Full test sweep with branch coverage** - -Run: `just test-branch` -Expected: all tests pass; 100% statement + branch coverage. - -- [ ] **Step 3: Docs build** - -Run: `uv run --with mkdocs --with mkdocs-material mkdocs build --strict` -Expected: clean. If a doc page references the deleted `_validate_*` helpers or the old "shape invalid" / "malformed JSON" wording, update the prose. (Unlikely — the prior migration's final review confirmed `docs/` has no references to internal validator helpers.) - -- [ ] **Step 4: Verify the symbols we now depend on are present in the resolved version** - -Run: -```bash -uv run python -c " -import httpware -assert hasattr(httpware, 'DecodeError'), 'httpware.DecodeError missing' -assert hasattr(httpware.Client, 'send_with_response'), 'Client.send_with_response missing' -print('httpware seam OK') -" -``` -Expected: prints `httpware seam OK`. If it fails, the lockfile didn't pick up 0.8.2 — re-run `uv lock --upgrade-package httpware && just install`. - -- [ ] **Step 5: Skim `git log --oneline main..HEAD` to confirm the commit history reads cleanly** - -Expected sequence (or similar): -``` - providers/gitlab: adopt response_model=/send_with_response; delete in-tree validators - providers/_errors: translate httpware.DecodeError to ProviderAPIError - chore: floor httpware[pydantic] at >=0.8.2 (DecodeError, send_with_response) -``` - -If you committed any small lint / coverage follow-ups, those are fine too. - -- [ ] **Step 6 — Optional**: Invoke `superpowers:requesting-code-review` for a final cross-cutting review of the branch. - ---- - -## Self-review notes - -- **Spec coverage:** Every section of `planning/specs/2026-06-08-httpware-decoder-adoption-design.md` maps to a task: dep floor → Task 1; `_errors.py` extension → Task 2; call-site shapes + helper deletion → Task 3; verification gate → Task 4. The three "Open items for the implementation plan" are all resolved at plan-time: (1) RootModel availability verified (pydantic 2.13.4 installed); (2) exact count of assertion updates is 9 with explicit line numbers and replacement strings in Task 3 Step 8; (3) unused-import cleanup is Task 3 Step 7. -- **Placeholder scan:** No `TBD`, `TODO`, "appropriate error handling", or "similar to Task N" patterns. Step 7 of Task 3 includes a contingency (auto-fix or manual remove) but each branch is concrete. -- **Type consistency:** `_CommitList = RootModel[list[_CommitItem]]` and `_TagList = RootModel[list[_TagItem]]` are defined in Task 3 Step 2 and consumed in Steps 4 and 5 with `page.root` access — consistent. `httpware.DecodeError` has attributes `model` (type) and `original` (Exception) per spec §3 and per the production translator branch in Task 2 Step 3 — consistent with the test fixture in Task 2 Step 1 (`type("FakeModel", (), {})` + `ValueError("input should be...")`). diff --git a/planning/changes/2026-06-08.02-github-provider/design.md b/planning/changes/2026-06-08.02-github-provider.md similarity index 100% rename from planning/changes/2026-06-08.02-github-provider/design.md rename to planning/changes/2026-06-08.02-github-provider.md diff --git a/planning/changes/2026-06-08.02-github-provider/plan.md b/planning/changes/2026-06-08.02-github-provider/plan.md deleted file mode 100644 index 19ba401..0000000 --- a/planning/changes/2026-06-08.02-github-provider/plan.md +++ /dev/null @@ -1,1702 +0,0 @@ -# GitHub provider implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add `GitHubProvider` so `semvertag` works against `github.com` and GitHub Enterprise repos with the same CLI it works against GitLab today. After this lands the package description `"Auto-tag GitLab repos with semantic version tags"` becomes `"Auto-tag GitLab and GitHub repos with semantic version tags — one tool, two strategies, two providers."` - -**Architecture:** The new `GitHubProvider` parallels `GitLabProvider` method-for-method, conforms to the existing `Provider` `typing.Protocol`, and reuses every cross-cutting piece (`httpware.Client` + `httpware.Retry` + `httpware.PydanticDecoder`, the operator-action error tree, Link-header pagination). `Settings.provider` gains an env-aware `@model_validator(mode="after")` that auto-detects from `GITHUB_ACTIONS` / `GITLAB_CI` and enforces the right repo identifier per provider. `ioc.py` adds a `current_provider` selector that mirrors the existing `current_strategy` selector. Refactors land first (pagination helpers + transport-translator extraction) so the new code can hang off them cleanly. - -**Tech Stack:** Python 3.11+, `httpware[pydantic]>=0.8.2`, `pydantic 2.13+` (RootModel, model_validator), `pydantic-settings 2+` (env + alias chains), `typer` (CLI), `modern-di` (DI), `pytest` (test runner). Tests use `httpx2.MockTransport` injected via `httpware.Client(httpx2_client=...)`. - -**Reference spec:** `planning/specs/2026-06-08-github-provider-design.md` - ---- - -## File structure - -**Create:** -- `semvertag/_link_pagination.py` — extracted from `providers/gitlab.py`. Public surface: `next_page_url(response, *, current_url) -> str | None`, `same_origin(url, endpoint) -> bool`, `LINK_ENTRY_RE` constant. Module-internal: `_parse_rel_values`. -- `semvertag/providers/github.py` — `GitHubProvider` class + four `pydantic.BaseModel` types (`_RepoResponse`, `_CommitItem` with nested `_CommitAuthor`, `_TagItem` with nested `_TagCommit`) + two `pydantic.RootModel` wrappers (`_CommitList`, `_TagList`) + domain constants (`_API_PREFIX = "/repos"`, `_TAGS_PER_PAGE = 100`, `_MAX_TAG_PAGES = 100`). -- `tests/unit/test_link_pagination.py` — extracted from `tests/integration/test_gitlab_provider.py` (the 6 tests at lines 572-592). Imports from `semvertag._link_pagination`. -- `tests/integration/test_github_provider.py` — full integration suite parallel to `test_gitlab_provider.py`. Uses `httpx2.MockTransport` with GitHub-shaped JSON. -- `docs/providers/github.md` — parallel to `docs/providers/gitlab.md`. - -**Modify:** -- `semvertag/_settings.py` — add `Settings.provider`, `Settings.repo`, `GitHubConfig.endpoint`, `_detect_provider_from_env()` free function, `@model_validator(mode="after") _resolve_provider`. -- `semvertag/providers/_errors.py` — extract `_translate_transport(exc, *, provider_label: str)` shared by both translators; rewrite `_translate_gitlab_transport` callers to use it; add `translate_github(exc, *, repo)` and `translate_create_tag_github_unprocessable(exc, *, tag_name)`. -- `semvertag/providers/gitlab.py` — import pagination helpers from `_link_pagination` (drop in-module `_LINK_ENTRY_RE` / `_next_page_url` / `_parse_rel_values` / `_same_origin`). -- `semvertag/ioc.py` — add `_build_github_client` / `_build_github_provider` / `_close_github_provider` / `_build_current_provider`; add `github_client` / `github_provider` / `current_provider` factories in `ProvidersGroup`; switch `UseCasesGroup.semvertag_use_case` to reference `current_provider` instead of `gitlab_provider`. -- `semvertag/__main__.py` — add `--provider`, `--repo`, `--github-endpoint` flags; add `provider`/`repo`/`github_endpoint` to `_collect_overrides`; move `--token` handling out of `_collect_overrides` into a second-pass overlay applied after the active provider is resolved; refresh `MAIN_APP` help string. -- `tests/integration/test_gitlab_provider.py` — drop the 6 pagination tests at lines 572-592 (they move to `test_link_pagination.py`); drop the now-removed `_next_page_url` / `_parse_rel_values` imports. -- `tests/conftest.py` — add `GITHUB_ENDPOINT` / `GITHUB_TOKEN` / `GITHUB_REPO` constants + a shared `_make_github_provider(handler)` helper (or `_make_provider(handler, *, provider="gitlab")` parameterized helper — Task 5 picks). -- `tests/unit/test_settings.py` — add tests for `_resolve_provider` (env auto-detection rules, explicit override, both-set conflict, repo-identifier enforcement) + `GitHubConfig.endpoint` defaults. -- `tests/unit/test_providers_errors.py` — add ~15 tests for `translate_github` branches + tests for `translate_create_tag_github_unprocessable` + tests confirming `_translate_transport` produces identical-shape output for both labels. -- `tests/unit/test_ioc.py` — add tests for `current_provider` resolution under both `settings.provider` values. -- `tests/integration/test_cli_*.py` (specifically `test_cli_main_verb.py` and `test_cli_quiet_json_matrix.py`) — add smoke tests for `--provider github --repo OWNER/REPO`, env-driven auto-detection, and `--token` routing. -- `README.md` — hero string updated to mention GitHub; new "Use in GitHub Actions" section with the inline-job recipe. -- `pyproject.toml` — `description` updated; add `"github"` to `keywords`. - -**No deletions of whole files.** - ---- - -## Task 1: Extract `_link_pagination.py` (pure refactor) - -Pagination helpers are used by GitLab today and will be used by GitHub in Task 5. Extracting them first is a pure refactor with no behavior change — green to green. - -**Files:** -- Create: `semvertag/_link_pagination.py` -- Create: `tests/unit/test_link_pagination.py` -- Modify: `semvertag/providers/gitlab.py` (remove the pagination helpers + imports they use; import from new module) -- Modify: `tests/integration/test_gitlab_provider.py` (remove pagination tests + their imports) - -- [ ] **Step 1: Create the new pagination module** - -Create `semvertag/_link_pagination.py`: - -```python -import re -import typing -import urllib.parse - -import httpx2 - - -# RFC 8288 Link header: ;param=value;param="value";... -LINK_ENTRY_RE: typing.Final = re.compile( - r"<\s*(?P[^>]*?)\s*>(?P(?:\s*;\s*[^,;]+)*)", -) - - -def next_page_url(response: httpx2.Response, *, current_url: str) -> str | None: - """Walk the response's Link header and return the absolute URL of rel='next', or None.""" - link_header = response.headers.get("link") - if not link_header: - return None - for match in LINK_ENTRY_RE.finditer(link_header): - url_part = match.group("url").strip() - if not url_part: - continue - if "next" in _parse_rel_values(match.group("params")): - return urllib.parse.urljoin(current_url, url_part) - return None - - -def same_origin(url: str, endpoint: str) -> bool: - """Return True if `url` shares scheme + netloc with `endpoint`. Guards credential leaks.""" - parsed = urllib.parse.urlsplit(url) - expected = urllib.parse.urlsplit(endpoint) - return parsed.scheme == expected.scheme and parsed.netloc == expected.netloc - - -def _parse_rel_values(params_blob: str) -> set[str]: - for raw_param in params_blob.split(";"): - param = raw_param.strip() - if not param: - continue - name, _, value = param.partition("=") - if name.strip().lower() != "rel": - continue - cleaned = value.strip().strip('"').strip("'").lower() - return set(cleaned.split()) - return set() -``` - -Public symbols drop the leading underscore (`next_page_url` instead of `_next_page_url`, etc.) because the whole module is private. `_parse_rel_values` stays underscored — it's a module-internal helper. - -- [ ] **Step 2: Move the 6 pagination tests to a new unit test file** - -Create `tests/unit/test_link_pagination.py`. The 6 tests live today at `tests/integration/test_gitlab_provider.py:572-592`. Find them by their names: - -- `test_next_page_url_skips_entries_with_empty_uri_reference` -- `test_next_page_url_returns_none_when_link_header_absent` -- `test_next_page_url_returns_none_when_only_non_next_rel_present` -- `test_parse_rel_values_returns_empty_set_when_no_rel_param_present` -- `test_parse_rel_values_skips_non_rel_params_before_finding_rel` -- (the 6th — find via `grep -n "_parse_rel_values\|_next_page_url" tests/integration/test_gitlab_provider.py` to identify all six in case the count differs) - -Cut those test functions verbatim from `test_gitlab_provider.py`. Paste into `tests/unit/test_link_pagination.py`. Update imports at the top: - -```python -import httpx2 - -from semvertag._link_pagination import next_page_url, _parse_rel_values -``` - -(Drop `_` prefix in calls: replace `_next_page_url(` → `next_page_url(`. Keep `_parse_rel_values(` since it stays underscored.) - -The tests also reference `GITLAB_ENDPOINT` and `_TAGS_PATH` from `test_gitlab_provider.py`. These are URL strings used as `current_url=` arguments — replace with literal strings in the test file (e.g., `current_url="https://gitlab.example.test/api/v4/projects/999/repository/tags"`). The tests are about pagination semantics, not GitLab specifically. - -- [ ] **Step 3: Remove the pagination helpers from `gitlab.py`** - -In `semvertag/providers/gitlab.py`, delete: -- `import re` (no longer needed once the regex moves out) -- `import urllib.parse` (no longer needed if all uses moved) -- The `_LINK_ENTRY_RE` constant -- `def _next_page_url(...)` and its body -- `def _parse_rel_values(...)` and its body -- `def _same_origin(...)` and its body - -Add at the top: - -```python -from semvertag import _link_pagination -``` - -In `list_tags`, replace: -- `_next_page_url(response, current_url=str(response.request.url))` → `_link_pagination.next_page_url(response, current_url=str(response.request.url))` -- `_same_origin(next_url, self.config.endpoint)` → `_link_pagination.same_origin(next_url, self.config.endpoint)` - -- [ ] **Step 4: Remove pagination test imports from `test_gitlab_provider.py`** - -In `tests/integration/test_gitlab_provider.py`, drop these names from the `from semvertag.providers.gitlab import` line: - -```python -from semvertag.providers.gitlab import ( - GitLabProvider, - # _next_page_url, ← remove - # _parse_rel_values, ← remove -) -``` - -Leave `GitLabProvider`. - -If `re` or `urllib.parse` are still imported in `test_gitlab_provider.py` and unused, drop them (ruff will flag). - -- [ ] **Step 5: Run pagination tests in their new location** - -Run: `uv run pytest tests/unit/test_link_pagination.py -v` -Expected: 6 tests pass. - -- [ ] **Step 6: Run integration tests to confirm the gitlab side still works** - -Run: `uv run pytest tests/integration/test_gitlab_provider.py -v 2>&1 | tail -5` -Expected: all GitLab integration tests pass (the 6 pagination tests are no longer in this file). Count drops by 6 from previous baseline. - -- [ ] **Step 7: Full suite + coverage + lint** - -Run: `just test && just lint-ci` -Expected: 333 tests pass (same total — moved, not removed), 100% coverage, lint clean. - -- [ ] **Step 8: Commit** - -```bash -git add semvertag/_link_pagination.py semvertag/providers/gitlab.py tests/unit/test_link_pagination.py tests/integration/test_gitlab_provider.py -git commit -m "refactor: extract Link-header pagination to _link_pagination module" -``` - ---- - -## Task 2: Extract `_translate_transport` helper in `_errors.py` (pure refactor) - -`translate_gitlab` currently delegates transport-side errors to `_translate_gitlab_transport`. The new `translate_github` (Task 4) will need the same transport-side branches with one parameter change (provider label string). Extracting now keeps both translators thin. - -**Files:** -- Modify: `semvertag/providers/_errors.py` - -- [ ] **Step 1: Replace `_translate_gitlab_transport` with parameterized `_translate_transport`** - -In `semvertag/providers/_errors.py`, find `_translate_gitlab_transport`: - -```python -def _translate_gitlab_transport(exc: httpware.ClientError) -> Exception: - if isinstance(exc, httpware.DecodeError): - return ProviderAPIError(f"GitLab {exc.model.__name__} response could not be decoded: {exc.original}") - if isinstance(exc, httpware.TimeoutError): - return ProviderAPIError("GitLab request timed out. Try again or increase SEMVERTAG_REQUEST_TIMEOUT.") - if isinstance(exc, httpware.RetryBudgetExhaustedError): - return ProviderAPIError(f"GitLab retries exhausted after {exc.attempts} attempts. Try again later.") - if isinstance(exc, httpware.NetworkError): - return ProviderAPIError("GitLab unreachable. Check network connectivity.") - return ProviderAPIError(f"GitLab request failed: {type(exc).__name__}") -``` - -Replace with: - -```python -def _translate_transport(exc: httpware.ClientError, *, provider_label: str) -> Exception: - if isinstance(exc, httpware.DecodeError): - return ProviderAPIError( - f"{provider_label} {exc.model.__name__} response could not be decoded: {exc.original}" - ) - if isinstance(exc, httpware.TimeoutError): - return ProviderAPIError( - f"{provider_label} request timed out. Try again or increase SEMVERTAG_REQUEST_TIMEOUT." - ) - if isinstance(exc, httpware.RetryBudgetExhaustedError): - return ProviderAPIError( - f"{provider_label} retries exhausted after {exc.attempts} attempts. Try again later." - ) - if isinstance(exc, httpware.NetworkError): - return ProviderAPIError(f"{provider_label} unreachable. Check network connectivity.") - return ProviderAPIError(f"{provider_label} request failed: {type(exc).__name__}") -``` - -- [ ] **Step 2: Update `translate_gitlab` to call the parameterized helper** - -In the same file, find the `translate_gitlab` dispatcher: - -```python -def translate_gitlab(exc: httpware.ClientError, *, project_id: int) -> Exception: - ... - if isinstance(exc, httpware.StatusError): - return _translate_gitlab_status(exc, project_id=project_id) - return _translate_gitlab_transport(exc) -``` - -Replace the last line with: - -```python - return _translate_transport(exc, provider_label="GitLab") -``` - -- [ ] **Step 3: Run existing translator tests to confirm no behavior change** - -Run: `uv run pytest tests/unit/test_providers_errors.py -v` -Expected: all 16 tests pass with identical output. The parameterization only varies the label string; "GitLab request timed out..." remains identical to before. - -- [ ] **Step 4: Full suite + lint** - -Run: `just test && just lint-ci` -Expected: 333 tests pass, 100% coverage, lint clean. - -- [ ] **Step 5: Commit** - -```bash -git add semvertag/providers/_errors.py -git commit -m "refactor(providers/_errors): extract _translate_transport(exc, *, provider_label)" -``` - ---- - -## Task 3: Settings — add `provider`, `repo`, `GitHubConfig.endpoint`, validator (TDD) - -Settings layer is foundational — every later task depends on `Settings.provider` resolving correctly. TDD-style: write tests for every rule, then implement. - -**Files:** -- Modify: `semvertag/_settings.py` -- Modify: `tests/unit/test_settings.py` - -- [ ] **Step 1: Write failing tests for `_detect_provider_from_env` + `_resolve_provider`** - -Append to `tests/unit/test_settings.py` (find the appropriate spot — top-level test functions): - -```python -import pytest - -from semvertag._errors import ConfigError -from semvertag._settings import Settings - - -def test_provider_defaults_to_gitlab_when_no_ci_env_present(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - monkeypatch.delenv("GITLAB_CI", raising=False) - monkeypatch.delenv("SEMVERTAG_PROVIDER", raising=False) - monkeypatch.delenv("PROVIDER", raising=False) - settings = Settings(project_id=999) - assert settings.provider == "gitlab" - - -def test_provider_detects_github_from_github_actions_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("GITHUB_ACTIONS", "true") - monkeypatch.delenv("GITLAB_CI", raising=False) - monkeypatch.delenv("SEMVERTAG_PROVIDER", raising=False) - settings = Settings(repo="owner/repo") - assert settings.provider == "github" - - -def test_provider_detects_gitlab_from_gitlab_ci_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - monkeypatch.setenv("GITLAB_CI", "true") - monkeypatch.delenv("SEMVERTAG_PROVIDER", raising=False) - settings = Settings(project_id=999) - assert settings.provider == "gitlab" - - -def test_provider_raises_when_both_ci_envs_set(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("GITHUB_ACTIONS", "true") - monkeypatch.setenv("GITLAB_CI", "true") - monkeypatch.delenv("SEMVERTAG_PROVIDER", raising=False) - with pytest.raises(Exception, match="ambiguous"): - Settings(project_id=999, repo="owner/repo") - - -def test_explicit_provider_overrides_auto_detection(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("GITHUB_ACTIONS", "true") # auto-detect says "github" - monkeypatch.setenv("GITLAB_CI", "true") # would be ambiguous - settings = Settings(provider="gitlab", project_id=999) # explicit wins - assert settings.provider == "gitlab" - - -def test_provider_github_requires_repo(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - monkeypatch.delenv("GITLAB_CI", raising=False) - with pytest.raises(Exception, match="provider=github requires .*repo"): - Settings(provider="github") # no repo - - -def test_provider_gitlab_requires_project_id(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - monkeypatch.delenv("GITLAB_CI", raising=False) - with pytest.raises(Exception, match="provider=gitlab requires .*project_id"): - Settings(provider="gitlab") # no project_id - - -def test_github_config_endpoint_defaults_to_api_github_com() -> None: - settings = Settings(provider="github", repo="owner/repo") - assert settings.github.endpoint == "https://api.github.com" - - -def test_github_config_endpoint_overridable_for_enterprise(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("SEMVERTAG_GITHUB__ENDPOINT", "https://github.acme.com/api/v3") - settings = Settings(provider="github", repo="owner/repo") - assert settings.github.endpoint == "https://github.acme.com/api/v3" - - -def test_repo_alias_picks_up_github_repository_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("GITHUB_REPOSITORY", "octocat/Hello-World") - monkeypatch.setenv("GITHUB_ACTIONS", "true") - settings = Settings() - assert settings.repo == "octocat/Hello-World" - assert settings.provider == "github" -``` - -If `tests/unit/test_settings.py` doesn't exist yet, create it with `import pytest` at the top and the test functions above. - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `uv run pytest tests/unit/test_settings.py -v 2>&1 | tail -20` -Expected: tests fail with various errors — `Settings.provider` doesn't exist as a field; `Settings.repo` doesn't exist; the validator hasn't been added; `GitHubConfig` lacks `endpoint`. Read the failures to confirm they're all "missing implementation" not "wrong test setup". - -- [ ] **Step 3: Implement the Settings changes** - -Edit `semvertag/_settings.py`. Add `os` import at the top with the other stdlib imports: - -```python -import logging -import os -import typing -``` - -In `GitHubConfig`, add `endpoint`: - -```python -class GitHubConfig(pydantic_settings.BaseSettings): - model_config = pydantic_settings.SettingsConfigDict( - env_prefix="SEMVERTAG_GITHUB__", - case_sensitive=False, - extra="ignore", - populate_by_name=True, - ) - - endpoint: str = "https://api.github.com" # NEW - token: pydantic.SecretStr = pydantic.Field( - default=pydantic.SecretStr(""), - validation_alias=pydantic.AliasChoices( - "SEMVERTAG_GITHUB__TOKEN", - "SEMVERTAG_TOKEN", - "GITHUB_TOKEN", - ), - ) -``` - -Add the env-detection helper above the `Settings` class: - -```python -def _detect_provider_from_env() -> typing.Literal["gitlab", "github"]: - github_ci = os.environ.get("GITHUB_ACTIONS", "").lower() == "true" - gitlab_ci = os.environ.get("GITLAB_CI", "").lower() == "true" - if github_ci and gitlab_ci: - msg = ( - "Ambiguous CI context: both GITHUB_ACTIONS and GITLAB_CI are set. " - "Pass --provider github|gitlab or set SEMVERTAG_PROVIDER to disambiguate." - ) - raise ValueError(msg) - if github_ci: - return "github" - return "gitlab" -``` - -In `Settings`, add the new fields and the validator: - -```python -class Settings(pydantic_settings.BaseSettings): - model_config = pydantic_settings.SettingsConfigDict( - env_prefix=_ENV_PREFIX, - env_nested_delimiter=_ENV_NESTED_DELIMITER, - case_sensitive=False, - extra="ignore", - ) - - strategy: typing.Literal["branch-prefix", "conventional-commits"] = "branch-prefix" - provider: typing.Literal["gitlab", "github"] | None = pydantic.Field( - default=None, - validation_alias=pydantic.AliasChoices("SEMVERTAG_PROVIDER", "PROVIDER"), - ) - default_branch: str | None = None - request_timeout: float = pydantic.Field(default=8.0, gt=0) - project_id: int | None = pydantic.Field( - default=None, - validation_alias=pydantic.AliasChoices("SEMVERTAG_PROJECT_ID", "CI_PROJECT_ID"), - ) - repo: str | None = pydantic.Field( - default=None, - validation_alias=pydantic.AliasChoices("SEMVERTAG_REPO", "GITHUB_REPOSITORY"), - ) - gitlab: GitLabConfig = pydantic.Field(default_factory=GitLabConfig) - github: GitHubConfig = pydantic.Field(default_factory=GitHubConfig) - branch_prefix: BranchPrefixConfig = pydantic.Field(default_factory=BranchPrefixConfig) - conventional_commits: ConventionalCommitsConfig = pydantic.Field(default_factory=ConventionalCommitsConfig) - - @pydantic.field_validator("request_timeout") - @classmethod - def _clamp_request_timeout(cls, value: float) -> float: - if value > _REQUEST_TIMEOUT_CEILING: - _logger.warning( - "request_timeout=%.3f exceeds ceiling %.1f; clamping to %.1f", - value, - _REQUEST_TIMEOUT_CEILING, - _REQUEST_TIMEOUT_CEILING, - ) - return _REQUEST_TIMEOUT_CEILING - return value - - @pydantic.model_validator(mode="after") - def _resolve_provider(self) -> "Settings": - if self.provider is None: - self.provider = _detect_provider_from_env() - if self.provider == "github" and not self.repo: - msg = "provider=github requires `repo` (set GITHUB_REPOSITORY or pass --repo OWNER/REPO)" - raise ValueError(msg) - if self.provider == "gitlab" and self.project_id is None: - msg = "provider=gitlab requires `project_id` (set CI_PROJECT_ID or pass --project-id N)" - raise ValueError(msg) - return self -``` - -The validator mutates `self.provider` in place — this is safe inside a `model_validator(mode="after")` because the model instance is fully constructed by that point. Pydantic accepts the return. - -- [ ] **Step 4: Run the new settings tests** - -Run: `uv run pytest tests/unit/test_settings.py -v 2>&1 | tail -20` -Expected: all 10 new tests pass. - -- [ ] **Step 5: Run the full suite — settings change cascades** - -Run: `just test 2>&1 | tail -15` -Expected: existing tests that build `Settings(project_id=N)` still pass (they implicitly default `provider` to `"gitlab"` via the validator and `project_id` is satisfied). Existing tests that build `Settings()` with no args may now fail because the validator requires either `project_id` or `repo` once `provider` resolves. If that happens, the failing tests are in `tests/unit/test_ioc.py` (`_settings` fixture passes `project_id=999`, fine) and the integration CLI tests. Cross-check each failure. - -Most likely failure points to investigate: -- `tests/integration/test_cli_*.py` — CLI tests construct `Settings` via the `_main_callback` flow which goes through `apply_cli_overlay`. If they set up the env without `project_id` / `repo`, they'll fail. Fix: set `CI_PROJECT_ID` or `SEMVERTAG_PROJECT_ID` in test fixtures, or pass `--project-id`. - -For each failing test, the fix is to add a `project_id` (when testing GitLab paths) or `repo` (when testing GitHub paths). DO NOT loosen the validator to make tests pass — that would defeat the purpose. - -- [ ] **Step 6: Fix any cascading test failures** - -Walk each failure individually. The fix is always one of: -- Add `project_id=999` to a `Settings(...)` call -- Set `monkeypatch.setenv("CI_PROJECT_ID", "999")` in a fixture -- Set `monkeypatch.delenv("GITHUB_ACTIONS", raising=False)` + `monkeypatch.delenv("GITLAB_CI", raising=False)` to nail the default-provider state - -Re-run after each fix. - -- [ ] **Step 7: Lint** - -Run: `just lint-ci` -Expected: clean. (The validator's `# noqa` may be needed if ruff flags self-mutation; usually not.) - -- [ ] **Step 8: Commit** - -```bash -git add semvertag/_settings.py tests/unit/test_settings.py -git commit -m "settings: add provider/repo fields + env-aware _resolve_provider validator" -``` - -If you fixed cascading tests in Step 6, include those files in the same commit (the fixes are entirely defensive and not worth their own commit). - ---- - -## Task 4: Add `translate_github` + `translate_create_tag_github_unprocessable` (TDD) - -Translator additions. No production caller yet — `GitHubProvider` arrives in Task 5. TDD here so the translator is fully exercised before being used. - -**Files:** -- Modify: `semvertag/providers/_errors.py` -- Modify: `tests/unit/test_providers_errors.py` - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/unit/test_providers_errors.py`. Reuse the existing `_response()` and `_status_error()` helpers (no need to redefine). - -```python -from semvertag.providers._errors import translate_create_tag_github_unprocessable, translate_github - - -_REPO = "octocat/Hello-World" - - -# translate_github — status errors - -def test_translate_github_401_becomes_auth_error_with_token_guidance() -> None: - result = translate_github(_status_error(httpware.UnauthorizedError, 401), repo=_REPO) - assert isinstance(result, AuthError) - assert "Token rejected" in str(result) - assert "SEMVERTAG_TOKEN" in str(result) - - -def test_translate_github_403_becomes_auth_error_with_scope_guidance() -> None: - result = translate_github(_status_error(httpware.ForbiddenError, 403), repo=_REPO) - assert isinstance(result, AuthError) - assert "403" in str(result) - assert "contents: write" in str(result) or "public_repo" in str(result) or "repo" in str(result) - - -def test_translate_github_404_becomes_config_error_with_repo() -> None: - result = translate_github(_status_error(httpware.NotFoundError, 404), repo=_REPO) - assert isinstance(result, ConfigError) - assert f"repo='{_REPO}'" in str(result) - - -def test_translate_github_422_becomes_config_error() -> None: - result = translate_github(_status_error(httpware.UnprocessableEntityError, 422), repo=_REPO) - assert isinstance(result, ConfigError) - assert "422" in str(result) - - -def test_translate_github_429_becomes_provider_api_error() -> None: - result = translate_github(_status_error(httpware.RateLimitedError, 429), repo=_REPO) - assert isinstance(result, ProviderAPIError) - assert "rate limit" in str(result).lower() - - -def test_translate_github_500_becomes_provider_api_error_with_status_page() -> None: - result = translate_github(_status_error(httpware.InternalServerError, 500), repo=_REPO) - assert isinstance(result, ProviderAPIError) - assert "500" in str(result) - assert "githubstatus.com" in str(result) - - -def test_translate_github_503_becomes_provider_api_error() -> None: - result = translate_github(_status_error(httpware.ServiceUnavailableError, 503), repo=_REPO) - assert isinstance(result, ProviderAPIError) - - -def test_translate_github_unknown_4xx_falls_back_to_provider_api_error() -> None: - result = translate_github(_status_error(httpware.ClientStatusError, 418), repo=_REPO) - assert isinstance(result, ProviderAPIError) - assert "418" in str(result) - - -# translate_github — transport errors (via shared _translate_transport) - -def test_translate_github_timeout_becomes_provider_api_error() -> None: - exc = httpware.TimeoutError("read timed out") - result = translate_github(exc, repo=_REPO) - assert isinstance(result, ProviderAPIError) - assert "GitHub request timed out" in str(result) - - -def test_translate_github_network_error_becomes_provider_api_error() -> None: - exc = httpware.NetworkError("connection refused") - result = translate_github(exc, repo=_REPO) - assert isinstance(result, ProviderAPIError) - assert "GitHub unreachable" in str(result) - - -def test_translate_github_retry_budget_exhausted_becomes_provider_api_error() -> None: - exc = httpware.RetryBudgetExhaustedError(last_response=None, last_exception=None, attempts=3) - result = translate_github(exc, repo=_REPO) - assert isinstance(result, ProviderAPIError) - assert "GitHub retries exhausted" in str(result) - - -def test_translate_github_decode_error_becomes_provider_api_error() -> None: - underlying = ValueError("invalid") - exc = httpware.DecodeError( - response=_response(200, body=b"null"), - model=type("FakeModel", (), {}), - original=underlying, - ) - result = translate_github(exc, repo=_REPO) - assert isinstance(result, ProviderAPIError) - assert "GitHub FakeModel response could not be decoded" in str(result) - - -def test_translate_github_unknown_client_error_falls_back_to_provider_api_error() -> None: - exc = httpware.ClientError("unknown") - result = translate_github(exc, repo=_REPO) - assert isinstance(result, ProviderAPIError) - assert "GitHub request failed" in str(result) - - -# translate_create_tag_github_unprocessable - -def test_translate_create_tag_github_already_exists_structured_becomes_config_error() -> None: - exc = _status_error( - httpware.UnprocessableEntityError, - 422, - body=b'{"message":"Reference already exists","errors":[{"resource":"Reference","code":"already_exists"}]}', - ) - result = translate_create_tag_github_unprocessable(exc, tag_name="v1.2.3") - assert isinstance(result, ConfigError) - assert "v1.2.3" in str(result) - assert "already exists" in str(result).lower() - - -def test_translate_create_tag_github_already_exists_message_only_becomes_config_error() -> None: - # Safety-net match on the human-readable message even if structured code is absent. - exc = _status_error(httpware.UnprocessableEntityError, 422, body=b'{"message":"Reference already exists"}') - result = translate_create_tag_github_unprocessable(exc, tag_name="v1.2.3") - assert isinstance(result, ConfigError) - assert "already exists" in str(result).lower() - - -def test_translate_create_tag_github_other_422_becomes_generic_config_error() -> None: - exc = _status_error(httpware.UnprocessableEntityError, 422, body=b'{"message":"Invalid ref format"}') - result = translate_create_tag_github_unprocessable(exc, tag_name="v1.2.3") - assert isinstance(result, ConfigError) - assert "v1.2.3" not in str(result) - assert "422" in str(result) -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `uv run pytest tests/unit/test_providers_errors.py -v 2>&1 | tail -10` -Expected: 16 failures (one per new test) — `ImportError` on `translate_github` / `translate_create_tag_github_unprocessable`. - -- [ ] **Step 3: Add the translator functions** - -In `semvertag/providers/_errors.py`, append after `translate_gitlab` and `translate_create_tag_bad_request`: - -```python -def translate_github(exc: httpware.ClientError, *, repo: str) -> Exception: - """Translate an httpware ClientError into the semvertag domain error for GitHub. - - Mirrors translate_gitlab's dispatch order; status branches carry GitHub-specific - actionable hints. Transport branches (DecodeError, TimeoutError, RetryBudget, - NetworkError, fallback) delegate to the shared _translate_transport. - """ - if isinstance(exc, httpware.UnauthorizedError): - return AuthError("Token rejected: 401. Verify SEMVERTAG_TOKEN is valid.") - if isinstance(exc, httpware.ForbiddenError): - return AuthError( - "Token missing scope or insufficient permission: 403. " - "Verify SEMVERTAG_TOKEN has 'contents: write' scope " - "(or 'public_repo' / 'repo' for classic PATs)." - ) - if isinstance(exc, httpware.NotFoundError): - return ConfigError( - f"GitHub repo not found: repo='{repo}'. Verify GITHUB_REPOSITORY or --repo OWNER/REPO." - ) - if isinstance(exc, httpware.UnprocessableEntityError): - return ConfigError( - "Request rejected by GitHub: 422. Check ref format and that the referenced sha exists." - ) - if isinstance(exc, httpware.RateLimitedError): - return ProviderAPIError( - "GitHub rate limit: 429. Retries exhausted after 3 attempts; " - "try again later or check token rate-limit budget." - ) - if isinstance(exc, httpware.ServerStatusError): - return ProviderAPIError( - f"GitHub API failure: {exc.response.status_code}. " - "Retries exhausted after 3 attempts. Try again or check https://www.githubstatus.com." - ) - if isinstance(exc, httpware.StatusError): - return ProviderAPIError( - f"Unexpected GitHub response: {exc.response.status_code}. Please file an issue." - ) - return _translate_transport(exc, provider_label="GitHub") - - -def translate_create_tag_github_unprocessable( - exc: httpware.UnprocessableEntityError, *, tag_name: str -) -> Exception: - """create_tag's 422 has an 'already_exists' special case; everything else is a generic 422.""" - body = exc.response.text - if "already_exists" in body or "already exists" in body.lower(): - return ConfigError( - f"Tag already exists: '{tag_name}'. " - "The tag was created by a concurrent run or previous invocation." - ) - return ConfigError( - "Request rejected by GitHub: 422. Check ref format and that the referenced sha exists." - ) -``` - -If ruff flags `translate_github` for `PLR0911` (too many returns, max 6) or `C901` (complexity), follow the same pattern that exists for `translate_gitlab` (split into `_translate_github_status` + delegation). The existing `_translate_gitlab_status` extraction is the precedent; mirror it. If it doesn't trip the limit, keep flat for readability — easier to scan. - -- [ ] **Step 4: Run the new tests** - -Run: `uv run pytest tests/unit/test_providers_errors.py -v 2>&1 | tail -10` -Expected: all 16 new tests pass; the original 16 (translate_gitlab side) still pass. Total in this file: 32 tests. - -- [ ] **Step 5: Full suite + lint** - -Run: `just test && just lint-ci` -Expected: 349 tests pass (was 333; +16 new), 100% coverage, lint clean. - -- [ ] **Step 6: Commit** - -```bash -git add semvertag/providers/_errors.py tests/unit/test_providers_errors.py -git commit -m "providers/_errors: add translate_github + translate_create_tag_github_unprocessable" -``` - ---- - -## Task 5: Create `GitHubProvider` + integration test suite - -The biggest task. `GitHubProvider` parallels `GitLabProvider`; the integration tests parallel `test_gitlab_provider.py`. By the end of this task the provider works in isolation, but isn't wired through DI yet (that's Task 6). - -**Files:** -- Create: `semvertag/providers/github.py` -- Create: `tests/integration/test_github_provider.py` -- Modify: `tests/conftest.py` (add GitHub constants + a `_make_github_provider` helper) - -- [ ] **Step 1: Add GitHub constants + helper to `tests/conftest.py`** - -In `tests/conftest.py`, add near the existing `GITLAB_*` constants: - -```python -GITHUB_ENDPOINT: typing.Final = "https://api.github.test" -GITHUB_TOKEN: typing.Final = "ghp_XXXXXXXXXXXXXXXXXXXX" -GITHUB_REPO: typing.Final = "owner/repo" -``` - -A `_make_github_provider` helper isn't strictly required at conftest scope (the integration test file can define its own — same pattern as `_make_provider` in `test_gitlab_provider.py`). Skip the conftest helper for now; revisit if a second file needs it. - -- [ ] **Step 2: Create the provider module** - -Create `semvertag/providers/github.py`: - -```python -import dataclasses -import typing - -import httpware -import httpx2 # noqa: F401 — typing reference in pagination call site -import pydantic - -from semvertag import _link_pagination -from semvertag._errors import ConfigError, ProviderAPIError -from semvertag._settings import GitHubConfig -from semvertag._types import Commit, Tag -from semvertag.providers import _errors - - -_API_PREFIX: typing.Final = "/repos" -_TAGS_PER_PAGE: typing.Final = 100 -_MAX_TAG_PAGES: typing.Final = 100 - - -class _RepoResponse(pydantic.BaseModel): - default_branch: str | None - - -class _CommitAuthor(pydantic.BaseModel): - message: str - - -class _CommitItem(pydantic.BaseModel): - sha: str - commit: _CommitAuthor - - -class _TagCommit(pydantic.BaseModel): - sha: str - - -class _TagItem(pydantic.BaseModel): - name: str - commit: _TagCommit - - -class _CommitList(pydantic.RootModel[list[_CommitItem]]): - pass - - -class _TagList(pydantic.RootModel[list[_TagItem]]): - pass - - -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) -class GitHubProvider: - name: typing.ClassVar[str] = "github" - config: GitHubConfig - repo: str - http: httpware.Client - - def get_default_branch(self) -> str: - try: - repo_info = self.http.get( - f"{_API_PREFIX}/{self.repo}", - response_model=_RepoResponse, - ) - except httpware.ClientError as exc: - raise _errors.translate_github(exc, repo=self.repo) from exc - if not repo_info.default_branch: - msg = "Default branch missing from GitHub response. Verify the repo has a default branch." - raise ConfigError(msg) - return repo_info.default_branch - - def get_latest_commit_on_default_branch(self) -> Commit: - default_branch: typing.Final = self.get_default_branch() - try: - commits = self.http.get( - f"{_API_PREFIX}/{self.repo}/commits", - params={"sha": default_branch, "per_page": 1}, - response_model=_CommitList, - ) - except httpware.ClientError as exc: - raise _errors.translate_github(exc, repo=self.repo) from exc - if not commits.root: - msg = f"No commits on default branch '{default_branch}'. The branch appears empty." - raise ProviderAPIError(msg) - head = commits.root[0] - return Commit(sha=head.sha, message=head.commit.message) - - def list_tags(self) -> list[Tag]: - tags: list[Tag] = [] - url: str = f"{_API_PREFIX}/{self.repo}/tags" - params: dict[str, typing.Any] | None = {"per_page": _TAGS_PER_PAGE, "page": 1} - for _ in range(_MAX_TAG_PAGES): - try: - response, page = self.http.send_with_response( - self.http.build_request("GET", url, params=params), - response_model=_TagList, - ) - except httpware.ClientError as exc: - raise _errors.translate_github(exc, repo=self.repo) from exc - tags.extend(Tag(name=item.name, commit_sha=item.commit.sha) for item in page.root) - next_url = _link_pagination.next_page_url(response, current_url=str(response.request.url)) - if next_url is None: - return tags - if not _link_pagination.same_origin(next_url, self.config.endpoint): - msg = ( - "GitHub pagination Link header points to a different host than SEMVERTAG_GITHUB__ENDPOINT. " - "Refusing to follow to protect credentials." - ) - raise ProviderAPIError(msg) - url, params = next_url, None - msg = ( - f"Tag pagination exceeded {_MAX_TAG_PAGES} pages. " - "The repo has an unexpected number of tags; please file an issue." - ) - raise ProviderAPIError(msg) - - def create_tag(self, name: str, commit_sha: str) -> None: - try: - self.http.send(self.http.build_request( - "POST", - f"{_API_PREFIX}/{self.repo}/git/refs", - json={"ref": f"refs/tags/{name}", "sha": commit_sha}, - )) - except httpware.UnprocessableEntityError as exc: - raise _errors.translate_create_tag_github_unprocessable(exc, tag_name=name) from exc - except httpware.ClientError as exc: - raise _errors.translate_github(exc, repo=self.repo) from exc -``` - -Drop the `import httpx2` line if ruff flags it unused — pagination call sites use `response.request.url` via the response object passed in, not via direct `httpx2.X` references. - -- [ ] **Step 3: Create the integration test file** - -Create `tests/integration/test_github_provider.py`. Use `test_gitlab_provider.py` as a structural reference. Below is the **minimum viable set** of tests — happy path for each method + error translation + pagination. The implementer should expand coverage as needed to hit 100% on `github.py`; the existing `test_gitlab_provider.py` has ~50 tests and the GitHub counterpart will be similar in scale. - -Start with this skeleton: - -```python -import typing - -import httpx2 -import pydantic -import pytest - -import httpware - -from semvertag._errors import AuthError, ConfigError, ProviderAPIError -from semvertag._settings import GitHubConfig -from semvertag._types import Commit, Tag -from semvertag.providers._base import Provider -from semvertag.providers.github import GitHubProvider -from tests.conftest import ( - GITHUB_ENDPOINT, - GITHUB_REPO, - GITHUB_TOKEN, - HandlerCallable, - compose_handler, - default_handler, -) - - -_REPO_PATH: typing.Final = f"/repos/{GITHUB_REPO}" -_COMMITS_PATH: typing.Final = f"{_REPO_PATH}/commits" -_TAGS_PATH: typing.Final = f"{_REPO_PATH}/tags" -_REFS_PATH: typing.Final = f"{_REPO_PATH}/git/refs" -_DEFAULT_BRANCH: typing.Final = "main" -_DEFAULT_COMMIT_SHA: typing.Final = "abc1234" -_DEFAULT_COMMIT_MESSAGE: typing.Final = "default test commit" -_BEARER_HEADER: typing.Final = "Authorization" - - -def _github_default_handler(request: httpx2.Request) -> httpx2.Response: - method = request.method - path = request.url.path - if method == "GET" and path == _REPO_PATH: - return httpx2.Response(200, json={"default_branch": _DEFAULT_BRANCH}) - if method == "GET" and path == _COMMITS_PATH: - return httpx2.Response(200, json=[ - {"sha": _DEFAULT_COMMIT_SHA, "commit": {"message": _DEFAULT_COMMIT_MESSAGE}} - ]) - if method == "GET" and path == _TAGS_PATH: - return httpx2.Response(200, json=[ - {"name": "v0.1.0", "commit": {"sha": "old1234"}}, - {"name": "v0.2.0", "commit": {"sha": "new1234"}}, - ]) - if method == "POST" and path == _REFS_PATH: - return httpx2.Response(201, json={"ref": "refs/tags/v1.0.0", "object": {"sha": _DEFAULT_COMMIT_SHA}}) - return httpx2.Response(404, json={"message": "Not Found"}) - - -def _make_provider(handler: HandlerCallable) -> tuple[GitHubProvider, httpx2.Client]: - transport = httpx2.MockTransport(handler) - config = GitHubConfig(endpoint=GITHUB_ENDPOINT, token=pydantic.SecretStr(GITHUB_TOKEN)) - inner = httpx2.Client( - transport=transport, - base_url=GITHUB_ENDPOINT, - headers={ - _BEARER_HEADER: f"Bearer {config.token.get_secret_value()}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - ) - client = httpware.Client(httpx2_client=inner) - provider = GitHubProvider(config=config, repo=GITHUB_REPO, http=client) - # Return the inner httpx2.Client so tests can use it as a context manager - # for teardown; httpware.Client doesn't own its lifecycle when constructed via httpx2_client=. - return provider, inner - - -# Protocol conformance - -def test_github_provider_satisfies_provider_protocol() -> None: - provider, _client = _make_provider(_github_default_handler) - assert isinstance(provider, Provider) - - -# Happy paths - -def test_get_default_branch_returns_value(monkeypatch: pytest.MonkeyPatch) -> None: - provider, client = _make_provider(_github_default_handler) - with client: - assert provider.get_default_branch() == _DEFAULT_BRANCH - - -def test_get_latest_commit_returns_head(monkeypatch: pytest.MonkeyPatch) -> None: - provider, client = _make_provider(_github_default_handler) - with client: - commit = provider.get_latest_commit_on_default_branch() - assert commit == Commit(sha=_DEFAULT_COMMIT_SHA, message=_DEFAULT_COMMIT_MESSAGE) - - -def test_list_tags_returns_tags(monkeypatch: pytest.MonkeyPatch) -> None: - provider, client = _make_provider(_github_default_handler) - with client: - tags = provider.list_tags() - assert tags == [ - Tag(name="v0.1.0", commit_sha="old1234"), - Tag(name="v0.2.0", commit_sha="new1234"), - ] - - -def test_create_tag_succeeds_on_201(monkeypatch: pytest.MonkeyPatch) -> None: - provider, client = _make_provider(_github_default_handler) - with client: - provider.create_tag("v1.0.0", _DEFAULT_COMMIT_SHA) # raises on failure; nothing to assert - - -# Status-error translation paths - -def test_get_default_branch_raises_auth_error_on_401(monkeypatch: pytest.MonkeyPatch) -> None: - overrides = {("GET", _REPO_PATH): httpx2.Response(401, json={"message": "Bad credentials"})} - provider, client = _make_provider(compose_handler(_github_default_handler, overrides)) - with client, pytest.raises(AuthError, match="Token rejected"): - provider.get_default_branch() - - -def test_get_default_branch_raises_auth_error_on_403(monkeypatch: pytest.MonkeyPatch) -> None: - overrides = {("GET", _REPO_PATH): httpx2.Response(403, json={"message": "Forbidden"})} - provider, client = _make_provider(compose_handler(_github_default_handler, overrides)) - with client, pytest.raises(AuthError, match="403"): - provider.get_default_branch() - - -def test_get_default_branch_raises_config_error_on_404(monkeypatch: pytest.MonkeyPatch) -> None: - overrides = {("GET", _REPO_PATH): httpx2.Response(404, json={"message": "Not Found"})} - provider, client = _make_provider(compose_handler(_github_default_handler, overrides)) - with client, pytest.raises(ConfigError, match=f"repo='{GITHUB_REPO}'"): - provider.get_default_branch() - - -def test_get_default_branch_raises_config_error_when_default_branch_missing(monkeypatch: pytest.MonkeyPatch) -> None: - overrides = {("GET", _REPO_PATH): httpx2.Response(200, json={"default_branch": None})} - provider, client = _make_provider(compose_handler(_github_default_handler, overrides)) - with client, pytest.raises(ConfigError, match="Default branch missing"): - provider.get_default_branch() - - -# create_tag — already-exists 422 - -def test_create_tag_already_exists_structured_becomes_config_error(monkeypatch: pytest.MonkeyPatch) -> None: - overrides = { - ("POST", _REFS_PATH): httpx2.Response( - 422, - json={ - "message": "Reference already exists", - "errors": [{"resource": "Reference", "code": "already_exists"}], - }, - ) - } - provider, client = _make_provider(compose_handler(_github_default_handler, overrides)) - with client, pytest.raises(ConfigError, match="Tag already exists.*v1.0.0"): - provider.create_tag("v1.0.0", _DEFAULT_COMMIT_SHA) - - -def test_create_tag_other_422_becomes_generic_config_error(monkeypatch: pytest.MonkeyPatch) -> None: - overrides = {("POST", _REFS_PATH): httpx2.Response(422, json={"message": "Invalid ref format"})} - provider, client = _make_provider(compose_handler(_github_default_handler, overrides)) - with client, pytest.raises(ConfigError, match="422"): - provider.create_tag("invalid name", _DEFAULT_COMMIT_SHA) - - -# Pagination - -def test_list_tags_follows_link_header_next(monkeypatch: pytest.MonkeyPatch) -> None: - page1_url = f"{GITHUB_ENDPOINT}{_TAGS_PATH}?per_page=100&page=2" - - def handler(request: httpx2.Request) -> httpx2.Response: - page = request.url.params.get("page", "1") - if request.method == "GET" and request.url.path == _TAGS_PATH and page == "1": - return httpx2.Response( - 200, - json=[{"name": "v0.1.0", "commit": {"sha": "old1234"}}], - headers={"link": f'<{page1_url}>; rel="next"'}, - ) - if request.method == "GET" and request.url.path == _TAGS_PATH and page == "2": - return httpx2.Response(200, json=[{"name": "v0.2.0", "commit": {"sha": "new1234"}}]) - return httpx2.Response(404) - - provider, client = _make_provider(handler) - with client: - tags = provider.list_tags() - assert tags == [ - Tag(name="v0.1.0", commit_sha="old1234"), - Tag(name="v0.2.0", commit_sha="new1234"), - ] - - -def test_list_tags_refuses_cross_origin_next_link(monkeypatch: pytest.MonkeyPatch) -> None: - evil_url = "https://evil.test/repos/owner/repo/tags?page=2" - - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response( - 200, - json=[{"name": "v0.1.0", "commit": {"sha": "old1234"}}], - headers={"link": f'<{evil_url}>; rel="next"'}, - ) - - provider, client = _make_provider(handler) - with client, pytest.raises(ProviderAPIError, match="different host"): - provider.list_tags() - - -# Decoder-failure path - -def test_get_default_branch_raises_provider_api_error_on_malformed_body(monkeypatch: pytest.MonkeyPatch) -> None: - overrides = {("GET", _REPO_PATH): httpx2.Response(200, text="not json at all")} - provider, client = _make_provider(compose_handler(_github_default_handler, overrides)) - with client, pytest.raises(ProviderAPIError, match="_RepoResponse response could not be decoded"): - provider.get_default_branch() -``` - -If `tests.conftest` doesn't already export `compose_handler` and `default_handler` (it does — `test_gitlab_provider.py` uses them), the imports above just work. If `compose_handler`'s signature doesn't match the pattern above, read the existing `tests/conftest.py` and adjust accordingly. - -This skeleton has ~14 tests. Run them and check coverage; add more (e.g., 429, 5xx, NetworkError via raising handler, RetryBudget) until `semvertag/providers/github.py` reaches 100% statement + branch. - -- [ ] **Step 4: Run the integration tests** - -Run: `uv run pytest tests/integration/test_github_provider.py -v 2>&1 | tail -25` -Expected: most tests pass. Any failures: read the actual error, fix either the handler shape (JSON body mismatch) or the test expectation. The provider code is the spec's source of truth; the test should match it. - -- [ ] **Step 5: Full suite + coverage** - -Run: `just test 2>&1 | tail -5` -Expected: ~363 tests pass (was 349; +14 from GitHub integration tests), 100% coverage **on existing modules**. The new `semvertag/providers/github.py` will have 100% coverage if the integration tests exercise every branch — verify in the `Cover` column of the report. Add tests as needed for any uncovered lines. - -- [ ] **Step 6: Lint** - -Run: `just lint-ci` -Expected: clean. If ruff flags `httpx2` import in `github.py` as unused, remove it (the type reference in the helper signature uses the response object directly). - -- [ ] **Step 7: Commit** - -```bash -git add semvertag/providers/github.py tests/conftest.py tests/integration/test_github_provider.py -git commit -m "providers/github: add GitHubProvider with integration test suite" -``` - ---- - -## Task 6: Wire `current_provider` in ioc.py + update CLI, docs, README, pyproject - -The provider class works in isolation. This task plumbs it into the DI container, the CLI, the docs, and the public-facing project metadata. - -**Files:** -- Modify: `semvertag/ioc.py` -- Modify: `semvertag/__main__.py` -- Modify: `tests/unit/test_ioc.py` -- Modify: `tests/integration/test_cli_main_verb.py` (and possibly `test_cli_quiet_json_matrix.py`) -- Create: `docs/providers/github.md` -- Modify: `README.md` -- Modify: `pyproject.toml` - -- [ ] **Step 1: Rewrite `semvertag/ioc.py`** - -Replace the entire file contents with: - -```python -import typing - -import httpware -import modern_di -from modern_di import Scope, providers - -from semvertag._errors import ConfigError -from semvertag._settings import Settings -from semvertag._use_case import SemvertagUseCase -from semvertag.providers._base import Provider -from semvertag.providers.github import GitHubProvider -from semvertag.providers.gitlab import GitLabProvider -from semvertag.strategies._base import BumpStrategy -from semvertag.strategies.branch_prefix import BranchPrefixStrategy -from semvertag.strategies.conventional_commits import ConventionalCommitsStrategy - - -_GITLAB_TOKEN_HEADER: typing.Final = "PRIVATE-TOKEN" -_GITHUB_ACCEPT: typing.Final = "application/vnd.github+json" -_GITHUB_API_VERSION: typing.Final = "2022-11-28" -_RETRY_STATUS_CODES: typing.Final = frozenset({408, 429, 500, 502, 503, 504}) - - -def _build_gitlab_client(settings: Settings) -> httpware.Client: - return httpware.Client( - base_url=settings.gitlab.endpoint, - timeout=settings.request_timeout, - headers={_GITLAB_TOKEN_HEADER: settings.gitlab.token.get_secret_value()}, - middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], - ) - - -def _build_github_client(settings: Settings) -> httpware.Client: - return httpware.Client( - base_url=settings.github.endpoint, - timeout=settings.request_timeout, - headers={ - "Authorization": f"Bearer {settings.github.token.get_secret_value()}", - "Accept": _GITHUB_ACCEPT, - "X-GitHub-Api-Version": _GITHUB_API_VERSION, - }, - middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], - ) - - -def _build_gitlab_provider(settings: Settings, client: httpware.Client) -> GitLabProvider: - if settings.project_id is None: - msg = "Project id missing. Set CI_PROJECT_ID or pass --project-id." - raise ConfigError(msg) - return GitLabProvider(config=settings.gitlab, project_id=settings.project_id, http=client) - - -def _build_github_provider(settings: Settings, client: httpware.Client) -> GitHubProvider: - if settings.repo is None: - msg = "Repo missing. Set GITHUB_REPOSITORY or pass --repo OWNER/REPO." - raise ConfigError(msg) - return GitHubProvider(config=settings.github, repo=settings.repo, http=client) - - -def _build_current_provider( - settings: Settings, - gitlab_provider: GitLabProvider, - github_provider: GitHubProvider, -) -> Provider: - if settings.provider == "github": - return github_provider - return gitlab_provider - - -def _build_branch_prefix_strategy(settings: Settings) -> BranchPrefixStrategy: - return BranchPrefixStrategy(config=settings.branch_prefix) - - -def _build_conventional_commits_strategy(settings: Settings) -> ConventionalCommitsStrategy: - return ConventionalCommitsStrategy(config=settings.conventional_commits) - - -def _build_current_strategy(settings: Settings) -> BumpStrategy: - if settings.strategy == "conventional-commits": - return _build_conventional_commits_strategy(settings) - return _build_branch_prefix_strategy(settings) - - -def _close_gitlab_provider(provider: GitLabProvider) -> None: - provider.http.close() - - -def _close_github_provider(provider: GitHubProvider) -> None: - provider.http.close() - - -class SettingsGroup(modern_di.Group): - settings = providers.ContextProvider(scope=Scope.APP, context_type=Settings) - - -class ProvidersGroup(modern_di.Group): - gitlab_client = providers.Factory(scope=Scope.APP, creator=_build_gitlab_client) - gitlab_provider = providers.Factory( - scope=Scope.APP, - creator=_build_gitlab_provider, - kwargs={"client": gitlab_client}, - cache_settings=providers.CacheSettings(finalizer=_close_gitlab_provider), - ) - github_client = providers.Factory(scope=Scope.APP, creator=_build_github_client) - github_provider = providers.Factory( - scope=Scope.APP, - creator=_build_github_provider, - kwargs={"client": github_client}, - cache_settings=providers.CacheSettings(finalizer=_close_github_provider), - ) - current_provider = providers.Factory( - scope=Scope.APP, - creator=_build_current_provider, - kwargs={"gitlab_provider": gitlab_provider, "github_provider": github_provider}, - ) - - -class StrategiesGroup(modern_di.Group): - branch_prefix_strategy = providers.Factory(scope=Scope.APP, creator=_build_branch_prefix_strategy) - conventional_commits_strategy = providers.Factory(scope=Scope.APP, creator=_build_conventional_commits_strategy) - current_strategy = providers.Factory(scope=Scope.APP, creator=_build_current_strategy) - - -class UseCasesGroup(modern_di.Group): - semvertag_use_case = providers.Factory( - scope=Scope.APP, - creator=SemvertagUseCase, - kwargs={ - "provider": ProvidersGroup.current_provider, - "strategy": StrategiesGroup.current_strategy, - }, - ) - - -ALL_GROUPS: typing.Final[list[type[modern_di.Group]]] = [ - SettingsGroup, - ProvidersGroup, - StrategiesGroup, - UseCasesGroup, -] - - -container: typing.Final = modern_di.Container(groups=ALL_GROUPS) -``` - -Key changes from before: -- `_build_github_client`, `_build_github_provider`, `_build_current_provider`, `_close_github_provider` added -- `_close_provider_client` renamed → `_close_gitlab_provider` for symmetry -- `ProvidersGroup` adds `github_client`, `github_provider`, `current_provider` -- `UseCasesGroup.semvertag_use_case` references `ProvidersGroup.current_provider` (was `gitlab_provider`) - -- [ ] **Step 2: Add `current_provider` resolution tests** - -In `tests/unit/test_ioc.py`, add: - -```python -def test_container_resolves_github_provider_when_settings_provider_is_github() -> None: - settings = Settings(provider="github", repo="owner/repo") - with ioc.container: - ioc.container.set_context(Settings, settings) - provider = ioc.container.resolve_provider(ioc.ProvidersGroup.current_provider) - assert isinstance(provider, GitHubProvider) - assert provider.name == "github" - - -def test_container_resolves_gitlab_provider_when_settings_provider_is_gitlab() -> None: - settings = Settings(provider="gitlab", project_id=999) - with ioc.container: - ioc.container.set_context(Settings, settings) - provider = ioc.container.resolve_provider(ioc.ProvidersGroup.current_provider) - assert isinstance(provider, GitLabProvider) - assert provider.name == "gitlab" -``` - -Add the import at the top: - -```python -from semvertag.providers.github import GitHubProvider -from semvertag.providers.gitlab import GitLabProvider -``` - -- [ ] **Step 3: Rewrite `_main_callback` in `semvertag/__main__.py`** - -The function gets three new params, and `--token` moves out of `_collect_overrides` into a second-pass overlay. - -Find and replace `_collect_overrides`: - -```python -def _collect_overrides( # noqa: PLR0913 - *, - project_id: int | None, - strategy: str | None, - default_branch: str | None, - gitlab_endpoint: str | None, - github_endpoint: str | None, - provider: str | None, - repo: str | None, - request_timeout: float | None, -) -> dict[str, typing.Any]: - overrides: dict[str, typing.Any] = {} - if provider is not None: - overrides["provider"] = provider - if project_id is not None: - overrides["project_id"] = project_id - if repo is not None: - overrides["repo"] = repo - if strategy is not None: - overrides["strategy"] = strategy - if default_branch is not None: - overrides["default_branch"] = default_branch - if gitlab_endpoint is not None: - overrides["gitlab.endpoint"] = gitlab_endpoint - if github_endpoint is not None: - overrides["github.endpoint"] = github_endpoint - if request_timeout is not None: - overrides["request_timeout"] = request_timeout - return overrides -``` - -Note: `token` is no longer a parameter. - -Find and replace `_main_callback`: - -```python -@MAIN_APP.callback() -def _main_callback( # noqa: PLR0913 - ctx: typer.Context, - project_id: typing.Annotated[ - int | None, - typer.Option("--project-id", help="GitLab project id (or set CI_PROJECT_ID)."), - ] = None, - repo: typing.Annotated[ - str | None, - typer.Option("--repo", help="GitHub repo as OWNER/REPO (or set GITHUB_REPOSITORY)."), - ] = None, - provider: typing.Annotated[ - str | None, - typer.Option("--provider", help="Provider: 'github' or 'gitlab' (default: auto-detect from CI env)."), - ] = None, - strategy: typing.Annotated[ - str | None, - typer.Option("--strategy", help="Bump strategy: branch-prefix | conventional-commits."), - ] = None, - token: typing.Annotated[ - str | None, - typer.Option("--token", help="API token (overrides SEMVERTAG_TOKEN); routed to the active provider."), - ] = None, - default_branch: typing.Annotated[ - str | None, - typer.Option("--default-branch", help="Default branch name override."), - ] = None, - gitlab_endpoint: typing.Annotated[ - str | None, - typer.Option("--gitlab-endpoint", help="GitLab API endpoint URL."), - ] = None, - github_endpoint: typing.Annotated[ - str | None, - typer.Option("--github-endpoint", help="GitHub API endpoint URL (for GitHub Enterprise)."), - ] = None, - request_timeout: typing.Annotated[ - float | None, - typer.Option("--request-timeout", help="Per-request timeout in seconds (clamped to 10)."), - ] = None, - _version: typing.Annotated[ - bool | None, - typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version and exit."), - ] = None, -) -> None: - if ctx.resilient_parsing: - return - - try: - settings = Settings() - try: - overrides = _collect_overrides( - project_id=project_id, - strategy=strategy, - default_branch=default_branch, - gitlab_endpoint=gitlab_endpoint, - github_endpoint=github_endpoint, - provider=provider, - repo=repo, - request_timeout=request_timeout, - ) - settings = apply_cli_overlay(settings, overrides) - # Second pass: route --token to the resolved active provider. - if token is not None: - settings = apply_cli_overlay( - settings, {f"{settings.provider}.token": pydantic.SecretStr(token)} - ) - except ValueError as exc: - raise ConfigError(str(exc)) from exc - except pydantic.ValidationError as exc: - err = _config_error_from_validation(exc) - typer.echo(f"Error: {err}", err=True) - raise typer.Exit(code=err.exit_code) from err - except ConfigError as err: - typer.echo(f"Error: {err}", err=True) - raise typer.Exit(code=err.exit_code) from err - - app_container = modern_di_typer.fetch_di_container(ctx) - app_container.set_context(Settings, settings) -``` - -And update `MAIN_APP`: - -```python -MAIN_APP: typing.Final = typer.Typer( - name="semvertag", - help=( - "Auto-tag GitLab and GitHub repos with semantic version tags — " - "one tool, two strategies, two providers." - ), - no_args_is_help=True, - add_completion=True, -) -``` - -- [ ] **Step 4: Run CLI integration tests; fix breakage** - -Run: `uv run pytest tests/integration/test_cli_main_verb.py tests/integration/test_cli_quiet_json_matrix.py -v 2>&1 | tail -30` -Expected: most pass; some may fail because they construct `Settings` paths that no longer satisfy the validator (e.g., `Settings()` with no `project_id` and no env). Fix each failure by either (a) adding `monkeypatch.setenv("CI_PROJECT_ID", "999")` to the test fixture, or (b) updating the test to pass `--project-id 999` to the CLI invocation. - -- [ ] **Step 5: Add CLI smoke tests for GitHub paths** - -In `tests/integration/test_cli_main_verb.py` (or wherever the existing CLI smoke tests live), add: - -```python -def test_main_callback_accepts_github_provider_with_repo(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - monkeypatch.delenv("GITLAB_CI", raising=False) - runner = CliRunner() - result = runner.invoke( - MAIN_APP, - ["--provider", "github", "--repo", "owner/repo", "--token", "ghp_xxx", "tag", "--quiet"], - # ... whatever invocation shape the existing tests use - ) - # Assert the callback succeeded (resolved settings.provider == "github" without error) - # The actual `tag` execution will fail because no real network; check exit code - # is the expected ProviderAPIError exit (4), not ConfigError exit (2). - assert result.exit_code in (0, 4) - - -def test_main_callback_auto_detects_github_from_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("GITHUB_ACTIONS", "true") - monkeypatch.setenv("GITHUB_REPOSITORY", "owner/repo") - monkeypatch.setenv("GITHUB_TOKEN", "ghp_xxx") - runner = CliRunner() - result = runner.invoke(MAIN_APP, ["tag", "--quiet"]) - assert result.exit_code in (0, 4) -``` - -Adjust the invocation shape to match the existing tests' conventions in that file. - -- [ ] **Step 6: Run the full suite again** - -Run: `just test 2>&1 | tail -5` -Expected: all tests pass, 100% coverage. - -- [ ] **Step 7: Lint** - -Run: `just lint-ci` -Expected: clean. - -- [ ] **Step 8: Create `docs/providers/github.md`** - -Use `docs/providers/gitlab.md` as a structural reference (read it first; mirror the section order and tone). The new file should cover: - -- **Authentication**: PAT (classic with `repo` or `public_repo`; fine-grained with `contents: write`) or `GITHUB_TOKEN` from GitHub Actions (workflow must declare `permissions: contents: write`). -- **Environment variables**: `GITHUB_TOKEN` / `SEMVERTAG_GITHUB__TOKEN` / `SEMVERTAG_TOKEN`, `GITHUB_REPOSITORY` / `SEMVERTAG_REPO`, `SEMVERTAG_GITHUB__ENDPOINT` (for GitHub Enterprise). -- **Inline GitHub Actions job recipe** — mirror the GitLab CI recipe shape. Use `actions/setup-python@v5` + `uvx semvertag tag`. Include a minimal workflow YAML: - - ```yaml - name: semvertag - on: - push: - branches: [main] - permissions: - contents: write - jobs: - tag: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - run: pip install uv - - run: uvx semvertag tag --strategy conventional-commits - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ``` - -- **Troubleshooting**: 401 (token rejected/invalid), 403 (scope missing — direct user to `contents: write`), 404 (repo not found — check `GITHUB_REPOSITORY`), 422 (tag exists / invalid ref format). - -- [ ] **Step 9: Update `README.md`** - -Two changes: - -1. Hero string at the top — change `"Auto-tag GitLab repos with semantic version tags"` to `"Auto-tag GitLab and GitHub repos with semantic version tags"`. - -2. Add a new "Use in GitHub Actions" section parallel to the existing GitLab CI section. Copy the inline YAML from `docs/providers/github.md` (Step 8) into this section. The README version can be a 5-7 line excerpt linking to the full docs. - -- [ ] **Step 10: Update `pyproject.toml`** - -Find the `description` and `keywords` lines: - -```toml -description = "Auto-tag GitLab repos with semantic version tags — one tool, two strategies." -... -keywords = ["semver", "gitlab", "ci", "auto-tag", "conventional-commits"] -``` - -Replace with: - -```toml -description = "Auto-tag GitLab and GitHub repos with semantic version tags — one tool, two strategies, two providers." -... -keywords = ["semver", "gitlab", "github", "ci", "auto-tag", "conventional-commits"] -``` - -- [ ] **Step 11: Full suite + lint + docs build** - -Run: -```bash -just lint-ci -just test -uv run --with mkdocs --with mkdocs-material mkdocs build --strict -``` - -Expected: all clean. The docs build should pick up the new `docs/providers/github.md` automatically if `mkdocs.yml`'s nav is auto-discovered; if it has an explicit nav, add an entry for it. - -- [ ] **Step 12: Commit** - -```bash -git add semvertag/ioc.py semvertag/__main__.py tests/unit/test_ioc.py tests/integration/test_cli_main_verb.py docs/providers/github.md README.md pyproject.toml -git commit -m "ioc+cli+docs: wire GitHub provider end-to-end" -``` - -If you also modified `tests/integration/test_cli_quiet_json_matrix.py`, include it in the commit. - ---- - -## Task 7: Final validation - -**Files:** none modified — verification gate. - -- [ ] **Step 1: Full lint sweep** - -Run: `just lint-ci` -Expected: clean. - -- [ ] **Step 2: Full test sweep with branch coverage** - -Run: `just test-branch` -Expected: all tests pass; 100% statement + branch coverage on all modules including the new `semvertag/providers/github.py` and `semvertag/_link_pagination.py`. - -- [ ] **Step 3: Docs build** - -Run: `uv run --with mkdocs --with mkdocs-material mkdocs build --strict` -Expected: clean. - -- [ ] **Step 4: Verify the GitHub provider boots end-to-end via DI** - -Run: -```bash -uv run python -c " -import os -os.environ['GITHUB_ACTIONS'] = 'true' -os.environ['GITHUB_REPOSITORY'] = 'octocat/Hello-World' -os.environ['GITHUB_TOKEN'] = 'ghp_xxx' - -from semvertag import ioc -from semvertag._settings import Settings -from semvertag.providers._base import Provider -from semvertag.providers.github import GitHubProvider - -with ioc.container: - ioc.container.set_context(Settings, Settings()) - provider = ioc.container.resolve_provider(ioc.ProvidersGroup.current_provider) - assert isinstance(provider, GitHubProvider), f'expected GitHubProvider, got {type(provider).__name__}' - assert isinstance(provider, Provider) - print(f'GitHub DI seam OK: provider={provider.name}, repo={provider.repo}') -" -``` -Expected: prints `GitHub DI seam OK: provider=github, repo=octocat/Hello-World`. If it fails, the wiring in Task 6 is broken — re-check `_build_current_provider` and the `ProvidersGroup.current_provider` factory's `kwargs`. - -- [ ] **Step 5: Verify back-compat — GitLab path still works under DI** - -Run: -```bash -uv run python -c " -import os -for k in ('GITHUB_ACTIONS', 'GITLAB_CI', 'GITHUB_REPOSITORY', 'SEMVERTAG_REPO', 'SEMVERTAG_PROVIDER'): - os.environ.pop(k, None) -os.environ['CI_PROJECT_ID'] = '999' - -from semvertag import ioc -from semvertag._settings import Settings -from semvertag.providers._base import Provider -from semvertag.providers.gitlab import GitLabProvider - -with ioc.container: - ioc.container.set_context(Settings, Settings()) - provider = ioc.container.resolve_provider(ioc.ProvidersGroup.current_provider) - assert isinstance(provider, GitLabProvider), f'expected GitLabProvider, got {type(provider).__name__}' - print(f'GitLab DI seam OK: provider={provider.name}, project_id={provider.project_id}') -" -``` -Expected: prints `GitLab DI seam OK: provider=gitlab, project_id=999`. Confirms the default-to-gitlab back-compat for 0.2.x users running outside CI. - -- [ ] **Step 6: Skim `git log --oneline main..HEAD` for clean history** - -Expected sequence: -``` - ioc+cli+docs: wire GitHub provider end-to-end - providers/github: add GitHubProvider with integration test suite - providers/_errors: add translate_github + translate_create_tag_github_unprocessable - settings: add provider/repo fields + env-aware _resolve_provider validator - refactor(providers/_errors): extract _translate_transport(exc, *, provider_label) - refactor: extract Link-header pagination to _link_pagination module -``` - -- [ ] **Step 7 — Optional**: The controller will dispatch a final cross-cutting code review separately. - ---- - -## Self-review notes - -- **Spec coverage:** Every spec section maps to a task: Target shape → Tasks 1-6; Provider selection (auto-detection + validator) → Task 3; GitHubProvider implementation → Task 5; Error translation (extract transport + add github) → Tasks 2 and 4; Pagination helpers extraction → Task 1; Wiring (ioc.py + CLI + docs + README + pyproject) → Task 6. The five spec open items are all resolved at plan-time inside the task steps: (1) `_detect_provider_from_env` placed as a free function in `_settings.py` (Task 3 Step 3); (2) `_link_pagination` public-no-underscore naming committed (Task 1 Step 1); (3) two-pass `--token` overlay confirmed to work (the second `apply_cli_overlay` call in Task 6 Step 3); (4) integration-test fixture sharing — skip the shared conftest helper; each integration file owns its own `_make_provider` (Task 5 Step 2 inline); (5) pagination tests moved to `tests/unit/test_link_pagination.py` (Task 1 Step 2). -- **Placeholder scan:** No `TBD`, `TODO`, "appropriate error handling", or "similar to Task N" patterns. Task 5 Step 3 explicitly says "this skeleton has ~14 tests; add more until 100% coverage" — that's a definite directive, not a TBD: the implementer keeps adding tests using the same patterns until coverage gate passes. -- **Type consistency:** `GitHubProvider.repo: str` consistent across Task 5 (definition) and Task 6 (ioc factory). `Settings.provider: Literal["gitlab", "github"] | None` consistent across Task 3 (definition) and Task 6 (factory dispatch on it). `_translate_transport(exc, *, provider_label: str)` signature defined in Task 2, called with `provider_label="GitLab"` in Task 2 (updated `translate_gitlab`) and `provider_label="GitHub"` in Task 4 (`translate_github`). `httpware.DecodeError`/`UnprocessableEntityError`/`StatusError`/`ClientError` references all match httpware 0.8.2's public surface (already pinned). diff --git a/planning/changes/2026-06-08.03-action-yml-composite-wrapper/design.md b/planning/changes/2026-06-08.03-action-yml-composite-wrapper.md similarity index 100% rename from planning/changes/2026-06-08.03-action-yml-composite-wrapper/design.md rename to planning/changes/2026-06-08.03-action-yml-composite-wrapper.md diff --git a/planning/changes/2026-06-08.03-action-yml-composite-wrapper/plan.md b/planning/changes/2026-06-08.03-action-yml-composite-wrapper/plan.md deleted file mode 100644 index 7576547..0000000 --- a/planning/changes/2026-06-08.03-action-yml-composite-wrapper/plan.md +++ /dev/null @@ -1,820 +0,0 @@ -# action.yml composite wrapper — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Reintroduce `action.yml` at repo root as a composite GitHub Action so users can replace the 11-line install-and-run block with `uses: modern-python/semvertag@v0`. Ship a floating-major-tag workflow, migrate the dogfood + CI to consume the local action, rewrite docs, and produce a v0.4.0 release runbook including the manual Marketplace publication procedure. - -**Architecture:** Pure-YAML + Markdown work. Two new workflow files (`action.yml`, `.github/workflows/tag-major.yml`), one modified workflow (`.github/workflows/semvertag.yml` dogfood), one modified workflow (`.github/workflows/ci.yml` adds an `action-smoke` job that runs `uses: ./` against the action being introduced — the chicken-and-egg is resolved by floor `'semvertag>=0.3.1,<1'` which is satisfiable from PyPI today). Docs rewrite touches README and `docs/providers/github.md`. Runbook captures the manual Marketplace publication procedure as five numbered steps for the maintainer to follow. - -**Tech Stack:** GitHub Actions composite action syntax, `astral-sh/setup-uv@v7`, bash + `jq` (default on `ubuntu-latest`), MkDocs (`mkdocs build --strict` as the docs gate). - -**Spec:** `planning/specs/2026-06-08-action-yml-composite-wrapper-design.md` - -**Branch convention:** This is a `feat/` branch (the dogfood workflow's `SEMVERTAG_BRANCH_PREFIX__MINOR: '["feat/"]'` maps it to a minor bump). Suggested branch: `feat/action-yml-composite-wrapper`. - ---- - -## File structure - -| File | Action | Purpose | -|---|---|---| -| `action.yml` | **create** | Composite action — `setup-uv` then `uvx 'semvertag>=0.3.1,<1' tag --json` with output parsing | -| `.github/workflows/tag-major.yml` | **create** | Force-update floating `v0` tag on each non-prerelease release | -| `.github/workflows/semvertag.yml` | **modify** | Collapse 4 install/run steps into `uses: ./` (dogfood the action) | -| `.github/workflows/ci.yml` | **modify** | Add `action-smoke` job that runs `uses: ./` and asserts outputs exist | -| `README.md` | **modify** | Replace 24-line "Use it in GitHub Actions" section (lines 42–69) with action-based snippet | -| `docs/providers/github.md` | **modify** | Delete pending callout, replace Quick Start, add Outputs section, add env-var passthrough note, add "Without the composite action" fallback | -| `planning/releases/0.4.0.md` | **create** | Release runbook with pre-flight + v0 bootstrap + Marketplace publication procedure | - ---- - -## Verification commands referenced throughout - -- **YAML parse check:** `python3 -c "import yaml; yaml.safe_load(open('PATH'))"` -- **Docs gate:** `mkdocs build --strict` (run from repo root with `.venv` active; if not active, `uv run mkdocs build --strict`) -- **Lint gate:** `just lint-ci` -- **Test gate (sanity, even though no Python changed):** `just test` - -If a step says "verify" and the command exits 0 with no output, the check passed. - ---- - -### Task 1: Create `action.yml` - -**Files:** -- Create: `action.yml` - -- [ ] **Step 1: Create the composite action file** - -Write `action.yml` at the repo root with exactly this content: - -```yaml -name: 'semvertag' -description: 'Auto-tag your GitHub repository with a SemVer git tag based on commits or branch prefixes.' -author: 'modern-python' - -branding: - icon: 'tag' - color: 'blue' - -inputs: - strategy: - description: 'Bump strategy: branch-prefix (default) or conventional-commits.' - required: false - default: 'branch-prefix' - token: - description: 'GitHub token with contents: write. Defaults to the workflow-issued github.token.' - required: false - default: ${{ github.token }} - -outputs: - tag: - description: 'The created tag (e.g. v1.2.3), or empty string if no bump was warranted.' - value: ${{ steps.run.outputs.tag }} - bump: - description: 'The computed bump: none | patch | minor | major.' - value: ${{ steps.run.outputs.bump }} - status: - description: 'The run status: created (tag pushed) | no-bump (nothing to tag).' - value: ${{ steps.run.outputs.status }} - -runs: - using: 'composite' - steps: - - uses: astral-sh/setup-uv@v7 - - - name: Run semvertag - id: run - shell: bash - env: - GITHUB_TOKEN: ${{ inputs.token }} - SEMVERTAG_STRATEGY: ${{ inputs.strategy }} - run: | - set -euo pipefail - result=$(uvx 'semvertag>=0.3.1,<1' tag --json) - printf '%s\n' "$result" - # Normalize the CLI's internal status (`no_tags`, `already_tagged`, - # `no_merge_commit`, `no_conforming_commit`, ...) to a stable - # consumer-facing enum. `set -euo pipefail` ensures we never reach - # here on CLI errors, so there is no `error` value to surface. - case "$(jq -r '.status' <<<"$result")" in - created) status='created' ;; - *) status='no-bump' ;; - esac - printf 'tag=%s\n' "$(jq -r '.tag // ""' <<<"$result")" >> "$GITHUB_OUTPUT" - printf 'bump=%s\n' "$(jq -r '.bump' <<<"$result")" >> "$GITHUB_OUTPUT" - printf 'status=%s\n' "$status" >> "$GITHUB_OUTPUT" -``` - -- [ ] **Step 2: Verify YAML parses** - -Run: `python3 -c "import yaml; yaml.safe_load(open('action.yml'))"` -Expected: exits 0, no output. - -- [ ] **Step 3: Commit** - -```bash -git add action.yml -git commit -m "feat: add action.yml composite GitHub Action" -``` - ---- - -### Task 2: Create `.github/workflows/tag-major.yml` - -**Files:** -- Create: `.github/workflows/tag-major.yml` - -- [ ] **Step 1: Create the workflow file** - -Write `.github/workflows/tag-major.yml` with exactly this content: - -```yaml -name: tag-major - -# Maintains the floating `v0` major tag so users can pin `uses: -# modern-python/semvertag@v0` and ride minor bumps. Skipped on -# prereleases so an `v0.5.0-rc1` does not drag `v0` ahead of the latest -# stable. When v1.0.0 ships, this same job creates `v1` automatically -# from the tag name's leading segment. - -on: - release: - types: [published] - -permissions: - contents: write - -jobs: - update-major-tag: - if: ${{ !github.event.release.prerelease }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Update major tag - env: - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: | - set -euo pipefail - # RELEASE_TAG = 'v0.4.0' → major = 'v0' - major="${RELEASE_TAG%%.*}" - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git tag -fa "$major" "$RELEASE_TAG" -m "Update $major to $RELEASE_TAG" - git push -f origin "$major" -``` - -- [ ] **Step 2: Verify YAML parses** - -Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/tag-major.yml'))"` -Expected: exits 0, no output. - -- [ ] **Step 3: Commit** - -```bash -git add .github/workflows/tag-major.yml -git commit -m "ci: add tag-major workflow to maintain floating v0 tag" -``` - ---- - -### Task 3: Migrate `.github/workflows/semvertag.yml` to consume the local action - -**Files:** -- Modify: `.github/workflows/semvertag.yml` - -- [ ] **Step 1: Replace the file contents** - -Overwrite `.github/workflows/semvertag.yml` with exactly this content: - -```yaml -name: semvertag - -# Dogfood the local composite action against this repo. Auto-tags on -# push to main when the latest commit is a merge from `feat/...` (minor -# bump) or `bugfix/`/`hotfix/...` (patch). This repo's branch -# convention uses `feat/...`, so SEMVERTAG_BRANCH_PREFIX__MINOR -# overrides the default `feature/` mapping. -# -# The workflow only creates a tag — it does NOT trigger publish.yml, -# which fires on GitHub release creation. To publish to PyPI, create a -# GitHub release pointed at the auto-tagged commit. -# -# `uses: ./` exercises the action.yml in the current checkout, so any -# breaking change to action.yml fails the dogfood run before it can -# affect external users. - -on: - push: - branches: [main] - -permissions: - contents: write - -concurrency: - group: semvertag - cancel-in-progress: false - -jobs: - tag: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: ./ - env: - SEMVERTAG_BRANCH_PREFIX__MINOR: '["feat/"]' -``` - -- [ ] **Step 2: Verify YAML parses** - -Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/semvertag.yml'))"` -Expected: exits 0, no output. - -- [ ] **Step 3: Commit** - -```bash -git add .github/workflows/semvertag.yml -git commit -m "ci: dogfood the composite action via uses: ./" -``` - ---- - -### Task 4: Add `action-smoke` job to `.github/workflows/ci.yml` - -**Files:** -- Modify: `.github/workflows/ci.yml` - -- [ ] **Step 1: Read current `ci.yml`** - -Open `.github/workflows/ci.yml`. It currently has two jobs: `lint` and `pytest`. You will add a third job `action-smoke` at the end, preserving the existing two jobs unchanged. - -- [ ] **Step 2: Append the `action-smoke` job** - -Add this job to the `jobs:` block (after the `pytest:` job, with one blank line separating them): - -```yaml - action-smoke: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - id: semvertag - uses: ./ - env: - SEMVERTAG_BRANCH_PREFIX__MINOR: '["feat/"]' - - name: Verify outputs were emitted - run: | - test -n "${{ steps.semvertag.outputs.status }}" - test -n "${{ steps.semvertag.outputs.bump }}" -``` - -After the edit, the full file should look like this (lint and pytest unchanged, action-smoke appended): - -```yaml -name: main - -on: - push: - branches: - - main - pull_request: {} - -concurrency: - group: ${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: extractions/setup-just@v2 - - uses: astral-sh/setup-uv@v3 - with: - enable-cache: true - cache-dependency-glob: "**/pyproject.toml" - - run: uv python install 3.11 - - run: just install lint-ci - - pytest: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: - - "3.11" - - "3.12" - - "3.13" - - "3.14" - steps: - - uses: actions/checkout@v4 - - uses: extractions/setup-just@v2 - - uses: astral-sh/setup-uv@v3 - with: - enable-cache: true - cache-dependency-glob: "**/pyproject.toml" - - run: uv python install ${{ matrix.python-version }} - - run: just install - - run: just test --cov-report xml - - action-smoke: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - id: semvertag - uses: ./ - env: - SEMVERTAG_BRANCH_PREFIX__MINOR: '["feat/"]' - - name: Verify outputs were emitted - run: | - test -n "${{ steps.semvertag.outputs.status }}" - test -n "${{ steps.semvertag.outputs.bump }}" -``` - -- [ ] **Step 3: Verify YAML parses** - -Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))"` -Expected: exits 0, no output. - -- [ ] **Step 4: Commit** - -```bash -git add .github/workflows/ci.yml -git commit -m "ci: add action-smoke job that exercises the composite action" -``` - ---- - -### Task 5: Rewrite README's "Use it in GitHub Actions" section - -**Files:** -- Modify: `README.md` (lines 42–69, the "Use it in GitHub Actions" section) - -- [ ] **Step 1: Read current `README.md`** - -The current section spans lines 42–69 and contains an 11-step `steps:` block. You will replace it with an action-based snippet. - -- [ ] **Step 2: Replace the section** - -Replace the entire block from `## Use it in GitHub Actions` (line 42) through `> [GitHub Actions docs](docs/providers/github.md) for token scopes,` and the closing `> GitHub Enterprise setup, and troubleshooting.` (line 75, inclusive) with: - -```markdown -## Use it in GitHub Actions - -Paste this workflow into `.github/workflows/semvertag.yml`: - -```yaml -name: semvertag -on: - push: - branches: [main] - -permissions: - contents: write - -jobs: - tag: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: modern-python/semvertag@v0 -``` - -semvertag auto-detects GitHub Actions, picks the bump from the latest -commit, and creates the tag ref via the GitHub API. `fetch-depth: 0` -matters — the default `1` misses tag-relative history. See -[GitHub Actions docs](docs/providers/github.md) for token scopes, -GitHub Enterprise setup, outputs, and troubleshooting. -``` - -Note: the inner fenced ```yaml block must use triple-backticks, and the outer instruction must show those backticks literally. In the file, type three real backticks for both the opening and closing of the yaml block. - -- [ ] **Step 3: Verify Markdown still renders (mkdocs gate)** - -Run: `uv run mkdocs build --strict` from the repo root. -Expected: build succeeds with no warnings. - -If mkdocs reports a strict warning, it most likely indicates a broken intra-repo link in the section you just edited. Verify the relative path `docs/providers/github.md` resolves from the repo root (it does). - -- [ ] **Step 4: Commit** - -```bash -git add README.md -git commit -m "docs: README — advertise the composite action as the GHA recipe" -``` - ---- - -### Task 6: Rewrite `docs/providers/github.md` - -**Files:** -- Modify: `docs/providers/github.md` - -This task has several sub-edits. Do them in order. Each sub-edit's "verify" step is run at the end (Step 8) as one mkdocs build. - -- [ ] **Step 1: Delete the "Composite wrapper pending" callout** - -Remove lines 7–11 (the `> **GitHub Actions composite wrapper pending.** …` blockquote). The "## Quick Start" header on line 13 should be the next thing after the lead paragraph (lines 1–5). - -After this edit, lines 1–13 should read: - -```markdown -# GitHub Actions - -Use semvertag in GitHub Actions via a small workflow that installs -`uv` and runs `uvx semvertag tag`. No composite action in your repo, -no maintained workflow YAML beyond the snippet below. - -## Quick Start - -The minimum useful workflow: auto-tag on every push to the default -branch. -``` - -Then update the lead paragraph (lines 1–5) to reflect that the action is now the recommended path. Replace lines 1–5 with: - -```markdown -# GitHub Actions - -Use semvertag in GitHub Actions via the published composite action -(`uses: modern-python/semvertag@v0`). The action installs `uv`, runs -`semvertag tag`, and surfaces the result as step outputs. A pure-CLI -fallback for environments that can't consume the action lives at the -bottom of this page. -``` - -- [ ] **Step 2: Replace the Quick Start workflow** - -The current Quick Start workflow (the 11-step `steps:` block now around lines 21–44 after the prior edits) becomes: - -````markdown -> **Required setup.** Either rely on the workflow-scoped -> `GITHUB_TOKEN` (which is auto-issued per job) — in which case the -> workflow MUST declare `permissions: contents: write` — OR provide a -> fine-grained PAT with `contents: write` (single repo) or a classic -> PAT with `repo` / `public_repo` scope. Store the PAT as a repo -> secret named `SEMVERTAG_TOKEN`; the alias chain picks it up ahead -> of `GITHUB_TOKEN`. - -```yaml -name: semvertag -on: - push: - branches: [main] - -permissions: - contents: write - -jobs: - tag: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: modern-python/semvertag@v0 -``` -```` - -The auto-detection / `fetch-depth: 0` / token paragraphs that follow the snippet remain unchanged. - -- [ ] **Step 3: Add an "Outputs" section** - -Insert this section directly after "## Required permissions" (currently the section ending around line 87) and before "## Token scope": - -````markdown -## Outputs - -When you give the step an `id:`, downstream steps can read three outputs: - -| Output | Value | -|---|---| -| `tag` | The created tag (e.g. `v1.2.3`), or empty string when `status` is `no-bump`. | -| `bump` | `none` \| `patch` \| `minor` \| `major`. | -| `status` | `created` (tag pushed) \| `no-bump` (nothing to tag — no prior tag, already tagged, no merge commit, or non-conforming commit). On CLI error the action itself exits non-zero and this output is not written. | - -Example: trigger a downstream release-notes job only when a tag was -created. - -```yaml -jobs: - tag-and-release: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - id: semvertag - uses: modern-python/semvertag@v0 - - if: steps.semvertag.outputs.status == 'created' - run: | - echo "tagged ${{ steps.semvertag.outputs.tag }}" - echo "bump=${{ steps.semvertag.outputs.bump }}" -``` -```` - -- [ ] **Step 4: Update the Strategy section** - -Replace the inline `uvx` snippet (currently around line 78 — the one-liner showing `--strategy conventional-commits`) with the action-input form: - -```yaml - - uses: modern-python/semvertag@v0 - with: - strategy: conventional-commits -``` - -- [ ] **Step 5: Add an env-var passthrough note** - -After the Strategy table (currently around lines 71–75), add this note: - -````markdown -> **Strategy-specific env vars** (e.g. `SEMVERTAG_BRANCH_PREFIX__MINOR`) -> remain configured on the calling step. The composite action only -> explicitly sets `GITHUB_TOKEN` and `SEMVERTAG_STRATEGY`; every other -> env var on the calling step passes through to the action's run step. -> -> ```yaml -> - uses: modern-python/semvertag@v0 -> env: -> SEMVERTAG_BRANCH_PREFIX__MINOR: '["feat/"]' -> ``` -```` - -- [ ] **Step 6: Add a "Without the composite action" section** - -Insert a new section immediately above "## Troubleshooting": - -````markdown -## Without the composite action - -If your environment can't consume the action — GitHub Enterprise -instances without Marketplace access, security-constrained orgs that -forbid third-party actions, or anyone who wants explicit control over -the uv install step — paste the pure-CLI recipe instead: - -```yaml -jobs: - tag: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - run: pip install --quiet --no-cache-dir 'uv>=0.4,<1' - - run: uvx 'semvertag>=0.3.1,<1' tag - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -``` - -The behavior matches the composite action exactly; only the install -shape differs. Strategy is set via env (`SEMVERTAG_STRATEGY`) or CLI -flag (`--strategy …`). No outputs are produced in this shape — read -the CLI stdout, or invoke `semvertag tag --json` and parse the -envelope yourself. -```` - -- [ ] **Step 7: Update Troubleshooting's `GITHUB_TOKEN` hint** - -The current troubleshooting note (around line 153–155) says: - -> For workflow-scoped tokens, this usually means `GITHUB_TOKEN` was not exported into the step's `env:` — add the `env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}` line shown in the Quick Start. - -Replace that sentence with: - -> When using the composite action, `GITHUB_TOKEN` is set automatically -> from the `token` input (which defaults to `${{ github.token }}`). -> When using the pure-CLI recipe in "Without the composite action", -> add `env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}` to the run -> step. - -- [ ] **Step 8: Verify mkdocs gate** - -Run: `uv run mkdocs build --strict` from the repo root. -Expected: build succeeds with no warnings. - -Common pitfalls if it fails: -- A fenced code block left unclosed during the multi-step edit. Search for orphan ` ``` ` markers. -- A broken intra-repo link. The only links you should have introduced/touched are `[Branch-prefix strategy](../strategies/branch-prefix.md)` and `[Conventional Commits strategy](../strategies/conventional-commits.md)` — both exist; do not modify them. - -- [ ] **Step 9: Commit** - -```bash -git add docs/providers/github.md -git commit -m "docs(providers/github): rewrite Quick Start around the composite action" -``` - ---- - -### Task 7: Create `planning/releases/0.4.0.md` runbook - -**Files:** -- Create: `planning/releases/0.4.0.md` - -- [ ] **Step 1: Write the runbook** - -Create `planning/releases/0.4.0.md` with this content: - -```markdown -# semvertag 0.4.0 — composite GitHub Action - -**Minor release shipping the `action.yml` composite wrapper that has been on the deferred list since 0.3.0.** Users can now write `uses: modern-python/semvertag@v0` instead of pasting an 11-line install-and-run block. No CLI changes; the action wraps the existing `semvertag tag --json` invocation and surfaces `tag` / `bump` / `status` as step outputs. - -If you're already using the documented pure-CLI snippet and don't want to consume third-party actions, you can stay on it — `docs/providers/github.md` preserves it as the "Without the composite action" recipe. - -## What landed - -- `action.yml` at repo root — composite action: `astral-sh/setup-uv@v7`, then `uvx 'semvertag>=0.3.1,<1' tag --json`, then parses the envelope into `tag` / `bump` / `status` step outputs. -- `.github/workflows/tag-major.yml` — fires on release published (non-prerelease) and force-updates the floating `v0` major tag so consumers can pin `@v0` and ride minor bumps automatically. -- Dogfood workflow migration — `.github/workflows/semvertag.yml` now consumes `uses: ./`, exercising the action against the working tree on every push to main. -- `action-smoke` CI job — runs `uses: ./` on every PR and asserts that `status` and `bump` outputs are non-empty. Real tag creation against the GitHub API is covered by the post-merge dogfood run, not the PR-time job (forks can't have `contents: write`). -- README + `docs/providers/github.md` rewrite — Quick Start leads with the action; Outputs section documents the three step outputs; "Without the composite action" preserves the pure-CLI fallback for constrained environments. - -## CLI version floor - -`action.yml` pins `'semvertag>=0.3.1,<1'`. 0.3.1 is the minimum CLI version that ships every feature the action depends on (`--json` envelope, `GITHUB_ACTIONS=true` auto-detection, branch-prefix GitHub merge subject recognition). The floor only needs to bump when a future release breaks CLI contract — not on every minor. - -## Release procedure (maintainer) - -### Step 1: Pre-flight check - -Before tagging: - -- Search https://github.com/marketplace?type=actions for "semvertag" — the listing name `semvertag` must not be taken by another action. - - **If it's taken:** edit `action.yml`'s `name:` field to `'semvertag tag'` (Marketplace permits spaces in display names) and re-PR before continuing. The `uses: modern-python/semvertag@v0` syntax depends on the repo slug, not the display name, so consumer-facing docs do not change. -- Confirm `branding.icon` is one of the Feather icon names GitHub accepts and `branding.color` is one of `white | yellow | blue | green | orange | red | purple | gray-dark`. (We ship `icon: tag`, `color: blue` — both valid.) -- Confirm CI is green on main, including the new `action-smoke` job. - -### Step 2: Cut the v0.4.0 release - -Follow the project's existing release flow: tag, push, create a GitHub release. `publish.yml` fires on release creation and pushes to PyPI via `just publish` (which uses `uv version $GITHUB_REF_NAME` to inject the version at build time). - -### Step 3: Bootstrap the floating `v0` tag (one-time) - -The `tag-major.yml` workflow handles the floating tag on every release from v0.4.1 forward. For v0.4.0 specifically — the first release after the workflow landed — the floating tag does not yet exist and must be bootstrapped manually: - -```sh -git fetch --tags -git tag -fa v0 v0.4.0 -m 'Update v0 to v0.4.0' -git push -f origin v0 -``` - -After this, `uses: modern-python/semvertag@v0` resolves successfully for consumers. - -### Step 4: Publish to Marketplace (manual UI step) - -1. Navigate to https://github.com/modern-python/semvertag/releases/tag/v0.4.0. -2. Click **Edit release**. -3. Check **Publish this Action to the GitHub Marketplace**. -4. Accept the Marketplace Terms of Service if prompted (one-time for the repo). -5. Select **Primary Category:** `Continuous integration`. -6. Select **Secondary Category** (optional): `Utilities`. -7. Save the release. - -### Step 5: Post-publish smoke test - -- Confirm the listing appears at https://github.com/marketplace/actions/semvertag (the slug derives from the `name:` field; if you renamed in Step 1 the slug will differ). -- In a sandbox repo, paste the README snippet (`uses: modern-python/semvertag@v0` after a `actions/checkout@v4` with `fetch-depth: 0`) and confirm the workflow runs end-to-end. - -## Breaking changes - -None. The action is additive; the pure-CLI recipe still works exactly as before (and remains documented as the fallback). - -## See also - -- Spec: `planning/specs/2026-06-08-action-yml-composite-wrapper-design.md` -- Implementation plan: `planning/plans/2026-06-08-action-yml-composite-wrapper.md` -``` - -- [ ] **Step 2: Verify mkdocs still passes** - -Run: `uv run mkdocs build --strict` from the repo root. -Expected: build succeeds with no warnings. - -(The file lives under `planning/`, which is not in the mkdocs nav, so it shouldn't affect the build — but run the gate to confirm nothing else regressed.) - -- [ ] **Step 3: Commit** - -```bash -git add planning/releases/0.4.0.md -git commit -m "docs(release): draft 0.4.0 notes (composite action + tag-major workflow)" -``` - ---- - -### Task 8: Final verification - -**Files:** none modified - -- [ ] **Step 1: Run the full lint gate** - -Run: `just lint-ci` -Expected: exits 0. No Python source files changed, but this confirms the lint configuration still applies cleanly and we haven't accidentally introduced an EOF / formatting issue in the modified YAML/Markdown files via eof-fixer. - -- [ ] **Step 2: Run the test gate (sanity)** - -Run: `just test` -Expected: exits 0 with the existing test suite green. No tests were added or removed; this is a regression sanity check. - -- [ ] **Step 3: Run the docs gate** - -Run: `uv run mkdocs build --strict` -Expected: build succeeds with no warnings. - -- [ ] **Step 4: Inspect git log on the branch** - -Run: `git log --oneline main..HEAD` -Expected: seven commits, one per Task 1–7, in order: - -``` - docs(release): draft 0.4.0 notes (composite action + tag-major workflow) - docs(providers/github): rewrite Quick Start around the composite action - docs: README — advertise the composite action as the GHA recipe - ci: add action-smoke job that exercises the composite action - ci: dogfood the composite action via uses: ./ - ci: add tag-major workflow to maintain floating v0 tag - feat: add action.yml composite GitHub Action -``` - -- [ ] **Step 5: Push the branch and open a PR** - -```bash -git push -u origin feat/action-yml-composite-wrapper -gh pr create --title "feat: add action.yml composite wrapper + supporting workflows" --body "$(cat <<'EOF' -## Summary - -- Add `action.yml` so users can write `uses: modern-python/semvertag@v0` instead of pasting the 11-line install-and-run block. -- Add `.github/workflows/tag-major.yml` to maintain the floating `v0` major tag on each non-prerelease release. -- Migrate the dogfood workflow to consume `uses: ./` and add a PR-time `action-smoke` CI job. -- Rewrite README + `docs/providers/github.md` around the action; preserve the pure-CLI recipe as the "Without the composite action" fallback. -- Add `planning/releases/0.4.0.md` runbook including the manual Marketplace publication procedure. - -## Spec and plan - -- Spec: `planning/specs/2026-06-08-action-yml-composite-wrapper-design.md` -- Plan: `planning/plans/2026-06-08-action-yml-composite-wrapper.md` - -## Test plan - -- [ ] CI green on this PR (lint, pytest matrix, action-smoke). -- [ ] After merge: dogfood workflow on main creates a tag (this PR is on a `feat/` branch, so branch-prefix maps it to a minor bump → v0.4.0). -- [ ] After v0.4.0 release: bootstrap the floating `v0` tag per the runbook. -- [ ] After Marketplace publish: smoke-test `uses: modern-python/semvertag@v0` in a sandbox repo. -EOF -)" -``` - -Expected: PR opens; the GH Actions run starts. Monitor it. - -- [ ] **Step 6: Verify CI passes on the PR** - -Wait for CI to complete on the PR. Expected: -- `lint` job: green (same as before this PR). -- `pytest` job (matrix of 4 Python versions): green (same as before this PR). -- `action-smoke` job: green. The new job will: - - Check out the repo with `fetch-depth: 0`. - - Run `uses: ./` which executes the action.yml: install uv via `setup-uv@v7`, then `uvx 'semvertag>=0.3.1,<1' tag --json`. - - The CLI emits a JSON envelope. On a PR commit (not a merge commit), branch-prefix returns `status=no-bump` because no merge subject matches. `tag` is empty; `bump` is `none`; `status` is `no-bump`. - - The verify step asserts that `status` and `bump` outputs are non-empty. Both are (`"no-bump"` and `"none"`), so the assertion passes. - -If `action-smoke` fails: -- **`Required module unavailable: jq`** — jq missing from the runner. Unexpected on `ubuntu-latest`; if this happens, add `sudo apt-get install -y jq` before the `uses: ./` step in `ci.yml` (and document the dependency in `docs/providers/github.md`). -- **`semvertag` resolution error** — verify PyPI has 0.3.1; run `uvx 'semvertag>=0.3.1,<1' --version` locally to reproduce. -- **`Token rejected: 401`** — the action-smoke job declared `permissions: contents: write` but the runner still issued a read-only token. This can happen on PRs from forks; CI from a branch in the same repo is fine. The job's verify step does not require write to succeed (branch-prefix returns no-bump before any write would occur), so the actual API write path is never exercised in PR CI. - -- [ ] **Step 7: Hand off to the maintainer for the release** - -Once the PR is reviewed and merged, post a comment linking to the runbook (`planning/releases/0.4.0.md`) so the maintainer can follow the manual Marketplace publication procedure. No further plan steps after this — the runbook drives the rest. - ---- - -## Acceptance against the spec - -| Spec acceptance criterion | Task that covers it | -|---|---| -| `action.yml` exists at repo root with the shape in §`action.yml` | Task 1 | -| `.github/workflows/tag-major.yml` exists with the shape in §`tag-major.yml` | Task 2 | -| `.github/workflows/semvertag.yml` consumes `uses: ./` | Task 3 | -| `.github/workflows/ci.yml` has an `action-smoke` job that asserts on `status` and `bump` outputs | Task 4 | -| `README.md` advertises `uses: modern-python/semvertag@v0` | Task 5 | -| `docs/providers/github.md` Quick Start uses the action; pending callout gone; Outputs section exists; "Without the composite action" fallback section preserves the inline-CLI recipe | Task 6 | -| `planning/releases/0.4.0.md` exists with the five-step Marketplace publication procedure | Task 7 | -| CI green on a PR that introduces all of the above; the dogfood workflow runs to completion on main after the PR merges | Task 8 (CI green) + post-merge dogfood (out of plan scope, observed in the release runbook) | diff --git a/planning/changes/2026-06-09.01-mkdocs-github-actions/design.md b/planning/changes/2026-06-09.01-mkdocs-github-actions.md similarity index 100% rename from planning/changes/2026-06-09.01-mkdocs-github-actions/design.md rename to planning/changes/2026-06-09.01-mkdocs-github-actions.md diff --git a/planning/changes/2026-06-09.01-mkdocs-github-actions/plan.md b/planning/changes/2026-06-09.01-mkdocs-github-actions/plan.md deleted file mode 100644 index 264d97e..0000000 --- a/planning/changes/2026-06-09.01-mkdocs-github-actions/plan.md +++ /dev/null @@ -1,434 +0,0 @@ -# mkdocs deploy via GitHub Actions Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Wire up mkdocs auto-deploy on push to `main` (mirroring `modern-di`'s setup) and bump action pins across existing workflows to match. - -**Architecture:** Pure CI/configuration change. Adds a `docs-deploy` Justfile recipe, a `Deploy Docs` GitHub Actions workflow, a `site_url` + `docs/CNAME` for the `semvertag.modern-python.org` custom subdomain, and mechanical action-version bumps in four existing workflow files. No application code changes. No automated tests — verification is observational (local `mkdocs build --strict`, YAML validation, post-merge `workflow_dispatch` trigger). - -**Tech Stack:** GitHub Actions, mkdocs + mkdocs-material (already in `docs/requirements.txt`), `uv`/`uvx`, `just`. - -**Spec:** `planning/specs/2026-06-09-mkdocs-github-actions-design.md` - ---- - -## File Structure - -| Path | Status | Responsibility | -|---|---|---| -| `Justfile` | Modify (append) | Add `docs-deploy` recipe | -| `.github/workflows/docs.yml` | Create | Auto-deploy on push to `main` when docs change | -| `mkdocs.yml` | Modify | Add `site_url` for custom subdomain | -| `docs/CNAME` | Create | One-line file containing the custom subdomain hostname | -| `.github/workflows/ci.yml` | Modify | Bump action pins (3x checkout, 2x setup-just, 2x setup-uv) | -| `.github/workflows/publish.yml` | Modify | Bump action pins (1x each) | -| `.github/workflows/semvertag.yml` | Modify | Bump `checkout@v4` → `@v6` | -| `.github/workflows/tag-major.yml` | Modify | Bump `checkout@v4` → `@v6` | - -## Branch - -Work happens on a single feature branch. Per `CLAUDE.md` + the repo's branch-prefix strategy (`SEMVERTAG_BRANCH_PREFIX__MINOR: '["feat/"]'`), `feat/...` maps to a minor bump on merge — which is what this change deserves. - -Branch name: `feat/docs-github-actions` - -Run before Task 1: - -```bash -git checkout -b feat/docs-github-actions -``` - ---- - -### Task 1: Add `docs-deploy` recipe to `Justfile` - -**Files:** -- Modify: `Justfile` (append at end) - -- [ ] **Step 1: Append the recipe to `Justfile`** - -Open `Justfile` and append at the end (after the existing `publish` recipe). Keep the foot-gun comment verbatim — it's load-bearing for anyone who runs this locally: - -```make - -# Force-pushes built site to gh-pages; CI runs this on push to main. -# Manual invocation from a stale checkout will roll the live site back. -docs-deploy: - uvx --with-requirements docs/requirements.txt mkdocs gh-deploy --force -``` - -Note the leading blank line — `Justfile` recipes are separated by blank lines. - -- [ ] **Step 2: Verify just sees the recipe** - -Run: `just --list` - -Expected output includes a line like: - -``` - docs-deploy # Force-pushes built site to gh-pages; CI runs this on push to main. -``` - -If the recipe doesn't appear, check that the indentation under `docs-deploy:` is a literal tab (Justfile, like Make, requires tabs for recipe bodies). - -- [ ] **Step 3: Verify the diff** - -Run: `git diff Justfile` - -Expected: only the addition of the `docs-deploy` recipe (3 added lines: comment, comment, recipe header) + 1 indented body line. No other changes. - -- [ ] **Step 4: Commit** - -```bash -git add Justfile -git commit -m "ci: add docs-deploy recipe to Justfile" -``` - ---- - -### Task 2: Add `.github/workflows/docs.yml` - -**Files:** -- Create: `.github/workflows/docs.yml` - -- [ ] **Step 1: Create the workflow file** - -Write `.github/workflows/docs.yml` with this exact content: - -```yaml -name: Deploy Docs - -on: - push: - branches: [main] - paths: - - "docs/**" - - "mkdocs.yml" - - ".github/workflows/docs.yml" - workflow_dispatch: - -concurrency: - group: docs-deploy - cancel-in-progress: true - -permissions: - contents: write - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - uses: extractions/setup-just@v4 - - uses: astral-sh/setup-uv@v8.2.0 - - run: just docs-deploy -``` - -- [ ] **Step 2: Validate YAML syntax** - -Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/docs.yml'))"` - -Expected: no output, exit code 0. Any YAML parse error means a typo (most commonly: bad indentation under `on:`, or accidentally tabs instead of spaces — workflows must use spaces). - -- [ ] **Step 3: Sanity-check the file structurally** - -Run: `git diff --stat .github/workflows/docs.yml` - -Expected: shows the file as added with ~25 lines. - -- [ ] **Step 4: Commit** - -```bash -git add .github/workflows/docs.yml -git commit -m "ci(docs): add Deploy Docs workflow" -``` - ---- - -### Task 3: Add `site_url` to `mkdocs.yml` and create `docs/CNAME` - -**Files:** -- Modify: `mkdocs.yml` (insert after line 1, `site_name: semvertag`) -- Create: `docs/CNAME` - -- [ ] **Step 1: Add `site_url` to `mkdocs.yml`** - -Open `mkdocs.yml`. After the `site_name: semvertag` line (current line 1), insert: - -```yaml -site_url: https://semvertag.modern-python.org -``` - -The top of the file should now read: - -```yaml -site_name: semvertag -site_url: https://semvertag.modern-python.org -repo_url: https://github.com/modern-python/semvertag -docs_dir: docs -edit_uri: edit/main/docs/ -``` - -- [ ] **Step 2: Create `docs/CNAME`** - -Create `docs/CNAME` with this single line (no trailing blank line beyond the single newline at EOF — `eof-fixer` will normalize, but only if there's exactly one): - -``` -semvertag.modern-python.org -``` - -- [ ] **Step 3: Verify the docs still build locally** - -Run: `uvx --with-requirements docs/requirements.txt mkdocs build --strict 2>&1 | tail -20` - -Expected: ends with a line like `INFO - Documentation built in . seconds`. No `WARNING` or `ERROR` lines. - -If `--strict` flags a warning about `site_url` not matching `repo_url`, ignore — that's expected; they're intentionally different. - -If `--strict` fails for an unrelated reason (e.g. a broken link in an existing page), that's a pre-existing issue out of scope — stash the local build and proceed; surface it in the final review. - -Clean up the build artifact: - -```bash -rm -rf site/ -``` - -- [ ] **Step 4: Verify the diff** - -Run: `git status --short docs/ mkdocs.yml` - -Expected: - -``` - M mkdocs.yml -?? docs/CNAME -``` - -Run: `git diff mkdocs.yml` - -Expected: a single line addition — the `site_url:` line under `site_name:`. - -- [ ] **Step 5: Commit** - -```bash -git add mkdocs.yml docs/CNAME -git commit -m "docs: configure custom subdomain semvertag.modern-python.org" -``` - ---- - -### Task 4: Bump action pins across existing workflows - -This task touches four files in one commit. The bumps are mechanical and consistent; bundling them is more readable than four single-line commits. - -**Files:** -- Modify: `.github/workflows/ci.yml` (3x checkout, 2x setup-just, 2x setup-uv) -- Modify: `.github/workflows/publish.yml` (1x each) -- Modify: `.github/workflows/semvertag.yml` (1x checkout) -- Modify: `.github/workflows/tag-major.yml` (1x checkout) - -- [ ] **Step 1: Bump pins in `ci.yml`** - -In `.github/workflows/ci.yml`, replace every occurrence: -- `uses: actions/checkout@v4` → `uses: actions/checkout@v6` (3 occurrences: lint job, pytest job, action-smoke job) -- `uses: extractions/setup-just@v2` → `uses: extractions/setup-just@v4` (2 occurrences: lint job, pytest job) -- `uses: astral-sh/setup-uv@v3` → `uses: astral-sh/setup-uv@v8.2.0` (2 occurrences: lint job, pytest job) - -A safe way to do this is with `sed`: - -```bash -sed -i.bak \ - -e 's|actions/checkout@v4|actions/checkout@v6|g' \ - -e 's|extractions/setup-just@v2|extractions/setup-just@v4|g' \ - -e 's|astral-sh/setup-uv@v3|astral-sh/setup-uv@v8.2.0|g' \ - .github/workflows/ci.yml && rm .github/workflows/ci.yml.bak -``` - -- [ ] **Step 2: Verify `ci.yml` diff** - -Run: `git diff --stat .github/workflows/ci.yml` - -Expected: `7 +++++++ 7 -------` (3 + 2 + 2 = 7 lines changed). - -Run: `git diff .github/workflows/ci.yml | grep -c '^+[^+]'` — should print `7`. -Run: `git diff .github/workflows/ci.yml | grep -c '^-[^-]'` — should print `7`. - -Run: `grep -E '@(v4|v2|v3)$' .github/workflows/ci.yml` — should print nothing (no old pins left). - -- [ ] **Step 3: Bump pins in `publish.yml`** - -Same sed pattern: - -```bash -sed -i.bak \ - -e 's|actions/checkout@v4|actions/checkout@v6|g' \ - -e 's|extractions/setup-just@v2|extractions/setup-just@v4|g' \ - -e 's|astral-sh/setup-uv@v3|astral-sh/setup-uv@v8.2.0|g' \ - .github/workflows/publish.yml && rm .github/workflows/publish.yml.bak -``` - -- [ ] **Step 4: Verify `publish.yml` diff** - -Run: `git diff --stat .github/workflows/publish.yml` - -Expected: `3 +++ 3 ---`. - -- [ ] **Step 5: Bump pins in `semvertag.yml`** - -Only `checkout` needs bumping here (no `setup-just`/`setup-uv` in this file): - -```bash -sed -i.bak 's|actions/checkout@v4|actions/checkout@v6|g' \ - .github/workflows/semvertag.yml && rm .github/workflows/semvertag.yml.bak -``` - -- [ ] **Step 6: Verify `semvertag.yml` diff** - -Run: `git diff --stat .github/workflows/semvertag.yml` - -Expected: `1 + 1 -`. - -- [ ] **Step 7: Bump pins in `tag-major.yml`** - -```bash -sed -i.bak 's|actions/checkout@v4|actions/checkout@v6|g' \ - .github/workflows/tag-major.yml && rm .github/workflows/tag-major.yml.bak -``` - -- [ ] **Step 8: Verify `tag-major.yml` diff** - -Run: `git diff --stat .github/workflows/tag-major.yml` - -Expected: `1 + 1 -`. - -- [ ] **Step 9: Verify no stale `.bak` files were left behind** - -Run: `find .github/workflows -name '*.bak'` - -Expected: no output. If any `.bak` file is listed, delete it: `rm .github/workflows/*.bak`. - -- [ ] **Step 10: Validate every workflow file still parses as YAML** - -Run: - -```bash -for f in .github/workflows/*.yml; do - python3 -c "import sys, yaml; yaml.safe_load(open('$f'))" || { echo "FAIL: $f"; exit 1; } -done -echo "all workflows parse OK" -``` - -Expected: `all workflows parse OK`. - -- [ ] **Step 11: Commit** - -```bash -git add .github/workflows/ci.yml .github/workflows/publish.yml \ - .github/workflows/semvertag.yml .github/workflows/tag-major.yml -git commit -m "ci: bump action pins (checkout v6, setup-just v4, setup-uv v8.2.0)" -``` - ---- - -### Task 5: Final verification and PR - -- [ ] **Step 1: Skim the full branch diff** - -Run: `git log main..HEAD --oneline` - -Expected: four commits in order: - -``` - ci: bump action pins (checkout v6, setup-just v4, setup-uv v8.2.0) - docs: configure custom subdomain semvertag.modern-python.org - ci(docs): add Deploy Docs workflow - ci: add docs-deploy recipe to Justfile -``` - -Run: `git diff main..HEAD --stat` - -Expected: 8 files touched (Justfile, mkdocs.yml, docs/CNAME, and the 5 workflow files). - -- [ ] **Step 2: Run the repo's lint-ci gate** - -Run: `just lint-ci` - -Expected: passes. This validates `eof-fixer`, `ruff format`, `ruff check`, and `ty check` — none of which should be affected by this change, but they're the standard gate. - -If `eof-fixer` complains about `docs/CNAME` missing/extra trailing newline, that's the file-format expectation; `eof-fixer` will tell you to either run `just lint` (which auto-fixes) or fix manually. Fix and re-stage: - -```bash -just lint # auto-fix -git add docs/CNAME -git commit --amend --no-edit # only if this is the most recent commit on the branch -``` - -If the file with the bad EOF is not in the most recent commit, make a tiny follow-up commit rather than amending an earlier commit: - -```bash -git add docs/CNAME -git commit -m "docs: fix CNAME trailing newline" -``` - -- [ ] **Step 3: Push the branch** - -```bash -git push -u origin feat/docs-github-actions -``` - -- [ ] **Step 4: Open the PR** - -Run: - -```bash -gh pr create --title "ci: deploy mkdocs via GitHub Actions" --body "$(cat <<'EOF' -## Summary - -- Adds a `Deploy Docs` workflow that auto-deploys `mkdocs` to `gh-pages` on push to `main` when `docs/**` or `mkdocs.yml` changes. -- Adds a `docs-deploy` recipe to `Justfile` (`uvx mkdocs gh-deploy --force`), mirroring `modern-di`. -- Configures the custom subdomain `semvertag.modern-python.org` via `site_url` in `mkdocs.yml` and `docs/CNAME`. -- Bumps action pins across `ci.yml`, `publish.yml`, `semvertag.yml`, `tag-major.yml` to match `modern-di` parity: `checkout@v6`, `setup-just@v4`, `setup-uv@v8.2.0`. - -Spec: `planning/specs/2026-06-09-mkdocs-github-actions-design.md` - -## Test plan - -- [ ] CI lint job passes under bumped action pins -- [ ] CI pytest matrix passes under bumped action pins -- [ ] `action-smoke` job passes under `checkout@v6` (this is the one job where the bump could surface a difference — composite action exercised via `uses: ./`) -- [ ] After merge: trigger `Deploy Docs` via `workflow_dispatch` from the Actions UI — workflow succeeds, creates `gh-pages` branch -- [ ] After merge: enable GitHub Pages on the `gh-pages` branch in repo Settings → Pages -- [ ] After merge: configure DNS for `semvertag.modern-python.org` (CNAME → `modern-python.github.io.`) and verify the site serves - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -Expected: PR URL printed. Return it to the user. - -- [ ] **Step 5: Confirm the PR's own CI run passes** - -Watch the PR's checks. Two jobs are the bump's load-bearing tests: - -- `lint` — sanity-check that `setup-just@v4` + `setup-uv@v8.2.0` still expose the same interfaces -- `action-smoke` — the one job where `checkout@v6` could surface a real difference, since it exercises the composite action via `uses: ./` - -If `action-smoke` fails *only* due to the `checkout@v6` bump (and not a pre-existing flake), the rollback per the spec's Risks section is: revert that one pin in `ci.yml` (the action-smoke job's `actions/checkout@v6` back to `@v4`) and keep the rest. Commit and push. - -If everything passes, the PR is ready for review. - ---- - -## Post-merge follow-ups (NOT part of this plan) - -These steps are outside the code change and the user (maintainer) will run them after the PR merges. Listed here so they're not forgotten: - -1. From the Actions UI, manually trigger the `Deploy Docs` workflow via `workflow_dispatch`. First run creates the `gh-pages` branch. -2. Repo Settings → Pages: set source = "Deploy from a branch", branch = `gh-pages`, folder = `/ (root)`. -3. DNS: add CNAME record `semvertag.modern-python.org` → `modern-python.github.io.` in the `modern-python.org` zone. -4. Verify `https://semvertag.modern-python.org` serves the docs. -5. Repo Settings → Pages: enable "Enforce HTTPS" once the cert provisions. - -If DNS for `modern-python.org` can't be configured, the fallback is to revert the `docs/CNAME` addition + the `site_url` line; the site will then serve at `https://modern-python.github.io/semvertag/`. diff --git a/planning/changes/2026-06-09.02-dry-run-flag/design.md b/planning/changes/2026-06-09.02-dry-run-flag.md similarity index 100% rename from planning/changes/2026-06-09.02-dry-run-flag/design.md rename to planning/changes/2026-06-09.02-dry-run-flag.md diff --git a/planning/changes/2026-06-09.02-dry-run-flag/plan.md b/planning/changes/2026-06-09.02-dry-run-flag/plan.md deleted file mode 100644 index c30a8d5..0000000 --- a/planning/changes/2026-06-09.02-dry-run-flag/plan.md +++ /dev/null @@ -1,510 +0,0 @@ -# semvertag `--dry-run` Flag Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `--dry-run` flag to `semvertag tag` that computes the bump and emits an informative result without calling `provider.create_tag`. - -**Architecture:** One CLI flag, one new kwarg on the use case, one new branch in `_use_case.__call__` before `create_tag`, one new branch in `_output._format_result` for the human-readable rendering. New `dry_run` status value joins the existing `RunResult.status` strings. No type widening (status is already plain `str`). Strategy code is untouched; the dry-run skip sits in the use case so both `branch-prefix` and `conventional-commits` benefit. - -**Tech Stack:** Python 3.11+, typer, pytest, `--cov=semvertag --cov-branch --fail_under=100` (already configured in `pyproject.toml`). - -**Spec:** `planning/specs/2026-06-09-dry-run-flag-design.md` - ---- - -## File Structure - -| Path | Status | Responsibility | -|---|---|---| -| `semvertag/_use_case.py` | Modify | Add `dry_run: bool = False` kwarg to `__call__`; skip `create_tag` and emit `status="dry_run"` when true and a bump would have been created | -| `semvertag/__main__.py` | Modify | Add `--dry-run` Typer option to `_tag_command`; pass through to `use_case(...)` | -| `semvertag/_output.py` | Modify | Add `dry_run` branch in `_format_result` for human-readable rendering | -| `tests/unit/test_use_case.py` | Modify | Add unit tests for the dry-run path (status, output, no `create_tag` call) | -| `tests/integration/test_cli_main_verb.py` | Modify | Add integration test exercising `semvertag tag --dry-run --json` against a fake transport; assert no POST to `/repository/tags` | -| `tests/unit/test_output_rich.py` | Modify | Add a test for the new `_format_result` branch | - -## Branch - -Already on `feat/dry-run-flag` (branched from `origin/main`). Spec commit `9a2d897` is the only commit so far. - ---- - -### Task 1: Use case dry-run kwarg - -**Files:** -- Modify: `semvertag/_use_case.py` — `SemvertagUseCase.__call__` -- Test: `tests/unit/test_use_case.py` - -- [ ] **Step 1: Write a failing test for the dry-run path** - -Append to `tests/unit/test_use_case.py` (after the last existing test): - -```python -def test_dry_run_skips_create_tag_and_emits_dry_run_status() -> None: - use_case, provider, output = _make_use_case() - - result: typing.Final = use_case(output=output, dry_run=True) - - assert result.status == "dry_run" - assert result.tag == _EXPECTED_NEW_TAG - assert result.bump == "minor" - assert result.strategy == _BRANCH_PREFIX_STRATEGY - assert result.commit == _LATEST_SHA - assert result.reason is None - assert provider.create_tag_calls == [] - assert output.emitted_results == [result] - - -def test_dry_run_does_not_emit_creating_tag_progress() -> None: - use_case, _provider, output = _make_use_case() - - use_case(output=output, dry_run=True) - - assert not any("Creating tag" in msg for msg in output.progress_messages) - - -def test_dry_run_does_not_affect_already_tagged_path() -> None: - use_case, provider, output = _make_use_case( - tags=[Tag(name=_LATEST_TAG_NAME, commit_sha=_LATEST_SHA)], - ) - - result: typing.Final = use_case(output=output, dry_run=True) - - assert result.status == "already_tagged" - assert provider.create_tag_calls == [] - - -def test_dry_run_does_not_affect_no_tags_path() -> None: - use_case, provider, output = _make_use_case(tags=[]) - - result: typing.Final = use_case(output=output, dry_run=True) - - assert result.status == "no_tags" - assert provider.create_tag_calls == [] - - -def test_dry_run_does_not_affect_strategy_no_bump_path() -> None: - use_case, provider, output = _make_use_case( - commit_message=_NON_MERGE_MESSAGE, - bump=Bump.NONE, - ) - - result: typing.Final = use_case(output=output, dry_run=True) - - assert result.status == "no_merge_commit" - assert provider.create_tag_calls == [] - - -def test_dry_run_false_default_creates_tag() -> None: - use_case, provider, output = _make_use_case() - - result: typing.Final = use_case(output=output) - - assert result.status == "created" - assert provider.create_tag_calls == [(_EXPECTED_NEW_TAG, _LATEST_SHA)] -``` - -The last test (`test_dry_run_false_default_creates_tag`) is intentionally redundant with `test_creates_tag_with_minor_bump_when_feature_merge_against_prior_semver_tag` — keep it anyway. It serves as the regression baseline that the default kwarg value didn't accidentally flip. - -- [ ] **Step 2: Run the new tests and confirm they fail** - -Run: `uv run pytest tests/unit/test_use_case.py -v -k dry_run` - -Expected: all 6 new `*_dry_run_*` tests fail with `TypeError: __call__() got an unexpected keyword argument 'dry_run'` (or similar). The default-creates test passes only because `dry_run` defaults aren't yet involved. - -Actually — `test_dry_run_false_default_creates_tag` will PASS because it doesn't pass `dry_run`. The others will FAIL. That's fine; this gives us a confirmed-red baseline. - -- [ ] **Step 3: Modify `SemvertagUseCase.__call__` to accept and honor `dry_run`** - -Open `semvertag/_use_case.py`. Replace the `__call__` method (lines 21-72) with this version: - -```python -def __call__(self, *, output: Output, dry_run: bool = False) -> RunResult: - output.progress(f"Detected strategy: {self.strategy.name}") - output.progress("Fetching latest commit on default branch...") - commit: typing.Final = self.provider.get_latest_commit_on_default_branch() - - output.progress("Fetching tag history...") - tags: typing.Final = self.provider.list_tags() - latest_semver_tag: typing.Final = _pick_latest_semver_tag(tags) - - if latest_semver_tag is None: - return self._emit( - output=output, - bump=Bump.NONE, - status="no_tags", - tag=None, - commit=commit.sha, - reason=_NO_TAGS_REASON, - ) - - if latest_semver_tag.commit_sha == commit.sha: - return self._emit( - output=output, - bump=Bump.NONE, - status="already_tagged", - tag=latest_semver_tag.name, - commit=commit.sha, - reason=_ALREADY_TAGGED_REASON, - ) - - output.progress("Computing bump...") - bump: typing.Final = self.strategy.decide(commit) - if bump is Bump.NONE: - return self._emit( - output=output, - bump=Bump.NONE, - status=self.strategy.no_bump_status, - tag=None, - commit=commit.sha, - reason=self.strategy.no_bump_reason, - ) - - new_version: typing.Final = _compute_new_version(latest_semver_tag, bump) - if dry_run: - return self._emit( - output=output, - bump=bump, - status="dry_run", - tag=new_version, - commit=commit.sha, - reason=None, - ) - - output.progress(f"Creating tag {new_version}...") - self.provider.create_tag(name=new_version, commit_sha=commit.sha) - return self._emit( - output=output, - bump=bump, - status="created", - tag=new_version, - commit=commit.sha, - reason=None, - ) -``` - -The only changes from the existing method: -1. New kwarg `dry_run: bool = False` in the signature. -2. New `if dry_run:` block immediately after `new_version` is computed and before `output.progress("Creating tag ...")` is called. - -- [ ] **Step 4: Run the new tests and confirm they pass** - -Run: `uv run pytest tests/unit/test_use_case.py -v -k dry_run` - -Expected: all 6 tests PASS. - -- [ ] **Step 5: Run the full use case test file** - -Run: `uv run pytest tests/unit/test_use_case.py -v` - -Expected: all tests pass (existing ones unchanged, new ones added). - -- [ ] **Step 6: Run the full unit test suite under coverage** - -Run: `just test tests/unit/` - -Expected: passes; coverage gate intact (`fail_under = 100` is enforced in `pyproject.toml`). The new dry-run branch must be fully covered by the new tests. - -If coverage fails on the new `if dry_run:` block, ensure `test_dry_run_skips_create_tag_and_emits_dry_run_status` is the test exercising it. - -- [ ] **Step 7: Commit** - -```bash -git add semvertag/_use_case.py tests/unit/test_use_case.py -git commit -m "feat(use-case): add dry_run kwarg that skips provider.create_tag" -``` - ---- - -### Task 2: CLI `--dry-run` option - -**Files:** -- Modify: `semvertag/__main__.py` — `_tag_command` -- Test: `tests/integration/test_cli_main_verb.py` - -- [ ] **Step 1: Write a failing integration test** - -Append to `tests/integration/test_cli_main_verb.py`: - -```python -def test_dry_run_skips_post_to_tags_endpoint_and_emits_dry_run_status( - cli_env: None, # noqa: ARG001 - install_mock_transport: collections.abc.Callable[[HandlerCallable], None], - cli_runner: CliRunner, -) -> None: - recorded: list[httpx2.Request] = [] - install_mock_transport(_make_recording_handler(merge_commit_handler(), recorded)) - - result: typing.Final = cli_runner.invoke(MAIN_APP, ["tag", "--dry-run", "--json"]) - - assert result.exit_code == 0, result.output + result.stderr - lines: typing.Final = [line for line in result.stdout.splitlines() if line.strip()] - assert len(lines) == 1, f"expected one JSON line, got: {lines!r}" - payload: typing.Final = json_module.loads(lines[0]) - assert payload["status"] == "dry_run" - assert payload["tag"] == _EXPECTED_NEW_TAG - assert payload["bump"] == "minor" - posted: typing.Final = [r for r in recorded if r.method == "POST" and r.url.path == _TAGS_POST_PATH] - assert posted == [], f"dry-run must not POST to tags endpoint; got: {posted}" -``` - -This reuses the existing `_make_recording_handler` helper and `merge_commit_handler` fixture (both already defined at the top of the file). - -- [ ] **Step 2: Run the new test and confirm it fails** - -Run: `uv run pytest tests/integration/test_cli_main_verb.py -v -k dry_run` - -Expected: FAIL. Likely error is one of: -- Typer error: `No such option: --dry-run` (CLI rejects the flag because it's not defined yet) -- Or status mismatch if Typer silently absorbs unknown options (it shouldn't, but log shape varies). - -- [ ] **Step 3: Add the `--dry-run` Typer option** - -Open `semvertag/__main__.py`. Find `_tag_command` (around line 168-192). Replace it with: - -```python -@MAIN_APP.command("tag") -def _tag_command( - ctx: typer.Context, - quiet: typing.Annotated[ - bool, - typer.Option("--quiet", help="Suppress progress narrative; final result still emits."), - ] = False, - json_flag: typing.Annotated[ - bool, - typer.Option("--json", help="Emit a JSON envelope on stdout instead of human-readable output."), - ] = False, - dry_run: typing.Annotated[ - bool, - typer.Option("--dry-run", help="Compute the bump and print the result, but do not push a tag."), - ] = False, -) -> None: - output: Output = build_json_output(quiet=quiet) if json_flag else build_rich_output(quiet=quiet) - try: - use_case = _resolve_use_case(ctx=ctx) - use_case(output=output, dry_run=dry_run) - except ImportError as exc: - err = ConfigError(f"Required module unavailable: {exc}.") - output.error(str(err)) - raise typer.Exit(code=err.exit_code) from exc - except SemvertagError as err: - output.error(str(err)) - raise typer.Exit(code=err.exit_code) from err - except BrokenPipeError as exc: - raise typer.Exit(code=0) from exc -``` - -Only two changes: -1. New `dry_run` parameter with the `typer.Option("--dry-run", ...)` annotation. -2. Pass `dry_run=dry_run` to the `use_case(...)` call. - -Everything else (`quiet`, `json_flag`, the try/except, all error paths) is unchanged. - -- [ ] **Step 4: Run the new integration test and confirm it passes** - -Run: `uv run pytest tests/integration/test_cli_main_verb.py -v -k dry_run` - -Expected: PASS. - -- [ ] **Step 5: Run the full integration suite** - -Run: `uv run pytest tests/integration/ -v` - -Expected: all integration tests pass (the new one + the existing ones unchanged). - -- [ ] **Step 6: Commit** - -```bash -git add semvertag/__main__.py tests/integration/test_cli_main_verb.py -git commit -m "feat(cli): add --dry-run flag to tag command" -``` - ---- - -### Task 3: Human-readable rendering for `dry_run` - -**Files:** -- Modify: `semvertag/_output.py` — `_format_result` -- Test: `tests/unit/test_output_rich.py` - -- [ ] **Step 1: Write a failing test** - -Append to `tests/unit/test_output_rich.py`: - -```python -def test_emit_renders_dry_run_with_would_create_phrasing() -> None: - output, stdout_buf, _stderr = _make_pair() - dry_run_result: typing.Final = RunResult( - strategy="branch-prefix", - bump="minor", - status="dry_run", - tag="1.2.0", - commit="a2b4d12abc1234567890", - reason=None, - ) - output.emit(dry_run_result) - stdout_text: typing.Final = stdout_buf.getvalue() - assert "Dry run" in stdout_text - assert "would create tag 1.2.0" in stdout_text - assert "a2b4d12" in stdout_text - assert "branch-prefix" in stdout_text - assert "minor" in stdout_text -``` - -- [ ] **Step 2: Run the new test and confirm it fails** - -Run: `uv run pytest tests/unit/test_output_rich.py -v -k dry_run` - -Expected: FAIL. The current `_format_result` falls through to the catch-all `"No tag created (status: dry_run, ...)"`, so `"Dry run"` and `"would create tag 1.2.0"` won't appear. - -- [ ] **Step 3: Add the `dry_run` branch to `_format_result`** - -Open `semvertag/_output.py`. Find `_format_result` (lines 56-63). Replace it with: - -```python -def _format_result(result: RunResult) -> str: - short: typing.Final = (result.commit or "")[:_COMMIT_SHORT_LEN] - if result.status == "created": - return f"Created tag {result.tag} on commit {short} (strategy: {result.strategy}, bump: {result.bump})" - if result.status == "dry_run": - return f"Dry run: would create tag {result.tag} on commit {short} (strategy: {result.strategy}, bump: {result.bump})" - return ( - f"No tag created (status: {result.status}, strategy: {result.strategy}, " - f"bump: {result.bump}, reason: {result.reason})" - ) -``` - -`short` is hoisted above the if-chain so it's declared once with `typing.Final`, no duplicate-Final conflict for `ty`. The no-tag fallback branch doesn't use `short` — the wasted 2-byte slice is fine; clarity beats micro-optimization here. - -- [ ] **Step 4: Run the new test and confirm it passes** - -Run: `uv run pytest tests/unit/test_output_rich.py -v -k dry_run` - -Expected: PASS. - -- [ ] **Step 5: Run the full output test suite** - -Run: `uv run pytest tests/unit/test_output_rich.py tests/unit/test_output_json.py -v` - -Expected: all tests pass. The JSON output test file is unaffected (JSON serialization goes through `dataclasses.asdict`, not `_format_result`). - -- [ ] **Step 6: Commit** - -```bash -git add semvertag/_output.py tests/unit/test_output_rich.py -git commit -m "feat(output): render dry_run status with 'would create tag' phrasing" -``` - ---- - -### Task 4: Final verification, lint, push, open PR - -- [ ] **Step 1: Run the full test suite** - -Run: `just test` - -Expected: all tests pass; 100% branch coverage; no `fail_under` violation. - -If coverage fails, find the uncovered branch: -- `--cov-report term-missing` (already configured) prints missing line numbers. -- Most likely culprit: the `dry_run` branch in `_use_case.py` if its dedicated test was skipped, or the `dry_run` branch in `_format_result` if its dedicated test was skipped. - -- [ ] **Step 2: Run lint** - -Run: `just lint-ci` - -Expected: passes (eof-fixer, ruff format, ruff check, ty check). - -If `ty` complains about the second `short` declaration in `_format_result` not being `typing.Final` (or being shadowed), drop `typing.Final` from the first declaration too. The intent is just a local variable; the `Final` annotation isn't load-bearing. - -If `ty` complains about something else, fix the actual issue. Do NOT add `ty: ignore` comments unless the issue is a known false positive. - -- [ ] **Step 3: Skim the full branch diff** - -Run: `git log origin/main..HEAD --oneline` - -Expected: four commits on `feat/dry-run-flag`, in this order (newest last): - -``` - docs: add spec for semvertag --dry-run flag - feat(use-case): add dry_run kwarg that skips provider.create_tag - feat(cli): add --dry-run flag to tag command - feat(output): render dry_run status with 'would create tag' phrasing -``` - -Run: `git diff origin/main..HEAD --stat` - -Expected: 6 files touched (the spec + the 5 production-and-test files from the File Structure table; the spec lives on its own). - -- [ ] **Step 4: Push the branch** - -```bash -git push -u origin feat/dry-run-flag -``` - -- [ ] **Step 5: Open the PR** - -Run: - -```bash -gh pr create --title "feat: add semvertag tag --dry-run flag" --body "$(cat <<'EOF' -## Summary - -- Adds \`--dry-run\` to \`semvertag tag\`. When set, the use case computes the bump and emits an informative result (\`status="dry_run"\` with \`bump\` and \`tag\` populated) but never calls \`provider.create_tag\`. -- New status \`dry_run\` joins the existing well-known \`RunResult.status\` values (\`created\`, \`no_tags\`, \`already_tagged\`, strategy-specific \`no_*_commit\`). \`RunResult.status\` is already plain \`str\`; no type widening. -- Human-readable output renders dry-run as: \`Dry run: would create tag {tag} on commit {short} (strategy: {strategy}, bump: {bump})\`. - -Spec: \`planning/specs/2026-06-09-dry-run-flag-design.md\`. - -## Motivation - -PR #14 surfaced a structural issue with \`action-smoke\`: it ran semvertag with \`contents: write\` against the real \`main\`, and when main's HEAD wasn't already tagged, semvertag pushed a real release tag (\`0.4.1\`) from a PR's CI run. \`--dry-run\` is the first half of the fix; the follow-up PR (PR B) will land an \`action.yml\` \`dry-run\` input, update \`action-smoke\` to use it, and bump the version floor. - -## Test plan - -- [x] Unit tests for the dry-run use-case path (status, output, no \`create_tag\` call) -- [x] Unit tests that dry-run does NOT affect the other early-return paths (\`already_tagged\`, \`no_tags\`, strategy \`no_*_commit\`) -- [x] Integration test for \`semvertag tag --dry-run --json\` against a fake GitLab transport — asserts no POST to \`/repository/tags\` -- [x] Unit test for the new \`_format_result\` \`dry_run\` branch -- [x] 100% branch coverage (\`fail_under = 100\` in \`pyproject.toml\`) -- [x] \`just lint-ci\` passes - -## Post-merge follow-ups (NOT in this PR) - -1. Cut release \`0.5.0\` (this is a minor — new CLI feature) via the dogfood workflow. \`tag-major.yml\` will float \`v0\`. \`publish.yml\` will push to PyPI on the GitHub release. -2. Open PR B: \`action.yml\` adds a \`dry-run\` input; \`ci.yml\`'s \`action-smoke\` job sets \`with: { dry-run: true }\`, drops \`permissions: contents: write\`, and switches its assertion to "outputs are well-formed AND status != created". Bumps semvertag floor in \`action.yml\` from \`>=0.3.1,<1\` to \`>=0.5.0,<1\`. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -Expected: PR URL printed. Capture and report it. - -If `gh` complains about the heredoc backtick escaping, write the body to a tempfile first: - -```bash -cat > /tmp/pr-body.md <<'EOF' -... same body, but without the leading \ on each backtick ... -EOF -gh pr create --title "feat: add semvertag tag --dry-run flag" --body-file /tmp/pr-body.md -``` - -- [ ] **Step 6: Report the PR URL** - -Report the URL verbatim from `gh pr create` output. Do not fabricate it. - ---- - -## Post-merge follow-ups (NOT part of this plan) - -Listed here so they're not forgotten: - -1. **Release 0.5.0.** After this PR merges, the dogfood workflow (`semvertag.yml`) will auto-tag the merge commit (likely `0.4.x` patch under branch-prefix; since this is a `feat/` merge, it'll be `0.5.0` minor). Manually create a GitHub release pointing at the tag — that fires `publish.yml` (PyPI) and `tag-major.yml` (floats `v0`). -2. **Open PR B.** Once `0.5.0` is on PyPI, open the follow-up PR with: - - `action.yml`: new `dry-run` input (default `'false'`), pass `--dry-run` to the CLI conditionally, bump version floor from `>=0.3.1,<1` to `>=0.5.0,<1`. - - `ci.yml`'s `action-smoke` job: drop `permissions: contents: write`, set `with: { dry-run: true }` on `uses: ./`, weaken the assertion to "bump in {none,patch,minor,major} AND status in {created,no-bump} AND status != created". - - Update the action-smoke comment block in `ci.yml` to reference dry-run as the mechanism (not the "main HEAD is already tagged" assumption). - - Optionally update `docs/providers/github.md` with a "preview the next bump" usage example. - -If `0.5.0` doesn't ship for any reason, PR B can't be tested locally and shouldn't be opened — the floor bump would point at a non-existent version. diff --git a/planning/changes/2026-06-09.03-action-yml-dry-run/design.md b/planning/changes/2026-06-09.03-action-yml-dry-run.md similarity index 100% rename from planning/changes/2026-06-09.03-action-yml-dry-run/design.md rename to planning/changes/2026-06-09.03-action-yml-dry-run.md diff --git a/planning/changes/2026-06-09.03-action-yml-dry-run/plan.md b/planning/changes/2026-06-09.03-action-yml-dry-run/plan.md deleted file mode 100644 index e28c500..0000000 --- a/planning/changes/2026-06-09.03-action-yml-dry-run/plan.md +++ /dev/null @@ -1,460 +0,0 @@ -# action.yml `dry-run` + side-effect-free action-smoke Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Expose `--dry-run` through the composite action, switch the `action-smoke` job to use it, and drop `contents: write` from that job so it cannot mint real tags even under regression. - -**Architecture:** Pure YAML + Markdown + small git plumbing. No Python source changes — the CLI side landed in PR #15 (semvertag 0.5.0, now on PyPI). action.yml gains a `dry-run` boolean input that shell-conditionally appends `--dry-run` to the `uvx semvertag tag --json` call. The composite's existing status-normalization case block already routes `dry_run` to the public `no-bump` value (PR A confirmed this is the default-arm behavior). action-smoke passes `dry-run: 'true'`, drops `permissions: contents: write`, and reduces its assertion to `status == no-bump` — the strongest signal that the dry-run wiring is intact (under dry-run, `created` is structurally unreachable; a `created` would prove a regression). - -**Tech Stack:** GitHub Actions composite (`action.yml`), `bash`, `uvx`, `jq`, MkDocs (for the docs build check), `just`. - -**Spec:** `planning/specs/2026-06-09-action-yml-dry-run-design.md` - ---- - -## File Structure - -| Path | Status | Responsibility | -|---|---|---| -| `README.md` | Modify | Fix one docs URL (`readthedocs.io` → `modern-python.org`) — already edited in working tree | -| `pyproject.toml` | Modify | Fix the same URL in the project's `urls.docs` field — already edited in working tree | -| `action.yml` | Modify | Add `dry-run` input; conditionally append `--dry-run` to the CLI call; bump version floor to `>=0.5.0,<1`; mention `dry_run` in the status-normalization comment | -| `.github/workflows/ci.yml` | Modify | `action-smoke` job: drop `permissions: contents: write`; pass `with: { dry-run: 'true' }`; reduce assertion to one line; rewrite comment blocks | -| `docs/providers/github.md` | Modify | Insert a `## Preview the next bump` h2 section between line 91 (`## Outputs`) and line 127 (`## Token scope: ...`) | - -## Branch - -Already on `feat/action-yml-dry-run` (branched from updated `main` after PR #14 + PR #15 landed). The spec commit (`72e3830`) is currently HEAD; the URL fixes are uncommitted in the working tree. The plan you're reading is about to land as the second commit. - -After this plan commit, the remaining task commits are: -1. (next) URL fix from working tree -2. action.yml: dry-run input + version floor -3. ci.yml: action-smoke uses dry-run + drops contents: write -4. docs/providers/github.md: Preview the next bump section -5. Verification + PR - ---- - -### Task 1: Commit the working-tree URL fixes - -**Files:** -- Modify: `README.md` (already edited; line 78) -- Modify: `pyproject.toml` (already edited; line 30) - -- [ ] **Step 1: Confirm the working-tree changes are still present** - -```bash -git status --short README.md pyproject.toml -``` - -Expected: - -``` - M README.md - M pyproject.toml -``` - -If either file is missing from the output, the local changes were stashed or lost. Stop and ask the user to restore them. - -- [ ] **Step 2: Re-verify the diff matches the spec** - -```bash -git diff README.md pyproject.toml -``` - -Expected: the only changes are `readthedocs.io` → `modern-python.org` in one line of each file. Specifically: - -``` -README.md: -- Both are configurable via env vars. See [docs](https://semvertag.readthedocs.io) -+ Both are configurable via env vars. See [docs](https://semvertag.modern-python.org) - -pyproject.toml: -- docs = "https://semvertag.readthedocs.io" -+ docs = "https://semvertag.modern-python.org" -``` - -If the diff contains anything else, STOP — the working tree has unrelated changes that should not land in this commit. - -- [ ] **Step 3: Run lint to confirm the edits don't break anything** - -```bash -just lint-ci -``` - -Expected: passes. README/pyproject changes are pure URL text — lint should be untouched. - -- [ ] **Step 4: Commit** - -```bash -git add README.md pyproject.toml -git commit -m "docs: update URL to new subdomain in README and pyproject" -``` - ---- - -### Task 2: action.yml — dry-run input, conditional CLI flag, version floor bump - -**Files:** -- Modify: `action.yml` (currently 56 lines) - -- [ ] **Step 1: Add the `dry-run` input** - -Open `action.yml`. Find the existing `inputs:` block (lines 9-17). Append a new `dry-run` input after `token` so the block reads: - -```yaml -inputs: - strategy: - description: 'Bump strategy: branch-prefix (default) or conventional-commits.' - required: false - default: 'branch-prefix' - token: - description: 'GitHub token with contents: write. Defaults to the workflow-issued github.token.' - required: false - default: ${{ github.token }} - dry-run: - description: 'If true, compute the bump and emit the planned tag/bump but do not push a tag.' - required: false - default: 'false' -``` - -The default `'false'` is a quoted string — GitHub Actions input values are always strings, and the explicit quoting makes that obvious. - -- [ ] **Step 2: Rewrite the `Run semvertag` step's run script** - -Find the `run: |` block (lines 41-55). Replace it with this version (adds the `dry_run_flag` shell variable, bumps the version floor, updates the normalization comment): - -```yaml - - name: Run semvertag - id: run - shell: bash - env: - GITHUB_TOKEN: ${{ inputs.token }} - SEMVERTAG_STRATEGY: ${{ inputs.strategy }} - run: | - set -euo pipefail - dry_run_flag='' - if [ "${{ inputs.dry-run }}" = "true" ]; then dry_run_flag='--dry-run'; fi - result=$(uvx 'semvertag>=0.5.0,<1' tag --json $dry_run_flag) - printf '%s\n' "$result" - # Normalize the CLI's internal status (`no_tags`, `already_tagged`, - # `no_merge_commit`, `no_conforming_commit`, `dry_run`, ...) to a stable - # consumer-facing enum. `set -euo pipefail` ensures we never reach - # here on CLI errors, so there is no `error` value to surface. - case "$(jq -r '.status' <<<"$result")" in - created) status='created' ;; - *) status='no-bump' ;; - esac - printf 'tag=%s\n' "$(jq -r '.tag // ""' <<<"$result")" >> "$GITHUB_OUTPUT" - printf 'bump=%s\n' "$(jq -r '.bump' <<<"$result")" >> "$GITHUB_OUTPUT" - printf 'status=%s\n' "$status" >> "$GITHUB_OUTPUT" -``` - -Three concrete changes from the previous version: -- New `dry_run_flag=''` line + the conditional `if` line that sets it to `'--dry-run'` when the input is true. -- `$dry_run_flag` injected (deliberately unquoted) into the `uvx` invocation — empty value expands to nothing, non-empty expands to the literal `--dry-run`. -- Version constraint changed from `>=0.3.1,<1` to `>=0.5.0,<1`. -- Comment block now lists `dry_run` among the known internal statuses. - -- [ ] **Step 3: Validate the YAML parses** - -```bash -python3 -c "import yaml; yaml.safe_load(open('action.yml'))" -``` - -Expected: no output, exit code 0. - -If parse fails, the most likely culprit is indentation around the new `dry-run` input or inside the `run: |` block. action.yml uses 2-space YAML indent (not tabs). - -- [ ] **Step 4: Diff sanity check** - -```bash -git diff action.yml -``` - -Expected diff stat: ~13 added, ~2 removed (one new input block, one new shell-variable assignment + conditional, the version constraint change, the comment update). - -No changes to: the `name`, `description`, `branding`, `outputs`, the `setup-uv` step, the `jq` invocations, the `GITHUB_OUTPUT` writes. - -- [ ] **Step 5: Commit** - -```bash -git add action.yml -git commit -m "feat(action): add dry-run input and bump version floor to >=0.5.0" -``` - ---- - -### Task 3: ci.yml action-smoke — use dry-run, drop contents: write - -**Files:** -- Modify: `.github/workflows/ci.yml` (`action-smoke` job, lines 47-74) - -- [ ] **Step 1: Replace the action-smoke job** - -Open `.github/workflows/ci.yml`. Replace the existing `action-smoke` job (lines 47-74) with this version: - -```yaml - action-smoke: - # PR-only: action-smoke has no value on push-to-main, where the dogfood - # workflow (`.github/workflows/semvertag.yml`) exercises the real - # composite action against the merge commit. Under `dry-run: true`, - # this job is side-effect-free regardless of main's tag state. - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - # No `permissions:` block: `contents: read` is sufficient because - # dry-run guarantees no tag-push attempt. Even a future regression - # that bypassed dry-run would be denied by the API. - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - id: semvertag - uses: ./ - with: - dry-run: 'true' - env: - SEMVERTAG_BRANCH_PREFIX__MINOR: '["feat/"]' - - name: Verify the composite normalized dry-run to no-bump - # Under `dry-run: true`, the CLI's status is one of `dry_run`, - # `no_tags`, `already_tagged`, `no_merge_commit`, `no_conforming_commit` — - # all normalized to `no-bump` by action.yml. If action.yml's - # dry-run wiring regresses, the CLI would push a tag, status would - # be `created`, and this assertion would fail loudly. `bump` is - # intentionally NOT asserted: under dry-run it reflects the would-be - # value (`patch`/`minor`/`major` for an untagged merge, `none` - # otherwise) — not a stable smoke value. - run: | - test "${{ steps.semvertag.outputs.status }}" = "no-bump" -``` - -Four concrete changes from the previous version: -1. The `permissions: contents: write` block (old lines 55-56) is GONE. -2. The `uses: ./` step now has `with: { dry-run: 'true' }`. -3. The assertion shrinks from 2 lines (`status` + `bump` checks) to 1 line (`status` only). -4. Comment blocks rewritten — the old "PR-only because main HEAD might not be tagged" rationale is replaced with the new "PR-only because action-smoke has no value on push-to-main" rationale, and a new comment explains why no `permissions:` block exists. - -- [ ] **Step 2: Validate YAML parses** - -```bash -python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" -``` - -Expected: no output, exit code 0. - -- [ ] **Step 3: Diff sanity check** - -```bash -git diff .github/workflows/ci.yml -``` - -Expected: only the `action-smoke` job changed. The `lint` and `pytest` jobs above must remain identical. - -Spot-check `git diff --stat .github/workflows/ci.yml`: somewhere around 18-20 lines added, 14-15 removed (the block was 28 lines, the new block is ~30 lines, plus comment changes). - -- [ ] **Step 4: Commit** - -```bash -git add .github/workflows/ci.yml -git commit -m "ci(action-smoke): use dry-run and drop contents: write" -``` - ---- - -### Task 4: docs/providers/github.md — Preview the next bump - -**Files:** -- Modify: `docs/providers/github.md` (insertion between line 91 `## Outputs` section's end and line 127 `## Token scope: ...`) - -- [ ] **Step 1: Find the exact insertion point** - -```bash -grep -n "^## Token scope" docs/providers/github.md -``` - -Expected: a single line like `127:## Token scope: \`GITHUB_TOKEN\` vs Personal Access Tokens`. - -That line is the anchor. The new section goes immediately ABOVE it, separated by a blank line. - -- [ ] **Step 2: Insert the new section** - -Open `docs/providers/github.md`. Before the `## Token scope:` heading (line 127), insert this block (note: the new section is its own h2; preserve a blank line before and after it): - -```markdown -## Preview the next bump - -Pass `dry-run: true` to compute the bump without pushing a tag — useful in -CI smoke tests, in PR previews, or to see what the next release would be: - -​```yaml -- uses: modern-python/semvertag@v0 - with: - dry-run: true -​``` - -When `dry-run: true`, the action's `status` output is `no-bump` (no real tag -was pushed) and `bump` / `tag` reflect what *would* have happened. - -You can also run this locally without the action: - -​```bash -uvx 'semvertag>=0.5.0' tag --dry-run --json -​``` - -Output (example): - -​```json -{"schema_version":"1.0","strategy":"branch-prefix","bump":"minor","status":"dry_run","tag":"0.6.0","commit":"abc1234..."} -​``` - -``` - -Important: in the actual file, the code-fence markers are three backticks each (` ``` `). The leading zero-width-marker (​) above is only present in this plan to keep the markdown nested correctly — the file should have plain triple-backticks. - -- [ ] **Step 3: Verify the docs still build** - -```bash -uvx --with-requirements docs/requirements.txt mkdocs build --strict 2>&1 | tail -5 -``` - -Expected: ends with `INFO - Documentation built in . seconds`. No WARNING or ERROR lines from the strict check. - -If `--strict` flags a link or anchor issue from the new section, fix it. Common culprits: orphaned heading anchor, missing blank line around a fenced block. - -Clean up afterwards: - -```bash -rm -rf site/ -``` - -- [ ] **Step 4: Diff sanity check** - -```bash -git diff docs/providers/github.md | head -50 -``` - -Expected: pure addition of the new `## Preview the next bump` section. No other lines changed. - -```bash -git diff --stat docs/providers/github.md -``` - -Expected: around 20-25 lines added, 0 removed. - -- [ ] **Step 5: Commit** - -```bash -git add docs/providers/github.md -git commit -m "docs(providers/github): document dry-run usage" -``` - ---- - -### Task 5: Final verification + PR - -- [ ] **Step 1: Skim the branch diff** - -```bash -git log origin/main..HEAD --oneline -``` - -Expected: 6 commits, in this order (newest first): - -``` - docs(providers/github): document dry-run usage - ci(action-smoke): use dry-run and drop contents: write - feat(action): add dry-run input and bump version floor to >=0.5.0 - docs: update URL to new subdomain in README and pyproject - docs: add implementation plan for action.yml dry-run - docs: add design spec for action.yml dry-run -``` - -```bash -git diff origin/main..HEAD --stat -``` - -Expected files: `README.md`, `pyproject.toml`, `action.yml`, `.github/workflows/ci.yml`, `docs/providers/github.md`, `planning/specs/2026-06-09-action-yml-dry-run-design.md`, `planning/plans/2026-06-09-action-yml-dry-run.md`. - -- [ ] **Step 2: Run lint + tests** - -```bash -just lint-ci -``` - -Expected: passes. - -```bash -just test -``` - -Expected: 428 tests pass, 100% branch coverage. (No Python source changes in this PR, so the suite should be unaffected.) - -- [ ] **Step 3: Run mkdocs build one more time** - -```bash -uvx --with-requirements docs/requirements.txt mkdocs build --strict 2>&1 | tail -5 -rm -rf site/ -``` - -Expected: `Documentation built in ...`. - -- [ ] **Step 4: Push the branch** - -```bash -git push -u origin feat/action-yml-dry-run -``` - -- [ ] **Step 5: Open the PR** - -```bash -cat > /tmp/pr-b-body.md <<'EOF' -## Summary - -- Adds `dry-run` boolean input to `action.yml` (default `'false'`). When true, the composite passes `--dry-run` to `semvertag tag`; semvertag skips `provider.create_tag` and emits `status="dry_run"`, which the existing case block normalizes to public `status="no-bump"`. -- Switches `ci.yml`'s `action-smoke` job to use `dry-run: 'true'` and drops `permissions: contents: write`. The job can no longer push real tags even under regression — GitHub would 403 first. -- Reduces the action-smoke assertion to a single line: `status == no-bump`. Under dry-run this is guaranteed true; if action.yml's dry-run wiring breaks, status becomes `created` and the assertion fails loudly. -- Bumps semvertag version floor in `action.yml` from `>=0.3.1,<1` to `>=0.5.0,<1` (the release that ships `--dry-run`). -- Documents the new input under a `## Preview the next bump` section in `docs/providers/github.md`. -- Drive-by: README + pyproject docs URL fix to the new `modern-python.org` subdomain (a leftover from PR #14). - -Spec: `planning/specs/2026-06-09-action-yml-dry-run-design.md`. Predecessor: PR #15 (semvertag CLI `--dry-run` flag; shipped as `0.5.0`). - -## Motivation - -PR #14 surfaced the underlying smell: `action-smoke` ran with `contents: write` against main HEAD via the GitHub API, so whenever main HEAD was an untagged `feat/`/`bugfix/` merge, the smoke test minted and pushed a real release tag from a PR's CI run. PR A landed the CLI half (`semvertag tag --dry-run`); this PR wires it through the composite action and switches `action-smoke` to use it. After merge, `action-smoke` is structurally side-effect-free. - -## Test plan - -- [x] `just lint-ci` passes -- [x] `just test` passes (428 tests, 100% coverage — no Python source changed) -- [x] `mkdocs build --strict` passes -- [x] PR's own `action-smoke` job passes — confirms end-to-end wiring of the new `dry-run` input -- [x] After merge: confirm `action-smoke` on the NEXT PR (whatever it is) still passes — verifies the contract holds across consecutive PRs - -## Post-merge follow-ups (NOT in this PR) - -- Cut `0.6.0` (this is a `feat/` merge under the convention → minor bump). PR B's CLI behavior is unchanged from 0.5.0, but the action.yml's contract grew, so a minor bump is the right communication. Optional — could wait until the next user-visible CLI change. -- Consider mentioning `--dry-run` in `docs/index.md`'s quick-start. Low priority. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -gh pr create --title "feat(action): add dry-run input + make action-smoke side-effect-free" --body-file /tmp/pr-b-body.md -``` - -Expected: a GitHub PR URL is printed. Report it verbatim — do NOT fabricate. - -- [ ] **Step 6: Watch PR CI** - -The action-smoke job in this PR's CI must pass on the first try. If it doesn't: - -- **`action-smoke` failed, output says "uvx: error: no matching distribution":** semvertag `0.5.0` is not on PyPI (or hasn't propagated). Check `pip index versions semvertag` from outside the runner. If 0.5.0 isn't there, this PR can't merge until the release process completes. -- **`action-smoke` failed, output shows `status=created`:** the dry-run wiring is broken in `action.yml`. Re-examine the `dry_run_flag` shell construction in Task 2 Step 2. -- **`action-smoke` failed, output shows `status=no-bump` but the assertion still failed:** this is impossible given the assertion is `test "$status" = "no-bump"`. If it happens, capture the raw CI log and STOP. -- **`lint` or `pytest` failed:** these jobs are not touched by this PR, so any failure is a pre-existing flake or an unrelated regression. Investigate before merging. - ---- - -## Post-merge follow-ups (NOT part of this plan) - -1. The dogfood workflow will run on the merge commit. Since this is a `feat/`-prefixed branch, branch-prefix bumps minor → `0.6.0` (next minor after the current `0.5.0`). -2. Optionally cut a `0.6.0` release pointing at the merge commit to publish to PyPI. The CLI behavior is unchanged from 0.5.0; only the composite action's contract grew (new input). A `0.6.0` PyPI release isn't strictly needed unless a consumer relies on `uvx semvertag` (not the action) for dry-run — and `0.5.0` already supports that. diff --git a/planning/changes/2026-06-13.01-portable-planning-convention/design.md b/planning/changes/2026-06-13.01-portable-planning-convention.md similarity index 100% rename from planning/changes/2026-06-13.01-portable-planning-convention/design.md rename to planning/changes/2026-06-13.01-portable-planning-convention.md diff --git a/planning/changes/2026-06-13.01-portable-planning-convention/plan.md b/planning/changes/2026-06-13.01-portable-planning-convention/plan.md deleted file mode 100644 index 9cd4d3b..0000000 --- a/planning/changes/2026-06-13.01-portable-planning-convention/plan.md +++ /dev/null @@ -1,871 +0,0 @@ -# Portable Planning Convention Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps use -> checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Adopt the portable two-axis planning convention in `semvertag` — -seed an `architecture/` truth home, migrate the 15 spec/plan pairs into dated -`planning/changes/` bundles, and copy the byte-identical convention scaffolding -(`README.md` `## Conventions`, `_templates/`, `deferred.md`). - -**Architecture:** Pure docs / file-moves. No runtime code, tests, or public API -touched, so there is no TDD loop — each task's "test" is a structural check -(file exists, frontmatter parses, grep sweep clean) plus the standing gates -`just lint-ci`, `just test`, and `mkdocs build --strict`. Existing specs/plans -move via `git mv` (history preserved) and gain only YAML frontmatter. - -**Tech Stack:** Markdown, YAML frontmatter, `git mv`, `just`, `mkdocs`. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `docs/portable-planning-convention` (already created; the spec is -committed there). - -**Commit strategy:** Per-task commits. - ---- - -### Task 1: Scaffold the portable convention files - -**Files:** -- Create: `planning/_templates/design.md` -- Create: `planning/_templates/plan.md` -- Create: `planning/_templates/change.md` -- Create: `planning/deferred.md` -- Create: `planning/changes/active/.gitkeep` -- Create: `planning/changes/.gitkeep` - -These four content files are copied **byte-identical** from `lite-bootstrap` -(the portable convention). Reproduce them exactly. - -- [ ] **Step 1: Write `planning/_templates/design.md`** - -```markdown ---- -status: draft -date: YYYY-MM-DD -slug: my-change -supersedes: null -superseded_by: null -pr: null -outcome: null ---- - -# Design: One-line capitalized title - -## Summary - -One paragraph. What changes, at the level a reader needs to decide if this -spec is worth reading in full. - -## Motivation - -Why now. What is broken or missing. Concrete observations / numbers, not -abstract complaints. Link to memory entries or earlier specs when relevant. - -## Non-goals - -What is deliberately out of scope and (when nontrivial) why. Each item is -a sentence; one line each. - -## Design - -### 1. - -What changes, in enough detail that a reader who has not seen the codebase -can follow. Code samples / diagrams welcome. - -### 2. - -... - -## Operations - -Out-of-repo steps (DNS, infra, external account changes). Omit if none. - -## Out of scope - -Already covered above under Non-goals if appropriate. Repeat-list of -explicitly-excluded follow-ups belongs here when the list is long. - -## Testing - -How we know it landed correctly. New pytest? Smoke check on live URL? -Lint pass? Be specific. - -## Risk - -What could go wrong, ranked by likelihood × impact. Mitigations. -``` - -- [ ] **Step 2: Write `planning/_templates/plan.md`** - -```markdown ---- -status: draft -date: YYYY-MM-DD -slug: my-change -spec: my-change -pr: null ---- - -# — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** One sentence — what shipping this plan achieves. No design -rationale; link to the spec for that. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `feat/my-change` (or `fix/`, `chore/`, etc.) - -**Commit strategy:** Per-task commits / single commit / squash on merge. -Whichever fits. - ---- - -### Task 1: - -**Files:** -- Modify: `path/to/file.py` -- Create: `path/to/new.py` - -One sentence on what this task accomplishes. No deeper reasoning — that's -in the spec. - -- [ ] **Step 1: ** - - Run / edit / verify command. Expected output. - -- [ ] **Step 2: ** - - ... - -- [ ] **Step 3: Commit** - - ```bash - git add path/to/file.py - git commit -m ": - - Co-Authored-By: Claude Opus 4.7 (1M context) " - ``` - ---- - -### Task 2: ... -``` - -- [ ] **Step 3: Write `planning/_templates/change.md`** - -```markdown ---- -status: draft -date: YYYY-MM-DD -slug: my-change -supersedes: null -superseded_by: null -pr: null -outcome: null ---- - -# Change: One-line capitalized title - -**Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API -change, a single straightforward test. If it outgrows this, split into -`design.md` + `plan.md`. - -## Goal - -One or two sentences: what changes and why. - -## Approach - -The shape of the change in brief — enough that a reviewer sees the design -without a full spec. Link the truth home (`architecture/.md`) if a -capability contract moves. - -## Files - -- `path/to/file.py` — what changes -- `tests/test_x.py` — test added / updated - -## Verification - -- [ ] Failing test first — command + expected error. -- [ ] Apply the change. -- [ ] Test passes — command. -- [ ] `just test` — full suite green. -- [ ] `just lint` — clean. -``` - -- [ ] **Step 4: Write `planning/deferred.md`** - -```markdown -# Deferred Work - -Items raised in reviews or audits that are real but not actionable now. -Each is parked here with the reason it's deferred and the concrete trigger -that should bring it back. This is the long-tail register — not a backlog -of planned work. When an item is picked up it graduates to a spec/plan -bundle in [`changes/active/`](changes/active/); see [CLAUDE.md](../CLAUDE.md#workflow). - -## Open - -_None._ -``` - -- [ ] **Step 5: Create the `changes/` directory keepers** - -```bash -mkdir -p planning/changes/active planning/changes -touch planning/changes/active/.gitkeep planning/changes/.gitkeep -``` - -- [ ] **Step 6: Verify the templates are byte-identical to lite-bootstrap** - -```bash -for f in design plan change; do - diff <(gh api "repos/modern-python/lite-bootstrap/contents/planning/_templates/$f.md?ref=main" --jq '.content' | base64 -d) \ - planning/_templates/$f.md && echo "$f.md: identical" -done -diff <(gh api "repos/modern-python/lite-bootstrap/contents/planning/deferred.md?ref=main" --jq '.content' | base64 -d) \ - planning/deferred.md && echo "deferred.md: identical" -``` -Expected: four `identical` lines, no diff output. - -- [ ] **Step 7: Commit** - -```bash -git add planning/_templates planning/deferred.md planning/changes/active/.gitkeep planning/changes/.gitkeep -git commit -m "docs(planning): add portable templates, deferred register, changes/ skeleton - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -### Task 2: Fix the `.gitignore` trap and add the `docs-build` gate - -**Files:** -- Modify: `.gitignore` -- Modify: `Justfile` - -The bare `plan.md` rule in `.gitignore` would silently exclude every bundle's -`plan.md`. Remove it. Add a strict local docs build recipe mirroring CI. - -- [ ] **Step 1: Remove the `plan.md` line from `.gitignore`** - -Delete the line containing exactly `plan.md` (between `.python-version`/`.venv` -and `/site/`). Leave every other line untouched. - -- [ ] **Step 2: Verify no bundle plan.md is ignored** - -```bash -git check-ignore planning/changes/active/2026-06-13.01-portable-planning-convention/plan.md; echo "exit=$?" -``` -Expected: no output, `exit=1` (not ignored). - -- [ ] **Step 3: Add the `docs-build` recipe to `Justfile`** - -Insert this recipe immediately before the existing `docs-deploy` recipe: - -```makefile -# Strict local docs build (no deploy). Mirrors CI's link/strict checks. -docs-build: - uvx --with-requirements docs/requirements.txt mkdocs build --strict -``` - -- [ ] **Step 4: Verify the recipe runs** - -```bash -just docs-build -``` -Expected: `mkdocs build --strict` exits 0 (site builds, no warnings-as-errors). - -- [ ] **Step 5: Commit** - -```bash -git add .gitignore Justfile -git commit -m "chore: untrack bundle plan.md and add docs-build gate - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -### Task 3: Migrate the 15 spec/plan pairs into archived bundles - -**Files (per pair):** -- Move: `planning/specs/--design.md` → `planning/changes/.NN-/design.md` -- Move: `planning/plans/-.md` → `planning/changes/.NN-/plan.md` -- Delete: `planning/specs/.gitkeep`, `planning/plans/.gitkeep` (after dirs are empty) - -This is the bundle table. `.NN` is the git first-commit order. `pr` stays -`null`; the PR reference (where one exists) goes in `outcome` per the -lite-bootstrap house style. The eight `2026-05-31` items predate PR numbering -(direct pre-1.0 merges), so they carry a factual bootstrap note. - -| Bundle dir (`.NN-`) | Source slug (`-`) | `outcome` value | -|---|---|---| -| `2026-05-31.01-bmad-to-superpowers-migration-and-httpx2-wrapper` | `2026-05-31-bmad-to-superpowers-migration-and-httpx2-wrapper` | shipped in the pre-1.0 bootstrap (retired BMad; added the httpx HTTP-client wrapper) | -| `2026-05-31.02-drop-doctor` | `2026-05-31-drop-doctor` | shipped in the pre-1.0 bootstrap (removed the doctor command) | -| `2026-05-31.03-settings-aliaschoices` | `2026-05-31-settings-aliaschoices` | shipped in the pre-1.0 bootstrap (pydantic-settings AliasChoices) | -| `2026-05-31.04-usecase-callable` | `2026-05-31-usecase-callable` | shipped in the pre-1.0 bootstrap (callable use-case) | -| `2026-05-31.05-ioc-idiomatic-modern-di-typer` | `2026-05-31-ioc-idiomatic-modern-di-typer` | shipped in the pre-1.0 bootstrap (modern-di + Typer IoC) | -| `2026-05-31.06-cli-overlay-simplification` | `2026-05-31-cli-overlay-simplification` | shipped in the pre-1.0 bootstrap (model_copy CLI overlay) | -| `2026-05-31.07-strategy-no-bump-cleanup` | `2026-05-31-strategy-no-bump-cleanup` | shipped in the pre-1.0 bootstrap (no-bump return path) | -| `2026-05-31.08-v0-1-0-release-prep` | `2026-05-31-v0-1-0-release-prep` | shipped in the pre-1.0 bootstrap (0.1.0 release prep) | -| `2026-06-07.01-httpware-migration` | `2026-06-07-httpware-migration` | shipped (#2) | -| `2026-06-08.01-httpware-decoder-adoption` | `2026-06-08-httpware-decoder-adoption` | shipped (#3) | -| `2026-06-08.02-github-provider` | `2026-06-08-github-provider` | shipped (#4) | -| `2026-06-08.03-action-yml-composite-wrapper` | `2026-06-08-action-yml-composite-wrapper` | shipped (#10) | -| `2026-06-09.01-mkdocs-github-actions` | `2026-06-09-mkdocs-github-actions` | shipped (#14) | -| `2026-06-09.02-dry-run-flag` | `2026-06-09-dry-run-flag` | shipped (#15) | -| `2026-06-09.03-action-yml-dry-run` | `2026-06-09-action-yml-dry-run` | shipped (#16) | - -- [ ] **Step 1: `git mv` every pair into its bundle folder** - -For each row, derive `` and `` from the bundle dir, then: - -```bash -mkdir -p "planning/changes/.NN-" -git mv "planning/specs/--design.md" "planning/changes/.NN-/design.md" -git mv "planning/plans/-.md" "planning/changes/.NN-/plan.md" -``` - -Concretely, the 30 moves are (note `2026-05-31.01`'s plan keeps its full -descriptive source name): - -```bash -B=planning/changes -S=planning/specs -P=planning/plans -for row in \ - "2026-05-31.01-bmad-to-superpowers-migration-and-httpx2-wrapper|2026-05-31-bmad-to-superpowers-migration-and-httpx2-wrapper" \ - "2026-05-31.02-drop-doctor|2026-05-31-drop-doctor" \ - "2026-05-31.03-settings-aliaschoices|2026-05-31-settings-aliaschoices" \ - "2026-05-31.04-usecase-callable|2026-05-31-usecase-callable" \ - "2026-05-31.05-ioc-idiomatic-modern-di-typer|2026-05-31-ioc-idiomatic-modern-di-typer" \ - "2026-05-31.06-cli-overlay-simplification|2026-05-31-cli-overlay-simplification" \ - "2026-05-31.07-strategy-no-bump-cleanup|2026-05-31-strategy-no-bump-cleanup" \ - "2026-05-31.08-v0-1-0-release-prep|2026-05-31-v0-1-0-release-prep" \ - "2026-06-07.01-httpware-migration|2026-06-07-httpware-migration" \ - "2026-06-08.01-httpware-decoder-adoption|2026-06-08-httpware-decoder-adoption" \ - "2026-06-08.02-github-provider|2026-06-08-github-provider" \ - "2026-06-08.03-action-yml-composite-wrapper|2026-06-08-action-yml-composite-wrapper" \ - "2026-06-09.01-mkdocs-github-actions|2026-06-09-mkdocs-github-actions" \ - "2026-06-09.02-dry-run-flag|2026-06-09-dry-run-flag" \ - "2026-06-09.03-action-yml-dry-run|2026-06-09-action-yml-dry-run" \ -; do - dir="${row%%|*}"; src="${row##*|}" - mkdir -p "$B/$dir" - git mv "$S/$src-design.md" "$B/$dir/design.md" - git mv "$P/$src.md" "$B/$dir/plan.md" -done -``` - -- [ ] **Step 2: Verify all 15 source pairs moved and dirs are empty** - -```bash -ls planning/specs planning/plans # expect only .gitkeep in each -find planning/changes -name design.md | wc -l # expect 15 -find planning/changes -name plan.md | wc -l # expect 15 -``` - -- [ ] **Step 3: Prepend frontmatter to each `design.md`** - -For every bundle, insert this block at the very top of `design.md`, filling -`date`, `slug`, and `outcome` from the table above (slug = the bundle slug -without the `.NN-` prefix): - -```yaml ---- -status: shipped -date: -slug: -supersedes: null -superseded_by: null -pr: null -outcome: ---- - -``` - -The existing `# Title` / `**Date:**` / `**Status:**` body stays unchanged below -the block. None of the `outcome` values has a space-preceded `#`, so none needs -quoting. - -- [ ] **Step 4: Prepend frontmatter to each `plan.md`** - -For every bundle, insert this block at the very top of `plan.md`: - -```yaml ---- -status: shipped -date: -slug: -spec: -pr: null ---- - -``` - -- [ ] **Step 5: Verify frontmatter parses as YAML on all 30 files** - -```bash -python3 - <<'PY' -import pathlib, yaml, sys -bad = 0 -for f in pathlib.Path("planning/changes").rglob("*.md"): - text = f.read_text() - if not text.startswith("---\n"): - print("NO FRONTMATTER:", f); bad += 1; continue - fm = text.split("---\n", 2)[1] - try: - d = yaml.safe_load(fm) - assert d["status"] == "shipped" and d["slug"] and d["date"] - except Exception as e: - print("BAD:", f, e); bad += 1 -print("checked", len(list(pathlib.Path('planning/changes').rglob('*.md'))), "files, bad =", bad) -sys.exit(1 if bad else 0) -PY -``` -Expected: `bad = 0`, exit 0. - -- [ ] **Step 6: Verify `git mv` preserved history on a sample file** - -```bash -git log --follow --oneline -- planning/changes/2026-06-08.02-github-provider/design.md | tail -1 -``` -Expected: a commit predating this branch (history followed through the rename). - -- [ ] **Step 7: Remove the now-empty source dirs** - -```bash -git rm planning/specs/.gitkeep planning/plans/.gitkeep -rmdir planning/specs planning/plans 2>/dev/null || true -``` - -- [ ] **Step 8: Commit** - -```bash -git add -A planning/changes planning/specs planning/plans -git commit -m "docs(planning): migrate 15 spec/plan pairs into archived change bundles - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -### Task 4: Write `planning/README.md` - -**Files:** -- Create: `planning/README.md` - -The `## Conventions` block is byte-identical to lite-bootstrap; the `## Index` -is semvertag-specific. - -- [ ] **Step 1: Write the file** - -```markdown -# Planning - -Specs, plans, and change history for `semvertag`. The living truth -about *what the system does now* lives in [`architecture/`](../architecture/) -at the repo root; this directory records *how it got there*. - -## Conventions - -> This section is the portable convention — identical across the -> modern-python repos. The Index below is repo-specific. To adopt elsewhere, -> copy this section plus [`_templates/`](_templates/) and point that repo's -> `CLAUDE.md` Workflow + truth home at it. - -### Two axes, never mixed - -- **`architecture/` (repo root) — the present.** One file per capability, - living prose, updated whenever a change ships. The truth home. -- **`planning/changes/` — the past-and-pending.** One folder per change, - frozen once shipped. - -Shipping a change **promotes** its conclusions into the affected -`architecture/.md` by hand, then archives the bundle. That -hand-edit is what keeps `architecture/` true; the archived bundle carries the -*why*. - -### Change bundles - -A change is a folder `changes/active/YYYY-MM-DD.NN-/`: - -- `YYYY-MM-DD` — proposal date; `.NN` — zero-padded intra-day counter - (`.01`, `.02`, …) that breaks same-date ties so the timeline sorts stably. -- `` — kebab-case description, not a story ID. - -On merge the folder moves to `changes/` with `status: shipped`, `pr:`, -and `outcome:` filled, and its line moves from **Active** to **Archived** in -the Index below. - -### Three lanes - -| Lane | Artifacts | Use when | -|------|-----------|----------| -| **Full** | `design.md` + `plan.md` | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | -| **Lightweight** | `change.md` | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | -| **Tiny** | none — conventional commit | typo, dep bump, linter/formatter/CI tweak, mechanical rename, single-line config | - -Heavier lane wins on ambiguity. A `change.md` that outgrows its lane splits -into `design.md` + `plan.md`. - -### Artifacts at a glance - -- **`design.md`** — the spec: the *thinking* (why, design, trade-offs, scope). -- **`plan.md`** — the plan: the *sequencing* (the executor's task checklist). -- **`change.md`** — both, condensed, for the lightweight lane. -- **`releases/.md`** — per-release user-facing notes. -- **`audits/-.md`** — findings from a code/docs/bug-hunt sweep; - spawns fix changes. -- **`retros/-.md`** — what we learned after a body of work. -- **`deferred.md`** — real-but-unscheduled items, each with a revisit trigger. - -Templates live in [`_templates/`](_templates/). - -### Frontmatter - -`design.md` / `change.md`: `status` (draft|approved|shipped|superseded), -`date`, `slug`, `supersedes`, `superseded_by`, `pr`, `outcome`. -`plan.md`: `status`, `date`, `slug`, `spec`, `pr`. Files in `architecture/` -carry **no** frontmatter — living prose, dated by git. - -## Index - -### Active - -- **[portable-planning-convention](changes/active/2026-06-13.01-portable-planning-convention/design.md)** - (2026-06-13) — Adopt the portable two-axis convention: `architecture/` truth - home + `changes/` bundles, migrate the 15 spec/plan pairs, fresh Index. - -### Archived (shipped) - -- **[action-yml-dry-run](changes/2026-06-09.03-action-yml-dry-run/design.md)** - (#16, 2026-06-09) — Composite action `dry-run` input wired to the CLI flag. -- **[dry-run-flag](changes/2026-06-09.02-dry-run-flag/design.md)** - (#15, 2026-06-09) — `--dry-run` CLI flag: compute the next tag without - creating it. -- **[mkdocs-github-actions](changes/2026-06-09.01-mkdocs-github-actions/design.md)** - (#14, 2026-06-09) — Docs hosting via MkDocs + GitHub Actions + Pages. -- **[action-yml-composite-wrapper](changes/2026-06-08.03-action-yml-composite-wrapper/design.md)** - (#10, 2026-06-08) — `action.yml` composite GitHub Action wrapping the CLI. -- **[github-provider](changes/2026-06-08.02-github-provider/design.md)** - (#4, 2026-06-08) — GitHub provider alongside GitLab. -- **[httpware-decoder-adoption](changes/2026-06-08.01-httpware-decoder-adoption/design.md)** - (#3, 2026-06-08) — Adopt the httpware response decoder in the providers. -- **[httpware-migration](changes/2026-06-07.01-httpware-migration/design.md)** - (#2, 2026-06-07) — Migrate the HTTP client onto httpware. -- **[v0-1-0-release-prep](changes/2026-05-31.08-v0-1-0-release-prep/design.md)** - (2026-05-31) — Pre-1.0 release preparation. -- **[strategy-no-bump-cleanup](changes/2026-05-31.07-strategy-no-bump-cleanup/design.md)** - (2026-05-31) — Clean up the strategies' no-bump return path. -- **[cli-overlay-simplification](changes/2026-05-31.06-cli-overlay-simplification/design.md)** - (2026-05-31) — Replace the CLI-overlay machinery with `model_copy`. -- **[ioc-idiomatic-modern-di-typer](changes/2026-05-31.05-ioc-idiomatic-modern-di-typer/design.md)** - (2026-05-31) — Idiomatic modern-di + Typer IoC wiring. -- **[usecase-callable](changes/2026-05-31.04-usecase-callable/design.md)** - (2026-05-31) — Make the use-case a callable. -- **[settings-aliaschoices](changes/2026-05-31.03-settings-aliaschoices/design.md)** - (2026-05-31) — pydantic-settings `AliasChoices` for env/CLI names. -- **[drop-doctor](changes/2026-05-31.02-drop-doctor/design.md)** - (2026-05-31) — Remove the `doctor` command. -- **[bmad-to-superpowers-migration-and-httpx2-wrapper](changes/2026-05-31.01-bmad-to-superpowers-migration-and-httpx2-wrapper/design.md)** - (2026-05-31) — Retire BMad for Superpowers; add the HTTP-client wrapper. -``` - -- [ ] **Step 2: Verify the Conventions block is byte-identical to lite-bootstrap** - -```bash -diff <(gh api "repos/modern-python/lite-bootstrap/contents/planning/README.md?ref=main" --jq '.content' | base64 -d | sed -n '/^## Conventions$/,/^## Index$/p' | sed '$d') \ - <(sed -n '/^## Conventions$/,/^## Index$/p' planning/README.md | sed '$d') \ - && echo "Conventions block: identical" -``` -Expected: `Conventions block: identical`, no diff output. - -- [ ] **Step 3: Verify every Index link resolves** - -```bash -grep -oE '\(changes/[^)]+\)' planning/README.md | tr -d '()' | while read p; do - test -f "planning/$p" || echo "BROKEN: $p" -done; echo "link check done" -``` -Expected: only `link check done` (no `BROKEN` lines). - -- [ ] **Step 4: Commit** - -```bash -git add planning/README.md -git commit -m "docs(planning): add README with portable conventions and index - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -### Task 5: Seed `architecture/` with three capability files - -**Files:** -- Create: `architecture/strategies.md` -- Create: `architecture/providers.md` -- Create: `architecture/cli.md` - -Each file is **living prose, no frontmatter** (dated by git). Write it by -**reading the listed source files first**, then stating the invariants below as -dense, factual prose — match the tone of lite-bootstrap's `architecture/*.md` -(present-tense, names the symbols and files, states the *why* of each -invariant). Do not copy code; describe contracts. Every bullet below is a fact -the file must capture; expand each into prose, correcting any detail the source -contradicts. - -- [ ] **Step 1: Write `architecture/strategies.md`** - -Read first: `semvertag/strategies/_base.py`, `semvertag/strategies/branch_prefix.py`, -`semvertag/strategies/conventional_commits.py`, `semvertag/strategies/__init__.py`, -`semvertag/_commit_parse.py`. - -Capture, with a `# Strategies` H1 and one section per topic: - -- **What a strategy is.** A strategy decides the next semver tag from the - current tags plus repository signal (branch name or commit messages). The - base contract lives in `strategies/_base.py`; name the base type and the - method(s) every strategy implements and what they receive/return. -- **`branch-prefix`** (`branch_prefix.py`) — maps a branch-name prefix to a - bump level; state the default prefix→level mapping and that it recognizes a - GitHub PR merge-commit subject under the defaults (the `#7` fix). Note the - configured-prefix source. -- **`conventional-commits`** (`conventional_commits.py`) — derives the bump - from Conventional Commits parsed by `_commit_parse.py`; state the - type→level mapping (`feat`→minor, `fix`→patch, breaking→major) and how a - commit range is scanned. -- **No-bump path** — when no signal warrants a bump the strategy yields a - no-bump result rather than a forced increment (the `strategy-no-bump-cleanup` - change). State exactly what is returned and how callers detect it. -- **`_commit_parse.py`** — the single place commit subjects/bodies are parsed; - note what it extracts (type, scope, breaking marker). - -- [ ] **Step 2: Write `architecture/providers.md`** - -Read first: `semvertag/providers/_base.py`, `semvertag/providers/gitlab.py`, -`semvertag/providers/github.py`, `semvertag/providers/_errors.py`, -`semvertag/_link_pagination.py`, `semvertag/_redact.py`, `semvertag/_errors.py`. - -Capture, with a `# Providers` H1 and one section per topic: - -- **What a provider is.** A provider is the API adapter for one forge; the base - contract is `providers/_base.py`. Name the abstract operations (list tags, - read commits/branch, create tag) and their signatures. -- **GitLab** (`gitlab.py`) and **GitHub** (`github.py`) — one section each: - the endpoints used, how tags are created, and any auth/header handling. -- **HTTP client (httpware).** Requests go through the httpware-based client - (the `httpware-migration` + `httpware-decoder-adoption` changes); state how - the client is constructed and that responses are decoded via the httpware - decoder. -- **Link-header pagination** (`_link_pagination.py`) — how `Link` headers are - followed to page through tags/commits; name the function and where providers - call it. -- **Secret redaction** (`_redact.py`) — tokens are redacted from errors/log - output; state what is redacted and where it is applied. -- **Errors** (`providers/_errors.py`, `_errors.py`) — the provider error types - and what maps to them (auth failure, not-found, rate-limit, etc.). - -- [ ] **Step 3: Write `architecture/cli.md`** - -Read first: `semvertag/__main__.py`, `semvertag/ioc.py`, `semvertag/_settings.py`, -`semvertag/_use_case.py`, `semvertag/_output.py`, `semvertag/_types.py`, -`action.yml`, `templates/semvertag.yml`. - -Capture, with a `# CLI` H1 and one section per topic: - -- **Entry point** (`__main__.py`) — the Typer app, the command(s) it exposes, - and the `--dry-run` flag (compute the next tag without creating it; the - `dry-run-flag` change). State what `--dry-run` short-circuits. -- **IoC wiring** (`ioc.py`) — the modern-di container; what it provides - (settings, provider, strategy, use-case) and how the CLI resolves the - use-case (the `ioc-idiomatic-modern-di-typer` change). Note the eager-DI - None-field guard if present. -- **Settings** (`_settings.py`) — pydantic-settings model; env + CLI sources, - `AliasChoices` for alternate names (the `settings-aliaschoices` change), and - the `apply_cli_overlay` overlay built on `model_copy(update=...)` (the - `cli-overlay-simplification` change). State precedence (CLI over env over - default). -- **Use-case** (`_use_case.py`) — the callable that wires provider + strategy - to produce/create the tag (the `usecase-callable` change); state its inputs - and return. -- **Output** (`_output.py`) — how results are rendered (human vs machine - output, if both). -- **Distribution wrappers** — `action.yml` is the composite GitHub Action - wrapping the CLI (the `action-yml-composite-wrapper` + `action-yml-dry-run` - changes); `templates/semvertag.yml` is the GitLab CI component. State that - both shell out to the same CLI and pass `--dry-run` through. - -- [ ] **Step 4: Verify the files exist, are non-empty, and carry no frontmatter** - -```bash -for f in strategies providers cli; do - test -s "architecture/$f.md" || echo "MISSING/EMPTY: $f.md" - head -1 "architecture/$f.md" | grep -q '^---$' && echo "HAS FRONTMATTER (remove): $f.md" -done; echo "architecture check done" -``` -Expected: only `architecture check done`. - -- [ ] **Step 5: Verify `mkdocs build --strict` still passes (architecture/ is outside docs_dir)** - -```bash -just docs-build -``` -Expected: exit 0; the published site is unchanged. - -- [ ] **Step 6: Commit** - -```bash -git add architecture -git commit -m "docs(architecture): seed strategies, providers, and cli truth files - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -### Task 6: Rewrite the `CLAUDE.md` Workflow section - -**Files:** -- Modify: `CLAUDE.md` - -Replace the current `## Workflow` section (the Superpowers/brainstorm→plan→TDD -list that points at `planning/specs/` + `planning/plans/`) with the -bundle-convention text below. Leave `## Commit messages`, `## Reference -directories`, and `## What the codebase ships` untouched. - -- [ ] **Step 1: Replace the `## Workflow` section body** - -Use this text (between the `## Workflow` heading and the next `##` heading): - -```markdown -## Workflow - -This project uses **Superpowers** (brainstorm → plan → TDD → review) with the -portable two-axis planning convention. The living truth about *what the system -does now* lives in [`architecture/`](architecture/) at the repo root (one file -per capability: `strategies.md`, `providers.md`, `cli.md`); `planning/` records -*how it got there*. See [`planning/README.md`](planning/README.md) for the full -conventions and the change Index, and [`planning/_templates/`](planning/_templates/) -for copy-and-fill starters. - -Per feature: brainstorming → spec in -`planning/changes/active/YYYY-MM-DD.NN-/design.md` → writing-plans → plan -in the same bundle's `plan.md` → executing-plans / subagent-driven-development → -requesting-code-review → finishing-a-development-branch. `` is a -kebab-case description, not a story ID; `.NN` is a zero-padded intra-day counter -that breaks same-date ties. On merge the bundle moves to -`planning/changes/` with `status: shipped`, `pr:`, and `outcome:` -filled, **and the change promotes its conclusions into the affected -`architecture/.md`** — that hand-edit is what keeps `architecture/` -true. - -**Three lanes.** Scale the artifact to the change. **Full** — a `design.md` + -`plan.md` bundle — for real design judgment, a new file/module, a public-API -change, cross-cutting/multi-file work, or non-trivial test design. -**Lightweight** — a single `change.md` — for small-but-real changes (≲30 LOC -net, ≤2 files, no new file, no public-API change, a single straightforward -test). **Tiny** — no bundle, just a conventional commit — for a typo, dep bump, -linter/formatter/CI tweak, a mechanical rename, or a single-line config change. -Heavier lane wins on ambiguity. - -Use TDD by default: red, green, refactor. Tests before implementation. Use git -worktrees for feature isolation (`superpowers:using-git-worktrees`). Use the -verification gate before claiming work complete -(`superpowers:verification-before-completion`). Request code review via a -subagent before landing (`superpowers:requesting-code-review`). - -Planning artifacts live under `planning/` (not under `docs/`, so they're -excluded from the mkdocs site automatically). When superpowers skills default to -`docs/superpowers/specs/` or `docs/superpowers/plans/`, use the change bundle -under `planning/changes/active/` here instead. -``` - -- [ ] **Step 2: Verify no stale references remain** - -```bash -grep -rn 'planning/specs\|planning/plans' CLAUDE.md README.md mkdocs.yml docs/ 2>/dev/null && echo "STALE FOUND" || echo "no stale references" -``` -Expected: `no stale references`. - -- [ ] **Step 3: Commit** - -```bash -git add CLAUDE.md -git commit -m "docs: rewrite CLAUDE.md workflow for the two-axis convention - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -### Task 7: Final verification sweep - -**Files:** none (verification only). - -- [ ] **Step 1: Repo-wide stale-pointer sweep** - -```bash -grep -rn 'planning/specs\|planning/plans' . --include='*.md' --include='*.yml' --include='*.toml' \ - --exclude-dir=.git --exclude-dir=.venv --exclude-dir=_archive --exclude-dir=_autosemver_reference \ - && echo "STALE FOUND" || echo "no stale references" -``` -Expected: `no stale references`. (The `_archive/` and `_autosemver_reference/` -trees are excluded — do not edit them.) - -- [ ] **Step 2: Lint** - -```bash -just lint-ci -``` -Expected: eof-fixer, ruff format check, ruff check, ty — all clean. - -- [ ] **Step 3: Tests** - -```bash -just test -``` -Expected: full suite passes, coverage unchanged from `main` (no runtime code -touched). - -- [ ] **Step 4: Docs build** - -```bash -just docs-build -``` -Expected: `mkdocs build --strict` exits 0. - -- [ ] **Step 5: Confirm final tree shape** - -```bash -test ! -d planning/specs && test ! -d planning/plans && echo "old dirs gone" -find architecture -name '*.md' | wc -l # expect 3 -find planning/changes -name design.md | wc -l # expect 15 -ls planning/changes/active # expect the convention bundle + .gitkeep -``` -Expected: `old dirs gone`, `3`, `15`, and the active bundle present. - ---- - -## On merge - -Move `planning/changes/active/2026-06-13.01-portable-planning-convention/` → -`planning/changes/`, fill its `design.md` frontmatter (`status: -shipped`, `pr:` = this PR's number, `outcome:`) and `plan.md` (`status: -shipped`, `pr:`), and shift its README Index line from **Active** to -**Archived**. No `architecture/` promotion — this change defines the -convention, not a library capability. diff --git a/planning/changes/2026-06-16.01-httpware-0.12-get-with-response/change.md b/planning/changes/2026-06-16.01-httpware-0.12-get-with-response.md similarity index 100% rename from planning/changes/2026-06-16.01-httpware-0.12-get-with-response/change.md rename to planning/changes/2026-06-16.01-httpware-0.12-get-with-response.md diff --git a/planning/changes/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md b/planning/changes/2026-06-16.02-branch-prefix-patch-on-non-merge.md similarity index 100% rename from planning/changes/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md rename to planning/changes/2026-06-16.02-branch-prefix-patch-on-non-merge.md diff --git a/planning/changes/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md b/planning/changes/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md deleted file mode 100644 index a0b529d..0000000 --- a/planning/changes/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md +++ /dev/null @@ -1,220 +0,0 @@ -# branch-prefix-patch-on-non-merge — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add an opt-in `branch-prefix` config flag, -`patch_on_non_merge_commit` (default `False`), that makes a plain (non-merge) -HEAD commit bump patch instead of producing no bump. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `feat/branch-prefix-patch-on-non-merge` - -**Commit strategy:** Per-task commits (two tasks: code, then docs). - -**Context for an engineer new to this codebase:** - -- The strategy lives in `semvertag/strategies/branch_prefix.py`. `decide(commit)` - returns a `Bump` enum (`NONE | PATCH | MINOR | MAJOR` from - `semvertag/_types.py`). The config is a frozen pydantic model - `BranchPrefixConfig` in the same file. -- `Commit` is a frozen dataclass with `sha` and `message`. -- Tests use `pytest` with `--cov-branch` and `fail_under = 100` (see - `pyproject.toml [tool.pytest.ini_options]` / `[tool.coverage.report]`). Every - branch must be covered or the suite fails. The new ternary has a `True` arm - (new tests) and a `False` arm (existing default-off tests). -- Run tests with `just test`; lint with `just lint-ci`. `just test` uses - `--no-sync`, so if dependencies changed run `uv sync` first — not needed here. -- The config field needs no IoC/settings wiring: `Settings.branch_prefix` - (`semvertag/_settings.py:98`) already carries `BranchPrefixConfig` whole, so a - new field is automatically settable via - `SEMVERTAG_BRANCH_PREFIX__PATCH_ON_NON_MERGE_COMMIT`. - ---- - -### Task 1: Add `patch_on_non_merge_commit` flag and the non-merge fallback - -**Files:** -- Modify: `semvertag/strategies/branch_prefix.py` -- Test: `tests/unit/test_branch_prefix_strategy.py` - -Add the config field and the single `decide` ternary, test-first. - -- [ ] **Step 1: Write the failing tests** - - Append to `tests/unit/test_branch_prefix_strategy.py` (the helpers - `_commit`, `_NON_MERGE_CASES`, `_UNRECOGNIZED_MERGE_CASES`, and the imports - `Bump`, `BranchPrefixConfig`, `BranchPrefixStrategy` already exist at the top - of the file — reuse them, do not redefine): - - ```python - _FALLBACK_STRATEGY: typing.Final = BranchPrefixStrategy( - config=BranchPrefixConfig(patch_on_non_merge_commit=True), - ) - - - @pytest.mark.parametrize("message", [message for message, _ in _NON_MERGE_CASES]) - def test_returns_patch_for_non_merge_commit_when_flag_enabled(message: str) -> None: - assert _FALLBACK_STRATEGY.decide(_commit(message)) is Bump.PATCH - - - def test_flag_leaves_recognized_merge_paths_unchanged() -> None: - assert _FALLBACK_STRATEGY.decide(_commit("Merge branch 'feature/x' into main")) is Bump.MINOR - assert _FALLBACK_STRATEGY.decide(_commit("Merge branch 'bugfix/y' into main")) is Bump.PATCH - - - @pytest.mark.parametrize("message", [message for message, _ in _UNRECOGNIZED_MERGE_CASES]) - def test_flag_leaves_unrecognized_merge_as_none(message: str) -> None: - assert _FALLBACK_STRATEGY.decide(_commit(message)) is Bump.NONE - - - def test_patch_on_non_merge_commit_defaults_to_false() -> None: - assert BranchPrefixConfig().patch_on_non_merge_commit is False - ``` - -- [ ] **Step 2: Run the new tests, verify they fail** - - Run: `just test tests/unit/test_branch_prefix_strategy.py -p no:randomly --override-ini="addopts=" -q` - - Expected: FAIL. `BranchPrefixConfig` uses `ConfigDict(frozen=True)` without - `extra="forbid"`, so pydantic v2 silently *ignores* the unknown - `patch_on_non_merge_commit=True` kwarg — construction does not raise. The - failures are therefore behavioral: `test_returns_patch_for_non_merge_commit_when_flag_enabled` - fails with `AssertionError` (`decide` still returns `Bump.NONE`), and - `test_patch_on_non_merge_commit_defaults_to_false` fails with `AttributeError` - (no such attribute). - -- [ ] **Step 3: Add the config field** - - In `semvertag/strategies/branch_prefix.py`, add the field to - `BranchPrefixConfig` (after `merge_mark_texts`): - - ```python - merge_mark_texts: tuple[_NonEmptyStr, ...] = pydantic.Field( - default=("Merge branch", "Merge pull request"), - min_length=1, - ) - patch_on_non_merge_commit: bool = False - ``` - -- [ ] **Step 4: Add the fallback to `decide`** - - Replace the non-merge exit (currently `return Bump.NONE` under the - `if not any(mark in subject ...)` guard) with: - - ```python - def decide(self, commit: Commit) -> Bump: - subject: typing.Final = subject_line(commit.message) - if not any(mark in subject for mark in self.config.merge_mark_texts): - return Bump.PATCH if self.config.patch_on_non_merge_commit else Bump.NONE - if any(prefix in subject for prefix in self.config.minor): - return Bump.MINOR - if any(prefix in subject for prefix in self.config.patch): - return Bump.PATCH - return Bump.NONE - ``` - - Leave the trailing `return Bump.NONE` (the unrecognized-merge exit) untouched. - -- [ ] **Step 5: Run the strategy tests, verify they pass** - - Run: `just test tests/unit/test_branch_prefix_strategy.py -p no:randomly --override-ini="addopts=" -q` - - Expected: PASS — all existing + 4 new test functions green. - -- [ ] **Step 6: Run the full gated suite and lint** - - Run: `just test` - Expected: PASS — full suite green at 100% branch coverage (the new ternary's - `True` arm is covered by the new tests, the `False` arm by the existing - default-off tests). - - Run: `just lint-ci` - Expected: PASS — eof-fixer, ruff format, ruff check, ty all clean. - -- [ ] **Step 7: Commit** - - ```bash - git add semvertag/strategies/branch_prefix.py tests/unit/test_branch_prefix_strategy.py - git commit -m "strategies: add opt-in patch bump for non-merge commits - - Co-Authored-By: Claude Opus 4.8 (1M context) " - ``` - ---- - -### Task 2: Document the flag (architecture + user docs) - -**Files:** -- Modify: `architecture/strategies.md` -- Modify: `docs/strategies/branch-prefix.md` - -Promote the new behavior into the capability doc and the user-facing docs. - -- [ ] **Step 1: Update `architecture/strategies.md`** - - In the `## branch-prefix` section, extend step 1 (the `Bump.NONE` rule for a - subject with no merge mark) to note the opt-in. Add after the existing step-1 - sentence: - - > When `config.patch_on_non_merge_commit` is `True` (default `False`), this - > non-merge case returns `Bump.PATCH` instead of `Bump.NONE`, so a direct push - > to the default branch auto-tags a patch release. The flag governs only this - > exit — a merge commit with an unrecognized prefix (step 4) still returns - > `Bump.NONE`. - -- [ ] **Step 2: Update `docs/strategies/branch-prefix.md` — detection section** - - In `## Merge-commit detection`, change the bullet: - - ```markdown - - Direct pushes to the default branch → bump = none. - ``` - - to: - - ```markdown - - Direct pushes to the default branch → bump = none, unless - `patch_on_non_merge_commit` is enabled (see below), in which case - bump = patch. - ``` - -- [ ] **Step 3: Update `docs/strategies/branch-prefix.md` — config list** - - In `## Customizing the prefixes`, add a fourth bullet after `merge_mark_texts`: - - ```markdown - - `patch_on_non_merge_commit` — when `true`, a plain (non-merge) commit on - the default branch bumps patch instead of producing no bump (default - `false`). Set via `SEMVERTAG_BRANCH_PREFIX__PATCH_ON_NON_MERGE_COMMIT=true`. - Affects only the non-merge case; a merge commit with an unrecognized prefix - still produces no bump. - ``` - -- [ ] **Step 4: Verify the docs build** - - Run: `mkdocs build --strict` - Expected: PASS — no broken links or warnings. - -- [ ] **Step 5: Commit** - - ```bash - git add architecture/strategies.md docs/strategies/branch-prefix.md - git commit -m "docs: document branch-prefix patch_on_non_merge_commit flag - - Co-Authored-By: Claude Opus 4.8 (1M context) " - ``` - ---- - -## Notes for finishing - -- This bundle is on lane **full** (`design.md` + `plan.md`). On merge: move the - bundle to `planning/changes/` with `status: shipped`, `pr:`, and - `outcome:` filled, and confirm the `architecture/strategies.md` edit from - Task 2 landed (that hand-edit is what keeps `architecture/` true). -- Release tags are bare semver — not relevant to this change, but the flag, once - enabled by a consumer, will cause their next direct push to emit a patch tag. diff --git a/planning/changes/2026-06-16.03-httpware-max-error-body-bytes/design.md b/planning/changes/2026-06-16.03-httpware-max-error-body-bytes.md similarity index 100% rename from planning/changes/2026-06-16.03-httpware-max-error-body-bytes/design.md rename to planning/changes/2026-06-16.03-httpware-max-error-body-bytes.md diff --git a/planning/changes/2026-06-16.03-httpware-max-error-body-bytes/plan.md b/planning/changes/2026-06-16.03-httpware-max-error-body-bytes/plan.md deleted file mode 100644 index 6cf2e27..0000000 --- a/planning/changes/2026-06-16.03-httpware-max-error-body-bytes/plan.md +++ /dev/null @@ -1,281 +0,0 @@ -# httpware-max-error-body-bytes — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build both provider HTTP clients with a 1 MiB `max_error_body_bytes` -cap and translate the resulting `ResponseTooLargeError` into a clear -`ProviderAPIError`. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `feat/httpware-max-error-body-bytes` - -**Commit strategy:** Per-task commits (wiring, translation, docs). - -**Context for an engineer new to this codebase:** - -- Provider HTTP clients are built in `semvertag/ioc.py` by `_build_gitlab_client` - and `_build_github_client`, each returning an `httpware.Client`. httpware - 0.12.0 is already a dependency. -- `httpware.Client(..., max_error_body_bytes=N)` makes the client raise - `httpware.ResponseTooLargeError` on a 4xx/5xx whose declared `Content-Length` - exceeds `N`, before reading the body. The cap is stored on the private - attribute `client._max_error_body_bytes` (httpware exposes no public getter). -- `ResponseTooLargeError` is an `httpware.ClientError` subclass (NOT a - `StatusError`). Its constructor is keyword-only: - `httpware.ResponseTooLargeError(*, status_code: int, limit: int, - content_length: int | None)`. -- Provider errors are translated to the semvertag domain hierarchy in - `semvertag/providers/_errors.py`. `translate_gitlab` / `translate_github` - route any non-`StatusError` `ClientError` to the shared `_translate_transport`, - which currently ends in a generic fallback. -- Tests: `just test` runs the full suite with `--cov-branch` and - `fail_under = 100` — every branch must be covered. The per-file fast loop is - `just test -p no:randomly --override-ini="addopts=" -q` (disables - coverage + random ordering). `ruff` runs with `select = ["ALL"]` but - `tests/**/*.py` already ignores `SLF001`, so reading private members - (`ioc._build_*`, `client._max_error_body_bytes`) in tests needs no suppression. -- `just lint-ci` must pass (eof-fixer, ruff format, ruff check, ty). - ---- - -### Task 1: Cap constant and client wiring - -**Files:** -- Modify: `semvertag/ioc.py` -- Test: `tests/unit/test_ioc.py` - -Add the cap constant and pass it to both client builders, test-first. - -- [ ] **Step 1: Write the failing tests** - - Append to `tests/unit/test_ioc.py` (the imports `typing`, `httpware`, `ioc`, - and the `_settings` helper already exist at the top of the file — reuse them): - - ```python - def test_gitlab_client_is_built_with_error_body_cap() -> None: - client: typing.Final = ioc._build_gitlab_client(_settings()) - assert client._max_error_body_bytes == ioc._MAX_ERROR_BODY_BYTES - - - def test_github_client_is_built_with_error_body_cap() -> None: - client: typing.Final = ioc._build_github_client(_settings()) - assert client._max_error_body_bytes == ioc._MAX_ERROR_BODY_BYTES - - - def test_error_body_cap_is_one_mebibyte() -> None: - assert ioc._MAX_ERROR_BODY_BYTES == 1024 * 1024 - ``` - -- [ ] **Step 2: Run the new tests, verify they fail** - - Run: `just test tests/unit/test_ioc.py -p no:randomly --override-ini="addopts=" -q` - - Expected: FAIL with `AttributeError: module 'semvertag.ioc' has no attribute - '_MAX_ERROR_BODY_BYTES'` (the constant does not exist yet). - -- [ ] **Step 3: Add the constant** - - In `semvertag/ioc.py`, add alongside the other module constants (after - `_RETRY_STATUS_CODES`): - - ```python - _MAX_ERROR_BODY_BYTES: typing.Final = 1024 * 1024 # 1 MiB — defensive cap on 4xx/5xx error bodies - ``` - -- [ ] **Step 4: Pass the cap to both builders** - - In `_build_gitlab_client`, add the argument to the `httpware.Client(...)` call: - - ```python - def _build_gitlab_client(settings: Settings) -> httpware.Client: - return httpware.Client( - base_url=settings.gitlab.endpoint, - timeout=settings.request_timeout, - headers={_GITLAB_TOKEN_HEADER: settings.gitlab.token.get_secret_value()}, - middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], - max_error_body_bytes=_MAX_ERROR_BODY_BYTES, - ) - ``` - - In `_build_github_client`, add the same argument: - - ```python - def _build_github_client(settings: Settings) -> httpware.Client: - return httpware.Client( - base_url=settings.github.endpoint, - timeout=settings.request_timeout, - headers={ - "Authorization": f"Bearer {settings.github.token.get_secret_value()}", - "Accept": _GITHUB_ACCEPT, - "X-GitHub-Api-Version": _GITHUB_API_VERSION, - }, - middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], - max_error_body_bytes=_MAX_ERROR_BODY_BYTES, - ) - ``` - -- [ ] **Step 5: Run the ioc tests, verify they pass** - - Run: `just test tests/unit/test_ioc.py -p no:randomly --override-ini="addopts=" -q` - Expected: PASS — the three new tests plus the existing ones green. - -- [ ] **Step 6: Commit** - - ```bash - git add semvertag/ioc.py tests/unit/test_ioc.py - git commit -m "providers: cap provider error-body reads at 1 MiB - - Co-Authored-By: Claude Opus 4.8 (1M context) " - ``` - ---- - -### Task 2: Translate ResponseTooLargeError - -**Files:** -- Modify: `semvertag/providers/_errors.py` -- Test: `tests/unit/test_providers_errors.py` - -Map `ResponseTooLargeError` to an actionable `ProviderAPIError`, test-first. - -- [ ] **Step 1: Write the failing tests** - - Append to `tests/unit/test_providers_errors.py` (the imports `httpware`, - `ProviderAPIError`, `translate_gitlab`, `translate_github`, and the constant - `_PROJECT_ID` already exist at the top of the file — reuse them): - - ```python - def test_translate_gitlab_response_too_large_becomes_provider_api_error() -> None: - exc = httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, content_length=5_000_000) - result = translate_gitlab(exc, project_id=_PROJECT_ID) - assert isinstance(result, ProviderAPIError) - assert "GitLab" in str(result) - assert "5000000" in str(result) - assert "1048576" in str(result) - - - def test_translate_github_response_too_large_becomes_provider_api_error() -> None: - exc = httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, content_length=5_000_000) - result = translate_github(exc, repo="owner/repo") - assert isinstance(result, ProviderAPIError) - assert "GitHub" in str(result) - assert "5000000" in str(result) - ``` - -- [ ] **Step 2: Run the new tests, verify they fail** - - Run: `just test tests/unit/test_providers_errors.py -p no:randomly --override-ini="addopts=" -q` - - Expected: FAIL with `AssertionError`. `ResponseTooLargeError` is a - `ClientError`, so it currently falls through `_translate_transport` to the - generic fallback `"GitLab request failed: ResponseTooLargeError"`, which - contains neither `"5000000"` nor `"1048576"`. - -- [ ] **Step 3: Add the translation branch** - - In `semvertag/providers/_errors.py`, inside `_translate_transport`, add a - branch before the final `return` fallback (placement among the other - `isinstance` branches is fine — the types are disjoint): - - ```python - if isinstance(exc, httpware.ResponseTooLargeError): - return ProviderAPIError( - f"{provider_label} returned an error response body of {exc.content_length} bytes, " - f"exceeding the {exc.limit}-byte cap (HTTP {exc.status_code}); refusing to read it." - ) - ``` - - For reference, the function becomes: - - ```python - def _translate_transport(exc: httpware.ClientError, *, provider_label: str) -> Exception: - if isinstance(exc, httpware.DecodeError): - return ProviderAPIError(f"{provider_label} {exc.model.__name__} response could not be decoded: {exc.original}") - if isinstance(exc, httpware.TimeoutError): - return ProviderAPIError(f"{provider_label} request timed out. Try again or increase SEMVERTAG_REQUEST_TIMEOUT.") - if isinstance(exc, httpware.RetryBudgetExhaustedError): - return ProviderAPIError(f"{provider_label} retries exhausted after {exc.attempts} attempts. Try again later.") - if isinstance(exc, httpware.NetworkError): - return ProviderAPIError(f"{provider_label} unreachable. Check network connectivity.") - if isinstance(exc, httpware.ResponseTooLargeError): - return ProviderAPIError( - f"{provider_label} returned an error response body of {exc.content_length} bytes, " - f"exceeding the {exc.limit}-byte cap (HTTP {exc.status_code}); refusing to read it." - ) - return ProviderAPIError(f"{provider_label} request failed: {type(exc).__name__}") - ``` - -- [ ] **Step 4: Run the error tests, verify they pass** - - Run: `just test tests/unit/test_providers_errors.py -p no:randomly --override-ini="addopts=" -q` - Expected: PASS — both new tests plus the existing ones green. - -- [ ] **Step 5: Run the full gated suite and lint** - - Run: `just test` - Expected: PASS — full suite green at 100% branch coverage (the new branch's - taken path is covered by both new translation tests). - - Run: `just lint-ci` - Expected: PASS — eof-fixer, ruff format, ruff check, ty all clean. - -- [ ] **Step 6: Commit** - - ```bash - git add semvertag/providers/_errors.py tests/unit/test_providers_errors.py - git commit -m "providers: translate ResponseTooLargeError to ProviderAPIError - - Co-Authored-By: Claude Opus 4.8 (1M context) " - ``` - ---- - -### Task 3: Document the cap - -**Files:** -- Modify: `architecture/providers.md` - -Promote the hardening into the capability doc. - -- [ ] **Step 1: Update `architecture/providers.md`** - - In the HTTP-client section (the `## HTTP client` heading near the end of the - file), add a sentence noting the cap. Match the surrounding prose style: - - > Both clients are built with a 1 MiB `max_error_body_bytes` cap - > (`semvertag/ioc.py`): httpware raises `ResponseTooLargeError` on a 4xx/5xx - > whose declared `Content-Length` exceeds the cap, before reading the body, as - > a defensive bound against a hostile or malfunctioning endpoint. The error is - > an `httpware.ClientError` (not a `StatusError`), so `_translate_transport` - > maps it to `ProviderAPIError`. Real GitLab/GitHub error bodies are tiny JSON - > and never approach the cap. - -- [ ] **Step 2: Verify the docs build** - - Run: `just docs-build` - Expected: PASS — strict mkdocs build with no warnings. (`architecture/` is - outside the mkdocs site, so this mainly confirms nothing else broke; run it - anyway as the docs gate.) - -- [ ] **Step 3: Commit** - - ```bash - git add architecture/providers.md - git commit -m "docs: note the 1 MiB provider error-body cap - - Co-Authored-By: Claude Opus 4.8 (1M context) " - ``` - ---- - -## Notes for finishing - -- Lane **full** (`design.md` + `plan.md`). On merge: move the bundle to - `planning/changes/` with `status: shipped`, `pr:`, `outcome:` filled; - confirm the `architecture/providers.md` edit landed; and remove the - "httpware bounded-error-body adoption" item from `planning/deferred.md` (this - change resolves it). diff --git a/planning/changes/2026-06-24.01-default-branch-override/design.md b/planning/changes/2026-06-24.01-default-branch-override.md similarity index 100% rename from planning/changes/2026-06-24.01-default-branch-override/design.md rename to planning/changes/2026-06-24.01-default-branch-override.md diff --git a/planning/changes/2026-06-24.01-default-branch-override/plan.md b/planning/changes/2026-06-24.01-default-branch-override/plan.md deleted file mode 100644 index 0500489..0000000 --- a/planning/changes/2026-06-24.01-default-branch-override/plan.md +++ /dev/null @@ -1,44 +0,0 @@ -# default-branch-override — implementation plan - -**Goal:** Make `--default-branch` / `SEMVERTAG_DEFAULT_BRANCH` actually override -the resolved default branch, skipping the default-branch API call when set. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `fix/default-branch-override` - -**Commit strategy:** Single commit (small, cohesive bug fix). - -TDD throughout: red (failing tests) → green (implement) → refactor. - ---- - -### Task 1: Failing tests (red) - -**Files:** -- Modify: `tests/integration/test_github_provider.py` -- Modify: `tests/integration/test_gitlab_provider.py` -- Modify: `tests/unit/test_ioc.py` -- Modify: `tests/unit/test_settings.py` - -- [x] Provider tests (both forges): override returns without hitting the - repo/project endpoint; commit lookup uses the override and skips the - default-branch GET. -- [x] IoC test: `_build_current_provider` propagates `settings.default_branch`. -- [x] Settings test: blank `default_branch` (empty/whitespace) → `None`; padded name stripped. -- [x] Confirm the new provider tests fail (field/short-circuit absent). - -### Task 2: Implement (green) - -**Files:** -- Modify: `semvertag/_settings.py` — field validator normalizing blank `default_branch` → `None` -- Modify: `semvertag/providers/github.py` — add field + short-circuit -- Modify: `semvertag/providers/gitlab.py` — add field + short-circuit -- Modify: `semvertag/ioc.py` — wire `default_branch=settings.default_branch` - -- [x] Implement; run `just test` to green, `just lint-ci` clean. - -### Task 3: Promote + ship - -- [x] Update `architecture/providers.md` to document the override seam. -- [x] `just index`; set `status: shipped`, fill `pr`/`outcome` at merge. diff --git a/planning/changes/2026-06-24.02-closed-outcome-type/design.md b/planning/changes/2026-06-24.02-closed-outcome-type.md similarity index 100% rename from planning/changes/2026-06-24.02-closed-outcome-type/design.md rename to planning/changes/2026-06-24.02-closed-outcome-type.md diff --git a/planning/changes/2026-06-24.02-closed-outcome-type/plan.md b/planning/changes/2026-06-24.02-closed-outcome-type/plan.md deleted file mode 100644 index a41fdb3..0000000 --- a/planning/changes/2026-06-24.02-closed-outcome-type/plan.md +++ /dev/null @@ -1,70 +0,0 @@ -# closed-outcome-type — implementation plan - -**Goal:** Replace the free-form run-status strings with a closed `Outcome` sum -the renderers dispatch over exhaustively, keeping the JSON wire envelope -byte-identical. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `refactor/closed-outcome-type` - -**Commit strategy:** Per-task commits; squash on merge. - -TDD throughout: red → green → refactor. Keep `just lint-ci` + `just test` -(100% branch) green at each task boundary. - ---- - -### Task 1: `_outcome.py` — the closed sum + wire mapping - -**Files:** -- Create: `semvertag/_outcome.py` -- Create: `tests/unit/test_outcome.py` - -Define the 5 frozen/slotted/kw-only variants, the `Outcome` union alias, and -`to_run_result(outcome, *, strategy) -> RunResult` (single exhaustive `match`, -`assert_never` final arm). Move `_NO_TAGS_REASON` / `_ALREADY_TAGGED_REASON` here. - -- [x] Red: `test_outcome.py` asserts each variant → expected `RunResult` wire - fields (status token, `bump`, tag/None, reason, commit); `bump="none"` for - NoTags/AlreadyTagged/NoBump; NoBump token+reason passthrough. -- [x] Green: implement `_outcome.py`. `# pragma: no cover` the `assert_never` arm. -- [x] `just lint-ci` clean (confirm `ty` accepts the match + alias). -- [x] Commit. - -### Task 2: `Output.emit(outcome: Outcome)` — renderers dispatch - -**Files:** -- Modify: `semvertag/_output.py` -- Modify: `tests/unit/test_output_rich.py`, `tests/unit/test_output_json.py` - -`Output.emit` takes `Outcome`. `RichOutput.emit` matches → human sentence per -variant (new no-bump wording, no raw token). `JsonOutput.emit` calls -`to_run_result` → `asdict` → JSON. Drop `_format_result`. - -- [x] Red/update: `test_output_json` inputs become `Outcome` variants; envelope - assertions unchanged (guardrail). `test_output_rich` updated to new wording. -- [x] Green: implement; second `assert_never` arm in `RichOutput.emit`. -- [x] `just test` green; `test_output_json` proves byte-identical envelope. -- [x] Commit. - -### Task 3: use-case returns `Outcome` - -**Files:** -- Modify: `semvertag/_use_case.py` -- Modify: `tests/unit/test_use_case.py` - -`__call__` builds + returns variants, calls `output.emit(outcome)`. Remove -`_emit`, the `status="…"` literals, and the reason constants (now in `_outcome`). - -- [x] Update `test_use_case` to assert on returned `Outcome` variants. -- [x] Green: `just test` + `just lint-ci`. -- [x] Commit. - -### Task 4: exhaustiveness proof + promote - -- [x] Deliberate red: add a throwaway 6th variant, confirm `ty` fails BOTH - matches (`to_run_result`, `RichOutput.emit`), then revert. -- [x] Promote `architecture/cli.md` (Output + use-case sections) to describe the - `Outcome` seam and `to_run_result`. -- [x] `just index`; set `status: shipped`, fill `pr`/`outcome` at merge. diff --git a/planning/changes/2026-06-25.01-tag-driven-release/design.md b/planning/changes/2026-06-25.01-tag-driven-release.md similarity index 100% rename from planning/changes/2026-06-25.01-tag-driven-release/design.md rename to planning/changes/2026-06-25.01-tag-driven-release.md diff --git a/planning/changes/2026-06-25.01-tag-driven-release/plan.md b/planning/changes/2026-06-25.01-tag-driven-release/plan.md deleted file mode 100644 index 3e0223c..0000000 --- a/planning/changes/2026-06-25.01-tag-driven-release/plan.md +++ /dev/null @@ -1,386 +0,0 @@ -# tag-driven-release — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make a hand-pushed bare semver tag the sole release entry point — -one `release.yml` publishes to PyPI, creates the GitHub Release, and floats -`v0` — and stop the dogfood from auto-tagging. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `ci/tag-driven-release` - -**Commit strategy:** Per-task commits; single PR; squash on merge. - -## Global constraints (copy verbatim, every task) - -- **Release tags are bare semver, no `v` prefix** (`0.9.0`, not `v0.9.0`). - `$GITHUB_REF_NAME` is the bare tag. -- **Action pins:** `actions/checkout@v6`, `extractions/setup-just@v4`, - `astral-sh/setup-uv@v7` (the `@v7` pin matches the org canonical template). -- **PyPI is irreversible → it runs first.** Nothing user-facing (Release, `v0`) - is created before `just publish` succeeds. -- **PyPI auth is the existing `PYPI_TOKEN` secret** (not OIDC). Reused under the - same name. -- **Pre-release tags use PEP 440** (`0.9.0rc1`), detected by a letter in the tag. -- These are CI/docs YAML + Markdown changes; the 100%-branch pytest gate is - Python-only and does not apply. Verification is YAML-parse + grep + lint + - strict docs build. -- Commit messages: imperative, scoped (`ci:` / `docs:`), no story prefixes; end - with `Co-Authored-By: Claude Opus 4.8 (1M context) `. - ---- - -### Task 1: Add tag-driven `release.yml`; delete `publish.yml` and `tag-major.yml` - -**Files:** -- Create: `.github/workflows/release.yml` -- Delete: `.github/workflows/publish.yml` -- Delete: `.github/workflows/tag-major.yml` - -Replace the manual `release: published` publish gate and the separate -`tag-major` workflow with one tag-driven workflow. The `v0` float logic from -`tag-major.yml` is folded into `release.yml`'s final step. - -- [ ] **Step 1: Create `.github/workflows/release.yml`** with exactly this content: - - ```yaml - name: Release - - # Tag-driven: pushing a bare semver tag publishes to PyPI, creates the matching - # GitHub Release, and floats the `v0` action tag. Replaces the old - # `release: published` publish.yml (deleted) and folds in tag-major.yml (deleted). - # - # The tag is the sole, deliberate entry point. semvertag.yml dogfoods in - # DRY-RUN, so it never pushes a tag — only a maintainer's manual `git push` of - # a tag reaches here (GitHub suppresses workflow triggers from - # GITHUB_TOKEN-pushed refs, so even an auto-push would not fire this). By - # convention a tag is cut only off green main, so there is no in-workflow CI gate. - on: - push: - tags: - - '[0-9]+.[0-9]+.[0-9]+' # stable: 0.9.0 - - '[0-9]+.[0-9]+.[0-9]+[a-z]+[0-9]+' # pre-release: 0.9.0rc1, 1.0.0a2 - - # Needed for softprops/action-gh-release to create the Release and for the - # v0 force-push. - permissions: - contents: write - - jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: extractions/setup-just@v4 - - uses: astral-sh/setup-uv@v7 - - # PyPI is irreversible, so it runs FIRST: if it fails the job stops and no - # GitHub Release or v0 move advertises a version that never reached PyPI. - # `just publish` derives the version from $GITHUB_REF_NAME (the tag name). - - run: just publish - env: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - - # Description source: planning/releases/.md if present (verbatim, no - # auto-changelog appended); otherwise GitHub's generated notes. A tag with - # a letter (0.9.0rc1) is a pre-release -> flagged so GitHub won't mark it - # "Latest" and so the v0 float below is skipped. - - name: Resolve release metadata - id: meta - run: | - set -euo pipefail - notes="planning/releases/${GITHUB_REF_NAME}.md" - if [ -f "$notes" ]; then - echo "body_path=$notes" >> "$GITHUB_OUTPUT" - echo "generate_notes=false" >> "$GITHUB_OUTPUT" - else - echo "generate_notes=true" >> "$GITHUB_OUTPUT" - fi - if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then - echo "prerelease=true" >> "$GITHUB_OUTPUT" - else - echo "prerelease=false" >> "$GITHUB_OUTPUT" - fi - - - name: Publish GitHub Release - uses: softprops/action-gh-release@v3 - with: - body_path: ${{ steps.meta.outputs.body_path }} - generate_release_notes: ${{ steps.meta.outputs.generate_notes }} - prerelease: ${{ steps.meta.outputs.prerelease }} - draft: false - - # Floating major tag (folded-in tag-major.yml): consumers pin - # `uses: modern-python/semvertag@v0` and ride minor bumps. Skipped on - # pre-releases so a 0.9.0rc1 doesn't drag v0 ahead of the latest stable. - # References HEAD (the tag commit), so no fetch-depth: 0 is needed. - - name: Float major tag - if: steps.meta.outputs.prerelease == 'false' - run: | - set -euo pipefail - major="v${GITHUB_REF_NAME%%.*}" # 0.9.0 -> v0 - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git tag -fa "$major" -m "Update $major to $GITHUB_REF_NAME" - git push -f origin "$major" - ``` - -- [ ] **Step 2: Delete the two superseded workflows** - - ```bash - git rm .github/workflows/publish.yml .github/workflows/tag-major.yml - ``` - -- [ ] **Step 3: Verify `release.yml` parses as YAML** - - Run: - ```bash - python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml'))" && echo OK - ``` - Expected: `OK` - -- [ ] **Step 4: Verify the deletions are clean and nothing references them** - - Run (scoped to live config — `planning/` keeps its historical references on purpose): - ```bash - ls .github/workflows/ - grep -rn 'publish\.yml\|tag-major' .github/ mkdocs.yml docs/ || echo CLEAN - ``` - Expected: `publish.yml` and `tag-major.yml` absent from the listing. The grep - still prints one line — `semvertag.yml`'s stale `does NOT trigger publish.yml` - comment — which Task 2 rewrites. That single hit is expected here; do not fix - it in this task. No `tag-major` hits, no hits in `mkdocs.yml`/`docs/`. - -- [ ] **Step 5: Commit** - - ```bash - git add .github/workflows/release.yml - git commit -m "ci: replace publish.yml + tag-major.yml with tag-driven release.yml - - Co-Authored-By: Claude Opus 4.8 (1M context) " - ``` - ---- - -### Task 2: Switch the dogfood (`semvertag.yml`) to dry-run - -**Files:** -- Modify: `.github/workflows/semvertag.yml` - -The auto-tagger must stop pushing tags, so the only tags that reach the repo are -deliberate, maintainer-pushed release tags. It still exercises `action.yml` on -every push to `main` via `--dry-run`. - -- [ ] **Step 1: Replace the entire contents of `.github/workflows/semvertag.yml`** with: - - ```yaml - name: semvertag - - # Dogfood the local composite action against this repo in DRY-RUN: on every - # push to main it computes the planned bump (exercising action.yml + the - # published semvertag) but never pushes a tag. This keeps action.yml honest — - # a breaking change fails the run before it can affect external users. - # - # Releases are NOT cut here. A release is a maintainer pushing a bare semver - # tag by hand, which triggers .github/workflows/release.yml (PyPI + GitHub - # Release + v0). Dry-run is load-bearing: it guarantees the only tags in the - # repo are deliberate release tags — do not give this job a push token or - # remove `dry-run: true`. - # - # This repo's branch convention uses `feat/...`, so SEMVERTAG_BRANCH_PREFIX__MINOR - # overrides the default `feature/` mapping. - - on: - push: - branches: [main] - - permissions: - contents: read - - concurrency: - group: semvertag - cancel-in-progress: false - - jobs: - tag: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - uses: ./ - with: - dry-run: true - env: - SEMVERTAG_BRANCH_PREFIX__MINOR: '["feat/"]' - ``` - -- [ ] **Step 2: Verify it parses and the dry-run wiring is present** - - Run: - ```bash - python3 -c "import yaml; yaml.safe_load(open('.github/workflows/semvertag.yml'))" && echo OK - grep -n 'dry-run: true\|contents: read' .github/workflows/semvertag.yml - ``` - Expected: `OK`, and both `dry-run: true` and `contents: read` print. - -- [ ] **Step 3: Commit** - - ```bash - git add .github/workflows/semvertag.yml - git commit -m "ci: run the semvertag dogfood in dry-run (stop auto-tagging) - - Co-Authored-By: Claude Opus 4.8 (1M context) " - ``` - ---- - -### Task 3: Move the release runbook out of user-facing docs - -**Files:** -- Delete: `docs/contributing/release.md` -- Modify: `mkdocs.yml` (remove the `Contributing:` nav node) -- Modify: `CLAUDE.md` (add maintainer release note; repoint the `v0` paragraph) - -The release process is maintainer-only and should not ship in the docs site. It -lives in `CLAUDE.md` instead. `release.md` is the only page under -`docs/contributing/`, so its whole nav node goes too. - -- [ ] **Step 1: Delete the runbook page** - - ```bash - git rm docs/contributing/release.md - ``` - -- [ ] **Step 2: Remove the `Contributing:` nav node from `mkdocs.yml`** - - Delete these two lines (currently lines 14–15): - ```yaml - - Contributing: - - Release runbook: contributing/release.md - ``` - After the edit, the `nav:` block ends with the `Strategies:` node - (`Conventional commits: strategies/conventional-commits.md`). - -- [ ] **Step 3: Add the maintainer release note to `CLAUDE.md`** - - In the `## Workflow` section, immediately after the paragraph that ends - "...use the change bundle under `planning/changes/` here instead." (the - "Planning artifacts live under `planning/`..." paragraph), insert a blank line - then this paragraph: - - ```markdown - **Cutting a release (maintainers)** is tag-driven via - [`.github/workflows/release.yml`](.github/workflows/release.yml): write the - notes at `planning/releases/.md` (used verbatim as the GitHub Release - body), then push a bare semver tag off green `main` — - `git tag 0.9.0 && git push origin 0.9.0`. The workflow runs `just publish` (the - tag sets the version via `uv version $GITHUB_REF_NAME`; no `pyproject.toml` - bump) to PyPI, then creates the GitHub Release, then floats the `v0` action tag - — PyPI first, so a failed publish creates no Release. Pre-releases use the - PEP 440 form (`0.9.0rc1`, not `0.9.0-rc1`). PyPI is irreversible; there is no - CI gate (a tag is the commitment point). The dogfood (`semvertag.yml`) runs in - dry-run and never auto-tags, so the tag you push is the only tag. - ``` - -- [ ] **Step 4: Repoint the `v0` paragraph in the "Tag and release naming" section** - - In `CLAUDE.md`, replace the second bullet (currently referencing - `.github/workflows/tag-major.yml`): - - ```markdown - - **Action floating tag: `v`-prefixed** (`v0`). `.github/workflows/tag-major.yml` - strips any leading `v` from the release tag then prepends `v` to the major - segment (`0.4.0` → `v0`), so consumers can pin `uses: modern-python/semvertag@v0` - per the GHA ecosystem convention. When touching `tag-major.yml` or - action-consumer docs, think `v`-prefix. - ``` - - with: - - ```markdown - - **Action floating tag: `v`-prefixed** (`v0`). The `Float major tag` step in - [`.github/workflows/release.yml`](.github/workflows/release.yml) prepends `v` - to the release tag's major segment (`0.4.0` → `v0`) and force-updates the - floating tag, so consumers can pin `uses: modern-python/semvertag@v0` per the - GHA ecosystem convention. Skipped on pre-releases. When touching that step or - action-consumer docs, think `v`-prefix. - ``` - -- [ ] **Step 5: Verify the docs build strictly and no stale refs remain** - - Run: - ```bash - grep -rn 'tag-major\|publish\.yml\|contributing/release' mkdocs.yml CLAUDE.md docs/ || echo CLEAN - just docs-build - ``` - Expected: grep prints `CLEAN`; `mkdocs build --strict` succeeds (proves the nav - no longer points at the deleted page and there are no dead links to it). - -- [ ] **Step 6: Commit** - - ```bash - git add docs/contributing/release.md mkdocs.yml CLAUDE.md - git commit -m "docs: move the release runbook into CLAUDE.md (maintainer-only) - - Co-Authored-By: Claude Opus 4.8 (1M context) " - ``` - ---- - -### Task 4: Ship-time bookkeeping - -**Files:** -- Modify: `planning/changes/2026-06-25.01-tag-driven-release/design.md` (frontmatter) -- Modify: `planning/changes/2026-06-25.01-tag-driven-release/plan.md` (frontmatter) - -The implementing PR flips the bundle to `shipped` per the project convention. -No `architecture/` promotion — the release flow is not one of the capability -files (`strategies.md` / `providers.md` / `cli.md`). - -- [ ] **Step 1: Run the full verification gate once more** - - Run: - ```bash - python3 -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml')); yaml.safe_load(open('.github/workflows/semvertag.yml'))" && echo YAML-OK - just lint-ci - just docs-build - ``` - Expected: `YAML-OK`, `lint-ci` clean, strict docs build succeeds. - -- [ ] **Step 2: Set `status: shipped` and fill `pr` in both frontmatters** - - In `design.md`: `status: draft` → `status: shipped`, and set `pr:` to the PR - number/URL. In `plan.md`: set `pr:` likewise. (Do this once the PR exists.) - -- [ ] **Step 3: Regenerate the change index** - - ```bash - just index - ``` - -- [ ] **Step 4: Commit** - - ```bash - git add planning/changes/2026-06-25.01-tag-driven-release/ - git commit -m "planning: ship tag-driven release bundle - - Co-Authored-By: Claude Opus 4.8 (1M context) " - ``` - ---- - -## Post-merge (maintainer, not a code task) - -The true integration test is the next real release. When this PR merges to -`main`, the merge commit already carries the dry-run `semvertag.yml`, so the -merge itself creates no auto-tag. To cut the first release on the new flow: - -1. Ensure `planning/releases/.md` exists for the target version. -2. Off green `main`: `git tag && git push origin `. -3. Watch `release.yml`: PyPI upload (version from the tag) → GitHub Release built - from the notes file → `v0` repointed at the tag commit. diff --git a/planning/changes/2026-06-26.01-load-settings-pipeline/design.md b/planning/changes/2026-06-26.01-load-settings-pipeline.md similarity index 100% rename from planning/changes/2026-06-26.01-load-settings-pipeline/design.md rename to planning/changes/2026-06-26.01-load-settings-pipeline.md diff --git a/planning/changes/2026-06-26.01-load-settings-pipeline/plan.md b/planning/changes/2026-06-26.01-load-settings-pipeline/plan.md deleted file mode 100644 index 02e90f4..0000000 --- a/planning/changes/2026-06-26.01-load-settings-pipeline/plan.md +++ /dev/null @@ -1,294 +0,0 @@ -# load-settings-pipeline — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Consolidate the env→CLI→validate→token settings pipeline behind one -public `load_settings` entry point in `semvertag/_settings.py` that raises only -`ConfigError`, shrinking `__main__` to flag-collection + error-to-exit mapping. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `refactor/load-settings-pipeline` - -**Commit strategy:** Per-task commits. - -## Global Constraints - -- Python imports at module level only — never inside function bodies. -- Annotate every test function argument (including fixtures). -- `just test` runs pytest with `--cov-branch` and `fail_under = 100`: every new - branch must be covered. -- `just lint-ci` is check-only and runs the planning validator - (`just check-planning`); `just lint` autofixes. -- Behavior-preserving: no change to `Settings` fields/validators/aliases, - precedence semantics, the `Provider` protocol, or process exit codes. -- The `ioc._build_current_provider` asserts stay untouched (out of scope). - ---- - -### Task 1: Add `load_settings` (additive) with its unit test surface - -**Files:** -- Modify: `semvertag/_settings.py` -- Test: `tests/unit/test_settings.py` - -**Interfaces:** -- Consumes: existing `Settings`, `apply_cli_overlay` (still public at this point), - `pydantic`, and `semvertag._errors.ConfigError`. -- Produces: `load_settings(cli_overrides: dict[str, typing.Any], *, token: str | None = None) -> Settings` - and module-private `_config_error_from_validation(exc: pydantic.ValidationError) -> ConfigError`. - -This task is purely additive — `apply_cli_overlay` stays public and `__main__` is -untouched, so nothing breaks. `load_settings` replicates `__main__`'s current -orchestration so its tests characterize today's behavior. - -- [ ] **Step 1: Write the failing tests** - - Add the two imports to the **module-level import block** at the top of - `tests/unit/test_settings.py` (not inside the appended block — imports stay at - module level): `from semvertag._errors import ConfigError` and add - `load_settings` to the existing `from semvertag._settings import ...` line. - Then append the test functions: - - ```python - @pytest.mark.usefixtures("clean_settings_env") - def test_load_settings_cli_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("SEMVERTAG_STRATEGY", "conventional-commits") - settings = load_settings({"strategy": "branch-prefix", "provider": "gitlab", "project_id": 1}) - assert settings.strategy == "branch-prefix" - - - @pytest.mark.usefixtures("clean_settings_env") - def test_load_settings_uses_defaults_when_unset() -> None: - settings = load_settings({"provider": "gitlab", "project_id": 1}) - assert settings.request_timeout == _TIMEOUT_DEFAULT_VALUE - - - @pytest.mark.usefixtures("clean_settings_env") - def test_load_settings_routes_token_to_explicit_github() -> None: - settings = load_settings({"provider": "github", "repo": "o/r"}, token=_PLAINTEXT_SECRET) - assert settings.github.token.get_secret_value() == _PLAINTEXT_SECRET - assert settings.gitlab.token.get_secret_value() == "" - - - @pytest.mark.usefixtures("clean_settings_env") - def test_load_settings_routes_token_to_explicit_gitlab() -> None: - settings = load_settings({"provider": "gitlab", "project_id": 1}, token=_PLAINTEXT_SECRET) - assert settings.gitlab.token.get_secret_value() == _PLAINTEXT_SECRET - - - @pytest.mark.usefixtures("clean_settings_env") - def test_load_settings_routes_token_to_autodetected_provider(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("GITLAB_CI", raising=False) - monkeypatch.setenv("GITHUB_ACTIONS", "true") - settings = load_settings({"repo": "o/r"}, token=_PLAINTEXT_SECRET) - assert settings.provider == "github" - assert settings.github.token.get_secret_value() == _PLAINTEXT_SECRET - - - @pytest.mark.usefixtures("clean_settings_env") - def test_load_settings_translates_validation_error_to_config_error() -> None: - with pytest.raises(ConfigError): - load_settings({"provider": "gitlab"}) # gitlab requires project_id - - - @pytest.mark.usefixtures("clean_settings_env") - def test_load_settings_translates_overlay_value_error_to_config_error() -> None: - with pytest.raises(ConfigError): - load_settings({"provider": "gitlab", "project_id": 1, "gitlab.foo.bar": "x"}) - ``` - -- [ ] **Step 2: Run the tests to verify they fail** - - Run: `just test tests/unit/test_settings.py -q` - Expected: FAIL — `ImportError: cannot import name 'load_settings'`. - -- [ ] **Step 3: Implement `load_settings` and the relocated translator** - - In `semvertag/_settings.py`, add the import near the top - (`from semvertag._errors import ConfigError` — `_errors` imports nothing from - `semvertag`, so no cycle) and append: - - ```python - def _config_error_from_validation(exc: pydantic.ValidationError) -> ConfigError: - first: typing.Final = exc.errors()[0] - loc: typing.Final = ".".join(str(part) for part in first.get("loc", ())) - detail: typing.Final = first.get("msg", "invalid value") - msg: typing.Final = ( - f"Configuration error at '{loc}': {detail}. Check environment variables and command-line flags." - ) - return ConfigError(msg) - - - def load_settings(cli_overrides: dict[str, typing.Any], *, token: str | None = None) -> Settings: - """Build a validated Settings from environment + CLI overrides. - - Owns the whole pipeline: split top-level vs dotted once, construct (env + - top-level), overlay nested, then route --token to the resolved provider. - Raises only ConfigError on any invalid input. - """ - top_overrides: typing.Final = {k: v for k, v in cli_overrides.items() if "." not in k} - nested_overrides: typing.Final = {k: v for k, v in cli_overrides.items() if "." in k} - try: - settings = Settings(**top_overrides) - settings = apply_cli_overlay(settings, nested_overrides) - if token is not None: - settings = apply_cli_overlay(settings, {f"{settings.provider}.token": pydantic.SecretStr(token)}) - except pydantic.ValidationError as exc: - raise _config_error_from_validation(exc) from exc - except ValueError as exc: # _apply_cli_overlay depth-2 guard; ValidationError caught above - raise ConfigError(str(exc)) from exc - return settings - ``` - - Note: `pydantic.ValidationError` subclasses `ValueError`, so the - `ValidationError` arm must precede the `ValueError` arm. - -- [ ] **Step 4: Run the tests to verify they pass** - - Run: `just test tests/unit/test_settings.py -q` - Expected: PASS, all new tests green. - -- [ ] **Step 5: Commit** - - ```bash - git add semvertag/_settings.py tests/unit/test_settings.py - git commit -m "settings: add load_settings pipeline entry point" - ``` - ---- - -### Task 2: Rewire `__main__` to `load_settings`; privatize the overlay - -**Files:** -- Modify: `semvertag/__main__.py` -- Modify: `semvertag/_settings.py` -- Modify: `tests/unit/test_settings.py:163-172` (overlay tests) -- Modify: `tests/integration/test_cli_errors.py:95-107` - -**Interfaces:** -- Consumes: `load_settings` from Task 1. -- Produces: `_apply_cli_overlay` (renamed from `apply_cli_overlay`, now private); - `__main__` no longer exports `apply_cli_overlay` or `_config_error_from_validation`. - -Swap the CLI orchestration for a single `load_settings` call, then privatize the -overlay now that nothing public calls it. The existing CLI/integration precedence -tests must stay green — that is the behavior-preservation proof. - -- [ ] **Step 1: Replace the orchestration block in `_main_callback`** - - In `semvertag/__main__.py`, replace lines 136-155 (the - `top_overrides`/`Settings(**...)`/`apply_cli_overlay`/token/except block) with: - - ```python - try: - settings = load_settings(overrides, token=token) - except ConfigError as err: - typer.echo(f"Error: {err}", err=True) - raise typer.Exit(code=err.exit_code) from err - ``` - - Delete `_config_error_from_validation` (lines 70-75) — it now lives in - `_settings.py`. Update the import on line 11 to - `from semvertag._settings import Settings, load_settings`. Remove - `import pydantic` (line 5) if no other use remains (the `SecretStr` wrap moved - into `load_settings`; verify with `grep -n pydantic semvertag/__main__.py`). - -- [ ] **Step 2: Privatize the overlay** - - In `semvertag/_settings.py`, rename `apply_cli_overlay` → `_apply_cli_overlay` - and update the two call sites inside `load_settings`. - -- [ ] **Step 3: Update the overlay's direct tests** - - In `tests/unit/test_settings.py`: change the import on line 6 to drop - `apply_cli_overlay`, and in the two tests at lines 163-172 call - `_apply_cli_overlay` (import it: `from semvertag._settings import _apply_cli_overlay`). - Rename those two tests' bodies' calls accordingly. - -- [ ] **Step 4: Retarget the integration monkeypatch** - - In `tests/integration/test_cli_errors.py:104`, change the patch target to the - new private name in its home module: - - ```python - monkeypatch.setattr("semvertag._settings._apply_cli_overlay", raise_value_error) - ``` - -- [ ] **Step 5: Run the full suite** - - Run: `just test -q` - Expected: PASS — all unit, integration, and CLI tests green (behavior - preserved). If `import pydantic` was left unused, ruff (`just lint-ci`) flags it. - -- [ ] **Step 6: Commit** - - ```bash - git add semvertag/__main__.py semvertag/_settings.py tests/unit/test_settings.py tests/integration/test_cli_errors.py - git commit -m "settings: route CLI through load_settings; privatize overlay" - ``` - ---- - -### Task 3: Promote architecture, prune redundant coverage, finalize bundle - -**Files:** -- Modify: `architecture/cli.md` (the "Settings" section) -- Modify: `tests/integration/` (prune precedence-only assertions now unit-covered) -- Modify: `planning/changes/2026-06-26.01-load-settings-pipeline/design.md` (finalize `summary`) - -Promote the living truth and remove integration assertions that only existed to -prove precedence (now owned by the `load_settings` unit tests). Keep one -integration assertion that the CLI wires `load_settings` → DI. - -- [ ] **Step 1: Update `architecture/cli.md`** - - In the "Settings" section, replace the description of `apply_cli_overlay` and the - two-pass token routing with: env + CLI flow funnels through `load_settings`, the - single entry point that splits top-level vs dotted overrides, constructs + - overlays + routes `--token` to the resolved provider, and raises only - `ConfigError`. Note the overlay is now an internal helper (`_apply_cli_overlay`). - -- [ ] **Step 2: Prune redundant integration assertions** - - Run: `grep -rn "strategy ==\|request_timeout\|precedence\|overrides env" tests/integration/` - Remove assertions in the CLI integration tests that only re-prove precedence / - token routing (now covered by Task 1's unit tests). Keep tests that prove the - CLI→DI wiring and exit-code mapping. Run `just test -q` after each removal to - confirm coverage stays at 100% (`fail_under` will fail the run if a removal drops - a branch — restore it if so). - -- [ ] **Step 3: Finalize the bundle summary** - - Edit `design.md` frontmatter `summary:` to state the realized result (what - shipped and its effect), per the planning convention. - -- [ ] **Step 4: Run all gates** - - Run: `just lint-ci && just test && just docs-build` - Expected: lint clean, tests pass at 100% branch coverage, planning validator - passes (`just check-planning`), strict mkdocs build succeeds. - -- [ ] **Step 5: Commit** - - ```bash - git add architecture/cli.md tests/integration planning/changes/2026-06-26.01-load-settings-pipeline/design.md - git commit -m "docs: promote load_settings to architecture; prune redundant tests" - ``` - ---- - -## Self-review notes - -- **Spec coverage:** load_settings (Task 1), apply_cli_overlay→private (Task 2), - `_config_error_from_validation` relocation (Tasks 1+2), `__main__` shrink - (Task 2), unit test surface (Task 1), architecture promotion + prune (Task 3). - The `ioc` asserts are a non-goal and correctly have no task. -- **Type consistency:** `load_settings(cli_overrides: dict[str, typing.Any], *, - token: str | None = None) -> Settings` and `_apply_cli_overlay` are used with - identical names/signatures across all tasks. -- **Behavior preservation:** Task 2's gate is the unchanged CLI/integration suite - going green against the rewired callback. diff --git a/planning/changes/2026-06-26.02-provider-target/design.md b/planning/changes/2026-06-26.02-provider-target.md similarity index 100% rename from planning/changes/2026-06-26.02-provider-target/design.md rename to planning/changes/2026-06-26.02-provider-target.md diff --git a/planning/changes/2026-06-26.02-provider-target/plan.md b/planning/changes/2026-06-26.02-provider-target/plan.md deleted file mode 100644 index 34b0849..0000000 --- a/planning/changes/2026-06-26.02-provider-target/plan.md +++ /dev/null @@ -1,270 +0,0 @@ -# provider-target — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the duplicated provider invariant (validator + two `ioc` -asserts) with a discriminated `ProviderTarget` the validator builds and `ioc` -matches exhaustively, removing the asserts and the cross-module coupling. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `refactor/provider-target` - -**Commit strategy:** Per-task commits. - -## Global Constraints - -- Python imports at MODULE LEVEL only; every test function argument annotated. -- `just test` enforces `fail_under = 100` branch coverage. -- BEHAVIOR-PRESERVING: identical invariant error messages and raise conditions, - unchanged auto-detection / precedence / env aliases / exit codes; no change to - the `Provider` protocol, providers, use-case, or output. `Settings.provider` - (the `Literal` selector) stays. -- Mirror the `_outcome.to_run_result` closed-sum convention for the `ioc` match - (`case _: typing.assert_never(...) # pragma: no cover`). - ---- - -### Task 1: Add `ProviderTarget`; build it in the validator - -**Files:** -- Modify: `semvertag/_settings.py` -- Test: `tests/unit/test_settings.py` - -**Interfaces:** -- Consumes: existing `Settings`, `pydantic`, `typing`. -- Produces: `GitHubTarget(repo: str)`, `GitLabTarget(project_id: int)`, - `ProviderTarget = GitHubTarget | GitLabTarget`, and - `Settings.provider_target -> ProviderTarget`. - -Additive plus a behavior-preserving validator restructure. `ioc` still uses its -asserts after this task (Task 2 swaps it), so nothing breaks. - -- [ ] **Step 1: Write the failing tests** - - Add the two imports to the module-level import block at the top of - `tests/unit/test_settings.py` — add `GitHubTarget`, `GitLabTarget` to the - existing `from semvertag._settings import ...` line. Then append: - - ```python - @pytest.mark.usefixtures("clean_settings_env") - def test_provider_target_is_github_target_for_github() -> None: - settings = Settings(provider="github", repo="o/r") - assert settings.provider_target == GitHubTarget(repo="o/r") - - - @pytest.mark.usefixtures("clean_settings_env") - def test_provider_target_is_gitlab_target_for_gitlab() -> None: - settings = Settings(provider="gitlab", project_id=_PROJECT_ID_INT_SEMVERTAG) - assert settings.provider_target == GitLabTarget(project_id=_PROJECT_ID_INT_SEMVERTAG) - ``` - -- [ ] **Step 2: Run the tests to verify they fail** - - Run: `just test tests/unit/test_settings.py -q` - Expected: FAIL — `ImportError: cannot import name 'GitHubTarget'`. - -- [ ] **Step 3: Add the target types** - - In `semvertag/_settings.py`, add `import dataclasses` to the module imports, - and define the targets above the `Settings` class: - - ```python - @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) - class GitHubTarget: - repo: str - - - @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) - class GitLabTarget: - project_id: int - - - ProviderTarget: typing.TypeAlias = GitHubTarget | GitLabTarget - ``` - -- [ ] **Step 4: Add the PrivateAttr, property, and build it in the validator** - - Inside `Settings`, add the private attr (with the other field declarations) - and the property: - - ```python - _provider_target: ProviderTarget | None = pydantic.PrivateAttr(default=None) - - @property - def provider_target(self) -> ProviderTarget: - assert self._provider_target is not None, "provider_target is set by _resolve_provider" # noqa: S101 - return self._provider_target - ``` - - Replace the existing `_resolve_provider` body's two-`if` invariant block: - - ```python - if self.provider == "github" and not self.repo: - msg = "provider=github requires `repo` (set GITHUB_REPOSITORY or pass --repo OWNER/REPO)" - raise ValueError(msg) - if self.provider == "gitlab" and self.project_id is None: - msg = "provider=gitlab requires `project_id` (set CI_PROJECT_ID or pass --project-id N)" - raise ValueError(msg) - return self - ``` - - with the build-using-narrowing-locals version (keep the `if self.provider is - None:` auto-detect line above it unchanged): - - ```python - if self.provider == "github": - repo = self.repo - if not repo: - msg = "provider=github requires `repo` (set GITHUB_REPOSITORY or pass --repo OWNER/REPO)" - raise ValueError(msg) - self._provider_target = GitHubTarget(repo=repo) - else: - project_id = self.project_id - if project_id is None: - msg = "provider=gitlab requires `project_id` (set CI_PROJECT_ID or pass --project-id N)" - raise ValueError(msg) - self._provider_target = GitLabTarget(project_id=project_id) - return self - ``` - - The messages and conditions are byte-identical to the originals; the `else` - branch is gitlab because `provider` is guaranteed `"github"` or `"gitlab"` - after the auto-detect line. The locals `repo` / `project_id` narrow to - `str` / `int` across their guards, so the target build needs no `assert`/`cast`. - -- [ ] **Step 5: Run the tests to verify they pass** - - Run: `just test tests/unit/test_settings.py -q` - Expected: PASS, including the two new tests and all existing invariant tests - (`test_provider_github_requires_repo`, `test_provider_gitlab_requires_project_id`). - -- [ ] **Step 6: Full suite + lint gate** - - Run: `just test` then `just lint-ci` - Expected: full suite green at 100% branch coverage; lint/ty/planning clean. - -- [ ] **Step 7: Commit** - - ```bash - git add semvertag/_settings.py tests/unit/test_settings.py - git commit -m "settings: build a discriminated ProviderTarget in the validator" - ``` - ---- - -### Task 2: Match the target in `ioc`; remove the asserts - -**Files:** -- Modify: `semvertag/ioc.py` - -**Interfaces:** -- Consumes: `GitHubTarget`, `GitLabTarget`, `Settings.provider_target` from Task 1. - -- [ ] **Step 1: Update the import** - - In `semvertag/ioc.py`, change `from semvertag._settings import Settings` to - `from semvertag._settings import GitHubTarget, GitLabTarget, Settings`. - -- [ ] **Step 2: Replace `_build_current_provider` body** - - Replace the `if settings.provider == "github": assert ... else assert ...` - block with the exhaustive match, and update the docstring to drop the - assert/`ty`-narrowing sentences while keeping the eager-resolution note: - - ```python - def _build_current_provider( - settings: Settings, - gitlab_client: httpware.Client, - github_client: httpware.Client, - ) -> Provider: - """Construct the active provider. - - Both clients are eagerly resolved (modern-di Factory eagerly resolves all - provider_kwargs in resolve()). That's acceptable — httpx2 connection pools - are lazy, so the unused client doesn't open sockets. The active forge is - the validated, narrowed Settings.provider_target; the match is exhaustive - over the closed ProviderTarget sum. - """ - match settings.provider_target: - case GitHubTarget(repo=repo): - return GitHubProvider( - config=settings.github, repo=repo, http=github_client, default_branch=settings.default_branch - ) - case GitLabTarget(project_id=project_id): - return GitLabProvider( - config=settings.gitlab, - project_id=project_id, - http=gitlab_client, - default_branch=settings.default_branch, - ) - case _: # pragma: no cover - typing.assert_never(settings.provider_target) - ``` - -- [ ] **Step 3: Full suite + gates** - - Run: `just test` then `just lint-ci` - Expected: 100% branch coverage (both match arms covered by `test_ioc.py`'s - github + gitlab container tests; the `case _` is `# pragma: no cover`); ty - passes (no asserts needed — `repo` / `project_id` come from the narrowed - target fields); lint/planning clean. - -- [ ] **Step 4: Commit** - - ```bash - git add semvertag/ioc.py - git commit -m "ioc: match ProviderTarget exhaustively; drop invariant asserts" - ``` - ---- - -### Task 3: Promote architecture; finalize bundle - -**Files:** -- Modify: `architecture/cli.md` (the IoC-wiring section) -- Modify: `planning/changes/2026-06-26.02-provider-target/design.md` (finalize `summary`) - -- [ ] **Step 1: Update `architecture/cli.md`** - - Find the IoC-wiring section that describes `_build_current_provider` carrying - `assert` guards on `repo` / `project_id` (the "eager-resolution None-field - guard" sentence). Rewrite it to describe the new reality: the validator builds - a discriminated `ProviderTarget` (`GitHubTarget | GitLabTarget`) whose id field - is non-optional, and `_build_current_provider` matches it exhaustively (closed - sum, `assert_never` arm) — so the invariant is enforced once, in the validator, - and `ioc` no longer asserts it. Match the file's prose style; ground every - sentence against `semvertag/_settings.py` and `semvertag/ioc.py`. - -- [ ] **Step 2: Finalize the bundle summary** - - Edit the `summary:` frontmatter in this bundle's `design.md` to the realized - result (past tense, one line). - -- [ ] **Step 3: All gates** - - Run: `just lint-ci && just test && just docs-build` - Expected: lint/ty/planning clean, 100% branch coverage, strict mkdocs build - succeeds. - -- [ ] **Step 4: Commit** - - ```bash - git add architecture/cli.md planning/changes/2026-06-26.02-provider-target/design.md - git commit -m "docs: promote ProviderTarget to architecture" - ``` - ---- - -## Self-review notes - -- **Spec coverage:** `ProviderTarget` types + validator build (Task 1), - `provider_target` property + tests (Task 1), `ioc` exhaustive match + assert - removal (Task 2), architecture promotion (Task 3). -- **Type consistency:** `GitHubTarget(repo: str)`, `GitLabTarget(project_id: - int)`, `provider_target -> ProviderTarget` used identically across tasks. -- **Behavior preservation:** identical invariant messages/conditions; the - existing invariant + `test_ioc` suites are the green-bar proof after each task. diff --git a/planning/changes/2026-06-26.03-semver-tag-selection/design.md b/planning/changes/2026-06-26.03-semver-tag-selection.md similarity index 100% rename from planning/changes/2026-06-26.03-semver-tag-selection/design.md rename to planning/changes/2026-06-26.03-semver-tag-selection.md diff --git a/planning/changes/2026-06-26.03-semver-tag-selection/plan.md b/planning/changes/2026-06-26.03-semver-tag-selection/plan.md deleted file mode 100644 index 4273995..0000000 --- a/planning/changes/2026-06-26.03-semver-tag-selection/plan.md +++ /dev/null @@ -1,238 +0,0 @@ -# semver-tag-selection — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fold the four-helper semver-tag-selection chain in `_use_case.py` into -one selector that carries the parsed `Version` (no double-parse), and finalize -prerelease baselines via `next_version`. - -**Spec:** [`design.md`](./design.md) · **Decision:** -[`../../decisions/2026-06-26-semver-form-tags-only.md`](../../decisions/2026-06-26-semver-form-tags-only.md) - -**Branch:** `refactor/semver-tag-selection` - -**Commit strategy:** Per-task commits. - -## Global Constraints - -- Python imports at MODULE LEVEL only; every test function argument annotated. -- `just test` enforces `fail_under = 100` branch coverage. -- BEHAVIOR-PRESERVING **except** the one deliberate change: `next_version` instead - of `bump_*`, which equals `bump_*` on stable baselines **without build metadata** - and differs only by finalizing SemVer-form prerelease baselines. The selector - strips build metadata (`.replace(build=None)`) so the carried `Version` is always - build-free; a hand-pushed `1.0.0+build` tag therefore bumps correctly to `1.0.1`. - No change to `Outcome`, providers, strategies, output, or DI. -- Selection stays **SemVer-form only** — no PEP 440 / `v`-prefix recognition (per - the decision record). -- Tie order: `sorted(...)[-1]` (last-equal-wins) — do NOT use `max()`. - ---- - -### Task 1: Fold the selection chain; bump via `next_version` - -**Files:** -- Modify: `semvertag/_use_case.py` -- Test: `tests/unit/test_use_case.py` - -**Interfaces:** -- Removed: `_try_parse_semver`, `_parse_semver_tags`, `_pick_latest_semver_tag`, - `_BUMP_FUNCTIONS`. -- Produced: `_select_latest_semver_tag(tags: list[Tag]) -> tuple[Tag, semver.Version] | None`, - `_BUMP_PARTS: dict[Bump, str]`, and `_compute_new_version(version: semver.Version, bump: Bump) -> str`. - -- [ ] **Step 1: Write the failing direct helper tests** - - In `tests/unit/test_use_case.py`, add `_select_latest_semver_tag` and - `_compute_new_version` to the existing `from semvertag._use_case import ...` - line, and add `import semver` to the module imports. Append: - - ```python - def test_select_latest_returns_none_for_empty() -> None: - assert _select_latest_semver_tag([]) is None - - - def test_select_latest_returns_none_when_all_unparseable() -> None: - tags = [ - Tag(name="release-2024-Q1", commit_sha="a"), - Tag(name="latest", commit_sha="b"), - Tag(name="v0", commit_sha="c"), - ] - assert _select_latest_semver_tag(tags) is None - - - def test_select_latest_skips_pep440_prerelease() -> None: - tags = [Tag(name="0.8.1", commit_sha="a"), Tag(name="0.9.0rc1", commit_sha="b")] - selected = _select_latest_semver_tag(tags) - assert selected is not None - tag, version = selected - assert tag.name == "0.8.1" - assert version == semver.Version.parse("0.8.1") - - - def test_select_latest_includes_semver_form_prerelease_in_ordering() -> None: - tags = [Tag(name="1.0.0-rc.1", commit_sha="a"), Tag(name="0.9.0", commit_sha="b")] - selected = _select_latest_semver_tag(tags) - assert selected is not None - tag, _version = selected - assert tag.name == "1.0.0-rc.1" - - - def test_select_latest_tie_keeps_last_in_input() -> None: - tags = [Tag(name="1.0.0+a", commit_sha="x"), Tag(name="1.0.0+b", commit_sha="y")] - selected = _select_latest_semver_tag(tags) - assert selected is not None - tag, _version = selected - assert tag.commit_sha == "y" - - - def test_compute_new_version_finalizes_semver_prerelease() -> None: - assert _compute_new_version(semver.Version.parse("1.0.0-rc.1"), Bump.PATCH) == "1.0.0" - ``` - -- [ ] **Step 2: Run the tests to verify they fail** - - Run: `just test tests/unit/test_use_case.py -q` - Expected: FAIL — `ImportError: cannot import name '_select_latest_semver_tag'`. - -- [ ] **Step 3: Rewrite the helpers in `_use_case.py`** - - Remove `_try_parse_semver`, `_parse_semver_tags`, `_pick_latest_semver_tag`, and - the `_BUMP_FUNCTIONS` dict. Add: - - ```python - def _select_latest_semver_tag(tags: list[Tag]) -> tuple[Tag, semver.Version] | None: - parsed: list[tuple[semver.Version, Tag]] = [] - for tag in tags: - try: - version = semver.Version.parse(tag.name) - except ValueError: - continue - parsed.append((version, tag)) - if not parsed: - return None - parsed.sort(key=lambda item: item[0]) - version, tag = parsed[-1] - return tag, version - - - _BUMP_PARTS: typing.Final[dict[Bump, str]] = { - Bump.MAJOR: "major", - Bump.MINOR: "minor", - Bump.PATCH: "patch", - } - - - def _compute_new_version(version: semver.Version, bump: Bump) -> str: - return str(version.next_version(_BUMP_PARTS[bump])) - ``` - -- [ ] **Step 4: Rewire `__call__`** - - Replace the selection + already-tagged + compute lines so they consume the - tuple and pass the carried `Version`: - - ```python - output.progress("Fetching tag history...") - tags: typing.Final = self.provider.list_tags() - selected: typing.Final = _select_latest_semver_tag(tags) - - if selected is None: - return self._emit(output, NoTags(commit=commit.sha)) - - latest_tag, latest_version = selected - if latest_tag.commit_sha == commit.sha: - return self._emit(output, AlreadyTagged(tag=latest_tag.name, commit=commit.sha)) - - output.progress("Computing bump...") - bump: typing.Final = self.strategy.decide(commit) - if bump is Bump.NONE: - return self._emit( - output, - NoBump(status=self.strategy.no_bump_status, reason=self.strategy.no_bump_reason, commit=commit.sha), - ) - - new_version: typing.Final = _compute_new_version(latest_version, bump) - ``` - - Leave the `dry_run` / `create_tag` / `Created` lines below unchanged. - -- [ ] **Step 5: Run the tests to verify they pass** - - Run: `just test tests/unit/test_use_case.py -q` - Expected: PASS — new direct tests green, AND every existing use-case test green - (behavior preserved; `next_version` equals `bump_*` on the `1.4.2`/`2.0.0` - stable baselines those tests use). - -- [ ] **Step 6: Full suite + lint gate** - - Run: `just test` then `just lint-ci` - Expected: full suite at 100% branch coverage; ruff/ty/planning clean. - -- [ ] **Step 7: Commit** - - ```bash - git add semvertag/_use_case.py tests/unit/test_use_case.py - git commit -m "use-case: fold semver-tag selection into one selector; bump via next_version" - ``` - ---- - -### Task 2: Promote architecture; finalize bundle - -**Files:** -- Modify: `architecture/cli.md` (the "Use-case" section) -- Modify: `planning/changes/2026-06-26.03-semver-tag-selection/design.md` (finalize `summary`) - -- [ ] **Step 1: Update `architecture/cli.md`** - - In the "Use-case" section, find step 2 (currently: "list tags and pick the - highest semver-parseable one (`_pick_latest_semver_tag` sorts by - `semver.Version`; unparseable names are skipped)") and step 5 (currently: - "compute the new version (`_compute_new_version` via `semver`'s - `bump_major/minor/patch`)"). Rewrite them to the new reality, grounding every - claim against `semvertag/_use_case.py`: - - Step 2: `_select_latest_semver_tag` parses each tag, skips non-SemVer names - (PEP 440 prereleases and `v`-prefixed tags included), sorts by `semver.Version` - precedence, and returns the winning `Tag` **with its parsed `Version`** - (last-equal-wins on ties). - - Step 5: the new version is computed by `_compute_new_version` from the carried - `Version` via `Version.next_version` (which finalizes a SemVer-form prerelease - baseline), so the winning tag is not parsed twice. - Match the file's prose style. Add a brief pointer to - `decisions/2026-06-26-semver-form-tags-only.md` if the section already - cross-references decisions; otherwise keep it inline. - -- [ ] **Step 2: Finalize the bundle summary** - - Edit the `summary:` frontmatter in this bundle's `design.md` to the realized - result (past tense, one line). - -- [ ] **Step 3: All gates** - - Run: `just lint-ci && just test && just docs-build` - Expected: lint/ty/planning clean, 100% branch coverage, strict mkdocs build - succeeds. - -- [ ] **Step 4: Commit** - - ```bash - git add architecture/cli.md planning/changes/2026-06-26.03-semver-tag-selection/design.md - git commit -m "docs: promote semver-tag selection + next_version to architecture" - ``` - ---- - -## Self-review notes - -- **Spec coverage:** selector fold + carried `Version` (Task 1), `next_version` - bump (Task 1), direct edge tests (Task 1), architecture promotion + summary - (Task 2). The decision record ships with the bundle's planning commit. -- **Type consistency:** `_select_latest_semver_tag(...) -> tuple[Tag, - semver.Version] | None` and `_compute_new_version(version: semver.Version, bump: - Bump) -> str` used identically across tasks. -- **Behavior preservation:** existing use-case tests are the green-bar proof; - `next_version` is the only deliberate behavior change (prerelease finalize). diff --git a/planning/index.py b/planning/index.py index 8916661..2d70ac3 100644 --- a/planning/index.py +++ b/planning/index.py @@ -1,14 +1,12 @@ -# ruff: noqa: INP001, D212 # planning/ is not a Python package; D212/D213 conflict differs from faststream-outbox -""" -Generate the planning index from frontmatter. +# ruff: noqa: INP001 # planning/ is not a Python package (this file is vendored into consumers' planning/) +"""Generate the planning index from frontmatter. -Run via ``just index``. Globs ``planning/changes/*/`` (each bundle's -``design.md``, falling back to ``change.md``) and ``planning/decisions/*.md``, -reads their frontmatter, and prints a Markdown listing to stdout — changes -then decisions, newest-first. Never writes a file: +Run via ``just index``. Globs ``planning/changes/*.md`` and +``planning/decisions/*.md``, reads their frontmatter, and prints a Markdown +listing to stdout — changes then decisions, newest-first. Never writes a file: the listing is a query over the files, not a committed artifact. -``date`` and ``slug`` are derived from the directory / file name, not +``date`` and ``slug`` are derived from the file name, not frontmatter — the name is the single source of truth for both. """ @@ -17,12 +15,10 @@ import sys -CHANGES_DIR = pathlib.Path(__file__).parent / "changes" -DECISIONS_DIR = pathlib.Path(__file__).parent / "decisions" +ROOT = pathlib.Path(__file__).parent VALID_DECISION_STATUS = {"accepted", "superseded"} -BUNDLE_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})\.\d{2}-(?P.+)$") +CHANGE_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})\.\d{2}-(?P.+)$") DECISION_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})-(?P.+)$") -ALLOWED_BUNDLE_FILES = {"design.md", "plan.md", "change.md"} SPEC_REQUIRED = ("summary",) DECISION_REQUIRED = ("status", "summary") @@ -47,7 +43,7 @@ def parse_frontmatter(text: str) -> dict[str, str]: def _named(fields: dict[str, str], name: str, pattern: re.Pattern[str]) -> dict[str, str]: - """Inject ``date``/``slug`` derived from a dir/file name into ``fields``.""" + """Inject ``date``/``slug`` derived from a file name into ``fields``.""" match = pattern.match(name) if match: fields["date"] = match.group("date") @@ -55,30 +51,29 @@ def _named(fields: dict[str, str], name: str, pattern: re.Pattern[str]) -> dict[ return fields -def load_bundles() -> list[dict[str, str]]: - """Read each bundle's summary; derive date/slug from the directory name.""" - bundles: list[dict[str, str]] = [] - for bundle in sorted(CHANGES_DIR.iterdir()): - if not bundle.is_dir(): - continue - spec = bundle / "design.md" - if not spec.exists(): - spec = bundle / "change.md" - if not spec.exists(): +def load_changes(root: pathlib.Path) -> list[dict[str, str]]: + """Read each change file's summary; derive date/slug from the file name.""" + changes_dir = root / "changes" + changes: list[dict[str, str]] = [] + if not changes_dir.is_dir(): + return changes + for path in sorted(changes_dir.glob("*.md")): + if path.name == "README.md" or path.name.startswith(("_", ".")): continue - fields = _named(parse_frontmatter(spec.read_text(encoding="utf-8")), bundle.name, BUNDLE_RE) - fields["path"] = f"changes/{bundle.name}/{spec.name}" - fields["name"] = bundle.name - bundles.append(fields) - return bundles + fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, CHANGE_RE) + fields["path"] = f"changes/{path.name}" + fields["name"] = path.stem + changes.append(fields) + return changes -def load_decisions() -> list[dict[str, str]]: +def load_decisions(root: pathlib.Path) -> list[dict[str, str]]: """Read each decision's frontmatter; derive date/slug from the file name.""" + decisions_dir = root / "decisions" decisions: list[dict[str, str]] = [] - if not DECISIONS_DIR.is_dir(): + if not decisions_dir.is_dir(): return decisions - for path in sorted(DECISIONS_DIR.glob("*.md")): + for path in sorted(decisions_dir.glob("*.md")): if path.name == "README.md" or path.name.startswith("_"): continue fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, DECISION_RE) @@ -88,24 +83,24 @@ def load_decisions() -> list[dict[str, str]]: return decisions -def format_row(bundle: dict[str, str]) -> str: - """Render one bundle as a Markdown list item.""" - slug = bundle.get("slug", "?") - path = bundle.get("path", "") - date = bundle.get("date", "") - summary = bundle.get("summary") or "(no summary)" +def format_row(row: dict[str, str]) -> str: + """Render one change or decision as a Markdown list item.""" + slug = row.get("slug", "?") + path = row.get("path", "") + date = row.get("date", "") + summary = row.get("summary") or "(no summary)" line = f"- **[{slug}]({path})** ({date}) — {summary}" - if bundle.get("supersedes"): - line += f" _(supersedes {bundle['supersedes']})_" - if bundle.get("superseded_by"): - line += f" _(superseded by {bundle['superseded_by']})_" + if row.get("supersedes"): + line += f" _(supersedes {row['supersedes']})_" + if row.get("superseded_by"): + line += f" _(superseded by {row['superseded_by']})_" return line -def render(bundles: list[dict[str, str]], decisions: list[dict[str, str]]) -> str: +def render(changes: list[dict[str, str]], decisions: list[dict[str, str]]) -> str: """Render the full Markdown listing: changes then decisions, newest-first.""" out = ["# Planning index", "", "_Generated by `just index` — do not edit._", "", "## Changes", ""] - change_rows = sorted(bundles, key=lambda b: b.get("name", ""), reverse=True) + change_rows = sorted(changes, key=lambda b: b.get("name", ""), reverse=True) out += [format_row(b) for b in change_rows] if change_rows else ["_None._"] out += ["", "## Decisions", ""] decision_rows = sorted(decisions, key=lambda d: d.get("name", ""), reverse=True) @@ -119,32 +114,15 @@ def _require(fields: dict[str, str], keys: tuple[str, ...], rel: str, violations violations.extend(f"{rel}: missing or empty frontmatter key '{key}'" for key in keys if not fields.get(key)) -def _check_spec_file(path: pathlib.Path, rel: str, violations: list[str]) -> None: - """Validate a design.md / change.md spec file (requires `summary`).""" +def _check_change(path: pathlib.Path, violations: list[str]) -> None: + """Validate one change file (requires `summary`).""" + rel = f"changes/{path.name}" + if CHANGE_RE.match(path.stem) is None: + violations.append(f"{rel}: file name is not 'YYYY-MM-DD.NN-slug.md'") fields = parse_frontmatter(path.read_text(encoding="utf-8")) _require(fields, SPEC_REQUIRED, rel, violations) -def _check_bundle(bundle: pathlib.Path, violations: list[str]) -> None: - """Validate one change bundle directory.""" - rel = f"changes/{bundle.name}" - if BUNDLE_RE.match(bundle.name) is None: - violations.append(f"{rel}: directory name is not 'YYYY-MM-DD.NN-slug'") - violations.extend( - f"{rel}/{child.name}: unexpected file in bundle (allowed: {', '.join(sorted(ALLOWED_BUNDLE_FILES))})" - for child in sorted(bundle.iterdir()) - if child.name not in ALLOWED_BUNDLE_FILES - ) - design = bundle / "design.md" - change = bundle / "change.md" - if not design.exists() and not change.exists(): - violations.append(f"{rel}: bundle has neither design.md nor change.md") - for spec_file in (design, change): - if spec_file.exists(): - _check_spec_file(spec_file, f"{rel}/{spec_file.name}", violations) - # plan.md carries no frontmatter — its identity comes from the bundle dir. - - def _check_decision(path: pathlib.Path, violations: list[str]) -> None: """Validate one decision file (requires `status` + `summary`).""" rel = f"decisions/{path.name}" @@ -157,24 +135,39 @@ def _check_decision(path: pathlib.Path, violations: list[str]) -> None: violations.append(f"{rel}: invalid status '{status}' (allowed: {', '.join(sorted(VALID_DECISION_STATUS))})") -def check() -> list[str]: - """Validate every bundle and decision; return the list of violation strings.""" +def check(root: pathlib.Path) -> list[str]: + """Validate every change file and decision; return the list of violation strings.""" violations: list[str] = [] - for bundle in sorted(CHANGES_DIR.iterdir()): - if bundle.is_dir(): - _check_bundle(bundle, violations) - if DECISIONS_DIR.is_dir(): - for path in sorted(DECISIONS_DIR.glob("*.md")): + changes_dir = root / "changes" + decisions_dir = root / "decisions" + if changes_dir.is_dir(): + for path in sorted(changes_dir.iterdir()): + if path.is_dir(): + violations.append( + f"changes/{path.name}: directory found — convention 2.0.0 uses flat change files " + f"(changes/YYYY-MM-DD.NN-slug.md; see CHANGELOG 2.0.0 for the migration)" + ) + continue + if path.name == "README.md" or path.name.startswith(("_", ".")): + continue + if path.suffix != ".md": + violations.append(f"changes/{path.name}: unexpected non-md file in changes/") + else: + _check_change(path, violations) + if decisions_dir.is_dir(): + for path in sorted(decisions_dir.glob("*.md")): if path.name == "README.md" or path.name.startswith("_"): continue _check_decision(path, violations) return violations -def main() -> int: - """Print the listing to stdout, or validate bundles with --check.""" - if "--check" in sys.argv[1:]: - violations = check() +def main(argv: list[str] | None = None, root: pathlib.Path | None = None) -> int: + """Print the listing to stdout, or validate change files and decisions with --check.""" + argv = sys.argv[1:] if argv is None else argv + root = ROOT if root is None else root + if "--check" in argv: + violations = check(root) if violations: sys.stderr.write(f"planning: {len(violations)} violation(s)\n") for violation in violations: @@ -182,7 +175,7 @@ def main() -> int: return 1 sys.stdout.write("planning: OK\n") return 0 - sys.stdout.write(render(load_bundles(), load_decisions())) + sys.stdout.write(render(load_changes(root), load_decisions(root))) return 0 From adac5c7330527ffc46d10ddb1facff319cb73bcb Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 8 Jul 2026 10:38:47 +0300 Subject: [PATCH 2/3] chore(planning): merge convention 2.0.0 prose and bump to 2.0.0 Rebuild planning/README.md's Conventions section from the canonical 2.0.0 body (adds the Glossary, Plans-are-ephemeral, and Lean-specs subsections; retires the folder-bundle model). Align CLAUDE.md and Justfile wording from "bundle" to "change file", and record planning/.convention-version=2.0.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 6 +-- Justfile | 2 +- planning/.convention-version | 2 +- planning/README.md | 98 ++++++++++++++++++++++++------------ 4 files changed, 72 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 955688b..ad16b57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,8 +29,8 @@ portable two-axis planning convention. `architecture/` (repo root) is the living truth home; `planning/` records *how it got there*. **Start at the [Quick path](planning/README.md#quick-path-start-here)** in [`planning/README.md`](planning/README.md) to choose a lane (Full / Lightweight / -Tiny), create a bundle, and ship — that file is the authoritative spec. Run -`just check-planning` to validate bundles and `just index` to print the change +Tiny), create a change file, and ship — that file is the authoritative spec. Run +`just check-planning` to validate changes and `just index` to print the change listing. Per feature: brainstorming → `design.md` → writing-plans → `plan.md` → @@ -42,7 +42,7 @@ and a subagent code review before landing (`superpowers:requesting-code-review`) Planning artifacts live under `planning/` (not `docs/`, so they're excluded from the mkdocs site automatically). When superpowers skills default to -`docs/superpowers/specs/` or `docs/superpowers/plans/`, use the change bundle +`docs/superpowers/specs/` or `docs/superpowers/plans/`, use the change file under `planning/changes/` here instead. **Cutting a release (maintainers)** is tag-driven via diff --git a/Justfile b/Justfile index c335f0c..7379f20 100644 --- a/Justfile +++ b/Justfile @@ -35,6 +35,6 @@ docs-build: index: uv run python planning/index.py -# Validate planning bundles + decisions; CI runs this. +# Validate planning changes + decisions; CI runs this. check-planning: uv run python planning/index.py --check diff --git a/planning/.convention-version b/planning/.convention-version index 3eefcb9..227cea2 100644 --- a/planning/.convention-version +++ b/planning/.convention-version @@ -1 +1 @@ -1.0.0 +2.0.0 diff --git a/planning/README.md b/planning/README.md index cc48ed6..0c05eac 100644 --- a/planning/README.md +++ b/planning/README.md @@ -12,25 +12,22 @@ at the repo root; this directory records *how it got there*. **1. Choose a lane — first matching rule wins:** 1. Any of: needs design judgment · new file/module · public-API change · - cross-cutting or multi-file · non-trivial test design → **Full** - (`design.md` + `plan.md`) + cross-cutting or multi-file · non-trivial test design → **Full** (design template) 2. Purely mechanical: typo · dep bump · linter/formatter/CI tweak · - mechanical rename · single-line config → **Tiny** (no bundle, conventional + mechanical rename · single-line config → **Tiny** (no change file, conventional commit) 3. Small-but-real, none of the above: ≲30 LOC net · ≤2 files · no new file · - no public-API change · one straightforward test → **Lightweight** - (`change.md`) + no public-API change · one straightforward test → **Lightweight** (change template) -Ambiguous between two? Take the heavier. A `change.md` that outgrows its lane -splits into `design.md` + `plan.md`. +Ambiguous between two? Take the heavier. A lightweight change file that outgrows its lane is rewritten from the design template. -**2. Create the bundle** (Full / Lightweight only): -`planning/changes/YYYY-MM-DD.NN-/`, where `.NN` is a zero-padded -intra-day counter. Copy the matching template from +**2. Create the change file** (Full / Lightweight only): +`planning/changes/YYYY-MM-DD.NN-.md`, where `.NN` is a zero-padded +intra-day counter — copied from the matching template (design or change) in [`_templates/`](_templates/). **3. Ship in the implementing PR:** hand-edit the affected -`architecture/.md`, finalize the bundle's `summary:` to the +`architecture/.md`, finalize the change file's `summary:` to the realized result, and run `just check-planning` before pushing. ## Conventions @@ -43,20 +40,41 @@ realized result, and run `just check-planning` before pushing. ### Two axes, never mixed -- **`architecture/` (repo root) — the present.** One file per capability, - living prose, updated in the same PR that ships the change. The truth home. -- **`planning/changes/` — the past-and-pending.** One folder per change, +- **`architecture/` (repo root) — the present.** One file per capability, plus + a single `glossary.md` (the ubiquitous language); living prose, updated in the + same PR that ships the change. The truth home. +- **`planning/changes/` — the past-and-pending.** One file per change, kept in place after ship. A change **promotes** its conclusions into the affected `architecture/.md` by hand **in the implementing PR, alongside the code** — the edit rides in the same diff and is reviewed with it, never applied as a separate post-merge step. That hand-edit is what keeps `architecture/` -true; the bundle stays in `changes/` as the *why*. +true; the change file stays in `changes/` as the *why*. -### Change bundles +### Glossary -A change is a folder `changes/YYYY-MM-DD.NN-/`: +`architecture/glossary.md` is the project's **ubiquitous language** — one page +defining the domain terms that code, specs, and capability pages all share. Like +the capability files beside it, it is living prose with **no frontmatter**, dated +by git, and authored lazily: it appears when the first term is worth pinning down. + +Each entry is a term, a one-or-two-sentence definition of what it *is* (not what +it does), and an optional `_Avoid_:` line naming the synonyms to reject: + +```md +**Timer**: +A scheduled future delivery, identified by a timer id. +_Avoid_: job, task, alarm +``` + +Keep it a glossary, not a spec — no implementation detail. A change that +introduces or sharpens a term updates `glossary.md` in the same PR, the same way +a behavior change promotes into a capability file. + +### Change files + +A change is a file `changes/YYYY-MM-DD.NN-.md`: - `YYYY-MM-DD` — proposal date; `.NN` — zero-padded intra-day counter (`.01`, `.02`, …) that breaks same-date ties so the timeline sorts stably. @@ -65,25 +83,44 @@ A change is a folder `changes/YYYY-MM-DD.NN-/`: `summary` is written when the change is created (the intent one-liner) and **finalized at ship** to state the realized result — set in the implementing PR, alongside the code and the `architecture/` promotion. No post-merge -bookkeeping, no folder move. `date` and `slug` are never written — they are -read from the bundle's directory name. +bookkeeping, no file move. `date` and `slug` are never written — they are +read from the file name. ### Three lanes | Lane | Artifacts | Use when | |------|-----------|----------| -| **Full** | `design.md` + `plan.md` | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | -| **Lightweight** | `change.md` | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | +| **Full** | one change file from the design template | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | +| **Lightweight** | one change file from the change template | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | | **Tiny** | none — conventional commit | typo, dep bump, linter/formatter/CI tweak, mechanical rename, single-line config | -Heavier lane wins on ambiguity. A `change.md` that outgrows its lane splits -into `design.md` + `plan.md`. +Heavier lane wins on ambiguity. A lightweight change file that outgrows its lane is rewritten from the design template. + +### Plans are ephemeral + +The executable plan — task checklists, embedded code, commit sequences, +whatever the executor needs — is a working artifact, not history. Keep it out +of `changes/` and out of version control (git-ignored scratch, e.g. +`.superpowers/`). Once the change ships, the diff and the PR are the record +of execution; a committed plan duplicates them. `check-planning` rejects +anything in `changes/` that is not a flat change file. + +### Lean specs + +The change file is the single home of a change's rationale: + +- The PR body summarizes and links to the change file — it never restates it. +- Rejected alternatives live in `decisions/` and are referenced, not retold. +- Show a sketch when the design needs code; never the full diff-to-be. +- Delete template sections that don't apply — an empty section is ceremony. +- Most designs fit well under ~700 words; length must buy information. ### Artifacts at a glance -- **`design.md`** — the spec: the *thinking* (why, design, trade-offs, scope). -- **`plan.md`** — the plan: the *sequencing* (the executor's task checklist). -- **`change.md`** — both, condensed, for the lightweight lane. +- **design template** — the spec: the *thinking* (why, design, trade-offs, + scope); the change file it produces is the single home of rationale (see + [Lean specs](#lean-specs)). +- **change template** — the condensed spec for the lightweight lane. - **`releases/.md`** — per-release user-facing notes. - **`audits/-.md`** — findings from a code/docs/bug-hunt sweep; spawns fix changes. @@ -97,11 +134,10 @@ Templates live in [`_templates/`](_templates/). ### Frontmatter -`date` and `slug` are **derived from the directory / file name** — never +`date` and `slug` are **derived from the file name** — never repeated in frontmatter. So: -- `design.md` / `change.md`: `summary` (single line) only. -- `plan.md`: **no frontmatter** — its identity is the bundle directory. +- `changes/*.md`: `summary` (single line) only. - `decisions/*.md`: `status` (accepted|superseded), `summary`, and optional `supersedes` / `superseded_by`. - Files in `architecture/` carry **no** frontmatter — living prose, dated by git. @@ -113,9 +149,9 @@ only field the index renders. ## Index The listing is **generated**, not maintained — run `just index` to print it: -changes then decisions, newest-first. The frontmatter in each bundle / decision +changes then decisions, newest-first. The frontmatter in each change / decision file is the single source of truth; there is no committed copy to drift. -Run `just check-planning` to validate every bundle and decision. +Run `just check-planning` to validate every change and decision. ## Other From 7db105e324113f968510173f629b94c022ab6a45 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 8 Jul 2026 12:19:31 +0300 Subject: [PATCH 3/3] docs: retire "change bundle" wording in living-truth prose Promotion-rule prose now says "change file" (2.0.0 flat-file model). Co-Authored-By: Claude Opus 4.8 (1M context) --- architecture/README.md | 2 +- planning/deferred.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/architecture/README.md b/architecture/README.md index 21d52b0..aa6d146 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -22,4 +22,4 @@ it got here*; this directory records *what it is*. When a change alters a capability's behavior, hand-edit the matching `architecture/.md` in the **same PR** as the code, reviewed in the same diff — never as a separate post-merge step. That hand-edit is what keeps -this directory true; the change bundle in `planning/changes/` stays as the *why*. +this directory true; the change file in `planning/changes/` stays as the *why*. diff --git a/planning/deferred.md b/planning/deferred.md index f7f1770..ace4bb0 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -4,7 +4,7 @@ Items raised in reviews or audits that are real but not actionable now. Each is parked here with the reason it's deferred and the concrete trigger that should bring it back. This is the long-tail register — not a backlog of planned work. When an item is picked up it graduates to a spec/plan -bundle in [`changes/active/`](changes/active/); see [CLAUDE.md](../CLAUDE.md#workflow). +change file in [`changes/active/`](changes/active/); see [CLAUDE.md](../CLAUDE.md#workflow). ## Open