From 3c605bf47bdf41021a180d7efad4701d08f61d9d Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Tue, 10 Mar 2026 10:10:05 +0800 Subject: [PATCH 01/11] Setup PrHelper --- src/git_autograder/helpers/__init__.py | 3 ++- src/git_autograder/helpers/pr_helper.py | 12 ++++++++++++ src/git_autograder/repo/null_repo.py | 7 +++++++ src/git_autograder/repo/repo_base.py | 5 +++++ 4 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 src/git_autograder/helpers/pr_helper.py diff --git a/src/git_autograder/helpers/__init__.py b/src/git_autograder/helpers/__init__.py index 938eb72..7561ae8 100644 --- a/src/git_autograder/helpers/__init__.py +++ b/src/git_autograder/helpers/__init__.py @@ -1,6 +1,7 @@ -__all__ = ["BranchHelper", "CommitHelper", "RemoteHelper", "FileHelper"] +__all__ = ["BranchHelper", "CommitHelper", "RemoteHelper", "FileHelper", "PrHelper"] from .branch_helper import BranchHelper from .commit_helper import CommitHelper from .remote_helper import RemoteHelper from .file_helper import FileHelper +from .pr_helper import PrHelper diff --git a/src/git_autograder/helpers/pr_helper.py b/src/git_autograder/helpers/pr_helper.py new file mode 100644 index 0000000..9ee392b --- /dev/null +++ b/src/git_autograder/helpers/pr_helper.py @@ -0,0 +1,12 @@ +from typing import Optional + +from git import Repo + +from git_autograder.exception import GitAutograderInvalidStateException + + +class PrHelper: + MISSING_PR = "PR {pr} is missing." + + def __init__(self, repo: Repo) -> None: + self.repo = repo diff --git a/src/git_autograder/repo/null_repo.py b/src/git_autograder/repo/null_repo.py index 553e529..61c965b 100644 --- a/src/git_autograder/repo/null_repo.py +++ b/src/git_autograder/repo/null_repo.py @@ -3,6 +3,7 @@ from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper +from git_autograder.helpers.pr_helper import PrHelper from git_autograder.helpers.remote_helper import RemoteHelper from git_autograder.repo.repo_base import GitAutograderRepoBase @@ -37,6 +38,12 @@ def files(self) -> FileHelper: raise AttributeError( "Cannot access attribute files on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." ) + + @property + def prs(self) -> PrHelper: + raise AttributeError( + "Cannot access attribute prs on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." + ) def __getattr__(self, name: str) -> None: raise AttributeError( diff --git a/src/git_autograder/repo/repo_base.py b/src/git_autograder/repo/repo_base.py index b0f2ef3..5f6e220 100644 --- a/src/git_autograder/repo/repo_base.py +++ b/src/git_autograder/repo/repo_base.py @@ -5,6 +5,7 @@ from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper +from git_autograder.helpers.pr_helper import PrHelper from git_autograder.helpers.remote_helper import RemoteHelper @@ -28,3 +29,7 @@ def remotes(self) -> RemoteHelper: ... @property @abstractmethod def files(self) -> FileHelper: ... + + @property + @abstractmethod + def prs(self) -> PrHelper: ... From 59442419c8c7e986a6b02bc3901117a42f4286b1 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Sat, 14 Mar 2026 21:58:01 +0800 Subject: [PATCH 02/11] Add PrHelper to GitAutograderRepo --- src/git_autograder/exercise.py | 10 +++++ src/git_autograder/exercise_config.py | 4 ++ src/git_autograder/helpers/pr_helper.py | 50 +++++++++++++++++++++---- src/git_autograder/repo/null_repo.py | 4 +- src/git_autograder/repo/repo.py | 9 +++++ src/git_autograder/repo/repo_base.py | 4 +- 6 files changed, 70 insertions(+), 11 deletions(-) diff --git a/src/git_autograder/exercise.py b/src/git_autograder/exercise.py index 58a3d4c..c749a91 100644 --- a/src/git_autograder/exercise.py +++ b/src/git_autograder/exercise.py @@ -15,6 +15,7 @@ GitAutograderWrongAnswerException, ) from git_autograder.exercise_config import ExerciseConfig +from git_autograder.helpers.pr_helper import PrContext from git_autograder.output import GitAutograderOutput from git_autograder.repo.null_repo import NullGitAutograderRepo from git_autograder.repo.repo import GitAutograderRepo @@ -69,9 +70,18 @@ def __init__( if self.config.exercise_repo.repo_type == "ignore" or self.config.exercise_repo.repo_type == "local-ignore": self.repo = NullGitAutograderRepo() else: + pr_number = self.config.exercise_repo.pr_number + pr_repo_full_name = self.config.exercise_repo.pr_repo_full_name + pr_context: Optional[PrContext] = None + if pr_number is not None and pr_repo_full_name is not None: + pr_context = PrContext( + pr_number=pr_number, + pr_repo_full_name=pr_repo_full_name, + ) self.repo = GitAutograderRepo( self.config.exercise_name, Path(exercise_path) / self.config.exercise_repo.repo_name, + pr_context, ) except InvalidGitRepositoryError: raise GitAutograderInvalidStateException("Exercise is not a Git repository") diff --git a/src/git_autograder/exercise_config.py b/src/git_autograder/exercise_config.py index 1ac7272..0b0779b 100644 --- a/src/git_autograder/exercise_config.py +++ b/src/git_autograder/exercise_config.py @@ -13,6 +13,8 @@ class ExerciseRepoConfig: repo_title: Optional[str] create_fork: Optional[bool] init: Optional[bool] + pr_number: Optional[int] + pr_repo_full_name: Optional[str] exercise_name: str tags: List[str] @@ -50,6 +52,8 @@ def read_config(path: str | Path) -> "ExerciseConfig": repo_title=exercise_repo["repo_title"], create_fork=exercise_repo["create_fork"], init=exercise_repo["init"], + pr_number=exercise_repo["pr_number"], + pr_repo_full_name=exercise_repo["pr_repo_full_name"], ), downloaded_at=raw_config["downloaded_at"], ) diff --git a/src/git_autograder/helpers/pr_helper.py b/src/git_autograder/helpers/pr_helper.py index 9ee392b..69ff164 100644 --- a/src/git_autograder/helpers/pr_helper.py +++ b/src/git_autograder/helpers/pr_helper.py @@ -1,12 +1,48 @@ -from typing import Optional +from abc import ABC, abstractmethod +from dataclasses import dataclass -from git import Repo -from git_autograder.exception import GitAutograderInvalidStateException +@dataclass(frozen=True) +class PrContext: + pr_number: int + pr_repo_full_name: str -class PrHelper: - MISSING_PR = "PR {pr} is missing." +class PrHelperBase(ABC): + @property + @abstractmethod + def pr_number(self) -> int: ... - def __init__(self, repo: Repo) -> None: - self.repo = repo + @property + @abstractmethod + def pr_repo_full_name(self) -> str: ... + + +class PrHelper(PrHelperBase): + def __init__(self, context: PrContext) -> None: + self._pr_number = context.pr_number + self._pr_repo_full_name = context.pr_repo_full_name + + @property + def pr_number(self) -> int: + return self._pr_number + + @property + def pr_repo_full_name(self) -> str: + return self._pr_repo_full_name + + +class NullPrHelper(PrHelperBase): + @property + def pr_number(self) -> int: + raise AttributeError( + "Cannot access attribute pr_number on NullPrHelper. Check that your exercise repo's " \ + "pr_context is properly initialized." + ) + + @property + def pr_repo_full_name(self) -> str: + raise AttributeError( + "Cannot access attribute pr_repo_full_name on NullPrHelper. Check that your exercise repo's " \ + "pr_context is properly initialized." + ) \ No newline at end of file diff --git a/src/git_autograder/repo/null_repo.py b/src/git_autograder/repo/null_repo.py index 61c965b..76965a9 100644 --- a/src/git_autograder/repo/null_repo.py +++ b/src/git_autograder/repo/null_repo.py @@ -3,7 +3,7 @@ from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper -from git_autograder.helpers.pr_helper import PrHelper +from git_autograder.helpers.pr_helper import NullPrHelper, PrHelper from git_autograder.helpers.remote_helper import RemoteHelper from git_autograder.repo.repo_base import GitAutograderRepoBase @@ -40,7 +40,7 @@ def files(self) -> FileHelper: ) @property - def prs(self) -> PrHelper: + def prs(self) -> PrHelper | NullPrHelper: raise AttributeError( "Cannot access attribute prs on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." ) diff --git a/src/git_autograder/repo/repo.py b/src/git_autograder/repo/repo.py index 9dbd36d..9923760 100644 --- a/src/git_autograder/repo/repo.py +++ b/src/git_autograder/repo/repo.py @@ -1,10 +1,12 @@ import os +from typing import Optional from git import Repo from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper +from git_autograder.helpers.pr_helper import NullPrHelper, PrContext, PrHelper from git_autograder.helpers.remote_helper import RemoteHelper from git_autograder.repo.repo_base import GitAutograderRepoBase @@ -14,9 +16,11 @@ def __init__( self, exercise_name: str, repo_path: str | os.PathLike, + pr_context: Optional[PrContext] = None, ) -> None: self.exercise_name = exercise_name self.repo_path = repo_path + self.pr_context = pr_context self._repo: Repo = Repo(self.repo_path) @@ -24,6 +28,7 @@ def __init__( self._commits: CommitHelper = CommitHelper(self._repo) self._remotes: RemoteHelper = RemoteHelper(self._repo) self._files: FileHelper = FileHelper(self._repo) + self._prs: PrHelper | NullPrHelper = PrHelper(self.pr_context) if self.pr_context else NullPrHelper() @property def repo(self) -> Repo: @@ -44,3 +49,7 @@ def remotes(self) -> RemoteHelper: @property def files(self) -> FileHelper: return self._files + + @property + def prs(self) -> PrHelper | NullPrHelper: + return self._prs diff --git a/src/git_autograder/repo/repo_base.py b/src/git_autograder/repo/repo_base.py index 5f6e220..ba62b31 100644 --- a/src/git_autograder/repo/repo_base.py +++ b/src/git_autograder/repo/repo_base.py @@ -5,7 +5,7 @@ from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper -from git_autograder.helpers.pr_helper import PrHelper +from git_autograder.helpers.pr_helper import NullPrHelper, PrHelper from git_autograder.helpers.remote_helper import RemoteHelper @@ -32,4 +32,4 @@ def files(self) -> FileHelper: ... @property @abstractmethod - def prs(self) -> PrHelper: ... + def prs(self) -> PrHelper | NullPrHelper: ... From d52f88241d2deb06de864be5ba3a704cb8a5324b Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Mon, 16 Mar 2026 19:14:01 +0800 Subject: [PATCH 03/11] Implement PrHelper --- src/git_autograder/helpers/pr_helper.py | 48 ------- .../helpers/pr_helper/null_pr_helper.py | 11 ++ .../helpers/pr_helper/pr_helper.py | 122 ++++++++++++++++++ .../helpers/pr_helper/pr_helper_base.py | 9 ++ 4 files changed, 142 insertions(+), 48 deletions(-) delete mode 100644 src/git_autograder/helpers/pr_helper.py create mode 100644 src/git_autograder/helpers/pr_helper/null_pr_helper.py create mode 100644 src/git_autograder/helpers/pr_helper/pr_helper.py create mode 100644 src/git_autograder/helpers/pr_helper/pr_helper_base.py diff --git a/src/git_autograder/helpers/pr_helper.py b/src/git_autograder/helpers/pr_helper.py deleted file mode 100644 index 69ff164..0000000 --- a/src/git_autograder/helpers/pr_helper.py +++ /dev/null @@ -1,48 +0,0 @@ -from abc import ABC, abstractmethod -from dataclasses import dataclass - - -@dataclass(frozen=True) -class PrContext: - pr_number: int - pr_repo_full_name: str - - -class PrHelperBase(ABC): - @property - @abstractmethod - def pr_number(self) -> int: ... - - @property - @abstractmethod - def pr_repo_full_name(self) -> str: ... - - -class PrHelper(PrHelperBase): - def __init__(self, context: PrContext) -> None: - self._pr_number = context.pr_number - self._pr_repo_full_name = context.pr_repo_full_name - - @property - def pr_number(self) -> int: - return self._pr_number - - @property - def pr_repo_full_name(self) -> str: - return self._pr_repo_full_name - - -class NullPrHelper(PrHelperBase): - @property - def pr_number(self) -> int: - raise AttributeError( - "Cannot access attribute pr_number on NullPrHelper. Check that your exercise repo's " \ - "pr_context is properly initialized." - ) - - @property - def pr_repo_full_name(self) -> str: - raise AttributeError( - "Cannot access attribute pr_repo_full_name on NullPrHelper. Check that your exercise repo's " \ - "pr_context is properly initialized." - ) \ No newline at end of file diff --git a/src/git_autograder/helpers/pr_helper/null_pr_helper.py b/src/git_autograder/helpers/pr_helper/null_pr_helper.py new file mode 100644 index 0000000..d7bc273 --- /dev/null +++ b/src/git_autograder/helpers/pr_helper/null_pr_helper.py @@ -0,0 +1,11 @@ +from git_autograder.helpers.pr_helper.pr_helper_base import PrHelperBase +from git_autograder.pr import GitAutograderPr + + +class NullPrHelper(PrHelperBase): + @property + def pr(self) -> GitAutograderPr: + raise AttributeError( + f"Cannot access attribute pr on NullPrHelper. " + "Check that your exercise repo's pr_context is properly initialized." + ) diff --git a/src/git_autograder/helpers/pr_helper/pr_helper.py b/src/git_autograder/helpers/pr_helper/pr_helper.py new file mode 100644 index 0000000..c7c28e0 --- /dev/null +++ b/src/git_autograder/helpers/pr_helper/pr_helper.py @@ -0,0 +1,122 @@ +import json +import subprocess +from dataclasses import dataclass +from typing import Any, Dict + +from git_autograder.exception import GitAutograderInvalidStateException +from git_autograder.helpers.pr_helper.pr_helper_base import PrHelperBase +from git_autograder.pr import GitAutograderPr +from git_autograder.pr_comment import GitAutograderPrComment +from git_autograder.pr_review import GitAutograderPrReview + + +@dataclass(frozen=True) +class PrContext: + pr_number: int + pr_repo_full_name: str + + +class PrHelper(PrHelperBase): + def __init__(self, context: PrContext) -> None: + self._pr_number = context.pr_number + self._pr_repo_full_name = context.pr_repo_full_name + raw_data = self._fetch_pr_data() + self._pr = self._build_pr(raw_data) + + def _fetch_pr_data(self) -> Dict[str, Any]: + fields = [ + "number", + "title", + "body", + "state", + "author", + "baseRefName", + "headRefName", + "isDraft", + "mergedAt", + "mergedBy", + "latestReviews", + "comments", + ] + command = [ + "gh", + "pr", + "view", + str(self._pr_number), + "--repo", + self._pr_repo_full_name, + "--json", + ",".join(fields), + ] + + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + stderr = result.stderr.strip() or "Unknown gh error" + raise GitAutograderInvalidStateException( + f"Failed to load PR #{self._pr_number} from {self._pr_repo_full_name}: {stderr}" + ) + + try: + data = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise GitAutograderInvalidStateException( + "Failed to parse pull request metadata returned by GitHub CLI." + ) from error + + if not isinstance(data, dict): + raise GitAutograderInvalidStateException( + "Unexpected pull request metadata shape returned by GitHub CLI." + ) + return data + + def _build_pr(self, data: Dict[str, Any]) -> GitAutograderPr: + latest_reviews = data.get("latestReviews") or [] + comments = data.get("comments") or [] + + merged_at = data.get("mergedAt") + + return GitAutograderPr( + number=self._pr_number, + repo_full_name=self._pr_repo_full_name, + title=str(data.get("title") or ""), + body=str(data.get("body") or ""), + state=str(data.get("state") or ""), + author_login=(data.get("author") or {}).get("login"), + base_branch=str(data.get("baseRefName") or ""), + head_branch=str(data.get("headRefName") or ""), + is_draft=bool(data.get("isDraft", False)), + merged_at=merged_at if isinstance(merged_at, str) else None, + merged_by_login=(data.get("mergedBy") or {}).get("login"), + reviews=[ + GitAutograderPrReview( + author_login=review.get("author", {}).get("login") + if isinstance(review, dict) + else None, + state=review.get("state") if isinstance(review, dict) else None, + body=review.get("body") if isinstance(review, dict) else None, + ) + for review in latest_reviews + if isinstance(review, dict) + ], + comments=[ + GitAutograderPrComment( + author_login=comment.get("author", {}).get("login") + if isinstance(comment, dict) + else None, + body=comment.get("body") if isinstance(comment, dict) else None, + ) + for comment in comments + if isinstance(comment, dict) + ], + ) + + @property + def pr(self) -> GitAutograderPr: + return self._pr + \ No newline at end of file diff --git a/src/git_autograder/helpers/pr_helper/pr_helper_base.py b/src/git_autograder/helpers/pr_helper/pr_helper_base.py new file mode 100644 index 0000000..4005f99 --- /dev/null +++ b/src/git_autograder/helpers/pr_helper/pr_helper_base.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + +from git_autograder.pr import GitAutograderPr + + +class PrHelperBase(ABC): + @property + @abstractmethod + def pr(self) -> GitAutograderPr: ... From 3e00bfbffc0ab55f4714cc806882208176420f66 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Mon, 16 Mar 2026 19:16:09 +0800 Subject: [PATCH 04/11] Update repo to support PrHelper --- src/git_autograder/__init__.py | 6 ++++++ src/git_autograder/exercise.py | 2 +- src/git_autograder/exercise_config.py | 4 ++-- src/git_autograder/helpers/__init__.py | 5 +++-- src/git_autograder/repo/null_repo.py | 3 ++- src/git_autograder/repo/repo.py | 3 ++- src/git_autograder/repo/repo_base.py | 3 ++- 7 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index 41ec0b0..6bf3033 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -9,6 +9,9 @@ "GitAutograderBranch", "GitAutograderRemote", "GitAutograderCommit", + "GitAutograderPr", + "GitAutograderPrComment", + "GitAutograderPrReview", "GitAutograderExercise", ] @@ -21,6 +24,9 @@ ) from .exercise import GitAutograderExercise from .output import GitAutograderOutput +from .pr import GitAutograderPr +from .pr_comment import GitAutograderPrComment +from .pr_review import GitAutograderPrReview from .remote import GitAutograderRemote from .repo.repo import GitAutograderRepo from .repo.repo_base import GitAutograderRepoBase diff --git a/src/git_autograder/exercise.py b/src/git_autograder/exercise.py index c749a91..55c52fa 100644 --- a/src/git_autograder/exercise.py +++ b/src/git_autograder/exercise.py @@ -15,7 +15,7 @@ GitAutograderWrongAnswerException, ) from git_autograder.exercise_config import ExerciseConfig -from git_autograder.helpers.pr_helper import PrContext +from git_autograder.helpers.pr_helper.pr_helper import PrContext from git_autograder.output import GitAutograderOutput from git_autograder.repo.null_repo import NullGitAutograderRepo from git_autograder.repo.repo import GitAutograderRepo diff --git a/src/git_autograder/exercise_config.py b/src/git_autograder/exercise_config.py index 0b0779b..5dce8c5 100644 --- a/src/git_autograder/exercise_config.py +++ b/src/git_autograder/exercise_config.py @@ -52,8 +52,8 @@ def read_config(path: str | Path) -> "ExerciseConfig": repo_title=exercise_repo["repo_title"], create_fork=exercise_repo["create_fork"], init=exercise_repo["init"], - pr_number=exercise_repo["pr_number"], - pr_repo_full_name=exercise_repo["pr_repo_full_name"], + pr_number=exercise_repo["pr_number"] if "pr_number" in exercise_repo else None, + pr_repo_full_name=exercise_repo["pr_repo_full_name"] if "pr_repo_full_name" in exercise_repo else None, ), downloaded_at=raw_config["downloaded_at"], ) diff --git a/src/git_autograder/helpers/__init__.py b/src/git_autograder/helpers/__init__.py index 7561ae8..369eecc 100644 --- a/src/git_autograder/helpers/__init__.py +++ b/src/git_autograder/helpers/__init__.py @@ -1,7 +1,8 @@ -__all__ = ["BranchHelper", "CommitHelper", "RemoteHelper", "FileHelper", "PrHelper"] +__all__ = ["BranchHelper", "CommitHelper", "RemoteHelper", "FileHelper", "PrHelper", "NullPrHelper"] from .branch_helper import BranchHelper from .commit_helper import CommitHelper from .remote_helper import RemoteHelper from .file_helper import FileHelper -from .pr_helper import PrHelper +from .pr_helper.pr_helper import PrHelper +from .pr_helper.null_pr_helper import NullPrHelper diff --git a/src/git_autograder/repo/null_repo.py b/src/git_autograder/repo/null_repo.py index 76965a9..66292ef 100644 --- a/src/git_autograder/repo/null_repo.py +++ b/src/git_autograder/repo/null_repo.py @@ -3,7 +3,8 @@ from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper -from git_autograder.helpers.pr_helper import NullPrHelper, PrHelper +from git_autograder.helpers.pr_helper.null_pr_helper import NullPrHelper +from git_autograder.helpers.pr_helper.pr_helper import PrHelper from git_autograder.helpers.remote_helper import RemoteHelper from git_autograder.repo.repo_base import GitAutograderRepoBase diff --git a/src/git_autograder/repo/repo.py b/src/git_autograder/repo/repo.py index 9923760..8b74900 100644 --- a/src/git_autograder/repo/repo.py +++ b/src/git_autograder/repo/repo.py @@ -6,7 +6,8 @@ from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper -from git_autograder.helpers.pr_helper import NullPrHelper, PrContext, PrHelper +from git_autograder.helpers.pr_helper.null_pr_helper import NullPrHelper +from git_autograder.helpers.pr_helper.pr_helper import PrContext, PrHelper from git_autograder.helpers.remote_helper import RemoteHelper from git_autograder.repo.repo_base import GitAutograderRepoBase diff --git a/src/git_autograder/repo/repo_base.py b/src/git_autograder/repo/repo_base.py index ba62b31..f7d0437 100644 --- a/src/git_autograder/repo/repo_base.py +++ b/src/git_autograder/repo/repo_base.py @@ -5,7 +5,8 @@ from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper -from git_autograder.helpers.pr_helper import NullPrHelper, PrHelper +from git_autograder.helpers.pr_helper.null_pr_helper import NullPrHelper +from git_autograder.helpers.pr_helper.pr_helper import PrHelper from git_autograder.helpers.remote_helper import RemoteHelper From b83b8bf101cfc29afd5555280e6b69ad2c764f37 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Mon, 16 Mar 2026 19:16:34 +0800 Subject: [PATCH 05/11] Implement pr_comment, pr_review and pr --- src/git_autograder/pr.py | 114 +++++++++++++++++++++++++++++++ src/git_autograder/pr_comment.py | 38 +++++++++++ src/git_autograder/pr_review.py | 51 ++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 src/git_autograder/pr.py create mode 100644 src/git_autograder/pr_comment.py create mode 100644 src/git_autograder/pr_review.py diff --git a/src/git_autograder/pr.py b/src/git_autograder/pr.py new file mode 100644 index 0000000..403e9f8 --- /dev/null +++ b/src/git_autograder/pr.py @@ -0,0 +1,114 @@ +from typing import Any, List, Optional + +from .pr_comment import GitAutograderPrComment +from .pr_review import GitAutograderPrReview + + +class GitAutograderPr: + def __init__( + self, + number: int, + repo_full_name: str, + title: str, + body: str, + state: str, + author_login: Optional[str], + base_branch: str, + head_branch: str, + is_draft: bool, + merged_at: Optional[str], + merged_by_login: Optional[str], + reviews: List[GitAutograderPrReview], + comments: List[GitAutograderPrComment], + ) -> None: + self._number = number + self._repo_full_name = repo_full_name + self._title = title + self._body = body + self._state = state # e.g. OPEN, CLOSED, MERGED + self._author_login = author_login + self._base_branch = base_branch # Branch targeted by the PR, e.g. main + self._head_branch = head_branch # Branch where PR changes originate + self._is_draft = is_draft + self._merged_at = merged_at + self._merged_by_login = merged_by_login + self._reviews = reviews + self._comments = comments + + def __eq__(self, value: Any) -> bool: + if not isinstance(value, GitAutograderPr): + return False + return ( + value.number == self.number + and value.repo_full_name == self.repo_full_name + and value.state == self.state + ) + + @property + def number(self) -> int: + return self._number + + @property + def repo_full_name(self) -> str: + return self._repo_full_name + + @property + def title(self) -> str: + return self._title + + @property + def body(self) -> str: + return self._body + + @property + def state(self) -> str: + return self._state + + @property + def author_login(self) -> Optional[str]: + return self._author_login + + @property + def base_branch(self) -> str: + return self._base_branch + + @property + def head_branch(self) -> str: + return self._head_branch + + @property + def is_draft(self) -> bool: + return self._is_draft + + @property + def merged_at(self) -> Optional[str]: + return self._merged_at + + @property + def merged_by_login(self) -> Optional[str]: + return self._merged_by_login + + @property + def reviews(self) -> List[GitAutograderPrReview]: + return self._reviews + + @property + def comments(self) -> List[GitAutograderPrComment]: + return self._comments + + @property + def user_reviews(self) -> List[GitAutograderPrReview]: + return [review for review in self._reviews if review.is_from_user()] + + @property + def user_comments(self) -> List[GitAutograderPrComment]: + return [comment for comment in self._comments if comment.is_from_user()] + + def is_open(self) -> bool: + return self._state.upper() == "OPEN" + + def is_closed(self) -> bool: + return self._state.upper() == "CLOSED" + + def is_merged(self) -> bool: + return self._state.upper() == "MERGED" \ No newline at end of file diff --git a/src/git_autograder/pr_comment.py b/src/git_autograder/pr_comment.py new file mode 100644 index 0000000..0018c99 --- /dev/null +++ b/src/git_autograder/pr_comment.py @@ -0,0 +1,38 @@ +from typing import Optional + +from git_autograder.role_marker import RoleMarker + + +class GitAutograderPrComment: + def __init__( + self, + author_login: Optional[str], + body: Optional[str], + ) -> None: + self._author_login = author_login + self._body = body + + def __eq__(self, value: object) -> bool: + if not isinstance(value, GitAutograderPrComment): + return False + return ( + value.author_login == self.author_login + and value.body == self.body + ) + + @property + def author_login(self) -> Optional[str]: + return self._author_login + + @property + def body(self) -> Optional[str]: + return self._body + + def is_from_user(self) -> bool: + if self._body: + return not RoleMarker.has_role_marker(self._body) + # If there is no body, we can assume it's from a user. + return True + + def is_content_equal(self, body: str) -> bool: + return self._body.lower() == body.lower() if self._body else False diff --git a/src/git_autograder/pr_review.py b/src/git_autograder/pr_review.py new file mode 100644 index 0000000..b386f33 --- /dev/null +++ b/src/git_autograder/pr_review.py @@ -0,0 +1,51 @@ +from typing import Optional + +from git_autograder.role_marker import RoleMarker + + +class GitAutograderPrReview: + def __init__( + self, + author_login: Optional[str], + state: Optional[str], + body: Optional[str], + ) -> None: + self._author_login = author_login + self._state = state + self._body = body + + def __eq__(self, value: object) -> bool: + if not isinstance(value, GitAutograderPrReview): + return False + return ( + value.author_login == self.author_login + and value.state == self.state + and value.body == self.body + ) + + @property + def author_login(self) -> Optional[str]: + return self._author_login + + @property + def state(self) -> Optional[str]: + return self._state + + @property + def body(self) -> Optional[str]: + return self._body + + def is_from_user(self) -> bool: + if self._body: + return not RoleMarker.has_role_marker(self._body) + # If there is no body, we can assume it's from a user. + return True + + def is_change_request(self) -> bool: + return self._state.upper() == "REQUEST_CHANGES" if self._state else False + + def is_comment(self) -> bool: + return self._state.upper() == "COMMENT" if self._state else False + + def is_content_equal(self, body: str) -> bool: + return self._body.lower() == body.lower() if self._body else False From 283be2b7b8be17e408c33bd9c61a364174b7a6eb Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Mon, 16 Mar 2026 19:16:49 +0800 Subject: [PATCH 06/11] Add role_marker --- src/git_autograder/role_marker.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/git_autograder/role_marker.py diff --git a/src/git_autograder/role_marker.py b/src/git_autograder/role_marker.py new file mode 100644 index 0000000..639df3c --- /dev/null +++ b/src/git_autograder/role_marker.py @@ -0,0 +1,12 @@ +import re + + +class RoleMarker: + """Utility class for handling role markers in text.""" + + PATTERN = re.compile(r"^\[ROLE:([a-zA-Z0-9_-]+)\]\s*", re.IGNORECASE) + + @staticmethod + def has_role_marker(text: str) -> bool: + """Check if text contains a role marker.""" + return RoleMarker.PATTERN.match(text) is not None \ No newline at end of file From 7d866dcefc84b3cc3d8b2c8a2f849a0b79ca9b02 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Mon, 16 Mar 2026 19:25:20 +0800 Subject: [PATCH 07/11] Add timeout check when fetching pr --- .../helpers/pr_helper/pr_helper.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/git_autograder/helpers/pr_helper/pr_helper.py b/src/git_autograder/helpers/pr_helper/pr_helper.py index c7c28e0..f4a9b5f 100644 --- a/src/git_autograder/helpers/pr_helper/pr_helper.py +++ b/src/git_autograder/helpers/pr_helper/pr_helper.py @@ -49,13 +49,18 @@ def _fetch_pr_data(self) -> Dict[str, Any]: ",".join(fields), ] - result = subprocess.run( - command, - capture_output=True, - text=True, - check=False, - ) - + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + timeout=60, + ) + except subprocess.TimeoutExpired: + raise GitAutograderInvalidStateException( + f"Timed out fetching PR #{self._pr_number} from {self._pr_repo_full_name}" + ) if result.returncode != 0: stderr = result.stderr.strip() or "Unknown gh error" raise GitAutograderInvalidStateException( From 1e841670a87696003e87449786071cb26a429ac7 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Wed, 18 Mar 2026 11:55:45 +0800 Subject: [PATCH 08/11] Enable fetching pr during verification --- src/git_autograder/exercise.py | 29 +++++++++++++++++++--------- src/git_autograder/repo/null_repo.py | 8 +++++++- src/git_autograder/repo/repo.py | 7 ++++++- src/git_autograder/repo/repo_base.py | 6 +++++- 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/git_autograder/exercise.py b/src/git_autograder/exercise.py index 55c52fa..71074a3 100644 --- a/src/git_autograder/exercise.py +++ b/src/git_autograder/exercise.py @@ -70,18 +70,10 @@ def __init__( if self.config.exercise_repo.repo_type == "ignore" or self.config.exercise_repo.repo_type == "local-ignore": self.repo = NullGitAutograderRepo() else: - pr_number = self.config.exercise_repo.pr_number - pr_repo_full_name = self.config.exercise_repo.pr_repo_full_name - pr_context: Optional[PrContext] = None - if pr_number is not None and pr_repo_full_name is not None: - pr_context = PrContext( - pr_number=pr_number, - pr_repo_full_name=pr_repo_full_name, - ) self.repo = GitAutograderRepo( self.config.exercise_name, Path(exercise_path) / self.config.exercise_repo.repo_name, - pr_context, + self.get_pr_context(), ) except InvalidGitRepositoryError: raise GitAutograderInvalidStateException("Exercise is not a Git repository") @@ -156,3 +148,22 @@ def to_output( def wrong_answer(self, comments: List[str]) -> GitAutograderWrongAnswerException: return GitAutograderWrongAnswerException(comments) + + def get_pr_context(self) -> Optional[PrContext]: + pr_number = self.config.exercise_repo.pr_number + pr_repo_full_name = self.config.exercise_repo.pr_repo_full_name + pr_context: Optional[PrContext] = None + if pr_number is not None and pr_repo_full_name is not None: + pr_context = PrContext( + pr_number=pr_number, + pr_repo_full_name=pr_repo_full_name, + ) + return pr_context + + def fetch_pr(self) -> None: + pr_context = self.get_pr_context() + if pr_context is None: + raise GitAutograderInvalidStateException( + "Exercise does not have valid PR context" + ) + self.repo.refresh_pr_helper(pr_context) diff --git a/src/git_autograder/repo/null_repo.py b/src/git_autograder/repo/null_repo.py index 66292ef..df0b30a 100644 --- a/src/git_autograder/repo/null_repo.py +++ b/src/git_autograder/repo/null_repo.py @@ -1,10 +1,11 @@ from git import Repo +from typing import Optional from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper from git_autograder.helpers.pr_helper.null_pr_helper import NullPrHelper -from git_autograder.helpers.pr_helper.pr_helper import PrHelper +from git_autograder.helpers.pr_helper.pr_helper import PrContext, PrHelper from git_autograder.helpers.remote_helper import RemoteHelper from git_autograder.repo.repo_base import GitAutograderRepoBase @@ -46,6 +47,11 @@ def prs(self) -> PrHelper | NullPrHelper: "Cannot access attribute prs on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." ) + def refresh_pr_helper(self, pr_context: Optional[PrContext]) -> None: + raise AttributeError( + "Cannot refresh PR helper on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." + ) + def __getattr__(self, name: str) -> None: raise AttributeError( f"Cannot access attribute {name} on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." diff --git a/src/git_autograder/repo/repo.py b/src/git_autograder/repo/repo.py index 8b74900..9e3b277 100644 --- a/src/git_autograder/repo/repo.py +++ b/src/git_autograder/repo/repo.py @@ -29,7 +29,8 @@ def __init__( self._commits: CommitHelper = CommitHelper(self._repo) self._remotes: RemoteHelper = RemoteHelper(self._repo) self._files: FileHelper = FileHelper(self._repo) - self._prs: PrHelper | NullPrHelper = PrHelper(self.pr_context) if self.pr_context else NullPrHelper() + self._prs: PrHelper | NullPrHelper = NullPrHelper() + self.refresh_pr_helper(self.pr_context) @property def repo(self) -> Repo: @@ -54,3 +55,7 @@ def files(self) -> FileHelper: @property def prs(self) -> PrHelper | NullPrHelper: return self._prs + + def refresh_pr_helper(self, pr_context: Optional[PrContext]) -> None: + self.pr_context = pr_context + self._prs = PrHelper(pr_context) if pr_context else NullPrHelper() diff --git a/src/git_autograder/repo/repo_base.py b/src/git_autograder/repo/repo_base.py index f7d0437..9f32db5 100644 --- a/src/git_autograder/repo/repo_base.py +++ b/src/git_autograder/repo/repo_base.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod, abstractproperty +from typing import Optional from git import Repo @@ -6,7 +7,7 @@ from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper from git_autograder.helpers.pr_helper.null_pr_helper import NullPrHelper -from git_autograder.helpers.pr_helper.pr_helper import PrHelper +from git_autograder.helpers.pr_helper.pr_helper import PrContext, PrHelper from git_autograder.helpers.remote_helper import RemoteHelper @@ -34,3 +35,6 @@ def files(self) -> FileHelper: ... @property @abstractmethod def prs(self) -> PrHelper | NullPrHelper: ... + + @abstractmethod + def refresh_pr_helper(self, pr_context: Optional[PrContext]) -> None: ... From 41e178d7adc80bf515d50addf7a123a7a79954c2 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Thu, 19 Mar 2026 11:46:03 +0800 Subject: [PATCH 09/11] Update refresh_pr_helper --- src/git_autograder/exercise.py | 7 +------ src/git_autograder/repo/null_repo.py | 2 +- src/git_autograder/repo/repo.py | 27 +++++++++++++++++++++++---- src/git_autograder/repo/repo_base.py | 5 ++--- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/git_autograder/exercise.py b/src/git_autograder/exercise.py index 71074a3..c2609b1 100644 --- a/src/git_autograder/exercise.py +++ b/src/git_autograder/exercise.py @@ -161,9 +161,4 @@ def get_pr_context(self) -> Optional[PrContext]: return pr_context def fetch_pr(self) -> None: - pr_context = self.get_pr_context() - if pr_context is None: - raise GitAutograderInvalidStateException( - "Exercise does not have valid PR context" - ) - self.repo.refresh_pr_helper(pr_context) + self.repo.refresh_pr_helper() diff --git a/src/git_autograder/repo/null_repo.py b/src/git_autograder/repo/null_repo.py index df0b30a..65b6130 100644 --- a/src/git_autograder/repo/null_repo.py +++ b/src/git_autograder/repo/null_repo.py @@ -47,7 +47,7 @@ def prs(self) -> PrHelper | NullPrHelper: "Cannot access attribute prs on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." ) - def refresh_pr_helper(self, pr_context: Optional[PrContext]) -> None: + def refresh_pr_helper(self) -> None: raise AttributeError( "Cannot refresh PR helper on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." ) diff --git a/src/git_autograder/repo/repo.py b/src/git_autograder/repo/repo.py index 9e3b277..7f9b8fe 100644 --- a/src/git_autograder/repo/repo.py +++ b/src/git_autograder/repo/repo.py @@ -1,8 +1,10 @@ import os +from pathlib import Path from typing import Optional from git import Repo +from git_autograder.exercise_config import ExerciseConfig from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper @@ -30,7 +32,7 @@ def __init__( self._remotes: RemoteHelper = RemoteHelper(self._repo) self._files: FileHelper = FileHelper(self._repo) self._prs: PrHelper | NullPrHelper = NullPrHelper() - self.refresh_pr_helper(self.pr_context) + self.refresh_pr_helper() @property def repo(self) -> Repo: @@ -56,6 +58,23 @@ def files(self) -> FileHelper: def prs(self) -> PrHelper | NullPrHelper: return self._prs - def refresh_pr_helper(self, pr_context: Optional[PrContext]) -> None: - self.pr_context = pr_context - self._prs = PrHelper(pr_context) if pr_context else NullPrHelper() + def refresh_pr_helper(self) -> None: + self.pr_context = self._read_pr_context_from_config() + self._prs = ( + PrHelper(self.pr_context, self._repo) + if self.pr_context + else NullPrHelper() + ) + + def _read_pr_context_from_config(self) -> Optional[PrContext]: + config_path = Path(self.repo_path).parent / ".gitmastery-exercise.json" + if not config_path.is_file(): + return None + + config = ExerciseConfig.read_config(config_path) + pr_number = config.exercise_repo.pr_number + pr_repo_full_name = config.exercise_repo.pr_repo_full_name + if pr_number is None or pr_repo_full_name is None: + return None + + return PrContext(pr_number=pr_number, pr_repo_full_name=pr_repo_full_name) diff --git a/src/git_autograder/repo/repo_base.py b/src/git_autograder/repo/repo_base.py index 9f32db5..2c865d3 100644 --- a/src/git_autograder/repo/repo_base.py +++ b/src/git_autograder/repo/repo_base.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod, abstractproperty -from typing import Optional from git import Repo @@ -7,7 +6,7 @@ from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper from git_autograder.helpers.pr_helper.null_pr_helper import NullPrHelper -from git_autograder.helpers.pr_helper.pr_helper import PrContext, PrHelper +from git_autograder.helpers.pr_helper.pr_helper import PrHelper from git_autograder.helpers.remote_helper import RemoteHelper @@ -37,4 +36,4 @@ def files(self) -> FileHelper: ... def prs(self) -> PrHelper | NullPrHelper: ... @abstractmethod - def refresh_pr_helper(self, pr_context: Optional[PrContext]) -> None: ... + def refresh_pr_helper(self) -> None: ... From d52fcfc81820781284b46f335009f764ad8610e7 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Thu, 19 Mar 2026 11:46:23 +0800 Subject: [PATCH 10/11] Update GitAutograderCommit with Role Marker --- src/git_autograder/commit.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/git_autograder/commit.py b/src/git_autograder/commit.py index d67cf71..51df7f6 100644 --- a/src/git_autograder/commit.py +++ b/src/git_autograder/commit.py @@ -4,6 +4,8 @@ from git import Commit, Stats +from git_autograder.role_marker import RoleMarker + class GitAutograderCommit: def __init__(self, commit: Commit) -> None: @@ -66,3 +68,13 @@ def file(self, file_path: str) -> Iterator[Optional[str]]: except Exception: content = None yield content + + def is_from_user(self) -> bool: + message = self.commit.message + if isinstance(message, bytes): + message = message.decode("utf-8", errors="replace") + + if message: + return not RoleMarker.has_role_marker(message) + # If there is no message, we can assume it's from a user. + return True From 63075dddcbb36627a68d148b9ba10682f564c3f2 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Thu, 19 Mar 2026 11:48:45 +0800 Subject: [PATCH 11/11] Refactor pr and pr_helper --- .../helpers/pr_helper/pr_helper.py | 113 +----------------- src/git_autograder/pr.py | 98 ++++++++++++++- src/git_autograder/pr_builders.py | 83 +++++++++++++ src/git_autograder/pr_gateway.py | 109 +++++++++++++++++ 4 files changed, 292 insertions(+), 111 deletions(-) create mode 100644 src/git_autograder/pr_builders.py create mode 100644 src/git_autograder/pr_gateway.py diff --git a/src/git_autograder/helpers/pr_helper/pr_helper.py b/src/git_autograder/helpers/pr_helper/pr_helper.py index f4a9b5f..8f5d823 100644 --- a/src/git_autograder/helpers/pr_helper/pr_helper.py +++ b/src/git_autograder/helpers/pr_helper/pr_helper.py @@ -1,13 +1,7 @@ -import json -import subprocess from dataclasses import dataclass -from typing import Any, Dict - -from git_autograder.exception import GitAutograderInvalidStateException +from git import Repo from git_autograder.helpers.pr_helper.pr_helper_base import PrHelperBase from git_autograder.pr import GitAutograderPr -from git_autograder.pr_comment import GitAutograderPrComment -from git_autograder.pr_review import GitAutograderPrReview @dataclass(frozen=True) @@ -17,108 +11,13 @@ class PrContext: class PrHelper(PrHelperBase): - def __init__(self, context: PrContext) -> None: + def __init__(self, context: PrContext, repo: Repo) -> None: self._pr_number = context.pr_number self._pr_repo_full_name = context.pr_repo_full_name - raw_data = self._fetch_pr_data() - self._pr = self._build_pr(raw_data) - - def _fetch_pr_data(self) -> Dict[str, Any]: - fields = [ - "number", - "title", - "body", - "state", - "author", - "baseRefName", - "headRefName", - "isDraft", - "mergedAt", - "mergedBy", - "latestReviews", - "comments", - ] - command = [ - "gh", - "pr", - "view", - str(self._pr_number), - "--repo", - self._pr_repo_full_name, - "--json", - ",".join(fields), - ] - - try: - result = subprocess.run( - command, - capture_output=True, - text=True, - check=False, - timeout=60, - ) - except subprocess.TimeoutExpired: - raise GitAutograderInvalidStateException( - f"Timed out fetching PR #{self._pr_number} from {self._pr_repo_full_name}" - ) - if result.returncode != 0: - stderr = result.stderr.strip() or "Unknown gh error" - raise GitAutograderInvalidStateException( - f"Failed to load PR #{self._pr_number} from {self._pr_repo_full_name}: {stderr}" - ) - - try: - data = json.loads(result.stdout) - except json.JSONDecodeError as error: - raise GitAutograderInvalidStateException( - "Failed to parse pull request metadata returned by GitHub CLI." - ) from error - - if not isinstance(data, dict): - raise GitAutograderInvalidStateException( - "Unexpected pull request metadata shape returned by GitHub CLI." - ) - return data - - def _build_pr(self, data: Dict[str, Any]) -> GitAutograderPr: - latest_reviews = data.get("latestReviews") or [] - comments = data.get("comments") or [] - - merged_at = data.get("mergedAt") - - return GitAutograderPr( - number=self._pr_number, - repo_full_name=self._pr_repo_full_name, - title=str(data.get("title") or ""), - body=str(data.get("body") or ""), - state=str(data.get("state") or ""), - author_login=(data.get("author") or {}).get("login"), - base_branch=str(data.get("baseRefName") or ""), - head_branch=str(data.get("headRefName") or ""), - is_draft=bool(data.get("isDraft", False)), - merged_at=merged_at if isinstance(merged_at, str) else None, - merged_by_login=(data.get("mergedBy") or {}).get("login"), - reviews=[ - GitAutograderPrReview( - author_login=review.get("author", {}).get("login") - if isinstance(review, dict) - else None, - state=review.get("state") if isinstance(review, dict) else None, - body=review.get("body") if isinstance(review, dict) else None, - ) - for review in latest_reviews - if isinstance(review, dict) - ], - comments=[ - GitAutograderPrComment( - author_login=comment.get("author", {}).get("login") - if isinstance(comment, dict) - else None, - body=comment.get("body") if isinstance(comment, dict) else None, - ) - for comment in comments - if isinstance(comment, dict) - ], + self._pr = GitAutograderPr.fetch( + pr_number=self._pr_number, + pr_repo_full_name=self._pr_repo_full_name, + repo=repo, ) @property diff --git a/src/git_autograder/pr.py b/src/git_autograder/pr.py index 403e9f8..98d35c3 100644 --- a/src/git_autograder/pr.py +++ b/src/git_autograder/pr.py @@ -1,10 +1,63 @@ -from typing import Any, List, Optional - +from typing import Any, Dict, List, Optional + +from git import Repo +from git_autograder.commit import GitAutograderCommit +from git_autograder.exception import GitAutograderInvalidStateException +from git_autograder.pr_builders import ( + build_commits, + build_comments, + build_reviews, +) +from .pr_gateway import fetch_pull_request_data from .pr_comment import GitAutograderPrComment from .pr_review import GitAutograderPrReview class GitAutograderPr: + @classmethod + def fetch( + cls, + pr_number: int, + pr_repo_full_name: str, + repo: Repo, + ) -> "GitAutograderPr": + data = fetch_pull_request_data(pr_number, pr_repo_full_name) + return cls._build_from_data(pr_number, pr_repo_full_name, data, repo) + + @classmethod + def _build_from_data( + cls, + pr_number: int, + pr_repo_full_name: str, + data: Dict[str, Any], + repo: Repo, + ) -> "GitAutograderPr": + latest_reviews_data = (data.get("latestReviews") or {}).get("nodes") or [] + comments_data = (data.get("comments") or {}).get("nodes") or [] + commits = (data.get("commits") or {}).get("nodes") or [] + merged_at = data.get("mergedAt") + created_at = data.get("createdAt") + + built_commits = build_commits(commits, repo) + + return cls( + number=pr_number, + repo_full_name=pr_repo_full_name, + title=str(data.get("title") or ""), + body=str(data.get("body") or ""), + state=str(data.get("state") or ""), + author_login=(data.get("author") or {}).get("login"), + base_branch=str(data.get("baseRefName") or ""), + head_branch=str(data.get("headRefName") or ""), + is_draft=bool(data.get("isDraft", False)), + merged_at=merged_at if isinstance(merged_at, str) else None, + merged_by_login=(data.get("mergedBy") or {}).get("login"), + created_at=created_at if isinstance(created_at, str) else None, + commits=built_commits, + reviews=build_reviews(latest_reviews_data), + comments=build_comments(comments_data), + ) + def __init__( self, number: int, @@ -18,6 +71,8 @@ def __init__( is_draft: bool, merged_at: Optional[str], merged_by_login: Optional[str], + created_at: Optional[str], + commits: List[GitAutograderCommit], reviews: List[GitAutograderPrReview], comments: List[GitAutograderPrComment], ) -> None: @@ -32,8 +87,13 @@ def __init__( self._is_draft = is_draft self._merged_at = merged_at self._merged_by_login = merged_by_login + self._created_at = created_at + self._commits = commits self._reviews = reviews self._comments = comments + self._user_reviews = [review for review in reviews if review.is_from_user()] + self._user_comments = [comment for comment in comments if comment.is_from_user()] + self._user_commits = [commit for commit in commits if commit.is_from_user()] def __eq__(self, value: Any) -> bool: if not isinstance(value, GitAutograderPr): @@ -88,6 +148,10 @@ def merged_at(self) -> Optional[str]: def merged_by_login(self) -> Optional[str]: return self._merged_by_login + @property + def created_at(self) -> Optional[str]: + return self._created_at + @property def reviews(self) -> List[GitAutograderPrReview]: return self._reviews @@ -96,14 +160,40 @@ def reviews(self) -> List[GitAutograderPrReview]: def comments(self) -> List[GitAutograderPrComment]: return self._comments + @property + def commits(self) -> List[GitAutograderCommit]: + return self._commits + @property def user_reviews(self) -> List[GitAutograderPrReview]: - return [review for review in self._reviews if review.is_from_user()] + return self._user_reviews @property def user_comments(self) -> List[GitAutograderPrComment]: - return [comment for comment in self._comments if comment.is_from_user()] + return self._user_comments + @property + def user_commits(self) -> List[GitAutograderCommit]: + return self._user_commits + + @property + def last_user_review(self) -> GitAutograderPrReview: + if not self._user_reviews: + raise GitAutograderInvalidStateException("No user reviews found for this PR.") + return self._user_reviews[-1] + + @property + def last_user_comment(self) -> GitAutograderPrComment: + if not self._user_comments: + raise GitAutograderInvalidStateException("No user comments found for this PR.") + return self._user_comments[-1] + + @property + def last_user_commit(self) -> GitAutograderCommit: + if not self._user_commits: + raise GitAutograderInvalidStateException("No user commits found for this PR.") + return self._user_commits[-1] + def is_open(self) -> bool: return self._state.upper() == "OPEN" diff --git a/src/git_autograder/pr_builders.py b/src/git_autograder/pr_builders.py new file mode 100644 index 0000000..9df743d --- /dev/null +++ b/src/git_autograder/pr_builders.py @@ -0,0 +1,83 @@ +from datetime import datetime, timezone +from typing import Any, List + +from git import Repo +from git_autograder.commit import GitAutograderCommit +from git_autograder.pr_comment import GitAutograderPrComment +from git_autograder.pr_review import GitAutograderPrReview + + +def extract_commit_shas(commits: List[Any]) -> List[str]: + commit_shas: List[str] = [] + for commit in commits: + if not isinstance(commit, dict): + continue + + sha = commit.get("oid") + if not isinstance(sha, str): + commit_node = commit.get("commit") + if isinstance(commit_node, dict): + nested_sha = commit_node.get("oid") + if isinstance(nested_sha, str): + sha = nested_sha + + if isinstance(sha, str): + commit_shas.append(sha) + + return commit_shas + + +def build_commits(commits: List[Any], repo: Repo) -> List[GitAutograderCommit]: + commit_shas = extract_commit_shas(commits) + git_commits = [repo.commit(sha) for sha in commit_shas] + git_commits.sort(key=lambda commit: commit.committed_datetime) + return [GitAutograderCommit(commit) for commit in git_commits] + + +def build_reviews(latest_reviews: List[Any]) -> List[GitAutograderPrReview]: + sorted_reviews = sorted( + [review for review in latest_reviews if isinstance(review, dict)], + key=lambda review: _parse_iso_or_min( + review.get("submittedAt") or review.get("createdAt") + ), + ) + + return [ + GitAutograderPrReview( + author_login=review.get("author", {}).get("login") + if isinstance(review, dict) + else None, + state=review.get("state") if isinstance(review, dict) else None, + body=review.get("body") if isinstance(review, dict) else None, + ) + for review in sorted_reviews + ] + + +def build_comments(comments: List[Any]) -> List[GitAutograderPrComment]: + sorted_comments = sorted( + [comment for comment in comments if isinstance(comment, dict)], + key=lambda comment: _parse_iso_or_min(comment.get("createdAt")), + ) + + return [ + GitAutograderPrComment( + comment.get("author", {}).get("login") if isinstance(comment, dict) else None, + comment.get("body") if isinstance(comment, dict) else None, + ) + for comment in sorted_comments + ] + + +def _parse_iso_or_min(value: Any) -> datetime: + if not isinstance(value, str): + return datetime.min.replace(tzinfo=timezone.utc) + + try: + iso_value = value.replace("Z", "+00:00") + dt = datetime.fromisoformat(iso_value) + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt + except ValueError: + return datetime.min.replace(tzinfo=timezone.utc) diff --git a/src/git_autograder/pr_gateway.py b/src/git_autograder/pr_gateway.py new file mode 100644 index 0000000..0a2edb4 --- /dev/null +++ b/src/git_autograder/pr_gateway.py @@ -0,0 +1,109 @@ +import json +import subprocess +from typing import Any, Dict + +from git_autograder.exception import GitAutograderInvalidStateException + + +PR_GRAPHQL_QUERY = """ +query ($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + number + title + body + createdAt + state + author { login } + baseRefName + headRefName + isDraft + mergedAt + mergedBy { login } + commits(first: 50) { + nodes { + commit { oid } + } + } + latestReviews(first: 50) { + nodes { + author { login } + state + body + submittedAt + createdAt + } + } + comments(first: 50) { + nodes { + author { login } + body + createdAt + } + } + } + } +} +""" + + +def fetch_pull_request_data(pr_number: int, pr_repo_full_name: str) -> Dict[str, Any]: + repo_parts = pr_repo_full_name.split("/", 1) + if len(repo_parts) != 2: + raise GitAutograderInvalidStateException( + f"Invalid repository full name: {pr_repo_full_name}" + ) + + owner, name = repo_parts + command = [ + "gh", + "api", + "graphql", + "-f", + f"query={PR_GRAPHQL_QUERY}", + "-F", + f"owner={owner}", + "-F", + f"name={name}", + "-F", + f"number={pr_number}", + ] + + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + timeout=60, + ) + except subprocess.TimeoutExpired: + raise GitAutograderInvalidStateException( + f"Timed out fetching PR #{pr_number} from {pr_repo_full_name}" + ) + + if result.returncode != 0: + stderr = result.stderr.strip() or "Unknown gh error" + raise GitAutograderInvalidStateException( + f"Failed to load PR #{pr_number} from {pr_repo_full_name}: {stderr}" + ) + + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise GitAutograderInvalidStateException( + "Failed to parse pull request metadata returned by GitHub CLI." + ) from error + + if not isinstance(payload, dict): + raise GitAutograderInvalidStateException( + "Unexpected pull request metadata shape returned by GitHub CLI." + ) + + data = payload.get("data", {}).get("repository", {}).get("pullRequest") + if not isinstance(data, dict): + raise GitAutograderInvalidStateException( + "Unexpected pull request metadata shape returned by GitHub CLI." + ) + + return data