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])
|
issue = int(endpoint.split("/issues/")[1].split("/")[0])
|
||||||
blockers = state["dependencies"].get(str(issue), [])
|
blockers = state["dependencies"].get(str(issue), [])
|
||||||
out([next(x for x in state["issues"] if x["number"] == number) for number in blockers])
|
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:
|
elif "/pulls/" in endpoint:
|
||||||
number = int(endpoint.rsplit("/", 1)[1])
|
number = int(endpoint.rsplit("/", 1)[1])
|
||||||
out(state["prs"][str(number)])
|
out(state["prs"][str(number)])
|
||||||
|
|||||||
@@ -174,5 +174,54 @@ class StateTests(RepositoryFixture):
|
|||||||
self.assertFalse(workflow.state_path.exists())
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -322,6 +322,13 @@ class Gitea:
|
|||||||
except json.JSONDecodeError as error:
|
except json.JSONDecodeError as error:
|
||||||
raise WorkItemError(f"Could not parse created pull request: {output}") from 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]:
|
def pr(self, number: int) -> dict[str, Any]:
|
||||||
return self.api(f"{self.base}/pulls/{number}")
|
return self.api(f"{self.base}/pulls/{number}")
|
||||||
|
|
||||||
@@ -399,7 +406,12 @@ class Workflow:
|
|||||||
self.git.update_main()
|
self.git.update_main()
|
||||||
story = self.gitea.next_story()
|
story = self.gitea.next_story()
|
||||||
branch = self.git.create_story_branch(story)
|
branch = self.git.create_story_branch(story)
|
||||||
|
try:
|
||||||
self.gitea.claim(story)
|
self.gitea.claim(story)
|
||||||
|
except Exception:
|
||||||
|
self.git.git("switch", self.config.main_branch)
|
||||||
|
self.git.git("branch", "-D", branch)
|
||||||
|
raise
|
||||||
state = {
|
state = {
|
||||||
"issue": story.number,
|
"issue": story.number,
|
||||||
"story_id": story.story_id,
|
"story_id": story.story_id,
|
||||||
@@ -421,6 +433,15 @@ class Workflow:
|
|||||||
state = self.load_state()
|
state = self.load_state()
|
||||||
if self.git.current_branch() != state["branch"]:
|
if self.git.current_branch() != state["branch"]:
|
||||||
raise WorkItemError(f"Expected branch {state['branch']}, found {self.git.current_branch()}")
|
raise WorkItemError(f"Expected branch {state['branch']}, found {self.git.current_branch()}")
|
||||||
|
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={},
|
||||||
|
)
|
||||||
|
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)))
|
commands = tuple(dict.fromkeys((*self.config.required_tests, *tests)))
|
||||||
if not commands:
|
if not commands:
|
||||||
raise WorkItemError("At least one automated test command is required")
|
raise WorkItemError("At least one automated test command is required")
|
||||||
@@ -434,12 +455,14 @@ class Workflow:
|
|||||||
"Review the changes and rerun with --yes to commit:\n"
|
"Review the changes and rerun with --yes to commit:\n"
|
||||||
f"{summary}\nFiles: {', '.join(path.as_posix() for path in paths)}"
|
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"])
|
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 = self.gitea.create_pr(story, state["branch"], commands, commit)
|
||||||
pr_number = int(pr.get("index") or pr.get("number"))
|
pr_number = int(pr.get("index") or pr.get("number"))
|
||||||
state.update({"status": "review", "pr": pr_number, "commit": commit, "tests": list(commands)})
|
state.update({"status": "review", "pr": pr_number, "commit": commit, "tests": list(commands)})
|
||||||
|
|||||||
Reference in New Issue
Block a user