strandedMerge()->count(); } /** * The stranded rows themselves. * * @return \Illuminate\Database\Eloquent\Collection */ 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, unmerged: list, cleared: list} * 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; } }