Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bb1813715 | ||
|
|
f7f53941a1 | ||
|
|
5edfdaa7aa | ||
|
|
893da8ba34 | ||
|
|
64a58874b6 | ||
|
|
c1c2041ca4 | ||
|
|
49ff8870b1 | ||
|
|
4c1ecbf4fa | ||
|
|
93a1cb9945 | ||
|
|
507bc86b92 | ||
|
|
81dc30ecff | ||
|
|
3c421dd1ad | ||
|
|
11ce99756c | ||
|
|
6149167ba2 | ||
|
|
014ab0b63f | ||
|
|
2a3ee1ec96 | ||
|
|
5d4968eb21 | ||
|
|
33cfbb0f41 | ||
|
|
598238e4d6 | ||
|
|
925b99b7db |
@@ -108,7 +108,8 @@ jobs:
|
|||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "build-images/build-and-push" \
|
--workflow "build-images/build-and-push" \
|
||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}" \
|
||||||
|
--auto-login
|
||||||
|
|
||||||
cleanup:
|
cleanup:
|
||||||
needs: [build-and-push]
|
needs: [build-and-push]
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ jobs:
|
|||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "post-merge/release" \
|
--workflow "post-merge/release" \
|
||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}" \
|
||||||
|
--auto-login
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: [release]
|
needs: [release]
|
||||||
@@ -160,7 +161,8 @@ jobs:
|
|||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "post-merge/publish" \
|
--workflow "post-merge/publish" \
|
||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}" \
|
||||||
|
--auto-login
|
||||||
|
|
||||||
sync-wiki:
|
sync-wiki:
|
||||||
needs: [detect-type]
|
needs: [detect-type]
|
||||||
@@ -195,7 +197,8 @@ jobs:
|
|||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "post-merge/sync-wiki" \
|
--workflow "post-merge/sync-wiki" \
|
||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}" \
|
||||||
|
--auto-login
|
||||||
|
|
||||||
badges:
|
badges:
|
||||||
needs: [detect-type]
|
needs: [detect-type]
|
||||||
@@ -235,7 +238,8 @@ jobs:
|
|||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "post-merge/badges" \
|
--workflow "post-merge/badges" \
|
||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}" \
|
||||||
|
--auto-login
|
||||||
|
|
||||||
vikunja:
|
vikunja:
|
||||||
needs: [detect-type]
|
needs: [detect-type]
|
||||||
@@ -271,7 +275,8 @@ jobs:
|
|||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "post-merge/vikunja" \
|
--workflow "post-merge/vikunja" \
|
||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}" \
|
||||||
|
--auto-login
|
||||||
|
|
||||||
configure-repo:
|
configure-repo:
|
||||||
needs: [detect-type]
|
needs: [detect-type]
|
||||||
@@ -304,4 +309,5 @@ jobs:
|
|||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "post-merge/configure-repo" \
|
--workflow "post-merge/configure-repo" \
|
||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}" \
|
||||||
|
--auto-login
|
||||||
|
|||||||
@@ -62,12 +62,13 @@ src/devx/
|
|||||||
│ ├── classify_changes.py # User-facing vs workflow-only change detection
|
│ ├── classify_changes.py # User-facing vs workflow-only change detection
|
||||||
│ ├── detect_release_commit.py # Detect release commits on master
|
│ ├── detect_release_commit.py # Detect release commits on master
|
||||||
│ ├── validate_commit_msg.py # Conventional commit validation
|
│ ├── validate_commit_msg.py # Conventional commit validation
|
||||||
│ ├── pr_review.py # Automated PR review
|
│ ├── pr_review.py # Automated PR review + manual reviews (--event, --body, --checklist-confirmed)
|
||||||
│ ├── post_merge.py # Vikunja task updates after merge
|
│ ├── post_merge.py # Vikunja task updates after merge
|
||||||
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
||||||
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
|
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
|
||||||
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
|
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
|
||||||
│ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling)
|
│ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling)
|
||||||
|
│ ├── distribute_items.py # Distribute generic items (VMs, hosts) across parallel runners (LPT)
|
||||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||||
│ ├── check_translations.py # Translation completeness check
|
│ ├── check_translations.py # Translation completeness check
|
||||||
│ └── doc_coverage.py # Documentation coverage check
|
│ └── doc_coverage.py # Documentation coverage check
|
||||||
@@ -83,7 +84,12 @@ src/devx/
|
|||||||
│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules)
|
│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules)
|
||||||
│ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns)
|
│ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns)
|
||||||
│ ├── configure_repo.py # Branch protection and label setup
|
│ ├── 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)
|
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
@@ -398,6 +404,10 @@ projects.
|
|||||||
| `devx-create-pr` | Create a PR with auto-derived title |
|
| `devx-create-pr` | Create a PR with auto-derived title |
|
||||||
| `devx-push` | Push current branch to origin |
|
| `devx-push` | Push current branch to origin |
|
||||||
| `devx-push-with-pr` | Push and create PR in one step |
|
| `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-pr-review` | Post a review on a PR (`PR=`, `EVENT=`, `BODY=`, `CHECKLIST=`) |
|
||||||
| `devx-check-config` | Validate devx configuration |
|
| `devx-check-config` | Validate devx configuration |
|
||||||
| `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry |
|
| `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry |
|
||||||
| `devx-env` | Create .env from .env.example |
|
| `devx-env` | Create .env from .env.example |
|
||||||
|
|||||||
@@ -2,6 +2,43 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.26.0] - 2026-06-28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add distribute_items CI tool for parallel VM deployment
|
||||||
|
|
||||||
|
## [0.25.0] - 2026-06-28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add manual review support to pr_review (--event, --body, --checklist-confirmed)
|
||||||
|
|
||||||
|
## [0.24.1] - 2026-06-28
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Add find_task_by_identifier, config fallbacks for tools
|
||||||
|
|
||||||
|
## [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
|
## [0.23.2] - 2026-06-27
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](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/src/branch/master/LICENSE)
|
[](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/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/wiki)
|
||||||
[](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/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
|
|||||||
+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/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](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/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/wiki)
|
||||||
[](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/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -139,8 +139,12 @@ infrastructure_overrides = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# User-facing overrides — safety override for broad infrastructure patterns
|
# User-facing overrides — safety override for broad infrastructure patterns
|
||||||
# (empty — add when an infrastructure pattern is too broad)
|
# devx workflow files (.gitea/**) are reference implementations that
|
||||||
user_facing_overrides = []
|
# 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
|
# Tag patterns — additional categories for CI conditional execution
|
||||||
# Orthogonal to release impact (user-facing vs infrastructure)
|
# Orthogonal to release impact (user-facing vs infrastructure)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.23.2"
|
__version__ = "0.26.0"
|
||||||
|
|||||||
@@ -223,6 +223,20 @@ class GiteaClient:
|
|||||||
r = self._request("GET", f"/pulls/{pr_number}/files")
|
r = self._request("GET", f"/pulls/{pr_number}/files")
|
||||||
return r.json()
|
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]]:
|
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||||
"""Fetch the commits included in a pull request."""
|
"""Fetch the commits included in a pull request."""
|
||||||
r = self._request("GET", f"/pulls/{pr_number}/commits")
|
r = self._request("GET", f"/pulls/{pr_number}/commits")
|
||||||
@@ -301,6 +315,31 @@ class GiteaClient:
|
|||||||
return existing
|
return existing
|
||||||
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
|
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:
|
class VikunjaClient:
|
||||||
"""Low-level Vikunja REST API client with connection pooling."""
|
"""Low-level Vikunja REST API client with connection pooling."""
|
||||||
@@ -368,6 +407,25 @@ class VikunjaClient:
|
|||||||
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
|
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
def find_task_by_identifier(self, project_id: int, identifier: str, per_page: int = 50) -> dict[str, Any] | None:
|
||||||
|
"""Find a task by its identifier (e.g. ``DEVX-42``) in a project.
|
||||||
|
|
||||||
|
Paginates through all tasks in the project. Returns the task dict
|
||||||
|
or None if not found.
|
||||||
|
"""
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
tasks = self.list_project_tasks(project_id, page=page, per_page=per_page)
|
||||||
|
if not tasks:
|
||||||
|
break
|
||||||
|
for t in tasks:
|
||||||
|
if t.get("identifier") == identifier:
|
||||||
|
return t
|
||||||
|
if len(tasks) < per_page:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return None
|
||||||
|
|
||||||
def create_task(self, project_id: int, title: str, description: str = "") -> dict[str, Any]:
|
def create_task(self, project_id: int, title: str, description: str = "") -> dict[str, Any]:
|
||||||
"""Create a task in a project and return the created task dict.
|
"""Create a task in a project and return the created task dict.
|
||||||
|
|
||||||
|
|||||||
@@ -174,15 +174,25 @@ def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
|
|||||||
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
||||||
"""Extract the conventional commit message from PR commits.
|
"""Extract the conventional commit message from PR commits.
|
||||||
|
|
||||||
Iterates commits in reverse order (newest first) to find the first
|
Picks the highest-priority conventional commit message from the PR.
|
||||||
message matching the conventional commit format. Falls back to the
|
Priority: feat > fix > refactor > docs > chore > other.
|
||||||
newest commit message if none match.
|
Falls back to the newest commit message if none match.
|
||||||
"""
|
"""
|
||||||
|
priority = {"feat": 5, "fix": 4, "refactor": 3, "docs": 2, "chore": 1, "ci": 1, "style": 1, "test": 1}
|
||||||
|
best_msg = ""
|
||||||
|
best_score = 0
|
||||||
for commit in reversed(commits):
|
for commit in reversed(commits):
|
||||||
commit_info = commit.get("commit", {})
|
commit_info = commit.get("commit", {})
|
||||||
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
||||||
if CONVENTIONAL_RE.match(message):
|
m = CONVENTIONAL_RE.match(message)
|
||||||
return message
|
if m:
|
||||||
|
prefix = m.group(1).split("(")[0].strip() # e.g. "feat" from "feat(scope)"
|
||||||
|
score = priority.get(prefix, 0)
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_msg = message
|
||||||
|
if best_msg:
|
||||||
|
return best_msg
|
||||||
# Fallback: use the newest commit's first line
|
# Fallback: use the newest commit's first line
|
||||||
if commits:
|
if commits:
|
||||||
commit_info = commits[-1].get("commit", {})
|
commit_info = commits[-1].get("commit", {})
|
||||||
|
|||||||
@@ -393,14 +393,15 @@ class ChangeClassifier:
|
|||||||
tags = self._compute_tags(file_path)
|
tags = self._compute_tags(file_path)
|
||||||
|
|
||||||
# 1. User-facing overrides (highest priority — safety)
|
# 1. User-facing overrides (highest priority — safety)
|
||||||
if file_path in self._user_overrides:
|
for pattern in self._user_overrides:
|
||||||
return FileClassification(
|
if _matches_glob(file_path, pattern):
|
||||||
path=file_path,
|
return FileClassification(
|
||||||
is_user_facing=True,
|
path=file_path,
|
||||||
reason="User-facing override (safety override)",
|
is_user_facing=True,
|
||||||
matched_rule="user_facing_overrides",
|
reason=f"User-facing override (matches '{pattern}')",
|
||||||
tags=tags,
|
matched_rule="user_facing_overrides",
|
||||||
)
|
tags=tags,
|
||||||
|
)
|
||||||
|
|
||||||
# 2. Infrastructure overrides
|
# 2. Infrastructure overrides
|
||||||
if file_path in self._infra_overrides:
|
if file_path in self._infra_overrides:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import os
|
|||||||
import click
|
import click
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from devx.config import GITEA_API_URL
|
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||||
|
|
||||||
DEFAULT_MAX_RUNNERS = 3
|
DEFAULT_MAX_RUNNERS = 3
|
||||||
|
|
||||||
@@ -151,9 +151,9 @@ def main(
|
|||||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||||
|
|
||||||
if owner is None:
|
if owner is None:
|
||||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||||
if repo is None:
|
if repo is None:
|
||||||
repo = os.environ.get("DEVX_REPO_NAME", "devx")
|
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
|
||||||
|
|
||||||
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
||||||
indices = generate_indices(count)
|
indices = generate_indices(count)
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Distribute a list of items across N parallel runners using LPT scheduling.
|
||||||
|
|
||||||
|
Generic item distribution for CI matrix jobs. Items are read from a JSON
|
||||||
|
array on stdin (or from a file via --items-file), sorted for deterministic
|
||||||
|
ordering, then assigned to *max_runners* groups using LPT (Longest
|
||||||
|
Processing Time first) scheduling.
|
||||||
|
|
||||||
|
Each item is a string (e.g. an Ansible ``--limit`` pattern like
|
||||||
|
``observability`` or ``infra-314-vm``). Optionally, items can be objects
|
||||||
|
with ``{"id": "...", "weight": N}`` to provide explicit weights.
|
||||||
|
|
||||||
|
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` as
|
||||||
|
``ASSIGNED_ITEMS`` (space-delimited) for use by subsequent steps.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
echo '["observability", "infra-314-vm"]' | \\
|
||||||
|
python3 -m devx.ci.distribute_items \\
|
||||||
|
--runner-index 1 --max-runners 3 \\
|
||||||
|
--github-env --skip-if-excess
|
||||||
|
|
||||||
|
# With weights:
|
||||||
|
echo '[{"id": "observability", "weight": 5}, {"id": "customer-1", "weight": 3}]' | \\
|
||||||
|
python3 -m devx.ci.distribute_items \\
|
||||||
|
--runner-index 1 --max-runners 3 --github-env
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
DEFAULT_MAX_RUNNERS = 3
|
||||||
|
DEFAULT_WEIGHT = 1
|
||||||
|
|
||||||
|
|
||||||
|
def parse_items(raw: str) -> list[str]:
|
||||||
|
"""Parse a JSON array into a list of item identifier strings.
|
||||||
|
|
||||||
|
Accepts both plain string arrays (``["a", "b"]``) and object arrays
|
||||||
|
(``[{"id": "a", "weight": 2}]``). Returns just the identifier strings.
|
||||||
|
"""
|
||||||
|
data = json.loads(raw)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise click.ClickException(_("Items input must be a JSON array, got {type}", type=type(data).__name__))
|
||||||
|
items: list[str] = []
|
||||||
|
for entry in data:
|
||||||
|
if isinstance(entry, str):
|
||||||
|
items.append(entry)
|
||||||
|
elif isinstance(entry, dict) and "id" in entry:
|
||||||
|
items.append(str(entry["id"]))
|
||||||
|
else:
|
||||||
|
raise click.ClickException(
|
||||||
|
_("Each item must be a string or an object with 'id', got {type}", type=type(entry).__name__)
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def parse_weighted_items(raw: str) -> tuple[list[str], list[int]]:
|
||||||
|
"""Parse a JSON array into (items, weights) lists.
|
||||||
|
|
||||||
|
For plain string arrays, all items get ``DEFAULT_WEIGHT``.
|
||||||
|
For object arrays, the ``weight`` field is used (default: ``DEFAULT_WEIGHT``).
|
||||||
|
"""
|
||||||
|
data = json.loads(raw)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise click.ClickException(_("Items input must be a JSON array, got {type}", type=type(data).__name__))
|
||||||
|
items: list[str] = []
|
||||||
|
weights: list[int] = []
|
||||||
|
for entry in data:
|
||||||
|
if isinstance(entry, str):
|
||||||
|
items.append(entry)
|
||||||
|
weights.append(DEFAULT_WEIGHT)
|
||||||
|
elif isinstance(entry, dict) and "id" in entry:
|
||||||
|
items.append(str(entry["id"]))
|
||||||
|
weights.append(int(entry.get("weight", DEFAULT_WEIGHT)))
|
||||||
|
else:
|
||||||
|
raise click.ClickException(
|
||||||
|
_("Each item must be a string or an object with 'id', got {type}", type=type(entry).__name__)
|
||||||
|
)
|
||||||
|
return items, weights
|
||||||
|
|
||||||
|
|
||||||
|
def distribute(items: list[str], weights: list[int], max_runners: int) -> list[list[str]]:
|
||||||
|
"""Split *items* into *max_runners* balanced groups using LPT scheduling.
|
||||||
|
|
||||||
|
Items are sorted by weight (descending), then assigned to the runner
|
||||||
|
with the least total weight.
|
||||||
|
"""
|
||||||
|
groups: list[list[str]] = [[] for _ in range(max_runners)]
|
||||||
|
loads = [0] * max_runners
|
||||||
|
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
|
||||||
|
for orig_idx, item in indexed:
|
||||||
|
min_runner = min(range(max_runners), key=lambda r: loads[r])
|
||||||
|
groups[min_runner].append(item)
|
||||||
|
loads[min_runner] += weights[orig_idx]
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def items_for_runner(items: list[str], weights: list[int], runner_index: int, max_runners: int) -> list[str]:
|
||||||
|
"""Return the subset of items assigned to *runner_index* (0-based)."""
|
||||||
|
groups = distribute(items, weights, max_runners)
|
||||||
|
if runner_index < 0 or runner_index >= len(groups):
|
||||||
|
raise click.ClickException(
|
||||||
|
_("Runner index {index} out of range (0..{max})", index=runner_index, max=max_runners - 1)
|
||||||
|
)
|
||||||
|
return groups[runner_index]
|
||||||
|
|
||||||
|
|
||||||
|
def _write_github_env(key: str, value: str) -> None:
|
||||||
|
gh_env = os.environ.get("GITHUB_ENV")
|
||||||
|
if not gh_env:
|
||||||
|
raise click.ClickException("GITHUB_ENV environment variable is not set")
|
||||||
|
with open(gh_env, "a") as f: # noqa: PTH123
|
||||||
|
if "\n" in value:
|
||||||
|
delimiter = "EOF"
|
||||||
|
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
|
||||||
|
else:
|
||||||
|
f.write(f"{key}={value}\n")
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--items-file",
|
||||||
|
type=click.Path(exists=True, file_okay=True, path_type=None),
|
||||||
|
default=None,
|
||||||
|
help="Read items from a JSON file instead of stdin.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--runner-index",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="One-based runner index. If omitted, prints all groups.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--max-runners",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_MAX_RUNNERS,
|
||||||
|
show_default=True,
|
||||||
|
help="Total number of parallel runners.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--github-env",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Write ASSIGNED_ITEMS and SKIP to $GITHUB_ENV.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--skip-if-excess",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
|
||||||
|
)
|
||||||
|
def main(
|
||||||
|
items_file: str | None,
|
||||||
|
runner_index: int | None,
|
||||||
|
max_runners: int,
|
||||||
|
github_env: bool,
|
||||||
|
skip_if_excess: bool,
|
||||||
|
) -> None:
|
||||||
|
# Read items from file or stdin
|
||||||
|
if items_file is not None:
|
||||||
|
with open(items_file) as f: # noqa: PTH123
|
||||||
|
raw = f.read()
|
||||||
|
else:
|
||||||
|
raw = sys.stdin.read()
|
||||||
|
|
||||||
|
raw = raw.strip()
|
||||||
|
if not raw:
|
||||||
|
raw = "[]"
|
||||||
|
|
||||||
|
items, weights = parse_weighted_items(raw)
|
||||||
|
|
||||||
|
if runner_index is None:
|
||||||
|
groups = distribute(items, weights, max_runners)
|
||||||
|
for i, group in enumerate(groups):
|
||||||
|
labels = " ".join(group) if group else "(none)"
|
||||||
|
click.echo(f"Runner {i}: {labels}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if skip_if_excess and github_env and runner_index > max_runners:
|
||||||
|
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
||||||
|
_write_github_env("ASSIGNED_ITEMS", "")
|
||||||
|
_write_github_env("SKIP", "true")
|
||||||
|
return
|
||||||
|
|
||||||
|
if runner_index < 1:
|
||||||
|
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
|
||||||
|
|
||||||
|
zero_based = runner_index - 1
|
||||||
|
assigned = items_for_runner(items, weights, zero_based, max_runners)
|
||||||
|
encoded = " ".join(assigned)
|
||||||
|
|
||||||
|
if github_env:
|
||||||
|
_write_github_env("ASSIGNED_ITEMS", encoded)
|
||||||
|
_write_github_env("SKIP", "false")
|
||||||
|
click.echo(f"Assigned {len(assigned)} items to runner {runner_index}: {encoded}")
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(encoded)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
||||||
@@ -36,6 +36,7 @@ import time
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from devx.config import REPO_NAME, REPO_OWNER
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
from devx.molecule.molecule_ci_guard import (
|
from devx.molecule.molecule_ci_guard import (
|
||||||
poll_for_other_failures,
|
poll_for_other_failures,
|
||||||
@@ -53,10 +54,10 @@ def cli(pytest_args: tuple[str, ...]) -> None:
|
|||||||
run_id = int(os.environ.get("RUN_ID", "0"))
|
run_id = int(os.environ.get("RUN_ID", "0"))
|
||||||
job_name = os.environ.get("JOB_NAME", "integration-tests")
|
job_name = os.environ.get("JOB_NAME", "integration-tests")
|
||||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||||
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
|
repository = os.environ.get("GITEA_REPOSITORY", "")
|
||||||
owner, _sep, repo = repository.partition("/")
|
owner, _sep, repo = repository.partition("/")
|
||||||
if not owner or not repo:
|
if not owner or not repo:
|
||||||
owner, repo = "oblachno-oss", "devx"
|
owner, repo = REPO_OWNER, REPO_NAME
|
||||||
|
|
||||||
if not all([gitea_url, token, run_id]):
|
if not all([gitea_url, token, run_id]):
|
||||||
click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||||
|
|||||||
@@ -520,12 +520,102 @@ def post_review(client: GiteaClient, pr_number: str, result: ReviewResult) -> di
|
|||||||
return client.create_review(pr_number, event=event, body=body, comments=comments)
|
return client.create_review(pr_number, event=event, body=body, comments=comments)
|
||||||
|
|
||||||
|
|
||||||
|
def _post_manual_review(
|
||||||
|
client: GiteaClient,
|
||||||
|
pr_number: str,
|
||||||
|
event: str,
|
||||||
|
body: str | None,
|
||||||
|
checklist_confirmed: bool,
|
||||||
|
checklist_categories: str | None,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Post a manual review with validation for APPROVE events."""
|
||||||
|
if not body or len(body) < 50:
|
||||||
|
raise click.ClickException(_("Review body must be at least 50 characters."))
|
||||||
|
|
||||||
|
if event == "APPROVE":
|
||||||
|
if not checklist_confirmed:
|
||||||
|
raise click.ClickException(
|
||||||
|
_("--checklist-confirmed is required for APPROVE events."),
|
||||||
|
)
|
||||||
|
cats = [c.strip() for c in (checklist_categories or "").split(",") if c.strip()]
|
||||||
|
cat_nums: list[int] = []
|
||||||
|
for c in cats:
|
||||||
|
try:
|
||||||
|
cat_nums.append(int(c))
|
||||||
|
except ValueError:
|
||||||
|
raise click.ClickException(
|
||||||
|
_("Invalid checklist category: {cat}. Must be numbers.", cat=c),
|
||||||
|
) from None
|
||||||
|
if len(cat_nums) < 8:
|
||||||
|
raise click.ClickException(
|
||||||
|
_("--checklist-categories must list at least 8 of 13 categories. Got {count}.", count=len(cat_nums)),
|
||||||
|
)
|
||||||
|
|
||||||
|
click.echo(f"Manual review event: {event}")
|
||||||
|
click.echo(f"Body: {body[:80]}...")
|
||||||
|
if checklist_confirmed:
|
||||||
|
click.echo(f"Checklist confirmed: {checklist_categories}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
click.echo("\n[dry-run] Review not posted.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
review = client.create_review(pr_number, event=event, body=body)
|
||||||
|
except APIError as e:
|
||||||
|
if "approve" in e.message.lower() or "422" in str(e.status):
|
||||||
|
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
|
||||||
|
review = client.create_review(pr_number, event="COMMENT", body=body)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
review_id = review.get("id", "?")
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||||
|
review_id=review_id,
|
||||||
|
pr_number=pr_number,
|
||||||
|
event=event,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.argument("pr_number")
|
@click.argument("pr_number")
|
||||||
@click.argument("repo")
|
@click.argument("repo")
|
||||||
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.")
|
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.")
|
||||||
def main(pr_number: str, repo: str, dry_run: bool) -> None:
|
@click.option(
|
||||||
"""Run automated PR review and post results to Gitea."""
|
"--event",
|
||||||
|
type=click.Choice(["APPROVE", "REQUEST_CHANGES", "COMMENT"], case_sensitive=False),
|
||||||
|
default=None,
|
||||||
|
help="Post a manual review with the given event (skips automated checks).",
|
||||||
|
)
|
||||||
|
@click.option("--body", default=None, help="Review body text (required with --event).")
|
||||||
|
@click.option(
|
||||||
|
"--checklist-confirmed",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Attest that REVIEW_CHECKLIST.md categories were checked (required for APPROVE).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--checklist-categories",
|
||||||
|
default=None,
|
||||||
|
help="Comma-separated checklist category numbers (required for APPROVE, min 8 of 13).",
|
||||||
|
)
|
||||||
|
def main(
|
||||||
|
pr_number: str,
|
||||||
|
repo: str,
|
||||||
|
dry_run: bool,
|
||||||
|
event: str | None,
|
||||||
|
body: str | None,
|
||||||
|
checklist_confirmed: bool,
|
||||||
|
checklist_categories: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Run automated PR review and post results to Gitea.
|
||||||
|
|
||||||
|
Without --event: runs automated checks and posts COMMENT/REQUEST_CHANGES.
|
||||||
|
With --event: posts a manual review (skips automated checks).
|
||||||
|
"""
|
||||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||||
if not token:
|
if not token:
|
||||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||||
@@ -533,6 +623,10 @@ def main(pr_number: str, repo: str, dry_run: bool) -> None:
|
|||||||
owner, repo_name = repo.split("/")
|
owner, repo_name = repo.split("/")
|
||||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||||
|
|
||||||
|
if event is not None:
|
||||||
|
_post_manual_review(client, pr_number, event.upper(), body, checklist_confirmed, checklist_categories, dry_run)
|
||||||
|
return
|
||||||
|
|
||||||
result = run_review(client, pr_number)
|
result = run_review(client, pr_number)
|
||||||
|
|
||||||
body = build_review_body(result)
|
body = build_review_body(result)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from pathlib import Path
|
|||||||
import click
|
import click
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||||
|
|
||||||
from devx.config import GITEA_API_URL
|
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||||
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ def _default_gitea_registry_url() -> str:
|
|||||||
base = base[: -len("/api/v1")]
|
base = base[: -len("/api/v1")]
|
||||||
elif base.endswith("/api"):
|
elif base.endswith("/api"):
|
||||||
base = base[: -len("/api")]
|
base = base[: -len("/api")]
|
||||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||||
return f"{base}/api/packages/{owner}/pypi"
|
return f"{base}/api/packages/{owner}/pypi"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import click
|
|||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||||
|
|
||||||
from devx.api_clients import GiteaClient
|
from devx.api_clients import GiteaClient
|
||||||
from devx.config import GITEA_API_URL
|
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||||
from devx.exceptions import APIError
|
from devx.exceptions import APIError
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
@@ -230,8 +230,8 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
|||||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||||
|
|
||||||
if repo is None:
|
if repo is None:
|
||||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||||
repo_name = os.environ.get("DEVX_REPO_NAME", "devx")
|
repo_name = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
|
||||||
else:
|
else:
|
||||||
owner, repo_name = repo.split("/")
|
owner, repo_name = repo.split("/")
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||||
# No default: prevents silent 404s when the wrong owner is used.
|
# No default: prevents silent 404s when the wrong owner is used.
|
||||||
REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "")
|
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 for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||||
TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX")
|
TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX")
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi;
|
|||||||
$(DEVX_BIN)/pip
|
$(DEVX_BIN)/pip
|
||||||
|
|
||||||
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
.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 devx-pr-review
|
||||||
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
|
.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-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
|
||||||
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
|
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
|
||||||
@@ -94,6 +95,45 @@ devx-check-config:
|
|||||||
# Push and create PR in one step
|
# Push and create PR in one step
|
||||||
devx-push-with-pr: devx-push devx-create-pr
|
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)
|
||||||
|
|
||||||
|
# Usage: make devx-pr-review PR=42 EVENT=APPROVE BODY="..." CHECKLIST=1,2,3,4,5,6,7,8,9,10,11,12,13
|
||||||
|
# make devx-pr-review PR=42 EVENT=REQUEST_CHANGES BODY="..."
|
||||||
|
# make devx-pr-review PR=42 (auto review)
|
||||||
|
devx-pr-review:
|
||||||
|
@$(DEVX_PYTHON) -m devx.ci.pr_review \
|
||||||
|
$(PR) $(DEVX_REPO_OWNER)/$(DEVX_REPO_NAME) \
|
||||||
|
$(if $(EVENT),--event $(EVENT)) \
|
||||||
|
$(if $(BODY),--body "$(BODY)") \
|
||||||
|
$(if $(CHECKLIST),--checklist-confirmed --checklist-categories $(CHECKLIST))
|
||||||
|
|
||||||
# ── Environment setup ─────────────────────────────────────────────────────────
|
# ── Environment setup ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Configure Gitea private PyPI registry so pip can find devx and other
|
# Configure Gitea private PyPI registry so pip can find devx and other
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import os
|
|||||||
import click
|
import click
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from devx.config import GITEA_API_URL
|
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||||
|
|
||||||
DEFAULT_MAX_RUNNERS = 3
|
DEFAULT_MAX_RUNNERS = 3
|
||||||
|
|
||||||
@@ -145,9 +145,9 @@ def main(
|
|||||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||||
|
|
||||||
if owner is None:
|
if owner is None:
|
||||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||||
if repo is None:
|
if repo is None:
|
||||||
repo = os.environ.get("DEVX_REPO_NAME", "devx")
|
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
|
||||||
|
|
||||||
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
||||||
indices = generate_indices(count)
|
indices = generate_indices(count)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from pathlib import Path
|
|||||||
import click
|
import click
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from devx.config import REPO_NAME, REPO_OWNER
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
POLL_INTERVAL = 10
|
POLL_INTERVAL = 10
|
||||||
@@ -167,10 +168,10 @@ def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
|
|||||||
run_id = int(os.environ.get("RUN_ID", "0"))
|
run_id = int(os.environ.get("RUN_ID", "0"))
|
||||||
job_name = os.environ.get("JOB_NAME", "molecule-tests")
|
job_name = os.environ.get("JOB_NAME", "molecule-tests")
|
||||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||||
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
|
repository = os.environ.get("GITEA_REPOSITORY", "")
|
||||||
owner, _sep, repo = repository.partition("/")
|
owner, _sep, repo = repository.partition("/")
|
||||||
if not owner or not repo:
|
if not owner or not repo:
|
||||||
owner, repo = "oblachno-oss", "devx"
|
owner, repo = REPO_OWNER, REPO_NAME
|
||||||
|
|
||||||
if not all([gitea_url, token, run_id]):
|
if not all([gitea_url, token, run_id]):
|
||||||
click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ Usage::
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
import tomllib
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -27,8 +26,7 @@ def cli() -> None:
|
|||||||
"""Validate devx configuration in pyproject.toml."""
|
"""Validate devx configuration in pyproject.toml."""
|
||||||
path = Path("pyproject.toml")
|
path = Path("pyproject.toml")
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
click.echo(_("pyproject.toml not found in current directory."))
|
raise click.ClickException(_("pyproject.toml not found in current directory."))
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
with open(path, "rb") as f: # noqa: PTH123
|
with open(path, "rb") as f: # noqa: PTH123
|
||||||
data = tomllib.load(f)
|
data = tomllib.load(f)
|
||||||
@@ -65,7 +63,7 @@ def cli() -> None:
|
|||||||
if errors:
|
if errors:
|
||||||
for err in errors:
|
for err in errors:
|
||||||
click.echo(f"ERROR: {err}", err=True)
|
click.echo(f"ERROR: {err}", err=True)
|
||||||
sys.exit(1)
|
raise click.ClickException(_("Configuration validation failed."))
|
||||||
|
|
||||||
click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent."))
|
click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent."))
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ Usage::
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
from devx.config import _load_pyproject_devx
|
from devx.config import _load_pyproject_devx
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
@@ -203,49 +203,37 @@ def _find_missing_tests(
|
|||||||
return missing
|
return missing
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
@click.command()
|
||||||
parser = argparse.ArgumentParser(
|
@click.option("--staged-only", is_flag=True, help=_("Only check staged files (for pre-commit)"))
|
||||||
description=_("Check that changed files have corresponding tests"),
|
@click.option("--warn-only", is_flag=True, help=_("Print warnings but always exit 0"))
|
||||||
)
|
def cli(staged_only: bool, warn_only: bool) -> None:
|
||||||
parser.add_argument(
|
"""Check that changed files have corresponding tests."""
|
||||||
"--staged-only",
|
|
||||||
action="store_true",
|
|
||||||
help=_("Only check staged files (for pre-commit)"),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--warn-only",
|
|
||||||
action="store_true",
|
|
||||||
help=_("Print warnings but always exit 0"),
|
|
||||||
)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
repo_root = Path.cwd()
|
repo_root = Path.cwd()
|
||||||
rules, skip_patterns, test_indicators, skip_extensions = _load_rules()
|
rules, skip_patterns, test_indicators, skip_extensions = _load_rules()
|
||||||
|
|
||||||
files = _changed_files(args.staged_only, repo_root)
|
files = _changed_files(staged_only, repo_root)
|
||||||
if not files:
|
if not files:
|
||||||
print(_("[check_test_coverage] No changed files to check."))
|
click.echo(_("[check_test_coverage] No changed files to check."))
|
||||||
return 0
|
return
|
||||||
|
|
||||||
missing = _find_missing_tests(files, repo_root, rules, skip_patterns, test_indicators, skip_extensions)
|
missing = _find_missing_tests(files, repo_root, rules, skip_patterns, test_indicators, skip_extensions)
|
||||||
if not missing:
|
if not missing:
|
||||||
print(f"[check_test_coverage] All {len(files)} changed file(s) have tests.")
|
click.echo(f"[check_test_coverage] All {len(files)} changed file(s) have tests.")
|
||||||
return 0
|
return
|
||||||
|
|
||||||
print("[check_test_coverage] FAILED: missing tests for changed files:\n", file=sys.stderr)
|
click.echo("[check_test_coverage] FAILED: missing tests for changed files:\n", err=True)
|
||||||
for f, reason in missing.items():
|
for f, reason in missing.items():
|
||||||
print(f" {f}", file=sys.stderr)
|
click.echo(f" {f}", err=True)
|
||||||
print(f" -> {reason}", file=sys.stderr)
|
click.echo(f" -> {reason}", err=True)
|
||||||
|
|
||||||
print(
|
click.echo(
|
||||||
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
_("\n[check_test_coverage] Fix: add the missing test file(s) before committing."),
|
||||||
file=sys.stderr,
|
err=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if args.warn_only:
|
if not warn_only:
|
||||||
return 0
|
raise click.ClickException(_("Missing tests for changed files."))
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
sys.exit(main())
|
cli() # pragma: no cover
|
||||||
|
|||||||
@@ -34,12 +34,13 @@ Authentication uses ``CI_GITEA_TOKEN`` environment variable.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from devx.config import GITEA_API_URL
|
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
|
|
||||||
@@ -85,15 +86,37 @@ def delete_package_version(
|
|||||||
token: str,
|
token: str,
|
||||||
*,
|
*,
|
||||||
timeout: int = 30,
|
timeout: int = 30,
|
||||||
|
package_type: str = "container",
|
||||||
|
max_retries: int = 3,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Delete a specific version of a container package.
|
"""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.
|
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}"}
|
headers = {"Authorization": f"token {token}"}
|
||||||
resp = requests.delete(url, headers=headers, timeout=timeout)
|
for attempt in range(max_retries):
|
||||||
return resp.status_code in (204, 200)
|
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(
|
def sort_versions_by_date(
|
||||||
@@ -128,8 +151,8 @@ def select_for_deletion(
|
|||||||
@click.command()
|
@click.command()
|
||||||
@click.option(
|
@click.option(
|
||||||
"--owner",
|
"--owner",
|
||||||
required=True,
|
default=None,
|
||||||
help="Package owner (user or org).",
|
help="Package owner (user or org, default: from [tool.devx] repo_owner).",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--name",
|
"--name",
|
||||||
@@ -157,7 +180,7 @@ def select_for_deletion(
|
|||||||
help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).",
|
help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).",
|
||||||
)
|
)
|
||||||
def main(
|
def main(
|
||||||
owner: str,
|
owner: str | None,
|
||||||
names: tuple[str, ...],
|
names: tuple[str, ...],
|
||||||
keep: int,
|
keep: int,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
@@ -167,10 +190,15 @@ def main(
|
|||||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||||
if not token:
|
if not token:
|
||||||
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required"))
|
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required"))
|
||||||
|
if not owner:
|
||||||
|
owner = REPO_OWNER
|
||||||
|
if not owner:
|
||||||
|
raise click.ClickException(_("Package owner not specified. Use --owner or set [tool.devx] repo_owner."))
|
||||||
base_url = api_url or GITEA_API_URL
|
base_url = api_url or GITEA_API_URL
|
||||||
|
|
||||||
total_deleted = 0
|
total_deleted = 0
|
||||||
total_kept = 0
|
total_kept = 0
|
||||||
|
total_failed = 0
|
||||||
for name in names:
|
for name in names:
|
||||||
click.echo(f"\n{'=' * 60}")
|
click.echo(f"\n{'=' * 60}")
|
||||||
click.echo(f"Package: {owner}/{name}")
|
click.echo(f"Package: {owner}/{name}")
|
||||||
@@ -182,6 +210,7 @@ def main(
|
|||||||
_("Failed to list versions for {name}: {error}", name=name, error=exc),
|
_("Failed to list versions for {name}: {error}", name=name, error=exc),
|
||||||
err=True,
|
err=True,
|
||||||
)
|
)
|
||||||
|
total_failed += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not versions:
|
if not versions:
|
||||||
@@ -203,6 +232,7 @@ def main(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
|
failed_count = 0
|
||||||
for v in to_delete:
|
for v in to_delete:
|
||||||
version = str(v.get("version", ""))
|
version = str(v.get("version", ""))
|
||||||
if delete_package_version(base_url, owner, name, version, token):
|
if delete_package_version(base_url, owner, name, version, token):
|
||||||
@@ -210,11 +240,15 @@ def main(
|
|||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
else:
|
else:
|
||||||
click.echo(f" FAILED to delete: {version}", err=True)
|
click.echo(f" FAILED to delete: {version}", err=True)
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
total_deleted += deleted_count
|
total_deleted += deleted_count
|
||||||
total_kept += kept_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
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from typing import Any, cast
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from devx.api_clients import GiteaClient
|
from devx.api_clients import GiteaClient
|
||||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||||
from devx.exceptions import APIError
|
from devx.exceptions import APIError
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ def main(repo: str | None, owner: str | None, branch: str, api_url: str | None)
|
|||||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||||
|
|
||||||
if repo is None:
|
if repo is None:
|
||||||
repo = os.environ.get("DEVX_REPO_NAME", "")
|
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
|
||||||
if not repo:
|
if not repo:
|
||||||
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
|
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
|
||||||
|
|
||||||
|
|||||||
+15
-20
@@ -34,6 +34,7 @@ from devx.api_clients import GiteaClient, VikunjaClient
|
|||||||
from devx.config import (
|
from devx.config import (
|
||||||
DEFAULT_PER_PAGE,
|
DEFAULT_PER_PAGE,
|
||||||
GITEA_API_URL,
|
GITEA_API_URL,
|
||||||
|
REPO_NAME,
|
||||||
REPO_OWNER,
|
REPO_OWNER,
|
||||||
TASK_ID_RE,
|
TASK_ID_RE,
|
||||||
TASK_PREFIX,
|
TASK_PREFIX,
|
||||||
@@ -46,15 +47,17 @@ load_dotenv()
|
|||||||
|
|
||||||
|
|
||||||
def get_repo_name() -> str:
|
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", "")
|
name = os.environ.get("DEVX_REPO_NAME", "")
|
||||||
if name:
|
if name:
|
||||||
return name
|
return name
|
||||||
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
|
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
if github_repo and "/" in github_repo:
|
if github_repo and "/" in github_repo:
|
||||||
return github_repo.split("/", 1)[1]
|
return github_repo.split("/", 1)[1]
|
||||||
|
if REPO_NAME:
|
||||||
|
return REPO_NAME
|
||||||
raise click.ClickException(
|
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."),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -73,24 +76,16 @@ def get_vikunja_task_title(task_id: str) -> str:
|
|||||||
if not token:
|
if not token:
|
||||||
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title."))
|
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title."))
|
||||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||||
page = 1
|
task = client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE)
|
||||||
while True:
|
if not task:
|
||||||
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
raise click.ClickException(
|
||||||
if not tasks:
|
_(
|
||||||
break
|
"Could not find Vikunja task {task_id} in project {project_id}.",
|
||||||
matches = [t for t in tasks if t.get("identifier") == task_id]
|
task_id=task_id,
|
||||||
if matches:
|
project_id=VIKUNJA_PROJECT_ID,
|
||||||
return str(matches[0].get("title", ""))
|
),
|
||||||
if len(tasks) < DEFAULT_PER_PAGE:
|
)
|
||||||
break
|
return str(task.get("title", ""))
|
||||||
page += 1
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Could not find Vikunja task {task_id} in project {project_id}.",
|
|
||||||
task_id=task_id,
|
|
||||||
project_id=VIKUNJA_PROJECT_ID,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def find_existing_pr(client: GiteaClient, branch: str) -> dict | None:
|
def find_existing_pr(client: GiteaClient, branch: str) -> dict | None:
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ def _generate(prefix: str) -> str:
|
|||||||
@click.option(
|
@click.option(
|
||||||
"--prefix",
|
"--prefix",
|
||||||
default=TASK_PREFIX,
|
default=TASK_PREFIX,
|
||||||
help="Task ID prefix for commit preprocessor (default: DEVX_TASK_PREFIX env var or 'DEVX').",
|
help="Task ID prefix for commit preprocessor (default: from [tool.devx] task_prefix in pyproject.toml).",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--output",
|
"--output",
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ def _download_binary() -> None:
|
|||||||
TARGET_PATH.chmod(0o755)
|
TARGET_PATH.chmod(0o755)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
@click.command()
|
||||||
|
def cli() -> None:
|
||||||
"""Install checkmake if not already present."""
|
"""Install checkmake if not already present."""
|
||||||
if shutil.which("checkmake") is not None:
|
if shutil.which("checkmake") is not None:
|
||||||
return
|
return
|
||||||
@@ -66,4 +67,4 @@ def main() -> None:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
main() # pragma: no cover
|
cli() # pragma: no cover
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -59,17 +59,7 @@ def task_exists(task_id: str) -> bool:
|
|||||||
if not token:
|
if not token:
|
||||||
return False
|
return False
|
||||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||||
page = 1
|
return client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) is not None
|
||||||
while True:
|
|
||||||
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
|
||||||
if not tasks:
|
|
||||||
break
|
|
||||||
if any(t.get("identifier") == task_id for t in tasks):
|
|
||||||
return True
|
|
||||||
if len(tasks) < DEFAULT_PER_PAGE:
|
|
||||||
break
|
|
||||||
page += 1
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def validate(branch: str) -> None:
|
def validate(branch: str) -> None:
|
||||||
|
|||||||
+256
-16
@@ -463,14 +463,6 @@
|
|||||||
"ru": "Bumping version: {current} -> v{new_version}",
|
"ru": "Bumping version: {current} -> v{new_version}",
|
||||||
"zh": "Bumping version: {current} -> v{new_version}"
|
"zh": "Bumping version: {current} -> v{new_version}"
|
||||||
},
|
},
|
||||||
"Check that changed files have corresponding tests": {
|
|
||||||
"bg": "Check that changed files have corresponding tests",
|
|
||||||
"de": "Check that changed files have corresponding tests",
|
|
||||||
"en": "Check that changed files have corresponding tests",
|
|
||||||
"pl": "Check that changed files have corresponding tests",
|
|
||||||
"ru": "Check that changed files have corresponding tests",
|
|
||||||
"zh": "Check that changed files have corresponding tests"
|
|
||||||
},
|
|
||||||
"Checking CLI command documentation...": {
|
"Checking CLI command documentation...": {
|
||||||
"bg": "Checking CLI command documentation...",
|
"bg": "Checking CLI command documentation...",
|
||||||
"de": "Checking CLI command documentation...",
|
"de": "Checking CLI command documentation...",
|
||||||
@@ -647,6 +639,14 @@
|
|||||||
"ru": "Dockerfile not found: {path}",
|
"ru": "Dockerfile not found: {path}",
|
||||||
"zh": "Dockerfile not found: {path}"
|
"zh": "Dockerfile not found: {path}"
|
||||||
},
|
},
|
||||||
|
"Each item must be a string or an object with 'id', got {type}": {
|
||||||
|
"bg": "Всеки елемент трябва да е низ или обект с 'id', получено {type}",
|
||||||
|
"de": "Jedes Element muss ein String oder ein Objekt mit 'id' sein, erhalten {type}",
|
||||||
|
"en": "Each item must be a string or an object with 'id', got {type}",
|
||||||
|
"pl": "Każdy element musi być ciągiem lub obiektem z 'id', otrzymano {type}",
|
||||||
|
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
|
||||||
|
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
|
||||||
|
},
|
||||||
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
|
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
|
||||||
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||||
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||||
@@ -1391,6 +1391,14 @@
|
|||||||
"ru": "CI_GITEA_TOKEN environment variable required",
|
"ru": "CI_GITEA_TOKEN environment variable required",
|
||||||
"zh": "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.": {
|
"CI_GITEA_TOKEN is not set. Required to create a PR.": {
|
||||||
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
|
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
|
||||||
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
|
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
|
||||||
@@ -1463,14 +1471,6 @@
|
|||||||
"ru": "Repository in owner/name format",
|
"ru": "Repository in owner/name format",
|
||||||
"zh": "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.": {
|
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
|
||||||
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или 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.",
|
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
|
||||||
@@ -2030,5 +2030,245 @@
|
|||||||
"pl": "Configuring tea login '{name}' for {url}...",
|
"pl": "Configuring tea login '{name}' for {url}...",
|
||||||
"ru": "Configuring tea login '{name}' for {url}...",
|
"ru": "Configuring tea login '{name}' for {url}...",
|
||||||
"zh": "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)..."
|
||||||
|
},
|
||||||
|
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.": {
|
||||||
|
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||||
|
"bg": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||||
|
"de": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||||
|
"pl": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||||
|
"ru": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||||
|
"zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing."
|
||||||
|
},
|
||||||
|
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": {
|
||||||
|
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||||
|
"bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||||
|
"de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||||
|
"pl": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||||
|
"ru": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||||
|
"zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner."
|
||||||
|
},
|
||||||
|
"Configuration validation failed.": {
|
||||||
|
"en": "Configuration validation failed.",
|
||||||
|
"bg": "Configuration validation failed.",
|
||||||
|
"de": "Configuration validation failed.",
|
||||||
|
"pl": "Configuration validation failed.",
|
||||||
|
"ru": "Configuration validation failed.",
|
||||||
|
"zh": "Configuration validation failed."
|
||||||
|
},
|
||||||
|
"Missing tests for changed files.": {
|
||||||
|
"en": "Missing tests for changed files.",
|
||||||
|
"bg": "Missing tests for changed files.",
|
||||||
|
"de": "Missing tests for changed files.",
|
||||||
|
"pl": "Missing tests for changed files.",
|
||||||
|
"ru": "Missing tests for changed files.",
|
||||||
|
"zh": "Missing tests for changed files."
|
||||||
|
},
|
||||||
|
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.": {
|
||||||
|
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||||
|
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||||
|
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||||
|
"pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||||
|
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||||
|
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
|
||||||
|
},
|
||||||
|
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": {
|
||||||
|
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||||
|
"bg": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||||
|
"de": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||||
|
"pl": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||||
|
"ru": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||||
|
"zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
|
||||||
|
},
|
||||||
|
"--checklist-confirmed is required for APPROVE events.": {
|
||||||
|
"en": "--checklist-confirmed is required for APPROVE events.",
|
||||||
|
"bg": "--checklist-confirmed is required for APPROVE events.",
|
||||||
|
"de": "--checklist-confirmed is required for APPROVE events.",
|
||||||
|
"pl": "--checklist-confirmed is required for APPROVE events.",
|
||||||
|
"ru": "--checklist-confirmed is required for APPROVE events.",
|
||||||
|
"zh": "--checklist-confirmed is required for APPROVE events."
|
||||||
|
},
|
||||||
|
"Invalid checklist category: {cat}. Must be numbers.": {
|
||||||
|
"en": "Invalid checklist category: {cat}. Must be numbers.",
|
||||||
|
"bg": "Invalid checklist category: {cat}. Must be numbers.",
|
||||||
|
"de": "Invalid checklist category: {cat}. Must be numbers.",
|
||||||
|
"pl": "Invalid checklist category: {cat}. Must be numbers.",
|
||||||
|
"ru": "Invalid checklist category: {cat}. Must be numbers.",
|
||||||
|
"zh": "Invalid checklist category: {cat}. Must be numbers."
|
||||||
|
},
|
||||||
|
"Items input must be a JSON array, got {type}": {
|
||||||
|
"bg": "Входните данни трябва да са JSON масив, получено {type}",
|
||||||
|
"de": "Eingabe muss ein JSON-Array sein, erhalten {type}",
|
||||||
|
"en": "Items input must be a JSON array, got {type}",
|
||||||
|
"pl": "Dane wejściowe muszą być tablicą JSON, otrzymano {type}",
|
||||||
|
"ru": "Входные данные должны быть JSON-массивом, получено {type}",
|
||||||
|
"zh": "输入必须是 JSON 数组,得到 {type}"
|
||||||
|
},
|
||||||
|
"Review body must be at least 50 characters.": {
|
||||||
|
"en": "Review body must be at least 50 characters.",
|
||||||
|
"bg": "Review body must be at least 50 characters.",
|
||||||
|
"de": "Review body must be at least 50 characters.",
|
||||||
|
"pl": "Review body must be at least 50 characters.",
|
||||||
|
"ru": "Review body must be at least 50 characters.",
|
||||||
|
"zh": "Review body must be at least 50 characters."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -781,6 +781,42 @@ class TestVikunjaClient:
|
|||||||
call_kwargs = client._session.request.call_args.kwargs
|
call_kwargs = client._session.request.call_args.kwargs
|
||||||
assert call_kwargs["json"]["description"] == ""
|
assert call_kwargs["json"]["description"] == ""
|
||||||
|
|
||||||
|
def test_find_task_by_identifier_found(self) -> None:
|
||||||
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
|
client._session.request = MagicMock(
|
||||||
|
return_value=_mock_response([{"identifier": "DEVX-1"}, {"identifier": "DEVX-42", "title": "Found"}])
|
||||||
|
)
|
||||||
|
result = client.find_task_by_identifier(6, "DEVX-42", per_page=50)
|
||||||
|
assert result is not None
|
||||||
|
assert result["title"] == "Found"
|
||||||
|
|
||||||
|
def test_find_task_by_identifier_not_found(self) -> None:
|
||||||
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
|
client._session.request = MagicMock(
|
||||||
|
return_value=_mock_response([{"identifier": "DEVX-1"}, {"identifier": "DEVX-2"}])
|
||||||
|
)
|
||||||
|
result = client.find_task_by_identifier(6, "DEVX-99", per_page=50)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_find_task_by_identifier_empty_project(self) -> None:
|
||||||
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
|
client._session.request = MagicMock(return_value=_mock_response([]))
|
||||||
|
result = client.find_task_by_identifier(6, "DEVX-1", per_page=50)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_find_task_by_identifier_paginates(self) -> None:
|
||||||
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
|
full_page = [{"identifier": f"DEVX-{i}"} for i in range(50)]
|
||||||
|
client._session.request = MagicMock(
|
||||||
|
side_effect=[
|
||||||
|
_mock_response(full_page),
|
||||||
|
_mock_response([{"identifier": "DEVX-50", "title": "Found on page 2"}]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = client.find_task_by_identifier(6, "DEVX-50", per_page=50)
|
||||||
|
assert result is not None
|
||||||
|
assert result["title"] == "Found on page 2"
|
||||||
|
|
||||||
|
|
||||||
class TestIsRetryable:
|
class TestIsRetryable:
|
||||||
def test_connection_error_is_retryable(self) -> None:
|
def test_connection_error_is_retryable(self) -> None:
|
||||||
@@ -797,5 +833,88 @@ class TestIsRetryable:
|
|||||||
err = _mock_http_error(404, "not found")
|
err = _mock_http_error(404, "not found")
|
||||||
assert _is_retryable(err) is False
|
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:
|
def test_generic_exception_is_not_retryable(self) -> None:
|
||||||
assert _is_retryable(ValueError("oops")) is False
|
assert _is_retryable(ValueError("oops")) is False
|
||||||
|
|||||||
@@ -195,6 +195,29 @@ class TestExtractConventionalMsg:
|
|||||||
]
|
]
|
||||||
assert extract_conventional_msg(commits) == "feat: add feature"
|
assert extract_conventional_msg(commits) == "feat: add feature"
|
||||||
|
|
||||||
|
def test_prefers_feat_over_refactor(self) -> None:
|
||||||
|
"""When both feat and refactor commits exist, feat wins."""
|
||||||
|
commits = [
|
||||||
|
{"commit": {"message": "refactor: add find_task_by_identifier"}},
|
||||||
|
{"commit": {"message": "fix: remove hardcoded fallbacks"}},
|
||||||
|
{"commit": {"message": "feat: add manual review support"}},
|
||||||
|
]
|
||||||
|
assert extract_conventional_msg(commits) == "feat: add manual review support"
|
||||||
|
|
||||||
|
def test_prefers_fix_over_docs(self) -> None:
|
||||||
|
commits = [
|
||||||
|
{"commit": {"message": "docs: update README"}},
|
||||||
|
{"commit": {"message": "fix: resolve bug"}},
|
||||||
|
]
|
||||||
|
assert extract_conventional_msg(commits) == "fix: resolve bug"
|
||||||
|
|
||||||
|
def test_scope_in_prefix(self) -> None:
|
||||||
|
commits = [
|
||||||
|
{"commit": {"message": "refactor(ci): cleanup code"}},
|
||||||
|
{"commit": {"message": "feat(api): add endpoint"}},
|
||||||
|
]
|
||||||
|
assert extract_conventional_msg(commits) == "feat(api): add endpoint"
|
||||||
|
|
||||||
|
|
||||||
# -- run_cmd --
|
# -- run_cmd --
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,25 @@ class TestCleanImagesAPI:
|
|||||||
from devx.tools.clean_images import delete_package_version
|
from devx.tools.clean_images import delete_package_version
|
||||||
|
|
||||||
mock_resp = MagicMock(status_code=204)
|
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):
|
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
|
||||||
assert (
|
assert (
|
||||||
delete_package_version(
|
delete_package_version(
|
||||||
@@ -336,7 +355,7 @@ class TestCleanImagesAPI:
|
|||||||
def test_delete_package_version_failure(self) -> None:
|
def test_delete_package_version_failure(self) -> None:
|
||||||
from devx.tools.clean_images import delete_package_version
|
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):
|
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
|
||||||
assert (
|
assert (
|
||||||
delete_package_version(
|
delete_package_version(
|
||||||
@@ -349,6 +368,110 @@ class TestCleanImagesAPI:
|
|||||||
is False
|
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:
|
class TestCLIBuildImage:
|
||||||
def test_single_image_build(self, tmp_path: Path) -> None:
|
def test_single_image_build(self, tmp_path: Path) -> None:
|
||||||
@@ -557,7 +680,7 @@ class TestCLICleanImages:
|
|||||||
clean_main,
|
clean_main,
|
||||||
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
|
["--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
|
assert "Failed to list" in result.output
|
||||||
|
|
||||||
def test_delete_failure_in_cli(self) -> None:
|
def test_delete_failure_in_cli(self) -> None:
|
||||||
@@ -571,13 +694,44 @@ class TestCLICleanImages:
|
|||||||
{"version": "0.3.0", "created_at": "2025-03-01"},
|
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||||
]
|
]
|
||||||
list_resp.raise_for_status = MagicMock()
|
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.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.get", return_value=list_resp):
|
||||||
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
|
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
|
||||||
result = runner.invoke(
|
with patch("devx.tools.clean_images.time.sleep"):
|
||||||
clean_main,
|
result = runner.invoke(
|
||||||
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
|
clean_main,
|
||||||
)
|
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
|
||||||
assert result.exit_code == 0
|
)
|
||||||
assert "FAILED" in result.output
|
assert result.exit_code != 0
|
||||||
|
assert "FAILED" in result.output
|
||||||
|
|
||||||
|
@patch("devx.tools.clean_images.REPO_OWNER", "oblachno-oss")
|
||||||
|
def test_owner_from_config(self) -> None:
|
||||||
|
from devx.tools.clean_images import main as clean_main
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = []
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
|
||||||
|
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
|
||||||
|
result = runner.invoke(
|
||||||
|
clean_main,
|
||||||
|
["--name", "ci-base", "--dry-run"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "oblachno-oss/ci-base" in result.output
|
||||||
|
|
||||||
|
@patch("devx.tools.clean_images.REPO_OWNER", "")
|
||||||
|
def test_no_owner_raises(self) -> None:
|
||||||
|
from devx.tools.clean_images import main as clean_main
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
|
||||||
|
result = runner.invoke(
|
||||||
|
clean_main,
|
||||||
|
["--name", "ci-base"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "owner" in result.output.lower()
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from devx.tools.check_test_coverage import (
|
from devx.tools.check_test_coverage import (
|
||||||
BUILTIN_RULES,
|
BUILTIN_RULES,
|
||||||
DEFAULT_SKIP_EXTENSIONS,
|
DEFAULT_SKIP_EXTENSIONS,
|
||||||
@@ -13,7 +15,7 @@ from devx.tools.check_test_coverage import (
|
|||||||
_load_rules,
|
_load_rules,
|
||||||
_resolve_test_path,
|
_resolve_test_path,
|
||||||
_should_skip_file,
|
_should_skip_file,
|
||||||
main,
|
cli,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -201,7 +203,10 @@ class TestMain:
|
|||||||
),
|
),
|
||||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||||
):
|
):
|
||||||
assert main([]) == 0
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "No changed files" in result.output
|
||||||
|
|
||||||
def test_all_have_tests(self, tmp_path: Path) -> None:
|
def test_all_have_tests(self, tmp_path: Path) -> None:
|
||||||
(tmp_path / "scripts" / "tests").mkdir(parents=True)
|
(tmp_path / "scripts" / "tests").mkdir(parents=True)
|
||||||
@@ -214,7 +219,10 @@ class TestMain:
|
|||||||
),
|
),
|
||||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||||
):
|
):
|
||||||
assert main([]) == 0
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "have tests" in result.output
|
||||||
|
|
||||||
def test_missing_test_returns_1(self, tmp_path: Path) -> None:
|
def test_missing_test_returns_1(self, tmp_path: Path) -> None:
|
||||||
with (
|
with (
|
||||||
@@ -225,7 +233,9 @@ class TestMain:
|
|||||||
),
|
),
|
||||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||||
):
|
):
|
||||||
assert main([]) == 1
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
def test_warn_only_returns_0(self, tmp_path: Path) -> None:
|
def test_warn_only_returns_0(self, tmp_path: Path) -> None:
|
||||||
with (
|
with (
|
||||||
@@ -236,4 +246,6 @@ class TestMain:
|
|||||||
),
|
),
|
||||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||||
):
|
):
|
||||||
assert main(["--warn-only"]) == 0
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--warn-only"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|||||||
@@ -264,6 +264,16 @@ class TestChangeClassifier:
|
|||||||
assert fc.is_user_facing
|
assert fc.is_user_facing
|
||||||
assert fc.matched_rule == "user_facing_overrides"
|
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:
|
def test_user_facing_override_beats_infrastructure_override(self) -> None:
|
||||||
"""User-facing overrides beat infrastructure overrides (safety first)."""
|
"""User-facing overrides beat infrastructure overrides (safety first)."""
|
||||||
classifier = self._make_classifier(
|
classifier = self._make_classifier(
|
||||||
@@ -489,7 +499,7 @@ class TestMain:
|
|||||||
@patch("devx.ci.classify_changes.get_changed_files")
|
@patch("devx.ci.classify_changes.get_changed_files")
|
||||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
@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:
|
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()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, [])
|
result = runner.invoke(main, [])
|
||||||
assert result.exit_code == 2
|
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_changed_files")
|
||||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
@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:
|
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()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--quiet"])
|
result = runner.invoke(main, ["--quiet"])
|
||||||
assert result.exit_code == 0
|
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_changed_files")
|
||||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
@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:
|
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()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ class TestMain:
|
|||||||
assert "CI_GITEA_TOKEN" in result.output
|
assert "CI_GITEA_TOKEN" in result.output
|
||||||
|
|
||||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
|
@patch("devx.tools.configure_repo.REPO_NAME", "")
|
||||||
def test_main_no_repo(self) -> None:
|
def test_main_no_repo(self) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, [])
|
result = runner.invoke(main, [])
|
||||||
@@ -197,3 +198,19 @@ class TestMain:
|
|||||||
call_args = mock_client_cls.call_args
|
call_args = mock_client_cls.call_args
|
||||||
assert call_args[0][2] == "oblachno" # owner
|
assert call_args[0][2] == "oblachno" # owner
|
||||||
assert call_args[0][3] == "infra" # repo
|
assert call_args[0][3] == "infra" # repo
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
|
@patch("devx.tools.configure_repo.REPO_NAME", "devx")
|
||||||
|
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno-oss")
|
||||||
|
@patch("devx.tools.configure_repo.GiteaClient")
|
||||||
|
def test_main_repo_from_pyproject(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
"""When no env var is set, repo name should come from pyproject.toml."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
call_args = mock_client_cls.call_args
|
||||||
|
assert call_args[0][2] == "oblachno-oss" # owner
|
||||||
|
assert call_args[0][3] == "devx" # repo
|
||||||
|
|||||||
@@ -29,10 +29,22 @@ class TestGetRepoName:
|
|||||||
def test_from_env(self) -> None:
|
def test_from_env(self) -> None:
|
||||||
assert get_repo_name() == "infra"
|
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)
|
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
|
||||||
def test_from_github(self) -> None:
|
def test_from_github(self) -> None:
|
||||||
assert get_repo_name() == "infra"
|
assert get_repo_name() == "infra"
|
||||||
|
|
||||||
|
@patch("devx.tools.create_pr.REPO_NAME", "")
|
||||||
@patch.dict("os.environ", {}, clear=True)
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
def test_missing_raises(self) -> None:
|
def test_missing_raises(self) -> None:
|
||||||
with pytest.raises(click.ClickException, match="Repository name"):
|
with pytest.raises(click.ClickException, match="Repository name"):
|
||||||
@@ -44,7 +56,7 @@ class TestGetVikunjaTaskTitle:
|
|||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
def test_found(self, mock_client_cls: MagicMock) -> None:
|
def test_found(self, mock_client_cls: MagicMock) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42", "title": "Add feature"}]
|
mock_client.find_task_by_identifier.return_value = {"identifier": "DEVX-42", "title": "Add feature"}
|
||||||
mock_client_cls.return_value = mock_client
|
mock_client_cls.return_value = mock_client
|
||||||
assert get_vikunja_task_title("DEVX-42") == "Add feature"
|
assert get_vikunja_task_title("DEVX-42") == "Add feature"
|
||||||
|
|
||||||
@@ -57,20 +69,7 @@ class TestGetVikunjaTaskTitle:
|
|||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
def test_not_found(self, mock_client_cls: MagicMock) -> None:
|
def test_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.list_project_tasks.return_value = []
|
mock_client.find_task_by_identifier.return_value = None
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
with pytest.raises(click.ClickException, match="Could not find"):
|
|
||||||
get_vikunja_task_title("DEVX-42")
|
|
||||||
|
|
||||||
@patch("devx.tools.create_pr.VikunjaClient")
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
from devx.config import DEFAULT_PER_PAGE
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
|
|
||||||
page2 = [{"identifier": "OTHER-99"}]
|
|
||||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
|
||||||
mock_client_cls.return_value = mock_client
|
mock_client_cls.return_value = mock_client
|
||||||
with pytest.raises(click.ClickException, match="Could not find"):
|
with pytest.raises(click.ClickException, match="Could not find"):
|
||||||
get_vikunja_task_title("DEVX-42")
|
get_vikunja_task_title("DEVX-42")
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""Unit tests for devx.ci.distribute_items."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.ci.distribute_items import (
|
||||||
|
DEFAULT_WEIGHT,
|
||||||
|
distribute,
|
||||||
|
items_for_runner,
|
||||||
|
main,
|
||||||
|
parse_items,
|
||||||
|
parse_weighted_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseItems:
|
||||||
|
def test_string_array(self) -> None:
|
||||||
|
assert parse_items('["a", "b", "c"]') == ["a", "b", "c"]
|
||||||
|
|
||||||
|
def test_object_array(self) -> None:
|
||||||
|
raw = '[{"id": "a", "weight": 2}, {"id": "b"}]'
|
||||||
|
assert parse_items(raw) == ["a", "b"]
|
||||||
|
|
||||||
|
def test_empty_array(self) -> None:
|
||||||
|
assert parse_items("[]") == []
|
||||||
|
|
||||||
|
def test_not_an_array(self) -> None:
|
||||||
|
with pytest.raises(Exception, match="must be a JSON array"):
|
||||||
|
parse_items('{"key": "value"}')
|
||||||
|
|
||||||
|
def test_invalid_entry_type(self) -> None:
|
||||||
|
with pytest.raises(Exception, match="must be a string or an object"):
|
||||||
|
parse_items("[42]")
|
||||||
|
|
||||||
|
def test_object_without_id(self) -> None:
|
||||||
|
with pytest.raises(Exception, match="must be a string or an object"):
|
||||||
|
parse_items('[{"weight": 2}]')
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseWeightedItems:
|
||||||
|
def test_string_array_default_weights(self) -> None:
|
||||||
|
items, weights = parse_weighted_items('["a", "b"]')
|
||||||
|
assert items == ["a", "b"]
|
||||||
|
assert weights == [DEFAULT_WEIGHT, DEFAULT_WEIGHT]
|
||||||
|
|
||||||
|
def test_object_array_with_weights(self) -> None:
|
||||||
|
items, weights = parse_weighted_items('[{"id": "a", "weight": 5}, {"id": "b", "weight": 1}]')
|
||||||
|
assert items == ["a", "b"]
|
||||||
|
assert weights == [5, 1]
|
||||||
|
|
||||||
|
def test_object_array_missing_weight(self) -> None:
|
||||||
|
items, weights = parse_weighted_items('[{"id": "a"}]')
|
||||||
|
assert items == ["a"]
|
||||||
|
assert weights == [DEFAULT_WEIGHT]
|
||||||
|
|
||||||
|
def test_not_an_array(self) -> None:
|
||||||
|
with pytest.raises(Exception, match="must be a JSON array"):
|
||||||
|
parse_weighted_items('"hello"')
|
||||||
|
|
||||||
|
def test_invalid_entry(self) -> None:
|
||||||
|
with pytest.raises(Exception, match="must be a string or an object"):
|
||||||
|
parse_weighted_items("[true]")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDistribute:
|
||||||
|
def test_even_split(self) -> None:
|
||||||
|
items = [f"vm-{i}" for i in range(6)]
|
||||||
|
weights = [1] * 6
|
||||||
|
groups = distribute(items, weights, 3)
|
||||||
|
assert len(groups) == 3
|
||||||
|
assert all(len(g) == 2 for g in groups)
|
||||||
|
|
||||||
|
def test_uneven_split(self) -> None:
|
||||||
|
items = [f"vm-{i}" for i in range(5)]
|
||||||
|
weights = [1] * 5
|
||||||
|
groups = distribute(items, weights, 3)
|
||||||
|
assert len(groups[0]) == 2
|
||||||
|
assert len(groups[1]) == 2
|
||||||
|
assert len(groups[2]) == 1
|
||||||
|
|
||||||
|
def test_more_runners_than_items(self) -> None:
|
||||||
|
items = ["vm-a"]
|
||||||
|
weights = [1]
|
||||||
|
groups = distribute(items, weights, 5)
|
||||||
|
assert len(groups) == 5
|
||||||
|
assert len(groups[0]) == 1
|
||||||
|
assert all(len(g) == 0 for g in groups[1:])
|
||||||
|
|
||||||
|
def test_lpt_heavy_item_on_least_loaded(self) -> None:
|
||||||
|
items = ["heavy", "light1", "light2", "light3"]
|
||||||
|
weights = [10, 1, 1, 1]
|
||||||
|
groups = distribute(items, weights, 2)
|
||||||
|
# Heavy item goes to runner 0, lights go to runner 1 (least loaded)
|
||||||
|
assert "heavy" in groups[0]
|
||||||
|
# Runner 1 should have more items but less total weight
|
||||||
|
assert len(groups[1]) >= 2
|
||||||
|
|
||||||
|
def test_empty_items(self) -> None:
|
||||||
|
groups = distribute([], [], 3)
|
||||||
|
assert len(groups) == 3
|
||||||
|
assert all(len(g) == 0 for g in groups)
|
||||||
|
|
||||||
|
def test_single_runner(self) -> None:
|
||||||
|
items = ["a", "b", "c"]
|
||||||
|
weights = [1, 2, 3]
|
||||||
|
groups = distribute(items, weights, 1)
|
||||||
|
assert len(groups) == 1
|
||||||
|
assert len(groups[0]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemsForRunner:
|
||||||
|
def test_returns_assigned_subset(self) -> None:
|
||||||
|
items = ["a", "b", "c", "d", "e", "f"]
|
||||||
|
weights = [1] * 6
|
||||||
|
result = items_for_runner(items, weights, 0, 3)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert all(item in items for item in result)
|
||||||
|
|
||||||
|
def test_out_of_range(self) -> None:
|
||||||
|
with pytest.raises(Exception, match="out of range"):
|
||||||
|
items_for_runner(["a"], [1], 5, 3)
|
||||||
|
|
||||||
|
def test_negative_index(self) -> None:
|
||||||
|
with pytest.raises(Exception, match="out of range"):
|
||||||
|
items_for_runner(["a"], [1], -1, 3)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
def test_stdin_string_array(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--runner-index", "1", "--max-runners", "2"], input='["a", "b", "c"]')
|
||||||
|
assert result.exit_code == 0
|
||||||
|
# LPT: heaviest first, so "a" goes to runner 0, "b" to runner 1, "c" to runner 0
|
||||||
|
# All weights equal, so round-robin-ish: runner 0 gets "a","c"; runner 1 gets "b"
|
||||||
|
assert "a" in result.output
|
||||||
|
|
||||||
|
def test_stdin_object_array(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["--runner-index", "1", "--max-runners", "2"],
|
||||||
|
input='[{"id": "a", "weight": 5}, {"id": "b", "weight": 1}]',
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "a" in result.output
|
||||||
|
|
||||||
|
def test_items_file(self, tmp_path: object) -> None:
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
items_file = pathlib.Path(str(tmp_path)) / "items.json"
|
||||||
|
items_file.write_text('["x", "y", "z"]')
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--items-file", str(items_file), "--runner-index", "1", "--max-runners", "3"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "x" in result.output
|
||||||
|
|
||||||
|
def test_print_all_groups_no_runner_index(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--max-runners", "2"], input='["a", "b"]')
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Runner 0:" in result.output
|
||||||
|
assert "Runner 1:" in result.output
|
||||||
|
|
||||||
|
def test_empty_stdin(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--runner-index", "1", "--max-runners", "3"], input="")
|
||||||
|
assert result.exit_code == 0
|
||||||
|
# Empty input → empty assigned items
|
||||||
|
assert result.output.strip() == ""
|
||||||
|
|
||||||
|
def test_github_env(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
|
||||||
|
gh_env.write_text("")
|
||||||
|
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["--runner-index", "1", "--max-runners", "2", "--github-env"],
|
||||||
|
input='["a", "b"]',
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = gh_env.read_text()
|
||||||
|
assert "ASSIGNED_ITEMS=" in content
|
||||||
|
assert "SKIP=false" in content
|
||||||
|
|
||||||
|
def test_skip_if_excess(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
|
||||||
|
gh_env.write_text("")
|
||||||
|
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"],
|
||||||
|
input='["a"]',
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = gh_env.read_text()
|
||||||
|
assert "ASSIGNED_ITEMS=" in content
|
||||||
|
assert "SKIP=true" in content
|
||||||
|
|
||||||
|
def test_runner_index_zero(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--runner-index", "0"], input='["a"]')
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "out of range" in result.output
|
||||||
|
|
||||||
|
def test_default_max_runners(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--runner-index", "1"], input='["a"]')
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "a" in result.output
|
||||||
|
|
||||||
|
def test_github_env_not_set(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.delenv("GITHUB_ENV", raising=False)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["--runner-index", "1", "--github-env"],
|
||||||
|
input='["a"]',
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "GITHUB_ENV" in result.output
|
||||||
|
|
||||||
|
def test_invalid_json(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--runner-index", "1"], input="not json")
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
def test_multiline_github_env(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
|
||||||
|
gh_env.write_text("")
|
||||||
|
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
|
||||||
|
runner = CliRunner()
|
||||||
|
# Items with newlines in their IDs would trigger multiline syntax
|
||||||
|
# Normal items don't have newlines, but test the path anyway
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["--runner-index", "1", "--max-runners", "1", "--github-env"],
|
||||||
|
input='["a\\nb"]',
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = gh_env.read_text()
|
||||||
|
# Item "a\nb" contains a newline → heredoc syntax
|
||||||
|
assert "ASSIGNED_ITEMS<<" in content
|
||||||
@@ -63,13 +63,19 @@ class TestDownloadBinary:
|
|||||||
|
|
||||||
class TestMain:
|
class TestMain:
|
||||||
def test_already_installed(self) -> None:
|
def test_already_installed(self) -> None:
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
with patch("shutil.which", return_value="/usr/bin/checkmake"):
|
with patch("shutil.which", return_value="/usr/bin/checkmake"):
|
||||||
install_checkmake.main()
|
runner = CliRunner()
|
||||||
|
runner.invoke(install_checkmake.cli, [])
|
||||||
|
|
||||||
def test_install_with_go(self) -> None:
|
def test_install_with_go(self) -> None:
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
with patch("shutil.which", side_effect=[None, "/usr/bin/go"]):
|
with patch("shutil.which", side_effect=[None, "/usr/bin/go"]):
|
||||||
with patch("subprocess.run") as mock_run:
|
with patch("subprocess.run") as mock_run:
|
||||||
install_checkmake.main()
|
runner = CliRunner()
|
||||||
|
runner.invoke(install_checkmake.cli, [])
|
||||||
mock_run.assert_called_once_with(
|
mock_run.assert_called_once_with(
|
||||||
[
|
[
|
||||||
"/usr/bin/go",
|
"/usr/bin/go",
|
||||||
@@ -80,6 +86,8 @@ class TestMain:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_download_when_no_go(self, tmp_path: Path) -> None:
|
def test_download_when_no_go(self, tmp_path: Path) -> None:
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
target = tmp_path / "checkmake"
|
target = tmp_path / "checkmake"
|
||||||
|
|
||||||
def _write_file(url: str, path: str) -> tuple[str, None]:
|
def _write_file(url: str, path: str) -> tuple[str, None]:
|
||||||
@@ -90,5 +98,6 @@ class TestMain:
|
|||||||
with patch("shutil.which", side_effect=[None, None]):
|
with patch("shutil.which", side_effect=[None, None]):
|
||||||
with patch.object(platform, "machine", return_value="x86_64"):
|
with patch.object(platform, "machine", return_value="x86_64"):
|
||||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
||||||
install_checkmake.main()
|
runner = CliRunner()
|
||||||
|
runner.invoke(install_checkmake.cli, [])
|
||||||
mock_retrieve.assert_called_once()
|
mock_retrieve.assert_called_once()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -733,6 +733,182 @@ class TestMain:
|
|||||||
assert "CI_GITEA_TOKEN" in result.output
|
assert "CI_GITEA_TOKEN" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestManualReview:
|
||||||
|
@patch("devx.ci.pr_review.GiteaClient")
|
||||||
|
def test_approve_success(self, mock_client_class: MagicMock) -> None:
|
||||||
|
mock_client_class.return_value.create_review.return_value = {"id": 200}
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"42",
|
||||||
|
"oblachno-oss/devx",
|
||||||
|
"--event",
|
||||||
|
"APPROVE",
|
||||||
|
"--body",
|
||||||
|
"All 13 REVIEW_CHECKLIST.md categories verified. Architecture: clean. Security: no issues.",
|
||||||
|
"--checklist-confirmed",
|
||||||
|
"--checklist-categories",
|
||||||
|
"1,2,3,4,5,6,7,8,9,10,11,12,13",
|
||||||
|
],
|
||||||
|
env={"CI_GITEA_TOKEN": "fake"},
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Review #200" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.pr_review.GiteaClient")
|
||||||
|
def test_approve_without_checklist_confirmed_fails(self, mock_client_class: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"42",
|
||||||
|
"oblachno-oss/devx",
|
||||||
|
"--event",
|
||||||
|
"APPROVE",
|
||||||
|
"--body",
|
||||||
|
"x" * 60,
|
||||||
|
"--checklist-categories",
|
||||||
|
"1,2,3,4,5,6,7,8",
|
||||||
|
],
|
||||||
|
env={"CI_GITEA_TOKEN": "fake"},
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "checklist-confirmed" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.pr_review.GiteaClient")
|
||||||
|
def test_approve_with_too_few_categories_fails(self, mock_client_class: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"42",
|
||||||
|
"oblachno-oss/devx",
|
||||||
|
"--event",
|
||||||
|
"APPROVE",
|
||||||
|
"--body",
|
||||||
|
"x" * 60,
|
||||||
|
"--checklist-confirmed",
|
||||||
|
"--checklist-categories",
|
||||||
|
"1,2,3",
|
||||||
|
],
|
||||||
|
env={"CI_GITEA_TOKEN": "fake"},
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "at least 8" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.pr_review.GiteaClient")
|
||||||
|
def test_approve_with_short_body_fails(self, mock_client_class: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"42",
|
||||||
|
"oblachno-oss/devx",
|
||||||
|
"--event",
|
||||||
|
"APPROVE",
|
||||||
|
"--body",
|
||||||
|
"LGTM",
|
||||||
|
"--checklist-confirmed",
|
||||||
|
"--checklist-categories",
|
||||||
|
"1,2,3,4,5,6,7,8",
|
||||||
|
],
|
||||||
|
env={"CI_GITEA_TOKEN": "fake"},
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "50 characters" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.pr_review.GiteaClient")
|
||||||
|
def test_approve_with_invalid_category_fails(self, mock_client_class: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"42",
|
||||||
|
"oblachno-oss/devx",
|
||||||
|
"--event",
|
||||||
|
"APPROVE",
|
||||||
|
"--body",
|
||||||
|
"x" * 60,
|
||||||
|
"--checklist-confirmed",
|
||||||
|
"--checklist-categories",
|
||||||
|
"1,2,abc,4",
|
||||||
|
],
|
||||||
|
env={"CI_GITEA_TOKEN": "fake"},
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Invalid" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.pr_review.GiteaClient")
|
||||||
|
def test_request_changes_success(self, mock_client_class: MagicMock) -> None:
|
||||||
|
mock_client_class.return_value.create_review.return_value = {"id": 201}
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"42",
|
||||||
|
"oblachno-oss/devx",
|
||||||
|
"--event",
|
||||||
|
"REQUEST_CHANGES",
|
||||||
|
"--body",
|
||||||
|
"Please fix the architecture issues in the CLI module before merging.",
|
||||||
|
],
|
||||||
|
env={"CI_GITEA_TOKEN": "fake"},
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Review #201" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.pr_review.GiteaClient")
|
||||||
|
def test_manual_review_dry_run(self, mock_client_class: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["42", "oblachno-oss/devx", "--event", "COMMENT", "--body", "x" * 60, "--dry-run"],
|
||||||
|
env={"CI_GITEA_TOKEN": "fake"},
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "[dry-run]" in result.output
|
||||||
|
mock_client_class.return_value.create_review.assert_not_called()
|
||||||
|
|
||||||
|
@patch("devx.ci.pr_review.GiteaClient")
|
||||||
|
def test_manual_review_self_approval_fallback(self, mock_client_class: MagicMock) -> None:
|
||||||
|
client = mock_client_class.return_value
|
||||||
|
client.create_review.side_effect = [
|
||||||
|
APIError(422, "approve your own pull is not allowed"),
|
||||||
|
{"id": 202},
|
||||||
|
]
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"42",
|
||||||
|
"oblachno-oss/devx",
|
||||||
|
"--event",
|
||||||
|
"APPROVE",
|
||||||
|
"--body",
|
||||||
|
"x" * 60,
|
||||||
|
"--checklist-confirmed",
|
||||||
|
"--checklist-categories",
|
||||||
|
"1,2,3,4,5,6,7,8",
|
||||||
|
],
|
||||||
|
env={"CI_GITEA_TOKEN": "fake"},
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Review #202" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.pr_review.GiteaClient")
|
||||||
|
def test_manual_review_other_error_re_raises(self, mock_client_class: MagicMock) -> None:
|
||||||
|
client = mock_client_class.return_value
|
||||||
|
client.create_review.side_effect = APIError(500, "Internal server error")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
["42", "oblachno-oss/devx", "--event", "COMMENT", "--body", "x" * 60],
|
||||||
|
env={"CI_GITEA_TOKEN": "fake"},
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
|
||||||
def test_main_module_block() -> None:
|
def test_main_module_block() -> None:
|
||||||
import devx.ci.pr_review as pr
|
import devx.ci.pr_review as pr
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -43,7 +43,7 @@ class TestTaskExists:
|
|||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
def test_found(self, mock_client_cls: MagicMock) -> None:
|
def test_found(self, mock_client_cls: MagicMock) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42"}]
|
mock_client.find_task_by_identifier.return_value = {"identifier": "DEVX-42"}
|
||||||
mock_client_cls.return_value = mock_client
|
mock_client_cls.return_value = mock_client
|
||||||
assert task_exists("DEVX-42") is True
|
assert task_exists("DEVX-42") is True
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ class TestTaskExists:
|
|||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||||
def test_not_found(self, mock_client_cls: MagicMock) -> None:
|
def test_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-99"}]
|
mock_client.find_task_by_identifier.return_value = None
|
||||||
mock_client_cls.return_value = mock_client
|
mock_client_cls.return_value = mock_client
|
||||||
assert task_exists("DEVX-42") is False
|
assert task_exists("DEVX-42") is False
|
||||||
|
|
||||||
@@ -59,37 +59,6 @@ class TestTaskExists:
|
|||||||
def test_no_token(self) -> None:
|
def test_no_token(self) -> None:
|
||||||
assert task_exists("DEVX-42") is False
|
assert task_exists("DEVX-42") is False
|
||||||
|
|
||||||
@patch("devx.tools.pre_push_check.VikunjaClient")
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
def test_pagination(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
mock_client = MagicMock()
|
|
||||||
# First page: full page (50 items, none matching), second page: match
|
|
||||||
page1 = [{"identifier": f"OTHER-{i}"} for i in range(50)]
|
|
||||||
page2 = [{"identifier": "DEVX-42"}]
|
|
||||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
assert task_exists("DEVX-42") is True
|
|
||||||
|
|
||||||
@patch("devx.tools.pre_push_check.VikunjaClient")
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
def test_empty_project(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = []
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
assert task_exists("DEVX-42") is False
|
|
||||||
|
|
||||||
@patch("devx.tools.pre_push_check.VikunjaClient")
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
from devx.config import DEFAULT_PER_PAGE
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
|
|
||||||
page2 = [{"identifier": "OTHER-99"}]
|
|
||||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
assert task_exists("DEVX-42") is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidate:
|
class TestValidate:
|
||||||
def test_master_branch_skips(self) -> None:
|
def test_master_branch_skips(self) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user