diff --git a/tags_push/.gitmastery-exercise.json b/tags_push/.gitmastery-exercise.json new file mode 100644 index 00000000..e9922170 --- /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 00000000..3f6e7bdd --- /dev/null +++ b/tags_push/README.md @@ -0,0 +1 @@ +See https://git-mastery.github.io/lessons/tag/exercise-tags-push.html diff --git a/tags_push/__init__.py b/tags_push/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tags_push/download.py b/tags_push/download.py new file mode 100644 index 00000000..36918666 --- /dev/null +++ b/tags_push/download.py @@ -0,0 +1,12 @@ +from exercise_utils.cli import run_command +from exercise_utils.git import tag, tag_with_options + + +def setup(verbose: bool = False): + 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) + + tag_with_options("v1.0", ["HEAD~4"], verbose) + tag_with_options("v2.0", ["-a", "HEAD~1", "-m", "First stable roster"], verbose) diff --git a/tags_push/tests/__init__.py b/tags_push/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tags_push/tests/specs/base.yml b/tags_push/tests/specs/base.yml new file mode 100644 index 00000000..00c3a538 --- /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 00000000..0d6a5620 --- /dev/null +++ b/tags_push/tests/test_verify.py @@ -0,0 +1,135 @@ +import json +from pathlib import Path +from unittest.mock import patch + +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) + + +# 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, match=IMPROPER_GH_CLI_SETUP), + ): + verify(exercise) + + +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, match=TAG_DELETE_NOT_REMOVED), + ): + verify(exercise) + + +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, match=TAG_1_MISSING), + ): + verify(exercise) + + +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_1_MISSING, + TAG_2_MISSING, + TAG_DELETE_NOT_REMOVED, + ] diff --git a/tags_push/verify.py b/tags_push/verify.py new file mode 100644 index 00000000..90eef876 --- /dev/null +++ b/tags_push/verify.py @@ -0,0 +1,73 @@ +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"]) + + +def get_remote_tags(username: str) -> List[str]: + raw_tags = run_command(["git", "ls-remote", "--tags"]) + if raw_tags is None: + return [] + return [line.split("/")[2] 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]) + + tag_names = get_remote_tags(username) + + 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( + [ + "Wonderful! You have successfully synced the local tags with the remote tags!" + ], + GitAutograderStatus.SUCCESSFUL, + )