|
| 1 | +component extends="wheels.WheelsTest" { |
| 2 | + |
| 3 | + // Regression for issue ##2849 (a follow-up to ##2847). |
| 4 | + // |
| 5 | + // The "Verify resolution, push, or escalate (loop-safe)" step in |
| 6 | + // .github/workflows/bot-resolve-conflicts.yml runs under `set -euo pipefail`. |
| 7 | + // It finishes a resolved-but-uncommitted merge with `git commit --no-edit`, |
| 8 | + // then — if no new commit landed — escalates by applying the |
| 9 | + // `conflict:needs-human` label and posting a comment carrying the |
| 10 | + // `wheels-bot:conflict-attempted` marker. The freshen sweep's skip-check |
| 11 | + // keys off that marker, so the marker is what stops the resolver from being |
| 12 | + // re-dispatched on every cycle. |
| 13 | + // |
| 14 | + // The bug: a *bare* `git commit --no-edit` is fatal under `set -e`. If the |
| 15 | + // commit itself exits non-zero (e.g. a pre-commit hook rejects it), the step |
| 16 | + // aborts at that line, BEFORE the escalation block runs. No marker is posted, |
| 17 | + // the PR stays DIRTY, and the freshen sweep re-dispatches this resolver every |
| 18 | + // cycle (runaway loop). The author's original comment anticipated the |
| 19 | + // "merge aborted -> no new commit" path (which correctly falls through to |
| 20 | + // escalation) but not the "the commit command itself failed" path. |
| 21 | + // |
| 22 | + // The fix arms an exit-time trap that posts the marker on ANY non-zero exit |
| 23 | + // of the step (a hook-rejected commit, a failed push, or any command added |
| 24 | + // later) — satisfying the acceptance criterion that "any failure on the |
| 25 | + // resolve path leaves a conflict-attempted marker". This spec pins that |
| 26 | + // invariant with a static check of the workflow, since the step's behaviour |
| 27 | + // (gh / git side effects against a real checkout) cannot be exercised in a |
| 28 | + // unit test. |
| 29 | + |
| 30 | + function run() { |
| 31 | + |
| 32 | + describe("bot-resolve-conflicts.yml loop-safe escalation (issue ##2849)", () => { |
| 33 | + |
| 34 | + // expandPath("/wheels") resolves to vendor/wheels via the configured |
| 35 | + // Lucee mapping; the repo root is two levels above. |
| 36 | + var repoRoot = expandPath("/wheels/../.."); |
| 37 | + var workflow = repoRoot & "/.github/workflows/bot-resolve-conflicts.yml"; |
| 38 | + |
| 39 | + // Scope assertions to the finalize step (the last step in the file) |
| 40 | + // so we test the loop-safe escalation path specifically, not the |
| 41 | + // separate code-conflict escalation step that shares the same marker. |
| 42 | + var stepAnchor = "Verify resolution, push, or escalate (loop-safe)"; |
| 43 | + |
| 44 | + it("protects the merge commit so a failed commit cannot bypass escalation", () => { |
| 45 | + expect(fileExists(workflow)).toBeTrue("Missing file: " & workflow); |
| 46 | + var src = fileRead(workflow); |
| 47 | + |
| 48 | + var anchorPos = find(stepAnchor, src); |
| 49 | + expect(anchorPos > 0).toBeTrue("Could not find the '" & stepAnchor & "' step in " & workflow); |
| 50 | + var block = mid(src, anchorPos, len(src) - anchorPos + 1); |
| 51 | + |
| 52 | + // The step should still finish a resolved merge with a commit. |
| 53 | + var commitPos = reFindNoCase("git[[:space:]]+commit[[:space:]]+--no-edit", block); |
| 54 | + expect(commitPos > 0).toBeTrue( |
| 55 | + "The finalize step should still finish a resolved-but-uncommitted merge with " |
| 56 | + & "`git commit --no-edit`. See issue ##2849." |
| 57 | + ); |
| 58 | + |
| 59 | + // Protection takes one of the two shapes blessed by the acceptance |
| 60 | + // criteria: an exit-time trap armed BEFORE the commit, or the commit |
| 61 | + // itself guarded so its failure falls through to escalation. |
| 62 | + var trapPos = reFindNoCase("trap[[:space:]]+[^\n]*EXIT", block); |
| 63 | + var trapArmedBeforeCommit = trapPos > 0 && trapPos < commitPos; |
| 64 | + var commitIsGuarded = |
| 65 | + reFindNoCase("if[[:space:]]+![^\n]*git[[:space:]]+commit[[:space:]]+--no-edit", block) > 0 |
| 66 | + || reFindNoCase("git[[:space:]]+commit[[:space:]]+--no-edit[^\n]*\|\|", block) > 0; |
| 67 | + |
| 68 | + expect(trapArmedBeforeCommit || commitIsGuarded).toBeTrue( |
| 69 | + "issue ##2849: under `set -euo pipefail` a bare `git commit --no-edit` that exits " |
| 70 | + & "non-zero (e.g. a pre-commit hook rejects it) aborts the step BEFORE the " |
| 71 | + & "escalation block runs, so no `conflict-attempted` marker is posted and the " |
| 72 | + & "freshen sweep re-dispatches the resolver forever. The finalize step MUST " |
| 73 | + & "either arm an exit-time trap before the commit (`trap ... EXIT`) or guard the " |
| 74 | + & "commit itself (`if ! git commit ...` / `git commit ... ||`) so the failure " |
| 75 | + & "falls through to the escalation path." |
| 76 | + ); |
| 77 | + }); |
| 78 | + |
| 79 | + it("always posts the conflict-attempted marker and needs-human label on the escalation path", () => { |
| 80 | + var src = fileRead(workflow); |
| 81 | + |
| 82 | + var anchorPos = find(stepAnchor, src); |
| 83 | + expect(anchorPos > 0).toBeTrue("Could not find the '" & stepAnchor & "' step in " & workflow); |
| 84 | + var block = mid(src, anchorPos, len(src) - anchorPos + 1); |
| 85 | + |
| 86 | + expect(reFindNoCase("wheels-bot:conflict-attempted:", block) > 0).toBeTrue( |
| 87 | + "The finalize step must post the `wheels-bot:conflict-attempted` marker when " |
| 88 | + & "resolution does not complete — the freshen skip-check keys off it to stop " |
| 89 | + & "re-dispatching the resolver. See issue ##2849." |
| 90 | + ); |
| 91 | + expect(reFindNoCase("conflict:needs-human", block) > 0).toBeTrue( |
| 92 | + "The finalize step must apply the `conflict:needs-human` label on escalation. " |
| 93 | + & "See issue ##2849." |
| 94 | + ); |
| 95 | + expect(reFindNoCase("gh[[:space:]]+pr[[:space:]]+comment", block) > 0).toBeTrue( |
| 96 | + "The finalize step must publish the marker via `gh pr comment`. See issue ##2849." |
| 97 | + ); |
| 98 | + }); |
| 99 | + |
| 100 | + }); |
| 101 | + |
| 102 | + } |
| 103 | + |
| 104 | +} |
0 commit comments