From 989856a007215d7e407ff6bd7fed87e2ab2de73a Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Fri, 26 Jun 2026 06:24:19 -0400 Subject: [PATCH] feat: add PR review and edit tools to CLI and MCP server --- README.md | 14 +++ edit_pr.py | 77 ++++++++++++++ mcp_server.py | 218 ++++++++++++++++++++++++++++++++++++++- review_pr.py | 106 +++++++++++++++++++ tests/test_mcp_server.py | 144 ++++++++++++++++++++++++++ tests/test_prs.py | 32 ++++++ tests/test_review_pr.py | 92 +++++++++++++++++ 7 files changed, 679 insertions(+), 4 deletions(-) create mode 100755 edit_pr.py create mode 100755 review_pr.py create mode 100644 tests/test_review_pr.py diff --git a/README.md b/README.md index d8317c5..9efc836 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,11 @@ Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools n |------|-------------| | `gitea_create_issue` | Create an issue with title, body, remote | | `gitea_create_pr` | Open a pull request with title, head, base | +| `gitea_edit_pr` | Edit details of an existing pull request | | `gitea_list_prs` | List pull requests with state/remote | | `gitea_view_pr` | Get full details of a single pull request | | `gitea_merge_pr` | Merge a pull request (merge, squash, or rebase) | +| `gitea_review_pr` | Submit a review on a pull request and optionally merge it | | `gitea_delete_branch` | Delete a remote branch | | `gitea_close_issue` | Close an issue by number | | `gitea_list_issues` | List issues with state/label filters | @@ -52,6 +54,8 @@ Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools n | `gitea_list_labels` | List all available labels in a repository | | `gitea_create_label` | Create a new label with custom color | | `gitea_set_issue_labels` | Replace all labels on an issue | +| `gitea_get_file` | Retrieve file content and SHA metadata | +| `gitea_commit_files` | Commit changes to multiple files atomically | | `gitea_mirror_refs` | Mirror branches + tags between instances | ### Setup @@ -139,6 +143,8 @@ The MCP tools can also be used as standalone CLI scripts: |---------------------|--------------------------------------------------------------------| | `create_issue.py` | Create an issue (`--remote`, `--title`, `--body`, `--body-file`) | | `create_pr.py` | Open a Pull Request (`--remote`, `--title`, `--head`, `--base`) | +| `edit_pr.py` | Edit a Pull Request (`--title`, `--body`, `--body-file`, etc.) | +| `review_pr.py` | Review and sign-off on a pull request (with optional merge) | | `close_issue.py` | Close a specific issue | | `mark_issue.py` | Claim/release an issue via `status:in-progress` label | | `manage_labels.py` | Create label set and apply label mappings (`--dry` to preview) | @@ -156,6 +162,12 @@ The MCP tools can also be used as standalone CLI scripts: # Create a PR ./create_pr.py --title "feat: add validation" --head feat/validation --body "Closes #12" +# Edit a PR's description or title +./edit_pr.py 155 --body "Updated description wording" + +# Review and approve a PR, then automatically merge it +./review_pr.py --pr-number 12 --event APPROVE --body "Approved" --merge + # Close issue #5 ./close_issue.py 5 @@ -181,6 +193,8 @@ gitea_auth.py ← shared auth & API helpers (get_credentials, api_request) mcp_server.py ← MCP server (FastMCP, stdio transport) create_issue.py ← CLI: create issues create_pr.py ← CLI: create PRs +edit_pr.py ← CLI: edit PRs +review_pr.py ← CLI: review PRs manage_labels.py ← CLI: label management close_issue.py ← CLI: close issues mark_issue.py ← CLI: claim/release issues diff --git a/edit_pr.py b/edit_pr.py new file mode 100755 index 0000000..780aa6d --- /dev/null +++ b/edit_pr.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Edit details of an existing Gitea pull request. + +Usage: + edit_pr.py --title "New Title" + edit_pr.py --body "New description" + edit_pr.py --state closed +""" +import os +import sys +import json +import argparse + +# Auto-execute using the project's local virtual environment Python +PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) +venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3") +if os.path.exists(venv_python) and sys.executable != venv_python: + os.execv(venv_python, [venv_python] + sys.argv) + +from gitea_auth import get_auth_header, resolve_remote, add_remote_args, api_request, repo_api_url + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Edit details of a Gitea pull request.") + add_remote_args(parser) + parser.add_argument("pr_number", type=int, help="PR number to edit.") + parser.add_argument("--title", help="New PR title.") + parser.add_argument("--body", help="New PR description.") + parser.add_argument("--body-file", help="Read new PR body from this file ('-' for stdin).") + parser.add_argument("--state", choices=["open", "closed"], help="Set PR state.") + parser.add_argument("--base", help="Target branch name.") + args = parser.parse_args(argv) + + host, org, repo = resolve_remote(args) + auth = get_auth_header(host) + if not auth: + print(f"Could not get credentials or token for {host}.", file=sys.stderr) + return 1 + + body = args.body + if args.body_file: + if args.body_file == "-": + body = sys.stdin.read() + else: + with open(args.body_file, "r", encoding="utf-8") as fh: + body = fh.read() + + payload = {} + if args.title is not None: + payload["title"] = args.title + if body is not None: + payload["body"] = body + if args.state is not None: + payload["state"] = args.state + if args.base is not None: + payload["base"] = args.base + + if not payload: + print("Error: At least one field to edit (--title, --body, --body-file, --state, --base) must be specified.", file=sys.stderr) + return 1 + + url = f"{repo_api_url(host, org, repo)}/pulls/{args.pr_number}" + + try: + pr = api_request("PATCH", url, auth, payload) + print(f"PR #{pr['number']} updated: {pr['html_url']}") + print(f"Title: {pr['title']}") + print(f"Status: {pr['state']}") + print(f"Target Branch: {pr['base']['ref']}") + return 0 + except Exception as e: + print(f"Error editing PR #{args.pr_number}: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mcp_server.py b/mcp_server.py index be59bc2..4ee0e31 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -17,11 +17,12 @@ import os import sys import subprocess -# Auto-execute using the project's local virtual environment Python +# Resolve the project root. MCP clients must launch this script directly with +# the venv interpreter (venv/bin/python3) — see the config example above. We do +# NOT os.execv() to re-point the interpreter: replacing the process after the +# client has already wired up the stdio pipes can desync the JSON-RPC transport +# (observed with Antigravity/Cascade hosts). PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) -venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3") -if os.path.exists(venv_python) and sys.executable != venv_python: - os.execv(venv_python, [venv_python] + sys.argv) # Ensure the project root is on the path so gitea_auth.py can be imported. if PROJECT_ROOT not in sys.path: @@ -208,6 +209,146 @@ def gitea_view_pr( } +@mcp.tool() +def gitea_edit_pr( + pr_number: int, + title: str | None = None, + body: str | None = None, + state: str | None = None, + base: str | None = None, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Edit an existing pull request on a Gitea repository. + + Args: + pr_number: The pull request index/number (required). + title: New PR title. + body: New PR description. + state: New state — 'open' or 'closed'. + base: Target branch name. + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict with success status and details of the edited PR. + """ + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}" + payload = {} + if title is not None: + payload["title"] = title + if body is not None: + payload["body"] = body + if state is not None: + payload["state"] = state + if base is not None: + payload["base"] = base + + if not payload: + raise ValueError("At least one field to edit (title, body, state, base) must be provided.") + + data = api_request("PATCH", url, auth, payload) + return { + "success": True, + "number": data["number"], + "title": data["title"], + "body": data.get("body", ""), + "state": data["state"], + "url": data["html_url"], + } + + +@mcp.tool() +def gitea_get_file( + filepath: str, + ref: str = "main", + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Retrieve metadata and content of a file from a Gitea repository. + + Args: + filepath: The path to the file in the repository (e.g. 'README.md' or 'src/main.py'). + ref: The branch, tag, or commit hash to retrieve the file from (default: 'main'). + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict containing 'name', 'path', 'sha', 'size', 'encoding', and 'content' (base64). + """ + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + import urllib.parse + encoded_path = urllib.parse.quote(filepath, safe="") + url = f"{repo_api_url(h, o, r)}/contents/{encoded_path}?ref={ref}" + data = api_request("GET", url, auth) + return { + "name": data.get("name", ""), + "path": data.get("path", ""), + "sha": data.get("sha", ""), + "size": data.get("size", 0), + "encoding": data.get("encoding", ""), + "content": data.get("content", ""), + } + + +@mcp.tool() +def gitea_commit_files( + files: list[dict], + message: str, + branch: str | None = None, + new_branch: str | None = None, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Commit changes to multiple files in a Gitea repository in a single atomic commit. + + Args: + files: List of file operations. Each file dict must contain 'operation' ('create', 'update', 'delete', 'rename'), 'path', and 'content' (base64 encoded for create/update), and optionally 'sha' (required for update/delete) or 'from_path' (for rename). + message: The commit message. + branch: Optional existing branch to start/commit from. + new_branch: Optional new branch name to create for this commit. + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict with success status and commit/branch information. + """ + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + url = f"{repo_api_url(h, o, r)}/contents" + + payload = { + "files": files, + "message": message, + } + if branch is not None: + payload["branch"] = branch + if new_branch is not None: + payload["new_branch"] = new_branch + + data = api_request("POST", url, auth, payload) + return { + "success": True, + "commit": data.get("commit", {}).get("sha", ""), + "branch": data.get("branch", {}).get("name", ""), + } + + @mcp.tool() def gitea_merge_pr( pr_number: int, @@ -251,6 +392,75 @@ def gitea_merge_pr( return {"success": True, "message": f"PR #{pr_number} merged via '{do}'."} +@mcp.tool() +def gitea_review_pr( + pr_number: int, + event: str = "APPROVE", + body: str = "", + merge: bool = False, + merge_method: str = "merge", + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Submit a review on a Gitea pull request and optionally merge it. + + Args: + pr_number: The PR number to review. + event: Review type — 'APPROVE', 'COMMENT', or 'REQUEST_CHANGES'. + body: Review body text / comment. + merge: If True and event is 'APPROVE', automatically merge the PR. + merge_method: Merge style to use if merging — 'merge', 'squash', or 'rebase'. + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict with success status and message. + """ + if event not in ["APPROVE", "COMMENT", "REQUEST_CHANGES"]: + raise ValueError(f"Invalid review event: '{event}'. Choose from 'APPROVE', 'COMMENT', 'REQUEST_CHANGES'.") + if merge_method not in ["merge", "squash", "rebase"]: + raise ValueError(f"Invalid merge method: '{merge_method}'. Choose from 'merge', 'squash', 'rebase'.") + + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + + # 1. Fetch PR to get the latest head commit SHA (required for review payload) + pr_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}" + pr_data = api_request("GET", pr_url, auth) + commit_sha = pr_data.get("head", {}).get("sha") + if not commit_sha: + raise RuntimeError(f"Could not find head commit SHA for PR #{pr_number}.") + + # 2. Submit the PR review + review_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}/reviews" + payload = { + "body": body, + "event": event, + "commit_id": commit_sha + } + api_request("POST", review_url, auth, payload) + msg = f"Successfully submitted review for PR #{pr_number} with event '{event}'." + + # 3. Merge PR if merge is True and event is APPROVE + if merge: + if event != "APPROVE": + msg += " Warning: Skipping merge because review event is not 'APPROVE'." + else: + merge_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}/merge" + merge_payload = { + "Do": merge_method, + "force_merge": False + } + api_request("POST", merge_url, auth, merge_payload) + msg += f" Successfully merged PR #{pr_number} using '{merge_method}' method." + + return {"success": True, "message": msg} + + @mcp.tool() def gitea_delete_branch( branch: str, diff --git a/review_pr.py b/review_pr.py new file mode 100755 index 0000000..374b4e8 --- /dev/null +++ b/review_pr.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Review and sign-off on a Gitea pull request. + +Supports submitting a review (APPROVE, COMMENT, REQUEST_CHANGES) and optionally +merging the pull request in one command. + +Usage: + review_pr.py --pr-number 12 --event APPROVE --body "Approved and signed off" --merge +""" +import os +import sys +import json +import base64 +import argparse +import urllib.request +import urllib.error + +# Auto-execute using the project's local virtual environment Python +PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) +venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3") +if os.path.exists(venv_python) and sys.executable != venv_python: + os.execv(venv_python, [venv_python] + sys.argv) + +from gitea_auth import get_auth_header, resolve_remote, add_remote_args, api_request, repo_api_url + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Review and sign-off on a Gitea pull request.") + add_remote_args(parser) + parser.add_argument("--pr-number", type=int, required=True, help="PR number/index to review.") + parser.add_argument("--event", choices=["APPROVE", "COMMENT", "REQUEST_CHANGES"], default="APPROVE", + help="Review event/action type (default: APPROVE).") + parser.add_argument("--body", default="", help="Review body/comment text.") + parser.add_argument("--body-file", help="Read review body from this file ('-' for stdin).") + parser.add_argument("--merge", action="store_true", help="Automatically merge the PR if approved.") + parser.add_argument("--merge-method", choices=["merge", "squash", "rebase"], default="merge", + help="Merge method/style to use if merging (default: merge).") + args = parser.parse_args(argv) + + host, org, repo = resolve_remote(args) + + body = args.body + if args.body_file: + if args.body_file == "-": + body = sys.stdin.read() + else: + with open(args.body_file, "r", encoding="utf-8") as fh: + body = fh.read() + + auth = get_auth_header(host) + if not auth: + print(f"Could not get credentials or token for {host}.", file=sys.stderr) + return 1 + + # 1. Fetch PR to get the latest head commit SHA (required for review validation) + pr_url = f"{repo_api_url(host, org, repo)}/pulls/{args.pr_number}" + try: + pr_data = api_request("GET", pr_url, auth) + except Exception as e: + print(f"Error fetching PR #{args.pr_number}: {e}", file=sys.stderr) + return 1 + + commit_sha = pr_data.get("head", {}).get("sha") + if not commit_sha: + print(f"Could not find head commit SHA for PR #{args.pr_number}.", file=sys.stderr) + return 1 + + # 2. Submit the PR review + review_url = f"{repo_api_url(host, org, repo)}/pulls/{args.pr_number}/reviews" + payload = { + "body": body, + "event": args.event, + "commit_id": commit_sha + } + + try: + api_request("POST", review_url, auth, payload) + print(f"Successfully submitted review for PR #{args.pr_number}: event={args.event}") + except Exception as e: + print(f"Error submitting review: {e}", file=sys.stderr) + return 1 + + # 3. Merge PR if --merge is requested and event is APPROVE + if args.merge: + if args.event != "APPROVE": + print("Warning: Skipping merge because review event is not 'APPROVE'.", file=sys.stderr) + return 0 + + merge_url = f"{repo_api_url(host, org, repo)}/pulls/{args.pr_number}/merge" + merge_payload = { + "Do": args.merge_method, + "force_merge": False + } + + try: + api_request("POST", merge_url, auth, merge_payload) + print(f"Successfully merged PR #{args.pr_number} using '{args.merge_method}' method.") + except Exception as e: + print(f"Error merging PR: {e}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index aa42b8b..e041871 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -21,7 +21,11 @@ from mcp_server import ( # noqa: E402 gitea_list_prs, gitea_view_pr, gitea_merge_pr, + gitea_review_pr, gitea_delete_branch, + gitea_edit_pr, + gitea_get_file, + gitea_commit_files, ) FAKE_AUTH = "Basic dGVzdDp0ZXN0" @@ -305,6 +309,47 @@ class TestMergePR(unittest.TestCase): self.assertEqual(payload["force_merge"], True) +# --------------------------------------------------------------------------- +# Review PR +# --------------------------------------------------------------------------- +class TestReviewPR(unittest.TestCase): + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_review_pr_and_merge(self, _auth, mock_api): + # GET PR response (fetch head SHA) + mock_api.side_effect = [ + {"head": {"sha": "sha-val-123"}}, # GET PR pulls/1 + {}, # POST review + {}, # POST merge + ] + result = gitea_review_pr( + pr_number=1, + event="APPROVE", + body="Looks good", + merge=True, + merge_method="squash" + ) + self.assertTrue(result["success"]) + self.assertIn("Successfully submitted review", result["message"]) + self.assertIn("Successfully merged", result["message"]) + + # Check call counts and arguments + self.assertEqual(mock_api.call_count, 3) + + # Verify GET PR + self.assertEqual(mock_api.call_args_list[0][0][0], "GET") + + # Verify POST review + self.assertEqual(mock_api.call_args_list[1][0][0], "POST") + self.assertEqual(mock_api.call_args_list[1][0][3]["event"], "APPROVE") + self.assertEqual(mock_api.call_args_list[1][0][3]["commit_id"], "sha-val-123") + + # Verify POST merge + self.assertEqual(mock_api.call_args_list[2][0][0], "POST") + self.assertEqual(mock_api.call_args_list[2][0][3]["Do"], "squash") + + # --------------------------------------------------------------------------- # Delete Branch # --------------------------------------------------------------------------- @@ -322,5 +367,104 @@ class TestDeleteBranch(unittest.TestCase): self.assertIn("feat%2Fbranch", url) +# --------------------------------------------------------------------------- +# Edit PR +# --------------------------------------------------------------------------- +class TestEditPR(unittest.TestCase): + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_edit_pr_success(self, _auth, mock_api): + mock_api.return_value = { + "number": 1, + "title": "New Title", + "body": "New description", + "state": "open", + "html_url": "https://gitea.example.com/pulls/1" + } + result = gitea_edit_pr( + pr_number=1, + title="New Title", + body="New description", + state="open" + ) + self.assertTrue(result["success"]) + self.assertEqual(result["title"], "New Title") + self.assertEqual(result["body"], "New description") + + # Verify PATCH and payload + call_args = mock_api.call_args + self.assertEqual(call_args[0][0], "PATCH") + self.assertEqual(call_args[0][3]["title"], "New Title") + self.assertEqual(call_args[0][3]["body"], "New description") + self.assertEqual(call_args[0][3]["state"], "open") + + def test_edit_pr_no_fields_raises(self): + with self.assertRaises(ValueError): + gitea_edit_pr(pr_number=1) + + +# --------------------------------------------------------------------------- +# Get File +# --------------------------------------------------------------------------- +class TestGetFile(unittest.TestCase): + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_get_file_success(self, _auth, mock_api): + mock_api.return_value = { + "name": "README.md", + "path": "README.md", + "sha": "3a0b123", + "size": 100, + "encoding": "base64", + "content": "SGVsbG8gV29ybGQ=" + } + result = gitea_get_file(filepath="README.md", ref="main") + self.assertEqual(result["name"], "README.md") + self.assertEqual(result["sha"], "3a0b123") + self.assertEqual(result["content"], "SGVsbG8gV29ybGQ=") + + # Verify endpoint and GET method + call_args = mock_api.call_args + self.assertEqual(call_args[0][0], "GET") + self.assertIn("contents/README.md", call_args[0][1]) + self.assertIn("ref=main", call_args[0][1]) + + +# --------------------------------------------------------------------------- +# Commit Files +# --------------------------------------------------------------------------- +class TestCommitFiles(unittest.TestCase): + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_commit_files_success(self, _auth, mock_api): + mock_api.return_value = { + "commit": {"sha": "commit-sha-123"}, + "branch": {"name": "test-branch"} + } + files = [ + {"operation": "create", "path": "test.txt", "content": "SGVsbG8="} + ] + result = gitea_commit_files( + files=files, + message="Initial commit", + new_branch="test-branch" + ) + self.assertTrue(result["success"]) + self.assertEqual(result["commit"], "commit-sha-123") + self.assertEqual(result["branch"], "test-branch") + + # Verify POST method and payload + call_args = mock_api.call_args + self.assertEqual(call_args[0][0], "POST") + self.assertIn("/contents", call_args[0][1]) + payload = call_args[0][3] + self.assertEqual(payload["message"], "Initial commit") + self.assertEqual(payload["new_branch"], "test-branch") + self.assertEqual(payload["files"], files) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_prs.py b/tests/test_prs.py index 4d1fdc1..0255584 100644 --- a/tests/test_prs.py +++ b/tests/test_prs.py @@ -10,6 +10,7 @@ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.par import list_prs # noqa: E402 import view_pr # noqa: E402 import delete_branch # noqa: E402 +import edit_pr # noqa: E402 FAKE_CREDS = "Basic dGVzdHVzZXI6dGVzdHBhc3M=" @@ -64,5 +65,36 @@ class TestDeleteBranch(unittest.TestCase): delete_branch.main([]) +class TestEditPR(unittest.TestCase): + + @patch("edit_pr.api_request") + @patch("edit_pr.get_auth_header", return_value=FAKE_CREDS) + def test_edit_pr_success(self, _auth, mock_api): + mock_api.return_value = { + "number": 1, + "title": "New Title", + "state": "open", + "html_url": "http://url1", + "base": {"ref": "main"}, + "body": "New Description" + } + rc = edit_pr.main(["1", "--title", "New Title", "--body", "New Description"]) + self.assertEqual(rc, 0) + mock_api.assert_called_once() + # Verify call payload + payload = mock_api.call_args[0][3] + self.assertEqual(payload["title"], "New Title") + self.assertEqual(payload["body"], "New Description") + + @patch("edit_pr.get_auth_header", return_value=FAKE_CREDS) + def test_missing_fields_fails(self, _auth): + rc = edit_pr.main(["1"]) + self.assertEqual(rc, 1) + + def test_missing_pr_number_exits(self): + with self.assertRaises(SystemExit): + edit_pr.main([]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_review_pr.py b/tests/test_review_pr.py new file mode 100644 index 0000000..0d0ac7f --- /dev/null +++ b/tests/test_review_pr.py @@ -0,0 +1,92 @@ +"""Tests for review_pr.py. + +Mocks api_request and credentials. +""" +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) +import review_pr # noqa: E402 + + +FAKE_CREDS = "Basic dGVzdHVzZXI6dGVzdHBhc3M=" +FAKE_PR_DATA = { + "number": 81, + "state": "open", + "head": { + "ref": "feature-branch", + "sha": "abcdef1234567890" + }, + "base": { + "ref": "main" + }, + "html_url": "https://gitea.example.com/pulls/81" +} + + +class TestArgParsing(unittest.TestCase): + + @patch("review_pr.get_auth_header", return_value=FAKE_CREDS) + def test_missing_pr_number_exits(self, _auth): + with self.assertRaises(SystemExit): + review_pr.main([]) + + +class TestAPIPayload(unittest.TestCase): + + @patch("review_pr.api_request") + @patch("review_pr.get_auth_header", return_value=FAKE_CREDS) + def test_payload_fields_and_workflow(self, _auth, mock_api): + # Setup mock api_request to return PR details, then review response + mock_api.side_effect = [FAKE_PR_DATA, {}] + + rc = review_pr.main([ + "--pr-number", "81", + "--event", "APPROVE", + "--body", "Approved and ready to merge", + ]) + self.assertEqual(rc, 0) + self.assertEqual(mock_api.call_count, 2) + + # Verify first call: GET PR + first_call_args = mock_api.call_args_list[0] + self.assertEqual(first_call_args[0][0], "GET") + self.assertEqual(first_call_args[0][1], "https://gitea.dadeschools.net/api/v1/repos/Contractor/Timesheet/pulls/81") + + # Verify second call: POST review + second_call_args = mock_api.call_args_list[1] + self.assertEqual(second_call_args[0][0], "POST") + self.assertEqual(second_call_args[0][1], "https://gitea.dadeschools.net/api/v1/repos/Contractor/Timesheet/pulls/81/reviews") + payload = second_call_args[0][3] + self.assertEqual(payload["event"], "APPROVE") + self.assertEqual(payload["body"], "Approved and ready to merge") + self.assertEqual(payload["commit_id"], "abcdef1234567890") + + @patch("review_pr.api_request") + @patch("review_pr.get_auth_header", return_value=FAKE_CREDS) + def test_approve_and_merge_workflow(self, _auth, mock_api): + # Setup mock api_request to return PR details, review response, and merge response + mock_api.side_effect = [FAKE_PR_DATA, {}, {}] + + rc = review_pr.main([ + "--pr-number", "81", + "--event", "APPROVE", + "--body", "Approved", + "--merge", + "--merge-method", "squash" + ]) + self.assertEqual(rc, 0) + self.assertEqual(mock_api.call_count, 3) + + # Verify third call: POST merge + third_call_args = mock_api.call_args_list[2] + self.assertEqual(third_call_args[0][0], "POST") + self.assertEqual(third_call_args[0][1], "https://gitea.dadeschools.net/api/v1/repos/Contractor/Timesheet/pulls/81/merge") + payload = third_call_args[0][3] + self.assertEqual(payload["Do"], "squash") + self.assertEqual(payload["force_merge"], False) + + +if __name__ == "__main__": + unittest.main()