Compare commits

...
31 Commits
Author SHA1 Message Date
devx-ci-bot 700d3b55c6 release: v0.14.2
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Publish Release / publish (push) Successful in 30s
Post-merge / badges (push) Successful in 1m8s
Post-merge / detect-type (push) Successful in 8s
2026-06-26 00:19:13 +00:00
emil 701363d935 DEVX-59: fix: make repo arg optional in publish CLI, auto-detect from GITHUB_REPOSITORY
Post-merge / vikunja (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 1m13s
Post-merge / badges (push) Successful in 1m22s
Post-merge / detect-type (push) Successful in 14s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / release (push) Successful in 1m8s
Post-merge / configure-repo (push) Successful in 9s
2026-06-26 00:17:55 +00:00
gitea-actions-bot ddb2d43b4e chore: update badge URLs to commit 3fb76ae0 [skip ci] 2026-06-26 01:34:46 +02:00
gitea-actions-bot fe6373b682 chore: update badge URLs to commit 27c68d16 [skip ci] 2026-06-25 23:34:31 +00:00
devx-ci-bot 33434d5750 release: v0.14.1
Post-merge / detect-type (push) Successful in 16s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Publish Release / publish (push) Successful in 31s
Post-merge / badges (push) Successful in 1m2s
2026-06-26 01:33:23 +02:00
emil dfcd33c35b DEVX-58: fix: handle 'already a release' error idempotently in publish
Post-merge / detect-type (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 14s
Post-merge / release (push) Successful in 55s
Post-merge / vikunja (push) Successful in 9s
Post-merge / sync-wiki (push) Successful in 54s
Post-merge / badges (push) Successful in 58s
2026-06-25 23:32:28 +00:00
gitea-actions-bot 0aefe1f028 chore: update badge URLs to commit f3c14a15 [skip ci] 2026-06-25 23:24:58 +00:00
gitea-actions-bot 891b0b5dba chore: update badge URLs to commit e89db952 [skip ci] 2026-06-25 23:24:49 +00:00
devx-ci-bot 8f15e5402b release: v0.14.0
Post-merge / detect-type (push) Successful in 14s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Publish Release / publish (push) Failing after 32s
Post-merge / badges (push) Successful in 55s
2026-06-26 01:23:45 +02:00
emil 9060cd7b1e DEVX-57: feat: add FORCE_DEPLOY env var, --git flag, --from-tag flag
Post-merge / detect-type (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / release (push) Successful in 58s
Post-merge / vikunja (push) Successful in 19s
Post-merge / badges (push) Successful in 55s
Post-merge / sync-wiki (push) Successful in 1m18s
2026-06-25 23:22:41 +00:00
gitea-actions-bot f687ab5aa3 chore: update badge URLs to commit c31f8a46 [skip ci] 2026-06-26 00:59:13 +02:00
gitea-actions-bot 4738b594b2 chore: update badge URLs to commit a398ba43 [skip ci] 2026-06-25 22:58:46 +00:00
devx-ci-bot f6e9f2013b release: v0.13.0
Post-merge / detect-type (push) Successful in 14s
Publish Release / publish (push) Failing after 16s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 1m13s
2026-06-25 22:57:37 +00:00
emil 8450f33e88 DEVX-56: feat: add --force flag to classify_changes, fix api_clients coverage
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 1m5s
Post-merge / vikunja (push) Successful in 32s
Post-merge / badges (push) Successful in 58s
Post-merge / sync-wiki (push) Successful in 1m18s
2026-06-25 22:56:31 +00:00
gitea-actions-bot 904812dfae chore: update badge URLs to commit 525d3b70 [skip ci] 2026-06-26 00:11:59 +02:00
emil 95384c26e1 DEVX-55: fix: revert squash-merge format to use colon after task ID
Post-merge / detect-type (push) Successful in 22s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Failing after 54s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 49s
2026-06-25 22:09:46 +00:00
gitea-actions-bot c10b759f6b chore: update badge URLs to commit 47d60f8b [skip ci] 2026-06-25 21:55:13 +00:00
gitea-actions-bot 4c818b32ce chore: update badge URLs to commit 9c9fe0f1 [skip ci] 2026-06-25 23:54:43 +02:00
devx-ci-bot bbf09c07df release: v0.1.0 2026-06-25 21:53:29 +00:00
emil faff67aa6a DEVX-54: fix: squash-merge format uses space not colon after task ID 2026-06-25 21:51:36 +00:00
gitea-actions-bot 3e4dfcadb7 chore: update badge URLs to commit 1c7678eb [skip ci] 2026-06-25 23:41:29 +02:00
gitea-actions-bot 2385747bed chore: update badge URLs to commit cb9e5b47 [skip ci] 2026-06-25 23:41:22 +02:00
devx-ci-bot 3625bf2872 release: v0.12.5
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 1m2s
Publish Release / publish (push) Failing after 29s
2026-06-25 23:40:08 +02:00
emil 8affccfa35 DEVX-53: fix: make PyPI publish failures non-fatal
Post-merge / detect-type (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 48s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / vikunja (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 44s
Post-merge / badges (push) Successful in 1m3s
2026-06-25 21:39:12 +00:00
gitea-actions-bot 7ae85b6955 chore: update badge URLs to commit e5e2b54b [skip ci] 2026-06-25 23:28:38 +02:00
gitea-actions-bot f702286779 chore: update badge URLs to commit 0ed104f4 [skip ci] 2026-06-25 21:28:25 +00:00
devx-ci-bot d4b58fa86f release: v0.12.4
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 56s
Publish Release / publish (push) Failing after 23s
2026-06-25 23:27:13 +02:00
emil 44c6c42ede DEVX-52: fix: guarantee Gitea release for every tag
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 14s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Successful in 54s
Post-merge / vikunja (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 51s
Post-merge / badges (push) Successful in 1m9s
2026-06-25 21:26:13 +00:00
gitea-actions-bot 14c585971d chore: update badge URLs to commit dfe5ee1e [skip ci] 2026-06-25 23:04:02 +02:00
emil 3e2342c347 DEVX-51: fix: pass REPO_TOKEN to setup-release so tea login is configured
Post-merge / detect-type (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 14s
Post-merge / release (push) Successful in 47s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 49s
Post-merge / badges (push) Successful in 57s
2026-06-25 21:02:00 +00:00
gitea-actions-bot f3d5b0ff45 chore: update badge URLs to commit d5507246 [skip ci] 2026-06-25 20:56:25 +00:00
18 changed files with 682 additions and 58 deletions
+4 -7
View File
@@ -82,6 +82,8 @@ jobs:
fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }}
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release
- name: Configure git
run: |
@@ -106,13 +108,8 @@ jobs:
echo "No tag found — skipping publish"
exit 0
fi
HEAD_MSG=$(git log -1 --format=%s)
if echo "$HEAD_MSG" | grep -q "^release: ${TAG}"; then
echo "Publishing release $TAG..."
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
else
echo "HEAD is not a release commit for $TAG — skipping publish"
fi
echo "Publishing release $TAG (idempotent — skips if already published)..."
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
- name: Notify on failure
if: failure()
env:
+3 -3
View File
@@ -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
2. **Check** that at least one substantive APPROVE review exists
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
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
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 `
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 commits | `<conventional commit>` | `feat: add release script` |
| 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
+44
View File
@@ -2,6 +2,50 @@
All notable changes to this project will be documented in this file.
## [0.14.2] - 2026-06-26
### Bug Fixes
- Make repo arg optional in publish CLI, auto-detect from GITHUB_REPOSITORY
## [0.14.1] - 2026-06-25
### Bug Fixes
- Handle 'already a release' error idempotently in publish
## [0.14.0] - 2026-06-25
### Features
- Add FORCE_DEPLOY env var, --git flag, --from-tag flag
## [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
### Bug Fixes
- Make PyPI publish failures non-fatal
## [0.12.4] - 2026-06-25
### Bug Fixes
- Pass REPO_TOKEN to setup-release so tea login is configured
- Guarantee Gitea release for every tag
## [0.12.3] - 2026-06-25
### Refactor
+6 -6
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7341990c89084e3dfba55b3083ba8f6657df84b9/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7341990c89084e3dfba55b3083ba8f6657df84b9/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7341990c89084e3dfba55b3083ba8f6657df84b9/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7341990c89084e3dfba55b3083ba8f6657df84b9/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7341990c89084e3dfba55b3083ba8f6657df84b9/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7341990c89084e3dfba55b3083ba8f6657df84b9/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/python.svg)](https://www.python.org/downloads/)
## Why devx?
+6 -6
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7341990c89084e3dfba55b3083ba8f6657df84b9/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7341990c89084e3dfba55b3083ba8f6657df84b9/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7341990c89084e3dfba55b3083ba8f6657df84b9/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7341990c89084e3dfba55b3083ba8f6657df84b9/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7341990c89084e3dfba55b3083ba8f6657df84b9/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7341990c89084e3dfba55b3083ba8f6657df84b9/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/3fb76ae012113ad6f982f184d4753cad29a5f22e/python.svg)](https://www.python.org/downloads/)
## Overview
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.12.3"
__version__ = "0.14.2"
+34
View File
@@ -192,6 +192,17 @@ class GiteaClient:
r = self._request("GET", f"/pulls/{pr_number}")
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]]:
"""Fetch the list of files changed in a pull request."""
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:
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:
"""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)
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()
+19 -1
View File
@@ -615,11 +615,29 @@ def _write_github_output(key: str, value: str) -> None:
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). "
"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."""
classifier = _get_classifier()
available_tags = list(classifier.config.tags.keys())
# --force can also be activated via FORCE_DEPLOY env var (for workflow_dispatch)
if os.environ.get("FORCE_DEPLOY", "").lower() == "true":
force = True
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:
base = get_latest_tag()
if not base:
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Detect whether the latest git commit is a release commit.
Release commits have the format ``release: vX.Y.Z [skip ci]``.
Release commits have the format ``release: vX.Y.Z``.
This script writes ``is-release=true`` or ``is-release=false`` to
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
+97 -19
View File
@@ -135,13 +135,21 @@ def publish_to_gitea_registry(registry_url: str, token: str) -> None:
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_(
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
stderr=result.stderr.strip(),
# Twine writes errors to stdout (not stderr), so check both.
combined = f"{result.stdout}\n{result.stderr}".strip()
# 409 Conflict means the package version is already published —
# this is not an error, just a sign we're re-running publish.
if "409" in combined or "Conflict" in combined:
click.echo(_("Gitea PyPI registry: {tag} already published — continuing.", tag=""))
else:
raise click.ClickException(
_(
"Oops! Gitea PyPI registry publish failed:\n{stderr}",
stderr=combined,
)
)
)
click.echo(_("Published to Gitea PyPI registry."))
else:
click.echo(_("Published to Gitea PyPI registry."))
def _default_gitea_registry_url() -> str:
@@ -161,9 +169,37 @@ def _default_gitea_registry_url() -> str:
return f"{base}/api/packages/{owner}/pypi"
def get_latest_tag() -> str | None:
"""Get the latest git tag, or None if no tags exist."""
try:
result = subprocess.run( # nosec
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
def is_release_commit(tag: str) -> bool:
"""Check if HEAD commit message starts with 'release: <tag>'."""
try:
result = subprocess.run( # nosec
["git", "log", "-1", "--format=%s"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip().startswith(f"release: {tag}")
except subprocess.CalledProcessError:
return False
@click.command()
@click.argument("tag")
@click.argument("repo")
@click.argument("tag", required=False)
@click.argument("repo", required=False)
@click.option(
"--registry-url",
default=None,
@@ -178,7 +214,37 @@ def _default_gitea_registry_url() -> str:
help="Skip package build and PyPI publish (for non-Python repos that only "
"need a Gitea release with git-cliff notes).",
)
def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> None:
@click.option(
"--from-tag",
is_flag=True,
default=False,
help="Auto-detect latest tag and check if HEAD is a release commit. "
"Skips publish if no tag or HEAD is not a release commit for that tag.",
)
def main(
tag: str | None,
repo: str | None,
registry_url: str | None,
skip_build: bool,
from_tag: bool,
) -> None:
if repo is None:
repo = os.environ.get("GITHUB_REPOSITORY", "")
if not repo:
raise click.ClickException(_("REPO argument is required (or set GITHUB_REPOSITORY env var)."))
if from_tag:
detected_tag = get_latest_tag()
if not detected_tag:
click.echo(_("No tag found — skipping publish."))
return
if not is_release_commit(detected_tag):
click.echo(_("HEAD is not a release commit for {tag} — skipping publish.", tag=detected_tag))
return
tag = detected_tag
click.echo(_("Publishing release {tag}...", tag=tag))
if not tag:
raise click.ClickException(_("Tag is required (or use --from-tag)."))
gitea_token = os.environ.get("REPO_TOKEN", "")
if not gitea_token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
@@ -194,18 +260,27 @@ def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> Non
if not skip_build:
build_package()
if pypi_token:
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
publish_to_pypi(pypi_token)
elif registry_url:
# Gitea PyPI registry flow
publish_to_gitea_registry(registry_url, gitea_token)
else:
try:
if pypi_token:
# Standard PyPI flow takes precedence when PYPI_TOKEN is set
publish_to_pypi(pypi_token)
elif registry_url:
# Gitea PyPI registry flow
publish_to_gitea_registry(registry_url, gitea_token)
else:
click.echo(
_(
"PYPI_TOKEN not set and no registry URL configured — "
"skipping PyPI publish. No worries, we'll just create the Gitea release."
)
)
except click.ClickException as e:
click.echo(
_(
"PYPI_TOKEN not set and no registry URL configured — "
"skipping PyPI publish. No worries, we'll just create the Gitea release."
)
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}",
error=str(e),
),
err=True,
)
else:
click.echo(_("--skip-build: skipping package build and PyPI publish."))
@@ -227,6 +302,9 @@ def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> Non
try:
tea.create_release(repo, tag=tag, title=tag, body=release_body)
except TeaCLIError as e:
if "already" in str(e).lower() and "release" in str(e).lower():
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
return
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
click.echo(
+1 -1
View File
@@ -279,7 +279,7 @@ def commit_release_changes(new_version: str) -> bool:
if status.returncode == 0:
click.echo(_("No staged changes — version and changelog already up to date."))
return False
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version}"])
return True
+31 -4
View File
@@ -14,6 +14,7 @@ task ID format for each project.
import re
import subprocess # nosec B404
import sys
import click
@@ -23,6 +24,17 @@ from devx.i18n import _
MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:")
def get_latest_commit_msg() -> str:
"""Get the latest commit message from git."""
result = subprocess.run( # nosec
["git", "log", "-1", "--format=%B"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def first_line(text: str) -> str:
return text.split("\n")[0]
@@ -41,11 +53,26 @@ def get_branch() -> str:
@click.command()
@click.argument("commit_msg_file")
@click.argument("commit_msg_file", required=False)
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
def main(commit_msg_file: str, branch: str | None) -> None:
with open(commit_msg_file) as f:
msg = f.read().strip()
@click.option(
"--git",
"from_git",
is_flag=True,
default=False,
help="Read commit message from git log instead of a file.",
)
def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> None:
if from_git:
msg = get_latest_commit_msg()
elif commit_msg_file:
if commit_msg_file == "-":
msg = sys.stdin.read().strip()
else:
with open(commit_msg_file) as f:
msg = f.read().strip()
else:
raise click.ClickException(_("Provide a commit message file or use --git."))
if branch is None:
branch = get_branch()
+64
View File
@@ -599,6 +599,14 @@
"ru": "Generated {file} with prefix '{prefix}'.",
"zh": "Generated {file} with prefix '{prefix}'."
},
"Gitea PyPI registry: {tag} already published — continuing.": {
"bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.",
"de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.",
"en": "Gitea PyPI registry: {tag} already published — continuing.",
"pl": "Gitea PyPI registry: {tag} już opublikowano — kontynuacja.",
"ru": "Gitea PyPI registry: {tag} уже опубликован — продолжаем.",
"zh": "Gitea PyPI registry: {tag} 已发布 — 继续。"
},
"Gitea release {tag} already exists — skipping creation.": {
"bg": "Gitea release {tag} вече съществува — прескачане на създаването.",
"de": "Gitea-Release {tag} existiert bereits — Erstellung übersprungen.",
@@ -631,6 +639,14 @@
"ru": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping.",
"zh": "HEAD is already a release commit ('{msg}') and tag {tag} points to HEAD. Skipping."
},
"HEAD is not a release commit for {tag} — skipping publish.": {
"bg": "HEAD is not a release commit for {tag} — skipping publish.",
"de": "HEAD is not a release commit for {tag} — skipping publish.",
"en": "HEAD is not a release commit for {tag} — skipping publish.",
"pl": "HEAD nie jest commitem wydania dla {tag} — pomijanie publikacji.",
"ru": "HEAD is not a release commit for {tag} — skipping publish.",
"zh": "HEAD is not a release commit for {tag} — skipping publish."
},
"HTTP error: {status} — {message}": {
"bg": "HTTP грешка: {status} — {message}",
"de": "HTTP-Fehler: {status} — {message}",
@@ -791,6 +807,14 @@
"ru": "No staged changes — version and changelog already up to date.",
"zh": "No staged changes — version and changelog already up to date."
},
"No tag found — skipping publish.": {
"bg": "No tag found — skipping publish.",
"de": "No tag found — skipping publish.",
"en": "No tag found — skipping publish.",
"pl": "Nie znaleziono tagu — pomijanie publikacji.",
"ru": "No tag found — skipping publish.",
"zh": "No tag found — skipping publish."
},
"No tags found — treating all changes as user-facing.": {
"bg": "No tags found — treating all changes as user-facing.",
"de": "No tags found — treating all changes as user-facing.",
@@ -959,6 +983,14 @@
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
},
"Provide a commit message file or use --git.": {
"bg": "Provide a commit message file or use --git.",
"de": "Provide a commit message file or use --git.",
"en": "Provide a commit message file or use --git.",
"pl": "Podaj plik komunikatu commitu lub użyj --git.",
"ru": "Provide a commit message file or use --git.",
"zh": "Provide a commit message file or use --git."
},
"Published to Gitea PyPI registry.": {
"bg": "Публикувано в Gitea PyPI registry.",
"de": "In der Gitea PyPI-Registry veröffentlicht.",
@@ -975,6 +1007,14 @@
"ru": "Опубликовано в PyPI.",
"zh": "已发布到 PyPI。"
},
"Publishing release {tag}...": {
"bg": "Publishing release {tag}...",
"de": "Publishing release {tag}...",
"en": "Publishing release {tag}...",
"pl": "Publikowanie wydania {tag}...",
"ru": "Publishing release {tag}...",
"zh": "Publishing release {tag}..."
},
"Pushed release commit to master.": {
"bg": "Pushed release commit to master.",
"de": "Pushed release commit to master.",
@@ -983,6 +1023,14 @@
"ru": "Pushed release commit to master.",
"zh": "Pushed release commit to master."
},
"PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}": {
"bg": "Публикуването в PyPI неуспешно (некритично — продължава към Gitea release):\n{error}",
"de": "PyPI-Veröffentlichung fehlgeschlagen (nicht fatal — Gitea-Release wird fortgesetzt):\n{error}",
"en": "PyPI publish failed (non-fatal — continuing to Gitea release):\n{error}",
"pl": "Publikacja PyPI nie powiodła się (niekrytyczne — kontynuacja Gitea release):\n{error}",
"ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}",
"zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}"
},
"Release creation failed: {error}": {
"bg": "Release creation failed: {error}",
"de": "Release creation failed: {error}",
@@ -1079,6 +1127,22 @@
"ru": "Tag consistency check failed.",
"zh": "Tag consistency check failed."
},
"Tag is required (or use --from-tag).": {
"bg": "Tag is required (or use --from-tag).",
"de": "Tag is required (or use --from-tag).",
"en": "Tag is required (or use --from-tag).",
"pl": "Tag jest wymagany (lub użyj --from-tag).",
"ru": "Tag is required (or use --from-tag).",
"zh": "Tag is required (or use --from-tag)."
},
"REPO argument is required (or set GITHUB_REPOSITORY env var).": {
"bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"de": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"en": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).",
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
},
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
+69
View File
@@ -278,6 +278,35 @@ class TestGiteaClient:
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:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
@@ -566,6 +595,46 @@ class TestVikunjaClient:
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")
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
client = VikunjaClient("https://work.example.com", "tok")
+92
View File
@@ -742,3 +742,95 @@ class TestGithubOutput:
assert "user-facing-changed=true" in content
# No tag outputs since no tags are configured
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
@patch("devx.ci.classify_changes._get_classifier")
def test_force_deploy_env_var(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""FORCE_DEPLOY=true env var activates force mode without --force flag."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
monkeypatch.setenv("FORCE_DEPLOY", "true")
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=true" in content
assert "ansible-changed=true" in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_deploy_env_var_false(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""FORCE_DEPLOY=false does not activate force mode."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
monkeypatch.setenv("FORCE_DEPLOY", "false")
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, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "user-facing-changed=false" in content
@patch("devx.ci.classify_changes._get_classifier")
def test_force_flag_overrides_env_var(
self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""--force flag works even when FORCE_DEPLOY=false."""
mock_clf.return_value = self._make_classifier_with_ansible()
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
monkeypatch.setenv("FORCE_DEPLOY", "false")
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
+165 -6
View File
@@ -11,6 +11,8 @@ from devx.ci.publish import (
_default_gitea_registry_url,
build_package,
generate_release_notes,
get_latest_tag,
is_release_commit,
main,
publish_to_gitea_registry,
publish_to_pypi,
@@ -122,11 +124,18 @@ class TestPublishToGiteaRegistry:
@patch("devx.ci.publish.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="registry upload failed")
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="registry upload failed")
with pytest.raises(click.ClickException) as exc:
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
assert "Gitea PyPI registry" in str(exc.value)
@patch("devx.ci.publish.subprocess.run")
def test_409_conflict_is_non_fatal(self, mock_run: MagicMock) -> None:
"""409 Conflict (already published) should not raise — just continue."""
mock_run.return_value = MagicMock(returncode=1, stdout="ERROR 409 Conflict from url", stderr="")
# Should not raise
publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok")
class TestDefaultGiteaRegistryUrl:
@patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True)
@@ -294,14 +303,21 @@ class TestMain:
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_publish_failure_raises_click(
def test_publish_failure_continues_to_gitea_release(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""PyPI publish failure is non-fatal — Gitea release is still created."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
mock_publish.side_effect = click.ClickException("publish failed")
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "publish" in result.output
assert result.exit_code == 0
assert "non-fatal" in result.output
mock_tea.create_release.assert_called_once_with(
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
)
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@@ -371,5 +387,148 @@ class TestMain:
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "Gitea release v1.0.0 created" in result.output
mock_tea.create_release.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_create_release_already_exists_is_idempotent(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""If create_release fails with 'already exists', treat as success."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea.create_release.side_effect = TeaCLIError("there is already a release for this tag")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "already exists" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_create_release_other_error_raises(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""If create_release fails with a non-'already exists' error, raise."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea.create_release.side_effect = TeaCLIError("network error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code != 0
assert "Release creation failed" in result.output
class TestFromTag:
def test_get_latest_tag_success(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="v1.2.3\n")
result = get_latest_tag()
assert result == "v1.2.3"
def test_get_latest_tag_no_tags(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, [])
result = get_latest_tag()
assert result is None
def test_is_release_commit_match(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout="release: v1.2.3 [skip ci]\n"
)
result = is_release_commit("v1.2.3")
assert result is True
def test_is_release_commit_no_match(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n")
result = is_release_commit("v1.2.3")
assert result is False
def test_is_release_commit_git_error(self) -> None:
import subprocess
with patch("devx.ci.publish.subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, [])
result = is_release_commit("v1.2.3")
assert result is False
@patch("devx.ci.publish.get_latest_tag", return_value=None)
def test_from_tag_no_tag_skips(self, _mock: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
assert result.exit_code == 0
assert "No tag found" in result.output
@patch("devx.ci.publish.get_latest_tag", return_value=None)
def test_from_tag_no_repo_uses_env(self, _mock: MagicMock) -> None:
runner = CliRunner()
with patch.dict("os.environ", {"GITHUB_REPOSITORY": "owner/repo"}):
result = runner.invoke(main, ["--from-tag", "--skip-build"])
assert result.exit_code == 0
assert "No tag found" in result.output
@patch("devx.ci.publish.get_latest_tag", return_value=None)
def test_from_tag_no_repo_no_env_raises(self, _mock: MagicMock) -> None:
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(main, ["--from-tag", "--skip-build"])
assert result.exit_code != 0
assert "REPO argument is required" in result.output
@patch("devx.ci.publish.is_release_commit", return_value=False)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_not_release_commit_skips(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
assert result.exit_code == 0
assert "not a release commit" in result.output
@patch("devx.ci.publish.is_release_commit", return_value=True)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_publishes(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"])
assert result.exit_code == 0
assert "Publishing release v1.0.0" in result.output
@patch("devx.ci.publish.is_release_commit", return_value=True)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_publishes_no_repo_arg(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "GITHUB_REPOSITORY": "owner/repo"}):
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
runner = CliRunner()
result = runner.invoke(main, ["--from-tag", "--skip-build"])
assert result.exit_code == 0
assert "Publishing release v1.0.0" in result.output
def test_no_tag_no_from_tag_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["", "owner/repo", "--skip-build"])
assert result.exit_code != 0
assert "Tag is required" in result.output
+1 -1
View File
@@ -766,7 +766,7 @@ class TestCommitReleaseChanges:
assert result is True
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "add", "src/devx/__init__.py", "CHANGELOG.md"] in calls
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls
@patch("devx.ci.release.run_cmd")
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
+44 -2
View File
@@ -7,7 +7,7 @@ from unittest.mock import patch
from click.testing import CliRunner
from devx.ci.validate_commit_msg import first_line, get_branch, main
from devx.ci.validate_commit_msg import first_line, get_branch, get_latest_commit_msg, main
from devx.config import CONVENTIONAL_RE, TASK_ID_RE
@@ -126,7 +126,7 @@ class TestMain:
def test_usage_message_without_args(self) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 2
assert result.exit_code != 0
def test_branch_override_accepts_master_commit(self) -> None:
"""--branch master overrides branch detection (for CI use)."""
@@ -257,3 +257,45 @@ def test_main_module_block() -> None:
namespace["main"]([msg_path], standalone_mode=False)
os.unlink(msg_path)
class TestGitMode:
def test_git_flag_reads_from_git(self, tmp_path) -> None:
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value="feat: add feature"):
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"):
runner = CliRunner()
result = runner.invoke(main, ["--git"])
assert result.exit_code == 0
def test_git_flag_master_valid(self) -> None:
msg = "DEVX-24: fix: resolve timeout"
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg):
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(main, ["--git", "--branch", "master"])
assert result.exit_code == 0
def test_git_flag_master_invalid(self) -> None:
msg = "fix: resolve timeout"
with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg):
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(main, ["--git", "--branch", "master"])
assert result.exit_code != 0
def test_no_file_no_git_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--branch", "master"])
assert result.exit_code != 0
def test_get_latest_commit_msg_success(self) -> None:
with patch("subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: test\n\nBody")
result = get_latest_commit_msg()
assert result == "feat: test\n\nBody"
def test_stdin_input(self) -> None:
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"):
runner = CliRunner()
result = runner.invoke(main, input="feat: add feature\n", args=["-", "--branch", "feature-branch"])
assert result.exit_code == 0