GRM-33: feat: add mandatory PR review step to workflow

This commit is contained in:
2026-06-21 06:02:50 +00:00
parent 1717d55013
commit 5c1d848311
11 changed files with 726 additions and 35 deletions
+90 -4
View File
@@ -16,17 +16,103 @@ make test-all # pytest-cov + molecule
- **Python CLI** (`src/gitea_runner_manager/`) — Click-based CLI that delegates to Ansible
- **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup
- **CI Scripts** (`scripts/`) — Automation for auto-merge, post-merge, publishing, molecule distribution
- **CI Scripts** (`scripts/`) — Automation for auto-merge, post-merge, publishing, molecule distribution, PR reviews
## PR Workflow (Mandatory)
Every change to master goes through this workflow. No exceptions.
### 1. Create Vikunja Task
Create a task in Vikunja project 6 to get a `GRM-N` identifier.
### 2. Create Branch
```bash
git checkout master && git pull
git checkout -b GRM-N-short-description
```
### 3. Implement Changes
- Write code following conventions below
- Write/update tests (100% coverage required)
- Update documentation (CHANGELOG, README, AGENTS.md as needed)
### 4. Commit (Conventional Commits)
Branch commits use conventional commit format (no `GRM-N:` prefix):
```
feat: add new feature
fix: resolve bug
docs: update README
```
### 5. Push and Create PR
- **PR title format**: `GRM-N: <vikunja task title>` (colon-separated)
- PR body: summary of changes, `Closes GRM-N`
- Add `ready-to-merge` label **only after review is complete**
### 6. Review the PR (Mandatory — Before Adding ready-to-merge Label)
Review the full diff (`git diff master...HEAD`) focusing on:
- **Functional completeness**: Does the code do what it claims? Are all requirements met?
- **Edge cases**: Are boundary conditions, empty inputs, error paths handled?
- **Technical excellence**:
- Architecture compliance and evolution
- Single Responsibility Principle (SRP)
- Deduplication (no copy-paste, single source of truth)
- Code smells detection and removal
- Best industry practices
- Industry-grade code quality
- Reusability
- Clean code
- Readability
- Maintainability
- Extensibility
- **Performance**: No unnecessary allocations, O(n) vs O(n²), efficient data structures
- **Security**: No secrets in logs/process list, input validation, no injection vectors
- **User experience**: Clear error messages, intuitive CLI flags, helpful output
- **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates
Post review comments using `scripts/review_pr.py`:
```bash
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <owner/repo> \
--event REQUEST_CHANGES \
--body "Review summary" \
--comments-json comments.json
```
### 7. Address Review Comments
Fix each comment one by one, commit, and push. Re-review until satisfied.
### 8. Approve and Merge
Once all comments are addressed:
```bash
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <owner/repo> \
--event APPROVE \
--body "All comments addressed. LGTM."
```
Then add the `ready-to-merge` label. The auto-merge workflow will:
1. Wait for all CI checks to pass
2. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated)
3. The post-merge workflow marks the Vikunja task as done
### Title Format Summary
| What | Format | Example |
|------|--------|---------|
| Branch name | `GRM-N-short-description` | `GRM-33-add-pr-review-step` |
| Branch commits | `<conventional commit>` | `feat: add review script` |
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
## Key Conventions
- Python 3.12+ required (ruff/pyright target `py312`)
- 100% test coverage required (`--cov-fail-under=100`)
- Conventional commits on feature branches (no `GRM-N:` prefix)
- `GRM-N:` prefix on master branch (added by auto-merge)
- Branch names must include `GRM-N` task ID
- Line length: 120 chars
- Secrets are passed via temp JSON files, never on the command line (CWE-214)
- CI triggers only on `opened` and `synchronize` PR events (not `labeled`)
## Ansible Role Structure
@@ -36,8 +122,8 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
- `install_runner.yml` handles: download, config, validate, register, service
- `main.yml` handles: prune, integration_test (NOT install_runner — avoids duplicates)
- All `systemctl --user` tasks must be guarded by `docker_rootless_setup`
- All template creation tasks must be guarded by `docker_rootless_setup`
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just create files)
## Molecule Scenarios
+11
View File
@@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file.
### Added
- **Mandatory PR review step**: `scripts/review_pr.py` — CLI to post Gitea PR reviews (COMMENT, APPROVE, REQUEST_CHANGES) with inline comments via `--comments-json` or `--comments-stdin`.
- `GiteaClient.get_pr_files`, `GiteaClient.get_pr_commits`, `GiteaClient.create_review` — API methods for PR review workflow.
- `VikunjaClient.get_task` — fetch a single task by numeric ID.
- PR title format: `GRM-N: <vikunja task title>` (colon-separated, human-friendly).
- Merge commit format: `GRM-N <conventional commit message>` (space-separated, conventional).
- `auto_merge.py` now extracts the conventional commit message from PR commits and constructs the merge title as `GRM-N <conventional commit>`.
- `post_merge.py` `extract_conventional_msg` now handles both legacy (`GRM-N: <msg>`) and current (`GRM-N <msg>`) merge commit formats.
- Full PR workflow documented in `AGENTS.md` and `README.md` (Vikunja task → branch → implement → commit → PR → review → address comments → approve → merge).
### Changed
- Parameterized all hardcoded configuration values as Ansible variables in `defaults/main.yml`:
- `gitea_runner_data_dir` — Runtime data directory
- `gitea_runner_config_dir` — Config directory
+21 -3
View File
@@ -10,10 +10,28 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
## Commit Convention & Branch Naming
This project uses **conventional commits** and **GRM-N branch prefixes**. See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
This project uses **conventional commits** and **GRM-N branch prefixes**. See [AGENTS.md](AGENTS.md) for the full workflow.
- Branches: `GRM-N` or `GRM-N-brief-description` (required for CI automation)
- Commits: `feat:`, `fix:`, `chore:`, etc. (no `GRM-N:` prefix on feature branches)
| What | Format | Example |
|------|--------|---------|
| Branch name | `GRM-N-short-description` | `GRM-33-add-pr-review-step` |
| Branch commits | `<conventional commit>` | `feat: add review script` |
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
### PR Workflow
Every change to master goes through a mandatory review workflow:
1. **Create Vikunja task** — get a `GRM-N` identifier
2. **Create branch**`GRM-N-short-description`
3. **Implement** — write code, tests (100% coverage), update docs
4. **Commit** — conventional commits (no `GRM-N:` prefix on branch)
5. **Push & create PR** — title: `GRM-N: <vikunja task title>`
6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `scripts/review_pr.py`.
7. **Address comments** — fix each comment, commit, push, re-review
8. **Approve** — post an `APPROVE` review via `scripts/review_pr.py`
9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, post-merge workflow marks the Vikunja task as done
## Features
+60 -8
View File
@@ -1,14 +1,23 @@
#!/usr/bin/env python3
"""Auto-merge PR by extracting task ID from branch and validating PR title.
"""Auto-merge PR by extracting task ID from branch and constructing merge title.
Waits for CI checks to complete before attempting the merge.
PR title format: ``GRM-N: <vikunja task title>``
Merge commit format: ``GRM-N <conventional commit message>``
The conventional commit message is taken from the first commit on the PR
branch (the branch HEAD). This allows the PR title to be a human-friendly
Vikunja task title while the squashed commit follows conventional commits.
Usage:
REPO_TOKEN=<token> python3 scripts/auto_merge.py <branch> <pr_title> <repo> <pr_number> [label_name]
"""
import os
import re
import time
from typing import Any
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
@@ -22,6 +31,9 @@ READY_TO_MERGE = "ready-to-merge"
MAX_WAIT_SECONDS = 900 # 15 minutes
POLL_INTERVAL_SECONDS = 30
# PR title: GRM-N: <vikunja task title>
PR_TITLE_RE = re.compile(r"^GRM-\d+:\s+.+")
load_dotenv(override=True)
@@ -31,17 +43,50 @@ def extract_task_id(branch: str) -> str:
return match.group(0) if match else ""
def validate_pr_title(pr_title: str) -> None:
"""Raise ClickException if PR title does not follow conventional commits."""
if not CONVENTIONAL_RE.match(pr_title):
def validate_pr_title(pr_title: str, task_id: str) -> None:
"""Raise ClickException if PR title does not follow the required format.
Expected: ``GRM-N: <vikunja task title>``
"""
if not PR_TITLE_RE.match(pr_title):
raise click.ClickException(
_(
"Oops! PR title must follow conventional commit format.\n"
" Expected: <type>: <description>\n"
"Oops! PR title must follow format 'GRM-N: <task title>'.\n"
" Expected: {task_id}: <task title>\n"
" Got: {pr_title}",
task_id=task_id,
pr_title=pr_title,
)
)
if not pr_title.startswith(f"{task_id}:"):
raise click.ClickException(
_(
"Oops! PR title task ID mismatch.\n"
" Branch task ID: {task_id}\n"
" PR title: {pr_title}",
task_id=task_id,
pr_title=pr_title,
)
)
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
"""Extract the conventional commit message from PR commits.
Iterates commits in reverse order (newest first) to find the first
message matching the conventional commit format. Falls back to the
newest commit message if none match.
"""
for commit in reversed(commits):
commit_info = commit.get("commit", {})
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
if CONVENTIONAL_RE.match(message):
return message
# Fallback: use the newest commit's first line
if commits:
commit_info = commits[-1].get("commit", {})
return str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
return ""
def has_ready_to_merge_label(client: GiteaClient, pr_number: str) -> bool:
@@ -138,7 +183,7 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: str)
)
)
validate_pr_title(pr_title)
validate_pr_title(pr_title, task_id)
# Wait for CI checks to complete before attempting merge.
pr = client.get_pr(pr_number)
@@ -152,7 +197,14 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: str)
else:
click.echo(_("Warning: could not determine PR head SHA, proceeding without CI wait."))
merge_title = f"{task_id}: {pr_title}"
# Build merge title: GRM-N <conventional commit message>
commits = client.get_pr_commits(pr_number)
conv_msg = extract_conventional_msg(commits)
if not conv_msg:
raise click.ClickException(
_("Could not extract conventional commit message from PR commits.")
)
merge_title = f"{task_id} {conv_msg}"
try:
client.merge_pr(pr_number, merge_title)
+7 -2
View File
@@ -27,9 +27,14 @@ def extract_task_id(commit_msg: str) -> str:
def extract_conventional_msg(commit_msg: str) -> str:
"""Strip the GRM-N prefix from the commit subject."""
"""Strip the GRM-N prefix from the commit subject.
Handles both formats:
- ``GRM-N: <message>`` (legacy, colon-separated)
- ``GRM-N <message>`` (current, space-separated)
"""
first_line = commit_msg.split("\n")[0]
return re.sub(r"^GRM-\d+:\s*", "", first_line)
return re.sub(r"^GRM-\d+[:\s]\s*", "", first_line)
def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Post a review on a Gitea pull request.
Used by the GRM workflow to post structured PR reviews. The review body
is provided via --body and inline comments via a JSON file
(--comments-json) or stdin (--comments-stdin). This script is a thin
CLI wrapper around ``GiteaClient.create_review`` — the actual review
analysis is performed by the agent before invoking this tool.
Usage:
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <repo> \
--event COMMENT \
--body "Review body text" \
--comments-json comments.json
The comments JSON file is a list of objects with keys:
- path: file path in the repo
- body: comment text
- new_position: line number in the new file (1-based)
- old_position: (optional) line number in the old file
Review focus areas (for the reviewer, not enforced by this script):
- Functional completeness
- Edge cases
- Technical excellence: architecture compliance, SRP, deduplication,
code smells, best practices, code quality, reusability, clean code,
readability, maintainability, extensibility
- Performance
- Security
- User experience
- Documentation completeness and relevance
"""
from __future__ import annotations
import json
import os
import sys
from typing import Any
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import GITEA_API_URL
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
load_dotenv(override=True)
VALID_EVENTS = ("APPROVE", "REQUEST_CHANGES", "COMMENT")
def parse_comments(comments_json: str | None, comments_stdin: bool) -> list[dict[str, Any]]:
"""Parse inline comments from a JSON file or stdin."""
if comments_json:
try:
with open(comments_json) as f:
data = json.load(f)
except json.JSONDecodeError as e:
raise click.ClickException(_("Invalid JSON in comments file: {error}", error=str(e))) from None
if not isinstance(data, list):
raise click.ClickException(_("Comments JSON must be a list of objects."))
return data
if comments_stdin:
raw = sys.stdin.read().strip()
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise click.ClickException(_("Invalid JSON on stdin: {error}", error=str(e))) from None
if not isinstance(data, list):
raise click.ClickException(_("Stdin comments JSON must be a list of objects."))
return data
return []
@click.command()
@click.argument("pr_number")
@click.argument("repo")
@click.option(
"--event",
default="COMMENT",
type=click.Choice(VALID_EVENTS),
help="Review event type: APPROVE, REQUEST_CHANGES, or COMMENT.",
)
@click.option("--body", default="", help="Top-level review body text.")
@click.option("--comments-json", default=None, help="Path to JSON file with inline comments.")
@click.option(
"--comments-stdin",
is_flag=True,
default=False,
help="Read inline comments JSON from stdin.",
)
def main(
pr_number: str,
repo: str,
event: str,
body: str,
comments_json: str | None,
comments_stdin: bool,
) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
comments = parse_comments(comments_json, comments_stdin)
if event != "APPROVE" and not body and not comments:
raise click.ClickException(_("Review body or inline comments are required for event '{event}'.", event=event))
try:
review = client.create_review(pr_number, event=event, body=body, comments=comments)
except APIError as e:
raise click.ClickException(
_(
"Failed to post review: HTTP {status}{message}",
status=e.status,
message=e.message,
)
) from None
review_id = review.get("id", "?")
click.echo(
_(
"Review #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
review_id=review_id,
pr_number=pr_number,
event=event,
num_comments=len(comments),
)
)
if __name__ == "__main__": # pragma: no cover
main()
+36
View File
@@ -126,6 +126,37 @@ class GiteaClient:
r = self._request("GET", f"/pulls/{pr_number}")
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")
return r.json()
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch the commits included in a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/commits")
return r.json()
def create_review(
self,
pr_number: str | int,
event: str = "COMMENT",
body: str = "",
comments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Post a review on a pull request.
Args:
event: ``APPROVE``, ``REQUEST_CHANGES``, or ``COMMENT``.
body: Top-level review body text.
comments: Line-level comments with ``path``, ``body``,
``new_position`` (and optionally ``old_position``).
"""
payload: dict[str, Any] = {"event": event, "body": body}
if comments:
payload["comments"] = comments
r = self._request("POST", f"/pulls/{pr_number}/reviews", json=payload)
return r.json()
def create_release(
self,
tag: str,
@@ -167,6 +198,11 @@ class VikunjaClient:
r = self._request("GET", "/tasks", params=params)
return r.json()
def get_task(self, task_id: int) -> dict[str, Any]:
"""Fetch a single task by its numeric ID."""
r = self._request("GET", f"/tasks/{task_id}")
return r.json()
def list_project_tasks(self, project_id: int, **params: Any) -> list[dict[str, Any]]:
"""List tasks in a specific project (more efficient than listing all tasks)."""
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
+72
View File
@@ -228,6 +228,63 @@ class TestGiteaClient:
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_files(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"filename": "src/main.py", "status": "modified"}])
)
result = client.get_pr_files(7)
assert len(result) == 1
assert result[0]["filename"] == "src/main.py"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/files",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_commits(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"sha": "abc123", "commit": {"message": "fix: bug"}}])
)
result = client.get_pr_commits(7)
assert len(result) == 1
assert result[0]["commit"]["message"] == "fix: bug"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/commits",
timeout=DEFAULT_TIMEOUT,
)
def test_create_review_comment(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 42}))
result = client.create_review(7, event="COMMENT", body="Looks good")
assert result["id"] == 42
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "COMMENT", "body": "Looks good"},
)
def test_create_review_with_inline_comments(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 43}))
comments = [{"path": "src/main.py", "body": "Fix this", "new_position": 10}]
result = client.create_review(7, event="REQUEST_CHANGES", body="Please fix", comments=comments)
assert result["id"] == 43
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "REQUEST_CHANGES", "body": "Please fix", "comments": comments},
)
def test_update_repo_settings(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"default_delete_branch_after_merge": True}))
@@ -289,6 +346,21 @@ class TestVikunjaClient:
params={"page": 1, "per_page": DEFAULT_PER_PAGE},
)
def test_get_task(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response({"id": 292, "identifier": "GRM-32", "title": "Some task"})
)
result = client.get_task(292)
assert result["identifier"] == "GRM-32"
assert result["title"] == "Some task"
client._session.request.assert_called_once_with(
"GET",
"https://work.example.com/tasks/292",
timeout=DEFAULT_TIMEOUT,
)
def test_post_comment(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response())
+113 -17
View File
@@ -10,6 +10,8 @@ from click.testing import CliRunner
from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
from gitea_runner_manager.exceptions import APIError
from scripts.auto_merge import (
PR_TITLE_RE,
extract_conventional_msg,
extract_task_id,
has_ready_to_merge_label,
main,
@@ -26,6 +28,10 @@ def _status(context: str, status: str, updated_at: str = "2026-01-01T00:00:00Z")
return {"context": context, "status": status, "updated_at": updated_at}
def _commit(message: str) -> dict[str, dict[str, str]]:
return {"commit": {"message": message}}
class TestRegexes:
def test_task_id_re_matches(self) -> None:
assert TASK_ID_RE.search("GRM-19-fix-bug")
@@ -42,6 +48,15 @@ class TestRegexes:
assert not CONVENTIONAL_RE.match("random message")
assert not CONVENTIONAL_RE.match("feat:")
def test_pr_title_re_matches(self) -> None:
assert PR_TITLE_RE.match("GRM-19: Some task title")
assert PR_TITLE_RE.match("GRM-42: double space title")
def test_pr_title_re_rejects(self) -> None:
assert not PR_TITLE_RE.match("fix: resolve timeout")
assert not PR_TITLE_RE.match("GRM-19:No space after colon")
assert not PR_TITLE_RE.match("random message")
class TestExtractTaskId:
def test_extracts_from_branch(self) -> None:
@@ -56,15 +71,58 @@ class TestExtractTaskId:
class TestValidatePrTitle:
def test_valid_title_passes(self) -> None:
validate_pr_title("fix: resolve timeout")
validate_pr_title("GRM-19: Some task title", "GRM-19")
def test_valid_title_with_scope_passes(self) -> None:
validate_pr_title("feat(cli): add --url option")
validate_pr_title("GRM-42: Add --url option", "GRM-42")
def test_invalid_title_raises(self) -> None:
def test_invalid_format_raises(self) -> None:
with pytest.raises(click.ClickException) as exc:
validate_pr_title("random message")
assert "conventional" in str(exc.value)
validate_pr_title("random message", "GRM-19")
assert "GRM-N" in str(exc.value)
def test_invalid_format_conventional_raises(self) -> None:
with pytest.raises(click.ClickException) as exc:
validate_pr_title("fix: resolve timeout", "GRM-19")
assert "GRM-N" in str(exc.value)
def test_task_id_mismatch_raises(self) -> None:
with pytest.raises(click.ClickException) as exc:
validate_pr_title("GRM-42: Some task title", "GRM-19")
assert "mismatch" in str(exc.value)
class TestExtractConventionalMsg:
def test_returns_newest_conventional(self) -> None:
"""Iterates in reverse — picks the newest conventional commit."""
commits = [
_commit("fix: resolve timeout"),
_commit("random message"),
_commit("feat: add thing"),
]
assert extract_conventional_msg(commits) == "feat: add thing"
def test_falls_back_to_newest_commit(self) -> None:
commits = [
_commit("another random"),
_commit("random message"),
]
assert extract_conventional_msg(commits) == "random message"
def test_uses_first_line_only(self) -> None:
commits = [_commit("fix: resolve timeout\n\nBody text here")]
assert extract_conventional_msg(commits) == "fix: resolve timeout"
def test_empty_commits_returns_empty(self) -> None:
assert extract_conventional_msg([]) == ""
def test_commit_with_scope(self) -> None:
commits = [_commit("feat(api): new endpoint")]
assert extract_conventional_msg(commits) == "feat(api): new endpoint"
def test_missing_commit_key(self) -> None:
commits = [{}] # type: ignore[list-item]
assert extract_conventional_msg(commits) == ""
class TestHasReadyToMergeLabel:
@@ -161,6 +219,10 @@ def _mock_ci_passing() -> list[dict[str, str]]:
return [_status("CI / quality (pull_request)", CI_SUCCESS)]
def _mock_commits() -> list[dict[str, dict[str, str]]]:
return [_commit("fix: resolve timeout")]
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
@@ -169,14 +231,15 @@ class TestMain:
mock_client_cls.return_value = mock_client
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = _mock_commits()
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7", "ready-to-merge"],
["GRM-19-fix-bug", "GRM-19: Some task title", "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")
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")
@@ -186,15 +249,16 @@ class TestMain:
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = _mock_commits()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7"],
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7"],
)
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")
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")
@@ -206,7 +270,7 @@ class TestMain:
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7", "bug"],
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7", "bug"],
)
assert result.exit_code == 0
assert "skipping" in result.output
@@ -220,11 +284,12 @@ class TestMain:
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = _mock_commits()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7", ""],
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7", ""],
)
assert result.exit_code == 0
assert "squash-merged" in result.output
@@ -243,7 +308,7 @@ class TestMain:
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["feature-no-id", "fix: bug", "owner/repo", "1"])
result = runner.invoke(main, ["feature-no-id", "GRM-19: bug", "owner/repo", "1"])
assert result.exit_code == 1
assert "task ID" in result.output
@@ -256,7 +321,35 @@ class TestMain:
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "random title", "owner/repo", "1"])
assert result.exit_code == 1
assert "conventional" in result.output
assert "GRM-N" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
def test_pr_title_task_id_mismatch_exits(self, mock_client_cls: MagicMock) -> None:
"""PR title has a different task ID than the branch."""
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", "GRM-42: Some task title", "owner/repo", "1"])
assert result.exit_code == 1
assert "mismatch" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
def test_empty_commits_exits(self, mock_client_cls: MagicMock) -> None:
"""PR has no commits — cannot extract conventional message."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = []
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
assert result.exit_code == 1
assert "conventional commit" in result.output
mock_client.merge_pr.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
@@ -265,10 +358,11 @@ class TestMain:
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = _mock_commits()
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
assert result.exit_code == 1
assert "HTTP" in result.output
@@ -279,10 +373,11 @@ class TestMain:
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.get_pr_commits.return_value = _mock_commits()
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
assert result.exit_code == 1
assert str(http.HTTPStatus.BAD_GATEWAY) in result.output
@@ -298,7 +393,7 @@ class TestMain:
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
assert result.exit_code == 1
assert "CI checks did not pass" in result.output
mock_client.merge_pr.assert_not_called()
@@ -310,8 +405,9 @@ class TestMain:
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = {"head": {}}
mock_client.get_pr_commits.return_value = _mock_commits()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
assert result.exit_code == 0
assert "squash-merged" in result.output
+6 -1
View File
@@ -26,9 +26,14 @@ class TestExtractTaskId:
class TestExtractConventionalMsg:
def test_strips_task_id_prefix(self) -> None:
def test_strips_colon_prefix(self) -> None:
"""Legacy format: GRM-N: <message>"""
assert extract_conventional_msg("GRM-19: fix: resolve bug") == "fix: resolve bug"
def test_strips_space_prefix(self) -> None:
"""Current format: GRM-N <message>"""
assert extract_conventional_msg("GRM-19 fix: resolve bug") == "fix: resolve bug"
def test_returns_unchanged_without_prefix(self) -> None:
assert extract_conventional_msg("fix: resolve bug") == "fix: resolve bug"
+170
View File
@@ -0,0 +1,170 @@
"""Unit tests for scripts/review_pr.py."""
import http
import json
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from gitea_runner_manager.exceptions import APIError
from scripts.review_pr import main, parse_comments
class TestParseComments:
def test_parse_from_json_file(self, tmp_path) -> None:
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
f = tmp_path / "comments.json"
f.write_text(json.dumps(comments))
assert parse_comments(str(f), False) == comments
def test_parse_from_stdin(self) -> None:
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
mock_stdin.read.return_value = json.dumps(comments)
assert parse_comments(None, True) == comments
def test_no_comments_returns_empty(self) -> None:
assert parse_comments(None, False) == []
def test_invalid_json_file_raises(self, tmp_path) -> None:
f = tmp_path / "comments.json"
f.write_text("not json{")
with pytest.raises(click.ClickException):
parse_comments(str(f), False)
def test_non_list_json_raises(self, tmp_path) -> None:
f = tmp_path / "comments.json"
f.write_text(json.dumps({"path": "a.py"}))
with pytest.raises(click.ClickException):
parse_comments(str(f), False)
def test_stdin_non_list_raises(self) -> None:
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
mock_stdin.read.return_value = json.dumps({"path": "a.py"})
with pytest.raises(click.ClickException):
parse_comments(None, True)
def test_stdin_invalid_json_raises(self) -> None:
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
mock_stdin.read.return_value = "not json{"
with pytest.raises(click.ClickException):
parse_comments(None, True)
def test_stdin_empty_returns_empty(self) -> None:
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
mock_stdin.read.return_value = " "
assert parse_comments(None, True) == []
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
def test_successful_comment_review(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_review.return_value = {"id": 42}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["5", "owner/repo", "--event", "COMMENT", "--body", "LGTM"],
)
assert result.exit_code == 0
assert "Review #42" in result.output
mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="LGTM", comments=[])
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
def test_successful_approve_review(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_review.return_value = {"id": 7}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["5", "owner/repo", "--event", "APPROVE"])
assert result.exit_code == 0
assert "Review #7" in result.output
mock_client.create_review.assert_called_once_with("5", event="APPROVE", body="", comments=[])
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
def test_successful_with_inline_comments(self, mock_client_cls: MagicMock, tmp_path) -> None:
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
f = tmp_path / "comments.json"
f.write_text(json.dumps(comments))
mock_client = MagicMock()
mock_client.create_review.return_value = {"id": 9}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["5", "owner/repo", "--comments-json", str(f)],
)
assert result.exit_code == 0
mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="", comments=comments)
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
def test_successful_with_stdin_comments(self, mock_client_cls: MagicMock) -> None:
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
mock_client = MagicMock()
mock_client.create_review.return_value = {"id": 11}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["5", "owner/repo", "--comments-stdin"],
input=json.dumps(comments),
)
assert result.exit_code == 0
mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="", comments=comments)
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["5", "owner/repo", "--body", "x"])
assert result.exit_code == 1
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
def test_no_body_or_comments_for_comment_event(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["5", "owner/repo", "--event", "COMMENT"])
assert result.exit_code == 1
assert "required" in result.output
mock_client.create_review.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
def test_no_body_or_comments_for_request_changes(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["5", "owner/repo", "--event", "REQUEST_CHANGES"])
assert result.exit_code == 1
assert "required" in result.output
mock_client.create_review.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
def test_api_error_raises_click(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_review.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["5", "owner/repo", "--body", "x"])
assert result.exit_code == 1
assert "HTTP" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
def test_invalid_event_choice(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["5", "owner/repo", "--event", "Bogus"])
assert result.exit_code != 0
mock_client.create_review.assert_not_called()