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..5dbc29e677 100644 --- a/Bugzilla/Bug.pm +++ b/Bugzilla/Bug.pm @@ -15,6 +15,7 @@ use Bugzilla::Attachment; use Bugzilla::Constants; use Bugzilla::Field; use Bugzilla::Flag; +use Bugzilla::FlagActivity; use Bugzilla::FlagType; use Bugzilla::Hook; use Bugzilla::Keyword; @@ -1221,6 +1222,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] : ''; @@ -4854,6 +4856,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'); @@ -4916,6 +4930,44 @@ sub GetBugActivity { push @args, $bug_id; } + my $flag_bug_when = $dbh->sql_date_format('fsa.flag_when', '%Y-%m-%d %H:%i:%s'); + my $flag_newer = Bugzilla::FlagActivity->newer_than_bugs_activity_sql('fsa'); + my $flag_last = Bugzilla::FlagActivity->last_of_request_sql('fsa'); + my $flag_prev_id = Bugzilla::FlagActivity->previous_id_sql('fsa'); + $query .= " + UNION ALL + SELECT 'flagtypes.name' AS field_name, + fsa.id 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, + changer.login_name AS who, + NULL AS comment_id + FROM flag_activity fsa + JOIN flagtypes ft ON ft.id = fsa.type_id + JOIN profiles changer ON changer.userid = fsa.who + LEFT JOIN profiles req ON req.userid = fsa.requestee_id + LEFT JOIN flag_activity prev ON prev.id = $flag_prev_id + LEFT JOIN profiles preq ON preq.userid = prev.requestee_id + $flag_suppjoins + WHERE fsa.bug_id = ? + AND $flag_newer + AND $flag_last + $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. diff --git a/Bugzilla/BugMail.pm b/Bugzilla/BugMail.pm index a8cd371129..443b39a455 100644 --- a/Bugzilla/BugMail.pm +++ b/Bugzilla/BugMail.pm @@ -17,6 +17,7 @@ use Bugzilla::Constants; use Bugzilla::Util; use Bugzilla::Bug; use Bugzilla::Comment; +use Bugzilla::FlagActivity; use Bugzilla::Logging; use Bugzilla::Mailer; use Bugzilla::Hook; @@ -705,12 +706,17 @@ sub _get_diffs { my ($bug, $end, $user_cache) = @_; my $dbh = Bugzilla->dbh; - my @args = ($bug->id); + my @args = ($bug->id); + my $flag_newer = Bugzilla::FlagActivity->newer_than_bugs_activity_sql('fsa'); + my $flag_last = Bugzilla::FlagActivity->last_of_request_sql('fsa'); + my $flag_prev_id = Bugzilla::FlagActivity->previous_id_sql('fsa'); # 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); } @@ -719,14 +725,42 @@ sub _get_diffs { fielddefs.description AS field_desc, bugs_activity.bug_when, bugs_activity.removed AS old, bugs_activity.added AS new, bugs_activity.attach_id, - bugs_activity.comment_id, bugs_activity.who + bugs_activity.comment_id, bugs_activity.who, + bugs_activity.id AS activity_id FROM bugs_activity INNER JOIN fielddefs ON fielddefs.id = bugs_activity.fieldid WHERE bugs_activity.bug_id = ? $when_restriction - ORDER BY bugs_activity.bug_when, fielddefs.description", {Slice => {}}, - @args + 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.who AS who, + fsa.id AS activity_id + 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 prev.id = $flag_prev_id + LEFT JOIN profiles preq ON preq.userid = prev.requestee_id + WHERE fsa.bug_id = ? + AND $flag_newer + AND $flag_last + $flag_when_restriction + ORDER BY bug_when, field_desc, activity_id", {Slice => {}}, + @args, $bug->id, ($bug->lastdiffed ? ($bug->lastdiffed, $end) : ()) ); my $referenced_bugs = []; diff --git a/Bugzilla/DB/Schema.pm b/Bugzilla/DB/Schema.pm index a2bbd3224a..8f3e43e485 100644 --- a/Bugzilla/DB/Schema.pm +++ b/Bugzilla/DB/Schema.pm @@ -747,6 +747,13 @@ use constant ABSTRACT_SCHEMA => { NOTNULL => 1, REFERENCES => {TABLE => 'profiles', COLUMN => 'userid'}, }, + # The user who made the change. Not always the setter: redirecting a + # request keeps the original setter. + who => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => {TABLE => 'profiles', COLUMN => 'userid'}, + }, requestee_id => { TYPE => 'INT3', REFERENCES => {TABLE => 'profiles', COLUMN => 'userid'}, diff --git a/Bugzilla/Flag.pm b/Bugzilla/Flag.pm index 9d90970636..e601e09c72 100644 --- a/Bugzilla/Flag.pm +++ b/Bugzilla/Flag.pm @@ -494,6 +494,7 @@ sub create { Bugzilla::FlagActivity->create({ flag_when => $timestamp, setter_id => $flag->setter_id, + who => Bugzilla->user->id || $flag->setter_id, status => $flag->status, type_id => $flag->type_id, flag_id => $flag->id, @@ -522,6 +523,7 @@ sub update { Bugzilla::FlagActivity->create({ flag_when => $timestamp, setter_id => $self->setter_id, + who => Bugzilla->user->id || $self->setter_id, status => $self->status, type_id => $self->type_id, flag_id => $self->id, @@ -603,6 +605,7 @@ sub update_flags { Bugzilla::FlagActivity->create({ flag_when => $timestamp, setter_id => Bugzilla->user->id || $old_flag->setter_id, + who => Bugzilla->user->id || $old_flag->setter_id, status => 'X', type_id => $old_flag->type_id, flag_id => $old_flag->id, @@ -725,6 +728,7 @@ sub force_retarget { Bugzilla::FlagActivity->create({ flag_when => $timestamp, setter_id => Bugzilla->user->id || $flag->setter_id, + who => Bugzilla->user->id || $flag->setter_id, status => 'X', type_id => $flag->type_id, flag_id => $flag->id, diff --git a/Bugzilla/FlagActivity.pm b/Bugzilla/FlagActivity.pm index da41a79980..3c91956223 100644 --- a/Bugzilla/FlagActivity.pm +++ b/Bugzilla/FlagActivity.pm @@ -29,6 +29,7 @@ use constant DB_COLUMNS => qw( type_id flag_id setter_id + who requestee_id bug_id attachment_id @@ -69,6 +70,7 @@ use constant VALIDATORS => { type_id => _check_param_required('type_id'), flag_id => _check_param_required('flag_id'), setter_id => _check_param_required('setter_id'), + who => _check_param_required('who'), bug_id => _check_param_required('bug_id'), status => \&_check_status, }; @@ -77,11 +79,52 @@ sub flag_when { return $_[0]->{flag_when} } sub type_id { return $_[0]->{type_id} } sub flag_id { return $_[0]->{flag_id} } sub setter_id { return $_[0]->{setter_id} } +sub who { return $_[0]->{who} } sub bug_id { return $_[0]->{bug_id} } sub requestee_id { return $_[0]->{requestee_id} } sub attachment_id { return $_[0]->{attachment_id} } sub status { return $_[0]->{status} } +# SQL condition on flag_activity row $alias: true when it is newer than the +# bug's last flag change in bugs_activity. Older flag changes are read from +# bugs_activity, which has them all; flag_activity has only some of them, +# written to both tables, which must not be listed twice. +sub newer_than_bugs_activity_sql { + my ($class, $alias) = @_; + return "$alias.flag_when > COALESCE( + (SELECT MAX(ba.bug_when) FROM bugs_activity ba + WHERE ba.bug_id = $alias.bug_id + AND ba.fieldid = (SELECT id FROM fielddefs + WHERE name = 'flagtypes.name')), + '1970-01-01')"; +} + +# A flag created and updated in one request has several rows with the same +# flag_when and who. History shows the request's net change, like +# bugs_activity did: only its last row, compared with the flag's last row +# before the request. Rows are picked by id, which orders same-second rows. + +# SQL condition on flag_activity row $alias: true for the request's last row. +sub last_of_request_sql { + my ($class, $alias) = @_; + return "NOT EXISTS ( + SELECT 1 FROM flag_activity next_fa + WHERE next_fa.flag_id = $alias.flag_id + AND next_fa.flag_when = $alias.flag_when + AND next_fa.who = $alias.who + AND next_fa.id > $alias.id)"; +} + +# SQL subquery: the id of the flag's last row before $alias's request. +sub previous_id_sql { + my ($class, $alias) = @_; + return "(SELECT MAX(prev_fa.id) FROM flag_activity prev_fa + WHERE prev_fa.flag_id = $alias.flag_id + AND prev_fa.id < $alias.id + AND NOT (prev_fa.flag_when = $alias.flag_when + AND prev_fa.who = $alias.who))"; +} + sub type { my ($self) = @_; return $self->{type} diff --git a/Bugzilla/Install/DB.pm b/Bugzilla/Install/DB.pm index 5147beaa07..d13bfa6391 100644 --- a/Bugzilla/Install/DB.pm +++ b/Bugzilla/Install/DB.pm @@ -844,6 +844,10 @@ sub update_table_definitions { # Bug 1949556 - dkl@mozilla.com $dbh->bz_add_index('audit_log', 'audit_log_object_id_idx', ['object_id']); + # Bug 2085398 - xavier.lhour@gmail.com + # Before _migrate_flag_state_activity, which copies into this column. + _add_flag_activity_who(); + # Bug 1806896 - xavier.lhour@gmail.com _migrate_flag_state_activity(); @@ -4531,16 +4535,34 @@ sub _migrate_flag_state_activity { # id is not carried over: nothing references flag_activity.id externally. $dbh->do( 'INSERT INTO flag_activity - (flag_when, type_id, flag_id, setter_id, requestee_id, bug_id, + (flag_when, type_id, flag_id, setter_id, who, requestee_id, bug_id, attachment_id, status) - SELECT flag_when, type_id, flag_id, setter_id, requestee_id, bug_id, - attachment_id, status + SELECT flag_when, type_id, flag_id, setter_id, setter_id, requestee_id, + bug_id, attachment_id, status FROM flag_state_activity' ); $dbh->bz_drop_table('flag_state_activity'); } +sub _add_flag_activity_who { + my $dbh = Bugzilla->dbh; + + return if $dbh->bz_column_info('flag_activity', 'who'); + + # Existing rows don't record who made the change; the setter is the best + # value available. + $dbh->bz_add_column('flag_activity', 'who', + {TYPE => 'INT3', REFERENCES => {TABLE => 'profiles', COLUMN => 'userid'}}); + $dbh->do('UPDATE flag_activity SET who = setter_id'); + $dbh->bz_alter_column('flag_activity', 'who', + { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => {TABLE => 'profiles', COLUMN => 'userid'} + }); +} + sub _remove_duo_recovery_codes { my $dbh = Bugzilla->dbh; diff --git a/Bugzilla/Search.pm b/Bugzilla/Search.pm index 5247e51f06..8745ac825f 100644 --- a/Bugzilla/Search.pm +++ b/Bugzilla/Search.pm @@ -285,7 +285,15 @@ use constant OPERATOR_FIELD_OVERRIDE => { dependson => MULTI_SELECT_OVERRIDE, duplicates => MULTI_SELECT_OVERRIDE, keywords => MULTI_SELECT_OVERRIDE, - 'flagtypes.name' => {_non_changed => \&_flagtypes_nonchanged,}, + 'flagtypes.name' => { + _non_changed => \&_flagtypes_nonchanged, + changedbefore => \&_flagtypes_changed, + changedafter => \&_flagtypes_changed, + changedfrom => \&_flagtypes_changed, + changedto => \&_flagtypes_changed, + changedby => \&_flagtypes_changed, + everchanged => \&_flagtypes_changed, + }, longdesc => { changedby => \&_long_desc_changedby, everchanged => \&_long_desc_everchanged, @@ -3745,6 +3753,74 @@ sub _changed_security_check { } } +# Flag changes are recorded in flag_activity. Older changes are only in +# bugs_activity, so match either. +sub _flagtypes_changed { + my ($self, $args) = @_; + my $dbh = Bugzilla->dbh; + $self->_convert_everchanged($args) if $args->{operator} eq 'everchanged'; + my ($chart_id, $operator, $value, $bugs_table) + = @$args{qw(chart_id operator value bugs_table)}; + + $self->_do_operator_function($args); + my $legacy_term = $args->{term}; + + my $fa = "flag_act_$chart_id"; + my @where = ("$fa.bug_id = $bugs_table.bug_id"); + + if ($operator =~ /^changed(before|after)$/) { + my $sql_operator = $1 eq 'before' ? '<=' : '>='; + my $sql_date = $dbh->quote(SqlifyDate($value)); + push @where, "$fa.flag_when $sql_operator $sql_date"; + } + elsif ($operator eq 'changedby') { + my $user_id = $value =~ /^\d+$/ ? $value : $self->_get_user_id($value); + push @where, "$fa.who = $user_id"; + } + elsif ($operator eq 'changedto') { + my $match = _flag_state_match($fa, $value) or return; + push @where, $match; + } + else { # changedfrom: the state before this change + my $prev = "flag_prev_$chart_id"; + my $match = _flag_state_match($prev, $value) or return; + push @where, "EXISTS ( + SELECT 1 FROM flag_activity $prev + WHERE $prev.id = (SELECT MAX(p2.id) FROM flag_activity p2 + WHERE p2.flag_id = $fa.flag_id AND p2.id < $fa.id) + AND $match)"; + } + + if (!$self->_user->is_insider) { + push @where, "COALESCE((SELECT isprivate FROM attachments + WHERE attach_id = $fa.attachment_id), 0) = 0"; + } + + $args->{term} = "(($legacy_term) OR EXISTS (SELECT 1 FROM flag_activity $fa + WHERE " . join(' AND ', @where) . '))'; +} + +# SQL matching a flag_activity row against a history value like +# "review+" or "review?(user@example.com)". Returns nothing if the value +# isn't a flag state. +sub _flag_state_match { + my ($table, $value) = @_; + my $dbh = Bugzilla->dbh; + my ($name, $status, $requestee) + = $value =~ /^\s*(.+?)([?+\-])(?:\(([^)]+)\))?\s*$/ + or return; + + my $sql = "$table.type_id IN (SELECT id FROM flagtypes WHERE name = " + . $dbh->quote($name) . ") AND $table.status = " . $dbh->quote($status); + if ($status eq '?') { + $sql .= $requestee + ? " AND $table.requestee_id = (SELECT userid FROM profiles" + . ' WHERE login_name = ' . $dbh->quote($requestee) . ')' + : " AND $table.requestee_id IS NULL"; + } + return $sql; +} + sub _isempty { my ($self, $args) = @_; my $full_field = $args->{full_field}; diff --git a/editusers.cgi b/editusers.cgi index 9446ff2922..35dfbf655e 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,20 @@ 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, + who => $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; diff --git a/extensions/BMO/lib/Reports/ReleaseTracking.pm b/extensions/BMO/lib/Reports/ReleaseTracking.pm index fa798a237f..3b6c20b023 100644 --- a/extensions/BMO/lib/Reports/ReleaseTracking.pm +++ b/extensions/BMO/lib/Reports/ReleaseTracking.pm @@ -195,29 +195,31 @@ sub report { my $query = " SELECT DISTINCT b.bug_id FROM bugs b - INNER JOIN flags f ON f.bug_id = b.bug_id\n"; + INNER JOIN flags f ON f.bug_id = b.bug_id + WHERE "; - if ($q->{start_date}) { - $query .= "INNER JOIN bugs_activity a ON a.bug_id = b.bug_id\n"; - } - - $query .= "WHERE "; + my ($type_id) = $dbh->selectrow_array("SELECT id FROM flagtypes WHERE name = ?", + undef, $q->{flag_name}); if ($q->{start_date}) { - push @where, "(a.fieldid = ?)"; - push @params, $q->{field_id}; - - push @where, "(CONVERT_TZ(a.bug_when, 'UTC', 'America/Los_Angeles') >= ?)"; - push @params, $q->{start_date} . ' 00:00:00'; - push @where, "(CONVERT_TZ(a.bug_when, 'UTC', 'America/Los_Angeles') <= ?)"; - push @params, $q->{end_date} . ' 23:59:59'; - - push @where, "(a.added LIKE ?)"; - push @params, '%' . $q->{flag_name} . $q->{flag_status} . '%'; + my @range = ($q->{start_date} . ' 00:00:00', $q->{end_date} . ' 23:59:59'); + + # Flag changes are in flag_activity; older ones only in bugs_activity. + push @where, "(EXISTS ( + SELECT 1 FROM bugs_activity a + WHERE a.bug_id = b.bug_id AND a.fieldid = ? + AND CONVERT_TZ(a.bug_when, 'UTC', 'America/Los_Angeles') BETWEEN ? AND ? + AND a.added LIKE ?) + OR EXISTS ( + SELECT 1 FROM flag_activity fa + WHERE fa.bug_id = b.bug_id + AND fa.type_id = ? AND fa.status = ? + AND CONVERT_TZ(fa.flag_when, 'UTC', 'America/Los_Angeles') BETWEEN ? AND ?))"; + push @params, $q->{field_id}, @range, + '%' . $q->{flag_name} . $q->{flag_status} . '%', + $type_id, $q->{flag_status}, @range; } - my ($type_id) = $dbh->selectrow_array("SELECT id FROM flagtypes WHERE name = ?", - undef, $q->{flag_name}); push @where, "(f.type_id = ?)"; push @params, $type_id; diff --git a/extensions/BMO/lib/Reports/UserActivity.pm b/extensions/BMO/lib/Reports/UserActivity.pm index ddf7cfca37..75e012dd64 100644 --- a/extensions/BMO/lib/Reports/UserActivity.pm +++ b/extensions/BMO/lib/Reports/UserActivity.pm @@ -12,6 +12,7 @@ use strict; use warnings; use Bugzilla::Error; +use Bugzilla::FlagActivity; use Bugzilla::User; use Bugzilla::Util qw(trim); use Bugzilla::Util::DateTime; @@ -55,6 +56,7 @@ sub report { my ($activity_joins, $activity_where) = ('', ''); my ($attachments_joins, $attachments_where) = ('', ''); my ($tags_activity_joins, $tags_activity_where) = ('', ''); + my ($flag_activity_joins, $flag_activity_where) = ('', ''); if (Bugzilla->params->{"insidergroup"} && !Bugzilla->user->in_group(Bugzilla->params->{'insidergroup'})) { @@ -63,6 +65,10 @@ sub report { $activity_where = "AND COALESCE(attachments.isprivate, 0) = 0"; $attachments_where = $activity_where; + $flag_activity_joins = "LEFT JOIN attachments + ON attachments.attach_id = fsa.attachment_id"; + $flag_activity_where = $activity_where; + $tags_activity_joins = 'LEFT JOIN longdescs ON longdescs_tags_activity.comment_id = longdescs.comment_id'; $tags_activity_where = 'AND COALESCE(longdescs.isprivate, 0) = 0'; @@ -93,7 +99,7 @@ sub report { $from_dt = $from_dt->ymd() . ' 00:00:00'; $to_dt = $to_dt->ymd() . ' 23:59:59'; my @params; - for (1 .. 5) { + for (1 .. 6) { push @params, @who; push @params, ($from_dt, $to_dt); } @@ -108,6 +114,10 @@ sub report { $comment_filter = 'AND longdescs.isprivate = 0'; } + # Flag changes are in flag_activity, older ones in bugs_activity. + my $flag_newer = Bugzilla::FlagActivity->newer_than_bugs_activity_sql('fsa'); + my $flag_last = Bugzilla::FlagActivity->last_of_request_sql('fsa'); + my $flag_prev_id = Bugzilla::FlagActivity->previous_id_sql('fsa'); my $query = " SELECT fielddefs.name, @@ -134,6 +144,47 @@ sub report { UNION ALL + SELECT + 'flagtypes.name' AS name, + fsa.bug_id, + fsa.attachment_id AS attach_id, + " + . $dbh->sql_date_format('fsa.flag_when', '%Y-%m-%d %H:%i:%s') + . " AS ts, + 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, + profiles.login_name, + NULL AS comment_id, + fsa.flag_when AS bug_when + FROM flag_activity fsa + $flag_activity_joins + INNER JOIN flagtypes ft + ON ft.id = fsa.type_id + INNER JOIN profiles + ON profiles.userid = fsa.who + LEFT JOIN profiles req + ON req.userid = fsa.requestee_id + LEFT JOIN flag_activity prev + ON prev.id = $flag_prev_id + LEFT JOIN profiles preq + ON preq.userid = prev.requestee_id + WHERE profiles.login_name IN ($who_bits) + AND CONVERT_TZ(fsa.flag_when, 'UTC', 'America/Los_Angeles') >= ? + AND CONVERT_TZ(fsa.flag_when, 'UTC', 'America/Los_Angeles') <= ? + AND $flag_newer + AND $flag_last + $flag_activity_where + + UNION ALL + SELECT 'comment_tag' AS name, longdescs_tags_activity.bug_id, diff --git a/extensions/BugModal/lib/ActivityStream.pm b/extensions/BugModal/lib/ActivityStream.pm index 5745511763..41b0ae8bee 100644 --- a/extensions/BugModal/lib/ActivityStream.pm +++ b/extensions/BugModal/lib/ActivityStream.pm @@ -96,35 +96,35 @@ 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; - - # unfortunately bugs_activity treats all flag changes as the same field, so - # we don't have an object_id to match on + my $flag_id = $flag->id; + my $stream = $self->activity_stream; + + 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, $who) = Bugzilla->dbh->selectrow_array( + "SELECT flag_when, who FROM flag_activity WHERE flag_id = ? + ORDER BY id 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 == $who + && $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 diff --git a/t/flag-activity-history.t b/t/flag-activity-history.t new file mode 100644 index 0000000000..a69265d4da --- /dev/null +++ b/t/flag-activity-history.t @@ -0,0 +1,132 @@ +#!/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::Bug; +use Bugzilla::BugMail; +use Bugzilla::User; +use Test2::V0; + +# Bug history and bugmail read the flag changes made before flag_activity +# existed from bugs_activity, and only the newer ones from flag_activity. + +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', '*'); + +sub new_bug { + return create_bug( + short_desc => shift, + comment => 'Bug for the flag_activity history 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_flag_activity { + my ($bug_id, $when, $flag_id, $who, $requestee_id, $status) = @_; + $dbh->do( + 'INSERT INTO flag_activity + (flag_when, type_id, flag_id, setter_id, who, requestee_id, bug_id, + status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)', undef, $when, $review_id, $flag_id, + $who->id, $who->id, $requestee_id, $bug_id, $status + ); +} + +# [who, removed, added] of the flag changes in bug history and in bugmail. +sub flag_changes { + my ($bug_id) = @_; + + my ($operations) = Bugzilla::Bug::GetBugActivity($bug_id); + my @history = map { + my $who = $_->{who}; + map { [$who, $_->{removed}, $_->{added}] } + grep { $_->{fieldname} eq 'flagtypes.name' } @{$_->{changes}} + } @$operations; + + $dbh->do('UPDATE bugs SET lastdiffed = NULL WHERE bug_id = ?', + undef, $bug_id); + my ($diffs) = Bugzilla::BugMail::_get_diffs(Bugzilla::Bug->new($bug_id), + '2026-12-31 00:00:00', {}); + my @mail = map { [$_->{who}->login, $_->{old}, $_->{new}] } + grep { $_->{field_name} eq 'flagtypes.name' } @$diffs; + + return (\@history, \@mail); +} + +# Legacy changes, only in bugs_activity. +my $legacy_bug = new_bug('legacy'); +for my $row ( + ['2020-01-01 10:00:00', '', 'review?(bob@bmo.test)'], + ['2020-01-02 10:00:00', 'review?(bob@bmo.test)', ''], + ['2020-01-03 10:00:00', '', 'review+'], + ) +{ + $dbh->do( + 'INSERT INTO bugs_activity (bug_id, who, bug_when, fieldid, removed, added) + VALUES (?, ?, ?, ?, ?, ?)', undef, $legacy_bug, $setter->id, $row->[0], + $fieldid, $row->[1], $row->[2] + ); +} + +# The last legacy change was also written to flag_activity, then the flag +# changed again once only flag_activity was written. +add_flag_activity($legacy_bug, '2020-01-03 10:00:00', 42, $setter, undef, '+'); +add_flag_activity($legacy_bug, '2026-01-01 10:00:00', 42, $alice, undef, '-'); + +my $expected = [ + ['setter@bmo.test', '', 'review?(bob@bmo.test)'], + ['setter@bmo.test', 'review?(bob@bmo.test)', ''], + ['setter@bmo.test', '', 'review+'], + ['alice@bmo.test', 'review+', 'review-'], +]; +my ($history, $mail) = flag_changes($legacy_bug); +is($history, $expected, 'history: each flag change once, with old value'); +is($mail, $expected, 'bugmail: each flag change once, with old value'); + +# A flag created and updated in one request has two rows in the same second: +# history shows the request's net change, like bugs_activity did. +my $same_second_bug = new_bug('same second'); +add_flag_activity($same_second_bug, '2026-02-01 10:00:00', 43, $alice, $bob->id, + '?'); +add_flag_activity($same_second_bug, '2026-02-01 10:00:00', 43, $alice, undef, + '+'); +add_flag_activity($same_second_bug, '2026-02-02 10:00:00', 43, $setter, undef, + '-'); + +$expected = [ + ['alice@bmo.test', '', 'review+'], + ['setter@bmo.test', 'review+', 'review-'], +]; +($history, $mail) = flag_changes($same_second_bug); +is($history, $expected, 'history: one net change per request'); +is($mail, $expected, 'bugmail: one net change per request'); + +done_testing; diff --git a/t/flag-activity-search.t b/t/flag-activity-search.t new file mode 100644 index 0000000000..aad72b4360 --- /dev/null +++ b/t/flag-activity-search.t @@ -0,0 +1,95 @@ +#!/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::Search; +use Bugzilla::User; +use Test2::V0; + +# Search changed* on flags matches changes in flag_activity and the older +# ones that are only in bugs_activity. + +create_user('automation@bmo.tld', '*'); +Bugzilla->set_user(Bugzilla::User->super_user); +my $dbh = Bugzilla->dbh; + +my $alice = create_user('alice@bmo.test', '*'); +my $bob = create_user('bob@bmo.test', '*'); + +sub new_bug { + return create_bug( + short_desc => shift, + comment => 'Flag search test bug', + 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'"); + +# Legacy: review+ by bob in 2020, only in bugs_activity. +my $legacy_bug = new_bug('legacy'); +$dbh->do( + 'INSERT INTO bugs_activity (bug_id, who, bug_when, fieldid, removed, added) + VALUES (?, ?, ?, ?, ?, ?)', undef, $legacy_bug, $bob->id, + '2020-01-01 10:00:00', $fieldid, '', 'review+' +); + +# Current: alice asks bob for review, then grants it, only in flag_activity. +my $current_bug = new_bug('current'); +for my $row (['2026-01-01 10:00:00', '?', $bob->id], + ['2026-01-02 10:00:00', '+', undef]) +{ + $dbh->do( + 'INSERT INTO flag_activity + (flag_when, type_id, flag_id, setter_id, who, requestee_id, bug_id, + status) + VALUES (?, ?, 42, ?, ?, ?, ?, ?)', undef, $row->[0], $review_id, + $alice->id, $alice->id, $row->[2], $current_bug, $row->[1] + ); +} + +sub search { + my ($operator, $value) = @_; + my $search = Bugzilla::Search->new( + fields => ['bug_id'], + params => {f1 => 'flagtypes.name', o1 => $operator, v1 => $value}, + ); + return [sort { $a <=> $b } map { $_->[0] } @{$search->data}]; +} + +is(search(changedto => 'review+'), [$legacy_bug, $current_bug], 'changedto'); +is(search(changedto => 'review?(bob@bmo.test)'), [$current_bug], + 'changedto with requestee'); +is(search(changedfrom => 'review?(bob@bmo.test)'), [$current_bug], + 'changedfrom uses the previous flag_activity row'); +is(search(changedfrom => 'review+'), [], 'changedfrom no match'); +is(search(changedbefore => '2021-01-01'), [$legacy_bug], 'changedbefore'); +is(search(changedafter => '2025-01-01'), [$current_bug], 'changedafter'); +is(search(changedby => 'alice@bmo.test'), [$current_bug], + 'changedby flag_activity'); +is(search(changedby => 'bob@bmo.test'), [$legacy_bug], + 'changedby bugs_activity'); +is(search(everchanged => ''), [$legacy_bug, $current_bug], 'everchanged'); + +done_testing; diff --git a/t/flag-activity-who.t b/t/flag-activity-who.t new file mode 100644 index 0000000000..ac11cbb34e --- /dev/null +++ b/t/flag-activity-who.t @@ -0,0 +1,81 @@ +#!/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::MockParams (mail_delivery_method => 'None'); +use Bugzilla::Test::Util qw(create_bug create_user); +use Bugzilla::Bug; +use Bugzilla::User; +use Test2::V0; + +# Redirecting a request keeps the original setter, but flag_activity.who and +# the bug history record the user who redirected it. + +create_user('automation@bmo.tld', '*'); +Bugzilla->set_user(Bugzilla::User->super_user); +my $dbh = Bugzilla->dbh; + +my $requester = create_user('requester@bmo.test', '*'); +my $redirector = create_user('redirector@bmo.test', '*'); +create_user($_, '*') for qw(bob@bmo.test carol@bmo.test); + +my $bug_id = create_bug( + short_desc => 'Flag redirect test bug', + comment => 'Bug for the flag_activity who tests', + bug_type => 'defect', + assigned_to => 'nobody@mozilla.org', +)->id; +my ($needinfo_id) + = $dbh->selectrow_array("SELECT id FROM flagtypes WHERE name = 'needinfo'"); + +Bugzilla->set_user($requester); +my $bug = Bugzilla::Bug->new($bug_id); +$bug->set_flags([], + [{type_id => $needinfo_id, status => '?', requestee => 'bob@bmo.test'}]); +$bug->update('2026-01-01 10:00:00'); + +Bugzilla->set_user($redirector); +$bug = Bugzilla::Bug->new($bug_id); +my ($flag) = @{$bug->flags}; +$bug->set_flags([{id => $flag->id, status => '?', requestee => 'carol@bmo.test'}], + []); +$bug->update('2026-01-01 11:00:00'); + +$flag = Bugzilla::Flag->new($flag->id); +is($flag->setter_id, $requester->id, 'the setter is kept on redirect'); + +my $rows = $dbh->selectall_arrayref( + 'SELECT setter_id, who FROM flag_activity WHERE flag_id = ? ORDER BY id', + {Slice => {}}, $flag->id); +is( + $rows, + [ + {setter_id => $requester->id, who => $requester->id}, + {setter_id => $requester->id, who => $redirector->id}, + ], + 'who is the user who made each change' +); + +my ($operations) = Bugzilla::Bug::GetBugActivity($bug_id); +my ($redirect) = grep { + grep { $_->{fieldname} eq 'flagtypes.name' && $_->{added} =~ /carol/ } + @{$_->{changes}} +} @$operations; +is($redirect->{who}, 'redirector@bmo.test', 'history shows the redirector'); + +done_testing;