diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index 8daee45..55cd278 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -138,9 +138,7 @@ jobs: with: fetch-depth: 0 - name: Set up environment - env: - REPO_TOKEN: ${{ secrets.REPO_TOKEN }} - run: make setup-release + run: make setup-image EXTRAS=release - name: Build and publish release env: REPO_TOKEN: ${{ secrets.REPO_TOKEN }} @@ -148,7 +146,7 @@ jobs: run: | . .venv/bin/activate export PATH="$HOME/.local/bin:$PATH" - python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" + python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" --auto-login - name: Notify on failure if: failure() env: diff --git a/src/devx/ci/notify_failure.py b/src/devx/ci/notify_failure.py index 0a4efc0..797fe43 100644 --- a/src/devx/ci/notify_failure.py +++ b/src/devx/ci/notify_failure.py @@ -22,14 +22,12 @@ from __future__ import annotations import logging import os -import shutil -import subprocess # nosec B404 import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from devx.config import GITEA_API_URL -from devx.gitea_cli import TeaCLI, TeaCLIError +from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login from devx.i18n import _ load_dotenv() @@ -37,49 +35,6 @@ load_dotenv() logger = logging.getLogger("devx") -def _configure_tea_login(login_name: str = "devx") -> None: - """Configure tea CLI login from REPO_TOKEN and DEVX_GITEA_API_URL. - - 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. - """ - tea_bin = shutil.which("tea") - if tea_bin is None: - click.echo("notify_failure: tea not installed — skipping login configuration.") - return - - token = os.environ.get("REPO_TOKEN", "") - if not token: - click.echo("notify_failure: REPO_TOKEN not set — skipping login configuration.") - return - - gitea_url = GITEA_API_URL.replace("/api/v1", "") - - result = subprocess.run( # nosec B603 - [tea_bin, "login", "list", "--output", "simple"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0 and login_name in result.stdout: - click.echo(f"notify_failure: tea login '{login_name}' already configured.") - return - - click.echo(f"notify_failure: configuring tea login '{login_name}' for {gitea_url}...") - subprocess.run( # nosec B603 - [tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token], - capture_output=True, - text=True, - check=False, - ) - subprocess.run( # nosec B603 - [tea_bin, "login", "default", login_name], - capture_output=True, - text=True, - check=False, - ) - - def _create_issue_via_tea(repo: str, title: str, body: str) -> int: """Create issue via tea CLI. Returns issue index. @@ -124,7 +79,7 @@ def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) - raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) if auto_login: - _configure_tea_login() + configure_tea_login() title = f"[CI] {workflow} workflow failed (run #{run_id})" body = ( diff --git a/src/devx/ci/publish.py b/src/devx/ci/publish.py index abc9fd7..39e0c33 100644 --- a/src/devx/ci/publish.py +++ b/src/devx/ci/publish.py @@ -29,7 +29,7 @@ import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from devx.config import GITEA_API_URL -from devx.gitea_cli import TeaCLI, TeaCLIError +from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login from devx.i18n import _ load_dotenv() @@ -221,12 +221,20 @@ def is_release_commit(tag: str) -> bool: help="Auto-detect latest tag and check if HEAD is a release commit. " "Skips publish if no tag or HEAD is not a release commit for that tag.", ) +@click.option( + "--auto-login", + is_flag=True, + default=False, + help="Configure tea CLI login from REPO_TOKEN before creating the Gitea release. " + "Eliminates the need for a separate tea login step in containerized CI jobs.", +) def main( tag: str | None, repo: str | None, registry_url: str | None, skip_build: bool, from_tag: bool, + auto_login: bool, ) -> None: if repo is None: repo = os.environ.get("GITHUB_REPOSITORY", "") @@ -287,6 +295,9 @@ def main( tea = TeaCLI(repo=repo) + if auto_login: + configure_tea_login() + # Check if release already exists (idempotent — avoids failure when # called multiple times, e.g. by both post-merge and publish workflows) try: diff --git a/src/devx/gitea_cli.py b/src/devx/gitea_cli.py index 72bb198..12185dd 100644 --- a/src/devx/gitea_cli.py +++ b/src/devx/gitea_cli.py @@ -40,15 +40,67 @@ Usage:: from __future__ import annotations import json +import os import shutil import subprocess # nosec B404 from typing import Any +import click + +from devx.config import GITEA_API_URL +from devx.i18n import _ + class TeaCLIError(Exception): """Raised when a tea CLI command fails.""" +def configure_tea_login(login_name: str = "devx") -> None: + """Configure tea CLI login from REPO_TOKEN and DEVX_GITEA_API_URL. + + 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. + + Used by CI scripts (publish, notify_failure) that need tea login but + run in containerized environments where ``make setup`` was not called. + """ + tea_bin = shutil.which("tea") + if tea_bin is None: + click.echo(_("tea not installed — skipping login configuration.")) + return + + token = os.environ.get("REPO_TOKEN", "") + if not token: + click.echo(_("REPO_TOKEN not set — skipping login configuration.")) + return + + gitea_url = GITEA_API_URL.replace("/api/v1", "") + + result = subprocess.run( # nosec B603 + [tea_bin, "login", "list", "--output", "simple"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0 and login_name in result.stdout: + click.echo(_("tea login '{name}' already configured.", name=login_name)) + return + + click.echo(_("Configuring tea login '{name}' for {url}...", name=login_name, url=gitea_url)) + subprocess.run( # nosec B603 + [tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token], + capture_output=True, + text=True, + check=False, + ) + subprocess.run( # nosec B603 + [tea_bin, "login", "default", login_name], + capture_output=True, + text=True, + check=False, + ) + + class TeaCLI: """Wrapper around the ``tea`` Gitea CLI tool. diff --git a/src/devx/make/devx.mak b/src/devx/make/devx.mak index be5b526..dcd2ae1 100644 --- a/src/devx/make/devx.mak +++ b/src/devx/make/devx.mak @@ -69,6 +69,7 @@ DEVX_PIP_INSTALL := if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ .PHONY: devx-clean devx-pre-push .PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed .PHONY: devx-test-unit devx-pytest-cov +.PHONY: devx-setup-image # ── Vikunja task and PR management ──────────────────────────────────────────── @@ -254,17 +255,25 @@ devx-clean: # # When running inside a pre-built Docker runner image (ci-base, ci-quality, # ci-full), all deps are already installed in /opt/venv. This target links -# the venv and installs the project itself (no-deps, fast). -# Falls back to devx-setup-ci if /opt/venv is not present (local dev). +# the venv and installs the project itself (with optional extras). +# +# Usage: +# make devx-setup-image (runtime deps only) +# make devx-setup-image EXTRAS=lint (runtime + lint deps) +# make devx-setup-image EXTRAS=ci,lint (runtime + ci + lint deps) +# +# Falls back to setup-ci if /opt/venv is not present (local dev). +# Note: the fallback target name is project-specific (setup-ci, not +# devx-setup-ci) — each project defines its own setup-ci target. devx-setup-image: @if [ -d /opt/venv ]; then \ ln -sf /opt/venv $(DEVX_VENV); \ - . $(DEVX_BIN)/activate && pip install -e . --no-deps 2>/dev/null; \ - echo "[devx-setup-image] Linked /opt/venv and installed project (no-deps)."; \ + . $(DEVX_BIN)/activate && pip install -e .$(if $(EXTRAS),[$(EXTRAS)],) 2>/dev/null; \ + echo "[devx-setup-image] Linked /opt/venv and installed project$(if $(EXTRAS), with [$(EXTRAS)],)."; \ else \ - echo "[devx-setup-image] /opt/venv not found — falling back to devx-setup-ci"; \ - $(MAKE) devx-setup-ci; \ + echo "[devx-setup-image] /opt/venv not found — falling back to setup-ci"; \ + $(MAKE) setup-ci; \ fi # ── Docker image build / push / cleanup ─────────────────────────────────────── diff --git a/src/devx/translations.json b/src/devx/translations.json index 0302d1c..b67bd5a 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -1998,5 +1998,37 @@ "pl": "", "ru": "", "zh": "" + }, + "tea not installed — skipping login configuration.": { + "bg": "tea not installed — skipping login configuration.", + "de": "tea not installed — skipping login configuration.", + "en": "tea not installed — skipping login configuration.", + "pl": "tea not installed — skipping login configuration.", + "ru": "tea not installed — skipping login configuration.", + "zh": "tea not installed — skipping login configuration." + }, + "REPO_TOKEN not set — skipping login configuration.": { + "bg": "REPO_TOKEN not set — skipping login configuration.", + "de": "REPO_TOKEN not set — skipping login configuration.", + "en": "REPO_TOKEN not set — skipping login configuration.", + "pl": "REPO_TOKEN not set — skipping login configuration.", + "ru": "REPO_TOKEN not set — skipping login configuration.", + "zh": "REPO_TOKEN not set — skipping login configuration." + }, + "tea login '{name}' already configured.": { + "bg": "tea login '{name}' already configured.", + "de": "tea login '{name}' already configured.", + "en": "tea login '{name}' already configured.", + "pl": "tea login '{name}' already configured.", + "ru": "tea login '{name}' already configured.", + "zh": "tea login '{name}' already configured." + }, + "Configuring tea login '{name}' for {url}...": { + "bg": "Configuring tea login '{name}' for {url}...", + "de": "Configuring tea login '{name}' for {url}...", + "en": "Configuring tea login '{name}' for {url}...", + "pl": "Configuring tea login '{name}' for {url}...", + "ru": "Configuring tea login '{name}' for {url}...", + "zh": "Configuring tea login '{name}' for {url}..." } -} \ No newline at end of file +} diff --git a/tests/unit/test_gitea_cli.py b/tests/unit/test_gitea_cli.py index 5da27f7..802dee6 100644 --- a/tests/unit/test_gitea_cli.py +++ b/tests/unit/test_gitea_cli.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch import pytest -from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number +from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number, configure_tea_login class TestExtractIssueNumber: @@ -359,3 +359,37 @@ class TestWhoami: mock_result = MagicMock(returncode=0, stdout="testuser", stderr="") with patch("subprocess.run", return_value=mock_result): assert cli.whoami() == "testuser" + + +class TestConfigureTeaLogin: + @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) + @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") + def test_no_token_skips(self, mock_which: MagicMock) -> None: + """configure_tea_login with no token prints skip message and returns.""" + configure_tea_login() + + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + @patch("devx.gitea_cli.shutil.which", return_value=None) + def test_no_tea_skips(self, mock_which: MagicMock) -> None: + """configure_tea_login with no tea binary prints skip message and returns.""" + configure_tea_login() + + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") + @patch("devx.gitea_cli.subprocess.run") + def test_configures_login_when_not_present(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None: + """configure_tea_login adds login when not already configured.""" + mock_list = MagicMock(returncode=0, stdout="") + mock_subprocess.return_value = mock_list + configure_tea_login() + assert mock_subprocess.call_count >= 2 # login list + login add + login default + + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") + @patch("devx.gitea_cli.subprocess.run") + def test_skips_when_already_configured(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None: + """configure_tea_login skips if login already exists.""" + mock_list = MagicMock(returncode=0, stdout="devx https://git.example.com") + mock_subprocess.return_value = mock_list + configure_tea_login() + assert mock_subprocess.call_count == 1 # only login list, no add diff --git a/tests/unit/test_notify_failure.py b/tests/unit/test_notify_failure.py index c54d2e4..c59f71e 100644 --- a/tests/unit/test_notify_failure.py +++ b/tests/unit/test_notify_failure.py @@ -4,8 +4,8 @@ from unittest.mock import MagicMock, patch from click.testing import CliRunner -from devx.ci.notify_failure import _configure_tea_login, main -from devx.gitea_cli import TeaCLIError +from devx.ci.notify_failure import main +from devx.gitea_cli import TeaCLIError, configure_tea_login class TestNotifyFailure: @@ -116,7 +116,7 @@ class TestNotifyFailure: assert "REPO_TOKEN" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("devx.ci.notify_failure.shutil.which", return_value=None) + @patch("devx.gitea_cli.shutil.which", return_value=None) @patch("devx.ci.notify_failure.TeaCLI") def test_auto_login_no_tea_skips(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None: """--auto-login with tea not installed skips login and still creates issue.""" @@ -134,7 +134,7 @@ class TestNotifyFailure: assert "issue #60" in result.output @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) - @patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") + @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") @patch("devx.ci.notify_failure.TeaCLI") def test_auto_login_no_token_skips_login(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None: """--auto-login with no REPO_TOKEN skips login but raises before creating issue.""" @@ -152,20 +152,20 @@ class TestNotifyFailure: class TestConfigureTeaLogin: @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) - @patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") + @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") def test_no_token_skips(self, mock_which: MagicMock) -> None: - """_configure_tea_login with no token prints skip message and returns.""" - _configure_tea_login() + """configure_tea_login with no token prints skip message and returns.""" + configure_tea_login() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("devx.ci.notify_failure.shutil.which", return_value=None) + @patch("devx.gitea_cli.shutil.which", return_value=None) def test_no_tea_skips(self, mock_which: MagicMock) -> None: - """_configure_tea_login with no tea binary prints skip message and returns.""" - _configure_tea_login() + """configure_tea_login with no tea binary prints skip message and returns.""" + configure_tea_login() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") - @patch("devx.ci.notify_failure.subprocess.run") + @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") + @patch("devx.gitea_cli.subprocess.run") @patch("devx.ci.notify_failure.TeaCLI") def test_auto_login_configures_tea( self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock @@ -192,8 +192,8 @@ class TestConfigureTeaLogin: assert mock_subprocess.call_count >= 2 @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") - @patch("devx.ci.notify_failure.subprocess.run") + @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") + @patch("devx.gitea_cli.subprocess.run") @patch("devx.ci.notify_failure.TeaCLI") def test_auto_login_skips_if_already_configured( self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock diff --git a/tests/unit/test_publish.py b/tests/unit/test_publish.py index b3a0dae..2d1068d 100644 --- a/tests/unit/test_publish.py +++ b/tests/unit/test_publish.py @@ -551,3 +551,37 @@ class TestFromTag: result = runner.invoke(main, ["", "owner/repo", "--skip-build"]) assert result.exit_code != 0 assert "Tag is required" in result.output + + +class TestPublishAutoLogin: + """Tests for --auto-login flag in publish.""" + + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + @patch("devx.ci.publish.configure_tea_login") + @patch("devx.ci.publish.TeaCLI") + def test_auto_login_calls_configure(self, mock_tea_cls: MagicMock, mock_login: MagicMock) -> None: + """--auto-login calls configure_tea_login before creating release.""" + mock_tea = MagicMock() + mock_tea.list_releases.return_value = [] + mock_tea.create_release.return_value = {"tag_name": "v1.0.0"} + mock_tea_cls.return_value = mock_tea + with patch("devx.ci.publish.generate_release_notes", return_value="notes"): + runner = CliRunner() + result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build", "--auto-login"]) + assert result.exit_code == 0 + mock_login.assert_called_once() + + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + @patch("devx.ci.publish.configure_tea_login") + @patch("devx.ci.publish.TeaCLI") + def test_no_auto_login_skips_configure(self, mock_tea_cls: MagicMock, mock_login: MagicMock) -> None: + """Without --auto-login, configure_tea_login is not called.""" + mock_tea = MagicMock() + mock_tea.list_releases.return_value = [] + mock_tea.create_release.return_value = {"tag_name": "v1.0.0"} + mock_tea_cls.return_value = mock_tea + with patch("devx.ci.publish.generate_release_notes", return_value="notes"): + runner = CliRunner() + result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"]) + assert result.exit_code == 0 + mock_login.assert_not_called()