From da38556343de6e6f8d2c4d62a99cfe5e8cbeaab8 Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Tue, 28 Oct 2025 18:42:07 +0800 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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 = [] From ef903c460158d58456455d2472387a01587d0c98 Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Mon, 24 Nov 2025 18:47:06 +0800 Subject: [PATCH 5/6] Implement feedback - Added instructions to README - Removed hacks and cleaned up download.py - Removed GH API call - Cleaned up test_verify.py - Added new test --- tags_push/README.md | 16 +++------------- tags_push/download.py | 23 +++++++---------------- tags_push/tests/test_verify.py | 22 ++++++++++++---------- tags_push/verify.py | 6 ++---- 4 files changed, 24 insertions(+), 43 deletions(-) diff --git a/tags_push/README.md b/tags_push/README.md index 9861ea4..6c60c32 100644 --- a/tags_push/README.md +++ b/tags_push/README.md @@ -1,18 +1,8 @@ # tags-push - +The duty-roster repo contains text files that track which people are assigned for duties on which days of the week. This repo is backed up in a remote named production. Apparently, tags in the local repo are not in sync with the tags in your remote. ## Task - - -## Hints - - - +1. Push both tags in the local repo to the remote. +2. If any tags are present in the remote production but not in the local repo (i.e., likely result of you previously deleting them in the local repo but forgetting to delete them in the remote repo), delete them in the remote. diff --git a/tags_push/download.py b/tags_push/download.py index a34b48d..b3230dd 100644 --- a/tags_push/download.py +++ b/tags_push/download.py @@ -1,21 +1,12 @@ from exercise_utils.cli import run_command -from exercise_utils.git import tag, push +from exercise_utils.git import tag 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", "remote", "rename", "origin", "production"], verbose) + tag("beta", verbose) + run_command(["git", "push", "production", "--tags"], verbose) + run_command(["git", "tag", "-d", "beta"], 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) + run_command(["git", "tag", "v1.0", "HEAD~4"], verbose) + run_command(["git", "tag", "-a", "v2.0", "HEAD~1", "-m", f"\"{"First stable roster"}\""], verbose) diff --git a/tags_push/tests/test_verify.py b/tags_push/tests/test_verify.py index f42b424..9a5957b 100644 --- a/tags_push/tests/test_verify.py +++ b/tags_push/tests/test_verify.py @@ -78,38 +78,40 @@ 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, + pytest.raises(GitAutograderWrongAnswerException, match=IMPROPER_GH_CLI_SETUP), ): 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, + pytest.raises(GitAutograderWrongAnswerException, match=TAG_DELETE_NOT_REMOVED), ): 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, + pytest.raises(GitAutograderWrongAnswerException, match=TAG_1_MISSING), ): 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, match=TAG_2_MISSING), + ): + verify(exercise) + +def test_all_wrong(exercise: GitAutograderExercise): + with ( + patch("tags_push.verify.get_username", return_value="dummy"), + patch("tags_push.verify.get_remote_tags", return_value=[TAG_DELETE_NAME]), pytest.raises(GitAutograderWrongAnswerException) as exception, ): verify(exercise) - assert exception.value.message == [TAG_2_MISSING] \ No newline at end of file + assert exception.value.message == [TAG_1_MISSING, TAG_2_MISSING, TAG_DELETE_NOT_REMOVED] \ No newline at end of file diff --git a/tags_push/verify.py b/tags_push/verify.py index 55a9b83..1a94111 100644 --- a/tags_push/verify.py +++ b/tags_push/verify.py @@ -36,10 +36,8 @@ def get_username() -> Optional[str]: return run_command(["gh", "api", "user", "-q", ".login"]) 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()] + raw_tags = run_command(["git", "ls-remote", "--tags"]) + return [line.split("/")[2] for line in raw_tags.strip().splitlines()] def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: username = get_username() From 9f15849c71a52084e04d577a59e26df582274418 Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Tue, 2 Dec 2025 13:30:20 +0800 Subject: [PATCH 6/6] Fix formatting --- tags_push/download.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tags_push/download.py b/tags_push/download.py index b3230dd..cd1105a 100644 --- a/tags_push/download.py +++ b/tags_push/download.py @@ -1,6 +1,5 @@ from exercise_utils.cli import run_command from exercise_utils.git import tag -from exercise_utils.gitmastery import create_start_tag def setup(verbose: bool = False): run_command(["git", "remote", "rename", "origin", "production"], verbose) @@ -9,4 +8,4 @@ def setup(verbose: bool = False): run_command(["git", "tag", "-d", "beta"], verbose) run_command(["git", "tag", "v1.0", "HEAD~4"], verbose) - run_command(["git", "tag", "-a", "v2.0", "HEAD~1", "-m", f"\"{"First stable roster"}\""], verbose) + run_command(["git", "tag", "-a", "v2.0", "HEAD~1", "-m", "First stable roster"], verbose)