From e711fa38d99746022501336961e8f07999304a5f Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 2 Jul 2026 13:54:42 +0200 Subject: [PATCH 1/6] Bug 2085398 - Add backfill script to populate flag_activity from bugs_activity --- scripts/flag_activity_backfill.pl | 217 ++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 scripts/flag_activity_backfill.pl diff --git a/scripts/flag_activity_backfill.pl b/scripts/flag_activity_backfill.pl new file mode 100644 index 0000000000..e4b4c5c50e --- /dev/null +++ b/scripts/flag_activity_backfill.pl @@ -0,0 +1,217 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +# Bug 2085398 - Backfill flag_activity from bugs_activity. +# +# Parses historical flagtypes.name rows in bugs_activity and inserts the +# missing records into flag_activity. Safe to run while dual-write is active; +# skips entries already present in flag_activity. +# +# Backfilled rows get flag_id 0: the flag can't be recovered reliably from +# bugs_activity, and bug history and bugmail skip the previous-state lookup +# for flag_id 0. +# +# Works through bugs in bug_id batches, one transaction per batch. +# +# Usage: perl scripts/flag_activity_backfill.pl [--dry-run] + +use 5.10.1; +use strict; +use warnings; + +use File::Basename qw(dirname); +use File::Spec::Functions qw(catdir rel2abs); + +BEGIN { + require lib; + my $dir = rel2abs(catdir(dirname(__FILE__), '..')); + lib->import($dir, catdir($dir, 'lib'), catdir($dir, qw(local lib perl5))); +} + +use Bugzilla; +use Bugzilla::Constants; + +use constant BATCH_SIZE => 1000; + +Bugzilla->usage_mode(USAGE_MODE_CMDLINE); + +my $dry_run = grep { $_ eq '--dry-run' } @ARGV; +print "DRY RUN — no data will be written.\n" if $dry_run; + +my $dbh = Bugzilla->dbh; + +# Get the fieldid for 'flagtypes.name' in bugs_activity. +my ($fieldid) + = $dbh->selectrow_array( + "SELECT id FROM fielddefs WHERE name = 'flagtypes.name'"); +die "Could not find fieldid for 'flagtypes.name' in fielddefs\n" + unless $fieldid; + +# Build a cache of flag type name -> id. +my %type_id_cache; +my $types + = $dbh->selectall_arrayref("SELECT id, name FROM flagtypes", {Slice => {}}); +$type_id_cache{$_->{name}} = $_->{id} for @$types; + +# login_name -> userid, filled as requestees are seen. +my %user_id_cache; + +sub requestee_id { + my ($email) = @_; + return undef unless $email; + $user_id_cache{$email} + //= $dbh->selectrow_array("SELECT userid FROM profiles WHERE login_name = ?", + undef, $email) // 0; + return $user_id_cache{$email} || undef; +} + +# Parse a single flag string like "review?(user@example.com)" or "review+". +# Returns (type_name, status, requestee_email_or_undef) or empty list. +sub parse_flag_str { + my ($str) = @_; + $str =~ s/^\s+|\s+$//g; + return unless $str =~ /^(.+?)([?+\-])(?:\(([^)]+)\))?$/; + return ($1, $2, $3); +} + +# Duplicate keys. The full key includes the requestee, so asking two people +# for the same flag in one change backfills both rows. When the requestee +# can't be resolved (account renamed or deleted since), fall back to the key +# without it so dual-written rows are not inserted twice. +sub short_key { + my ($bug_id, $when, $type_id, $status, $attach_id) = @_; + return join(':', $bug_id, $when, $type_id, $status, $attach_id // ''); +} + +sub full_key { + my ($requestee_id, @short) = @_; + return join(':', short_key(@short), $requestee_id // ''); +} + +my ($max_bug_id) + = $dbh->selectrow_array( + "SELECT MAX(bug_id) FROM bugs_activity WHERE fieldid = ?", + undef, $fieldid); +$max_bug_id //= 0; + +my ($total, $inserted, $skipped, $unknown_type, $unknown_user) + = (0, 0, 0, 0, 0); + +# Flag states to record for one bugs_activity row: +# - Each item in 'added' records its new state directly. +# - Items in 'removed' with no counterpart type in 'added' were deleted. +sub flag_changes { + my ($row) = @_; + my (@changes, %added_types); + + for my $item (split /,\s*/, $row->{added} // '') { + my ($name, $status, $requestee_email) = parse_flag_str($item) or next; + $added_types{$name} = 1; + push @changes, + {name => $name, status => $status, requestee_email => $requestee_email}; + } + + for my $item (split /,\s*/, $row->{removed} // '') { + my ($name, $status, $requestee_email) = parse_flag_str($item) or next; + + # Only record 'X' if this type does not appear in added (truly deleted). + next if $added_types{$name}; + push @changes, + {name => $name, status => 'X', requestee_email => $requestee_email}; + } + + return @changes; +} + +sub backfill_batch { + my ($from, $to) = @_; + + my (%existing, %existing_short); + my $existing_rows = $dbh->selectall_arrayref( + "SELECT bug_id, flag_when, type_id, status, attachment_id, requestee_id + FROM flag_activity + WHERE bug_id BETWEEN ? AND ?", undef, $from, $to + ); + for my $row (@$existing_rows) { + my @short = @$row[0 .. 4]; + $existing{full_key($row->[5], @short)} = 1; + $existing_short{short_key(@short)} = 1; + } + + my $rows = $dbh->selectall_arrayref( + "SELECT bug_id, who, bug_when, removed, added, attach_id + FROM bugs_activity + WHERE fieldid = ? AND bug_id BETWEEN ? AND ? + ORDER BY bug_when, id", {Slice => {}}, $fieldid, $from, $to + ); + return unless @$rows; + + $dbh->bz_start_transaction() unless $dry_run; + + for my $row (@$rows) { + $total++; + + for my $change (flag_changes($row)) { + my $type_id = $type_id_cache{$change->{name}}; + unless ($type_id) { + $unknown_type++; + next; + } + + my $requestee_id = requestee_id($change->{requestee_email}); + $unknown_user++ if $change->{requestee_email} && !$requestee_id; + + my @short = ( + $row->{bug_id}, $row->{bug_when}, $type_id, + $change->{status}, $row->{attach_id} + ); + my $key = full_key($requestee_id, @short); + + # Skip if already in flag_activity. + if ($existing{$key} || (!$requestee_id && $existing_short{short_key(@short)})) { + $skipped++; + next; + } + + unless ($dry_run) { + $dbh->do( + "INSERT INTO flag_activity + (flag_when, type_id, flag_id, setter_id, requestee_id, + bug_id, attachment_id, status) + VALUES (?, ?, 0, ?, ?, ?, ?, ?)", undef, $row->{bug_when}, $type_id, + $row->{who}, $requestee_id, $row->{bug_id}, $row->{attach_id}, $change->{status} + ); + } + + $existing{$key} = 1; + $existing_short{short_key(@short)} = 1; + $inserted++; + } + } + + $dbh->bz_commit_transaction() unless $dry_run; + + printf + " Bugs %d-%d done, %d bugs_activity rows processed (inserted: %d, skipped: %d)\n", + $from, $to, $total, $inserted, $skipped; +} + +print "Processing bugs_activity rows...\n"; + +my $from = 1; +while ($from <= $max_bug_id) { + backfill_batch($from, $from + BATCH_SIZE - 1); + $from += BATCH_SIZE; +} + +print "\nDone.\n"; +printf " bugs_activity rows processed : %d\n", $total; +printf " flag_activity rows inserted : %d\n", $inserted; +printf " already present (skipped) : %d\n", $skipped; +printf " unknown flag type (skipped) : %d\n", $unknown_type; +printf " unknown requestee (NULL inserted): %d\n", $unknown_user; From 66044bee2420ea523d4eae7a607675f036488515 Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 2 Jul 2026 13:57:16 +0200 Subject: [PATCH 2/6] Bug 2085398 - Stop writing flag changes to bugs_activity --- Bugzilla/Attachment.pm | 3 ++- Bugzilla/Bug.pm | 1 + editusers.cgi | 19 +++++++++++++------ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Bugzilla/Attachment.pm b/Bugzilla/Attachment.pm index f3874733ec..1896b6ef7d 100644 --- a/Bugzilla/Attachment.pm +++ b/Bugzilla/Attachment.pm @@ -855,8 +855,9 @@ sub update { # Record changes in the activity table. require Bugzilla::Bug; foreach my $field (keys %$changes) { + next if $field eq 'flagtypes.name'; # now tracked in flag_activity my $change = $changes->{$field}; - $field = "attachments.$field" unless $field eq "flagtypes.name"; + $field = "attachments.$field"; Bugzilla::Bug::LogActivityEntry($self->bug_id, $field, $change->[0], $change->[1], $user->id, $timestamp, undef, $self->id); } diff --git a/Bugzilla/Bug.pm b/Bugzilla/Bug.pm index 062cb43d45..f12d1c6fac 100644 --- a/Bugzilla/Bug.pm +++ b/Bugzilla/Bug.pm @@ -1221,6 +1221,7 @@ sub update { # XXX Eventually, when bugs_activity is able to track the dupe_id, # this code should go below the duplicates-table-updating code below. foreach my $field (keys %$changes) { + next if $field eq 'flagtypes.name'; # now tracked in flag_activity my $change = $changes->{$field}; my $from = defined $change->[0] ? $change->[0] : ''; my $to = defined $change->[1] ? $change->[1] : ''; diff --git a/editusers.cgi b/editusers.cgi index 9446ff2922..21ad3be5f0 100755 --- a/editusers.cgi +++ b/editusers.cgi @@ -20,6 +20,7 @@ use Bugzilla::User; use Bugzilla::Bug; use Bugzilla::BugMail; use Bugzilla::Flag; +use Bugzilla::FlagActivity; use Bugzilla::Field; use Bugzilla::Group; use Bugzilla::Token; @@ -521,13 +522,19 @@ elsif ($action eq 'delete') { push(@{$bugs{$_->bug_id}->{$_->attach_id || 0}}, $_) foreach @$flags; foreach my $bug_id (keys %bugs) { foreach my $attach_id (keys %{$bugs{$bug_id}}) { - my @old_summaries = Bugzilla::Flag->snapshot($bugs{$bug_id}->{$attach_id}); $_->_set_requestee() foreach @{$bugs{$bug_id}->{$attach_id}}; - my @new_summaries = Bugzilla::Flag->snapshot($bugs{$bug_id}->{$attach_id}); - my ($removed, $added) - = Bugzilla::Flag->update_activity(\@old_summaries, \@new_summaries); - LogActivityEntry($bug_id, 'flagtypes.name', $removed, $added, $userid, - $timestamp, undef, $attach_id); + foreach my $flag (@{$bugs{$bug_id}->{$attach_id}}) { + Bugzilla::FlagActivity->create({ + flag_when => $timestamp, + setter_id => $userid, + status => $flag->status, + type_id => $flag->type_id, + flag_id => $flag->id, + requestee_id => $flag->requestee_id, + bug_id => $bug_id, + attachment_id => $attach_id || undef, + }); + } } $sth_set_bug_timestamp->execute($timestamp, $bug_id); $updatedbugs{$bug_id} = 1; From ba89cc24b599f4b324a1a867ee3da0ff95610a90 Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 2 Jul 2026 14:23:49 +0200 Subject: [PATCH 3/6] Bug 2085398 - GetBugActivity reads flag changes from flag_activity --- Bugzilla/Bug.pm | 55 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/Bugzilla/Bug.pm b/Bugzilla/Bug.pm index f12d1c6fac..7605c21866 100644 --- a/Bugzilla/Bug.pm +++ b/Bugzilla/Bug.pm @@ -4855,6 +4855,18 @@ sub GetBugActivity { $suppwhere = "AND COALESCE(attachments.isprivate, 0) = 0"; } + my $flag_suppjoins = ""; + my $flag_suppwhere = ""; + if (!$user->is_insider) { + $flag_suppjoins = "LEFT JOIN attachments fa_att ON fa_att.attach_id = fsa.attachment_id"; + $flag_suppwhere = "AND COALESCE(fa_att.isprivate, 0) = 0"; + } + + my $flag_attachpart = ""; + if ($attach_id) { + $flag_attachpart = "AND fsa.attachment_id = ?"; + } + my $bug_when = $dbh->sql_date_format('bugs_activity.bug_when', '%Y-%m-%d %H:%i:%s'); @@ -4881,7 +4893,8 @@ sub GetBugActivity { ON profiles.userid = bugs_activity.who WHERE bugs_activity.bug_id = ? $attachpart - $suppwhere "; + $suppwhere + AND (fielddefs.name IS NULL OR fielddefs.name != 'flagtypes.name') "; if ( Bugzilla->params->{'comment_taggers_group'} && $include_comment_activity @@ -4917,6 +4930,46 @@ sub GetBugActivity { push @args, $bug_id; } + my $flag_bug_when = $dbh->sql_date_format('fsa.flag_when', '%Y-%m-%d %H:%i:%s'); + $query .= " + UNION ALL + SELECT 'flagtypes.name' AS field_name, + NULL AS activity_id, + fsa.attachment_id AS attach_id, + $flag_bug_when AS bug_when, + CASE WHEN prev.id IS NULL THEN '' + WHEN prev.status = '?' AND preq.login_name IS NOT NULL + THEN CONCAT(ft.name, '?(', preq.login_name, ')') + ELSE CONCAT(ft.name, prev.status) + END AS removed, + CASE WHEN fsa.status = 'X' THEN '' + WHEN fsa.status = '?' AND req.login_name IS NOT NULL + THEN CONCAT(ft.name, '?(', req.login_name, ')') + ELSE CONCAT(ft.name, fsa.status) + END AS added, + setter.login_name AS who, + NULL AS comment_id + FROM flag_activity fsa + JOIN flagtypes ft ON ft.id = fsa.type_id + JOIN profiles setter ON setter.userid = fsa.setter_id + LEFT JOIN profiles req ON req.userid = fsa.requestee_id + LEFT JOIN flag_activity prev + ON fsa.flag_id != 0 + AND prev.flag_id = fsa.flag_id + AND prev.flag_when = ( + SELECT MAX(p2.flag_when) + FROM flag_activity p2 + WHERE p2.flag_id = fsa.flag_id + AND p2.flag_when < fsa.flag_when) + LEFT JOIN profiles preq ON preq.userid = prev.requestee_id + $flag_suppjoins + WHERE fsa.bug_id = ? + $flag_attachpart + $flag_suppwhere + "; + push @args, $bug_id; + push @args, $attach_id if $attach_id; + $query .= ") AS OUTER_TABLE"; # Only consider changes since $starttime, if given. From 9c38108fa64869a9c6dccadcefb05ae14ea5233f Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 2 Jul 2026 14:28:21 +0200 Subject: [PATCH 4/6] Bug 2085398 - BugMail reads flag changes from flag_activity --- Bugzilla/BugMail.pm | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/Bugzilla/BugMail.pm b/Bugzilla/BugMail.pm index a8cd371129..51813c078f 100644 --- a/Bugzilla/BugMail.pm +++ b/Bugzilla/BugMail.pm @@ -708,9 +708,11 @@ sub _get_diffs { my @args = ($bug->id); # If lastdiffed is NULL, then we don't limit the search on time. - my $when_restriction = ''; + my $when_restriction = ''; + my $flag_when_restriction = ''; if ($bug->lastdiffed) { - $when_restriction = ' AND bug_when > ? AND bug_when <= ?'; + $when_restriction = ' AND bug_when > ? AND bug_when <= ?'; + $flag_when_restriction = ' AND fsa.flag_when > ? AND fsa.flag_when <= ?'; push @args, ($bug->lastdiffed, $end); } @@ -725,8 +727,40 @@ sub _get_diffs { ON fielddefs.id = bugs_activity.fieldid WHERE bugs_activity.bug_id = ? $when_restriction - ORDER BY bugs_activity.bug_when, fielddefs.description", {Slice => {}}, - @args + AND fielddefs.name != 'flagtypes.name' + UNION ALL + SELECT 'flagtypes.name' AS field_name, + (SELECT description FROM fielddefs WHERE name = 'flagtypes.name') AS field_desc, + fsa.flag_when AS bug_when, + CASE WHEN prev.id IS NULL THEN '' + WHEN prev.status = '?' AND preq.login_name IS NOT NULL + THEN CONCAT(ft.name, '?(', preq.login_name, ')') + ELSE CONCAT(ft.name, prev.status) + END AS old, + CASE WHEN fsa.status = 'X' THEN '' + WHEN fsa.status = '?' AND req.login_name IS NOT NULL + THEN CONCAT(ft.name, '?(', req.login_name, ')') + ELSE CONCAT(ft.name, fsa.status) + END AS new, + fsa.attachment_id AS attach_id, + NULL AS comment_id, + fsa.setter_id AS who + FROM flag_activity fsa + JOIN flagtypes ft ON ft.id = fsa.type_id + LEFT JOIN profiles req ON req.userid = fsa.requestee_id + LEFT JOIN flag_activity prev + ON fsa.flag_id != 0 + AND prev.flag_id = fsa.flag_id + AND prev.flag_when = ( + SELECT MAX(p2.flag_when) + FROM flag_activity p2 + WHERE p2.flag_id = fsa.flag_id + AND p2.flag_when < fsa.flag_when) + LEFT JOIN profiles preq ON preq.userid = prev.requestee_id + WHERE fsa.bug_id = ? + $flag_when_restriction + ORDER BY bug_when, field_desc", {Slice => {}}, + @args, $bug->id, ($bug->lastdiffed ? ($bug->lastdiffed, $end) : ()) ); my $referenced_bugs = []; From f2c6cc3b6a123e44f90bb76ba4436de7dbcd1db4 Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 2 Jul 2026 14:32:42 +0200 Subject: [PATCH 5/6] Bug 2085398 - ActivityStream uses flag_activity flag_id for lookup --- extensions/BugModal/lib/ActivityStream.pm | 41 ++++++++++++----------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/extensions/BugModal/lib/ActivityStream.pm b/extensions/BugModal/lib/ActivityStream.pm index 5745511763..c71715c51f 100644 --- a/extensions/BugModal/lib/ActivityStream.pm +++ b/extensions/BugModal/lib/ActivityStream.pm @@ -96,35 +96,36 @@ sub find_activity_id_for_attachment { sub find_activity_id_for_flag { my ($self, $flag) = @_; - my $flagtype_name = $flag->type->name; - my $date = $flag->modification_date; - my $setter_id = $flag->setter->id; - my $stream = $self->activity_stream; + my $flag_id = $flag->id; + my $setter_id = $flag->setter->id; + my $stream = $self->activity_stream; - # unfortunately bugs_activity treats all flag changes as the same field, so - # we don't have an object_id to match on + if (!exists $self->{activity_cache}->{flag}->{$flag_id}) { + # flag_activity records each change against the flag's object id, so we + # can look up the exact timestamp without relying on flag-type name matching. + my ($flag_when) = Bugzilla->dbh->selectrow_array( + "SELECT flag_when FROM flag_activity WHERE flag_id = ? + ORDER BY flag_when DESC LIMIT 1", + undef, $flag_id + ); - if (!exists $self->{activity_cache}->{flag}->{$flag->id}) { - foreach my $change_set (reverse @$stream) { + SEARCH: foreach my $change_set (reverse @$stream) { foreach my $activity (@{$change_set->{activity}}) { - - # match by user, timestamp, and flag-type name - next unless $activity->{who}->id == $setter_id && $activity->{when} eq $date; + next unless $flag_when + && $activity->{who}->id == $setter_id + && $activity->{when} eq $flag_when; foreach my $change (@{$activity->{changes}}) { - next - unless $change->{fieldname} eq 'flagtypes.name' - && $change->{flagtype_name} eq $flagtype_name; - $self->{activity_cache}->{flag}->{$flag->id} = $change_set->{id}; - return $change_set->{id}; + next unless $change->{fieldname} eq 'flagtypes.name'; + $self->{activity_cache}->{flag}->{$flag_id} = $change_set->{id}; + last SEARCH; } } } - # if we couldn't find the flag in bugs_activity it means it was set - # during bug creation - $self->{activity_cache}->{flag}->{$flag->id} = 'c0'; + # flag set during bug creation, or no flag_activity entry yet + $self->{activity_cache}->{flag}->{$flag_id} //= 'c0'; } - return $self->{activity_cache}->{flag}->{$flag->id}; + return $self->{activity_cache}->{flag}->{$flag_id}; } # comments are processed first, so there's no need to merge into existing entries From d042c0ceb98a9750b1fe94aa09d08b8d237f5b4a Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Fri, 23 Oct 2026 19:04:26 +0200 Subject: [PATCH 6/6] Bug 2085398 - Add tests for the flag_activity backfill script --- t/flag-activity-backfill.t | 131 +++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 t/flag-activity-backfill.t diff --git a/t/flag-activity-backfill.t b/t/flag-activity-backfill.t new file mode 100644 index 0000000000..6d94115fb3 --- /dev/null +++ b/t/flag-activity-backfill.t @@ -0,0 +1,131 @@ +#!/usr/bin/env perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. +use strict; +use warnings; +use 5.10.1; +use lib qw( . lib local/lib/perl5 ); + +BEGIN { + $ENV{LOG4PERL_CONFIG_FILE} = 'log4perl-t.conf'; + $ENV{BUGZILLA_DISABLE_HOSTAGE} = 1; +} + +use Bugzilla::Test::MockLocalconfig (urlbase => 'http://bmo.test'); +use Bugzilla::Test::MockDB; +use Bugzilla::Test::Util qw(create_bug create_user); +use Bugzilla::User; +use Capture::Tiny qw(capture_stdout); +use Test2::V0; + +# scripts/flag_activity_backfill.pl copies the flag changes recorded in +# bugs_activity into flag_activity, skipping the ones already there. + +# super_user() resolves 'automation@bmo.tld', which the mock DB does not ship. +create_user('automation@bmo.tld', '*'); +Bugzilla->set_user(Bugzilla::User->super_user); +my $dbh = Bugzilla->dbh; + +my $setter = create_user('setter@bmo.test', '*'); +my $alice = create_user('alice@bmo.test', '*'); +my $bob = create_user('bob@bmo.test', '*'); + +my $bug_id = create_bug( + short_desc => 'Backfill test bug', + comment => 'Bug for the flag_activity backfill tests', + bug_type => 'defect', + assigned_to => 'nobody@mozilla.org', +)->id; + +my ($fieldid) + = $dbh->selectrow_array( + "SELECT id FROM fielddefs WHERE name = 'flagtypes.name'"); +my ($review_id) + = $dbh->selectrow_array("SELECT id FROM flagtypes WHERE name = 'review'"); + +sub add_activity { + my ($when, $removed, $added) = @_; + $dbh->do( + 'INSERT INTO bugs_activity (bug_id, who, bug_when, fieldid, removed, added) + VALUES (?, ?, ?, ?, ?, ?)', undef, $bug_id, $setter->id, $when, $fieldid, + $removed, $added + ); +} + +# One change asks two people for review. +add_activity('2020-01-01 10:00:00', + '', 'review?(alice@bmo.test), review?(bob@bmo.test)'); + +# A dual-written change, already in flag_activity with its real flag_id. +add_activity('2020-01-02 10:00:00', 'review?(alice@bmo.test)', 'review+'); +$dbh->do( + 'INSERT INTO flag_activity + (flag_when, type_id, flag_id, setter_id, requestee_id, bug_id, status) + VALUES (?, ?, 42, ?, NULL, ?, ?)', undef, '2020-01-02 10:00:00', $review_id, + $setter->id, $bug_id, '+' +); + +# A flag cleared, asked from an account that no longer exists. +add_activity('2020-01-03 10:00:00', 'review?(gone@bmo.test)', ''); + +sub run_backfill { + local @ARGV = (); + local $SIG{__WARN__} = sub { warn @_ unless $_[0] =~ /redefined/ }; + my $output = capture_stdout { do './scripts/flag_activity_backfill.pl' }; + die $@ if $@; + return $output; +} + +sub rows { + return $dbh->selectall_arrayref( + 'SELECT flag_when, flag_id, requestee_id, status + FROM flag_activity ORDER BY flag_when, requestee_id', {Slice => {}} + ); +} + +my $output = run_backfill(); +like( + $output, + qr/unknown requestee \(NULL inserted\): 1/, + 'the deleted requestee is counted' +); + +is( + rows(), + [ + { + flag_when => '2020-01-01 10:00:00', + flag_id => 0, + requestee_id => $alice->id, + status => '?' + }, + { + flag_when => '2020-01-01 10:00:00', + flag_id => 0, + requestee_id => $bob->id, + status => '?' + }, + { + flag_when => '2020-01-02 10:00:00', + flag_id => 42, + requestee_id => undef, + status => '+' + }, + { + flag_when => '2020-01-03 10:00:00', + flag_id => 0, + requestee_id => undef, + status => 'X' + }, + ], + 'both requestees backfilled, the dual-written row kept once, flag_id 0' +); + +run_backfill(); +is(scalar @{rows()}, 4, 'a second run inserts nothing'); + +done_testing;