A row the merge bug minted is visible, fixable, and repairable in bulk (#938)

#920 made a merged ticket read-only on the server and shipped NO data change,
deliberately: its guard reads `status === 'merged'` and not
`merged_into_id !== null`, so the rows the bug had already produced — the
pointer set, the status back to `open` — stayed EDITABLE and could be corrected
in the product instead of in SQL. What it left behind was rows that are
simultaneously live in the All Open queue and merged into another ticket, with
nothing on any screen saying so and nothing offering to fix them.

Both halves of the card ship, because option A is a BEHAVIOUR RULE and a
behaviour rule is a setting:

  B — the office fixes them one at a time. `merged_inconsistent` rides on the
      model (Ticket::$appends), so the list row, the index detail pane and the
      Show header all draw the same amber badge — "merged_into set but status
      open — fix?" — off one predicate rather than three copies of an
      expression. The badge IS the button: it opens a modal that says what will
      change and what will not, and posts to a new `mark-merged` verb that
      writes `status` and nothing else. A new "Merged" view lists every ticket
      folded into another one BY THE POINTER, so the consistent and the broken
      appear side by side; a status filter would show one or the other and
      never the pair worth comparing.

  A — the migration re-marks all of them at once, and it is INERT unless
      `ticketing.auto_repair_merged_on_deploy` is on. It defaults OFF, which is
      also what a box that has never heard of the setting does, so this can
      ship ahead of the operator's decision and change nothing unasked. The
      toggle is on the Automation tab with the live count beside it, because
      nobody should arm a bulk data change without seeing how many rows it
      touches.

`MergedTicketRepair` is one implementation with three callers for one reason:
"which rows are broken" and "what fixing one means" must not drift, or the
badge offers a fix on a row the migration skips.

THE PROD COUNT THE CARD ASKS FOR IS NOT IN HERE, and it could not be: this
worker box holds no ssh private key, so central-prod is unreachable from it and
there is no prod container locally. `php artisan ticketing:repair-merged
--dry-run` is the read-only answer for whoever does have access — it prints the
card's own SELECT COUNT and lists the rows, and `--force` (not the bare form)
is the only spelling that writes.

Repairing NEVER reverts. Messages, participants, assignee and title edits made
to a row while it was wrongly editable are somebody's work and the only copy of
it; there is no record of what the fields held before the bad save, so
"reverting" would invent a previous state rather than restore one.

Proved by tests/Feature/Ticketing/MergedTicketInconsistentRowsTest.php (22
tests) in dashboard-shell, over the fixture the card names — one inconsistent
row beside a genuinely merged one, both produced by the real merge endpoint.
The NEGCTRL half is what keeps it honest: the consistent row and an ordinary
open ticket must come out of every path untouched, and a repaired row must keep
the reply and the rename it was given after the merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
central-worker-4
2026-09-04 02:53:23 -07:00
parent 5315afc239
commit 420ef78603
8 changed files with 391 additions and 1 deletions

View File

@@ -2,6 +2,7 @@
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -37,6 +38,19 @@ class Ticket extends Model
// value) still stores the right day — measured in UTC and in
// America/New_York. So nothing was ever lost in the database; the value
// simply had no way of reaching the screen.
/**
* `merged_inconsistent` rides on every serialized ticket — #938.
*
* The badge and its "Mark merged" button are rendered per ROW, on the index
* list, in the detail pane and on the Show header, so the page needs the
* answer for each ticket it draws. Handing it the model's own predicate
* rather than letting three Vue files each write
* `t.merged_into_id && t.status !== 'merged'` is what keeps the client's idea
* of "inconsistent" from drifting away from the scope the repair uses.
* It costs no query — both columns are already loaded.
*/
protected $appends = ['merged_inconsistent'];
protected $casts = [
'due_date' => 'date:Y-m-d',
];
@@ -96,4 +110,38 @@ class Ticket extends Model
{
return $this->status === self::STATUS_MERGED;
}
/**
* THE SHAPE THE MERGE BUG MINTED — [central-ticketing] #938.
*
* `merged_into_id` points at a target while `status` is something other than
* `merged`: the row was merged, and then a save through the unguarded Edit
* form (#920) put the status back. #920 stopped new ones being made and
* deliberately left the existing ones EDITABLE — `isMerged()` reads the
* status, not the pointer — so that the office can correct one rather than
* needing SQL. This is the predicate that finds them.
*
* It is one definition and three callers read it: the badge that offers the
* fix, the `mark-merged` verb that applies it one row at a time, and the
* repair migration that applies it to all of them at once. Two spellings of
* "inconsistent" that drifted apart would mean a badge on a row the repair
* skips.
*/
public function scopeInconsistentlyMerged(Builder $query): Builder
{
return $query->whereNotNull('merged_into_id')
->where('status', '!=', self::STATUS_MERGED);
}
/** The row-level twin of {@see scopeInconsistentlyMerged()}. */
public function isInconsistentlyMerged(): bool
{
return $this->merged_into_id !== null && ! $this->isMerged();
}
/** @see $appends — the same answer, for the page. */
public function getMergedInconsistentAttribute(): bool
{
return $this->isInconsistentlyMerged();
}
}