GRM-27: fix: CI workflows for rootless runner compatibility
Post-merge Vikunja update / vikunja (push) Failing after 5s
CI / quality (push) Successful in 1m3s
CI / molecule-tests (0) (push) Failing after 3m35s
CI / molecule-tests (1) (push) Failing after 5m53s
CI / molecule-tests (2) (push) Failing after 5m59s

This commit was merged in pull request #6.
This commit is contained in:
2026-06-20 17:01:55 +00:00
parent ea18793963
commit 0fadb7c504
7 changed files with 125 additions and 17 deletions
+4 -3
View File
@@ -6,18 +6,19 @@ on:
jobs: jobs:
merge: merge:
if: github.event.label.name == 'ready-to-merge'
runs-on: docker runs-on: docker
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: python3 -m pip install requests run: python3 -m pip install --break-system-packages requests python-dotenv click
- name: Squash merge with task ID - name: Squash merge with task ID
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: | run: |
python3 scripts/auto_merge.py \ python3 scripts/auto_merge.py \
"${{ github.head_ref }}" \ "${{ github.head_ref }}" \
"${{ github.event.pull_request.title }}" \ "${{ github.event.pull_request.title }}" \
"${{ github.repository }}" \ "${{ github.repository }}" \
"${{ github.event.number }}" "${{ github.event.number }}" \
"${{ github.event.label.name }}"
+2 -1
View File
@@ -12,10 +12,11 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install dependencies - name: Install dependencies
run: python3 -m pip install requests run: python3 -m pip install --break-system-packages requests python-dotenv click
- name: Update Vikunja task - name: Update Vikunja task
env: env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: src
run: | run: |
python3 scripts/post_merge.py \ python3 scripts/post_merge.py \
"$(git log -1 --pretty=%B)" \ "$(git log -1 --pretty=%B)" \
+2 -1
View File
@@ -12,11 +12,12 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install build tools - name: Install build tools
run: | run: |
python3 -m pip install build twine requests python3 -m pip install --break-system-packages build twine requests python-dotenv click
- name: Build and publish release - name: Build and publish release
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
PYTHONPATH: src
run: | run: |
python3 scripts/publish.py \ python3 scripts/publish.py \
"${{ github.ref_name }}" \ "${{ github.ref_name }}" \
+19 -4
View File
@@ -2,7 +2,7 @@
"""Auto-merge PR by extracting task ID from branch and validating PR title. """Auto-merge PR by extracting task ID from branch and validating PR title.
Usage: Usage:
REPO_TOKEN=<token> python3 scripts/auto_merge.py <branch> <pr_title> <repo> <pr_number> REPO_TOKEN=<token> python3 scripts/auto_merge.py <branch> <pr_title> <repo> <pr_number> [label_name]
""" """
import os import os
@@ -15,6 +15,8 @@ from gitea_runner_manager.config import CONVENTIONAL_RE, GITEA_API_URL, TASK_ID_
from gitea_runner_manager.exceptions import APIError from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _ from gitea_runner_manager.i18n import _
READY_TO_MERGE = "ready-to-merge"
load_dotenv(override=True) load_dotenv(override=True)
@@ -37,16 +39,31 @@ def validate_pr_title(pr_title: str) -> None:
) )
def has_ready_to_merge_label(client: GiteaClient, pr_number: str) -> bool:
"""Check whether the PR has the ready-to-merge label via the API."""
labels = client.get_pr_labels(pr_number)
return any(label.get("name") == READY_TO_MERGE for label in labels)
@click.command() @click.command()
@click.argument("branch") @click.argument("branch")
@click.argument("pr_title") @click.argument("pr_title")
@click.argument("repo") @click.argument("repo")
@click.argument("pr_number") @click.argument("pr_number")
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None: @click.argument("label_name", required=False, default="")
def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: str) -> None:
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("REPO_TOKEN", "")
if not token: if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
# Gitea Actions may not populate github.event.label.name; fall back to API check.
if label_name != READY_TO_MERGE and not has_ready_to_merge_label(client, pr_number):
click.echo(_("Label '{label}' is not '{rtm}', skipping.", label=label_name, rtm=READY_TO_MERGE))
return
task_id = extract_task_id(branch) task_id = extract_task_id(branch)
if not task_id: if not task_id:
raise click.ClickException( raise click.ClickException(
@@ -59,8 +76,6 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
validate_pr_title(pr_title) validate_pr_title(pr_title)
merge_title = f"{task_id}: {pr_title}" merge_title = f"{task_id}: {pr_title}"
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
try: try:
client.merge_pr(pr_number, merge_title) client.merge_pr(pr_number, merge_title)
+5
View File
@@ -107,6 +107,11 @@ class GiteaClient:
# -- pulls / releases -- # -- pulls / releases --
def get_pr_labels(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch labels currently attached to a pull request."""
r = self._request("GET", f"/issues/{pr_number}/labels")
return r.json()
def merge_pr(self, pr_number: str | int, merge_title: str) -> None: def merge_pr(self, pr_number: str | int, merge_title: str) -> None:
payload = {"Do": "squash", "MergeTitleField": merge_title} payload = {"Do": "squash", "MergeTitleField": merge_title}
self._request("POST", f"/pulls/{pr_number}/merge", json=payload) self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
+12
View File
@@ -189,6 +189,18 @@ class TestGiteaClient:
json={"Do": "squash", "MergeTitleField": "fix: bug"}, json={"Do": "squash", "MergeTitleField": "fix: bug"},
) )
def test_get_pr_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
result = client.get_pr_labels(5)
assert result == [{"name": "ready-to-merge"}]
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/issues/5/labels",
timeout=DEFAULT_TIMEOUT,
)
def test_update_repo_settings(self) -> None: def test_update_repo_settings(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({"default_delete_branch_after_merge": True})) client._session.request = MagicMock(return_value=_mock_response({"default_delete_branch_after_merge": True}))
+81 -8
View File
@@ -9,7 +9,7 @@ from click.testing import CliRunner
from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
from gitea_runner_manager.exceptions import APIError from gitea_runner_manager.exceptions import APIError
from scripts.auto_merge import extract_task_id, main, validate_pr_title from scripts.auto_merge import extract_task_id, has_ready_to_merge_label, main, validate_pr_title
class TestRegexes: class TestRegexes:
@@ -53,40 +53,111 @@ class TestValidatePrTitle:
assert "conventional" in str(exc.value) assert "conventional" in str(exc.value)
class TestHasReadyToMergeLabel:
def test_label_present(self) -> None:
client = MagicMock()
client.get_pr_labels.return_value = [{"name": "bug"}, {"name": "ready-to-merge"}]
assert has_ready_to_merge_label(client, "5") is True
def test_label_absent(self) -> None:
client = MagicMock()
client.get_pr_labels.return_value = [{"name": "bug"}]
assert has_ready_to_merge_label(client, "5") is False
def test_no_labels(self) -> None:
client = MagicMock()
client.get_pr_labels.return_value = []
assert has_ready_to_merge_label(client, "5") is False
class TestMain: class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient") @patch("scripts.auto_merge.GiteaClient")
def test_successful_flow(self, mock_client_cls: MagicMock) -> None: def test_successful_flow_with_label_arg(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7", "ready-to-merge"],
)
assert result.exit_code == 0
assert "squash-merged" in result.output
mock_client.merge_pr.assert_called_once_with("7", "GRM-19: fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
def test_successful_flow_label_fallback(self, mock_client_cls: MagicMock) -> None:
"""Label not passed via arg, but PR has ready-to-merge via API."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke( result = runner.invoke(
main, main,
["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7"], ["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7"],
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert "squash-merged" in result.output assert "squash-merged" in result.output
mock_client_cls.assert_called_once()
mock_client.merge_pr.assert_called_once_with("7", "GRM-19: fix: resolve timeout") mock_client.merge_pr.assert_called_once_with("7", "GRM-19: fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
def test_wrong_label_skips_merge(self, mock_client_cls: MagicMock) -> None:
"""Label is not ready-to-merge and PR doesn't have it via API either."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "bug"}]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7", "bug"],
)
assert result.exit_code == 0
assert "skipping" in result.output
mock_client.merge_pr.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
def test_empty_label_falls_back_to_api(self, mock_client_cls: MagicMock) -> None:
"""Gitea Actions doesn't populate label name, but API shows ready-to-merge."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7", ""],
)
assert result.exit_code == 0
assert "squash-merged" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None: def test_missing_token_exits(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["branch", "title", "repo", "1"]) result = runner.invoke(main, ["branch", "title", "owner/repo", "1"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "REPO_TOKEN" in result.output assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"REPO_TOKEN": "tok"})
def test_missing_task_id_exits(self) -> None: @patch("scripts.auto_merge.GiteaClient")
def test_missing_task_id_exits(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["feature-no-id", "fix: bug", "repo", "1"]) result = runner.invoke(main, ["feature-no-id", "fix: bug", "owner/repo", "1"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "task ID" in result.output assert "task ID" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"REPO_TOKEN": "tok"})
def test_invalid_pr_title_exits(self) -> None: @patch("scripts.auto_merge.GiteaClient")
def test_invalid_pr_title_exits(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "random title", "repo", "1"]) result = runner.invoke(main, ["GRM-19-fix", "random title", "owner/repo", "1"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "conventional" in result.output assert "conventional" in result.output
@@ -94,6 +165,7 @@ class TestMain:
@patch("scripts.auto_merge.GiteaClient") @patch("scripts.auto_merge.GiteaClient")
def test_merge_pr_failure_raises_click(self, mock_client_cls: MagicMock) -> None: def test_merge_pr_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error") mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
@@ -105,6 +177,7 @@ class TestMain:
@patch("scripts.auto_merge.GiteaClient") @patch("scripts.auto_merge.GiteaClient")
def test_merge_pr_json_parse_failure(self, mock_client_cls: MagicMock) -> None: def test_merge_pr_json_parse_failure(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway") mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()