diff --git a/src/poly/cli.py b/src/poly/cli.py index 29d2b06..db1d3f2 100644 --- a/src/poly/cli.py +++ b/src/poly/cli.py @@ -12,6 +12,7 @@ import shutil import subprocess import sys +import webbrowser from argparse import SUPPRESS, ArgumentParser, RawTextHelpFormatter from importlib.metadata import version as get_package_version from typing import Any, Optional @@ -54,6 +55,14 @@ DOCUMENT_CHOICES = AgentStudioProject.discover_docs() +def _format_gist_choice(g: dict) -> str: + """Format a gist dict as a human-readable choice label.""" + id_hint = g["id"][:7] + date = g.get("created_at", "")[:10] # YYYY-MM-DD + parts = [p for p in [date, id_hint, g["description"]] if p] + return " ".join(parts) + + class AgentStudioCLI: """CLI Interface for Agent Studio.""" @@ -338,8 +347,8 @@ def _create_parser(cls) -> ArgumentParser: # REVIEW review_parser = subparsers.add_parser( "review", - parents=[verbose_parent], - help="Create an experience similar to a Pull Request, so that people can review changes locally or between versions/branches.", + parents=[verbose_parent, json_parent], + help="Create a GitHub Gist of Agent Studio project changes to share changes.", description=( "Make a review page against project configuration in Agent Studio.\n\n" "If you do not specify --before/--after, it compares your local project " @@ -351,6 +360,10 @@ def _create_parser(cls) -> ArgumentParser: " poly review --path /path/to/project --before main --after feature-branch\n" " poly review --path /path/to/project --before sandbox --after live\n" " poly review --path /path/to/project --before version-hash-1 --after version-hash-2\n" + " poly review list\n" + " poly review list --json\n" + " poly review delete\n" + " poly review delete --id GIST_ID\n" ), formatter_class=RawTextHelpFormatter, ) @@ -363,66 +376,122 @@ def _create_parser(cls) -> ArgumentParser: review_parser.add_argument( "--before", type=str, - help="Name of the original branch or version to compare against", + help="Name of the original branch or version to compare against.", ) review_parser.add_argument( "--after", type=str, - help="Name of the branch or version to compare with", + help="Name of the branch or version to compare with.", ) - review_parser.add_argument( - "--delete", - action="store_true", - help="Delete all the fully diff gists in your GitHub account.", + review_parser.set_defaults(review_subcommand=None) + review_subparsers = review_parser.add_subparsers(dest="review_subcommand") + + review_list_parser = review_subparsers.add_parser( + "list", + parents=[json_parent], + help="Interactively select a review gist to open in the browser.", ) + review_list_parser.set_defaults(review_subcommand="list") + + review_delete_parser = review_subparsers.add_parser( + "delete", + parents=[json_parent], + help="Interactively select and delete review gists.", + ) + review_delete_parser.add_argument( + "--id", + type=str, + default=None, + metavar="GIST_ID", + help="Gist ID (or first 7 characters) to delete directly, skipping the interactive prompt.", + ) + review_delete_parser.set_defaults(review_subcommand="delete") # Branch - # GET BRANCHES 'branch list' + branch_path_parent = ArgumentParser(add_help=False) + branch_path_parent.add_argument( + "--path", + type=str, + default=os.getcwd(), + help="Base path to the project. Defaults to current working directory.", + ) + branches_parser = subparsers.add_parser( "branch", parents=[verbose_parent, json_parent], help="Manage branches in the Agent Studio project.", - description="Manage branches in the Agent Studio project.\n\nExamples:\n poly branch list\n poly branch create new-branch\n poly branch switch existing-branch", + description=( + "Manage branches in the Agent Studio project.\n\n" + "Examples:\n" + " poly branch list\n" + " poly branch create new-branch\n" + " poly branch switch existing-branch\n" + " poly branch current\n" + ), formatter_class=RawTextHelpFormatter, ) - branches_parser.add_argument( - "--path", - type=str, - default=os.getcwd(), - help="Base path to push the project. Defaults to current working directory.", + branch_subparsers = branches_parser.add_subparsers(dest="branch_subcommand", required=True) + + branch_list_parser = branch_subparsers.add_parser( + "list", + parents=[branch_path_parent], + help="List all branches in the project.", + ) + branch_list_parser.set_defaults(branch_subcommand="list") + + branch_create_parser = branch_subparsers.add_parser( + "create", + parents=[branch_path_parent], + help="Create a new branch.", ) - branches_parser.add_argument( - "action", - choices=["list", "create", "switch", "current"], + branch_create_parser.add_argument( + "branch_name", nargs="?", help="Name of the branch to create." ) - branches_parser.add_argument( - "branch_name", nargs="?", help="Name of the branch to create or switch to." + branch_create_parser.set_defaults(branch_subcommand="create") + + branch_switch_parser = branch_subparsers.add_parser( + "switch", + parents=[branch_path_parent], + help="Switch to a different branch.", ) - branches_parser.add_argument("--debug", action="store_true", help="Display debug logs.") - branches_parser.add_argument( + branch_switch_parser.add_argument( + "branch_name", nargs="?", help="Name of the branch to switch to." + ) + branch_switch_parser.add_argument( + "--debug", action="store_true", help="Display debug logs." + ) + branch_switch_parser.add_argument( "--format", action="store_true", help="Format the project after switching branches.", ) - branches_parser.add_argument( + branch_switch_parser.add_argument( "--force", "-f", action="store_true", help="Force switch to a different branch and discard changes.", ) - branches_parser.add_argument( + branch_switch_parser.add_argument( "--from-projection", type=str, metavar="JSON|-", help=SUPPRESS, default=None, ) - branches_parser.add_argument( + branch_switch_parser.add_argument( "--output-json-projection", action="store_true", help="Output the projection in json format", default=False, ) + branch_switch_parser.set_defaults(branch_subcommand="switch") + + branch_current_parser = branch_subparsers.add_parser( + "current", + parents=[branch_path_parent], + help="Show the current branch.", + ) + branch_current_parser.set_defaults(branch_subcommand="current") # FORMAT format_parser = subparsers.add_parser( @@ -621,26 +690,29 @@ def _run_command(cls, args): cls.diff(args.path, args.files, args.json) elif args.command == "review": - if args.delete: - cls.delete_gists() + if args.review_subcommand == "delete": + cls.delete_gists(gist_id=args.id, output_json=args.json) + elif args.review_subcommand == "list": + cls.list_gists(output_json=args.json) else: if args.before and args.after: cls.review( base_path=args.path, before_name=args.before, after_name=args.after, + output_json=args.json, ) else: - cls.review(args.path) + cls.review(args.path, output_json=args.json) elif args.command == "branch": - if args.action == "list": + if args.branch_subcommand == "list": cls.branch_list(args.path, args.json) - elif args.action == "create": + elif args.branch_subcommand == "create": cls.branch_create(args.path, args.branch_name, args.json) - elif args.action == "switch": + elif args.branch_subcommand == "switch": cls.branch_switch( args.path, args.branch_name, @@ -651,7 +723,7 @@ def _run_command(cls, args): from_projection=args.from_projection, ) - elif args.action == "current": + elif args.branch_subcommand == "current": cls.get_current_branch(args.path, args.json) elif args.command == "format": @@ -1182,25 +1254,30 @@ def review( base_path: str, before_name: str = None, after_name: str = None, + output_json: bool = False, ) -> None: """Show the changes made to the project in a Pull Request format. Args: base_path: Base path for the project (used to read project config) before_name: Optional name of base branch (for comparing two remote branches) after_name: Optional name of compare branch (for comparing two remote branches) + output_json: If True, print result as a JSON object instead of rich text """ + project_name = "/".join(os.path.abspath(base_path).split(os.sep)[-2:]) if before_name and after_name: body = cls._review( base_path=base_path, before_name=before_name, after_name=after_name, ) - description = f"Diff between '{before_name}' and '{after_name}'" + description = f"Poly ADK: {project_name}: {before_name} → {after_name}" else: body = cls._review(base_path) - description = f"Diff for {'/'.join(base_path.split(os.sep)[-2:])}" + description = f"{project_name}: local → remote" if not body: + if output_json: + json_print({"success": False, "message": "No changes to review."}) return try: @@ -1209,26 +1286,141 @@ def review( description=description, public=False, ) - success(f"Gist created: {url}") + if output_json: + json_print({"success": True, "link": url}) + else: + success(f"Gist created: {url}") except requests.HTTPError as e: - error(f"GitHub API error: {e}") + if output_json: + json_print({"success": False, "message": f"GitHub API error: {e}"}) + else: + error(f"GitHub API error: {e}") + except OSError as e: + if output_json: + json_print({"success": False, "message": str(e)}) + else: + error(str(e)) + + @classmethod + def list_gists(cls, output_json: bool = False) -> None: + """Interactively select a review gist and open it in the browser.""" + try: + gists = GitHubAPIHandler.list_diff_gists() + except requests.HTTPError as e: + if output_json: + json_print({"success": False, "message": f"GitHub API error: {e}"}) + else: + error(f"GitHub API error: {e}") + return except OSError as e: - error(str(e)) - return + if output_json: + json_print({"success": False, "message": str(e)}) + else: + error(str(e)) + return + + if output_json: + json_print(gists) + return + + if not gists: + plain("[muted]No review gists found.[/muted]") + return + + url_by_choice = {_format_gist_choice(g): g["html_url"] for g in gists} + selected = questionary.select("Select a gist to open", choices=list(url_by_choice)).ask() + if not selected: + return + + webbrowser.open(url_by_choice[selected]) @classmethod - def delete_gists(cls) -> None: - """Delete the gists made for the reviews of the project.""" + def delete_gists(cls, gist_id: Optional[str] = None, output_json: bool = False) -> None: + """Interactively select and delete review gists from the user's GitHub account. + + If gist_id is provided (full ID or first 7 characters), delete that specific gist + without an interactive prompt. + """ + try: + gists = GitHubAPIHandler.list_diff_gists() + except requests.HTTPError as e: + if output_json: + json_print({"success": False, "message": f"GitHub API error: {e}"}) + else: + error(f"GitHub API error: {e}") + return + except OSError as e: + if output_json: + json_print({"success": False, "message": str(e)}) + else: + error(str(e)) + return + + if gist_id: + matched = next( + (g for g in gists if g["id"].startswith(gist_id)), + None, + ) + if not matched: + if output_json: + json_print( + {"success": False, "message": f"No review gist found matching '{gist_id}'."} + ) + else: + error(f"No review gist found matching '{gist_id}'.") + return + try: + GitHubAPIHandler.delete_gist(matched["id"]) + except requests.HTTPError as e: + if output_json: + json_print({"success": False, "message": f"GitHub API error: {e}"}) + else: + error(f"GitHub API error: {e}") + return + except OSError as e: + if output_json: + json_print({"success": False, "message": str(e)}) + else: + error(str(e)) + return + if output_json: + json_print({"success": True}) + else: + success(f"Deleted gist: {matched['id']}") + return + + if not gists: + plain("[muted]No review gists found.[/muted]") + return + + choices = [_format_gist_choice(g) for g in gists] + description_to_id = {_format_gist_choice(g): g["id"] for g in gists} + + selected = questionary.checkbox("Select gists to delete", choices=choices).ask() + if not selected: + warning("No gists selected. Exiting.") + return + try: - deleted = GitHubAPIHandler.delete_diff_gists() - for gist_id in deleted: - plain(f" [muted]Deleted gist:[/muted] {gist_id}") - success("All diff gists deleted.") + for description in selected: + gist_id = description_to_id[description] + GitHubAPIHandler.delete_gist(gist_id) + if not output_json: + plain(f" [muted]Deleted gist:[/muted] {description}") + if output_json: + json_print({"success": True}) + else: + success(f"Deleted {len(selected)} gist(s).") except requests.HTTPError as e: - error(f"GitHub API error: {e}") + if output_json: + json_print({"success": False, "message": f"GitHub API error: {e}"}) + else: + error(f"GitHub API error: {e}") except OSError as e: - error(str(e)) - return + if output_json: + json_print({"success": False, "message": str(e)}) + else: + error(str(e)) @classmethod def branch_list(cls, base_path: str, output_json: bool = False) -> None: diff --git a/src/poly/handlers/github_api_handler.py b/src/poly/handlers/github_api_handler.py index 8748301..6f32742 100644 --- a/src/poly/handlers/github_api_handler.py +++ b/src/poly/handlers/github_api_handler.py @@ -87,13 +87,21 @@ def list_gists(cls) -> Any: return resp.json() @classmethod - def delete_diff_gists(cls) -> list[str]: - """Delete all review gists (comprised only of .diff files) and return IDs.""" - deleted_ids: list[str] = [] + def list_diff_gists(cls) -> list[dict[str, str]]: + """Return review gists (comprised only of .diff files) as a list of {id, description}.""" gists = cls.list_gists() - for gist in gists: - # Delete gists comprised only of *.diff files - if all(file.endswith(".diff") for file in gist["files"]): - cls._request("DELETE", f"{GIST_URL}/{gist['id']}") - deleted_ids.append(gist["id"]) - return deleted_ids + return [ + { + "id": g["id"], + "description": g.get("description") or g["id"], + "created_at": g.get("created_at", ""), + "html_url": g.get("html_url", ""), + } + for g in gists + if g.get("files") and all(f.endswith(".diff") for f in g["files"]) + ] + + @classmethod + def delete_gist(cls, gist_id: str) -> None: + """Delete a single gist by ID.""" + cls._request("DELETE", f"{GIST_URL}/{gist_id}") diff --git a/src/poly/tests/github_api_test.py b/src/poly/tests/github_api_test.py new file mode 100644 index 0000000..4dc9d71 --- /dev/null +++ b/src/poly/tests/github_api_test.py @@ -0,0 +1,477 @@ +"""Tests for GitHub API gist interactions: review, list_gists, delete_gists, and gist formatting. + +Copyright PolyAI Limited +""" + +import unittest +from unittest.mock import patch + +from poly.cli import AgentStudioCLI, _format_gist_choice +from poly.handlers.github_api_handler import GitHubAPIHandler + + +class FormatGistChoiceTest(unittest.TestCase): + """Tests for the _format_gist_choice module-level helper.""" + + def test_formats_date_short_id_and_description(self): + """All three parts are joined with double-space separators.""" + gist = { + "id": "abc1234567890", + "description": "my-project: local → remote", + "created_at": "2026-03-25T10:30:00Z", + "html_url": "https://gist.github.com/abc1234567890", + } + + result = _format_gist_choice(gist) + + self.assertEqual(result, "2026-03-25 abc1234 my-project: local → remote") + + def test_id_truncated_to_seven_characters(self): + """Only the first 7 characters of the gist ID appear.""" + gist = { + "id": "deadbeef12345", + "description": "desc", + "created_at": "2026-01-01T00:00:00Z", + } + + result = _format_gist_choice(gist) + + self.assertIn("deadbee", result) + self.assertNotIn("deadbeef", result) + + def test_missing_created_at_omits_date(self): + """When created_at is absent, the output starts with the ID.""" + gist = { + "id": "abc1234567890", + "description": "some description", + } + + result = _format_gist_choice(gist) + + self.assertEqual(result, "abc1234 some description") + + def test_empty_created_at_omits_date(self): + """When created_at is an empty string, the output starts with the ID.""" + gist = { + "id": "abc1234567890", + "description": "desc", + "created_at": "", + } + + result = _format_gist_choice(gist) + + self.assertEqual(result, "abc1234 desc") + + def test_empty_description_omits_it(self): + """When description is empty, only date and ID appear.""" + gist = { + "id": "abc1234567890", + "description": "", + "created_at": "2026-03-25T10:30:00Z", + } + + result = _format_gist_choice(gist) + + self.assertEqual(result, "2026-03-25 abc1234") + + +class ListDiffGistsTest(unittest.TestCase): + """Tests for GitHubAPIHandler.list_diff_gists filtering logic.""" + + @patch.object(GitHubAPIHandler, "list_gists") + def test_returns_only_diff_file_gists(self, mock_list): + """Gists whose files are all .diff are included; others are excluded.""" + mock_list.return_value = [ + { + "id": "aaa1111", + "description": "review diff", + "created_at": "2026-03-20T00:00:00Z", + "html_url": "https://gist.github.com/aaa1111", + "files": {"config.diff": {}, "flows.diff": {}}, + }, + { + "id": "bbb2222", + "description": "not a review", + "created_at": "2026-03-21T00:00:00Z", + "html_url": "https://gist.github.com/bbb2222", + "files": {"notes.txt": {}}, + }, + ] + + result = GitHubAPIHandler.list_diff_gists() + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["id"], "aaa1111") + + @patch.object(GitHubAPIHandler, "list_gists") + def test_returns_expected_keys(self, mock_list): + """Each returned dict contains id, description, created_at, and html_url.""" + mock_list.return_value = [ + { + "id": "aaa1111", + "description": "review", + "created_at": "2026-03-20T00:00:00Z", + "html_url": "https://gist.github.com/aaa1111", + "files": {"flow.diff": {}}, + }, + ] + + result = GitHubAPIHandler.list_diff_gists() + + self.assertEqual(set(result[0].keys()), {"id", "description", "created_at", "html_url"}) + + @patch.object(GitHubAPIHandler, "list_gists") + def test_empty_gist_list_returns_empty(self, mock_list): + """When there are no gists at all, returns an empty list.""" + mock_list.return_value = [] + + result = GitHubAPIHandler.list_diff_gists() + + self.assertEqual(result, []) + + @patch.object(GitHubAPIHandler, "list_gists") + def test_excludes_gists_with_no_files(self, mock_list): + """Gists with no files key or empty files are excluded.""" + mock_list.return_value = [ + {"id": "ccc3333", "description": "empty", "files": {}}, + {"id": "ddd4444", "description": "none"}, + ] + + result = GitHubAPIHandler.list_diff_gists() + + self.assertEqual(result, []) + + @patch.object(GitHubAPIHandler, "list_gists") + def test_description_falls_back_to_id_when_missing(self, mock_list): + """When description is None or empty, the gist ID is used instead.""" + mock_list.return_value = [ + { + "id": "eee5555", + "description": None, + "created_at": "", + "html_url": "", + "files": {"a.diff": {}}, + }, + ] + + result = GitHubAPIHandler.list_diff_gists() + + self.assertEqual(result[0]["description"], "eee5555") + + @patch.object(GitHubAPIHandler, "list_gists") + def test_mixed_diff_and_non_diff_files_excluded(self, mock_list): + """A gist with both .diff and non-.diff files is excluded.""" + mock_list.return_value = [ + { + "id": "fff6666", + "description": "mixed", + "files": {"a.diff": {}, "readme.md": {}}, + }, + ] + + result = GitHubAPIHandler.list_diff_gists() + + self.assertEqual(result, []) + + +class DeleteGistsTest(unittest.TestCase): + """Tests for AgentStudioCLI.delete_gists interactive deletion flow.""" + + SAMPLE_GISTS = [ + { + "id": "aaa1111111", + "description": "proj: local → remote", + "created_at": "2026-03-20T00:00:00Z", + "html_url": "https://gist.github.com/aaa1111111", + }, + { + "id": "bbb2222222", + "description": "proj: main → dev", + "created_at": "2026-03-21T00:00:00Z", + "html_url": "https://gist.github.com/bbb2222222", + }, + ] + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists", return_value=[]) + @patch("poly.cli.plain") + def test_no_gists_found_prints_message(self, mock_plain, mock_list): + """When no review gists exist, a 'no gists found' message is displayed.""" + AgentStudioCLI.delete_gists() + + mock_plain.assert_called_once() + self.assertIn("No review gists found", mock_plain.call_args[0][0]) + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists") + @patch("poly.cli.questionary") + @patch("poly.cli.warning") + def test_user_selects_none_shows_warning(self, mock_warning, mock_q, mock_list): + """When user cancels or selects nothing, a warning is shown and no deletions occur.""" + mock_list.return_value = self.SAMPLE_GISTS + mock_q.checkbox.return_value.ask.return_value = [] + + AgentStudioCLI.delete_gists() + + mock_warning.assert_called_once() + self.assertIn("No gists selected", mock_warning.call_args[0][0]) + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists") + @patch("poly.cli.GitHubAPIHandler.delete_gist") + @patch("poly.cli.questionary") + @patch("poly.cli.success") + @patch("poly.cli.plain") + def test_user_selects_and_deletes_gists( + self, mock_plain, mock_success, mock_q, mock_delete, mock_list + ): + """Selected gists are deleted and a success message reports the count.""" + mock_list.return_value = self.SAMPLE_GISTS + # Simulate user selecting the first gist + first_choice = _format_gist_choice(self.SAMPLE_GISTS[0]) + mock_q.checkbox.return_value.ask.return_value = [first_choice] + + AgentStudioCLI.delete_gists() + + mock_delete.assert_called_once_with("aaa1111111") + mock_success.assert_called_once() + self.assertIn("1 gist(s)", mock_success.call_args[0][0]) + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists") + @patch("poly.cli.GitHubAPIHandler.delete_gist") + @patch("poly.cli.questionary") + @patch("poly.cli.success") + @patch("poly.cli.plain") + def test_deleting_multiple_gists( + self, mock_plain, mock_success, mock_q, mock_delete, mock_list + ): + """Selecting multiple gists deletes each and reports total count.""" + mock_list.return_value = self.SAMPLE_GISTS + choices = [_format_gist_choice(g) for g in self.SAMPLE_GISTS] + mock_q.checkbox.return_value.ask.return_value = choices + + AgentStudioCLI.delete_gists() + + self.assertEqual(mock_delete.call_count, 2) + self.assertIn("2 gist(s)", mock_success.call_args[0][0]) + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists") + @patch("poly.cli.GitHubAPIHandler.delete_gist") + @patch("poly.cli.success") + def test_direct_gist_id_skips_interactive_prompt(self, mock_success, mock_delete, mock_list): + """Passing a full gist_id deletes it directly without showing a checkbox prompt.""" + mock_list.return_value = self.SAMPLE_GISTS + + AgentStudioCLI.delete_gists(gist_id="aaa1111111") + + mock_delete.assert_called_once_with("aaa1111111") + mock_success.assert_called_once() + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists") + @patch("poly.cli.GitHubAPIHandler.delete_gist") + @patch("poly.cli.success") + def test_short_gist_id_prefix_matches(self, mock_success, mock_delete, mock_list): + """Passing the first 7 characters of a gist ID resolves and deletes the full gist.""" + mock_list.return_value = self.SAMPLE_GISTS + + AgentStudioCLI.delete_gists(gist_id="aaa1111") + + mock_delete.assert_called_once_with("aaa1111111") + mock_success.assert_called_once() + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists") + @patch("poly.cli.GitHubAPIHandler.delete_gist") + @patch("poly.cli.error") + def test_unmatched_gist_id_shows_error(self, mock_error, mock_delete, mock_list): + """An ID that doesn't match any review gist shows an error and does not delete.""" + mock_list.return_value = self.SAMPLE_GISTS + + AgentStudioCLI.delete_gists(gist_id="zzz9999") + + mock_delete.assert_not_called() + mock_error.assert_called_once() + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists") + @patch("poly.cli.GitHubAPIHandler.delete_gist") + @patch("poly.cli.error") + def test_full_id_sharing_prefix_does_not_match_wrong_gist( + self, mock_error, mock_delete, mock_list + ): + """A full ID that shares a 7-char prefix with a gist but doesn't match it exactly + should not delete the wrong gist.""" + mock_list.return_value = self.SAMPLE_GISTS # contains "aaa1111111" + + # "aaa1111xyz" shares the "aaa1111" prefix but is not a valid ID + AgentStudioCLI.delete_gists(gist_id="aaa1111xyz") + + mock_delete.assert_not_called() + mock_error.assert_called_once() + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists") + @patch("poly.cli.GitHubAPIHandler.delete_gist") + @patch("poly.cli.json_print") + def test_direct_gist_id_with_json_output(self, mock_json_print, mock_delete, mock_list): + """With output_json=True and a gist_id, result is printed as JSON.""" + mock_list.return_value = self.SAMPLE_GISTS + + AgentStudioCLI.delete_gists(gist_id="aaa1111111", output_json=True) + + mock_delete.assert_called_once_with("aaa1111111") + mock_json_print.assert_called_once_with({"success": True}) + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists") + @patch("poly.cli.GitHubAPIHandler.delete_gist") + @patch("poly.cli.questionary") + @patch("poly.cli.json_print") + def test_json_output_prints_success(self, mock_json_print, mock_q, mock_delete, mock_list): + """With output_json=True, a success JSON object is printed after deletion.""" + mock_list.return_value = self.SAMPLE_GISTS + first_choice = _format_gist_choice(self.SAMPLE_GISTS[0]) + mock_q.checkbox.return_value.ask.return_value = [first_choice] + + AgentStudioCLI.delete_gists(output_json=True) + + mock_json_print.assert_called_once_with({"success": True}) + + +class ListGistsTest(unittest.TestCase): + """Tests for AgentStudioCLI.list_gists interactive selection flow.""" + + SAMPLE_GISTS = [ + { + "id": "aaa1111111", + "description": "proj: local → remote", + "created_at": "2026-03-20T00:00:00Z", + "html_url": "https://gist.github.com/aaa1111111", + }, + ] + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists", return_value=[]) + @patch("poly.cli.plain") + def test_no_gists_found_prints_message(self, mock_plain, mock_list): + """When no review gists exist, a 'no gists found' message is displayed.""" + AgentStudioCLI.list_gists() + + mock_plain.assert_called_once() + self.assertIn("No review gists found", mock_plain.call_args[0][0]) + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists") + @patch("poly.cli.questionary") + @patch("poly.cli.webbrowser") + def test_user_selects_gist_opens_browser(self, mock_browser, mock_q, mock_list): + """Selecting a gist opens its html_url in the browser.""" + mock_list.return_value = self.SAMPLE_GISTS + choice_label = _format_gist_choice(self.SAMPLE_GISTS[0]) + mock_q.select.return_value.ask.return_value = choice_label + + AgentStudioCLI.list_gists() + + mock_browser.open.assert_called_once_with("https://gist.github.com/aaa1111111") + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists") + @patch("poly.cli.questionary") + @patch("poly.cli.webbrowser") + def test_user_cancels_selection_does_not_open_browser(self, mock_browser, mock_q, mock_list): + """When user cancels the selection prompt, no browser is opened.""" + mock_list.return_value = self.SAMPLE_GISTS + mock_q.select.return_value.ask.return_value = None + + AgentStudioCLI.list_gists() + + mock_browser.open.assert_not_called() + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists") + @patch("poly.cli.json_print") + def test_json_output_prints_gist_list(self, mock_json_print, mock_list): + """With output_json=True, gists are printed as JSON without an interactive prompt.""" + mock_list.return_value = self.SAMPLE_GISTS + + AgentStudioCLI.list_gists(output_json=True) + + mock_json_print.assert_called_once_with(self.SAMPLE_GISTS) + + @patch("poly.cli.GitHubAPIHandler.list_diff_gists", side_effect=OSError("disk error")) + @patch("poly.cli.json_print") + def test_json_output_on_error_prints_failure(self, mock_json_print, _mock_list): + """With output_json=True, an API/OS error prints {success: false, message: ...}.""" + AgentStudioCLI.list_gists(output_json=True) + + mock_json_print.assert_called_once() + result = mock_json_print.call_args[0][0] + self.assertFalse(result["success"]) + self.assertIn("disk error", result["message"]) + + +class ReviewDescriptionTest(unittest.TestCase): + """Tests for AgentStudioCLI.review gist description formatting.""" + + @patch("poly.cli.GitHubAPIHandler.create_gist", return_value="https://gist.github.com/xyz") + @patch("poly.cli.AgentStudioCLI._review", return_value={"file.diff": {"content": "+line"}}) + @patch("poly.cli.success") + def test_local_to_remote_description_includes_project_name( + self, mock_success, mock_review, mock_create + ): + """Local-to-remote review uses 'project_name: local -> remote' description.""" + AgentStudioCLI.review(base_path="/some/my-project") + + description = mock_create.call_args[1]["description"] + self.assertIn("my-project", description) + self.assertIn("local → remote", description) + + @patch("poly.cli.GitHubAPIHandler.create_gist", return_value="https://gist.github.com/xyz") + @patch("poly.cli.AgentStudioCLI._review", return_value={"file.diff": {"content": "+line"}}) + @patch("poly.cli.success") + def test_branch_comparison_description_includes_branch_names( + self, mock_success, mock_review, mock_create + ): + """Branch-to-branch review uses 'project_name: before -> after' description.""" + AgentStudioCLI.review( + base_path="/some/my-project", + before_name="main", + after_name="dev", + ) + + description = mock_create.call_args[1]["description"] + self.assertIn("my-project", description) + self.assertIn("main → dev", description) + + @patch("poly.cli.GitHubAPIHandler.create_gist") + @patch("poly.cli.AgentStudioCLI._review", return_value={}) + def test_empty_diff_does_not_create_gist(self, mock_review, mock_create): + """When _review returns an empty dict, no gist is created.""" + AgentStudioCLI.review(base_path="/some/my-project") + + mock_create.assert_not_called() + + @patch("poly.cli.GitHubAPIHandler.create_gist", return_value="https://gist.github.com/xyz") + @patch("poly.cli.AgentStudioCLI._review", return_value={"file.diff": {"content": "+line"}}) + @patch("poly.cli.success") + def test_gist_created_as_private(self, mock_success, mock_review, mock_create): + """Review gists are always created as private (public=False).""" + AgentStudioCLI.review(base_path="/some/my-project") + + self.assertFalse(mock_create.call_args[1]["public"]) + + @patch("poly.cli.GitHubAPIHandler.create_gist", return_value="https://gist.github.com/xyz") + @patch("poly.cli.AgentStudioCLI._review", return_value={"file.diff": {"content": "+line"}}) + @patch("poly.cli.json_print") + def test_json_output_prints_success_and_link(self, mock_json_print, mock_review, mock_create): + """With output_json=True, a successful review prints {success: true, link: url}.""" + AgentStudioCLI.review(base_path="/some/my-project", output_json=True) + + mock_json_print.assert_called_once_with( + {"success": True, "link": "https://gist.github.com/xyz"} + ) + + @patch("poly.cli.GitHubAPIHandler.create_gist") + @patch("poly.cli.AgentStudioCLI._review", return_value={}) + @patch("poly.cli.json_print") + def test_json_output_empty_diff_prints_failure(self, mock_json_print, mock_review, mock_create): + """With output_json=True, an empty diff prints {success: false, message: ...}.""" + AgentStudioCLI.review(base_path="/some/my-project", output_json=True) + + mock_json_print.assert_called_once() + result = mock_json_print.call_args[0][0] + self.assertFalse(result["success"]) + self.assertIn("message", result) + + +if __name__ == "__main__": + unittest.main()