Files
central-ticketing/src/Support/MergedTicketRepair.php
central-worker-8 9cc2e59b45 #4781: the bulk merged-repair is on the record too
MergedTicketRepair::repairAll() re-marks tickets as merged (permanently
read-only) per model instead of with one builder update, so each frozen
ticket gets its own Changes-log entry — the same as the per-ticket button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 05:16:23 -07:00

139 lines
5.6 KiB
PHP

<?php
namespace Dashboard\Ticketing\Support;
use App\Models\Setting;
use Dashboard\Ticketing\Models\Ticket;
use Illuminate\Support\Facades\Log;
/**
* REPAIRING THE ROWS THE MERGE BUG MINTED — [central-ticketing] #938.
*
* #920 stopped the bug making new ones: a ticket whose `status` is `merged` is
* read-only server-side, so the Edit form can no longer write `status = open`
* over a row that has already been folded into another ticket. It shipped NO
* data change, on purpose, and its guard reads the status rather than the
* pointer precisely so the rows already carrying the corrupt shape
* (`status = open, merged_into_id = 56`) stay editable and can be corrected
* through the UI instead of by hand in SQL.
*
* This class is the correction, and it is deliberately ONE implementation with
* three callers, because "which rows are broken" and "what fixing one means"
* are the two things that must not drift:
*
* - {@see \Dashboard\Ticketing\Http\Controllers\TicketController::markMerged()}
* — the office fixes one row, from the badge on the ticket header.
* - {@see \Dashboard\Ticketing\Console\Commands\RepairMergedTickets}
* — `--dry-run` is the COUNT, and it is the whole reason the class is not
* just a line inside the migration: the operator has to be able to see how
* many rows there are, and which, before anything writes.
* - the `2026_09_04_000001_repair_inconsistent_merged_tickets` migration
* — every row at once, on deploy, and ONLY when the operator has turned
* {@see SETTING} on.
*
* ── WHAT REPAIRING DOES *NOT* DO ────────────────────────────────────────────
*
* It writes `status = 'merged'` and nothing else. Messages, participants,
* assignee, priority and title are left exactly as they are, including edits
* made to the row AFTER the merge — those are somebody's work, they are the
* only copy, and there is no record of what the fields held before the bad
* save to revert them to. The ticket becoming read-only is the whole change.
*/
class MergedTicketRepair
{
/**
* "Auto-repair inconsistent merged tickets on deploy", and it DEFAULTS OFF.
*
* A deploy that silently rewrites rows nobody asked it to rewrite is the one
* outcome #938 rules out by name, so the migration reads this and no-ops
* when it is unset. Off is therefore the whole behaviour of a box that has
* never heard of this setting, which is what makes shipping the migration
* safe before the operator has decided anything.
*/
public const SETTING = 'ticketing.auto_repair_merged_on_deploy';
/** How many rows are inconsistent right now. The dry run, and it writes nothing. */
public static function count(): int
{
return Ticket::query()->inconsistentlyMerged()->count();
}
/**
* The rows themselves, for a human to look at before deciding.
*
* @return \Illuminate\Database\Eloquent\Collection<int,Ticket>
*/
public static function rows()
{
return Ticket::query()->inconsistentlyMerged()->orderBy('id')->get();
}
/** Has the operator asked for the deploy-time repair? */
public static function enabledOnDeploy(): bool
{
return filter_var(Setting::get(self::SETTING, '0'), FILTER_VALIDATE_BOOLEAN);
}
/**
* Re-mark every inconsistent row as merged. Returns how many rows changed.
*
* IDEMPOTENT, and it is the scope that makes it so rather than a flag: the
* second run matches nothing, because the first run's rows no longer satisfy
* `status <> 'merged'`. So a migration that is re-run, a command run twice
* and a deploy that repeats all answer 0 and write nothing.
*/
public static function repairAll(string $by): int
{
$tickets = Ticket::query()->inconsistentlyMerged()->orderBy('id')->get();
if ($tickets->isEmpty()) {
Log::info("[ticketing] #938 merged-ticket repair ({$by}): nothing to repair.");
return 0;
}
// Per model, not one builder update (#4781/#4807): `merged` makes a ticket
// permanently read-only, and a bulk statement would freeze it with nothing
// on the Changes log — while repair() below, the same change one ticket at
// a time, is recorded.
$tickets->each(fn (Ticket $ticket) => $ticket->forceFill(['status' => Ticket::STATUS_MERGED])->save());
$numbers = $tickets->pluck('number')->all();
$changed = $tickets->count();
// The numbers, not just the count. These rows are being made read-only,
// and "which ones" is the question the office will ask afterwards.
Log::info(sprintf(
'[ticketing] #938 merged-ticket repair (%s): re-marked %d ticket(s) as merged — %s',
$by,
$changed,
implode(', ', $numbers),
));
return $changed;
}
/**
* Re-mark ONE row. Returns false when it was not inconsistent to begin with,
* so a double-clicked button and a stale page are both no-ops rather than a
* second write.
*/
public static function repair(Ticket $ticket, string $by): bool
{
if (! $ticket->isInconsistentlyMerged()) {
return false;
}
$ticket->forceFill(['status' => Ticket::STATUS_MERGED])->save();
Log::info(sprintf(
'[ticketing] #938 merged-ticket repair (%s): re-marked %s as merged (merged into #%d).',
$by,
$ticket->number,
$ticket->merged_into_id,
));
return true;
}
}