Repository navigation
Fix #1011: Finish parked builds when GitHub PR is merged - #1070
Conversation
3d8c153 to
154610f
Compare
czpython
left a comment
There was a problem hiding this comment.
Thanks, the fix works. Answering ReviewWork with merged lets the build return, so DBOS records SUCCESS. I checked the parts that worried me:
resolveand the park-time subscriber can both answer the same round.Run.resumekeys each reply on the parked round, so the second send collapses.- DBOS accepts
sendfrom inside a step, so the answer fromrun.parked:propagatereaches the gate. - An operator cannot post
mergedfrom the UI.ReviewWorkasks on GitHub and offers no in-app controls. - The
result.action == "merged"check inbuild_start_marks_ticket_in_progressis needed. The DBOS send commits before the webhook transaction does, so the subscriber can still read an emptyresolution.
Before merge, please cut the PR down. The inline comments have the details:
- Remove
_is_merged. - Revert
.gitignore. - Remove the new test in
test_durable_sdk.py. - Patch
ReviewWork.answer, notDBOS.send_async. - Rewrite the PR description. It should say what the change does and end with
Closes #1011.The test report, the local paths, and the MCP recovery tooling are not part of this diff.
One limit, not blocking: the park-time check makes a stranded build rare, but it does not exclude it. If the workflow commits its park after resolve reads the status but before the webhook commits, both sides miss it. That window is milliseconds.
| "url": f"https://github.com/{(await self.subject).repo}/pull/{self.pr_number}", | ||
| } | ||
| ) | ||
| if decision.action == "merged" or await self._is_merged(): |
There was a problem hiding this comment.
Please drop _is_merged and keep if decision.action == "merged": return True. When a merge lands while the build runs, the PARKED subscriber already answers merged at the next park. The step only covers the moment between the reply and this line, and it adds a step to every review reply. Dropping it also removes the __wrapped__ patches in test_build_plan_phase.py and test_merged_work_exits_review_without_more_work.
| /dist/ | ||
| # ADRs are private, kept out of the repo. | ||
| /docs/adr/ | ||
| python_combine.py |
There was a problem hiding this comment.
These are local files. Please revert this hunk. It also drops the trailing newline.
|
|
||
|
|
||
| @pytest.mark.parametrize("merge_timing", ["parked", "before_park", "after_review"]) | ||
| async def test_merged_build_finishes_successfully(runtime, monkeypatch, merge_timing): |
There was a problem hiding this comment.
Please remove this test. test_durable_sdk.py tests the platform, and this test makes it import software_factory and the dashboard. It mocks eight Build internals, and most of what it checks is DBOS behavior. The tests in software_factory/ already cover the policy. If you want one real-DBOS check, keep only the parked case, put it in backend/tests/software_factory/, and drop the dashboard asserts.
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def merged_reply(monkeypatch): |
There was a problem hiding this comment.
Patch ReviewWork.answer instead of DBOS.send_async, and only in the two tests that merge a parked run. test_lane_reactions.py already does it that way. Then assert on the answer call, not on the DBOS send arguments.
| merged, | ||
| ): | ||
| send = AsyncMock() | ||
| monkeypatch.setattr("dbos.DBOS.send_async", send) |
There was a problem hiding this comment.
Same here: patch ReviewWork.answer, not DBOS.
| class ReviewWork(Gate): | ||
| """The verdict on the built work: approve/request_changes (PR webhook) or | ||
| revise_contract (UI).""" | ||
| revise_contract (UI), or merged (GitHub closed the PR with a merge).""" |
There was a problem hiding this comment.
Nit: "approve/request_changes (PR review webhook), revise_contract (UI), or merged (pr.closed webhook)."
|
Implemented the requested review cleanup and pushed:
Another full local UI test isn’t necessary for this cleanup. Most changes removed unnecessary code or adjusted tests. The 69 focused tests passed. |
czpython
left a comment
There was a problem hiding this comment.
Thanks for the quick turn. One more pass, and it is all removal.
After the implementer opens the PR, a Build can park only on review_work. _plan_phase has the only other park, and the build never goes back to it after _implement_phase starts. So three branches in this PR cover states that cannot occur:
elif status.is_parked: await Build.cancel(self)inresolve.not subject.resolutionin the RUNNING subscriber.elif not subject.resolutionin the PARKED subscriber.
Please remove them and the tests that pin them. The inline comments have the details. After this, the resolve hunk is one line from main: the cancel becomes the answer.
| status = await self.get_status(workflow=Build) | ||
| if status.is_parked and status.gate == ReviewWork.name: | ||
| await ReviewWork.answer(self, action="merged") | ||
| elif status.is_parked: |
There was a problem hiding this comment.
A Build with a PR can only be parked on review_work, so this branch never runs. Please remove it:
if (await self.get_status(workflow=Build)).is_parked:
await ReviewWork.answer(self, action="merged")If a park on another gate ever appears, ReviewWork.answer raises, and that is what we want.
| *, subject: WorkItem, result: dict | None = None, **_: object | ||
| ) -> None: | ||
| # The gate reply can arrive before the merge webhook commits its resolution. | ||
| if not subject.resolution and (not result or result.get("action") != "merged"): |
There was a problem hiding this comment.
The result check is the real fix: the DBOS send commits before the webhook commits resolution. The not subject.resolution half never matters. A closed or cancelled build is cancelled and never resumes. A merged build resumes only through the merged reply. Every Build reply has action, so read it directly:
# A merged reply ends the build, and the merge already moved the ticket to Done.
if not result or result["action"] != "merged":
await subject.set_ticket_status(TicketStatus.IN_PROGRESS)| # A merge can arrive before the gate parks, when there is no ask to answer yet. | ||
| if subject.resolution == Resolution.MERGED: | ||
| await ReviewWork.answer(subject, action="merged") | ||
| elif not subject.resolution: |
There was a problem hiding this comment.
A review_work park never happens with a closed or cancelled resolution, so this can be a plain else.
| assert reparked == [True] | ||
|
|
||
|
|
||
| async def test_merged_work_exits_review_without_more_work(monkeypatch): |
There was a problem hiding this comment.
Please remove this test. It only shows that if decision.action == "merged": return True returns True.
|
|
||
|
|
||
| @pytest.mark.parametrize("resolution", ["merged", "closed", "cancelled"]) | ||
| async def test_resolved_build_resume_keeps_ticket_status(druks_db, monkeypatch, resolution): |
There was a problem hiding this comment.
Please remove this test. It pins the not subject.resolution guard, which goes away. Keep the two tests below it.
| assert not (await _fresh_run(run_id)).is_active | ||
| answer.assert_awaited_once_with(item, action="merged") | ||
| run = await _fresh_run(run_id) | ||
| assert run.state == "parked" |
There was a problem hiding this comment.
With answer mocked, these three checks only show that cancel did not run. The answer.assert_awaited_once_with(...) above is enough. Please remove them and the _fresh_run call.
| assert await _milestone_count(work_item_id, "merged") == 0 | ||
| assert not (await _fresh_run(run_id)).is_active | ||
| run = await _fresh_run(run_id) | ||
| assert run.state == "cancelled" |
There was a problem hiding this comment.
This PR does not change the closed-unmerged path. Please revert this hunk.
| assert [event.type for event in events] == [outcome] | ||
| assert events[0].payload == {"repo": "acme/widget", "pr_number": 42} | ||
| assert item.resolution == outcome | ||
| if merged and state == "parked": |
There was a problem hiding this comment.
Keep the answer patch, but please remove these checks. This test is about the activity record, and the webhook test already pins the answer call.
| ) | ||
|
|
||
| await run.cancel(failure="pr merged while parked") | ||
| await run.cancel(failure="operator cancelled") |
There was a problem hiding this comment.
With the cancel branch gone, this PR has no reason to change this string. Please revert this file.
When GitHub merges a pull request while its build waits for implementation review, answer the review gate with
mergedso the workflow returns successfully and the build finishes. Also answer the gate when a merge is already recorded as the build parks, and keep resolved tickets from moving back to In Progress on resume.Closing a pull request without merging still cancels the build with
pr closed without merge. Activity shows “Build finished” and “Merged on GitHub” for the successful path.Closes #1011