From da38556343de6e6f8d2c4d62a99cfe5e8cbeaab8 Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Tue, 28 Oct 2025 18:42:07 +0800 Subject: [PATCH 1/4] Add tags push --- tags_push/.gitmastery-exercise.json | 16 ++++++++++++++++ tags_push/README.md | 18 ++++++++++++++++++ tags_push/__init__.py | 0 tags_push/download.py | 8 ++++++++ tags_push/tests/__init__.py | 0 tags_push/tests/specs/base.yml | 6 ++++++ tags_push/tests/test_verify.py | 12 ++++++++++++ tags_push/verify.py | 11 +++++++++++ 8 files changed, 71 insertions(+) create mode 100644 tags_push/.gitmastery-exercise.json create mode 100644 tags_push/README.md create mode 100644 tags_push/__init__.py create mode 100644 tags_push/download.py create mode 100644 tags_push/tests/__init__.py create mode 100644 tags_push/tests/specs/base.yml create mode 100644 tags_push/tests/test_verify.py create mode 100644 tags_push/verify.py diff --git a/tags_push/.gitmastery-exercise.json b/tags_push/.gitmastery-exercise.json new file mode 100644 index 0000000..e992217 --- /dev/null +++ b/tags_push/.gitmastery-exercise.json @@ -0,0 +1,16 @@ +{ + "exercise_name": "tags-push", + "tags": [ + "git-tag" + ], + "requires_git": true, + "requires_github": true, + "base_files": {}, + "exercise_repo": { + "repo_type": "remote", + "repo_name": "duty-roster", + "repo_title": "gm-duty-roster", + "create_fork": true, + "init": null + } +} \ No newline at end of file diff --git a/tags_push/README.md b/tags_push/README.md new file mode 100644 index 0000000..9861ea4 --- /dev/null +++ b/tags_push/README.md @@ -0,0 +1,18 @@ +# tags-push + + + +## Task + + + +## Hints + + + diff --git a/tags_push/__init__.py b/tags_push/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tags_push/download.py b/tags_push/download.py new file mode 100644 index 0000000..df0bbc2 --- /dev/null +++ b/tags_push/download.py @@ -0,0 +1,8 @@ +from exercise_utils.cli import run_command +from exercise_utils.gitmastery import create_start_tag + +__resources__ = {} + + +def setup(verbose: bool = False): + create_start_tag(verbose) diff --git a/tags_push/tests/__init__.py b/tags_push/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tags_push/tests/specs/base.yml b/tags_push/tests/specs/base.yml new file mode 100644 index 0000000..00c3a53 --- /dev/null +++ b/tags_push/tests/specs/base.yml @@ -0,0 +1,6 @@ +initialization: + steps: + - type: commit + empty: true + message: Empty commit + id: start diff --git a/tags_push/tests/test_verify.py b/tags_push/tests/test_verify.py new file mode 100644 index 0000000..36e5d72 --- /dev/null +++ b/tags_push/tests/test_verify.py @@ -0,0 +1,12 @@ +from git_autograder import GitAutograderTestLoader + +from ..verify import verify + +REPOSITORY_NAME = "tags-push" + +loader = GitAutograderTestLoader(__file__, REPOSITORY_NAME, verify) + + +def test_base(): + with loader.load("specs/base.yml", "start"): + pass diff --git a/tags_push/verify.py b/tags_push/verify.py new file mode 100644 index 0000000..1288d3d --- /dev/null +++ b/tags_push/verify.py @@ -0,0 +1,11 @@ +from git_autograder import ( + GitAutograderOutput, + GitAutograderExercise, + GitAutograderStatus, +) + + +def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: + # INSERT YOUR GRADING CODE HERE + + return exercise.to_output([], GitAutograderStatus.SUCCESSFUL) From 40732700f5edde5040ac12ba6e462c41ce23e8ca Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Tue, 28 Oct 2025 20:58:04 +0800 Subject: [PATCH 2/4] Add download.py --- tags_push/download.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tags_push/download.py b/tags_push/download.py index df0bbc2..a34b48d 100644 --- a/tags_push/download.py +++ b/tags_push/download.py @@ -1,8 +1,21 @@ from exercise_utils.cli import run_command +from exercise_utils.git import tag, push from exercise_utils.gitmastery import create_start_tag __resources__ = {} +REMOTE_NAME = "production" +TAG_1_NAME = "v1.0" +TAG_2_NAME = "v2.0" +TAG_DELETE_NAME = "beta" +TAG_2_MESSAGE = "First stable roster" def setup(verbose: bool = False): create_start_tag(verbose) + run_command(["git", "remote", "rename", "origin", REMOTE_NAME], verbose) + tag(TAG_DELETE_NAME, verbose) + push(REMOTE_NAME, "--tags", verbose) # somewhat hacky, maybe use run_command instead + run_command(["git", "tag", "-d", TAG_DELETE_NAME], verbose) + + run_command(["git", "tag", TAG_1_NAME, "HEAD~4"], verbose) + run_command(["git", "tag", "-a", TAG_2_NAME, "HEAD~1", "-m", f"\"{TAG_2_MESSAGE}\""], verbose) From 96c3409236b266d9fb86ac695af789e50a6cec9f Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Wed, 29 Oct 2025 02:02:24 +0800 Subject: [PATCH 3/4] Add verify.py --- tags_push/verify.py | 62 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/tags_push/verify.py b/tags_push/verify.py index 1288d3d..76370f4 100644 --- a/tags_push/verify.py +++ b/tags_push/verify.py @@ -1,11 +1,69 @@ +import os +import subprocess +from typing import List, Optional + from git_autograder import ( GitAutograderOutput, GitAutograderExercise, GitAutograderStatus, ) +IMPROPER_GH_CLI_SETUP = "Your Github CLI is not setup correctly" + +TAG_1_NAME = "v1.0" +TAG_2_NAME = "v2.0" +TAG_DELETE_NAME = "beta" + +TAG_1_MISSING = f"Tag {TAG_1_NAME} is missing, did you push it to the remote?" +TAG_2_MISSING = f"Tag {TAG_2_NAME} is missing, did you push it to the remote?" +TAG_DELETE_NOT_REMOVED = f"Tag {TAG_DELETE_NAME} is still on the remote!" + +def run_command(command: List[str]) -> Optional[str]: + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + check=True, + env=dict(os.environ, **{"GH_PAGER": "cat"}), + ) + return result.stdout.strip() + except subprocess.CalledProcessError: + return None + + +def get_username() -> Optional[str]: + return run_command(["gh", "api", "user", "-q", ".login"]) + +# git ls-remote --tags origin (i.e. production) + +def get_remote_tags(username: str) -> Optional[str]: + return run_command(["gh", "api", + f"repos/{username}/{username}-gitmastery-gm-duty-roster/tags", + "--paginate", "--jq", ".[].name"]) def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: - # INSERT YOUR GRADING CODE HERE + username = get_username() + if username is None: + raise exercise.wrong_answer([IMPROPER_GH_CLI_SETUP]) + + raw_tags = get_remote_tags(username) + tag_names = [line.strip() for line in raw_tags.strip().splitlines()] + + comments = [] + + if TAG_1_NAME not in tag_names: + comments.append(TAG_1_MISSING) + + if TAG_2_NAME not in tag_names: + comments.append(TAG_2_MISSING) + + if TAG_DELETE_NAME in tag_names: + comments.append(TAG_DELETE_NOT_REMOVED) + + if comments: + raise exercise.wrong_answer(comments) - return exercise.to_output([], GitAutograderStatus.SUCCESSFUL) + return exercise.to_output( + ["Wonderful! You have successfully synced the local tags with the remote tags!"], + GitAutograderStatus.SUCCESSFUL) From 7fb80a2818af0ddf8eb064c69f71a7194c857640 Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Wed, 29 Oct 2025 02:58:21 +0800 Subject: [PATCH 4/4] Add unit tests and clean verify.py --- tags_push/tests/test_verify.py | 113 +++++++++++++++++++++++++++++++-- tags_push/verify.py | 10 ++- 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/tags_push/tests/test_verify.py b/tags_push/tests/test_verify.py index 36e5d72..f42b424 100644 --- a/tags_push/tests/test_verify.py +++ b/tags_push/tests/test_verify.py @@ -1,12 +1,115 @@ -from git_autograder import GitAutograderTestLoader +import json +from pathlib import Path +from unittest.mock import patch -from ..verify import verify +import pytest +from git.repo import Repo + +from git_autograder import ( + GitAutograderExercise, + GitAutograderStatus, + GitAutograderTestLoader, + GitAutograderWrongAnswerException, + assert_output) + +from ..verify import ( + IMPROPER_GH_CLI_SETUP, + TAG_1_NAME, + TAG_2_NAME, + TAG_DELETE_NAME, + TAG_1_MISSING, + TAG_2_MISSING, + TAG_DELETE_NOT_REMOVED, + verify) REPOSITORY_NAME = "tags-push" loader = GitAutograderTestLoader(__file__, REPOSITORY_NAME, verify) -def test_base(): - with loader.load("specs/base.yml", "start"): - pass +# 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. + + +# TODO: The current tooling isn't mature enough to handle mock GitAutograderExercise in +# cases like these. We would ideally need some abstraction rather than creating our own. + + +@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": "tags-push", + "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": None, + }, + "downloaded_at": None, + } + ) + ) + + exercise = GitAutograderExercise(exercise_path=tmp_path) + return exercise + + +def test_pass(exercise: GitAutograderExercise): + with ( + patch("tags_push.verify.get_username", return_value="dummy"), + patch("tags_push.verify.get_remote_tags", return_value=[TAG_1_NAME, TAG_2_NAME]), + ): + output = verify(exercise) + assert_output(output, GitAutograderStatus.SUCCESSFUL) + +def test_improper_gh_setup(exercise: GitAutograderExercise): + with ( + patch("tags_push.verify.get_username", return_value=None), + patch("tags_push.verify.get_remote_tags", return_value=[TAG_1_NAME, TAG_2_NAME]), + pytest.raises(GitAutograderWrongAnswerException) as exception, + ): + verify(exercise) + + assert exception.value.message == [IMPROPER_GH_CLI_SETUP] + +def test_beta_present(exercise: GitAutograderExercise): + with ( + patch("tags_push.verify.get_username", return_value="dummy"), + patch("tags_push.verify.get_remote_tags", return_value=[TAG_1_NAME, TAG_2_NAME, TAG_DELETE_NAME]), + pytest.raises(GitAutograderWrongAnswerException) as exception, + ): + verify(exercise) + + assert exception.value.message == [TAG_DELETE_NOT_REMOVED] + +def test_tag_1_absent(exercise: GitAutograderExercise): + with ( + patch("tags_push.verify.get_username", return_value="dummy"), + patch("tags_push.verify.get_remote_tags", return_value=[TAG_2_NAME]), + pytest.raises(GitAutograderWrongAnswerException) as exception, + ): + verify(exercise) + + assert exception.value.message == [TAG_1_MISSING] + +def test_tag_2_absent(exercise: GitAutograderExercise): + with ( + patch("tags_push.verify.get_username", return_value="dummy"), + patch("tags_push.verify.get_remote_tags", return_value=[TAG_1_NAME]), + pytest.raises(GitAutograderWrongAnswerException) as exception, + ): + verify(exercise) + + assert exception.value.message == [TAG_2_MISSING] \ No newline at end of file diff --git a/tags_push/verify.py b/tags_push/verify.py index 76370f4..55a9b83 100644 --- a/tags_push/verify.py +++ b/tags_push/verify.py @@ -35,20 +35,18 @@ def run_command(command: List[str]) -> Optional[str]: def get_username() -> Optional[str]: return run_command(["gh", "api", "user", "-q", ".login"]) -# git ls-remote --tags origin (i.e. production) - -def get_remote_tags(username: str) -> Optional[str]: - return run_command(["gh", "api", +def get_remote_tags(username: str) -> List[str]: + raw_tags = run_command(["gh", "api", f"repos/{username}/{username}-gitmastery-gm-duty-roster/tags", "--paginate", "--jq", ".[].name"]) + return [line.strip() for line in raw_tags.strip().splitlines()] def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: username = get_username() if username is None: raise exercise.wrong_answer([IMPROPER_GH_CLI_SETUP]) - raw_tags = get_remote_tags(username) - tag_names = [line.strip() for line in raw_tags.strip().splitlines()] + tag_names = get_remote_tags(username) comments = []