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/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 diff --git a/src/git_autograder/exercise.py b/src/git_autograder/exercise.py index 58a3d4c..c2609b1 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.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 @@ -72,6 +73,7 @@ def __init__( self.repo = GitAutograderRepo( self.config.exercise_name, Path(exercise_path) / self.config.exercise_repo.repo_name, + self.get_pr_context(), ) except InvalidGitRepositoryError: raise GitAutograderInvalidStateException("Exercise is not a Git repository") @@ -146,3 +148,17 @@ 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: + self.repo.refresh_pr_helper() diff --git a/src/git_autograder/exercise_config.py b/src/git_autograder/exercise_config.py index 1ac7272..5dce8c5 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"] 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 938eb72..369eecc 100644 --- a/src/git_autograder/helpers/__init__.py +++ b/src/git_autograder/helpers/__init__.py @@ -1,6 +1,8 @@ -__all__ = ["BranchHelper", "CommitHelper", "RemoteHelper", "FileHelper"] +__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.pr_helper import PrHelper +from .pr_helper.null_pr_helper import NullPrHelper 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..8f5d823 --- /dev/null +++ b/src/git_autograder/helpers/pr_helper/pr_helper.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from git import Repo +from git_autograder.helpers.pr_helper.pr_helper_base import PrHelperBase +from git_autograder.pr import GitAutograderPr + + +@dataclass(frozen=True) +class PrContext: + pr_number: int + pr_repo_full_name: str + + +class PrHelper(PrHelperBase): + def __init__(self, context: PrContext, repo: Repo) -> None: + self._pr_number = context.pr_number + self._pr_repo_full_name = context.pr_repo_full_name + self._pr = GitAutograderPr.fetch( + pr_number=self._pr_number, + pr_repo_full_name=self._pr_repo_full_name, + repo=repo, + ) + + @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: ... diff --git a/src/git_autograder/pr.py b/src/git_autograder/pr.py new file mode 100644 index 0000000..98d35c3 --- /dev/null +++ b/src/git_autograder/pr.py @@ -0,0 +1,204 @@ +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, + 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], + created_at: Optional[str], + commits: List[GitAutograderCommit], + 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._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): + 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 created_at(self) -> Optional[str]: + return self._created_at + + @property + def reviews(self) -> List[GitAutograderPrReview]: + return self._reviews + + @property + 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 self._user_reviews + + @property + def user_comments(self) -> List[GitAutograderPrComment]: + 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" + + 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_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_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_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 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 diff --git a/src/git_autograder/repo/null_repo.py b/src/git_autograder/repo/null_repo.py index 553e529..65b6130 100644 --- a/src/git_autograder/repo/null_repo.py +++ b/src/git_autograder/repo/null_repo.py @@ -1,8 +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 PrContext, PrHelper from git_autograder.helpers.remote_helper import RemoteHelper from git_autograder.repo.repo_base import GitAutograderRepoBase @@ -37,6 +40,17 @@ 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 | NullPrHelper: + raise AttributeError( + "Cannot access attribute prs on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." + ) + + def refresh_pr_helper(self) -> 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( diff --git a/src/git_autograder/repo/repo.py b/src/git_autograder/repo/repo.py index 9dbd36d..7f9b8fe 100644 --- a/src/git_autograder/repo/repo.py +++ b/src/git_autograder/repo/repo.py @@ -1,10 +1,15 @@ 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 +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 @@ -14,9 +19,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 +31,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 = NullPrHelper() + self.refresh_pr_helper() @property def repo(self) -> Repo: @@ -44,3 +53,28 @@ def remotes(self) -> RemoteHelper: @property def files(self) -> FileHelper: return self._files + + @property + def prs(self) -> PrHelper | NullPrHelper: + return self._prs + + 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 b0f2ef3..2c865d3 100644 --- a/src/git_autograder/repo/repo_base.py +++ b/src/git_autograder/repo/repo_base.py @@ -5,6 +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.null_pr_helper import NullPrHelper +from git_autograder.helpers.pr_helper.pr_helper import PrHelper from git_autograder.helpers.remote_helper import RemoteHelper @@ -28,3 +30,10 @@ def remotes(self) -> RemoteHelper: ... @property @abstractmethod def files(self) -> FileHelper: ... + + @property + @abstractmethod + def prs(self) -> PrHelper | NullPrHelper: ... + + @abstractmethod + def refresh_pr_helper(self) -> None: ... 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