use Bugzilla::Error;
use Bugzilla::Status qw(is_open_state);
-use Bugzilla::Util qw(datetime_from);
+use Bugzilla::Util qw(datetime_from diff_arrays);
use Bugzilla;
+
+use Chart::Clicker;
+use Chart::Clicker::Axis::DateTime;
+use Chart::Clicker::Data::Series;
+use Chart::Clicker::Data::DataSet;
+use Chart::Clicker::Renderer::Point;
+use Chart::Clicker::Renderer::StackedArea;
+
use DateTime;
-use List::Util qw(any first sum);
+use JSON::PP::Boolean;
+use List::Util qw(any first sum uniq);
+use Mojo::File qw(tempfile);
use POSIX qw(ceil);
use Type::Utils;
-use Types::Standard qw(Num Int Bool Str HashRef ArrayRef CodeRef Map Dict Enum);
+use Types::Standard qw(Num Int Bool Str HashRef ArrayRef CodeRef Maybe Map Dict Enum Optional Object);
-my $DateTime = class_type { class => 'DateTime' };
+my $DateTime = class_type {class => 'DateTime'};
+my $JSONBool = class_type {class => 'JSON::PP::Boolean'};
-has 'start_date' => (
- is => 'ro',
- required => 1,
- isa => $DateTime,
-);
+has 'start_date' => (is => 'ro', required => 1, isa => $DateTime,);
-has 'end_date' => (
- is => 'ro',
- required => 1,
- isa => $DateTime,
-);
+has 'end_date' => (is => 'ro', required => 1, isa => $DateTime,);
-has 'products' => (
- is => 'ro',
- required => 1,
- isa => ArrayRef [Str],
+# The teams are loaded from an admin parameter containing JSON, e.g.:
+# {
+# "Plugins": {
+# "Core": {
+# "all_components": false,
+# "prefixed_components": ["Plugin"],
+# "named_components": [
+# "Plug-ins"
+# ]
+# },
+# "Plugins": { "all_components": true },
+# "External Software Affecting Firefox": { "all_components": true }
+# },
+# ...
+# }
+# This will create a new team ("Plugins") which groups bugs in the following components:
+# - All components in the Core product that start with "Plugin" (e.g. Plugin:FlashPlayer)
+# - The single "Plug-ins" component in the Core product.
+# - All components in the Plugins _product_.
+# - All components in the External Software Affecting Firefox product.
+has 'teams' => (
+ is => 'ro',
+ required => 1,
+ isa => HashRef [
+ HashRef [
+ Dict [
+ all_components => $JSONBool,
+ prefixed_components => Optional [ArrayRef [Str]],
+ named_components => Optional [ArrayRef [Str]],
+ ],
+ ],
+ ],
);
-has 'sec_keywords' => (
- is => 'ro',
- required => 1,
- isa => ArrayRef [Str],
-);
+has 'sec_keywords' => (is => 'ro', required => 1, isa => ArrayRef [Str],);
-has 'initial_bug_ids' => (
- is => 'lazy',
- isa => ArrayRef [Int],
-);
+has 'products' => (is => 'lazy', isa => ArrayRef [Str],);
+
+has 'initial_bug_ids' => (is => 'lazy', isa => ArrayRef [Int],);
has 'initial_bugs' => (
- is => 'lazy',
- isa => HashRef [
- Dict [
- id => Int,
- product => Str,
- sec_level => Str,
- is_open => Bool,
- created_at => $DateTime,
- ],
+ is => 'lazy',
+ isa => HashRef [
+ Dict [
+ id => Int,
+ product => Str,
+ component => Str,
+ team => Maybe [Str],
+ sec_level => Str,
+ status => Str,
+ is_stalled => Bool,
+ is_open => Bool,
+ created_at => $DateTime,
],
+ ],
);
-has 'check_open_state' => (
- is => 'ro',
- isa => CodeRef,
- default => sub { return \&is_open_state; },
-);
+has 'check_open_state' => (is => 'ro', isa => CodeRef, default => sub { return \&is_open_state; },);
has 'events' => (
- is => 'lazy',
- isa => ArrayRef [
- Dict [
- bug_id => Int,
- bug_when => $DateTime,
- field_name => Enum [qw(bug_status keywords)],
- removed => Str,
- added => Str,
- ],
+ is => 'lazy',
+ isa => ArrayRef [
+ Dict [
+ bug_id => Int,
+ bug_when => $DateTime,
+ field_name => Enum [qw(bug_status keywords)],
+ removed => Str,
+ added => Str,
],
+ ],
);
has 'results' => (
- is => 'lazy',
- isa => ArrayRef [
- Dict [
- date => $DateTime,
- bugs_by_product => HashRef [
- Dict [
- open => ArrayRef [Int],
- closed => ArrayRef [Int],
- median_age_open => Num
- ]
- ],
- bugs_by_sec_keyword => HashRef [
- Dict [
- open => ArrayRef [Int],
- closed => ArrayRef [Int],
- median_age_open => Num
- ]
- ],
- ],
+ is => 'lazy',
+ isa => ArrayRef [
+ Dict [
+ date => $DateTime,
+ bugs_by_team => HashRef [Dict [open => ArrayRef [Int], closed => ArrayRef [Int], median_age_open => Num]],
+ bugs_by_sec_keyword => HashRef [Dict [open => ArrayRef [Int], closed => ArrayRef [Int], median_age_open => Num]],
],
+ ],
+);
+
+has 'deltas' => (
+ is => 'lazy',
+ isa => Dict [
+ by_sec_keyword => HashRef [Dict [added => ArrayRef [Int], closed => ArrayRef [Int],],],
+ by_team => HashRef [Dict [added => ArrayRef [Int], closed => ArrayRef [Int],],],
+ ],
);
+has 'graphs' => (
+ is => 'lazy',
+ isa => Dict [bugs_by_sec_keyword_count => Object, bugs_by_sec_keyword_age => Object, bugs_by_team_age => Object,],
+);
+
+sub _build_products {
+ my ($self) = @_;
+ my @products = ();
+ foreach my $team (values %{$self->teams}) {
+ foreach my $product (keys %$team) {
+ push @products, $product;
+ }
+ }
+ @products = uniq @products;
+ return \@products;
+}
+
sub _build_initial_bug_ids {
- # TODO: Handle changes in product (e.g. gravyarding) by searching the events table
- # for changes to the 'product' field where one of $self->products is found in
- # the 'removed' field, add the related bug id to the list of initial bugs.
- my ($self) = @_;
- my $dbh = Bugzilla->dbh;
- my $products = join ', ', map { $dbh->quote($_) } @{ $self->products };
- my $sec_keywords = join ', ', map { $dbh->quote($_) } @{ $self->sec_keywords };
- my $query = qq{
+
+# TODO: Handle changes in product (e.g. gravyarding) by searching the events table
+# for changes to the 'product' field where one of $self->products is found in
+# the 'removed' field, add the related bug id to the list of initial bugs.
+ my ($self) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $products = join ', ', map { $dbh->quote($_) } @{$self->products};
+ my $sec_keywords = join ', ', map { $dbh->quote($_) } @{$self->sec_keywords};
+ my $query = qq{
SELECT
bug_id
FROM
keyword.name IN ($sec_keywords)
AND product.name IN ($products)
};
- return Bugzilla->dbh->selectcol_arrayref($query);
+ return Bugzilla->dbh->selectcol_arrayref($query);
}
sub _build_initial_bugs {
- my ($self) = @_;
- my $bugs = {};
- my $bugs_list = Bugzilla::Bug->new_from_list( $self->initial_bug_ids );
- for my $bug (@$bugs_list) {
- $bugs->{ $bug->id } = {
- id => $bug->id,
- product => $bug->product,
- sec_level => (
- # Select the first keyword matching one of the target keywords
- # (of which there _should_ only be one found anyway).
- first {
- my $x = $_;
- grep { lc($_) eq lc( $x->name ) } @{ $self->sec_keywords }
- }
- @{ $bug->keyword_objects }
- )->name,
- is_open => $self->check_open_state->( $bug->status->name ),
- created_at => datetime_from( $bug->creation_ts ),
- };
- }
- return $bugs;
+ my ($self) = @_;
+ my $bugs = {};
+ my $bugs_list = Bugzilla::Bug->new_from_list($self->initial_bug_ids);
+ for my $bug (@$bugs_list) {
+ my $is_stalled = grep { lc($_->name) eq 'stalled' } @{$bug->keyword_objects};
+ $bugs->{$bug->id} = {
+ id => $bug->id,
+ product => $bug->product,
+ component => $bug->component,
+ team => $self->_find_team($bug->product, $bug->component),
+ sec_level => (
+
+ # Select the first keyword matching one of the target keywords
+ # (of which there _should_ only be one found anyway).
+ first {
+ my $x = $_;
+ grep { lc($_) eq lc($x->name) } @{$self->sec_keywords}
+ }
+ @{$bug->keyword_objects}
+ )->name,
+ status => $bug->status->name,
+ is_stalled => scalar $is_stalled,
+ is_open => $self->_is_bug_open($bug->status->name, scalar $is_stalled),
+ created_at => datetime_from($bug->creation_ts),
+ };
+ }
+ return $bugs;
}
sub _build_events {
- my ($self) = @_;
- return [] if !(@{$self->initial_bug_ids});
- my $bug_ids = join ', ', @{ $self->initial_bug_ids };
- my $start_date = $self->start_date->ymd('-');
- my $query = qq{
+ my ($self) = @_;
+ return [] if !(@{$self->initial_bug_ids});
+ my $bug_ids = join ', ', @{$self->initial_bug_ids};
+ my $start_date = $self->start_date->ymd('-');
+ my $query = qq{
SELECT
bug_id,
bug_when,
AND bug_when >= '$start_date 00:00:00'
GROUP BY bug_id , bug_when , field.name
};
- my $result = Bugzilla->dbh->selectall_hashref( $query, 'bug_id' );
- my @events = values %$result;
- foreach my $event (@events) {
- $event->{bug_when} = datetime_from( $event->{bug_when} );
- }
+ my $result = Bugzilla->dbh->selectall_hashref($query, 'bug_id');
+ my @events = values %$result;
+ foreach my $event (@events) {
+ $event->{bug_when} = datetime_from($event->{bug_when});
+ }
- # We sort by reverse chronological order instead of ORDER BY
- # since values %hash doesn't guareentee any order.
- @events = sort { $b->{bug_when} cmp $a->{bug_when} } @events;
- return \@events;
+ # We sort by reverse chronological order instead of ORDER BY
+ # since values %hash doesn't guareentee any order.
+ @events = sort { $b->{bug_when} cmp $a->{bug_when} } @events;
+ return \@events;
}
sub _build_results {
- my ($self) = @_;
- my $e = 0;
- my $bugs = $self->initial_bugs;
- my @results = ();
-
- # We must generate a report for each week in the target time interval, regardless of
- # whether anything changed. The for loop here ensures that we do so.
- for ( my $report_date = $self->end_date; $report_date >= $self->start_date; $report_date->subtract( weeks => 1 ) ) {
- # We rewind events while there are still events existing which occured after the start
- # of the report week. The bugs will reflect a snapshot of how they were at the start of the week.
- # $self->events is ordered reverse chronologically, so the end of the array is the earliest event.
- while ( $e < @{ $self->events }
- && ( @{ $self->events }[$e] )->{bug_when} > $report_date )
- {
- my $event = @{ $self->events }[$e];
- my $bug = $bugs->{ $event->{bug_id} };
-
- # Undo bug status changes
- if ( $event->{field_name} eq 'bug_status' ) {
- $bug->{is_open} = $self->check_open_state->( $event->{removed} );
- }
-
- # Undo keyword changes
- if ( $event->{field_name} eq 'keywords' ) {
- my $bug_sec_level = $bug->{sec_level};
- if ( $event->{added} =~ /\b\Q$bug_sec_level\E\b/ ) {
- # If the currently set sec level was added in this event, remove it.
- $bug->{sec_level} = undef;
- }
- if ( $event->{removed} ) {
- # If a target sec keyword was removed, add the first one back.
- my $removed_sec = first { $event->{removed} =~ /\b\Q$_\E\b/ } @{ $self->sec_keywords };
- $bug->{sec_level} = $removed_sec if ($removed_sec);
- }
- }
-
- $e++;
+ my ($self) = @_;
+ my $e = 0;
+ my $bugs = $self->initial_bugs;
+ my @results = ();
+
+# We must generate a report for each week in the target time interval, regardless of
+# whether anything changed. The for loop here ensures that we do so.
+ for (my $report_date = $self->end_date->clone();
+ $report_date >= $self->start_date; $report_date->subtract(weeks => 1))
+ {
+
+# We rewind events while there are still events existing which occured after the start
+# of the report week. The bugs will reflect a snapshot of how they were at the start of the week.
+# $self->events is ordered reverse chronologically, so the end of the array is the earliest event.
+ while ($e < @{$self->events} && (@{$self->events}[$e])->{bug_when} > $report_date) {
+ my $event = @{$self->events}[$e];
+ my $bug = $bugs->{$event->{bug_id}};
+
+
+ # Undo bug status changes
+ if ($event->{field_name} eq 'bug_status') {
+ $bug->{status} = $event->{removed};
+ $bug->{is_open} = $self->_is_bug_open($bug->{status}, $bug->{is_stalled});
+ }
+
+ # Undo sec keyword changes
+ if ($event->{field_name} eq 'keywords') {
+ my $bug_sec_level = $bug->{sec_level};
+ if ($event->{added} =~ /\b\Q$bug_sec_level\E\b/) {
+
+ # If the currently set sec level was added in this event, remove it.
+ $bug->{sec_level} = undef;
+ }
+ if ($event->{removed}) {
+
+ # If a target sec keyword was removed, add the first one back.
+ my $removed_sec = first { $event->{removed} =~ /\b\Q$_\E\b/ }
+ @{$self->sec_keywords};
+ $bug->{sec_level} = $removed_sec if ($removed_sec);
}
+ }
- # Remove uncreated bugs
- foreach my $bug_key ( keys %$bugs ) {
- if ( $bugs->{$bug_key}->{created_at} > $report_date ) {
- delete $bugs->{$bug_key};
- }
+ # Undo stalled keyword changes
+ if ($event->{field_name} eq 'keywords') {
+ if ($event->{added} =~ /\b\stalled\b/) {
+
+ # If the stalled keyword was added in this event, remove it:
+ $bug->{is_stalled} = 0;
+ }
+ if ($event->{removed} =~ /\b\stalled\b/) {
+
+ # If the stalled keyword was removed in this event, add it:
+ $bug->{is_stalled} = 1;
}
+ $bug->{is_open} = $self->_is_bug_open($bug->{status}, $bug->{is_stalled});
+ }
+
+ $e++;
+ }
- # Report!
- my $date_snapshot = $report_date->clone();
- my @bugs_snapshot = values %$bugs;
- my $result = {
- date => $date_snapshot,
- bugs_by_product => $self->_bugs_by_product( $date_snapshot, @bugs_snapshot ),
- bugs_by_sec_keyword => $self->_bugs_by_sec_keyword( $date_snapshot, @bugs_snapshot ),
- };
- push @results, $result;
+ # Remove uncreated bugs
+ foreach my $bug_key (keys %$bugs) {
+ if ($bugs->{$bug_key}->{created_at} > $report_date) {
+ delete $bugs->{$bug_key};
+ }
}
- return [reverse @results];
+ # Report!
+ my $date_snapshot = $report_date->clone();
+ my @bugs_snapshot = values %$bugs;
+ my $result = {
+ date => $date_snapshot,
+ bugs_by_team => $self->_bugs_by_team($date_snapshot, @bugs_snapshot),
+ bugs_by_sec_keyword => $self->_bugs_by_sec_keyword($date_snapshot, @bugs_snapshot),
+ };
+ push @results, $result;
+ }
+
+ return [reverse @results];
}
-sub _bugs_by_product {
- my ( $self, $report_date, @bugs ) = @_;
- my $result = {};
- my $groups = {};
- foreach my $product ( @{ $self->products } ) {
- $groups->{$product} = [];
+sub _build_graphs {
+ my ($self) = @_;
+ my $graphs = {};
+ my $data = [
+ {
+ id => 'bugs_by_sec_keyword_count',
+ title => sprintf('Open security bugs by severity (%s to %s)', $self->start_date->ymd, $self->end_date->ymd),
+ range_label => 'Open Bugs Count',
+ datasets => [
+ map {
+ my $keyword = $_;
+ {
+ name => $_,
+ keys => [map { $_->{date}->epoch } @{$self->results}],
+ values => [map { scalar @{$_->{bugs_by_sec_keyword}->{$keyword}->{open}} } @{$self->results}],
+ }
+ } @{$self->sec_keywords}
+ ],
+ renderer => Chart::Clicker::Renderer::StackedArea->new(opacity => .6),
+ image_file => tempfile(SUFFIX => '.png'),
+ },
+ {
+ id => 'bugs_by_sec_keyword_age',
+ title => sprintf(
+ 'Median age of open security bugs by severity (%s to %s)',
+ $self->start_date->ymd,
+ $self->end_date->ymd
+ ),
+ range_label => 'Median Age (days)',
+ datasets => [
+ map {
+ my $keyword = $_;
+ {
+ name => $_,
+ keys => [map { $_->{date}->epoch } @{$self->results}],
+ values => [map { $_->{bugs_by_sec_keyword}->{$keyword}->{median_age_open} } @{$self->results}],
+ }
+ } @{$self->sec_keywords}
+ ],
+ image_file => tempfile(SUFFIX => '.png'),
+ },
+ {
+ id => 'bugs_by_team_age',
+ title =>
+ sprintf('Median age of open security bugs by team (%s to %s)', $self->start_date->ymd, $self->end_date->ymd),
+ range_label => 'Median Age (days)',
+ datasets => [
+ map {
+ my $team = $_;
+ {
+ name => $_,
+ keys => [map { $_->{date}->epoch } @{$self->results}],
+ values => [map { $_->{bugs_by_team}->{$team}->{median_age_open} } @{$self->results}],
+ }
+ } keys %{$self->teams}
+ ],
+ image_file => tempfile(SUFFIX => '.png'),
+ },
+ ];
+
+ foreach my $datum (@$data) {
+ my $cc = Chart::Clicker->new;
+ $cc->title->text($datum->{title});
+ $cc->title->font->size(14);
+ $cc->title->padding->bottom(5);
+ $cc->title->padding->top(5);
+ foreach my $dataset (@{$datum->{datasets}}) {
+ my $series = Chart::Clicker::Data::Series->new(
+ name => $dataset->{name},
+ values => $dataset->{values},
+ keys => $dataset->{keys},
+ );
+ my $ds = Chart::Clicker::Data::DataSet->new(series => [$series]);
+ $cc->add_to_datasets($ds);
}
- foreach my $bug (@bugs) {
- # We skip over bugs with no sec level which can happen during event rewinding.
- if ( $bug->{sec_level} ) {
- push @{ $groups->{ $bug->{product} } }, $bug;
- }
+ my $ctx = $cc->get_context('default');
+ $ctx->renderer($datum->{renderer}) if exists $datum->{renderer};
+ $ctx->range_axis->label($datum->{range_label});
+ $ctx->domain_axis(
+ Chart::Clicker::Axis::DateTime->new(position => 'bottom', orientation => 'horizontal', format => "%m/%d",));
+ $cc->write_output($datum->{image_file});
+ $graphs->{$datum->{id}} = $datum->{image_file};
+ }
+
+ return $graphs;
+}
+
+sub _build_deltas {
+ my ($self) = @_;
+ my @teams = keys %{$self->teams};
+ my $deltas = {by_team => {}, by_sec_keyword => {}};
+ my $data = [
+ {domain => \@teams, results_key => 'bugs_by_team', deltas_key => 'by_team',},
+ {domain => $self->sec_keywords, results_key => 'bugs_by_sec_keyword', deltas_key => 'by_sec_keyword',}
+ ];
+
+ foreach my $datum (@$data) {
+ foreach my $item (@{$datum->{domain}}) {
+ my $current_result = $self->results->[-1]->{$datum->{results_key}}->{$item};
+ my $last_result = $self->results->[-2]->{$datum->{results_key}}->{$item};
+
+ my @all_bugs_this_week = (@{$current_result->{open}}, @{$current_result->{closed}});
+ my @all_bugs_last_week = (@{$last_result->{open}}, @{$last_result->{closed}});
+
+ my $added_delta = (diff_arrays(\@all_bugs_this_week, \@all_bugs_last_week))[0];
+ my $closed_delta = (diff_arrays($current_result->{closed}, $last_result->{closed}))[0];
+
+ $deltas->{$datum->{deltas_key}}->{$item} = {added => $added_delta, closed => $closed_delta};
}
- foreach my $product ( @{ $self->products } ) {
- my @open = map { $_->{id} } grep { ( $_->{is_open} ) } @{ $groups->{$product} };
- my @closed = map { $_->{id} } grep { !( $_->{is_open} ) } @{ $groups->{$product} };
- my @ages = map { $_->{created_at}->subtract_datetime_absolute($report_date)->seconds / 86_400; }
- grep { ( $_->{is_open} ) } @{ $groups->{$product} };
- $result->{$product} = {
- open => \@open,
- closed => \@closed,
- median_age_open => @ages ? _median(@ages) : 0,
- };
+ }
+ return $deltas;
+}
+
+sub _bugs_by_team {
+ my ($self, $report_date, @bugs) = @_;
+ my $result = {};
+ my $groups = {};
+ foreach my $team (keys %{$self->teams}) {
+ $groups->{$team} = [];
+ }
+ foreach my $bug (@bugs) {
+
+ # We skip over bugs with no sec level which can happen during event rewinding.
+ # We also skip over bugs that don't fall into one of the specified teams.
+ if (defined $bug->{sec_level} && defined $bug->{team}) {
+ push @{$groups->{$bug->{team}}}, $bug;
}
+ }
+ foreach my $team (keys %{$self->teams}) {
+ my @open = map { $_->{id} } grep { ($_->{is_open}) } @{$groups->{$team}};
+ my @closed = map { $_->{id} } grep { !($_->{is_open}) } @{$groups->{$team}};
+ my @ages = map { $_->{created_at}->subtract_datetime_absolute($report_date)->seconds / 86_400; }
+ grep { ($_->{is_open}) } @{$groups->{$team}};
+ $result->{$team} = {open => \@open, closed => \@closed, median_age_open => @ages ? _median(@ages) : 0,};
+ }
- return $result;
+ return $result;
}
sub _bugs_by_sec_keyword {
- my ( $self, $report_date, @bugs ) = @_;
- my $result = {};
- my $groups = {};
- foreach my $sec_keyword ( @{ $self->sec_keywords } ) {
- $groups->{$sec_keyword} = [];
- }
- foreach my $bug (@bugs) {
- # We skip over bugs with no sec level which can happen during event rewinding.
- if ( $bug->{sec_level} ) {
- push @{ $groups->{ $bug->{sec_level} } }, $bug;
- }
- }
- foreach my $sec_keyword ( @{ $self->sec_keywords } ) {
- my @open = map { $_->{id} } grep { ( $_->{is_open} ) } @{ $groups->{$sec_keyword} };
- my @closed = map { $_->{id} } grep { !( $_->{is_open} ) } @{ $groups->{$sec_keyword} };
- my @ages = map { $_->{created_at}->subtract_datetime_absolute($report_date)->seconds / 86_400 }
- grep { ( $_->{is_open} ) } @{ $groups->{$sec_keyword} };
- $result->{$sec_keyword} = {
- open => \@open,
- closed => \@closed,
- median_age_open => @ages ? _median(@ages) : 0,
- };
+ my ($self, $report_date, @bugs) = @_;
+ my $result = {};
+ my $groups = {};
+ foreach my $sec_keyword (@{$self->sec_keywords}) {
+ $groups->{$sec_keyword} = [];
+ }
+ foreach my $bug (@bugs) {
+
+ # We skip over bugs with no sec level which can happen during event rewinding.
+ if (defined $bug->{sec_level}) {
+ push @{$groups->{$bug->{sec_level}}}, $bug;
}
+ }
+ foreach my $sec_keyword (@{$self->sec_keywords}) {
+ my @open = map { $_->{id} } grep { ($_->{is_open}) } @{$groups->{$sec_keyword}};
+ my @closed = map { $_->{id} } grep { !($_->{is_open}) } @{$groups->{$sec_keyword}};
+ my @ages = map { $_->{created_at}->subtract_datetime_absolute($report_date)->seconds / 86_400 }
+ grep { ($_->{is_open}) } @{$groups->{$sec_keyword}};
+ $result->{$sec_keyword} = {open => \@open, closed => \@closed, median_age_open => @ages ? _median(@ages) : 0,};
+ }
- return $result;
+ return $result;
+}
+
+sub _is_bug_open {
+ my ($self, $status, $is_stalled) = @_;
+ return ($self->check_open_state->($status)) && !($is_stalled);
+}
+
+sub _find_team {
+ my ($self, $product, $component) = @_;
+ foreach my $team_key (keys %{$self->teams}) {
+ my $team = $self->teams->{$team_key};
+ if (exists $team->{$product}) {
+ return $team_key if $team->{$product}->{all_components};
+ return $team_key if any { lc $component eq lc $_ } @{$team->{$product}->{named_components}};
+ return $team_key if any { $component =~ /^\Q$_\E/i } @{$team->{$product}->{prefixed_components}};
+ }
+ }
+ return undef;
}
sub _median {
- # From tlm @ https://www.perlmonks.org/?node_id=474564. Jul 14, 2005
- return sum( ( sort { $a <=> $b } @_ )[ int( $#_ / 2 ), ceil( $#_ / 2 ) ] ) / 2;
+
+ # From tlm @ https://www.perlmonks.org/?node_id=474564. Jul 14, 2005
+ return sum((sort { $a <=> $b } @_)[int($#_ / 2), ceil($#_ / 2)]) / 2;
}
+
1;
use DateTime;
use URI;
use JSON::MaybeXS;
+use Mojo::File;
BEGIN { Bugzilla->extensions }
Bugzilla->usage_mode(USAGE_MODE_CMDLINE);
my $html;
my $template = Bugzilla->template();
-my $end_date = DateTime->new( year => $ARGV[0], month => $ARGV[1], day => $ARGV[2] );
-my $start_date = $end_date->clone()->subtract( months => 6 );
+my $end_date = DateTime->new(year => $ARGV[0], month => $ARGV[1], day => $ARGV[2]);
+my $start_date = $end_date->clone()->subtract(months => 12);
my $report_week = $end_date->ymd('-');
-my $products = decode_json( Bugzilla->params->{report_secbugs_products} );
-my $sec_keywords = [ 'sec-critical', 'sec-high' ];
+my $teams = decode_json(Bugzilla->params->{report_secbugs_teams});
+my $sec_keywords = ['sec-critical', 'sec-high'];
my $report = Bugzilla::Report::SecurityRisk->new(
- start_date => $start_date,
- end_date => $end_date,
- products => $products,
- sec_keywords => $sec_keywords
+ start_date => $start_date,
+ end_date => $end_date,
+ teams => $teams,
+ sec_keywords => $sec_keywords
);
+
+my $bugs_by_team = $report->results->[-1]->{bugs_by_team};
+my @sorted_team_names = sort { ## no critic qw(BuiltinFunctions::ProhibitReverseSortBlock
+ @{$bugs_by_team->{$b}->{open}} <=> @{$bugs_by_team->{$a}->{open}} ## no critic qw(Freenode::DollarAB)
+ || $a cmp $b
+} keys %$teams;
+
my $vars = {
- urlbase => Bugzilla->localconfig->{urlbase},
- report_week => $report_week,
- products => $products,
- sec_keywords => $sec_keywords,
- results => $report->results,
- build_bugs_link => \&build_bugs_link,
+ urlbase => Bugzilla->localconfig->{urlbase},
+ report_week => $report_week,
+ teams => \@sorted_team_names,
+ sec_keywords => $sec_keywords,
+ results => $report->results,
+ deltas => $report->deltas,
+ build_bugs_link => \&build_bugs_link,
};
-$template->process( 'reports/email/security-risk.html.tmpl', $vars, \$html )
- or ThrowTemplateError( $template->error() );
+$template->process('reports/email/security-risk.html.tmpl', $vars, \$html) or ThrowTemplateError($template->error());
# For now, only send HTML email.
-my $email = Email::MIME->create(
- header_str => [
- From => Bugzilla->params->{'mailfrom'},
- To => Bugzilla->params->{report_secbugs_emails},
- Subject => "Security Bugs Report for $report_week",
- 'X-Bugzilla-Type' => 'admin'
- ],
- attributes => {
- content_type => 'text/html',
+my @parts = (
+ Email::MIME->create(
+ attributes => {content_type => 'text/html', charset => 'UTF-8', encoding => 'quoted-printable',},
+ body_str => $html,
+ ),
+ map {
+ Email::MIME->create(
+ header_str => ['Content-ID' => "<$_.png>",],
+ attributes => {
+ filename => "$_.png",
charset => 'UTF-8',
- encoding => 'quoted-printable',
- },
- body_str => $html,
+ content_type => 'image/png',
+ disposition => 'inline',
+ name => "$_.png",
+ encoding => 'base64',
+ },
+ body => $report->graphs->{$_}->slurp,
+ )
+ } sort { $a cmp $b } keys %{$report->graphs}
+);
+
+my $email = Email::MIME->create(
+ header_str => [
+ From => Bugzilla->params->{'mailfrom'},
+ To => Bugzilla->params->{report_secbugs_emails},
+ Subject => "Security Bugs Report for $report_week",
+ 'X-Bugzilla-Type' => 'admin',
+ ],
+ parts => [@parts],
);
MessageToMTA($email);
sub build_bugs_link {
- my ( $arr, $product ) = @_;
- my $uri = URI->new( Bugzilla->localconfig->{urlbase} . 'buglist.cgi' );
- $uri->query_param( bug_id => ( join ',', @$arr ) );
- $uri->query_param( product => $product ) if $product;
- return $uri->as_string;
+ my ($arr, $product) = @_;
+ my $uri = URI->new(Bugzilla->localconfig->{urlbase} . 'buglist.cgi');
+ $uri->query_param(bug_id => (join ',', @$arr));
+ $uri->query_param(product => $product) if $product;
+ return $uri->as_string;
}
use lib qw( . lib local/lib/perl5 );
use Bugzilla;
-BEGIN { Bugzilla->extensions };
+BEGIN { Bugzilla->extensions }
+use JSON::MaybeXS;
use Test::More;
use Test2::Tools::Mock;
use Try::Tiny;
can_ok('Bugzilla::Report::SecurityRisk', qw(new results));
sub check_open_state_mock {
- my ($state) = @_;
- return grep { /^$state$/ } qw(UNCOMFIRMED NEW ASSIGNED REOPENED);
+ my ($state) = @_;
+ return grep {/^$state$/} qw(UNCOMFIRMED NEW ASSIGNED REOPENED);
}
+my $teams_json
+ = '{ "Frontend": { "Firefox": { "all_components": true } }, "Backend": { "Core": { "all_components": true } } }';
+
try {
- use Bugzilla::Report::SecurityRisk;
- my $report = Bugzilla::Report::SecurityRisk->new(
- start_date => DateTime->new( year => 2000, month => 1, day => 9 ),
- end_date => DateTime->new( year => 2000, month => 1, day => 16 ),
- products => [ 'Firefox', 'Core' ],
- sec_keywords => [ 'sec-critical', 'sec-high' ],
- check_open_state => \&check_open_state_mock,
- initial_bug_ids => [ 1, 2, 3, 4 ],
- initial_bugs => {
- 1 => {
- id => 1,
- product => 'Firefox',
- sec_level => 'sec-high',
- is_open => 0,
- created_at => DateTime->new( year => 2000, month => 1, day => 1 ),
- },
- 2 => {
- id => 2,
- product => 'Core',
- sec_level => 'sec-critical',
- is_open => 0,
- created_at => DateTime->new( year => 2000, month => 1, day => 1 ),
- },
- 3 => {
- id => 3,
- product => 'Core',
- sec_level => 'sec-high',
- is_open => 1,
- created_at => DateTime->new( year => 2000, month => 1, day => 5 ),
- },
- 4 => {
- id => 4,
- product => 'Firefox',
- sec_level => 'sec-critical',
- is_open => 1,
- created_at => DateTime->new( year => 2000, month => 1, day => 10 ),
- },
- },
- events => [
- # Canned event's should be in reverse chronological order.
- {
- bug_id => 2,
- bug_when => DateTime->new( year => 2000, month => 1, day => 14 ),
- field_name => 'keywords',
- removed => '',
- added => 'sec-critical',
+ use Bugzilla::Report::SecurityRisk;
+ my $report = Bugzilla::Report::SecurityRisk->new(
+ start_date => DateTime->new(year => 2000, month => 1, day => 9),
+ end_date => DateTime->new(year => 2000, month => 1, day => 16),
+ teams => decode_json($teams_json),
+ sec_keywords => ['sec-critical', 'sec-high'],
+ check_open_state => \&check_open_state_mock,
+ initial_bug_ids => [1, 2, 3, 4],
+ initial_bugs => {
+ 1 => {
+ id => 1,
+ product => 'Firefox',
+ component => 'ComponentA',
+ team => 'Frontend',
+ sec_level => 'sec-high',
+ status => 'RESOLVED',
+ is_open => 0,
+ is_stalled => 0,
+ created_at => DateTime->new(year => 2000, month => 1, day => 1),
+ },
+ 2 => {
+ id => 2,
+ product => 'Core',
+ component => 'ComponentB',
+ team => 'Backend',
+ sec_level => 'sec-critical',
+ status => 'RESOLVED',
+ is_open => 0,
+ is_stalled => 0,
+ created_at => DateTime->new(year => 2000, month => 1, day => 1),
+ },
+ 3 => {
+ id => 3,
+ product => 'Core',
+ component => 'ComponentB',
+ team => 'Backend',
+ sec_level => 'sec-high',
+ status => 'ASSIGNED',
+ is_open => 1,
+ is_stalled => 0,
+ created_at => DateTime->new(year => 2000, month => 1, day => 5),
+ },
+ 4 => {
+ id => 4,
+ product => 'Firefox',
+ component => 'ComponentA',
+ team => 'Frontend',
+ sec_level => 'sec-critical',
+ status => 'ASSIGNED',
+ is_open => 1,
+ is_stalled => 0,
+ created_at => DateTime->new(year => 2000, month => 1, day => 10),
+ },
+ },
+ events => [
+
+ # Canned event's should be in reverse chronological order.
+ {
+ bug_id => 2,
+ bug_when => DateTime->new(year => 2000, month => 1, day => 14),
+ field_name => 'keywords',
+ removed => '',
+ added => 'sec-critical',
+
+ },
+ {
+ bug_id => 1,
+ bug_when => DateTime->new(year => 2000, month => 1, day => 12),
+ field_name => 'bug_status',
+ removed => 'ASSIGNED',
+ added => 'RESOLVED',
+ },
+ ],
+ );
+ my $actual_results = $report->results;
+ my $expected_results = [
+ {
+ date => DateTime->new(year => 2000, month => 1, day => 9),
+ bugs_by_team => {
+ 'Frontend' => {
- },
- {
- bug_id => 1,
- bug_when => DateTime->new( year => 2000, month => 1, day => 12 ),
- field_name => 'bug_status',
- removed => 'ASSIGNED',
- added => 'RESOLVED',
- },
- ],
- );
- my $actual_results = $report->results;
- my $expected_results = [
- {
- date => DateTime->new( year => 2000, month => 1, day => 9 ),
- bugs_by_product => {
- 'Firefox' => {
- # Rewind the event that caused 1 to close.
- open => [1],
- closed => [],
- median_age_open => 8
- },
- 'Core' => {
- # 2 wasn't a sec-critical bug on the report date.
- open => [3],
- closed => [],
- median_age_open => 4
- }
- },
- bugs_by_sec_keyword => {
- 'sec-critical' => {
- # 2 wasn't a sec-crtical bug and 4 wasn't created yet on the report date.
- open => [],
- closed => [],
- median_age_open => 0
- },
- 'sec-high' => {
- # Rewind the event that caused 1 to close.
- open => [ 1, 3 ],
- closed => [],
- median_age_open => 6
- }
- },
+ # Rewind the event that caused 1 to close.
+ open => [1],
+ closed => [],
+ median_age_open => 8
},
- { # The report on 2000-01-16 matches the state of initial_bugs.
- date => DateTime->new( year => 2000, month => 1, day => 16 ),
- bugs_by_product => {
- 'Firefox' => {
- open => [4],
- closed => [1],
- median_age_open => 6
- },
- 'Core' => {
- open => [3],
- closed => [2],
- median_age_open => 11
- }
- },
- bugs_by_sec_keyword => {
- 'sec-critical' => {
- open => [4],
- closed => [2],
- median_age_open => 6
- },
- 'sec-high' => {
- open => [3],
- closed => [1],
- median_age_open => 11
- }
- },
+ 'Backend' => {
+
+ # 2 wasn't a sec-critical bug on the report date.
+ open => [3],
+ closed => [],
+ median_age_open => 4
+ }
+ },
+ bugs_by_sec_keyword => {
+ 'sec-critical' => {
+
+ # 2 wasn't a sec-crtical bug and 4 wasn't created yet on the report date.
+ open => [],
+ closed => [],
+ median_age_open => 0
},
- ];
+ 'sec-high' => {
+
+ # Rewind the event that caused 1 to close.
+ open => [1, 3],
+ closed => [],
+ median_age_open => 6
+ }
+ },
+ },
+ { # The report on 2000-01-16 matches the state of initial_bugs.
+ date => DateTime->new(year => 2000, month => 1, day => 16),
+ bugs_by_team => {
+ 'Frontend' => {open => [4], closed => [1], median_age_open => 6},
+ 'Backend' => {open => [3], closed => [2], median_age_open => 11}
+ },
+ bugs_by_sec_keyword => {
+ 'sec-critical' => {open => [4], closed => [2], median_age_open => 6},
+ 'sec-high' => {open => [3], closed => [1], median_age_open => 11}
+ },
+ },
+ ];
- is_deeply($actual_results, $expected_results, 'Report results are accurate');
+ is_deeply($actual_results, $expected_results, 'Report results are accurate');
}
catch {
- fail('got an exception during main part of test');
- diag($_);
+ fail('got an exception during main part of test');
+ diag($_);
};
done_testing;
<base href="[% urlbase FILTER txt %]">
</head>
<body>
-<p>Security [% terms.Bugs %] Report for the week of [% report_week FILTER html %]</p>
-<p>To narrow down open [% terms.bugs %] click on the link and at the bottom of the search results use the 'Edit Search' functionality to filter by component and so on.
-This will filter only the open [% terms.bugs %] counted in the report (as long as you do not modify the '[% terms.Bugs %] numbered' section of the search).
-</p>
-<h3>[% terms.Bugs %] By Severity</h3>
-<table style="border: 1px solid grey">
+<h3>Sec-Critical + Sec-High [% terms.Bugs %] by Team</h3>
+<table style="border-spacing: 0">
<tr>
- <th style="border: 1px solid grey"></th>
- [% FOREACH keyword IN sec_keywords %]
- <th style="border: 1px solid grey; text-align: center" colspan="2"><b>[% keyword FILTER html %]</b></th>
+ <th style="padding: 0px 15px 10px 0px;">Team</th>
+ <th style="padding: 0px 15px 10px 0px; text-align: right;">
+ Open<br>[% results.reverse.0.date FILTER time('%m/%d') %]
+ </th>
+ <th style="padding: 0px 15px 10px 0px; text-align: right;">Closed<br />Last Week</th>
+ <th style="padding: 0px 15px 10px 0px; text-align: right;">Added<br />Last Week</th>
+ [% FOREACH result IN results.reverse %]
+ [% NEXT IF loop.count < 2 %]
+ [% LAST IF loop.count > 4 %]
+ <th style="padding: 0px 15px 10px [% IF loop.count == 2 %] 10px [% ELSE %] 0px [% END %]; text-align: right; [% IF loop.count == 1 %] border-right: 1px solid grey; [% END %]">
+ Open<br>[% result.date FILTER time('%m/%d') %]
+ </th>
[% END %]
</tr>
- <tr>
- <td style="border: 1px solid grey"></td>
- [% FOREACH keyword IN sec_keywords %]
- <td style="border: 1px solid grey; text-align: right">Open Count</td>
- <td style="border: 1px solid grey; text-align: right">Median Days Open</td>
- [% END %]
- </tr>
- [% FOREACH result IN results.reverse %]
- <tr>
- <td style="border: 1px solid grey">[% result.date.ymd('-') FILTER html %]</td>
- [% FOREACH keyword IN sec_keywords %]
- <td style="border: 1px solid grey; text-align: right">
- [% IF result.bugs_by_sec_keyword.$keyword.open.size %]
- <a href="[% build_bugs_link(result.bugs_by_sec_keyword.$keyword.open) FILTER html %]">
- [% result.bugs_by_sec_keyword.$keyword.open.size FILTER html %]
+ [% FOREACH team IN teams %]
+ <tr>
+ <td style="padding: 0px 15px 10px 0px;">[% team FILTER html %]</td>
+ <td style="padding: 0px 15px 10px 0px; text-align: right;">
+ [% IF results.reverse.0.bugs_by_team.$team.open.size %]
+ <a style="text-decoration: none;" href="[% build_bugs_link(results.reverse.0.bugs_by_team.$team.open) FILTER html %]">
+ [% results.reverse.0.bugs_by_team.$team.open.size FILTER html %]
+ </a>
+ [% ELSE %]
+ 0
+ [% END %]
+ </td>
+ <td style="padding: 0px 15px 10px 0px; text-align: right;
+ [% IF deltas.by_team.$team.closed.size %] background-color: #e6ffe6 [% END %]">
+ [% IF deltas.by_team.$team.closed.size %]
+ <a style="text-decoration: none;"
+ href="[% build_bugs_link(deltas.by_team.$team.closed) FILTER html %]">
+ -[% deltas.by_team.$team.closed.size FILTER html %]
</a>
[% ELSE %]
- [% result.bugs_by_sec_keyword.$keyword.open.size FILTER html %]
+ 0
[% END %]
</td>
- <td style="border: 1px solid grey; text-align: right">
- [% result.bugs_by_sec_keyword.$keyword.median_age_open FILTER format("%.2f") FILTER html %]
+ <td style="padding: 0px 15px 10px 0px; text-align: right; border-right: 1px solid grey;
+ [% IF deltas.by_team.$team.added.size %] background-color: #ffe6e6 [% END %]">
+ [% IF deltas.by_team.$team.added.size %]
+ <a style="text-decoration: none;" href="[% build_bugs_link(deltas.by_team.$team.added) FILTER html %]">
+ +[% deltas.by_team.$team.added.size FILTER html %]
+ </a>
+ [% ELSE %]
+ 0
+ [% END %]
</td>
- [% END %]
- </tr>
+ [% FOREACH result IN results.reverse %]
+ [% NEXT IF loop.count < 2 %]
+ [% LAST IF loop.count > 4 %]
+ <td style="padding: 0px 15px 10px [% IF loop.count == 2 %] 10px [% ELSE %] 0px [% END %]; text-align: right; [% IF loop.count == 1 %] border-right: 1px solid grey; [% END %]">
+ [% IF result.bugs_by_team.$team.open.size %]
+ <a style="text-decoration: none;" href="[% build_bugs_link(result.bugs_by_team.$team.open) FILTER html %]">
+ [% result.bugs_by_team.$team.open.size FILTER html %]
+ </a>
+ [% ELSE %]
+ 0
+ [% END %]
+ </td>
+ [% END %]
+ </tr>
[% END %]
</table>
+<br/>
-<h3>Sec-Critical + Sec-High [% terms.Bugs %] by Product</h3>
-<table style="border: 1px solid grey">
- <tr>
- <th style="border: 1px solid grey"></th>
- [% FOREACH product IN products %]
- <th style="border: 1px solid grey; text-align: center" colspan="2"><b>[% product FILTER html %]</b></th>
- [% END %]
- </tr>
+<h3>[% terms.Bugs %] By Severity</h3>
+<table style="border-spacing: 0">
<tr>
- <td style="border: 1px solid grey"></td>
- [% FOREACH product IN products %]
- <td style="border: 1px solid grey; text-align: right">Open Count</td>
- <td style="border: 1px solid grey; text-align: right">Median Days Open</td>
+ <th style="padding: 0px 15px 10px 0px;">Category</th>
+ <th style="padding: 0px 15px 10px 0px; text-align: right;">
+ Open<br>[% results.reverse.0.date FILTER time('%m/%d') %]
+ </th>
+ <th style="padding: 0px 15px 10px 0px; text-align: right;">Closed<br />Last Week</th>
+ <th style="padding: 0px 15px 10px 0px; text-align: right;">Added<br />Last Week</th>
+ <th style="padding: 0px 15px 10px 0px; text-align: right; border-right: 1px solid grey;">
+ Median Age<br/>of Open [% terms.Bugs %]<br>
+ </th>
+ [% FOREACH result IN results.reverse %]
+ [% NEXT IF loop.count < 2 %]
+ [% LAST IF loop.count > 4 %]
+ <th style="padding: 0px 15px 10px [% IF loop.count == 2 %] 10px [% ELSE %] 0px [% END %]; text-align: right; [% IF loop.count == 1 %] border-right: 1px solid grey; [% END %]">
+ Open<br>[% result.date FILTER time('%m/%d') %]
+ </th>
[% END %]
</tr>
- [% FOREACH result IN results.reverse %]
- <tr>
- <td style="border: 1px solid grey">[% result.date.ymd('-') FILTER html %]</td>
- [% FOREACH product IN products %]
- <td style="border: 1px solid grey; text-align: right">
- [% IF result.bugs_by_product.$product.open.size %]
- <a href="[% build_bugs_link(result.bugs_by_product.$product.open, product) FILTER html %]">
- [% result.bugs_by_product.$product.open.size FILTER html %]
+ [% FOREACH keyword IN sec_keywords %]
+ <tr>
+ <td style="padding: 0px 15px 10px 0px;">[% keyword FILTER html %]</td>
+ <td style="padding: 0px 15px 10px 0px; text-align: right;">
+ [% IF results.reverse.0.bugs_by_sec_keyword.$keyword.open %]
+ <a style="text-decoration: none;" href="[% build_bugs_link(results.reverse.0.bugs_by_sec_keyword.$keyword.open) FILTER html %]">
+ [% results.reverse.0.bugs_by_sec_keyword.$keyword.open.size FILTER html %]
+ </a>
+ [% ELSE %]
+ 0
+ [% END %]
+ </td>
+ <td style="padding: 0px 15px 10px 0px; text-align: right;
+ [% IF deltas.by_sec_keyword.$keyword.closed.size %] background-color: #e6ffe6 [% END %]">
+ [% IF deltas.by_sec_keyword.$keyword.closed.size %]
+ <a style="text-decoration: none;" href="[% build_bugs_link(deltas.by_sec_keyword.$keyword.closed) FILTER html %]">
+ -[% deltas.by_sec_keyword.$keyword.closed.size FILTER html %]
</a>
[% ELSE %]
- [% result.bugs_by_product.$product.open.size FILTER html %]
+ 0
[% END %]
</td>
- <td style="border: 1px solid grey; text-align: right">
- [% result.bugs_by_product.$product.median_age_open FILTER format("%.2f") FILTER html %]
+ <td style="padding: 0px 15px 10px 0px; text-align: right;
+ [% IF deltas.by_sec_keyword.$keyword.added.size %] background-color: #ffe6e6 [% END %]">
+ [% IF deltas.by_sec_keyword.$keyword.added.size %]
+ <a style="text-decoration: none;" href="[% build_bugs_link(deltas.by_sec_keyword.$keyword.added) FILTER html %]">
+ +[% deltas.by_sec_keyword.$keyword.added.size FILTER html %]
+ </a>
+ [% ELSE %]
+ 0
+ [% END %]
</td>
- [% END %]
- </tr>
+ <td style="padding: 0px 15px 10px 0px; text-align: right; border-right: 1px solid grey; ">
+ [% results.-1.bugs_by_sec_keyword.$keyword.median_age_open FILTER format("%.0f") FILTER html %] days
+ </td>
+ [% FOREACH result IN results.reverse %]
+ [% NEXT IF loop.count < 2 %]
+ [% LAST IF loop.count > 4 %]
+ <td style="padding: 0px 15px 10px [% IF loop.count == 2 %] 10px [% ELSE %] 0px [% END %]; text-align: right; [% IF loop.count == 1 %] border-right: 1px solid grey; [% END %]">
+ [% IF result.bugs_by_sec_keyword.$keyword.open %]
+ <a style="text-decoration: none;" href="[% build_bugs_link(result.bugs_by_sec_keyword.$keyword.open) FILTER html %]">
+ [% result.bugs_by_sec_keyword.$keyword.open.size FILTER html %]
+ </a>
+ [% ELSE %]
+ 0
+ [% END %]
+ </td>
+ [% END %]
+ </tr>
[% END %]
</table>
+
+<p>
+To narrow down open [% terms.bugs %] click on the link and at the bottom of the search results use the
+'Edit Search' functionality to filter by component and so on. This will filter only the open [% terms.bugs %]
+counted in the report (as long as you do not modify the '[% terms.Bugs %] numbered' section of the search).
+Keep in mind that you will only be able to see [% terms.bugs %] that you are allowed to see. Also keep in mind that
+this report treats marking a [% terms.bug %] as 'stalled' the same as closing it.
+</p>
+
+<p>Attached to this email are some graphs with stats for the past 12 months.</p>
+
</body>
</html>