GRM-27: fix: CI workflows for rootless runner compatibility
This commit was merged in pull request #6.
This commit is contained in:
@@ -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 }}"
|
||||||
|
|||||||
@@ -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)" \
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user