diff --git a/src/clayde/orchestrator.py b/src/clayde/orchestrator.py index 209d060..50bb4e7 100644 --- a/src/clayde/orchestrator.py +++ b/src/clayde/orchestrator.py @@ -198,6 +198,16 @@ def _prune_closed_issues(g: Github, issues_state: dict) -> None: save_state(state) +def _configure_global_git_identity(settings) -> None: + git_name = settings.effective_git_name + git_email = settings.git_email + if not isinstance(git_name, str) or not isinstance(git_email, str) or not git_name or not git_email: + log.error("CLAYDE_GIT_NAME (or CLAYDE_GITHUB_USERNAME) and CLAYDE_GIT_EMAIL must be set to non-empty strings") + sys.exit(1) + subprocess.run(["git", "config", "--global", "user.name", git_name], check=True) + subprocess.run(["git", "config", "--global", "user.email", git_email], check=True) + + def main(): settings = get_settings() @@ -208,13 +218,7 @@ def main(): os.environ["GH_TOKEN"] = settings.github_token - git_name = settings.effective_git_name - git_email = settings.git_email - if not git_name or not git_email: - log.error("CLAYDE_GIT_NAME (or CLAYDE_GITHUB_USERNAME) and CLAYDE_GIT_EMAIL must be set") - sys.exit(1) - subprocess.run(["git", "config", "--global", "user.name", git_name], check=True) - subprocess.run(["git", "config", "--global", "user.email", git_email], check=True) + _configure_global_git_identity(settings) provider = init_tracer() tracer = get_tracer() diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 86f6732..cc52f17 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -1,5 +1,6 @@ """Tests for clayde.orchestrator — event-driven loop.""" +from contextlib import ExitStack, contextmanager from datetime import datetime, timezone from unittest.mock import MagicMock, patch @@ -19,65 +20,65 @@ def _mock_settings(enabled=False, github_token="tok", github_username="ClaydeCod s.enabled = enabled s.github_token = github_token s.github_username = github_username + s.effective_git_name = "Test Bot" + s.git_email = "test@example.com" return s +@contextmanager +def _patched_main(enabled=True, claude_available=True, assigned=(), state=None): + """Patch every external dependency `main()` touches. + + Yields a dict of name → mock so individual tests can assert on call behavior. + """ + targets = { + "get_settings": {"return_value": _mock_settings(enabled=enabled)}, + "setup_logging": {}, + "init_tracer": {}, + "_configure_global_git_identity": {}, + "is_claude_available": {"return_value": claude_available}, + "get_github_client": {}, + "get_assigned_issues": {"return_value": list(assigned)}, + "load_state": {"return_value": state if state is not None else {"issues": {}}}, + "_prune_closed_issues": {}, + "_handle_issue": {}, + } + with ExitStack() as stack: + yield { + name: stack.enter_context(patch(f"clayde.orchestrator.{name}", **kwargs)) + for name, kwargs in targets.items() + } + + class TestMain: def test_exits_when_disabled(self): - with patch("clayde.orchestrator.setup_logging"), \ - patch("clayde.orchestrator.get_settings", return_value=_mock_settings(enabled=False)): + with _patched_main(enabled=False): with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code == 0 def test_returns_when_claude_unavailable(self): - with patch("clayde.orchestrator.get_settings", return_value=_mock_settings(enabled=True)), \ - patch("clayde.orchestrator.setup_logging"), \ - patch("clayde.orchestrator.init_tracer"), \ - patch("clayde.orchestrator.is_claude_available", return_value=False), \ - patch("clayde.orchestrator.get_github_client") as mock_gc: + with _patched_main(claude_available=False) as mocks: main() - mock_gc.assert_not_called() + mocks["get_github_client"].assert_not_called() def test_returns_when_no_assigned_issues(self): - with patch("clayde.orchestrator.get_settings", return_value=_mock_settings(enabled=True)), \ - patch("clayde.orchestrator.setup_logging"), \ - patch("clayde.orchestrator.init_tracer"), \ - patch("clayde.orchestrator.is_claude_available", return_value=True), \ - patch("clayde.orchestrator.get_github_client"), \ - patch("clayde.orchestrator.get_assigned_issues", return_value=[]), \ - patch("clayde.orchestrator.load_state", return_value={"issues": {}}): + with _patched_main(assigned=[]): main() def test_calls_handle_issue_for_each_assigned(self): issue = MagicMock() issue.html_url = "https://github.com/o/r/issues/1" - with patch("clayde.orchestrator.get_settings", return_value=_mock_settings(enabled=True)), \ - patch("clayde.orchestrator.setup_logging"), \ - patch("clayde.orchestrator.init_tracer"), \ - patch("clayde.orchestrator.is_claude_available", return_value=True), \ - patch("clayde.orchestrator.get_github_client"), \ - patch("clayde.orchestrator.get_assigned_issues", return_value=[issue]), \ - patch("clayde.orchestrator.load_state", return_value={"issues": {}}), \ - patch("clayde.orchestrator._prune_closed_issues"), \ - patch("clayde.orchestrator._handle_issue") as mock_handle: + with _patched_main(assigned=[issue]) as mocks: main() - mock_handle.assert_called_once() + mocks["_handle_issue"].assert_called_once() def test_main_calls_prune(self): issue = MagicMock() issue.html_url = "https://github.com/o/r/issues/1" - with patch("clayde.orchestrator.get_settings", return_value=_mock_settings(enabled=True)), \ - patch("clayde.orchestrator.setup_logging"), \ - patch("clayde.orchestrator.init_tracer"), \ - patch("clayde.orchestrator.is_claude_available", return_value=True), \ - patch("clayde.orchestrator.get_github_client"), \ - patch("clayde.orchestrator.get_assigned_issues", return_value=[issue]), \ - patch("clayde.orchestrator.load_state", return_value={"issues": {}}), \ - patch("clayde.orchestrator._prune_closed_issues") as mock_prune, \ - patch("clayde.orchestrator._handle_issue"): + with _patched_main(assigned=[issue]) as mocks: main() - mock_prune.assert_called_once() + mocks["_prune_closed_issues"].assert_called_once() class TestHandleIssue: