Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6e9f2013b | ||
|
|
8450f33e88 | ||
|
|
904812dfae | ||
|
|
95384c26e1 | ||
|
|
c10b759f6b | ||
|
|
4c818b32ce | ||
|
|
bbf09c07df | ||
|
|
faff67aa6a | ||
|
|
3e4dfcadb7 | ||
|
|
2385747bed |
@@ -162,7 +162,7 @@ the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
|
|||||||
1. **Validate** PR title format (`DEVX-N: <vikunja task title>`) and match against Vikunja task title
|
1. **Validate** PR title format (`DEVX-N: <vikunja task title>`) and match against Vikunja task title
|
||||||
2. **Check** that at least one substantive APPROVE review exists
|
2. **Check** that at least one substantive APPROVE review exists
|
||||||
3. Wait for all CI checks to pass (including the `pr-review` job)
|
3. Wait for all CI checks to pass (including the `pr-review` job)
|
||||||
4. Squash-merge with title: `DEVX-N <conventional commit message>` (space-separated, no colon after DEVX-N)
|
4. Squash-merge with title: `DEVX-N: <conventional commit message>`
|
||||||
5. The post-merge workflow marks the Vikunja task as done
|
5. The post-merge workflow marks the Vikunja task as done
|
||||||
6. The release workflow automatically versions, tags, and publishes
|
6. The release workflow automatically versions, tags, and publishes
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ by `python -m devx.tools.install_tools` and configured by
|
|||||||
|
|
||||||
### git-cliff Commit Preprocessing
|
### git-cliff Commit Preprocessing
|
||||||
|
|
||||||
Merge commits on master have the format `DEVX-N <conventional commit>`. The
|
Merge commits on master have the format `DEVX-N: <conventional commit>`. The
|
||||||
`cliff.toml` includes a `commit_preprocessors` entry that strips the `DEVX-N `
|
`cliff.toml` includes a `commit_preprocessors` entry that strips the `DEVX-N `
|
||||||
prefix before parsing. This ensures all merged work appears in the changelog.
|
prefix before parsing. This ensures all merged work appears in the changelog.
|
||||||
|
|
||||||
@@ -282,7 +282,7 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`.
|
|||||||
| Branch name | `DEVX-N-short-description` | `DEVX-12-add-release-script` |
|
| Branch name | `DEVX-N-short-description` | `DEVX-12-add-release-script` |
|
||||||
| Branch commits | `<conventional commit>` | `feat: add release script` |
|
| Branch commits | `<conventional commit>` | `feat: add release script` |
|
||||||
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
|
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
|
||||||
| Merge commit | `DEVX-N <conventional commit>` | `DEVX-12 feat: add release script` |
|
| Merge commit | `DEVX-N: <conventional commit>` | `DEVX-12: feat: add release script` |
|
||||||
|
|
||||||
### Task ID Resolution
|
### Task ID Resolution
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
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.13.0] - 2026-06-25
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add --force flag to classify_changes, fix api_clients coverage
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Squash-merge format uses space not colon after task ID
|
||||||
|
- Revert squash-merge format to use colon after task ID
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-06-25
|
||||||
|
|
||||||
## [0.12.5] - 2026-06-25
|
## [0.12.5] - 2026-06-25
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -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.12.5"
|
__version__ = "0.13.0"
|
||||||
|
|||||||
@@ -192,6 +192,17 @@ class GiteaClient:
|
|||||||
r = self._request("GET", f"/pulls/{pr_number}")
|
r = self._request("GET", f"/pulls/{pr_number}")
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
def list_prs(self, state: str = "all", **params: Any) -> list[dict[str, Any]]:
|
||||||
|
"""List pull requests, optionally filtered by state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: ``open``, ``closed``, ``all`` (default).
|
||||||
|
**params: Additional query params (e.g. ``q="keyword"`` for title search).
|
||||||
|
"""
|
||||||
|
params.setdefault("state", state)
|
||||||
|
r = self._request("GET", "/pulls", params=params)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
|
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||||
"""Fetch the list of files changed in a pull request."""
|
"""Fetch the list of files changed in a pull request."""
|
||||||
r = self._request("GET", f"/pulls/{pr_number}/files")
|
r = self._request("GET", f"/pulls/{pr_number}/files")
|
||||||
@@ -345,5 +356,28 @@ class VikunjaClient:
|
|||||||
def post_comment(self, task_id: int, comment: str) -> None:
|
def post_comment(self, task_id: int, comment: str) -> None:
|
||||||
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
|
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
|
||||||
|
|
||||||
|
def list_comments(self, task_id: int) -> list[dict[str, Any]]:
|
||||||
|
"""List all comments on a task."""
|
||||||
|
r = self._request("GET", f"/tasks/{task_id}/comments")
|
||||||
|
return r.json()
|
||||||
|
|
||||||
def update_task(self, task_id: int, **fields: Any) -> None:
|
def update_task(self, task_id: int, **fields: Any) -> None:
|
||||||
|
"""Update task fields via POST (full replacement semantics).
|
||||||
|
|
||||||
|
Warning: Vikunja's POST /tasks/{id} replaces the entire task body.
|
||||||
|
Unspecified fields are reset to their type defaults. Use
|
||||||
|
``update_task_safe`` to preserve existing fields.
|
||||||
|
"""
|
||||||
self._request("POST", f"/tasks/{task_id}", json=fields)
|
self._request("POST", f"/tasks/{task_id}", json=fields)
|
||||||
|
|
||||||
|
def update_task_safe(self, task_id: int, **fields: Any) -> dict[str, Any]:
|
||||||
|
"""Safely update task fields using read-merge-write pattern.
|
||||||
|
|
||||||
|
Fetches the full task body, merges the provided fields on top,
|
||||||
|
and POSTs the complete body back. This prevents accidental
|
||||||
|
resets of done status, title, etc.
|
||||||
|
"""
|
||||||
|
task = self.get_task(task_id)
|
||||||
|
task.update(fields)
|
||||||
|
r = self._request("POST", f"/tasks/{task_id}", json=task)
|
||||||
|
return r.json()
|
||||||
|
|||||||
@@ -615,11 +615,25 @@ def _write_github_output(key: str, value: str) -> None:
|
|||||||
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). "
|
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). "
|
||||||
"Outputs 'user-facing-changed' and '<tag>-changed' for each configured tag.",
|
"Outputs 'user-facing-changed' and '<tag>-changed' for each configured tag.",
|
||||||
)
|
)
|
||||||
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
|
@click.option(
|
||||||
|
"--force",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Force user-facing-changed=true regardless of actual changes. "
|
||||||
|
"Used by workflow_dispatch with force-deploy input.",
|
||||||
|
)
|
||||||
|
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool, force: bool) -> None:
|
||||||
"""Classify git changes and output results."""
|
"""Classify git changes and output results."""
|
||||||
classifier = _get_classifier()
|
classifier = _get_classifier()
|
||||||
available_tags = list(classifier.config.tags.keys())
|
available_tags = list(classifier.config.tags.keys())
|
||||||
|
|
||||||
|
if force and github_output:
|
||||||
|
_write_github_output("user-facing-changed", "true")
|
||||||
|
for tag in available_tags:
|
||||||
|
_write_github_output(f"{tag}-changed", "true")
|
||||||
|
click.echo("Forced user-facing-changed=true via --force flag.")
|
||||||
|
return
|
||||||
|
|
||||||
if base is None:
|
if base is None:
|
||||||
base = get_latest_tag()
|
base = get_latest_tag()
|
||||||
if not base:
|
if not base:
|
||||||
|
|||||||
@@ -278,6 +278,35 @@ class TestGiteaClient:
|
|||||||
timeout=DEFAULT_TIMEOUT,
|
timeout=DEFAULT_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_list_prs(self) -> None:
|
||||||
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
|
client._session.request = MagicMock(
|
||||||
|
return_value=_mock_response([{"number": 1, "title": "feat: add"}, {"number": 2, "title": "fix: bug"}])
|
||||||
|
)
|
||||||
|
|
||||||
|
result = client.list_prs()
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["number"] == 1
|
||||||
|
client._session.request.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
"https://git.example.com/repos/owner/repo/pulls",
|
||||||
|
params={"state": "all"},
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_prs_with_params(self) -> None:
|
||||||
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
|
client._session.request = MagicMock(return_value=_mock_response([{"number": 3, "title": "docs: update"}]))
|
||||||
|
|
||||||
|
result = client.list_prs(state="closed", q="docs")
|
||||||
|
assert len(result) == 1
|
||||||
|
client._session.request.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
"https://git.example.com/repos/owner/repo/pulls",
|
||||||
|
params={"state": "closed", "q": "docs"},
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
def test_get_pr_reviews(self) -> None:
|
def test_get_pr_reviews(self) -> None:
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
|
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
|
||||||
@@ -566,6 +595,46 @@ class TestVikunjaClient:
|
|||||||
json={"done": True},
|
json={"done": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_list_comments(self) -> None:
|
||||||
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
|
client._session.request = MagicMock(
|
||||||
|
return_value=_mock_response([{"id": 1, "comment": "first"}, {"id": 2, "comment": "second"}])
|
||||||
|
)
|
||||||
|
|
||||||
|
result = client.list_comments(42)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["comment"] == "first"
|
||||||
|
client._session.request.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
"https://work.example.com/tasks/42/comments",
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_update_task_safe(self) -> None:
|
||||||
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
|
client._session.request = MagicMock(
|
||||||
|
side_effect=[
|
||||||
|
_mock_response({"id": 42, "title": "My task", "done": False}),
|
||||||
|
_mock_response({"id": 42, "title": "My task", "done": True}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = client.update_task_safe(42, done=True)
|
||||||
|
assert result["done"] is True
|
||||||
|
assert result["title"] == "My task"
|
||||||
|
assert client._session.request.call_count == 2
|
||||||
|
client._session.request.assert_any_call(
|
||||||
|
"GET",
|
||||||
|
"https://work.example.com/tasks/42",
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
client._session.request.assert_any_call(
|
||||||
|
"POST",
|
||||||
|
"https://work.example.com/tasks/42",
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
json={"id": 42, "title": "My task", "done": True},
|
||||||
|
)
|
||||||
|
|
||||||
@patch("devx.api_clients.time.sleep")
|
@patch("devx.api_clients.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")
|
||||||
|
|||||||
@@ -742,3 +742,49 @@ class TestGithubOutput:
|
|||||||
assert "user-facing-changed=true" in content
|
assert "user-facing-changed=true" in content
|
||||||
# No tag outputs since no tags are configured
|
# No tag outputs since no tags are configured
|
||||||
assert "ansible-changed" not in content
|
assert "ansible-changed" not in content
|
||||||
|
|
||||||
|
@patch("devx.ci.classify_changes._get_classifier")
|
||||||
|
def test_force_outputs_true(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""--force with --github-output writes user-facing-changed=true and all tags true."""
|
||||||
|
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||||
|
gh_file = tmp_path / "output.txt"
|
||||||
|
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--github-output", "--force"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = gh_file.read_text()
|
||||||
|
assert "user-facing-changed=true" in content
|
||||||
|
assert "ansible-changed=true" in content
|
||||||
|
assert "Forced user-facing-changed=true" in result.output
|
||||||
|
|
||||||
|
@patch("devx.ci.classify_changes._get_classifier")
|
||||||
|
def test_force_without_github_output_does_nothing(
|
||||||
|
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""--force without --github-output falls through to normal classification."""
|
||||||
|
mock_clf.return_value = self._make_classifier_with_ansible()
|
||||||
|
monkeypatch.setenv("GITHUB_OUTPUT", str(tmp_path / "output.txt"))
|
||||||
|
with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"):
|
||||||
|
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--force", "--quiet"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.output.strip() == "false"
|
||||||
|
|
||||||
|
@patch("devx.ci.classify_changes._get_classifier")
|
||||||
|
def test_force_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""--force with --github-output and no tags writes only user-facing-changed=true."""
|
||||||
|
mock_clf.return_value = ChangeClassifier(
|
||||||
|
ClassifierConfig(
|
||||||
|
infrastructure=[".gitea/**"],
|
||||||
|
tags={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
gh_file = tmp_path / "output.txt"
|
||||||
|
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--github-output", "--force"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = gh_file.read_text()
|
||||||
|
assert "user-facing-changed=true" in content
|
||||||
|
assert "ansible-changed" not in content
|
||||||
|
|||||||
Reference in New Issue
Block a user