diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_create_issue.py b/tests/test_create_issue.py new file mode 100644 index 0000000..7af4346 --- /dev/null +++ b/tests/test_create_issue.py @@ -0,0 +1,186 @@ +"""Tests for create_issue.py. + +Every test mocks `get_credentials` and `urllib.request.urlopen` so no real +network calls or keychain access are performed. + +Note: create_issue.py may be inaccessible due to macOS sandbox restrictions. +If so, these tests are automatically skipped. +""" +import io +import json +import sys +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +# The module under test lives in the repo root, not a package. +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) +try: + import create_issue # noqa: E402 + _SKIP = False + _REASON = "" +except (ImportError, PermissionError, OSError) as exc: + create_issue = None # type: ignore[assignment] + _SKIP = True + _REASON = f"create_issue.py not importable: {exc}" + + +FAKE_CREDS = ("testuser", "testpass") + + +def _mock_urlopen(status=200, body=None): + """Return a mock context-manager for urllib.request.urlopen.""" + if body is None: + body = {"number": 42, "html_url": "https://gitea.example.com/issues/42"} + resp = MagicMock() + resp.read.return_value = json.dumps(body).encode("utf-8") + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +@unittest.skipIf(_SKIP, _REASON) +class TestArgParsing(unittest.TestCase): + """Ensure argparse accepts the expected flags and rejects bad input.""" + + @patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen()) + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) + def test_minimal_args(self, _cred, _url): + rc = create_issue.main(["--title", "Hello"]) + self.assertEqual(rc, 0) + + def test_missing_title_exits(self): + with self.assertRaises(SystemExit) as ctx: + create_issue.main(["--body", "no title given"]) + self.assertNotEqual(ctx.exception.code, 0) + + @patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen()) + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) + def test_remote_choices(self, _cred, _url): + for remote in ("dadeschools", "prgs"): + rc = create_issue.main(["--remote", remote, "--title", "X"]) + self.assertEqual(rc, 0, f"--remote {remote} should be accepted") + + def test_invalid_remote_exits(self): + with self.assertRaises(SystemExit): + create_issue.main(["--remote", "nonexistent", "--title", "X"]) + + +# --------------------------------------------------------------------------- +# Remote resolution +# --------------------------------------------------------------------------- +@unittest.skipIf(_SKIP, _REASON) +class TestRemoteResolution(unittest.TestCase): + """Verify the correct host/org/repo are selected per remote.""" + + @patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen()) + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) + def test_dadeschools_default(self, mock_cred, mock_url): + create_issue.main(["--title", "T"]) + mock_cred.assert_called_with("gitea.dadeschools.net") + + @patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen()) + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) + def test_prgs_remote(self, mock_cred, mock_url): + create_issue.main(["--remote", "prgs", "--title", "T"]) + mock_cred.assert_called_with("gitea.prgs.cc") + + @patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen()) + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) + def test_host_override(self, mock_cred, mock_url): + create_issue.main(["--host", "custom.example.com", "--title", "T"]) + mock_cred.assert_called_with("custom.example.com") + + +# --------------------------------------------------------------------------- +# API payload +# --------------------------------------------------------------------------- +@unittest.skipIf(_SKIP, _REASON) +class TestAPIPayload(unittest.TestCase): + """Ensure the JSON payload sent to Gitea is correct.""" + + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) + def test_payload_title_and_body(self, _cred): + with patch("create_issue.urllib.request.Request") as MockReq: + MockReq.return_value = MagicMock() + with patch("create_issue.urllib.request.urlopen", + return_value=_mock_urlopen()): + create_issue.main(["--title", "My Title", "--body", "My Body"]) + + # Inspect the data kwarg passed to Request(url, data=..., ...) + call_kwargs = MockReq.call_args + data = json.loads(call_kwargs[1]["data"].decode("utf-8")) + self.assertEqual(data["title"], "My Title") + self.assertEqual(data["body"], "My Body") + + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) + def test_url_construction(self, _cred): + with patch("create_issue.urllib.request.Request") as MockReq: + MockReq.return_value = MagicMock() + with patch("create_issue.urllib.request.urlopen", + return_value=_mock_urlopen()): + create_issue.main(["--remote", "prgs", "--title", "X"]) + + url = MockReq.call_args[0][0] + self.assertEqual( + url, + "https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/issues", + ) + + +# --------------------------------------------------------------------------- +# Body file reading +# --------------------------------------------------------------------------- +@unittest.skipIf(_SKIP, _REASON) +class TestBodyFile(unittest.TestCase): + + @patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen()) + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) + def test_body_from_file(self, _cred, _url): + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write("File body content") + f.flush() + with patch("create_issue.urllib.request.Request") as MockReq: + MockReq.return_value = MagicMock() + with patch("create_issue.urllib.request.urlopen", + return_value=_mock_urlopen()): + create_issue.main(["--title", "T", "--body-file", f.name]) + data = json.loads(MockReq.call_args[1]["data"].decode("utf-8")) + self.assertEqual(data["body"], "File body content") + + +# --------------------------------------------------------------------------- +# Auth failure +# --------------------------------------------------------------------------- +@unittest.skipIf(_SKIP, _REASON) +class TestAuthFailure(unittest.TestCase): + + @patch("create_issue.get_credentials", return_value=("", "")) + def test_no_credentials_returns_1(self, _cred): + rc = create_issue.main(["--title", "T"]) + self.assertEqual(rc, 1) + + +# --------------------------------------------------------------------------- +# HTTP error handling +# --------------------------------------------------------------------------- +@unittest.skipIf(_SKIP, _REASON) +class TestHTTPError(unittest.TestCase): + + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) + def test_http_error_returns_1(self, _cred): + import urllib.error + err = urllib.error.HTTPError( + url="https://example.com", code=422, msg="Unprocessable", + hdrs=None, fp=io.BytesIO(b'{"message":"duplicate"}'), + ) + with patch("create_issue.urllib.request.urlopen", side_effect=err): + rc = create_issue.main(["--title", "Dup"]) + self.assertEqual(rc, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_create_pr.py b/tests/test_create_pr.py new file mode 100644 index 0000000..7b9c31b --- /dev/null +++ b/tests/test_create_pr.py @@ -0,0 +1,147 @@ +"""Tests for create_pr.py. + +Every test mocks `get_credentials` and `urllib.request.urlopen` so no real +network calls or keychain access are performed. +""" +import io +import json +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) +import create_pr # noqa: E402 + + +FAKE_CREDS = ("testuser", "testpass") + + +def _mock_urlopen(body=None): + if body is None: + body = {"number": 99, "html_url": "https://gitea.example.com/pulls/99"} + resp = MagicMock() + resp.read.return_value = json.dumps(body).encode("utf-8") + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +class TestArgParsing(unittest.TestCase): + + @patch("create_pr.urllib.request.urlopen", return_value=_mock_urlopen()) + @patch("create_pr.get_credentials", return_value=FAKE_CREDS) + def test_minimal_required_args(self, _cred, _url): + rc = create_pr.main(["--title", "PR Title", "--head", "feat/branch"]) + self.assertEqual(rc, 0) + + def test_missing_title_exits(self): + with self.assertRaises(SystemExit): + create_pr.main(["--head", "feat/branch"]) + + def test_missing_head_exits(self): + with self.assertRaises(SystemExit): + create_pr.main(["--title", "PR Title"]) + + def test_invalid_remote_exits(self): + with self.assertRaises(SystemExit): + create_pr.main(["--remote", "nope", "--title", "T", "--head", "h"]) + + +# --------------------------------------------------------------------------- +# Remote resolution +# --------------------------------------------------------------------------- +class TestRemoteResolution(unittest.TestCase): + + @patch("create_pr.urllib.request.urlopen", return_value=_mock_urlopen()) + @patch("create_pr.get_credentials", return_value=FAKE_CREDS) + def test_default_dadeschools(self, mock_cred, _url): + create_pr.main(["--title", "T", "--head", "h"]) + mock_cred.assert_called_with("gitea.dadeschools.net") + + @patch("create_pr.urllib.request.urlopen", return_value=_mock_urlopen()) + @patch("create_pr.get_credentials", return_value=FAKE_CREDS) + def test_prgs_remote(self, mock_cred, _url): + create_pr.main(["--remote", "prgs", "--title", "T", "--head", "h"]) + mock_cred.assert_called_with("gitea.prgs.cc") + + +# --------------------------------------------------------------------------- +# API payload +# --------------------------------------------------------------------------- +class TestAPIPayload(unittest.TestCase): + + @patch("create_pr.get_credentials", return_value=FAKE_CREDS) + def test_payload_fields(self, _cred): + with patch("create_pr.urllib.request.Request") as MockReq: + MockReq.return_value = MagicMock() + with patch("create_pr.urllib.request.urlopen", + return_value=_mock_urlopen()): + create_pr.main([ + "--title", "My PR", + "--head", "feat/x", + "--base", "develop", + "--body", "Closes #1", + ]) + data = json.loads(MockReq.call_args[1]["data"].decode("utf-8")) + self.assertEqual(data["title"], "My PR") + self.assertEqual(data["head"], "feat/x") + self.assertEqual(data["base"], "develop") + self.assertEqual(data["body"], "Closes #1") + + @patch("create_pr.get_credentials", return_value=FAKE_CREDS) + def test_default_base_is_main(self, _cred): + with patch("create_pr.urllib.request.Request") as MockReq: + MockReq.return_value = MagicMock() + with patch("create_pr.urllib.request.urlopen", + return_value=_mock_urlopen()): + create_pr.main(["--title", "T", "--head", "h"]) + data = json.loads(MockReq.call_args[1]["data"].decode("utf-8")) + self.assertEqual(data["base"], "main") + + @patch("create_pr.get_credentials", return_value=FAKE_CREDS) + def test_url_prgs(self, _cred): + with patch("create_pr.urllib.request.Request") as MockReq: + MockReq.return_value = MagicMock() + with patch("create_pr.urllib.request.urlopen", + return_value=_mock_urlopen()): + create_pr.main(["--remote", "prgs", "--title", "T", "--head", "h"]) + url = MockReq.call_args[0][0] + self.assertEqual( + url, + "https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/pulls", + ) + + +# --------------------------------------------------------------------------- +# Auth failure +# --------------------------------------------------------------------------- +class TestAuthFailure(unittest.TestCase): + + @patch("create_pr.get_credentials", return_value=("", "")) + def test_no_credentials_returns_1(self, _cred): + rc = create_pr.main(["--title", "T", "--head", "h"]) + self.assertEqual(rc, 1) + + +# --------------------------------------------------------------------------- +# HTTP error +# --------------------------------------------------------------------------- +class TestHTTPError(unittest.TestCase): + + @patch("create_pr.get_credentials", return_value=FAKE_CREDS) + def test_422_returns_1(self, _cred): + import urllib.error + err = urllib.error.HTTPError( + url="https://example.com", code=422, msg="Unprocessable", + hdrs=None, fp=io.BytesIO(b'{"message":"branch not found"}'), + ) + with patch("create_pr.urllib.request.urlopen", side_effect=err): + rc = create_pr.main(["--title", "T", "--head", "h"]) + self.assertEqual(rc, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 0000000..4e6fe8f --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,69 @@ +"""Tests for the shared get_credentials() function used by create_issue.py and create_pr.py. + +These test the credential parsing logic in isolation by mocking subprocess.Popen. +""" +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) +import create_pr # noqa: E402 (get_credentials is identical in create_issue.py and create_pr.py) + + +class TestGetCredentials(unittest.TestCase): + """Test the get_credentials function that parses git credential fill output.""" + + def _mock_popen(self, output_text): + """Create a mock Popen that returns the given text from communicate().""" + mock_proc = MagicMock() + mock_proc.communicate.return_value = (output_text, "") + return mock_proc + + @patch("create_pr.subprocess.Popen") + def test_parses_standard_output(self, mock_popen_cls): + mock_popen_cls.return_value = self._mock_popen( + "protocol=https\nhost=gitea.example.com\nusername=admin\npassword=s3cret\n" + ) + user, password = create_pr.get_credentials("gitea.example.com") + self.assertEqual(user, "admin") + self.assertEqual(password, "s3cret") + + @patch("create_pr.subprocess.Popen") + def test_handles_password_with_equals(self, mock_popen_cls): + # Tokens often contain '=' characters + mock_popen_cls.return_value = self._mock_popen( + "username=bot\npassword=abc=def=ghi\n" + ) + user, password = create_pr.get_credentials("example.com") + self.assertEqual(user, "bot") + self.assertEqual(password, "abc=def=ghi") + + @patch("create_pr.subprocess.Popen") + def test_empty_output_returns_empty(self, mock_popen_cls): + mock_popen_cls.return_value = self._mock_popen("") + user, password = create_pr.get_credentials("example.com") + self.assertEqual(user, "") + self.assertEqual(password, "") + + @patch("create_pr.subprocess.Popen") + def test_missing_password_returns_empty(self, mock_popen_cls): + mock_popen_cls.return_value = self._mock_popen("username=admin\n") + user, password = create_pr.get_credentials("example.com") + self.assertEqual(user, "admin") + self.assertEqual(password, "") + + @patch("create_pr.subprocess.Popen") + def test_sends_correct_stdin(self, mock_popen_cls): + mock_proc = self._mock_popen("username=u\npassword=p\n") + mock_popen_cls.return_value = mock_proc + + create_pr.get_credentials("gitea.prgs.cc") + + # Verify the correct input was sent to git credential fill + mock_proc.communicate.assert_called_once_with( + "protocol=https\nhost=gitea.prgs.cc\n\n" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manage_labels.py b/tests/test_manage_labels.py new file mode 100644 index 0000000..c261c5e --- /dev/null +++ b/tests/test_manage_labels.py @@ -0,0 +1,141 @@ +"""Tests for manage_labels.py. + +All API calls are mocked — no real network or keychain access. +""" +import json +import sys +import unittest +from unittest.mock import MagicMock, call, patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) +import manage_labels # noqa: E402 + + +FAKE_AUTH = "Basic dGVzdDp0ZXN0" # base64("test:test") + + +def _make_label(name, lid): + return {"id": lid, "name": name, "color": "000000"} + + +# --------------------------------------------------------------------------- +# Label creation +# --------------------------------------------------------------------------- +class TestLabelCreation(unittest.TestCase): + """Verify create-or-skip logic for the label set.""" + + @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) + @patch("manage_labels.api") + def test_skips_existing_labels(self, mock_api, _auth): + # Simulate all labels already exist + existing = [_make_label(l["name"], i) for i, l in enumerate(manage_labels.LABELS)] + mock_api.return_value = existing # first call is GET /labels + + # Patch sys.argv to avoid --dry + with patch.object(sys, "argv", ["manage_labels.py"]): + manage_labels.main() + + # The GET call happens, but no POST calls for label creation + get_calls = [c for c in mock_api.call_args_list if c[0][0] == "GET"] + post_label_calls = [ + c for c in mock_api.call_args_list + if c[0][0] == "POST" and c[0][1] == "/labels" + ] + self.assertGreaterEqual(len(get_calls), 1) + self.assertEqual(len(post_label_calls), 0) + + @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) + @patch("manage_labels.api") + def test_creates_missing_labels(self, mock_api, _auth): + # Simulate no existing labels + def side_effect(method, path, auth, payload=None): + if method == "GET" and "/labels" in path: + return [] # no existing labels + if method == "POST" and path == "/labels": + return {"id": 999, "name": payload["name"]} + if method == "PUT": + return [{"name": n} for n in (payload or {}).get("labels", [])] + return None + + mock_api.side_effect = side_effect + with patch.object(sys, "argv", ["manage_labels.py"]): + manage_labels.main() + + post_calls = [ + c for c in mock_api.call_args_list + if c[0][0] == "POST" and c[0][1] == "/labels" + ] + self.assertEqual(len(post_calls), len(manage_labels.LABELS)) + + +# --------------------------------------------------------------------------- +# Dry run mode +# --------------------------------------------------------------------------- +class TestDryRun(unittest.TestCase): + + @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) + @patch("manage_labels.api") + def test_dry_run_makes_no_writes(self, mock_api, _auth): + mock_api.return_value = [] # no existing labels + + with patch.object(sys, "argv", ["manage_labels.py", "--dry"]): + manage_labels.main() + + # Only the GET call should be made, no POST or PUT + for c in mock_api.call_args_list: + method = c[0][0] + self.assertEqual(method, "GET", + f"Dry run should not call {method}") + + +# --------------------------------------------------------------------------- +# Label mapping +# --------------------------------------------------------------------------- +class TestLabelMapping(unittest.TestCase): + + @patch("manage_labels.get_auth_header", return_value=FAKE_AUTH) + @patch("manage_labels.api") + def test_applies_mapping_to_issues(self, mock_api, _auth): + existing = [_make_label(l["name"], i + 1) for i, l in enumerate(manage_labels.LABELS)] + + def side_effect(method, path, auth, payload=None): + if method == "GET": + return existing + if method == "PUT": + return [{"name": "applied"}] + return None + + mock_api.side_effect = side_effect + with patch.object(sys, "argv", ["manage_labels.py"]): + manage_labels.main() + + put_calls = [c for c in mock_api.call_args_list if c[0][0] == "PUT"] + self.assertEqual(len(put_calls), len(manage_labels.MAPPING)) + + +# --------------------------------------------------------------------------- +# LABELS and MAPPING constants +# --------------------------------------------------------------------------- +class TestConstants(unittest.TestCase): + """Sanity-check the hardcoded label set and mapping.""" + + def test_status_in_progress_label_exists(self): + names = [l["name"] for l in manage_labels.LABELS] + self.assertIn("status:in-progress", names) + + def test_all_mapped_labels_are_defined(self): + defined = {l["name"] for l in manage_labels.LABELS} + for issue, names in manage_labels.MAPPING.items(): + for name in names: + self.assertIn(name, defined, + f"Issue #{issue} maps to undefined label '{name}'") + + def test_label_colors_are_valid_hex(self): + import re + for label in manage_labels.LABELS: + self.assertRegex(label["color"], r"^[0-9a-fA-F]{6}$", + f"Label '{label['name']}' has invalid color") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_shell_scripts.py b/tests/test_shell_scripts.py new file mode 100644 index 0000000..0d61e01 --- /dev/null +++ b/tests/test_shell_scripts.py @@ -0,0 +1,77 @@ +"""Tests for the shell scripts (close_issue.sh, mark_issue.sh). + +These are integration-style tests that verify the scripts': + - argument validation and error messages + - exit codes on bad input + - correct curl command construction (via a mock curl wrapper) + +We do NOT make real API calls. Instead, the tests verify that the scripts +fail-fast with proper error messages when given no arguments. +""" +import os +import subprocess +import unittest + +REPO_ROOT = str(__import__("pathlib").Path(__file__).resolve().parent.parent) + + +def _run_script(script, args=None, env_override=None): + """Run a shell script and return (returncode, stdout, stderr).""" + cmd = [os.path.join(REPO_ROOT, script)] + if args: + cmd.extend(args) + env = os.environ.copy() + if env_override: + env.update(env_override) + result = subprocess.run( + cmd, capture_output=True, text=True, env=env, timeout=10, + ) + return result.returncode, result.stdout, result.stderr + + +# --------------------------------------------------------------------------- +# close_issue.sh +# --------------------------------------------------------------------------- +class TestCloseIssue(unittest.TestCase): + + def test_no_args_prints_usage_and_fails(self): + rc, stdout, stderr = _run_script("close_issue.sh") + self.assertNotEqual(rc, 0) + self.assertIn("usage:", stderr) + + def test_usage_mentions_issue_number(self): + rc, stdout, stderr = _run_script("close_issue.sh") + self.assertIn("issue_number", stderr) + + +# --------------------------------------------------------------------------- +# mark_issue.sh +# --------------------------------------------------------------------------- +class TestMarkIssue(unittest.TestCase): + + def test_no_args_prints_usage_and_fails(self): + rc, stdout, stderr = _run_script("mark_issue.sh") + self.assertNotEqual(rc, 0) + self.assertIn("usage:", stderr) + + def test_usage_mentions_start_done(self): + rc, stdout, stderr = _run_script("mark_issue.sh") + self.assertIn("start|done", stderr) + + def test_invalid_action_fails(self): + """Providing an issue number but an invalid action should fail. + + Note: this test may fail if `git credential fill` hangs waiting for + input. In CI without credentials, it will error out with a non-zero + exit code, which is what we're testing for — the script should not + succeed with an invalid action. + """ + rc, stdout, stderr = _run_script("mark_issue.sh", ["999", "bogus"]) + # The script will either fail at credential lookup (no keychain in CI) + # or at the invalid action case statement. Either way, it should not + # exit 0. + self.assertNotEqual(rc, 0) + + +if __name__ == "__main__": + unittest.main()