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..a34b48d --- /dev/null +++ b/tags_push/download.py @@ -0,0 +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) 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..f42b424 --- /dev/null +++ b/tags_push/tests/test_verify.py @@ -0,0 +1,115 @@ +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) 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 new file mode 100644 index 0000000..55a9b83 --- /dev/null +++ b/tags_push/verify.py @@ -0,0 +1,67 @@ +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(["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]) + + 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)