Harden work-item failure recovery

This commit is contained in:
2026-07-13 13:55:37 +02:00
parent 80447092e9
commit 084a0b5058
3 changed files with 90 additions and 16 deletions

View File

@@ -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)])

View File

@@ -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()

View File

@@ -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)