Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
507bc86b92 | ||
|
|
81dc30ecff | ||
|
|
3c421dd1ad | ||
|
|
11ce99756c | ||
|
|
6149167ba2 | ||
|
|
014ab0b63f | ||
|
|
2a3ee1ec96 | ||
|
|
5d4968eb21 | ||
|
|
33cfbb0f41 | ||
|
|
598238e4d6 | ||
|
|
925b99b7db | ||
|
|
16ed48bd26 | ||
|
|
3b0500164b | ||
|
|
00a44ec5dc | ||
|
|
b385c57621 | ||
|
|
3181b24f5e | ||
|
|
bc8478220c | ||
|
|
a568c0899f | ||
|
|
4216698ca8 | ||
|
|
f3685b9029 | ||
|
|
2e5470236e | ||
|
|
d2dcf8f7c4 | ||
|
|
357e07a9a6 | ||
|
|
ffb3976224 | ||
|
|
ad75b22f2a |
@@ -23,6 +23,10 @@ on:
|
||||
- src/devx/**
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: build-images
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
detect-type:
|
||||
runs-on: docker
|
||||
@@ -104,7 +108,8 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "build-images/build-and-push" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
cleanup:
|
||||
needs: [build-and-push]
|
||||
|
||||
@@ -122,7 +122,8 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/release" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
publish:
|
||||
needs: [release]
|
||||
@@ -160,7 +161,8 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/publish" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
sync-wiki:
|
||||
needs: [detect-type]
|
||||
@@ -195,7 +197,8 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/sync-wiki" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
badges:
|
||||
needs: [detect-type]
|
||||
@@ -235,7 +238,8 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/badges" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
vikunja:
|
||||
needs: [detect-type]
|
||||
@@ -271,7 +275,8 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/vikunja" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
configure-repo:
|
||||
needs: [detect-type]
|
||||
@@ -304,4 +309,5 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/configure-repo" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps, set up hooks, install CI tools
|
||||
make install-tools # Install actionlint, git-cliff, act_runner to ~/.local/bin
|
||||
make lint-all # ruff + pyright + bandit + actionlint
|
||||
make install-tools # Install actionlint, git-cliff, act_runner, tea, hadolint to ~/.local/bin
|
||||
make lint-all # ruff + pyright + bandit + actionlint + lint-dockerfiles
|
||||
make pytest-cov # Unit tests with 100% coverage enforcement
|
||||
make test-unit # Unit tests without coverage
|
||||
make workflow-lint # Static lint of .gitea/workflows/*.yml (actionlint)
|
||||
@@ -16,7 +16,7 @@ make clean # Remove caches, build artifacts, coverage data
|
||||
|
||||
`make setup` automatically installs all development tools:
|
||||
- **Python deps** via `python -m devx.tools.setup` (pip install -e .[dev], pre-commit hooks)
|
||||
- **actionlint, git-cliff, act_runner, tea** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
|
||||
- **actionlint, git-cliff, act_runner, tea, hadolint** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
|
||||
- **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env` `CI_GITEA_TOKEN`)
|
||||
|
||||
## Workflow Verification (Before Push)
|
||||
@@ -73,7 +73,7 @@ src/devx/
|
||||
│ └── doc_coverage.py # Documentation coverage check
|
||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||
│ ├── setup.py # Environment setup (venv, deps, hooks)
|
||||
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea
|
||||
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint
|
||||
│ ├── install_checkmake.py # Install checkmake (Makefile linter)
|
||||
│ ├── build_image.py # Build and push Docker images to Gitea registry
|
||||
│ ├── clean_images.py # Clean up old Docker image versions from Gitea registry
|
||||
@@ -83,7 +83,12 @@ src/devx/
|
||||
│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules)
|
||||
│ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns)
|
||||
│ ├── configure_repo.py # Branch protection and label setup
|
||||
│ └── generate_badges.py # Badge SVG generation
|
||||
│ ├── generate_badges.py # Badge SVG generation
|
||||
│ ├── create_task.py # Create Vikunja tasks
|
||||
│ ├── create_pr.py # Create PRs with auto-derived title from Vikunja
|
||||
│ ├── pr_status.py # Check CI status for a PR/commit (--wait polls)
|
||||
│ ├── pr_logs.py # Fetch logs for failed CI jobs
|
||||
│ └── pr_label.py # Add labels to PRs (idempotent)
|
||||
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||
@@ -398,13 +403,16 @@ projects.
|
||||
| `devx-create-pr` | Create a PR with auto-derived title |
|
||||
| `devx-push` | Push current branch to origin |
|
||||
| `devx-push-with-pr` | Push and create PR in one step |
|
||||
| `devx-pr-status` | Check CI status for a PR (`PR=`, `WAIT=`, `TIMEOUT=`) |
|
||||
| `devx-pr-logs` | Fetch logs for failed CI jobs (`PR=`, `JOB=`, `TAIL=`) |
|
||||
| `devx-pr-label` | Add a label to a PR (`PR=`, `LABEL=ready-to-merge`) |
|
||||
| `devx-check-config` | Validate devx configuration |
|
||||
| `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry |
|
||||
| `devx-env` | Create .env from .env.example |
|
||||
| `devx-venv` | Create Python venv with version check |
|
||||
| `devx-activate-scripts` | Create shell/fish/zsh activate scripts |
|
||||
| `devx-install-hooks` | Set git hooks path to hooks/ |
|
||||
| `devx-install-tools` | Install actionlint, git-cliff, act_runner, tea |
|
||||
| `devx-install-tools` | Install actionlint, git-cliff, act_runner, tea, hadolint |
|
||||
| `devx-install-checkmake` | Install checkmake (Makefile linter) |
|
||||
| `devx-checkmake` | Lint Makefiles with checkmake |
|
||||
| `devx-workflow-lint` | Static lint of Gitea Actions YAML (actionlint) |
|
||||
@@ -428,6 +436,7 @@ projects.
|
||||
| `devx-pre-push` | Run lint + tests before push |
|
||||
| `devx-clean` | Remove caches, build artifacts, coverage data |
|
||||
| `devx-setup-image` | Link /opt/venv + install project (for pre-built image CI jobs) |
|
||||
| `devx-lint-dockerfiles` | Lint Dockerfiles with hadolint (fail-fast, parameterized by `DEVX_DOCKERFILE_PATHS`) |
|
||||
| `devx-build-images` | Build Docker images from manifest (no push) |
|
||||
| `devx-push-images` | Build and push Docker images to Gitea registry |
|
||||
| `devx-build-images-dry-run` | Show what would be built/pushed |
|
||||
@@ -441,6 +450,7 @@ projects.
|
||||
- `DEVX_COV_PKG` — coverage package (default: `src/devx`)
|
||||
- `DEVX_TEST_PATHS` — pytest paths (default: `tests/`)
|
||||
- `DEVX_PR_BASE` — PR base branch (default: `master`)
|
||||
- `DEVX_DOCKERFILE_PATHS` — directory to search for Dockerfiles (default: `docker`)
|
||||
- `DEVX_GITEA_REGISTRY` — registry URL (default: `git.oblachno.oblachno.fyi`)
|
||||
- `DEVX_IMAGE_MANIFEST` — path to JSON manifest (default: `docker/images.json`)
|
||||
- `DEVX_IMAGE_OWNER` — package owner for cleanup (default: `oblachno-oss`)
|
||||
@@ -453,7 +463,7 @@ to eliminate the 40-120s setup tax on every CI job:
|
||||
| Image | Contains | Used by jobs |
|
||||
|-------|----------|-------------|
|
||||
| `ci-base-latest` | Python 3.12 + devx[ci] + tea | detect-changes, detect-type, validate-commit-msg, pr-review, auto-merge, sync-wiki, vikunja, configure-repo |
|
||||
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake | quality, badges |
|
||||
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake + hadolint | quality, badges |
|
||||
| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, release-dry-run, molecule-tests, deploy jobs |
|
||||
|
||||
**Build process** (in `build-images.yml` workflow):
|
||||
|
||||
@@ -2,6 +2,47 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.24.0] - 2026-06-27
|
||||
|
||||
### Features
|
||||
|
||||
- Add pr_status, pr_logs, pr_label tools
|
||||
|
||||
## [0.23.4] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add --auto-login to all notify_failure calls in workflows
|
||||
- Classify .gitea/** as user-facing for devx, support glob in user_facing_overrides
|
||||
|
||||
## [0.23.3] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Correct clean_images delete URL and add retry with error handling
|
||||
|
||||
## [0.23.2] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add skip-ci flag to release commits and concurrency to build-images
|
||||
|
||||
## [0.23.1] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add rsync to ci-full image for molecule_docker
|
||||
|
||||
## [0.23.0] - 2026-06-27
|
||||
|
||||
### Features
|
||||
|
||||
- Add devx-lint-dockerfiles to devx.mak, alias setup-image
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove hadolint on-the-fly install from setup-image
|
||||
|
||||
## [0.22.1] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -31,10 +31,11 @@ setup-release: $(VENV)/bin/activate .env
|
||||
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint,release" --no-pre-commit
|
||||
|
||||
# Setup for pre-built image jobs (deps already in image, just link venv + install project)
|
||||
# Note: Not aliased to devx-setup-image because devx's own CI images may have
|
||||
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
|
||||
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
|
||||
setup-image:
|
||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install -e . --no-deps 2>/dev/null; \
|
||||
export PATH="$$HOME/.local/bin:$$PATH"; \
|
||||
python3 -m devx.tools.install_tools --tool hadolint 2>/dev/null || true; \
|
||||
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||
|
||||
.env:
|
||||
@@ -101,6 +102,8 @@ git-push: devx-push
|
||||
lint-all: lint workflow-lint lint-dockerfiles
|
||||
@echo "[lint-all] All linting checks passed."
|
||||
|
||||
# Note: Not aliased to devx-lint-dockerfiles for the same reason as setup-image —
|
||||
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
|
||||
lint-dockerfiles:
|
||||
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
|
||||
@if ! command -v hadolint >/dev/null 2>&1; then \
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
# Install rsync (required by molecule_docker for file sync between host and test containers)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends rsync \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install devx[release,molecule,deploy] from local source
|
||||
COPY . /tmp/devx
|
||||
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
|
||||
|
||||
+6
-6
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
+6
-2
@@ -139,8 +139,12 @@ infrastructure_overrides = [
|
||||
]
|
||||
|
||||
# User-facing overrides — safety override for broad infrastructure patterns
|
||||
# (empty — add when an infrastructure pattern is too broad)
|
||||
user_facing_overrides = []
|
||||
# devx workflow files (.gitea/**) are reference implementations that
|
||||
# downstream repos (grm, infra) copy from. Changes to them affect how
|
||||
# consumer projects run their CI, so they must trigger a release.
|
||||
user_facing_overrides = [
|
||||
".gitea/**",
|
||||
]
|
||||
|
||||
# Tag patterns — additional categories for CI conditional execution
|
||||
# Orthogonal to release impact (user-facing vs infrastructure)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.22.1"
|
||||
__version__ = "0.24.0"
|
||||
|
||||
@@ -223,6 +223,20 @@ class GiteaClient:
|
||||
r = self._request("GET", f"/pulls/{pr_number}/files")
|
||||
return r.json()
|
||||
|
||||
def add_pr_label(self, pr_number: str | int, label_names: list[str]) -> None:
|
||||
"""Attach labels to a PR/issue by name.
|
||||
|
||||
Args:
|
||||
pr_number: PR or issue number.
|
||||
label_names: List of label names to attach.
|
||||
"""
|
||||
self._request("POST", f"/issues/{pr_number}/labels", json={"labels": label_names})
|
||||
|
||||
def get_pr_label_names(self, pr_number: str | int) -> list[str]:
|
||||
"""Return label names currently attached to a PR/issue."""
|
||||
r = self._request("GET", f"/issues/{pr_number}/labels")
|
||||
return [label.get("name", "") for label in r.json()]
|
||||
|
||||
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch the commits included in a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/commits")
|
||||
@@ -301,6 +315,31 @@ class GiteaClient:
|
||||
return existing
|
||||
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
|
||||
|
||||
# -- actions (CI/CD) --
|
||||
|
||||
def list_action_runs(self, **params: Any) -> dict[str, Any]:
|
||||
"""List workflow runs for the repository.
|
||||
|
||||
Returns the raw API response dict (includes ``workflow_runs`` and
|
||||
``total_count`` keys per Gitea API).
|
||||
"""
|
||||
r = self._request("GET", "/actions/runs", params=params)
|
||||
return r.json()
|
||||
|
||||
def get_action_run_jobs(self, run_id: str | int) -> list[dict[str, Any]]:
|
||||
"""List jobs for a specific workflow run."""
|
||||
r = self._request("GET", f"/actions/runs/{run_id}/jobs")
|
||||
data = r.json()
|
||||
return data.get("jobs", [])
|
||||
|
||||
def get_action_job_logs(self, job_id: str | int) -> str:
|
||||
"""Fetch logs for a specific CI job.
|
||||
|
||||
Returns the raw log text. Raises APIError if logs are unavailable.
|
||||
"""
|
||||
r = self._request("GET", f"/actions/jobs/{job_id}/logs")
|
||||
return r.text
|
||||
|
||||
|
||||
class VikunjaClient:
|
||||
"""Low-level Vikunja REST API client with connection pooling."""
|
||||
|
||||
@@ -393,14 +393,15 @@ class ChangeClassifier:
|
||||
tags = self._compute_tags(file_path)
|
||||
|
||||
# 1. User-facing overrides (highest priority — safety)
|
||||
if file_path in self._user_overrides:
|
||||
return FileClassification(
|
||||
path=file_path,
|
||||
is_user_facing=True,
|
||||
reason="User-facing override (safety override)",
|
||||
matched_rule="user_facing_overrides",
|
||||
tags=tags,
|
||||
)
|
||||
for pattern in self._user_overrides:
|
||||
if _matches_glob(file_path, pattern):
|
||||
return FileClassification(
|
||||
path=file_path,
|
||||
is_user_facing=True,
|
||||
reason=f"User-facing override (matches '{pattern}')",
|
||||
matched_rule="user_facing_overrides",
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# 2. Infrastructure overrides
|
||||
if file_path in self._infra_overrides:
|
||||
|
||||
@@ -279,7 +279,7 @@ def commit_release_changes(new_version: str) -> bool:
|
||||
if status.returncode == 0:
|
||||
click.echo(_("No staged changes — version and changelog already up to date."))
|
||||
return False
|
||||
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version}"])
|
||||
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
|
||||
return True
|
||||
|
||||
|
||||
@@ -705,7 +705,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
|
||||
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
|
||||
click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE))
|
||||
click.echo(_("[dry-run] Would commit: release: v{version}", version=new_version))
|
||||
click.echo(_("[dry-run] Would commit: release: v{version} [skip ci]", version=new_version))
|
||||
click.echo(_("[dry-run] Would push commit to master"))
|
||||
click.echo(_("[dry-run] Would create tag: v{version}", version=new_version))
|
||||
return
|
||||
|
||||
@@ -74,6 +74,7 @@ VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://work.
|
||||
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||
# No default: prevents silent 404s when the wrong owner is used.
|
||||
REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "")
|
||||
REPO_NAME = _get("repo_name", "DEVX_REPO_NAME", "")
|
||||
|
||||
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||
TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX")
|
||||
|
||||
+51
-1
@@ -51,6 +51,7 @@ DEVX_GITEA_PYPI_HOST ?= git.oblachno.oblachno.fyi
|
||||
DEVX_GITEA_PYPI_ORG ?= oblachno-oss
|
||||
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
|
||||
DEVX_WORKFLOW_DIR ?= .gitea/workflows
|
||||
DEVX_DOCKERFILE_PATHS ?= docker
|
||||
|
||||
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
|
||||
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
|
||||
@@ -62,6 +63,7 @@ DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi;
|
||||
$(DEVX_BIN)/pip
|
||||
|
||||
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
||||
.PHONY: devx-pr-status devx-pr-logs devx-pr-label
|
||||
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
|
||||
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
|
||||
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
|
||||
@@ -69,7 +71,7 @@ DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_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
|
||||
.PHONY: devx-setup-image devx-lint-dockerfiles
|
||||
|
||||
# ── Vikunja task and PR management ────────────────────────────────────────────
|
||||
|
||||
@@ -93,6 +95,35 @@ devx-check-config:
|
||||
# Push and create PR in one step
|
||||
devx-push-with-pr: devx-push devx-create-pr
|
||||
|
||||
# Check CI status for a PR (auto-detects current branch's PR)
|
||||
# Usage: make devx-pr-status
|
||||
# make devx-pr-status PR=42
|
||||
# make devx-pr-status PR=42 WAIT=1 TIMEOUT=600
|
||||
devx-pr-status:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_status \
|
||||
$(if $(PR),--pr $(PR)) \
|
||||
$(if $(WAIT),--wait) \
|
||||
$(if $(TIMEOUT),--timeout $(TIMEOUT))
|
||||
|
||||
# Fetch logs for failed CI jobs on a PR
|
||||
# Usage: make devx-pr-logs
|
||||
# make devx-pr-logs PR=42
|
||||
# make devx-pr-logs PR=42 JOB=quality TAIL=50
|
||||
devx-pr-logs:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_logs \
|
||||
$(if $(PR),--pr $(PR)) \
|
||||
$(if $(JOB),--job $(JOB)) \
|
||||
$(if $(TAIL),--tail $(TAIL))
|
||||
|
||||
# Add a label to a PR (default: ready-to-merge)
|
||||
# Usage: make devx-pr-label
|
||||
# make devx-pr-label PR=42
|
||||
# make devx-pr-label PR=42 LABEL=ready-to-merge
|
||||
devx-pr-label:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_label \
|
||||
$(if $(PR),--pr $(PR)) \
|
||||
--label $(or $(LABEL),ready-to-merge)
|
||||
|
||||
# ── Environment setup ─────────────────────────────────────────────────────────
|
||||
|
||||
# Configure Gitea private PyPI registry so pip can find devx and other
|
||||
@@ -251,6 +282,25 @@ devx-clean:
|
||||
@find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
@rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/ .molecule/ 2>/dev/null || true
|
||||
|
||||
# ── Dockerfile linting ────────────────────────────────────────────────────────
|
||||
#
|
||||
# Lint Dockerfiles with hadolint. Fails fast if hadolint is not installed
|
||||
# (no silent skip). Set DEVX_DOCKERFILE_PATHS to the directory containing
|
||||
# your Dockerfiles (default: docker).
|
||||
#
|
||||
# Usage:
|
||||
# make devx-lint-dockerfiles (lints docker/ directory)
|
||||
# make devx-lint-dockerfiles DEVX_DOCKERFILE_PATHS=ansible (lints ansible/)
|
||||
|
||||
devx-lint-dockerfiles:
|
||||
@echo "[devx-lint-dockerfiles] Linting Dockerfiles with hadolint..."
|
||||
@if ! command -v hadolint >/dev/null 2>&1; then \
|
||||
echo "[devx-lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
@find $(DEVX_DOCKERFILE_PATHS) -name 'Dockerfile*' -exec hadolint {} +
|
||||
@echo "[devx-lint-dockerfiles] All Dockerfiles passed."
|
||||
|
||||
# ── Pre-built image setup ─────────────────────────────────────────────────────
|
||||
#
|
||||
# When running inside a pre-built Docker runner image (ci-base, ci-quality,
|
||||
|
||||
@@ -34,6 +34,7 @@ Authentication uses ``CI_GITEA_TOKEN`` environment variable.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
@@ -85,15 +86,37 @@ def delete_package_version(
|
||||
token: str,
|
||||
*,
|
||||
timeout: int = 30,
|
||||
package_type: str = "container",
|
||||
max_retries: int = 3,
|
||||
) -> bool:
|
||||
"""Delete a specific version of a container package.
|
||||
|
||||
Uses the Gitea API endpoint ``DELETE /packages/{owner}/{type}/{name}/{version}``.
|
||||
Retries on transient failures (5xx, timeouts) up to ``max_retries`` times.
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
url = f"{api_url}/packages/{owner}/{name}/{version}"
|
||||
url = f"{api_url}/packages/{owner}/{package_type}/{name}/{version}"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
resp = requests.delete(url, headers=headers, timeout=timeout)
|
||||
return resp.status_code in (204, 200)
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
resp = requests.delete(url, headers=headers, timeout=timeout)
|
||||
except requests.RequestException:
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
return False
|
||||
if resp.status_code in (204, 200):
|
||||
return True
|
||||
# 404 means already deleted — treat as success
|
||||
if resp.status_code == 404:
|
||||
return True
|
||||
# 5xx is transient — retry
|
||||
if 500 <= resp.status_code < 600 and attempt < max_retries - 1:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def sort_versions_by_date(
|
||||
@@ -171,6 +194,7 @@ def main(
|
||||
|
||||
total_deleted = 0
|
||||
total_kept = 0
|
||||
total_failed = 0
|
||||
for name in names:
|
||||
click.echo(f"\n{'=' * 60}")
|
||||
click.echo(f"Package: {owner}/{name}")
|
||||
@@ -182,6 +206,7 @@ def main(
|
||||
_("Failed to list versions for {name}: {error}", name=name, error=exc),
|
||||
err=True,
|
||||
)
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
if not versions:
|
||||
@@ -203,6 +228,7 @@ def main(
|
||||
continue
|
||||
|
||||
deleted_count = 0
|
||||
failed_count = 0
|
||||
for v in to_delete:
|
||||
version = str(v.get("version", ""))
|
||||
if delete_package_version(base_url, owner, name, version, token):
|
||||
@@ -210,11 +236,15 @@ def main(
|
||||
deleted_count += 1
|
||||
else:
|
||||
click.echo(f" FAILED to delete: {version}", err=True)
|
||||
failed_count += 1
|
||||
|
||||
total_deleted += deleted_count
|
||||
total_kept += kept_count
|
||||
total_failed += failed_count
|
||||
|
||||
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}.")
|
||||
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}, failed {total_failed}.")
|
||||
if total_failed > 0:
|
||||
raise click.ClickException(_("Failed to delete {count} image version(s)", count=total_failed))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -34,6 +34,7 @@ from devx.api_clients import GiteaClient, VikunjaClient
|
||||
from devx.config import (
|
||||
DEFAULT_PER_PAGE,
|
||||
GITEA_API_URL,
|
||||
REPO_NAME,
|
||||
REPO_OWNER,
|
||||
TASK_ID_RE,
|
||||
TASK_PREFIX,
|
||||
@@ -46,15 +47,17 @@ load_dotenv()
|
||||
|
||||
|
||||
def get_repo_name() -> str:
|
||||
"""Auto-detect repository name from env vars or git remote."""
|
||||
"""Auto-detect repository name from env vars, pyproject.toml, or git remote."""
|
||||
name = os.environ.get("DEVX_REPO_NAME", "")
|
||||
if name:
|
||||
return name
|
||||
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
if github_repo and "/" in github_repo:
|
||||
return github_repo.split("/", 1)[1]
|
||||
if REPO_NAME:
|
||||
return REPO_NAME
|
||||
raise click.ClickException(
|
||||
_("Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var."),
|
||||
_("Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add a label to a pull request (idempotent).
|
||||
|
||||
Commonly used to add the ``ready-to-merge`` label after CI passes and
|
||||
review is complete. The operation is idempotent — if the label is already
|
||||
attached, it succeeds without error.
|
||||
|
||||
Usage::
|
||||
|
||||
# Add ready-to-merge to PR #42
|
||||
python -m devx.tools.pr_label --pr 42 --label ready-to-merge
|
||||
|
||||
# Add label to current branch's PR
|
||||
python -m devx.tools.pr_label --label ready-to-merge
|
||||
|
||||
# Add multiple labels
|
||||
python -m devx.tools.pr_label --pr 42 --label ready-to-merge --label reviewed
|
||||
|
||||
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||
"""
|
||||
|
||||
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.tools.create_pr import get_repo_name
|
||||
from devx.tools.pr_status import _get_current_branch_pr
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
|
||||
@click.option("--label", "labels", multiple=True, required=True, help="Label name(s) to add (can be repeated).")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||
def cli(
|
||||
pr_number: int | None,
|
||||
labels: tuple[str, ...],
|
||||
owner: str | None,
|
||||
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."))
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||
repo_name = repo or get_repo_name()
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||
|
||||
if pr_number is None:
|
||||
pr_number = _get_current_branch_pr(client)
|
||||
|
||||
label_list = list(labels)
|
||||
existing = client.get_pr_label_names(pr_number)
|
||||
to_add = [lbl for lbl in label_list if lbl not in existing]
|
||||
already = [lbl for lbl in label_list if lbl in existing]
|
||||
|
||||
if already:
|
||||
for lbl in already:
|
||||
click.echo(_("Label '{label}' already on PR #{pr}.", label=lbl, pr=pr_number))
|
||||
|
||||
if to_add:
|
||||
client.add_pr_label(pr_number, to_add)
|
||||
for lbl in to_add:
|
||||
click.echo(_("Added label '{label}' to PR #{pr}.", label=lbl, pr=pr_number))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch logs for failed CI jobs on a pull request.
|
||||
|
||||
Lists CI jobs for the latest workflow run of a PR's branch, then fetches
|
||||
and prints the logs of any failed jobs. Useful for diagnosing CI failures
|
||||
without navigating the web UI.
|
||||
|
||||
Usage::
|
||||
|
||||
# Show failed job logs for PR #42
|
||||
python -m devx.tools.pr_logs --pr 42
|
||||
|
||||
# Show failed job logs for current branch's PR
|
||||
python -m devx.tools.pr_logs
|
||||
|
||||
# Show logs for a specific job (by name)
|
||||
python -m devx.tools.pr_logs --pr 42 --job quality
|
||||
|
||||
# Show last N lines of each failed job's logs
|
||||
python -m devx.tools.pr_logs --pr 42 --tail 50
|
||||
|
||||
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||
"""
|
||||
|
||||
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.tools.create_pr import get_repo_name
|
||||
from devx.tools.pr_status import _get_current_branch_pr
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _get_pr_sha(client: GiteaClient, pr_number: int) -> str:
|
||||
"""Fetch the head SHA of a PR."""
|
||||
pr = client.get_pr(pr_number)
|
||||
return pr.get("head", {}).get("sha", "")
|
||||
|
||||
|
||||
def _find_latest_run_by_sha(client: GiteaClient, sha: str) -> dict | None:
|
||||
"""Find the latest workflow run for a commit SHA.
|
||||
|
||||
Gitea Actions API doesn't set head_branch for pull_request events,
|
||||
so we filter by head_sha instead.
|
||||
"""
|
||||
data = client.list_action_runs(limit=50)
|
||||
for run in data.get("workflow_runs", []):
|
||||
if run.get("head_sha", "").startswith(sha):
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _find_failed_jobs(jobs: list[dict]) -> list[dict]:
|
||||
"""Return jobs with conclusion 'failure'."""
|
||||
return [j for j in jobs if j.get("conclusion") == "failure"]
|
||||
|
||||
|
||||
def _find_job_by_name(jobs: list[dict], name: str) -> dict | None:
|
||||
"""Find a job by name (case-insensitive partial match)."""
|
||||
name_lower = name.lower()
|
||||
for j in jobs:
|
||||
if name_lower in j.get("name", "").lower():
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
def _print_job_summary(jobs: list[dict]) -> None:
|
||||
"""Print a summary table of all jobs and their status."""
|
||||
for j in jobs:
|
||||
name = j.get("name", "?")
|
||||
conclusion = j.get("conclusion", "pending")
|
||||
status = j.get("status", "?")
|
||||
symbol = "[FAIL]" if conclusion == "failure" else "[OK]" if conclusion == "success" else f"[{conclusion}]"
|
||||
click.echo(f" {symbol} {name} (status: {status}, conclusion: {conclusion})")
|
||||
|
||||
|
||||
def _print_failed_steps(job: dict) -> list[int]:
|
||||
"""Print failed steps for a job. Returns list of failed step numbers."""
|
||||
failed_steps = []
|
||||
for step in job.get("steps", []):
|
||||
if step.get("conclusion") == "failure":
|
||||
name = step.get("name", "?")
|
||||
num = step.get("number", "?")
|
||||
click.echo(f" FAILED step #{num}: {name}")
|
||||
failed_steps.append(num)
|
||||
return failed_steps
|
||||
|
||||
|
||||
def _print_logs(client: GiteaClient, job_id: int, tail: int = 0) -> None:
|
||||
"""Fetch and print logs for a job. If tail > 0, print only last N lines."""
|
||||
try:
|
||||
logs = client.get_action_job_logs(job_id)
|
||||
except APIError as e:
|
||||
click.echo(_(" Could not fetch logs: {error}", error=str(e)))
|
||||
return
|
||||
|
||||
if tail > 0:
|
||||
lines = logs.strip().split("\n")
|
||||
if len(lines) > tail:
|
||||
click.echo(f" ... (showing last {tail} of {len(lines)} lines)")
|
||||
logs = "\n".join(lines[-tail:])
|
||||
|
||||
for line in logs.split("\n"):
|
||||
click.echo(f" {line}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
|
||||
@click.option("--job", default=None, help="Job name to show logs for (partial match, case-insensitive).")
|
||||
@click.option("--tail", type=int, default=80, show_default=True, help="Show last N lines of logs (0 = all).")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||
def cli(
|
||||
pr_number: int | None,
|
||||
job: str | None,
|
||||
tail: int,
|
||||
owner: str | None,
|
||||
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."))
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||
repo_name = repo or get_repo_name()
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||
|
||||
if pr_number is None:
|
||||
pr_number = _get_current_branch_pr(client)
|
||||
click.echo(_("Fetching logs for PR #{pr_number}...", pr_number=pr_number))
|
||||
|
||||
sha = _get_pr_sha(client, pr_number)
|
||||
if not sha:
|
||||
raise click.ClickException(_("Could not determine head SHA for PR #{pr_number}.", pr_number=pr_number))
|
||||
|
||||
run = _find_latest_run_by_sha(client, sha)
|
||||
if not run:
|
||||
raise click.ClickException(_("No workflow runs found for SHA {sha}.", sha=sha[:8]))
|
||||
|
||||
run_id = run.get("id", 0)
|
||||
run_status = run.get("status", "?")
|
||||
click.echo(_("Latest run: #{run_id} (status: {status})", run_id=run_id, status=run_status))
|
||||
click.echo("")
|
||||
|
||||
jobs = client.get_action_run_jobs(run_id)
|
||||
if not jobs:
|
||||
click.echo(_("No jobs found for run #{run_id}.", run_id=run_id))
|
||||
return
|
||||
|
||||
_print_job_summary(jobs)
|
||||
click.echo("")
|
||||
|
||||
if job:
|
||||
target = _find_job_by_name(jobs, job)
|
||||
if not target:
|
||||
raise click.ClickException(_("No job matching '{job}' found.", job=job))
|
||||
click.echo(f"Logs for job '{target.get('name', '?')}' (id={target.get('id')}):")
|
||||
_print_failed_steps(target)
|
||||
click.echo("")
|
||||
_print_logs(client, target["id"], tail)
|
||||
else:
|
||||
failed = _find_failed_jobs(jobs)
|
||||
if not failed:
|
||||
click.echo(_("No failed jobs."))
|
||||
return
|
||||
for fj in failed:
|
||||
click.echo(f"Logs for failed job '{fj.get('name', '?')}' (id={fj.get('id')}):")
|
||||
_print_failed_steps(fj)
|
||||
click.echo("")
|
||||
_print_logs(client, fj["id"], tail)
|
||||
click.echo("")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check CI status for a pull request or commit.
|
||||
|
||||
Displays the status of all CI checks for a PR (or a specific commit SHA).
|
||||
Optionally polls until all checks complete (``--wait``).
|
||||
|
||||
Usage::
|
||||
|
||||
# Check status of PR #42
|
||||
python -m devx.tools.pr_status --pr 42
|
||||
|
||||
# Check status of current branch's PR
|
||||
python -m devx.tools.pr_status
|
||||
|
||||
# Wait for all checks to complete (timeout 600s)
|
||||
python -m devx.tools.pr_status --pr 42 --wait --timeout 600
|
||||
|
||||
# Check a specific commit SHA
|
||||
python -m devx.tools.pr_status --sha abc1234
|
||||
|
||||
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import time
|
||||
|
||||
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.tools.create_pr import get_repo_name
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Status symbols for terminal output
|
||||
_STATUS_SYMBOLS = {
|
||||
"success": "[OK]",
|
||||
"failure": "[FAIL]",
|
||||
"error": "[FAIL]",
|
||||
"pending": "[..]",
|
||||
"skipped": "[SKIP]",
|
||||
"none": "[--]",
|
||||
}
|
||||
|
||||
|
||||
def _get_symbol(status: str) -> str:
|
||||
return _STATUS_SYMBOLS.get(status, f"[{status}]")
|
||||
|
||||
|
||||
def _get_pr_sha(client: GiteaClient, pr_number: int) -> str:
|
||||
"""Fetch the head SHA of a PR."""
|
||||
pr = client.get_pr(pr_number)
|
||||
return pr.get("head", {}).get("sha", "")
|
||||
|
||||
|
||||
def _get_current_branch_pr(client: GiteaClient) -> int:
|
||||
"""Find the open PR for the current git branch."""
|
||||
result = subprocess.run( # nosec
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(_("Could not detect current branch: {error}", error=result.stderr.strip()))
|
||||
branch = result.stdout.strip()
|
||||
|
||||
prs = client.list_prs(state="open")
|
||||
for pr in prs:
|
||||
if pr.get("head", {}).get("ref") == branch:
|
||||
return int(pr["number"])
|
||||
raise click.ClickException(_("No open PR found for branch '{branch}'.", branch=branch))
|
||||
|
||||
|
||||
def print_status(client: GiteaClient, sha: str) -> str:
|
||||
"""Print CI check statuses for a commit SHA. Returns the overall state."""
|
||||
statuses = client.get_commit_status(sha)
|
||||
if not statuses:
|
||||
click.echo(_("No CI checks found for commit {sha}.", sha=sha[:8]))
|
||||
return "none"
|
||||
|
||||
overall = "success"
|
||||
for s in statuses:
|
||||
context = s.get("context", "?")
|
||||
status = s.get("status", "pending")
|
||||
symbol = _get_symbol(status)
|
||||
click.echo(f" {symbol} {context}")
|
||||
if status in ("failure", "error"):
|
||||
overall = "failure"
|
||||
elif status == "pending" and overall != "failure":
|
||||
overall = "pending"
|
||||
elif status == "skipped" and overall == "success":
|
||||
overall = "success"
|
||||
|
||||
click.echo(f"\n Overall: {_get_symbol(overall)} {overall}")
|
||||
return overall
|
||||
|
||||
|
||||
def wait_for_completion(
|
||||
client: GiteaClient,
|
||||
sha: str,
|
||||
timeout: int = 600,
|
||||
interval: int = 30,
|
||||
) -> str:
|
||||
"""Poll CI status until all checks complete or timeout. Returns final state."""
|
||||
click.echo(_("Waiting for CI checks to complete (timeout: {timeout}s)...", timeout=timeout))
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
state = print_status(client, sha)
|
||||
if state in ("success", "failure", "error", "none"):
|
||||
return state
|
||||
click.echo(f" ...still pending, retrying in {interval}s\n")
|
||||
time.sleep(interval)
|
||||
click.echo(_("Timeout reached after {timeout}s.", timeout=timeout))
|
||||
return "pending"
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
|
||||
@click.option("--sha", default=None, help="Commit SHA to check (alternative to --pr).")
|
||||
@click.option("--wait", "do_wait", is_flag=True, help="Poll until all checks complete.")
|
||||
@click.option("--timeout", type=int, default=600, show_default=True, help="Wait timeout in seconds.")
|
||||
@click.option("--interval", type=int, default=30, show_default=True, help="Poll interval in seconds.")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||
def cli(
|
||||
pr_number: int | None,
|
||||
sha: str | None,
|
||||
do_wait: bool,
|
||||
timeout: int,
|
||||
interval: int,
|
||||
owner: str | None,
|
||||
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."))
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||
repo_name = repo or get_repo_name()
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||
|
||||
if sha is None:
|
||||
if pr_number is None:
|
||||
pr_number = _get_current_branch_pr(client)
|
||||
click.echo(_("Checking status for PR #{pr_number}...", pr_number=pr_number))
|
||||
pr = client.get_pr(pr_number)
|
||||
sha = pr.get("head", {}).get("sha", "")
|
||||
if not sha:
|
||||
raise click.ClickException(_("Could not determine head SHA for PR #{pr_number}.", pr_number=pr_number))
|
||||
|
||||
click.echo(_("Commit: {sha}", sha=sha[:12]))
|
||||
click.echo("")
|
||||
|
||||
state = wait_for_completion(client, sha, timeout, interval) if do_wait else print_status(client, sha)
|
||||
|
||||
if state in ("failure", "error"):
|
||||
raise click.ClickException(_("CI checks failed."))
|
||||
if state == "pending" and do_wait:
|
||||
raise click.ClickException(_("CI checks did not complete within timeout."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
+175
-15
@@ -1391,6 +1391,14 @@
|
||||
"ru": "CI_GITEA_TOKEN environment variable required",
|
||||
"zh": "CI_GITEA_TOKEN environment variable required"
|
||||
},
|
||||
"Failed to delete {count} image version(s)": {
|
||||
"bg": "Failed to delete {count} image version(s)",
|
||||
"de": "Failed to delete {count} image version(s)",
|
||||
"en": "Failed to delete {count} image version(s)",
|
||||
"pl": "Failed to delete {count} image version(s)",
|
||||
"ru": "Failed to delete {count} image version(s)",
|
||||
"zh": "Failed to delete {count} image version(s)"
|
||||
},
|
||||
"CI_GITEA_TOKEN is not set. Required to create a PR.": {
|
||||
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
|
||||
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
|
||||
@@ -1463,14 +1471,6 @@
|
||||
"ru": "Repository in owner/name format",
|
||||
"zh": "Repository in owner/name format"
|
||||
},
|
||||
"Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.": {
|
||||
"bg": "Името на хранилището не е зададено. Използвайте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
|
||||
"de": "Repository-Name nicht gesetzt. Verwende DEVX_REPO_NAME oder GITHUB_REPOSITORY env var.",
|
||||
"en": "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.",
|
||||
"pl": "Nazwa repozytorium nie jest ustawiona. Użyj DEVX_REPO_NAME lub GITHUB_REPOSITORY env var.",
|
||||
"ru": "Имя репозитория не установлено. Используйте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
|
||||
"zh": "仓库名称未设置。使用 DEVX_REPO_NAME 或 GITHUB_REPOSITORY 环境变量。"
|
||||
},
|
||||
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
|
||||
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
|
||||
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
|
||||
@@ -1799,13 +1799,13 @@
|
||||
"ru": "[check_test_coverage] No changed files to check.",
|
||||
"zh": "[check_test_coverage] No changed files to check."
|
||||
},
|
||||
"[dry-run] Would commit: release: v{version}": {
|
||||
"bg": "[dry-run] Would commit: release: v{version}",
|
||||
"de": "[dry-run] Would commit: release: v{version}",
|
||||
"en": "[dry-run] Would commit: release: v{version}",
|
||||
"pl": "[dry-run] Utworzono by commit: release: v{version}",
|
||||
"ru": "[dry-run] Would commit: release: v{version}",
|
||||
"zh": "[dry-run] Would commit: release: v{version}"
|
||||
"[dry-run] Would commit: release: v{version} [skip ci]": {
|
||||
"bg": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
"de": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
"en": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
"pl": "[dry-run] Utworzono by commit: release: v{version} [skip ci]",
|
||||
"ru": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
"zh": "[dry-run] Would commit: release: v{version} [skip ci]"
|
||||
},
|
||||
"[dry-run] Would create tag: v{version}": {
|
||||
"bg": "[dry-run] Would create tag: v{version}",
|
||||
@@ -2030,5 +2030,165 @@
|
||||
"pl": "Configuring tea login '{name}' for {url}...",
|
||||
"ru": "Configuring tea login '{name}' for {url}...",
|
||||
"zh": "Configuring tea login '{name}' for {url}..."
|
||||
},
|
||||
" Could not fetch logs: {error}": {
|
||||
"en": " Could not fetch logs: {error}",
|
||||
"bg": " Could not fetch logs: {error}",
|
||||
"de": " Could not fetch logs: {error}",
|
||||
"pl": " Could not fetch logs: {error}",
|
||||
"ru": " Could not fetch logs: {error}",
|
||||
"zh": " Could not fetch logs: {error}"
|
||||
},
|
||||
"Added label '{label}' to PR #{pr}.": {
|
||||
"en": "Added label '{label}' to PR #{pr}.",
|
||||
"bg": "Added label '{label}' to PR #{pr}.",
|
||||
"de": "Added label '{label}' to PR #{pr}.",
|
||||
"pl": "Added label '{label}' to PR #{pr}.",
|
||||
"ru": "Added label '{label}' to PR #{pr}.",
|
||||
"zh": "Added label '{label}' to PR #{pr}."
|
||||
},
|
||||
"CI checks did not complete within timeout.": {
|
||||
"en": "CI checks did not complete within timeout.",
|
||||
"bg": "CI checks did not complete within timeout.",
|
||||
"de": "CI checks did not complete within timeout.",
|
||||
"pl": "CI checks did not complete within timeout.",
|
||||
"ru": "CI checks did not complete within timeout.",
|
||||
"zh": "CI checks did not complete within timeout."
|
||||
},
|
||||
"CI checks failed.": {
|
||||
"en": "CI checks failed.",
|
||||
"bg": "CI checks failed.",
|
||||
"de": "CI checks failed.",
|
||||
"pl": "CI checks failed.",
|
||||
"ru": "CI checks failed.",
|
||||
"zh": "CI checks failed."
|
||||
},
|
||||
"CI_GITEA_TOKEN is not set.": {
|
||||
"en": "CI_GITEA_TOKEN is not set.",
|
||||
"bg": "CI_GITEA_TOKEN is not set.",
|
||||
"de": "CI_GITEA_TOKEN is not set.",
|
||||
"pl": "CI_GITEA_TOKEN is not set.",
|
||||
"ru": "CI_GITEA_TOKEN is not set.",
|
||||
"zh": "CI_GITEA_TOKEN is not set."
|
||||
},
|
||||
"Checking status for PR #{pr_number}...": {
|
||||
"en": "Checking status for PR #{pr_number}...",
|
||||
"bg": "Checking status for PR #{pr_number}...",
|
||||
"de": "Checking status for PR #{pr_number}...",
|
||||
"pl": "Checking status for PR #{pr_number}...",
|
||||
"ru": "Checking status for PR #{pr_number}...",
|
||||
"zh": "Checking status for PR #{pr_number}..."
|
||||
},
|
||||
"Commit: {sha}": {
|
||||
"en": "Commit: {sha}",
|
||||
"bg": "Commit: {sha}",
|
||||
"de": "Commit: {sha}",
|
||||
"pl": "Commit: {sha}",
|
||||
"ru": "Commit: {sha}",
|
||||
"zh": "Commit: {sha}"
|
||||
},
|
||||
"Could not determine head SHA for PR #{pr_number}.": {
|
||||
"en": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"bg": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"de": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"pl": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"ru": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"zh": "Could not determine head SHA for PR #{pr_number}."
|
||||
},
|
||||
"Fetching logs for PR #{pr_number}...": {
|
||||
"en": "Fetching logs for PR #{pr_number}...",
|
||||
"bg": "Fetching logs for PR #{pr_number}...",
|
||||
"de": "Fetching logs for PR #{pr_number}...",
|
||||
"pl": "Fetching logs for PR #{pr_number}...",
|
||||
"ru": "Fetching logs for PR #{pr_number}...",
|
||||
"zh": "Fetching logs for PR #{pr_number}..."
|
||||
},
|
||||
"Label '{label}' already on PR #{pr}.": {
|
||||
"en": "Label '{label}' already on PR #{pr}.",
|
||||
"bg": "Label '{label}' already on PR #{pr}.",
|
||||
"de": "Label '{label}' already on PR #{pr}.",
|
||||
"pl": "Label '{label}' already on PR #{pr}.",
|
||||
"ru": "Label '{label}' already on PR #{pr}.",
|
||||
"zh": "Label '{label}' already on PR #{pr}."
|
||||
},
|
||||
"Latest run: #{run_id} (status: {status})": {
|
||||
"en": "Latest run: #{run_id} (status: {status})",
|
||||
"bg": "Latest run: #{run_id} (status: {status})",
|
||||
"de": "Latest run: #{run_id} (status: {status})",
|
||||
"pl": "Latest run: #{run_id} (status: {status})",
|
||||
"ru": "Latest run: #{run_id} (status: {status})",
|
||||
"zh": "Latest run: #{run_id} (status: {status})"
|
||||
},
|
||||
"No CI checks found for commit {sha}.": {
|
||||
"en": "No CI checks found for commit {sha}.",
|
||||
"bg": "No CI checks found for commit {sha}.",
|
||||
"de": "No CI checks found for commit {sha}.",
|
||||
"pl": "No CI checks found for commit {sha}.",
|
||||
"ru": "No CI checks found for commit {sha}.",
|
||||
"zh": "No CI checks found for commit {sha}."
|
||||
},
|
||||
"No failed jobs.": {
|
||||
"en": "No failed jobs.",
|
||||
"bg": "No failed jobs.",
|
||||
"de": "No failed jobs.",
|
||||
"pl": "No failed jobs.",
|
||||
"ru": "No failed jobs.",
|
||||
"zh": "No failed jobs."
|
||||
},
|
||||
"No job matching '{job}' found.": {
|
||||
"en": "No job matching '{job}' found.",
|
||||
"bg": "No job matching '{job}' found.",
|
||||
"de": "No job matching '{job}' found.",
|
||||
"pl": "No job matching '{job}' found.",
|
||||
"ru": "No job matching '{job}' found.",
|
||||
"zh": "No job matching '{job}' found."
|
||||
},
|
||||
"No jobs found for run #{run_id}.": {
|
||||
"en": "No jobs found for run #{run_id}.",
|
||||
"bg": "No jobs found for run #{run_id}.",
|
||||
"de": "No jobs found for run #{run_id}.",
|
||||
"pl": "No jobs found for run #{run_id}.",
|
||||
"ru": "No jobs found for run #{run_id}.",
|
||||
"zh": "No jobs found for run #{run_id}."
|
||||
},
|
||||
"No open PR found for branch '{branch}'.": {
|
||||
"en": "No open PR found for branch '{branch}'.",
|
||||
"bg": "No open PR found for branch '{branch}'.",
|
||||
"de": "No open PR found for branch '{branch}'.",
|
||||
"pl": "No open PR found for branch '{branch}'.",
|
||||
"ru": "No open PR found for branch '{branch}'.",
|
||||
"zh": "No open PR found for branch '{branch}'."
|
||||
},
|
||||
"No workflow runs found for SHA {sha}.": {
|
||||
"en": "No workflow runs found for SHA {sha}.",
|
||||
"bg": "No workflow runs found for SHA {sha}.",
|
||||
"de": "No workflow runs found for SHA {sha}.",
|
||||
"pl": "No workflow runs found for SHA {sha}.",
|
||||
"ru": "No workflow runs found for SHA {sha}.",
|
||||
"zh": "No workflow runs found for SHA {sha}."
|
||||
},
|
||||
"Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": {
|
||||
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"bg": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"de": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"pl": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"ru": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
|
||||
},
|
||||
"Timeout reached after {timeout}s.": {
|
||||
"en": "Timeout reached after {timeout}s.",
|
||||
"bg": "Timeout reached after {timeout}s.",
|
||||
"de": "Timeout reached after {timeout}s.",
|
||||
"pl": "Timeout reached after {timeout}s.",
|
||||
"ru": "Timeout reached after {timeout}s.",
|
||||
"zh": "Timeout reached after {timeout}s."
|
||||
},
|
||||
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
|
||||
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"pl": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,5 +797,88 @@ class TestIsRetryable:
|
||||
err = _mock_http_error(404, "not found")
|
||||
assert _is_retryable(err) is False
|
||||
|
||||
|
||||
class TestGiteaClientPrLabels:
|
||||
def test_add_pr_label(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({}))
|
||||
client.add_pr_label(42, ["ready-to-merge"])
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/issues/42/labels",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"labels": ["ready-to-merge"]},
|
||||
)
|
||||
|
||||
def test_add_pr_label_multiple(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({}))
|
||||
client.add_pr_label(42, ["ready-to-merge", "reviewed"])
|
||||
call_kwargs = client._session.request.call_args.kwargs
|
||||
assert call_kwargs["json"]["labels"] == ["ready-to-merge", "reviewed"]
|
||||
|
||||
def test_get_pr_label_names(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"name": "bug"}, {"name": "ready-to-merge"}]))
|
||||
result = client.get_pr_label_names(42)
|
||||
assert result == ["bug", "ready-to-merge"]
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/issues/42/labels",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
class TestGiteaClientActions:
|
||||
def test_list_action_runs(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"workflow_runs": [{"id": 1, "status": "completed"}], "total_count": 1})
|
||||
)
|
||||
result = client.list_action_runs(branch="feature-branch", limit=1)
|
||||
assert result["total_count"] == 1
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/actions/runs",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
params={"branch": "feature-branch", "limit": 1},
|
||||
)
|
||||
|
||||
def test_get_action_run_jobs(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"jobs": [{"id": 100, "name": "quality", "conclusion": "failure"}]})
|
||||
)
|
||||
result = client.get_action_run_jobs(1410)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "quality"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/actions/runs/1410/jobs",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_action_run_jobs_empty(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({}))
|
||||
result = client.get_action_run_jobs(1410)
|
||||
assert result == []
|
||||
|
||||
def test_get_action_job_logs(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.text = "log line 1\nlog line 2"
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
result = client.get_action_job_logs(10026)
|
||||
assert "log line 1" in result
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/actions/jobs/10026/logs",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
class TestIsRetryableGeneric:
|
||||
def test_generic_exception_is_not_retryable(self) -> None:
|
||||
assert _is_retryable(ValueError("oops")) is False
|
||||
|
||||
@@ -321,6 +321,25 @@ class TestCleanImagesAPI:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
mock_resp = MagicMock(status_code=204)
|
||||
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp) as mock_del:
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
)
|
||||
is True
|
||||
)
|
||||
# Verify URL includes container type
|
||||
url = mock_del.call_args.args[0]
|
||||
assert "/container/" in url
|
||||
|
||||
def test_delete_package_version_404_treated_as_success(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
mock_resp = MagicMock(status_code=404)
|
||||
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
|
||||
assert (
|
||||
delete_package_version(
|
||||
@@ -336,7 +355,7 @@ class TestCleanImagesAPI:
|
||||
def test_delete_package_version_failure(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
mock_resp = MagicMock(status_code=404)
|
||||
mock_resp = MagicMock(status_code=403)
|
||||
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
|
||||
assert (
|
||||
delete_package_version(
|
||||
@@ -349,6 +368,110 @@ class TestCleanImagesAPI:
|
||||
is False
|
||||
)
|
||||
|
||||
def test_delete_package_version_retries_on_5xx(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
responses = [
|
||||
MagicMock(status_code=500),
|
||||
MagicMock(status_code=502),
|
||||
MagicMock(status_code=204),
|
||||
]
|
||||
with patch("devx.tools.clean_images.requests.delete", side_effect=responses):
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=3,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_delete_package_version_retries_on_exception(self) -> None:
|
||||
import requests as req
|
||||
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
responses = [
|
||||
req.ConnectionError("network down"),
|
||||
MagicMock(status_code=204),
|
||||
]
|
||||
with patch("devx.tools.clean_images.requests.delete", side_effect=responses):
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=3,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_delete_package_version_exhausts_retries_on_exception(self) -> None:
|
||||
import requests as req
|
||||
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
with patch(
|
||||
"devx.tools.clean_images.requests.delete",
|
||||
side_effect=req.ConnectionError("network down"),
|
||||
):
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=2,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_delete_package_version_exhausts_retries_on_5xx(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
with patch(
|
||||
"devx.tools.clean_images.requests.delete",
|
||||
return_value=MagicMock(status_code=500),
|
||||
):
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=2,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_delete_package_version_zero_retries(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
with patch("devx.tools.clean_images.requests.delete") as mock_del:
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=0,
|
||||
)
|
||||
is False
|
||||
)
|
||||
mock_del.assert_not_called()
|
||||
|
||||
|
||||
class TestCLIBuildImage:
|
||||
def test_single_image_build(self, tmp_path: Path) -> None:
|
||||
@@ -557,7 +680,7 @@ class TestCLICleanImages:
|
||||
clean_main,
|
||||
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert result.exit_code != 0
|
||||
assert "Failed to list" in result.output
|
||||
|
||||
def test_delete_failure_in_cli(self) -> None:
|
||||
@@ -571,13 +694,15 @@ class TestCLICleanImages:
|
||||
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||
]
|
||||
list_resp.raise_for_status = MagicMock()
|
||||
delete_resp = MagicMock(status_code=500)
|
||||
delete_resp = MagicMock(status_code=403)
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
|
||||
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
|
||||
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
|
||||
result = runner.invoke(
|
||||
clean_main,
|
||||
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "FAILED" in result.output
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
result = runner.invoke(
|
||||
clean_main,
|
||||
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "FAILED" in result.output
|
||||
assert "failed" in result.output.lower()
|
||||
|
||||
@@ -264,6 +264,16 @@ class TestChangeClassifier:
|
||||
assert fc.is_user_facing
|
||||
assert fc.matched_rule == "user_facing_overrides"
|
||||
|
||||
def test_user_facing_override_glob_matches_nested(self) -> None:
|
||||
"""User-facing overrides support glob patterns like infrastructure."""
|
||||
classifier = self._make_classifier(
|
||||
infrastructure=[".gitea/**"],
|
||||
user_facing_overrides=[".gitea/**"],
|
||||
)
|
||||
fc = classifier.classify_file(".gitea/workflows/ci.yml")
|
||||
assert fc.is_user_facing
|
||||
assert fc.matched_rule == "user_facing_overrides"
|
||||
|
||||
def test_user_facing_override_beats_infrastructure_override(self) -> None:
|
||||
"""User-facing overrides beat infrastructure overrides (safety first)."""
|
||||
classifier = self._make_classifier(
|
||||
@@ -489,7 +499,7 @@ class TestMain:
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_workflow_only_exits_2(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
|
||||
mock_changes.return_value = ["docs/index.md", "README.md"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 2
|
||||
@@ -572,7 +582,7 @@ class TestMain:
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml"]
|
||||
mock_changes.return_value = ["docs/index.md"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
@@ -631,7 +641,7 @@ class TestMain:
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"]
|
||||
mock_changes.return_value = ["docs/index.md", "tests/test_foo.py"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -29,10 +29,22 @@ class TestGetRepoName:
|
||||
def test_from_env(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "devx")
|
||||
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
|
||||
def test_env_overrides_pyproject(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "devx")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_from_pyproject(self) -> None:
|
||||
assert get_repo_name() == "devx"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "")
|
||||
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
|
||||
def test_from_github(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_missing_raises(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="Repository name"):
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Unit tests for devx.tools.pr_label."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
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)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.tools.pr_label.REPO_OWNER", "")
|
||||
def test_no_owner_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
assert result.exit_code != 0
|
||||
assert "owner" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
def test_adds_new_label(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr_label_names.return_value = []
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
assert result.exit_code == 0
|
||||
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
|
||||
assert "Added label" in result.output
|
||||
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
def test_skips_existing_label(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr_label_names.return_value = ["ready-to-merge"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
assert result.exit_code == 0
|
||||
client.add_pr_label.assert_not_called()
|
||||
assert "already" in result.output
|
||||
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
def test_mixed_new_and_existing(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr_label_names.return_value = ["reviewed"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge", "--label", "reviewed"])
|
||||
assert result.exit_code == 0
|
||||
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
|
||||
assert "Added label" in result.output
|
||||
assert "already" in result.output
|
||||
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_auto_detect_pr(
|
||||
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
|
||||
client = mock_client_cls.return_value
|
||||
client.list_prs.return_value = [{"number": 42, "head": {"ref": "feature-branch"}}]
|
||||
client.get_pr_label_names.return_value = []
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--label", "ready-to-merge"])
|
||||
assert result.exit_code == 0
|
||||
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Unit tests for devx.tools.pr_logs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.api_clients import APIError, GiteaClient
|
||||
from devx.tools.pr_logs import (
|
||||
_find_failed_jobs,
|
||||
_find_job_by_name,
|
||||
_find_latest_run_by_sha,
|
||||
_get_pr_sha,
|
||||
_print_failed_steps,
|
||||
_print_job_summary,
|
||||
_print_logs,
|
||||
cli,
|
||||
)
|
||||
|
||||
|
||||
class TestGetPrSha:
|
||||
def test_returns_sha(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
assert _get_pr_sha(client, 42) == "abc123"
|
||||
|
||||
def test_returns_empty_when_missing(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_pr.return_value = {"head": {}}
|
||||
assert _get_pr_sha(client, 42) == ""
|
||||
|
||||
|
||||
class TestFindLatestRunBySha:
|
||||
def test_returns_matching_run(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [
|
||||
{"id": 2, "head_sha": "def456"},
|
||||
{"id": 1, "head_sha": "abc123def"},
|
||||
],
|
||||
}
|
||||
result = _find_latest_run_by_sha(client, "abc123")
|
||||
assert result is not None
|
||||
assert result["id"] == 1
|
||||
|
||||
def test_returns_none_when_no_match(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [{"id": 1, "head_sha": "def456"}],
|
||||
}
|
||||
result = _find_latest_run_by_sha(client, "abc123")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_empty(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.list_action_runs.return_value = {"workflow_runs": []}
|
||||
result = _find_latest_run_by_sha(client, "abc123")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestFindFailedJobs:
|
||||
def test_returns_failed(self) -> None:
|
||||
jobs = [
|
||||
{"id": 1, "name": "quality", "conclusion": "failure"},
|
||||
{"id": 2, "name": "lint", "conclusion": "success"},
|
||||
]
|
||||
result = _find_failed_jobs(jobs)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "quality"
|
||||
|
||||
def test_empty_when_none_failed(self) -> None:
|
||||
jobs = [{"id": 1, "name": "quality", "conclusion": "success"}]
|
||||
assert _find_failed_jobs(jobs) == []
|
||||
|
||||
|
||||
class TestFindJobByName:
|
||||
def test_case_insensitive_partial(self) -> None:
|
||||
jobs = [{"id": 1, "name": "CI / quality (pull_request)"}]
|
||||
result = _find_job_by_name(jobs, "QUALITY")
|
||||
assert result is not None
|
||||
assert result["id"] == 1
|
||||
|
||||
def test_returns_none_when_not_found(self) -> None:
|
||||
jobs = [{"id": 1, "name": "quality"}]
|
||||
assert _find_job_by_name(jobs, "molecule") is None
|
||||
|
||||
|
||||
class TestPrintJobSummary:
|
||||
def test_prints_all_jobs(self, capsys: pytest.CaptureFixture) -> None:
|
||||
jobs = [
|
||||
{"id": 1, "name": "quality", "conclusion": "failure", "status": "completed"},
|
||||
{"id": 2, "name": "lint", "conclusion": "success", "status": "completed"},
|
||||
]
|
||||
_print_job_summary(jobs)
|
||||
out = capsys.readouterr().out
|
||||
assert "[FAIL]" in out
|
||||
assert "[OK]" in out
|
||||
assert "quality" in out
|
||||
assert "lint" in out
|
||||
|
||||
|
||||
class TestPrintFailedSteps:
|
||||
def test_prints_failed_steps(self, capsys: pytest.CaptureFixture) -> None:
|
||||
job = {
|
||||
"steps": [
|
||||
{"name": "checkout", "number": 1, "conclusion": "success"},
|
||||
{"name": "Unit tests", "number": 3, "conclusion": "failure"},
|
||||
]
|
||||
}
|
||||
result = _print_failed_steps(job)
|
||||
assert result == [3]
|
||||
out = capsys.readouterr().out
|
||||
assert "FAILED step #3" in out
|
||||
assert "Unit tests" in out
|
||||
|
||||
def test_no_failed_steps(self, capsys: pytest.CaptureFixture) -> None:
|
||||
job = {"steps": [{"name": "checkout", "number": 1, "conclusion": "success"}]}
|
||||
result = _print_failed_steps(job)
|
||||
assert result == []
|
||||
|
||||
def test_no_steps_key(self, capsys: pytest.CaptureFixture) -> None:
|
||||
result = _print_failed_steps({})
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestPrintLogs:
|
||||
def test_prints_all_lines(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_action_job_logs.return_value = "line 1\nline 2\nline 3"
|
||||
_print_logs(client, 100, tail=0)
|
||||
out = capsys.readouterr().out
|
||||
assert "line 1" in out
|
||||
assert "line 3" in out
|
||||
|
||||
def test_tail_truncates(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_action_job_logs.return_value = "\n".join(f"line {i}" for i in range(100))
|
||||
_print_logs(client, 100, tail=10)
|
||||
out = capsys.readouterr().out
|
||||
assert "line 99" in out
|
||||
assert "line 0" not in out
|
||||
assert "showing last 10" in out
|
||||
|
||||
def test_api_error_handled(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_action_job_logs.side_effect = APIError(404, "not found")
|
||||
_print_logs(client, 100, tail=0)
|
||||
out = capsys.readouterr().out
|
||||
assert "Could not fetch logs" in out
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.REPO_OWNER", "")
|
||||
def test_no_owner_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "owner" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_auto_detect_pr(
|
||||
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
|
||||
client = mock_client_cls.return_value
|
||||
client.list_prs.return_value = [{"number": 42, "head": {"ref": "feature-branch"}}]
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {"workflow_runs": []}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
assert "Fetching logs for PR #42" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_no_runs_found(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {"workflow_runs": []}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "No workflow runs" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_no_jobs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
|
||||
}
|
||||
client.get_action_run_jobs.return_value = []
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code == 0
|
||||
assert "No jobs" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_no_failed_jobs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
|
||||
}
|
||||
client.get_action_run_jobs.return_value = [
|
||||
{"id": 100, "name": "quality", "conclusion": "success", "status": "completed", "steps": []}
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code == 0
|
||||
assert "No failed jobs" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_failed_job_logs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
|
||||
}
|
||||
client.get_action_run_jobs.return_value = [
|
||||
{
|
||||
"id": 100,
|
||||
"name": "quality",
|
||||
"conclusion": "failure",
|
||||
"status": "completed",
|
||||
"steps": [
|
||||
{"name": "checkout", "number": 1, "conclusion": "success"},
|
||||
{"name": "Unit tests", "number": 3, "conclusion": "failure"},
|
||||
],
|
||||
}
|
||||
]
|
||||
client.get_action_job_logs.return_value = "error: test failed"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--tail", "0"])
|
||||
assert result.exit_code == 0
|
||||
assert "FAILED step #3" in result.output
|
||||
assert "error: test failed" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_specific_job(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
|
||||
}
|
||||
client.get_action_run_jobs.return_value = [
|
||||
{"id": 100, "name": "quality", "conclusion": "success", "status": "completed", "steps": []},
|
||||
{"id": 101, "name": "lint", "conclusion": "success", "status": "completed", "steps": []},
|
||||
]
|
||||
client.get_action_job_logs.return_value = "lint output here"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--job", "lint", "--tail", "0"])
|
||||
assert result.exit_code == 0
|
||||
assert "lint output here" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_job_not_found(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
|
||||
}
|
||||
client.get_action_run_jobs.return_value = [
|
||||
{"id": 100, "name": "quality", "conclusion": "success", "status": "completed", "steps": []}
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--job", "nonexistent"])
|
||||
assert result.exit_code != 0
|
||||
assert "No job matching" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_no_sha_raises(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {}}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "SHA" in result.output
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Unit tests for devx.tools.pr_status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.tools.pr_status import (
|
||||
_get_pr_sha,
|
||||
_get_symbol,
|
||||
cli,
|
||||
print_status,
|
||||
wait_for_completion,
|
||||
)
|
||||
|
||||
|
||||
class TestGetSymbol:
|
||||
def test_success(self) -> None:
|
||||
assert _get_symbol("success") == "[OK]"
|
||||
|
||||
def test_failure(self) -> None:
|
||||
assert _get_symbol("failure") == "[FAIL]"
|
||||
|
||||
def test_pending(self) -> None:
|
||||
assert _get_symbol("pending") == "[..]"
|
||||
|
||||
def test_unknown(self) -> None:
|
||||
assert _get_symbol("weird") == "[weird]"
|
||||
|
||||
|
||||
class TestGetPrSha:
|
||||
def test_returns_head_sha(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
assert _get_pr_sha(client, 42) == "abc123"
|
||||
|
||||
def test_returns_empty_when_missing(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_pr.return_value = {"head": {}}
|
||||
assert _get_pr_sha(client, 42) == ""
|
||||
|
||||
|
||||
class TestPrintStatus:
|
||||
def test_no_statuses(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.return_value = []
|
||||
result = print_status(client, "abc123")
|
||||
assert result == "none"
|
||||
|
||||
def test_all_success(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "success"},
|
||||
{"context": "CI / lint", "status": "success"},
|
||||
]
|
||||
result = print_status(client, "abc123")
|
||||
assert result == "success"
|
||||
|
||||
def test_has_failure(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "success"},
|
||||
{"context": "CI / lint", "status": "failure"},
|
||||
]
|
||||
result = print_status(client, "abc123")
|
||||
assert result == "failure"
|
||||
|
||||
def test_pending(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "pending"},
|
||||
]
|
||||
result = print_status(client, "abc123")
|
||||
assert result == "pending"
|
||||
|
||||
def test_skipped_still_success(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "success"},
|
||||
{"context": "CI / molecule", "status": "skipped"},
|
||||
]
|
||||
result = print_status(client, "abc123")
|
||||
assert result == "success"
|
||||
|
||||
|
||||
class TestWaitForCompletion:
|
||||
@patch("devx.tools.pr_status.time.sleep")
|
||||
@patch("devx.tools.pr_status.time.time", side_effect=[0, 0, 100, 200])
|
||||
def test_success_after_pending(
|
||||
self, mock_time: MagicMock, mock_sleep: MagicMock, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.side_effect = [
|
||||
[{"context": "CI / quality", "status": "pending"}],
|
||||
[{"context": "CI / quality", "status": "success"}],
|
||||
]
|
||||
result = wait_for_completion(client, "abc", timeout=600, interval=1)
|
||||
assert result == "success"
|
||||
|
||||
@patch("devx.tools.pr_status.time.sleep")
|
||||
@patch("devx.tools.pr_status.time.time", side_effect=[0, 0, 100, 200])
|
||||
def test_failure_after_pending(self, mock_time: MagicMock, mock_sleep: MagicMock) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.side_effect = [
|
||||
[{"context": "CI / quality", "status": "pending"}],
|
||||
[{"context": "CI / quality", "status": "failure"}],
|
||||
]
|
||||
result = wait_for_completion(client, "abc", timeout=600, interval=1)
|
||||
assert result == "failure"
|
||||
|
||||
@patch("devx.tools.pr_status.time.sleep")
|
||||
@patch("devx.tools.pr_status.time.time", side_effect=[0, 700])
|
||||
def test_timeout(self, mock_time: MagicMock, mock_sleep: MagicMock) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.return_value = [{"context": "CI / quality", "status": "pending"}]
|
||||
result = wait_for_completion(client, "abc", timeout=600, interval=1)
|
||||
assert result == "pending"
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.REPO_OWNER", "")
|
||||
@patch("devx.tools.pr_status.get_repo_name", side_effect=Exception("should not reach"))
|
||||
def test_no_owner_raises(self, mock_repo_name: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "owner" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_check_pr_status(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "success"},
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code == 0
|
||||
assert "[OK]" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_check_sha_directly(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "success"},
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--sha", "abc123"])
|
||||
assert result.exit_code == 0
|
||||
assert "[OK]" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_failure_raises_exception(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "failure"},
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "failed" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_auto_detect_branch(
|
||||
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
|
||||
client = mock_client_cls.return_value
|
||||
client.list_prs.return_value = [{"number": 42, "head": {"ref": "feature-branch"}}]
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.get_commit_status.return_value = [{"context": "CI / quality", "status": "success"}]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "PR #42" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_auto_detect_no_pr_found(
|
||||
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
|
||||
client = mock_client_cls.return_value
|
||||
client.list_prs.return_value = []
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
assert "No open PR" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_auto_detect_branch_error(
|
||||
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
mock_subprocess.return_value = MagicMock(returncode=1, stderr="git error\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
assert "Could not detect" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_no_sha_raises(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {}}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "SHA" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.time.sleep")
|
||||
@patch("devx.tools.pr_status.time.time", side_effect=[0, 0, 100, 200])
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_wait_success(
|
||||
self, mock_client_cls: MagicMock, mock_time: MagicMock, mock_sleep: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.get_commit_status.side_effect = [
|
||||
[{"context": "CI / quality", "status": "pending"}],
|
||||
[{"context": "CI / quality", "status": "success"}],
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--wait", "--timeout", "600", "--interval", "1"])
|
||||
assert result.exit_code == 0
|
||||
assert "[OK]" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.time.sleep")
|
||||
@patch("devx.tools.pr_status.time.time", side_effect=[0, 700])
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_wait_timeout(
|
||||
self, mock_client_cls: MagicMock, mock_time: MagicMock, mock_sleep: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.get_commit_status.return_value = [{"context": "CI / quality", "status": "pending"}]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--wait", "--timeout", "600", "--interval", "1"])
|
||||
assert result.exit_code != 0
|
||||
assert "timeout" in result.output.lower()
|
||||
@@ -802,7 +802,7 @@ class TestCommitReleaseChanges:
|
||||
assert result is True
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "add", "src/devx/__init__.py", "CHANGELOG.md"] in calls
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
|
||||
@@ -811,7 +811,7 @@ class TestCommitReleaseChanges:
|
||||
result = commit_release_changes("0.1.0")
|
||||
assert result is False
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.1.0"] not in calls
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.1.0 [skip ci]"] not in calls
|
||||
|
||||
|
||||
class TestCreateAndPushTag:
|
||||
|
||||
Reference in New Issue
Block a user