summaryrefslogtreecommitdiffstats
path: root/extensions/BMO/lib
diff options
context:
space:
mode:
Diffstat (limited to 'extensions/BMO/lib')
-rw-r--r--extensions/BMO/lib/Reports.pm1191
-rw-r--r--extensions/BMO/lib/Reports/EmailQueue.pm69
-rw-r--r--extensions/BMO/lib/Reports/Groups.pm243
-rw-r--r--extensions/BMO/lib/Reports/ReleaseTracking.pm393
-rw-r--r--extensions/BMO/lib/Reports/Triage.pm217
-rw-r--r--extensions/BMO/lib/Reports/UserActivity.pm302
-rw-r--r--extensions/BMO/lib/Util.pm84
7 files changed, 1308 insertions, 1191 deletions
diff --git a/extensions/BMO/lib/Reports.pm b/extensions/BMO/lib/Reports.pm
deleted file mode 100644
index 0f3a391d9..000000000
--- a/extensions/BMO/lib/Reports.pm
+++ /dev/null
@@ -1,1191 +0,0 @@
-# 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.
-
-package Bugzilla::Extension::BMO::Reports;
-use strict;
-
-use Bugzilla::Extension::BMO::Data qw($cf_disabled_flags);
-
-use Bugzilla::Constants;
-use Bugzilla::Error;
-use Bugzilla::Field;
-use Bugzilla::Group;
-use Bugzilla::User;
-use Bugzilla::Util qw(trim detaint_natural trick_taint correct_urlbase);
-
-use Date::Parse;
-use DateTime;
-use JSON qw(-convert_blessed_universally);
-use List::MoreUtils qw(uniq);
-
-use base qw(Exporter);
-
-our @EXPORT = qw( user_activity_report
- triage_reports
- group_admins_report
- email_queue_report
- release_tracking_report
- group_membership_report
- group_members_report );
-
-sub user_activity_report {
- my ($vars) = @_;
- my $dbh = Bugzilla->dbh;
- my $input = Bugzilla->input_params;
-
- my @who = ();
- my $from = trim($input->{'from'} || '');
- my $to = trim($input->{'to'} || '');
- my $action = $input->{'action'} || '';
-
- # fix non-breaking hyphens
- $from =~ s/\N{U+2011}/-/g;
- $to =~ s/\N{U+2011}/-/g;
-
- if ($from eq '') {
- my $dt = DateTime->now()->subtract('weeks' => 8);
- $from = $dt->ymd('-');
- }
- if ($to eq '') {
- my $dt = DateTime->now();
- $to = $dt->ymd('-');
- }
-
- if ($action eq 'run') {
- if ($input->{'who'} eq '') {
- ThrowUserError('user_activity_missing_username');
- }
- Bugzilla::User::match_field({ 'who' => {'type' => 'multi'} });
-
- my $from_dt = _string_to_datetime($from);
- $from = $from_dt->ymd();
-
- my $to_dt = _string_to_datetime($to);
- $to = $to_dt->ymd();
- # add one day to include all activity that happened on the 'to' date
- $to_dt->add(days => 1);
-
- my ($activity_joins, $activity_where) = ('', '');
- my ($attachments_joins, $attachments_where) = ('', '');
- if (Bugzilla->params->{"insidergroup"}
- && !Bugzilla->user->in_group(Bugzilla->params->{'insidergroup'}))
- {
- $activity_joins = "LEFT JOIN attachments
- ON attachments.attach_id = bugs_activity.attach_id";
- $activity_where = "AND COALESCE(attachments.isprivate, 0) = 0";
- $attachments_where = $activity_where;
- }
-
- my @who_bits;
- foreach my $who (
- ref $input->{'who'}
- ? @{$input->{'who'}}
- : $input->{'who'}
- ) {
- push @who, $who;
- push @who_bits, '?';
- }
- my $who_bits = join(',', @who_bits);
-
- if (!@who) {
- my $template = Bugzilla->template;
- my $cgi = Bugzilla->cgi;
- my $vars = {};
- $vars->{'script'} = $cgi->url(-relative => 1);
- $vars->{'fields'} = {};
- $vars->{'matches'} = [];
- $vars->{'matchsuccess'} = 0;
- $vars->{'matchmultiple'} = 1;
- print $cgi->header();
- $template->process("global/confirm-user-match.html.tmpl", $vars)
- || ThrowTemplateError($template->error());
- exit;
- }
-
- $from_dt = $from_dt->ymd() . ' 00:00:00';
- $to_dt = $to_dt->ymd() . ' 23:59:59';
- my @params;
- for (1..4) {
- push @params, @who;
- push @params, ($from_dt, $to_dt);
- }
-
- my $order = ($input->{'sort'} && $input->{'sort'} eq 'bug')
- ? 'bug_id, bug_when' : 'bug_when';
-
- my $comment_filter = '';
- if (!Bugzilla->user->is_insider) {
- $comment_filter = 'AND longdescs.isprivate = 0';
- }
-
- my $query = "
- SELECT
- fielddefs.name,
- bugs_activity.bug_id,
- bugs_activity.attach_id,
- ".$dbh->sql_date_format('bugs_activity.bug_when', '%Y.%m.%d %H:%i:%s')." AS ts,
- bugs_activity.removed,
- bugs_activity.added,
- profiles.login_name,
- bugs_activity.comment_id,
- bugs_activity.bug_when
- FROM bugs_activity
- $activity_joins
- LEFT JOIN fielddefs
- ON bugs_activity.fieldid = fielddefs.id
- INNER JOIN profiles
- ON profiles.userid = bugs_activity.who
- WHERE profiles.login_name IN ($who_bits)
- AND bugs_activity.bug_when >= ? AND bugs_activity.bug_when <= ?
- $activity_where
-
- UNION ALL
-
- SELECT
- 'bug_id' AS name,
- bugs.bug_id,
- NULL AS attach_id,
- ".$dbh->sql_date_format('bugs.creation_ts', '%Y.%m.%d %H:%i:%s')." AS ts,
- '(new bug)' AS removed,
- bugs.short_desc AS added,
- profiles.login_name,
- NULL AS comment_id,
- bugs.creation_ts AS bug_when
- FROM bugs
- INNER JOIN profiles
- ON profiles.userid = bugs.reporter
- WHERE profiles.login_name IN ($who_bits)
- AND bugs.creation_ts >= ? AND bugs.creation_ts <= ?
-
- UNION ALL
-
- SELECT
- 'longdesc' AS name,
- longdescs.bug_id,
- NULL AS attach_id,
- DATE_FORMAT(longdescs.bug_when, '%Y.%m.%d %H:%i:%s') AS ts,
- '' AS removed,
- '' AS added,
- profiles.login_name,
- longdescs.comment_id AS comment_id,
- longdescs.bug_when
- FROM longdescs
- INNER JOIN profiles
- ON profiles.userid = longdescs.who
- WHERE profiles.login_name IN ($who_bits)
- AND longdescs.bug_when >= ? AND longdescs.bug_when <= ?
- $comment_filter
-
- UNION ALL
-
- SELECT
- 'attachments.description' AS name,
- attachments.bug_id,
- attachments.attach_id,
- ".$dbh->sql_date_format('attachments.creation_ts', '%Y.%m.%d %H:%i:%s')." AS ts,
- '(new attachment)' AS removed,
- attachments.description AS added,
- profiles.login_name,
- NULL AS comment_id,
- attachments.creation_ts AS bug_when
- FROM attachments
- INNER JOIN profiles
- ON profiles.userid = attachments.submitter_id
- WHERE profiles.login_name IN ($who_bits)
- AND attachments.creation_ts >= ? AND attachments.creation_ts <= ?
- $attachments_where
-
- ORDER BY $order ";
-
- my $list = $dbh->selectall_arrayref($query, undef, @params);
-
- if ($input->{debug}) {
- while (my $param = shift @params) {
- $query =~ s/\?/$dbh->quote($param)/e;
- }
- $vars->{debug_sql} = $query;
- }
-
- my @operations;
- my $operation = {};
- my $changes = [];
- my $incomplete_data = 0;
- my %bug_ids;
-
- foreach my $entry (@$list) {
- my ($fieldname, $bugid, $attachid, $when, $removed, $added, $who,
- $comment_id) = @$entry;
- my %change;
- my $activity_visible = 1;
-
- next unless Bugzilla->user->can_see_bug($bugid);
-
- # check if the user should see this field's activity
- if ($fieldname eq 'remaining_time'
- || $fieldname eq 'estimated_time'
- || $fieldname eq 'work_time'
- || $fieldname eq 'deadline')
- {
- $activity_visible = Bugzilla->user->is_timetracker;
- }
- elsif ($fieldname eq 'longdescs.isprivate'
- && !Bugzilla->user->is_insider
- && $added)
- {
- $activity_visible = 0;
- }
- else {
- $activity_visible = 1;
- }
-
- if ($activity_visible) {
- # Check for the results of an old Bugzilla data corruption bug
- if (($added eq '?' && $removed eq '?')
- || ($added =~ /^\? / || $removed =~ /^\? /)) {
- $incomplete_data = 1;
- }
-
- # Start a new changeset if required (depends on the sort order)
- my $is_new_changeset;
- if ($order eq 'bug_when') {
- $is_new_changeset =
- $operation->{'who'} &&
- (
- $who ne $operation->{'who'}
- || $when ne $operation->{'when'}
- || $bugid != $operation->{'bug'}
- );
- } else {
- $is_new_changeset =
- $operation->{'bug'} &&
- $bugid != $operation->{'bug'};
- }
- if ($is_new_changeset) {
- $operation->{'changes'} = $changes;
- push (@operations, $operation);
- $operation = {};
- $changes = [];
- }
-
- $bug_ids{$bugid} = 1;
-
- $operation->{'bug'} = $bugid;
- $operation->{'who'} = $who;
- $operation->{'when'} = $when;
-
- $change{'fieldname'} = $fieldname;
- $change{'attachid'} = $attachid;
- $change{'removed'} = $removed;
- $change{'added'} = $added;
- $change{'when'} = $when;
-
- if ($comment_id) {
- $change{'comment'} = Bugzilla::Comment->new($comment_id);
- next if $change{'comment'}->count == 0;
- }
-
- if ($attachid) {
- $change{'attach'} = Bugzilla::Attachment->new($attachid);
- }
-
- push (@$changes, \%change);
- }
- }
-
- if ($operation->{'who'}) {
- $operation->{'changes'} = $changes;
- push (@operations, $operation);
- }
-
- $vars->{'incomplete_data'} = $incomplete_data;
- $vars->{'operations'} = \@operations;
-
- my @bug_ids = sort { $a <=> $b } keys %bug_ids;
- $vars->{'bug_ids'} = \@bug_ids;
- }
-
- $vars->{'action'} = $action;
- $vars->{'who'} = join(',', @who);
- $vars->{'who_count'} = scalar @who;
- $vars->{'from'} = $from;
- $vars->{'to'} = $to;
- $vars->{'sort'} = $input->{'sort'};
-}
-
-sub _string_to_datetime {
- my $input = shift;
- my $time = _parse_date($input)
- or ThrowUserError('report_invalid_date', { date => $input });
- return _time_to_datetime($time);
-}
-
-sub _time_to_datetime {
- my $time = shift;
- return DateTime->from_epoch(epoch => $time)
- ->set_time_zone('local')
- ->truncate(to => 'day');
-}
-
-sub _parse_date {
- my ($str) = @_;
- if ($str =~ /^(-|\+)?(\d+)([hHdDwWmMyY])$/) {
- # relative date
- my ($sign, $amount, $unit, $date) = ($1, $2, lc $3, time);
- my ($sec, $min, $hour, $mday, $month, $year, $wday) = localtime($date);
- $amount = -$amount if $sign && $sign eq '+';
- if ($unit eq 'w') {
- # convert weeks to days
- $amount = 7*$amount + $wday;
- $unit = 'd';
- }
- if ($unit eq 'd') {
- $date -= $sec + 60*$min + 3600*$hour + 24*3600*$amount;
- return $date;
- }
- elsif ($unit eq 'y') {
- return str2time(sprintf("%4d-01-01 00:00:00", $year+1900-$amount));
- }
- elsif ($unit eq 'm') {
- $month -= $amount;
- while ($month<0) { $year--; $month += 12; }
- return str2time(sprintf("%4d-%02d-01 00:00:00", $year+1900, $month+1));
- }
- elsif ($unit eq 'h') {
- # Special case 0h for 'beginning of this hour'
- if ($amount == 0) {
- $date -= $sec + 60*$min;
- } else {
- $date -= 3600*$amount;
- }
- return $date;
- }
- return undef;
- }
- return str2time($str);
-}
-
-sub triage_reports {
- my ($vars, $filter) = @_;
- my $dbh = Bugzilla->dbh;
- my $input = Bugzilla->input_params;
- my $user = Bugzilla->user;
-
- if (exists $input->{'action'} && $input->{'action'} eq 'run' && $input->{'product'}) {
-
- # load product and components from input
-
- my $product = Bugzilla::Product->new({ name => $input->{'product'} })
- || ThrowUserError('invalid_object', { object => 'Product', value => $input->{'product'} });
-
- my @component_ids;
- if ($input->{'component'} ne '') {
- my $ra_components = ref($input->{'component'})
- ? $input->{'component'} : [ $input->{'component'} ];
- foreach my $component_name (@$ra_components) {
- my $component = Bugzilla::Component->new({ name => $component_name, product => $product })
- || ThrowUserError('invalid_object', { object => 'Component', value => $component_name });
- push @component_ids, $component->id;
- }
- }
-
- # determine which comment filters to run
-
- my $filter_commenter = $input->{'filter_commenter'};
- my $filter_commenter_on = $input->{'commenter'};
- my $filter_last = $input->{'filter_last'};
- my $filter_last_period = $input->{'last'};
-
- if (!$filter_commenter || $filter_last) {
- $filter_commenter = '1';
- $filter_commenter_on = 'reporter';
- }
-
- my $filter_commenter_id;
- if ($filter_commenter && $filter_commenter_on eq 'is') {
- Bugzilla::User::match_field({ 'commenter_is' => {'type' => 'single'} });
- my $user = Bugzilla::User->new({ name => $input->{'commenter_is'} })
- || ThrowUserError('invalid_object', { object => 'User', value => $input->{'commenter_is'} });
- $filter_commenter_id = $user ? $user->id : 0;
- }
-
- my $filter_last_time;
- if ($filter_last) {
- if ($filter_last_period eq 'is') {
- $filter_last_period = -1;
- $filter_last_time = str2time($input->{'last_is'} . " 00:00:00") || 0;
- } else {
- detaint_natural($filter_last_period);
- $filter_last_period = 14 if $filter_last_period < 14;
- }
- }
-
- # form sql queries
-
- my $now = (time);
- my $bugs_sql = "
- SELECT bug_id, short_desc, reporter, creation_ts
- FROM bugs
- WHERE product_id = ?
- AND bug_status = 'UNCONFIRMED'";
- if (@component_ids) {
- $bugs_sql .= " AND component_id IN (" . join(',', @component_ids) . ")";
- }
- $bugs_sql .= "
- ORDER BY creation_ts
- ";
-
- my $comment_count_sql = "
- SELECT COUNT(*)
- FROM longdescs
- WHERE bug_id = ?
- ";
-
- my $comment_sql = "
- SELECT who, bug_when, type, thetext, extra_data
- FROM longdescs
- WHERE bug_id = ?
- ";
- if (!Bugzilla->user->is_insider) {
- $comment_sql .= " AND isprivate = 0 ";
- }
- $comment_sql .= "
- ORDER BY bug_when DESC
- LIMIT 1
- ";
-
- my $attach_sql = "
- SELECT description, isprivate
- FROM attachments
- WHERE attach_id = ?
- ";
-
- # work on an initial list of bugs
-
- my $list = $dbh->selectall_arrayref($bugs_sql, undef, $product->id);
- my @bugs;
-
- foreach my $entry (@$list) {
- my ($bug_id, $summary, $reporter_id, $creation_ts) = @$entry;
-
- next unless $user->can_see_bug($bug_id);
-
- # get last comment information
-
- my ($comment_count) = $dbh->selectrow_array($comment_count_sql, undef, $bug_id);
- my ($commenter_id, $comment_ts, $type, $comment, $extra)
- = $dbh->selectrow_array($comment_sql, undef, $bug_id);
- my $commenter = 0;
-
- # apply selected filters
-
- if ($filter_commenter) {
- next if $comment_count <= 1;
-
- if ($filter_commenter_on eq 'reporter') {
- next if $commenter_id != $reporter_id;
-
- } elsif ($filter_commenter_on eq 'noconfirm') {
- $commenter = Bugzilla::User->new($commenter_id);
- next if $commenter_id != $reporter_id
- || $commenter->in_group('canconfirm');
-
- } elsif ($filter_commenter_on eq 'is') {
- next if $commenter_id != $filter_commenter_id;
- }
- } else {
- $input->{'commenter'} = '';
- $input->{'commenter_is'} = '';
- }
-
- if ($filter_last) {
- my $comment_time = str2time($comment_ts)
- or next;
- if ($filter_last_period == -1) {
- next if $comment_time >= $filter_last_time;
- } else {
- next if $now - $comment_time <= 60 * 60 * 24 * $filter_last_period;
- }
- } else {
- $input->{'last'} = '';
- $input->{'last_is'} = '';
- }
-
- # get data for attachment comments
-
- if ($comment eq '' && $type == CMT_ATTACHMENT_CREATED) {
- my ($description, $is_private) = $dbh->selectrow_array($attach_sql, undef, $extra);
- next if $is_private && !Bugzilla->user->is_insider;
- $comment = "(Attachment) " . $description;
- }
-
- # truncate long comments
-
- if (length($comment) > 80) {
- $comment = substr($comment, 0, 80) . '...';
- }
-
- # build bug hash for template
-
- my $bug = {};
- $bug->{id} = $bug_id;
- $bug->{summary} = $summary;
- $bug->{reporter} = Bugzilla::User->new($reporter_id);
- $bug->{creation_ts} = $creation_ts;
- $bug->{commenter} = $commenter || Bugzilla::User->new($commenter_id);
- $bug->{comment_ts} = $comment_ts;
- $bug->{comment} = $comment;
- $bug->{comment_count} = $comment_count;
- push @bugs, $bug;
- }
-
- @bugs = sort { $b->{comment_ts} cmp $a->{comment_ts} } @bugs;
-
- $vars->{bugs} = \@bugs;
- } else {
- $input->{action} = '';
- }
-
- if (!$input->{filter_commenter} && !$input->{filter_last}) {
- $input->{filter_commenter} = 1;
- }
-
- $vars->{'input'} = $input;
-}
-
-sub group_admins_report {
- my ($vars) = @_;
- my $dbh = Bugzilla->dbh;
- my $user = Bugzilla->user;
-
- ($user->in_group('editusers') || $user->in_group('infrasec'))
- || ThrowUserError('auth_failure', { group => 'editusers',
- action => 'run',
- object => 'group_admins' });
-
- my $query = "
- SELECT groups.name, " .
- $dbh->sql_group_concat('profiles.login_name', "','", 1) . "
- FROM groups
- LEFT JOIN user_group_map
- ON user_group_map.group_id = groups.id
- AND user_group_map.isbless = 1
- AND user_group_map.grant_type = 0
- LEFT JOIN profiles
- ON user_group_map.user_id = profiles.userid
- WHERE groups.isbuggroup = 1
- GROUP BY groups.name";
-
- my @groups;
- foreach my $group (@{ $dbh->selectall_arrayref($query) }) {
- my @admins;
- if ($group->[1]) {
- foreach my $admin (split(/,/, $group->[1])) {
- push(@admins, Bugzilla::User->new({ name => $admin }));
- }
- }
- push(@groups, { name => $group->[0], admins => \@admins });
- }
-
- $vars->{'groups'} = \@groups;
-}
-
-sub group_membership_report {
- my ($page, $vars) = @_;
- my $dbh = Bugzilla->dbh;
- my $user = Bugzilla->user;
- my $cgi = Bugzilla->cgi;
-
- ($user->in_group('editusers') || $user->in_group('infrasec'))
- || ThrowUserError('auth_failure', { group => 'editusers',
- action => 'run',
- object => 'group_admins' });
-
- my $who = $cgi->param('who');
- if (!defined($who) || $who eq '') {
- if ($page eq 'group_membership.txt') {
- print $cgi->redirect("page.cgi?id=group_membership.html&output=txt");
- exit;
- }
- $vars->{'output'} = $cgi->param('output');
- return;
- }
-
- Bugzilla::User::match_field({ 'who' => {'type' => 'multi'} });
- $who = Bugzilla->input_params->{'who'};
- $who = ref($who) ? $who : [ $who ];
-
- my @users;
- foreach my $login (@$who) {
- my $u = Bugzilla::User->new(login_to_id($login, 1));
-
- # this is lifted from $user->groups()
- # we need to show which groups are direct and which are inherited
-
- my $groups_to_check = $dbh->selectcol_arrayref(
- q{SELECT DISTINCT group_id
- FROM user_group_map
- WHERE user_id = ? AND isbless = 0}, undef, $u->id);
-
- my $rows = $dbh->selectall_arrayref(
- "SELECT DISTINCT grantor_id, member_id
- FROM group_group_map
- WHERE grant_type = " . GROUP_MEMBERSHIP);
-
- my %group_membership;
- foreach my $row (@$rows) {
- my ($grantor_id, $member_id) = @$row;
- push (@{ $group_membership{$member_id} }, $grantor_id);
- }
-
- my %checked_groups;
- my %direct_groups;
- my %indirect_groups;
- my %groups;
-
- foreach my $member_id (@$groups_to_check) {
- $direct_groups{$member_id} = 1;
- }
-
- while (scalar(@$groups_to_check) > 0) {
- my $member_id = shift @$groups_to_check;
- if (!$checked_groups{$member_id}) {
- $checked_groups{$member_id} = 1;
- my $members = $group_membership{$member_id};
- my @new_to_check = grep(!$checked_groups{$_}, @$members);
- push(@$groups_to_check, @new_to_check);
- foreach my $id (@new_to_check) {
- $indirect_groups{$id} = $member_id;
- }
- $groups{$member_id} = 1;
- }
- }
-
- my @groups;
- my $ra_groups = Bugzilla::Group->new_from_list([keys %groups]);
- foreach my $group (@$ra_groups) {
- my $via;
- if ($direct_groups{$group->id}) {
- $via = '';
- } else {
- foreach my $g (@$ra_groups) {
- if ($g->id == $indirect_groups{$group->id}) {
- $via = $g->name;
- last;
- }
- }
- }
- push @groups, {
- name => $group->name,
- desc => $group->description,
- via => $via,
- };
- }
-
- push @users, {
- user => $u,
- groups => \@groups,
- };
- }
-
- $vars->{'who'} = $who;
- $vars->{'users'} = \@users;
-}
-
-sub group_members_report {
- my ($vars) = @_;
- my $dbh = Bugzilla->dbh;
- my $user = Bugzilla->user;
- my $cgi = Bugzilla->cgi;
-
- ($user->in_group('editusers') || $user->in_group('infrasec'))
- || ThrowUserError('auth_failure', { group => 'editusers',
- action => 'run',
- object => 'group_admins' });
-
- my $include_disabled = $cgi->param('include_disabled') ? 1 : 0;
- $vars->{'include_disabled'} = $include_disabled;
-
- # don't allow all groups, to avoid putting pain on the servers
- my @group_names =
- sort
- grep { !/^(?:bz_.+|canconfirm|editbugs|everyone)$/ }
- map { lc($_->name) }
- Bugzilla::Group->get_all;
- unshift(@group_names, '');
- $vars->{'groups'} = \@group_names;
-
- # load selected group
- my $group = lc(trim($cgi->param('group') || ''));
- $group = '' unless grep { $_ eq $group } @group_names;
- return if $group eq '';
- my $group_obj = Bugzilla::Group->new({ name => $group });
- $vars->{'group'} = $group;
-
- # direct members
- my @types = (
- {
- name => 'direct',
- members => _filter_userlist($group_obj->members_direct, $include_disabled),
- },
- );
-
- # indirect members, by group
- foreach my $member_group (sort @{ $group_obj->grant_direct(GROUP_MEMBERSHIP) }) {
- push @types, {
- name => $member_group->name,
- members => _filter_userlist($member_group->members_direct, $include_disabled),
- },
- }
-
- # make it easy for the template to detect an empty group
- my $has_members = 0;
- foreach my $type (@types) {
- $has_members += scalar(@{ $type->{members} });
- last if $has_members;
- }
- @types = () unless $has_members;
-
- if (@types) {
- # add last-login
- my $user_ids = join(',', map { map { $_->id } @{ $_->{members} } } @types);
- my $tokens = $dbh->selectall_hashref("
- SELECT profiles.userid,
- (SELECT DATEDIFF(curdate(), logincookies.lastused) lastseen
- FROM logincookies
- WHERE logincookies.userid = profiles.userid
- ORDER BY lastused DESC
- LIMIT 1) lastseen
- FROM profiles
- WHERE userid IN ($user_ids)",
- 'userid');
- foreach my $type (@types) {
- foreach my $member (@{ $type->{members} }) {
- $member->{lastseen} =
- defined $tokens->{$member->id}->{lastseen}
- ? $tokens->{$member->id}->{lastseen}
- : '>' . MAX_LOGINCOOKIE_AGE;
- }
- }
- }
-
- $vars->{'types'} = \@types;
-}
-
-sub _filter_userlist {
- my ($list, $include_disabled) = @_;
- $list = [ grep { $_->is_enabled } @$list ] unless $include_disabled;
- return [ sort { lc($a->identity) cmp lc($b->identity) } @$list ];
-}
-
-sub email_queue_report {
- my ($vars, $filter) = @_;
- my $dbh = Bugzilla->dbh;
- my $user = Bugzilla->user;
-
- $user->in_group('admin') || $user->in_group('infra')
- || ThrowUserError('auth_failure', { group => 'admin',
- action => 'run',
- object => 'email_queue' });
-
- my $query = "
- SELECT j.jobid,
- j.insert_time,
- j.run_after AS run_time,
- COUNT(e.jobid) AS error_count,
- MAX(e.error_time) AS error_time,
- e.message AS error_message
- FROM ts_job j
- LEFT JOIN ts_error e ON e.jobid = j.jobid
- GROUP BY j.jobid
- ORDER BY j.run_after";
-
- $vars->{'jobs'} = $dbh->selectall_arrayref($query, { Slice => {} });
- $vars->{'now'} = (time);
-}
-
-sub release_tracking_report {
- my ($vars) = @_;
- my $dbh = Bugzilla->dbh;
- my $input = Bugzilla->input_params;
- my $user = Bugzilla->user;
-
- my @flag_names = qw(
- approval-mozilla-release
- approval-mozilla-beta
- approval-mozilla-aurora
- approval-mozilla-central
- approval-comm-release
- approval-comm-beta
- approval-comm-aurora
- approval-calendar-release
- approval-calendar-beta
- approval-calendar-aurora
- approval-mozilla-esr10
- );
-
- my @flags_json;
- my @fields_json;
- my @products_json;
-
- #
- # tracking flags
- #
-
- my $all_products = $user->get_selectable_products;
- my @usable_products;
-
- # build list of flags and their matching products
-
- my @invalid_flag_names;
- foreach my $flag_name (@flag_names) {
- # grab all matching flag_types
- my @flag_types = @{Bugzilla::FlagType::match({ name => $flag_name, is_active => 1 })};
-
- # remove invalid flags
- if (!@flag_types) {
- push @invalid_flag_names, $flag_name;
- next;
- }
-
- # we need a list of products, based on inclusions/exclusions
- my @products;
- my %flag_types;
- foreach my $flag_type (@flag_types) {
- $flag_types{$flag_type->name} = $flag_type->id;
- my $has_all = 0;
- my @exclusion_ids;
- my @inclusion_ids;
- foreach my $flag_type (@flag_types) {
- if (scalar keys %{$flag_type->inclusions}) {
- my $inclusions = $flag_type->inclusions;
- foreach my $key (keys %$inclusions) {
- push @inclusion_ids, ($inclusions->{$key} =~ /^(\d+)/);
- }
- } elsif (scalar keys %{$flag_type->exclusions}) {
- my $exclusions = $flag_type->exclusions;
- foreach my $key (keys %$exclusions) {
- push @exclusion_ids, ($exclusions->{$key} =~ /^(\d+)/);
- }
- } else {
- $has_all = 1;
- last;
- }
- }
-
- if ($has_all) {
- push @products, @$all_products;
- } elsif (scalar @exclusion_ids) {
- push @products, @$all_products;
- foreach my $exclude_id (uniq @exclusion_ids) {
- @products = grep { $_->id != $exclude_id } @products;
- }
- } else {
- foreach my $include_id (uniq @inclusion_ids) {
- push @products, grep { $_->id == $include_id } @$all_products;
- }
- }
- }
- @products = uniq @products;
- push @usable_products, @products;
- my @product_ids = map { $_->id } sort { lc($a->name) cmp lc($b->name) } @products;
-
- push @flags_json, {
- name => $flag_name,
- id => $flag_types{$flag_name} || 0,
- products => \@product_ids,
- fields => [],
- };
- }
- foreach my $flag_name (@invalid_flag_names) {
- @flag_names = grep { $_ ne $flag_name } @flag_names;
- }
- @usable_products = uniq @usable_products;
-
- # build a list of tracking flags for each product
- # also build the list of all fields
-
- my @unlink_products;
- foreach my $product (@usable_products) {
- my @fields =
- grep { _is_active_status_field($_->name) }
- Bugzilla->active_custom_fields({ product => $product });
- my @field_ids = map { $_->id } @fields;
- if (!scalar @fields) {
- push @unlink_products, $product;
- next;
- }
-
- # product
- push @products_json, {
- name => $product->name,
- id => $product->id,
- fields => \@field_ids,
- };
-
- # add fields to flags
- foreach my $rh (@flags_json) {
- if (grep { $_ eq $product->id } @{$rh->{products}}) {
- push @{$rh->{fields}}, @field_ids;
- }
- }
-
- # add fields to fields_json
- foreach my $field (@fields) {
- my $existing = 0;
- foreach my $rh (@fields_json) {
- if ($rh->{id} == $field->id) {
- $existing = 1;
- last;
- }
- }
- if (!$existing) {
- push @fields_json, {
- name => $field->name,
- id => $field->id,
- };
- }
- }
- }
- foreach my $rh (@flags_json) {
- my @fields = uniq @{$rh->{fields}};
- $rh->{fields} = \@fields;
- }
-
- # remove products which aren't linked with status fields
-
- foreach my $rh (@flags_json) {
- my @product_ids;
- foreach my $id (@{$rh->{products}}) {
- unless (grep { $_->id == $id } @unlink_products) {
- push @product_ids, $id;
- }
- $rh->{products} = \@product_ids;
- }
- }
-
- #
- # rapid release dates
- #
-
- my @ranges;
- my $start_date = _string_to_datetime('2011-08-16');
- my $end_date = $start_date->clone->add(weeks => 6)->add(days => -1);
- my $now_date = _string_to_datetime('2012-11-19');
-
- while ($start_date <= $now_date) {
- unshift @ranges, {
- value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')),
- label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')),
- };
-
- $start_date = $end_date->clone;;
- $start_date->add(days => 1);
- $end_date->add(weeks => 6);
- }
-
- # 2012-11-20 - 2013-01-06 was a 7 week release cycle instead of 6
- $start_date = _string_to_datetime('2012-11-20');
- $end_date = $start_date->clone->add(weeks => 7)->add(days => -1);
- unshift @ranges, {
- value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')),
- label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')),
- };
-
- # Back on track with 6 week releases
- $start_date = _string_to_datetime('2013-01-08');
- $end_date = $start_date->clone->add(weeks => 6)->add(days => -1);
- $now_date = _time_to_datetime((time));
-
- while ($start_date <= $now_date) {
- unshift @ranges, {
- value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')),
- label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')),
- };
-
- $start_date = $end_date->clone;;
- $start_date->add(days => 1);
- $end_date->add(weeks => 6);
- }
-
- push @ranges, {
- value => '*',
- label => 'Anytime',
- };
-
- #
- # run report
- #
-
- if ($input->{q} && !$input->{edit}) {
- my $q = _parse_query($input->{q});
-
- my @where;
- my @params;
- my $query = "
- SELECT DISTINCT b.bug_id
- FROM bugs b
- INNER JOIN flags f ON f.bug_id = b.bug_id ";
-
- if ($q->{start_date}) {
- $query .= "INNER JOIN bugs_activity a ON a.bug_id = b.bug_id ";
- }
-
- $query .= "WHERE ";
-
- if ($q->{start_date}) {
- push @where, "(a.fieldid = ?)";
- push @params, $q->{field_id};
-
- push @where, "(a.bug_when >= ?)";
- push @params, $q->{start_date} . ' 00:00:00';
- push @where, "(a.bug_when < ?)";
- push @params, $q->{end_date} . ' 00:00:00';
-
- push @where, "(a.added LIKE ?)";
- push @params, '%' . $q->{flag_name} . $q->{flag_status} . '%';
- }
-
- push @where, "(f.type_id IN (SELECT id FROM flagtypes WHERE name = ?))";
- push @params, $q->{flag_name};
-
- push @where, "(f.status = ?)";
- push @params, $q->{flag_status};
-
- if ($q->{product_id}) {
- push @where, "(b.product_id = ?)";
- push @params, $q->{product_id};
- }
-
- if (scalar @{$q->{fields}}) {
- my @fields;
- foreach my $field (@{$q->{fields}}) {
- push @fields,
- "(" .
- ($field->{value} eq '+' ? '' : '!') .
- "(b.".$field->{name}." IN ('fixed','verified'))" .
- ") ";
- }
- my $join = uc $q->{join};
- push @where, '(' . join(" $join ", @fields) . ')';
- }
-
- $query .= join("\nAND ", @where);
-
- if ($input->{debug}) {
- print "Content-Type: text/plain\n\n";
- $query =~ s/\?/\000/g;
- foreach my $param (@params) {
- $query =~ s/\000/$param/;
- }
- print "$query\n";
- exit;
- }
-
- my $bugs = $dbh->selectcol_arrayref($query, undef, @params);
- push @$bugs, 0 unless @$bugs;
-
- my $urlbase = correct_urlbase();
- my $cgi = Bugzilla->cgi;
- print $cgi->redirect(
- -url => "${urlbase}buglist.cgi?bug_id=" . join(',', @$bugs)
- );
- exit;
- }
-
- #
- # set template vars
- #
-
- my $json = JSON->new();
- if (0) {
- # debugging
- $json->shrink(0);
- $json->canonical(1);
- $vars->{flags_json} = $json->pretty->encode(\@flags_json);
- $vars->{products_json} = $json->pretty->encode(\@products_json);
- $vars->{fields_json} = $json->pretty->encode(\@fields_json);
- } else {
- $json->shrink(1);
- $vars->{flags_json} = $json->encode(\@flags_json);
- $vars->{products_json} = $json->encode(\@products_json);
- $vars->{fields_json} = $json->encode(\@fields_json);
- }
-
- $vars->{flag_names} = \@flag_names;
- $vars->{ranges} = \@ranges;
- $vars->{default_query} = $input->{q};
- foreach my $field (qw(product flags range)) {
- $vars->{$field} = $input->{$field};
- }
-}
-
-sub _parse_query {
- my $q = shift;
- my @query = split(/:/, $q);
- my $query;
-
- # field_id for flag changes
- $query->{field_id} = get_field_id('flagtypes.name');
-
- # flag_name
- my $flag_name = shift @query;
- @{Bugzilla::FlagType::match({ name => $flag_name, is_active => 1 })}
- or ThrowUserError('report_invalid_parameter', { name => 'flag_name' });
- trick_taint($flag_name);
- $query->{flag_name} = $flag_name;
-
- # flag_status
- my $flag_status = shift @query;
- $flag_status =~ /^([\?\-\+])$/
- or ThrowUserError('report_invalid_parameter', { name => 'flag_status' });
- $query->{flag_status} = $1;
-
- # date_range -> from_ymd to_ymd
- my $date_range = shift @query;
- if ($date_range ne '*') {
- $date_range =~ /^(\d\d\d\d)(\d\d)(\d\d)-(\d\d\d\d)(\d\d)(\d\d)$/
- or ThrowUserError('report_invalid_parameter', { name => 'date_range' });
- $query->{start_date} = "$1-$2-$3";
- $query->{end_date} = "$4-$5-$6";
- }
-
- # product_id
- my $product_id = shift @query;
- $product_id =~ /^(\d+)$/
- or ThrowUserError('report_invalid_parameter', { name => 'product_id' });
- $query->{product_id} = $1;
-
- # join
- my $join = shift @query;
- $join =~ /^(and|or)$/
- or ThrowUserError('report_invalid_parameter', { name => 'join' });
- $query->{join} = $1;
-
- # fields
- my @fields;
- foreach my $field (@query) {
- $field =~ /^(\d+)([\-\+])$/
- or ThrowUserError('report_invalid_parameter', { name => 'fields' });
- my ($id, $value) = ($1, $2);
- my $field_obj = Bugzilla::Field->new($id)
- or ThrowUserError('report_invalid_parameter', { name => 'field_id' });
- push @fields, { id => $id, value => $value, name => $field_obj->name };
- }
- $query->{fields} = \@fields;
-
- return $query;
-}
-
-sub _is_active_status_field {
- my ($field_name) = @_;
- if ($field_name =~ /^cf_status/) {
- return !grep { $field_name eq $_ } @$cf_disabled_flags
- }
- return 0;
-}
-
-1;
diff --git a/extensions/BMO/lib/Reports/EmailQueue.pm b/extensions/BMO/lib/Reports/EmailQueue.pm
new file mode 100644
index 000000000..1bf2ca003
--- /dev/null
+++ b/extensions/BMO/lib/Reports/EmailQueue.pm
@@ -0,0 +1,69 @@
+# 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.
+
+package Bugzilla::Extension::BMO::Reports::EmailQueue;
+use strict;
+use warnings;
+
+use Bugzilla::Error;
+use Scalar::Util qw(blessed);
+use Storable ();
+
+sub report {
+ my ($vars, $filter) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $user = Bugzilla->user;
+
+ $user->in_group('admin') || $user->in_group('infra')
+ || ThrowUserError('auth_failure', { group => 'admin',
+ action => 'run',
+ object => 'email_queue' });
+
+ my $query = "
+ SELECT j.jobid,
+ j.arg,
+ j.insert_time,
+ j.run_after AS run_time,
+ COUNT(e.jobid) AS error_count,
+ MAX(e.error_time) AS error_time,
+ e.message AS error_message
+ FROM ts_job j
+ LEFT JOIN ts_error e ON e.jobid = j.jobid
+ GROUP BY j.jobid
+ ORDER BY j.run_after";
+
+ $vars->{'jobs'} = $dbh->selectall_arrayref($query, { Slice => {} });
+ foreach my $job (@{ $vars->{'jobs'} }) {
+ eval {
+ my $msg = _cond_thaw(delete $job->{'arg'})->{msg};
+ if (ref($msg) && blessed($msg) eq 'Email::MIME') {
+ $job->{'subject'} = $msg->header('subject');
+ } else {
+ ($job->{'subject'}) = $msg =~ /\nSubject: ([^\n]+)/;
+ }
+ };
+ }
+ $vars->{'now'} = (time);
+}
+
+sub _cond_thaw {
+ my $data = shift;
+ my $magic = eval { Storable::read_magic($data); };
+ if ($magic && $magic->{major} && $magic->{major} >= 2 && $magic->{major} <= 5) {
+ my $thawed = eval { Storable::thaw($data) };
+ if ($@) {
+ # false alarm... looked like a Storable, but wasn't.
+ return $data;
+ }
+ return $thawed;
+ } else {
+ return $data;
+ }
+}
+
+
+1;
diff --git a/extensions/BMO/lib/Reports/Groups.pm b/extensions/BMO/lib/Reports/Groups.pm
new file mode 100644
index 000000000..9fc9cfbe5
--- /dev/null
+++ b/extensions/BMO/lib/Reports/Groups.pm
@@ -0,0 +1,243 @@
+# 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.
+
+package Bugzilla::Extension::BMO::Reports::Groups;
+use strict;
+use warnings;
+
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Group;
+use Bugzilla::User;
+use Bugzilla::Util qw(trim);
+
+sub admins_report {
+ my ($vars) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $user = Bugzilla->user;
+
+ ($user->in_group('editusers') || $user->in_group('infrasec'))
+ || ThrowUserError('auth_failure', { group => 'editusers',
+ action => 'run',
+ object => 'group_admins' });
+
+ my $query = "
+ SELECT groups.name, " .
+ $dbh->sql_group_concat('profiles.login_name', "','", 1) . "
+ FROM groups
+ LEFT JOIN user_group_map
+ ON user_group_map.group_id = groups.id
+ AND user_group_map.isbless = 1
+ AND user_group_map.grant_type = 0
+ LEFT JOIN profiles
+ ON user_group_map.user_id = profiles.userid
+ WHERE groups.isbuggroup = 1
+ GROUP BY groups.name";
+
+ my @groups;
+ foreach my $group (@{ $dbh->selectall_arrayref($query) }) {
+ my @admins;
+ if ($group->[1]) {
+ foreach my $admin (split(/,/, $group->[1])) {
+ push(@admins, Bugzilla::User->new({ name => $admin }));
+ }
+ }
+ push(@groups, { name => $group->[0], admins => \@admins });
+ }
+
+ $vars->{'groups'} = \@groups;
+}
+
+sub membership_report {
+ my ($page, $vars) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $user = Bugzilla->user;
+ my $cgi = Bugzilla->cgi;
+
+ ($user->in_group('editusers') || $user->in_group('infrasec'))
+ || ThrowUserError('auth_failure', { group => 'editusers',
+ action => 'run',
+ object => 'group_admins' });
+
+ my $who = $cgi->param('who');
+ if (!defined($who) || $who eq '') {
+ if ($page eq 'group_membership.txt') {
+ print $cgi->redirect("page.cgi?id=group_membership.html&output=txt");
+ exit;
+ }
+ $vars->{'output'} = $cgi->param('output');
+ return;
+ }
+
+ Bugzilla::User::match_field({ 'who' => {'type' => 'multi'} });
+ $who = Bugzilla->input_params->{'who'};
+ $who = ref($who) ? $who : [ $who ];
+
+ my @users;
+ foreach my $login (@$who) {
+ my $u = Bugzilla::User->new(login_to_id($login, 1));
+
+ # this is lifted from $user->groups()
+ # we need to show which groups are direct and which are inherited
+
+ my $groups_to_check = $dbh->selectcol_arrayref(
+ q{SELECT DISTINCT group_id
+ FROM user_group_map
+ WHERE user_id = ? AND isbless = 0}, undef, $u->id);
+
+ my $rows = $dbh->selectall_arrayref(
+ "SELECT DISTINCT grantor_id, member_id
+ FROM group_group_map
+ WHERE grant_type = " . GROUP_MEMBERSHIP);
+
+ my %group_membership;
+ foreach my $row (@$rows) {
+ my ($grantor_id, $member_id) = @$row;
+ push (@{ $group_membership{$member_id} }, $grantor_id);
+ }
+
+ my %checked_groups;
+ my %direct_groups;
+ my %indirect_groups;
+ my %groups;
+
+ foreach my $member_id (@$groups_to_check) {
+ $direct_groups{$member_id} = 1;
+ }
+
+ while (scalar(@$groups_to_check) > 0) {
+ my $member_id = shift @$groups_to_check;
+ if (!$checked_groups{$member_id}) {
+ $checked_groups{$member_id} = 1;
+ my $members = $group_membership{$member_id};
+ my @new_to_check = grep(!$checked_groups{$_}, @$members);
+ push(@$groups_to_check, @new_to_check);
+ foreach my $id (@new_to_check) {
+ $indirect_groups{$id} = $member_id;
+ }
+ $groups{$member_id} = 1;
+ }
+ }
+
+ my @groups;
+ my $ra_groups = Bugzilla::Group->new_from_list([keys %groups]);
+ foreach my $group (@$ra_groups) {
+ my $via;
+ if ($direct_groups{$group->id}) {
+ $via = '';
+ } else {
+ foreach my $g (@$ra_groups) {
+ if ($g->id == $indirect_groups{$group->id}) {
+ $via = $g->name;
+ last;
+ }
+ }
+ }
+ push @groups, {
+ name => $group->name,
+ desc => $group->description,
+ via => $via,
+ };
+ }
+
+ push @users, {
+ user => $u,
+ groups => \@groups,
+ };
+ }
+
+ $vars->{'who'} = $who;
+ $vars->{'users'} = \@users;
+}
+
+sub members_report {
+ my ($vars) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $user = Bugzilla->user;
+ my $cgi = Bugzilla->cgi;
+
+ ($user->in_group('editusers') || $user->in_group('infrasec'))
+ || ThrowUserError('auth_failure', { group => 'editusers',
+ action => 'run',
+ object => 'group_admins' });
+
+ my $include_disabled = $cgi->param('include_disabled') ? 1 : 0;
+ $vars->{'include_disabled'} = $include_disabled;
+
+ # don't allow all groups, to avoid putting pain on the servers
+ my @group_names =
+ sort
+ grep { !/^(?:bz_.+|canconfirm|editbugs|everyone)$/ }
+ map { lc($_->name) }
+ Bugzilla::Group->get_all;
+ unshift(@group_names, '');
+ $vars->{'groups'} = \@group_names;
+
+ # load selected group
+ my $group = lc(trim($cgi->param('group') || ''));
+ $group = '' unless grep { $_ eq $group } @group_names;
+ return if $group eq '';
+ my $group_obj = Bugzilla::Group->new({ name => $group });
+ $vars->{'group'} = $group;
+
+ # direct members
+ my @types = (
+ {
+ name => 'direct',
+ members => _filter_userlist($group_obj->members_direct, $include_disabled),
+ },
+ );
+
+ # indirect members, by group
+ foreach my $member_group (sort @{ $group_obj->grant_direct(GROUP_MEMBERSHIP) }) {
+ push @types, {
+ name => $member_group->name,
+ members => _filter_userlist($member_group->members_direct, $include_disabled),
+ },
+ }
+
+ # make it easy for the template to detect an empty group
+ my $has_members = 0;
+ foreach my $type (@types) {
+ $has_members += scalar(@{ $type->{members} });
+ last if $has_members;
+ }
+ @types = () unless $has_members;
+
+ if (@types) {
+ # add last-login
+ my $user_ids = join(',', map { map { $_->id } @{ $_->{members} } } @types);
+ my $tokens = $dbh->selectall_hashref("
+ SELECT profiles.userid,
+ (SELECT DATEDIFF(curdate(), logincookies.lastused) lastseen
+ FROM logincookies
+ WHERE logincookies.userid = profiles.userid
+ ORDER BY lastused DESC
+ LIMIT 1) lastseen
+ FROM profiles
+ WHERE userid IN ($user_ids)",
+ 'userid');
+ foreach my $type (@types) {
+ foreach my $member (@{ $type->{members} }) {
+ $member->{lastseen} =
+ defined $tokens->{$member->id}->{lastseen}
+ ? $tokens->{$member->id}->{lastseen}
+ : '>' . MAX_LOGINCOOKIE_AGE;
+ }
+ }
+ }
+
+ $vars->{'types'} = \@types;
+}
+
+sub _filter_userlist {
+ my ($list, $include_disabled) = @_;
+ $list = [ grep { $_->is_enabled } @$list ] unless $include_disabled;
+ return [ sort { lc($a->identity) cmp lc($b->identity) } @$list ];
+}
+
+1;
diff --git a/extensions/BMO/lib/Reports/ReleaseTracking.pm b/extensions/BMO/lib/Reports/ReleaseTracking.pm
new file mode 100644
index 000000000..e307da192
--- /dev/null
+++ b/extensions/BMO/lib/Reports/ReleaseTracking.pm
@@ -0,0 +1,393 @@
+# 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.
+
+package Bugzilla::Extension::BMO::Reports::ReleaseTracking;
+use strict;
+use warnings;
+
+use Bugzilla::Error;
+use Bugzilla::Extension::BMO::Util;
+use Bugzilla::Field;
+use Bugzilla::FlagType;
+use Bugzilla::Util qw(correct_urlbase trick_taint);
+use JSON qw(-convert_blessed_universally);
+use List::MoreUtils qw(uniq);
+
+sub report {
+ my ($vars) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $input = Bugzilla->input_params;
+ my $user = Bugzilla->user;
+
+ my @flag_names = qw(
+ approval-mozilla-release
+ approval-mozilla-beta
+ approval-mozilla-aurora
+ approval-mozilla-central
+ approval-comm-release
+ approval-comm-beta
+ approval-comm-aurora
+ approval-calendar-release
+ approval-calendar-beta
+ approval-calendar-aurora
+ approval-mozilla-esr10
+ );
+
+ my @flags_json;
+ my @fields_json;
+ my @products_json;
+
+ #
+ # tracking flags
+ #
+
+ my $all_products = $user->get_selectable_products;
+ my @usable_products;
+
+ # build list of flags and their matching products
+
+ my @invalid_flag_names;
+ foreach my $flag_name (@flag_names) {
+ # grab all matching flag_types
+ my @flag_types = @{Bugzilla::FlagType::match({ name => $flag_name, is_active => 1 })};
+
+ # remove invalid flags
+ if (!@flag_types) {
+ push @invalid_flag_names, $flag_name;
+ next;
+ }
+
+ # we need a list of products, based on inclusions/exclusions
+ my @products;
+ my %flag_types;
+ foreach my $flag_type (@flag_types) {
+ $flag_types{$flag_type->name} = $flag_type->id;
+ my $has_all = 0;
+ my @exclusion_ids;
+ my @inclusion_ids;
+ foreach my $flag_type (@flag_types) {
+ if (scalar keys %{$flag_type->inclusions}) {
+ my $inclusions = $flag_type->inclusions;
+ foreach my $key (keys %$inclusions) {
+ push @inclusion_ids, ($inclusions->{$key} =~ /^(\d+)/);
+ }
+ } elsif (scalar keys %{$flag_type->exclusions}) {
+ my $exclusions = $flag_type->exclusions;
+ foreach my $key (keys %$exclusions) {
+ push @exclusion_ids, ($exclusions->{$key} =~ /^(\d+)/);
+ }
+ } else {
+ $has_all = 1;
+ last;
+ }
+ }
+
+ if ($has_all) {
+ push @products, @$all_products;
+ } elsif (scalar @exclusion_ids) {
+ push @products, @$all_products;
+ foreach my $exclude_id (uniq @exclusion_ids) {
+ @products = grep { $_->id != $exclude_id } @products;
+ }
+ } else {
+ foreach my $include_id (uniq @inclusion_ids) {
+ push @products, grep { $_->id == $include_id } @$all_products;
+ }
+ }
+ }
+ @products = uniq @products;
+ push @usable_products, @products;
+ my @product_ids = map { $_->id } sort { lc($a->name) cmp lc($b->name) } @products;
+
+ push @flags_json, {
+ name => $flag_name,
+ id => $flag_types{$flag_name} || 0,
+ products => \@product_ids,
+ fields => [],
+ };
+ }
+ foreach my $flag_name (@invalid_flag_names) {
+ @flag_names = grep { $_ ne $flag_name } @flag_names;
+ }
+ @usable_products = uniq @usable_products;
+
+ # build a list of tracking flags for each product
+ # also build the list of all fields
+
+ my @unlink_products;
+ foreach my $product (@usable_products) {
+ my @fields =
+ grep { is_active_status_field($_->name) }
+ Bugzilla->active_custom_fields({ product => $product });
+ my @field_ids = map { $_->id } @fields;
+ if (!scalar @fields) {
+ push @unlink_products, $product;
+ next;
+ }
+
+ # product
+ push @products_json, {
+ name => $product->name,
+ id => $product->id,
+ fields => \@field_ids,
+ };
+
+ # add fields to flags
+ foreach my $rh (@flags_json) {
+ if (grep { $_ eq $product->id } @{$rh->{products}}) {
+ push @{$rh->{fields}}, @field_ids;
+ }
+ }
+
+ # add fields to fields_json
+ foreach my $field (@fields) {
+ my $existing = 0;
+ foreach my $rh (@fields_json) {
+ if ($rh->{id} == $field->id) {
+ $existing = 1;
+ last;
+ }
+ }
+ if (!$existing) {
+ push @fields_json, {
+ name => $field->name,
+ id => $field->id,
+ };
+ }
+ }
+ }
+ foreach my $rh (@flags_json) {
+ my @fields = uniq @{$rh->{fields}};
+ $rh->{fields} = \@fields;
+ }
+
+ # remove products which aren't linked with status fields
+
+ foreach my $rh (@flags_json) {
+ my @product_ids;
+ foreach my $id (@{$rh->{products}}) {
+ unless (grep { $_->id == $id } @unlink_products) {
+ push @product_ids, $id;
+ }
+ $rh->{products} = \@product_ids;
+ }
+ }
+
+ #
+ # rapid release dates
+ #
+
+ my @ranges;
+ my $start_date = string_to_datetime('2011-08-16');
+ my $end_date = $start_date->clone->add(weeks => 6)->add(days => -1);
+ my $now_date = string_to_datetime('2012-11-19');
+
+ while ($start_date <= $now_date) {
+ unshift @ranges, {
+ value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')),
+ label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')),
+ };
+
+ $start_date = $end_date->clone;;
+ $start_date->add(days => 1);
+ $end_date->add(weeks => 6);
+ }
+
+ # 2012-11-20 - 2013-01-06 was a 7 week release cycle instead of 6
+ $start_date = string_to_datetime('2012-11-20');
+ $end_date = $start_date->clone->add(weeks => 7)->add(days => -1);
+ unshift @ranges, {
+ value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')),
+ label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')),
+ };
+
+ # Back on track with 6 week releases
+ $start_date = string_to_datetime('2013-01-08');
+ $end_date = $start_date->clone->add(weeks => 6)->add(days => -1);
+ $now_date = time_to_datetime((time));
+
+ while ($start_date <= $now_date) {
+ unshift @ranges, {
+ value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')),
+ label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')),
+ };
+
+ $start_date = $end_date->clone;;
+ $start_date->add(days => 1);
+ $end_date->add(weeks => 6);
+ }
+
+ push @ranges, {
+ value => '*',
+ label => 'Anytime',
+ };
+
+ #
+ # run report
+ #
+
+ if ($input->{q} && !$input->{edit}) {
+ my $q = _parse_query($input->{q});
+
+ my @where;
+ my @params;
+ my $query = "
+ SELECT DISTINCT b.bug_id
+ FROM bugs b
+ INNER JOIN flags f ON f.bug_id = b.bug_id ";
+
+ if ($q->{start_date}) {
+ $query .= "INNER JOIN bugs_activity a ON a.bug_id = b.bug_id ";
+ }
+
+ $query .= "WHERE ";
+
+ if ($q->{start_date}) {
+ push @where, "(a.fieldid = ?)";
+ push @params, $q->{field_id};
+
+ push @where, "(a.bug_when >= ?)";
+ push @params, $q->{start_date} . ' 00:00:00';
+ push @where, "(a.bug_when < ?)";
+ push @params, $q->{end_date} . ' 00:00:00';
+
+ push @where, "(a.added LIKE ?)";
+ push @params, '%' . $q->{flag_name} . $q->{flag_status} . '%';
+ }
+
+ push @where, "(f.type_id IN (SELECT id FROM flagtypes WHERE name = ?))";
+ push @params, $q->{flag_name};
+
+ push @where, "(f.status = ?)";
+ push @params, $q->{flag_status};
+
+ if ($q->{product_id}) {
+ push @where, "(b.product_id = ?)";
+ push @params, $q->{product_id};
+ }
+
+ if (scalar @{$q->{fields}}) {
+ my @fields;
+ foreach my $field (@{$q->{fields}}) {
+ push @fields,
+ "(" .
+ ($field->{value} eq '+' ? '' : '!') .
+ "(b.".$field->{name}." IN ('fixed','verified'))" .
+ ") ";
+ }
+ my $join = uc $q->{join};
+ push @where, '(' . join(" $join ", @fields) . ')';
+ }
+
+ $query .= join("\nAND ", @where);
+
+ if ($input->{debug}) {
+ print "Content-Type: text/plain\n\n";
+ $query =~ s/\?/\000/g;
+ foreach my $param (@params) {
+ $query =~ s/\000/$param/;
+ }
+ print "$query\n";
+ exit;
+ }
+
+ my $bugs = $dbh->selectcol_arrayref($query, undef, @params);
+ push @$bugs, 0 unless @$bugs;
+
+ my $urlbase = correct_urlbase();
+ my $cgi = Bugzilla->cgi;
+ print $cgi->redirect(
+ -url => "${urlbase}buglist.cgi?bug_id=" . join(',', @$bugs)
+ );
+ exit;
+ }
+
+ #
+ # set template vars
+ #
+
+ my $json = JSON->new();
+ if (0) {
+ # debugging
+ $json->shrink(0);
+ $json->canonical(1);
+ $vars->{flags_json} = $json->pretty->encode(\@flags_json);
+ $vars->{products_json} = $json->pretty->encode(\@products_json);
+ $vars->{fields_json} = $json->pretty->encode(\@fields_json);
+ } else {
+ $json->shrink(1);
+ $vars->{flags_json} = $json->encode(\@flags_json);
+ $vars->{products_json} = $json->encode(\@products_json);
+ $vars->{fields_json} = $json->encode(\@fields_json);
+ }
+
+ $vars->{flag_names} = \@flag_names;
+ $vars->{ranges} = \@ranges;
+ $vars->{default_query} = $input->{q};
+ foreach my $field (qw(product flags range)) {
+ $vars->{$field} = $input->{$field};
+ }
+}
+
+sub _parse_query {
+ my $q = shift;
+ my @query = split(/:/, $q);
+ my $query;
+
+ # field_id for flag changes
+ $query->{field_id} = get_field_id('flagtypes.name');
+
+ # flag_name
+ my $flag_name = shift @query;
+ @{Bugzilla::FlagType::match({ name => $flag_name, is_active => 1 })}
+ or ThrowUserError('report_invalid_parameter', { name => 'flag_name' });
+ trick_taint($flag_name);
+ $query->{flag_name} = $flag_name;
+
+ # flag_status
+ my $flag_status = shift @query;
+ $flag_status =~ /^([\?\-\+])$/
+ or ThrowUserError('report_invalid_parameter', { name => 'flag_status' });
+ $query->{flag_status} = $1;
+
+ # date_range -> from_ymd to_ymd
+ my $date_range = shift @query;
+ if ($date_range ne '*') {
+ $date_range =~ /^(\d\d\d\d)(\d\d)(\d\d)-(\d\d\d\d)(\d\d)(\d\d)$/
+ or ThrowUserError('report_invalid_parameter', { name => 'date_range' });
+ $query->{start_date} = "$1-$2-$3";
+ $query->{end_date} = "$4-$5-$6";
+ }
+
+ # product_id
+ my $product_id = shift @query;
+ $product_id =~ /^(\d+)$/
+ or ThrowUserError('report_invalid_parameter', { name => 'product_id' });
+ $query->{product_id} = $1;
+
+ # join
+ my $join = shift @query;
+ $join =~ /^(and|or)$/
+ or ThrowUserError('report_invalid_parameter', { name => 'join' });
+ $query->{join} = $1;
+
+ # fields
+ my @fields;
+ foreach my $field (@query) {
+ $field =~ /^(\d+)([\-\+])$/
+ or ThrowUserError('report_invalid_parameter', { name => 'fields' });
+ my ($id, $value) = ($1, $2);
+ my $field_obj = Bugzilla::Field->new($id)
+ or ThrowUserError('report_invalid_parameter', { name => 'field_id' });
+ push @fields, { id => $id, value => $value, name => $field_obj->name };
+ }
+ $query->{fields} = \@fields;
+
+ return $query;
+}
+
+1;
diff --git a/extensions/BMO/lib/Reports/Triage.pm b/extensions/BMO/lib/Reports/Triage.pm
new file mode 100644
index 000000000..debb50577
--- /dev/null
+++ b/extensions/BMO/lib/Reports/Triage.pm
@@ -0,0 +1,217 @@
+# 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.
+
+package Bugzilla::Extension::BMO::Reports::Triage;
+use strict;
+
+use Bugzilla::Component;
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Product;
+use Bugzilla::User;
+use Bugzilla::Util qw(detaint_natural);
+use Date::Parse;
+
+# set an upper limit on the *unfiltered* number of bugs to process
+use constant MAX_NUMBER_BUGS => 4000;
+
+sub report {
+ my ($vars, $filter) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $input = Bugzilla->input_params;
+ my $user = Bugzilla->user;
+
+ if (exists $input->{'action'} && $input->{'action'} eq 'run' && $input->{'product'}) {
+
+ # load product and components from input
+
+ my $product = Bugzilla::Product->new({ name => $input->{'product'} })
+ || ThrowUserError('invalid_object', { object => 'Product', value => $input->{'product'} });
+
+ my @component_ids;
+ if ($input->{'component'} ne '') {
+ my $ra_components = ref($input->{'component'})
+ ? $input->{'component'} : [ $input->{'component'} ];
+ foreach my $component_name (@$ra_components) {
+ my $component = Bugzilla::Component->new({ name => $component_name, product => $product })
+ || ThrowUserError('invalid_object', { object => 'Component', value => $component_name });
+ push @component_ids, $component->id;
+ }
+ }
+
+ # determine which comment filters to run
+
+ my $filter_commenter = $input->{'filter_commenter'};
+ my $filter_commenter_on = $input->{'commenter'};
+ my $filter_last = $input->{'filter_last'};
+ my $filter_last_period = $input->{'last'};
+
+ if (!$filter_commenter || $filter_last) {
+ $filter_commenter = '1';
+ $filter_commenter_on = 'reporter';
+ }
+
+ my $filter_commenter_id;
+ if ($filter_commenter && $filter_commenter_on eq 'is') {
+ Bugzilla::User::match_field({ 'commenter_is' => {'type' => 'single'} });
+ my $user = Bugzilla::User->new({ name => $input->{'commenter_is'} })
+ || ThrowUserError('invalid_object', { object => 'User', value => $input->{'commenter_is'} });
+ $filter_commenter_id = $user ? $user->id : 0;
+ }
+
+ my $filter_last_time;
+ if ($filter_last) {
+ if ($filter_last_period eq 'is') {
+ $filter_last_period = -1;
+ $filter_last_time = str2time($input->{'last_is'} . " 00:00:00") || 0;
+ } else {
+ detaint_natural($filter_last_period);
+ $filter_last_period = 14 if $filter_last_period < 14;
+ }
+ }
+
+ # form sql queries
+
+ my $now = (time);
+ my $bugs_sql = "
+ SELECT bug_id, short_desc, reporter, creation_ts
+ FROM bugs
+ WHERE product_id = ?
+ AND bug_status = 'UNCONFIRMED'";
+ if (@component_ids) {
+ $bugs_sql .= " AND component_id IN (" . join(',', @component_ids) . ")";
+ }
+ $bugs_sql .= "
+ ORDER BY creation_ts
+ ";
+
+ my $comment_count_sql = "
+ SELECT COUNT(*)
+ FROM longdescs
+ WHERE bug_id = ?
+ ";
+
+ my $comment_sql = "
+ SELECT who, bug_when, type, thetext, extra_data
+ FROM longdescs
+ WHERE bug_id = ?
+ ";
+ if (!Bugzilla->user->is_insider) {
+ $comment_sql .= " AND isprivate = 0 ";
+ }
+ $comment_sql .= "
+ ORDER BY bug_when DESC
+ LIMIT 1
+ ";
+
+ my $attach_sql = "
+ SELECT description, isprivate
+ FROM attachments
+ WHERE attach_id = ?
+ ";
+
+ # work on an initial list of bugs
+
+ my $list = $dbh->selectall_arrayref($bugs_sql, undef, $product->id);
+ my @bugs;
+
+ # this can be slow to process, resulting in 'service unavailable' errors from zeus
+ # so if too many bugs are returned, throw an error
+
+ if (scalar(@$list) > MAX_NUMBER_BUGS) {
+ ThrowUserError('report_too_many_bugs');
+ }
+
+ foreach my $entry (@$list) {
+ my ($bug_id, $summary, $reporter_id, $creation_ts) = @$entry;
+
+ next unless $user->can_see_bug($bug_id);
+
+ # get last comment information
+
+ my ($comment_count) = $dbh->selectrow_array($comment_count_sql, undef, $bug_id);
+ my ($commenter_id, $comment_ts, $type, $comment, $extra)
+ = $dbh->selectrow_array($comment_sql, undef, $bug_id);
+ my $commenter = 0;
+
+ # apply selected filters
+
+ if ($filter_commenter) {
+ next if $comment_count <= 1;
+
+ if ($filter_commenter_on eq 'reporter') {
+ next if $commenter_id != $reporter_id;
+
+ } elsif ($filter_commenter_on eq 'noconfirm') {
+ $commenter = Bugzilla::User->new({ id => $commenter_id, cache => 1 });
+ next if $commenter_id != $reporter_id
+ || $commenter->in_group('canconfirm');
+
+ } elsif ($filter_commenter_on eq 'is') {
+ next if $commenter_id != $filter_commenter_id;
+ }
+ } else {
+ $input->{'commenter'} = '';
+ $input->{'commenter_is'} = '';
+ }
+
+ if ($filter_last) {
+ my $comment_time = str2time($comment_ts)
+ or next;
+ if ($filter_last_period == -1) {
+ next if $comment_time >= $filter_last_time;
+ } else {
+ next if $now - $comment_time <= 60 * 60 * 24 * $filter_last_period;
+ }
+ } else {
+ $input->{'last'} = '';
+ $input->{'last_is'} = '';
+ }
+
+ # get data for attachment comments
+
+ if ($comment eq '' && $type == CMT_ATTACHMENT_CREATED) {
+ my ($description, $is_private) = $dbh->selectrow_array($attach_sql, undef, $extra);
+ next if $is_private && !Bugzilla->user->is_insider;
+ $comment = "(Attachment) " . $description;
+ }
+
+ # truncate long comments
+
+ if (length($comment) > 80) {
+ $comment = substr($comment, 0, 80) . '...';
+ }
+
+ # build bug hash for template
+
+ my $bug = {};
+ $bug->{id} = $bug_id;
+ $bug->{summary} = $summary;
+ $bug->{reporter} = Bugzilla::User->new({ id => $reporter_id, cache => 1 });
+ $bug->{creation_ts} = $creation_ts;
+ $bug->{commenter} = $commenter || Bugzilla::User->new({ id => $commenter_id, cache => 1 });
+ $bug->{comment_ts} = $comment_ts;
+ $bug->{comment} = $comment;
+ $bug->{comment_count} = $comment_count;
+ push @bugs, $bug;
+ }
+
+ @bugs = sort { $b->{comment_ts} cmp $a->{comment_ts} } @bugs;
+
+ $vars->{bugs} = \@bugs;
+ } else {
+ $input->{action} = '';
+ }
+
+ if (!$input->{filter_commenter} && !$input->{filter_last}) {
+ $input->{filter_commenter} = 1;
+ }
+
+ $vars->{'input'} = $input;
+}
+
+1;
diff --git a/extensions/BMO/lib/Reports/UserActivity.pm b/extensions/BMO/lib/Reports/UserActivity.pm
new file mode 100644
index 000000000..feca7c4b7
--- /dev/null
+++ b/extensions/BMO/lib/Reports/UserActivity.pm
@@ -0,0 +1,302 @@
+# 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.
+
+package Bugzilla::Extension::BMO::Reports::UserActivity;
+use strict;
+use warnings;
+
+use Bugzilla::Error;
+use Bugzilla::Extension::BMO::Util;
+use Bugzilla::User;
+use Bugzilla::Util qw(trim);
+use DateTime;
+
+sub report {
+ my ($vars) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $input = Bugzilla->input_params;
+
+ my @who = ();
+ my $from = trim($input->{'from'} || '');
+ my $to = trim($input->{'to'} || '');
+ my $action = $input->{'action'} || '';
+
+ # fix non-breaking hyphens
+ $from =~ s/\N{U+2011}/-/g;
+ $to =~ s/\N{U+2011}/-/g;
+
+ if ($from eq '') {
+ my $dt = DateTime->now()->subtract('weeks' => 8);
+ $from = $dt->ymd('-');
+ }
+ if ($to eq '') {
+ my $dt = DateTime->now();
+ $to = $dt->ymd('-');
+ }
+
+ if ($action eq 'run') {
+ if ($input->{'who'} eq '') {
+ ThrowUserError('user_activity_missing_username');
+ }
+ Bugzilla::User::match_field({ 'who' => {'type' => 'multi'} });
+
+ my $from_dt = string_to_datetime($from);
+ $from = $from_dt->ymd();
+
+ my $to_dt = string_to_datetime($to);
+ $to = $to_dt->ymd();
+ # add one day to include all activity that happened on the 'to' date
+ $to_dt->add(days => 1);
+
+ my ($activity_joins, $activity_where) = ('', '');
+ my ($attachments_joins, $attachments_where) = ('', '');
+ if (Bugzilla->params->{"insidergroup"}
+ && !Bugzilla->user->in_group(Bugzilla->params->{'insidergroup'}))
+ {
+ $activity_joins = "LEFT JOIN attachments
+ ON attachments.attach_id = bugs_activity.attach_id";
+ $activity_where = "AND COALESCE(attachments.isprivate, 0) = 0";
+ $attachments_where = $activity_where;
+ }
+
+ my @who_bits;
+ foreach my $who (
+ ref $input->{'who'}
+ ? @{$input->{'who'}}
+ : $input->{'who'}
+ ) {
+ push @who, $who;
+ push @who_bits, '?';
+ }
+ my $who_bits = join(',', @who_bits);
+
+ if (!@who) {
+ my $template = Bugzilla->template;
+ my $cgi = Bugzilla->cgi;
+ my $vars = {};
+ $vars->{'script'} = $cgi->url(-relative => 1);
+ $vars->{'fields'} = {};
+ $vars->{'matches'} = [];
+ $vars->{'matchsuccess'} = 0;
+ $vars->{'matchmultiple'} = 1;
+ print $cgi->header();
+ $template->process("global/confirm-user-match.html.tmpl", $vars)
+ || ThrowTemplateError($template->error());
+ exit;
+ }
+
+ $from_dt = $from_dt->ymd() . ' 00:00:00';
+ $to_dt = $to_dt->ymd() . ' 23:59:59';
+ my @params;
+ for (1..4) {
+ push @params, @who;
+ push @params, ($from_dt, $to_dt);
+ }
+
+ my $order = ($input->{'sort'} && $input->{'sort'} eq 'bug')
+ ? 'bug_id, bug_when' : 'bug_when';
+
+ my $comment_filter = '';
+ if (!Bugzilla->user->is_insider) {
+ $comment_filter = 'AND longdescs.isprivate = 0';
+ }
+
+ my $query = "
+ SELECT
+ fielddefs.name,
+ bugs_activity.bug_id,
+ bugs_activity.attach_id,
+ ".$dbh->sql_date_format('bugs_activity.bug_when', '%Y.%m.%d %H:%i:%s')." AS ts,
+ bugs_activity.removed,
+ bugs_activity.added,
+ profiles.login_name,
+ bugs_activity.comment_id,
+ bugs_activity.bug_when
+ FROM bugs_activity
+ $activity_joins
+ LEFT JOIN fielddefs
+ ON bugs_activity.fieldid = fielddefs.id
+ INNER JOIN profiles
+ ON profiles.userid = bugs_activity.who
+ WHERE profiles.login_name IN ($who_bits)
+ AND bugs_activity.bug_when >= ? AND bugs_activity.bug_when <= ?
+ $activity_where
+
+ UNION ALL
+
+ SELECT
+ 'bug_id' AS name,
+ bugs.bug_id,
+ NULL AS attach_id,
+ ".$dbh->sql_date_format('bugs.creation_ts', '%Y.%m.%d %H:%i:%s')." AS ts,
+ '(new bug)' AS removed,
+ bugs.short_desc AS added,
+ profiles.login_name,
+ NULL AS comment_id,
+ bugs.creation_ts AS bug_when
+ FROM bugs
+ INNER JOIN profiles
+ ON profiles.userid = bugs.reporter
+ WHERE profiles.login_name IN ($who_bits)
+ AND bugs.creation_ts >= ? AND bugs.creation_ts <= ?
+
+ UNION ALL
+
+ SELECT
+ 'longdesc' AS name,
+ longdescs.bug_id,
+ NULL AS attach_id,
+ DATE_FORMAT(longdescs.bug_when, '%Y.%m.%d %H:%i:%s') AS ts,
+ '' AS removed,
+ '' AS added,
+ profiles.login_name,
+ longdescs.comment_id AS comment_id,
+ longdescs.bug_when
+ FROM longdescs
+ INNER JOIN profiles
+ ON profiles.userid = longdescs.who
+ WHERE profiles.login_name IN ($who_bits)
+ AND longdescs.bug_when >= ? AND longdescs.bug_when <= ?
+ $comment_filter
+
+ UNION ALL
+
+ SELECT
+ 'attachments.description' AS name,
+ attachments.bug_id,
+ attachments.attach_id,
+ ".$dbh->sql_date_format('attachments.creation_ts', '%Y.%m.%d %H:%i:%s')." AS ts,
+ '(new attachment)' AS removed,
+ attachments.description AS added,
+ profiles.login_name,
+ NULL AS comment_id,
+ attachments.creation_ts AS bug_when
+ FROM attachments
+ INNER JOIN profiles
+ ON profiles.userid = attachments.submitter_id
+ WHERE profiles.login_name IN ($who_bits)
+ AND attachments.creation_ts >= ? AND attachments.creation_ts <= ?
+ $attachments_where
+
+ ORDER BY $order ";
+
+ my $list = $dbh->selectall_arrayref($query, undef, @params);
+
+ if ($input->{debug}) {
+ while (my $param = shift @params) {
+ $query =~ s/\?/$dbh->quote($param)/e;
+ }
+ $vars->{debug_sql} = $query;
+ }
+
+ my @operations;
+ my $operation = {};
+ my $changes = [];
+ my $incomplete_data = 0;
+ my %bug_ids;
+
+ foreach my $entry (@$list) {
+ my ($fieldname, $bugid, $attachid, $when, $removed, $added, $who,
+ $comment_id) = @$entry;
+ my %change;
+ my $activity_visible = 1;
+
+ next unless Bugzilla->user->can_see_bug($bugid);
+
+ # check if the user should see this field's activity
+ if ($fieldname eq 'remaining_time'
+ || $fieldname eq 'estimated_time'
+ || $fieldname eq 'work_time'
+ || $fieldname eq 'deadline')
+ {
+ $activity_visible = Bugzilla->user->is_timetracker;
+ }
+ elsif ($fieldname eq 'longdescs.isprivate'
+ && !Bugzilla->user->is_insider
+ && $added)
+ {
+ $activity_visible = 0;
+ }
+ else {
+ $activity_visible = 1;
+ }
+
+ if ($activity_visible) {
+ # Check for the results of an old Bugzilla data corruption bug
+ if (($added eq '?' && $removed eq '?')
+ || ($added =~ /^\? / || $removed =~ /^\? /)) {
+ $incomplete_data = 1;
+ }
+
+ # Start a new changeset if required (depends on the sort order)
+ my $is_new_changeset;
+ if ($order eq 'bug_when') {
+ $is_new_changeset =
+ $operation->{'who'} &&
+ (
+ $who ne $operation->{'who'}
+ || $when ne $operation->{'when'}
+ || $bugid != $operation->{'bug'}
+ );
+ } else {
+ $is_new_changeset =
+ $operation->{'bug'} &&
+ $bugid != $operation->{'bug'};
+ }
+ if ($is_new_changeset) {
+ $operation->{'changes'} = $changes;
+ push (@operations, $operation);
+ $operation = {};
+ $changes = [];
+ }
+
+ $bug_ids{$bugid} = 1;
+
+ $operation->{'bug'} = $bugid;
+ $operation->{'who'} = $who;
+ $operation->{'when'} = $when;
+
+ $change{'fieldname'} = $fieldname;
+ $change{'attachid'} = $attachid;
+ $change{'removed'} = $removed;
+ $change{'added'} = $added;
+ $change{'when'} = $when;
+
+ if ($comment_id) {
+ $change{'comment'} = Bugzilla::Comment->new($comment_id);
+ next if $change{'comment'}->count == 0;
+ }
+
+ if ($attachid) {
+ $change{'attach'} = Bugzilla::Attachment->new($attachid);
+ }
+
+ push (@$changes, \%change);
+ }
+ }
+
+ if ($operation->{'who'}) {
+ $operation->{'changes'} = $changes;
+ push (@operations, $operation);
+ }
+
+ $vars->{'incomplete_data'} = $incomplete_data;
+ $vars->{'operations'} = \@operations;
+
+ my @bug_ids = sort { $a <=> $b } keys %bug_ids;
+ $vars->{'bug_ids'} = \@bug_ids;
+ }
+
+ $vars->{'action'} = $action;
+ $vars->{'who'} = join(',', @who);
+ $vars->{'who_count'} = scalar @who;
+ $vars->{'from'} = $from;
+ $vars->{'to'} = $to;
+ $vars->{'sort'} = $input->{'sort'};
+}
+
+1;
diff --git a/extensions/BMO/lib/Util.pm b/extensions/BMO/lib/Util.pm
new file mode 100644
index 000000000..ccb25758b
--- /dev/null
+++ b/extensions/BMO/lib/Util.pm
@@ -0,0 +1,84 @@
+# 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.
+
+package Bugzilla::Extension::BMO::Util;
+use strict;
+use warnings;
+
+use Bugzilla::Error;
+use Bugzilla::Extension::BMO::Data qw($cf_disabled_flags);
+use Date::Parse;
+use DateTime;
+
+use base qw(Exporter);
+
+our @EXPORT = qw( string_to_datetime
+ time_to_datetime
+ parse_date
+ is_active_status_field );
+
+sub string_to_datetime {
+ my $input = shift;
+ my $time = parse_date($input)
+ or ThrowUserError('report_invalid_date', { date => $input });
+ return time_to_datetime($time);
+}
+
+sub time_to_datetime {
+ my $time = shift;
+ return DateTime->from_epoch(epoch => $time)
+ ->set_time_zone('local')
+ ->truncate(to => 'day');
+}
+
+sub parse_date {
+ my ($str) = @_;
+ if ($str =~ /^(-|\+)?(\d+)([hHdDwWmMyY])$/) {
+ # relative date
+ my ($sign, $amount, $unit, $date) = ($1, $2, lc $3, time);
+ my ($sec, $min, $hour, $mday, $month, $year, $wday) = localtime($date);
+ $amount = -$amount if $sign && $sign eq '+';
+ if ($unit eq 'w') {
+ # convert weeks to days
+ $amount = 7 * $amount + $wday;
+ $unit = 'd';
+ }
+ if ($unit eq 'd') {
+ $date -= $sec + 60 * $min + 3600 * $hour + 24 * 3600 * $amount;
+ return $date;
+ }
+ elsif ($unit eq 'y') {
+ return str2time(sprintf("%4d-01-01 00:00:00", $year + 1900 - $amount));
+ }
+ elsif ($unit eq 'm') {
+ $month -= $amount;
+ while ($month < 0) { $year--; $month += 12; }
+ return str2time(sprintf("%4d-%02d-01 00:00:00", $year + 1900, $month + 1));
+ }
+ elsif ($unit eq 'h') {
+ # Special case 0h for 'beginning of this hour'
+ if ($amount == 0) {
+ $date -= $sec + 60 * $min;
+ } else {
+ $date -= 3600 * $amount;
+ }
+ return $date;
+ }
+ return undef;
+ }
+ return str2time($str);
+}
+
+sub is_active_status_field {
+ my ($field_name) = @_;
+ if ($field_name =~ /^cf_status/) {
+ return !grep { $field_name eq $_ } @$cf_disabled_flags
+ }
+ return 0;
+}
+
+1;