Deleting a merge target un-merges what was folded into it (#1567)
`tickets.merged_into_id` is `nullOnDelete()` and `destroy()` was a one-liner, so deleting the surviving ticket of a merge blanked the pointer on every ticket merged into it and left `status = 'merged'` behind. The row that came out was read-only for good (#920 guards on the status), carried a banner naming a ticket that no longer existed, reported `merged_inconsistent = false` — the flag that exists to catch exactly this — and dropped out of the Merged view, which was scoped by the pointer, leaving it visible only under Resolved. `StrandedMerge::handleTargetDeletion()` runs inside destroy()'s transaction, before the row goes: sources are RE-POINTED at the doomed ticket's own merge target when it has one (the messages really did travel that far up the chain), otherwise UN-MERGED to `closed` — out of the work queues, as they were, but editable, reopenable and answerable again — with a system note naming what was deleted and why. #938's dangling pointers are simply cleared: those rows are somebody's open work. The flash names every ticket that changed, and the delete confirm names them before the click instead of the generic "cannot be undone". The shape prod already holds gets a name: `scopeStrandedMerge()`, reported as `merged_inconsistent` (now the union of both broken shapes) plus `merged_stranded` so a page can tell which fix to offer, listed by the Merged view via `scopeEverMerged()`, and released one at a time by `unmerge`. #938's repair scope is deliberately untouched — it writes `status = 'merged'`, which a stranded row already has, and matching it would break its idempotency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
202
src/Support/StrandedMerge.php
Normal file
202
src/Support/StrandedMerge.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Ticketing\Support;
|
||||
|
||||
use Dashboard\Ticketing\Models\Ticket;
|
||||
use Dashboard\Ticketing\Models\TicketMessage;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* DELETING A MERGE TARGET MUST NOT STRAND WHAT WAS MERGED INTO IT — #1567.
|
||||
*
|
||||
* `tickets.merged_into_id` is declared `nullOnDelete()`, so deleting a ticket
|
||||
* that other tickets had been merged INTO used to blank their pointer and walk
|
||||
* away, leaving `status = 'merged'` behind with nothing on the other end:
|
||||
*
|
||||
* - {@see Ticket::isMerged()} still calls the row read-only, so the page drops
|
||||
* the status / priority / assignee selects and the reply composer, and the
|
||||
* only control left on it is Delete. Nothing can reopen it, reassign it or
|
||||
* reply to it — ever.
|
||||
* - the banner "This ticket has been merged and is read-only." names a merge
|
||||
* target that no longer exists.
|
||||
* - the Merged view was scoped by the pointer, so the row vanished from the one
|
||||
* view whose job is showing merges and turned up only under Resolved.
|
||||
*
|
||||
* And `destroy()` gave no hint any of this was about to happen: the confirm was
|
||||
* the generic "This cannot be undone."
|
||||
*
|
||||
* ── WHAT THIS DOES INSTEAD ──────────────────────────────────────────────────
|
||||
*
|
||||
* {@see handleTargetDeletion()} runs inside `destroy()`'s transaction, BEFORE the
|
||||
* row goes, and gives every ticket pointing at it somewhere real to be:
|
||||
*
|
||||
* RE-POINT, when the doomed ticket was itself merged into a third ticket. The
|
||||
* messages genuinely did travel that far — merge moves them to the target, so
|
||||
* merging the target onward moved them again — and the surviving ticket at the
|
||||
* end of the chain really is where this ticket's content lives. This is the
|
||||
* answer that keeps the merge true.
|
||||
*
|
||||
* UN-MERGE, otherwise. There is nowhere left to point, so the row stops
|
||||
* claiming to be merged: {@see Ticket::STATUS_UNMERGED} (`closed`) and a NULL
|
||||
* pointer, which is an ordinary closed ticket — out of the work queues, but
|
||||
* editable, reopenable and answerable again.
|
||||
*
|
||||
* Either way the source gets a system note saying which ticket was deleted and
|
||||
* what happened to it, because the row is being changed by somebody who was
|
||||
* looking at a different ticket entirely and its own history is the only place
|
||||
* that will ever explain it.
|
||||
*
|
||||
* ── THE ROWS ALREADY OUT THERE ──────────────────────────────────────────────
|
||||
*
|
||||
* This stops new ones. The stranded rows prod already holds are found by
|
||||
* {@see Ticket::scopeStrandedMerge()}, now report `merged_inconsistent`, are
|
||||
* listed by the Merged view, and are released one at a time by
|
||||
* {@see \Dashboard\Ticketing\Http\Controllers\TicketController::unmerge()} —
|
||||
* which calls {@see release()} here, so "un-merged" means one thing whichever
|
||||
* door it came through.
|
||||
*
|
||||
* Deliberately NOT part of {@see MergedTicketRepair}: that class repairs #938's
|
||||
* shape by writing `status = 'merged'` onto a row that has a target. This is the
|
||||
* opposite operation on the opposite shape, and the two must not share a scope.
|
||||
*/
|
||||
class StrandedMerge
|
||||
{
|
||||
/** How many rows are stranded right now — status `merged`, pointer NULL. */
|
||||
public static function count(): int
|
||||
{
|
||||
return Ticket::query()->strandedMerge()->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* The stranded rows themselves.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int,Ticket>
|
||||
*/
|
||||
public static function rows()
|
||||
{
|
||||
return Ticket::query()->strandedMerge()->orderBy('id')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every ticket merged into `$target` somewhere real to be, before
|
||||
* `$target` is deleted. Call inside the deleting transaction.
|
||||
*
|
||||
* Group membership is NOT re-checked per source. A source can live in another
|
||||
* group, and the alternative to touching it is leaving it permanently frozen
|
||||
* — this is the delete's own clean-up, not a write the operator chose to make
|
||||
* on that ticket.
|
||||
*
|
||||
* @return array{repointed: list<string>, unmerged: list<string>, cleared: list<string>}
|
||||
* ticket numbers, by what happened to them
|
||||
*/
|
||||
public static function handleTargetDeletion(Ticket $target, string $by): array
|
||||
{
|
||||
$sources = Ticket::where('merged_into_id', $target->id)->get();
|
||||
|
||||
$result = ['repointed' => [], 'unmerged' => [], 'cleared' => []];
|
||||
|
||||
if ($sources->isEmpty()) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Where the content actually went. `merged_into_id` is nulled when ITS
|
||||
// target is deleted, so this is either a live ticket or nothing.
|
||||
$chain = $target->merged_into_id;
|
||||
|
||||
foreach ($sources as $source) {
|
||||
// #938's shape: the pointer is stale but the status is live, so the
|
||||
// row is an ordinary working ticket that happens to carry a dangling
|
||||
// id. Dropping the id is the whole fix — and it is what the FK was
|
||||
// about to do anyway; doing it here means the row is consistent
|
||||
// inside this transaction rather than as a side effect afterwards.
|
||||
if (! $source->isMerged()) {
|
||||
$source->forceFill(['merged_into_id' => null])->save();
|
||||
$result['cleared'][] = $source->number;
|
||||
continue;
|
||||
}
|
||||
|
||||
// `$chain === $source->id` cannot be produced by `merge()` (it
|
||||
// refuses a merged source and a self-merge), but a cycle would make
|
||||
// the row point at itself, which is worse than un-merging it.
|
||||
if ($chain !== null && $chain !== $source->id) {
|
||||
$chainTicket = Ticket::find($chain);
|
||||
|
||||
if ($chainTicket) {
|
||||
$source->forceFill(['merged_into_id' => $chainTicket->id])->save();
|
||||
|
||||
TicketMessage::system($source, sprintf(
|
||||
'Ticket %s was deleted. This ticket had been merged into it, and %s '
|
||||
.'had itself been merged into %s — where its messages now live — so it '
|
||||
.'is recorded as merged into %s.',
|
||||
$target->number,
|
||||
$target->number,
|
||||
$chainTicket->number,
|
||||
$chainTicket->number,
|
||||
));
|
||||
|
||||
$result['repointed'][] = $source->number;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
self::release(
|
||||
$source,
|
||||
sprintf(
|
||||
'Ticket %s, which this ticket had been merged into, was deleted. This '
|
||||
.'ticket has been un-merged so that it is no longer read-only; its status '
|
||||
.'is now %s.',
|
||||
$target->number,
|
||||
Ticket::STATUS_UNMERGED,
|
||||
),
|
||||
$by,
|
||||
);
|
||||
|
||||
$result['unmerged'][] = $source->number;
|
||||
}
|
||||
|
||||
Log::info(sprintf(
|
||||
'[ticketing] #1567 deleting %s: re-pointed %d, un-merged %d, cleared %d dangling pointer(s) (%s).',
|
||||
$target->number,
|
||||
count($result['repointed']),
|
||||
count($result['unmerged']),
|
||||
count($result['cleared']),
|
||||
$by,
|
||||
));
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-merge ONE ticket: status to {@see Ticket::STATUS_UNMERGED}, pointer to
|
||||
* NULL, and a system note saying why.
|
||||
*
|
||||
* `forceFill`, not `update([...])`, for the same reason the rest of the merge
|
||||
* code uses it: this is the machinery correcting its own state, and it must
|
||||
* not be routed through anything that treats `merged` as read-only.
|
||||
*
|
||||
* Returns false when the row was not merged at all, so a double-clicked
|
||||
* button and a stale page are no-ops rather than a second write.
|
||||
*/
|
||||
public static function release(Ticket $ticket, string $reason, string $by): bool
|
||||
{
|
||||
if (! $ticket->isMerged()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ticket->forceFill([
|
||||
'status' => Ticket::STATUS_UNMERGED,
|
||||
'merged_into_id' => null,
|
||||
])->save();
|
||||
|
||||
TicketMessage::system($ticket, $reason);
|
||||
|
||||
Log::info(sprintf(
|
||||
'[ticketing] #1567 un-merged %s to %s (%s).',
|
||||
$ticket->number,
|
||||
Ticket::STATUS_UNMERGED,
|
||||
$by,
|
||||
));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user