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>
139 lines
5.6 KiB
PHP
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;
|
|
}
|
|
}
|