Compare commits

...
5 Commits
Author SHA1 Message Date
devx-ci-bot 1d7bf7118a release: v0.36.0 [skip ci] 2026-07-07 11:58:00 +00:00
emil f98534ebe2 DEVX-119: feat: add GiteaClient repo variable methods and parallelize pytest-cov 2026-07-07 11:57:04 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c62b168b25 DEVX-116: chore: update grm package name references
Update hardcoded path and docstring examples from
`gitea_runner_manager` to `grm` after the package rename in grm PR #203.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 15:51:10 +02:00
devx-ci-bot 40a94df029 release: v0.35.7 [skip ci] 2026-07-06 13:22:27 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f50c4c1e00 DEVX-118: fix: use Gitea wiki dash-marker filename convention
Gitea appends a ".-" suffix before ".md" for wiki page titles that
contain dashes, to distinguish literal dashes from space-to-dash
conversions. For example, "Getting-Started" becomes
"Getting-Started.-.md", while "Architecture" becomes "Architecture.md".

Previously the code wrote "Getting-Started.md" which Gitea couldn't
recognize as a valid wiki page, causing verification to fail with
"page not found" for 15 of 21 pages.

Also force-push to handle concurrent CI runs that may have pushed to
the wiki repo between our clone and push.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 15:21:22 +02:00
16 changed files with 992 additions and 643 deletions
+1 -2
View File
@@ -55,8 +55,7 @@ jobs:
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" make devx-vale
vale --minAlertLevel=error docs/ AGENTS.md README.md
- name: Translation completeness check - name: Translation completeness check
env: env:
PYTHONPATH: src PYTHONPATH: src
+12
View File
@@ -2,6 +2,18 @@
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.36.0] - 2026-07-07
### Features
- Add GiteaClient repo variable methods and parallelize pytest-cov
## [0.35.7] - 2026-07-06
### Bug Fixes
- Use Gitea wiki dash-marker filename convention
## [0.35.6] - 2026-07-06 ## [0.35.6] - 2026-07-06
### Bug Fixes ### Bug Fixes
+3 -3
View File
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.35.6", "devx>=0.36.0",
] ]
[tool.pip] [tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
``` ```
> **Note:** If your project requires a specific devx version, pin it in > **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.35.6"`) or use a version constraint > `dependencies` (for example, `"devx==0.36.0"`) or use a version constraint
> (for example, `"devx>=0.35.6,<0.36"`). > (for example, `"devx>=0.36.0,<0.37"`).
### Optional extras ### Optional extras
+1 -1
View File
@@ -13,7 +13,7 @@ RUN pip install --no-cache-dir /tmp/devx[lint] \
&& rm -rf /tmp/devx && rm -rf /tmp/devx
# Install CI/CD binary tools # Install CI/CD binary tools
RUN python3 -m devx.tools.install_tools --tool actionlint \ RUN python3 -m devx.tools.install_tools --tool actionlint --tool vale \
&& python3 -m devx.tools.install_checkmake && python3 -m devx.tools.install_checkmake
# Install hadolint (Dockerfile linter) # Install hadolint (Dockerfile linter)
+2 -2
View File
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.35.6", "devx>=0.36.0",
] ]
[tool.pip] [tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple" extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
``` ```
Pin a specific version if needed: `"devx==0.35.6"` or `"devx>=0.35.6,<0.36"`. Pin a specific version if needed: `"devx==0.36.0"` or `"devx>=0.36.0,<0.37"`.
### Optional extras ### Optional extras
+3 -1
View File
@@ -122,7 +122,9 @@ exponential backoff (2s, 4s, 8s).
- Labels (list, create, add to issues) - Labels (list, create, add to issues)
- Issues (create, list) - Issues (create, list)
- Pull requests (get commits, merge, create review) - Pull requests (get commits, merge, create review)
- Releases (list) - Releases (list, create idempotent)
- Actions (list runs, list jobs, get job logs)
- Actions variables (get, set idempotent)
- Wiki pages (list, fetch, create, update, delete) - Wiki pages (list, fetch, create, update, delete)
**`VikunjaClient`** — Vikunja REST API wrapper: **`VikunjaClient`** — Vikunja REST API wrapper:
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.35.6", "devx>=0.36.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"devx>=0.35.6", "devx>=0.36.0",
] ]
``` ```
+1 -1
View File
@@ -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.35.6" __version__ = "0.36.0"
+29
View File
@@ -372,6 +372,35 @@ class GiteaClient:
r = self._request("GET", f"/actions/jobs/{job_id}/logs") r = self._request("GET", f"/actions/jobs/{job_id}/logs")
return r.text return r.text
# -- actions variables (repo-level) --
def get_repo_variable(self, name: str) -> str | None:
"""Read a Gitea Actions repository variable.
Returns the variable value, or ``None`` if the variable is not set.
Raises :class:`APIError` on other HTTP errors.
"""
try:
r = self._request("GET", f"/actions/variables/{name}")
return r.json().get("value")
except APIError as e:
if e.status == 404:
return None
raise
def set_repo_variable(self, name: str, value: str) -> None:
"""Create or update a Gitea Actions repository variable (idempotent).
Tries PATCH first; if the variable doesn't exist (404), creates it
via POST.
"""
try:
self._request("PATCH", f"/actions/variables/{name}", json={"value": value})
except APIError as e:
if e.status != 404:
raise
self._request("POST", "/actions/variables", json={"name": name, "value": value})
class VikunjaClient: class VikunjaClient:
"""Low-level Vikunja REST API client with connection pooling.""" """Low-level Vikunja REST API client with connection pooling."""
+1
View File
@@ -185,6 +185,7 @@ def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
# Try common locations # Try common locations
candidates = [ candidates = [
root / "src" / "devx" / "translations.json", root / "src" / "devx" / "translations.json",
root / "src" / "grm" / "translations.json",
] ]
# Also search for any translations.json in src/ # Also search for any translations.json in src/
for match in root.glob("src/*/translations.json"): for match in root.glob("src/*/translations.json"):
+26 -10
View File
@@ -33,7 +33,7 @@ import subprocess # nosec B404
import tempfile import tempfile
import time import time
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import quote, urlparse
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
@@ -50,6 +50,23 @@ MAPPING_FILE = DOCS_DIR / "mapping.json"
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)") _LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
def wiki_filename(page_title: str) -> str:
"""Convert a wiki page title to its Gitea wiki filename.
Gitea uses a "dash marker" (``.-``) suffix to distinguish literal dashes
from space-to-dash conversions. See Gitea's ``services/wiki/wiki_path.go``.
- "Architecture" (no dashes) → ``Architecture.md``
- "Getting-Started" (has dashes) → ``Getting-Started.-.md``
- "Home" (no dashes) → ``Home.md``
"""
name = page_title.replace(" ", "-")
if "-" in name:
name += ".-"
name += ".md"
return quote(name, safe="")
def load_mapping() -> dict[str, str]: def load_mapping() -> dict[str, str]:
"""Load the file-to-wiki-page mapping from mapping.json.""" """Load the file-to-wiki-page mapping from mapping.json."""
with open(MAPPING_FILE, encoding="utf-8") as f: with open(MAPPING_FILE, encoding="utf-8") as f:
@@ -171,16 +188,15 @@ def sync_files(
# Transform links # Transform links
transformed = transform_links(content) transformed = transform_links(content)
# Wiki filename: use the page title with spaces → underscores # Wiki filename: Gitea uses a dash-marker convention for titles with dashes
# Gitea wiki uses the page title as filename (spaces become dashes) fname = wiki_filename(page_title)
wiki_filename = page_title.replace(" ", "-") + ".md" expected_files.add(fname)
expected_files.add(wiki_filename)
if not dry_run: if not dry_run:
dest = wiki_dir / wiki_filename dest = wiki_dir / fname
dest.write_text(transformed, encoding="utf-8") dest.write_text(transformed, encoding="utf-8")
synced += 1 synced += 1
click.echo(_(" Synced: {title} → {file}", title=page_title, file=wiki_filename)) click.echo(_(" Synced: {title} → {file}", title=page_title, file=fname))
# Prune stale pages (in wiki but not in mapping) # Prune stale pages (in wiki but not in mapping)
pruned = 0 pruned = 0
@@ -235,7 +251,7 @@ def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool:
# Push # Push
result = subprocess.run( # nosec result = subprocess.run( # nosec
["git", "push", wiki_url, "HEAD:master"], ["git", "push", "--force", wiki_url, "HEAD:master"],
cwd=wiki_dir, cwd=wiki_dir,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -321,8 +337,8 @@ def main(dry_run: bool, repo: str | None, verify: bool) -> None:
raise click.ClickException(_("Wiki verification failed — could not clone wiki")) raise click.ClickException(_("Wiki verification failed — could not clone wiki"))
failures = 0 failures = 0
for _file_path, page_title in sorted(mapping.items()): for _file_path, page_title in sorted(mapping.items()):
wiki_filename = page_title.replace(" ", "-") + ".md" fname = wiki_filename(page_title)
if (verify_dir / wiki_filename).exists(): if (verify_dir / fname).exists():
click.echo(_(" OK: {title}", title=page_title)) click.echo(_(" OK: {title}", title=page_title))
else: else:
click.echo(_(" FAIL: {title} — page not found in wiki!", title=page_title)) click.echo(_(" FAIL: {title} — page not found in wiki!", title=page_title))
+7 -3
View File
@@ -304,7 +304,7 @@ devx-test-unit:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov @$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov
devx-pytest-cov: devx-pytest-cov:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100 @$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -n auto --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
# ── Quality checks ──────────────────────────────────────────────────────────── # ── Quality checks ────────────────────────────────────────────────────────────
@@ -328,10 +328,14 @@ devx-check-docs:
devx-check-doc-versions: devx-check-doc-versions:
@$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root . @$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root .
# Run Vale prose linter on docs and README # Run Vale prose linter on docs and README (skips if vale not installed)
devx-vale: devx-vale:
@export PATH="$$HOME/.local/bin:$$PATH" && \ @export PATH="$$HOME/.local/bin:$$PATH" && \
vale --minAlertLevel=error docs/ AGENTS.md README.md if ! command -v vale >/dev/null 2>&1; then \
echo "[devx-vale] vale not installed — skipping (install with 'make install-tools')"; \
else \
vale --minAlertLevel=error docs/ AGENTS.md README.md; \
fi
# Verify test suite timing # Verify test suite timing
devx-check-test-speed: devx-check-test-speed:
+2 -2
View File
@@ -68,8 +68,8 @@ def detect_package_name(repo_root: Path) -> str | None:
Looks for the first subdirectory under ``src/`` that contains Looks for the first subdirectory under ``src/`` that contains
an ``__init__.py`` file with ``__version__``. an ``__init__.py`` file with ``__version__``.
Returns the package directory name (e.g., ``devx``) or ``None`` if Returns the package directory name (e.g., ``devx``,
no package is found. ``grm``) or ``None`` if no package is found.
""" """
src_dir = repo_root / "src" src_dir = repo_root / "src"
if not src_dir.is_dir(): if not src_dir.is_dir():
+816 -616
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -909,3 +909,65 @@ 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,
) )
def test_get_repo_variable_returns_value(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"value": "v0.28.1"}))
result = client.get_repo_variable("PRODUCTION_DEPLOY_TAG")
assert result == "v0.28.1"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/actions/variables/PRODUCTION_DEPLOY_TAG",
timeout=DEFAULT_TIMEOUT,
)
def test_get_repo_variable_returns_none_on_404(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
not_found = MagicMock()
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
client._session.request = MagicMock(return_value=not_found)
result = client.get_repo_variable("MISSING_VAR")
assert result is None
@patch("time.sleep")
def test_get_repo_variable_reraises_non_404(self, mock_sleep: MagicMock) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
server_error = MagicMock()
server_error.raise_for_status.side_effect = _mock_http_error(500, "server error")
client._session.request = MagicMock(return_value=server_error)
with pytest.raises(APIError) as exc_info:
client.get_repo_variable("SOME_VAR")
assert exc_info.value.status == 500
def test_set_repo_variable_updates_existing(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({}))
client.set_repo_variable("PRODUCTION_DEPLOY_TAG", "v0.28.2")
client._session.request.assert_called_once_with(
"PATCH",
"https://git.example.com/repos/owner/repo/actions/variables/PRODUCTION_DEPLOY_TAG",
timeout=DEFAULT_TIMEOUT,
json={"value": "v0.28.2"},
)
def test_set_repo_variable_creates_on_404(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
not_found = MagicMock()
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
created = _mock_response({})
client._session.request = MagicMock(side_effect=[not_found, created])
client.set_repo_variable("NEW_VAR", "v0.29.0")
assert client._session.request.call_count == 2
second_call = client._session.request.call_args_list[1]
assert second_call.args[0] == "POST"
assert second_call.args[1] == "https://git.example.com/repos/owner/repo/actions/variables"
assert second_call.kwargs["json"] == {"name": "NEW_VAR", "value": "v0.29.0"}
def test_set_repo_variable_reraises_non_404(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
forbidden = MagicMock()
forbidden.raise_for_status.side_effect = _mock_http_error(403, "forbidden")
client._session.request = MagicMock(return_value=forbidden)
with pytest.raises(APIError) as exc_info:
client.set_repo_variable("SOME_VAR", "val")
assert exc_info.value.status == 403
+24
View File
@@ -19,6 +19,7 @@ from devx.ci.sync_wiki import (
main, main,
sync_files, sync_files,
transform_links, transform_links,
wiki_filename,
) )
@@ -96,6 +97,29 @@ class TestGetWikiCloneUrl:
assert "owner/repo.wiki.git" in url assert "owner/repo.wiki.git" in url
class TestWikiFilename:
def test_no_dashes(self) -> None:
assert wiki_filename("Home") == "Home.md"
def test_single_word(self) -> None:
assert wiki_filename("Architecture") == "Architecture.md"
def test_with_dashes_adds_marker(self) -> None:
assert wiki_filename("Getting-Started") == "Getting-Started.-.md"
def test_spaces_become_dashes_with_marker(self) -> None:
# "Getting Started" → "Getting-Started" (has dash) → marker added
assert wiki_filename("Getting Started") == "Getting-Started.-.md"
def test_spaces_no_dashes_no_marker(self) -> None:
# "Foo Bar" → "Foo-Bar" (has dash) → marker added
assert wiki_filename("Foo Bar") == "Foo-Bar.-.md"
def test_single_word_with_spaces_no_dash(self) -> None:
# No dash at all after conversion → no marker
assert wiki_filename("HelloWorld") == "HelloWorld.md"
class TestCloneWiki: class TestCloneWiki:
@patch("devx.ci.sync_wiki.subprocess.run") @patch("devx.ci.sync_wiki.subprocess.run")
def test_clone_success(self, mock_run: MagicMock, tmp_path: Path) -> None: def test_clone_success(self, mock_run: MagicMock, tmp_path: Path) -> None: