Public Access
DEVX-1: Extract reusable dev/CI tools from GRM into devx package #1
@@ -120,8 +120,8 @@ jobs:
|
||||
|
||||
auto-merge:
|
||||
# Auto-merge runs after all CI checks pass. It reads the task ID
|
||||
# from .taskid file, validates the PR title, and squash-merges.
|
||||
# No manual label or review needed — CI is the quality gate.
|
||||
# from the branch name (falling back to .taskid file), validates
|
||||
# the PR title, and squash-merges.
|
||||
needs: [quality, detect-changes, pr-review]
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
|
||||
@@ -285,6 +285,37 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`.
|
||||
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
|
||||
| Merge commit | `DEVX-N <conventional commit>` | `DEVX-12 feat: add release script` |
|
||||
|
||||
### Task ID Resolution
|
||||
|
||||
`auto_merge` resolves the task ID from the branch name first (e.g.
|
||||
`DEVX-12-fix-foo` → `DEVX-12`), falling back to the `.taskid` file
|
||||
for branches without a task ID prefix. If both exist and disagree,
|
||||
a warning is printed and the branch task ID is preferred.
|
||||
|
||||
**When creating a new branch from an existing branch**, the `.taskid`
|
||||
file may be stale (it contains the old branch's task ID). Either:
|
||||
1. Update `.taskid` to match the new branch's task ID, or
|
||||
2. Delete `.taskid` — the branch name is the primary source of truth
|
||||
|
||||
### Workflow `auto-merge` Job and `always()`
|
||||
|
||||
When `auto-merge` depends on a job that can be skipped (e.g.
|
||||
`molecule-tests`), the `if:` condition MUST include `always() &&`
|
||||
at the start. Without it, Gitea Actions skips `auto-merge` when any
|
||||
dependency is skipped, even if the condition explicitly allows
|
||||
`result == 'skipped'`.
|
||||
|
||||
```yaml
|
||||
auto-merge:
|
||||
needs: [quality, detect-changes, pr-review, molecule-tests]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.quality.result == 'success' &&
|
||||
needs.pr-review.result == 'success' &&
|
||||
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
|
||||
```
|
||||
|
||||
## Config System
|
||||
|
||||
devx uses environment variables with `.env` file fallback for configuration.
|
||||
@@ -295,6 +326,10 @@ devx uses environment variables with `.env` file fallback for configuration.
|
||||
|----------|---------|-------------|
|
||||
| `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL |
|
||||
| `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL |
|
||||
| `DEVX_REPO_OWNER` | **(none — must be set)** | Repository owner for API calls |
|
||||
| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
|
||||
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
||||
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
|
||||
| `DEVX_LANG` | `en` | Language for i18n (en, bg) |
|
||||
| `REPO_TOKEN` | (from .env) | Gitea API token |
|
||||
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-merge PR when all CI checks pass.
|
||||
|
||||
Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file
|
||||
(falling back to branch name extraction for backwards compatibility),
|
||||
Runs as the final job in ci.yml. Reads the task ID from the branch name
|
||||
(falling back to ``.taskid`` file for branches without a task ID prefix),
|
||||
validates the PR title, and squash-merges with a conventional commit
|
||||
message prefixed by the task ID.
|
||||
|
||||
@@ -68,15 +68,38 @@ def read_taskid(branch: str) -> str:
|
||||
The branch name is the primary source of truth for the task ID
|
||||
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``). The ``.taskid`` file
|
||||
is a legacy fallback for branches without a task ID prefix.
|
||||
|
||||
If both sources exist and disagree, a warning is printed and the
|
||||
branch task ID is preferred (it is the current source of truth).
|
||||
"""
|
||||
branch_task_id = extract_task_id(branch)
|
||||
if branch_task_id:
|
||||
# Check for stale .taskid file that disagrees with branch name
|
||||
path = Path(TASKID_FILE)
|
||||
if path.exists():
|
||||
file_task_id = path.read_text(encoding="utf-8").strip()
|
||||
if file_task_id and file_task_id != branch_task_id:
|
||||
click.echo(
|
||||
_(
|
||||
"WARNING: .taskid file ({file_id}) disagrees with branch name ({branch_id}). "
|
||||
"Using branch task ID. Update or delete .taskid to silence this warning.",
|
||||
file_id=file_task_id,
|
||||
branch_id=branch_task_id,
|
||||
)
|
||||
)
|
||||
return branch_task_id
|
||||
# Fallback: read from .taskid file
|
||||
path = Path(TASKID_FILE)
|
||||
if path.exists():
|
||||
task_id = path.read_text(encoding="utf-8").strip()
|
||||
if task_id:
|
||||
click.echo(
|
||||
_(
|
||||
"Task ID from .taskid file: {task_id} (not found in branch name '{branch}')",
|
||||
task_id=task_id,
|
||||
branch=branch,
|
||||
)
|
||||
)
|
||||
return task_id
|
||||
return ""
|
||||
|
||||
|
||||
+3
-2
@@ -13,8 +13,9 @@ import re
|
||||
GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
|
||||
|
||||
# Organization defaults
|
||||
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||
# No default: prevents silent 404s when the wrong owner is used.
|
||||
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "")
|
||||
|
||||
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||
TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX")
|
||||
|
||||
@@ -155,6 +155,19 @@ def main(repo: str | None, owner: str | None, branch: str, api_url: str | None)
|
||||
if not repo:
|
||||
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
|
||||
|
||||
# If DEVX_REPO_NAME contains a slash (e.g. "oblachno/infra"), split into owner/repo.
|
||||
# This prevents 404s when workflows set DEVX_REPO_NAME to the full path.
|
||||
if "/" in repo and owner is None:
|
||||
parts = repo.split("/", 1)
|
||||
owner, repo = parts[0], parts[1]
|
||||
click.echo(
|
||||
_(
|
||||
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME",
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
)
|
||||
)
|
||||
|
||||
if owner is None:
|
||||
owner = REPO_OWNER
|
||||
|
||||
|
||||
@@ -811,6 +811,13 @@
|
||||
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
||||
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
|
||||
},
|
||||
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": {
|
||||
"bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME",
|
||||
"de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert",
|
||||
"en": "Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME",
|
||||
"ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME",
|
||||
"zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}"
|
||||
},
|
||||
"PASSED: {pair}": {
|
||||
"bg": "PASSED: {pair}",
|
||||
"de": "PASSED: {pair}",
|
||||
@@ -986,6 +993,13 @@
|
||||
"ru": "Task ID: {task_id}",
|
||||
"zh": "Task ID: {task_id}"
|
||||
},
|
||||
"Task ID from .taskid file: {task_id} (not found in branch name '{branch}')": {
|
||||
"bg": "Task ID от .taskid файл: {task_id} (не е намерен в името на клона '{branch}')",
|
||||
"de": "Task ID aus .taskid-Datei: {task_id} (nicht im Branch-Namen '{branch}' gefunden)",
|
||||
"en": "Task ID from .taskid file: {task_id} (not found in branch name '{branch}')",
|
||||
"ru": "Task ID из файла .taskid: {task_id} (не найден в имени ветки '{branch}')",
|
||||
"zh": "来自 .taskid 文件的 Task ID: {task_id}(在分支名 '{branch}' 中未找到)"
|
||||
},
|
||||
"Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.": {
|
||||
"bg": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
|
||||
"de": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
|
||||
@@ -1070,6 +1084,13 @@
|
||||
"ru": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
"zh": "WARNING: --skip-tests passed — skipping test verification."
|
||||
},
|
||||
"WARNING: .taskid file ({file_id}) disagrees with branch name ({branch_id}). Using branch task ID. Update or delete .taskid to silence this warning.": {
|
||||
"bg": "ВНИМАНИЕ: .taskid файл ({file_id}) не съвпада с името на клона ({branch_id}). Използва се task ID от клона. Актуализирайте или изтрийте .taskid за да премахнете това предупреждение.",
|
||||
"de": "WARNUNG: .taskid-Datei ({file_id}) stimmt nicht mit Branch-Namen ({branch_id}) überein. Branch-Task-ID wird verwendet. Aktualisieren oder löschen Sie .taskid, um diese Warnung zu unterdrücken.",
|
||||
"en": "WARNING: .taskid file ({file_id}) disagrees with branch name ({branch_id}). Using branch task ID. Update or delete .taskid to silence this warning.",
|
||||
"ru": "ВНИМАНИЕ: файл .taskid ({file_id}) не совпадает с именем ветки ({branch_id}). Используется Task ID из ветки. Обновите или удалите .taskid, чтобы скрыть это предупреждение.",
|
||||
"zh": "警告:.taskid 文件 ({file_id}) 与分支名 ({branch_id}) 不一致。使用分支 Task ID。更新或删除 .taskid 以消除此警告。"
|
||||
},
|
||||
"Warning: could not fetch tags from origin.": {
|
||||
"bg": "Warning: could not fetch tags from origin.",
|
||||
"de": "Warning: could not fetch tags from origin.",
|
||||
|
||||
@@ -46,6 +46,16 @@ class TestReadTaskid:
|
||||
(tmp_path / ".taskid").write_text("\n")
|
||||
assert read_taskid("DEVX-42-test") == "DEVX-42"
|
||||
|
||||
def test_warns_on_stale_taskid_file(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-60\n")
|
||||
# Branch name takes priority, but stale .taskid should produce a warning
|
||||
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
|
||||
captured = capsys.readouterr()
|
||||
assert "WARNING" in captured.out
|
||||
assert "DEVX-60" in captured.out
|
||||
assert "DEVX-19" in captured.out
|
||||
|
||||
|
||||
# -- extract_task_id (legacy fallback) --
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class TestConfigConstants:
|
||||
assert DEFAULT_PER_PAGE == 50
|
||||
|
||||
def test_owner(self) -> None:
|
||||
assert REPO_OWNER == "oblachno-oss"
|
||||
assert REPO_OWNER == ""
|
||||
|
||||
def test_task_prefix(self) -> None:
|
||||
assert TASK_PREFIX == "DEVX"
|
||||
|
||||
@@ -163,3 +163,37 @@ class TestMain:
|
||||
mock_client.ensure_branch_protection.assert_called_once()
|
||||
args = mock_client.ensure_branch_protection.call_args
|
||||
assert args[0][0] == "develop"
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None:
|
||||
"""DEVX_REPO_NAME with 'owner/repo' format should be split."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
# Verify GiteaClient was constructed with parsed owner and repo (positional)
|
||||
call_args = mock_client_cls.call_args
|
||||
assert call_args[0][2] == "oblachno" # owner is 3rd positional arg
|
||||
assert call_args[0][3] == "infra" # repo is 4th positional arg
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"},
|
||||
clear=True,
|
||||
)
|
||||
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno")
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_no_slash_when_owner_set_separately(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When DEVX_REPO_OWNER is set, DEVX_REPO_NAME should not be split."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
call_args = mock_client_cls.call_args
|
||||
assert call_args[0][2] == "oblachno" # owner
|
||||
assert call_args[0][3] == "infra" # repo
|
||||
|
||||
Reference in New Issue
Block a user