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.pm589
-rwxr-xr-xextensions/Review/bin/review_requests_rebuild.pl29
-rw-r--r--extensions/Review/lib/Util.pm63
-rw-r--r--extensions/Review/lib/WebService.pm195
-rw-r--r--extensions/Review/template/en/default/hook/admin/components/edit-common-rows.html.tmpl22
-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-end.html.tmpl16
-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.tmpl90
-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.tmpl18
-rw-r--r--extensions/Review/template/en/default/hook/reports/menu-end.html.tmpl16
-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/review.js210
-rw-r--r--extensions/Review/web/styles/badge.css19
-rw-r--r--extensions/Review/web/styles/reports.css41
-rw-r--r--extensions/Review/web/styles/review.css10
23 files changed, 1565 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..c2951d29b
--- /dev/null
+++ b/extensions/Review/Extension.pm
@@ -0,0 +1,589 @@
+# 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::Util;
+use Bugzilla::Install::Filesystem;
+use Bugzilla::User;
+use Bugzilla::Util qw(clean_text);
+
+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::is_mentor = \&_bug_is_mentor;
+ *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 { $_->name !~ UNAVAILABLE_RE } @reviewers;
+ }
+ $object->{reviewers} = \@reviewers;
+ }
+ return $object->{reviewers};
+}
+
+sub _bug_mentors {
+ my ($self) = @_;
+ # extract the mentors from the status_whiteboard
+ # when the mentors gets its own field, this will be easier
+ if (!exists $self->{mentors}) {
+ my @mentors;
+ my $whiteboard = $self->status_whiteboard;
+ my $logout = 0;
+ while ($whiteboard =~ /\[mentor=([^\]]+)\]/g) {
+ my $mentor_string = $1;
+ my $user;
+ if ($mentor_string =~ /\@/) {
+ # assume it's a full username if it contains an @
+ $user = Bugzilla::User->new({ name => $mentor_string });
+ } else {
+ # otherwise assume it's a : prefixed nick. only works if a
+ # single user matches.
+
+ # we need to be logged in to do user searching
+ if (!Bugzilla->user->id) {
+ Bugzilla->set_user(Bugzilla::User->check({ name => 'nobody@mozilla.org' }));
+ $logout = 1;
+ }
+
+ foreach my $query ("*:$mentor_string*", "*$mentor_string*") {
+ my $matches = Bugzilla::User::match($query, 2);
+ if ($matches && scalar(@$matches) == 1) {
+ $user = $matches->[0];
+ last;
+ }
+ }
+ }
+ push @mentors, $user if $user;
+ }
+ Bugzilla->logout_request() if $logout;
+ $self->{mentors} = \@mentors;
+ }
+ return $self->{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 _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};
+}
+
+#
+# reviewer-required
+#
+
+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);
+ }
+}
+
+#
+# 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') || $object->isa('Bugzilla::Component')) {
+ my ($new, $new_users) = _new_reviewers_from_input();
+ _update_reviewers($object, [], $new_users);
+ }
+ elsif (_is_countable_flag($object) && $object->requestee_id && $object->status eq '?') {
+ _adjust_request_count($object, +1);
+ }
+}
+
+sub object_end_of_update {
+ my ($self, $args) = @_;
+ my ($object, $old_object, $changes) = @$args{qw(object old_object changes)};
+
+ if ($object->isa('Bugzilla::Product') || $object->isa('Bugzilla::Component')) {
+ my ($new, $new_users) = _new_reviewers_from_input();
+ my $old = $old_object->reviewers(1);
+ if ($old ne $new) {
+ _update_reviewers($object, $old_object->reviewers_objs(1), $new_users);
+ $changes->{reviewers} = [ $old ? $old : undef, $new ? $new : undef ];
+ }
+ }
+ 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 object_before_delete {
+ my ($self, $args) = @_;
+ my $object = $args->{object};
+
+ if (_is_countable_flag($object) && $object->requestee_id && $object->status eq '?') {
+ _adjust_request_count($object, -1);
+ }
+}
+
+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 _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
+ );
+}
+
+sub _new_reviewers_from_input {
+ if (!Bugzilla->input_params->{reviewers}) {
+ return ('', []);
+ }
+ Bugzilla::User::match_field({ 'reviewers' => {'type' => 'multi'} });
+ my $new = Bugzilla->input_params->{reviewers};
+ $new = [ $new ] unless ref($new);
+ my $new_users = [];
+ foreach my $login (@$new) {
+ push @$new_users, Bugzilla::User->check($login);
+ }
+ $new = join(', ', @$new);
+ return ($new, $new_users);
+}
+
+sub _update_reviewers {
+ my ($object, $old_users, $new_users) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $type = $object->isa('Bugzilla::Product') ? 'product' : 'component';
+
+ # remove deleted users
+ foreach my $old_user (@$old_users) {
+ if (!grep { $_->id == $old_user->id } @$new_users) {
+ $dbh->do(
+ "DELETE FROM ${type}_reviewers WHERE ${type}_id=? AND user_id=?",
+ undef,
+ $object->id, $old_user->id,
+ );
+ }
+ }
+ # add new users
+ foreach my $new_user (@$new_users) {
+ if (!grep { $_->id == $new_user->id } @$old_users) {
+ $dbh->do(
+ "INSERT INTO ${type}_reviewers(${type}_id,user_id) VALUES (?,?)",
+ undef,
+ $object->id, $new_user->id,
+ );
+ }
+ }
+ # and update the sortkey for all users
+ for (my $i = 0; $i < scalar(@$new_users); $i++) {
+ $dbh->do(
+ "UPDATE ${type}_reviewers SET sortkey=? WHERE ${type}_id=? AND user_id=?",
+ undef,
+ ($i + 1) * 10, $object->id, $new_users->[$i]->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) = @_;
+ 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-$_") ne 'X' } @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');
+ }
+ }
+}
+
+#
+# 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);
+ }
+}
+
+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;
+ }
+}
+
+#
+# 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',
+ },
+ ],
+ };
+
+}
+
+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 }
+ );
+}
+
+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/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/Util.pm b/extensions/Review/lib/Util.pm
new file mode 100644
index 000000000..7304f9ba6
--- /dev/null
+++ b/extensions/Review/lib/Util.pm
@@ -0,0 +1,63 @@
+# 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};
+ $dbh->bz_commit_transaction;
+}
+
+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}
+ );
+}
+
+1;
diff --git a/extensions/Review/lib/WebService.pm b/extensions/Review/lib/WebService.pm
new file mode 100644
index 000000000..4cb3d48d8
--- /dev/null
+++ b/extensions/Review/lib/WebService.pm
@@ -0,0 +1,195 @@
+# 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;
+
+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 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',
+ },
+ },
+ ];
+};
+
+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
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..297b08506
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/admin/components/edit-common-rows.html.tmpl
@@ -0,0 +1,22 @@
+[%# 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)
+ %]
+ </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-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/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..33e899492
--- /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
+%]
+
+<span id="badge" 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 -%]
+">
+ <a href="request.cgi?action=queue&amp;requestee=[% user.login FILTER uri %]&amp;group=type">
+ [%- user.review_request_count + user.feedback_request_count + user.needinfo_request_count ~%]
+ </a>
+</span>
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..0c276ad8c
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/global/header-start.html.tmpl
@@ -0,0 +1,90 @@
+[%# 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' %]
+
+[% 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/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..856ff3c75
--- /dev/null
+++ b/extensions/Review/template/en/default/hook/global/user-error-errors.html.tmpl
@@ -0,0 +1,18 @@
+[%# 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).
+
+[% 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_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/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/styles/badge.css b/extensions/Review/web/styles/badge.css
new file mode 100644
index 000000000..8b0791f12
--- /dev/null
+++ b/extensions/Review/web/styles/badge.css
@@ -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. */
+
+#badge {
+ background-color: #c00;
+ font-size: small;
+ font-weight: bold;
+ padding: 0 5px;
+ border-radius: 10px;
+ margin: 0 5px;
+}
+
+#badge a {
+ 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;
+}