Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d7bf7118a | ||
|
|
f98534ebe2 | ||
|
|
c62b168b25 | ||
|
|
40a94df029 | ||
|
|
f50c4c1e00 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,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"
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user