Harden work-item failure recovery
This commit is contained in:
@@ -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)])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user