From 0228fce5b9b95797a4c2dcae02b34082bca97c59 Mon Sep 17 00:00:00 2001 From: emil Date: Wed, 8 Jul 2026 19:30:10 +0000 Subject: [PATCH] DEVX-123: feat: introduce role-based Gitea API token environment variables --- .env.example | 17 ++++- .gitea/workflows/build-images.yml | 20 ++++-- .gitea/workflows/ci.yml | 19 +++-- .gitea/workflows/post-merge.yml | 40 +++++++---- src/devx/ci/auto_merge.py | 20 +++--- src/devx/ci/check_auto_merge_ready.py | 26 ++++--- src/devx/ci/discover_runners.py | 8 ++- src/devx/ci/integration_guard.py | 8 ++- src/devx/ci/notify_failure.py | 13 ++-- src/devx/ci/post_merge.py | 9 +-- src/devx/ci/pr_review.py | 11 +-- src/devx/ci/publish.py | 14 ++-- src/devx/ci/release.py | 2 +- src/devx/ci/sync_wiki.py | 10 +-- src/devx/gitea_cli.py | 11 +-- src/devx/make/devx.mak | 22 +++--- src/devx/molecule/discover_runners.py | 8 ++- src/devx/molecule/molecule_ci_guard.py | 8 ++- src/devx/tokens.py | 76 ++++++++++++++++++++ src/devx/tools/_shared.py | 7 +- src/devx/tools/build_image.py | 12 ++-- src/devx/tools/clean_images.py | 11 +-- src/devx/tools/configure_repo.py | 10 ++- src/devx/tools/create_pr.py | 15 ++-- src/devx/tools/create_task.py | 10 +-- src/devx/tools/pr_label.py | 10 +-- src/devx/tools/pr_logs.py | 10 +-- src/devx/tools/pr_rebase.py | 8 ++- src/devx/tools/pr_status.py | 9 +-- src/devx/tools/pre_push_check.py | 12 ++-- src/devx/tools/setup.py | 13 ++-- src/devx/tools/setup_image.py | 7 +- src/devx/translations.json | 8 +++ tests/unit/test_discover_runners.py | 12 ++++ tests/unit/test_molecule_ci_guard.py | 22 ++++++ tests/unit/test_molecule_discover_runners.py | 12 ++++ tests/unit/test_pr_label.py | 9 ++- tests/unit/test_pr_logs.py | 9 ++- tests/unit/test_pr_review.py | 3 +- tests/unit/test_pr_status.py | 9 ++- tests/unit/test_sync_wiki.py | 5 +- 41 files changed, 413 insertions(+), 152 deletions(-) create mode 100644 src/devx/tokens.py diff --git a/.env.example b/.env.example index 98ea7b6..9bfe097 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,19 @@ -# Gitea API token (required for CI scripts that interact with Gitea) +# Role-based Gitea API tokens. +# Each token serves a specific role. For small teams the developer and CI +# tokens may belong to the same user, but the reviewer token MUST belong to a +# different Gitea user than the PR author so Gitea accepts approval reviews. # Create at: https://git.oblachno.oblachno.fyi/user/settings/applications -CI_GITEA_TOKEN= + +# Developer token — used by local tooling: create-task, create-pr, setup, etc. +DEVELOPER_GITEA_API_TOKEN= + +# CI token — used by CI workflows and scripts that do not post approvals. +# Legacy CI_GITEA_TOKEN is also accepted. +CI_GITEA_API_TOKEN= + +# Reviewer token — used by the auto-merge workflow to post APPROVE reviews. +# This must be a different Gitea user from the developer/CI user. +REVIEWER_GITEA_API_TOKEN= # Vikunja API token (required for post-merge task updates) # Create at: https://work.oblachno.oblachno.fyi/settings/tokens diff --git a/.gitea/workflows/build-images.yml b/.gitea/workflows/build-images.yml index 212c042..e88e5e2 100644 --- a/.gitea/workflows/build-images.yml +++ b/.gitea/workflows/build-images.yml @@ -38,6 +38,8 @@ jobs: with: fetch-depth: 1 - name: Set up environment + env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-ci - name: Check if this is a release commit id: check @@ -62,18 +64,22 @@ jobs: fetch-depth: 0 - name: Set up environment env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-release - name: Docker registry login env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} run: | . .venv/bin/activate - echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin + _TOKEN="$CI_GITEA_API_TOKEN" + [ -z "$_TOKEN" ] && _TOKEN="$DEVELOPER_GITEA_API_TOKEN" + [ -z "$_TOKEN" ] && _TOKEN="$CI_GITEA_TOKEN" + if [ -z "$_TOKEN" ]; then echo "Gitea API token not set — skipping Docker login"; exit 1; fi + echo "$_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin - name: Build and push tier images env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} PYTHONPATH: src run: | @@ -103,7 +109,7 @@ jobs: - name: Notify on failure if: failure() env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src run: | . .venv/bin/activate 2>/dev/null || true @@ -125,10 +131,12 @@ jobs: with: fetch-depth: 1 - name: Set up environment + env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-ci - name: Clean up old image versions env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src run: | . .venv/bin/activate diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fe13506..0222e2c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up environment + env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-image - name: Lint all run: | @@ -79,6 +81,8 @@ jobs: with: fetch-depth: 0 - name: Set up environment + env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-image - name: Detect changed paths id: detect @@ -106,10 +110,11 @@ jobs: fetch-depth: 0 - name: Set up environment env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-image - name: Release dry-run validation env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src run: | . .venv/bin/activate 2>/dev/null || true @@ -127,10 +132,12 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up environment + env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-image - name: Run automated PR review env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src run: | set -euo pipefail @@ -160,12 +167,14 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.CI_GITEA_TOKEN }} + token: ${{ secrets.CI_GITEA_API_TOKEN }} - name: Set up environment + env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-image - name: Post approval review env: - CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }} + REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }} PR_NUMBER: ${{ github.event.number }} REPOSITORY: ${{ github.repository }} PYTHONPATH: src @@ -180,7 +189,7 @@ jobs: --body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)." - name: Squash merge with task ID env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} DEVX_VIKUNJA_PROJECT_ID: "8" PYTHONPATH: src diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index 3137031..11963ae 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -47,6 +47,8 @@ jobs: with: fetch-depth: 1 - name: Set up environment + env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-image - name: Check if this is a release commit id: check @@ -70,6 +72,8 @@ jobs: with: fetch-depth: 1 - name: Set up environment + env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-image - name: Validate latest commit message env: @@ -95,10 +99,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.CI_GITEA_TOKEN }} + token: ${{ secrets.CI_GITEA_API_TOKEN }} - name: Set up environment env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-image - name: Configure git run: | @@ -107,6 +111,7 @@ jobs: - name: Run release id: release-tag env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src run: | . .venv/bin/activate 2>/dev/null || true @@ -115,7 +120,7 @@ jobs: - name: Notify on failure if: failure() env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src run: | . .venv/bin/activate 2>/dev/null || true @@ -142,10 +147,12 @@ jobs: fetch-depth: 0 ref: ${{ needs.release.outputs.tag }} - name: Set up environment + env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-image EXTRAS=release - name: Build and publish release env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src run: | . .venv/bin/activate 2>/dev/null || true @@ -154,7 +161,7 @@ jobs: - name: Notify on failure if: failure() env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src run: | . .venv/bin/activate 2>/dev/null || true @@ -183,10 +190,12 @@ jobs: with: fetch-depth: 0 - name: Set up environment + env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-image - name: Sync documentation to wiki env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src run: | . .venv/bin/activate 2>/dev/null || true @@ -194,7 +203,7 @@ jobs: - name: Notify on failure if: failure() env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src run: | export PATH="$HOME/.local/bin:$PATH" @@ -219,15 +228,18 @@ jobs: with: fetch-depth: 0 ref: master - token: ${{ secrets.CI_GITEA_TOKEN }} + token: ${{ secrets.CI_GITEA_API_TOKEN }} - name: Fetch latest master run: | git fetch origin master git reset --hard origin/master - name: Set up environment + env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-image - name: Generate and push badges env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PRE_COMMIT_ALLOW_NO_CONFIG: "1" run: | . .venv/bin/activate 2>/dev/null || true @@ -235,7 +247,7 @@ jobs: - name: Notify on failure if: failure() env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src run: | export PATH="$HOME/.local/bin:$PATH" @@ -260,6 +272,8 @@ jobs: with: fetch-depth: 0 - name: Set up environment + env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-image - name: Update Vikunja task env: @@ -272,7 +286,7 @@ jobs: - name: Notify on failure if: failure() env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src run: | export PATH="$HOME/.local/bin:$PATH" @@ -295,10 +309,12 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up environment + env: + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} run: make setup-image - name: Ensure branch protection and labels env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src DEVX_REPO_NAME: devx DEVX_REPO_OWNER: oblachno-oss @@ -308,7 +324,7 @@ jobs: - name: Notify on failure if: failure() env: - CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} + CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} PYTHONPATH: src run: | export PATH="$HOME/.local/bin:$PATH" diff --git a/src/devx/ci/auto_merge.py b/src/devx/ci/auto_merge.py index 0fb4ea0..846a84f 100644 --- a/src/devx/ci/auto_merge.py +++ b/src/devx/ci/auto_merge.py @@ -17,10 +17,9 @@ This allows the PR title to be a human-friendly Vikunja task title while the squashed commit follows conventional commits. Usage: - CI_GITEA_TOKEN= python3 -m devx.ci.auto_merge + CI_GITEA_API_TOKEN= VIKUNJA_TOKEN= python3 -m devx.ci.auto_merge """ -import os import re from pathlib import Path from typing import Any @@ -40,6 +39,7 @@ from devx.config import ( ) from devx.exceptions import APIError from devx.i18n import _ +from devx.tokens import get_ci_token, get_vikunja_token # Strip leading task ID prefix (e.g. "DEVX-12: " or "OBL-INFRA-364: ") from commit subjects. _TASK_ID_PREFIX_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s*") @@ -115,9 +115,12 @@ def get_vikunja_task_title(task_id: str) -> str: Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found. """ - token = os.environ.get("VIKUNJA_TOKEN", "") - if not token: - raise click.ClickException(_("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.")) + try: + token = get_vikunja_token() + except click.ClickException: + raise click.ClickException( + _("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.") + ) from None client = VikunjaClient(VIKUNJA_API_URL, token) page = 1 while True: @@ -197,9 +200,10 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str: @click.argument("repo") @click.argument("pr_number") def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None: - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token: - raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) + try: + token = get_ci_token() + except click.ClickException: + raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None # Validate PR number is an integer try: diff --git a/src/devx/ci/check_auto_merge_ready.py b/src/devx/ci/check_auto_merge_ready.py index 791daba..5c657a8 100644 --- a/src/devx/ci/check_auto_merge_ready.py +++ b/src/devx/ci/check_auto_merge_ready.py @@ -15,7 +15,7 @@ Exit code 1 = NOT ready — fix issues before pushing. Usage:: - # CI (with VIKUNJA_TOKEN and CI_GITEA_TOKEN): + # CI (with VIKUNJA_TOKEN and CI_GITEA_API_TOKEN): python3 -m devx.ci.check_auto_merge_ready \\ --branch "$HEAD_REF" \\ --pr-title "$PR_TITLE" \\ @@ -34,13 +34,12 @@ skipped (with a warning) — this allows local pre-push hooks to run without CI secrets. In CI, the token is always set and the check is mandatory. -If ``CI_GITEA_TOKEN`` is not set and ``--pr-number`` is not provided, only +If ``CI_GITEA_API_TOKEN`` is not set and ``--pr-number`` is not provided, only branch-name and PR-title-format checks run (local mode). """ from __future__ import annotations -import os import subprocess # nosec B404 import click @@ -55,6 +54,7 @@ from devx.config import ( ) from devx.exceptions import APIError from devx.i18n import _ +from devx.tokens import get_ci_token, get_vikunja_token load_dotenv() @@ -99,10 +99,13 @@ def is_branch_behind_master(branch: str) -> bool: def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None: """Fetch the PR title from the Gitea API. - Returns ``None`` if ``CI_GITEA_TOKEN`` is not set or the PR cannot be fetched. + Returns ``None`` if no token is set or the PR cannot be fetched. """ - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token or "/" not in repo: + try: + token = get_ci_token() + except click.ClickException: + return None + if "/" not in repo: return None owner, repo_name = repo.split("/", 1) client = GiteaClient(GITEA_API_URL, token, owner, repo_name) @@ -120,8 +123,9 @@ def get_vikunja_title_optional(task_id: str) -> str | None: raise when ``VIKUNJA_TOKEN`` is missing — it returns ``None`` so the caller can skip the check in local mode. """ - token = os.environ.get("VIKUNJA_TOKEN", "") - if not token: + try: + token = get_vikunja_token() + except click.ClickException: return None client = VikunjaClient(VIKUNJA_API_URL, token) from devx.config import DEFAULT_PER_PAGE @@ -221,7 +225,11 @@ def cli( if not skip_vikunja: vikunja_title = get_vikunja_title_optional(task_id) if vikunja_title is None: - token_set = bool(os.environ.get("VIKUNJA_TOKEN", "")) + try: + get_vikunja_token() + token_set = True + except click.ClickException: + token_set = False if token_set: errors.append( _( diff --git a/src/devx/ci/discover_runners.py b/src/devx/ci/discover_runners.py index 943cc79..c31005d 100644 --- a/src/devx/ci/discover_runners.py +++ b/src/devx/ci/discover_runners.py @@ -31,6 +31,7 @@ import requests from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER from devx.i18n import _ +from devx.tokens import get_ci_token DEFAULT_MAX_RUNNERS = 3 @@ -96,7 +97,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int: return total -def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int: +def get_runner_count(api_url: str, token: str | None, owner: str, repo: str) -> int: """Determine the number of available runners. Tries the Gitea API first, then falls back to env vars, then default. @@ -152,7 +153,10 @@ def main( output_indices: bool, github_output: bool, ) -> None: - token = os.environ.get("CI_GITEA_TOKEN", "") + try: + token = get_ci_token() + except click.ClickException: + token = None if owner is None: owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER diff --git a/src/devx/ci/integration_guard.py b/src/devx/ci/integration_guard.py index a35992a..5fba8f5 100644 --- a/src/devx/ci/integration_guard.py +++ b/src/devx/ci/integration_guard.py @@ -17,7 +17,7 @@ Usage:: Environment variables: GITEA_URL Base URL of the Gitea instance. - CI_GITEA_TOKEN API token with repo access. + CI_GITEA_API_TOKEN API token with repo access (CI_GITEA_TOKEN accepted for legacy). RUN_ID Workflow run ID (GITHUB_RUN_ID). JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests". MATRIX_INDEX Current matrix index (runner-index). @@ -41,6 +41,7 @@ from devx.i18n import _ from devx.molecule.molecule_ci_guard import ( poll_for_other_failures, ) +from devx.tokens import get_ci_token POLL_INTERVAL = 10 @@ -50,7 +51,10 @@ POLL_INTERVAL = 10 def cli(pytest_args: tuple[str, ...]) -> None: """Run pytest with cross-runner failure detection.""" gitea_url = os.environ.get("GITEA_URL", "") - token = os.environ.get("CI_GITEA_TOKEN", "") + try: + token = get_ci_token() + except click.ClickException: + token = None run_id = int(os.environ.get("RUN_ID", "0")) job_name = os.environ.get("JOB_NAME", "integration-tests") current_index = int(os.environ.get("MATRIX_INDEX", "0")) diff --git a/src/devx/ci/notify_failure.py b/src/devx/ci/notify_failure.py index 242e501..ddfd485 100644 --- a/src/devx/ci/notify_failure.py +++ b/src/devx/ci/notify_failure.py @@ -6,7 +6,7 @@ otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI for issue creation — tea must be installed and configured. Usage: - CI_GITEA_TOKEN= python3 -m devx.ci.notify_failure \ + CI_GITEA_API_TOKEN= python3 -m devx.ci.notify_failure \ --repo \ --run-id \ --workflow \ @@ -14,14 +14,13 @@ Usage: --auto-login With ``--auto-login``, the script configures the tea CLI login profile -from ``CI_GITEA_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue, +from the CI API token and ``DEVX_GITEA_API_URL`` before creating the issue, eliminating the need for a separate ``tea login add`` step in the workflow. """ from __future__ import annotations import logging -import os import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] @@ -29,6 +28,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk from devx.config import GITEA_API_URL from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login from devx.i18n import _ +from devx.tokens import get_ci_token load_dotenv() @@ -74,9 +74,10 @@ def _create_issue_via_tea(repo: str, title: str, body: str) -> int: help="Configure tea CLI login from CI_GITEA_TOKEN before creating the issue.", ) def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None: - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token: - raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) + try: + get_ci_token() + except click.ClickException: + raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None if auto_login: configure_tea_login() diff --git a/src/devx/ci/post_merge.py b/src/devx/ci/post_merge.py index a5a67d1..594c879 100644 --- a/src/devx/ci/post_merge.py +++ b/src/devx/ci/post_merge.py @@ -5,7 +5,6 @@ Usage: VIKUNJA_TOKEN= python3 -m devx.ci.post_merge [--commit-sha ] """ -import os import re import subprocess # nosec B404 @@ -17,6 +16,7 @@ from devx.ci._shared import extract_task_id as _extract_task_id from devx.config import DEFAULT_PER_PAGE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID from devx.exceptions import APIError from devx.i18n import _ +from devx.tokens import get_vikunja_token load_dotenv() @@ -127,9 +127,10 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str) commit_sha = _get_git_commit_sha() if not commit_msg: raise click.ClickException("commit_msg argument is required (or use --from-git or --git-sha)") - token = os.environ.get("VIKUNJA_TOKEN", "") - if not token: - raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set.")) + try: + token = get_vikunja_token() + except click.ClickException: + raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set.")) from None task_id = extract_task_id(commit_msg) if not task_id: diff --git a/src/devx/ci/pr_review.py b/src/devx/ci/pr_review.py index c0c6b92..bff91e6 100644 --- a/src/devx/ci/pr_review.py +++ b/src/devx/ci/pr_review.py @@ -17,12 +17,11 @@ Checks performed: 8. Commit conventions — conventional commit format on branch commits Usage: - CI_GITEA_TOKEN= python3 -m devx.ci.pr_review + CI_GITEA_API_TOKEN= [REVIEWER_GITEA_API_TOKEN=] python3 -m devx.ci.pr_review """ from __future__ import annotations -import os import re from dataclasses import dataclass, field from typing import Any @@ -34,6 +33,7 @@ from devx.api_clients import GiteaClient from devx.config import GITEA_API_URL from devx.exceptions import APIError from devx.i18n import _ +from devx.tokens import get_ci_token, get_reviewer_token load_dotenv() @@ -636,9 +636,10 @@ def main( Without --event: runs automated checks and posts COMMENT/REQUEST_CHANGES. With --event: posts a manual review (skips automated checks). """ - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token: - raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) + try: + token = get_reviewer_token() if (event and event.upper() == "APPROVE") else get_ci_token() + except click.ClickException: + raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None owner, repo_name = repo.split("/") client = GiteaClient(GITEA_API_URL, token, owner, repo_name) diff --git a/src/devx/ci/publish.py b/src/devx/ci/publish.py index 3dea9c7..a0ff244 100644 --- a/src/devx/ci/publish.py +++ b/src/devx/ci/publish.py @@ -9,14 +9,14 @@ Publishing destinations (checked in order): ``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL`` is converted to a packages URL). Uses ``twine upload --repository-url -u -p `` with the - ``CI_GITEA_TOKEN`` as both username and password. + CI API token as both username and password. 2. **Standard PyPI** — if ``PYPI_TOKEN`` is set. Uses the standard ``twine upload -u __token__ -p `` flow. 3. **Skip** — if neither is configured, only the Gitea release is created. Usage: - CI_GITEA_TOKEN= [PYPI_TOKEN=] python3 -m devx.ci.publish - CI_GITEA_TOKEN= python3 -m devx.ci.publish --registry-url https://git.example.com/api/packages/owner/pypi + CI_GITEA_API_TOKEN= [PYPI_TOKEN=] python3 -m devx.ci.publish + CI_GITEA_API_TOKEN= python3 -m devx.ci.publish --registry-url https://git.example.com/api/packages/owner/pypi """ import os @@ -31,6 +31,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk from devx.config import GITEA_API_URL, REPO_OWNER from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login from devx.i18n import _ +from devx.tokens import get_ci_token load_dotenv() @@ -253,9 +254,10 @@ def main( if not tag: raise click.ClickException(_("Tag is required (or use --from-tag).")) - gitea_token = os.environ.get("CI_GITEA_TOKEN", "") - if not gitea_token: - raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) + try: + gitea_token = get_ci_token() + except click.ClickException: + raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None pypi_token = os.environ.get("PYPI_TOKEN", "") diff --git a/src/devx/ci/release.py b/src/devx/ci/release.py index fc30573..4f4cb7c 100644 --- a/src/devx/ci/release.py +++ b/src/devx/ci/release.py @@ -29,7 +29,7 @@ version. This prevents duplicate release commits (a common issue when CI checkouts don't fetch tags) and ensures tag/version/commit alignment. Usage: - CI_GITEA_TOKEN= python3 -m devx.ci.release [--dry-run] [--skip-tests] + CI_GITEA_API_TOKEN= python3 -m devx.ci.release [--dry-run] [--skip-tests] python3 -m devx.ci.release --verify # Check tag/version/release alignment """ diff --git a/src/devx/ci/sync_wiki.py b/src/devx/ci/sync_wiki.py index 54e994b..263d908 100644 --- a/src/devx/ci/sync_wiki.py +++ b/src/devx/ci/sync_wiki.py @@ -21,7 +21,7 @@ Link transformations: - Anchor-only links (``#section``) are preserved Usage: - CI_GITEA_TOKEN= python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo] + CI_GITEA_API_TOKEN= python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo] """ from __future__ import annotations @@ -40,6 +40,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER from devx.i18n import _ +from devx.tokens import get_ci_token load_dotenv() @@ -274,9 +275,10 @@ def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool: ) def main(dry_run: bool, repo: str | None, verify: bool) -> None: """Sync documentation to the Gitea wiki via Git.""" - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token: - raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) + try: + token = get_ci_token() + except click.ClickException: + raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None if repo is None: owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER diff --git a/src/devx/gitea_cli.py b/src/devx/gitea_cli.py index 96989a4..583f31e 100644 --- a/src/devx/gitea_cli.py +++ b/src/devx/gitea_cli.py @@ -40,7 +40,6 @@ Usage:: from __future__ import annotations import json -import os import shutil import subprocess # nosec B404 from typing import Any @@ -49,6 +48,7 @@ import click from devx.config import GITEA_API_URL from devx.i18n import _ +from devx.tokens import get_ci_token class TeaCLIError(Exception): @@ -56,10 +56,10 @@ class TeaCLIError(Exception): def configure_tea_login(login_name: str = "devx") -> None: - """Configure tea CLI login from CI_GITEA_TOKEN and DEVX_GITEA_API_URL. + """Configure tea CLI login from CI_GITEA_API_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 CI_GITEA_TOKEN is not set. + Skips silently if tea is not installed or no token is set. Used by CI scripts (publish, notify_failure) that need tea login but run in containerized environments where ``make setup`` was not called. @@ -69,8 +69,9 @@ def configure_tea_login(login_name: str = "devx") -> None: click.echo(_("tea not installed — skipping login configuration.")) return - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token: + try: + token = get_ci_token() + except click.ClickException: click.echo(_("CI_GITEA_TOKEN not set — skipping login configuration.")) return diff --git a/src/devx/make/devx.mak b/src/devx/make/devx.mak index 48e6ce4..451c013 100644 --- a/src/devx/make/devx.mak +++ b/src/devx/make/devx.mak @@ -61,10 +61,12 @@ DEVX_VALE_LEVEL ?= warning # Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]' # CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable. # Projects can alias: PIP_INSTALL = $(DEVX_PIP_INSTALL) -DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ - CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \ +DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_API_TOKEN" ] && [ -z "$$DEVELOPER_GITEA_API_TOKEN" ] && [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ + _TOKEN="$$CI_GITEA_API_TOKEN"; \ + [ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; \ + [ -z "$$_TOKEN" ] && _TOKEN="$$CI_GITEA_TOKEN"; \ _PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \ - if [ -n "$$CI_GITEA_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \ + if [ -n "$$_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \ $(DEVX_BIN)/pip # ── Virtual environment management ──────────────────────────────────────────── @@ -196,12 +198,14 @@ devx-pr-rebase: # ── Environment setup ───────────────────────────────────────────────────────── # Configure Gitea private PyPI registry so pip can find devx and other -# private packages. In CI, CI_GITEA_TOKEN is set as a secret. Locally, it's in .env. +# private packages. In CI, CI_GITEA_API_TOKEN is set as a secret. Locally, DEVELOPER_GITEA_API_TOKEN or CI_GITEA_TOKEN can be used. devx-configure-gitea-pypi: - @if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ - CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \ - if [ -z "$$CI_GITEA_TOKEN" ]; then echo "[configure-gitea-pypi] CI_GITEA_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \ - echo "[configure-gitea-pypi] Gitea PyPI registry configured (CI_GITEA_TOKEN present)." + @if [ -z "$$CI_GITEA_API_TOKEN" ] && [ -z "$$DEVELOPER_GITEA_API_TOKEN" ] && [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ + _TOKEN="$$CI_GITEA_API_TOKEN"; \ + [ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; \ + [ -z "$$_TOKEN" ] && _TOKEN="$$CI_GITEA_TOKEN"; \ + if [ -z "$$_TOKEN" ]; then echo "[configure-gitea-pypi] Gitea API token not set — skipping (devx must be on public PyPI)"; exit 0; fi; \ + echo "[configure-gitea-pypi] Gitea PyPI registry configured (token present)." # Create .env from .env.example if it doesn't exist devx-env: @@ -268,7 +272,7 @@ devx-workflow-check: devx-workflow-lint devx-workflow-dryrun # Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure. # Usage: make devx-notify-failure WORKFLOW=post-merge/release -# Requires: CI_GITEA_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA +# Requires: CI_GITEA_API_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA devx-notify-failure: @. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \ export PATH="$(HOME)/.local/bin:$$PATH"; \ diff --git a/src/devx/molecule/discover_runners.py b/src/devx/molecule/discover_runners.py index 135f0ed..a938e6f 100644 --- a/src/devx/molecule/discover_runners.py +++ b/src/devx/molecule/discover_runners.py @@ -30,6 +30,7 @@ import click import requests from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER +from devx.tokens import get_ci_token DEFAULT_MAX_RUNNERS = 3 @@ -86,7 +87,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int: return total -def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int: +def get_runner_count(api_url: str, token: str | None, owner: str, repo: str) -> int: """Determine the number of available runners. Tries the Gitea API first, then falls back to env vars, then default. @@ -142,7 +143,10 @@ def main( output_indices: bool, github_output: bool, ) -> None: - token = os.environ.get("CI_GITEA_TOKEN", "") + try: + token = get_ci_token() + except click.ClickException: + token = None if owner is None: owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER diff --git a/src/devx/molecule/molecule_ci_guard.py b/src/devx/molecule/molecule_ci_guard.py index 393b736..a13f16f 100644 --- a/src/devx/molecule/molecule_ci_guard.py +++ b/src/devx/molecule/molecule_ci_guard.py @@ -22,7 +22,7 @@ Usage:: Environment variables: GITEA_URL Base URL of the Gitea instance. - CI_GITEA_TOKEN API token with repo access. + CI_GITEA_API_TOKEN API token with repo access (CI_GITEA_TOKEN accepted for legacy). RUN_ID Workflow run ID (GITHUB_RUN_ID). JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests". MATRIX_INDEX Current matrix index (runner-index). @@ -45,6 +45,7 @@ import requests from devx.config import REPO_NAME, REPO_OWNER from devx.i18n import _ +from devx.tokens import get_ci_token POLL_INTERVAL = 10 @@ -164,7 +165,10 @@ def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Pat def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None: """Run molecule pairs sequentially, stop if another CI runner fails.""" gitea_url = os.environ.get("GITEA_URL", "") - token = os.environ.get("CI_GITEA_TOKEN", "") + try: + token = get_ci_token() + except click.ClickException: + token = None run_id = int(os.environ.get("RUN_ID", "0")) job_name = os.environ.get("JOB_NAME", "molecule-tests") current_index = int(os.environ.get("MATRIX_INDEX", "0")) diff --git a/src/devx/tokens.py b/src/devx/tokens.py new file mode 100644 index 0000000..25110aa --- /dev/null +++ b/src/devx/tokens.py @@ -0,0 +1,76 @@ +"""Token resolution helpers for devx tools. + +Centralizes Gitea/Vikunja token discovery with role-based environment +variable names and backwards compatibility with the legacy +``CI_GITEA_TOKEN`` / ``REVIEW_GITEA_TOKEN`` naming convention. + +Roles: +- ``CI_GITEA_API_TOKEN``: CI workflows (read actions, post status, merge, etc.) +- ``REVIEWER_GITEA_API_TOKEN``: PR approval reviews (must be a different user + from the PR author for Gitea to accept the review as an approval) +- ``DEVELOPER_GITEA_API_TOKEN``: local development tools (create-task, + create-pr, setup, etc.) + +Fallbacks: +- New role names are checked first. +- Legacy names (``CI_GITEA_TOKEN``, ``REVIEW_GITEA_TOKEN``) are accepted for + backwards compatibility. +- If no role-specific token is set, the generic CI tokens are tried last. +""" + +from __future__ import annotations + +import os + +import click + +from devx.i18n import _ + +# Token environment variable names, in lookup priority order. +CI_TOKEN_NAMES = ["CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"] +REVIEWER_TOKEN_NAMES = [ + "REVIEWER_GITEA_API_TOKEN", + # Legacy name used before role-based tokens. + "REVIEW_GITEA_TOKEN", + *CI_TOKEN_NAMES, +] +DEVELOPER_TOKEN_NAMES = ["DEVELOPER_GITEA_API_TOKEN", *CI_TOKEN_NAMES] + +VIKUNJA_TOKEN_NAMES = ["VIKUNJA_TOKEN"] + + +def get_token(*names: str) -> str: + """Return the first non-empty value from the listed environment variables. + + Raises a ``click.ClickException`` if none of the listed variables are set. + """ + for name in names: + token = os.environ.get(name, "").strip() + if token: + return token + raise click.ClickException( + _( + "Gitea API token not set. Set one of: {names}", + names=", ".join(names), + ) + ) + + +def get_ci_token() -> str: + """Resolve the CI Gitea API token.""" + return get_token(*CI_TOKEN_NAMES) + + +def get_reviewer_token() -> str: + """Resolve the reviewer Gitea API token used for PR approvals.""" + return get_token(*REVIEWER_TOKEN_NAMES) + + +def get_developer_token() -> str: + """Resolve the developer Gitea API token used for local tooling.""" + return get_token(*DEVELOPER_TOKEN_NAMES) + + +def get_vikunja_token() -> str: + """Resolve the Vikunja API token.""" + return get_token(*VIKUNJA_TOKEN_NAMES) diff --git a/src/devx/tools/_shared.py b/src/devx/tools/_shared.py index 0707ee3..6e66f90 100644 --- a/src/devx/tools/_shared.py +++ b/src/devx/tools/_shared.py @@ -8,6 +8,8 @@ import subprocess # nosec B404 import click +from devx.tokens import get_developer_token + def arch_string() -> str: """Return the architecture string used by release assets. @@ -45,8 +47,9 @@ def detect_pr_number() -> int | None: if branch == "HEAD": return None - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token: + try: + token = get_developer_token() + except click.ClickException: return None owner = os.environ.get("DEVX_REPO_OWNER", "") diff --git a/src/devx/tools/build_image.py b/src/devx/tools/build_image.py index fd82cd9..f1ea7ae 100644 --- a/src/devx/tools/build_image.py +++ b/src/devx/tools/build_image.py @@ -34,8 +34,8 @@ The manifest file is a JSON list of dicts, each with: - ``context``: build context directory (optional, defaults to repo root) - ``tags``: list of tags (optional, defaults to ``["latest"]``) -Registry authentication uses ``CI_GITEA_TOKEN`` and ``CI_GITEA_USERNAME`` -environment variables, matching the existing CI workflow patterns. +Registry authentication uses ``CI_GITEA_API_TOKEN`` (or legacy ``CI_GITEA_TOKEN``) +and ``CI_GITEA_USERNAME`` environment variables, matching the existing CI workflow patterns. """ from __future__ import annotations @@ -49,6 +49,7 @@ from pathlib import Path import click from devx.i18n import _ +from devx.tokens import get_developer_token @dataclass @@ -221,9 +222,12 @@ def push_image( def _get_registry_creds() -> tuple[str, str]: """Get registry credentials from environment variables.""" - token = os.environ.get("CI_GITEA_TOKEN", "") + try: + token = get_developer_token() + except click.ClickException: + token = None username = os.environ.get("CI_GITEA_USERNAME", "") - return username, token + return username, token or "" @click.command() diff --git a/src/devx/tools/clean_images.py b/src/devx/tools/clean_images.py index f829886..b7dec61 100644 --- a/src/devx/tools/clean_images.py +++ b/src/devx/tools/clean_images.py @@ -28,12 +28,11 @@ Usage:: --keep 2 \\ --dry-run -Authentication uses ``CI_GITEA_TOKEN`` environment variable. +Authentication uses ``CI_GITEA_API_TOKEN`` environment variable (or legacy ``CI_GITEA_TOKEN``). """ from __future__ import annotations -import os import time from typing import Any @@ -42,6 +41,7 @@ import requests from devx.config import GITEA_API_URL, REPO_OWNER from devx.i18n import _ +from devx.tokens import get_developer_token def list_package_versions( @@ -187,9 +187,10 @@ def main( api_url: str | None, ) -> None: """Clean up old Docker image versions from a Gitea registry.""" - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token: - raise click.ClickException(_("CI_GITEA_TOKEN environment variable required")) + try: + token = get_developer_token() + except click.ClickException: + raise click.ClickException(_("CI_GITEA_TOKEN environment variable required")) from None if not owner: owner = REPO_OWNER if not owner: diff --git a/src/devx/tools/configure_repo.py b/src/devx/tools/configure_repo.py index 779523d..b8e2754 100644 --- a/src/devx/tools/configure_repo.py +++ b/src/devx/tools/configure_repo.py @@ -7,8 +7,8 @@ ci-improvement, doc-improvement, workflow-improvement) are created idempotently via ``ensure_label``. Usage: - CI_GITEA_TOKEN= python3 -m devx.tools.configure_repo --repo my-repo - CI_GITEA_TOKEN= python3 -m devx.tools.configure_repo --repo my-repo --owner my-org + DEVELOPER_GITEA_API_TOKEN= python3 -m devx.tools.configure_repo --repo my-repo + DEVELOPER_GITEA_API_TOKEN= python3 -m devx.tools.configure_repo --repo my-repo --owner my-org """ from __future__ import annotations @@ -23,6 +23,7 @@ from devx.api_clients import GiteaClient from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER from devx.exceptions import APIError from devx.i18n import _ +from devx.tokens import get_developer_token def _default_status_checks() -> list[str]: @@ -175,7 +176,10 @@ def configure_repo( ) def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) -> None: """Configure branch protection and repository settings via the Gitea API.""" - token = os.environ.get("CI_GITEA_TOKEN", "") + try: + token = get_developer_token() + except click.ClickException: + raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None if repo is None: repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME diff --git a/src/devx/tools/create_pr.py b/src/devx/tools/create_pr.py index a1d95b4..8701817 100644 --- a/src/devx/tools/create_pr.py +++ b/src/devx/tools/create_pr.py @@ -42,6 +42,7 @@ from devx.config import ( VIKUNJA_PROJECT_ID, ) from devx.i18n import _ +from devx.tokens import get_developer_token, get_vikunja_token load_dotenv() @@ -72,9 +73,10 @@ def get_vikunja_task_title(task_id: str) -> str: Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found. """ - token = os.environ.get("VIKUNJA_TOKEN", "") - if not token: - raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title.")) + try: + token = get_vikunja_token() + except click.ClickException: + raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title.")) from None client = VikunjaClient(VIKUNJA_API_URL, token) task = client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) if not task: @@ -118,9 +120,10 @@ def create_pr( ), ) - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token: - raise click.ClickException(_("CI_GITEA_TOKEN is not set. Required to create a PR.")) + try: + token = get_developer_token() + except click.ClickException: + raise click.ClickException(_("CI_GITEA_TOKEN is not set. Required to create a PR.")) from None vikunja_title = get_vikunja_task_title(task_id) pr_title = f"{task_id}: {vikunja_title}" diff --git a/src/devx/tools/create_task.py b/src/devx/tools/create_task.py index 3b0b131..ca7ae9c 100644 --- a/src/devx/tools/create_task.py +++ b/src/devx/tools/create_task.py @@ -17,14 +17,13 @@ and ``DEVX_TASK_PREFIX`` environment variables (or ``.env``). from __future__ import annotations -import os - import click from dotenv import load_dotenv from devx.api_clients import VikunjaClient from devx.config import TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID from devx.i18n import _ +from devx.tokens import get_vikunja_token load_dotenv() @@ -39,9 +38,10 @@ load_dotenv() @click.option("--project-id", type=int, default=None, help="Vikunja project ID (default: DEVX_VIKUNJA_PROJECT_ID).") def cli(title: str, description: str, project_id: int | None) -> None: """Create a Vikunja task and print its identifier.""" - token = os.environ.get("VIKUNJA_TOKEN", "") - if not token: - raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment.")) + try: + token = get_vikunja_token() + except click.ClickException: + raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment.")) from None pid = project_id if project_id is not None else VIKUNJA_PROJECT_ID diff --git a/src/devx/tools/pr_label.py b/src/devx/tools/pr_label.py index fb3b581..3f896f2 100644 --- a/src/devx/tools/pr_label.py +++ b/src/devx/tools/pr_label.py @@ -22,14 +22,13 @@ The repository is auto-detected from ``DEVX_REPO_OWNER`` / from __future__ import annotations -import os - import click from dotenv import load_dotenv from devx.api_clients import GiteaClient from devx.config import GITEA_API_URL, REPO_OWNER from devx.i18n import _ +from devx.tokens import get_developer_token from devx.tools.create_pr import get_repo_name from devx.tools.pr_status import _get_current_branch_pr @@ -48,9 +47,10 @@ def cli( repo: str | None, ) -> None: """Add one or more labels to a pull request (idempotent).""" - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token: - raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) + try: + token = get_developer_token() + except click.ClickException: + raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None repo_owner = owner or REPO_OWNER if not repo_owner: diff --git a/src/devx/tools/pr_logs.py b/src/devx/tools/pr_logs.py index 4c1cadf..992cec1 100644 --- a/src/devx/tools/pr_logs.py +++ b/src/devx/tools/pr_logs.py @@ -25,14 +25,13 @@ The repository is auto-detected from ``DEVX_REPO_OWNER`` / from __future__ import annotations -import os - import click from dotenv import load_dotenv from devx.api_clients import APIError, GiteaClient from devx.config import GITEA_API_URL, REPO_OWNER from devx.i18n import _ +from devx.tokens import get_developer_token from devx.tools.create_pr import get_repo_name from devx.tools.pr_status import _get_current_branch_pr @@ -126,9 +125,10 @@ def cli( repo: str | None, ) -> None: """Fetch logs for failed CI jobs on a pull request.""" - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token: - raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) + try: + token = get_developer_token() + except click.ClickException: + raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None repo_owner = owner or REPO_OWNER if not repo_owner: diff --git a/src/devx/tools/pr_rebase.py b/src/devx/tools/pr_rebase.py index adedebc..00cbcf2 100644 --- a/src/devx/tools/pr_rebase.py +++ b/src/devx/tools/pr_rebase.py @@ -32,6 +32,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk from devx.api_clients import APIError, GiteaClient from devx.config import GITEA_API_URL from devx.i18n import _ +from devx.tokens import get_developer_token from devx.tools._shared import detect_pr_number @@ -41,9 +42,10 @@ def main(pr: int | None) -> None: """Rebase a pull request's head branch onto master via Gitea API.""" load_dotenv() - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token: - raise click.ClickException(_("CI_GITEA_TOKEN is not set. Add it to .env or export it.")) + try: + token = get_developer_token() + except click.ClickException: + raise click.ClickException(_("CI_GITEA_TOKEN is not set. Add it to .env or export it.")) from None pr_num = pr or detect_pr_number() if not pr_num: diff --git a/src/devx/tools/pr_status.py b/src/devx/tools/pr_status.py index 3df8245..6caf579 100644 --- a/src/devx/tools/pr_status.py +++ b/src/devx/tools/pr_status.py @@ -24,7 +24,6 @@ The repository is auto-detected from ``DEVX_REPO_OWNER`` / from __future__ import annotations -import os import subprocess # nosec B404 import time @@ -34,6 +33,7 @@ from dotenv import load_dotenv from devx.api_clients import GiteaClient from devx.config import GITEA_API_URL, REPO_OWNER from devx.i18n import _ +from devx.tokens import get_developer_token from devx.tools.create_pr import get_repo_name load_dotenv() @@ -139,9 +139,10 @@ def cli( repo: str | None, ) -> None: """Check CI status for a pull request or commit.""" - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token: - raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) + try: + token = get_developer_token() + except click.ClickException: + raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None repo_owner = owner or REPO_OWNER if not repo_owner: diff --git a/src/devx/tools/pre_push_check.py b/src/devx/tools/pre_push_check.py index 952b8b1..e6496fe 100644 --- a/src/devx/tools/pre_push_check.py +++ b/src/devx/tools/pre_push_check.py @@ -20,7 +20,6 @@ Exit codes: from __future__ import annotations -import os import subprocess # nosec B404 import click @@ -29,6 +28,7 @@ from dotenv import load_dotenv from devx.api_clients import VikunjaClient from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID from devx.i18n import _ +from devx.tokens import get_vikunja_token load_dotenv() @@ -55,8 +55,9 @@ def task_exists(task_id: str) -> bool: Returns ``False`` if VIKUNJA_TOKEN is not set (soft-fail in local mode). """ - token = os.environ.get("VIKUNJA_TOKEN", "") - if not token: + try: + token = get_vikunja_token() + except click.ClickException: return False client = VikunjaClient(VIKUNJA_API_URL, token) return client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) is not None @@ -84,8 +85,9 @@ def validate(branch: str) -> None: ) ) - token = os.environ.get("VIKUNJA_TOKEN", "") - if not token: + try: + get_vikunja_token() + except click.ClickException: click.echo( _( "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. " diff --git a/src/devx/tools/setup.py b/src/devx/tools/setup.py index b4e4bb1..f960522 100644 --- a/src/devx/tools/setup.py +++ b/src/devx/tools/setup.py @@ -16,6 +16,8 @@ from pathlib import Path import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] +from devx.tokens import get_developer_token + load_dotenv() @@ -64,19 +66,20 @@ def _install_ansible_collections(bin_dir: str) -> None: def _configure_tea_login() -> None: - """Configure tea CLI login from .env if CI_GITEA_TOKEN is set. + """Configure tea CLI login from .env if a Gitea token is set. Idempotent: if a login with the same name already exists, it is not re-added. - Skips if tea is not installed or CI_GITEA_TOKEN is not set. + Skips if tea is not installed or no Gitea token is set. """ tea_bin = shutil.which("tea") if tea_bin is None: click.echo("tea: not installed — run 'make install-tools' to install it.") return - token = os.environ.get("CI_GITEA_TOKEN", "") - if not token: - click.echo("tea: CI_GITEA_TOKEN not set — skipping login configuration.") + try: + token = get_developer_token() + except click.ClickException: + click.echo("tea: Gitea API token not set — skipping login configuration.") return api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1") diff --git a/src/devx/tools/setup_image.py b/src/devx/tools/setup_image.py index 274e7ab..c208d38 100644 --- a/src/devx/tools/setup_image.py +++ b/src/devx/tools/setup_image.py @@ -24,6 +24,8 @@ from pathlib import Path import click +from devx.tokens import get_developer_token + DEFAULT_VENV = ".venv" OPT_VENV = "/opt/venv" FALLBACK_TARGET = "setup-ci" @@ -67,7 +69,10 @@ def _install_in_image( cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec] env = os.environ.copy() - token = env.get("CI_GITEA_TOKEN", "") + try: + token = get_developer_token() + except click.ClickException: + token = None if token: username = env.get("CI_GITEA_USERNAME", "emil") env["PIP_EXTRA_INDEX_URL"] = _build_pip_extra_index_url( diff --git a/src/devx/translations.json b/src/devx/translations.json index b4cacc1..5b5f0fc 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -959,6 +959,14 @@ "ru": "CI checks failed.", "zh": "CI checks failed." }, + "Gitea API token not set. Set one of: {names}": { + "bg": "Gitea API token not set. Set one of: {names}", + "de": "Gitea API token not set. Set one of: {names}", + "en": "Gitea API token not set. Set one of: {names}", + "pl": "Gitea API token not set. Set one of: {names}", + "ru": "Gitea API token not set. Set one of: {names}", + "zh": "Gitea API token not set. Set one of: {names}" + }, "CI_GITEA_TOKEN environment variable required": { "bg": "CI_GITEA_TOKEN environment variable required", "de": "CI_GITEA_TOKEN environment variable required", diff --git a/tests/unit/test_discover_runners.py b/tests/unit/test_discover_runners.py index 81113a6..3fd4b38 100644 --- a/tests/unit/test_discover_runners.py +++ b/tests/unit/test_discover_runners.py @@ -4,6 +4,7 @@ import json from pathlib import Path from unittest.mock import MagicMock, patch +import click import pytest from click.testing import CliRunner @@ -249,3 +250,14 @@ class TestMain: args, kwargs = mock_count.call_args assert "myorg" in args assert "myrepo" in args + + @patch("devx.ci.discover_runners.get_ci_token", side_effect=click.ClickException("no token")) + @patch("devx.ci.discover_runners.get_runner_count", return_value=3) + def test_missing_token_runs_without_api(self, mock_count: MagicMock, mock_token: MagicMock) -> None: + """When no token is available, runner discovery falls back to env/default.""" + runner = CliRunner() + result = runner.invoke(main, ["--count"]) + assert result.exit_code == 0 + assert result.output.strip() == "3" + args, _ = mock_count.call_args + assert args[1] is None # token passed as None when missing diff --git a/tests/unit/test_molecule_ci_guard.py b/tests/unit/test_molecule_ci_guard.py index 90d44db..0afcc9e 100644 --- a/tests/unit/test_molecule_ci_guard.py +++ b/tests/unit/test_molecule_ci_guard.py @@ -186,6 +186,28 @@ class TestCli: timeout=60, ) + @patch("devx.molecule.molecule_ci_guard.get_ci_token", side_effect=click.ClickException("no token")) + def test_missing_token_runs_without_polling(self, mock_token: MagicMock) -> None: + """When no token is available, cross-runner polling is skipped.""" + from click.testing import CliRunner + + with ( + patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, + patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run, + patch("devx.molecule.molecule_ci_guard.poll_for_other_failures") as mock_poll, + patch("time.sleep"), + ): + proc = MagicMock() + proc.poll.return_value = 0 + proc.returncode = 0 + mock_popen.return_value = proc + mock_run.return_value = MagicMock(returncode=0) + + runner = CliRunner() + result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) + assert result.exit_code == 0 + mock_poll.assert_not_called() + def test_invalid_pair_format_raises(self) -> None: """Pair with fewer than 2 parts should raise.""" from click.testing import CliRunner diff --git a/tests/unit/test_molecule_discover_runners.py b/tests/unit/test_molecule_discover_runners.py index b1c5b4d..77f552a 100644 --- a/tests/unit/test_molecule_discover_runners.py +++ b/tests/unit/test_molecule_discover_runners.py @@ -4,6 +4,7 @@ import json from pathlib import Path from unittest.mock import MagicMock, patch +import click import pytest from click.testing import CliRunner @@ -219,3 +220,14 @@ class TestMain: args, kwargs = mock_count.call_args assert "myorg" in args assert "myrepo" in args + + @patch("devx.molecule.discover_runners.get_ci_token", side_effect=click.ClickException("no token")) + @patch("devx.molecule.discover_runners.get_runner_count", return_value=3) + def test_missing_token_runs_without_api(self, mock_count: MagicMock, mock_token: MagicMock) -> None: + """When no token is available, runner discovery falls back to env/default.""" + runner = CliRunner() + result = runner.invoke(main, ["--count"]) + assert result.exit_code == 0 + assert result.output.strip() == "3" + args, _ = mock_count.call_args + assert args[1] is None # token passed as None when missing diff --git a/tests/unit/test_pr_label.py b/tests/unit/test_pr_label.py index 10faca6..17ab212 100644 --- a/tests/unit/test_pr_label.py +++ b/tests/unit/test_pr_label.py @@ -12,9 +12,14 @@ from devx.tools.pr_label import cli class TestCli: def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("CI_GITEA_TOKEN", raising=False) + for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"): + monkeypatch.delenv(name, raising=False) runner = CliRunner() - result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"]) + result = runner.invoke( + cli, + ["--pr", "42", "--label", "ready-to-merge"], + env={"DEVELOPER_GITEA_API_TOKEN": "", "CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""}, + ) assert result.exit_code != 0 assert "CI_GITEA_TOKEN" in result.output diff --git a/tests/unit/test_pr_logs.py b/tests/unit/test_pr_logs.py index aae92a2..12f1a77 100644 --- a/tests/unit/test_pr_logs.py +++ b/tests/unit/test_pr_logs.py @@ -153,9 +153,14 @@ class TestPrintLogs: class TestCli: def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("CI_GITEA_TOKEN", raising=False) + for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"): + monkeypatch.delenv(name, raising=False) runner = CliRunner() - result = runner.invoke(cli, ["--pr", "42"]) + result = runner.invoke( + cli, + ["--pr", "42"], + env={"DEVELOPER_GITEA_API_TOKEN": "", "CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""}, + ) assert result.exit_code != 0 assert "CI_GITEA_TOKEN" in result.output diff --git a/tests/unit/test_pr_review.py b/tests/unit/test_pr_review.py index 5b45b12..c7746dd 100644 --- a/tests/unit/test_pr_review.py +++ b/tests/unit/test_pr_review.py @@ -756,9 +756,10 @@ class TestMain: result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": "fake"}) assert result.exit_code != 0 + @patch.dict("os.environ", {"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""}) def test_no_token_raises(self) -> None: runner = CliRunner() - result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": ""}) + result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""}) assert result.exit_code != 0 assert "CI_GITEA_TOKEN" in result.output diff --git a/tests/unit/test_pr_status.py b/tests/unit/test_pr_status.py index 6f2fe5b..1e1d2d9 100644 --- a/tests/unit/test_pr_status.py +++ b/tests/unit/test_pr_status.py @@ -122,9 +122,14 @@ class TestWaitForCompletion: class TestCli: def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("CI_GITEA_TOKEN", raising=False) + for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"): + monkeypatch.delenv(name, raising=False) runner = CliRunner() - result = runner.invoke(cli, ["--pr", "42"]) + result = runner.invoke( + cli, + ["--pr", "42"], + env={"DEVELOPER_GITEA_API_TOKEN": "", "CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""}, + ) assert result.exit_code != 0 assert "CI_GITEA_TOKEN" in result.output diff --git a/tests/unit/test_sync_wiki.py b/tests/unit/test_sync_wiki.py index 061a0b8..efe6d4c 100644 --- a/tests/unit/test_sync_wiki.py +++ b/tests/unit/test_sync_wiki.py @@ -256,9 +256,10 @@ class TestCommitAndPush: class TestMain: def test_no_token_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("CI_GITEA_TOKEN", raising=False) + for name in ("CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"): + monkeypatch.delenv(name, raising=False) runner = CliRunner() - result = runner.invoke(main, []) + result = runner.invoke(main, [], env={"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""}) assert result.exit_code != 0 assert "CI_GITEA_TOKEN" in result.output