From dcb2ed0fd9d52606e06951f2113bf221181144da Mon Sep 17 00:00:00 2001 From: emil Date: Mon, 22 Jun 2026 11:04:51 +0000 Subject: [PATCH] GRM-58: refactor: require tea CLI everywhere, fail on missing Vikunja task --- .gitea/workflows/post-merge.yml | 11 +++++ .taskid | 2 +- Makefile | 3 +- scripts/ci/auto_merge.py | 18 +++++-- scripts/ci/notify_failure.py | 71 ++++++++------------------ scripts/ci/post_merge.py | 34 ++++++------- scripts/setup.py | 6 +-- tests/unit/test_auto_merge.py | 16 +++--- tests/unit/test_notify_failure.py | 82 ++++++------------------------- tests/unit/test_post_merge.py | 16 +++--- tests/unit/test_setup.py | 7 ++- 11 files changed, 104 insertions(+), 162 deletions(-) diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index d8b376c..33c80cb 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -87,6 +87,7 @@ jobs: REPO_TOKEN: ${{ secrets.REPO_TOKEN }} PYTHONPATH: .:src run: | + export PATH="$HOME/.local/bin:$PATH" python3 scripts/ci/notify_failure.py \ --repo "${{ github.repository }}" \ --run-id "${{ github.run_id }}" \ @@ -117,6 +118,7 @@ jobs: REPO_TOKEN: ${{ secrets.REPO_TOKEN }} PYTHONPATH: .:src run: | + export PATH="$HOME/.local/bin:$PATH" python3 scripts/ci/notify_failure.py \ --repo "${{ github.repository }}" \ --run-id "${{ github.run_id }}" \ @@ -152,6 +154,7 @@ jobs: REPO_TOKEN: ${{ secrets.REPO_TOKEN }} PYTHONPATH: .:src run: | + export PATH="$HOME/.local/bin:$PATH" python3 scripts/ci/notify_failure.py \ --repo "${{ github.repository }}" \ --run-id "${{ github.run_id }}" \ @@ -180,6 +183,10 @@ jobs: REPO_TOKEN: ${{ secrets.REPO_TOKEN }} PYTHONPATH: .:src run: | + export PATH="$HOME/.local/bin:$PATH" + python3 scripts/install_tools.py --tool tea + tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true + tea login default grm || true python3 scripts/ci/notify_failure.py \ --repo "${{ github.repository }}" \ --run-id "${{ github.run_id }}" \ @@ -206,6 +213,10 @@ jobs: REPO_TOKEN: ${{ secrets.REPO_TOKEN }} PYTHONPATH: .:src run: | + export PATH="$HOME/.local/bin:$PATH" + python3 scripts/install_tools.py --tool tea + tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true + tea login default grm || true python3 scripts/ci/notify_failure.py \ --repo "${{ github.repository }}" \ --run-id "${{ github.run_id }}" \ diff --git a/.taskid b/.taskid index 8e26ddd..69f35fe 100644 --- a/.taskid +++ b/.taskid @@ -1 +1 @@ -GRM-57 +GRM-58 diff --git a/Makefile b/Makefile index af3d6e5..f1d007e 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,8 @@ CHECKMAKE := $(shell command -v checkmake 2>/dev/null || echo $(HOME)/go/bin/che all: setup setup: $(VENV)/bin/activate .env activate-scripts checkmake install-tools - @$(PYTHON) scripts/setup.py --bin "$(BIN)" + @export PATH="$(HOME)/.local/bin:$$PATH"; \ + $(PYTHON) scripts/setup.py --bin "$(BIN)" .env: @if [ ! -f .env ]; then \ diff --git a/scripts/ci/auto_merge.py b/scripts/ci/auto_merge.py index bc11cba..2004138 100644 --- a/scripts/ci/auto_merge.py +++ b/scripts/ci/auto_merge.py @@ -109,7 +109,8 @@ def validate_pr_title(pr_title: str, task_id: str) -> None: def get_vikunja_task_title(task_id: str) -> str: """Fetch the Vikunja task title for the given GRM-N identifier. - Returns empty string if VIKUNJA_TOKEN is not set (skip validation). + Returns empty string if VIKUNJA_TOKEN is not set (local dev without token). + Raises ClickException if the token is set but the task is not found. """ token = os.environ.get("VIKUNJA_TOKEN", "") if not token: @@ -126,17 +127,26 @@ def get_vikunja_task_title(task_id: str) -> str: if len(tasks) < DEFAULT_PER_PAGE: break page += 1 - return "" + raise click.ClickException( + _( + "Could not find Vikunja task {task_id} in project {project_id}. " + "Every PR must have a corresponding Vikunja task.", + task_id=task_id, + project_id=VIKUNJA_PROJECT_ID, + ) + ) def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None: """Validate that PR title matches the Vikunja task title. - Skips validation if VIKUNJA_TOKEN is not set. + Skips validation if VIKUNJA_TOKEN is not set (local dev). + Raises ClickException if the task is not found or the title doesn't match. """ vikunja_title = get_vikunja_task_title(task_id) if not vikunja_title: - click.echo(_("Warning: could not fetch Vikunja task title, skipping title match validation.")) + # VIKUNJA_TOKEN not set — skip validation (local dev) + click.echo(_("Warning: VIKUNJA_TOKEN not set, skipping title match validation.")) return expected = f"{task_id}: {vikunja_title}" if pr_title != expected: diff --git a/scripts/ci/notify_failure.py b/scripts/ci/notify_failure.py index 7895c87..5fc7e2a 100644 --- a/scripts/ci/notify_failure.py +++ b/scripts/ci/notify_failure.py @@ -3,8 +3,7 @@ Used by the release and publish workflows to alert on failures that would otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI -for issue creation when available, falling back to ``GiteaClient`` (direct -HTTP API) when tea is not installed. +for issue creation — tea must be installed and configured. Usage: REPO_TOKEN= python3 scripts/notify_failure.py \ @@ -18,59 +17,36 @@ from __future__ import annotations import contextlib import os -import shutil import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] -from gitea_runner_manager.api_clients import GiteaClient from gitea_runner_manager.config import GITEA_API_URL -from gitea_runner_manager.exceptions import APIError from gitea_runner_manager.i18n import _ +from scripts.gitea_cli import TeaCLI, TeaCLIError load_dotenv(override=True) -def _create_issue_via_tea(repo: str, title: str, body: str) -> int | None: - """Try creating issue via tea CLI. Returns issue index or None on failure.""" - if shutil.which("tea") is None: - return None - from scripts.gitea_cli import TeaCLI, TeaCLIError +def _create_issue_via_tea(repo: str, title: str, body: str) -> int: + """Create issue via tea CLI. Returns issue index. + Raises TeaCLIError if tea is not installed or the command fails. + """ tea = TeaCLI(repo=repo) - try: - # Check if "bug" label exists - labels: list[str] = [] - try: - existing_labels = tea.list_labels(repo) - if any(label.get("name") == "bug" for label in existing_labels): - labels = ["bug"] - except TeaCLIError: - pass - issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None) - if labels: - with contextlib.suppress(TeaCLIError): - tea.add_label(repo, issue["index"], labels) - return int(issue.get("index", 0)) - except TeaCLIError: - return None + # Check if "bug" label exists + labels: list[str] = [] + with contextlib.suppress(TeaCLIError): + existing_labels = tea.list_labels(repo) + if any(label.get("name") == "bug" for label in existing_labels): + labels = ["bug"] - -def _create_issue_via_client(repo: str, title: str, body: str) -> int: - """Create issue via GiteaClient (direct HTTP API). Returns issue ID.""" - token = os.environ.get("REPO_TOKEN", "") - owner, repo_name = repo.split("/") - client = GiteaClient(GITEA_API_URL, token, owner, repo_name) - - # Look up label IDs by name (Gitea API expects integer IDs, not strings) - label_ids: list[int] = [] - for label in client.list_labels(): - if label.get("name") == "bug": - label_ids.append(int(label["id"])) - break - issue = client.create_issue(title=title, body=body, labels=label_ids if label_ids else None) - return int(issue.get("id", 0)) + issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None) + if labels: + with contextlib.suppress(TeaCLIError): + tea.add_label(repo, issue["index"], labels) + return int(issue.get("index", 0)) @click.command() @@ -93,15 +69,10 @@ def main(repo: str, run_id: str, workflow: str, commit: str) -> None: f"Please investigate and fix the issue." ) - # Try tea CLI first, fall back to GiteaClient - issue_id = _create_issue_via_tea(repo, title, body) - if issue_id is None: - try: - issue_id = _create_issue_via_client(repo, title, body) - except APIError as e: - raise click.ClickException( - _("Failed to create issue: HTTP {status} — {message}", status=e.status, message=e.message) - ) from None + try: + issue_id = _create_issue_via_tea(repo, title, body) + except TeaCLIError as e: + raise click.ClickException(_("Failed to create issue via tea: {error}", error=str(e))) from None click.echo( _( diff --git a/scripts/ci/post_merge.py b/scripts/ci/post_merge.py index 76f3302..9a7af05 100644 --- a/scripts/ci/post_merge.py +++ b/scripts/ci/post_merge.py @@ -64,11 +64,11 @@ def extract_conventional_msg(commit_msg: str) -> str: return re.sub(r"^GRM-\d+[:\s]\s*", "", first_line) -def resolve_task_id(client: VikunjaClient, task_id: str) -> int | None: +def resolve_task_id(client: VikunjaClient, task_id: str) -> int: """Resolve GRM-N identifier to Vikunja numeric task ID. Paginates through the project's tasks to handle projects with more - than 50 tasks. Returns None if the task is not found. + than 50 tasks. Raises ClickException if the task is not found. """ page = 1 while True: @@ -81,7 +81,14 @@ def resolve_task_id(client: VikunjaClient, task_id: str) -> int | None: if len(tasks) < DEFAULT_PER_PAGE: break page += 1 - return None + raise click.ClickException( + _( + "Could not find Vikunja task {task_id} in project {project_id}. " + "Every PR must have a corresponding Vikunja task.", + task_id=task_id, + project_id=VIKUNJA_PROJECT_ID, + ) + ) def build_comment(task_id: str, conv_msg: str, commit_sha: str) -> str: @@ -154,23 +161,12 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str) return client = VikunjaClient(VIKUNJA_API_URL, token) - vikunja_task_id = 0 - try: - vikunja_task_id = resolve_task_id(client, task_id) or 0 - if not vikunja_task_id: - click.echo( - _( - "Warning: Could not find Vikunja task {task_id} in project {project_id}. " - "The merge succeeded — please update the Vikunja task manually.", - task_id=task_id, - project_id=VIKUNJA_PROJECT_ID, - ) - ) - return - conv_msg = extract_conventional_msg(commit_msg) - sha = commit_sha or "unknown" - html = build_comment(task_id, conv_msg, sha) + vikunja_task_id = resolve_task_id(client, task_id) + conv_msg = extract_conventional_msg(commit_msg) + sha = commit_sha or "unknown" + html = build_comment(task_id, conv_msg, sha) + try: client.post_comment(vikunja_task_id, html) client.update_task(vikunja_task_id, done=True) except APIError as e: diff --git a/scripts/setup.py b/scripts/setup.py index 7c0488f..e57b409 100644 --- a/scripts/setup.py +++ b/scripts/setup.py @@ -55,12 +55,12 @@ def _configure_tea_login() -> None: """Configure tea CLI login from .env if REPO_TOKEN is set. Idempotent: if a login with the same name already exists, it is not re-added. - Skips silently if tea is not installed or REPO_TOKEN is not set. + Fails if tea is not installed (run ``make install-tools`` first). + Skips if REPO_TOKEN is not set (local dev without token). """ tea_bin = shutil.which("tea") if tea_bin is None: - click.echo("tea: not installed — skipping login configuration.") - return + raise click.ClickException("tea: not installed — run 'make install-tools' to install it.") token = os.environ.get("REPO_TOKEN", "") if not token: diff --git a/tests/unit/test_auto_merge.py b/tests/unit/test_auto_merge.py index b6293ad..e933e41 100644 --- a/tests/unit/test_auto_merge.py +++ b/tests/unit/test_auto_merge.py @@ -104,13 +104,13 @@ class TestValidatePrTitleMatchesVikunja: @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True) @patch("scripts.ci.auto_merge.VikunjaClient") - def test_task_not_found_returns_empty(self, mock_client_cls: MagicMock) -> None: - """When Vikunja task is not found, returns empty string (skip validation).""" + def test_task_not_found_raises(self, mock_client_cls: MagicMock) -> None: + """When Vikunja task is not found and token is set, raises ClickException.""" mock_client = MagicMock() mock_client.list_project_tasks.return_value = [] mock_client_cls.return_value = mock_client - # Should not raise — just warn - validate_pr_title_matches_vikunja("GRM-99: test", "GRM-99") + with pytest.raises(click.ClickException, match="Could not find"): + validate_pr_title_matches_vikunja("GRM-99: test", "GRM-99") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True) @patch("scripts.ci.auto_merge.VikunjaClient") @@ -126,15 +126,15 @@ class TestValidatePrTitleMatchesVikunja: @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True) @patch("scripts.ci.auto_merge.VikunjaClient") - def test_task_not_found_partial_page(self, mock_client_cls: MagicMock) -> None: - """Pagination stops when page has fewer than DEFAULT_PER_PAGE results.""" + def test_task_not_found_partial_page_raises(self, mock_client_cls: MagicMock) -> None: + """Pagination stops when page has fewer than DEFAULT_PER_PAGE results. Task not found raises.""" mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ {"id": 1, "identifier": "GRM-1", "title": "Title 1"}, ] mock_client_cls.return_value = mock_client - # Should not raise — returns empty, skips validation - validate_pr_title_matches_vikunja("GRM-99: test", "GRM-99") + with pytest.raises(click.ClickException, match="Could not find"): + validate_pr_title_matches_vikunja("GRM-99: test", "GRM-99") # -- extract_conventional_msg -- diff --git a/tests/unit/test_notify_failure.py b/tests/unit/test_notify_failure.py index c086a6b..5188195 100644 --- a/tests/unit/test_notify_failure.py +++ b/tests/unit/test_notify_failure.py @@ -1,20 +1,17 @@ """Unit tests for scripts/ci/notify_failure.py.""" -import http from unittest.mock import MagicMock, patch from click.testing import CliRunner -from gitea_runner_manager.exceptions import APIError from scripts.ci.notify_failure import main from scripts.gitea_cli import TeaCLIError class TestNotifyFailure: @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") - @patch("scripts.gitea_cli.TeaCLI") - def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None: + @patch("scripts.ci.notify_failure.TeaCLI") + def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock) -> None: mock_tea = MagicMock() mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}] mock_tea.create_issue.return_value = {"index": 42, "title": "test"} @@ -40,9 +37,8 @@ class TestNotifyFailure: mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"]) @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") - @patch("scripts.gitea_cli.TeaCLI") - def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None: + @patch("scripts.ci.notify_failure.TeaCLI") + def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock) -> None: mock_tea = MagicMock() mock_tea.list_labels.return_value = [{"id": 1, "name": "enhancement"}] mock_tea.create_issue.return_value = {"index": 43, "title": "test"} @@ -58,72 +54,25 @@ class TestNotifyFailure: mock_tea.add_label.assert_not_called() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") - @patch("scripts.gitea_cli.TeaCLI") - def test_tea_error_falls_back_to_client(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None: - """When tea fails, fall back to GiteaClient.""" + @patch("scripts.ci.notify_failure.TeaCLI") + def test_tea_error_raises(self, mock_tea_cls: MagicMock) -> None: + """When tea fails, the workflow fails — no fallback.""" mock_tea = MagicMock() mock_tea.list_labels.side_effect = TeaCLIError("network error") mock_tea.create_issue.side_effect = TeaCLIError("network error") mock_tea_cls.return_value = mock_tea - with patch("scripts.ci.notify_failure.GiteaClient") as mock_client_cls: - mock_client = MagicMock() - mock_client.list_labels.return_value = [{"id": 5, "name": "bug"}] - mock_client.create_issue.return_value = {"id": 50} - mock_client_cls.return_value = mock_client - - runner = CliRunner() - result = runner.invoke( - main, - ["--repo", "owner/repo", "--run-id", "125", "--workflow", "release", "--commit", "abc"], - ) - assert result.exit_code == 0 - assert "issue #50" in result.output - mock_client.create_issue.assert_called_once() - - @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.ci.notify_failure.shutil.which", return_value=None) - @patch("scripts.ci.notify_failure.GiteaClient") - def test_tea_not_installed_uses_client(self, mock_client_cls: MagicMock, mock_which: MagicMock) -> None: - """When tea is not installed, use GiteaClient directly.""" - mock_client = MagicMock() - mock_client.list_labels.return_value = [{"id": 5, "name": "bug"}] - mock_client.create_issue.return_value = {"id": 51} - mock_client_cls.return_value = mock_client - runner = CliRunner() result = runner.invoke( main, - ["--repo", "owner/repo", "--run-id", "126", "--workflow", "release", "--commit", "abc"], + ["--repo", "owner/repo", "--run-id", "125", "--workflow", "release", "--commit", "abc"], ) - assert result.exit_code == 0 - assert "issue #51" in result.output - mock_client.create_issue.assert_called_once() + assert result.exit_code != 0 + assert "tea" in result.output.lower() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.ci.notify_failure.shutil.which", return_value=None) - @patch("scripts.ci.notify_failure.GiteaClient") - def test_client_api_error_raises(self, mock_client_cls: MagicMock, mock_which: MagicMock) -> None: - mock_client = MagicMock() - mock_client.list_labels.return_value = [] - mock_client.create_issue.side_effect = APIError(http.HTTPStatus.FORBIDDEN, "forbidden") - mock_client_cls.return_value = mock_client - - runner = CliRunner() - result = runner.invoke( - main, - ["--repo", "owner/repo", "--run-id", "127", "--workflow", "release", "--commit", "abc"], - ) - assert result.exit_code == 1 - assert "403" in result.output - - @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") - @patch("scripts.gitea_cli.TeaCLI") - def test_tea_list_labels_error_continues_without_labels( - self, mock_tea_cls: MagicMock, mock_which: MagicMock - ) -> None: + @patch("scripts.ci.notify_failure.TeaCLI") + def test_tea_list_labels_error_continues_without_labels(self, mock_tea_cls: MagicMock) -> None: """If listing labels fails via tea, issue is still created without labels.""" mock_tea = MagicMock() mock_tea.list_labels.side_effect = TeaCLIError("network error") @@ -139,9 +88,8 @@ class TestNotifyFailure: assert "issue #50" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") - @patch("scripts.gitea_cli.TeaCLI") - def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None: + @patch("scripts.ci.notify_failure.TeaCLI") + def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock) -> None: """If adding label fails via tea, issue is still reported as created.""" mock_tea = MagicMock() mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}] @@ -164,5 +112,5 @@ class TestNotifyFailure: main, ["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc"], ) - assert result.exit_code == 1 + assert result.exit_code != 0 assert "REPO_TOKEN" in result.output diff --git a/tests/unit/test_post_merge.py b/tests/unit/test_post_merge.py index c664aed..310288e 100644 --- a/tests/unit/test_post_merge.py +++ b/tests/unit/test_post_merge.py @@ -58,10 +58,12 @@ class TestResolveTaskId: assert resolve_task_id(mock_client, "GRM-19") == 42 mock_client.list_project_tasks.assert_called_once() - def test_not_found_returns_none(self) -> None: + def test_not_found_raises(self) -> None: + """Missing Vikunja task is a fatal error — every PR must have a task.""" mock_client = MagicMock() mock_client.list_project_tasks.return_value = [] - assert resolve_task_id(mock_client, "GRM-99") is None + with pytest.raises(click.ClickException, match="Could not find"): + resolve_task_id(mock_client, "GRM-99") def test_found_on_second_page(self) -> None: """Task is on page 2 when project has more than 50 tasks.""" @@ -77,7 +79,8 @@ class TestResolveTaskId: mock_client = MagicMock() page1 = [{"id": i, "identifier": f"GRM-{i}"} for i in range(10)] mock_client.list_project_tasks.return_value = page1 - assert resolve_task_id(mock_client, "GRM-99") is None + with pytest.raises(click.ClickException, match="Could not find"): + resolve_task_id(mock_client, "GRM-99") assert mock_client.list_project_tasks.call_count == 1 def test_http_error_propagates(self) -> None: @@ -164,16 +167,15 @@ class TestMain: @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("scripts.ci.post_merge.VikunjaClient") - def test_resolve_failure_warns(self, mock_client_cls: MagicMock) -> None: - """Missing Vikunja task should warn, not fail — the merge already succeeded.""" + def test_resolve_failure_fails(self, mock_client_cls: MagicMock) -> None: + """Missing Vikunja task is a fatal error — every PR must have a task.""" mock_client = MagicMock() mock_client.list_project_tasks.return_value = [] mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(main, ["GRM-20: fix: bug"]) - assert result.exit_code == 0 + assert result.exit_code != 0 assert "Could not find" in result.output - assert "Warning" in result.output @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("scripts.ci.post_merge.VikunjaClient") diff --git a/tests/unit/test_setup.py b/tests/unit/test_setup.py index 773572e..bd1dfad 100644 --- a/tests/unit/test_setup.py +++ b/tests/unit/test_setup.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path from unittest.mock import patch +import click import pytest from click.testing import CliRunner @@ -81,10 +82,12 @@ class TestVerify: class TestConfigureTeaLogin: - def test_tea_not_installed(self) -> None: + def test_tea_not_installed_raises(self) -> None: + """tea must be installed — setup fails if it's missing.""" with patch("shutil.which", return_value=None): with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): - setup._configure_tea_login() + with pytest.raises(click.ClickException, match="not installed"): + setup._configure_tea_login() def test_no_repo_token(self) -> None: with patch("shutil.which", return_value="/usr/bin/tea"):