diff --git a/tests/test_cli_e2e.py b/tests/test_cli_e2e.py index 7032db5..8a12b99 100644 --- a/tests/test_cli_e2e.py +++ b/tests/test_cli_e2e.py @@ -43,6 +43,8 @@ if args[0] == "api": issue = int(endpoint.split("/issues/")[1].split("/")[0]) blockers = state["dependencies"].get(str(issue), []) out([next(x for x in state["issues"] if x["number"] == number) for number in blockers]) + elif endpoint.endswith("/pulls?state=open&limit=50"): + out([pr for pr in state["prs"].values() if not pr["merged"]]) elif "/pulls/" in endpoint: number = int(endpoint.rsplit("/", 1)[1]) out(state["prs"][str(number)]) diff --git a/tests/test_core.py b/tests/test_core.py index 1ea6d7d..0c52c1e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -174,5 +174,54 @@ class StateTests(RepositoryFixture): self.assertFalse(workflow.state_path.exists()) +class RecoveryTests(RepositoryFixture): + def story(self) -> Story: + return Story(1, "US01-01", "Recover Workflow", 1, 1, frozenset(), (), {}) + + def test_failed_remote_claim_rolls_back_created_branch(self) -> None: + workflow = Workflow(self.repo, self.config) + workflow.gitea = Mock() + workflow.gitea.next_story.return_value = self.story() + workflow.gitea.claim.side_effect = WorkItemError("remote claim failed") + with self.assertRaisesRegex(WorkItemError, "remote claim failed"): + workflow.claim() + self.assertEqual(workflow.git.current_branch(), "main") + self.assertNotIn("us/US01-01", workflow.git.git("branch")) + self.assertFalse(workflow.state_path.exists()) + + def test_pr_creation_failure_resumes_after_push_without_recommit(self) -> None: + config = config_for("domverse/photoanalyzer", max_file_bytes=1000) + workflow = Workflow(self.repo, config) + story = self.story() + branch = workflow.git.create_story_branch(story) + workflow.save_state({ + "issue": 1, + "story_id": story.story_id, + "title": story.title, + "branch": branch, + "status": "in-progress", + }) + (self.repo / "feature.txt").write_text("implemented\n", encoding="utf-8") + workflow.gitea = Mock() + workflow.gitea.find_open_pr.return_value = None + workflow.gitea.create_pr.side_effect = [ + WorkItemError("response lost"), + {"number": 9, "index": 9, "url": "https://example.test/pr/9"}, + ] + + with self.assertRaisesRegex(WorkItemError, "response lost"): + workflow.submit(("true",), confirmed=True) + pushed = workflow.load_state() + self.assertEqual(pushed["status"], "pushed") + commit = pushed["commit"] + self.assertEqual(workflow.git.git("status", "--porcelain"), "") + + result = workflow.submit((), confirmed=True) + self.assertEqual(result["status"], "review") + self.assertEqual(result["commit"], commit) + self.assertEqual(workflow.gitea.create_pr.call_count, 2) + workflow.gitea.mark_review.assert_called_once() + + if __name__ == "__main__": unittest.main() diff --git a/work_item/core.py b/work_item/core.py index 753dc7f..2c7a21b 100644 --- a/work_item/core.py +++ b/work_item/core.py @@ -322,6 +322,13 @@ class Gitea: except json.JSONDecodeError as error: raise WorkItemError(f"Could not parse created pull request: {output}") from error + def find_open_pr(self, branch: str) -> dict[str, Any] | None: + pulls = self.api(f"{self.base}/pulls?state=open&limit=50") + for pull in pulls: + if (pull.get("head") or {}).get("ref") == branch: + return pull + return None + def pr(self, number: int) -> dict[str, Any]: return self.api(f"{self.base}/pulls/{number}") @@ -399,7 +406,12 @@ class Workflow: self.git.update_main() story = self.gitea.next_story() branch = self.git.create_story_branch(story) - self.gitea.claim(story) + try: + self.gitea.claim(story) + except Exception: + self.git.git("switch", self.config.main_branch) + self.git.git("branch", "-D", branch) + raise state = { "issue": story.number, "story_id": story.story_id, @@ -421,26 +433,37 @@ class Workflow: state = self.load_state() if self.git.current_branch() != state["branch"]: raise WorkItemError(f"Expected branch {state['branch']}, found {self.git.current_branch()}") - commands = tuple(dict.fromkeys((*self.config.required_tests, *tests))) - if not commands: - raise WorkItemError("At least one automated test command is required") - self.git.assert_safe_changes() - for command in commands: - self.runner.run_shell(command, cwd=self.root) - paths = self.git.assert_safe_changes() - if not confirmed: - summary = self.git.git("diff", "--stat") - raise WorkItemError( - "Review the changes and rerun with --yes to commit:\n" - f"{summary}\nFiles: {', '.join(path.as_posix() for path in paths)}" - ) story = Story( number=int(state["issue"]), story_id=state["story_id"], title=state["title"], epic=int(state["story_id"][2:4]), sequence=int(state["story_id"][5:7]), labels=frozenset(), assignees=(), raw={}, ) - commit = self.git.stage_commit_push(story, state["branch"]) - pr = self.gitea.create_pr(story, state["branch"], commands, commit) + if state.get("status") == "pushed": + commands = tuple(state["tests"]) + commit = state["commit"] + elif state.get("status") == "in-progress": + commands = tuple(dict.fromkeys((*self.config.required_tests, *tests))) + if not commands: + raise WorkItemError("At least one automated test command is required") + self.git.assert_safe_changes() + for command in commands: + self.runner.run_shell(command, cwd=self.root) + paths = self.git.assert_safe_changes() + if not confirmed: + summary = self.git.git("diff", "--stat") + raise WorkItemError( + "Review the changes and rerun with --yes to commit:\n" + f"{summary}\nFiles: {', '.join(path.as_posix() for path in paths)}" + ) + commit = self.git.stage_commit_push(story, state["branch"]) + state.update({"status": "pushed", "commit": commit, "tests": list(commands)}) + self.save_state(state) + else: + raise WorkItemError(f"Story cannot be submitted from status {state.get('status')!r}") + + pr = self.gitea.find_open_pr(state["branch"]) + if pr is None: + pr = self.gitea.create_pr(story, state["branch"], commands, commit) pr_number = int(pr.get("index") or pr.get("number")) state.update({"status": "review", "pr": pr_number, "commit": commit, "tests": list(commands)}) self.save_state(state)