Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8d0ad04a2 | ||
|
|
e836c09088 | ||
|
|
507436b134 | ||
|
|
32cec2c5ad | ||
|
|
55c530eb00 | ||
|
|
3928de4507 | ||
|
|
8bb1813715 | ||
|
|
f7f53941a1 | ||
|
|
5edfdaa7aa | ||
|
|
893da8ba34 | ||
|
|
64a58874b6 | ||
|
|
c1c2041ca4 |
@@ -160,6 +160,21 @@ jobs:
|
|||||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
token: ${{ secrets.CI_GITEA_TOKEN }}
|
||||||
- name: Set up environment
|
- name: Set up environment
|
||||||
run: make setup-image
|
run: make setup-image
|
||||||
|
- name: Post approval review
|
||||||
|
env:
|
||||||
|
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||||
|
PR_NUMBER: ${{ github.event.number }}
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
python3 -m devx.ci.pr_review \
|
||||||
|
"$PR_NUMBER" \
|
||||||
|
"$REPOSITORY" \
|
||||||
|
--event APPROVE \
|
||||||
|
--checklist-confirmed \
|
||||||
|
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
|
||||||
|
--body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)."
|
||||||
- name: Squash merge with task ID
|
- name: Squash merge with task ID
|
||||||
env:
|
env:
|
||||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -406,6 +407,7 @@ projects.
|
|||||||
| `devx-pr-status` | Check CI status for a PR (`PR=`, `WAIT=`, `TIMEOUT=`) |
|
| `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-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-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,30 @@
|
|||||||
|
|
||||||
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.2] - 2026-06-28
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Block admin merge override and auto-approve with review token
|
||||||
|
|
||||||
|
## [0.26.1] - 2026-06-28
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Force pip upgrade in setup-image to install new dependencies
|
||||||
|
|
||||||
|
## [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
|
## [0.24.1] - 2026-06-28
|
||||||
|
|
||||||
### Refactor
|
### Refactor
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ setup-release: $(VENV)/bin/activate .env
|
|||||||
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
|
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
|
||||||
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
|
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
|
||||||
setup-image:
|
setup-image:
|
||||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install -e . --no-deps 2>/dev/null; \
|
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
|
||||||
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||||
|
|
||||||
.env:
|
.env:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ dependencies = [
|
|||||||
"requests>=2.34.2",
|
"requests>=2.34.2",
|
||||||
"python-dotenv>=1.2.2",
|
"python-dotenv>=1.2.2",
|
||||||
"click>=8.4.1",
|
"click>=8.4.1",
|
||||||
|
"tenacity>=8.2", # retry logic for GiteaClient/VikunjaClient
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -97,6 +98,8 @@ indent-style = "space"
|
|||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
include = ["src"]
|
include = ["src"]
|
||||||
pythonVersion = "3.12"
|
pythonVersion = "3.12"
|
||||||
|
venvPath = "."
|
||||||
|
venv = ".venv"
|
||||||
strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"]
|
strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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.24.1"
|
__version__ = "0.26.2"
|
||||||
|
|||||||
+72
-93
@@ -4,10 +4,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from tenacity import (
|
||||||
|
before_sleep_log,
|
||||||
|
retry,
|
||||||
|
retry_if_exception_type,
|
||||||
|
stop_after_attempt,
|
||||||
|
wait_exponential,
|
||||||
|
)
|
||||||
|
|
||||||
from devx.config import DEFAULT_TIMEOUT, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
|
from devx.config import DEFAULT_TIMEOUT, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
|
||||||
from devx.exceptions import APIError
|
from devx.exceptions import APIError
|
||||||
@@ -27,14 +33,69 @@ def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
|
|||||||
return status, message
|
return status, message
|
||||||
|
|
||||||
|
|
||||||
def _is_retryable(e: Exception) -> bool:
|
class _TransientHTTPError(requests.HTTPError):
|
||||||
"""Check if an exception is a transient error worth retrying."""
|
"""HTTP error with a retryable status code (wrapped for tenacity)."""
|
||||||
if isinstance(e, requests.ConnectionError):
|
|
||||||
return True
|
|
||||||
if isinstance(e, requests.HTTPError):
|
class _RetryableRequestError(Exception):
|
||||||
status, _ = _parse_error(e)
|
"""Connection/timeout error wrapped for tenacity retry."""
|
||||||
return status in RETRY_STATUS_CODES
|
|
||||||
return isinstance(e, requests.Timeout)
|
|
||||||
|
def _execute_request(
|
||||||
|
session: requests.Session,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> requests.Response:
|
||||||
|
"""Execute a single HTTP request, wrapping transient errors for tenacity.
|
||||||
|
|
||||||
|
Non-retryable HTTP errors (4xx except 429) raise :class:`APIError` directly.
|
||||||
|
Retryable errors (429, 5xx, connection, timeout) raise exceptions that
|
||||||
|
tenacity will retry.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
status, message = _parse_error(e)
|
||||||
|
if status in RETRY_STATUS_CODES:
|
||||||
|
# Wrap in _TransientHTTPError so tenacity retries it
|
||||||
|
raise _TransientHTTPError(message, response=e.response) from e
|
||||||
|
raise APIError(status, message) from e
|
||||||
|
except (requests.ConnectionError, requests.Timeout) as e:
|
||||||
|
raise _RetryableRequestError(str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
# Tenacity retry decorator shared by both clients.
|
||||||
|
# Retries on transient HTTP errors (429, 5xx) and connection/timeout errors.
|
||||||
|
_retry_decorator = retry(
|
||||||
|
stop=stop_after_attempt(MAX_RETRIES),
|
||||||
|
wait=wait_exponential(multiplier=RETRY_BACKOFF_BASE, min=RETRY_BACKOFF_BASE, max=RETRY_BACKOFF_BASE**MAX_RETRIES),
|
||||||
|
retry=retry_if_exception_type((_TransientHTTPError, _RetryableRequestError)),
|
||||||
|
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_with_retry(
|
||||||
|
session: requests.Session,
|
||||||
|
url: str,
|
||||||
|
method: str,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> requests.Response:
|
||||||
|
"""Execute an HTTP request with tenacity-managed retry logic.
|
||||||
|
|
||||||
|
On exhaustion, the last exception is translated to :class:`APIError`.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _retry_decorator(_execute_request)(session, method, url, **kwargs)
|
||||||
|
except _TransientHTTPError as e:
|
||||||
|
response = getattr(e, "response", None)
|
||||||
|
status = response.status_code if response is not None else 0
|
||||||
|
raise APIError(status, str(e)) from e
|
||||||
|
except _RetryableRequestError as e:
|
||||||
|
raise APIError(0, str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
class GiteaClient:
|
class GiteaClient:
|
||||||
@@ -56,49 +117,7 @@ class GiteaClient:
|
|||||||
return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}"
|
return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}"
|
||||||
|
|
||||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||||
url = self._url(path)
|
return _request_with_retry(self._session, self._url(path), method, **kwargs)
|
||||||
last_exc: Exception | None = None
|
|
||||||
for attempt in range(MAX_RETRIES):
|
|
||||||
try:
|
|
||||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response
|
|
||||||
except requests.HTTPError as e:
|
|
||||||
status, message = _parse_error(e)
|
|
||||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
|
||||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
|
||||||
logger.warning(
|
|
||||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
|
||||||
status,
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
wait,
|
|
||||||
attempt + 1,
|
|
||||||
MAX_RETRIES,
|
|
||||||
)
|
|
||||||
time.sleep(wait)
|
|
||||||
last_exc = e
|
|
||||||
continue
|
|
||||||
raise APIError(status, message) from e
|
|
||||||
except (requests.ConnectionError, requests.Timeout) as e:
|
|
||||||
if attempt < MAX_RETRIES - 1:
|
|
||||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
|
||||||
logger.warning(
|
|
||||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
wait,
|
|
||||||
attempt + 1,
|
|
||||||
MAX_RETRIES,
|
|
||||||
)
|
|
||||||
time.sleep(wait)
|
|
||||||
last_exc = e
|
|
||||||
continue
|
|
||||||
raise APIError(0, str(e)) from e
|
|
||||||
# Should not reach here, but just in case
|
|
||||||
if last_exc: # pragma: no cover
|
|
||||||
raise APIError(0, str(last_exc)) from last_exc
|
|
||||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
|
||||||
|
|
||||||
# -- repo settings --
|
# -- repo settings --
|
||||||
|
|
||||||
@@ -351,47 +370,7 @@ class VikunjaClient:
|
|||||||
|
|
||||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||||
url = f"{self._base_url}{path}"
|
url = f"{self._base_url}{path}"
|
||||||
last_exc: Exception | None = None
|
return _request_with_retry(self._session, url, method, **kwargs)
|
||||||
for attempt in range(MAX_RETRIES):
|
|
||||||
try:
|
|
||||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response
|
|
||||||
except requests.HTTPError as e:
|
|
||||||
status, message = _parse_error(e)
|
|
||||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
|
||||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
|
||||||
logger.warning(
|
|
||||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
|
||||||
status,
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
wait,
|
|
||||||
attempt + 1,
|
|
||||||
MAX_RETRIES,
|
|
||||||
)
|
|
||||||
time.sleep(wait)
|
|
||||||
last_exc = e
|
|
||||||
continue
|
|
||||||
raise APIError(status, message) from e
|
|
||||||
except (requests.ConnectionError, requests.Timeout) as e:
|
|
||||||
if attempt < MAX_RETRIES - 1:
|
|
||||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
|
||||||
logger.warning(
|
|
||||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
wait,
|
|
||||||
attempt + 1,
|
|
||||||
MAX_RETRIES,
|
|
||||||
)
|
|
||||||
time.sleep(wait)
|
|
||||||
last_exc = e
|
|
||||||
continue
|
|
||||||
raise APIError(0, str(e)) from e
|
|
||||||
if last_exc: # pragma: no cover
|
|
||||||
raise APIError(0, str(last_exc)) from last_exc
|
|
||||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
|
||||||
|
|
||||||
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
|
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
|
||||||
r = self._request("GET", "/tasks", params=params)
|
r = self._request("GET", "/tasks", params=params)
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.config import TASK_ID_RE
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
|
||||||
def get_latest_tag() -> str:
|
def get_latest_tag() -> str:
|
||||||
"""Get the latest git tag, or empty string if none exists."""
|
"""Get the latest git tag, or empty string if none exists."""
|
||||||
@@ -16,3 +22,97 @@ def get_latest_tag() -> str:
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return ""
|
return ""
|
||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def run_cmd(
|
||||||
|
args: list[str],
|
||||||
|
check: bool = True,
|
||||||
|
capture: bool = True,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Run a command and return the completed process.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Command and arguments as a list.
|
||||||
|
check: If True, raise :class:`click.ClickException` on non-zero exit.
|
||||||
|
capture: If True, capture stdout/stderr. If False, inherit parent's.
|
||||||
|
"""
|
||||||
|
result = subprocess.run( # nosec B603
|
||||||
|
args,
|
||||||
|
capture_output=capture,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if check and result.returncode != 0:
|
||||||
|
raise click.ClickException(
|
||||||
|
_(
|
||||||
|
"Command failed ({cmd}): {stderr}",
|
||||||
|
cmd=" ".join(args),
|
||||||
|
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def extract_task_id(text: str) -> str:
|
||||||
|
"""Extract the ``{PREFIX}-N`` task identifier from *text*.
|
||||||
|
|
||||||
|
Returns the matched string (e.g. ``DEVX-42``) or an empty string if
|
||||||
|
no task ID is found.
|
||||||
|
"""
|
||||||
|
match = TASK_ID_RE.search(text)
|
||||||
|
return match.group(0) if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
def write_github_env(key: str, value: str) -> None:
|
||||||
|
"""Append a key=value line to the ``$GITHUB_ENV`` file.
|
||||||
|
|
||||||
|
Multi-line values use the heredoc syntax required by Gitea Actions.
|
||||||
|
Raises :class:`click.ClickException` if ``GITHUB_ENV`` is not set.
|
||||||
|
"""
|
||||||
|
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", encoding="utf-8") 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")
|
||||||
|
|
||||||
|
|
||||||
|
def write_github_output(key: str, value: str) -> None:
|
||||||
|
"""Append a key=value line to the ``$GITHUB_OUTPUT`` file.
|
||||||
|
|
||||||
|
Raises :class:`click.ClickException` if ``GITHUB_OUTPUT`` is not set.
|
||||||
|
"""
|
||||||
|
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||||
|
if not gh_output:
|
||||||
|
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||||
|
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
|
||||||
|
f.write(f"{key}={value}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
|
||||||
|
"""Distribute *items* across *max_runners* using LPT scheduling.
|
||||||
|
|
||||||
|
Sorts items by weight (descending), then assigns each to the runner
|
||||||
|
with the least total weight. This produces a more balanced distribution
|
||||||
|
than naive round-robin when items have varying costs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: Items to distribute.
|
||||||
|
weights: Parallel list of integer weights (higher = heavier).
|
||||||
|
max_runners: Number of runner groups to create.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of ``max_runners`` lists, each containing the items assigned
|
||||||
|
to that runner.
|
||||||
|
"""
|
||||||
|
groups: list[list[T]] = [[] 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
|
||||||
|
|||||||
+18
-24
@@ -22,7 +22,6 @@ Usage:
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess # nosec B404
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -30,11 +29,11 @@ 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, VikunjaClient
|
from devx.api_clients import GiteaClient, VikunjaClient
|
||||||
|
from devx.ci._shared import extract_task_id as _extract_task_id
|
||||||
from devx.config import (
|
from devx.config import (
|
||||||
CONVENTIONAL_RE,
|
CONVENTIONAL_RE,
|
||||||
DEFAULT_PER_PAGE,
|
DEFAULT_PER_PAGE,
|
||||||
GITEA_API_URL,
|
GITEA_API_URL,
|
||||||
TASK_ID_RE,
|
|
||||||
TASK_PREFIX,
|
TASK_PREFIX,
|
||||||
VIKUNJA_API_URL,
|
VIKUNJA_API_URL,
|
||||||
VIKUNJA_PROJECT_ID,
|
VIKUNJA_PROJECT_ID,
|
||||||
@@ -48,20 +47,6 @@ PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
||||||
"""Run a command and return the completed process."""
|
|
||||||
result = subprocess.run(args, capture_output=True, text=True, check=False) # nosec B603
|
|
||||||
if check and result.returncode != 0:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Command failed ({cmd}): {stderr}",
|
|
||||||
cmd=" ".join(args),
|
|
||||||
stderr=result.stderr.strip() or result.stdout.strip(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def read_taskid(branch: str) -> str:
|
def read_taskid(branch: str) -> str:
|
||||||
"""Read task ID from branch name.
|
"""Read task ID from branch name.
|
||||||
|
|
||||||
@@ -92,9 +77,8 @@ def read_taskid(branch: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def extract_task_id(branch: str) -> str:
|
def extract_task_id(branch: str) -> str:
|
||||||
"""Extract DEVX-N task identifier from branch name (legacy fallback)."""
|
"""Extract task identifier from branch name (delegates to shared utility)."""
|
||||||
match = TASK_ID_RE.search(branch)
|
return _extract_task_id(branch)
|
||||||
return match.group(0) if match else ""
|
|
||||||
|
|
||||||
|
|
||||||
def validate_pr_title(pr_title: str, task_id: str) -> None:
|
def validate_pr_title(pr_title: str, task_id: str) -> None:
|
||||||
@@ -174,15 +158,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", {})
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from devx.config import (
|
|||||||
VIKUNJA_API_URL,
|
VIKUNJA_API_URL,
|
||||||
VIKUNJA_PROJECT_ID,
|
VIKUNJA_PROJECT_ID,
|
||||||
)
|
)
|
||||||
|
from devx.exceptions import APIError
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -108,7 +109,7 @@ def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
|
|||||||
try:
|
try:
|
||||||
pr = client.get_pr(pr_number)
|
pr = client.get_pr(pr_number)
|
||||||
return str(pr.get("title", ""))
|
return str(pr.get("title", ""))
|
||||||
except Exception:
|
except APIError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ from typing import Any
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from devx.ci._shared import get_latest_tag
|
from devx.ci._shared import get_latest_tag, write_github_output
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -584,15 +584,8 @@ def has_user_facing_changes(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _write_github_output(key: str, value: str) -> None:
|
# ---------------------------------------------------------------------------
|
||||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
# Classification logic
|
||||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
|
||||||
if not gh_output:
|
|
||||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
|
||||||
with open(gh_output, "a") as f: # noqa: PTH123
|
|
||||||
f.write(f"{key}={value}\n")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CLI
|
# CLI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -633,9 +626,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
|||||||
force = True
|
force = True
|
||||||
|
|
||||||
if force and github_output:
|
if force and github_output:
|
||||||
_write_github_output("user-facing-changed", "true")
|
write_github_output("user-facing-changed", "true")
|
||||||
for tag in available_tags:
|
for tag in available_tags:
|
||||||
_write_github_output(f"{tag}-changed", "true")
|
write_github_output(f"{tag}-changed", "true")
|
||||||
click.echo("Forced user-facing-changed=true via --force flag.")
|
click.echo("Forced user-facing-changed=true via --force flag.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -643,9 +636,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
|||||||
base = get_latest_tag()
|
base = get_latest_tag()
|
||||||
if not base:
|
if not base:
|
||||||
if github_output:
|
if github_output:
|
||||||
_write_github_output("user-facing-changed", "true")
|
write_github_output("user-facing-changed", "true")
|
||||||
for tag in available_tags:
|
for tag in available_tags:
|
||||||
_write_github_output(f"{tag}-changed", "true")
|
write_github_output(f"{tag}-changed", "true")
|
||||||
click.echo("No tags found — treating all changes as user-facing.")
|
click.echo("No tags found — treating all changes as user-facing.")
|
||||||
return
|
return
|
||||||
if quiet:
|
if quiet:
|
||||||
@@ -657,9 +650,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
|||||||
files = get_changed_files(base, head)
|
files = get_changed_files(base, head)
|
||||||
if not files:
|
if not files:
|
||||||
if github_output:
|
if github_output:
|
||||||
_write_github_output("user-facing-changed", "false")
|
write_github_output("user-facing-changed", "false")
|
||||||
for tag in available_tags:
|
for tag in available_tags:
|
||||||
_write_github_output(f"{tag}-changed", "false")
|
write_github_output(f"{tag}-changed", "false")
|
||||||
click.echo(f"No changes between {base} and {head}.")
|
click.echo(f"No changes between {base} and {head}.")
|
||||||
return
|
return
|
||||||
if quiet:
|
if quiet:
|
||||||
@@ -671,9 +664,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
|||||||
result = classifier.classify(files)
|
result = classifier.classify(files)
|
||||||
|
|
||||||
if github_output:
|
if github_output:
|
||||||
_write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
|
write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
|
||||||
for tag in available_tags:
|
for tag in available_tags:
|
||||||
_write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
|
write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
|
||||||
click.echo(f"User-facing files changed: {result.has_user_facing}")
|
click.echo(f"User-facing files changed: {result.has_user_facing}")
|
||||||
for tag in available_tags:
|
for tag in available_tags:
|
||||||
click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}")
|
click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}")
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ Usage::
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from devx.ci._shared import write_github_output
|
||||||
|
|
||||||
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
|
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
|
||||||
|
|
||||||
|
|
||||||
@@ -39,15 +40,6 @@ def is_release_commit(message: str) -> bool:
|
|||||||
return bool(RELEASE_RE.match(message))
|
return bool(RELEASE_RE.match(message))
|
||||||
|
|
||||||
|
|
||||||
def write_github_output(key: str, value: str) -> None:
|
|
||||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
|
||||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
|
||||||
if not gh_output:
|
|
||||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
|
||||||
with open(gh_output, "a") as f: # noqa: PTH123
|
|
||||||
f.write(f"{key}={value}\n")
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
|
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ def main(
|
|||||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||||
if not gh_output:
|
if not gh_output:
|
||||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||||
with open(gh_output, "a") as f: # noqa: PTH123
|
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
|
||||||
f.write(f"runner-count={count}\n")
|
f.write(f"runner-count={count}\n")
|
||||||
f.write(f"runner-indices={json.dumps(indices)}\n")
|
f.write(f"runner-indices={json.dumps(indices)}\n")
|
||||||
click.echo(f"Runner count: {count}")
|
click.echo(f"Runner count: {count}")
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import os
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from devx.ci._shared import lpt_distribute, write_github_env
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
DEFAULT_MAX_RUNNERS = 3
|
DEFAULT_MAX_RUNNERS = 3
|
||||||
@@ -54,15 +55,7 @@ def distribute(files: list[str], max_runners: int) -> list[list[str]]:
|
|||||||
the runner with the least total weight.
|
the runner with the least total weight.
|
||||||
"""
|
"""
|
||||||
weights = [_file_weight(f) for f in files]
|
weights = [_file_weight(f) for f in files]
|
||||||
groups: list[list[str]] = [[] for _ in range(max_runners)]
|
return lpt_distribute(files, weights, max_runners)
|
||||||
loads = [0] * max_runners
|
|
||||||
# Sort by weight descending, preserving original order for ties
|
|
||||||
indexed = sorted(enumerate(files), key=lambda x: (-weights[x[0]], x[0]))
|
|
||||||
for orig_idx, f in indexed:
|
|
||||||
min_runner = min(range(max_runners), key=lambda r: loads[r])
|
|
||||||
groups[min_runner].append(f)
|
|
||||||
loads[min_runner] += weights[orig_idx]
|
|
||||||
return groups
|
|
||||||
|
|
||||||
|
|
||||||
def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]:
|
def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]:
|
||||||
@@ -75,19 +68,6 @@ def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> l
|
|||||||
return groups[runner_index]
|
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:
|
|
||||||
# Multi-line values require the heredoc syntax in $GITHUB_ENV.
|
|
||||||
delimiter = "EOF"
|
|
||||||
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
|
|
||||||
else:
|
|
||||||
f.write(f"{key}={value}\n")
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.option("--pattern", required=True, help="Glob pattern for files to distribute.")
|
@click.option("--pattern", required=True, help="Glob pattern for files to distribute.")
|
||||||
@click.option(
|
@click.option(
|
||||||
@@ -127,8 +107,8 @@ def main(pattern: str, runner_index: int | None, max_runners: int, github_env: b
|
|||||||
|
|
||||||
if skip_if_excess and github_env and runner_index > max_runners:
|
if skip_if_excess and github_env and runner_index > max_runners:
|
||||||
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
||||||
_write_github_env("ASSIGNED_FILES", "")
|
write_github_env("ASSIGNED_FILES", "")
|
||||||
_write_github_env("SKIP", "true")
|
write_github_env("SKIP", "true")
|
||||||
return
|
return
|
||||||
|
|
||||||
if runner_index < 1:
|
if runner_index < 1:
|
||||||
@@ -139,8 +119,8 @@ def main(pattern: str, runner_index: int | None, max_runners: int, github_env: b
|
|||||||
encoded = "\n".join(assigned)
|
encoded = "\n".join(assigned)
|
||||||
|
|
||||||
if github_env:
|
if github_env:
|
||||||
_write_github_env("ASSIGNED_FILES", encoded)
|
write_github_env("ASSIGNED_FILES", encoded)
|
||||||
_write_github_env("SKIP", "false")
|
write_github_env("SKIP", "false")
|
||||||
click.echo(f"Assigned {len(assigned)} files to runner {runner_index}")
|
click.echo(f"Assigned {len(assigned)} files to runner {runner_index}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/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 sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.ci._shared import lpt_distribute, write_github_env
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
return lpt_distribute(items, weights, max_runners)
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
@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, encoding="utf-8") 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()
|
||||||
@@ -13,7 +13,8 @@ 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 VikunjaClient
|
from devx.api_clients import VikunjaClient
|
||||||
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
from devx.ci._shared import extract_task_id as _extract_task_id
|
||||||
|
from devx.config import DEFAULT_PER_PAGE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||||
from devx.exceptions import APIError
|
from devx.exceptions import APIError
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
@@ -47,10 +48,9 @@ def _get_git_commit_sha() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def extract_task_id(commit_msg: str) -> str:
|
def extract_task_id(commit_msg: str) -> str:
|
||||||
"""Extract DEVX-N task identifier from the first line of commit message."""
|
"""Extract task identifier from the first line of commit message (delegates to shared utility)."""
|
||||||
first_line = commit_msg.split("\n")[0]
|
first_line = commit_msg.split("\n")[0]
|
||||||
match = TASK_ID_RE.search(first_line)
|
return _extract_task_id(first_line)
|
||||||
return match.group(0) if match else ""
|
|
||||||
|
|
||||||
|
|
||||||
def extract_conventional_msg(commit_msg: str) -> str:
|
def extract_conventional_msg(commit_msg: str) -> str:
|
||||||
@@ -61,7 +61,7 @@ def extract_conventional_msg(commit_msg: str) -> str:
|
|||||||
- ``DEVX-N <message>`` (current, space-separated)
|
- ``DEVX-N <message>`` (current, space-separated)
|
||||||
"""
|
"""
|
||||||
first_line = commit_msg.split("\n")[0]
|
first_line = commit_msg.split("\n")[0]
|
||||||
return re.sub(r"^DEVX-\d+[:\s]\s*", "", first_line)
|
return re.sub(rf"^{TASK_PREFIX}-\d+[:\s]\s*", "", first_line)
|
||||||
|
|
||||||
|
|
||||||
def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
|
def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+14
-36
@@ -37,13 +37,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess # nosec B404
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||||
|
|
||||||
from devx.ci._shared import get_latest_tag
|
from devx.ci._shared import get_latest_tag, run_cmd, write_github_output
|
||||||
from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
|
from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
@@ -54,25 +53,6 @@ CHANGELOG_FILE = "CHANGELOG.md"
|
|||||||
CLIFF_CONFIG = "cliff.toml"
|
CLIFF_CONFIG = "cliff.toml"
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess[str]:
|
|
||||||
"""Run a command and return the completed process."""
|
|
||||||
result = subprocess.run( # nosec B603
|
|
||||||
args,
|
|
||||||
capture_output=capture,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if check and result.returncode != 0:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Command failed ({cmd}): {stderr}",
|
|
||||||
cmd=" ".join(args),
|
|
||||||
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def tag_exists(tag: str) -> bool:
|
def tag_exists(tag: str) -> bool:
|
||||||
"""Check if a git tag already exists."""
|
"""Check if a git tag already exists."""
|
||||||
result = run_cmd(["git", "tag", "-l", tag], check=False)
|
result = run_cmd(["git", "tag", "-l", tag], check=False)
|
||||||
@@ -217,7 +197,7 @@ def has_unreleased_changes(bumped_version: str | None = None) -> bool:
|
|||||||
|
|
||||||
def update_init_version(new_version: str) -> None:
|
def update_init_version(new_version: str) -> None:
|
||||||
"""Update __version__ in __init__.py."""
|
"""Update __version__ in __init__.py."""
|
||||||
with open(INIT_FILE) as f:
|
with open(INIT_FILE, encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE):
|
if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE):
|
||||||
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
|
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
|
||||||
@@ -228,7 +208,7 @@ def update_init_version(new_version: str) -> None:
|
|||||||
count=1,
|
count=1,
|
||||||
flags=re.MULTILINE,
|
flags=re.MULTILINE,
|
||||||
)
|
)
|
||||||
with open(INIT_FILE, "w") as f:
|
with open(INIT_FILE, "w", encoding="utf-8") as f:
|
||||||
f.write(updated)
|
f.write(updated)
|
||||||
|
|
||||||
|
|
||||||
@@ -245,10 +225,10 @@ def update_changelog(changelog: str) -> None:
|
|||||||
changelog = changelog[section_match.start() :]
|
changelog = changelog[section_match.start() :]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(CHANGELOG_FILE) as f:
|
with open(CHANGELOG_FILE, encoding="utf-8") as f:
|
||||||
existing = f.read()
|
existing = f.read()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
with open(CHANGELOG_FILE, "w") as f:
|
with open(CHANGELOG_FILE, "w", encoding="utf-8") as f:
|
||||||
f.write(changelog + "\n")
|
f.write(changelog + "\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -261,7 +241,7 @@ def update_changelog(changelog: str) -> None:
|
|||||||
else:
|
else:
|
||||||
# No version sections found — append
|
# No version sections found — append
|
||||||
updated = existing.rstrip() + "\n\n" + changelog + "\n"
|
updated = existing.rstrip() + "\n\n" + changelog + "\n"
|
||||||
with open(CHANGELOG_FILE, "w") as f:
|
with open(CHANGELOG_FILE, "w", encoding="utf-8") as f:
|
||||||
f.write(updated)
|
f.write(updated)
|
||||||
|
|
||||||
|
|
||||||
@@ -317,18 +297,16 @@ def run_tests() -> None:
|
|||||||
click.echo(_("Tests passed."))
|
click.echo(_("Tests passed."))
|
||||||
|
|
||||||
|
|
||||||
def _write_github_output(tag: str) -> None:
|
def _write_release_tag(tag: str) -> None:
|
||||||
"""Write the release tag to GITHUB_OUTPUT for downstream jobs.
|
"""Write the release tag to GITHUB_OUTPUT for downstream jobs.
|
||||||
|
|
||||||
This allows a publish job (needs: release) to read the tag via
|
This allows a publish job (needs: release) to read the tag via
|
||||||
``${{ needs.release.outputs.tag }}`` instead of relying on
|
``${{ needs.release.outputs.tag }}`` instead of relying on
|
||||||
tag-push event triggering a separate workflow.
|
tag-push event triggering a separate workflow.
|
||||||
"""
|
"""
|
||||||
github_output = os.environ.get("GITHUB_OUTPUT")
|
if not os.environ.get("GITHUB_OUTPUT"):
|
||||||
if not github_output:
|
|
||||||
return
|
return
|
||||||
with open(github_output, "a") as f: # noqa: PTH123
|
write_github_output("tag", tag)
|
||||||
f.write(f"tag={tag}\n")
|
|
||||||
click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag))
|
click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag))
|
||||||
|
|
||||||
|
|
||||||
@@ -360,7 +338,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
|||||||
if not dry_run:
|
if not dry_run:
|
||||||
# Ensure the existing tag is pushed
|
# Ensure the existing tag is pushed
|
||||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
|
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
|
||||||
_write_github_output(tag)
|
_write_release_tag(tag)
|
||||||
return False
|
return False
|
||||||
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
||||||
if dry_run:
|
if dry_run:
|
||||||
@@ -368,7 +346,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
|||||||
return True
|
return True
|
||||||
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
||||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
|
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
|
||||||
_write_github_output(tag)
|
_write_release_tag(tag)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -380,7 +358,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
|||||||
def get_init_version() -> str | None:
|
def get_init_version() -> str | None:
|
||||||
"""Read __version__ from the version file."""
|
"""Read __version__ from the version file."""
|
||||||
try:
|
try:
|
||||||
with open(INIT_FILE) as f:
|
with open(INIT_FILE, encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE)
|
match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE)
|
||||||
return match.group(1) if match else None
|
return match.group(1) if match else None
|
||||||
@@ -391,7 +369,7 @@ def get_init_version() -> str | None:
|
|||||||
def get_changelog_versions() -> list[str]:
|
def get_changelog_versions() -> list[str]:
|
||||||
"""Extract version numbers from CHANGELOG.md headers, in order."""
|
"""Extract version numbers from CHANGELOG.md headers, in order."""
|
||||||
try:
|
try:
|
||||||
with open(CHANGELOG_FILE) as f:
|
with open(CHANGELOG_FILE, encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE)
|
return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -634,7 +612,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
|||||||
tag=release_tag,
|
tag=release_tag,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
_write_github_output(release_tag)
|
_write_release_tag(release_tag)
|
||||||
return
|
return
|
||||||
# Tag is missing — recover by creating and pushing it
|
# Tag is missing — recover by creating and pushing it
|
||||||
click.echo(
|
click.echo(
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def load_mapping() -> dict[str, str]:
|
|||||||
|
|
||||||
Validates that the mapping is a dict of string-to-string pairs.
|
Validates that the mapping is a dict of string-to-string pairs.
|
||||||
"""
|
"""
|
||||||
with open(MAPPING_FILE) as f:
|
with open(MAPPING_FILE, encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
@@ -64,7 +64,7 @@ def load_mapping() -> dict[str, str]:
|
|||||||
def read_doc_content(file_path: str) -> str:
|
def read_doc_content(file_path: str) -> str:
|
||||||
"""Read markdown content from a docs file."""
|
"""Read markdown content from a docs file."""
|
||||||
full_path = DOCS_DIR / file_path
|
full_path = DOCS_DIR / file_path
|
||||||
with open(full_path) as f:
|
with open(full_path, encoding="utf-8") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> Non
|
|||||||
if commit_msg_file == "-":
|
if commit_msg_file == "-":
|
||||||
msg = sys.stdin.read().strip()
|
msg = sys.stdin.read().strip()
|
||||||
else:
|
else:
|
||||||
with open(commit_msg_file) as f:
|
with open(commit_msg_file, encoding="utf-8") as f:
|
||||||
msg = f.read().strip()
|
msg = f.read().strip()
|
||||||
else:
|
else:
|
||||||
raise click.ClickException(_("Provide a commit message file or use --git."))
|
raise click.ClickException(_("Provide a commit message file or use --git."))
|
||||||
|
|||||||
+12
-2
@@ -63,7 +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
|
.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
|
||||||
@@ -124,6 +124,16 @@ devx-pr-label:
|
|||||||
$(if $(PR),--pr $(PR)) \
|
$(if $(PR),--pr $(PR)) \
|
||||||
--label $(or $(LABEL),ready-to-merge)
|
--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
|
||||||
@@ -320,7 +330,7 @@ devx-setup-image:
|
|||||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(DEVX_VENV); . $(DEVX_BIN)/activate; \
|
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(DEVX_VENV); . $(DEVX_BIN)/activate; \
|
||||||
_U="$${CI_GITEA_USERNAME:-emil}"; \
|
_U="$${CI_GITEA_USERNAME:-emil}"; \
|
||||||
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$_U:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
|
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$_U:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
|
||||||
pip install -e .$(if $(EXTRAS),[$(EXTRAS)],); \
|
pip install --no-cache-dir -e .$(if $(EXTRAS),[$(EXTRAS)],); \
|
||||||
echo "[devx-setup-image] Linked /opt/venv$(if $(EXTRAS), with [$(EXTRAS)],)."; \
|
echo "[devx-setup-image] Linked /opt/venv$(if $(EXTRAS), with [$(EXTRAS)],)."; \
|
||||||
else echo "[devx-setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
else echo "[devx-setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Molecule testing helpers for Ansible projects."""
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ def main(
|
|||||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||||
if not gh_output:
|
if not gh_output:
|
||||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||||
with open(gh_output, "a") as f: # noqa: PTH123
|
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
|
||||||
f.write(f"runner-count={count}\n")
|
f.write(f"runner-count={count}\n")
|
||||||
f.write(f"runner-indices={json.dumps(indices)}\n")
|
f.write(f"runner-indices={json.dumps(indices)}\n")
|
||||||
click.echo(f"Runner count: {count}")
|
click.echo(f"Runner count: {count}")
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from devx.ci._shared import lpt_distribute, write_github_env
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
from devx.molecule.platforms import PLATFORMS, load_platforms
|
from devx.molecule.platforms import PLATFORMS, load_platforms
|
||||||
|
|
||||||
@@ -216,22 +217,8 @@ def _scenario_weight(scenario: str, role: str | None = None) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
|
def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
|
||||||
"""Distribute *items* across *max_runners* using LPT (Longest Processing Time first).
|
"""Distribute *items* across *max_runners* using LPT (delegates to shared utility)."""
|
||||||
|
return lpt_distribute(items, weights, max_runners)
|
||||||
Sorts items by weight (descending), then assigns each to the runner
|
|
||||||
with the least total weight. This produces a more balanced distribution
|
|
||||||
than naive round-robin when items have varying costs.
|
|
||||||
"""
|
|
||||||
groups: list[list[T]] = [[] for _ in range(max_runners)]
|
|
||||||
loads = [0] * max_runners
|
|
||||||
# Sort by weight descending, preserving original order for ties
|
|
||||||
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
|
|
||||||
for orig_idx, item in indexed:
|
|
||||||
# Find the runner with the minimum load
|
|
||||||
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 distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
||||||
@@ -283,14 +270,8 @@ def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int)
|
|||||||
|
|
||||||
|
|
||||||
def _write_github_env(key: str, value: str) -> None:
|
def _write_github_env(key: str, value: str) -> None:
|
||||||
"""Append a key=value line to the $GITHUB_ENV file."""
|
"""Append a key=value line to the $GITHUB_ENV file (delegates to shared utility)."""
|
||||||
import os
|
write_github_env(key, value)
|
||||||
|
|
||||||
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
|
|
||||||
f.write(f"{key}={value}\n")
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def load_platforms(platforms_file: str | Path | None = None) -> list[dict[str, s
|
|||||||
path = Path(platforms_file)
|
path = Path(platforms_file)
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return PLATFORMS
|
return PLATFORMS
|
||||||
with path.open() as f:
|
with path.open(encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
if not isinstance(data, list) or not data:
|
if not isinstance(data, list) or not data:
|
||||||
return PLATFORMS
|
return PLATFORMS
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
|||||||
click.echo(_("Docker daemon failed to start"))
|
click.echo(_("Docker daemon failed to start"))
|
||||||
click.echo("--- dockerd log ---")
|
click.echo("--- dockerd log ---")
|
||||||
try:
|
try:
|
||||||
with open(log_file.name) as f:
|
with open(log_file.name, encoding="utf-8") as f:
|
||||||
log_content = f.read()
|
log_content = f.read()
|
||||||
click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content)
|
click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -191,7 +191,7 @@ def main(timeout: int) -> None:
|
|||||||
# Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps
|
# Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps
|
||||||
github_env = os.environ.get("GITHUB_ENV")
|
github_env = os.environ.get("GITHUB_ENV")
|
||||||
if github_env and os.environ.get("DOCKER_HOST"):
|
if github_env and os.environ.get("DOCKER_HOST"):
|
||||||
with open(github_env, "a") as f:
|
with open(github_env, "a", encoding="utf-8") as f:
|
||||||
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
|
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
|
||||||
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
|
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Shared utilities for tools modules."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import platform
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
def arch_string() -> str:
|
||||||
|
"""Return the architecture string used by release assets.
|
||||||
|
|
||||||
|
Maps ``platform.machine()`` to the common release asset naming:
|
||||||
|
``amd64`` for x86_64, ``arm64`` for aarch64.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
click.ClickException: If the architecture is not supported.
|
||||||
|
"""
|
||||||
|
machine = platform.machine().lower()
|
||||||
|
if machine in {"x86_64", "amd64"}:
|
||||||
|
return "amd64"
|
||||||
|
if machine in {"aarch64", "arm64"}:
|
||||||
|
return "arm64"
|
||||||
|
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||||
@@ -88,7 +88,7 @@ def load_manifest(path: str | Path) -> list[ImageSpec]:
|
|||||||
p = Path(path)
|
p = Path(path)
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
raise click.ClickException(_("Manifest file not found: {path}", path=p))
|
raise click.ClickException(_("Manifest file not found: {path}", path=p))
|
||||||
with p.open() as f: # noqa: PTH123
|
with p.open(encoding="utf-8") as f: # noqa: PTH123
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
if not isinstance(data, list):
|
if not isinstance(data, list):
|
||||||
raise click.ClickException(_("Manifest must be a JSON list"))
|
raise click.ClickException(_("Manifest must be a JSON list"))
|
||||||
|
|||||||
@@ -50,11 +50,17 @@ def _default_branch_protection_config() -> dict[str, Any]:
|
|||||||
"push_whitelist_usernames": [],
|
"push_whitelist_usernames": [],
|
||||||
"enable_status_check": True,
|
"enable_status_check": True,
|
||||||
"status_check_contexts": _default_status_checks(),
|
"status_check_contexts": _default_status_checks(),
|
||||||
"required_approvals": 0,
|
"required_approvals": 1,
|
||||||
"dismiss_stale_approvals": True,
|
"dismiss_stale_approvals": True,
|
||||||
"block_on_outdated_branch": True,
|
"block_on_outdated_branch": True,
|
||||||
"block_on_rejected_reviews": True,
|
"block_on_rejected_reviews": True,
|
||||||
"block_on_official_review_requests": True,
|
"block_on_official_review_requests": True,
|
||||||
|
# Prevent admins from force-merging PRs that don't meet branch
|
||||||
|
# protection requirements (e.g. missing approvals). Without this,
|
||||||
|
# an admin token can bypass the approval gate via force_merge=true,
|
||||||
|
# allowing merges that failed the auto-merge CI job to reach master
|
||||||
|
# and trigger the post-merge release pipeline.
|
||||||
|
"block_admin_merge_override": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -123,6 +129,7 @@ def configure_repo(
|
|||||||
click.echo(_(" - Dismiss stale approvals: yes"))
|
click.echo(_(" - Dismiss stale approvals: yes"))
|
||||||
click.echo(_(" - Block outdated branches: yes"))
|
click.echo(_(" - Block outdated branches: yes"))
|
||||||
click.echo(_(" - Block rejected reviews: yes"))
|
click.echo(_(" - Block rejected reviews: yes"))
|
||||||
|
click.echo(_(" - Block admin merge override: yes"))
|
||||||
checks = ", ".join(cast(list[str], bp_config["status_check_contexts"]))
|
checks = ", ".join(cast(list[str], bp_config["status_check_contexts"]))
|
||||||
click.echo(_(" - Required status checks: {checks}", checks=checks))
|
click.echo(_(" - Required status checks: {checks}", checks=checks))
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ pre-built Linux binary from the official GitHub releases.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import platform
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -15,6 +14,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from devx.tools._shared import arch_string
|
||||||
|
|
||||||
CHECKMAKE_VERSION = "0.3.2"
|
CHECKMAKE_VERSION = "0.3.2"
|
||||||
RELEASE_URL_TEMPLATE = (
|
RELEASE_URL_TEMPLATE = (
|
||||||
"https://github.com/checkmake/checkmake/releases/download/"
|
"https://github.com/checkmake/checkmake/releases/download/"
|
||||||
@@ -23,18 +24,11 @@ RELEASE_URL_TEMPLATE = (
|
|||||||
TARGET_PATH = Path("/usr/local/bin/checkmake")
|
TARGET_PATH = Path("/usr/local/bin/checkmake")
|
||||||
|
|
||||||
|
|
||||||
def _arch() -> str:
|
|
||||||
"""Return the architecture string used by checkmake releases."""
|
|
||||||
machine = platform.machine().lower()
|
|
||||||
if machine in {"x86_64", "amd64"}:
|
|
||||||
return "amd64"
|
|
||||||
if machine in {"aarch64", "arm64"}:
|
|
||||||
return "arm64"
|
|
||||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
|
||||||
|
|
||||||
|
|
||||||
def _install_with_go() -> bool:
|
def _install_with_go() -> bool:
|
||||||
"""Install checkmake using go install if Go is available."""
|
"""Install checkmake using go install if Go is available.
|
||||||
|
|
||||||
|
Returns True if the installation succeeded, False if Go is not installed.
|
||||||
|
"""
|
||||||
go_bin = shutil.which("go")
|
go_bin = shutil.which("go")
|
||||||
if go_bin is None:
|
if go_bin is None:
|
||||||
return False
|
return False
|
||||||
@@ -51,7 +45,7 @@ def _install_with_go() -> bool:
|
|||||||
|
|
||||||
def _download_binary() -> None:
|
def _download_binary() -> None:
|
||||||
"""Download the prebuilt checkmake binary for the current architecture."""
|
"""Download the prebuilt checkmake binary for the current architecture."""
|
||||||
url = RELEASE_URL_TEMPLATE.format(arch=_arch())
|
url = RELEASE_URL_TEMPLATE.format(arch=arch_string())
|
||||||
urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310
|
urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310
|
||||||
TARGET_PATH.chmod(0o755)
|
TARGET_PATH.chmod(0o755)
|
||||||
|
|
||||||
|
|||||||
@@ -44,13 +44,10 @@ HADOLINT_VERSION = "2.12.0"
|
|||||||
|
|
||||||
|
|
||||||
def _arch() -> str:
|
def _arch() -> str:
|
||||||
"""Return the architecture string used by release assets."""
|
"""Return the architecture string used by release assets (delegates to shared utility)."""
|
||||||
machine = platform.machine().lower()
|
from devx.tools._shared import arch_string
|
||||||
if machine in {"x86_64", "amd64"}:
|
|
||||||
return "amd64"
|
return arch_string()
|
||||||
if machine in {"aarch64", "arm64"}:
|
|
||||||
return "arm64"
|
|
||||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_target_dir() -> Path:
|
def _ensure_target_dir() -> Path:
|
||||||
|
|||||||
@@ -639,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.",
|
||||||
@@ -2214,5 +2222,61 @@
|
|||||||
"pl": "Missing tests for changed files.",
|
"pl": "Missing tests for changed files.",
|
||||||
"ru": "Missing tests for changed files.",
|
"ru": "Missing tests for changed files.",
|
||||||
"zh": "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."
|
||||||
|
},
|
||||||
|
" - Block admin merge override: yes": {
|
||||||
|
"bg": " - Блокиране на admin merge override: да",
|
||||||
|
"de": " - Admin-Merge-Override blockieren: ja",
|
||||||
|
"en": " - Block admin merge override: yes",
|
||||||
|
"pl": " - Blokuj admin merge override: tak",
|
||||||
|
"ru": " - Блокировать admin merge override: да",
|
||||||
|
"zh": " - 阻止管理员合并覆盖:是"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from devx.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error
|
from devx.api_clients import GiteaClient, VikunjaClient, _parse_error
|
||||||
from devx.config import (
|
from devx.config import (
|
||||||
DEFAULT_PER_PAGE,
|
DEFAULT_PER_PAGE,
|
||||||
DEFAULT_TIMEOUT,
|
DEFAULT_TIMEOUT,
|
||||||
@@ -507,7 +507,7 @@ class TestGiteaClient:
|
|||||||
assert result["id"] == 1
|
assert result["id"] == 1
|
||||||
assert client._session.request.call_count == 2
|
assert client._session.request.call_count == 2
|
||||||
|
|
||||||
@patch("devx.api_clients.time.sleep")
|
@patch("time.sleep")
|
||||||
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
|
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
|
||||||
"""Should retry on 429 rate limit with exponential backoff."""
|
"""Should retry on 429 rate limit with exponential backoff."""
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
@@ -520,7 +520,7 @@ class TestGiteaClient:
|
|||||||
assert client._session.request.call_count == 3
|
assert client._session.request.call_count == 3
|
||||||
assert mock_sleep.call_count == 2
|
assert mock_sleep.call_count == 2
|
||||||
|
|
||||||
@patch("devx.api_clients.time.sleep")
|
@patch("time.sleep")
|
||||||
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||||
"""Should retry on 503 service unavailable."""
|
"""Should retry on 503 service unavailable."""
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
@@ -532,7 +532,7 @@ class TestGiteaClient:
|
|||||||
assert result.json() == {"ok": True}
|
assert result.json() == {"ok": True}
|
||||||
assert client._session.request.call_count == 2
|
assert client._session.request.call_count == 2
|
||||||
|
|
||||||
@patch("devx.api_clients.time.sleep")
|
@patch("time.sleep")
|
||||||
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
|
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
|
||||||
"""Should NOT retry on 404 — it's not a transient error."""
|
"""Should NOT retry on 404 — it's not a transient error."""
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
@@ -545,7 +545,7 @@ class TestGiteaClient:
|
|||||||
assert client._session.request.call_count == 1
|
assert client._session.request.call_count == 1
|
||||||
mock_sleep.assert_not_called()
|
mock_sleep.assert_not_called()
|
||||||
|
|
||||||
@patch("devx.api_clients.time.sleep")
|
@patch("time.sleep")
|
||||||
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||||
"""Should retry on connection errors."""
|
"""Should retry on connection errors."""
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
@@ -555,7 +555,7 @@ class TestGiteaClient:
|
|||||||
assert result.json() == {"ok": True}
|
assert result.json() == {"ok": True}
|
||||||
assert client._session.request.call_count == 2
|
assert client._session.request.call_count == 2
|
||||||
|
|
||||||
@patch("devx.api_clients.time.sleep")
|
@patch("time.sleep")
|
||||||
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||||
"""Should raise APIError after max retries on persistent 503."""
|
"""Should raise APIError after max retries on persistent 503."""
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
@@ -567,7 +567,7 @@ class TestGiteaClient:
|
|||||||
assert exc_info.value.status == 503
|
assert exc_info.value.status == 503
|
||||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||||
|
|
||||||
@patch("devx.api_clients.time.sleep")
|
@patch("time.sleep")
|
||||||
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||||
"""Should raise APIError after max retries on persistent connection errors."""
|
"""Should raise APIError after max retries on persistent connection errors."""
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
@@ -691,7 +691,7 @@ class TestVikunjaClient:
|
|||||||
json={"id": 42, "title": "My task", "done": True},
|
json={"id": 42, "title": "My task", "done": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("devx.api_clients.time.sleep")
|
@patch("time.sleep")
|
||||||
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
|
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
|
||||||
client = VikunjaClient("https://work.example.com", "tok")
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
mock_resp = MagicMock()
|
mock_resp = MagicMock()
|
||||||
@@ -713,7 +713,7 @@ class TestVikunjaClient:
|
|||||||
client.list_tasks()
|
client.list_tasks()
|
||||||
assert "connection failed" in str(exc_info.value)
|
assert "connection failed" in str(exc_info.value)
|
||||||
|
|
||||||
@patch("devx.api_clients.time.sleep")
|
@patch("time.sleep")
|
||||||
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||||
"""VikunjaClient should also retry on 503."""
|
"""VikunjaClient should also retry on 503."""
|
||||||
client = VikunjaClient("https://work.example.com", "tok")
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
@@ -725,7 +725,7 @@ class TestVikunjaClient:
|
|||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert client._session.request.call_count == 2
|
assert client._session.request.call_count == 2
|
||||||
|
|
||||||
@patch("devx.api_clients.time.sleep")
|
@patch("time.sleep")
|
||||||
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||||
"""VikunjaClient should retry on connection errors."""
|
"""VikunjaClient should retry on connection errors."""
|
||||||
client = VikunjaClient("https://work.example.com", "tok")
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
@@ -735,7 +735,7 @@ class TestVikunjaClient:
|
|||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert client._session.request.call_count == 2
|
assert client._session.request.call_count == 2
|
||||||
|
|
||||||
@patch("devx.api_clients.time.sleep")
|
@patch("time.sleep")
|
||||||
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||||
"""VikunjaClient should raise APIError after max retries on persistent 503."""
|
"""VikunjaClient should raise APIError after max retries on persistent 503."""
|
||||||
client = VikunjaClient("https://work.example.com", "tok")
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
@@ -747,7 +747,7 @@ class TestVikunjaClient:
|
|||||||
assert exc_info.value.status == 503
|
assert exc_info.value.status == 503
|
||||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||||
|
|
||||||
@patch("devx.api_clients.time.sleep")
|
@patch("time.sleep")
|
||||||
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||||
"""VikunjaClient should raise APIError after max retries on persistent connection errors."""
|
"""VikunjaClient should raise APIError after max retries on persistent connection errors."""
|
||||||
client = VikunjaClient("https://work.example.com", "tok")
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
@@ -818,22 +818,6 @@ class TestVikunjaClient:
|
|||||||
assert result["title"] == "Found on page 2"
|
assert result["title"] == "Found on page 2"
|
||||||
|
|
||||||
|
|
||||||
class TestIsRetryable:
|
|
||||||
def test_connection_error_is_retryable(self) -> None:
|
|
||||||
assert _is_retryable(requests.ConnectionError("refused")) is True
|
|
||||||
|
|
||||||
def test_timeout_is_retryable(self) -> None:
|
|
||||||
assert _is_retryable(requests.Timeout("timed out")) is True
|
|
||||||
|
|
||||||
def test_429_is_retryable(self) -> None:
|
|
||||||
err = _mock_http_error(429, "rate limited")
|
|
||||||
assert _is_retryable(err) is True
|
|
||||||
|
|
||||||
def test_404_is_not_retryable(self) -> None:
|
|
||||||
err = _mock_http_error(404, "not found")
|
|
||||||
assert _is_retryable(err) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestGiteaClientPrLabels:
|
class TestGiteaClientPrLabels:
|
||||||
def test_add_pr_label(self) -> None:
|
def test_add_pr_label(self) -> None:
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
@@ -913,8 +897,3 @@ class TestGiteaClientActions:
|
|||||||
"https://git.example.com/repos/owner/repo/actions/jobs/10026/logs",
|
"https://git.example.com/repos/owner/repo/actions/jobs/10026/logs",
|
||||||
timeout=DEFAULT_TIMEOUT,
|
timeout=DEFAULT_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestIsRetryableGeneric:
|
|
||||||
def test_generic_exception_is_not_retryable(self) -> None:
|
|
||||||
assert _is_retryable(ValueError("oops")) is False
|
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import click
|
|||||||
import pytest
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.ci._shared import run_cmd
|
||||||
from devx.ci.auto_merge import (
|
from devx.ci.auto_merge import (
|
||||||
extract_conventional_msg,
|
extract_conventional_msg,
|
||||||
extract_task_id,
|
extract_task_id,
|
||||||
main,
|
main,
|
||||||
read_taskid,
|
read_taskid,
|
||||||
run_cmd,
|
|
||||||
validate_pr_title,
|
validate_pr_title,
|
||||||
validate_pr_title_matches_vikunja,
|
validate_pr_title_matches_vikunja,
|
||||||
)
|
)
|
||||||
@@ -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 --
|
||||||
|
|
||||||
@@ -376,7 +399,7 @@ class TestMain:
|
|||||||
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
|
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
|
||||||
mock_client_cls.return_value = mock_client
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
|
with patch("devx.ci._shared.run_cmd") as mock_run:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
main,
|
main,
|
||||||
|
|||||||
@@ -92,8 +92,10 @@ class TestGetPrTitleFromGitea:
|
|||||||
|
|
||||||
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
|
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
|
||||||
def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None:
|
def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
from devx.exceptions import APIError
|
||||||
|
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.get_pr.side_effect = Exception("API error")
|
mock_client.get_pr.side_effect = APIError(500, "API error")
|
||||||
mock_client_cls.return_value = mock_client
|
mock_client_cls.return_value = mock_client
|
||||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
|
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
|
||||||
result = get_pr_title_from_gitea("owner/repo", 1)
|
result = get_pr_title_from_gitea("owner/repo", 1)
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ class TestDefaultConfigs:
|
|||||||
assert config["branch_name"] == "master"
|
assert config["branch_name"] == "master"
|
||||||
assert config["enable_push"] is True
|
assert config["enable_push"] is True
|
||||||
assert config["enable_push_whitelist"] is False
|
assert config["enable_push_whitelist"] is False
|
||||||
assert config["required_approvals"] == 0
|
assert config["required_approvals"] == 1
|
||||||
assert isinstance(config["status_check_contexts"], list)
|
assert isinstance(config["status_check_contexts"], list)
|
||||||
assert "CI / quality (pull_request)" in config["status_check_contexts"]
|
assert "CI / quality (pull_request)" in config["status_check_contexts"]
|
||||||
|
assert config["block_admin_merge_override"] is True
|
||||||
|
|
||||||
def test_default_repo_settings_config(self) -> None:
|
def test_default_repo_settings_config(self) -> None:
|
||||||
config = _default_repo_settings_config()
|
config = _default_repo_settings_config()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,21 +8,22 @@ import pytest
|
|||||||
from click import ClickException
|
from click import ClickException
|
||||||
|
|
||||||
import devx.tools.install_checkmake as install_checkmake
|
import devx.tools.install_checkmake as install_checkmake
|
||||||
|
from devx.tools._shared import arch_string
|
||||||
|
|
||||||
|
|
||||||
class TestArch:
|
class TestArchString:
|
||||||
def test_amd64(self) -> None:
|
def test_amd64(self) -> None:
|
||||||
with patch.object(platform, "machine", return_value="x86_64"):
|
with patch.object(platform, "machine", return_value="x86_64"):
|
||||||
assert install_checkmake._arch() == "amd64"
|
assert arch_string() == "amd64"
|
||||||
|
|
||||||
def test_arm64(self) -> None:
|
def test_arm64(self) -> None:
|
||||||
with patch.object(platform, "machine", return_value="aarch64"):
|
with patch.object(platform, "machine", return_value="aarch64"):
|
||||||
assert install_checkmake._arch() == "arm64"
|
assert arch_string() == "arm64"
|
||||||
|
|
||||||
def test_unsupported(self) -> None:
|
def test_unsupported(self) -> None:
|
||||||
with patch.object(platform, "machine", return_value="riscv64"):
|
with patch.object(platform, "machine", return_value="riscv64"):
|
||||||
with pytest.raises(ClickException):
|
with pytest.raises(ClickException):
|
||||||
install_checkmake._arch()
|
arch_string()
|
||||||
|
|
||||||
|
|
||||||
class TestInstallWithGo:
|
class TestInstallWithGo:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -34,20 +34,20 @@ from devx.ci.release import (
|
|||||||
|
|
||||||
|
|
||||||
class TestRunCmd:
|
class TestRunCmd:
|
||||||
@patch("devx.ci.release.subprocess.run")
|
@patch("devx.ci._shared.subprocess.run")
|
||||||
def test_success(self, mock_run: MagicMock) -> None:
|
def test_success(self, mock_run: MagicMock) -> None:
|
||||||
mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="")
|
mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="")
|
||||||
result = run_cmd(["echo", "hi"])
|
result = run_cmd(["echo", "hi"])
|
||||||
assert result.returncode == 0
|
assert result.returncode == 0
|
||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
|
|
||||||
@patch("devx.ci.release.subprocess.run")
|
@patch("devx.ci._shared.subprocess.run")
|
||||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||||
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
|
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
|
||||||
with pytest.raises(click.ClickException):
|
with pytest.raises(click.ClickException):
|
||||||
run_cmd(["false"])
|
run_cmd(["false"])
|
||||||
|
|
||||||
@patch("devx.ci.release.subprocess.run")
|
@patch("devx.ci._shared.subprocess.run")
|
||||||
def test_check_false_no_raise(self, mock_run: MagicMock) -> None:
|
def test_check_false_no_raise(self, mock_run: MagicMock) -> None:
|
||||||
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
|
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
|
||||||
result = run_cmd(["false"], check=False)
|
result = run_cmd(["false"], check=False)
|
||||||
|
|||||||
@@ -272,18 +272,16 @@ class TestMain:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_start.assert_called_once_with(60)
|
mock_start.assert_called_once_with(60)
|
||||||
|
|
||||||
@patch("devx.molecule.start_docker.os.environ.get")
|
|
||||||
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
||||||
def test_exports_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None:
|
def test_exports_github_env(self, mock_start: MagicMock) -> None:
|
||||||
"""Should write DOCKER_HOST to GITHUB_ENV when available."""
|
"""Should write DOCKER_HOST to GITHUB_ENV when available."""
|
||||||
mock_get.side_effect = lambda key, default="": (
|
env = {"GITHUB_ENV": "/tmp/github_env", "DOCKER_HOST": f"unix://{DOCKER_SOCK}"}
|
||||||
"/tmp/github_env" if key == "GITHUB_ENV" else f"unix://{DOCKER_SOCK}" if key == "DOCKER_HOST" else default
|
with patch.dict("os.environ", env, clear=True):
|
||||||
)
|
|
||||||
with patch("builtins.open", mock_open()) as mock_file:
|
with patch("builtins.open", mock_open()) as mock_file:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, [])
|
result = runner.invoke(main, [])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_file.assert_called_with("/tmp/github_env", "a")
|
mock_file.assert_called_with("/tmp/github_env", "a", encoding="utf-8")
|
||||||
|
|
||||||
@patch("devx.molecule.start_docker.os.environ.get", return_value="")
|
@patch("devx.molecule.start_docker.os.environ.get", return_value="")
|
||||||
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user