summaryrefslogtreecommitdiffstats
path: root/extensions/Review
diff options
context:
space:
mode:
Diffstat (limited to 'extensions/Review')
-rw-r--r--extensions/Review/Config.pm15
-rw-r--r--extensions/Review/Extension.pm939
-rwxr-xr-xextensions/Review/bin/migrate_mentor_from_whiteboard.pl229
-rwxr-xr-xextensions/Review/bin/review_requests_rebuild.pl29
-rw-r--r--extensions/Review/lib/FlagStateActivity.pm122
-rw-r--r--extensions/Review/lib/Util.pm84
-rw-r--r--extensions/Review/lib/WebService.pm488
-rw-r--r--extensions/Review/template/en/default/hook/admin/components/edit-common-rows.html.tmpl23
-rw-r--r--extensions/Review/template/en/default/hook/admin/products/edit-common-rows.html.tmpl28
-rw-r--r--extensions/Review/template/en/default/hook/admin/products/updated-changes.html.tmpl19
-rw-r--r--extensions/Review/template/en/default/hook/attachment/create-end.html.tmpl20
-rw-r--r--extensions/Review/template/en/default/hook/attachment/edit-end.html.tmpl15
-rw-r--r--extensions/Review/template/en/default/hook/bug/create/create-after_custom_fields.html.tmpl20
-rw-r--r--extensions/Review/template/en/default/hook/bug/create/create-end.html.tmpl16
-rw-r--r--extensions/Review/template/en/default/hook/bug/edit-after_people.html.tmpl53
-rw-r--r--extensions/Review/template/en/default/hook/bug/show-bug_end.xml.tmpl12
-rw-r--r--extensions/Review/template/en/default/hook/flag/list-requestee.html.tmpl17
-rw-r--r--extensions/Review/template/en/default/hook/global/header-message.html.tmpl23
-rw-r--r--extensions/Review/template/en/default/hook/global/header-start.html.tmpl91
-rw-r--r--extensions/Review/template/en/default/hook/global/messages-component_updated_fields.html.tmpl11
-rw-r--r--extensions/Review/template/en/default/hook/global/user-error-auth_failure_object.html.tmpl11
-rw-r--r--extensions/Review/template/en/default/hook/global/user-error-errors.html.tmpl26
-rw-r--r--extensions/Review/template/en/default/hook/reports/menu-end.html.tmpl16
-rw-r--r--extensions/Review/template/en/default/pages/review_history.html.tmpl62
-rw-r--r--extensions/Review/template/en/default/pages/review_requests_rebuild.html.tmpl23
-rw-r--r--extensions/Review/template/en/default/pages/review_suggestions.html.tmpl76
-rw-r--r--extensions/Review/web/js/moment.min.js6
-rw-r--r--extensions/Review/web/js/review.js210
-rw-r--r--extensions/Review/web/js/review_history.js384
-rw-r--r--extensions/Review/web/styles/badge.css16
-rw-r--r--extensions/Review/web/styles/reports.css41
-rw-r--r--extensions/Review/web/styles/review.css10
-rw-r--r--extensions/Review/web/styles/review_history.css10
33 files changed, 3145 insertions, 0 deletions
diff --git a/extensions/Review/Config.pm b/extensions/Review/Config.pm
new file mode 100644
index 000000000..f7da458af
--- /dev/null
+++ b/extensions/Review/Config.pm
@@ -0,0 +1,15 @@
+# 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::Review;
+use strict;
+
+use constant NAME => 'Review';
+use constant REQUIRED_MODULES => [];
+use constant OPTIONAL_MODULES => [];
+
+__PACKAGE__->NAME;
diff --git a/extensions/Review/Extension.pm b/extensions/Review/Extension.pm
new file mode 100644
index 000000000..f6a3bf743
--- /dev/null
+++ b/extensions/Review/Extension.pm
@@ -0,0 +1,939 @@
+# 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::Review;
+use strict;
+use warnings;
+
+use base qw(Bugzilla::Extension);
+our $VERSION = '1';
+
+use Bugzilla;
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Extension::Review::FlagStateActivity;
+use Bugzilla::Extension::Review::Util;
+use Bugzilla::Install::Filesystem;
+use Bugzilla::Search;
+use Bugzilla::User;
+use Bugzilla::Util qw(clean_text diff_arrays);
+
+use constant UNAVAILABLE_RE => qr/\b(?:unavailable|pto|away)\b/i;
+
+#
+# monkey-patched methods
+#
+
+BEGIN {
+ *Bugzilla::Product::reviewers = \&_product_reviewers;
+ *Bugzilla::Product::reviewers_objs = \&_product_reviewers_objs;
+ *Bugzilla::Product::reviewer_required = \&_product_reviewer_required;
+ *Bugzilla::Component::reviewers = \&_component_reviewers;
+ *Bugzilla::Component::reviewers_objs = \&_component_reviewers_objs;
+ *Bugzilla::Bug::mentors = \&_bug_mentors;
+ *Bugzilla::Bug::bug_mentors = \&_bug_mentors;
+ *Bugzilla::Bug::is_mentor = \&_bug_is_mentor;
+ *Bugzilla::Bug::set_bug_mentors = \&_bug_set_bug_mentors;
+ *Bugzilla::User::review_count = \&_user_review_count;
+}
+
+#
+# monkey-patched methods
+#
+
+sub _product_reviewers { _reviewers($_[0], 'product', $_[1]) }
+sub _product_reviewers_objs { _reviewers_objs($_[0], 'product', $_[1]) }
+sub _component_reviewers { _reviewers($_[0], 'component', $_[1]) }
+sub _component_reviewers_objs { _reviewers_objs($_[0], 'component', $_[1]) }
+
+sub _reviewers {
+ my ($object, $type, $include_disabled) = @_;
+ return join(', ', map { $_->login } @{ _reviewers_objs($object, $type, $include_disabled) });
+}
+
+sub _reviewers_objs {
+ my ($object, $type, $include_disabled) = @_;
+ if (!$object->{reviewers}) {
+ my $dbh = Bugzilla->dbh;
+ my $user_ids = $dbh->selectcol_arrayref(
+ "SELECT user_id FROM ${type}_reviewers WHERE ${type}_id = ? ORDER BY sortkey",
+ undef,
+ $object->id,
+ );
+ # new_from_list always sorts according to the object's definition,
+ # so we have to reorder the list
+ my $users = Bugzilla::User->new_from_list($user_ids);
+ my %user_map = map { $_->id => $_ } @$users;
+ my @reviewers = map { $user_map{$_} } @$user_ids;
+ if (!$include_disabled) {
+ @reviewers = grep { $_->is_enabled
+ && $_->name !~ UNAVAILABLE_RE } @reviewers;
+ }
+ $object->{reviewers} = \@reviewers;
+ }
+ return $object->{reviewers};
+}
+
+sub _user_review_count {
+ my ($self) = @_;
+ if (!exists $self->{review_count}) {
+ my $dbh = Bugzilla->dbh;
+ ($self->{review_count}) = $dbh->selectrow_array(
+ "SELECT COUNT(*)
+ FROM flags
+ INNER JOIN flagtypes ON flagtypes.id = flags.type_id
+ WHERE flags.requestee_id = ?
+ AND " . $dbh->sql_in('flagtypes.name', [ "'review'", "'feedback'" ]),
+ undef,
+ $self->id,
+ );
+ }
+ return $self->{review_count};
+}
+
+#
+# mentor
+#
+
+sub _bug_mentors {
+ my ($self) = @_;
+ my $dbh = Bugzilla->dbh;
+ if (!$self->{bug_mentors}) {
+ my $mentor_ids = $dbh->selectcol_arrayref("
+ SELECT user_id FROM bug_mentors WHERE bug_id = ?",
+ undef,
+ $self->id);
+ $self->{bug_mentors} = [];
+ foreach my $mentor_id (@$mentor_ids) {
+ push(@{ $self->{bug_mentors} },
+ Bugzilla::User->new({ id => $mentor_id, cache => 1 }));
+ }
+ $self->{bug_mentors} = [
+ sort { $a->login cmp $b->login } @{ $self->{bug_mentors} }
+ ];
+ }
+ return $self->{bug_mentors};
+}
+
+sub _bug_is_mentor {
+ my ($self, $user) = @_;
+ my $user_id = ($user || Bugzilla->user)->id;
+ return (grep { $_->id == $user_id} @{ $self->mentors }) ? 1 : 0;
+}
+
+sub _bug_set_bug_mentors {
+ my ($self, $value) = @_;
+ $self->set('bug_mentors', $value);
+}
+
+sub object_validators {
+ my ($self, $args) = @_;
+ return unless $args->{class} eq 'Bugzilla::Bug';
+ $args->{validators}->{bug_mentors} = \&_bug_check_bug_mentors;
+}
+
+sub _bug_check_bug_mentors {
+ my ($self, $value) = @_;
+ return [
+ map { Bugzilla::User->check({ name => $_, cache => 1 }) }
+ ref($value) ? @$value : ($value)
+ ];
+}
+
+sub bug_user_match_fields {
+ my ($self, $args) = @_;
+ $args->{fields}->{bug_mentors} = { type => 'multi' };
+}
+
+sub bug_before_create {
+ my ($self, $args) = @_;
+ my $params = $args->{params};
+ my $stash = $args->{stash};
+ $stash->{bug_mentors} = delete $params->{bug_mentors};
+}
+
+sub bug_end_of_create {
+ my ($self, $args) = @_;
+ my $bug = $args->{bug};
+ my $stash = $args->{stash};
+ if (my $mentors = $stash->{bug_mentors}) {
+ $self->_update_user_table({
+ object => $bug,
+ old_users => [],
+ new_users => $self->_bug_check_bug_mentors($mentors),
+ table => 'bug_mentors',
+ id_field => 'bug_id',
+ });
+ }
+}
+
+sub _update_user_table {
+ my ($self, $args) = @_;
+ my ($object, $old_users, $new_users, $table, $id_field, $has_sortkey, $return) =
+ @$args{qw(object old_users new_users table id_field has_sortkey return)};
+ my $dbh = Bugzilla->dbh;
+ my (@removed, @added);
+
+ # remove deleted users
+ foreach my $old_user (@$old_users) {
+ if (!grep { $_->id == $old_user->id } @$new_users) {
+ $dbh->do(
+ "DELETE FROM $table WHERE $id_field = ? AND user_id = ?",
+ undef,
+ $object->id, $old_user->id,
+ );
+ push @removed, $old_user;
+ }
+ }
+ # add new users
+ foreach my $new_user (@$new_users) {
+ if (!grep { $_->id == $new_user->id } @$old_users) {
+ $dbh->do(
+ "INSERT INTO $table ($id_field, user_id) VALUES (?, ?)",
+ undef,
+ $object->id, $new_user->id,
+ );
+ push @added, $new_user;
+ }
+ }
+
+ return unless @removed || @added;
+
+ if ($has_sortkey) {
+ # update the sortkey for all users
+ for (my $i = 0; $i < scalar(@$new_users); $i++) {
+ $dbh->do(
+ "UPDATE $table SET sortkey=? WHERE $id_field = ? AND user_id = ?",
+ undef,
+ ($i + 1) * 10, $object->id, $new_users->[$i]->id,
+ );
+ }
+ }
+
+ if (!$return) {
+ return undef;
+ }
+ elsif ($return eq 'diff') {
+ return [
+ @removed ? join(', ', map { $_->login } @removed) : undef,
+ @added ? join(', ', map { $_->login } @added) : undef,
+ ];
+ }
+ elsif ($return eq 'old-new') {
+ return [
+ @$old_users ? join(', ', map { $_->login } @$old_users) : '',
+ @$new_users ? join(', ', map { $_->login } @$new_users) : '',
+ ];
+ }
+}
+
+#
+# reviewer-required, review counters, etc
+#
+
+sub _product_reviewer_required { $_[0]->{reviewer_required} }
+
+sub object_columns {
+ my ($self, $args) = @_;
+ my ($class, $columns) = @$args{qw(class columns)};
+ if ($class->isa('Bugzilla::Product')) {
+ push @$columns, 'reviewer_required';
+ }
+ elsif ($class->isa('Bugzilla::User')) {
+ push @$columns, qw(review_request_count feedback_request_count needinfo_request_count);
+ }
+}
+
+sub object_update_columns {
+ my ($self, $args) = @_;
+ my ($object, $columns) = @$args{qw(object columns)};
+ if ($object->isa('Bugzilla::Product')) {
+ push @$columns, 'reviewer_required';
+ }
+ elsif ($object->isa('Bugzilla::User')) {
+ push @$columns, qw(review_request_count feedback_request_count needinfo_request_count);
+ }
+}
+
+sub _new_users_from_input {
+ my ($field) = @_;
+ my $input_params = Bugzilla->input_params;
+ return undef unless exists $input_params->{$field};
+ return [] unless $input_params->{$field};
+ Bugzilla::User::match_field({ $field => {'type' => 'multi'} });;
+ my $value = $input_params->{$field};
+ return [
+ map { Bugzilla::User->check({ name => $_, cache => 1 }) }
+ ref($value) ? @$value : ($value)
+ ];
+}
+
+#
+# create/update
+#
+
+sub object_before_create {
+ my ($self, $args) = @_;
+ my ($class, $params) = @$args{qw(class params)};
+ return unless $class->isa('Bugzilla::Product');
+
+ $params->{reviewer_required} = Bugzilla->cgi->param('reviewer_required') ? 1 : 0;
+}
+
+sub object_end_of_set_all {
+ my ($self, $args) = @_;
+ my ($object, $params) = @$args{qw(object params)};
+ return unless $object->isa('Bugzilla::Product');
+
+ $object->set('reviewer_required', Bugzilla->cgi->param('reviewer_required') ? 1 : 0);
+}
+
+sub object_end_of_create {
+ my ($self, $args) = @_;
+ my ($object, $params) = @$args{qw(object params)};
+
+ if ($object->isa('Bugzilla::Product')) {
+ $self->_update_user_table({
+ object => $object,
+ old_users => [],
+ new_users => _new_users_from_input('reviewers'),
+ table => 'product_reviewers',
+ id_field => 'product_id',
+ has_sortkey => 1,
+ });
+ }
+ elsif ($object->isa('Bugzilla::Component')) {
+ $self->_update_user_table({
+ object => $object,
+ old_users => [],
+ new_users => _new_users_from_input('reviewers'),
+ table => 'component_reviewers',
+ id_field => 'component_id',
+ has_sortkey => 1,
+ });
+ }
+ elsif (_is_countable_flag($object) && $object->requestee_id && $object->status eq '?') {
+ _adjust_request_count($object, +1);
+ }
+ if (_is_countable_flag($object)) {
+ $self->_log_flag_state_activity($object, $object->status, $object->modification_date);
+ }
+}
+
+sub object_end_of_update {
+ my ($self, $args) = @_;
+ my ($object, $old_object, $changes) = @$args{qw(object old_object changes)};
+
+ if ($object->isa('Bugzilla::Product') && exists Bugzilla->input_params->{reviewers}) {
+ my $diff = $self->_update_user_table({
+ object => $object,
+ old_users => $old_object->reviewers_objs(1),
+ new_users => _new_users_from_input('reviewers'),
+ table => 'product_reviewers',
+ id_field => 'product_id',
+ has_sortkey => 1,
+ return => 'old-new',
+ });
+ $changes->{reviewers} = $diff if $diff;
+ }
+ elsif ($object->isa('Bugzilla::Component')) {
+ my $diff = $self->_update_user_table({
+ object => $object,
+ old_users => $old_object->reviewers_objs(1),
+ new_users => _new_users_from_input('reviewers'),
+ table => 'component_reviewers',
+ id_field => 'component_id',
+ has_sortkey => 1,
+ return => 'old-new',
+ });
+ $changes->{reviewers} = $diff if $diff;
+ }
+ elsif ($object->isa('Bugzilla::Bug')) {
+ my $diff = $self->_update_user_table({
+ object => $object,
+ old_users => $old_object->mentors,
+ new_users => $object->mentors,
+ table => 'bug_mentors',
+ id_field => 'bug_id',
+ return => 'diff',
+ });
+ $changes->{bug_mentor} = $diff if $diff;
+ }
+ elsif (_is_countable_flag($object)) {
+ my ($old_status, $new_status) = ($old_object->status, $object->status);
+ if ($old_status ne '?' && $new_status eq '?') {
+ # setting flag to ?
+ _adjust_request_count($object, +1);
+ }
+ elsif ($old_status eq '?' && $new_status ne '?') {
+ # setting flag from ?
+ _adjust_request_count($old_object, -1);
+ }
+ elsif ($old_object->requestee_id && !$object->requestee_id) {
+ # removing requestee
+ _adjust_request_count($old_object, -1);
+ }
+ elsif (!$old_object->requestee_id && $object->requestee_id) {
+ # setting requestee
+ _adjust_request_count($object, +1);
+ }
+ elsif ($old_object->requestee_id && $object->requestee_id
+ && $old_object->requestee_id != $object->requestee_id)
+ {
+ # changing requestee
+ _adjust_request_count($old_object, -1);
+ _adjust_request_count($object, +1);
+ }
+ }
+}
+
+sub flag_updated {
+ my ($self, $args) = @_;
+ my $flag = $args->{flag};
+ my $timestamp = $args->{timestamp};
+ my $changes = $args->{changes};
+
+ return unless scalar(keys %$changes);
+ if (_is_countable_flag($flag)) {
+ $self->_log_flag_state_activity($flag, $flag->status, $timestamp);
+ }
+}
+
+sub flag_deleted {
+ my ($self, $args) = @_;
+ my $flag = $args->{flag};
+ my $timestamp = $args->{timestamp};
+
+ if (_is_countable_flag($flag) && $flag->requestee_id && $flag->status eq '?') {
+ _adjust_request_count($flag, -1);
+ }
+
+ if (_is_countable_flag($flag)) {
+ $self->_log_flag_state_activity($flag, 'X', $timestamp, Bugzilla->user->id);
+ }
+}
+
+sub _is_countable_flag {
+ my ($object) = @_;
+ return unless $object->isa('Bugzilla::Flag');
+ my $type_name = $object->type->name;
+ return $type_name eq 'review' || $type_name eq 'feedback' || $type_name eq 'needinfo';
+}
+
+sub _log_flag_state_activity {
+ my ($self, $flag, $status, $timestamp, $setter_id) = @_;
+
+ $setter_id //= $flag->setter_id;
+
+ Bugzilla::Extension::Review::FlagStateActivity->create({
+ flag_when => $timestamp,
+ setter_id => $setter_id,
+ status => $status,
+ type_id => $flag->type_id,
+ flag_id => $flag->id,
+ requestee_id => $flag->requestee_id,
+ bug_id => $flag->bug_id,
+ attachment_id => $flag->attach_id,
+ });
+}
+
+sub _adjust_request_count {
+ my ($flag, $add) = @_;
+ return unless my $requestee_id = $flag->requestee_id;
+ my $field = $flag->type->name . '_request_count';
+
+ # update the current user's object so things are display correctly on the
+ # post-processing page
+ my $user = Bugzilla->user;
+ if ($requestee_id == $user->id) {
+ $user->{$field} += $add;
+ }
+
+ # update database directly to avoid creating audit_log entries
+ $add = $add == -1 ? ' - 1' : ' + 1';
+ Bugzilla->dbh->do(
+ "UPDATE profiles SET $field = $field $add WHERE userid = ?",
+ undef,
+ $requestee_id
+ );
+ Bugzilla->memcached->clear({ table => 'profiles', id => $requestee_id });
+}
+
+# bugzilla's handling of requestee matching when creating bugs is "if it's
+# wrong, or matches too many, default to empty", which breaks mandatory
+# reviewer requirements. instead we just throw an error.
+sub post_bug_attachment_flags {
+ my ($self, $args) = @_;
+ $self->_check_review_flag($args);
+}
+
+sub create_attachment_flags {
+ my ($self, $args) = @_;
+ $self->_check_review_flag($args);
+}
+
+sub _check_review_flag {
+ my ($self, $args) = @_;
+ my $bug = $args->{bug};
+ my $cgi = Bugzilla->cgi;
+
+ # extract the set flag-types
+ my @flagtype_ids = map { /^flag_type-(\d+)$/ ? $1 : () } $cgi->param();
+ @flagtype_ids = grep { $cgi->param("flag_type-$_") eq '?' } @flagtype_ids;
+ return unless scalar(@flagtype_ids);
+
+ # find valid review flagtypes
+ my $flag_types = Bugzilla::FlagType::match({
+ product_id => $bug->product_id,
+ component_id => $bug->component_id,
+ is_active => 1
+ });
+ foreach my $flag_type (@$flag_types) {
+ next unless $flag_type->name eq 'review'
+ && $flag_type->target_type eq 'attachment';
+ my $type_id = $flag_type->id;
+ next unless scalar(grep { $_ == $type_id } @flagtype_ids);
+
+ my $reviewers = clean_text($cgi->param("requestee_type-$type_id") || '');
+ if ($reviewers eq '' && $bug->product_obj->reviewer_required) {
+ ThrowUserError('reviewer_required');
+ }
+
+ foreach my $reviewer (split(/[,;]+/, $reviewers)) {
+ # search on the reviewer
+ my $users = Bugzilla::User::match($reviewer, 2, 1);
+
+ # no matches
+ if (scalar(@$users) == 0) {
+ ThrowUserError('user_match_failed', { name => $reviewer });
+ }
+
+ # more than one match, throw error
+ if (scalar(@$users) > 1) {
+ ThrowUserError('user_match_too_many', { fields => [ 'review' ] });
+ }
+ }
+ }
+}
+
+sub flag_end_of_update {
+ my ($self, $args) = @_;
+ my ($object, $new_flags) = @$args{qw(object new_flags)};
+ my $bug = $object->isa('Bugzilla::Attachment') ? $object->bug : $object;
+ return unless $bug->product_obj->reviewer_required;
+
+ foreach my $orig_change (@$new_flags) {
+ my $change = $orig_change; # work on a copy
+ $change =~ s/^[^:]+://;
+ my $reviewer = '';
+ if ($change =~ s/\(([^\)]+)\)$//) {
+ $reviewer = $1;
+ }
+ my ($name, $value) = $change =~ /^(.+)(.)$/;
+
+ if ($name eq 'review' && $value eq '?' && $reviewer eq '') {
+ ThrowUserError('reviewer_required');
+ }
+ }
+}
+
+#
+# search
+#
+
+sub buglist_columns {
+ my ($self, $args) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $columns = $args->{columns};
+ $columns->{bug_mentor} = { title => 'Mentor' };
+ if (Bugzilla->user->id) {
+ $columns->{bug_mentor}->{name}
+ = $dbh->sql_group_concat('map_mentors_names.login_name');
+ }
+ else {
+ $columns->{bug_mentor}->{name}
+ = $dbh->sql_group_concat('map_mentors_names.realname');
+
+ }
+}
+
+sub buglist_column_joins {
+ my ($self, $args) = @_;
+ my $column_joins = $args->{column_joins};
+ $column_joins->{bug_mentor} = {
+ as => 'map_mentors',
+ table => 'bug_mentors',
+ then_to => {
+ as => 'map_mentors_names',
+ table => 'profiles',
+ from => 'map_mentors.user_id',
+ to => 'userid',
+ },
+ },
+}
+
+sub search_operator_field_override {
+ my ($self, $args) = @_;
+ my $operators = $args->{operators};
+ $operators->{bug_mentor} = {
+ _non_changed => sub {
+ Bugzilla::Search::_user_nonchanged(@_)
+ }
+ };
+}
+
+#
+# web service / pages
+#
+
+sub webservice {
+ my ($self, $args) = @_;
+ my $dispatch = $args->{dispatch};
+ $dispatch->{Review} = "Bugzilla::Extension::Review::WebService";
+}
+
+sub page_before_template {
+ my ($self, $args) = @_;
+
+ if ($args->{page_id} eq 'review_suggestions.html') {
+ $self->review_suggestions_report($args);
+ }
+ elsif ($args->{page_id} eq 'review_requests_rebuild.html') {
+ $self->review_requests_rebuild($args);
+ }
+ elsif ($args->{page_id} eq 'review_history.html') {
+ $self->review_history($args);
+ }
+}
+
+sub review_suggestions_report {
+ my ($self, $args) = @_;
+
+ my $user = Bugzilla->login(LOGIN_REQUIRED);
+ my $products = [];
+ my @products = sort { lc($a->name) cmp lc($b->name) }
+ @{ Bugzilla->user->get_accessible_products };
+ foreach my $product_obj (@products) {
+ my $has_reviewers = 0;
+ my $product = {
+ name => $product_obj->name,
+ components => [],
+ reviewers => $product_obj->reviewers_objs(1),
+ };
+ $has_reviewers = scalar @{ $product->{reviewers} };
+
+ foreach my $component_obj (@{ $product_obj->components }) {
+ my $component = {
+ name => $component_obj->name,
+ reviewers => $component_obj->reviewers_objs(1),
+ };
+ if (@{ $component->{reviewers} }) {
+ push @{ $product->{components} }, $component;
+ $has_reviewers = 1;
+ }
+ }
+
+ if ($has_reviewers) {
+ push @$products, $product;
+ }
+ }
+ $args->{vars}->{products} = $products;
+}
+
+sub review_requests_rebuild {
+ my ($self, $args) = @_;
+
+ Bugzilla->user->in_group('admin')
+ || ThrowUserError('auth_failure', { group => 'admin',
+ action => 'run',
+ object => 'review_requests_rebuild' });
+ if (Bugzilla->cgi->param('rebuild')) {
+ my $processed_users = 0;
+ rebuild_review_counters(sub {
+ my ($count, $total) = @_;
+ $processed_users = $total;
+ });
+ $args->{vars}->{rebuild} = 1;
+ $args->{vars}->{total} = $processed_users;
+ }
+}
+
+sub review_history {
+ my ($self, $args) = @_;
+
+ my $user = Bugzilla->login(LOGIN_REQUIRED);
+
+ Bugzilla::User::match_field({ 'requestee' => { 'type' => 'single' } });
+ my $requestee = Bugzilla->input_params->{requestee};
+ if ($requestee) {
+ $args->{vars}{requestee} = Bugzilla::User->check({ name => $requestee, cache => 1 });
+ }
+ else {
+ $args->{vars}{requestee} = $user;
+ }
+}
+
+#
+# installation
+#
+
+sub db_schema_abstract_schema {
+ my ($self, $args) = @_;
+ $args->{'schema'}->{'product_reviewers'} = {
+ FIELDS => [
+ id => {
+ TYPE => 'MEDIUMSERIAL',
+ NOTNULL => 1,
+ PRIMARYKEY => 1,
+ },
+ user_id => {
+ TYPE => 'INT3',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'profiles',
+ COLUMN => 'userid',
+ DELETE => 'CASCADE',
+ }
+ },
+ display_name => {
+ TYPE => 'VARCHAR(64)',
+ },
+ product_id => {
+ TYPE => 'INT2',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'products',
+ COLUMN => 'id',
+ DELETE => 'CASCADE',
+ }
+ },
+ sortkey => {
+ TYPE => 'INT2',
+ NOTNULL => 1,
+ DEFAULT => 0,
+ },
+ ],
+ INDEXES => [
+ product_reviewers_idx => {
+ FIELDS => [ 'user_id', 'product_id' ],
+ TYPE => 'UNIQUE',
+ },
+ ],
+ };
+ $args->{'schema'}->{'component_reviewers'} = {
+ FIELDS => [
+ id => {
+ TYPE => 'MEDIUMSERIAL',
+ NOTNULL => 1,
+ PRIMARYKEY => 1,
+ },
+ user_id => {
+ TYPE => 'INT3',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'profiles',
+ COLUMN => 'userid',
+ DELETE => 'CASCADE',
+ }
+ },
+ display_name => {
+ TYPE => 'VARCHAR(64)',
+ },
+ component_id => {
+ TYPE => 'INT2',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'components',
+ COLUMN => 'id',
+ DELETE => 'CASCADE',
+ }
+ },
+ sortkey => {
+ TYPE => 'INT2',
+ NOTNULL => 1,
+ DEFAULT => 0,
+ },
+ ],
+ INDEXES => [
+ component_reviewers_idx => {
+ FIELDS => [ 'user_id', 'component_id' ],
+ TYPE => 'UNIQUE',
+ },
+ ],
+ };
+
+ $args->{'schema'}->{'flag_state_activity'} = {
+ FIELDS => [
+ id => {
+ TYPE => 'MEDIUMSERIAL',
+ NOTNULL => 1,
+ PRIMARYKEY => 1,
+ },
+
+ flag_when => {
+ TYPE => 'DATETIME',
+ NOTNULL => 1,
+ },
+
+ type_id => {
+ TYPE => 'INT2',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'flagtypes',
+ COLUMN => 'id',
+ DELETE => 'CASCADE'
+ }
+ },
+
+ flag_id => {
+ TYPE => 'INT3',
+ NOTNULL => 1,
+ },
+
+ setter_id => {
+ TYPE => 'INT3',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'profiles',
+ COLUMN => 'userid',
+ },
+ },
+
+ requestee_id => {
+ TYPE => 'INT3',
+ REFERENCES => {
+ TABLE => 'profiles',
+ COLUMN => 'userid',
+ },
+ },
+
+ bug_id => {
+ TYPE => 'INT3',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'bugs',
+ COLUMN => 'bug_id',
+ DELETE => 'CASCADE'
+ }
+ },
+
+ attachment_id => {
+ TYPE => 'INT3',
+ REFERENCES => {
+ TABLE => 'attachments',
+ COLUMN => 'attach_id',
+ DELETE => 'CASCADE'
+ }
+ },
+
+ status => {
+ TYPE => 'CHAR(1)',
+ NOTNULL => 1,
+ },
+ ],
+ };
+
+ $args->{'schema'}->{'bug_mentors'} = {
+ FIELDS => [
+ bug_id => {
+ TYPE => 'INT3',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'bugs',
+ COLUMN => 'bug_id',
+ DELETE => 'CASCADE',
+ },
+ },
+ user_id => {
+ TYPE => 'INT3',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'profiles',
+ COLUMN => 'userid',
+ DELETE => 'CASCADE',
+ }
+ },
+ ],
+ INDEXES => [
+ bug_mentors_idx => {
+ FIELDS => [ 'bug_id', 'user_id' ],
+ TYPE => 'UNIQUE',
+ },
+ bug_mentors_bug_id_idx => [ 'bug_id' ],
+ ],
+ };
+
+ $args->{'schema'}->{'bug_mentors'} = {
+ FIELDS => [
+ bug_id => {
+ TYPE => 'INT3',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'bugs',
+ COLUMN => 'bug_id',
+ DELETE => 'CASCADE',
+ },
+ },
+ user_id => {
+ TYPE => 'INT3',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'profiles',
+ COLUMN => 'userid',
+ DELETE => 'CASCADE',
+ }
+ },
+ ],
+ INDEXES => [
+ bug_mentors_idx => {
+ FIELDS => [ 'bug_id', 'user_id' ],
+ TYPE => 'UNIQUE',
+ },
+ bug_mentors_bug_id_idx => [ 'bug_id' ],
+ ],
+ };
+}
+
+sub install_update_db {
+ my $dbh = Bugzilla->dbh;
+ $dbh->bz_add_column(
+ 'products',
+ 'reviewer_required', { TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'FALSE' }
+ );
+ $dbh->bz_add_column(
+ 'profiles',
+ 'review_request_count', { TYPE => 'INT2', NOTNULL => 1, DEFAULT => 0 }
+ );
+ $dbh->bz_add_column(
+ 'profiles',
+ 'feedback_request_count', { TYPE => 'INT2', NOTNULL => 1, DEFAULT => 0 }
+ );
+ $dbh->bz_add_column(
+ 'profiles',
+ 'needinfo_request_count', { TYPE => 'INT2', NOTNULL => 1, DEFAULT => 0 }
+ );
+
+ my $field = Bugzilla::Field->new({ name => 'bug_mentor' });
+ if (!$field) {
+ Bugzilla::Field->create({
+ name => 'bug_mentor',
+ description => 'Mentor'
+ });
+ }
+}
+
+sub install_filesystem {
+ my ($self, $args) = @_;
+ my $files = $args->{files};
+ my $extensions_dir = bz_locations()->{extensionsdir};
+ $files->{"$extensions_dir/Review/bin/review_requests_rebuild.pl"} = {
+ perms => Bugzilla::Install::Filesystem::OWNER_EXECUTE
+ };
+}
+
+
+__PACKAGE__->NAME;
diff --git a/extensions/Review/bin/migrate_mentor_from_whiteboard.pl b/extensions/Review/bin/migrate_mentor_from_whiteboard.pl
new file mode 100755
index 000000000..8d34963ec
--- /dev/null
+++ b/extensions/Review/bin/migrate_mentor_from_whiteboard.pl
@@ -0,0 +1,229 @@
+#!/usr/bin/perl
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+use 5.10.1;
+use strict;
+use warnings;
+$| = 1;
+
+use FindBin qw($RealBin);
+use lib "$RealBin/../../..";
+
+use Bugzilla;
+BEGIN { Bugzilla->extensions() }
+
+use Bugzilla::Bug;
+use Bugzilla::Constants;
+use Bugzilla::Group;
+use Bugzilla::User;
+
+Bugzilla->usage_mode(USAGE_MODE_CMDLINE);
+
+print <<EOF;
+This script migrates mentors from the whiteboard to BMO's bug_mentor field.
+The mentor needs to be in the form of [mentor=UUU].
+
+It's safe to run this script multiple times, or to cancel this script while
+running.
+
+Press <Return> to start, or Ctrl+C to cancel..
+EOF
+<>;
+
+# we need to be logged in to do user searching and update bugs
+my $nobody = Bugzilla::User->check({ name => 'nobody@mozilla.org' });
+$nobody->{groups} = [ Bugzilla::Group->get_all ];
+Bugzilla->set_user($nobody);
+
+my $mentor_field = Bugzilla::Field->check({ name => 'bug_mentor' });
+my $dbh = Bugzilla->dbh;
+
+# fix broken migration
+
+my $sth = $dbh->prepare("
+ SELECT id, bug_id, bug_when, removed, added
+ FROM bugs_activity
+ WHERE fieldid = ?
+ ORDER BY bug_id,bug_when,removed
+");
+$sth->execute($mentor_field->id);
+my %pair;
+while (my $row = $sth->fetchrow_hashref) {
+ if ($row->{added} && $row->{removed}) {
+ %pair = ();
+ next;
+ }
+ if ($row->{added}) {
+ $pair{bug_id} = $row->{bug_id};
+ $pair{bug_when} = $row->{bug_when};
+ $pair{who} = $row->{added};
+ next;
+ }
+ if (!$pair{bug_id}) {
+ next;
+ }
+ if ($row->{removed}) {
+ if ($row->{bug_id} == $pair{bug_id}
+ && $row->{bug_when} eq $pair{bug_when}
+ && $row->{removed} eq $pair{who})
+ {
+ print "Fixing mentor on bug $row->{bug_id}\n";
+ my $user = Bugzilla::User->check({ name => $row->{removed} });
+ $dbh->bz_start_transaction;
+ $dbh->do(
+ "DELETE FROM bugs_activity WHERE id = ?",
+ undef,
+ $row->{id}
+ );
+ my ($exists) = $dbh->selectrow_array(
+ "SELECT 1 FROM bug_mentors WHERE bug_id = ? AND user_id = ?",
+ undef,
+ $row->{bug_id}, $user->id
+ );
+ if (!$exists) {
+ $dbh->do(
+ "INSERT INTO bug_mentors (bug_id, user_id) VALUES (?, ?)",
+ undef,
+ $row->{bug_id}, $user->id,
+ );
+ }
+ $dbh->bz_commit_transaction;
+ %pair = ();
+ }
+ }
+}
+
+# migrate remaining bugs
+
+my $bug_ids = $dbh->selectcol_arrayref("
+ SELECT bug_id
+ FROM bugs
+ WHERE status_whiteboard LIKE '%[mentor=%'
+ AND resolution=''
+ ORDER BY bug_id
+");
+print "Bugs found: " . scalar(@$bug_ids) . "\n";
+my $bugs = Bugzilla::Bug->new_from_list($bug_ids);
+foreach my $bug (@$bugs) {
+ my $whiteboard = $bug->status_whiteboard;
+ my $orig_whiteboard = $whiteboard;
+ my ($mentors, $errors) = extract_mentors($whiteboard);
+
+ printf "%7s %s\n", $bug->id, $whiteboard;
+ foreach my $error (@$errors) {
+ print " $error\n";
+ }
+ foreach my $user (@$mentors) {
+ print " Mentor: " . $user->identity . "\n";
+ }
+ next if @$errors;
+ $whiteboard =~ s/\[mentor=[^\]]+\]//g;
+
+ my $migrated = $dbh->selectcol_arrayref(
+ "SELECT user_id FROM bug_mentors WHERE bug_id = ?",
+ undef,
+ $bug->id
+ );
+ if (@$migrated) {
+ foreach my $migrated_id (@$migrated) {
+ $mentors = [
+ grep { $_->id != $migrated_id }
+ @$mentors
+ ];
+ }
+ if (!@$mentors) {
+ print " mentor(s) already migrated\n";
+ next;
+ }
+ }
+
+ my $delta_ts = $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)');
+ $dbh->bz_start_transaction;
+ $dbh->do(
+ "UPDATE bugs SET status_whiteboard=? WHERE bug_id=?",
+ undef,
+ $whiteboard, $bug->id
+ );
+ Bugzilla::Bug::LogActivityEntry(
+ $bug->id,
+ 'status_whiteboard',
+ $orig_whiteboard,
+ $whiteboard,
+ $nobody->id,
+ $delta_ts,
+ );
+ foreach my $mentor (@$mentors) {
+ $dbh->do(
+ "INSERT INTO bug_mentors (bug_id, user_id) VALUES (?, ?)",
+ undef,
+ $bug->id, $mentor->id,
+ );
+ Bugzilla::Bug::LogActivityEntry(
+ $bug->id,
+ 'bug_mentor',
+ '',
+ $mentor->login,
+ $nobody->id,
+ $delta_ts,
+ );
+ }
+ $dbh->do(
+ "UPDATE bugs SET lastdiffed = delta_ts WHERE bug_id = ?",
+ undef,
+ $bug->id,
+ );
+ $dbh->bz_commit_transaction;
+}
+
+sub extract_mentors {
+ my ($whiteboard) = @_;
+
+ my (@mentors, @errors);
+ my $logout = 0;
+ while ($whiteboard =~ /\[mentor=([^\]]+)\]/g) {
+ my $mentor_string = $1;
+ $mentor_string =~ s/(^\s+|\s+$)//g;
+ if ($mentor_string =~ /\@/) {
+ # assume it's a full username if it contains an @
+ my $user = Bugzilla::User->new({ name => $mentor_string });
+ if (!$user) {
+ push @errors, "'$mentor_string' failed to match any users";
+ } else {
+ push @mentors, $user;
+ }
+ } else {
+ # otherwise assume it's a : prefixed nick
+
+ $mentor_string =~ s/^://;
+ my $matches = find_users(":$mentor_string");
+ if (!@$matches) {
+ $matches = find_users($mentor_string);
+ }
+
+ if (!$matches || !@$matches) {
+ push @errors, "'$mentor_string' failed to match any users";
+ } elsif (scalar(@$matches) > 1) {
+ push @errors, "'$mentor_string' matches more than one user: " .
+ join(', ', map { $_->identity } @$matches);
+ } else {
+ push @mentors, $matches->[0];
+ }
+ }
+ }
+ return (\@mentors, \@errors);
+}
+
+sub find_users {
+ my ($query) = @_;
+ my $matches = Bugzilla::User::match("*$query*", 2);
+ return [
+ grep { $_->name =~ /:?\Q$query\E\b/i }
+ @$matches
+ ];
+}
diff --git a/extensions/Review/bin/review_requests_rebuild.pl b/extensions/Review/bin/review_requests_rebuild.pl
new file mode 100755
index 000000000..04f8b1042
--- /dev/null
+++ b/extensions/Review/bin/review_requests_rebuild.pl
@@ -0,0 +1,29 @@
+#!/usr/bin/perl
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+use strict;
+use warnings;
+$| = 1;
+
+use FindBin qw($Bin);
+use lib "$Bin/../../..";
+
+use Bugzilla;
+BEGIN { Bugzilla->extensions() }
+
+use Bugzilla::Constants;
+use Bugzilla::Install::Util qw(indicate_progress);
+use Bugzilla::Extension::Review::Util;
+
+Bugzilla->usage_mode(USAGE_MODE_CMDLINE);
+
+rebuild_review_counters(sub{
+ my ($count, $total) = @_;
+ indicate_progress({ current => $count, total => $total, every => 5 });
+});
diff --git a/extensions/Review/lib/FlagStateActivity.pm b/extensions/Review/lib/FlagStateActivity.pm
new file mode 100644
index 000000000..46e9300a5
--- /dev/null
+++ b/extensions/Review/lib/FlagStateActivity.pm
@@ -0,0 +1,122 @@
+# 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::Review::FlagStateActivity;
+use strict;
+use warnings;
+
+use Bugzilla::Error qw(ThrowUserError);
+use Bugzilla::Util qw(trim datetime_from);
+use List::MoreUtils qw(none);
+
+use base qw( Bugzilla::Object );
+
+use constant DB_TABLE => 'flag_state_activity';
+use constant LIST_ORDER => 'id';
+use constant AUDIT_CREATES => 0;
+use constant AUDIT_UPDATES => 0;
+use constant AUDIT_REMOVES => 0;
+
+use constant DB_COLUMNS => qw(
+ id
+ flag_when
+ type_id
+ flag_id
+ setter_id
+ requestee_id
+ bug_id
+ attachment_id
+ status
+);
+
+
+sub _check_param_required {
+ my ($param) = @_;
+
+ return sub {
+ my ($invocant, $value) = @_;
+ $value = trim($value)
+ or ThrowCodeError('param_required', {param => $param});
+ return $value;
+ },
+}
+
+sub _check_date {
+ my ($invocant, $date) = @_;
+
+ $date = trim($date);
+ datetime_from($date)
+ or ThrowUserError('illegal_date', { date => $date,
+ format => 'YYYY-MM-DD HH24:MI:SS' });
+ return $date;
+}
+
+sub _check_status {
+ my ($self, $status) = @_;
+
+ # - Make sure the status is valid.
+ # - Make sure the user didn't request the flag unless it's requestable.
+ # If the flag existed and was requested before it became unrequestable,
+ # leave it as is.
+ if (none { $status eq $_ } qw( X + - ? )) {
+ ThrowUserError(
+ 'flag_status_invalid',
+ {
+ id => $self->id,
+ status => $status
+ }
+ );
+ }
+ return $status;
+}
+
+use constant VALIDATORS => {
+ flag_when => \&_check_date,
+ type_id => _check_param_required('type_id'),
+ flag_id => _check_param_required('flag_id'),
+ setter_id => _check_param_required('setter_id'),
+ bug_id => _check_param_required('bug_id'),
+ status => \&_check_status,
+};
+
+sub flag_when { return $_[0]->{flag_when} }
+sub type_id { return $_[0]->{type_id} }
+sub flag_id { return $_[0]->{flag_id} }
+sub setter_id { return $_[0]->{setter_id} }
+sub bug_id { return $_[0]->{bug_id} }
+sub requestee_id { return $_[0]->{requestee_id} }
+sub attachment_id { return $_[0]->{attachment_id} }
+sub status { return $_[0]->{status} }
+
+sub type {
+ my ($self) = @_;
+ return $self->{type} //= Bugzilla::FlagType->new({ id => $self->type_id, cache => 1 });
+}
+
+sub setter {
+ my ($self) = @_;
+ return $self->{setter} //= Bugzilla::User->new({ id => $self->setter_id, cache => 1 });
+}
+
+sub requestee {
+ my ($self) = @_;
+ return undef unless defined $self->requestee_id;
+ return $self->{requestee} //= Bugzilla::User->new({ id => $self->requestee_id, cache => 1 });
+}
+
+sub bug {
+ my ($self) = @_;
+ return $self->{bug} //= Bugzilla::Bug->new({ id => $self->bug_id, cache => 1 });
+}
+
+sub attachment {
+ my ($self) = @_;
+ return $self->{attachment} //=
+ Bugzilla::Attachment->new({ id => $self->attachment_id, cache => 1 });
+}
+
+1;
diff --git a/extensions/Review/lib/Util.pm b/extensions/Review/lib/Util.pm
new file mode 100644
index 000000000..c00e31b6b
--- /dev/null
+++ b/extensions/Review/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::Review::Util;
+use strict;
+use warnings;
+
+use base qw(Exporter);
+use Bugzilla;
+
+our @EXPORT = qw( rebuild_review_counters );
+
+sub rebuild_review_counters {
+ my ($callback) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ $dbh->bz_start_transaction;
+
+ my $rows = $dbh->selectall_arrayref("
+ SELECT flags.requestee_id AS user_id,
+ flagtypes.name AS flagtype,
+ COUNT(*) as count
+ FROM flags
+ INNER JOIN profiles ON profiles.userid = flags.requestee_id
+ INNER JOIN flagtypes ON flagtypes.id = flags.type_id
+ WHERE flags.status = '?'
+ AND flagtypes.name IN ('review', 'feedback', 'needinfo')
+ GROUP BY flags.requestee_id, flagtypes.name
+ ", { Slice => {} });
+
+ my ($count, $total, $current) = (1, scalar(@$rows), { id => 0 });
+ foreach my $row (@$rows) {
+ $callback->($count++, $total) if $callback;
+ if ($row->{user_id} != $current->{id}) {
+ _update_profile($dbh, $current) if $current->{id};
+ $current = { id => $row->{user_id} };
+ }
+ $current->{$row->{flagtype}} = $row->{count};
+ }
+ _update_profile($dbh, $current) if $current->{id};
+
+ foreach my $field (qw( review feedback needinfo )) {
+ _fix_negatives($dbh, $field);
+ }
+
+ $dbh->bz_commit_transaction;
+}
+
+sub _fix_negatives {
+ my ($dbh, $field) = @_;
+ my $user_ids = $dbh->selectcol_arrayref(
+ "SELECT userid FROM profiles WHERE ${field}_request_count < 0"
+ );
+ return unless @$user_ids;
+ $dbh->do(
+ "UPDATE profiles SET ${field}_request_count = 0 WHERE " . $dbh->sql_in('userid', $user_ids)
+ );
+ foreach my $user_id (@$user_ids) {
+ Bugzilla->memcached->clear({ table => 'profiles', id => $user_id });
+ }
+}
+
+sub _update_profile {
+ my ($dbh, $data) = @_;
+ $dbh->do("
+ UPDATE profiles
+ SET review_request_count = ?,
+ feedback_request_count = ?,
+ needinfo_request_count = ?
+ WHERE userid = ?",
+ undef,
+ $data->{review} || 0,
+ $data->{feedback} || 0,
+ $data->{needinfo} || 0,
+ $data->{id}
+ );
+ Bugzilla->memcached->clear({ table => 'profiles', id => $data->{id} });
+}
+
+1;
diff --git a/extensions/Review/lib/WebService.pm b/extensions/Review/lib/WebService.pm
new file mode 100644
index 000000000..d16ab3dd8
--- /dev/null
+++ b/extensions/Review/lib/WebService.pm
@@ -0,0 +1,488 @@
+# 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::Review::WebService;
+
+use strict;
+use warnings;
+
+use base qw(Bugzilla::WebService);
+
+use Bugzilla::Bug;
+use Bugzilla::Component;
+use Bugzilla::Error;
+use Bugzilla::Util qw(detaint_natural trick_taint);
+use Bugzilla::WebService::Util 'filter';
+
+sub suggestions {
+ my ($self, $params) = @_;
+ my $dbh = Bugzilla->switch_to_shadow_db();
+
+ my ($bug, $product, $component);
+ if (exists $params->{bug_id}) {
+ $bug = Bugzilla::Bug->check($params->{bug_id});
+ $product = $bug->product_obj;
+ $component = $bug->component_obj;
+ }
+ elsif (exists $params->{product}) {
+ $product = Bugzilla::Product->check($params->{product});
+ if (exists $params->{component}) {
+ $component = Bugzilla::Component->check({
+ product => $product, name => $params->{component}
+ });
+ }
+ }
+ else {
+ ThrowUserError("reviewer_suggestions_param_required");
+ }
+
+ my @reviewers;
+ if ($bug) {
+ # we always need to be authentiated to perform user matching
+ my $user = Bugzilla->user;
+ if (!$user->id) {
+ Bugzilla->set_user(Bugzilla::User->check({ name => 'nobody@mozilla.org' }));
+ push @reviewers, @{ $bug->mentors };
+ Bugzilla->set_user($user);
+ } else {
+ push @reviewers, @{ $bug->mentors };
+ }
+ }
+ if ($component) {
+ push @reviewers, @{ $component->reviewers_objs };
+ }
+ if (!@{ $component->reviewers_objs }) {
+ push @reviewers, @{ $product->reviewers_objs };
+ }
+
+ my @result;
+ foreach my $reviewer (@reviewers) {
+ push @result, {
+ id => $self->type('int', $reviewer->id),
+ email => $self->type('email', $reviewer->login),
+ name => $self->type('string', $reviewer->name),
+ review_count => $self->type('int', $reviewer->review_count),
+ };
+ }
+ return \@result;
+}
+
+sub flag_activity {
+ my ($self, $params) = @_;
+ my $dbh = Bugzilla->switch_to_shadow_db();
+ my %match_criteria;
+
+ if (my $flag_id = $params->{flag_id}) {
+ detaint_natural($flag_id)
+ or ThrowUserError('invalid_flag_id', { flag_id => $flag_id });
+
+ $match_criteria{flag_id} = $flag_id;
+ }
+
+ if (my $flag_ids = $params->{flag_ids}) {
+ foreach my $flag_id (@$flag_ids) {
+ detaint_natural($flag_id)
+ or ThrowUserError('invalid_flag_id', { flag_id => $flag_id });
+ }
+
+ $match_criteria{flag_id} = $flag_ids;
+ }
+
+ if (my $type_id = $params->{type_id}) {
+ detaint_natural($type_id)
+ or ThrowUserError('invalid_flag_type_id', { type_id => $type_id });
+
+ $match_criteria{type_id} = $type_id;
+ }
+
+ if (my $type_name = $params->{type_name}) {
+ trick_taint($type_name);
+ my $flag_types = Bugzilla::FlagType::match({ name => $type_name });
+ $match_criteria{type_id} = [map { $_->id } @$flag_types];
+ }
+
+ for my $user_field (qw( requestee setter )) {
+ if (my $user_name = $params->{$user_field}) {
+ my $user = Bugzilla::User->check({ name => $user_name, cache => 1, _error => 'invalid_username' });
+
+ $match_criteria{ $user_field . "_id" } = $user->id;
+ }
+ }
+
+ ThrowCodeError('param_required', { param => 'limit', function => 'Review.flag_activity()' })
+ if defined $params->{offset} && !defined $params->{limit};
+
+ my $limit = delete $params->{limit};
+ my $offset = delete $params->{offset};
+ my $max_results = Bugzilla->params->{max_search_results};
+
+ if (!$limit || $limit > $max_results) {
+ $limit = $max_results;
+ }
+
+ $match_criteria{LIMIT} = $limit;
+ $match_criteria{OFFSET} = $offset if defined $offset;
+ # Hide data until Bug 1073364 is resolved.
+ $match_criteria{WHERE} = { 'flag_when > ?' => '2014-09-23 21:17:16' };
+
+ # Throw error if no other parameters have been passed other than limit and offset
+ if (!grep(!/^(LIMIT|OFFSET)$/, keys %match_criteria)) {
+ ThrowUserError('flag_activity_parameters_required');
+ }
+
+ my $matches = Bugzilla::Extension::Review::FlagStateActivity->match(\%match_criteria);
+ my $user = Bugzilla->user;
+ $user->visible_bugs([ map { $_->bug_id } @$matches ]);
+ my @results = map { $self->_flag_state_activity_to_hash($_, $params) }
+ grep { $user->can_see_bug($_->bug_id) && _can_see_attachment($user, $_) }
+ @$matches;
+ return \@results;
+}
+
+sub _can_see_attachment {
+ my ($user, $flag_state_activity) = @_;
+
+ return 1 if !$flag_state_activity->attachment_id;
+ return 0 if $flag_state_activity->attachment->isprivate && !$user->is_insider;
+ return 1;
+}
+
+sub rest_resources {
+ return [
+ # bug-id
+ qr{^/review/suggestions/(\d+)$}, {
+ GET => {
+ method => 'suggestions',
+ params => sub {
+ return { bug_id => $_[0] };
+ },
+ },
+ },
+ # product/component
+ qr{^/review/suggestions/([^/]+)/(.+)$}, {
+ GET => {
+ method => 'suggestions',
+ params => sub {
+ return { product => $_[0], component => $_[1] };
+ },
+ },
+ },
+ # just product
+ qr{^/review/suggestions/([^/]+)$}, {
+ GET => {
+ method => 'suggestions',
+ params => sub {
+ return { product => $_[0] };
+ },
+ },
+ },
+ # named parameters
+ qr{^/review/suggestions$}, {
+ GET => {
+ method => 'suggestions',
+ },
+ },
+ # flag activity by flag id
+ qr{^/review/flag_activity/(\d+)$}, {
+ GET => {
+ method => 'flag_activity',
+ params => sub {
+ return { flag_id => $_[0] }
+ },
+ },
+ },
+ qr{^/review/flag_activity/type_name/(\w+)$}, {
+ GET => {
+ method => 'flag_activity',
+ params => sub {
+ return { type_name => $_[0] }
+ },
+ },
+ },
+ # flag activity by user
+ qr{^/review/flag_activity/(requestee|setter|type_id)/(.*)$}, {
+ GET => {
+ method => 'flag_activity',
+ params => sub {
+ return { $_[0] => $_[1] };
+ },
+ },
+ },
+ # flag activity with only query strings
+ qr{^/review/flag_activity$}, {
+ GET => { method => 'flag_activity' },
+ },
+ ];
+}
+
+sub _flag_state_activity_to_hash {
+ my ($self, $fsa, $params) = @_;
+
+ my %flag = (
+ id => $self->type('int', $fsa->id),
+ creation_time => $self->type('string', $fsa->flag_when),
+ type => $self->_flagtype_to_hash($fsa->type),
+ setter => $self->_user_to_hash($fsa->setter),
+ bug_id => $self->type('int', $fsa->bug_id),
+ attachment_id => $self->type('int', $fsa->attachment_id),
+ status => $self->type('string', $fsa->status),
+ );
+
+ $flag{requestee} = $self->_user_to_hash($fsa->requestee) if $fsa->requestee;
+ $flag{flag_id} = $self->type('int', $fsa->flag_id) unless $params->{flag_id};
+
+ return filter($params, \%flag);
+}
+
+sub _flagtype_to_hash {
+ my ($self, $flagtype) = @_;
+ my $user = Bugzilla->user;
+
+ return {
+ id => $self->type('int', $flagtype->id),
+ name => $self->type('string', $flagtype->name),
+ description => $self->type('string', $flagtype->description),
+ type => $self->type('string', $flagtype->target_type),
+ is_active => $self->type('boolean', $flagtype->is_active),
+ is_requesteeble => $self->type('boolean', $flagtype->is_requesteeble),
+ is_multiplicable => $self->type('boolean', $flagtype->is_multiplicable),
+ };
+}
+
+sub _user_to_hash {
+ my ($self, $user) = @_;
+
+ return {
+ id => $self->type('int', $user->id),
+ real_name => $self->type('string', $user->name),
+ name => $self->type('email', $user->login),
+ };
+}
+
+1;
+__END__
+=head1 NAME
+
+Bugzilla::Extension::Review::WebService - Functions for the Mozilla specific
+'review' flag optimisations.
+
+=head1 METHODS
+
+See L<Bugzilla::WebService> for a description of how parameters are passed,
+and what B<STABLE>, B<UNSTABLE>, and B<EXPERIMENTAL> mean.
+
+Although the data input and output is the same for JSONRPC, XMLRPC and REST,
+the directions for how to access the data via REST is noted in each method
+where applicable.
+
+=head2 suggestions
+
+B<EXPERIMENTAL>
+
+=over
+
+=item B<Description>
+
+Returns the list of suggestions for reviewers.
+
+=item B<REST>
+
+GET /rest/review/suggestions/C<bug-id>
+
+GET /rest/review/suggestions/C<product-name>
+
+GET /rest/review/suggestions/C<product-name>/C<component-name>
+
+GET /rest/review/suggestions?product=C<product-name>
+
+GET /rest/review/suggestions?product=C<product-name>&component=C<component-name>
+
+The returned data format is the same as below.
+
+=item B<Params>
+
+Query by Bug:
+
+=over
+
+=over
+
+=item C<bug_id> (integer) - The bug ID.
+
+=back
+
+=back
+
+Query by Product or Component:
+
+=over
+
+=over
+
+=item C<product> (string) - The product name.
+
+=item C<component> (string) - The component name (optional). If providing a C<component>, a C<product> must also be provided.
+
+=back
+
+=back
+
+=item B<Returns>
+
+An array of hashes with the following keys/values:
+
+=over
+
+=item C<id> (integer) - The user's ID.
+
+=item C<email> (string) - The user's email address (aka login).
+
+=item C<name> (string) - The user's display name (may not match the Bugzilla "real name").
+
+=item C<review_count> (string) - The number of "review" and "feedback" requests in the user's queue.
+
+=back
+
+=back
+
+=head2 flag_activity
+
+B<EXPERIMENTAL>
+
+=over
+
+=item B<Description>
+
+Returns the history of flag status changes based on requestee, setter, flag_id, type_id, or all.
+
+=item B<REST>
+
+GET /rest/review/flag_activity/C<flag_id>
+
+GET /rest/review/flag_activity/requestee/C<requestee>
+
+GET /rest/review/flag_activity/setter/C<setter>
+
+GET /rest/review/flag_activity/type_id/C<type_id>
+
+GET /rest/review/flag_activity/type_name/C<type_name>
+
+GET /rest/review/flag_activity
+
+The returned data format is the same as below.
+
+=item B<Params>
+
+Use one or more of the following parameters to find specific flag status changes.
+
+=over
+
+=item C<flag_id> (integer) - The flag ID.
+
+Note that searching by C<flag_id> is not reliable because when flags are removed, flag_ids cease to exist.
+
+=item C<requestee> (string) - The bugzilla login of the flag's requestee
+
+=item C<setter> (string) - The bugzilla login of the flag's setter
+
+=item C<type_id> (int) - The flag type id of a change
+
+=item C<type_name> (string) - the flag type name of a change
+
+=back
+
+=item B<Returns>
+
+An array of hashes with the following keys/values:
+
+=over
+
+=item C<flag_id> (integer)
+
+The id of the flag that changed. This field may be absent after a flag is deleted.
+
+=item C<creation_time> (dateTime)
+
+Timestamp of when the flag status changed.
+
+=item C<type> (object)
+
+An object with the following fields:
+
+=over
+
+=item C<id> (integer)
+
+The flag type id of the flag that changed
+
+=item C<name> (string)
+
+The name of the flag type (review, feedback, etc)
+
+=item C<description> (string)
+
+A plain english description of the flag type.
+
+=item C<type> (string)
+
+The content of the target_type field of the flagtypes table.
+
+=item C<is_active> (boolean)
+
+Boolean flag indicating if the flag type is available for use.
+
+=item C<is_requesteeble> (boolean)
+
+Boolean flag indicating if the flag type is requesteeable.
+
+=item C<is_multiplicable> (boolean)
+
+Boolean flag indicating if the flag type is multiplicable.
+
+=back
+
+=item C<setter> (object)
+
+The setter is the bugzilla user that set the flag. It is represented by an object with the following fields.
+
+=over
+
+=item C<id> (integer)
+
+The id of the bugzilla user. A unique integer value.
+
+=item C<real_name> (string)
+
+The real name of the bugzilla user.
+
+=item C<name> (string)
+
+The bugzilla login of the bugzilla user (typically an email address).
+
+=back
+
+=item C<requestee> (object)
+
+The requestee is the bugzilla user that is specified by the flag. Optional - absent if there is no requestee.
+
+Requestee has the same keys/values as the setter object.
+
+=item C<bug_id> (integer)
+
+The id of the bugzilla bug that the changed flag belongs to.
+
+=item C<attachment_id> (integer)
+
+The id of the bugzilla attachment that the changed flag belongs to.
+
+=item C<status> (string)
+
+The status of the bugzilla flag that changed. One of C<+ - ? X>.
+
+=back
+
+=back
diff --git a/extensions/Review/template/en/default/hook/admin/components/edit-common-rows.html.tmpl b/extensions/Review/template/en/default/hook/admin/components/edit-common-rows.html.tmpl
new file mode 100644
index 000000000..42aa91ada
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/admin/components/edit-common-rows.html.tmpl
@@ -0,0 +1,23 @@
+[%# 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.
+ #%]
+
+<tr>
+ <th align="right">Suggested Reviewers:</th>
+ <td>
+ [% INCLUDE global/userselect.html.tmpl
+ id => "reviewers"
+ name => "reviewers"
+ value => comp.reviewers(1)
+ size => 64
+ emptyok => 1
+ title => "One or more email address (comma delimited)"
+ placeholder => product.reviewers(1)
+ multiple => 5
+ %]
+ </td>
+</tr>
diff --git a/extensions/Review/template/en/default/hook/admin/products/edit-common-rows.html.tmpl b/extensions/Review/template/en/default/hook/admin/products/edit-common-rows.html.tmpl
new file mode 100644
index 000000000..61a275e72
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/admin/products/edit-common-rows.html.tmpl
@@ -0,0 +1,28 @@
+[%# 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.
+ #%]
+
+<tr>
+ <th align="right">Reviewer required:</th>
+ <td>
+ <input type="checkbox" name="reviewer_required" value="1"
+ [% "checked" IF product.reviewer_required %]>
+ </td>
+</tr>
+<tr>
+ <th align="right">Suggested Reviewers:</th>
+ <td>
+ [% INCLUDE global/userselect.html.tmpl
+ id => "reviewers"
+ name => "reviewers"
+ value => product.reviewers(1)
+ size => 64
+ emptyok => 1
+ title => "One or more email address (comma delimited)"
+ %]
+ </td>
+</tr>
diff --git a/extensions/Review/template/en/default/hook/admin/products/updated-changes.html.tmpl b/extensions/Review/template/en/default/hook/admin/products/updated-changes.html.tmpl
new file mode 100644
index 000000000..667848281
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/admin/products/updated-changes.html.tmpl
@@ -0,0 +1,19 @@
+[%# 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.
+ #%]
+
+[% IF changes.reviewers.defined %]
+ <p>
+ Updated suggested reviewers from '[% changes.reviewers.0 FILTER html %]' to
+ '[% product.reviewers FILTER html %]'.
+ </p>
+[% END %]
+[% IF changes.reviewer_required.defined %]
+ <p>
+ [% changes.reviewer_required.1 ? "Enabled" : "Disabled" %] 'review required'.
+ </p>
+[% END %]
diff --git a/extensions/Review/template/en/default/hook/attachment/create-end.html.tmpl b/extensions/Review/template/en/default/hook/attachment/create-end.html.tmpl
new file mode 100644
index 000000000..55226545d
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/attachment/create-end.html.tmpl
@@ -0,0 +1,20 @@
+[%# 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.
+ #%]
+
+[% UNLESS bug %]
+ [% bug = attachment.bug %]
+[% END %]
+
+<script>
+ YAHOO.util.Event.onDOMReady(function() {
+ [% IF bug.product_obj.reviewer_required %]
+ REVIEW.init_mandatory();
+ [% END %]
+ REVIEW.init_create_attachment();
+ });
+</script>
diff --git a/extensions/Review/template/en/default/hook/attachment/edit-end.html.tmpl b/extensions/Review/template/en/default/hook/attachment/edit-end.html.tmpl
new file mode 100644
index 000000000..bc6230d1c
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/attachment/edit-end.html.tmpl
@@ -0,0 +1,15 @@
+[%# 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.
+ #%]
+
+[% IF attachment.bug.product_obj.reviewer_required %]
+<script>
+YAHOO.util.Event.onDOMReady(function() {
+ REVIEW.init_mandatory();
+});
+</script>
+[% END %]
diff --git a/extensions/Review/template/en/default/hook/bug/create/create-after_custom_fields.html.tmpl b/extensions/Review/template/en/default/hook/bug/create/create-after_custom_fields.html.tmpl
new file mode 100644
index 000000000..4a8f05755
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/bug/create/create-after_custom_fields.html.tmpl
@@ -0,0 +1,20 @@
+[%# 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.
+ #%]
+
+<tr>
+ <th class="field_label">Mentors:</th>
+ <td colspan="3" class="field_value">
+ [% INCLUDE global/userselect.html.tmpl
+ id = "bug_mentors"
+ name = "bug_mentors"
+ size = 30
+ multiple = 5
+ value = bug_mentors
+ %]
+ </td>
+</tr>
diff --git a/extensions/Review/template/en/default/hook/bug/create/create-end.html.tmpl b/extensions/Review/template/en/default/hook/bug/create/create-end.html.tmpl
new file mode 100644
index 000000000..a59cef950
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/bug/create/create-end.html.tmpl
@@ -0,0 +1,16 @@
+[%# 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.
+ #%]
+
+<script>
+ YAHOO.util.Event.onDOMReady(function() {
+ [% IF product.reviewer_required %]
+ REVIEW.init_mandatory();
+ [% END %]
+ REVIEW.init_enter_bug();
+ });
+</script>
diff --git a/extensions/Review/template/en/default/hook/bug/edit-after_people.html.tmpl b/extensions/Review/template/en/default/hook/bug/edit-after_people.html.tmpl
new file mode 100644
index 000000000..5f8ea8fa9
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/bug/edit-after_people.html.tmpl
@@ -0,0 +1,53 @@
+[%# 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.
+ #%]
+
+[% mentor_logins = [] %]
+[% FOREACH mentor = bug.mentors %]
+ [% mentor_logins.push(mentor.login) %]
+[% END %]
+<tr>
+ <th class="field_label">Mentors:</th>
+ <td>
+ [% IF bug.check_can_change_field("bug_mentors", 0, 1) %]
+ <div id="bz_bug_mentors_edit_container" class="bz_default_hidden">
+ <span>
+ [% FOREACH mentor = bug.mentors %]
+ [% INCLUDE global/user.html.tmpl who = mentor %]
+ [% "<br>" UNLESS loop.last %]
+ [% END %]
+ (<a href="#" id="bz_bug_mentors_edit_action">edit</a>)
+ </span>
+ </div>
+ <div id="bz_bug_mentors_input">
+ <input type="hidden" name="defined_bug_mentors"
+ value="[% mentor_logins.join(", ") FILTER html %]">
+ [% INCLUDE global/userselect.html.tmpl
+ id = "bug_mentors"
+ name = "bug_mentors"
+ value = mentor_logins.join(", ")
+ classes = ["bz_userfield"]
+ size = 30
+ multiple = 5
+ %]
+ <br>
+ </div>
+ <script type="text/javascript">
+ hideEditableField('bz_bug_mentors_edit_container',
+ 'bz_bug_mentors_input',
+ 'bz_bug_mentors_edit_action',
+ 'bug_mentors',
+ '[% mentor_logins.join(", ") FILTER js %]' );
+ </script>
+ [% ELSE %]
+ [% FOREACH mentor = bug.mentors %]
+ [% INCLUDE global/user.html.tmpl who = mentor %]<br>
+ [% END %]
+ [% END %]
+ </td>
+</tr>
+
diff --git a/extensions/Review/template/en/default/hook/bug/show-bug_end.xml.tmpl b/extensions/Review/template/en/default/hook/bug/show-bug_end.xml.tmpl
new file mode 100644
index 000000000..9ad650b2f
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/bug/show-bug_end.xml.tmpl
@@ -0,0 +1,12 @@
+[%# 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.
+ #%]
+
+[% FOREACH mentor = bug.mentors %]
+ <mentor name="[% mentor.name FILTER xml %]">
+ [% mentor.login FILTER email FILTER xml %]</mentor>
+[% END %]
diff --git a/extensions/Review/template/en/default/hook/flag/list-requestee.html.tmpl b/extensions/Review/template/en/default/hook/flag/list-requestee.html.tmpl
new file mode 100644
index 000000000..a3f0e8a44
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/flag/list-requestee.html.tmpl
@@ -0,0 +1,17 @@
+[%# 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.
+ #%]
+
+[% RETURN UNLESS type.name == 'review' %]
+
+<span id="[% fid FILTER none %]_suggestions" class="bz_default_hidden">
+ &nbsp;&nbsp;(<a href="#" id="[% fid FILTER none %]_suggestions_link">suggested reviewers</a>)
+</span>
+
+<script>
+ REVIEW.init_review_flag('[% fid FILTER none %]', '[% flag_name FILTER none %]');
+</script>
diff --git a/extensions/Review/template/en/default/hook/global/header-message.html.tmpl b/extensions/Review/template/en/default/hook/global/header-message.html.tmpl
new file mode 100644
index 000000000..e4bb1c687
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/global/header-message.html.tmpl
@@ -0,0 +1,23 @@
+[%# 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.
+ #%]
+
+[% RETURN UNLESS
+ user.review_request_count
+ || user.feedback_request_count
+ || user.needinfo_request_count
+%]
+
+<a id="badge"
+ href="request.cgi?action=queue&amp;requestee=[% user.login FILTER uri %]&amp;group=type"
+ title="Flags requested of you:
+ [%- " review (" _ user.review_request_count _ ")" IF user.review_request_count -%]
+ [%- " feedback (" _ user.feedback_request_count _ ")" IF user.feedback_request_count -%]
+ [%- " needinfo (" _ user.needinfo_request_count _ ")" IF user.needinfo_request_count -%]
+">
+ [%- user.review_request_count + user.feedback_request_count + user.needinfo_request_count ~%]
+</a>
diff --git a/extensions/Review/template/en/default/hook/global/header-start.html.tmpl b/extensions/Review/template/en/default/hook/global/header-start.html.tmpl
new file mode 100644
index 000000000..ff166ac4c
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/global/header-start.html.tmpl
@@ -0,0 +1,91 @@
+[%# 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.
+ #%]
+
+[% IF user.review_request_count
+ || user.feedback_request_count
+ || user.needinfo_request_count
+%]
+ [% style_urls.push('extensions/Review/web/styles/badge.css') %]
+[% END %]
+
+[% RETURN UNLESS template.name == 'attachment/edit.html.tmpl'
+ || template.name == 'attachment/create.html.tmpl'
+ || template.name == 'attachment/diff-header.html.tmpl'
+ || template.name == 'bug/create/create.html.tmpl'
+ || template.name == 'pages/splinter.html.tmpl' %]
+
+[% style_urls.push('extensions/Review/web/styles/review.css') %]
+[% javascript_urls.push('extensions/Review/web/js/review.js') %]
+
+[% IF bug %]
+ [%# create attachment %]
+ [% mentors = bug.mentors %]
+ [% product_obj = bug.product_obj %]
+ [% component_obj = bug.component_obj %]
+[% ELSIF attachment.bug %]
+ [%# edit attachment %]
+ [% mentors = attachment.bug.mentors %]
+ [% product_obj = attachment.bug.product_obj %]
+ [% component_obj = attachment.bug.component_obj %]
+[% ELSE %]
+ [%# create bug %]
+ [% mentors = [] %]
+ [% product_obj = product %]
+ [% component_obj = 0 %]
+[% END %]
+
+[% review_js = BLOCK %]
+ review_suggestions = {
+ _mentors: [
+ [% FOREACH u = mentors %]
+ [% PROCESS reviewer %][% "," UNLESS loop.last %]
+ [% END %]
+ ],
+
+ [% IF product_obj.reviewers %]
+ _product: [
+ [% FOREACH u = product_obj.reviewers_objs %]
+ [% PROCESS reviewer %][% "," UNLESS loop.last %]
+ [% END %]
+ ],
+ [% END %]
+
+ [% IF component_obj %]
+ [%# single component (create/edit attachment) %]
+ '[% component_obj.name FILTER js %]': [
+ [% FOREACH u = component_obj.reviewers_objs %]
+ [% PROCESS reviewer %][% "," UNLESS loop.last %]
+ [% END %]
+ ],
+ [% ELSE %]
+ [%# all components (create bug) %]
+ [% FOREACH c = product_obj.components %]
+ [% NEXT UNLESS c.reviewers %]
+ '[% c.name FILTER js %]': [
+ [% FOREACH u = c.reviewers_objs %]
+ [% PROCESS reviewer %][% "," UNLESS loop.last %]
+ [% END %]
+ ],
+ [% END %]
+ [% END %]
+
+ [%# to keep IE happy, no trailing commas %]
+ _end: 1
+ };
+
+ [% IF component_obj %]
+ static_component = '[% component_obj.name FILTER js %]';
+ [% ELSE %]
+ static_component = false;
+ [% END %]
+[% END %]
+[% javascript = javascript _ review_js %]
+
+[% BLOCK reviewer %]
+ { login: '[% u.login FILTER js%]', identity: '[% u.identity FILTER js %]', review_count: [% u.review_count FILTER js %] }
+[% END %]
diff --git a/extensions/Review/template/en/default/hook/global/messages-component_updated_fields.html.tmpl b/extensions/Review/template/en/default/hook/global/messages-component_updated_fields.html.tmpl
new file mode 100644
index 000000000..05b7bde82
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/global/messages-component_updated_fields.html.tmpl
@@ -0,0 +1,11 @@
+[%# 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.
+ #%]
+
+[% IF changes.reviewers.defined %]
+ <li>Suggested Reviewers changed to '[% comp.reviewers.join(", ") FILTER html %]'</li>
+[% END %]
diff --git a/extensions/Review/template/en/default/hook/global/user-error-auth_failure_object.html.tmpl b/extensions/Review/template/en/default/hook/global/user-error-auth_failure_object.html.tmpl
new file mode 100644
index 000000000..156f0aa93
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/global/user-error-auth_failure_object.html.tmpl
@@ -0,0 +1,11 @@
+[%# 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.
+ #%]
+
+[% IF object == 'review_requests_rebuild' %]
+ rebuild review request counters
+[% END %]
diff --git a/extensions/Review/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/Review/template/en/default/hook/global/user-error-errors.html.tmpl
new file mode 100644
index 000000000..ca143cca3
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/global/user-error-errors.html.tmpl
@@ -0,0 +1,26 @@
+[%# 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.
+ #%]
+
+[% IF error == "reviewer_required" %]
+ [% title = "Reviewer Required" %]
+ You must provide a reviewer for review requests.
+
+[% ELSIF error == "reviewer_suggestions_param_required" %]
+ [% title = "Parameter Required" %]
+ You must provide either a bug_id, or a product (and optionally a
+ component).
+
+[% ELSIF error == "invalid_flag_type_id" %]
+ [% title = "Invalid Flag Type ID" %]
+ The flag type id [% type_id FILTER html %] is invalid.
+
+[% ELSIF error == "flag_activity_parameters_required" %]
+ [% title = "Parameters Required" %]
+ You may not search flag state activity without any search terms.
+
+[% END %]
diff --git a/extensions/Review/template/en/default/hook/reports/menu-end.html.tmpl b/extensions/Review/template/en/default/hook/reports/menu-end.html.tmpl
new file mode 100644
index 000000000..d25ba20ee
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/reports/menu-end.html.tmpl
@@ -0,0 +1,16 @@
+[%# 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.
+ #%]
+
+<ul>
+ <li>
+ <strong>
+ <a href="[% urlbase FILTER none %]page.cgi?id=review_suggestions.html">Suggested Reviewers</a>
+ </strong> - All suggestions for the "review" flag.
+ </li>
+</ul>
+
diff --git a/extensions/Review/template/en/default/pages/review_history.html.tmpl b/extensions/Review/template/en/default/pages/review_history.html.tmpl
new file mode 100644
index 000000000..32ac83ceb
--- /dev/null
+++ b/extensions/Review/template/en/default/pages/review_history.html.tmpl
@@ -0,0 +1,62 @@
+[%# 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.
+ #%]
+
+[% PROCESS global/variables.none.tmpl %]
+
+[% PROCESS global/header.html.tmpl
+ title = "Review History"
+ style_urls = [ "extensions/Review/web/styles/review_history.css" ]
+ javascript_urls = [ 'js/yui3/yui/yui-min.js',
+ 'extensions/Review/web/js/review_history.js',
+ 'extensions/Review/web/js/moment.min.js',
+ 'js/util.js',
+ 'js/field.js' ]
+ yui = [ "autocomplete" ]
+%]
+
+<script type="text/javascript">
+ YUI({
+ base: 'js/yui3/',
+ combine: false,
+ groups: {
+ gallery: {
+ combine: false,
+ base: 'js/yui3/',
+ patterns: { 'gallery-': {} }
+ }
+ }
+ }).use('bz-review-history', function(Y) {
+ Y.ReviewHistory.render('#history', '#history-loading');
+ var requestee = Y.one('#requestee');
+
+ Y.ReviewHistory.refresh('[% requestee.login FILTER js %]', '[% requestee.name FILTER html FILTER js %]');
+ });
+</script>
+
+<div>
+ <form method="get">
+ <label class="field_label" for="user">Requestee </label>
+
+ [% INCLUDE global/userselect.html.tmpl
+ id => "requestee"
+ name => "requestee"
+ value => requestee.login
+ classes => ["bz_userfield"]
+ %]
+
+ <input type="submit" value="Generate Report">
+ <input type="hidden" name="id" value="review_history.html">
+ </form>
+</div>
+
+<div class="yui3-skin-sam">
+ <div id="history-loading">Loading...</div>
+ <div id="history"></div>
+</div>
+
+[% PROCESS global/footer.html.tmpl %]
diff --git a/extensions/Review/template/en/default/pages/review_requests_rebuild.html.tmpl b/extensions/Review/template/en/default/pages/review_requests_rebuild.html.tmpl
new file mode 100644
index 000000000..5ec811126
--- /dev/null
+++ b/extensions/Review/template/en/default/pages/review_requests_rebuild.html.tmpl
@@ -0,0 +1,23 @@
+[%# 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.
+ #%]
+
+[% INCLUDE global/header.html.tmpl
+ title = "Review Requests Rebuild"
+%]
+
+[% IF rebuild %]
+ Counters rebuilt for [% total FILTER html %] users.
+[% ELSE %]
+ <form method="post">
+ <input type="hidden" name="id" value="review_requests_rebuild.html">
+ <input type="hidden" name="rebuild" value="1">
+ <input type="submit" value="Rebuild Review Request Counters">
+ </form>
+[% END %]
+
+[% INCLUDE global/footer.html.tmpl %]
diff --git a/extensions/Review/template/en/default/pages/review_suggestions.html.tmpl b/extensions/Review/template/en/default/pages/review_suggestions.html.tmpl
new file mode 100644
index 000000000..5d9132e40
--- /dev/null
+++ b/extensions/Review/template/en/default/pages/review_suggestions.html.tmpl
@@ -0,0 +1,76 @@
+[%# 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.
+ #%]
+
+[% INCLUDE global/header.html.tmpl
+ title = "Suggested Reviewers Report"
+ style_urls = [ "extensions/BMO/web/styles/reports.css",
+ "extensions/Review/web/styles/reports.css" ]
+%]
+
+Products:
+<ul>
+ [% FOREACH product = products %]
+ <li>
+ <a href="#[% product.name FILTER uri %]">
+ [% product.name FILTER html %]
+ </a>
+ </li>
+ [% END %]
+</ul>
+
+<a href="enter_bug.cgi?product=bugzilla.mozilla.org&amp;component=Administration&amp;format=__default__">Request a change</a>
+
+<table id="report" class="hover" cellspacing="0">
+
+<tr id="report-header">
+ <th>Product/Component</th>
+ <th>Suggested Reviewers</th>
+</tr>
+
+[% FOREACH product = products %]
+ <tr class="report_subheader">
+ <td class="product_name">
+ <a name="[% product.name FILTER html %]">
+ [% product.name FILTER html %]
+ </a>
+ </td>
+ <td>
+ </td>
+ </tr>
+ [% row_class = "report_row_even" %]
+ [% FOREACH component = product.components %]
+ <tr class="[% row_class FILTER none %]">
+ <td class="component_name">[% component.name FILTER html %]</td>
+ <td class="reviewers">
+ [% FOREACH reviewer = component.reviewers %]
+ <span title="[% reviewer.name FILTER html %]">
+ [% reviewer.email FILTER html %]</span>
+ [% ", " UNLESS loop.last %]
+ [% END %]
+ </td>
+ </tr>
+ [% row_class = row_class == "report_row_even" ? "report_row_odd" : "report_row_even" %]
+ [% END %]
+ [% IF product.reviewers.size %]
+ <tr class="[% row_class FILTER none %]">
+ <td class="other_components">All [% product.components.size ? "other" : "" %] components</td>
+ <td class="reviewers">
+ [% FOREACH reviewer = product.reviewers %]
+ <span title="[% reviewer.name FILTER html %]">
+ [% reviewer.email FILTER html %]</span>
+ [% ", " UNLESS loop.last %]
+ [% END %]
+ </td>
+ </tr>
+ [% row_class = row_class == "report_row_even" ? "report_row_odd" : "report_row_even" %]
+ [% END %]
+[% END %]
+
+</table>
+
+[% INCLUDE global/footer.html.tmpl %]
diff --git a/extensions/Review/web/js/moment.min.js b/extensions/Review/web/js/moment.min.js
new file mode 100644
index 000000000..26ac5cc9d
--- /dev/null
+++ b/extensions/Review/web/js/moment.min.js
@@ -0,0 +1,6 @@
+//! moment.js
+//! version : 2.8.2
+//! authors : Tim Wood, Iskren Chernev, Moment.js contributors
+//! license : MIT
+//! momentjs.com
+(function(a){function b(a,b,c){switch(arguments.length){case 2:return null!=a?a:b;case 3:return null!=a?a:null!=b?b:c;default:throw new Error("Implement me")}}function c(a,b){return yb.call(a,b)}function d(){return{empty:!1,unusedTokens:[],unusedInput:[],overflow:-2,charsLeftOver:0,nullInput:!1,invalidMonth:null,invalidFormat:!1,userInvalidated:!1,iso:!1}}function e(a){sb.suppressDeprecationWarnings===!1&&"undefined"!=typeof console&&console.warn&&console.warn("Deprecation warning: "+a)}function f(a,b){var c=!0;return m(function(){return c&&(e(a),c=!1),b.apply(this,arguments)},b)}function g(a,b){pc[a]||(e(b),pc[a]=!0)}function h(a,b){return function(c){return p(a.call(this,c),b)}}function i(a,b){return function(c){return this.localeData().ordinal(a.call(this,c),b)}}function j(){}function k(a,b){b!==!1&&F(a),n(this,a),this._d=new Date(+a._d)}function l(a){var b=y(a),c=b.year||0,d=b.quarter||0,e=b.month||0,f=b.week||0,g=b.day||0,h=b.hour||0,i=b.minute||0,j=b.second||0,k=b.millisecond||0;this._milliseconds=+k+1e3*j+6e4*i+36e5*h,this._days=+g+7*f,this._months=+e+3*d+12*c,this._data={},this._locale=sb.localeData(),this._bubble()}function m(a,b){for(var d in b)c(b,d)&&(a[d]=b[d]);return c(b,"toString")&&(a.toString=b.toString),c(b,"valueOf")&&(a.valueOf=b.valueOf),a}function n(a,b){var c,d,e;if("undefined"!=typeof b._isAMomentObject&&(a._isAMomentObject=b._isAMomentObject),"undefined"!=typeof b._i&&(a._i=b._i),"undefined"!=typeof b._f&&(a._f=b._f),"undefined"!=typeof b._l&&(a._l=b._l),"undefined"!=typeof b._strict&&(a._strict=b._strict),"undefined"!=typeof b._tzm&&(a._tzm=b._tzm),"undefined"!=typeof b._isUTC&&(a._isUTC=b._isUTC),"undefined"!=typeof b._offset&&(a._offset=b._offset),"undefined"!=typeof b._pf&&(a._pf=b._pf),"undefined"!=typeof b._locale&&(a._locale=b._locale),Hb.length>0)for(c in Hb)d=Hb[c],e=b[d],"undefined"!=typeof e&&(a[d]=e);return a}function o(a){return 0>a?Math.ceil(a):Math.floor(a)}function p(a,b,c){for(var d=""+Math.abs(a),e=a>=0;d.length<b;)d="0"+d;return(e?c?"+":"":"-")+d}function q(a,b){var c={milliseconds:0,months:0};return c.months=b.month()-a.month()+12*(b.year()-a.year()),a.clone().add(c.months,"M").isAfter(b)&&--c.months,c.milliseconds=+b-+a.clone().add(c.months,"M"),c}function r(a,b){var c;return b=K(b,a),a.isBefore(b)?c=q(a,b):(c=q(b,a),c.milliseconds=-c.milliseconds,c.months=-c.months),c}function s(a,b){return function(c,d){var e,f;return null===d||isNaN(+d)||(g(b,"moment()."+b+"(period, number) is deprecated. Please use moment()."+b+"(number, period)."),f=c,c=d,d=f),c="string"==typeof c?+c:c,e=sb.duration(c,d),t(this,e,a),this}}function t(a,b,c,d){var e=b._milliseconds,f=b._days,g=b._months;d=null==d?!0:d,e&&a._d.setTime(+a._d+e*c),f&&mb(a,"Date",lb(a,"Date")+f*c),g&&kb(a,lb(a,"Month")+g*c),d&&sb.updateOffset(a,f||g)}function u(a){return"[object Array]"===Object.prototype.toString.call(a)}function v(a){return"[object Date]"===Object.prototype.toString.call(a)||a instanceof Date}function w(a,b,c){var d,e=Math.min(a.length,b.length),f=Math.abs(a.length-b.length),g=0;for(d=0;e>d;d++)(c&&a[d]!==b[d]||!c&&A(a[d])!==A(b[d]))&&g++;return g+f}function x(a){if(a){var b=a.toLowerCase().replace(/(.)s$/,"$1");a=ic[a]||jc[b]||b}return a}function y(a){var b,d,e={};for(d in a)c(a,d)&&(b=x(d),b&&(e[b]=a[d]));return e}function z(b){var c,d;if(0===b.indexOf("week"))c=7,d="day";else{if(0!==b.indexOf("month"))return;c=12,d="month"}sb[b]=function(e,f){var g,h,i=sb._locale[b],j=[];if("number"==typeof e&&(f=e,e=a),h=function(a){var b=sb().utc().set(d,a);return i.call(sb._locale,b,e||"")},null!=f)return h(f);for(g=0;c>g;g++)j.push(h(g));return j}}function A(a){var b=+a,c=0;return 0!==b&&isFinite(b)&&(c=b>=0?Math.floor(b):Math.ceil(b)),c}function B(a,b){return new Date(Date.UTC(a,b+1,0)).getUTCDate()}function C(a,b,c){return gb(sb([a,11,31+b-c]),b,c).week}function D(a){return E(a)?366:365}function E(a){return a%4===0&&a%100!==0||a%400===0}function F(a){var b;a._a&&-2===a._pf.overflow&&(b=a._a[Ab]<0||a._a[Ab]>11?Ab:a._a[Bb]<1||a._a[Bb]>B(a._a[zb],a._a[Ab])?Bb:a._a[Cb]<0||a._a[Cb]>23?Cb:a._a[Db]<0||a._a[Db]>59?Db:a._a[Eb]<0||a._a[Eb]>59?Eb:a._a[Fb]<0||a._a[Fb]>999?Fb:-1,a._pf._overflowDayOfYear&&(zb>b||b>Bb)&&(b=Bb),a._pf.overflow=b)}function G(a){return null==a._isValid&&(a._isValid=!isNaN(a._d.getTime())&&a._pf.overflow<0&&!a._pf.empty&&!a._pf.invalidMonth&&!a._pf.nullInput&&!a._pf.invalidFormat&&!a._pf.userInvalidated,a._strict&&(a._isValid=a._isValid&&0===a._pf.charsLeftOver&&0===a._pf.unusedTokens.length)),a._isValid}function H(a){return a?a.toLowerCase().replace("_","-"):a}function I(a){for(var b,c,d,e,f=0;f<a.length;){for(e=H(a[f]).split("-"),b=e.length,c=H(a[f+1]),c=c?c.split("-"):null;b>0;){if(d=J(e.slice(0,b).join("-")))return d;if(c&&c.length>=b&&w(e,c,!0)>=b-1)break;b--}f++}return null}function J(a){var b=null;if(!Gb[a]&&Ib)try{b=sb.locale(),require("./locale/"+a),sb.locale(b)}catch(c){}return Gb[a]}function K(a,b){return b._isUTC?sb(a).zone(b._offset||0):sb(a).local()}function L(a){return a.match(/\[[\s\S]/)?a.replace(/^\[|\]$/g,""):a.replace(/\\/g,"")}function M(a){var b,c,d=a.match(Mb);for(b=0,c=d.length;c>b;b++)d[b]=oc[d[b]]?oc[d[b]]:L(d[b]);return function(e){var f="";for(b=0;c>b;b++)f+=d[b]instanceof Function?d[b].call(e,a):d[b];return f}}function N(a,b){return a.isValid()?(b=O(b,a.localeData()),kc[b]||(kc[b]=M(b)),kc[b](a)):a.localeData().invalidDate()}function O(a,b){function c(a){return b.longDateFormat(a)||a}var d=5;for(Nb.lastIndex=0;d>=0&&Nb.test(a);)a=a.replace(Nb,c),Nb.lastIndex=0,d-=1;return a}function P(a,b){var c,d=b._strict;switch(a){case"Q":return Yb;case"DDDD":return $b;case"YYYY":case"GGGG":case"gggg":return d?_b:Qb;case"Y":case"G":case"g":return bc;case"YYYYYY":case"YYYYY":case"GGGGG":case"ggggg":return d?ac:Rb;case"S":if(d)return Yb;case"SS":if(d)return Zb;case"SSS":if(d)return $b;case"DDD":return Pb;case"MMM":case"MMMM":case"dd":case"ddd":case"dddd":return Tb;case"a":case"A":return b._locale._meridiemParse;case"X":return Wb;case"Z":case"ZZ":return Ub;case"T":return Vb;case"SSSS":return Sb;case"MM":case"DD":case"YY":case"GG":case"gg":case"HH":case"hh":case"mm":case"ss":case"ww":case"WW":return d?Zb:Ob;case"M":case"D":case"d":case"H":case"h":case"m":case"s":case"w":case"W":case"e":case"E":return Ob;case"Do":return Xb;default:return c=new RegExp(Y(X(a.replace("\\","")),"i"))}}function Q(a){a=a||"";var b=a.match(Ub)||[],c=b[b.length-1]||[],d=(c+"").match(gc)||["-",0,0],e=+(60*d[1])+A(d[2]);return"+"===d[0]?-e:e}function R(a,b,c){var d,e=c._a;switch(a){case"Q":null!=b&&(e[Ab]=3*(A(b)-1));break;case"M":case"MM":null!=b&&(e[Ab]=A(b)-1);break;case"MMM":case"MMMM":d=c._locale.monthsParse(b),null!=d?e[Ab]=d:c._pf.invalidMonth=b;break;case"D":case"DD":null!=b&&(e[Bb]=A(b));break;case"Do":null!=b&&(e[Bb]=A(parseInt(b,10)));break;case"DDD":case"DDDD":null!=b&&(c._dayOfYear=A(b));break;case"YY":e[zb]=sb.parseTwoDigitYear(b);break;case"YYYY":case"YYYYY":case"YYYYYY":e[zb]=A(b);break;case"a":case"A":c._isPm=c._locale.isPM(b);break;case"H":case"HH":case"h":case"hh":e[Cb]=A(b);break;case"m":case"mm":e[Db]=A(b);break;case"s":case"ss":e[Eb]=A(b);break;case"S":case"SS":case"SSS":case"SSSS":e[Fb]=A(1e3*("0."+b));break;case"X":c._d=new Date(1e3*parseFloat(b));break;case"Z":case"ZZ":c._useUTC=!0,c._tzm=Q(b);break;case"dd":case"ddd":case"dddd":d=c._locale.weekdaysParse(b),null!=d?(c._w=c._w||{},c._w.d=d):c._pf.invalidWeekday=b;break;case"w":case"ww":case"W":case"WW":case"d":case"e":case"E":a=a.substr(0,1);case"gggg":case"GGGG":case"GGGGG":a=a.substr(0,2),b&&(c._w=c._w||{},c._w[a]=A(b));break;case"gg":case"GG":c._w=c._w||{},c._w[a]=sb.parseTwoDigitYear(b)}}function S(a){var c,d,e,f,g,h,i;c=a._w,null!=c.GG||null!=c.W||null!=c.E?(g=1,h=4,d=b(c.GG,a._a[zb],gb(sb(),1,4).year),e=b(c.W,1),f=b(c.E,1)):(g=a._locale._week.dow,h=a._locale._week.doy,d=b(c.gg,a._a[zb],gb(sb(),g,h).year),e=b(c.w,1),null!=c.d?(f=c.d,g>f&&++e):f=null!=c.e?c.e+g:g),i=hb(d,e,f,h,g),a._a[zb]=i.year,a._dayOfYear=i.dayOfYear}function T(a){var c,d,e,f,g=[];if(!a._d){for(e=V(a),a._w&&null==a._a[Bb]&&null==a._a[Ab]&&S(a),a._dayOfYear&&(f=b(a._a[zb],e[zb]),a._dayOfYear>D(f)&&(a._pf._overflowDayOfYear=!0),d=cb(f,0,a._dayOfYear),a._a[Ab]=d.getUTCMonth(),a._a[Bb]=d.getUTCDate()),c=0;3>c&&null==a._a[c];++c)a._a[c]=g[c]=e[c];for(;7>c;c++)a._a[c]=g[c]=null==a._a[c]?2===c?1:0:a._a[c];a._d=(a._useUTC?cb:bb).apply(null,g),null!=a._tzm&&a._d.setUTCMinutes(a._d.getUTCMinutes()+a._tzm)}}function U(a){var b;a._d||(b=y(a._i),a._a=[b.year,b.month,b.day,b.hour,b.minute,b.second,b.millisecond],T(a))}function V(a){var b=new Date;return a._useUTC?[b.getUTCFullYear(),b.getUTCMonth(),b.getUTCDate()]:[b.getFullYear(),b.getMonth(),b.getDate()]}function W(a){if(a._f===sb.ISO_8601)return void $(a);a._a=[],a._pf.empty=!0;var b,c,d,e,f,g=""+a._i,h=g.length,i=0;for(d=O(a._f,a._locale).match(Mb)||[],b=0;b<d.length;b++)e=d[b],c=(g.match(P(e,a))||[])[0],c&&(f=g.substr(0,g.indexOf(c)),f.length>0&&a._pf.unusedInput.push(f),g=g.slice(g.indexOf(c)+c.length),i+=c.length),oc[e]?(c?a._pf.empty=!1:a._pf.unusedTokens.push(e),R(e,c,a)):a._strict&&!c&&a._pf.unusedTokens.push(e);a._pf.charsLeftOver=h-i,g.length>0&&a._pf.unusedInput.push(g),a._isPm&&a._a[Cb]<12&&(a._a[Cb]+=12),a._isPm===!1&&12===a._a[Cb]&&(a._a[Cb]=0),T(a),F(a)}function X(a){return a.replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g,function(a,b,c,d,e){return b||c||d||e})}function Y(a){return a.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&")}function Z(a){var b,c,e,f,g;if(0===a._f.length)return a._pf.invalidFormat=!0,void(a._d=new Date(0/0));for(f=0;f<a._f.length;f++)g=0,b=n({},a),b._pf=d(),b._f=a._f[f],W(b),G(b)&&(g+=b._pf.charsLeftOver,g+=10*b._pf.unusedTokens.length,b._pf.score=g,(null==e||e>g)&&(e=g,c=b));m(a,c||b)}function $(a){var b,c,d=a._i,e=cc.exec(d);if(e){for(a._pf.iso=!0,b=0,c=ec.length;c>b;b++)if(ec[b][1].exec(d)){a._f=ec[b][0]+(e[6]||" ");break}for(b=0,c=fc.length;c>b;b++)if(fc[b][1].exec(d)){a._f+=fc[b][0];break}d.match(Ub)&&(a._f+="Z"),W(a)}else a._isValid=!1}function _(a){$(a),a._isValid===!1&&(delete a._isValid,sb.createFromInputFallback(a))}function ab(b){var c,d=b._i;d===a?b._d=new Date:v(d)?b._d=new Date(+d):null!==(c=Jb.exec(d))?b._d=new Date(+c[1]):"string"==typeof d?_(b):u(d)?(b._a=d.slice(0),T(b)):"object"==typeof d?U(b):"number"==typeof d?b._d=new Date(d):sb.createFromInputFallback(b)}function bb(a,b,c,d,e,f,g){var h=new Date(a,b,c,d,e,f,g);return 1970>a&&h.setFullYear(a),h}function cb(a){var b=new Date(Date.UTC.apply(null,arguments));return 1970>a&&b.setUTCFullYear(a),b}function db(a,b){if("string"==typeof a)if(isNaN(a)){if(a=b.weekdaysParse(a),"number"!=typeof a)return null}else a=parseInt(a,10);return a}function eb(a,b,c,d,e){return e.relativeTime(b||1,!!c,a,d)}function fb(a,b,c){var d=sb.duration(a).abs(),e=xb(d.as("s")),f=xb(d.as("m")),g=xb(d.as("h")),h=xb(d.as("d")),i=xb(d.as("M")),j=xb(d.as("y")),k=e<lc.s&&["s",e]||1===f&&["m"]||f<lc.m&&["mm",f]||1===g&&["h"]||g<lc.h&&["hh",g]||1===h&&["d"]||h<lc.d&&["dd",h]||1===i&&["M"]||i<lc.M&&["MM",i]||1===j&&["y"]||["yy",j];return k[2]=b,k[3]=+a>0,k[4]=c,eb.apply({},k)}function gb(a,b,c){var d,e=c-b,f=c-a.day();return f>e&&(f-=7),e-7>f&&(f+=7),d=sb(a).add(f,"d"),{week:Math.ceil(d.dayOfYear()/7),year:d.year()}}function hb(a,b,c,d,e){var f,g,h=cb(a,0,1).getUTCDay();return h=0===h?7:h,c=null!=c?c:e,f=e-h+(h>d?7:0)-(e>h?7:0),g=7*(b-1)+(c-e)+f+1,{year:g>0?a:a-1,dayOfYear:g>0?g:D(a-1)+g}}function ib(b){var c=b._i,d=b._f;return b._locale=b._locale||sb.localeData(b._l),null===c||d===a&&""===c?sb.invalid({nullInput:!0}):("string"==typeof c&&(b._i=c=b._locale.preparse(c)),sb.isMoment(c)?new k(c,!0):(d?u(d)?Z(b):W(b):ab(b),new k(b)))}function jb(a,b){var c,d;if(1===b.length&&u(b[0])&&(b=b[0]),!b.length)return sb();for(c=b[0],d=1;d<b.length;++d)b[d][a](c)&&(c=b[d]);return c}function kb(a,b){var c;return"string"==typeof b&&(b=a.localeData().monthsParse(b),"number"!=typeof b)?a:(c=Math.min(a.date(),B(a.year(),b)),a._d["set"+(a._isUTC?"UTC":"")+"Month"](b,c),a)}function lb(a,b){return a._d["get"+(a._isUTC?"UTC":"")+b]()}function mb(a,b,c){return"Month"===b?kb(a,c):a._d["set"+(a._isUTC?"UTC":"")+b](c)}function nb(a,b){return function(c){return null!=c?(mb(this,a,c),sb.updateOffset(this,b),this):lb(this,a)}}function ob(a){return 400*a/146097}function pb(a){return 146097*a/400}function qb(a){sb.duration.fn[a]=function(){return this._data[a]}}function rb(a){"undefined"==typeof ender&&(tb=wb.moment,wb.moment=a?f("Accessing Moment through the global scope is deprecated, and will be removed in an upcoming release.",sb):sb)}for(var sb,tb,ub,vb="2.8.2",wb="undefined"!=typeof global?global:this,xb=Math.round,yb=Object.prototype.hasOwnProperty,zb=0,Ab=1,Bb=2,Cb=3,Db=4,Eb=5,Fb=6,Gb={},Hb=[],Ib="undefined"!=typeof module&&module.exports,Jb=/^\/?Date\((\-?\d+)/i,Kb=/(\-)?(?:(\d*)\.)?(\d+)\:(\d+)(?:\:(\d+)\.?(\d{3})?)?/,Lb=/^(-)?P(?:(?:([0-9,.]*)Y)?(?:([0-9,.]*)M)?(?:([0-9,.]*)D)?(?:T(?:([0-9,.]*)H)?(?:([0-9,.]*)M)?(?:([0-9,.]*)S)?)?|([0-9,.]*)W)$/,Mb=/(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Q|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|mm?|ss?|S{1,4}|X|zz?|ZZ?|.)/g,Nb=/(\[[^\[]*\])|(\\)?(LT|LL?L?L?|l{1,4})/g,Ob=/\d\d?/,Pb=/\d{1,3}/,Qb=/\d{1,4}/,Rb=/[+\-]?\d{1,6}/,Sb=/\d+/,Tb=/[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}/i,Ub=/Z|[\+\-]\d\d:?\d\d/gi,Vb=/T/i,Wb=/[\+\-]?\d+(\.\d{1,3})?/,Xb=/\d{1,2}/,Yb=/\d/,Zb=/\d\d/,$b=/\d{3}/,_b=/\d{4}/,ac=/[+-]?\d{6}/,bc=/[+-]?\d+/,cc=/^\s*(?:[+-]\d{6}|\d{4})-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,dc="YYYY-MM-DDTHH:mm:ssZ",ec=[["YYYYYY-MM-DD",/[+-]\d{6}-\d{2}-\d{2}/],["YYYY-MM-DD",/\d{4}-\d{2}-\d{2}/],["GGGG-[W]WW-E",/\d{4}-W\d{2}-\d/],["GGGG-[W]WW",/\d{4}-W\d{2}/],["YYYY-DDD",/\d{4}-\d{3}/]],fc=[["HH:mm:ss.SSSS",/(T| )\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss",/(T| )\d\d:\d\d:\d\d/],["HH:mm",/(T| )\d\d:\d\d/],["HH",/(T| )\d\d/]],gc=/([\+\-]|\d\d)/gi,hc=("Date|Hours|Minutes|Seconds|Milliseconds".split("|"),{Milliseconds:1,Seconds:1e3,Minutes:6e4,Hours:36e5,Days:864e5,Months:2592e6,Years:31536e6}),ic={ms:"millisecond",s:"second",m:"minute",h:"hour",d:"day",D:"date",w:"week",W:"isoWeek",M:"month",Q:"quarter",y:"year",DDD:"dayOfYear",e:"weekday",E:"isoWeekday",gg:"weekYear",GG:"isoWeekYear"},jc={dayofyear:"dayOfYear",isoweekday:"isoWeekday",isoweek:"isoWeek",weekyear:"weekYear",isoweekyear:"isoWeekYear"},kc={},lc={s:45,m:45,h:22,d:26,M:11},mc="DDD w W M D d".split(" "),nc="M D H h m s w W".split(" "),oc={M:function(){return this.month()+1},MMM:function(a){return this.localeData().monthsShort(this,a)},MMMM:function(a){return this.localeData().months(this,a)},D:function(){return this.date()},DDD:function(){return this.dayOfYear()},d:function(){return this.day()},dd:function(a){return this.localeData().weekdaysMin(this,a)},ddd:function(a){return this.localeData().weekdaysShort(this,a)},dddd:function(a){return this.localeData().weekdays(this,a)},w:function(){return this.week()},W:function(){return this.isoWeek()},YY:function(){return p(this.year()%100,2)},YYYY:function(){return p(this.year(),4)},YYYYY:function(){return p(this.year(),5)},YYYYYY:function(){var a=this.year(),b=a>=0?"+":"-";return b+p(Math.abs(a),6)},gg:function(){return p(this.weekYear()%100,2)},gggg:function(){return p(this.weekYear(),4)},ggggg:function(){return p(this.weekYear(),5)},GG:function(){return p(this.isoWeekYear()%100,2)},GGGG:function(){return p(this.isoWeekYear(),4)},GGGGG:function(){return p(this.isoWeekYear(),5)},e:function(){return this.weekday()},E:function(){return this.isoWeekday()},a:function(){return this.localeData().meridiem(this.hours(),this.minutes(),!0)},A:function(){return this.localeData().meridiem(this.hours(),this.minutes(),!1)},H:function(){return this.hours()},h:function(){return this.hours()%12||12},m:function(){return this.minutes()},s:function(){return this.seconds()},S:function(){return A(this.milliseconds()/100)},SS:function(){return p(A(this.milliseconds()/10),2)},SSS:function(){return p(this.milliseconds(),3)},SSSS:function(){return p(this.milliseconds(),3)},Z:function(){var a=-this.zone(),b="+";return 0>a&&(a=-a,b="-"),b+p(A(a/60),2)+":"+p(A(a)%60,2)},ZZ:function(){var a=-this.zone(),b="+";return 0>a&&(a=-a,b="-"),b+p(A(a/60),2)+p(A(a)%60,2)},z:function(){return this.zoneAbbr()},zz:function(){return this.zoneName()},X:function(){return this.unix()},Q:function(){return this.quarter()}},pc={},qc=["months","monthsShort","weekdays","weekdaysShort","weekdaysMin"];mc.length;)ub=mc.pop(),oc[ub+"o"]=i(oc[ub],ub);for(;nc.length;)ub=nc.pop(),oc[ub+ub]=h(oc[ub],2);oc.DDDD=h(oc.DDD,3),m(j.prototype,{set:function(a){var b,c;for(c in a)b=a[c],"function"==typeof b?this[c]=b:this["_"+c]=b},_months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),months:function(a){return this._months[a.month()]},_monthsShort:"Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"),monthsShort:function(a){return this._monthsShort[a.month()]},monthsParse:function(a){var b,c,d;for(this._monthsParse||(this._monthsParse=[]),b=0;12>b;b++)if(this._monthsParse[b]||(c=sb.utc([2e3,b]),d="^"+this.months(c,"")+"|^"+this.monthsShort(c,""),this._monthsParse[b]=new RegExp(d.replace(".",""),"i")),this._monthsParse[b].test(a))return b},_weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),weekdays:function(a){return this._weekdays[a.day()]},_weekdaysShort:"Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),weekdaysShort:function(a){return this._weekdaysShort[a.day()]},_weekdaysMin:"Su_Mo_Tu_We_Th_Fr_Sa".split("_"),weekdaysMin:function(a){return this._weekdaysMin[a.day()]},weekdaysParse:function(a){var b,c,d;for(this._weekdaysParse||(this._weekdaysParse=[]),b=0;7>b;b++)if(this._weekdaysParse[b]||(c=sb([2e3,1]).day(b),d="^"+this.weekdays(c,"")+"|^"+this.weekdaysShort(c,"")+"|^"+this.weekdaysMin(c,""),this._weekdaysParse[b]=new RegExp(d.replace(".",""),"i")),this._weekdaysParse[b].test(a))return b},_longDateFormat:{LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY LT",LLLL:"dddd, MMMM D, YYYY LT"},longDateFormat:function(a){var b=this._longDateFormat[a];return!b&&this._longDateFormat[a.toUpperCase()]&&(b=this._longDateFormat[a.toUpperCase()].replace(/MMMM|MM|DD|dddd/g,function(a){return a.slice(1)}),this._longDateFormat[a]=b),b},isPM:function(a){return"p"===(a+"").toLowerCase().charAt(0)},_meridiemParse:/[ap]\.?m?\.?/i,meridiem:function(a,b,c){return a>11?c?"pm":"PM":c?"am":"AM"},_calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},calendar:function(a,b){var c=this._calendar[a];return"function"==typeof c?c.apply(b):c},_relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},relativeTime:function(a,b,c,d){var e=this._relativeTime[c];return"function"==typeof e?e(a,b,c,d):e.replace(/%d/i,a)},pastFuture:function(a,b){var c=this._relativeTime[a>0?"future":"past"];return"function"==typeof c?c(b):c.replace(/%s/i,b)},ordinal:function(a){return this._ordinal.replace("%d",a)},_ordinal:"%d",preparse:function(a){return a},postformat:function(a){return a},week:function(a){return gb(a,this._week.dow,this._week.doy).week},_week:{dow:0,doy:6},_invalidDate:"Invalid date",invalidDate:function(){return this._invalidDate}}),sb=function(b,c,e,f){var g;return"boolean"==typeof e&&(f=e,e=a),g={},g._isAMomentObject=!0,g._i=b,g._f=c,g._l=e,g._strict=f,g._isUTC=!1,g._pf=d(),ib(g)},sb.suppressDeprecationWarnings=!1,sb.createFromInputFallback=f("moment construction falls back to js Date. This is discouraged and will be removed in upcoming major release. Please refer to https://github.com/moment/moment/issues/1407 for more info.",function(a){a._d=new Date(a._i)}),sb.min=function(){var a=[].slice.call(arguments,0);return jb("isBefore",a)},sb.max=function(){var a=[].slice.call(arguments,0);return jb("isAfter",a)},sb.utc=function(b,c,e,f){var g;return"boolean"==typeof e&&(f=e,e=a),g={},g._isAMomentObject=!0,g._useUTC=!0,g._isUTC=!0,g._l=e,g._i=b,g._f=c,g._strict=f,g._pf=d(),ib(g).utc()},sb.unix=function(a){return sb(1e3*a)},sb.duration=function(a,b){var d,e,f,g,h=a,i=null;return sb.isDuration(a)?h={ms:a._milliseconds,d:a._days,M:a._months}:"number"==typeof a?(h={},b?h[b]=a:h.milliseconds=a):(i=Kb.exec(a))?(d="-"===i[1]?-1:1,h={y:0,d:A(i[Bb])*d,h:A(i[Cb])*d,m:A(i[Db])*d,s:A(i[Eb])*d,ms:A(i[Fb])*d}):(i=Lb.exec(a))?(d="-"===i[1]?-1:1,f=function(a){var b=a&&parseFloat(a.replace(",","."));return(isNaN(b)?0:b)*d},h={y:f(i[2]),M:f(i[3]),d:f(i[4]),h:f(i[5]),m:f(i[6]),s:f(i[7]),w:f(i[8])}):"object"==typeof h&&("from"in h||"to"in h)&&(g=r(sb(h.from),sb(h.to)),h={},h.ms=g.milliseconds,h.M=g.months),e=new l(h),sb.isDuration(a)&&c(a,"_locale")&&(e._locale=a._locale),e},sb.version=vb,sb.defaultFormat=dc,sb.ISO_8601=function(){},sb.momentProperties=Hb,sb.updateOffset=function(){},sb.relativeTimeThreshold=function(b,c){return lc[b]===a?!1:c===a?lc[b]:(lc[b]=c,!0)},sb.lang=f("moment.lang is deprecated. Use moment.locale instead.",function(a,b){return sb.locale(a,b)}),sb.locale=function(a,b){var c;return a&&(c="undefined"!=typeof b?sb.defineLocale(a,b):sb.localeData(a),c&&(sb.duration._locale=sb._locale=c)),sb._locale._abbr},sb.defineLocale=function(a,b){return null!==b?(b.abbr=a,Gb[a]||(Gb[a]=new j),Gb[a].set(b),sb.locale(a),Gb[a]):(delete Gb[a],null)},sb.langData=f("moment.langData is deprecated. Use moment.localeData instead.",function(a){return sb.localeData(a)}),sb.localeData=function(a){var b;if(a&&a._locale&&a._locale._abbr&&(a=a._locale._abbr),!a)return sb._locale;if(!u(a)){if(b=J(a))return b;a=[a]}return I(a)},sb.isMoment=function(a){return a instanceof k||null!=a&&c(a,"_isAMomentObject")},sb.isDuration=function(a){return a instanceof l};for(ub=qc.length-1;ub>=0;--ub)z(qc[ub]);sb.normalizeUnits=function(a){return x(a)},sb.invalid=function(a){var b=sb.utc(0/0);return null!=a?m(b._pf,a):b._pf.userInvalidated=!0,b},sb.parseZone=function(){return sb.apply(null,arguments).parseZone()},sb.parseTwoDigitYear=function(a){return A(a)+(A(a)>68?1900:2e3)},m(sb.fn=k.prototype,{clone:function(){return sb(this)},valueOf:function(){return+this._d+6e4*(this._offset||0)},unix:function(){return Math.floor(+this/1e3)},toString:function(){return this.clone().locale("en").format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ")},toDate:function(){return this._offset?new Date(+this):this._d},toISOString:function(){var a=sb(this).utc();return 0<a.year()&&a.year()<=9999?N(a,"YYYY-MM-DD[T]HH:mm:ss.SSS[Z]"):N(a,"YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]")},toArray:function(){var a=this;return[a.year(),a.month(),a.date(),a.hours(),a.minutes(),a.seconds(),a.milliseconds()]},isValid:function(){return G(this)},isDSTShifted:function(){return this._a?this.isValid()&&w(this._a,(this._isUTC?sb.utc(this._a):sb(this._a)).toArray())>0:!1},parsingFlags:function(){return m({},this._pf)},invalidAt:function(){return this._pf.overflow},utc:function(a){return this.zone(0,a)},local:function(a){return this._isUTC&&(this.zone(0,a),this._isUTC=!1,a&&this.add(this._d.getTimezoneOffset(),"m")),this},format:function(a){var b=N(this,a||sb.defaultFormat);return this.localeData().postformat(b)},add:s(1,"add"),subtract:s(-1,"subtract"),diff:function(a,b,c){var d,e,f=K(a,this),g=6e4*(this.zone()-f.zone());return b=x(b),"year"===b||"month"===b?(d=432e5*(this.daysInMonth()+f.daysInMonth()),e=12*(this.year()-f.year())+(this.month()-f.month()),e+=(this-sb(this).startOf("month")-(f-sb(f).startOf("month")))/d,e-=6e4*(this.zone()-sb(this).startOf("month").zone()-(f.zone()-sb(f).startOf("month").zone()))/d,"year"===b&&(e/=12)):(d=this-f,e="second"===b?d/1e3:"minute"===b?d/6e4:"hour"===b?d/36e5:"day"===b?(d-g)/864e5:"week"===b?(d-g)/6048e5:d),c?e:o(e)},from:function(a,b){return sb.duration({to:this,from:a}).locale(this.locale()).humanize(!b)},fromNow:function(a){return this.from(sb(),a)},calendar:function(a){var b=a||sb(),c=K(b,this).startOf("day"),d=this.diff(c,"days",!0),e=-6>d?"sameElse":-1>d?"lastWeek":0>d?"lastDay":1>d?"sameDay":2>d?"nextDay":7>d?"nextWeek":"sameElse";return this.format(this.localeData().calendar(e,this))},isLeapYear:function(){return E(this.year())},isDST:function(){return this.zone()<this.clone().month(0).zone()||this.zone()<this.clone().month(5).zone()},day:function(a){var b=this._isUTC?this._d.getUTCDay():this._d.getDay();return null!=a?(a=db(a,this.localeData()),this.add(a-b,"d")):b},month:nb("Month",!0),startOf:function(a){switch(a=x(a)){case"year":this.month(0);case"quarter":case"month":this.date(1);case"week":case"isoWeek":case"day":this.hours(0);case"hour":this.minutes(0);case"minute":this.seconds(0);case"second":this.milliseconds(0)}return"week"===a?this.weekday(0):"isoWeek"===a&&this.isoWeekday(1),"quarter"===a&&this.month(3*Math.floor(this.month()/3)),this},endOf:function(a){return a=x(a),this.startOf(a).add(1,"isoWeek"===a?"week":a).subtract(1,"ms")},isAfter:function(a,b){return b="undefined"!=typeof b?b:"millisecond",+this.clone().startOf(b)>+sb(a).startOf(b)},isBefore:function(a,b){return b="undefined"!=typeof b?b:"millisecond",+this.clone().startOf(b)<+sb(a).startOf(b)},isSame:function(a,b){return b=b||"ms",+this.clone().startOf(b)===+K(a,this).startOf(b)},min:f("moment().min is deprecated, use moment.min instead. https://github.com/moment/moment/issues/1548",function(a){return a=sb.apply(null,arguments),this>a?this:a}),max:f("moment().max is deprecated, use moment.max instead. https://github.com/moment/moment/issues/1548",function(a){return a=sb.apply(null,arguments),a>this?this:a}),zone:function(a,b){var c,d=this._offset||0;return null==a?this._isUTC?d:this._d.getTimezoneOffset():("string"==typeof a&&(a=Q(a)),Math.abs(a)<16&&(a=60*a),!this._isUTC&&b&&(c=this._d.getTimezoneOffset()),this._offset=a,this._isUTC=!0,null!=c&&this.subtract(c,"m"),d!==a&&(!b||this._changeInProgress?t(this,sb.duration(d-a,"m"),1,!1):this._changeInProgress||(this._changeInProgress=!0,sb.updateOffset(this,!0),this._changeInProgress=null)),this)},zoneAbbr:function(){return this._isUTC?"UTC":""},zoneName:function(){return this._isUTC?"Coordinated Universal Time":""},parseZone:function(){return this._tzm?this.zone(this._tzm):"string"==typeof this._i&&this.zone(this._i),this},hasAlignedHourOffset:function(a){return a=a?sb(a).zone():0,(this.zone()-a)%60===0},daysInMonth:function(){return B(this.year(),this.month())},dayOfYear:function(a){var b=xb((sb(this).startOf("day")-sb(this).startOf("year"))/864e5)+1;return null==a?b:this.add(a-b,"d")},quarter:function(a){return null==a?Math.ceil((this.month()+1)/3):this.month(3*(a-1)+this.month()%3)},weekYear:function(a){var b=gb(this,this.localeData()._week.dow,this.localeData()._week.doy).year;return null==a?b:this.add(a-b,"y")},isoWeekYear:function(a){var b=gb(this,1,4).year;return null==a?b:this.add(a-b,"y")},week:function(a){var b=this.localeData().week(this);return null==a?b:this.add(7*(a-b),"d")},isoWeek:function(a){var b=gb(this,1,4).week;return null==a?b:this.add(7*(a-b),"d")},weekday:function(a){var b=(this.day()+7-this.localeData()._week.dow)%7;return null==a?b:this.add(a-b,"d")},isoWeekday:function(a){return null==a?this.day()||7:this.day(this.day()%7?a:a-7)},isoWeeksInYear:function(){return C(this.year(),1,4)},weeksInYear:function(){var a=this.localeData()._week;return C(this.year(),a.dow,a.doy)},get:function(a){return a=x(a),this[a]()},set:function(a,b){return a=x(a),"function"==typeof this[a]&&this[a](b),this},locale:function(b){return b===a?this._locale._abbr:(this._locale=sb.localeData(b),this)},lang:f("moment().lang() is deprecated. Use moment().localeData() instead.",function(b){return b===a?this.localeData():(this._locale=sb.localeData(b),this)}),localeData:function(){return this._locale}}),sb.fn.millisecond=sb.fn.milliseconds=nb("Milliseconds",!1),sb.fn.second=sb.fn.seconds=nb("Seconds",!1),sb.fn.minute=sb.fn.minutes=nb("Minutes",!1),sb.fn.hour=sb.fn.hours=nb("Hours",!0),sb.fn.date=nb("Date",!0),sb.fn.dates=f("dates accessor is deprecated. Use date instead.",nb("Date",!0)),sb.fn.year=nb("FullYear",!0),sb.fn.years=f("years accessor is deprecated. Use year instead.",nb("FullYear",!0)),sb.fn.days=sb.fn.day,sb.fn.months=sb.fn.month,sb.fn.weeks=sb.fn.week,sb.fn.isoWeeks=sb.fn.isoWeek,sb.fn.quarters=sb.fn.quarter,sb.fn.toJSON=sb.fn.toISOString,m(sb.duration.fn=l.prototype,{_bubble:function(){var a,b,c,d=this._milliseconds,e=this._days,f=this._months,g=this._data,h=0;g.milliseconds=d%1e3,a=o(d/1e3),g.seconds=a%60,b=o(a/60),g.minutes=b%60,c=o(b/60),g.hours=c%24,e+=o(c/24),h=o(ob(e)),e-=o(pb(h)),f+=o(e/30),e%=30,h+=o(f/12),f%=12,g.days=e,g.months=f,g.years=h},abs:function(){return this._milliseconds=Math.abs(this._milliseconds),this._days=Math.abs(this._days),this._months=Math.abs(this._months),this._data.milliseconds=Math.abs(this._data.milliseconds),this._data.seconds=Math.abs(this._data.seconds),this._data.minutes=Math.abs(this._data.minutes),this._data.hours=Math.abs(this._data.hours),this._data.months=Math.abs(this._data.months),this._data.years=Math.abs(this._data.years),this},weeks:function(){return o(this.days()/7)},valueOf:function(){return this._milliseconds+864e5*this._days+this._months%12*2592e6+31536e6*A(this._months/12)},humanize:function(a){var b=fb(this,!a,this.localeData());return a&&(b=this.localeData().pastFuture(+this,b)),this.localeData().postformat(b)},add:function(a,b){var c=sb.duration(a,b);return this._milliseconds+=c._milliseconds,this._days+=c._days,this._months+=c._months,this._bubble(),this},subtract:function(a,b){var c=sb.duration(a,b);return this._milliseconds-=c._milliseconds,this._days-=c._days,this._months-=c._months,this._bubble(),this},get:function(a){return a=x(a),this[a.toLowerCase()+"s"]()},as:function(a){var b,c;if(a=x(a),b=this._days+this._milliseconds/864e5,"month"===a||"year"===a)return c=this._months+12*ob(b),"month"===a?c:c/12;switch(b+=pb(this._months/12),a){case"week":return b/7;case"day":return b;case"hour":return 24*b;case"minute":return 24*b*60;case"second":return 24*b*60*60;case"millisecond":return 24*b*60*60*1e3;default:throw new Error("Unknown unit "+a)}},lang:sb.fn.lang,locale:sb.fn.locale,toIsoString:f("toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)",function(){return this.toISOString()}),toISOString:function(){var a=Math.abs(this.years()),b=Math.abs(this.months()),c=Math.abs(this.days()),d=Math.abs(this.hours()),e=Math.abs(this.minutes()),f=Math.abs(this.seconds()+this.milliseconds()/1e3);return this.asSeconds()?(this.asSeconds()<0?"-":"")+"P"+(a?a+"Y":"")+(b?b+"M":"")+(c?c+"D":"")+(d||e||f?"T":"")+(d?d+"H":"")+(e?e+"M":"")+(f?f+"S":""):"P0D"},localeData:function(){return this._locale}}),sb.duration.fn.toString=sb.duration.fn.toISOString;for(ub in hc)c(hc,ub)&&qb(ub.toLowerCase());sb.duration.fn.asMilliseconds=function(){return this.as("ms")},sb.duration.fn.asSeconds=function(){return this.as("s")},sb.duration.fn.asMinutes=function(){return this.as("m")},sb.duration.fn.asHours=function(){return this.as("h")},sb.duration.fn.asDays=function(){return this.as("d")},sb.duration.fn.asWeeks=function(){return this.as("weeks")},sb.duration.fn.asMonths=function(){return this.as("M")},sb.duration.fn.asYears=function(){return this.as("y")},sb.locale("en",{ordinal:function(a){var b=a%10,c=1===A(a%100/10)?"th":1===b?"st":2===b?"nd":3===b?"rd":"th";return a+c}}),Ib?module.exports=sb:"function"==typeof define&&define.amd?(define("moment",function(a,b,c){return c.config&&c.config()&&c.config().noGlobal===!0&&(wb.moment=tb),sb}),rb(!0)):rb()}).call(this); \ No newline at end of file
diff --git a/extensions/Review/web/js/review.js b/extensions/Review/web/js/review.js
new file mode 100644
index 000000000..08ae29547
--- /dev/null
+++ b/extensions/Review/web/js/review.js
@@ -0,0 +1,210 @@
+/* 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. */
+
+var Dom = YAHOO.util.Dom;
+var Event = YAHOO.util.Event;
+
+var REVIEW = {
+ widget: false,
+ target: false,
+ fields: [],
+ use_error_for: false,
+ ispatch_override: false,
+ description_override: false,
+
+ init_review_flag: function(fid, flag_name) {
+ var idx = this.fields.push({ 'fid': fid, 'flag_name': flag_name, 'component': '' }) - 1;
+ this.flag_change(false, idx);
+ Event.addListener(fid, 'change', this.flag_change, idx);
+ },
+
+ init_mandatory: function() {
+ var form = this.find_form();
+ if (!form) return;
+ Event.addListener(form, 'submit', this.check_mandatory);
+ for (var i = 0; i < this.fields.length; i++) {
+ var field = this.fields[i];
+ // existing reviews that have empty requestee shouldn't force a
+ // reviewer to be selected
+ field.old_empty_review = Dom.get(field.fid).value == '?'
+ && Dom.get(field.flag_name).value == '';
+ if (!field.old_empty_review)
+ Dom.addClass(field.flag_name, 'required');
+ }
+ },
+
+ init_enter_bug: function() {
+ Event.addListener('component', 'change', REVIEW.component_change);
+ BUGZILLA.string['reviewer_required'] = 'A reviewer is required.';
+ this.use_error_for = true;
+ this.init_create_attachment();
+ },
+
+ init_create_attachment: function() {
+ Event.addListener('data', 'change', REVIEW.attachment_change);
+ Event.addListener('description', 'change', REVIEW.description_change);
+ Event.addListener('ispatch', 'change', REVIEW.ispatch_change);
+ },
+
+ component_change: function() {
+ for (var i = 0; i < REVIEW.fields.length; i++) {
+ REVIEW.flag_change(false, i);
+ }
+ },
+
+ attachment_change: function() {
+ var filename = Dom.get('data').value.split('/').pop().split('\\').pop();
+ var description = Dom.get('description');
+ if (description.value == '' || !REVIEW.description_override) {
+ description.value = filename;
+ }
+ if (!REVIEW.ispatch_override) {
+ Dom.get('ispatch').checked =
+ REVIEW.endsWith(filename, '.diff') || REVIEW.endsWith(filename, '.patch');
+ }
+ setContentTypeDisabledState(this.form);
+ description.select();
+ description.focus();
+ },
+
+ description_change: function() {
+ REVIEW.description_override = true;
+ },
+
+ ispatch_change: function() {
+ REVIEW.ispatch_override = true;
+ },
+
+ flag_change: function(e, field_idx) {
+ var field = REVIEW.fields[field_idx];
+ var suggestions_span = Dom.get(field.fid + '_suggestions');
+
+ // for requests only
+ if (Dom.get(field.fid).value != '?') {
+ Dom.addClass(suggestions_span, 'bz_default_hidden');
+ return;
+ }
+
+ // find selected component
+ var component = static_component || Dom.get('component').value;
+ if (!component) {
+ Dom.addClass(suggestions_span, 'bz_default_hidden');
+ return;
+ }
+
+ // init menu and events
+ if (!field.menu) {
+ field.menu = new YAHOO.widget.Menu(field.fid + '_menu');
+ field.menu.render(document.body);
+ field.menu.subscribe('click', REVIEW.suggestion_click);
+ Event.addListener(field.fid + '_suggestions_link', 'click', REVIEW.suggestions_click, field_idx)
+ }
+
+ // build review list
+ if (field.component != component) {
+ field.menu.clearContent();
+ for (var i = 0, il = review_suggestions._mentors.length; i < il; i++) {
+ REVIEW.add_menu_item(field_idx, review_suggestions._mentors[i], true);
+ }
+ if (review_suggestions[component] && review_suggestions[component].length) {
+ REVIEW.add_menu_items(field_idx, review_suggestions[component]);
+ } else if (review_suggestions._product) {
+ REVIEW.add_menu_items(field_idx, review_suggestions._product);
+ }
+ field.menu.render();
+ field.component = component;
+ }
+
+ // show (or hide) the menu
+ if (field.menu.getItem(0)) {
+ Dom.removeClass(suggestions_span, 'bz_default_hidden');
+ } else {
+ Dom.addClass(suggestions_span, 'bz_default_hidden');
+ }
+ },
+
+ add_menu_item: function(field_idx, user, is_mentor) {
+ var menu = REVIEW.fields[field_idx].menu;
+ var items = menu.getItems();
+ for (var i = 0, il = items.length; i < il; i++) {
+ if (items[i].cfg.config.url.value == '#' + user.login) {
+ return;
+ }
+ }
+ var queue = '';
+ if (user.review_count == 0) {
+ queue = 'empty queue';
+ } else {
+ queue = user.review_count + ' review' + (user.review_count == 1 ? '' : 's') + ' in queue';
+ }
+ var item = menu.addItem(
+ { text: user.identity + ' (' + queue + ')', url: '#' + user.login }
+ );
+ if (is_mentor)
+ item.cfg.setProperty('classname', 'mentor');
+ },
+
+ add_menu_items: function(field_idx, users) {
+ for (var i = 0; i < users.length; i++) {
+ if (!review_suggestions._mentor
+ || users[i].login != review_suggestions._mentor.login)
+ {
+ REVIEW.add_menu_item(field_idx, users[i]);
+ }
+ }
+ },
+
+ suggestions_click: function(e, field_idx) {
+ var field = REVIEW.fields[field_idx];
+ field.menu.cfg.setProperty('xy', Event.getXY(e));
+ field.menu.show();
+ Event.stopEvent(e);
+ REVIEW.target = field.flag_name;
+ },
+
+ suggestion_click: function(type, args) {
+ if (args[1]) {
+ Dom.get(REVIEW.target).value = decodeURIComponent(args[1].cfg.getProperty('url')).substr(1);
+ }
+ Event.stopEvent(args[0]);
+ },
+
+ check_mandatory: function(e) {
+ if (Dom.get('data') && !Dom.get('data').value
+ && Dom.get('attach_text') && !Dom.get('attach_text').value)
+ {
+ return;
+ }
+ for (var i = 0; i < REVIEW.fields.length; i++) {
+ var field = REVIEW.fields[i];
+ if (!field.old_empty_review
+ && Dom.get(field.fid).value == '?'
+ && Dom.get(field.flag_name).value == '')
+ {
+ if (REVIEW.use_error_for) {
+ _errorFor(Dom.get(REVIEW.fields[i].flag_name), 'reviewer');
+ } else {
+ alert('You must provide a reviewer for review requests.');
+ }
+ Event.stopEvent(e);
+ }
+ }
+ },
+
+ find_form: function() {
+ for (var i = 0; i < document.forms.length; i++) {
+ var action = document.forms[i].getAttribute('action');
+ if (action == 'attachment.cgi' || action == 'post_bug.cgi')
+ return document.forms[i];
+ }
+ return false;
+ },
+
+ endsWith: function(str, suffix) {
+ return str.indexOf(suffix, str.length - suffix.length) !== -1;
+ }
+};
diff --git a/extensions/Review/web/js/review_history.js b/extensions/Review/web/js/review_history.js
new file mode 100644
index 000000000..ea35edf26
--- /dev/null
+++ b/extensions/Review/web/js/review_history.js
@@ -0,0 +1,384 @@
+/* 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. */
+
+(function () {
+ 'use strict';
+
+ YUI.add('bz-review-history', function (Y) {
+ function format_duration(o) {
+ if (o.value) {
+ if (o.value < 0) {
+ return "???";
+ } else {
+ return moment.duration(o.value).humanize();
+ }
+ }
+ else {
+ return "---";
+ }
+ }
+
+ function format_attachment(o) {
+ if (o.value) {
+ return o.value.description;
+ }
+ }
+
+ function format_action(o) {
+ return o.value;
+ }
+
+ function format_setter(o) {
+ return o.value.real_name ? o.value.real_name + " <" + o.value.name + ">" : o.value.name;
+ }
+
+ function format_date(o) {
+ return o.value && Y.DataType.Date.format(o.value, {
+ format: "%Y-%m-%d"
+ });
+ }
+
+ function parse_date(str) {
+ var parts = str.split(/\D/);
+ return new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], parts[5]);
+ }
+
+ var flagDS, bugDS, attachmentDS, historyTable;
+ flagDS = new Y.DataSource.IO({ source: 'jsonrpc.cgi' });
+ flagDS.plug(Y.Plugin.DataSourceJSONSchema, {
+ schema: {
+ resultListLocator: 'result',
+ resultFields: [
+ { key: 'id' },
+ { key: 'requestee' },
+ { key: 'setter' },
+ { key: 'flag_id' },
+ { key: 'creation_time' },
+ { key: 'status' },
+ { key: 'bug_id' },
+ { key: 'type' },
+ { key: 'attachment_id' }
+ ]
+ }
+ });
+
+ bugDS = new Y.DataSource.IO({ source: 'jsonrpc.cgi' });
+ bugDS.plug(Y.Plugin.DataSourceJSONSchema, {
+ schema: {
+ resultListLocator: 'result.bugs',
+ resultFields: [
+ { key: 'id' },
+ { key: 'summary' }
+ ]
+ }
+ });
+
+ attachmentDS = new Y.DataSource.IO({ source: 'jsonrpc.cgi' });
+ attachmentDS.plug(Y.Plugin.DataSourceJSONSchema, {
+ schema: {
+ metaFields: { 'attachments': 'result.attachments' }
+ }
+ });
+
+ historyTable = new Y.DataTable({
+ columns: [
+ { key: 'creation_time', label: 'Created', sortable: true, formatter: format_date },
+ { key: 'attachment', label: 'Attachment', formatter: format_attachment, allowHTML: true },
+ { key: 'setter', label: 'Requester', formatter: format_setter },
+ { key: "action", label: "Action", sortable: true, allowHTML: true, formatter: format_action },
+ { key: "duration", label: "Duration", sortable: true, formatter: format_duration },
+ { key: "bug_id", label: "Bug", sortable: true, allowHTML: true,
+ formatter: '<a href="show_bug.cgi?id={value}" target="_blank">{value}</a>' },
+ { key: 'bug_summary', label: 'Summary' }
+ ]
+ });
+
+ function fetch_flag_ids(user) {
+ return new Y.Promise(function (resolve, reject) {
+ var flagIdCallback = {
+ success: function (e) {
+ var flags = e.response.results;
+ var flag_ids = flags.filter(function (flag) {
+ return flag.status == '?';
+ })
+ .map(function (flag) {
+ return flag.flag_id;
+ });
+
+ if (flag_ids.length > 0) {
+ resolve(flag_ids);
+ } else {
+ reject("No reviews found");
+ }
+ },
+ failure: function (e) {
+ reject(e.error.message);
+ }
+ };
+
+ flagDS.sendRequest({
+ request: Y.JSON.stringify({
+ version: '1.1',
+ method: 'Review.flag_activity',
+ params: {
+ type_name: 'review',
+ requestee: user,
+ include_fields: ['flag_id', 'status']
+ }
+ }),
+ cfg: {
+ method: "POST",
+ headers: { 'Content-Type': 'application/json' }
+ },
+ callback: flagIdCallback
+ });
+ });
+ }
+
+ function fetch_flags(flag_ids) {
+ return new Y.Promise(function (resolve, reject) {
+ flagDS.sendRequest({
+ request: Y.JSON.stringify({
+ version: '1.1',
+ method: 'Review.flag_activity',
+ params: { flag_ids: flag_ids }
+ }),
+ cfg: {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' }
+ },
+ callback: {
+ success: function (e) {
+ var flags = e.response.results;
+ flags.forEach(function(flag) {
+ flag.creation_time = parse_date(flag.creation_time);
+ });
+ resolve(flags.sort(function (a, b) {
+ if (a.id > b.id) return 1;
+ if (a.id < b.id) return -1;
+ return 0;
+ }));
+ },
+ failure: function (e) {
+ reject(e.error.message);
+ }
+ }
+ });
+ });
+ }
+
+ function fetch_bug_summaries(flags) {
+ return new Y.Promise(function (resolve, reject) {
+ var bug_ids = Y.Array.dedupe(flags.map(function (f) {
+ return f.bug_id;
+ }));
+
+ bugDS.sendRequest({
+ request: Y.JSON.stringify({
+ version: '1.1',
+ method: 'Bug.get',
+ params: { ids: bug_ids, include_fields: ['summary', 'id'] }
+ }),
+ cfg: {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' }
+ },
+ callback: {
+ success: function (e) {
+ var bugs = e.response.results,
+ summary = {};
+
+ bugs.forEach(function (bug) {
+ summary[bug.id] = bug.summary;
+ });
+ flags.forEach(function (flag) {
+ flag.bug_summary = summary[flag.bug_id];
+ });
+ resolve(flags);
+ },
+ failure: function (e) {
+ reject(e.error.message);
+ }
+ }
+ });
+ });
+ }
+
+ function fetch_attachment_descriptions(flags) {
+ return new Y.Promise(function (resolve, reject) {
+ var attachment_ids = Y.Array.dedupe(flags.map(function (f) {
+ return f.attachment_id;
+ }));
+
+ attachmentDS.sendRequest({
+ request: Y.JSON.stringify({
+ version: '1.1',
+ method: 'Bug.attachments',
+ params: {
+ attachment_ids: attachment_ids,
+ include_fields: ['id', 'description']
+ }
+ }),
+ cfg: {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' }
+ },
+ callback: {
+ success: function (e) {
+ var attachments = e.response.meta.attachments;
+ flags.forEach(function (flag) {
+ flag.attachment = attachments[flag.attachment_id];
+ });
+ resolve(flags);
+ },
+ failure: function (e) {
+ reject(e.error.message);
+ }
+ }
+ });
+ });
+ }
+
+ function add_historical_action(history, flag, stash, action) {
+ history.push({
+ attachment: flag.attachment,
+ bug_id: flag.bug_id,
+ bug_summary: flag.bug_summary,
+ creation_time: stash.creation_time,
+ duration: flag.creation_time - stash.creation_time,
+ setter: stash.setter,
+ action: action
+ });
+ }
+
+ function generate_history(flags, user) {
+ var history = [],
+ stash = {},
+ flag, stash_key ;
+
+ flags.forEach(function (flag) {
+ var flag_id = flag.flag_id;
+
+ switch (flag.status) {
+ case '?':
+ // If we get a ? after a + or -, we get a fresh start.
+ if (stash[flag_id] && stash[flag_id].is_complete)
+ delete stash[flag_id];
+
+ // handle untargeted review requests.
+ if (!flag.requestee)
+ flag.requestee = { id: 'the wind', name: 'the wind' };
+
+ if (stash[flag_id]) {
+ // flag was reassigned
+ if (flag.requestee.id != stash[flag_id].requestee.id) {
+ // if ? started out mine, but went to someone else.
+ if (stash[flag_id].requestee.name == user) {
+ add_historical_action(history, flag, stash[flag_id], 'reassigned to ' + flag.requestee.name);
+ stash[flag_id] = flag;
+ }
+ else {
+ // flag changed hands. Reset the creation_time and requestee
+ stash[flag_id].creation_time = flag.creation_time;
+ stash[flag_id].requestee = flag.requestee;
+ }
+ }
+ } else {
+ stash[flag_id] = flag;
+ }
+ break;
+
+ case 'X':
+ if (stash[flag_id]) {
+ // Only process if we did not get a + or a - since
+ if (!stash[flag_id].is_complete) {
+ add_historical_action(history, flag, stash[flag_id], 'cancelled');
+ }
+ delete stash[flag_id];
+ }
+ break;
+
+
+ case '+':
+ case '-':
+ // if we get a + or -, we only accept it if the requestee is the user we're interested in.
+ // we set is_complete to handle cancelations.
+ if (stash[flag_id] && stash[flag_id].requestee.name == user) {
+ add_historical_action(history, flag, stash[flag_id], "review" + flag.status);
+ stash[flag_id].is_complete = true;
+ }
+ break;
+ }
+ });
+
+ for (stash_key in stash) {
+ flag = stash[stash_key];
+ if (flag.is_complete) continue;
+ if (flag.requestee.name != user) continue;
+ history.push({
+ attachment: flag.attachment,
+ bug_id: flag.bug_id,
+ bug_summary: flag.bug_summary,
+ creation_time: flag.creation_time,
+ duration: new Date() - flag.creation_time,
+ setter: flag.setter,
+ action: 'review?'
+ });
+ }
+
+ return history;
+ }
+
+ Y.ReviewHistory = {};
+
+ Y.ReviewHistory.render = function (sel) {
+ Y.one('#history-loading').hide();
+ historyTable.render(sel);
+ historyTable.setAttrs({
+ width: "100%"
+ }, true);
+ };
+
+ Y.ReviewHistory.refresh = function (user, real_name) {
+ var caption = "Review History for " + (real_name ? real_name + ' &lt;' + user + '&gt;' : user);
+ historyTable.setAttrs({
+ caption: caption
+ });
+ historyTable.set('data', null);
+ historyTable.showMessage('Loading...');
+ fetch_flag_ids(user)
+ .then(fetch_flags)
+ .then(fetch_bug_summaries)
+ .then(fetch_attachment_descriptions)
+ .then(function (flags) {
+ return new Y.Promise(function (resolve, reject) {
+ try {
+ resolve(generate_history(flags, user));
+ }
+ catch (e) {
+ reject(e.message);
+ }
+ });
+ })
+ .then(function (history) {
+ historyTable.set('data', history);
+ historyTable.sort({
+ creation_time: 'desc'
+ });
+ }, function (message) {
+ historyTable.showMessage(message);
+ });
+ };
+
+ }, '0.0.1', {
+ requires: [
+ "node", "datatype-date", "datatable", "datatable-sort", "datatable-message", "json-stringify",
+ "datatable-datasource", "datasource-io", "datasource-jsonschema", "cookie",
+ "gallery-datatable-row-expansion-bmo", "handlebars", "escape", "promise"
+ ]
+ });
+}());
diff --git a/extensions/Review/web/styles/badge.css b/extensions/Review/web/styles/badge.css
new file mode 100644
index 000000000..e699b5825
--- /dev/null
+++ b/extensions/Review/web/styles/badge.css
@@ -0,0 +1,16 @@
+/* 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. */
+
+#badge {
+ background-color: #c00;
+ font-size: small;
+ font-weight: bold;
+ padding: 0 5px;
+ border-radius: 10px;
+ margin: 0 5px;
+ color: #fff !important;
+}
diff --git a/extensions/Review/web/styles/reports.css b/extensions/Review/web/styles/reports.css
new file mode 100644
index 000000000..bbbf93559
--- /dev/null
+++ b/extensions/Review/web/styles/reports.css
@@ -0,0 +1,41 @@
+/* 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. */
+
+#report {
+ margin-top: 1em;
+}
+
+.product_name {
+ font-weight: bold;
+ white-space: nowrap;
+}
+
+.product_name a {
+ color: inherit;
+}
+
+.product_name a:hover {
+ color: inherit;
+ text-decoration: none;
+}
+
+.component_name, .other_components {
+ padding: 0 1em;
+ white-space: nowrap;
+}
+
+.component_name:before, .other_components:before {
+ content: "\a0\a0\a0\a0";
+}
+
+.other_components {
+ font-style: italic;
+}
+
+.reviewers {
+ width: 100%;
+}
diff --git a/extensions/Review/web/styles/review.css b/extensions/Review/web/styles/review.css
new file mode 100644
index 000000000..9f5b63603
--- /dev/null
+++ b/extensions/Review/web/styles/review.css
@@ -0,0 +1,10 @@
+/* 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. */
+
+.mentor {
+ font-weight: bold;
+}
diff --git a/extensions/Review/web/styles/review_history.css b/extensions/Review/web/styles/review_history.css
new file mode 100644
index 000000000..b72b2efb2
--- /dev/null
+++ b/extensions/Review/web/styles/review_history.css
@@ -0,0 +1,10 @@
+/* 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. */
+
+.yui3-skin-sam .yui3-datatable-table > table {
+ width: 100%;
+}