Public Access
DEVX-34: fix: retrospective fixes for CI/CD friction
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 25s
Post-merge / release (push) Successful in 50s
Post-merge / vikunja (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 41s
Post-merge / badges (push) Successful in 40s
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 25s
Post-merge / release (push) Successful in 50s
Post-merge / vikunja (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 41s
Post-merge / badges (push) Successful in 40s
This commit was merged in pull request #57.
This commit is contained in:
@@ -120,8 +120,8 @@ jobs:
|
|||||||
|
|
||||||
auto-merge:
|
auto-merge:
|
||||||
# Auto-merge runs after all CI checks pass. It reads the task ID
|
# Auto-merge runs after all CI checks pass. It reads the task ID
|
||||||
# from .taskid file, validates the PR title, and squash-merges.
|
# from the branch name (falling back to .taskid file), validates
|
||||||
# No manual label or review needed — CI is the quality gate.
|
# the PR title, and squash-merges.
|
||||||
needs: [quality, detect-changes, pr-review]
|
needs: [quality, detect-changes, pr-review]
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: docker
|
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` |
|
| 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
|
||||||
|
|
||||||
|
`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
|
## Config System
|
||||||
|
|
||||||
devx uses environment variables with `.env` file fallback for configuration.
|
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_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_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) |
|
| `DEVX_LANG` | `en` | Language for i18n (en, bg) |
|
||||||
| `REPO_TOKEN` | (from .env) | Gitea API token |
|
| `REPO_TOKEN` | (from .env) | Gitea API token |
|
||||||
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
|
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Auto-merge PR when all CI checks pass.
|
"""Auto-merge PR when all CI checks pass.
|
||||||
|
|
||||||
Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file
|
Runs as the final job in ci.yml. Reads the task ID from the branch name
|
||||||
(falling back to branch name extraction for backwards compatibility),
|
(falling back to ``.taskid`` file for branches without a task ID prefix),
|
||||||
validates the PR title, and squash-merges with a conventional commit
|
validates the PR title, and squash-merges with a conventional commit
|
||||||
message prefixed by the task ID.
|
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
|
The branch name is the primary source of truth for the task ID
|
||||||
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``). The ``.taskid`` file
|
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``). The ``.taskid`` file
|
||||||
is a legacy fallback for branches without a task ID prefix.
|
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)
|
branch_task_id = extract_task_id(branch)
|
||||||
if branch_task_id:
|
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
|
return branch_task_id
|
||||||
# Fallback: read from .taskid file
|
# Fallback: read from .taskid file
|
||||||
path = Path(TASKID_FILE)
|
path = Path(TASKID_FILE)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
task_id = path.read_text(encoding="utf-8").strip()
|
task_id = path.read_text(encoding="utf-8").strip()
|
||||||
if task_id:
|
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 task_id
|
||||||
return ""
|
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")
|
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")
|
VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
|
||||||
|
|
||||||
# Organization defaults
|
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||||
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "oblachno-oss")
|
# 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 for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||||
TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX")
|
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:
|
if not repo:
|
||||||
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
|
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:
|
if owner is None:
|
||||||
owner = REPO_OWNER
|
owner = REPO_OWNER
|
||||||
|
|
||||||
|
|||||||
@@ -811,6 +811,13 @@
|
|||||||
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
||||||
"zh": "哎呀!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}": {
|
"PASSED: {pair}": {
|
||||||
"bg": "PASSED: {pair}",
|
"bg": "PASSED: {pair}",
|
||||||
"de": "PASSED: {pair}",
|
"de": "PASSED: {pair}",
|
||||||
@@ -986,6 +993,13 @@
|
|||||||
"ru": "Task ID: {task_id}",
|
"ru": "Task ID: {task_id}",
|
||||||
"zh": "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.": {
|
"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.",
|
"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.",
|
"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.",
|
"ru": "WARNING: --skip-tests passed — skipping test verification.",
|
||||||
"zh": "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.": {
|
"Warning: could not fetch tags from origin.": {
|
||||||
"bg": "Warning: could not fetch tags from origin.",
|
"bg": "Warning: could not fetch tags from origin.",
|
||||||
"de": "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")
|
(tmp_path / ".taskid").write_text("\n")
|
||||||
assert read_taskid("DEVX-42-test") == "DEVX-42"
|
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) --
|
# -- extract_task_id (legacy fallback) --
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class TestConfigConstants:
|
|||||||
assert DEFAULT_PER_PAGE == 50
|
assert DEFAULT_PER_PAGE == 50
|
||||||
|
|
||||||
def test_owner(self) -> None:
|
def test_owner(self) -> None:
|
||||||
assert REPO_OWNER == "oblachno-oss"
|
assert REPO_OWNER == ""
|
||||||
|
|
||||||
def test_task_prefix(self) -> None:
|
def test_task_prefix(self) -> None:
|
||||||
assert TASK_PREFIX == "DEVX"
|
assert TASK_PREFIX == "DEVX"
|
||||||
|
|||||||
@@ -163,3 +163,37 @@ class TestMain:
|
|||||||
mock_client.ensure_branch_protection.assert_called_once()
|
mock_client.ensure_branch_protection.assert_called_once()
|
||||||
args = mock_client.ensure_branch_protection.call_args
|
args = mock_client.ensure_branch_protection.call_args
|
||||||
assert args[0][0] == "develop"
|
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