From ac15ba6b3b6ed6f8d4e559ed4ec7be5906891fbb Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Mon, 23 Mar 2026 10:48:32 +0800 Subject: [PATCH 1/8] Implement download --- create_pr_from_main/.gitmastery-exercise.json | 14 ++++++++++++++ create_pr_from_main/README.md | 1 + create_pr_from_main/__init__.py | 0 create_pr_from_main/download.py | 8 ++++++++ 4 files changed, 23 insertions(+) create mode 100644 create_pr_from_main/.gitmastery-exercise.json create mode 100644 create_pr_from_main/README.md create mode 100644 create_pr_from_main/__init__.py create mode 100644 create_pr_from_main/download.py diff --git a/create_pr_from_main/.gitmastery-exercise.json b/create_pr_from_main/.gitmastery-exercise.json new file mode 100644 index 00000000..214cf4ac --- /dev/null +++ b/create_pr_from_main/.gitmastery-exercise.json @@ -0,0 +1,14 @@ +{ + "exercise_name": "create-pr-from-main", + "tags": [], + "requires_git": true, + "requires_github": true, + "base_files": {}, + "exercise_repo": { + "repo_type": "remote", + "repo_name": "languages", + "repo_title": "gm-languages", + "create_fork": true, + "init": null + } +} \ No newline at end of file diff --git a/create_pr_from_main/README.md b/create_pr_from_main/README.md new file mode 100644 index 00000000..3e6955ee --- /dev/null +++ b/create_pr_from_main/README.md @@ -0,0 +1 @@ +See https://git-mastery.github.io/lessons/prsCreate/exercise-create-pr-from-main.html diff --git a/create_pr_from_main/__init__.py b/create_pr_from_main/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/create_pr_from_main/download.py b/create_pr_from_main/download.py new file mode 100644 index 00000000..d559aa84 --- /dev/null +++ b/create_pr_from_main/download.py @@ -0,0 +1,8 @@ +from pathlib import Path + +from exercise_utils.exercise_config import add_pr_config +from exercise_utils.gitmastery import create_start_tag + +def setup(verbose: bool = False): + create_start_tag(verbose) + add_pr_config(pr_repo_full_name="git-mastery/gm-languages", config_path=Path("../")) \ No newline at end of file From ac05f49d144b5831e8a8f893035537aafaef0e0f Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Mon, 23 Mar 2026 10:48:42 +0800 Subject: [PATCH 2/8] Implement verify --- create_pr_from_main/verify.py | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 create_pr_from_main/verify.py diff --git a/create_pr_from_main/verify.py b/create_pr_from_main/verify.py new file mode 100644 index 00000000..4d2f79a5 --- /dev/null +++ b/create_pr_from_main/verify.py @@ -0,0 +1,53 @@ +from pathlib import Path + +from git_autograder import ( + GitAutograderOutput, + GitAutograderExercise, + GitAutograderStatus, +) + +from exercise_utils.exercise_config import add_pr_config +from exercise_utils.github_cli import get_github_username, get_pr_numbers_by_author + + +JAVA_FILE_MISSING = "Java.txt file is missing in the latest commit on main branch." +JAVA_INVALID_CONTENT = "The content in Java.txt in main branch is not correct." +MUTIPLE_PRS = "Multiple PRs found. The lastest pr will be used in grading." +PR_MISSING = "No PR is found." +WRONG_HEAD_BRANCH = "The PR's head branch is not 'main'." + + +EXPECTED_CONTENT_STEP_3 = ["1955, by James Gosling"] + + +def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: + username = get_github_username(False) + target_repo = f"git-mastery/{exercise.config.exercise_repo.repo_title}" + comments = [] + + pr_numbers = get_pr_numbers_by_author(username, target_repo, False) + if not pr_numbers: + raise exercise.wrong_answer([PR_MISSING]) + if len(pr_numbers) > 1: + comments.append(MUTIPLE_PRS) + pr_number = pr_numbers[-1] + + add_pr_config(pr_number=pr_number, config_path=Path("./")) + exercise.fetch_pr() + + if exercise.repo.prs.pr.head_branch != "main": + comments.append(WRONG_HEAD_BRANCH) + raise exercise.wrong_answer(comments) + + latest_user_commit = exercise.repo.prs.pr.last_user_commit + with latest_user_commit.file("Java.txt") as content: + if content is None: + comments.append(JAVA_FILE_MISSING) + raise exercise.wrong_answer(comments) + extracted_content = [line.strip() for line in content.splitlines() if line.strip() != ""] + if extracted_content != EXPECTED_CONTENT_STEP_3: + comments.append(JAVA_INVALID_CONTENT) + raise exercise.wrong_answer(comments) + + comments.append("Good job creating the PR and pushing commits!") + return exercise.to_output(comments, GitAutograderStatus.SUCCESSFUL) From f07693d7067d1a2f28dea91dcf6ab3b8b50c940f Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Mon, 23 Mar 2026 10:48:52 +0800 Subject: [PATCH 3/8] Implement test-verify --- create_pr_from_main/test_verify.py | 166 +++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 create_pr_from_main/test_verify.py diff --git a/create_pr_from_main/test_verify.py b/create_pr_from_main/test_verify.py new file mode 100644 index 00000000..a1f86e06 --- /dev/null +++ b/create_pr_from_main/test_verify.py @@ -0,0 +1,166 @@ +import json +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import PropertyMock, patch + +import pytest +from exercise_utils.test import assert_output +from git.repo import Repo +from git_autograder import ( + GitAutograderExercise, + GitAutograderStatus, + GitAutograderWrongAnswerException, +) +from git_autograder.pr import GitAutograderPr + +from .verify import ( + EXPECTED_CONTENT_STEP_3, + JAVA_FILE_MISSING, + JAVA_INVALID_CONTENT, + PR_MISSING, + WRONG_HEAD_BRANCH, + verify, +) + + +@pytest.fixture +def exercise(tmp_path: Path) -> GitAutograderExercise: + repo_dir = tmp_path / "ignore-me" + repo_dir.mkdir() + Repo.init(repo_dir) + + with open(tmp_path / ".gitmastery-exercise.json", "a") as config_file: + config_file.write( + json.dumps( + { + "exercise_name": "create_pr_from_main", + "tags": [], + "requires_git": True, + "requires_github": True, + "base_files": {}, + "exercise_repo": { + "repo_type": "local", + "repo_name": "ignore-me", + "init": True, + "create_fork": None, + "repo_title": "gm-shapes", + "pr_number": 1, + "pr_repo_full_name": "dummy/repo", + }, + "downloaded_at": None, + } + ) + ) + + with patch( + "git_autograder.pr.fetch_pull_request_data", + return_value={ + "title": "", + "body": "", + "state": "OPEN", + "author": {"login": "dummy"}, + "baseRefName": "main", + "headRefName": "main", + "isDraft": False, + "mergedAt": None, + "mergedBy": None, + "createdAt": None, + "latestReviews": {"nodes": []}, + "comments": {"nodes": []}, + "commits": {"nodes": []}, + }, + ): + return GitAutograderExercise(exercise_path=tmp_path) + + +class FakeCommit: + def __init__(self, java_content: str | None) -> None: + self._java_content = java_content + + @contextmanager + def file(self, file_path: str): + yield self._java_content + + +def _run_verify( + exercise: GitAutograderExercise, + pr_numbers: list[int] = [], + head_branch: str = "", + java_content: str | None = None, +): + fake_commit = FakeCommit(java_content) + with ( + patch("create_pr_from_main.verify.get_github_username", return_value="dummy"), + patch( + "create_pr_from_main.verify.get_pr_numbers_by_author", + return_value=pr_numbers, + ), + patch("create_pr_from_main.verify.add_pr_config"), + patch.object(exercise, "fetch_pr", return_value=None), + patch.object( + GitAutograderPr, + "head_branch", + new_callable=PropertyMock, + return_value=head_branch, + ), + patch.object( + GitAutograderPr, + "last_user_commit", + new_callable=PropertyMock, + return_value=fake_commit, + ), + ): + return verify(exercise) + + +def test_success(exercise: GitAutograderExercise): + output = _run_verify( + exercise, + pr_numbers=[123], + head_branch="main", + java_content="\n".join(EXPECTED_CONTENT_STEP_3), + ) + + assert_output(output, GitAutograderStatus.SUCCESSFUL) + + +def test_pr_missing(exercise: GitAutograderExercise): + with pytest.raises(GitAutograderWrongAnswerException) as exception: + _run_verify(exercise) + + assert exception.value.message == [PR_MISSING] + + +def test_wrong_head_branch(exercise: GitAutograderExercise): + with pytest.raises(GitAutograderWrongAnswerException) as exception: + _run_verify( + exercise, + pr_numbers=[1], + head_branch="feature/pr-branch" + ) + + assert exception.value.message == [WRONG_HEAD_BRANCH] + + +def test_java_file_missing(exercise: GitAutograderExercise): + with pytest.raises(GitAutograderWrongAnswerException) as exception: + _run_verify( + exercise, + pr_numbers=[1], + head_branch="main", + java_content=None, + ) + + assert exception.value.message == [JAVA_FILE_MISSING] + + +def test_java_content_invalid(exercise: GitAutograderExercise): + with pytest.raises(GitAutograderWrongAnswerException) as exception: + _run_verify( + exercise, + pr_numbers=[1], + head_branch="main", + java_content="wrong content\n", + ) + + assert exception.value.message == [JAVA_INVALID_CONTENT] From 9d4c8409a7b3f26a0d1622eb4fdd7a6621f08b95 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Thu, 26 Mar 2026 08:10:20 +0800 Subject: [PATCH 4/8] Remove unnecessary fields in test_verify --- create_pr_from_main/test_verify.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/create_pr_from_main/test_verify.py b/create_pr_from_main/test_verify.py index a1f86e06..77975b19 100644 --- a/create_pr_from_main/test_verify.py +++ b/create_pr_from_main/test_verify.py @@ -54,21 +54,7 @@ def exercise(tmp_path: Path) -> GitAutograderExercise: with patch( "git_autograder.pr.fetch_pull_request_data", - return_value={ - "title": "", - "body": "", - "state": "OPEN", - "author": {"login": "dummy"}, - "baseRefName": "main", - "headRefName": "main", - "isDraft": False, - "mergedAt": None, - "mergedBy": None, - "createdAt": None, - "latestReviews": {"nodes": []}, - "comments": {"nodes": []}, - "commits": {"nodes": []}, - }, + return_value={}, ): return GitAutograderExercise(exercise_path=tmp_path) From 8c53ce12c61f1cf0711aa3aca603738f2b111dca Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Mon, 6 Apr 2026 23:46:43 +0800 Subject: [PATCH 5/8] Update test-verify --- create_pr_from_main/test_verify.py | 113 ++++++++++------------------- 1 file changed, 40 insertions(+), 73 deletions(-) diff --git a/create_pr_from_main/test_verify.py b/create_pr_from_main/test_verify.py index 77975b19..ff0c478f 100644 --- a/create_pr_from_main/test_verify.py +++ b/create_pr_from_main/test_verify.py @@ -1,13 +1,9 @@ -import json from contextlib import contextmanager -from pathlib import Path from unittest.mock import PropertyMock, patch import pytest -from exercise_utils.test import assert_output -from git.repo import Repo +from exercise_utils.test import GitAutograderTestLoader, assert_output from git_autograder import ( - GitAutograderExercise, GitAutograderStatus, GitAutograderWrongAnswerException, ) @@ -22,41 +18,12 @@ verify, ) +REPOSITORY_NAME = "create-pr-from-main" -@pytest.fixture -def exercise(tmp_path: Path) -> GitAutograderExercise: - repo_dir = tmp_path / "ignore-me" - repo_dir.mkdir() - Repo.init(repo_dir) - - with open(tmp_path / ".gitmastery-exercise.json", "a") as config_file: - config_file.write( - json.dumps( - { - "exercise_name": "create_pr_from_main", - "tags": [], - "requires_git": True, - "requires_github": True, - "base_files": {}, - "exercise_repo": { - "repo_type": "local", - "repo_name": "ignore-me", - "init": True, - "create_fork": None, - "repo_title": "gm-shapes", - "pr_number": 1, - "pr_repo_full_name": "dummy/repo", - }, - "downloaded_at": None, - } - ) - ) +loader = GitAutograderTestLoader(REPOSITORY_NAME, verify) - with patch( - "git_autograder.pr.fetch_pull_request_data", - return_value={}, - ): - return GitAutograderExercise(exercise_path=tmp_path) +# NOTE: This exercise is a special case where we do not require repo-smith. Instead, +# we directly mock function calls to verify that all branches are covered for us. class FakeCommit: @@ -69,39 +36,42 @@ def file(self, file_path: str): def _run_verify( - exercise: GitAutograderExercise, pr_numbers: list[int] = [], head_branch: str = "", java_content: str | None = None, ): fake_commit = FakeCommit(java_content) - with ( - patch("create_pr_from_main.verify.get_github_username", return_value="dummy"), - patch( - "create_pr_from_main.verify.get_pr_numbers_by_author", - return_value=pr_numbers, - ), - patch("create_pr_from_main.verify.add_pr_config"), - patch.object(exercise, "fetch_pr", return_value=None), - patch.object( - GitAutograderPr, - "head_branch", - new_callable=PropertyMock, - return_value=head_branch, - ), - patch.object( - GitAutograderPr, - "last_user_commit", - new_callable=PropertyMock, - return_value=fake_commit, - ), - ): - return verify(exercise) - - -def test_success(exercise: GitAutograderExercise): + with loader.start_mock_exercise( + has_pr_context=True, + pr_number=1, + pr_repo_full_name="dummy/repo" + ) as exercise: + with ( + patch("create_pr_from_main.verify.get_github_username", return_value="dummy"), + patch( + "create_pr_from_main.verify.get_pr_numbers_by_author", + return_value=pr_numbers, + ), + patch("create_pr_from_main.verify.add_pr_config"), + patch.object(exercise, "fetch_pr", return_value=None), + patch.object( + GitAutograderPr, + "head_branch", + new_callable=PropertyMock, + return_value=head_branch, + ), + patch.object( + GitAutograderPr, + "last_user_commit", + new_callable=PropertyMock, + return_value=fake_commit, + ), + ): + return verify(exercise) + + +def test_success(): output = _run_verify( - exercise, pr_numbers=[123], head_branch="main", java_content="\n".join(EXPECTED_CONTENT_STEP_3), @@ -110,17 +80,16 @@ def test_success(exercise: GitAutograderExercise): assert_output(output, GitAutograderStatus.SUCCESSFUL) -def test_pr_missing(exercise: GitAutograderExercise): +def test_pr_missing(): with pytest.raises(GitAutograderWrongAnswerException) as exception: - _run_verify(exercise) + _run_verify() assert exception.value.message == [PR_MISSING] -def test_wrong_head_branch(exercise: GitAutograderExercise): +def test_wrong_head_branch(): with pytest.raises(GitAutograderWrongAnswerException) as exception: _run_verify( - exercise, pr_numbers=[1], head_branch="feature/pr-branch" ) @@ -128,10 +97,9 @@ def test_wrong_head_branch(exercise: GitAutograderExercise): assert exception.value.message == [WRONG_HEAD_BRANCH] -def test_java_file_missing(exercise: GitAutograderExercise): +def test_java_file_missing(): with pytest.raises(GitAutograderWrongAnswerException) as exception: _run_verify( - exercise, pr_numbers=[1], head_branch="main", java_content=None, @@ -140,10 +108,9 @@ def test_java_file_missing(exercise: GitAutograderExercise): assert exception.value.message == [JAVA_FILE_MISSING] -def test_java_content_invalid(exercise: GitAutograderExercise): +def test_java_content_invalid(): with pytest.raises(GitAutograderWrongAnswerException) as exception: _run_verify( - exercise, pr_numbers=[1], head_branch="main", java_content="wrong content\n", From 3f69dbdb7de7c904c6cc3dc878f4a6cec7f1b593 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Tue, 7 Apr 2026 11:13:25 +0800 Subject: [PATCH 6/8] Update test-verify --- create_pr_from_main/test_verify.py | 1 - 1 file changed, 1 deletion(-) diff --git a/create_pr_from_main/test_verify.py b/create_pr_from_main/test_verify.py index ff0c478f..1f7e8091 100644 --- a/create_pr_from_main/test_verify.py +++ b/create_pr_from_main/test_verify.py @@ -47,7 +47,6 @@ def _run_verify( pr_repo_full_name="dummy/repo" ) as exercise: with ( - patch("create_pr_from_main.verify.get_github_username", return_value="dummy"), patch( "create_pr_from_main.verify.get_pr_numbers_by_author", return_value=pr_numbers, From e00400932d7a5266e1a1daf334ca9a1116149612 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Thu, 16 Apr 2026 10:09:58 +0800 Subject: [PATCH 7/8] Address copilot's comments --- create_pr_from_main/README.md | 2 +- create_pr_from_main/test_verify.py | 59 ++++++++++++++---------------- create_pr_from_main/verify.py | 10 +++-- 3 files changed, 35 insertions(+), 36 deletions(-) diff --git a/create_pr_from_main/README.md b/create_pr_from_main/README.md index 3e6955ee..da7d883e 100644 --- a/create_pr_from_main/README.md +++ b/create_pr_from_main/README.md @@ -1 +1 @@ -See https://git-mastery.github.io/lessons/prsCreate/exercise-create-pr-from-main.html +See https://git-mastery.org/lessons/prsCreate/exercise-create-pr-from-main.html diff --git a/create_pr_from_main/test_verify.py b/create_pr_from_main/test_verify.py index 1f7e8091..cdd38204 100644 --- a/create_pr_from_main/test_verify.py +++ b/create_pr_from_main/test_verify.py @@ -36,37 +36,37 @@ def file(self, file_path: str): def _run_verify( - pr_numbers: list[int] = [], + pr_numbers: list[int] | None = None, head_branch: str = "", java_content: str | None = None, ): + if pr_numbers is None: + pr_numbers = [] fake_commit = FakeCommit(java_content) - with loader.start_mock_exercise( - has_pr_context=True, - pr_number=1, - pr_repo_full_name="dummy/repo" - ) as exercise: - with ( - patch( - "create_pr_from_main.verify.get_pr_numbers_by_author", - return_value=pr_numbers, - ), - patch("create_pr_from_main.verify.add_pr_config"), - patch.object(exercise, "fetch_pr", return_value=None), - patch.object( - GitAutograderPr, - "head_branch", - new_callable=PropertyMock, - return_value=head_branch, - ), - patch.object( - GitAutograderPr, - "last_user_commit", - new_callable=PropertyMock, - return_value=fake_commit, - ), - ): - return verify(exercise) + with ( + loader.start_mock_exercise( + has_pr_context=True, pr_number=1, pr_repo_full_name="dummy/repo" + ) as exercise, + patch( + "create_pr_from_main.verify.get_pr_numbers_by_author", + return_value=pr_numbers, + ), + patch("create_pr_from_main.verify.add_pr_config"), + patch.object(exercise, "fetch_pr", return_value=None), + patch.object( + GitAutograderPr, + "head_branch", + new_callable=PropertyMock, + return_value=head_branch, + ), + patch.object( + GitAutograderPr, + "last_user_commit", + new_callable=PropertyMock, + return_value=fake_commit, + ), + ): + return verify(exercise) def test_success(): @@ -88,10 +88,7 @@ def test_pr_missing(): def test_wrong_head_branch(): with pytest.raises(GitAutograderWrongAnswerException) as exception: - _run_verify( - pr_numbers=[1], - head_branch="feature/pr-branch" - ) + _run_verify(pr_numbers=[1], head_branch="feature/pr-branch") assert exception.value.message == [WRONG_HEAD_BRANCH] diff --git a/create_pr_from_main/verify.py b/create_pr_from_main/verify.py index 4d2f79a5..dbb63367 100644 --- a/create_pr_from_main/verify.py +++ b/create_pr_from_main/verify.py @@ -12,7 +12,7 @@ JAVA_FILE_MISSING = "Java.txt file is missing in the latest commit on main branch." JAVA_INVALID_CONTENT = "The content in Java.txt in main branch is not correct." -MUTIPLE_PRS = "Multiple PRs found. The lastest pr will be used in grading." +MULTIPLE_PRS = "Multiple PRs found. The latest pr will be used in grading." PR_MISSING = "No PR is found." WRONG_HEAD_BRANCH = "The PR's head branch is not 'main'." @@ -24,12 +24,12 @@ def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: username = get_github_username(False) target_repo = f"git-mastery/{exercise.config.exercise_repo.repo_title}" comments = [] - + pr_numbers = get_pr_numbers_by_author(username, target_repo, False) if not pr_numbers: raise exercise.wrong_answer([PR_MISSING]) if len(pr_numbers) > 1: - comments.append(MUTIPLE_PRS) + comments.append(MULTIPLE_PRS) pr_number = pr_numbers[-1] add_pr_config(pr_number=pr_number, config_path=Path("./")) @@ -44,7 +44,9 @@ def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: if content is None: comments.append(JAVA_FILE_MISSING) raise exercise.wrong_answer(comments) - extracted_content = [line.strip() for line in content.splitlines() if line.strip() != ""] + extracted_content = [ + line.strip() for line in content.splitlines() if line.strip() != "" + ] if extracted_content != EXPECTED_CONTENT_STEP_3: comments.append(JAVA_INVALID_CONTENT) raise exercise.wrong_answer(comments) From 980bf1838bb7d12d5ee06984cfb06ded5ad01ab3 Mon Sep 17 00:00:00 2001 From: desmondwong1215 Date: Wed, 22 Apr 2026 00:06:17 +0800 Subject: [PATCH 8/8] Fix typo --- create_pr_from_main/verify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/create_pr_from_main/verify.py b/create_pr_from_main/verify.py index dbb63367..4ca7ec18 100644 --- a/create_pr_from_main/verify.py +++ b/create_pr_from_main/verify.py @@ -17,7 +17,7 @@ WRONG_HEAD_BRANCH = "The PR's head branch is not 'main'." -EXPECTED_CONTENT_STEP_3 = ["1955, by James Gosling"] +EXPECTED_CONTENT_STEP_3 = ["1995, by James Gosling"] def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: