Phase 2: A 409 Conflict is not recognised as a duplicate pull request #454
No reviewers
Labels
No labels
agent-ready
bug
contract
documentation
duplicate
enhancement
good first issue
help wanted
invalid
needs-human
needs-input
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
iterative-works/iw-cli!454
Loading…
Reference in a new issue
No description provided.
Delete branch "IW-437-phase-02"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The defect
iw phase-pris built to be re-entered: a gate that ends unfinished exits 3 and asks to be calledagain, and the second invocation is meant to recognise that the pull request already exists and read
its url off the review-state card. That recovery was gated on prose —
PhasePr.describesExistingtested the error message for
"already exists"— whileForgejoClient.createPullRequestcollapsedevery unhandled status, 409 included, into
Forgejo API error: 409.GitHub and GitLab worked by accident of vocabulary, because
ghandglabput those words in theirown stderr. Forgejo does not. On this project's own forge the resume the exit-3 contract asks for
could not re-enter at all, and the phase had to be merged by hand.
The cause was the port, not the adapter:
TrackerOps.createPullRequestreturnedEither[String, String], and aStringerror channel cannot carry a status.The open investigation task, answered
Does Forgejo 16.0.1 answer 409 to anything besides a duplicate pull request?
No. In Forgejo
v16.0.1,routers/api/v1/repo/pull.go, theCreatePullRequesthandler returnshttp.StatusConflictfrom exactly one branch — the duplicate check,GetUnmergedPullRequest→issues_model.ErrPullRequestAlreadyExists→ctx.Error(http.StatusConflict, ...). No other path inthat handler returns 409. Source: tag
v16.0.1oncodeberg.org/forgejo/forgejo.code.iterative.workswas confirmed to run 16.0.1 via unauthenticatedGET /api/v1/version.Consequence: the fix classifies a 409 on the status alone and does not inspect the response body.
Testing-requirement item 6 of the phase context (a 409 whose body is not a duplicate classifies as
Other) was explicitly conditional on this answer, so it is not needed.The fix
A duplicate is now recognised by what the forge said, not by how the adapter phrased it.
core/model/ForgeCreateError.scala(new) —enum ForgeCreateErrorwithDuplicateandOther(message).Duplicateis a payload-less marker: the fact of the duplicate is the wholesignal. Its companion carries
fromCliMessage, the one home for the predicate the CLI adaptersshare.
CommandEnv.scala— the port returnsEither[ForgeCreateError, String].ForgejoClient.scala—StatusCode.Conflictmaps toDuplicate. An unmapped status keepsboth the code and a body snippet, so the next surprise stays diagnosable.
GitHubClient.scala,GitLabClient.scala— each classifies its own CLI's wording throughForgeCreateError.fromCliMessage. The prose match did not disappear; it moved to the layer thatowns the tool's vocabulary, exactly as broad as it was before (case-insensitive
contains("already exists")). It was not narrowed: no tool-contract test in this repository pinswhat
ghandglabactually emit, so a tighter match would be a guess.PhasePr.scala—resolvePullRequestmatchesDuplicate;describesExistingis deleted withits comment. The false "the branch was already pushed, you can create the PR manually" line no
longer prints on a failure that is not a duplicate. When a duplicate meets a card that records no
url, the message names the branch the forge refused, so the user has something to search by.
Scope statements the phase context asked for
Forgejo API errorfall-throughs changed.ForgejoClienthas eight, notthe six the analysis counted:
fetchIssue,createIssue,createPullRequest,mergePullRequest,fetchPullRequestForBranch,fetchPullRequestByNumber,fetchPullRequestState,fetchPrHeadSha.Only
createPullRequestis in scope; the other seven are untouched by design, because no callermatches on their text. The asymmetry is deliberate, not an oversight.
gh/glabduplicate wording is an assumption, not a fact. Nothing in this repository pinsit, and no tool-contract test covers it. Moving the match into the adapters puts that assumption
where
core/CLAUDE.mdsays API clients belong, but it remains an assumption.core/commands/CiGate.scala,core/commands/CiGateCommand.scala,core/commands/PhaseMerge.scala,core/model/CiGate.scala. The test contracts that invert belongto phase 3.
Tests
The regression test fails before the fix and passes after: a 409 resumes off the card and exits 0.
ForgejoClientTest— a 409 classifies asDuplicatehowever the body is worded (the stubbedbody is deliberately not "already exists", so the test cannot pass under a regression to prose
matching); an unmapped status keeps code and body; the snippet strips control characters and caps
at 500; the snippet strips Unicode bidirectional formatting.
PhasePrHarnessTest— aDuplicateresumes off the card and gates; a duplicate the card cannotanswer names the branch it was refused for; the control still exits 1, still makes no CI snapshot
call, and its stderr no longer claims manual creation.
GitHubClientTest,GitLabClientTest— each CLI's duplicate wording classifies asDuplicate.ForgeCreateErrorTest(new) — the predicate is case-insensitive whatever case the CLI wrote, andan unrelated message keeps its text verbatim.
./mill core.test— 2499 assertions, 0 failures../iw ./test— exit 0.Review
Two iterations, ten reviewer passes, zero critical issues in either round. Six warnings in
iteration 1 and three in iteration 2, all resolved; full record in
review-phase-02-20260826-091102.md. Two findings were worth the rounds: the 409 test as firstwritten could not have caught a regression to prose matching, and the typed error silently dropped
the forge's own diagnostic on the duplicate-with-unreadable-card path.
🤖 Generated with Claude Code