From dd3a1f0435246020bff0d743d9488dcf2c89afe9 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Tue, 24 Feb 2026 01:33:23 +0800 Subject: [PATCH 01/17] Implement Role Marker --- exercise_utils/roles.py | 105 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 exercise_utils/roles.py diff --git a/exercise_utils/roles.py b/exercise_utils/roles.py new file mode 100644 index 00000000..bb446649 --- /dev/null +++ b/exercise_utils/roles.py @@ -0,0 +1,105 @@ +import re +from typing import Optional + +from exercise_utils import git, github_cli + + +class RoleMarker: + """Wrapper for git and GitHub operations with automatic role marker formatting. + + Usage: + bob = RoleMarker("teammate-bob") + bob.commit("Add feature", verbose=True) + # Creates: "[ROLE:teammate-bob] Add feature" + """ + + PATTERN = re.compile(r"^\[ROLE:([a-zA-Z0-9_-]+)\]\s*", re.IGNORECASE) + + def __init__(self, role: str) -> None: + """Initialize RoleMarker with a specific role.""" + self.role = role + + @staticmethod + def format(role: str, text: str) -> str: + """Format text with a role marker. + Example: + format('teammate-alice', 'Add feature') -> '[ROLE:teammate-alice] Add feature' + """ + return f"[ROLE:{role}] {text}" + + @staticmethod + def extract_role(text: str) -> Optional[str]: + """Extract role name from text with role marker if present.""" + match = RoleMarker.PATTERN.match(text) + return match.group(1).lower() if match else None + + @staticmethod + def has_role_marker(text: str) -> bool: + """Check if text contains a role marker.""" + return RoleMarker.PATTERN.match(text) is not None + + @staticmethod + def strip_role_marker(text: str) -> str: + """Remove role marker from text if present.""" + return RoleMarker.PATTERN.sub("", text) + + def _format_text(self, text: str) -> str: + """Format text with this instance's role marker if not already present.""" + if not self.has_role_marker(text): + return self.format(self.role, text) + return text + + # Git operations with automatic role markers + + def commit(self, message: str, verbose: bool) -> None: + """Create a commit with automatic role marker.""" + git.commit(self._format_text(message), verbose) + + def merge_with_message( + self, target_branch: str, ff: bool, message: str, verbose: bool + ) -> None: + """Merge branches with custom message and automatic role marker. """ + git.merge_with_message(target_branch, ff, self._format_text(message), verbose) + + # GitHub PR operations with automatic role markers + + def create_pr( + self, title: str, body: str, base: str, head: str, verbose: bool + ) -> str: + """Create a pull request with automatic role markers. + + Returns: + PR URL if successful, empty string otherwise + """ + return github_cli.create_pr( + self._format_text(title), self._format_text(body), base, head, verbose + ) + + def comment_on_pr(self, pr_number: int, comment: str, verbose: bool) -> bool: + """Add a comment to a pull request with automatic role marker. + + Returns: + True if comment was added successfully, False otherwise + """ + return github_cli.comment_on_pr(pr_number, self._format_text(comment), verbose) + + def review_pr( + self, pr_number: int, comment: str, action: str, verbose: bool + ) -> bool: + """Submit a review on a pull request with automatic role marker. + + Returns: + True if review was submitted successfully, False otherwise + """ + return github_cli.review_pr( + pr_number, self._format_text(comment), action, verbose + ) + + def close_pr(self, pr_number: int, verbose: bool, comment: Optional[str] = None) -> bool: + """Close a pull request without merging. + + Returns: + True if PR was closed successfully, False otherwise + """ + formatted_comment = self._format_text(comment) if comment else None + return github_cli.close_pr(pr_number, verbose, formatted_comment) From 8b646cb322de9c181a87fa4313f1f9ee40c17f85 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Tue, 24 Feb 2026 04:18:12 +0800 Subject: [PATCH 02/17] Add wrapper for gh cli --- exercise_utils/github_cli.py | 118 +++++++++++++++++++++++++++++++++++ exercise_utils/roles.py | 50 +++++++-------- 2 files changed, 140 insertions(+), 28 deletions(-) diff --git a/exercise_utils/github_cli.py b/exercise_utils/github_cli.py index 1efcf279..8d6fa244 100644 --- a/exercise_utils/github_cli.py +++ b/exercise_utils/github_cli.py @@ -127,3 +127,121 @@ def get_remote_url(repository_name: str, verbose: bool) -> str: remote_url = f"git@github.com:{repository_name}.git" return remote_url + + +def create_pr(title: str, body: str, base: str, head: str, verbose: bool) -> bool: + """Create a pull request.""" + command = [ + "gh", + "pr", + "create", + "--title", title, + "--body", body, + "--base", base, + "--head", head, + ] + + result = run(command, verbose) + return result.is_success() + + +def view_pr(pr_number: int, verbose: bool) -> dict[str, str]: + """View pull request details.""" + fields = "title,body,state,author,headRefName,baseRefName,comments,reviews" + + result = run( + [ + "gh", + "pr", + "view", + str(pr_number), + "--json", fields + ], + verbose, + ) + + if result.is_success(): + import json + + return json.loads(result.stdout) + return {} + + +def comment_on_pr(pr_number: int, comment: str, verbose: bool) -> bool: + """Add a comment to a pull request.""" + result = run( + ["gh", "pr", "comment", str(pr_number), "--body", comment], + verbose, + ) + return result.is_success() + + +def list_prs(state: str, verbose: bool) -> list[dict[str, str]]: + """ + List pull requests. + PR state filter ('open', 'closed', 'merged', 'all') + """ + result = run( + [ + "gh", + "pr", + "list", + "--state", + state, + "--json", + "number,title,state,author,headRefName,baseRefName", + ], + verbose, + ) + + if result.is_success(): + import json + + return json.loads(result.stdout) + return [] + + +def merge_pr( + pr_number: int, merge_method: str, verbose: bool, delete_branch: bool = True +) -> bool: + """ + Merge a pull request. + Merge method ('merge', 'squash', 'rebase') + """ + command = ["gh", "pr", "merge", str(pr_number), f"--{merge_method}"] + + if delete_branch: + command.append("--delete-branch") + + result = run(command, verbose) + return result.is_success() + + +def close_pr(pr_number: int, verbose: bool, comment: Optional[str] = None) -> bool: + """Close a pull request without merging.""" + command = ["gh", "pr", "close", str(pr_number)] + + if comment: + command.extend(["--comment", comment]) + + result = run(command, verbose) + return result.is_success() + + +def review_pr(pr_number: int, comment: str, action: str, verbose: bool) -> bool: + """ + Submit a review on a pull request. + Review action ('approve', 'request-changes', 'comment') + """ + command = [ + "gh", + "pr", + "review", + str(pr_number), + "--body", + comment, + f"--{action}", + ] + + result = run(command, verbose) + return result.is_success() diff --git a/exercise_utils/roles.py b/exercise_utils/roles.py index bb446649..46d0dbbf 100644 --- a/exercise_utils/roles.py +++ b/exercise_utils/roles.py @@ -19,6 +19,7 @@ def __init__(self, role: str) -> None: """Initialize RoleMarker with a specific role.""" self.role = role + @staticmethod def format(role: str, text: str) -> str: """Format text with a role marker. @@ -27,79 +28,72 @@ def format(role: str, text: str) -> str: """ return f"[ROLE:{role}] {text}" + @staticmethod def extract_role(text: str) -> Optional[str]: """Extract role name from text with role marker if present.""" match = RoleMarker.PATTERN.match(text) return match.group(1).lower() if match else None + @staticmethod def has_role_marker(text: str) -> bool: """Check if text contains a role marker.""" return RoleMarker.PATTERN.match(text) is not None + @staticmethod def strip_role_marker(text: str) -> str: """Remove role marker from text if present.""" return RoleMarker.PATTERN.sub("", text) + def _format_text(self, text: str) -> str: """Format text with this instance's role marker if not already present.""" if not self.has_role_marker(text): return self.format(self.role, text) return text + # Git operations with automatic role markers def commit(self, message: str, verbose: bool) -> None: """Create a commit with automatic role marker.""" git.commit(self._format_text(message), verbose) + def merge_with_message( self, target_branch: str, ff: bool, message: str, verbose: bool ) -> None: - """Merge branches with custom message and automatic role marker. """ + """Merge branches with custom message and automatic role marker.""" git.merge_with_message(target_branch, ff, self._format_text(message), verbose) + # GitHub PR operations with automatic role markers def create_pr( self, title: str, body: str, base: str, head: str, verbose: bool - ) -> str: - """Create a pull request with automatic role markers. + ) -> bool: + """Create a pull request with automatic role markers. """ + return github_cli.create_pr(self._format_text(title), self._format_text(body), base, head, verbose) - Returns: - PR URL if successful, empty string otherwise - """ - return github_cli.create_pr( - self._format_text(title), self._format_text(body), base, head, verbose - ) def comment_on_pr(self, pr_number: int, comment: str, verbose: bool) -> bool: - """Add a comment to a pull request with automatic role marker. - - Returns: - True if comment was added successfully, False otherwise - """ + """Add a comment to a pull request with automatic role marker.""" return github_cli.comment_on_pr(pr_number, self._format_text(comment), verbose) + def review_pr( self, pr_number: int, comment: str, action: str, verbose: bool ) -> bool: - """Submit a review on a pull request with automatic role marker. - - Returns: - True if review was submitted successfully, False otherwise - """ - return github_cli.review_pr( - pr_number, self._format_text(comment), action, verbose - ) - - def close_pr(self, pr_number: int, verbose: bool, comment: Optional[str] = None) -> bool: - """Close a pull request without merging. - - Returns: - True if PR was closed successfully, False otherwise + """Submit a review on a pull request with automatic role marker. True if review was submitted successfully, False otherwise """ + return github_cli.review_pr(pr_number, self._format_text(comment), action, verbose) + + + def close_pr( + self, pr_number: int, verbose: bool, comment: Optional[str] = None + ) -> bool: + """Close a pull request without merging.""" formatted_comment = self._format_text(comment) if comment else None return github_cli.close_pr(pr_number, verbose, formatted_comment) From 02ce7ad053f71f0623326683f673b9687934cc7d Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Tue, 24 Feb 2026 18:34:44 +0800 Subject: [PATCH 03/17] Reformat file --- exercise_utils/github_cli.py | 20 +++++++++----------- exercise_utils/roles.py | 24 ++++++++---------------- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/exercise_utils/github_cli.py b/exercise_utils/github_cli.py index 8d6fa244..6b049cb0 100644 --- a/exercise_utils/github_cli.py +++ b/exercise_utils/github_cli.py @@ -135,10 +135,14 @@ def create_pr(title: str, body: str, base: str, head: str, verbose: bool) -> boo "gh", "pr", "create", - "--title", title, - "--body", body, - "--base", base, - "--head", head, + "--title", + title, + "--body", + body, + "--base", + base, + "--head", + head, ] result = run(command, verbose) @@ -150,13 +154,7 @@ def view_pr(pr_number: int, verbose: bool) -> dict[str, str]: fields = "title,body,state,author,headRefName,baseRefName,comments,reviews" result = run( - [ - "gh", - "pr", - "view", - str(pr_number), - "--json", fields - ], + ["gh", "pr", "view", str(pr_number), "--json", fields], verbose, ) diff --git a/exercise_utils/roles.py b/exercise_utils/roles.py index 46d0dbbf..4f09b9aa 100644 --- a/exercise_utils/roles.py +++ b/exercise_utils/roles.py @@ -19,7 +19,6 @@ def __init__(self, role: str) -> None: """Initialize RoleMarker with a specific role.""" self.role = role - @staticmethod def format(role: str, text: str) -> str: """Format text with a role marker. @@ -28,68 +27,61 @@ def format(role: str, text: str) -> str: """ return f"[ROLE:{role}] {text}" - @staticmethod def extract_role(text: str) -> Optional[str]: """Extract role name from text with role marker if present.""" match = RoleMarker.PATTERN.match(text) return match.group(1).lower() if match else None - @staticmethod def has_role_marker(text: str) -> bool: """Check if text contains a role marker.""" return RoleMarker.PATTERN.match(text) is not None - @staticmethod def strip_role_marker(text: str) -> str: """Remove role marker from text if present.""" return RoleMarker.PATTERN.sub("", text) - def _format_text(self, text: str) -> str: """Format text with this instance's role marker if not already present.""" if not self.has_role_marker(text): return self.format(self.role, text) return text - # Git operations with automatic role markers def commit(self, message: str, verbose: bool) -> None: """Create a commit with automatic role marker.""" git.commit(self._format_text(message), verbose) - def merge_with_message( self, target_branch: str, ff: bool, message: str, verbose: bool ) -> None: """Merge branches with custom message and automatic role marker.""" git.merge_with_message(target_branch, ff, self._format_text(message), verbose) - # GitHub PR operations with automatic role markers def create_pr( self, title: str, body: str, base: str, head: str, verbose: bool ) -> bool: - """Create a pull request with automatic role markers. """ - return github_cli.create_pr(self._format_text(title), self._format_text(body), base, head, verbose) - + """Create a pull request with automatic role markers.""" + return github_cli.create_pr( + self._format_text(title), self._format_text(body), base, head, verbose + ) def comment_on_pr(self, pr_number: int, comment: str, verbose: bool) -> bool: """Add a comment to a pull request with automatic role marker.""" return github_cli.comment_on_pr(pr_number, self._format_text(comment), verbose) - def review_pr( self, pr_number: int, comment: str, action: str, verbose: bool ) -> bool: - """Submit a review on a pull request with automatic role marker. True if review was submitted successfully, False otherwise - """ - return github_cli.review_pr(pr_number, self._format_text(comment), action, verbose) - + """Submit a review on a pull request with automatic role marker. True if review was submitted successfully, False otherwise""" + return github_cli.review_pr( + pr_number, self._format_text(comment), action, verbose + ) def close_pr( self, pr_number: int, verbose: bool, comment: Optional[str] = None From f958bf8d84c4ade32f2bc4f9231da3a064d3129a Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Tue, 10 Mar 2026 10:57:52 +0800 Subject: [PATCH 04/17] Update config and github_cli --- exercise_utils/exercise_config.py | 31 +++++++ exercise_utils/github_cli.py | 133 +++++++++++++++++++++++++----- exercise_utils/roles.py | 50 +++++++++-- 3 files changed, 184 insertions(+), 30 deletions(-) create mode 100644 exercise_utils/exercise_config.py diff --git a/exercise_utils/exercise_config.py b/exercise_utils/exercise_config.py new file mode 100644 index 00000000..a9b581cb --- /dev/null +++ b/exercise_utils/exercise_config.py @@ -0,0 +1,31 @@ +# exercise_utils/json_config.py +import json +from pathlib import Path +from typing import Any + + +def update_json_fields(config_path: Path, updates: dict[str, Any]) -> None: + """ + Update a JSON file using dotted-path keys. + + Example updates: + { + "exercise_repo.pr_number": 1, + "exercise_repo.repo_full_name": "owner/repo", + "teammate_role": "teammate-bob", + } + """ + config = json.loads(config_path.read_text()) + + for dotted_path, value in updates.items(): + keys = dotted_path.split(".") + cursor = config + + for key in keys[:-1]: + if key not in cursor or not isinstance(cursor[key], dict): + cursor[key] = {} + cursor = cursor[key] + + cursor[keys[-1]] = value + + config_path.write_text(json.dumps(config, indent=2)) diff --git a/exercise_utils/github_cli.py b/exercise_utils/github_cli.py index 6b049cb0..989362a4 100644 --- a/exercise_utils/github_cli.py +++ b/exercise_utils/github_cli.py @@ -1,7 +1,7 @@ """Wrapper for Github CLI commands.""" # TODO: The following should be built using the builder pattern -from typing import Optional +from typing import Any, Optional from exercise_utils.cli import run @@ -129,7 +129,14 @@ def get_remote_url(repository_name: str, verbose: bool) -> str: return remote_url -def create_pr(title: str, body: str, base: str, head: str, verbose: bool) -> bool: +def create_pr( + title: str, + body: str, + base: str, + head: str, + verbose: bool, + repo_full_name: str, +) -> bool: """Create a pull request.""" command = [ "gh", @@ -145,16 +152,32 @@ def create_pr(title: str, body: str, base: str, head: str, verbose: bool) -> boo head, ] + command = _append_repo_flag(command, repo_full_name) + result = run(command, verbose) return result.is_success() -def view_pr(pr_number: int, verbose: bool) -> dict[str, str]: +def _append_repo_flag(command: list[str], repo_full_name: str) -> list[str]: + """Append --repo flag. PR commands require explicit repository context.""" + if repo_full_name.strip() == "": + raise ValueError( + "repo_full_name must be provided for deterministic PR commands" + ) + + command.extend(["--repo", repo_full_name]) + return command + + +def view_pr(pr_number: int, verbose: bool, repo_full_name: str) -> dict[str, Any]: """View pull request details.""" fields = "title,body,state,author,headRefName,baseRefName,comments,reviews" + command = ["gh", "pr", "view", str(pr_number), "--json", fields] + command = _append_repo_flag(command, repo_full_name) + result = run( - ["gh", "pr", "view", str(pr_number), "--json", fields], + command, verbose, ) @@ -165,32 +188,40 @@ def view_pr(pr_number: int, verbose: bool) -> dict[str, str]: return {} -def comment_on_pr(pr_number: int, comment: str, verbose: bool) -> bool: +def comment_on_pr( + pr_number: int, + comment: str, + verbose: bool, + repo_full_name: str, +) -> bool: """Add a comment to a pull request.""" + command = ["gh", "pr", "comment", str(pr_number), "--body", comment] + command = _append_repo_flag(command, repo_full_name) + result = run( - ["gh", "pr", "comment", str(pr_number), "--body", comment], + command, verbose, ) return result.is_success() -def list_prs(state: str, verbose: bool) -> list[dict[str, str]]: +def list_prs(state: str, verbose: bool, repo_full_name: str) -> list[dict[str, Any]]: """ List pull requests. PR state filter ('open', 'closed', 'merged', 'all') """ - result = run( - [ - "gh", - "pr", - "list", - "--state", - state, - "--json", - "number,title,state,author,headRefName,baseRefName", - ], - verbose, - ) + command = [ + "gh", + "pr", + "list", + "--state", + state, + "--json", + "number,title,state,author,headRefName,baseRefName", + ] + command = _append_repo_flag(command, repo_full_name) + + result = run(command, verbose) if result.is_success(): import json @@ -200,7 +231,11 @@ def list_prs(state: str, verbose: bool) -> list[dict[str, str]]: def merge_pr( - pr_number: int, merge_method: str, verbose: bool, delete_branch: bool = True + pr_number: int, + merge_method: str, + verbose: bool, + repo_full_name: str, + delete_branch: bool = True, ) -> bool: """ Merge a pull request. @@ -211,22 +246,37 @@ def merge_pr( if delete_branch: command.append("--delete-branch") + command = _append_repo_flag(command, repo_full_name) + result = run(command, verbose) return result.is_success() -def close_pr(pr_number: int, verbose: bool, comment: Optional[str] = None) -> bool: +def close_pr( + pr_number: int, + verbose: bool, + repo_full_name: str, + comment: Optional[str] = None, +) -> bool: """Close a pull request without merging.""" command = ["gh", "pr", "close", str(pr_number)] if comment: command.extend(["--comment", comment]) + command = _append_repo_flag(command, repo_full_name) + result = run(command, verbose) return result.is_success() -def review_pr(pr_number: int, comment: str, action: str, verbose: bool) -> bool: +def review_pr( + pr_number: int, + comment: str, + action: str, + verbose: bool, + repo_full_name: str, +) -> bool: """ Submit a review on a pull request. Review action ('approve', 'request-changes', 'comment') @@ -241,5 +291,44 @@ def review_pr(pr_number: int, comment: str, action: str, verbose: bool) -> bool: f"--{action}", ] + command = _append_repo_flag(command, repo_full_name) + result = run(command, verbose) return result.is_success() + + +def get_latest_pr_number_by_author( + username: str, repo_full_name: str, verbose: bool +) -> Optional[int]: + """Return the latest pull request number created by username in the repo.""" + command = [ + "gh", + "pr", + "list", + "--author", + username, + "--state", + "all", + "--limit", + "1", + "--json", + "number", + ] + command = _append_repo_flag(command, repo_full_name) + + result = run(command, verbose) + if not result.is_success(): + return None + + import json + + try: + prs = json.loads(result.stdout) + except json.JSONDecodeError: + return None + + if not prs: + return None + + pr_number = prs[0].get("number") + return pr_number if isinstance(pr_number, int) else None diff --git a/exercise_utils/roles.py b/exercise_utils/roles.py index 4f09b9aa..d504336a 100644 --- a/exercise_utils/roles.py +++ b/exercise_utils/roles.py @@ -64,28 +64,62 @@ def merge_with_message( # GitHub PR operations with automatic role markers def create_pr( - self, title: str, body: str, base: str, head: str, verbose: bool + self, + title: str, + body: str, + base: str, + head: str, + verbose: bool, + repo_full_name: str, ) -> bool: """Create a pull request with automatic role markers.""" return github_cli.create_pr( - self._format_text(title), self._format_text(body), base, head, verbose + self._format_text(title), + self._format_text(body), + base, + head, + verbose, + repo_full_name, ) - def comment_on_pr(self, pr_number: int, comment: str, verbose: bool) -> bool: + def comment_on_pr( + self, + pr_number: int, + comment: str, + verbose: bool, + repo_full_name: str, + ) -> bool: """Add a comment to a pull request with automatic role marker.""" - return github_cli.comment_on_pr(pr_number, self._format_text(comment), verbose) + return github_cli.comment_on_pr( + pr_number, self._format_text(comment), verbose, repo_full_name + ) def review_pr( - self, pr_number: int, comment: str, action: str, verbose: bool + self, + pr_number: int, + comment: str, + action: str, + verbose: bool, + repo_full_name: str, ) -> bool: """Submit a review on a pull request with automatic role marker. True if review was submitted successfully, False otherwise""" return github_cli.review_pr( - pr_number, self._format_text(comment), action, verbose + pr_number, + self._format_text(comment), + action, + verbose, + repo_full_name, ) def close_pr( - self, pr_number: int, verbose: bool, comment: Optional[str] = None + self, + pr_number: int, + verbose: bool, + repo_full_name: str, + comment: Optional[str] = None, ) -> bool: """Close a pull request without merging.""" formatted_comment = self._format_text(comment) if comment else None - return github_cli.close_pr(pr_number, verbose, formatted_comment) + return github_cli.close_pr( + pr_number, verbose, repo_full_name, formatted_comment + ) From b6eee082ef5b3ee50cafd6a7b2905fa32a9e27e7 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Tue, 10 Mar 2026 12:53:16 +0800 Subject: [PATCH 05/17] Update config and github_cli --- exercise_utils/exercise_config.py | 8 +++++-- exercise_utils/github_cli.py | 36 +++++++++++++++++++++---------- exercise_utils/roles.py | 18 ++++++++-------- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/exercise_utils/exercise_config.py b/exercise_utils/exercise_config.py index a9b581cb..1bd6d7c1 100644 --- a/exercise_utils/exercise_config.py +++ b/exercise_utils/exercise_config.py @@ -1,10 +1,9 @@ -# exercise_utils/json_config.py import json from pathlib import Path from typing import Any -def update_json_fields(config_path: Path, updates: dict[str, Any]) -> None: +def update_config_fields(updates: dict[str, Any]) -> None: """ Update a JSON file using dotted-path keys. @@ -15,6 +14,11 @@ def update_json_fields(config_path: Path, updates: dict[str, Any]) -> None: "teammate_role": "teammate-bob", } """ + config_path = Path("../.gitmastery-exercise.json") + if not config_path.exists(): + raise FileNotFoundError( + f".gitmastery-exercise.json file not found at {config_path.resolve()}" + ) config = json.loads(config_path.read_text()) for dotted_path, value in updates.items(): diff --git a/exercise_utils/github_cli.py b/exercise_utils/github_cli.py index 989362a4..20155781 100644 --- a/exercise_utils/github_cli.py +++ b/exercise_utils/github_cli.py @@ -1,6 +1,7 @@ """Wrapper for Github CLI commands.""" # TODO: The following should be built using the builder pattern +import re from typing import Any, Optional from exercise_utils.cli import run @@ -134,9 +135,9 @@ def create_pr( body: str, base: str, head: str, - verbose: bool, repo_full_name: str, -) -> bool: + verbose: bool, +) -> Optional[int]: """Create a pull request.""" command = [ "gh", @@ -155,7 +156,14 @@ def create_pr( command = _append_repo_flag(command, repo_full_name) result = run(command, verbose) - return result.is_success() + if not result.is_success(): + return None + + match = re.search(r"/pull/(\d+)", result.stdout) + if match is None: + return None + + return int(match.group(1)) def _append_repo_flag(command: list[str], repo_full_name: str) -> list[str]: @@ -169,7 +177,7 @@ def _append_repo_flag(command: list[str], repo_full_name: str) -> list[str]: return command -def view_pr(pr_number: int, verbose: bool, repo_full_name: str) -> dict[str, Any]: +def view_pr(pr_number: int, repo_full_name: str, verbose: bool) -> dict[str, Any]: """View pull request details.""" fields = "title,body,state,author,headRefName,baseRefName,comments,reviews" @@ -184,15 +192,18 @@ def view_pr(pr_number: int, verbose: bool, repo_full_name: str) -> dict[str, Any if result.is_success(): import json - return json.loads(result.stdout) + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return {} return {} def comment_on_pr( pr_number: int, comment: str, - verbose: bool, repo_full_name: str, + verbose: bool, ) -> bool: """Add a comment to a pull request.""" command = ["gh", "pr", "comment", str(pr_number), "--body", comment] @@ -205,7 +216,7 @@ def comment_on_pr( return result.is_success() -def list_prs(state: str, verbose: bool, repo_full_name: str) -> list[dict[str, Any]]: +def list_prs(state: str, repo_full_name: str, verbose: bool) -> list[dict[str, Any]]: """ List pull requests. PR state filter ('open', 'closed', 'merged', 'all') @@ -226,16 +237,19 @@ def list_prs(state: str, verbose: bool, repo_full_name: str) -> list[dict[str, A if result.is_success(): import json - return json.loads(result.stdout) + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return [] return [] def merge_pr( pr_number: int, merge_method: str, - verbose: bool, repo_full_name: str, delete_branch: bool = True, + verbose: bool = False, ) -> bool: """ Merge a pull request. @@ -254,9 +268,9 @@ def merge_pr( def close_pr( pr_number: int, - verbose: bool, repo_full_name: str, comment: Optional[str] = None, + verbose: bool = False, ) -> bool: """Close a pull request without merging.""" command = ["gh", "pr", "close", str(pr_number)] @@ -274,8 +288,8 @@ def review_pr( pr_number: int, comment: str, action: str, - verbose: bool, repo_full_name: str, + verbose: bool, ) -> bool: """ Submit a review on a pull request. diff --git a/exercise_utils/roles.py b/exercise_utils/roles.py index d504336a..79f169cd 100644 --- a/exercise_utils/roles.py +++ b/exercise_utils/roles.py @@ -69,29 +69,29 @@ def create_pr( body: str, base: str, head: str, - verbose: bool, repo_full_name: str, - ) -> bool: + verbose: bool, + ) -> Optional[int]: """Create a pull request with automatic role markers.""" return github_cli.create_pr( self._format_text(title), self._format_text(body), base, head, - verbose, repo_full_name, + verbose, ) def comment_on_pr( self, pr_number: int, comment: str, - verbose: bool, repo_full_name: str, + verbose: bool, ) -> bool: """Add a comment to a pull request with automatic role marker.""" return github_cli.comment_on_pr( - pr_number, self._format_text(comment), verbose, repo_full_name + pr_number, self._format_text(comment), repo_full_name, verbose ) def review_pr( @@ -99,27 +99,27 @@ def review_pr( pr_number: int, comment: str, action: str, - verbose: bool, repo_full_name: str, + verbose: bool, ) -> bool: """Submit a review on a pull request with automatic role marker. True if review was submitted successfully, False otherwise""" return github_cli.review_pr( pr_number, self._format_text(comment), action, - verbose, repo_full_name, + verbose, ) def close_pr( self, pr_number: int, - verbose: bool, repo_full_name: str, comment: Optional[str] = None, + verbose: bool = False, ) -> bool: """Close a pull request without merging.""" formatted_comment = self._format_text(comment) if comment else None return github_cli.close_pr( - pr_number, verbose, repo_full_name, formatted_comment + pr_number, repo_full_name, formatted_comment, verbose ) From 2c07ccc0315b423b3ea87e93f99e29639ba6d143 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Tue, 10 Mar 2026 14:05:22 +0800 Subject: [PATCH 06/17] Update exercise_config --- exercise_utils/exercise_config.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/exercise_utils/exercise_config.py b/exercise_utils/exercise_config.py index 1bd6d7c1..aa893616 100644 --- a/exercise_utils/exercise_config.py +++ b/exercise_utils/exercise_config.py @@ -33,3 +33,9 @@ def update_config_fields(updates: dict[str, Any]) -> None: cursor[keys[-1]] = value config_path.write_text(json.dumps(config, indent=2)) + +def add_pr_config(pr_number: int, repo_full_name: str) -> None: + update_config_fields({ + "exercise_repo.pr_number": pr_number, + "exercise_repo.repo_full_name": repo_full_name, + }) \ No newline at end of file From c18d8f38280fe845716bb2546b7c6438a9f4598e Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Wed, 11 Mar 2026 13:01:24 +0800 Subject: [PATCH 07/17] Rename repo_full_name to pr_repo_full_name --- exercise_utils/exercise_config.py | 15 ++++++----- exercise_utils/github_cli.py | 42 +++++++++++++++---------------- exercise_utils/roles.py | 18 ++++++------- 3 files changed, 37 insertions(+), 38 deletions(-) diff --git a/exercise_utils/exercise_config.py b/exercise_utils/exercise_config.py index aa893616..3a1354b9 100644 --- a/exercise_utils/exercise_config.py +++ b/exercise_utils/exercise_config.py @@ -10,7 +10,7 @@ def update_config_fields(updates: dict[str, Any]) -> None: Example updates: { "exercise_repo.pr_number": 1, - "exercise_repo.repo_full_name": "owner/repo", + "exercise_repo.pr_repo_full_name": "owner/repo", "teammate_role": "teammate-bob", } """ @@ -34,8 +34,11 @@ def update_config_fields(updates: dict[str, Any]) -> None: config_path.write_text(json.dumps(config, indent=2)) -def add_pr_config(pr_number: int, repo_full_name: str) -> None: - update_config_fields({ - "exercise_repo.pr_number": pr_number, - "exercise_repo.repo_full_name": repo_full_name, - }) \ No newline at end of file + +def add_pr_config(pr_number: int, pr_repo_full_name: str) -> None: + update_config_fields( + { + "exercise_repo.pr_number": pr_number, + "exercise_repo.pr_repo_full_name": pr_repo_full_name, + } + ) diff --git a/exercise_utils/github_cli.py b/exercise_utils/github_cli.py index 20155781..53d48f3e 100644 --- a/exercise_utils/github_cli.py +++ b/exercise_utils/github_cli.py @@ -135,7 +135,7 @@ def create_pr( body: str, base: str, head: str, - repo_full_name: str, + repo_name: str, verbose: bool, ) -> Optional[int]: """Create a pull request.""" @@ -153,7 +153,7 @@ def create_pr( head, ] - command = _append_repo_flag(command, repo_full_name) + command = _append_repo_flag(command, repo_name) result = run(command, verbose) if not result.is_success(): @@ -166,23 +166,21 @@ def create_pr( return int(match.group(1)) -def _append_repo_flag(command: list[str], repo_full_name: str) -> list[str]: +def _append_repo_flag(command: list[str], repo_name: str) -> list[str]: """Append --repo flag. PR commands require explicit repository context.""" - if repo_full_name.strip() == "": - raise ValueError( - "repo_full_name must be provided for deterministic PR commands" - ) + if repo_name.strip() == "": + raise ValueError("repo_name must be provided for deterministic PR commands") - command.extend(["--repo", repo_full_name]) + command.extend(["--repo", repo_name]) return command -def view_pr(pr_number: int, repo_full_name: str, verbose: bool) -> dict[str, Any]: +def view_pr(pr_number: int, repo_name: str, verbose: bool) -> dict[str, Any]: """View pull request details.""" fields = "title,body,state,author,headRefName,baseRefName,comments,reviews" command = ["gh", "pr", "view", str(pr_number), "--json", fields] - command = _append_repo_flag(command, repo_full_name) + command = _append_repo_flag(command, repo_name) result = run( command, @@ -202,12 +200,12 @@ def view_pr(pr_number: int, repo_full_name: str, verbose: bool) -> dict[str, Any def comment_on_pr( pr_number: int, comment: str, - repo_full_name: str, + repo_name: str, verbose: bool, ) -> bool: """Add a comment to a pull request.""" command = ["gh", "pr", "comment", str(pr_number), "--body", comment] - command = _append_repo_flag(command, repo_full_name) + command = _append_repo_flag(command, repo_name) result = run( command, @@ -216,7 +214,7 @@ def comment_on_pr( return result.is_success() -def list_prs(state: str, repo_full_name: str, verbose: bool) -> list[dict[str, Any]]: +def list_prs(state: str, repo_name: str, verbose: bool) -> list[dict[str, Any]]: """ List pull requests. PR state filter ('open', 'closed', 'merged', 'all') @@ -230,7 +228,7 @@ def list_prs(state: str, repo_full_name: str, verbose: bool) -> list[dict[str, A "--json", "number,title,state,author,headRefName,baseRefName", ] - command = _append_repo_flag(command, repo_full_name) + command = _append_repo_flag(command, repo_name) result = run(command, verbose) @@ -247,7 +245,7 @@ def list_prs(state: str, repo_full_name: str, verbose: bool) -> list[dict[str, A def merge_pr( pr_number: int, merge_method: str, - repo_full_name: str, + repo_name: str, delete_branch: bool = True, verbose: bool = False, ) -> bool: @@ -260,7 +258,7 @@ def merge_pr( if delete_branch: command.append("--delete-branch") - command = _append_repo_flag(command, repo_full_name) + command = _append_repo_flag(command, repo_name) result = run(command, verbose) return result.is_success() @@ -268,7 +266,7 @@ def merge_pr( def close_pr( pr_number: int, - repo_full_name: str, + repo_name: str, comment: Optional[str] = None, verbose: bool = False, ) -> bool: @@ -278,7 +276,7 @@ def close_pr( if comment: command.extend(["--comment", comment]) - command = _append_repo_flag(command, repo_full_name) + command = _append_repo_flag(command, repo_name) result = run(command, verbose) return result.is_success() @@ -288,7 +286,7 @@ def review_pr( pr_number: int, comment: str, action: str, - repo_full_name: str, + repo_name: str, verbose: bool, ) -> bool: """ @@ -305,14 +303,14 @@ def review_pr( f"--{action}", ] - command = _append_repo_flag(command, repo_full_name) + command = _append_repo_flag(command, repo_name) result = run(command, verbose) return result.is_success() def get_latest_pr_number_by_author( - username: str, repo_full_name: str, verbose: bool + username: str, repo_name: str, verbose: bool ) -> Optional[int]: """Return the latest pull request number created by username in the repo.""" command = [ @@ -328,7 +326,7 @@ def get_latest_pr_number_by_author( "--json", "number", ] - command = _append_repo_flag(command, repo_full_name) + command = _append_repo_flag(command, repo_name) result = run(command, verbose) if not result.is_success(): diff --git a/exercise_utils/roles.py b/exercise_utils/roles.py index 79f169cd..892db28f 100644 --- a/exercise_utils/roles.py +++ b/exercise_utils/roles.py @@ -69,7 +69,7 @@ def create_pr( body: str, base: str, head: str, - repo_full_name: str, + repo_name: str, verbose: bool, ) -> Optional[int]: """Create a pull request with automatic role markers.""" @@ -78,7 +78,7 @@ def create_pr( self._format_text(body), base, head, - repo_full_name, + repo_name, verbose, ) @@ -86,12 +86,12 @@ def comment_on_pr( self, pr_number: int, comment: str, - repo_full_name: str, + repo_name: str, verbose: bool, ) -> bool: """Add a comment to a pull request with automatic role marker.""" return github_cli.comment_on_pr( - pr_number, self._format_text(comment), repo_full_name, verbose + pr_number, self._format_text(comment), repo_name, verbose ) def review_pr( @@ -99,7 +99,7 @@ def review_pr( pr_number: int, comment: str, action: str, - repo_full_name: str, + repo_name: str, verbose: bool, ) -> bool: """Submit a review on a pull request with automatic role marker. True if review was submitted successfully, False otherwise""" @@ -107,19 +107,17 @@ def review_pr( pr_number, self._format_text(comment), action, - repo_full_name, + repo_name, verbose, ) def close_pr( self, pr_number: int, - repo_full_name: str, + repo_name: str, comment: Optional[str] = None, verbose: bool = False, ) -> bool: """Close a pull request without merging.""" formatted_comment = self._format_text(comment) if comment else None - return github_cli.close_pr( - pr_number, repo_full_name, formatted_comment, verbose - ) + return github_cli.close_pr(pr_number, repo_name, formatted_comment, verbose) From 475df7208faaecd0fd1de1caead125bb3ff63b9a Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Wed, 11 Mar 2026 19:42:14 +0800 Subject: [PATCH 08/17] Update exercise_config --- exercise_utils/exercise_config.py | 40 ++++++++++++++++++------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/exercise_utils/exercise_config.py b/exercise_utils/exercise_config.py index 3a1354b9..b6ff3720 100644 --- a/exercise_utils/exercise_config.py +++ b/exercise_utils/exercise_config.py @@ -3,15 +3,29 @@ from typing import Any +def _merge_config_fields(config: dict[str, Any], updates: dict[str, Any]) -> None: + for key, value in updates.items(): + if isinstance(value, dict): + current_value = config.get(key) + if not isinstance(current_value, dict): + config[key] = {} + _merge_config_fields(config[key], value) + continue + + config[key] = value + + def update_config_fields(updates: dict[str, Any]) -> None: """ - Update a JSON file using dotted-path keys. + Update fields in .gitmastery-exercise.json. Example updates: { - "exercise_repo.pr_number": 1, - "exercise_repo.pr_repo_full_name": "owner/repo", - "teammate_role": "teammate-bob", + "exercise_repo": { + "pr_number": 1, + "pr_repo_full_name": "owner/repo", + }, + "teammate": "teammate-bob", } """ config_path = Path("../.gitmastery-exercise.json") @@ -20,17 +34,7 @@ def update_config_fields(updates: dict[str, Any]) -> None: f".gitmastery-exercise.json file not found at {config_path.resolve()}" ) config = json.loads(config_path.read_text()) - - for dotted_path, value in updates.items(): - keys = dotted_path.split(".") - cursor = config - - for key in keys[:-1]: - if key not in cursor or not isinstance(cursor[key], dict): - cursor[key] = {} - cursor = cursor[key] - - cursor[keys[-1]] = value + _merge_config_fields(config, updates) config_path.write_text(json.dumps(config, indent=2)) @@ -38,7 +42,9 @@ def update_config_fields(updates: dict[str, Any]) -> None: def add_pr_config(pr_number: int, pr_repo_full_name: str) -> None: update_config_fields( { - "exercise_repo.pr_number": pr_number, - "exercise_repo.pr_repo_full_name": pr_repo_full_name, + "exercise_repo": { + "pr_number": pr_number, + "pr_repo_full_name": pr_repo_full_name, + } } ) From 6edb4f54639959903b1fe49d19491ffa4196f4d9 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Thu, 12 Mar 2026 21:39:49 +0800 Subject: [PATCH 09/17] Update github_cli --- exercise_utils/github_cli.py | 166 ++++++++++++++++++++--------------- 1 file changed, 96 insertions(+), 70 deletions(-) diff --git a/exercise_utils/github_cli.py b/exercise_utils/github_cli.py index 53d48f3e..344bbff9 100644 --- a/exercise_utils/github_cli.py +++ b/exercise_utils/github_cli.py @@ -1,12 +1,18 @@ """Wrapper for Github CLI commands.""" # TODO: The following should be built using the builder pattern +import json import re from typing import Any, Optional from exercise_utils.cli import run +_PR_STATES = {"open", "closed", "merged", "all"} +_PR_MERGE_METHODS = {"merge", "squash", "rebase"} +_PR_REVIEW_ACTIONS = {"request-changes", "comment"} + + def fork_repo( repository_name: str, fork_name: str, @@ -137,23 +143,15 @@ def create_pr( head: str, repo_name: str, verbose: bool, + draft: bool = False, ) -> Optional[int]: """Create a pull request.""" - command = [ - "gh", - "pr", - "create", - "--title", - title, - "--body", - body, - "--base", - base, - "--head", - head, - ] - - command = _append_repo_flag(command, repo_name) + command = _build_pr_command("create", repo_name=repo_name) + command = _append_value_flag(command, "--title", title) + command = _append_value_flag(command, "--body", body) + command = _append_value_flag(command, "--base", base) + command = _append_value_flag(command, "--head", head) + command = _append_bool_flag(command, draft, "--draft") result = run(command, verbose) if not result.is_success(): @@ -171,16 +169,53 @@ def _append_repo_flag(command: list[str], repo_name: str) -> list[str]: if repo_name.strip() == "": raise ValueError("repo_name must be provided for deterministic PR commands") - command.extend(["--repo", repo_name]) - return command + return [*command, "--repo", repo_name] + + +def _validate_choice(value: str, allowed: set[str], field_name: str) -> str: + """Validate a string argument against a known set of values.""" + if value not in allowed: + allowed_values = ", ".join(sorted(allowed)) + raise ValueError( + f"Invalid {field_name}: {value}. Allowed values: {allowed_values}" + ) + return value + + +def _build_pr_command(subcommand: str, *args: str, repo_name: str) -> list[str]: + """Build a gh pr command and append deterministic repository context.""" + return _append_repo_flag(["gh", "pr", subcommand, *args], repo_name) + + +def _append_bool_flag(command: list[str], enabled: bool, flag: str) -> list[str]: + """Append a CLI flag when the related boolean option is enabled.""" + return [*command, flag] if enabled else command + + +def _append_value_flag(command: list[str], flag: str, value: str) -> list[str]: + """Append a value-taking CLI option in --flag=value form.""" + return [*command, f"{flag}={value}"] + + +def _parse_json_or_default(raw_output: str, default: Any) -> Any: + """Parse JSON output and return a default value on decode failure.""" + try: + return json.loads(raw_output) + except json.JSONDecodeError: + return default def view_pr(pr_number: int, repo_name: str, verbose: bool) -> dict[str, Any]: """View pull request details.""" fields = "title,body,state,author,headRefName,baseRefName,comments,reviews" - command = ["gh", "pr", "view", str(pr_number), "--json", fields] - command = _append_repo_flag(command, repo_name) + command = _build_pr_command( + "view", + str(pr_number), + "--json", + fields, + repo_name=repo_name, + ) result = run( command, @@ -188,12 +223,8 @@ def view_pr(pr_number: int, repo_name: str, verbose: bool) -> dict[str, Any]: ) if result.is_success(): - import json - - try: - return json.loads(result.stdout) - except json.JSONDecodeError: - return {} + parsed = _parse_json_or_default(result.stdout, {}) + return parsed if isinstance(parsed, dict) else {} return {} @@ -204,8 +235,8 @@ def comment_on_pr( verbose: bool, ) -> bool: """Add a comment to a pull request.""" - command = ["gh", "pr", "comment", str(pr_number), "--body", comment] - command = _append_repo_flag(command, repo_name) + command = _build_pr_command("comment", str(pr_number), repo_name=repo_name) + command = _append_value_flag(command, "--body", comment) result = run( command, @@ -219,26 +250,21 @@ def list_prs(state: str, repo_name: str, verbose: bool) -> list[dict[str, Any]]: List pull requests. PR state filter ('open', 'closed', 'merged', 'all') """ - command = [ - "gh", - "pr", + validated_state = _validate_choice(state, _PR_STATES, "state") + command = _build_pr_command( "list", "--state", - state, + validated_state, "--json", "number,title,state,author,headRefName,baseRefName", - ] - command = _append_repo_flag(command, repo_name) + repo_name=repo_name, + ) result = run(command, verbose) if result.is_success(): - import json - - try: - return json.loads(result.stdout) - except json.JSONDecodeError: - return [] + parsed = _parse_json_or_default(result.stdout, []) + return parsed if isinstance(parsed, list) else [] return [] @@ -253,12 +279,19 @@ def merge_pr( Merge a pull request. Merge method ('merge', 'squash', 'rebase') """ - command = ["gh", "pr", "merge", str(pr_number), f"--{merge_method}"] - - if delete_branch: - command.append("--delete-branch") + validated_merge_method = _validate_choice( + merge_method, + _PR_MERGE_METHODS, + "merge_method", + ) + command = _build_pr_command( + "merge", + str(pr_number), + f"--{validated_merge_method}", + repo_name=repo_name, + ) - command = _append_repo_flag(command, repo_name) + command = _append_bool_flag(command, delete_branch, "--delete-branch") result = run(command, verbose) return result.is_success() @@ -268,15 +301,19 @@ def close_pr( pr_number: int, repo_name: str, comment: Optional[str] = None, + delete_branch: bool = False, verbose: bool = False, ) -> bool: """Close a pull request without merging.""" - command = ["gh", "pr", "close", str(pr_number)] + command = _build_pr_command( + "close", + str(pr_number), + repo_name=repo_name, + ) + command = _append_bool_flag(command, delete_branch, "--delete-branch") if comment: - command.extend(["--comment", comment]) - - command = _append_repo_flag(command, repo_name) + command = _append_value_flag(command, "--comment", comment) result = run(command, verbose) return result.is_success() @@ -291,19 +328,12 @@ def review_pr( ) -> bool: """ Submit a review on a pull request. - Review action ('approve', 'request-changes', 'comment') + Review action ('request-changes', 'comment') """ - command = [ - "gh", - "pr", - "review", - str(pr_number), - "--body", - comment, - f"--{action}", - ] - - command = _append_repo_flag(command, repo_name) + validated_action = _validate_choice(action, _PR_REVIEW_ACTIONS, "action") + command = _build_pr_command("review", str(pr_number), repo_name=repo_name) + command = _append_value_flag(command, "--body", comment) + command.append(f"--{validated_action}") result = run(command, verbose) return result.is_success() @@ -313,9 +343,7 @@ def get_latest_pr_number_by_author( username: str, repo_name: str, verbose: bool ) -> Optional[int]: """Return the latest pull request number created by username in the repo.""" - command = [ - "gh", - "pr", + command = _build_pr_command( "list", "--author", username, @@ -325,19 +353,17 @@ def get_latest_pr_number_by_author( "1", "--json", "number", - ] - command = _append_repo_flag(command, repo_name) + repo_name=repo_name, + ) result = run(command, verbose) if not result.is_success(): return None - import json - - try: - prs = json.loads(result.stdout) - except json.JSONDecodeError: + parsed = _parse_json_or_default(result.stdout, None) + if not isinstance(parsed, list): return None + prs = parsed if not prs: return None From 28661aacca3a1c3334ca9d2628890c1a8a01d56e Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Mon, 16 Mar 2026 13:38:37 +0800 Subject: [PATCH 10/17] Update github_cli wrapper function --- exercise_utils/github_cli.py | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/exercise_utils/github_cli.py b/exercise_utils/github_cli.py index 344bbff9..850c0388 100644 --- a/exercise_utils/github_cli.py +++ b/exercise_utils/github_cli.py @@ -212,10 +212,9 @@ def view_pr(pr_number: int, repo_name: str, verbose: bool) -> dict[str, Any]: command = _build_pr_command( "view", str(pr_number), - "--json", - fields, repo_name=repo_name, ) + command = _append_value_flag(command, "--json", fields) result = run( command, @@ -251,14 +250,10 @@ def list_prs(state: str, repo_name: str, verbose: bool) -> list[dict[str, Any]]: PR state filter ('open', 'closed', 'merged', 'all') """ validated_state = _validate_choice(state, _PR_STATES, "state") - command = _build_pr_command( - "list", - "--state", - validated_state, - "--json", - "number,title,state,author,headRefName,baseRefName", - repo_name=repo_name, - ) + fields = "number,title,state,author,headRefName,baseRefName" + command = _build_pr_command("list", repo_name=repo_name) + command = _append_value_flag(command, "--state", validated_state) + command = _append_value_flag(command, "--json", fields) result = run(command, verbose) @@ -343,18 +338,11 @@ def get_latest_pr_number_by_author( username: str, repo_name: str, verbose: bool ) -> Optional[int]: """Return the latest pull request number created by username in the repo.""" - command = _build_pr_command( - "list", - "--author", - username, - "--state", - "all", - "--limit", - "1", - "--json", - "number", - repo_name=repo_name, - ) + command = _build_pr_command("list", repo_name=repo_name) + command = _append_value_flag(command, "--author", username) + command = _append_value_flag(command, "--state", "all") + command = _append_value_flag(command, "--limit", "1") + command = _append_value_flag(command, "--json", "number") result = run(command, verbose) if not result.is_success(): From ff286944ff3c3c4c38f3eb60660258397022d356 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Thu, 19 Mar 2026 10:59:04 +0800 Subject: [PATCH 11/17] Update github_cli --- exercise_utils/github_cli.py | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/exercise_utils/github_cli.py b/exercise_utils/github_cli.py index 850c0388..5ac8ccce 100644 --- a/exercise_utils/github_cli.py +++ b/exercise_utils/github_cli.py @@ -334,27 +334,35 @@ def review_pr( return result.is_success() -def get_latest_pr_number_by_author( +def get_pr_numbers_by_author( username: str, repo_name: str, verbose: bool -) -> Optional[int]: - """Return the latest pull request number created by username in the repo.""" +) -> list[int]: + """Return the latest opened pull request numbers created by username in the repo.""" command = _build_pr_command("list", repo_name=repo_name) command = _append_value_flag(command, "--author", username) - command = _append_value_flag(command, "--state", "all") - command = _append_value_flag(command, "--limit", "1") + command = _append_value_flag(command, "--state", "open") command = _append_value_flag(command, "--json", "number") result = run(command, verbose) if not result.is_success(): - return None + return [] - parsed = _parse_json_or_default(result.stdout, None) - if not isinstance(parsed, list): - return None - prs = parsed + import json - if not prs: - return None + try: + prs = json.loads(result.stdout) + except json.JSONDecodeError: + return [] + + pr_numbers = [pr.get("number") for pr in prs if isinstance(pr.get("number"), int)] + pr_numbers.sort() + return pr_numbers + +def get_latest_pr_number_by_author( + username: str, repo_full_name: str, verbose: bool +) -> Optional[int]: + """Return the latest open pull request number created by username in the repo.""" + if pr_numbers := get_pr_numbers_by_author(username, repo_full_name, verbose): + return pr_numbers[-1] + raise ValueError(f"No open PRs found for user {username} in repo {repo_full_name}.") - pr_number = prs[0].get("number") - return pr_number if isinstance(pr_number, int) else None From 98796ef2a26ed7478fd3da6f0c561d49814a2f39 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Thu, 19 Mar 2026 12:13:08 +0800 Subject: [PATCH 12/17] Update exercise_config --- exercise_utils/exercise_config.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/exercise_utils/exercise_config.py b/exercise_utils/exercise_config.py index b6ff3720..cb0d4904 100644 --- a/exercise_utils/exercise_config.py +++ b/exercise_utils/exercise_config.py @@ -3,7 +3,13 @@ from typing import Any +EXERCISE_CONFIG_FILE_NAME = ".gitmastery-exercise.json" + + def _merge_config_fields(config: dict[str, Any], updates: dict[str, Any]) -> None: + """ + Recursively updates a JSON-like configuration as specified by the provided dictionary. + """ for key, value in updates.items(): if isinstance(value, dict): current_value = config.get(key) @@ -15,7 +21,7 @@ def _merge_config_fields(config: dict[str, Any], updates: dict[str, Any]) -> Non config[key] = value -def update_config_fields(updates: dict[str, Any]) -> None: +def update_config_fields(updates: dict[str, Any], config_path: Path) -> None: """ Update fields in .gitmastery-exercise.json. @@ -28,7 +34,7 @@ def update_config_fields(updates: dict[str, Any]) -> None: "teammate": "teammate-bob", } """ - config_path = Path("../.gitmastery-exercise.json") + config_path = Path(config_path) if not config_path.exists(): raise FileNotFoundError( f".gitmastery-exercise.json file not found at {config_path.resolve()}" @@ -39,12 +45,18 @@ def update_config_fields(updates: dict[str, Any]) -> None: config_path.write_text(json.dumps(config, indent=2)) -def add_pr_config(pr_number: int, pr_repo_full_name: str) -> None: +def add_pr_config( + config_path: Path, + pr_number: int | None = None, + pr_repo_full_name: str | None = None, +) -> None: + """Adds a PR config to .gitmastery-exercise.json.""" update_config_fields( { "exercise_repo": { "pr_number": pr_number, "pr_repo_full_name": pr_repo_full_name, } - } + }, + config_path=config_path / ".gitmastery-exercise.json", ) From 2945eed2c72f57f5e6de90c2ff622db82b0ad912 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Thu, 19 Mar 2026 12:15:53 +0800 Subject: [PATCH 13/17] Fix formatting issues --- exercise_utils/roles.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/exercise_utils/roles.py b/exercise_utils/roles.py index 892db28f..6a2e4b11 100644 --- a/exercise_utils/roles.py +++ b/exercise_utils/roles.py @@ -5,7 +5,8 @@ class RoleMarker: - """Wrapper for git and GitHub operations with automatic role marker formatting. + """ + Wrapper for git and GitHub operations with automatic role marker formatting. Usage: bob = RoleMarker("teammate-bob") @@ -21,7 +22,8 @@ def __init__(self, role: str) -> None: @staticmethod def format(role: str, text: str) -> str: - """Format text with a role marker. + """ + Format text with a role marker. Example: format('teammate-alice', 'Add feature') -> '[ROLE:teammate-alice] Add feature' """ @@ -102,7 +104,10 @@ def review_pr( repo_name: str, verbose: bool, ) -> bool: - """Submit a review on a pull request with automatic role marker. True if review was submitted successfully, False otherwise""" + """ + Submit a review on a pull request with automatic role marker. + True if review was submitted successfully, False otherwise + """ return github_cli.review_pr( pr_number, self._format_text(comment), From 5b33f09db3db3d89bef7e926e22252ba1813ba95 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Thu, 19 Mar 2026 12:32:10 +0800 Subject: [PATCH 14/17] Update list_prs github_cli --- exercise_utils/github_cli.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/exercise_utils/github_cli.py b/exercise_utils/github_cli.py index 5ac8ccce..579e9dc8 100644 --- a/exercise_utils/github_cli.py +++ b/exercise_utils/github_cli.py @@ -244,16 +244,27 @@ def comment_on_pr( return result.is_success() -def list_prs(state: str, repo_name: str, verbose: bool) -> list[dict[str, Any]]: +def list_prs( + state: str, + repo_name: str, + verbose: bool, + limit: int = 30, + search: Optional[str] = None, +) -> list[dict[str, Any]]: """ List pull requests. PR state filter ('open', 'closed', 'merged', 'all') + Optional search query using GitHub search syntax. """ validated_state = _validate_choice(state, _PR_STATES, "state") fields = "number,title,state,author,headRefName,baseRefName" command = _build_pr_command("list", repo_name=repo_name) command = _append_value_flag(command, "--state", validated_state) command = _append_value_flag(command, "--json", fields) + command = _append_value_flag(command, "--limit", str(limit)) + + if search is not None and search.strip() != "": + command = _append_value_flag(command, "--search", search) result = run(command, verbose) @@ -334,9 +345,7 @@ def review_pr( return result.is_success() -def get_pr_numbers_by_author( - username: str, repo_name: str, verbose: bool -) -> list[int]: +def get_pr_numbers_by_author(username: str, repo_name: str, verbose: bool) -> list[int]: """Return the latest opened pull request numbers created by username in the repo.""" command = _build_pr_command("list", repo_name=repo_name) command = _append_value_flag(command, "--author", username) @@ -353,11 +362,12 @@ def get_pr_numbers_by_author( prs = json.loads(result.stdout) except json.JSONDecodeError: return [] - + pr_numbers = [pr.get("number") for pr in prs if isinstance(pr.get("number"), int)] pr_numbers.sort() return pr_numbers + def get_latest_pr_number_by_author( username: str, repo_full_name: str, verbose: bool ) -> Optional[int]: @@ -365,4 +375,3 @@ def get_latest_pr_number_by_author( if pr_numbers := get_pr_numbers_by_author(username, repo_full_name, verbose): return pr_numbers[-1] raise ValueError(f"No open PRs found for user {username} in repo {repo_full_name}.") - From 7b91c5a0001b8dd30f065de71358b363a26fd90b Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Thu, 19 Mar 2026 13:25:30 +0800 Subject: [PATCH 15/17] Add missing parameters --- exercise_utils/roles.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/exercise_utils/roles.py b/exercise_utils/roles.py index 6a2e4b11..4a7690dd 100644 --- a/exercise_utils/roles.py +++ b/exercise_utils/roles.py @@ -73,6 +73,7 @@ def create_pr( head: str, repo_name: str, verbose: bool, + draft: bool = False, ) -> Optional[int]: """Create a pull request with automatic role markers.""" return github_cli.create_pr( @@ -82,6 +83,7 @@ def create_pr( head, repo_name, verbose, + draft, ) def comment_on_pr( @@ -121,8 +123,11 @@ def close_pr( pr_number: int, repo_name: str, comment: Optional[str] = None, + delete_branch: bool = False, verbose: bool = False, ) -> bool: """Close a pull request without merging.""" formatted_comment = self._format_text(comment) if comment else None - return github_cli.close_pr(pr_number, repo_name, formatted_comment, verbose) + return github_cli.close_pr( + pr_number, repo_name, formatted_comment, delete_branch, verbose + ) From 19d98a203755adcfacda52c1a8fd7e65964ebc29 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Mon, 23 Mar 2026 11:12:44 +0800 Subject: [PATCH 16/17] Fix bug when merging config --- exercise_utils/exercise_config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/exercise_utils/exercise_config.py b/exercise_utils/exercise_config.py index cb0d4904..7a0aecca 100644 --- a/exercise_utils/exercise_config.py +++ b/exercise_utils/exercise_config.py @@ -11,6 +11,9 @@ def _merge_config_fields(config: dict[str, Any], updates: dict[str, Any]) -> Non Recursively updates a JSON-like configuration as specified by the provided dictionary. """ for key, value in updates.items(): + if not value: + continue + if isinstance(value, dict): current_value = config.get(key) if not isinstance(current_value, dict): From 701f5b2da471df49f4cc765bd64967bcb19ba721 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Thu, 26 Mar 2026 08:26:31 +0800 Subject: [PATCH 17/17] Update exercise_config --- exercise_utils/exercise_config.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/exercise_utils/exercise_config.py b/exercise_utils/exercise_config.py index 7a0aecca..105361b5 100644 --- a/exercise_utils/exercise_config.py +++ b/exercise_utils/exercise_config.py @@ -11,9 +11,6 @@ def _merge_config_fields(config: dict[str, Any], updates: dict[str, Any]) -> Non Recursively updates a JSON-like configuration as specified by the provided dictionary. """ for key, value in updates.items(): - if not value: - continue - if isinstance(value, dict): current_value = config.get(key) if not isinstance(current_value, dict): @@ -53,13 +50,13 @@ def add_pr_config( pr_number: int | None = None, pr_repo_full_name: str | None = None, ) -> None: - """Adds a PR config to .gitmastery-exercise.json.""" + exercise_repo_updates: dict[str, int | str] = {} + if pr_number is not None: + exercise_repo_updates["pr_number"] = pr_number + if pr_repo_full_name is not None: + exercise_repo_updates["pr_repo_full_name"] = pr_repo_full_name + update_config_fields( - { - "exercise_repo": { - "pr_number": pr_number, - "pr_repo_full_name": pr_repo_full_name, - } - }, + {"exercise_repo": exercise_repo_updates}, config_path=config_path / ".gitmastery-exercise.json", )