diff options
Diffstat (limited to 'extensions')
643 files changed, 62745 insertions, 146 deletions
diff --git a/extensions/AntiSpam/Config.pm b/extensions/AntiSpam/Config.pm new file mode 100644 index 000000000..92c32f629 --- /dev/null +++ b/extensions/AntiSpam/Config.pm @@ -0,0 +1,21 @@ +# 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::AntiSpam; +use strict; + +use constant NAME => 'AntiSpam'; +use constant REQUIRED_MODULES => [ + { + package => 'Email-Address', + module => 'Email::Address', + version => 0, + }, +]; +use constant OPTIONAL_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/AntiSpam/Extension.pm b/extensions/AntiSpam/Extension.pm new file mode 100644 index 000000000..7a0f27b31 --- /dev/null +++ b/extensions/AntiSpam/Extension.pm @@ -0,0 +1,349 @@ +# 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::AntiSpam; + +use strict; +use warnings; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Error; +use Bugzilla::Group; +use Bugzilla::Util qw(remote_ip trick_taint); +use Email::Address; +use Encode; +use Socket; +use Sys::Syslog qw(:DEFAULT setlogsock); + +our $VERSION = '1'; + +# +# project honeypot integration +# + +sub _project_honeypot_blocking { + my ($self, $api_key, $login) = @_; + my $ip = remote_ip(); + return unless $ip =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/; + my $lookup = "$api_key.$4.$3.$2.$1.dnsbl.httpbl.org"; + return unless my $packed = gethostbyname($lookup); + my $honeypot = inet_ntoa($packed); + return unless $honeypot =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/; + my ($status, $days, $threat, $type) = ($1, $2, $3, $4); + + return if $status != 127 + || $threat < Bugzilla->params->{honeypot_threat_threshold}; + + _syslog(sprintf("[audit] blocked <%s> from creating %s, honeypot %s", $ip, $login, $honeypot)); + ThrowUserError('account_creation_restricted'); +} + +sub config_modify_panels { + my ($self, $args) = @_; + push @{ $args->{panels}->{auth}->{params} }, { + name => 'honeypot_api_key', + type => 't', + default => '', + }; + push @{ $args->{panels}->{auth}->{params} }, { + name => 'honeypot_threat_threshold', + type => 't', + default => '32', + }; +} + +# +# comment blocking +# + +sub _comment_blocking { + my ($self, $params) = @_; + + # as we want to use this sparingly, we only block comments on bugs which + # the user didn't report, and skip it completely if the user is in the + # editbugs group. + my $user = Bugzilla->user; + return if $user->in_group('editbugs'); + # new bug + return unless $params->{bug_id}; + # existing bug + my $bug = ref($params->{bug_id}) + ? $params->{bug_id} + : Bugzilla::Bug->new($params->{bug_id}); + return if $bug->reporter->id == $user->id; + + my $blocklist = Bugzilla->dbh->selectcol_arrayref( + 'SELECT word FROM antispam_comment_blocklist' + ); + return unless @$blocklist; + + my $regex = '\b(?:' . join('|', map { quotemeta } @$blocklist) . ')\b'; + if ($params->{thetext} =~ /$regex/i) { + ThrowUserError('antispam_comment_blocked'); + } +} + +# +# domain blocking +# + +sub _domain_blocking { + my ($self, $login) = @_; + my $address = Email::Address->new(undef, $login); + my $blocked = Bugzilla->dbh->selectrow_array( + "SELECT 1 FROM antispam_domain_blocklist WHERE domain=?", + undef, + $address->host + ); + if ($blocked) { + _syslog(sprintf("[audit] blocked <%s> from creating %s, blacklisted domain", remote_ip(), $login)); + ThrowUserError('account_creation_restricted'); + } +} + +# +# ip blocking +# + +sub _ip_blocking { + my ($self, $login) = @_; + my $ip = remote_ip(); + trick_taint($ip); + my $blocked = Bugzilla->dbh->selectrow_array( + "SELECT 1 FROM antispam_ip_blocklist WHERE ip_address=?", + undef, + $ip + ); + if ($blocked) { + _syslog(sprintf("[audit] blocked <%s> from creating %s, blacklisted IP", $ip, $login)); + ThrowUserError('account_creation_restricted'); + } +} + +# +# spam user disabling +# + +sub comment_after_add_tag { + my ($self, $args) = @_; + my $tag = lc($args->{tag}); + return unless $tag eq 'spam' or $tag eq 'abusive'; + my $comment = $args->{comment}; + my $author = $comment->author; + + # exclude disabled users + return if !$author->is_enabled; + + # exclude users by group + return if $author->in_group(Bugzilla->params->{antispam_spammer_exclude_group}); + + # exclude users who are no longer new + return if !$author->is_new; + + # exclude users who haven't made enough comments + my $count = $tag eq 'spam' + ? Bugzilla->params->{antispam_spammer_comment_count} + : Bugzilla->params->{antispam_abusive_comment_count}; + return if $author->comment_count < $count; + + # get user's comments + trick_taint($tag); + my $comments = Bugzilla->dbh->selectall_arrayref(" + SELECT longdescs.comment_id,longdescs_tags.id + FROM longdescs + LEFT JOIN longdescs_tags + ON longdescs_tags.comment_id = longdescs.comment_id + AND longdescs_tags.tag = ? + WHERE longdescs.who = ? + ORDER BY longdescs.bug_when + ", undef, $tag, $author->id); + + # this comment needs to be counted too + my $comment_id = $comment->id; + foreach my $ra (@$comments) { + if ($ra->[0] == $comment_id) { + $ra->[1] = 1; + last; + } + } + + # throw away comment id and negate bool to make it a list of not-spam/abuse + $comments = [ map { $_->[1] ? 0 : 1 } @$comments ]; + + my $reason; + + # check if the first N comments are spam/abuse + if (!scalar(grep { $_ } @$comments[0..($count - 1)])) { + $reason = "first $count comments are $tag"; + } + + # check if the last N comments are spam/abuse + elsif (!scalar(grep { $_ } @$comments[-$count..-1])) { + $reason = "last $count comments are $tag"; + } + + # disable + if ($reason) { + $author->set_disabledtext( + $tag eq 'spam' + ? Bugzilla->params->{antispam_spammer_disable_text} + : Bugzilla->params->{antispam_abusive_disable_text} + ); + $author->set_disable_mail(1); + $author->update(); + _syslog(sprintf("[audit] antispam disabled <%s>: %s", $author->login, $reason)); + } +} + +# +# hooks +# + +sub object_end_of_create_validators { + my ($self, $args) = @_; + if ($args->{class} eq 'Bugzilla::Comment') { + $self->_comment_blocking($args->{params}); + } +} + +sub user_verify_login { + my ($self, $args) = @_; + if (my $api_key = Bugzilla->params->{honeypot_api_key}) { + $self->_project_honeypot_blocking($api_key, $args->{login}); + } + $self->_ip_blocking($args->{login}); + $self->_domain_blocking($args->{login}); +} + +sub editable_tables { + my ($self, $args) = @_; + my $tables = $args->{tables}; + # allow these tables to be edited with the EditTables extension + $tables->{antispam_domain_blocklist} = { + id_field => 'id', + order_by => 'domain', + blurb => 'List of fully qualified domain names to block at account creation time.', + group => 'can_configure_antispam', + }; + $tables->{antispam_comment_blocklist} = { + id_field => 'id', + order_by => 'word', + blurb => "List of whole words that will cause comments containing \\b\$word\\b to be blocked.\n" . + "This only applies to comments on bugs which the user didn't report.\n" . + "Users in the editbugs group are exempt from comment blocking.", + group => 'can_configure_antispam', + }; + $tables->{antispam_ip_blocklist} = { + id_field => 'id', + order_by => 'ip_address', + blurb => 'List of IPv4 addresses which are prevented from creating accounts.', + group => 'can_configure_antispam', + }; +} + +sub config_add_panels { + my ($self, $args) = @_; + my $modules = $args->{panel_modules}; + $modules->{AntiSpam} = "Bugzilla::Extension::AntiSpam::Config"; +} + +# +# installation +# + +sub install_before_final_checks { + if (!Bugzilla::Group->new({ name => 'can_configure_antispam' })) { + Bugzilla::Group->create({ + name => 'can_configure_antispam', + description => 'Can configure Anti-Spam measures', + isbuggroup => 0, + }); + } +} + +sub db_schema_abstract_schema { + my ($self, $args) = @_; + $args->{'schema'}->{'antispam_domain_blocklist'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + domain => { + TYPE => 'VARCHAR(255)', + NOTNULL => 1, + }, + comment => { + TYPE => 'VARCHAR(255)', + NOTNULL => 1, + }, + ], + INDEXES => [ + antispam_domain_blocklist_idx => { + FIELDS => [ 'domain' ], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'antispam_comment_blocklist'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + word => { + TYPE => 'VARCHAR(255)', + NOTNULL => 1, + }, + ], + INDEXES => [ + antispam_comment_blocklist_idx => { + FIELDS => [ 'word' ], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'antispam_ip_blocklist'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + ip_address => { + TYPE => 'VARCHAR(15)', + NOTNULL => 1, + }, + comment => { + TYPE => 'VARCHAR(255)', + NOTNULL => 1, + }, + ], + INDEXES => [ + antispam_ip_blocklist_idx => { + FIELDS => [ 'ip_address' ], + TYPE => 'UNIQUE', + }, + ], + }; +} + +# +# utilities +# + +sub _syslog { + my $message = shift; + openlog('apache', 'cons,pid', 'local4'); + syslog('notice', encode_utf8($message)); + closelog(); +} + +__PACKAGE__->NAME; diff --git a/extensions/AntiSpam/lib/Config.pm b/extensions/AntiSpam/lib/Config.pm new file mode 100644 index 000000000..c8e1255c2 --- /dev/null +++ b/extensions/AntiSpam/lib/Config.pm @@ -0,0 +1,75 @@ +# 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::AntiSpam::Config; + +use strict; +use warnings; + +use Bugzilla::Config::Common; +use Bugzilla::Group; + +our $sortkey = 511; + +sub get_param_list { + my ($class) = @_; + + my @param_list = ( + { + name => 'antispam_spammer_exclude_group', + type => 's', + choices => \&_get_all_group_names, + default => 'canconfirm', + checker => \&check_group + }, + { + name => 'antispam_spammer_comment_count', + type => 't', + default => '3', + checker => \&check_numeric + }, + { + name => 'antispam_spammer_disable_text', + type => 'l', + default => + "This account has been automatically disabled as a result of " . + "a high number of spam comments.<br>\n<br>\n" . + "Please contact the address at the end of this message if " . + "you believe this to be an error." + }, + { + name => 'antispam_abusive_comment_count', + type => 't', + default => '5', + checker => \&check_numeric + }, + { + name => 'antispam_abusive_disable_text', + type => 'l', + default => + "This account has been automatically disabled as a result of " . + "a high number of comments tagged as abusive.<br>\n<br>\n" . + "All interactions on Bugzilla should follow our " . + "<a href=\"https://bugzilla.mozilla.org/page.cgi?id=etiquette.html\">" . + "etiquette guidelines</a>.<br>\n<br>\n" . + "Please contact the address at the end of this message if you " . + "believe this to be an error, or if you would like your account " . + "reactivated in order to interact within our etiquette " . + "guidelines." + }, + ); + + return @param_list; +} + +sub _get_all_group_names { + my @group_names = map {$_->name} Bugzilla::Group->get_all; + unshift(@group_names, ''); + return \@group_names; +} + +1; diff --git a/extensions/AntiSpam/template/en/default/admin/params/antispam.html.tmpl b/extensions/AntiSpam/template/en/default/admin/params/antispam.html.tmpl new file mode 100644 index 000000000..76299f546 --- /dev/null +++ b/extensions/AntiSpam/template/en/default/admin/params/antispam.html.tmpl @@ -0,0 +1,37 @@ +[%# 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. + #%] + +[% + title = "Anti-Spam" + desc = "Edit Anti-Spam Configuration" +%] + +[% param_descs = +{ + antispam_spammer_exclude_group => + "Users in this group will be excluded from automatic disabling." + + antispam_spammer_comment_count => + "If a user has made at least this many comments, and either their first " _ + "NNN comments or their last NNN comments have been tagged as spam, their " _ + "account will be automatically disabled." + + antispam_spammer_disable_text => + "This message will be displayed to the user when they try to log " _ + "in after their account is disabled due to spam." + + antispam_abusive_comment_count => + "If a user has made at least this many comments, and either their first " _ + "NNN comments or their last NNN comments have been tagged as abusive, their " _ + "account will be automatically disabled." + + antispam_abusive_disable_text => + "This message will be displayed to the user when they try to log " _ + "in after their account is disabled due to abuse." +} +%] diff --git a/extensions/AntiSpam/template/en/default/hook/admin/admin-end_links_right.html.tmpl b/extensions/AntiSpam/template/en/default/hook/admin/admin-end_links_right.html.tmpl new file mode 100644 index 000000000..e55475d98 --- /dev/null +++ b/extensions/AntiSpam/template/en/default/hook/admin/admin-end_links_right.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. + #%] + +[% IF user.in_group('can_configure_antispam') %] + <dt id="antispam" >AntiSpam</dt> + <dd> + <a href="page.cgi?id=edit_table.html&table=antispam_domain_blocklist">Domain Blocklist</a><br> + <a href="page.cgi?id=edit_table.html&table=antispam_comment_blocklist">Comment Blocklist</a><br> + <a href="page.cgi?id=edit_table.html&table=antispam_ip_blocklist">IP Address Blocklist</a><br> + </dd> +[% END %] diff --git a/extensions/AntiSpam/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl b/extensions/AntiSpam/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl new file mode 100644 index 000000000..e8e67eccb --- /dev/null +++ b/extensions/AntiSpam/template/en/default/hook/admin/params/editparams-current_panel.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. + #%] + +[% IF panel.name == "auth" %] + [% panel.param_descs.honeypot_api_key = + 'API Key for http://www.projecthoneypot.org' + %] + [% panel.param_descs.honeypot_threat_threshold = + 'Users will be unable to create accounts if their honeypot threat score is this value or higher.' + %] +[% END -%] diff --git a/extensions/AntiSpam/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/AntiSpam/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..44410ca2f --- /dev/null +++ b/extensions/AntiSpam/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,13 @@ +[%# 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 == "antispam_comment_blocked" %] + [% title = "Comment Blocked" %] + Your comment contains one or more words deemed inappropriate for use by the + administrators of this site. +[% END %] diff --git a/extensions/BMO/Config.pm b/extensions/BMO/Config.pm new file mode 100644 index 000000000..93445f576 --- /dev/null +++ b/extensions/BMO/Config.pm @@ -0,0 +1,48 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the BMO Bugzilla Extension. +# +# The Initial Developer of the Original Code is Gervase Markham +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Gervase Markham <gerv@gerv.net> + +package Bugzilla::Extension::BMO; +use strict; + +use constant NAME => 'BMO'; + +use constant REQUIRED_MODULES => [ + { + package => 'Tie-IxHash', + module => 'Tie::IxHash', + version => 0 + }, + { + package => 'Sys-Syslog', + module => 'Sys::Syslog', + version => 0 + }, + { + package => 'File-MimeInfo', + module => 'File::MimeInfo::Magic', + version => '0' + }, +]; + +use constant OPTIONAL_MODULES => [ +]; + +__PACKAGE__->NAME; diff --git a/extensions/BMO/Extension.pm b/extensions/BMO/Extension.pm new file mode 100644 index 000000000..c28238197 --- /dev/null +++ b/extensions/BMO/Extension.pm @@ -0,0 +1,1561 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the BMO Bugzilla Extension. +# +# The Initial Developer of the Original Code is Gervase Markham. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Gervase Markham <gerv@gerv.net> +# David Lawrence <dkl@mozilla.com> +# Byron Jones <glob@mozilla.com> + +package Bugzilla::Extension::BMO; +use strict; +use base qw(Bugzilla::Extension); + +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Field; +use Bugzilla::Group; +use Bugzilla::Mailer; +use Bugzilla::Product; +use Bugzilla::Status; +use Bugzilla::Token; +use Bugzilla::Install::Filesystem; +use Bugzilla::User; +use Bugzilla::User::Setting; +use Bugzilla::Util; + +use Date::Parse; +use DateTime; +use Encode qw(find_encoding encode_utf8); +use File::MimeInfo::Magic; +use List::MoreUtils qw(natatime); +use Scalar::Util qw(blessed); +use Sys::Syslog qw(:DEFAULT setlogsock); + +use Bugzilla::Extension::BMO::Constants; +use Bugzilla::Extension::BMO::FakeBug; +use Bugzilla::Extension::BMO::Data; + +our $VERSION = '0.1'; + +# +# Monkey-patched methods +# + +BEGIN { + *Bugzilla::Bug::last_closed_date = \&_last_closed_date; + *Bugzilla::Product::default_security_group = \&_default_security_group; + *Bugzilla::Product::default_security_group_obj = \&_default_security_group_obj; + *Bugzilla::Product::group_always_settable = \&_group_always_settable; + *Bugzilla::check_default_product_security_group = \&_check_default_product_security_group; +} + +sub template_before_process { + my ($self, $args) = @_; + my $file = $args->{'file'}; + my $vars = $args->{'vars'}; + + $vars->{'cf_hidden_in_product'} = \&cf_hidden_in_product; + + if ($file =~ /^list\/list/) { + # Purpose: enable correct sorting of list table + # Matched to changes in list/table.html.tmpl + my %db_order_column_name_map = ( + 'map_components.name' => 'component', + 'map_products.name' => 'product', + 'map_reporter.login_name' => 'reporter', + 'map_assigned_to.login_name' => 'assigned_to', + 'delta_ts' => 'opendate', + 'creation_ts' => 'changeddate', + ); + + my @orderstrings = split(/,\s*/, $vars->{'order'}); + + # contains field names of the columns being used to sort the table. + my @order_columns; + foreach my $o (@orderstrings) { + $o =~ s/bugs.//; + $o = $db_order_column_name_map{$o} if + grep($_ eq $o, keys(%db_order_column_name_map)); + next if (grep($_ eq $o, @order_columns)); + push(@order_columns, $o); + } + + $vars->{'order_columns'} = \@order_columns; + + # fields that have a custom sortkey. (So they are correctly sorted + # when using js) + my @sortkey_fields = qw(bug_status resolution bug_severity priority + rep_platform op_sys); + + my %columns_sortkey; + foreach my $field (@sortkey_fields) { + $columns_sortkey{$field} = _get_field_values_sort_key($field); + } + $columns_sortkey{'target_milestone'} = _get_field_values_sort_key('milestones'); + + $vars->{'columns_sortkey'} = \%columns_sortkey; + } + elsif ($file =~ /^bug\/create\/create[\.-](.*)/) { + my $format = $1; + if (!$vars->{'cloned_bug_id'}) { + # Allow status whiteboard values to be bookmarked + $vars->{'status_whiteboard'} = + Bugzilla->cgi->param('status_whiteboard') || ""; + } + + # Purpose: for pretty product chooser + $vars->{'format'} = Bugzilla->cgi->param('format'); + + if ($format eq 'doc.html.tmpl') { + my $versions = Bugzilla::Product->new({ name => 'Core' })->versions; + $vars->{'versions'} = [ reverse @$versions ]; + } + } + + + if ($file =~ /^list\/list/ || $file =~ /^bug\/create\/create[\.-]/) { + # hack to allow the bug entry templates to use check_can_change_field + # to see if various field values should be available to the current user. + $vars->{'default'} = Bugzilla::Extension::BMO::FakeBug->new($vars->{'default'} || {}); + } + + if ($file =~ /^attachment\/diff-header\./) { + my $attachid = $vars->{attachid} ? $vars->{attachid} : $vars->{newid}; + $vars->{attachment} = Bugzilla::Attachment->new({ id => $attachid, cache => 1 }) + if $attachid; + } +} + +sub page_before_template { + my ($self, $args) = @_; + my $page = $args->{'page_id'}; + my $vars = $args->{'vars'}; + + if ($page eq 'user_activity.html') { + require Bugzilla::Extension::BMO::Reports::UserActivity; + Bugzilla::Extension::BMO::Reports::UserActivity::report($vars); + + } elsif ($page eq 'triage_reports.html') { + require Bugzilla::Extension::BMO::Reports::Triage; + Bugzilla::Extension::BMO::Reports::Triage::report($vars); + } + elsif ($page eq 'group_admins.html') { + require Bugzilla::Extension::BMO::Reports::Groups; + Bugzilla::Extension::BMO::Reports::Groups::admins_report($vars); + } + elsif ($page eq 'group_membership.html' or $page eq 'group_membership.txt') { + require Bugzilla::Extension::BMO::Reports::Groups; + Bugzilla::Extension::BMO::Reports::Groups::membership_report($page, $vars); + } + elsif ($page eq 'group_members.html' or $page eq 'group_members.json') { + require Bugzilla::Extension::BMO::Reports::Groups; + Bugzilla::Extension::BMO::Reports::Groups::members_report($vars); + } + elsif ($page eq 'email_queue.html') { + require Bugzilla::Extension::BMO::Reports::EmailQueue; + Bugzilla::Extension::BMO::Reports::EmailQueue::report($vars); + } + elsif ($page eq 'release_tracking_report.html') { + require Bugzilla::Extension::BMO::Reports::ReleaseTracking; + Bugzilla::Extension::BMO::Reports::ReleaseTracking::report($vars); + } + elsif ($page eq 'product_security_report.html') { + require Bugzilla::Extension::BMO::Reports::ProductSecurity; + Bugzilla::Extension::BMO::Reports::ProductSecurity::report($vars); + } + elsif ($page eq 'fields.html') { + # Recently global/field-descs.none.tmpl and bug/field-help.none.tmpl + # were changed for better performance and are now only loaded once. + # I have not found an easy way to allow our hook template to check if + # it is called from pages/fields.html.tmpl. So we set a value in request_cache + # that our hook template can see. + Bugzilla->request_cache->{'bmo_fields_page'} = 1; + } + elsif ($page eq 'query_database.html') { + query_database($vars); + } +} + +sub _get_field_values_sort_key { + my ($field) = @_; + my $dbh = Bugzilla->dbh; + my $fields = $dbh->selectall_arrayref( + "SELECT value, sortkey FROM $field + ORDER BY sortkey, value"); + + my %field_values; + foreach my $field (@$fields) { + my ($value, $sortkey) = @$field; + $field_values{$value} = $sortkey; + } + return \%field_values; +} + +sub active_custom_fields { + my ($self, $args) = @_; + my $fields = $args->{'fields'}; + my $params = $args->{'params'}; + my $product = $params->{'product'}; + my $component = $params->{'component'}; + + return if !$product; + + my $product_name = blessed $product ? $product->name : $product; + my $component_name = blessed $component ? $component->name : $component; + + my @tmp_fields; + foreach my $field (@$$fields) { + next if cf_hidden_in_product($field->name, $product_name, $component_name); + push(@tmp_fields, $field); + } + $$fields = \@tmp_fields; +} + +sub cf_hidden_in_product { + my ($field_name, $product_name, $component_name) = @_; + + # If used in buglist.cgi, we pass in one_product which is a Bugzilla::Product + # elsewhere, we just pass the name of the product. + $product_name = blessed($product_name) + ? $product_name->name + : $product_name; + + # Also in buglist.cgi, we pass in a list of components instead + # of a single component name everywhere else. + my $component_list = []; + if ($component_name) { + $component_list = ref $component_name + ? $component_name + : [ $component_name ]; + } + + foreach my $field_re (keys %$cf_visible_in_products) { + if ($field_name =~ $field_re) { + # If no product given, for example more than one product + # in buglist.cgi, then hide field by default + return 1 if !$product_name; + + my $products = $cf_visible_in_products->{$field_re}; + foreach my $product (keys %$products) { + my $components = $products->{$product}; + + my $found_component = 0; + if (@$components) { + foreach my $component (@$components) { + if (ref($component) eq 'Regexp') { + if (grep($_ =~ $component, @$component_list)) { + $found_component = 1; + last; + } + } else { + if (grep($_ eq $component, @$component_list)) { + $found_component = 1; + last; + } + } + } + } + + # If product matches and at at least one component matches + # from component_list (if a matching component was required), + # we allow the field to be seen + if ($product eq $product_name && (!@$components || $found_component)) { + return 0; + } + } + return 1; + } + } + + return 0; +} + +# Purpose: CC certain email addresses on bugmail when a bug is added or +# removed from a particular group. +sub bugmail_recipients { + my ($self, $args) = @_; + my $bug = $args->{'bug'}; + my $recipients = $args->{'recipients'}; + my $diffs = $args->{'diffs'}; + + if (@$diffs) { + # Changed bug + foreach my $ref (@$diffs) { + my $old = $ref->{old}; + my $new = $ref->{new}; + my $fieldname = $ref->{field_name}; + + if ($fieldname eq "bug_group") { + _cc_if_special_group($old, $recipients); + _cc_if_special_group($new, $recipients); + } + } + } else { + # Determine if it's a new bug, or a comment without a field change + my $comment_count = scalar @{$bug->comments}; + if ($comment_count == 1) { + # New bug + foreach my $group (@{ $bug->groups_in }) { + _cc_if_special_group($group->{'name'}, $recipients); + } + } + } +} + +sub _cc_if_special_group { + my ($group, $recipients) = @_; + + return if !$group; + + if (exists $group_change_notification{$group}) { + foreach my $login (@{ $group_change_notification{$group} }) { + my $id = login_to_id($login); + $recipients->{$id}->{+REL_CC} = Bugzilla::BugMail::BIT_DIRECT(); + } + } +} + +sub _check_trusted { + my ($field, $trusted, $priv_results) = @_; + + my $needed_group = $trusted->{'_default'} || ""; + foreach my $dfield (keys %$trusted) { + if ($field =~ $dfield) { + $needed_group = $trusted->{$dfield}; + } + } + if ($needed_group && !Bugzilla->user->in_group($needed_group)) { + push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); + } +} + +sub _is_field_set { + my $value = shift; + return $value ne '---' && $value !~ /\?$/; +} + +sub bug_check_can_change_field { + my ($self, $args) = @_; + my $bug = $args->{'bug'}; + my $field = $args->{'field'}; + my $new_value = $args->{'new_value'}; + my $old_value = $args->{'old_value'}; + my $priv_results = $args->{'priv_results'}; + my $user = Bugzilla->user; + + if ($field =~ /^cf/ && !@$priv_results && $new_value ne '---') { + # "other" custom field setters restrictions + if (exists $cf_setters->{$field}) { + my $in_group = 0; + foreach my $group (@{$cf_setters->{$field}}) { + if ($user->in_group($group, $bug->product_id)) { + $in_group = 1; + last; + } + } + if (!$in_group) { + push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); + } + } + } + elsif ($field eq 'resolution' && $new_value eq 'EXPIRED') { + # The EXPIRED resolution should only be settable by gerv. + if ($user->login ne 'gerv@mozilla.org') { + push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); + } + + } elsif ($field eq 'resolution' && $new_value eq 'FIXED') { + # You need at least canconfirm to mark a bug as FIXED + if (!$user->in_group('canconfirm', $bug->{'product_id'})) { + push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); + } + + } elsif ( + ($field eq 'bug_status' && $old_value eq 'VERIFIED') + || ($field eq 'dup_id' && $bug->status->name eq 'VERIFIED') + || ($field eq 'resolution' && $bug->status->name eq 'VERIFIED') + ) { + # You need at least editbugs to reopen a resolved/verified bug + if (!$user->in_group('editbugs', $bug->{'product_id'})) { + push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); + } + + } elsif ($user->in_group('canconfirm', $bug->{'product_id'})) { + # Canconfirm is really "cantriage"; users with canconfirm can also mark + # bugs as DUPLICATE, WORKSFORME, and INCOMPLETE. + if ($field eq 'bug_status' + && is_open_state($old_value) + && !is_open_state($new_value)) + { + push (@$priv_results, PRIVILEGES_REQUIRED_NONE); + } + elsif ($field eq 'resolution' && + ($new_value eq 'DUPLICATE' || + $new_value eq 'WORKSFORME' || + $new_value eq 'INCOMPLETE')) + { + push (@$priv_results, PRIVILEGES_REQUIRED_NONE); + } + + } elsif ($field eq 'bug_status') { + # Disallow reopening of bugs which have been resolved for > 1 year + if (is_open_state($new_value) + && !is_open_state($old_value) + && $bug->resolution eq 'FIXED') + { + my $days_ago = DateTime->now(time_zone => Bugzilla->local_timezone); + $days_ago->subtract(days => 365); + my $last_closed = datetime_from($bug->last_closed_date); + if ($last_closed lt $days_ago) { + push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); + } + } + } +} + +# link up various Mozilla-specific strings +sub bug_format_comment { + my ($self, $args) = @_; + my $regexes = $args->{'regexes'}; + + # link to crash-stats + # Only match if not already in an URL using the negative lookbehind (?<!\/) + push (@$regexes, { + match => qr/(?<!\/)\bbp-([a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\- + [a-f0-9]{4}\-[a-f0-9]{12})\b/x, + replace => sub { + my $args = shift; + my $match = html_quote($args->{matches}->[0]); + return qq{<a href="https://crash-stats.mozilla.com/report/index/$match">bp-$match</a>}; + } + }); + + # link to CVE/CAN security releases + push (@$regexes, { + match => qr/(?<!\/|=)\b((?:CVE|CAN)-\d{4}-\d{4})\b/, + replace => sub { + my $args = shift; + my $match = html_quote($args->{matches}->[0]); + return qq{<a href="http://cve.mitre.org/cgi-bin/cvename.cgi?name=$match">$match</a>}; + } + }); + + # link to svn.m.o + push (@$regexes, { + match => qr/\br(\d{4,})\b/, + replace => sub { + my $args = shift; + my $match = html_quote($args->{matches}->[0]); + return qq{<a href="http://viewvc.svn.mozilla.org/vc?view=rev&revision=$match">r$match</a>}; + } + }); + + # link bzr commit messages + push (@$regexes, { + match => qr/\b(Committing\s+to:\sbzr\+ssh:\/\/ + (?:[^\@]+\@)?(bzr\.mozilla\.org[^\n]+)\n.*?\bCommitted\s) + (revision\s(\d+))/sx, + replace => sub { + my $args = shift; + my $preamble = html_quote($args->{matches}->[0]); + my $url = html_quote($args->{matches}->[1]); + my $text = html_quote($args->{matches}->[2]); + my $id = html_quote($args->{matches}->[3]); + $url =~ s/\s+$//; + $url =~ s/\/$//; + return qq{$preamble<a href="http://$url/revision/$id">$text</a>}; + } + }); + + # link git.mozilla.org commit messages + push (@$regexes, { + match => qr#^(To\s(?:ssh://)?(?:[^\@]+\@)?git\.mozilla\.org[:/](.+?\.git)\n + \s+)([0-9a-z]+\.\.([0-9a-z]+)\s+\S+\s->\s\S+)#mx, + replace => sub { + my $args = shift; + my $preamble = html_quote($args->{matches}->[0]); + my $repo = html_quote($args->{matches}->[1]); + my $text = $args->{matches}->[2]; + my $revision = $args->{matches}->[3]; + return qq#$preamble<a href="http://git.mozilla.org/?p=$repo;a=commitdiff;h=$revision">$text</a>#; + } + }); + + # link to hg.m.o + # Note: for grouping in this regexp, always use non-capturing parentheses. + my $hgrepos = join('|', qw!(?:releases/)?comm-[\w.]+ + (?:releases/)?mozilla-[\w.]+ + (?:releases/)?mobile-[\w.]+ + tracemonkey + tamarin-[\w.]+ + camino!); + + push (@$regexes, { + match => qr/\b(($hgrepos)\s+changeset:?\s+(?:\d+:)?([0-9a-fA-F]{12}))\b/, + replace => sub { + my $args = shift; + my $text = html_quote($args->{matches}->[0]); + my $repo = html_quote($args->{matches}->[1]); + my $id = html_quote($args->{matches}->[2]); + $repo = 'integration/mozilla-inbound' if $repo eq 'mozilla-inbound'; + return qq{<a href="https://hg.mozilla.org/$repo/rev/$id">$text</a>}; + } + }); +} + +sub quicksearch_map { + my ($self, $args) = @_; + my $map = $args->{'map'}; + + foreach my $name (keys %$map) { + if ($name =~ /cf_crash_signature$/) { + $map->{'sig'} = $name; + } + } +} + +sub object_end_of_create { + my ($self, $args) = @_; + my $class = $args->{class}; + + if ($class eq 'Bugzilla::User') { + my $user = $args->{object}; + + # Log real IP addresses for auditing + _syslog(sprintf('[audit] <%s> created user %s', remote_ip(), $user->login)); + + # Add default searches to new user's footer + my $dbh = Bugzilla->dbh; + + my $sharer = Bugzilla::User->new({ name => 'nobody@mozilla.org' }) + or return; + my $group = Bugzilla::Group->new({ name => 'everyone' }) + or return; + + foreach my $definition (@default_named_queries) { + my ($namedquery_id) = _get_named_query($sharer->id, $group->id, $definition); + $dbh->do( + "INSERT INTO namedqueries_link_in_footer(namedquery_id,user_id) VALUES (?,?)", + undef, + $namedquery_id, $user->id + ); + } + + } elsif ($class eq 'Bugzilla::Bug') { + # Log real IP addresses for auditing + _syslog(sprintf('[audit] %s <%s> created bug %s', + Bugzilla->user->login, remote_ip(), $args->{object}->id)); + } +} + +sub _get_named_query { + my ($sharer_id, $group_id, $definition) = @_; + my $dbh = Bugzilla->dbh; + # find existing namedquery + my ($namedquery_id) = $dbh->selectrow_array( + "SELECT id FROM namedqueries WHERE userid=? AND name=?", + undef, + $sharer_id, $definition->{name} + ); + return $namedquery_id if $namedquery_id; + # create namedquery + $dbh->do( + "INSERT INTO namedqueries(userid,name,query) VALUES (?,?,?)", + undef, + $sharer_id, $definition->{name}, $definition->{query} + ); + $namedquery_id = $dbh->bz_last_key(); + # and share it + $dbh->do( + "INSERT INTO namedquery_group_map(namedquery_id,group_id) VALUES (?,?)", + undef, + $namedquery_id, $group_id, + ); + return $namedquery_id; +} + +# Automatically CC users to bugs based on group & product +sub bug_end_of_create { + my ($self, $args) = @_; + my $bug = $args->{'bug'}; + + foreach my $group_name (keys %group_auto_cc) { + my $group_obj = Bugzilla::Group->new({ name => $group_name }); + if ($group_obj && $bug->in_group($group_obj)) { + my $ra_logins = exists $group_auto_cc{$group_name}->{$bug->product} + ? $group_auto_cc{$group_name}->{$bug->product} + : $group_auto_cc{$group_name}->{'_default'}; + foreach my $login (@$ra_logins) { + $bug->add_cc($login); + } + } + } +} + +# detect github pull requests and reviewboard reviews, set the content-type +sub attachment_process_data { + my ($self, $args) = @_; + my $attributes = $args->{attributes}; + + # must be a text attachment + return unless $attributes->{mimetype} eq 'text/plain'; + + # check the attachment size, and get attachment content if it isn't too large + my $data = $attributes->{data}; + my $url; + if (blessed($data) && blessed($data) eq 'Fh') { + # filehandle + my $size = -s $data; + return if $size > 256; + sysread($data, $url, $size); + seek($data, 0, 0); + } else { + # string + $url = $data; + } + + if (my $content_type = _get_review_content_type($url)) { + $attributes->{mimetype} = $content_type; + $attributes->{ispatch} = 0; + } +} + +sub _get_review_content_type { + my ($url) = @_; + + # trim and check for the pull request url + return unless defined $url; + return if length($url) > 256; + $url = trim($url); + return if $url =~ /\s/; + + if ($url =~ m#^https://github\.com/[^/]+/[^/]+/pull/\d+/?$#i) { + return GITHUB_PR_CONTENT_TYPE; + } + if ($url =~ m#^https?://reviewboard(?:-dev)?\.(?:allizom|mozilla)\.org/r/\d+/?#i) { + return RB_REQUEST_CONTENT_TYPE; + } + return; +} + +# redirect automatically to github urls +sub attachment_view { + my ($self, $args) = @_; + my $attachment = $args->{attachment}; + my $cgi = Bugzilla->cgi; + + # don't redirect if the content-type is specified explicitly + return if defined $cgi->param('content_type'); + + # must be our github/reviewboard content-type + return unless + $attachment->contenttype eq GITHUB_PR_CONTENT_TYPE + or $attachment->contenttype eq RB_REQUEST_CONTENT_TYPE; + + # must still be a valid url + return unless _get_review_content_type($attachment->data); + + # redirect + print $cgi->redirect(trim($attachment->data)); + exit; +} + +sub install_before_final_checks { + my ($self, $args) = @_; + + # Add product chooser setting + add_setting('product_chooser', + ['pretty_product_chooser', 'full_product_chooser'], + 'pretty_product_chooser'); + + # Add option to inject x-bugzilla headers into the message body to work + # around gmail filtering limitations + add_setting('headers_in_body', ['on', 'off'], 'off'); + + # Migrate from 'gmail_threading' setting to 'bugmail_new_prefix' + my $dbh = Bugzilla->dbh; + if ($dbh->selectrow_array("SELECT 1 FROM setting WHERE name='gmail_threading'")) { + $dbh->bz_start_transaction(); + $dbh->do("UPDATE profile_setting + SET setting_value='on-temp' + WHERE setting_name='gmail_threading' AND setting_value='Off'"); + $dbh->do("UPDATE profile_setting + SET setting_value='off' + WHERE setting_name='gmail_threading' AND setting_value='On'"); + $dbh->do("UPDATE profile_setting + SET setting_value='on' + WHERE setting_name='gmail_threading' AND setting_value='on-temp'"); + $dbh->do("UPDATE profile_setting + SET setting_name='bugmail_new_prefix' + WHERE setting_name='gmail_threading'"); + $dbh->do("DELETE FROM setting WHERE name='gmail_threading'"); + $dbh->bz_commit_transaction(); + } +} + +# Migrate old is_active stuff to new patch (is in core in 4.2), The old column +# name was 'is_active', the new one is 'isactive' (no underscore). +sub install_update_db { + my $dbh = Bugzilla->dbh; + + if ($dbh->bz_column_info('milestones', 'is_active')) { + $dbh->do("UPDATE milestones SET isactive = 0 WHERE is_active = 0;"); + $dbh->bz_drop_column('milestones', 'is_active'); + $dbh->bz_drop_column('milestones', 'is_searchable'); + } +} + +sub _last_closed_date { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + + return $self->{'last_closed_date'} if defined $self->{'last_closed_date'}; + + my $closed_statuses = "'" . join("','", map { $_->name } closed_bug_statuses()) . "'"; + my $status_field_id = get_field_id('bug_status'); + + $self->{'last_closed_date'} = $dbh->selectrow_array(" + SELECT bugs_activity.bug_when + FROM bugs_activity + WHERE bugs_activity.fieldid = ? + AND bugs_activity.added IN ($closed_statuses) + AND bugs_activity.bug_id = ? + ORDER BY bugs_activity.bug_when DESC " . $dbh->sql_limit(1), + undef, $status_field_id, $self->id + ); + + return $self->{'last_closed_date'}; +} + +sub field_end_of_create { + my ($self, $args) = @_; + my $field = $args->{'field'}; + + # Create an IT bug so Mozilla's DBAs so they can update the grants for metrics + + if (Bugzilla->params->{'urlbase'} ne 'https://bugzilla.mozilla.org/' + && Bugzilla->params->{'urlbase'} ne 'https://bugzilla.allizom.org/') + { + return; + } + + my $name = $field->name; + + if (Bugzilla->usage_mode == USAGE_MODE_CMDLINE) { + Bugzilla->set_user(Bugzilla::User->check({ name => 'nobody@mozilla.org' })); + print "Creating IT permission grant bug for new field '$name'..."; + } + + my $bug_data = { + short_desc => "Custom field '$name' added to bugzilla.mozilla.org", + product => 'mozilla.org', + component => 'Server Operations: Database', + bug_severity => 'normal', + op_sys => 'All', + rep_platform => 'All', + version => 'other', + }; + + my $comment = <<COMMENT; +The custom field '$name' has been added to the BMO database. +Please run the following on bugzilla1.db.scl3.mozilla.com: +COMMENT + + if ($field->type == FIELD_TYPE_SINGLE_SELECT + || $field->type == FIELD_TYPE_MULTI_SELECT) { + $comment .= <<COMMENT; + GRANT SELECT ON `bugs`.`$name` TO 'metrics'\@'10.22.70.20_'; + GRANT SELECT ON `bugs`.`$name` TO 'metrics'\@'10.22.70.21_'; +COMMENT + } + if ($field->type == FIELD_TYPE_MULTI_SELECT) { + $comment .= <<COMMENT; + GRANT SELECT ON `bugs`.`bug_$name` TO 'metrics'\@'10.22.70.20_'; + GRANT SELECT ON `bugs`.`bug_$name` TO 'metrics'\@'10.22.70.21_'; +COMMENT + } + if ($field->type != FIELD_TYPE_MULTI_SELECT) { + $comment .= <<COMMENT; + GRANT SELECT ($name) ON `bugs`.`bugs` TO 'metrics'\@'10.22.70.20_'; + GRANT SELECT ($name) ON `bugs`.`bugs` TO 'metrics'\@'10.22.70.21_'; +COMMENT + } + + $bug_data->{'comment'} = $comment; + + my $old_error_mode = Bugzilla->error_mode; + Bugzilla->error_mode(ERROR_MODE_DIE); + + my $new_bug = eval { Bugzilla::Bug->create($bug_data) }; + + my $error = $@; + undef $@; + Bugzilla->error_mode($old_error_mode); + + if ($error || !($new_bug && $new_bug->{'bug_id'})) { + warn "Error creating IT bug for new field $name: $error"; + if (Bugzilla->usage_mode == USAGE_MODE_CMDLINE) { + print "\nError: $error\n"; + } + } + else { + Bugzilla::BugMail::Send($new_bug->id, { changer => Bugzilla->user }); + if (Bugzilla->usage_mode == USAGE_MODE_CMDLINE) { + print "bug " . $new_bug->id . " created.\n"; + } + } +} + +sub webservice { + my ($self, $args) = @_; + + my $dispatch = $args->{dispatch}; + $dispatch->{BMO} = "Bugzilla::Extension::BMO::WebService"; +} + +our $search_content_matches; +BEGIN { + $search_content_matches = \&Bugzilla::Search::_content_matches; +} + +sub search_operator_field_override { + my ($self, $args) = @_; + my $search = $args->{'search'}; + my $operators = $args->{'operators'}; + + my $cgi = Bugzilla->cgi; + my @comments = $cgi->param('comments'); + my $exclude_comments = scalar(@comments) && !grep { $_ eq '1' } @comments; + + if ($cgi->param('query_format') + && $cgi->param('query_format') eq 'specific' + && $exclude_comments + ) { + # use the non-comment operator + $operators->{'content'}->{matches} = \&_short_desc_matches; + $operators->{'content'}->{notmatches} = \&_short_desc_matches; + + } else { + # restore default content operator + $operators->{'content'}->{matches} = $search_content_matches; + $operators->{'content'}->{notmatches} = $search_content_matches; + } +} + +sub _short_desc_matches { + # copy of Bugzilla::Search::_content_matches with comment searching removed + + my ($self, $args) = @_; + my ($chart_id, $joins, $fields, $operator, $value) = + @$args{qw(chart_id joins fields operator value)}; + my $dbh = Bugzilla->dbh; + + # Add the fulltext table to the query so we can search on it. + my $table = "bugs_fulltext_$chart_id"; + push(@$joins, { table => 'bugs_fulltext', as => $table }); + + # Create search terms to add to the SELECT and WHERE clauses. + my ($term, $rterm) = + $dbh->sql_fulltext_search("$table.short_desc", $value, 2); + $rterm = $term if !$rterm; + + # The term to use in the WHERE clause. + if ($operator =~ /not/i) { + $term = "NOT($term)"; + } + $args->{term} = $term; + + my $current = $self->COLUMNS->{'relevance'}->{name}; + $current = $current ? "$current + " : ''; + # For NOT searches, we just add 0 to the relevance. + my $select_term = $operator =~ /not/ ? 0 : "($current$rterm)"; + $self->COLUMNS->{'relevance'}->{name} = $select_term; +} + +sub mailer_before_send { + my ($self, $args) = @_; + my $email = $args->{email}; + + _log_sent_email($email); + + # $bug->mentors is added by the Review extension + if (Bugzilla::Bug->can('mentors')) { + _add_mentors_header($email); + } + + # insert x-bugzilla headers into the body + _inject_headers_into_body($email); +} + +# Log a summary of bugmail sent to the syslog, for auditing and monitoring +sub _log_sent_email { + my $email = shift; + + my $recipient = $email->header('to'); + return unless $recipient; + + my $subject = $email->header('Subject'); + + my $bug_id = $email->header('X-Bugzilla-ID'); + if (!$bug_id && $subject =~ /[\[\(]Bug (\d+)/i) { + $bug_id = $1; + } + $bug_id = $bug_id ? "bug-$bug_id" : '-'; + + my $message_type; + my $type = $email->header('X-Bugzilla-Type'); + my $reason = $email->header('X-Bugzilla-Reason'); + if ($type eq 'whine' || $type eq 'request' || $type eq 'admin') { + $message_type = $type; + } elsif ($reason && $reason ne 'None') { + $message_type = $reason; + } else { + $message_type = $email->header('X-Bugzilla-Watch-Reason'); + } + $message_type ||= $type || '?'; + + $subject =~ s/[\[\(]Bug \d+[\]\)]\s*//; + + _syslog("[bugmail] $recipient ($message_type) $bug_id $subject"); +} + +# Add X-Bugzilla-Mentors field to bugmail +sub _add_mentors_header { + my $email = shift; + return unless my $bug_id = $email->header('X-Bugzilla-ID'); + return unless my $bug = Bugzilla::Bug->new({ id => $bug_id, cache => 1 }); + return unless my $mentors = $bug->mentors; + return unless @$mentors; + $email->header_set('X-Bugzilla-Mentors', join(', ', map { $_->login } @$mentors)); +} + +sub _inject_headers_into_body { + my $email = shift; + my $replacement = ''; + + my $recipient = Bugzilla::User->new({ name => $email->header('To'), cache => 1 }); + if ($recipient + && $recipient->settings->{headers_in_body}->{value} eq 'on') + { + my @headers; + my $it = natatime(2, $email->header_pairs); + while (my ($name, $value) = $it->()) { + next unless $name =~ /^X-Bugzilla-(.+)/; + if ($name eq 'X-Bugzilla-Flags' || $name eq 'X-Bugzilla-Changed-Field-Names') { + # these are multi-value fields, split on space + foreach my $v (split(/\s+/, $value)) { + push @headers, "$name: $v"; + } + } + elsif ($name eq 'X-Bugzilla-Changed-Fields') { + # cannot split on space for this field, because field names contain + # spaces. instead work from a list of field names. + my @fields = + map { $_->description } + @{ Bugzilla->fields }; + # these aren't real fields, but exist in the headers + push @fields, ('Comment Created', 'Attachment Created'); + @fields = + sort { length($b) <=> length($a) } + @fields; + while ($value ne '') { + foreach my $field (@fields) { + if ($value eq $field) { + push @headers, "$name: $field"; + $value = ''; + last; + } + if (substr($value, 0, length($field) + 1) eq $field . ' ') { + push @headers, "$name: $field"; + $value = substr($value, length($field) + 1); + last; + } + } + } + } + else { + push @headers, "$name: $value"; + } + } + $replacement = join("\n", @headers); + } + + # update the message body + if (scalar($email->parts) > 1) { + $email->walk_parts(sub { + my ($part) = @_; + + # skip top-level + return if $part->parts > 1; + + # do not filter attachments such as patches, etc. + return if + $part->header('Content-Disposition') + && $part->header('Content-Disposition') =~ /attachment/; + + # text/plain|html only + return unless $part->content_type =~ /^text\/(?:html|plain)/; + + # hide in html content + if ($replacement && $part->content_type =~ /^text\/html/) { + $replacement = '<pre style="font-size: 0pt; color: #fff">' . $replacement . '</pre>'; + } + + # and inject + _replace_placeholder_in_part($part, $replacement); + }); + + # force Email::MIME to re-create all the parts. without this + # as_string() doesn't return the updated body for multi-part sub-parts. + $email->parts_set([ $email->subparts ]); + } + else { + # text-only email + _replace_placeholder_in_part($email, $replacement); + } +} + +sub _replace_placeholder_in_part { + my ($part, $replacement) = @_; + + # fix encoding + my $body = $part->body; + if (Bugzilla->params->{'utf8'}) { + $part->charset_set('UTF-8'); + my $raw = $part->body_raw; + if (utf8::is_utf8($raw)) { + utf8::encode($raw); + $part->body_set($raw); + } + } + $part->encoding_set('quoted-printable') if !is_7bit_clean($body); + + # replace + my $placeholder = quotemeta('@@body-headers@@'); + $body = $part->body_str; + $body =~ s/$placeholder/$replacement/; + $part->body_str_set($body); +} + +sub _syslog { + my $message = shift; + openlog('apache', 'cons,pid', 'local4'); + syslog('notice', encode_utf8($message)); + closelog(); +} + +sub post_bug_after_creation { + my ($self, $args) = @_; + return unless my $format = Bugzilla->input_params->{format}; + my $bug = $args->{vars}->{bug}; + + if ($format eq 'employee-incident' + && $bug->component eq 'Server Operations: Desktop Issues') + { + $self->_post_employee_incident_bug($args); + } + elsif ($format eq 'swag') { + $self->_post_gear_bug($args); + } + elsif ($format eq 'mozpr') { + $self->_post_mozpr_bug($args); + } +} + +sub _post_employee_incident_bug { + my ($self, $args) = @_; + my $vars = $args->{vars}; + my $bug = $vars->{bug}; + + my $error_mode_cache = Bugzilla->error_mode; + Bugzilla->error_mode(ERROR_MODE_DIE); + + my $template = Bugzilla->template; + my $cgi = Bugzilla->cgi; + + my ($investigate_bug, $ssh_key_bug); + my $old_user = Bugzilla->user; + eval { + Bugzilla->set_user(Bugzilla::User->new({ name => 'nobody@mozilla.org' })); + my $new_user = Bugzilla->user; + + # HACK: User needs to be in the editbugs and primary bug's group to allow + # setting of dependencies. + $new_user->{'groups'} = [ Bugzilla::Group->new({ name => 'editbugs' }), + Bugzilla::Group->new({ name => 'infra' }), + Bugzilla::Group->new({ name => 'infrasec' }) ]; + + my $recipients = { changer => $new_user }; + $vars->{original_reporter} = $old_user; + + my $comment; + $cgi->param('display_action', ''); + $template->process('bug/create/comment-employee-incident.txt.tmpl', $vars, \$comment) + || ThrowTemplateError($template->error()); + + $investigate_bug = Bugzilla::Bug->create({ + short_desc => 'Investigate Lost Device', + product => 'mozilla.org', + component => 'Security Assurance: Incident', + status_whiteboard => '[infrasec:incident]', + bug_severity => 'critical', + cc => [ 'jstevensen@mozilla.com' ], + groups => [ 'infrasec' ], + comment => $comment, + op_sys => 'All', + rep_platform => 'All', + version => 'other', + dependson => $bug->bug_id, + }); + $bug->set_all({ blocked => { add => [ $investigate_bug->bug_id ] }}); + Bugzilla::BugMail::Send($investigate_bug->id, $recipients); + + Bugzilla->set_user($old_user); + $vars->{original_reporter} = ''; + $comment = ''; + $cgi->param('display_action', 'ssh'); + $template->process('bug/create/comment-employee-incident.txt.tmpl', $vars, \$comment) + || ThrowTemplateError($template->error()); + + $ssh_key_bug = Bugzilla::Bug->create({ + short_desc => 'Disable/Regenerate SSH Key', + product => $bug->product, + component => $bug->component, + bug_severity => 'critical', + cc => $bug->cc, + groups => [ map { $_->{name} } @{ $bug->groups } ], + comment => $comment, + op_sys => 'All', + rep_platform => 'All', + version => 'other', + dependson => $bug->bug_id, + }); + $bug->set_all({ blocked => { add => [ $ssh_key_bug->bug_id ] }}); + Bugzilla::BugMail::Send($ssh_key_bug->id, $recipients); + }; + my $error = $@; + + Bugzilla->set_user($old_user); + Bugzilla->error_mode($error_mode_cache); + + if ($error || !$investigate_bug || !$ssh_key_bug) { + warn "Failed to create additional employee-incident bug: $error" if $error; + $vars->{'message'} = 'employee_incident_creation_failed'; + } +} + +sub _post_gear_bug { + my ($self, $args) = @_; + my $vars = $args->{vars}; + my $bug = $vars->{bug}; + my $input = Bugzilla->input_params; + + my ($team, $code) = $input->{teamcode} =~ /^(.+?) \((\d+)\)$/; + my @request = ( + "Date Required: $input->{date_required}", + "$input->{firstname} $input->{lastname}", + $input->{email}, + $input->{mozspace}, + $team, + $code, + $input->{purpose}, + ); + my @recipient = ( + "$input->{shiptofirstname} $input->{shiptolastname}", + $input->{shiptoemail}, + $input->{shiptoaddress1}, + $input->{shiptoaddress2}, + $input->{shiptocity}, + $input->{shiptostate}, + $input->{shiptopostcode}, + $input->{shiptocountry}, + "Phone: $input->{shiptophone}", + $input->{shiptoidrut}, + ); + + # the csv has 14 item fields + my @items = map { trim($_) } split(/\n/, $input->{items}); + my @csv; + while (@items) { + my @batch; + if (scalar(@items) > 14) { + @batch = splice(@items, 0, 14); + } + else { + @batch = @items; + push @batch, '' for scalar(@items)..13; + @items = (); + } + push @csv, [ @request, @batch, @recipient ]; + } + + # csv quoting and concat + foreach my $line (@csv) { + foreach my $field (@$line) { + if ($field =~ s/"/""/g || $field =~ /,/) { + $field = qq#"$field"#; + } + } + $line = join(',', @$line); + } + + $self->_add_attachment($args, { + data => join("\n", @csv), + description => "Items (CSV)", + filename => "gear_" . $bug->id . ".csv", + mimetype => "text/csv", + }); + $bug->update($bug->creation_ts); +} + +sub _post_mozpr_bug { + my ($self, $args) = @_; + my $vars = $args->{vars}; + my $bug = $vars->{bug}; + my $input = Bugzilla->input_params; + + if ($input->{proj_mat_file}) { + $self->_add_attachment($args, { + data => $input->{proj_mat_file_attach}, + description => $input->{proj_mat_file_desc}, + filename => scalar $input->{proj_mat_file_attach}, + }); + } + if ($input->{pr_mat_file}) { + $self->_add_attachment($args, { + data => $input->{pr_mat_file_attach}, + description => $input->{pr_mat_file_desc}, + filename => scalar $input->{pr_mat_file_attach}, + }); + } + $bug->update($bug->creation_ts); +} + +sub _add_attachment { + my ($self, $args, $attachment_args) = @_; + + my $bug = $args->{vars}->{bug}; + $attachment_args->{bug} = $bug; + $attachment_args->{creation_ts} = $bug->creation_ts; + $attachment_args->{ispatch} = 0 unless exists $attachment_args->{ispatch}; + $attachment_args->{isprivate} = 0 unless exists $attachment_args->{isprivate}; + $attachment_args->{mimetype} ||= $self->_detect_content_type($attachment_args->{data}); + + # If the attachment cannot be successfully added to the bug, + # we notify the user, but we don't interrupt the bug creation process. + my $old_error_mode = Bugzilla->error_mode; + Bugzilla->error_mode(ERROR_MODE_DIE); + my $attachment; + eval { + $attachment = Bugzilla::Attachment->create($attachment_args); + }; + warn "$@" if $@; + Bugzilla->error_mode($old_error_mode); + + if ($attachment) { + # Insert comment for attachment + $bug->add_comment('', { isprivate => 0, + type => CMT_ATTACHMENT_CREATED, + extra_data => $attachment->id }); + delete $bug->{attachments}; + } + else { + $args->{vars}->{'message'} = 'attachment_creation_failed'; + } + + # Note: you must call $bug->update($bug->creation_ts) after adding all attachments +} + +# bugzilla's content_type detection makes assumptions about form fields, which +# means we can't use it here. this code is lifted from +# Bugzilla::Attachment::get_content_type and the TypeSniffer extension. +sub _detect_content_type { + my ($self, $data) = @_; + my $cgi = Bugzilla->cgi; + + # browser provided content-type + my $content_type = $cgi->uploadInfo($data)->{'Content-Type'}; + $content_type = 'image/png' if $content_type eq 'image/x-png'; + + if ($content_type eq 'application/octet-stream') { + # detect from filename + my $filename = scalar($data); + if (my $from_filename = mimetype($filename)) { + return $from_filename; + } + } + + return $content_type || 'application/octet-stream'; +} + +sub buglist_columns { + my ($self, $args) = @_; + my $columns = $args->{columns}; + $columns->{'cc_count'} = { + name => '(SELECT COUNT(*) FROM cc WHERE cc.bug_id = bugs.bug_id)', + title => 'CC Count', + }; + $columns->{'dupe_count'} = { + name => '(SELECT COUNT(*) FROM duplicates WHERE duplicates.dupe_of = bugs.bug_id)', + title => 'Duplicate Count', + }; +} + +sub enter_bug_start { + my ($self, $args) = @_; + # if configured with create_bug_formats, force users into a custom bug + # format (can be overridden with a __standard__ format) + my $cgi = Bugzilla->cgi; + if ($cgi->param('format') && $cgi->param('format') eq '__standard__') { + $cgi->delete('format'); + } elsif (my $format = forced_format($cgi->param('product'))) { + $cgi->param('format', $format); + } + + # If product eq 'mozilla.org' and format eq 'itrequest', then + # switch to the new 'Infrastructure & Operations' product. + if ($cgi->param('product') && $cgi->param('product') eq 'mozilla.org' + && $cgi->param('format') && $cgi->param('format') eq 'itrequest') + { + $cgi->param('product', 'Infrastructure & Operations'); + } + + # map renamed groups + $cgi->param('groups', _map_groups($cgi->param('groups'))); +} + +sub bug_before_create { + my ($self, $args) = @_; + my $params = $args->{params}; + if (exists $params->{groups}) { + # map renamed groups + $params->{groups} = [ _map_groups($params->{groups}) ]; + } +} + +sub _map_groups { + my (@groups) = @_; + return unless @groups; + @groups = @{ $groups[0] } if ref($groups[0]); + return map { + # map mozilla-corporation-confidential => mozilla-employee-confidential + $_ eq 'mozilla-corporation-confidential' + ? 'mozilla-employee-confidential' + : $_ + } @groups; +} + +sub forced_format { + # note: this is also called from the guided bug entry extension + my ($product) = @_; + return undef unless defined $product; + + # always work on the correct product name + $product = Bugzilla::Product->new({ name => $product, cache => 1 }) + unless blessed($product); + return undef unless $product; + + # check for a forced-format entry + my $forced = $create_bug_formats{$product->name} + || return; + + # should this user be included? + my $user = Bugzilla->user; + my $include = ref($forced->{include}) ? $forced->{include} : [ $forced->{include} ]; + foreach my $inc (@$include) { + return $forced->{format} if $user->in_group($inc); + } + + return undef; +} + +sub query_database { + my ($vars) = @_; + + # validate group membership + my $user = Bugzilla->user; + $user->in_group('query_database') + || ThrowUserError('auth_failure', { group => 'query_database', + action => 'access', + object => 'query_database' }); + + # read query + my $input = Bugzilla->input_params; + my $query = $input->{query}; + $vars->{query} = $query; + + if ($query) { + trick_taint($query); + $vars->{executed} = 1; + + # add limit if missing + if ($query !~ /\sLIMIT\s+\d+\s*$/si) { + $query .= ' LIMIT 1000'; + $vars->{query} = $query; + } + + # log query + _syslog(sprintf("[db_query] %s %s", $user->login, $query)); + + # connect to database and execute + # switching to the shadow db gives us a read-only connection + my $dbh = Bugzilla->switch_to_shadow_db(); + my $sth; + eval { + $sth = $dbh->prepare($query); + $sth->execute(); + }; + if ($@) { + $vars->{sql_error} = $@; + return; + } + + # build result + my $columns = $sth->{NAME}; + my $rows; + while (my @row = $sth->fetchrow_array) { + push @$rows, \@row; + } + + # return results + $vars->{columns} = $columns; + $vars->{rows} = $rows; + } +} + +# you can always file bugs into a product's default security group, as well as +# into any of the groups in @always_fileable_groups +sub _group_always_settable { + my ($self, $group) = @_; + return + $group->name eq $self->default_security_group + || ((grep { $_ eq $group->name } @always_fileable_groups) ? 1 : 0); +} + +sub _default_security_group { + my ($self) = @_; + return exists $product_sec_groups{$self->name} + ? $product_sec_groups{$self->name} + : $product_sec_groups{_default}; +} + +sub _default_security_group_obj { + my ($self) = @_; + return unless my $group_name = $self->default_security_group; + return Bugzilla::Group->new({ name => $group_name, cache => 1 }) +} + +# called from the verify version, component, and group page. +# if we're making a group invalid, stuff the default group into the cgi param +# to make it checked by default. +sub _check_default_product_security_group { + my ($self, $product, $invalid_groups, $optional_group_controls) = @_; + return unless my $group = $product->default_security_group_obj; + if (@$invalid_groups) { + my $cgi = Bugzilla->cgi; + my @groups = $cgi->param('groups'); + push @groups, $group->name unless grep { $_ eq $group->name } @groups; + $cgi->param('groups', @groups); + } +} + +sub install_filesystem { + my ($self, $args) = @_; + my $files = $args->{files}; + my $extensions_dir = bz_locations()->{extensionsdir}; + $files->{"$extensions_dir/BMO/bin/migrate-github-pull-requests.pl"} = { + perms => Bugzilla::Install::Filesystem::OWNER_EXECUTE + }; +} + +# "deleted" comment tag + +sub config_modify_panels { + my ($self, $args) = @_; + push @{ $args->{panels}->{groupsecurity}->{params} }, { + name => 'delete_comments_group', + type => 's', + choices => \&Bugzilla::Config::GroupSecurity::_get_all_group_names, + default => 'admin', + checker => \&check_group + }; +} + +sub comment_after_add_tag { + my ($self, $args) = @_; + my $tag = $args->{tag}; + return unless lc($tag) eq 'deleted'; + + my $group_name = Bugzilla->params->{delete_comments_group}; + if (!$group_name || !Bugzilla->user->in_group($group_name)) { + ThrowUserError('auth_failure', { group => $group_name, + action => 'delete', + object => 'comments' }); + } +} + +sub comment_after_remove_tag { + my ($self, $args) = @_; + my $tag = $args->{tag}; + return unless lc($tag) eq 'deleted'; + + my $group_name = Bugzilla->params->{delete_comments_group}; + if (!$group_name || !Bugzilla->user->in_group($group_name)) { + ThrowUserError('auth_failure', { group => $group_name, + action => 'delete', + object => 'comments' }); + } +} + +BEGIN { + *Bugzilla::Comment::has_tag = \&_comment_has_tag; +} + +sub _comment_has_tag { + my ($self, $test_tag) = @_; + $test_tag = lc($test_tag); + foreach my $tag (@{ $self->tags }) { + return 1 if lc($tag) eq $test_tag; + } + return 0; +} + +sub bug_comments { + my ($self, $args) = @_; + my $can_delete = Bugzilla->user->in_group(Bugzilla->params->{delete_comments_group}); + my $comments = $args->{comments}; + my @deleted = grep { $_->has_tag('deleted') } @$comments; + while (my $comment = pop @deleted) { + for (my $i = scalar(@$comments) - 1; $i >= 0; $i--) { + if ($comment == $comments->[$i]) { + if ($can_delete) { + # don't remove comment from users who can "delete" them + # just collapse it instead + $comment->{collapsed} = 1; + } + else { + # otherwise, remove it from the array + splice(@$comments, $i, 1); + } + last; + } + } + } +} + +__PACKAGE__->NAME; diff --git a/extensions/BMO/bin/bug_1022707.pl b/extensions/BMO/bin/bug_1022707.pl new file mode 100755 index 000000000..c27757220 --- /dev/null +++ b/extensions/BMO/bin/bug_1022707.pl @@ -0,0 +1,50 @@ +#!/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; + +use FindBin qw($RealBin); +use lib "$RealBin/../../.."; + +use Bugzilla; +use Bugzilla::Constants qw( USAGE_MODE_CMDLINE ); +BEGIN { Bugzilla->extensions() } + +Bugzilla->usage_mode(USAGE_MODE_CMDLINE); + +my $dbh = Bugzilla->dbh; + +my $sql = q{ + SELECT flags.id FROM flags + INNER JOIN bugs ON bugs.bug_id = flags.bug_id + WHERE type_id = 748 + AND bugs.product_id != 21 +}; + +print "Searching for suitable flags..\n"; +my $flag_ids = $dbh->selectcol_arrayref($sql); +my $total = @$flag_ids; + +die "No suitable flags found\n" unless $total; +print "About to fix $total flags\n"; +print "Press <enter> to start, or ^C to cancel...\n"; +readline; + +my $update_fsa_sql= "UPDATE flag_state_activity SET type_id = 4 WHERE " . $dbh->sql_in('flag_id', $flag_ids); +my $update_flags_sql = "UPDATE flags SET type_id = 4 WHERE " . $dbh->sql_in('id', $flag_ids); + +$dbh->bz_start_transaction(); +$dbh->do($update_fsa_sql); +$dbh->do($update_flags_sql); +$dbh->bz_commit_transaction(); + +Bugzilla->memcached->clear_all(); + +print "Done.\n"; diff --git a/extensions/BMO/bin/migrate-github-pull-requests.pl b/extensions/BMO/bin/migrate-github-pull-requests.pl new file mode 100755 index 000000000..de71a7856 --- /dev/null +++ b/extensions/BMO/bin/migrate-github-pull-requests.pl @@ -0,0 +1,90 @@ +#!/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; + +use FindBin qw($RealBin); +use lib "$RealBin/../../.."; + +use Bugzilla; +BEGIN { Bugzilla->extensions() } + +use Bugzilla::Extension::BMO::Data; +use Bugzilla::Field; +use Bugzilla::Install::Util qw(indicate_progress); +use Bugzilla::User; +use Bugzilla::Util qw(trim); + +my $dbh = Bugzilla->dbh; +my $nobody = Bugzilla::User->check({ name => 'nobody@mozilla.org' }); +my $field = Bugzilla::Field->check({ name => 'attachments.mimetype' }); + +# grab list of suitable attachments + +my $sql = <<EOF; +SELECT attachments.attach_id, + attachments.bug_id, + attachments.mimetype, + attach_data.thedata + FROM attachments + INNER JOIN attach_data ON attach_data.id = attachments.attach_id + WHERE ispatch = 0 + AND mimetype = 'text/plain' + AND thedata IS NOT NULL + AND LENGTH(thedata) > 0 + AND LENGTH(thedata) <= 256 +EOF +print "Searching for suitable attachments..\n"; +my $attachments = $dbh->selectall_arrayref($sql, { Slice => {} }); +my ($current, $total, $updated) = (1, scalar(@$attachments), 0); + +die "No suitable attachments found\n" unless $total; +print "About to check $total attachments for github pull requests, and\n"; +print "update content-type if required.\n"; +print "Press <enter> to start, or ^C to cancel...\n"; +<>; + +foreach my $attachment (@$attachments) { + indicate_progress({ current => $current++, total => $total, every => 25 }); + + # check payload + my $url = trim($attachment->{thedata}); + next if $url =~ /\s/; + next unless $url =~ m#^https://github\.com/[^/]+/[^/]+/pull/\d+\/?$#i; + + $dbh->bz_start_transaction; + + # set content-type + $dbh->do( + "UPDATE attachments SET mimetype = ? WHERE attach_id = ?", + undef, + GITHUB_PR_CONTENT_TYPE, $attachment->{attach_id} + ); + + # insert into bugs_activity + my $timestamp = $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); + $dbh->do( + "INSERT INTO bugs_activity(bug_id, who, bug_when, fieldid, removed, added) + VALUES (?, ?, ?, ?, ?, ?)", + undef, + $attachment->{bug_id}, $nobody->id, $timestamp, $field->id, + $attachment->{mimetype}, GITHUB_PR_CONTENT_TYPE + ); + $dbh->do( + "UPDATE bugs SET delta_ts = ?, lastdiffed = ? WHERE bug_id = ?", + undef, + $timestamp, $timestamp, $attachment->{bug_id} + ); + + $dbh->bz_commit_transaction; + $updated++; +} + +print "Attachments updated: $updated\n"; diff --git a/extensions/BMO/lib/Constants.pm b/extensions/BMO/lib/Constants.pm new file mode 100644 index 000000000..23eaae9cb --- /dev/null +++ b/extensions/BMO/lib/Constants.pm @@ -0,0 +1,33 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the BMO Bugzilla Extension. +# +# The Initial Developer of the Original Code is the Mozilla Foundation. +# Portions created by the Initial Developer are Copyright (C) 2007 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# David Lawrence <dkl@mozilla.com> + +package Bugzilla::Extension::BMO::Constants; +use strict; +use base qw(Exporter); +our @EXPORT = qw( + REQUEST_MAX_ATTACH_LINES +); + +# Maximum attachment size in lines that will be sent with a +# requested attachment flag notification. +use constant REQUEST_MAX_ATTACH_LINES => 1000; + +1; diff --git a/extensions/BMO/lib/Data.pm b/extensions/BMO/lib/Data.pm new file mode 100644 index 000000000..7d0ec05fd --- /dev/null +++ b/extensions/BMO/lib/Data.pm @@ -0,0 +1,242 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::BMO::Data; +use strict; + +use base qw(Exporter); +use Tie::IxHash; + +our @EXPORT = qw( $cf_visible_in_products + %group_change_notification + $cf_setters + @always_fileable_groups + %group_auto_cc + %product_sec_groups + %create_bug_formats + @default_named_queries + GITHUB_PR_CONTENT_TYPE + RB_REQUEST_CONTENT_TYPE ); + +use constant GITHUB_PR_CONTENT_TYPE => 'text/x-github-pull-request'; +use constant RB_REQUEST_CONTENT_TYPE => 'text/x-review-board-request'; + +# Which custom fields are visible in which products and components. +# +# By default, custom fields are visible in all products. However, if the name +# of the field matches any of these regexps, it is only visible if the +# product (and component if necessary) is a member of the attached hash. [] +# for component means "all". +# +# IxHash keeps them in insertion order, and so we get regexp priorities right. +our $cf_visible_in_products; +tie(%$cf_visible_in_products, "Tie::IxHash", + qr/^cf_colo_site$/ => { + "mozilla.org" => [ + "Server Operations", + "Server Operations: DCOps", + "Server Operations: Projects", + "Server Operations: RelEng", + "Server Operations: Security", + ], + "Infrastructure & Operations" => [ + "RelOps", + "RelOps: Puppet" + ], + }, + qw/^cf_office$/ => { + "mozilla.org" => ["Server Operations: Desktop Issues"], + }, + qr/^cf_crash_signature$/ => { + "Add-on SDK" => [], + "addons.mozilla.org" => [], + "Android Background Services" => [], + "Calendar" => [], + "Camino" => [], + "Composer" => [], + "Core" => [], + "Directory" => [], + "Fennec" => [], + "Firefox" => [], + "Firefox for Android" => [], + "Firefox for Metro" => [], + "Firefox OS" => [], + "JSS" => [], + "MailNews Core" => [], + "Mozilla Labs" => [], + "Mozilla Localizations" => [], + "mozilla.org" => [], + "Mozilla Services" => [], + "NSPR" => [], + "NSS" => [], + "Other Applications" => [], + "Penelope" => [], + "Plugins" => [], + "Release Engineering" => [], + "Rhino" => [], + "SeaMonkey" => [], + "Tamarin" => [], + "Tech Evangelism" => [], + "Testing" => [], + "Thunderbird" => [], + "Toolkit" => [], + }, + qw/^cf_due_date$/ => { + "bugzilla.mozilla.org" => [], + "Community Building" => [], + "Data & BI Services Team" => [], + "Developer Engagement" => [], + "Infrastructure & Operations" => [], + "Marketing" => [], + "mozilla.org" => ["Security Assurance: Review Request"], + "Mozilla Reps" => [], + }, + qw/^cf_locale$/ => { + "Mozilla Localizations" => ['Other'], + "www.mozilla.org" => [], + }, + qw/^cf_mozilla_project$/ => { + "Data & BI Services Team" => [], + }, + qw/^cf_machine_state$/ => { + "Release Engineering" => ["Buildduty"], + }, +); + +# Who to CC on particular bugmails when certain groups are added or removed. +our %group_change_notification = ( + 'addons-security' => ['amo-editors@mozilla.org'], + 'bugzilla-security' => ['security@bugzilla.org'], + 'client-services-security' => ['amo-admins@mozilla.org', 'web-security@mozilla.org'], + 'core-security' => ['security@mozilla.org'], + 'mozilla-services-security' => ['web-security@mozilla.org'], + 'tamarin-security' => ['tamarinsecurity@adobe.com'], + 'websites-security' => ['web-security@mozilla.org'], + 'webtools-security' => ['web-security@mozilla.org'], +); + +# Who can set custom flags (use full field names only, not regex's) +our $cf_setters = { + 'cf_colo_site' => ['infra', 'build'], +}; + +# Groups in which you can always file a bug, regardless of product or user. +our @always_fileable_groups = qw( + addons-security + bugzilla-security + client-services-security + consulting + core-security + finance + infra + infrasec + l20n-security + marketing-private + mozilla-confidential + mozilla-employee-confidential + mozilla-foundation-confidential + mozilla-engagement + mozilla-messaging-confidential + partner-confidential + payments-confidential + tamarin-security + websites-security + webtools-security + winqual-data +); + +# Mapping of products to their security bits +our %product_sec_groups = ( + "addons.mozilla.org" => 'client-services-security', + "Air Mozilla" => 'mozilla-employee-confidential', + "Android Background Services" => 'mozilla-services-security', + "Audio/Visual Infrastructure" => 'mozilla-employee-confidential', + "AUS" => 'client-services-security', + "Bugzilla" => 'bugzilla-security', + "bugzilla.mozilla.org" => 'bugzilla-security', + "Community Tools" => 'websites-security', + "Data & BI Services Team" => 'metrics-private', + "Developer Documentation" => 'websites-security', + "Developer Ecosystem" => 'client-services-security', + "Finance" => 'finance', + "Firefox Health Report" => 'mozilla-services-security', + "Infrastructure & Operations" => 'mozilla-employee-confidential', + "Input" => 'websites-security', + "Intellego" => 'intellego-team', + "Internet Public Policy" => 'mozilla-employee-confidential', + "L20n" => 'l20n-security', + "Legal" => 'legal', + "Marketing" => 'marketing-private', + "Marketplace" => 'client-services-security', + "Mozilla Communities" => 'mozilla-communities-security', + "Mozilla Corporation" => 'mozilla-employee-confidential', + "Mozilla Developer Network" => 'websites-security', + "Mozilla Foundation" => 'mozilla-employee-confidential', + "Mozilla Grants" => 'grants', + "mozillaignite" => 'websites-security', + "Mozilla Messaging" => 'mozilla-messaging-confidential', + "Mozilla Metrics" => 'metrics-private', + "mozilla.org" => 'mozilla-employee-confidential', + "Mozilla PR" => 'pr-private', + "Mozilla QA" => 'mozilla-employee-confidential', + "Mozilla Reps" => 'mozilla-reps', + "Mozilla Services" => 'mozilla-services-security', + "Popcorn" => 'websites-security', + "Privacy" => 'privacy', + "quality.mozilla.org" => 'websites-security', + "Release Engineering" => 'mozilla-employee-confidential', + "Snippets" => 'websites-security', + "Socorro" => 'client-services-security', + "support.mozillamessaging.com" => 'websites-security', + "support.mozilla.org" => 'websites-security', + "Talkback" => 'talkback-private', + "Tamarin" => 'tamarin-security', + "Testopia" => 'bugzilla-security', + "Tree Management" => 'mozilla-employee-confidential', + "Web Apps" => 'client-services-security', + "Webmaker" => 'websites-security', + "Websites" => 'websites-security', + "Webtools" => 'webtools-security', + "www.mozilla.org" => 'websites-security', + "Mozilla Foundation Operations" => 'mozilla-foundation-operations', + "_default" => 'core-security' +); + +# Automatically CC users to bugs filed into configured groups and products +our %group_auto_cc = ( + 'partner-confidential' => { + 'Marketing' => ['jbalaco@mozilla.com'], + '_default' => ['mbest@mozilla.com'], + }, +); + +# Force create-bug template by product +# Users in 'include' group will be forced into using the form. +our %create_bug_formats = ( + 'Mozilla Developer Network' => { + 'format' => 'mdn', + 'include' => 'everyone', + }, + 'Legal' => { + 'format' => 'legal', + 'include' => 'everyone', + }, + 'Internet Public Policy' => { + 'format' => 'ipp', + 'include' => 'everyone', + }, +); + +# List of named queries which will be added to new users' footer +our @default_named_queries = ( + { + name => 'Bugs Filed Today', + query => 'query_format=advanced&chfieldto=Now&chfield=[Bug creation]&chfieldfrom=-24h&order=bug_id', + }, +); + +1; diff --git a/extensions/BMO/lib/FakeBug.pm b/extensions/BMO/lib/FakeBug.pm new file mode 100644 index 000000000..6127cb560 --- /dev/null +++ b/extensions/BMO/lib/FakeBug.pm @@ -0,0 +1,42 @@ +package Bugzilla::Extension::BMO::FakeBug; + +# hack to allow the bug entry templates to use check_can_change_field to see if +# various field values should be available to the current user + +use strict; + +use Bugzilla::Bug; + +our $AUTOLOAD; + +sub new { + my $class = shift; + my $self = shift; + bless $self, $class; + return $self; +} + +sub AUTOLOAD { + my $self = shift; + my $name = $AUTOLOAD; + $name =~ s/.*://; + return exists $self->{$name} ? $self->{$name} : undef; +} + +sub check_can_change_field { + my $self = shift; + return Bugzilla::Bug::check_can_change_field($self, @_) +} + +sub _changes_everconfirmed { + my $self = shift; + return Bugzilla::Bug::_changes_everconfirmed($self, @_) +} + +sub everconfirmed { + my $self = shift; + return ($self->{'status'} == 'UNCONFIRMED') ? 0 : 1; +} + +1; + diff --git a/extensions/BMO/lib/Reports/EmailQueue.pm b/extensions/BMO/lib/Reports/EmailQueue.pm new file mode 100644 index 000000000..f1383aac7 --- /dev/null +++ b/extensions/BMO/lib/Reports/EmailQueue.pm @@ -0,0 +1,84 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::BMO::Reports::EmailQueue; +use strict; +use warnings; + +use Bugzilla::Error; +use Scalar::Util qw(blessed); +use Storable (); + +sub report { + my ($vars, $filter) = @_; + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + + $user->in_group('admin') || $user->in_group('infra') + || ThrowUserError('auth_failure', { group => 'admin', + action => 'run', + object => 'email_queue' }); + + my $query = " + SELECT j.jobid, + j.arg, + j.insert_time, + j.run_after AS run_time, + COUNT(e.jobid) AS error_count, + MAX(e.error_time) AS error_time, + e.message AS error_message + FROM ts_job j + LEFT JOIN ts_error e ON e.jobid = j.jobid + GROUP BY j.jobid + ORDER BY j.run_after"; + + $vars->{'jobs'} = $dbh->selectall_arrayref($query, { Slice => {} }); + foreach my $job (@{ $vars->{'jobs'} }) { + eval { + my ($recipient, $description); + my $arg = _cond_thaw(delete $job->{arg}); + + if (exists $arg->{vars}) { + my $vars = $arg->{vars}; + $recipient = $vars->{to_user}->{login_name}; + $description = '[Bug ' . $vars->{bug}->{bug_id} . '] ' . $vars->{bug}->{short_desc}; + } elsif (exists $arg->{msg}) { + my $msg = $arg->{msg}; + if (ref($msg) && blessed($msg) eq 'Email::MIME') { + $recipient = $msg->header('to'); + $description = $msg->header('subject'); + } else { + ($recipient) = $msg =~ /\nTo: ([^\n]+)/; + ($description) = $msg =~ /\nSubject: ([^\n]+)/; + } + } + + if ($recipient) { + $job->{subject} = "<$recipient> $description"; + } + }; + } + $vars->{'now'} = (time); +} + +sub _cond_thaw { + my $data = shift; + my $magic = eval { Storable::read_magic($data); }; + if ($magic && $magic->{major} && $magic->{major} >= 2 && $magic->{major} <= 5) { + my $thawed = eval { Storable::thaw($data) }; + if ($@) { + # false alarm... looked like a Storable, but wasn't. + return $data; + } + return $thawed; + } else { + return $data; + } +} + + +1; diff --git a/extensions/BMO/lib/Reports/Groups.pm b/extensions/BMO/lib/Reports/Groups.pm new file mode 100644 index 000000000..ab0f1efa4 --- /dev/null +++ b/extensions/BMO/lib/Reports/Groups.pm @@ -0,0 +1,243 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::BMO::Reports::Groups; +use strict; +use warnings; + +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Group; +use Bugzilla::User; +use Bugzilla::Util qw(trim); + +sub admins_report { + my ($vars) = @_; + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + + ($user->in_group('editusers') || $user->in_group('infrasec')) + || ThrowUserError('auth_failure', { group => 'editusers', + action => 'run', + object => 'group_admins' }); + + my $query = " + SELECT groups.name, " . + $dbh->sql_group_concat('profiles.login_name', "','", 1) . " + FROM groups + LEFT JOIN user_group_map + ON user_group_map.group_id = groups.id + AND user_group_map.isbless = 1 + AND user_group_map.grant_type = 0 + LEFT JOIN profiles + ON user_group_map.user_id = profiles.userid + WHERE groups.isbuggroup = 1 + GROUP BY groups.name"; + + my @groups; + foreach my $group (@{ $dbh->selectall_arrayref($query) }) { + my @admins; + if ($group->[1]) { + foreach my $admin (split(/,/, $group->[1])) { + push(@admins, Bugzilla::User->new({ name => $admin })); + } + } + push(@groups, { name => $group->[0], admins => \@admins }); + } + + $vars->{'groups'} = \@groups; +} + +sub membership_report { + my ($page, $vars) = @_; + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + my $cgi = Bugzilla->cgi; + + ($user->in_group('editusers') || $user->in_group('infrasec')) + || ThrowUserError('auth_failure', { group => 'editusers', + action => 'run', + object => 'group_admins' }); + + my $who = $cgi->param('who'); + if (!defined($who) || $who eq '') { + if ($page eq 'group_membership.txt') { + print $cgi->redirect("page.cgi?id=group_membership.html&output=txt"); + exit; + } + $vars->{'output'} = $cgi->param('output'); + return; + } + + Bugzilla::User::match_field({ 'who' => {'type' => 'multi'} }); + $who = Bugzilla->input_params->{'who'}; + $who = ref($who) ? $who : [ $who ]; + + my @users; + foreach my $login (@$who) { + my $u = Bugzilla::User->new(login_to_id($login, 1)); + + # this is lifted from $user->groups() + # we need to show which groups are direct and which are inherited + + my $groups_to_check = $dbh->selectcol_arrayref( + q{SELECT DISTINCT group_id + FROM user_group_map + WHERE user_id = ? AND isbless = 0}, undef, $u->id); + + my $rows = $dbh->selectall_arrayref( + "SELECT DISTINCT grantor_id, member_id + FROM group_group_map + WHERE grant_type = " . GROUP_MEMBERSHIP); + + my %group_membership; + foreach my $row (@$rows) { + my ($grantor_id, $member_id) = @$row; + push (@{ $group_membership{$member_id} }, $grantor_id); + } + + my %checked_groups; + my %direct_groups; + my %indirect_groups; + my %groups; + + foreach my $member_id (@$groups_to_check) { + $direct_groups{$member_id} = 1; + } + + while (scalar(@$groups_to_check) > 0) { + my $member_id = shift @$groups_to_check; + if (!$checked_groups{$member_id}) { + $checked_groups{$member_id} = 1; + my $members = $group_membership{$member_id}; + my @new_to_check = grep(!$checked_groups{$_}, @$members); + push(@$groups_to_check, @new_to_check); + foreach my $id (@new_to_check) { + $indirect_groups{$id} = $member_id; + } + $groups{$member_id} = 1; + } + } + + my @groups; + my $ra_groups = Bugzilla::Group->new_from_list([keys %groups]); + foreach my $group (@$ra_groups) { + my $via; + if ($direct_groups{$group->id}) { + $via = ''; + } else { + foreach my $g (@$ra_groups) { + if ($g->id == $indirect_groups{$group->id}) { + $via = $g->name; + last; + } + } + } + push @groups, { + name => $group->name, + desc => $group->description, + via => $via, + }; + } + + push @users, { + user => $u, + groups => \@groups, + }; + } + + $vars->{'who'} = $who; + $vars->{'users'} = \@users; +} + +sub members_report { + my ($vars) = @_; + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + my $cgi = Bugzilla->cgi; + + ($user->in_group('editusers') || $user->in_group('infrasec')) + || ThrowUserError('auth_failure', { group => 'editusers', + action => 'run', + object => 'group_admins' }); + + my $include_disabled = $cgi->param('include_disabled') ? 1 : 0; + $vars->{'include_disabled'} = $include_disabled; + + # don't allow all groups, to avoid putting pain on the servers + my @group_names = + sort + grep { !/^(?:bz_.+|canconfirm|editbugs|editbugs-team|everyone)$/ } + map { lc($_->name) } + Bugzilla::Group->get_all; + unshift(@group_names, ''); + $vars->{'groups'} = \@group_names; + + # load selected group + my $group = lc(trim($cgi->param('group') || '')); + $group = '' unless grep { $_ eq $group } @group_names; + return if $group eq ''; + my $group_obj = Bugzilla::Group->new({ name => $group }); + $vars->{'group'} = $group; + + # direct members + my @types = ( + { + name => 'direct', + members => _filter_userlist($group_obj->members_direct, $include_disabled), + }, + ); + + # indirect members, by group + foreach my $member_group (sort @{ $group_obj->grant_direct(GROUP_MEMBERSHIP) }) { + push @types, { + name => $member_group->name, + members => _filter_userlist($member_group->members_direct, $include_disabled), + }, + } + + # make it easy for the template to detect an empty group + my $has_members = 0; + foreach my $type (@types) { + $has_members += scalar(@{ $type->{members} }); + last if $has_members; + } + @types = () unless $has_members; + + if (@types) { + # add last-login + my $user_ids = join(',', map { map { $_->id } @{ $_->{members} } } @types); + my $tokens = $dbh->selectall_hashref(" + SELECT profiles.userid, + (SELECT DATEDIFF(curdate(), logincookies.lastused) lastseen + FROM logincookies + WHERE logincookies.userid = profiles.userid + ORDER BY lastused DESC + LIMIT 1) lastseen + FROM profiles + WHERE userid IN ($user_ids)", + 'userid'); + foreach my $type (@types) { + foreach my $member (@{ $type->{members} }) { + $member->{lastseen} = + defined $tokens->{$member->id}->{lastseen} + ? $tokens->{$member->id}->{lastseen} + : '>' . MAX_LOGINCOOKIE_AGE; + } + } + } + + $vars->{'types'} = \@types; +} + +sub _filter_userlist { + my ($list, $include_disabled) = @_; + $list = [ grep { $_->is_enabled } @$list ] unless $include_disabled; + return [ sort { lc($a->identity) cmp lc($b->identity) } @$list ]; +} + +1; diff --git a/extensions/BMO/lib/Reports/ProductSecurity.pm b/extensions/BMO/lib/Reports/ProductSecurity.pm new file mode 100644 index 000000000..2324e725a --- /dev/null +++ b/extensions/BMO/lib/Reports/ProductSecurity.pm @@ -0,0 +1,67 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::BMO::Reports::ProductSecurity; +use strict; +use warnings; + +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Product; + +sub report { + my ($vars) = @_; + my $user = Bugzilla->user; + + ($user->in_group('admin') || $user->in_group('infrasec')) + || ThrowUserError('auth_failure', { group => 'admin', + action => 'run', + object => 'product_security' }); + + my $moco = Bugzilla::Group->new({ name => 'mozilla-employee-confidential' }) + or return; + + my $products = []; + foreach my $product (@{ Bugzilla::Product->match({}) }) { + my $default_group = $product->default_security_group_obj; + my $group_controls = $product->group_controls(); + + my $item = { + name => $product->name, + default_security_group => $product->default_security_group, + group_visibility => 'None/None', + moco => exists $group_controls->{$moco->id}, + }; + + if ($default_group) { + if (my $control = $group_controls->{$default_group->id}) { + $item->{group_visibility} = control_to_string($control->{membercontrol}) . + '/' . control_to_string($control->{othercontrol}); + } + } + + $item->{group_problem} = $default_group ? '' : "Invalid group " . $product->default_security_group; + $item->{visibility_problem} = 'Default security group should be Shown/Shown' + if ($item->{group_visibility} ne 'Shown/Shown') + && ($item->{group_visibility} ne 'Mandatory/Mandatory') + && ($item->{group_visibility} ne 'Default/Default'); + + push @$products, $item; + } + $vars->{products} = $products; +} + +sub control_to_string { + my ($control) = @_; + return 'NA' if $control == CONTROLMAPNA; + return 'Shown' if $control == CONTROLMAPSHOWN; + return 'Default' if $control == CONTROLMAPDEFAULT; + return 'Mandatory' if $control == CONTROLMAPMANDATORY; + return ''; +} + +1; diff --git a/extensions/BMO/lib/Reports/ReleaseTracking.pm b/extensions/BMO/lib/Reports/ReleaseTracking.pm new file mode 100644 index 000000000..5a07ae196 --- /dev/null +++ b/extensions/BMO/lib/Reports/ReleaseTracking.pm @@ -0,0 +1,409 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::BMO::Reports::ReleaseTracking; +use strict; +use warnings; + +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Extension::BMO::Util; +use Bugzilla::Field; +use Bugzilla::FlagType; +use Bugzilla::Util qw(correct_urlbase trick_taint); +use JSON qw(-convert_blessed_universally); +use List::MoreUtils qw(uniq); + +sub report { + my ($vars) = @_; + my $dbh = Bugzilla->dbh; + my $input = Bugzilla->input_params; + my $user = Bugzilla->user; + + my @flag_names = qw( + approval-mozilla-release + approval-mozilla-beta + approval-mozilla-aurora + approval-mozilla-central + approval-comm-release + approval-comm-beta + approval-comm-aurora + approval-calendar-release + approval-calendar-beta + approval-calendar-aurora + approval-mozilla-esr10 + ); + + my @flags_json; + my @fields_json; + my @products_json; + + # + # tracking flags + # + + my $all_products = $user->get_selectable_products; + my @usable_products; + + # build list of flags and their matching products + + my @invalid_flag_names; + foreach my $flag_name (@flag_names) { + # grab all matching flag_types + my @flag_types = @{Bugzilla::FlagType::match({ name => $flag_name, is_active => 1 })}; + + # remove invalid flags + if (!@flag_types) { + push @invalid_flag_names, $flag_name; + next; + } + + # we need a list of products, based on inclusions/exclusions + my @products; + my %flag_types; + foreach my $flag_type (@flag_types) { + $flag_types{$flag_type->name} = $flag_type->id; + my $has_all = 0; + my @exclusion_ids; + my @inclusion_ids; + foreach my $flag_type (@flag_types) { + if (scalar keys %{$flag_type->inclusions}) { + my $inclusions = $flag_type->inclusions; + foreach my $key (keys %$inclusions) { + push @inclusion_ids, ($inclusions->{$key} =~ /^(\d+)/); + } + } elsif (scalar keys %{$flag_type->exclusions}) { + my $exclusions = $flag_type->exclusions; + foreach my $key (keys %$exclusions) { + push @exclusion_ids, ($exclusions->{$key} =~ /^(\d+)/); + } + } else { + $has_all = 1; + last; + } + } + + if ($has_all) { + push @products, @$all_products; + } elsif (scalar @exclusion_ids) { + push @products, @$all_products; + foreach my $exclude_id (uniq @exclusion_ids) { + @products = grep { $_->id != $exclude_id } @products; + } + } else { + foreach my $include_id (uniq @inclusion_ids) { + push @products, grep { $_->id == $include_id } @$all_products; + } + } + } + @products = uniq @products; + push @usable_products, @products; + my @product_ids = map { $_->id } sort { lc($a->name) cmp lc($b->name) } @products; + + push @flags_json, { + name => $flag_name, + id => $flag_types{$flag_name} || 0, + products => \@product_ids, + fields => [], + }; + } + foreach my $flag_name (@invalid_flag_names) { + @flag_names = grep { $_ ne $flag_name } @flag_names; + } + @usable_products = uniq @usable_products; + + # build a list of tracking flags for each product + # also build the list of all fields + + my @unlink_products; + foreach my $product (@usable_products) { + my @fields = + grep { is_active_status_field($_) } + Bugzilla->active_custom_fields({ product => $product }); + my @field_ids = map { $_->id } @fields; + if (!scalar @fields) { + push @unlink_products, $product; + next; + } + + # product + push @products_json, { + name => $product->name, + id => $product->id, + fields => \@field_ids, + }; + + # add fields to flags + foreach my $rh (@flags_json) { + if (grep { $_ eq $product->id } @{$rh->{products}}) { + push @{$rh->{fields}}, @field_ids; + } + } + + # add fields to fields_json + foreach my $field (@fields) { + my $existing = 0; + foreach my $rh (@fields_json) { + if ($rh->{id} == $field->id) { + $existing = 1; + last; + } + } + if (!$existing) { + push @fields_json, { + name => $field->name, + id => $field->id, + }; + } + } + } + foreach my $rh (@flags_json) { + my @fields = uniq @{$rh->{fields}}; + $rh->{fields} = \@fields; + } + + # remove products which aren't linked with status fields + + foreach my $rh (@flags_json) { + my @product_ids; + foreach my $id (@{$rh->{products}}) { + unless (grep { $_->id == $id } @unlink_products) { + push @product_ids, $id; + } + $rh->{products} = \@product_ids; + } + } + + # + # rapid release dates + # + + my @ranges; + my $start_date = string_to_datetime('2011-08-16'); + my $end_date = $start_date->clone->add(weeks => 6)->add(days => -1); + my $now_date = string_to_datetime('2012-11-19'); + + while ($start_date <= $now_date) { + unshift @ranges, { + value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')), + label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')), + }; + + $start_date = $end_date->clone;; + $start_date->add(days => 1); + $end_date->add(weeks => 6); + } + + # 2012-11-20 - 2013-01-06 was a 7 week release cycle instead of 6 + $start_date = string_to_datetime('2012-11-20'); + $end_date = $start_date->clone->add(weeks => 7)->add(days => -1); + unshift @ranges, { + value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')), + label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')), + }; + + # Back on track with 6 week releases + $start_date = string_to_datetime('2013-01-08'); + $end_date = $start_date->clone->add(weeks => 6)->add(days => -1); + $now_date = time_to_datetime((time)); + + while ($start_date <= $now_date) { + unshift @ranges, { + value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')), + label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')), + }; + + $start_date = $end_date->clone;; + $start_date->add(days => 1); + $end_date->add(weeks => 6); + } + + push @ranges, { + value => '*', + label => 'Anytime', + }; + + # + # run report + # + + if ($input->{q} && !$input->{edit}) { + my $q = _parse_query($input->{q}); + + my @where; + my @params; + my $query = " + SELECT DISTINCT b.bug_id + FROM bugs b + INNER JOIN flags f ON f.bug_id = b.bug_id "; + + if ($q->{start_date}) { + $query .= "INNER JOIN bugs_activity a ON a.bug_id = b.bug_id "; + } + + if (grep($_ == FIELD_TYPE_EXTENSION, map { $_->{type} } @{ $q->{fields} })) { + $query .= "LEFT JOIN tracking_flags_bugs AS tfb ON tfb.bug_id = b.bug_id " . + "LEFT JOIN tracking_flags AS tf ON tfb.tracking_flag_id = tf.id "; + } + + $query .= "WHERE "; + + if ($q->{start_date}) { + push @where, "(a.fieldid = ?)"; + push @params, $q->{field_id}; + + push @where, "(a.bug_when >= ?)"; + push @params, $q->{start_date} . ' 00:00:00'; + push @where, "(a.bug_when < ?)"; + push @params, $q->{end_date} . ' 00:00:00'; + + push @where, "(a.added LIKE ?)"; + push @params, '%' . $q->{flag_name} . $q->{flag_status} . '%'; + } + + my ($type_id) = $dbh->selectrow_array( + "SELECT id FROM flagtypes WHERE name = ?", + undef, + $q->{flag_name} + ); + push @where, "(f.type_id = ?)"; + push @params, $type_id; + + push @where, "(f.status = ?)"; + push @params, $q->{flag_status}; + + if ($q->{product_id}) { + push @where, "(b.product_id = ?)"; + push @params, $q->{product_id}; + } + + if (scalar @{$q->{fields}}) { + my @fields; + foreach my $field (@{$q->{fields}}) { + my $field_sql = "("; + if ($field->{type} == FIELD_TYPE_EXTENSION) { + $field_sql .= "tf.name = " . $dbh->quote($field->{name}) . " AND COALESCE(tfb.value, '')"; + } + else { + $field_sql .= "b." . $field->{name}; + } + $field_sql .= " " . ($field->{value} eq '+' ? '' : 'NOT ') . "IN ('fixed','verified'))"; + push(@fields, $field_sql); + } + my $join = uc $q->{join}; + push @where, '(' . join(" $join ", @fields) . ')'; + } + + $query .= join("\nAND ", @where); + + if ($input->{debug}) { + print "Content-Type: text/plain\n\n"; + $query =~ s/\?/\000/g; + foreach my $param (@params) { + $query =~ s/\000/'$param'/; + } + print "$query\n"; + exit; + } + + my $bugs = $dbh->selectcol_arrayref($query, undef, @params); + push @$bugs, 0 unless @$bugs; + + my $urlbase = correct_urlbase(); + my $cgi = Bugzilla->cgi; + print $cgi->redirect( + -url => "${urlbase}buglist.cgi?bug_id=" . join(',', @$bugs) + ); + exit; + } + + # + # set template vars + # + + my $json = JSON->new(); + if (0) { + # debugging + $json->shrink(0); + $json->canonical(1); + $vars->{flags_json} = $json->pretty->encode(\@flags_json); + $vars->{products_json} = $json->pretty->encode(\@products_json); + $vars->{fields_json} = $json->pretty->encode(\@fields_json); + } else { + $json->shrink(1); + $vars->{flags_json} = $json->encode(\@flags_json); + $vars->{products_json} = $json->encode(\@products_json); + $vars->{fields_json} = $json->encode(\@fields_json); + } + + $vars->{flag_names} = \@flag_names; + $vars->{ranges} = \@ranges; + $vars->{default_query} = $input->{q}; + foreach my $field (qw(product flags range)) { + $vars->{$field} = $input->{$field}; + } +} + +sub _parse_query { + my $q = shift; + my @query = split(/:/, $q); + my $query; + + # field_id for flag changes + $query->{field_id} = get_field_id('flagtypes.name'); + + # flag_name + my $flag_name = shift @query; + @{Bugzilla::FlagType::match({ name => $flag_name, is_active => 1 })} + or ThrowUserError('report_invalid_parameter', { name => 'flag_name' }); + trick_taint($flag_name); + $query->{flag_name} = $flag_name; + + # flag_status + my $flag_status = shift @query; + $flag_status =~ /^([\?\-\+])$/ + or ThrowUserError('report_invalid_parameter', { name => 'flag_status' }); + $query->{flag_status} = $1; + + # date_range -> from_ymd to_ymd + my $date_range = shift @query; + if ($date_range ne '*') { + $date_range =~ /^(\d\d\d\d)(\d\d)(\d\d)-(\d\d\d\d)(\d\d)(\d\d)$/ + or ThrowUserError('report_invalid_parameter', { name => 'date_range' }); + $query->{start_date} = "$1-$2-$3"; + $query->{end_date} = "$4-$5-$6"; + } + + # product_id + my $product_id = shift @query; + $product_id =~ /^(\d+)$/ + or ThrowUserError('report_invalid_parameter', { name => 'product_id' }); + $query->{product_id} = $1; + + # join + my $join = shift @query; + $join =~ /^(and|or)$/ + or ThrowUserError('report_invalid_parameter', { name => 'join' }); + $query->{join} = $1; + + # fields + my @fields; + foreach my $field (@query) { + $field =~ /^(\d+)([\-\+])$/ + or ThrowUserError('report_invalid_parameter', { name => 'fields' }); + my ($id, $value) = ($1, $2); + my $field_obj = Bugzilla::Field->new($id) + or ThrowUserError('report_invalid_parameter', { name => 'field_id' }); + push @fields, { id => $id, value => $value, + name => $field_obj->name, type => $field_obj->type }; + } + $query->{fields} = \@fields; + + return $query; +} + +1; diff --git a/extensions/BMO/lib/Reports/Triage.pm b/extensions/BMO/lib/Reports/Triage.pm new file mode 100644 index 000000000..debb50577 --- /dev/null +++ b/extensions/BMO/lib/Reports/Triage.pm @@ -0,0 +1,217 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::BMO::Reports::Triage; +use strict; + +use Bugzilla::Component; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Product; +use Bugzilla::User; +use Bugzilla::Util qw(detaint_natural); +use Date::Parse; + +# set an upper limit on the *unfiltered* number of bugs to process +use constant MAX_NUMBER_BUGS => 4000; + +sub report { + my ($vars, $filter) = @_; + my $dbh = Bugzilla->dbh; + my $input = Bugzilla->input_params; + my $user = Bugzilla->user; + + if (exists $input->{'action'} && $input->{'action'} eq 'run' && $input->{'product'}) { + + # load product and components from input + + my $product = Bugzilla::Product->new({ name => $input->{'product'} }) + || ThrowUserError('invalid_object', { object => 'Product', value => $input->{'product'} }); + + my @component_ids; + if ($input->{'component'} ne '') { + my $ra_components = ref($input->{'component'}) + ? $input->{'component'} : [ $input->{'component'} ]; + foreach my $component_name (@$ra_components) { + my $component = Bugzilla::Component->new({ name => $component_name, product => $product }) + || ThrowUserError('invalid_object', { object => 'Component', value => $component_name }); + push @component_ids, $component->id; + } + } + + # determine which comment filters to run + + my $filter_commenter = $input->{'filter_commenter'}; + my $filter_commenter_on = $input->{'commenter'}; + my $filter_last = $input->{'filter_last'}; + my $filter_last_period = $input->{'last'}; + + if (!$filter_commenter || $filter_last) { + $filter_commenter = '1'; + $filter_commenter_on = 'reporter'; + } + + my $filter_commenter_id; + if ($filter_commenter && $filter_commenter_on eq 'is') { + Bugzilla::User::match_field({ 'commenter_is' => {'type' => 'single'} }); + my $user = Bugzilla::User->new({ name => $input->{'commenter_is'} }) + || ThrowUserError('invalid_object', { object => 'User', value => $input->{'commenter_is'} }); + $filter_commenter_id = $user ? $user->id : 0; + } + + my $filter_last_time; + if ($filter_last) { + if ($filter_last_period eq 'is') { + $filter_last_period = -1; + $filter_last_time = str2time($input->{'last_is'} . " 00:00:00") || 0; + } else { + detaint_natural($filter_last_period); + $filter_last_period = 14 if $filter_last_period < 14; + } + } + + # form sql queries + + my $now = (time); + my $bugs_sql = " + SELECT bug_id, short_desc, reporter, creation_ts + FROM bugs + WHERE product_id = ? + AND bug_status = 'UNCONFIRMED'"; + if (@component_ids) { + $bugs_sql .= " AND component_id IN (" . join(',', @component_ids) . ")"; + } + $bugs_sql .= " + ORDER BY creation_ts + "; + + my $comment_count_sql = " + SELECT COUNT(*) + FROM longdescs + WHERE bug_id = ? + "; + + my $comment_sql = " + SELECT who, bug_when, type, thetext, extra_data + FROM longdescs + WHERE bug_id = ? + "; + if (!Bugzilla->user->is_insider) { + $comment_sql .= " AND isprivate = 0 "; + } + $comment_sql .= " + ORDER BY bug_when DESC + LIMIT 1 + "; + + my $attach_sql = " + SELECT description, isprivate + FROM attachments + WHERE attach_id = ? + "; + + # work on an initial list of bugs + + my $list = $dbh->selectall_arrayref($bugs_sql, undef, $product->id); + my @bugs; + + # this can be slow to process, resulting in 'service unavailable' errors from zeus + # so if too many bugs are returned, throw an error + + if (scalar(@$list) > MAX_NUMBER_BUGS) { + ThrowUserError('report_too_many_bugs'); + } + + foreach my $entry (@$list) { + my ($bug_id, $summary, $reporter_id, $creation_ts) = @$entry; + + next unless $user->can_see_bug($bug_id); + + # get last comment information + + my ($comment_count) = $dbh->selectrow_array($comment_count_sql, undef, $bug_id); + my ($commenter_id, $comment_ts, $type, $comment, $extra) + = $dbh->selectrow_array($comment_sql, undef, $bug_id); + my $commenter = 0; + + # apply selected filters + + if ($filter_commenter) { + next if $comment_count <= 1; + + if ($filter_commenter_on eq 'reporter') { + next if $commenter_id != $reporter_id; + + } elsif ($filter_commenter_on eq 'noconfirm') { + $commenter = Bugzilla::User->new({ id => $commenter_id, cache => 1 }); + next if $commenter_id != $reporter_id + || $commenter->in_group('canconfirm'); + + } elsif ($filter_commenter_on eq 'is') { + next if $commenter_id != $filter_commenter_id; + } + } else { + $input->{'commenter'} = ''; + $input->{'commenter_is'} = ''; + } + + if ($filter_last) { + my $comment_time = str2time($comment_ts) + or next; + if ($filter_last_period == -1) { + next if $comment_time >= $filter_last_time; + } else { + next if $now - $comment_time <= 60 * 60 * 24 * $filter_last_period; + } + } else { + $input->{'last'} = ''; + $input->{'last_is'} = ''; + } + + # get data for attachment comments + + if ($comment eq '' && $type == CMT_ATTACHMENT_CREATED) { + my ($description, $is_private) = $dbh->selectrow_array($attach_sql, undef, $extra); + next if $is_private && !Bugzilla->user->is_insider; + $comment = "(Attachment) " . $description; + } + + # truncate long comments + + if (length($comment) > 80) { + $comment = substr($comment, 0, 80) . '...'; + } + + # build bug hash for template + + my $bug = {}; + $bug->{id} = $bug_id; + $bug->{summary} = $summary; + $bug->{reporter} = Bugzilla::User->new({ id => $reporter_id, cache => 1 }); + $bug->{creation_ts} = $creation_ts; + $bug->{commenter} = $commenter || Bugzilla::User->new({ id => $commenter_id, cache => 1 }); + $bug->{comment_ts} = $comment_ts; + $bug->{comment} = $comment; + $bug->{comment_count} = $comment_count; + push @bugs, $bug; + } + + @bugs = sort { $b->{comment_ts} cmp $a->{comment_ts} } @bugs; + + $vars->{bugs} = \@bugs; + } else { + $input->{action} = ''; + } + + if (!$input->{filter_commenter} && !$input->{filter_last}) { + $input->{filter_commenter} = 1; + } + + $vars->{'input'} = $input; +} + +1; diff --git a/extensions/BMO/lib/Reports/UserActivity.pm b/extensions/BMO/lib/Reports/UserActivity.pm new file mode 100644 index 000000000..04810c2ec --- /dev/null +++ b/extensions/BMO/lib/Reports/UserActivity.pm @@ -0,0 +1,327 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::BMO::Reports::UserActivity; +use strict; +use warnings; + +use Bugzilla::Error; +use Bugzilla::Extension::BMO::Util; +use Bugzilla::User; +use Bugzilla::Util qw(trim); +use DateTime; + +sub report { + my ($vars) = @_; + my $dbh = Bugzilla->dbh; + my $input = Bugzilla->input_params; + + my @who = (); + my $from = trim($input->{'from'} || ''); + my $to = trim($input->{'to'} || ''); + my $action = $input->{'action'} || ''; + + # fix non-breaking hyphens + $from =~ s/\N{U+2011}/-/g; + $to =~ s/\N{U+2011}/-/g; + + if ($from eq '') { + my $dt = DateTime->now()->subtract('weeks' => 1); + $from = $dt->ymd('-'); + } + if ($to eq '') { + my $dt = DateTime->now(); + $to = $dt->ymd('-'); + } + + if ($action eq 'run') { + if (!exists $input->{'who'} || $input->{'who'} eq '') { + ThrowUserError('user_activity_missing_username'); + } + Bugzilla::User::match_field({ 'who' => {'type' => 'multi'} }); + + my $from_dt = string_to_datetime($from); + $from = $from_dt->ymd(); + + my $to_dt = string_to_datetime($to); + $to = $to_dt->ymd(); + + my ($activity_joins, $activity_where) = ('', ''); + my ($attachments_joins, $attachments_where) = ('', ''); + my ($tags_activity_joins, $tags_activity_where) = ('', ''); + if (Bugzilla->params->{"insidergroup"} + && !Bugzilla->user->in_group(Bugzilla->params->{'insidergroup'})) + { + $activity_joins = "LEFT JOIN attachments + ON attachments.attach_id = bugs_activity.attach_id"; + $activity_where = "AND COALESCE(attachments.isprivate, 0) = 0"; + $attachments_where = $activity_where; + + $tags_activity_joins = 'LEFT JOIN longdescs + ON longdescs_tags_activity.comment_id = longdescs.comment_id'; + $tags_activity_where = 'AND COALESCE(longdescs.isprivate, 0) = 0'; + } + + my @who_bits; + foreach my $who ( + ref $input->{'who'} + ? @{$input->{'who'}} + : $input->{'who'} + ) { + push @who, $who; + push @who_bits, '?'; + } + my $who_bits = join(',', @who_bits); + + if (!@who) { + my $template = Bugzilla->template; + my $cgi = Bugzilla->cgi; + my $vars = {}; + $vars->{'script'} = $cgi->url(-relative => 1); + $vars->{'fields'} = {}; + $vars->{'matches'} = []; + $vars->{'matchsuccess'} = 0; + $vars->{'matchmultiple'} = 1; + print $cgi->header(); + $template->process("global/confirm-user-match.html.tmpl", $vars) + || ThrowTemplateError($template->error()); + exit; + } + + $from_dt = $from_dt->ymd() . ' 00:00:00'; + $to_dt = $to_dt->ymd() . ' 23:59:59'; + my @params; + for (1..5) { + push @params, @who; + push @params, ($from_dt, $to_dt); + } + + my $order = ($input->{'group'} && $input->{'group'} eq 'bug') + ? 'bug_id, bug_when' : 'bug_when'; + + my $comment_filter = ''; + if (!Bugzilla->user->is_insider) { + $comment_filter = 'AND longdescs.isprivate = 0'; + } + + my $query = " + SELECT + fielddefs.name, + bugs_activity.bug_id, + bugs_activity.attach_id, + ".$dbh->sql_date_format('bugs_activity.bug_when', '%Y.%m.%d %H:%i:%s')." AS ts, + bugs_activity.removed, + bugs_activity.added, + profiles.login_name, + bugs_activity.comment_id, + bugs_activity.bug_when + FROM bugs_activity + $activity_joins + LEFT JOIN fielddefs + ON bugs_activity.fieldid = fielddefs.id + INNER JOIN profiles + ON profiles.userid = bugs_activity.who + WHERE profiles.login_name IN ($who_bits) + AND bugs_activity.bug_when >= ? AND bugs_activity.bug_when <= ? + $activity_where + + UNION ALL + + SELECT + 'comment_tag' AS name, + longdescs_tags_activity.bug_id, + NULL as attach_id, + ".$dbh->sql_date_format('longdescs_tags_activity.bug_when', + '%Y.%m.%d %H:%i:%s') . " AS bug_when, + longdescs_tags_activity.removed, + longdescs_tags_activity.added, + profiles.login_name, + longdescs_tags_activity.comment_id, + longdescs_tags_activity.bug_when + FROM longdescs_tags_activity + $tags_activity_joins + INNER JOIN profiles + ON profiles.userid = longdescs_tags_activity.who + WHERE profiles.login_name IN ($who_bits) + AND longdescs_tags_activity.bug_when >= ? + AND longdescs_tags_activity.bug_when <= ? + $tags_activity_where + + UNION ALL + + SELECT + 'bug_id' AS name, + bugs.bug_id, + NULL AS attach_id, + ".$dbh->sql_date_format('bugs.creation_ts', '%Y.%m.%d %H:%i:%s')." AS ts, + '(new bug)' AS removed, + bugs.short_desc AS added, + profiles.login_name, + NULL AS comment_id, + bugs.creation_ts AS bug_when + FROM bugs + INNER JOIN profiles + ON profiles.userid = bugs.reporter + WHERE profiles.login_name IN ($who_bits) + AND bugs.creation_ts >= ? AND bugs.creation_ts <= ? + + UNION ALL + + SELECT + 'longdesc' AS name, + longdescs.bug_id, + NULL AS attach_id, + DATE_FORMAT(longdescs.bug_when, '%Y.%m.%d %H:%i:%s') AS ts, + '' AS removed, + '' AS added, + profiles.login_name, + longdescs.comment_id AS comment_id, + longdescs.bug_when + FROM longdescs + INNER JOIN profiles + ON profiles.userid = longdescs.who + WHERE profiles.login_name IN ($who_bits) + AND longdescs.bug_when >= ? AND longdescs.bug_when <= ? + $comment_filter + + UNION ALL + + SELECT + 'attachments.description' AS name, + attachments.bug_id, + attachments.attach_id, + ".$dbh->sql_date_format('attachments.creation_ts', '%Y.%m.%d %H:%i:%s')." AS ts, + '(new attachment)' AS removed, + attachments.description AS added, + profiles.login_name, + NULL AS comment_id, + attachments.creation_ts AS bug_when + FROM attachments + INNER JOIN profiles + ON profiles.userid = attachments.submitter_id + WHERE profiles.login_name IN ($who_bits) + AND attachments.creation_ts >= ? AND attachments.creation_ts <= ? + $attachments_where + + ORDER BY $order "; + + my $list = $dbh->selectall_arrayref($query, undef, @params); + + if ($input->{debug}) { + while (my $param = shift @params) { + $query =~ s/\?/$dbh->quote($param)/e; + } + $vars->{debug_sql} = $query; + } + + my @operations; + my $operation = {}; + my $changes = []; + my $incomplete_data = 0; + my %bug_ids; + + foreach my $entry (@$list) { + my ($fieldname, $bugid, $attachid, $when, $removed, $added, $who, + $comment_id) = @$entry; + my %change; + my $activity_visible = 1; + + next unless Bugzilla->user->can_see_bug($bugid); + + # check if the user should see this field's activity + if ($fieldname eq 'remaining_time' + || $fieldname eq 'estimated_time' + || $fieldname eq 'work_time' + || $fieldname eq 'deadline') + { + $activity_visible = Bugzilla->user->is_timetracker; + } + elsif ($fieldname eq 'longdescs.isprivate' + && !Bugzilla->user->is_insider + && $added) + { + $activity_visible = 0; + } + else { + $activity_visible = 1; + } + + if ($activity_visible) { + # Check for the results of an old Bugzilla data corruption bug + if (($added eq '?' && $removed eq '?') + || ($added =~ /^\? / || $removed =~ /^\? /)) { + $incomplete_data = 1; + } + + # Start a new changeset if required (depends on the grouping type) + my $is_new_changeset; + if ($order eq 'bug_when') { + $is_new_changeset = + $operation->{'who'} && + ( + $who ne $operation->{'who'} + || $when ne $operation->{'when'} + || $bugid != $operation->{'bug'} + ); + } else { + $is_new_changeset = + $operation->{'bug'} && + $bugid != $operation->{'bug'}; + } + if ($is_new_changeset) { + $operation->{'changes'} = $changes; + push (@operations, $operation); + $operation = {}; + $changes = []; + } + + $bug_ids{$bugid} = 1; + + $operation->{'bug'} = $bugid; + $operation->{'who'} = $who; + $operation->{'when'} = $when; + + $change{'fieldname'} = $fieldname; + $change{'attachid'} = $attachid; + $change{'removed'} = $removed; + $change{'added'} = $added; + $change{'when'} = $when; + + if ($comment_id) { + $change{'comment'} = Bugzilla::Comment->new($comment_id); + next if $change{'comment'}->count == 0; + } + + if ($attachid) { + $change{'attach'} = Bugzilla::Attachment->new($attachid); + } + + push (@$changes, \%change); + } + } + + if ($operation->{'who'}) { + $operation->{'changes'} = $changes; + push (@operations, $operation); + } + + $vars->{'incomplete_data'} = $incomplete_data; + $vars->{'operations'} = \@operations; + + my @bug_ids = sort { $a <=> $b } keys %bug_ids; + $vars->{'bug_ids'} = \@bug_ids; + } + + $vars->{'action'} = $action; + $vars->{'who'} = join(',', @who); + $vars->{'who_count'} = scalar @who; + $vars->{'from'} = $from; + $vars->{'to'} = $to; + $vars->{'group'} = $input->{'group'}; +} + +1; diff --git a/extensions/BMO/lib/Util.pm b/extensions/BMO/lib/Util.pm new file mode 100644 index 000000000..df781b9d2 --- /dev/null +++ b/extensions/BMO/lib/Util.pm @@ -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. + +package Bugzilla::Extension::BMO::Util; +use strict; +use warnings; + +use Bugzilla::Constants; +use Bugzilla::Error; +use Date::Parse; +use DateTime; + +use base qw(Exporter); + +our @EXPORT = qw( string_to_datetime + time_to_datetime + parse_date + is_active_status_field ); + +sub string_to_datetime { + my $input = shift; + my $time = parse_date($input) + or ThrowUserError('report_invalid_date', { date => $input }); + return time_to_datetime($time); +} + +sub time_to_datetime { + my $time = shift; + return DateTime->from_epoch(epoch => $time) + ->set_time_zone('local') + ->truncate(to => 'day'); +} + +sub parse_date { + my ($str) = @_; + if ($str =~ /^(-|\+)?(\d+)([hHdDwWmMyY])$/) { + # relative date + my ($sign, $amount, $unit, $date) = ($1, $2, lc $3, time); + my ($sec, $min, $hour, $mday, $month, $year, $wday) = localtime($date); + $amount = -$amount if $sign && $sign eq '+'; + if ($unit eq 'w') { + # convert weeks to days + $amount = 7 * $amount + $wday; + $unit = 'd'; + } + if ($unit eq 'd') { + $date -= $sec + 60 * $min + 3600 * $hour + 24 * 3600 * $amount; + return $date; + } + elsif ($unit eq 'y') { + return str2time(sprintf("%4d-01-01 00:00:00", $year + 1900 - $amount)); + } + elsif ($unit eq 'm') { + $month -= $amount; + while ($month < 0) { $year--; $month += 12; } + return str2time(sprintf("%4d-%02d-01 00:00:00", $year + 1900, $month + 1)); + } + elsif ($unit eq 'h') { + # Special case 0h for 'beginning of this hour' + if ($amount == 0) { + $date -= $sec + 60 * $min; + } else { + $date -= 3600 * $amount; + } + return $date; + } + return undef; + } + return str2time($str); +} + +sub is_active_status_field { + my ($field) = @_; + + if ($field->type == FIELD_TYPE_EXTENSION + && $field->isa('Bugzilla::Extension::TrackingFlags::Flag') + && $field->flag_type eq 'tracking' + && $field->name =~ /_status_/ + ) { + return $field->is_active; + } + + return 0; +} + +1; diff --git a/extensions/BMO/lib/WebService.pm b/extensions/BMO/lib/WebService.pm new file mode 100644 index 000000000..ed94aabfc --- /dev/null +++ b/extensions/BMO/lib/WebService.pm @@ -0,0 +1,200 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public License Version +# 1.1 (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS IS" basis, +# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +# the specific language governing rights and limitations under the License. +# +# The Original Code is the BMO Bugzilla Extension. +# +# The Initial Developer of the Original Code is Mozilla Foundation. Portions created +# by the Initial Developer are Copyright (C) 2011 the Mozilla Foundation. All +# Rights Reserved. +# +# Contributor(s): +# Dave Lawrence <dkl@mozilla.com> + +package Bugzilla::Extension::BMO::WebService; + +use strict; +use warnings; + +use base qw(Bugzilla::WebService); + +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Util qw(detaint_natural trick_taint); +use Bugzilla::WebService::Util qw(validate); +use Bugzilla::Field; + +sub getBugsConfirmer { + my ($self, $params) = validate(@_, 'names'); + my $dbh = Bugzilla->dbh; + + defined($params->{names}) + || ThrowCodeError('params_required', + { function => 'BMO.getBugsConfirmer', params => ['names'] }); + + my @user_objects = map { Bugzilla::User->check($_) } @{ $params->{names} }; + + # start filtering to remove duplicate user ids + @user_objects = values %{{ map { $_->id => $_ } @user_objects }}; + + my $fieldid = get_field_id('bug_status'); + + my $query = "SELECT DISTINCT bugs_activity.bug_id + FROM bugs_activity + LEFT JOIN bug_group_map + ON bugs_activity.bug_id = bug_group_map.bug_id + WHERE bugs_activity.fieldid = ? + AND bugs_activity.added = 'NEW' + AND bugs_activity.removed = 'UNCONFIRMED' + AND bugs_activity.who = ? + AND bug_group_map.bug_id IS NULL + ORDER BY bugs_activity.bug_id"; + + my %users; + foreach my $user (@user_objects) { + my $bugs = $dbh->selectcol_arrayref($query, undef, $fieldid, $user->id); + $users{$user->login} = $bugs; + } + + return \%users; +} + +sub getBugsVerifier { + my ($self, $params) = validate(@_, 'names'); + my $dbh = Bugzilla->dbh; + + defined($params->{names}) + || ThrowCodeError('params_required', + { function => 'BMO.getBugsVerifier', params => ['names'] }); + + my @user_objects = map { Bugzilla::User->check($_) } @{ $params->{names} }; + + # start filtering to remove duplicate user ids + @user_objects = values %{{ map { $_->id => $_ } @user_objects }}; + + my $fieldid = get_field_id('bug_status'); + + my $query = "SELECT DISTINCT bugs_activity.bug_id + FROM bugs_activity + LEFT JOIN bug_group_map + ON bugs_activity.bug_id = bug_group_map.bug_id + WHERE bugs_activity.fieldid = ? + AND bugs_activity.removed = 'RESOLVED' + AND bugs_activity.added = 'VERIFIED' + AND bugs_activity.who = ? + AND bug_group_map.bug_id IS NULL + ORDER BY bugs_activity.bug_id"; + + my %users; + foreach my $user (@user_objects) { + my $bugs = $dbh->selectcol_arrayref($query, undef, $fieldid, $user->id); + $users{$user->login} = $bugs; + } + + return \%users; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::Extension::BMO::Webservice - The BMO WebServices API + +=head1 DESCRIPTION + +This module contains API methods that are useful to user's of bugzilla.mozilla.org. + +=head1 METHODS + +See L<Bugzilla::WebService> for a description of how parameters are passed, +and what B<STABLE>, B<UNSTABLE>, and B<EXPERIMENTAL> mean. + +=head2 getBugsConfirmer + +B<UNSTABLE> + +=over + +=item B<Description> + +This method returns public bug ids that a given user has confirmed (changed from +C<UNCONFIRMED> to C<NEW>). + +=item B<Params> + +You pass a field called C<names> that is a list of Bugzilla login names to find bugs for. + +=over + +=item C<names> (array) - An array of strings representing Bugzilla login names. + +=back + +=item B<Returns> + +=over + +A hash of Bugzilla login names. Each name points to an array of bug ids that the user has confirmed. + +=back + +=item B<Errors> + +=item B<History> + +=over + +=item Added in BMO Bugzilla B<4.0>. + +=back + +=back + +=head2 getBugsVerifier + +B<UNSTABLE> + +=over + +=item B<Description> + +This method returns public bug ids that a given user has verified (changed from +C<RESOLVED> to C<VERIFIED>). + +=item B<Params> + +You pass a field called C<names> that is a list of Bugzilla login names to find bugs for. + +=over + +=item C<names> (array) - An array of strings representing Bugzilla login names. + +=back + +=item B<Returns> + +=over + +A hash of Bugzilla login names. Each name points to an array of bug ids that the user has verified. + +=back + +=item B<Errors> + +=item B<History> + +=over + +=item Added in BMO Bugzilla B<4.0>. + +=back + +=back diff --git a/extensions/BMO/t/bug_format_comment.t b/extensions/BMO/t/bug_format_comment.t new file mode 100644 index 000000000..0356684e9 --- /dev/null +++ b/extensions/BMO/t/bug_format_comment.t @@ -0,0 +1,84 @@ +#!/usr/bin/perl -T +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. +use strict; +use warnings; +use lib qw( . lib ); + +use Test::More; +use Bugzilla; +use Bugzilla::Extension; + +my $class = Bugzilla::Extension->load('extensions/BMO/Extension.pm', + 'extensions/BMO/Config.pm'); +ok( $class->can('bug_format_comment'), 'the function exists'); + +my $bmo = $class->new; +ok($bmo, "got a new bmo extension"); + +my $text = <<'END_OF_LINKS'; +# crash stats, a fake one +bp-deadbeef-deaf-beef-beed-cafefeed1337 + +# CVE/CAN security things +CVE-2014-0160 + +# svn +r2424 + +# bzr commit +Committing to: bzr+ssh://dlawrence%40mozilla.com@bzr.mozilla.org/bmo/4.2 +modified extensions/Review/Extension.pm +Committed revision 9257. + +# git with scp-style address +To gitolite3@git.mozilla.org:bugzilla/bugzilla.git + 36f56bd..eab44b1 nouri -> nouri + +# git with uri (with login) +To ssh://gitolite3@git.mozilla.org/bugzilla/bugzilla.git + 36f56bd..eab44b1 withuri -> withuri + +# git with uri (without login) +To ssh://git.mozilla.org/bugzilla/bugzilla.git + 36f56bd..eab44b1 nologin -> nologin +END_OF_LINKS + +my @regexes; + +$bmo->bug_format_comment({ regexes => \@regexes }); + +ok(@regexes > 0, "got some regexes to play with"); + +foreach my $re (@regexes) { + my ($match, $replace) = @$re{qw(match replace)}; + if (ref($replace) eq 'CODE') { + $text =~ s/$match/$replace->({matches => [ $1, $2, $3, $4, + $5, $6, $7, $8, + $9, $10]})/egx; + } + else { + $text =~ s/$match/$replace/egx; + } +} + +my @links = ( + '<a href="https://crash-stats.mozilla.com/report/index/deadbeef-deaf-beef-beed-cafefeed1337">bp-deadbeef-deaf-beef-beed-cafefeed1337</a>', + '<a href="http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-0160">CVE-2014-0160</a>', + '<a href="http://viewvc.svn.mozilla.org/vc?view=rev&revision=2424">r2424</a>', + '<a href="http://git.mozilla.org/?p=bugzilla/bugzilla.git;a=commit;h=eab44b1">36f56bd..eab44b1 withuri -> withuri</a>', + '<a href="http://git.mozilla.org/?p=bugzilla/bugzilla.git;a=commit;h=eab44b1">36f56bd..eab44b1 nouri -> nouri</a>', + '<a href="http://git.mozilla.org/?p=bugzilla/bugzilla.git;a=commit;h=eab44b1">36f56bd..eab44b1 nologin -> nologin</a>', + 'http://bzr.mozilla.org/bmo/4.2/revision/9257', +); + +foreach my $link (@links) { + ok(index($text, $link) > -1, "check for $link"); +} + + +done_testing; diff --git a/extensions/BMO/template/en/default/account/create.html.tmpl b/extensions/BMO/template/en/default/account/create.html.tmpl new file mode 100644 index 000000000..38acb6320 --- /dev/null +++ b/extensions/BMO/template/en/default/account/create.html.tmpl @@ -0,0 +1,178 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): Gervase Markham <gerv@gerv.net> + # Byron Jones <glob@mozilla.com> + #%] + +[%# INTERFACE + # none + # + # Param("maintainer") is used to display the maintainer's email. + # Param("emailsuffix") is used to pre-fill the email field. + #%] + +[% PROCESS global/variables.none.tmpl %] + +[% title = BLOCK %] + Create a new [% terms.Bugzilla %] account +[% END %] + +[% PROCESS global/header.html.tmpl + title = title + style_urls = [ 'extensions/BMO/web/styles/create_account.css' ] +%] + +<script type="text/javascript"> +function onSubmit() { + var email = document.getElementById('login').value; + if (email == '') { + alert('You must enter your email address.'); + return false; + } + var isValid = + email.match(/@/) + && email.match(/@.+\./) + && !email.match(/\.$/) + && !email.match(/[\\()&<>,'"\[\]]/) + ; + if (!isValid) { + alert( + "The e-mail address doesn't pass our syntax checking for a legal " + + "email address.\n\nA legal address must contain exactly one '@', and " + + "at least one '.' after the @.\n\nIt must also not contain any of " + + "these special characters: \ ( ) & < > , ; : \" [ ], or any whitespace." + ); + return false; + } + return true; +} +</script> + +<table border="0" id="create-account"> +<tr> + +<td width="50%" id="create-account-left" valign="top"> + + <h2 class="column-header">I need help using a Mozilla Product</h2> + + <table border="0" id="product-list"> + [% INCLUDE product + icon = "firefox" + name = "Firefox Support" + url = "http://support.mozilla.com/" + desc = "Support for the Firefox web browser." + %] + [% INCLUDE product + icon = "firefox" + name = "Firefox for Mobile Support" + url = "http://support.mozilla.com/mobile" + desc = "Support for the Firefox Mobile web browser." + %] + [% INCLUDE product + icon = "thunderbird" + name = "Thunderbird Support" + url = "http://www.mozillamessaging.com/support/" + desc = "Support for Thunderbird email client." + %] + [% INCLUDE product + icon = "other" + name = "Support for other products" + url = "http://www.mozilla.org/projects/" + desc = "Support for products not listed here." + %] + [% INCLUDE product + icon = "input" + name = "Feedback" + url = "http://input.mozilla.com/feedback" + desc = "Report issues with a web site that you use, or provide quick feedback for Firefox." + %] + </table> + +</td> + +<td width="50%" id="create-account-right" valign="top"> + + <h2 class="column-header">I want to help</h2> + + <div id="right-blurb"> + <p> + Great! There are three things to know and do: + </p> + <ol> + <li> + Please consider reading our + <a href="https://developer.mozilla.org/en/Bug_writing_guidelines" target="_blank">[% terms.bug %] writing guidelines</a>. + </li> + <li> + [% terms.Bugzilla %] is a public place, so what you type and your email address will be visible + to all logged-in users. Some people use an + <a href="http://email.about.com/od/freeemailreviews/tp/free_email.htm" target="_blank">alternative email address</a> + for this reason. + </li> + <li> + Please give us an email address you want to use. Once we confirm that it works, + you'll be asked to set a password and then you can start filing [% terms.bugs %] and helping fix them. + </li> + </ol> + </div> + + <h2 class="column-header">Create an account</h2> + + <form method="post" action="createaccount.cgi" onsubmit="return onSubmit()"> + <table id="create-account-form"> + <tr> + <td class="label">Email Address:</td> + <td> + <input size="35" id="login" name="login" placeholder="you@example.com">[% Param('emailsuffix') FILTER html %]</td> + <td> + <input type="hidden" id="token" name="token" value="[% issue_hash_token(['create_account']) FILTER html %]"> + <input type="submit" value="Create Account"> + </td> + </tr> + </table> + </form> + + [% Hook.process('additional_methods') %] + +</td> + +</tr> +</table> + +<p id="bmo-admin"> + If you think there's something wrong with [% terms.Bugzilla %], you can + <a href="mailto:bugzilla-admin@mozilla.org">send an email to the admins</a>, but + remember, they can't file [% terms.bugs %] for you, or solve tech support problems. +</p> + +[% PROCESS global/footer.html.tmpl %] + +[% BLOCK product %] + <tr> + <td valign="top"> + <a href="[% url FILTER none %]"><img + src="extensions/BMO/web/producticons/[% icon FILTER uri %].png" + border="0" width="64" height="64"></a> + </td> + <td valign="top"> + <h2><a href="[% url FILTER none %]">[% name FILTER html %]</a></h2> + <div>[% desc FILTER html %]</div> + </td> + </tr> +[% END %] + diff --git a/extensions/BMO/template/en/default/bug/create/comment-automative.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-automative.txt.tmpl new file mode 100644 index 000000000..c23a6427d --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-automative.txt.tmpl @@ -0,0 +1,52 @@ +[%# 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 %] +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi +%] +>>Problem: +[%+ cgi.param('desc_problem') %] + +>>Solution: +[%+ cgi.param('desc_solution') %] + +>>Mozilla Top Level Goal: +[%+ cgi.param('desc_top_level_goal') %] + +>>Existing [% terms.Bug %]: +[% IF cgi.param('existing_bug') %] +[%+ terms.Bug %] [% cgi.param("existing_bug") %] +[% ELSE %] +No [% terms.bug %] +[% END %] + +>>Per-Commit: +[%+ cgi.param('per_commit') || 'No' %] + +>>Data other than Pass/Fail: +[%+ cgi.param('desc_data_produce') || 'No' %] + +>>Prototype Date: +[%+ cgi.param("prototype_date") || 'Not provided' %] + +>>Production Date: +[%+ cgi.param("production_date") || 'Not provided' %] + +>>Most Valuable Piece: +[%+ cgi.param('most_valuable_piece') || 'Not provided' %] + +>>Responsible Engineer: +[%+ cgi.param('responsible_engineer') || 'Not provided' %] + +>>Manager: +[%+ cgi.param('manager') || 'Not provided' %] + +>>Other Teams/External Dependencies: +[%+ cgi.param('other_teams') || 'Not provided' %] + +>>Additional Info: +[%+ cgi.param('additional_info') || 'Not provided' %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-creative.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-creative.txt.tmpl new file mode 100644 index 000000000..311736b5e --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-creative.txt.tmpl @@ -0,0 +1,39 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi +%] +>>Project/Request Title: +[%+ cgi.param('short_desc') %] + +>>Project Overview: +[%+ cgi.param('overview') %] + +>> Creative Help Needed: +Copy: [% IF cgi.param('type_copy') %] Yes [% ELSE %] No [% END %] +Design: [% IF cgi.param('type_design') %] Yes [% ELSE %] No [% END %] +Video: [% IF cgi.param('type_video') %] Yes [% ELSE %] No [% END %] +Other: [% IF cgi.param('type_other') %][% cgi.param('type_other_text') %][% ELSE %]No[% END %] + +>>Creative Specs: +[%+ cgi.param("specs") %] + +>>CTA and Design: +[%+ cgi.param('cta_design') %] + +>>Creative Due Date: +[%+ cgi.param("cf_due_date") || 'Not provided' %] + +>>Launch Date: +[%+ cgi.param("launch_date") || 'Not provided' %] + +>>Mozilla Goal: +[%+ IF cgi.param("goal_other") %][% cgi.param("goal_other") %][% ELSE %][% cgi.param("goal") %][% END %] + +>>Points of Contact: +[%+ cgi.param('cc').join(', ') || 'Not provided' %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-dev-engagement-event.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-dev-engagement-event.txt.tmpl new file mode 100644 index 000000000..cb7473e22 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-dev-engagement-event.txt.tmpl @@ -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. + #%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +:: + +Name: +[%+ cgi.param('name') %] + +Email Address: +[%+ cgi.param('email') %] + +Role in relation to event: +[%+ cgi.param('role') %] + +:: + +Event Name: +[%+ cgi.param('event') %] + +Start Date: +[%+ cgi.param('start_date') %] + +End Date: +[%+ cgi.param('end_date') %] + +Event Location: +[%+ cgi.param('location') || "-" %] + +Venue: +[%+ cgi.param('venue') || "-" %] + +Weblink: +[%+ cgi.param('link') || "-" %] + +Expected Attendees: +[%+ cgi.param('attendees') || "-" %] + +Event Description: +[%+ cgi.param('desc') || "-" %] + +Primary Audience: +[%+ cgi.param('audience') || "-" %] + +Relevant Products: +[% "\n* Firefox OS" IF cgi.param('product-fxos') %] +[% "\n* Firefox Web Browser" IF cgi.param('product-fx') %] +[% "\n* Webmaker" IF cgi.param('product-webmaker') %] +[% "\n* Persona" IF cgi.param('product-persona') %] +[% "\n* Marketplace" IF cgi.param('product-marketplace') %] +[% "\n* Thunderbird" IF cgi.param('product-tb') %] +[% "\n* The Free and Open Web" IF cgi.param('product-fow') %] +[% "\n* Other: " _ cgi.param('product-other-text') IF cgi.param('product-other') %] + +:: + +Requests: +[% "\n* Keynote Presentation" IF cgi.param('request-keynote') %] +[% "\n* Talk Presentation" IF cgi.param('request-talk') %] +[% "\n* Workshop" IF cgi.param('request-workshop') %] +[% "\n* Sponsorship" IF cgi.param('request-sponsorship') %] +[% "\n* Other: " _ cgi.param('request-other-text') IF cgi.param('request-other') %] + +Suggested sponsorship amount/level: +[%+ cgi.param('sponsorship-suggestion') || "-" %] + +Already Registered Mozillians: +[%+ cgi.param('mozillians') || "-" %] + +Requesting A Specific Person: +[%+ cgi.param('specific') || "-" %] + +Alternative Person: +[%+ cgi.param('fallback') || "-" %] + +Anything Else: +[%+ cgi.param('else') || "-" %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-doc.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-doc.txt.tmpl new file mode 100644 index 000000000..4c878a867 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-doc.txt.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. + #%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi +%] +:: Developer Documentation Request + + Request Type: [% cgi.param("type") %] + Gecko Version: [% cgi.param("gecko") %] + Technical Contact: [% cgi.param("cc") %] + +:: Details + +[%+ cgi.param("details") %] + diff --git a/extensions/BMO/template/en/default/bug/create/comment-employee-incident.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-employee-incident.txt.tmpl new file mode 100644 index 000000000..1b0902d64 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-employee-incident.txt.tmpl @@ -0,0 +1,57 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the BMO Bugzilla Extension. + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): + # David Lawrence <dkl@mozilla.com> + #%] +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +[% IF cgi.param('incident_type') == 'stolen' %] +[% IF original_reporter -%] +Reporter: [% original_reporter.identity FILTER none %] +[%- END -%] + + [% IF cgi.param('display_action') %] + [% IF cgi.param('display_action') == 'ldap' %] +Action needed: Please immediately reset the LDAP password for this user. + [% ELSIF cgi.param('display_action') == 'ssh' %] +Action needed: Please immediately disable the SSH key for this user. + [% END %] + +The user reported that their mobile or laptop device has been lost or stolen. +This ticket was automatically generated from the employee incident reporting +form. An additional ticket has been filed (see blocker bugs) for InfraSec to +review the impact of this lost device. + [% END %] + +Type of device: [% cgi.param('device') %] +Was the device encrypted?: [% cgi.param('encrypted') %] +Any user data on the device?: [% cgi.param('userdata') %] + [% IF cgi.param('userdata') == 'Yes' %] +Sensitive data on the device: +[%+ cgi.param('sensitivedata') %] + [% END %] +Browser configured to remember passwords?: [% cgi.param('rememberpasswords') %] + [% IF cgi.param('rememberpasswords') == 'Yes' %] +Critical sites: +[%+ cgi.param('criticalsites') %] + [% END %] +[% END %] +[% IF cgi.param('comment') %] +Extra Notes: +[%+ cgi.param('comment') %] +[% END %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-finance.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-finance.txt.tmpl new file mode 100644 index 000000000..f0427b4c5 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-finance.txt.tmpl @@ -0,0 +1,35 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi %] + +Request Type: [% cgi.param('component') %] +Summary: [% cgi.param('short_desc') %] +Priority to your Team: [% cgi.param('team_priority') %] +Timeframe for Signature: [% cgi.param('signature_time') %] + +Name of Other Party: +[%+ cgi.param('other_party') %] + +Business Objective: +[%+ cgi.param('business_obj') %] + +What is this purchase?: +[%+ cgi.param('what_purchase') %] + +Why is this purchase needed?: +[%+ cgi.param('why_purchase') %] + +What is the risk if this is not purchased?: +[%+ cgi.param('risk_purchase') %] + +What is the alternative?: +[%+ cgi.param('alternative_purchase') %] + +Total Cost: [% cgi.param('total_cost') %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-fxos-betaprogram.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-fxos-betaprogram.txt.tmpl new file mode 100644 index 000000000..9370ff03c --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-fxos-betaprogram.txt.tmpl @@ -0,0 +1,24 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi %] + +Phone: +[%+ cgi.param('phone') == 'Other' ? cgi.param('phone_other') : cgi.param('phone') %] + +Firefox OS Version: +[%+ cgi.param('fxos_version') %] + +Issue Details: +[%+ cgi.param('details') %] + +[% IF cgi.param('app') %] +Associated App: +[%+ cgi.param('app') %] +[% END %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-fxos-feature.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-fxos-feature.txt.tmpl new file mode 100644 index 000000000..65224bfba --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-fxos-feature.txt.tmpl @@ -0,0 +1,24 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi +%] +>> Feature Request Title: +[%+ cgi.param('short_desc') %] + +>> Description of feature, or problem to be solved +[%+ cgi.param("description") %] + +>> Impact of implementing the feature/solution +[%+ cgi.param("implement_impact") %] + +>> Impact of NOT implementing the feature/solution +[%+ cgi.param("not_implement_impact") %] + +>> Date required +[%+ cgi.param("date_required") %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-fxos-mcts-waiver.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-fxos-mcts-waiver.txt.tmpl new file mode 100644 index 000000000..abad3f3c4 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-fxos-mcts-waiver.txt.tmpl @@ -0,0 +1,36 @@ +[%# 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 %] +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi +%] +> Company Name +[%+ cgi.param('company_name') %] + +> Device Description +[%+ cgi.param('device_desc') %] + +> Firefox OS Release +[%+ cgi.param('ffos_release') %] + +> Branding Tier +[%+ cgi.param('branding_tier') %] + +> Distribution Countries +[%+ cgi.param('dist_countries') %] + +> Distribution Channel +[%+ cgi.param('dist_channel') %] + +> Reason for Waiver Request +[%+ cgi.param('reason') %] + +> Rationale for Granting Waiver Request +[%+ cgi.param('rationale') %] + +> Impact Analysis +[%+ cgi.param('impact') %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-fxos-partner.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-fxos-partner.txt.tmpl new file mode 100644 index 000000000..aa26d778f --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-fxos-partner.txt.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. + #%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +What are the steps to reproduce?: +[%+ cgi.param('steps_to_reproduce') %] + +What was the actual behavior?: +[%+ cgi.param('actual_behavior') %] + +What was the expected behavior?: +[%+ cgi.param('expected_behavior') %] + +What build were you using?: [% cgi.param('build') %] + +What are the requirements?: [% cgi.param('requirements') %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-fxos-preload-app.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-fxos-preload-app.txt.tmpl new file mode 100644 index 000000000..a4e489724 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-fxos-preload-app.txt.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. + #%] +[% PROCESS global/variables.none.tmpl %] +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi +%] + +>>Company Name +[%+ cgi.param('company_name') %] + +>>Apps Business Development Contact +[%+ cgi.param('apps_business_dev_contact') %] + +>>Name of Firefox Marketplace apps of interest to you: +[%+ cgi.param('preload_apps') %] + +>>Countries where your device will be distributed +[%+ cgi.param('countries') %] + +>>Release Information +[%+ cgi.param('release_info') %] + +>>Device Information +[%+ cgi.param('device_info') %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-ipp.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-ipp.txt.tmpl new file mode 100644 index 000000000..5c73587a9 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-ipp.txt.tmpl @@ -0,0 +1,30 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi +%] +:: Internet Public Policy Issue + +Region/Country: [% cgi.param("region") %] + +:: Description + +[%+ cgi.param("desc") %] + +:: Relevance + +[%+ cgi.param("relevance") %] + +Goal: [% cgi.param("goal") %] +When: [% cgi.param("when") %] + +[% IF cgi.param("additional") %] +:: Additional Information + +[%+ cgi.param("additional") %] +[% END %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-legal.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-legal.txt.tmpl new file mode 100644 index 000000000..eb00a88d9 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-legal.txt.tmpl @@ -0,0 +1,39 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the BMO Bugzilla Extension. + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): + # David Lawrence <dkl@mozilla.com> + #%] +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +Priority for your Team: +[%+ cgi.param('teampriority') %] + +Timeframe for Completion: +[%+ cgi.param('timeframe') %] + +Goal: +[%+ cgi.param('goal') %] + +Business Objective: +[%+ cgi.param('busobj') %] + +Other Party: +[%+ cgi.param('otherparty') %] + +Description: +[%+ cgi.param("comment") %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-mdn.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-mdn.txt.tmpl new file mode 100644 index 000000000..60a443d2b --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-mdn.txt.tmpl @@ -0,0 +1,66 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi +%] + +[% IF cgi.param('request_type') == 'Bug' %] +What did you do? +================ +[%+ cgi.param('bug_actions') %] + +What happened? +============== +[%+ cgi.param('bug_actual_results') %] + +What should have happened? +========================== +[%+ cgi.param('bug_expected_results') %] + +[% ELSIF cgi.param('request_type') == 'Feature' %] +What problems would this solve? +=============================== +[%+ cgi.param('feature_problem_solving') %] + +Who would use this? +=================== +[%+ cgi.param('feature_audience') %] + +What would users see? +===================== +[%+ cgi.param('feature_interface') %] + +What would users do? What would happen as a result? +=================================================== +[%+ cgi.param('feature_process') %] + +[% ELSIF cgi.param('request_type') == 'Change' %] +What feature should be changed? Please provide the URL of the feature if possible. +================================================================================== +[%+ cgi.param('change_feature') %] + +What problems would this solve? +=============================== +[%+ cgi.param('change_problem_solving') %] + +Who would use this? +=================== +[%+ cgi.param('change_audience') %] + +What would users see? +===================== +[%+ cgi.param('change_interface') %] + +What would users do? What would happen as a result? +=================================================== +[%+ cgi.param('change_process') %] + +[% END %] +Is there anything else we should know? +====================================== +[%+ cgi.param("description") %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-mobile-compat.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-mobile-compat.txt.tmpl new file mode 100644 index 000000000..37b7d98d5 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-mobile-compat.txt.tmpl @@ -0,0 +1,33 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi +%] + +Site: [%+ cgi.param("bug_file_loc") %] +[%+ cgi.param("short_desc") %] + +:: Steps To Reproduce + +[%+ cgi.param("desc") %] + +:: Expected Result + +[%+ cgi.param("expected_result") %] + +:: Actual Result + +[%+ cgi.param("actual_result") %] + +:: Additional Information + +Software Version: [% cgi.param("software_version") %] +[% IF cgi.param("device") %] +Device Information: [% cgi.param("device") %] +[% END %] +Reporter's User Agent: [% cgi.param("user_agent") %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-mozlist.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-mozlist.txt.tmpl new file mode 100644 index 000000000..c62461d42 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-mozlist.txt.tmpl @@ -0,0 +1,44 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): Gervase Markham <gerv@gerv.net> + #%] +[%# INTERFACE: + # This template has no interface. + # + # Form variables from a bug submission (i.e. the fields on a template from + # enter_bug.cgi) can be access via Bugzilla.cgi.param. It can be used to + # pull out various custom fields and format an initial Description entry + # from them. + #%] +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] +List Name: [% cgi.param("listName") %] +List Admin: [% cgi.param("listAdmin") %] + +Short Description: +[%+ cgi.param("listShortDesc") %] + +[% IF cgi.param("listType") != "mozilla.com" %] +Long Description: +[%+ cgi.param("listLongDesc") %] +[% END %] + +Justification / Special Instructions: + +[%+ cgi.param("comment") IF cgi.param("comment") %] + diff --git a/extensions/BMO/template/en/default/bug/create/comment-mozpr.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-mozpr.txt.tmpl new file mode 100644 index 000000000..bfd421388 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-mozpr.txt.tmpl @@ -0,0 +1,130 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi +%] +[% PROCESS global/variables.none.tmpl +%] + + Project Title: [% cgi.param("short_desc") %] + +Project Description and Scope: +[%+ cgi.param("desc") %] + +== Timings + + Start Date: [% cgi.param("start_date") %] + Announcement Date: [% cgi.param("announce_date") %] + Internal Deadline: [% cgi.param("deadline") %] + +== Owners + + Project PR Owner: [% cgi.param("pr_owner") %] +[%~ " " _ cgi.param("pr_owner_other") IF cgi.param("pr_owner") == "Other:" %] + Project Owner: [% cgi.param("owner") %] + +== RASCI + + Responsible: [% cgi.param("rasci_r") || "-" %] + Approver: [% cgi.param("rasci_a") %] + Supporter: [% cgi.param("rasci_s") || "-" %] + Consultant: [% cgi.param("rasci_c") || "-" %] + Informed: [% cgi.param("rasci_i") || "-" %] + +== Details + + Tier: [% cgi.param("tier") %] + PR Approach: [% cgi.param("pr_approach") %] +Product Group Focus: [% cgi.param("group_focus") %] +[%~ " " _ cgi.param("group_focus_other") IF cgi.param("group_focus") == "Other:" %] + Region: [% cgi.param("region") %] +[%~ " " _ cgi.param("region_other") IF cgi.param("region") == "Other:" %] + +== Goals, Audience, and Messages + +Project Goals: +[%+ cgi.param("project_goals") %] + +PR Goals: +[%+ cgi.param("pr_goals") %] + +Company Goal: [% cgi.param("company_goal") %] + +Audiences: +[% FOREACH audience = cgi.param("audience") %] + - [% audience %] +[% " " _ cgi.param("audience_other") IF audience == "Other:" %] +[% END %] + +Key Messages: +[%+ cgi.param("key_messages") %] +[% IF cgi.param("proj_mat_online") %] + +== Project Materials - Online Documentation + + Description: [% cgi.param("proj_mat_online_desc") %] + Link: [% cgi.param("proj_mat_online_link") %] +[% END %] +[% IF cgi.param("proj_mat_file") %] + +== Project Materials - Attached + + Description: [% cgi.param("proj_mat_file_desc") %] + File Name: [% cgi.param("proj_mat_file_attach") %] +[% END %] +[% IF cgi.param("pr_mat_online") %] + +== PR Project Materials - Online Documentation + + Description: [% cgi.param("pr_mat_online_desc") %] + Link: [% cgi.param("pr_mat_online_link") %] +[% END %] +[% IF cgi.param("pr_mat_file") %] + +== PR Project Materials - Attached + + Description: [% cgi.param("pr_mat_file_desc") %] + File Name: [% cgi.param("pr_mat_file_attach") %] +[% END %] + +== Requirements + + Metrica Coverage: [% cgi.param("metrica") %] +[% IF cgi.param("press_center") %] + +Press Center Update: +[% FOREACH option = cgi.param("press_center") %] + - [% option %] +[% " " _ cgi.param("press_center_other") IF option == "Other:" %] +[% END %] +[% END %] +[% IF cgi.param("resources") || cgi.param("internal_resources") %] + + Internal Resources: +[% " " _ cgi.param("resources") IF cgi.param("resources") %] +[% FOREACH option = cgi.param("internal_resources") %] + - [% option %] +[% " " _ cgi.param("internal_resources_other") IF option == "Other:" %] +[% END %] +[% END %] +[% IF cgi.param("resources") || cgi.param("external_resources") %] + + External Resources: +[% FOREACH option = cgi.param("external_resources") %] + - [% option %] +[% " " _ cgi.param("external_resources_other") IF option == "Other:" %] +[% END %] +[% END %] + + Localization: [% cgi.param("localization") %] +[%~ " " _ cgi.param("localization_other") IF cgi.param("localization") == "Other:" %] + +== Budget + + Budget: [% cgi.param("budget") %] +[%~ " " _ cgi.param("budget_extra") IF cgi.param("budget") == "Extra" %] + diff --git a/extensions/BMO/template/en/default/bug/create/comment-privacy-data.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-privacy-data.txt.tmpl new file mode 100644 index 000000000..279d59b6b --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-privacy-data.txt.tmpl @@ -0,0 +1,30 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi %] + +Where does this data come from: + +[%+ cgi.param('source') %] + +What people and things does this data describe, and what fields does it contain: + +[%+ cgi.param('data_desc') %] + +What parts of this data do you want to release: + +[%+ cgi.param('release') %] + +Why are we releasing this data, and what do we hope people will do with it: + +[%+ cgi.param('why') %] + +Is there a particular time by which you would like to release this data: + +[%+ cgi.param('when') %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-recoverykey.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-recoverykey.txt.tmpl new file mode 100644 index 000000000..9a38af7cc --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-recoverykey.txt.tmpl @@ -0,0 +1,28 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the BMO Bugzilla Extension. + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): + # David Lawrence <dkl@mozilla.com> + #%] +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +Recovery Key: [% cgi.param('recoverykey') %] +Asset Tag Number: [% cgi.param('assettag') %] + +[% IF cgi.param('comment') %] +[%+ cgi.param('comment') %] +[% END %] diff --git a/extensions/BMO/template/en/default/bug/create/comment-swag.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-swag.txt.tmpl new file mode 100644 index 000000000..920d392da --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-swag.txt.tmpl @@ -0,0 +1,50 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi +%] +[% PROCESS global/variables.none.tmpl +%] +:: Gear Requested + + Purpose of Gear: [% cgi.param("purpose") %] [%+ cgi.param("purpose_other") %] + Date Required: [% cgi.param("date_required") || "-" %] + +[%+ cgi.param("items") %] + +:: Requester + + Name: [% cgi.param('firstname') %] [% cgi.param('lastname') %] + Email: [% cgi.param('email') %] + Mozilla Space: [% cgi.param('mozspace') || "-" %] + Team/Department: [% cgi.param('teamcode') %] + +:: Recipient + +[% IF cgi.param("purpose") == "Mozillian Recognition" %] +This [% terms.bug %] needs recipient shipping information: [% cgi.param("recognition_shipping") ? "Yes" : "No" %] +This [% terms.bug %] needs recipient size information: [% cgi.param("recognition_sizing") ? "Yes" : "No" %] +[% END %] + + Name: [%+ cgi.param("shiptofirstname") +%] [%+ cgi.param("shiptolastname") +%] + Email: [%+ cgi.param("shiptoemail") +%] +[% IF cgi.param("shiptoaddress1") %] + Address: + [%+ cgi.param("shiptoaddress1") +%] + [%+ cgi.param("shiptoaddress2") +%] + [%+ cgi.param("shiptocity") +%] [%+ cgi.param("shiptostate") +%] [%+ cgi.param("shiptopostcode") +%] + [%+ cgi.param("shiptocountry") %] + Phone: [% cgi.param("shiptophone") %] + Personal ID/RUT: [% cgi.param("shiptoidrut") || "-" %] +[% END %] + +[% IF cgi.param("comment") %] +:: Comments + +[%+ cgi.param("comment") %] +[% END %] + diff --git a/extensions/BMO/template/en/default/bug/create/comment-user-engagement.txt.tmpl b/extensions/BMO/template/en/default/bug/create/comment-user-engagement.txt.tmpl new file mode 100644 index 000000000..cff8f23b8 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/comment-user-engagement.txt.tmpl @@ -0,0 +1,36 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi +%] +>>Project/Request Title: +[%+ cgi.param('short_desc') %] + +>>Project Goals: +[%+ cgi.param('goals') %] + +>>Who are you trying to reach?: +[%+ cgi.param("audience") %] + +>>Localization: +[%+ cgi.param("localization") %] + +>>Destination URL: +[%+ cgi.param("bug_file_loc") %] + +>>Timing: +[%+ cgi.param("timing_date") %] + +>>Success: +[%+ cgi.param("success") %] + +>>Mozilla Goal: +[%+ cgi.param("mozilla_goal") %] + +>>Points of Contact: +[%+ cgi.param('cc') || 'Not provided' %] diff --git a/extensions/BMO/template/en/default/bug/create/create-automative.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-automative.html.tmpl new file mode 100644 index 000000000..cbe2da910 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-automative.html.tmpl @@ -0,0 +1,276 @@ +[%# 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 %] + +[% inline_style = BLOCK %] +#automative_form { + padding: 10px; +} +#automative_form .required:after { + content: " *"; + color: red; +} +#automative_form .field_label { + font-weight: bold; +} +#automative_form .field_desc { + padding-bottom: 3px; +} +#automative_form .field_desc, +#automative_form .head_desc { + width: 600px; + word-wrap: normal; +} +#automative_form .head_desc { + padding-top: 5px; + padding-bottom: 12px; +} +#automative_form .form_section { + margin-bottom: 10px; +} +#automative_form textarea { + font-family: inherit; + font-size: inherit; +} +#automative_form em { + font-size: 1em; +} +.yui-calcontainer { + z-index: 2; +} +[% END %] + +[% inline_javascript = BLOCK %] +function validateAndSubmit() { + 'use strict'; + var alert_text = ''; + var requiredLabels = YAHOO.util.Selector.query('label.required'); + if (requiredLabels) { + requiredLabels.forEach(function (label) { + var name = label.getAttribute('for'); + var ids = YAHOO.util.Selector.query( + '#automative_form *[name="' + name + '"]' + ).map(function (e) { + return e.id + }); + + if (ids && ids[0]) { + if (!isFilledOut(ids[0])) { + var desc = label.textContent || name; + alert_text += + "Please enter a value for " + + desc.replace(/[\r\n]+/, "").replace(/\s+/g, " ") + + "\n"; + } + } + }); + } + + if (alert_text != '') { + alert(alert_text); + return false; + } + return true; +} +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Automation Request Form" + style = inline_style + javascript = inline_javascript + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/field.js', 'js/util.js' ] + yui = [ "autocomplete", "calendar", "selector" ] +%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +<form id="automative_form" method="post" action="post_bug.cgi" + enctype="multipart/form-data" onSubmit="return validateAndSubmit();"> + <input type="hidden" name="format" value="automative"> + <input type="hidden" name="product" value="Testing"> + <input type="hidden" name="component" value="General"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + <input type="hidden" name="assigned_to" value="jgriffin@mozilla.com"> + + <div class="head_desc"> + Welcome to the Automation Request Form! + </div> + + <div class="form_section"> + <label for="short_desc" class="field_label required">Summary</label> + <div class="field_desc"> + One-line summary of the problem you'd like automation to help solve + </div> + <input type="text" name="short_desc" id="short_desc" size="80"> + </div> + + <div class="form_section"> + <label for="desc_problem" class="field_label required">Problem</label> + <div class="field_desc"> + Detailed description of the problem + </div> + <textarea id="desc_problem" name="desc_problem" + cols="80" rows="5"></textarea> + </div> + + <div class="form_section"> + <label for="desc_solution" class="field_label required">Solution</label> + <div class="field_desc"> + Detailed description of the proposed automation solution + </div> + <textarea id="desc_solution" name="desc_solution" + cols="80" rows="5"></textarea> + </div> + + <div class="form_section"> + <label for="desc_top_level_goal" class="field_label required">Top Level + Goal</label> + <div class="field_desc">Describe the top-level project goal which this is + supporting</div> + <textarea id="desc_top_level_goal" name="desc_top_level_goal" cols="80" + rows="5"></textarea> + </div> + + <div class="form_section"> + <label for="existing_bug" class="field_label">Existing [% terms.Bug %] + number </label> + <div class="field_desc"> Existing [% terms.bug %] (if any) </div> + <input type="text" name="existing_bug" id="existing_bug" size="80"> + </div> + + <div class="form_section"> + <label for="per_commit" class="field_label">Run per-commit?</label> + <div class="field_desc"> + Does this automation need to be run per-commit and report to TBPL? Can it + be run less frequently? + </div> + <input type="text" name="per_commit" id="per_commit" size="80"> + </div> + + <div class="form_section"> + <label for="desc_data_produce" class="field_label">Data capture?</label> + <div class="field_desc">If this automation will report data other than + pass/fail (e.g. some sort of performance metric), describe the data that + you'd like to have the automation produce. Do we already have a method of + capturing this kind of data, or do we need to develop one?</div> + <textarea id="desc_data_produce" name="desc_data_produce" cols="80" + rows="5"></textarea> + </div> + + <div class="form_section"> + <label for="prototype_date" class="field_label">Prototype Date</label> + <div class="field_desc"> + When is a prototype needed? + </div> + <input name="prototype_date" size="20" id="prototype_date" value="" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_prototype_date" + onclick="showCalendar('prototype_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_prototype_date"></div> + <script type="text/javascript"> + createCalendar('prototype_date') + </script> + </div> + + <div class="form_section"> + <label for="production_date" class="field_label">Production Date</label> + <div class="field_desc"> + When is a finished project running in production needed? + </div> + <input name="production_date" size="20" id="production_date" value="" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_production_date" + onclick="showCalendar('production_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_production_date"></div> + <script type="text/javascript"> + createCalendar('production_date') + </script> + </div> + + <div class="form_section"> + <label for="most_valuable_piece" class="field_label">Most Valuable + Piece?</label> + <div class="field_desc">If there are multiple pieces, tests, or features in + the proposed automation, what is the single most valuable piece?</div> + <input type="text" name="most_valuable_piece" id="most_valuable_piece" + size="80"> + </div> + + <div class="form_section"> + <label for="responsible_engineer" class="field_label">Responsible + Engineer</label> + <div class="field_desc"> + Which engineer is responsible for working with the automation engineer for + information, support, and troubleshooting? + </div> + [% INCLUDE global/userselect.html.tmpl + id => "responsible_engineer" + name => "responsible_engineer" + value => "" + size => 80 + classes => ["bz_userfield"] + %] + </div> + + <div class="form_section"> + <label for="manager" class="field_label">Manager</label> + <div class="field_desc"> + Which manager/project manager is responsible for issues related to + milestones and priorities? + </div> + [% INCLUDE global/userselect.html.tmpl + id => "manager" + name => "manager" + value => "" + size => 80 + classes => ["bz_userfield"] + %] + </div> + + <div class="form_section"> + <label for="other_teams" class="field_label">Other Teams</label> + <div class="field_desc"> + What other teams are involved and are there any other external + dependencies? + </div> + <textarea id="other_teams" name="other_teams" cols="80" + rows="5"></textarea> + </div> + + <div class="form_section"> + <label for="additional_info" class="field_label">Additional + Information</label> + <div class="field_desc"> + Additional information + </div> + <textarea id="additional_info" name="additional_info" cols="80" + rows="5"></textarea> + </div> + + <input type="submit" id="commit" value="Submit"> + + <p> + [ <span class="required_star">*</span> <span class="required_explanation"> + Required Field</span> ] + </p> +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-creative.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-creative.html.tmpl new file mode 100644 index 000000000..0c4fad8d6 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-creative.html.tmpl @@ -0,0 +1,259 @@ +[%# 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 %] + +[% inline_style = BLOCK %] +#creative_form { + padding: 10px; +} +#creative_form .required:after { + content: " *"; + color: red; +} +#creative_form .field_label { + font-weight: bold; +} +#creative_form .field_desc { + padding-bottom: 3px; +} +#creative_form .field_desc, +#creative_form .head_desc { + width: 600px; + word-wrap: normal; +} +#creative_form .head_desc { + padding-top: 5px; + padding-bottom: 12px; +} +#creative_form .form_section { + margin-bottom: 10px; +} +#creative_form textarea { + font-family: inherit; + font-size: inherit; +} +#creative_form em { + font-size: 1em; +} +.yui-calcontainer { + z-index: 2; +} +[% END %] + +[% inline_javascript = BLOCK %] +function validateAndSubmit() { + var alert_text = ''; + if (!isFilledOut('overview')) alert_text += 'Please enter a value for Project Overview.\n'; + if (!isFilledOut('short_desc')) alert_text += 'Please enter a value for Request Title.\n'; + if (!isFilledOut('specs')) alert_text += 'Please enter a value for Creative Specs.\n'; + if (!isFilledOut('cta_design')) alert_text += 'Please enter a value for CTA Design.\n'; + if (!isFilledOut('cf_due_date')) alert_text += 'Please enter a value for the creative due date.\n'; + if (!isFilledOut('goal')) alert_text += 'Please select a value for Mozilla Goal.\n'; + if (YAHOO.util.Dom.get('goal').value == 'Other') { + if (!isFilledOut('goal_other')) alert_text += 'Please select a value for Mozilla Goal Other.\n'; + } + if (YAHOO.util.Dom.get('type_copy').checked == false + && YAHOO.util.Dom.get('type_design').checked == false + && YAHOO.util.Dom.get('type_video').checked == false + && YAHOO.util.Dom.get('type_other').checked == false) + { + alert_text += 'Please select at least one type of help needed.\n'; + } + if (YAHOO.util.Dom.get('type_other').checked == true) { + if (!isFilledOut('type_other_text')) alert_text += 'Please enter a value for other type of help needed.\n'; + } + if (alert_text != '') { + alert(alert_text); + return false; + } + return true; +} +function toggleGoalOther() { + var goal_select = YAHOO.util.Dom.get('goal'); + if (goal_select.options[goal_select.selectedIndex].value == 'Other') { + YAHOO.util.Dom.removeClass('goal_other','bz_default_hidden'); + } + else { + YAHOO.util.Dom.addClass('goal_other','bz_default_hidden'); + } +} +function toggleTypeOther(element) { + var other_text = YAHOO.util.Dom.get('type_other_text'); + if (element.checked == true) { + other_text.disabled = false; + other_text.focus(); + } + else { + other_text.disabled = true; + } +} + +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Creative Initiation Form" + style = inline_style + javascript = inline_javascript + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/field.js', 'js/util.js' ] + yui = [ "autocomplete", "calendar" ] +%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +<form id="creative_form" method="post" action="post_bug.cgi" enctype="multipart/form-data" + onSubmit="return validateAndSubmit();"> + <input type="hidden" name="format" value="creative"> + <input type="hidden" name="product" value="Marketing"> + <input type="hidden" name="component" value="Design"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + +<img title="Creative Initiation Form" src="extensions/BMO/web/images/creative.png"> + +<div class="head_desc"> + Have a new project or campaign that requires copy, design, video or other awesomeness + from your friendly neighborhood Brand Team? Please use this form to tell us about it + and we'll get back to you with next steps as soon as possible. +</div> + +<div class="form_section"> + <label for="short_desc" class="field_label required">Project / Request Title</label> + <div class="field_desc"> + Describe your project or request in a few words or a short phrase. + </div> + <input type="text" name="short_desc" id="short_desc" size="80"> +</div> + +<div class="form_section"> + <label for="overview" class="field_label required">Project Overview</label> + <div class="field_desc"> + Briefly describe the background, goals and objectives for this project. + </div> + <textarea id="overview" name="overview" cols="80" rows="5"></textarea> +</div> + +<div class="form_section"> + <label for="specs" class="field_label required">Creative Specs and Deliverables</label> + <div class="field_desc"> + Select what sort of help you need (check at least one or more) + </div> + <input type="checkbox" name="type_copy" id="type_copy" value="1">Copy<br> + <input type="checkbox" name="type_design" id="type_design" value="1">Design<br> + <input type="checkbox" name="type_video" id="type_video" value="1">Video<br> + <input type="checkbox" name="type_other" id="type_other" value="1" onchange="toggleTypeOther(this);">Other + <input type="text" name="type_other_text" id="type_other_text"><br> + <br> + <div class="field_desc"> + <strong class="required">Specs</strong><br> + What is the final deliverable and what format should it be delivered in? + Please include information on the format, image/file size, word count, video length, + etc. We like details here. + </div> + <textarea id="specs" name="specs" cols="80" rows="5"></textarea> + <br> + <br> + <div class="field_desc"> + <strong class="required">CTAs and design directions</strong><br> + Provide as much information as possible. Make sure to include links to documents with copy, + mock-ups, wireframes, or any other information or assets that could help with direction. + </div> + <textarea id="cta_design" name="cta_design" cols="80" rows="5"></textarea> +</div> + +<div class="form_section"> + <label for="cf_due_date" class="field_label required">Creative Due Date</label> + <div class="field_desc"> + Working backwards from your launch/go-live date, when do you need final assets? + </div> + <input name="cf_due_date" size="20" id="cf_due_date" value="" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_cf_due_date" + onclick="showCalendar('cf_due_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_cf_due_date"></div> + <script type="text/javascript"> + createCalendar('cf_due_date') + </script> +</div> + +<div class="form_section"> + <label for="launch_date" class="field_label">Launch Date</label> + <div class="field_desc"> + When will your project go forth into the world? + </div> + <input name="launch_date" size="20" id="launch_date" value="" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_launch_date" + onclick="showCalendar('launch_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_launch_date"></div> + <script type="text/javascript"> + createCalendar('launch_date') + </script> +</div> + +<div class="form_section"> + <label for="goal" class="field_label required">Mozilla Goal</label> + <div class="field_desc"> + Which high-level Mozilla goal does this project support? + </div> + <select id="goal" name="goal" + onchange="toggleGoalOther();"> + <option value="">Please select..</option> + <option value="Firefox OS">Firefox OS</option> + <option value="Firefox Browser">Firefox Browser</option> + <option value="Million Mozillians">Million Mozillians</option> + <option value="Services">Services</option> + <option value="Org Support">Org Support</option> + <option value="Other">Other</option> + </select> + <br> + <input type="text" name="goal_other" id="goal_other" size="40" + class="bz_default_hidden" value=""> +</div> + +<div class="form_section"> + <label for="cc" class="field_label">Points of Contact</label> + <div class="field_desc"> + Who should be kept in the loop and informed of updates (and CC'd on the [% terms.bug %])? + </div> + [% INCLUDE global/userselect.html.tmpl + id => "cc" + name => "cc" + value => "" + size => 80 + classes => ["bz_userfield"] + multiple => 5 + %] +</div> + +<div class="head_desc"> + Thanks! Once you hit submit, your request will go off into the vortex of creative magic. + (Actually, it goes to [% terms.Bugzilla %], but that doesn't sound as cool.) We'll be in touch soon + with next steps and to let you know if we need any additional info. +</div> + +<input type="submit" id="commit" value="Submit"> + +<p> + [ <span class="required_star">*</span> <span class="required_explanation">Required Field</span> ] +</p> + +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-dev-engagement-event.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-dev-engagement-event.html.tmpl new file mode 100644 index 000000000..ef6737098 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-dev-engagement-event.html.tmpl @@ -0,0 +1,537 @@ +[%# 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 %] + +[% inline_css = BLOCK %] + #bug_form { + max-width: 50em; + } + + #bug_form th { + text-align: left; + padding-top: 0.5em; + } + + #bug_form .section-head { + font-size: larger; + padding-top: 1em; + } + + #bug_form th:not(.section-head), #bug_form td { + padding-left: 2em; + } + + #bug_form .mandatory { + color: red; + } + + #bug_form .blurb { + font-style: italic; + } + + #bug_form .wide { + width: 40em; + } + + #bug_form input[disabled] { + background: transparent; + } +[% END %] + +[% inline_js = BLOCK %] +// <script> + function onRequestOtherChange() { + var cb = document.getElementById('request-other'); + var input = document.getElementById('request-other-text'); + input.disabled = !cb.checked; + if (cb.checked) + input.focus(); + } + + function onRequestSponsorshipChange() { + var cb = document.getElementById('request-sponsorship'); + if (cb.checked) { + YAHOO.util.Dom.removeClass('sponsorship-suggestion-fields', 'bz_default_hidden'); + } + else { + YAHOO.util.Dom.addClass('sponsorship-suggestion-fields', 'bz_default_hidden'); + } + } + + function onProductOtherChange() { + var cb = document.getElementById('product-other'); + var input = document.getElementById('product-other-text'); + input.disabled = !cb.checked; + if (cb.checked) + input.focus(); + } + + function onSubmit() { + if (document.getElementById('request-other').checked + && !isFilledOut('request-other-text') + ) { + document.getElementById('request-other').checked = false; + onRequestOtherChange(); + } + + var alert_text = ''; + + if (!isFilledOut('name')) + alert_text += "Please enter your name.\n"; + if (!isFilledOut('email')) + alert_text += "Please enter your email address.\n"; + if (!isFilledOut('role')) + alert_text += "Please enter your role.\n"; + + if (!isFilledOut('event')) + alert_text += "Please enter the event name.\n"; + if (!isFilledOut('start_date')) + alert_text += "Please enter the event start date.\n"; + if (!isFilledOut('end_date')) + alert_text += "Please enter the event end date.\n"; + if (!isFilledOut('attendees')) + alert_text += "Please enter number of expected attendees.\n"; + if (!isFilledOut('audience')) + alert_text += "Please enter primary audience.\n"; + + + var wb = ''; + if (document.getElementById('request-keynote').checked) + wb += '[keynote] '; + if (document.getElementById('request-talk').checked) + wb += '[talk] '; + if (document.getElementById('request-workshop').checked) + wb += '[workshop] '; + if (document.getElementById('request-sponsorship').checked) + wb += '[sponsorship] '; + if (document.getElementById('request-other').checked) + wb += '[other] '; + if (wb == '') + alert_text += "Please select what you're requesting.\n"; + + if (alert_text != '') { + alert(alert_text); + return false; + } + + document.getElementById('status_whiteboard').value = wb.replace(/ $/, ''); + var summary = document.getElementById('event').value + ', ' + long_start_date(); + var loc = document.getElementById('location').value; + if (loc) + summary = summary + ' (' + loc + ')'; + document.getElementById('short_desc').value = summary; + document.getElementById('bug_file_loc').value = document.getElementById('link').value; + document.getElementById('cf_due_date').value = document.getElementById('start_date').value; + + return true; + } + + function long_start_date() { + var ymd = document.getElementById('start_date').value.split('-'); + if (ymd.length != 3) + return ''; + var month = YAHOO.bugzilla.calendar_start_date.cfg.getProperty('MONTHS_LONG')[ymd[1] - 1]; + return month + ' ' + ymd[0]; + } + + YAHOO.util.Event.onDOMReady(function() { + createCalendar('start_date'); + createCalendar('end_date'); + onRequestOtherChange(); + onRequestSponsorshipChange(); + onProductOtherChange(); + }); +// </script> +[% END %] + +[% mandatory = BLOCK %] + <span class="mandatory" title="Mandatory">*</span> +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Developer Events Request Form" + style = inline_css + style_urls = [ 'skins/standard/enter_bug.css' ] + javascript = inline_js + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', 'js/field.js', 'js/util.js' ] + yui = [ 'calendar' ] +%] + +<h2>Developer Events Request Form</h2> + +<form method="post" action="post_bug.cgi" id="bug_form" class="enter_bug_form" + enctype="multipart/form-data" onsubmit="return onSubmit();"> +<input type="hidden" name="format" value="dev-engagement-event"> +<input type="hidden" name="product" value="Developer Engagement"> +<input type="hidden" name="short_desc" id="short_desc" value=""> +<input type="hidden" name="component" value="Events Request"> +<input type="hidden" name="rep_platform" value="All"> +<input type="hidden" name="op_sys" value="All"> +<input type="hidden" name="priority" value="--"> +<input type="hidden" name="version" value="unspecified"> +<input type="hidden" name="bug_severity" id="bug_severity" value="normal"> +<input type="hidden" name="comment" id="comment" value=""> +<input type="hidden" name="status_whiteboard" id="status_whiteboard" value=""> +<input type="hidden" name="bug_file_loc" id="bug_file_loc" value=""> +<input type="hidden" name="cf_due_date" id="cf_due_date" value=""> +<input type="hidden" name="groups" id="groups" value="mozilla-employee-confidential"> +<input type="hidden" name="token" value="[% token FILTER html %]"> + +<table id="bug_form"> + +<tr> + <td> + <p> + Hi! Thanks so much for asking Mozilla to participate at your event! + </p> + <p> + The Developer Events Team evaluates each request individually, based on + multiple criteria, including quarterly goals and priorities. We meet at + least biweekly, and this form is designed to gather all the information + we need to evaluate each request at these meetings. Please take a minute + to fill it out thoroughly so we can process your request as soon as + possible. + </p> + <p> + Please review our <a href="https://wiki.mozilla.org/Engagement/Developer_Engagement/Event_request_guidelines"> + event request guidelines</a> for information about how we evaluate requests. + </p> + </td> +</tr> + +<tr> + <th class="section-head"> + First, tell us about yourself! + </th> +</tr> + +<tr> + <th> + What is your name? [% mandatory FILTER none %] + </th> +</tr> +<tr> + <td> + <input type="text" name="name" id="name" size="40" class="wide" + value="[% user.name FILTER html %]"> + </td> +</tr> + +<tr> + <th> + Please provide your email address. [% mandatory FILTER none %] + </th> +</tr> +<tr> + <td> + <input type="text" name="email" id="email" size="40" class="wide" + value="[% user.login FILTER html %]"> + </td> +</tr> + +<tr> + <th> + What is your role in relation to this event? [% mandatory FILTER none %] + </th> +</tr> +<tr> + <td> + <div class="blurb"> + eg. organizer, speaker/atendee (past), speaker/attendee (current), etc. + </div> + <input type="text" name="role" id="role" size="40" class="wide"> + </td> +</tr> + +<tr> + <th class="section-head"> + Let's start with the basics. + </th> +</tr> + +<tr> + <th> + Event Name [% mandatory FILTER none %] + </th> +</tr> +<tr> + <td> + <input type="text" name="event" id="event" size="40" class="wide"> + </td> +</tr> + +<tr> + <th> + Start Date [% mandatory FILTER none %] + </th> +</tr> +<tr> + <td> + <input type="text" name="start_date" id="start_date" size="15" class="date" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_start_date" + onclick="showCalendar('start_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_start_date"></div> + </td> +</tr> + +<tr> + <th> + End Date [% mandatory FILTER none %] + </th> +</tr> +<tr> + <td> + <input type="text" name="end_date" id="end_date" size="15" class="date" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_end_date" + onclick="showCalendar('end_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_end_date"></div> + </td> +</tr> + +<tr> + <th> + Event Location + </th> +</tr> +<tr> + <td> + <div class="blurb"> + Include city, state, and country. Please write "Multiple" if this event + takes place across several locations. + </div> + <input type="text" name="location" id="location" size="40" class="wide"> + </td> +</tr> + +<tr> + <th> + Venue + </th> +</tr> +<tr> + <td> + <div class="blurb"> + What is the name of the venue where your event will be held? Enter TBD if + you don't know yet. + </div> + <input type="text" name="venue" id="venue" size="40" class="wide"> + </td> +</tr> + +<tr> + <th> + Weblink + </th> +</tr> +<tr> + <td> + <div class="blurb"> + Weblink to the event site, Eventbrite page, Lanyrd page, Meetup page, etc. + </div> + <input type="text" name="link" id="link" size="40" class="wide"> + </td> +</tr> + +<tr> + <th> + Number of expected attendees [% mandatory FILTER none %] + </th> +</tr> +<tr> + <td> + <input type="text" name="attendees" id="attendees" size="15"> + </td> +</tr> + +<tr> + <th> + Please give a [short] description of the event. [% mandatory FILTER none %] + </th> +</tr> +<tr> + <td> + <div class="blurb"> + Include track topics, presentation topics, event format. + </div> + <textarea name="desc" id="desc" rows="10" cols="40" class="wide"></textarea> + </td> +</tr> + +<tr> + <th> + Who is the primary audience for this event? [% mandatory FILTER none %] + </th> +</tr> +<tr> + <td> + <div class="blurb"> + Developers (specify coding language and platform), business development, + marketing associates, corporate executives, etc. + </div> + <input type="text" name="audience" id="audience" size="40" class="wide"> + </td> +</tr> + +<tr> + <th> + Which Mozilla products/projects are most relevant to this event? [% mandatory FILTER none %] + </th> +</tr> +<tr> + <td> + <div class="blurb"> + Please select all that apply. + See <a href="https://www.mozilla.org/en-US/products/" target="_blank">mozilla.org/products</a> + for more information about Mozilla products. + </div> + <input type="checkbox" name="product-fxos" id="product-fxos"> + <label for="product-fxos">Firefox OS</label><br> + <input type="checkbox" name="product-fx" id="product-fx"> + <label for="product-fx">Firefox Web Browser</label><br> + <input type="checkbox" name="product-webmaker" id="product-webmaker"> + <label for="product-webmaker">Webmaker</label><br> + <input type="checkbox" name="product-persona" id="product-persona"> + <label for="product-persona">Persona</label><br> + <input type="checkbox" name="product-marketplace" id="product-marketplace"> + <label for="product-marketplace">Marketplace</label><br> + <input type="checkbox" name="product-tb" id="product-tb"> + <label for="product-tb">Thunderbird</label><br> + <input type="checkbox" name="product-fow" id="product-fow"> + <label for="product-fow">The Free and Open Web</label><br> + <input type="checkbox" name="product-other" id="product-other" onchange="onProductOtherChange()"> + <label for="product-other">Other:</label> + <input type="text" name="product-other-text" id="product-other-text" size="40" disabled> + </td> +</tr> + +<tr> + <th class="section-head"> + Tell us more about what you're looking for! + </th> +</tr> + +<tr> + <th> + What are you requesting from Mozilla? [% mandatory FILTER none %] + </th> +</tr> +<tr> + <td> + <div class="blurb"> + Please select all that apply. + </div> + <input type="checkbox" name="request-keynote" id="request-keynote"> + <label for="request-keynote">Keynote Presentation</label><br> + <input type="checkbox" name="request-talk" id="request-talk"> + <label for="request-talk">Talk Presentation (non-keynote)</label><br> + <input type="checkbox" name="request-workshop" id="request-workshop"> + <label for="request-workshop">Workshop</label><br> + <input type="checkbox" name="request-sponsorship" id="request-sponsorship" onchange="onRequestSponsorshipChange()"> + <label for="request-sponsorship">Sponsorship</label><br> + <input type="checkbox" name="request-other" id="request-other" onchange="onRequestOtherChange()"> + <label for="request-other">Other:</label> + <input type="text" name="request-other-text" id="request-other-text" size="40" disabled> + </td> +</tr> + +<tbody id="sponsorship-suggestion-fields"> + <tr> + <th> + If requesting sponsorship, what amount/level do you suggest? + </th> + </tr> + <tr> + <td> + <input type="text" name="sponsorship-suggestion" id="sponsorship-suggestion" size="40" class="wide"> + </td> + </tr> +</tbody> + +<tr> + <th> + Please list the names of anyone from Mozilla who are already registered to + attend, speak, or participate in this event. + </th> +</tr> +<tr> + <td> + <input type="text" name="mozillians" id="mozillians" size="40" class="wide"> + </td> +</tr> + +<tr> + <th> + Are you requesting a specific person to present or participate at this + event? If so, please list their name(s). + </th> +</tr> +<tr> + <td> + <input type="text" name="specific" id="specific" size="40" class="wide"> + </td> +</tr> + +<tr> + <th> + If this individual is unable to attend/speak/participate in this event, is + there anyone else you would like to request? + </th> +</tr> +<tr> + <td> + <input type="text" name="fallback" id="fallback" size="40" class="wide"> + </td> +</tr> + +<tr> + <th> + Please upload a Sponsorship Prospectus if you have one. + </th> +</tr> +<tr> + <td> + <input type="file" name="data" id="data" size="40"> + <input type="hidden" name="contenttypemethod" value="autodetect"> + <input type="hidden" id="description" name="description" value="Sponsorship Prospectus"> + </td> +</tr> + +<tr> + <th> + Anything else that may help us review this request? + </th> +</tr> +<tr> + <td> + <input type="text" name="else" id="else" size="40" class="wide"> + </td> +</tr> + +<tr> + <td> </td> +</tr> +<tr> + <td> + <input type="submit" id="commit" value="Submit Request"> + </td> +</tr> + +</table> + +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-doc.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-doc.html.tmpl new file mode 100644 index 000000000..5b75976d9 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-doc.html.tmpl @@ -0,0 +1,222 @@ +[%# 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 %] + +[% inline_style = BLOCK %] +#doc_form th { + text-align: right; +} + +#short_desc, #details { + width: 100%; +} +[% END %] + +[% inline_javascript = BLOCK %] +function validateAndSubmit() { + var alert_text = ''; + if (!isFilledOut('type')) alert_text += 'Please select the "Request Type".\n'; + if (!isFilledOut('short_desc')) alert_text += 'Please enter a "Summary".\n'; + if (!isFilledOut('gecko')) alert_text += 'Please select the "Gecko Version".\n'; + if (!isFilledOut('details')) alert_text += 'Please enter some "Details".\n'; + if (alert_text != '') { + alert(alert_text); + return false; + } + return true; +} +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Developer Documentation Request" + style = inline_style + javascript = inline_javascript + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/field.js', 'js/util.js', 'js/bug.js' ] + yui = [ 'autocomplete', 'datatable', 'button' ] +%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +<h1>Developer Documentation Request</h1> + +<p> + Use this form to request <b>new documentation</b> or <b>corrections</b> to existing documentation.<br> + [ <span class="required_star">*</span> <span class="required_explanation">Required Fields</span> ] +</p> + +<form method="post" action="post_bug.cgi" enctype="multipart/form-data" + onSubmit="return validateAndSubmit();"> + <input type="hidden" name="format" value="doc"> + <input type="hidden" name="product" value="Developer Documentation"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="status_whiteboard" id="status_whiteboard" value=""> + <input type="hidden" name="token" value="[% token FILTER html %]"> + +<table id="doc_form"> + +<tr> + <th class="required">Request Type</th> + <td> + <select name="type" id="type"> + <option value="">Please select..</option> + <option value="New Documentation">New Documentation</option> + <option value="Correction" [% "selected" IF cgi.param('bug_file_loc') %]>Correction</option> + </select> + </td> +</tr> + +<tr> + <th class="required">Topic</th> + <td> + <select name="component" id="component"> + [% FOREACH component = product.components %] + <option value="[% component.name FILTER html %]" + [% " selected" IF component.name == "General" %] + title="[% component.description FILTER html %]"> + [% component.name FILTER html %] + </option> + [% END %] + </select> + </td> +</tr> + +<tr> + <th class="required">Summary</th> + <td> + Please provide a brief summary of what documentation you're requesting, or + what problem you're reporting in existing documentation:<br> + <input type="text" name="short_desc" id="short_desc" size="60"> + </td> +</tr> + +[% IF feature_enabled('jsonrpc') AND !cloned_bug_id %] + <tr id="possible_duplicates_container" class="bz_default_hidden"> + <th>Possible<br>Duplicates:</th> + <td colspan="3"> + <div id="possible_duplicates"></div> + <script type="text/javascript"> + var dt_columns = [ + { key: "id", label: "[% field_descs.bug_id FILTER js %]", + formatter: YAHOO.bugzilla.dupTable.formatBugLink }, + { key: "summary", + label: "[% field_descs.short_desc FILTER js %]", + formatter: "text" }, + { key: "status", + label: "[% field_descs.bug_status FILTER js %]", + formatter: YAHOO.bugzilla.dupTable.formatStatus }, + { key: "update_token", label: '', + formatter: YAHOO.bugzilla.dupTable.formatCcButton } + ]; + YAHOO.bugzilla.dupTable.addCcMessage = "Add Me to the CC List"; + YAHOO.bugzilla.dupTable.init({ + container: 'possible_duplicates', + columns: dt_columns, + product_name: '[% product.name FILTER js %]', + summary_field: 'short_desc', + options: { + MSG_LOADING: 'Searching for possible duplicates...', + MSG_EMPTY: 'No possible duplicates found.', + SUMMARY: 'Possible Duplicates' + } + }); + </script> + </td> + </tr> +[% END %] + +<tr> + <th>Page to Update</th> + <td> + <input type="text" name="bug_file_loc" id="short_desc" size="60" + value="[% bug_file_loc FILTER html %]"> + </td> +</tr> + +<tr> + <th>Technical Contact</th> + <td> + [% INCLUDE global/userselect.html.tmpl + id => "cc" + name => "cc" + value => "" + size => 60 + classes => ["bz_userfield"] + multiple => 5 + %] + <br> + <a href="https://developer.mozilla.org/en-US/docs/Project:Subject-matter_experts" + target="_blank" id="common_topic_experts"> + List of common topic experts</a> + </td> +</tr> + +<tr> + <th class="required">Gecko Version</th> + <td> + <select name="gecko" id="gecko"> + [% FOREACH version = versions %] + <option value="[% version.name FILTER html %]" + [% " selected" IF version.name == "unspecified" %]> + [% version.name FILTER html %] + </option> + [% END %] + </select> + </td> +</tr> + +<tr> + <th class="required">Details</th> + <td> + <textarea id="details" name="details" cols="50" rows="10"></textarea> + </td> +</tr> + +<tr> + <th>Development [% terms.Bug %]</th> + <td> + <input type="text" id="blocked" name="blocked" size="10"> + <i>Corresponding development [% terms.bug %].</i> + </td> +</tr> + +<tr> + <th class="required">Urgency</th> + <td> + <select name="priority" id="priority"> + <option value="P1">Immediately</option> + <option value="P2">Before Release</option> + <option value="P3">Before Aurora</option> + <option value="P4">Before Beta</option> + <option value="P5" selected>No Rush</option> + </select> + <br> + Due to the volume of requests, the documentation team can't commit to + meeting specific deadlines for given documentation requests, but we will do + our best. + </td> +</tr> + +<tr> + <td> </td> +</tr> + +<tr> + <td> </td> + <td><input type="submit" id="commit" value="Submit Request"></td> +</tr> + +</table> +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-employee-incident.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-employee-incident.html.tmpl new file mode 100644 index 000000000..164dd482c --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-employee-incident.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. + #%] + +[% PROCESS global/redirect.html.tmpl + url = "https://mozilla.service-now.com/com.glideapp.servicecatalog_cat_item_view.do?sysparm_id=4f9468ef184a30004a467ddd1a20df63" +%] diff --git a/extensions/BMO/template/en/default/bug/create/create-finance.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-finance.html.tmpl new file mode 100644 index 000000000..fa8dc5f5b --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-finance.html.tmpl @@ -0,0 +1,257 @@ +[%# 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 %] + +[% inline_style = BLOCK %] + #bug_form input[type=text], #bug_form input[type=file], #cc_autocomplete, #bug_form textarea { + width: 100%; + } +[% END %] + +[% inline_js = BLOCK %] + var compdesc = new Array(); + [% FOREACH comp = product.components %] + compdesc['[% comp.name FILTER js %]'] = '[% comp.description FILTER js %]'; + [% END %] + function showCompDesc(component) { + var value = component.value; + document.getElementById('comp_description').innerHTML = compdesc[value]; + } + + function onSubmit() { + var alert_text = ''; + if (!isFilledOut('component')) + alert_text += "Please select a value for request type.\n"; + if (!isFilledOut('short_desc')) + alert_text += "Please enter a value for the summary.\n"; + if (!isFilledOut('team_priority')) + alert_text += "Please select a value for team priority.\n"; + if (!isFilledOut('signature_time')) + alert_text += "Please enter a value for signture timeframe.\n"; + if (!isFilledOut('other_party')) + alert_text += "Please enter a value for the name of other party.\n"; + if (!isFilledOut('business_obj')) + alert_text += "Please enter a value for business objective.\n"; + if (!isFilledOut('what_purchase')) + alert_text += "Please enter a value for what you are purchasing.\n"; + if (!isFilledOut('why_purchase')) + alert_text += "Please enter a value for why the purchase is needed.\n"; + if (!isFilledOut('risk_purchase')) + alert_text += "Please enter a value for the risk if not purchased.\n"; + if (!isFilledOut('alternative_purchase')) + alert_text += "Please enter a value for the purchase alternative.\n"; + if (!isFilledOut('total_cost')) + alert_text += "Please enter a value for total cost.\n"; + if (!isFilledOut('attachment')) + alert_text += "Please enter an attachment.\n"; + + if (alert_text != '') { + alert(alert_text); + return false; + } + + return true; + } +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Finance" + style = inline_style + style_urls = [ 'skins/standard/enter_bug.css' ] + javascript = inline_js + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/attachment.js', 'js/field.js', 'js/util.js' ] + onload = "showCompDesc(document.getElementById('component'));" +%] + +<h2>Finance</h2> + +<p>All fields are mandatory</p> + +<form method="post" action="post_bug.cgi" id="bug_form" class="enter_bug_form" + enctype="multipart/form-data" onsubmit="return onSubmit();"> +<input type="hidden" name="format" value="finance"> +<input type="hidden" name="product" value="Finance"> +<input type="hidden" name="rep_platform" value="All"> +<input type="hidden" name="op_sys" value="Other"> +<input type="hidden" name="priority" value="--"> +<input type="hidden" name="version" value="unspecified"> +<input type="hidden" name="bug_severity" id="bug_severity" value="normal"> +<input type="hidden" name="comment" id="comment" value=""> +<input type="hidden" name="groups" id="groups" value="finance"> +<input type="hidden" name="token" value="[% token FILTER html %]"> + +<table> + +<tr> + <th> + <label for="component">Request Type:</label> + </th> + <td> + <select name="component" id="component" onchange="showCompDesc(this);"> + [%- FOREACH c = product.components %] + [% NEXT IF NOT c.is_active %] + <option value="[% c.name FILTER html %]" + id="v[% c.id FILTER html %]_component" + [% IF c.name == default.component_ %] + selected="selected" + [% END %]> + [% c.name FILTER html -%] + </option> + [%- END %] + </select + </td> +</tr> + +<tr> + <td></td> + <td id="comp_description" align="left" style="color: green; padding-left: 1em"></td> +</tr> + +<tr> + <th> + <label for="short_desc">Description:</label> + </th> + <td> + <i>Short description of what is being asked to sign</i><br> + <input name="short_desc" id="short_desc" size="60" + value="[% short_desc FILTER html %]"> + </td> +</tr> + +<tr> + <th> + <label for="team_priority">Priority to your Team:</label> + </th> + <td> + <select id="team_priority" name="team_priority"> + <option value="Low">Low</option> + <option value="Medium">Medium</option> + <option value="High">High</option> + </select> + </td> +</tr> + +<tr> + <th> + <label for="signature_time">Timeframe for Signature:</label> + </th> + <td> + <select id="signature_time" name="signature_time"> + <option value="24 hours">Within 24 hours</option> + <option value="2 days">2 days</option> + <option value="A week">A week</option> + <option value="2 - 4 weeks" selected>2 -4 weeks</option> + </select> + </td> +</tr> + +<tr> + <th> + <label for="other_party">Name of Other Party:</label> + </th> + <td> + <i>Include full legal entity name and any other relevant contact information</i><br> + <textarea id="other_party" name="other_party" + rows="5" cols="40"></textarea> + </td> +<tr> + +<tr> + <th> + <label for="business_obj">Business Objective:</label> + </th> + <td> + <i> + Which Initiative or Overall goal this purchase is for. i.e. B2G, Data Center, Network, etc.</i><br> + <textarea id="business_obj" name="business_obj" rows="5" cols="40"></textarea> + </td> +<tr> + +<tr> + <th> + <label for="what_purchase">If this is a purchase order,<br>what are we purchasing?</label> + </th> + <td> + <i> + Describe your request, what items are we purchasing, including number of + units if available.<br>Also provide context and background. Enter No if not + a purchase order.</i><br> + <textarea name="what_purchase" id="what_purchase" rows="5" cols="40"></textarea> + </td> +</tr> + +<tr> + <th> + <label for="why_purchase">Why is this purchase needed?</label> + </th> + <td> + <i> + Why do we need this? What is the work around if this is not approved?</i><br> + <textarea name="why_purchase" id="why_purchase" rows="5" cols="40"></textarea> + </td> +</tr> + +<tr> + <th> + <label for="risk_purchase">What is the risk if<br>this is not purchased?</label> + </th> + <td> + <i> + What will happen if this is not purchased?</i><br> + <textarea name="risk_purchase" id="risk_purchase" rows="5" cols="40"></textarea> + </td> +</tr> + +<tr> + <th> + <label for="alternative_purchase">What is the alternative?</label> + </th> + <td> + <i> + How did the team come to this recommendation? Did we get other bids, if so, how many?</i><br> + <textarea name="alternative_purchase" id="alternative_purchase" rows="5" cols="40"></textarea> + </td> +</tr> + +<tr> + <th> + <label for="total_cost">Total Cost</label> + </th> + <td> + <input type="text" name="total_cost" id="total_cost" value="" size="60"> + </td> +</tr> + +<tr> + <th> + <label for="attachment">Attachment:</label> + </th> + <td> + <i>Upload document that needs to be signed. If this is a Purchase Request form,<br> + also upload any supporting document such as draft SOW, quote, order form, etc.</i> + <div> + <input type="file" id="attachment" name="data" size="50"> + <input type="hidden" name="contenttypemethod" value="autodetect"> + <input type="hidden" name="description" value="Finance Document"> + </div> + </td> +</tr> + +<tr> + <td> </td> + <td> + <input type="submit" id="commit" value="Submit Request"> + </td> +</tr> +</table> + +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-fxos-betaprogram.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-fxos-betaprogram.html.tmpl new file mode 100644 index 000000000..3f8bbdd71 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-fxos-betaprogram.html.tmpl @@ -0,0 +1,180 @@ +[%# 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 %] + +[% phones = [ + 'ZTE Open', + 'Alcatel One Touch', + 'LG' +] %] + +[% inline_css = BLOCK %] + #dogfood { + margin: 0 5em 0 2em; + } + #dogfood th { + text-align: left; + padding-top: 1em; + } +[% END %] + +[% inline_js = BLOCK %] + function onSubmit() { + var alert_text = ''; + + var phone = false; + [% FOREACH phone = phones %] + if (document.getElementById('phone-cb-[% phone FILTER js %]').checked) + phone = true; + [% END %] + if (document.getElementById('phone-cb-other').checked && isFilledOut('phone-other')) + phone = true; + if (!phone) + alert_text += "Please select the type of phone you have.\n"; + + if (!isFilledOut('fxos-version')) + alert_text += "Please provide the version of Firefox OS you are running.\n"; + + if (!isFilledOut('short_desc')) + alert_text += "Please enter a value for the summary.\n"; + + if (!isFilledOut('details')) + alert_text += "Please describe your issue in more detail.\n"; + + if (alert_text != '') { + alert(alert_text); + return false; + } + return true; + } +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Firefox OS Beta Program $terms.Bug Submission" + style = inline_css + style_urls = [ 'skins/standard/enter_bug.css' ] + javascript = inline_js + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', 'js/field.js', 'js/util.js' ] +%] + +<h2>Firefox OS Beta Program [% terms.Bug %] Submission</h2> + +<div id="public_place"> + As [% terms.Bugzilla %] is a public place, don't include any private or + personally identifying content. +</div> + +<form method="post" action="post_bug.cgi" id="bug_form" class="enter_bug_form" + enctype="multipart/form-data" onsubmit="return onSubmit();"> +<input type="hidden" name="format" value="fxos-betaprogram"> +<input type="hidden" name="created-format" value="fxos-betaprogram"> +<input type="hidden" name="product" value="Firefox OS"> +<input type="hidden" name="component" value="BetaTriage"> +<input type="hidden" name="rep_platform" value="ARM"> +<input type="hidden" name="op_sys" value="Gonk (Firefox OS)"> +<input type="hidden" name="priority" value="--"> +<input type="hidden" name="version" value="unspecified"> +<input type="hidden" name="bug_severity" id="bug_severity" value="normal"> +<input type="hidden" name="comment" id="comment" value=""> +<input type="hidden" name="status_whiteboard" id="status_whiteboard" value="[dogfood]"> +<input type="hidden" name="token" value="[% token FILTER html %]"> + +<table id="dogfood"> + +<tr> + <th> + What phone do you have? + </th> +</tr> +<tr> + <td> + [% FOREACH phone = phones %] + <input type="radio" name="phone" id="phone-cb-[% phone FILTER html %]" + value="[% phone FILTER html %]"> + <label for="phone-cb-[% phone FILTER html %]">[% phone FILTER html %]</label><br> + [% END %] + <input type="radio" name="phone" id="phone-cb-other" value="Other"> + <input type="text" name="phone_other" id="phone-other" placeholder="Other" size="20" + onfocus="document.getElementById('phone-cb-other').checked = true"> + </td> +</tr> + +<tr> + <th> + What version of Firefox OS are you running? + </th> +</tr> +<tr> + <td> + <i> + Please check settings > device information > more information > OS version<br> + </i> + <input type="text" name="fxos_version" id="fxos-version" size="20"> + </td> +</tr> + +<tr> + <th> + Please summarize your issue in one sentence: + </th> +</tr> +<tr> + <td> + <input type="text" name="short_desc" id="short_desc" size="60"> + </td> +</tr> + +<tr> + <th> + Please describe your issue in more detail. If you have steps to + reproduce the problem, please include them here: + </th> +</tr> +<tr> + <td> + <textarea id="details" name="details" rows="5" cols="60"></textarea> + </td> +</tr> + +<tr> + <th> + If your issue is associated with a specific app, which one + </th> +</tr> +<tr> + <td> + <input type="text" name="app" id="app" size="60"> + </td> +</tr> + +<tr> + <th>Security:</th> +</tr> +<tr> + <td> + <input type="checkbox" name="groups" id="default_security_group" + value="[% product.default_security_group FILTER html %]"> + <label for="default_security_group"> + Many users could be harmed by this security problem: + it should be kept hidden from the public until it is resolved. + </label> + </td> +</tr> + +<tr> + <td> + <input type="submit" id="commit" value="Submit Request"> + </td> +</tr> + +</table> + +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-fxos-feature.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-fxos-feature.html.tmpl new file mode 100644 index 000000000..faa0495a4 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-fxos-feature.html.tmpl @@ -0,0 +1,181 @@ +[%# 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 %] + +[% inline_style = BLOCK %] +#feature_form { + padding: 10px; +} +#feature_form .required:after { + content: " *"; + color: red; +} +#feature_form .field_label { + font-weight: bold; +} +#feature_form .field_desc { + padding-bottom: 3px; +} +#feature_form .field_desc, +#feature_form .head_desc { + width: 600px; + word-wrap: normal; +} +#feature_form .head_desc { + padding-top: 5px; + padding-bottom: 12px; +} +#feature_form .form_section { + margin-bottom: 10px; +} +#feature_form textarea { + font-family: inherit; + font-size: inherit; +} +#feature_form #comp_description { + test-align: left; + color: green; + padding-left: 1em; +} +[% END %] + +[% inline_javascript = BLOCK %] +var compdesc = new Array(); +compdesc[""] = 'Please select a component from the list above.'; +[% FOREACH comp = product.components %] + compdesc['[% comp.name FILTER js %]'] = '[% comp.description FILTER js %]'; +[% END %] +function showCompDesc() { + var comp_select = document.getElementById('component'); + document.getElementById('comp_description').innerHTML = compdesc[comp_select.value]; +} +function validateAndSubmit() { + var alert_text = ''; + if (!isFilledOut('component')) alert_text += 'Please select a value for product component.\n'; + if (!isFilledOut('short_desc')) alert_text += 'Please enter a value for feature request title.\n'; + if (alert_text != '') { + alert(alert_text); + return false; + } + return true; +} +YAHOO.util.Event.onDOMReady(showCompDesc); +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Firefox OS Feature Request Form" + style = inline_style + javascript = inline_javascript + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js' ] +%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +<form id="feature_form" method="post" action="post_bug.cgi" enctype="multipart/form-data" + onSubmit="return validateAndSubmit();"> + <input type="hidden" name="format" value="fxos-feature"> + <input type="hidden" name="product" value="Firefox OS"> + <input type="hidden" name="keywords" value="feature"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + +<img title="Firefox OS Feature Form" src="extensions/BMO/web/producticons/firefox.png"> + +<div class="form_section"> + <label for="component" class="field_label required">Product Component</label> + <div class="field_desc"> + Which product component is your feature request applicable to? + If you are not sure, choose "General". + </div> + <select name="component" id="component" onchange="showCompDesc(this);"> + <option value="">Select One</option> + [%- FOREACH c = product.components %] + [% NEXT IF NOT c.is_active %] + <option value="[% c.name FILTER html %]" + id="v[% c.id FILTER html %]_component" + [% IF c.name == default.component_ %] + selected="selected" + [% END %]> + [% c.name FILTER html -%] + </option> + [%- END %] + </select> + <div id="comp_description"></div> +</div> + +<div class="form_section"> + <label for="short_desc" class="field_label required">Feature Request Title</label> + <div class="field_desc"> + Please enter a title for your feature request that is brief and self explanatory. + (Example: "Memory dialing using keypad numbers") + </div> + <input type="text" name="short_desc" id="short_desc" size="80"> +</div> + +<div class="form_section"> + <label for="description" class="field_label">Description of feature or problem to be solved</label> + <div class="field_desc"> + Please describe the feature that you are requesting or the problem that you would like solved in detail + (Example, "Today, there is no way for the user to quickly dial user-defined numbers from the dial pad. + Instead the user must search for an find the contact in their contact list."). + If the described feature only applies to certain device types (eg. tablet vs. smartphone), please make note of it. + </div> + <textarea id="description" name="description" cols="80" rows="5"></textarea> +</div> + +<div class="form_section"> + <label for="implement_impact" class="field_label">Impact of implementing the feature/solution</label> + <div class="field_desc"> + If this solution were to be implemented, what would the impact be? + (Example, "If this solution were to be implemented, it would save the users + significant time when dialing commonly used phone numbers.") + </div> + <textarea id="implement_impact" name="implement_impact" cols="80" rows="5"></textarea> +</div> + +<div class="form_section"> + <label for="not_implement_impact" class="field_label">Impact of NOT implementing the feature/solution</label> + <div class="field_desc"> + If this solution were NOT to be implemented, what would the impact be? + (Example, "By not implementing this solution, we are unable to sell phones in + Iceland which has a certification requirement to have support for memory dialing.") + </div> + <textarea id="not_implement_impact" name="not_implement_impact" cols="80" rows="5"></textarea> +</div> + +<div class="form_section"> + <label for="date_required" class="field_label">Date required</label> + <div class="field_desc"> + Is this solution required by a certain date? Why? + (Example: "March 2014. We plan to sell phones in Iceland in June 2014 using Firefox OS 1.4. + Completing the feature in March would allow the device to pass operator certification in time + for a June retail launch.")<br> + <strong>Note:</strong> completing this field does not imply that the feature will indeed be delivered in this timeframe. + </div> + <textarea id="date_required" name="date_required" cols="80" rows="5"></textarea> +</div> + +<div class="head_desc"> + Once your form has been submitted, a tracking [% terms.bug %] will be created. We will + then reach out for additional info and next steps. Thanks! +</div> + +<input type="submit" id="commit" value="Submit"> + +<p> + [ <span class="required_star">*</span> <span class="required_explanation">Required Field</span> ] +</p> + +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-fxos-mcts-waiver.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-fxos-mcts-waiver.html.tmpl new file mode 100644 index 000000000..bfd624a8a --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-fxos-mcts-waiver.html.tmpl @@ -0,0 +1,208 @@ +[%# 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 %] + +[% inline_style = BLOCK %] +#fxos_mcts_waiver_form { + padding: 10px; +} +#fxos_mcts_waiver_form .required:after { + content: " *"; + color: red; +} +#fxos_mcts_waiver_form .field_label { + font-weight: bold; +} +#fxos_mcts_waiver_form .field_desc { + padding-bottom: 3px; +} +#fxos_mcts_waiver_form .field_desc, +#fxos_mcts_waiver_form .head_desc { + width: 600px; + word-wrap: normal; +} +#fxos_mcts_waiver_form .head_desc { + padding-top: 5px; + padding-bottom: 12px; +} +#fxos_mcts_waiver_form .form_section { + margin-bottom: 10px; +} +#fxos_mcts_waiver_form textarea { + font-family: inherit; + font-size: inherit; +} +#fxos_mcts_waiver_form em { + font-size: 1em; +} +[% END %] + +[% inline_javascript = BLOCK %] +function validateAndSubmit() { + 'use strict'; + var alert_text = ''; + var requiredLabels = YAHOO.util.Selector.query('label.required'); + if (requiredLabels) { + requiredLabels.forEach(function (label) { + var name = label.getAttribute('for'); + var ids = YAHOO.util.Selector.query( + '#fxos_mcts_waiver_form *[name="' + name + '"]' + ).map(function (e) { + return e.id + }); + + if (ids && ids[0]) { + if (!isFilledOut(ids[0])) { + var desc = label.textContent || name; + alert_text += + "Please enter a value for " + + desc.replace(/[\r\n]+/, "").replace(/\s+/g, " ") + + "\n"; + } + } + }); + } + + if (alert_text != '') { + alert(alert_text); + return false; + } + + var short_desc = document.getElementById('short_desc'); + var company_name = document.getElementById('company_name').value; + short_desc.value = "MCTS Waiver for " + company_name; + + return true; +} +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Firefox OS MCTS Waiver Form" + style = inline_style + javascript = inline_javascript + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/field.js', 'js/util.js' ] + yui = [ 'selector' ] +%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +<form id="fxos_mcts_waiver_form" method="post" action="post_bug.cgi" + enctype="multipart/form-data" onSubmit="return validateAndSubmit();"> + <input type="hidden" name="format" value="fxos-mcts-waiver"> + <input type="hidden" name="product" value="Firefox OS"> + <input type="hidden" name="component" value="MCTS Waiver Request"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + <input type="hidden" name="groups" value="mozilla-employee-confidential"> + <input type="hidden" id="short_desc" name="short_desc" value=""> + <input type="hidden" name="cf_user_story" value="Engineering Analysis: + + +Technical Account Manager Recommendation: + + +"> + + <div class="head_desc"> + Welcome to the [% title FILTER html %]! + </div> + + <div class="form_section"> + <label for="company_name" class="field_label required">Company Name</label> + <div class="field_desc"> + Please enter the legal name of the company requesting the Waiver + </div> + <input type="text" name="company_name" id="company_name" size="80"> + </div> + + <div class="form_section"> + <label for="device_desc" class="field_label required">Device Description</label> + <div class="field_desc"> + Please enter the Make, Model, Chipset, screensize and type the device associated with the waiver request. For + example type may be mobile phone, tablet, dongle, tv, etc. + </div> + <textarea id="device_desc" name="device_desc" cols="80" rows="5"></textarea> + </div> + + <div class="form_section"> + <label for="ffos_release" class="field_label required">FFOS Release</label> + <div class="field_desc"> + Please Enter the Release this Waiver applies to for this partner. + </div> + <input type="text" name="ffos_release" id="ffos_release" size="80"> + </div> + + <div class="form_section"> + <label for="branding_tier" class="field_label required">Branding Tier</label> + <div class="field_desc"> + Please Enter the Branding Tier associated with the Waiver Request (Powered by Firefox OS or Co-Branded). + </div> + <select name="branding_tier" id="branding_tier"> + <option value="Firefox OS Inside">Firefox OS Inside</option> + <option value="Powered by Firefox OS">Powered by Firefox OS</option> + <option value="Firefox OS Co-branded">Firefox OS Co-branded</option> + </select> + </div> + + <div class="form_section"> + <label for="dist_countries" class="field_label required">Distribution Countries</label> + <div class="field_desc"> + Please include list of countries where the device is planned to be distributed. + </div> + <textarea id="dist_countries" name="dist_countries" cols="80" rows="5"></textarea> + </div> + + <div class="form_section"> + <label for="dist_channel" class="field_label required">Distribution Channel</label> + <div class="field_desc"> + Please identify how this device will be sold. For example, Operator, Retail. + </div> + <input type="text" name="dist_channel" id="dist_channel" size="80"> + </div> + + <div class="form_section"> + <label for="reason" class="field_label required">Reason for Waiver Request</label> + <div class="field_desc"> + Please describe which test cases, Branding Guidelines and/or Requirements the Partner is request waived. + </div> + <textarea id="reason" name="reason" cols="80" rows="5"></textarea> + </div> + + <div class="form_section"> + <label for="rationale" class="field_label required">Rationale for Granting Waiver Request</label> + <div class="field_desc"> + Please document why the Partner thinks a waiver should be granted. + </div> + <textarea id="rationale" name="rationale" cols="80" rows="5"></textarea> + </div> + + <div class="form_section"> + <label for="impact" class="field_label required">Impact Analysis</label> + <div class="field_desc"> + Please provide an assessment of the impact of granting this waiver in general business terms (this should include + broad perspective of potential issues such as brand consistency, impacts on reporting & tracking capabilities, + help desk/support issues, etc.) + </div> + <textarea id="impact" name="impact" cols="80" rows="5"></textarea> + </div> + + <input type="submit" id="commit" value="Submit"> + + <p> + [ <span class="required_star">*</span> <span class="required_explanation"> + Required Field</span> ] + </p> +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-fxos-partner.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-fxos-partner.html.tmpl new file mode 100644 index 000000000..3e910990d --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-fxos-partner.html.tmpl @@ -0,0 +1,239 @@ +[%# 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 %] + +[% inline_js = BLOCK %] + var compdesc = new Array(); + compdesc[""] = 'Please select a component from the list above.'; + [% FOREACH comp = product.components %] + compdesc['[% comp.name FILTER js %]'] = '[% comp.description FILTER js %]'; + [% END %] + function showCompDesc(component) { + var value = component.value; + document.getElementById('comp_description').innerHTML = compdesc[value]; + } + function onSubmit() { + var alert_text = ''; + var status_whiteboard = ''; + + if (!isFilledOut('component')) + alert_text += "Please select a value for component.\n"; + if (!isFilledOut('short_desc')) + alert_text += "Please enter a value for the summary.\n"; + if (!isFilledOut('steps_to_reproduce')) + alert_text += "Please enter the steps to reproduce.\n"; + if (!isFilledOut('actual_behavior')) + alert_text += "Please enter the actual behavior.\n"; + if (!isFilledOut('expected_behavior')) + alert_text += "Please enter the expected behavior.\n"; + if (!isFilledOut('build')) + alert_text += "Please enter a value for the build.\n"; + if (!isFilledOut('requirements')) + alert_text += "Please enter a value for the requirements.\n"; + + var device_values = new Array(); + var device_select = document.getElementById("b2g_device"); + for (var i = 0, l = device_select.options.length; i < l; i++) { + if (device_select.options[i].selected) + device_values.push(device_select.options[i].value); + } + + if (device_values.length == 0) + alert_text += "Please select one or more devices.\n"; + + if (alert_text != '') { + alert(alert_text); + return false; + } + + for (var i = 0, l = device_values.length; i < l; i++) + status_whiteboard += '[device:' + device_values[i] + '] '; + + if (document.getElementById('third_party_app').checked) + status_whiteboard += '[apps watch list]'; + + document.getElementById('status_whiteboard').value = status_whiteboard; + + return true; + } +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Firefox OS Partner $terms.Bug Submission" + style_urls = [ 'skins/standard/enter_bug.css' ] + javascript = inline_js + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/attachment.js', 'js/field.js', 'js/util.js' ] + onload = "showCompDesc(document.getElementById('component'));" +%] + +<h2>Firefox OS Partner [% terms.Bug %] Submission</h2> + +<p>All fields are mandatory</p> + +<form method="post" action="post_bug.cgi" id="bug_form" class="enter_bug_form" + enctype="multipart/form-data" onsubmit="return onSubmit();"> +<input type="hidden" name="format" value="fxos-partner"> +<input type="hidden" name="product" value="Firefox OS"> +<input type="hidden" name="rep_platform" value="ARM"> +<input type="hidden" name="op_sys" value="Gonk (Firefox OS)"> +<input type="hidden" name="priority" value="--"> +<input type="hidden" name="version" value="unspecified"> +<input type="hidden" name="bug_severity" id="bug_severity" value="normal"> +<input type="hidden" name="comment" id="comment" value=""> +<input type="hidden" name="keywords" id="keywords" value="unagi"> +<input type="hidden" name="status_whiteboard" id="status_whiteboard" value=""> +<input type="hidden" name="token" value="[% token FILTER html %]"> + +<table> + +<tr> + <th> + <label for="short_desc">Summary:</label> + </th> + <td> + <input name="short_desc" id="short_desc" size="60" + value="[% short_desc FILTER html %]"> + </td> +</tr> + +<tr> + <th> + <label for="component">Component:</label> + </th> + <td> + <select name="component" id="component" onchange="showCompDesc(this);"> + <option value="">Select One</option> + [%- FOREACH c = product.components %] + [% NEXT IF NOT c.is_active %] + <option value="[% c.name FILTER html %]" + id="v[% c.id FILTER html %]_component" + [% IF c.name == default.component_ %] + selected="selected" + [% END %]> + [% c.name FILTER html -%] + </option> + [%- END %] + </select + </td> +</tr> + +<tr> + <td></td> + <td id="comp_description" align="left" style="color: green; padding-left: 1em"></td> +</tr> + +<tr> + <th> + <label for="b2g_device">B2G Device:</label> + </th> + <td> + <select name="b2g_device" id="b2g_device" + size="5" multiple="multiple"> + <option name="Otoro">Otoro</option> + <option name="Unagi">Unagi</option> + <option name="Inari">Inari</option> + <option name="Ikura">Ikura</option> + <option name="Hamachi">Hamachi</option> + <option name="Buri">Buri</option> + <option name="Toro">Toro</option> + <option name="Leo">Leo</option> + <option name="Twist">Twist</option> + <option name="Zero">Zero</option> + <option name="Tara">Tara</option> + </select> + </td> +</tr> + +<tr> + <th> + <label for="other_party">What are the steps to reproduce?:</label> + </th> + <td> + <textarea id="steps_to_reproduce" name="steps_to_reproduce" rows="5" cols="60">1. +2. +3.</textarea> + </td> +<tr> + +<tr> + <th> + <label for="actual_behavior">What was the actual behavior?:</label> + </th> + <td> + <textarea id="actual_behavior" name="actual_behavior" rows="5" cols="60"></textarea> + </td> +<tr> + +<tr> + <th> + <label for="expected_behavior">What was the expected behavior?:</label> + </th> + <td> + <textarea name="expected_behavior" id="expected_behavior" rows="5" cols="60"></textarea> + </td> +</tr> + +<tr> + <th> + <label for="build">What build were you using?:</label> + </th> + <td> + <input type="text" name="build" id="build" value="" size="60"> + </td> +</tr> + +<tr> + <th> + <label for="requirements">What are the requirements?:</label> + </th> + <td> + <input type="text" name="requirements" id="requirements" value="" size="60"> + </td> +</tr> + +<tr> + <th> + <label for="requirements">Third party app content?:</label> + </th> + <td> + <input type="checkbox" name="third_party_app" id="third_party_app"> + </td> +</tr> + +<tr> + <th>Security:</th> + <td> + <input type="checkbox" name="groups" id="default_security_group" + value="[% product.default_security_group FILTER html %]" + [% FOREACH g = group %] + [% IF g.name == name %] + [% ' checked="checked"' IF g.checked %] + [% LAST %] + [% END %] + [% END %] + > + <label for="default_security_group"> + Many users could be harmed by this security problem: + it should be kept hidden from the public until it is resolved. + </label> + </td> +</tr> + +<tr> + <td> </td> + <td> + <input type="submit" id="commit" value="Submit Request"> + </td> +</tr> +</table> + +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-fxos-preload-app.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-fxos-preload-app.html.tmpl new file mode 100644 index 000000000..edf7fdbae --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-fxos-preload-app.html.tmpl @@ -0,0 +1,185 @@ +[%# 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 %] + +[% inline_style = BLOCK %] +#fxos_preload_app_form { + padding: 10px; +} +#fxos_preload_app_form .required:after { + content: " *"; + color: red; +} +#fxos_preload_app_form .field_label { + font-weight: bold; +} +#fxos_preload_app_form .field_desc { + padding-bottom: 3px; +} +#fxos_preload_app_form .field_desc, +#fxos_preload_app_form .head_desc { + width: 600px; + word-wrap: normal; +} +#fxos_preload_app_form .head_desc { + padding-top: 5px; + padding-bottom: 12px; +} +#fxos_preload_app_form .form_section { + margin-bottom: 10px; +} +#fxos_preload_app_form textarea { + font-family: inherit; + font-size: inherit; +} +#fxos_preload_app_form em { + font-size: 1em; +} +.yui-calcontainer { + z-index: 2; +} +[% END %] + +[% inline_javascript = BLOCK %] +function validateAndSubmit() { + 'use strict'; + var alert_text = ''; + var requiredLabels = YAHOO.util.Selector.query('label.required'); + if (requiredLabels) { + requiredLabels.forEach(function (label) { + var name = label.getAttribute('for'); + var ids = YAHOO.util.Selector.query( + '#fxos_preload_app_form *[name="' + name + '"]' + ).map(function (e) { + return e.id + }); + + if (ids && ids[0]) { + if (!isFilledOut(ids[0])) { + var desc = label.textContent || name; + alert_text += + "Please enter a value for " + + desc.replace(/[\r\n]+/, "").replace(/\s+/g, " ") + + "\n"; + } + } + }); + } + + if (alert_text != '') { + alert(alert_text); + return false; + } + return true; +} +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Firefox OS Pre-load App" + style = inline_style + javascript = inline_javascript + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/field.js', 'js/util.js' ] + yui = [ "autocomplete", "calendar", "selector" ] +%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +<form id="fxos_preload_app_form" method="post" action="post_bug.cgi" + enctype="multipart/form-data" onSubmit="return validateAndSubmit();"> + <input type="hidden" name="format" value="fxos-preload-app"> + <input type="hidden" name="product" value="Marketplace"> + <input type="hidden" name="component" value="Pre-Installed Apps"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="version" value="1.0"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + <input type="hidden" name="short_desc" value="Information Request: Pre-Installed Apps"> + <input type="hidden" name="groups" value="mozilla-employee-confidential"> + + <div class="head_desc"> + Welcome to the Firefox OS Pre-load App Info Request Form! + </div> + + <div class="form_section"> + <label for="company_name" class="field_label required">Company Name</label> + <div class="field_desc"> + Please enter the legal name of your company + </div> + <input type="text" name="company_name" id="company_name" size="80"> + </div> + + + <div class="form_section"> + <label for="apps_business_dev_contact_name" class="field_label required">Apps Business Development Contact Name</label> + <div class="field_desc">Please enter your Name</div> + <input type="text" name="apps_business_dev_contact_name" id="apps_business_dev_contact_name" size="80"> + </div> + + <div class="form_section"> + <label for="apps_business_dev_contact_email" class="field_label required">Apps Business Development Contact Email</label> + <div class="field_desc">Please enter your Email address.</div> + <input type="text" name="apps_business_dev_contact_email" id="apps_business_dev_contact_email" + value="[% user.email FILTER html %]" size="80"> + </div> + + <div class="form_section"> + <label for="preload_apps" class="field_label required">Name of Firefox Marketplace apps of interest to you:</label> + <div class="field_desc"> + Please provide the App Name and Marketplace URL for each app you wish to pre-load on your certified, branded + Firefox OS device. The Marketplace URL is an important identifier because there are many apps in Marketplace with + the same name. + </div> + <textarea id="preload_apps" name="preload_apps" + cols="80" rows="5"></textarea> + </div> + + <div class="form_section"> + <label for="countries" class="field_label required">Countries where your device will be distributed</label> + <div class="field_desc"> + Please list the countries where your device will be distributed. This information is required because it + corresponds to the countries that the developers will evaluate for distribution rights. + </div> + <textarea id="countries" name="countries" + cols="80" rows="5"></textarea> + </div> + + <div class="form_section"> + <label for="release_info" class="field_label required">Release Information</label> + <div class="field_desc"> + Please provide the Version of Firefox OS for your Branded, Certified device on which you plan to pre-load the + requested apps. + </div> + <input type="text" name="release_info" id="release_info" size="80"> + </div> + + <div class="form_section"> + <label for="device_info" class="field_label required">Device Information</label> + <div class="field_desc"> + Please include the device make and model, screen size, Chipset and RAM configuration for the Branded, Certified + device on which you plan to pre-load the requested apps. + </div> + <textarea id="device_info" name="device_info" + cols="80" rows="5"></textarea> + </div> + + <p>When you press submit the information you've provided will be routed to Mozilla team members for follow up. The + system will also respond with a [% terms.Bugzilla %] tracking number that you may use for follow up.</p> + + <input type="submit" id="commit" value="Submit"> + + <p> + [ <span class="required_star">*</span> <span class="required_explanation"> + Required Field</span> ] + </p> +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-ipp.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-ipp.html.tmpl new file mode 100644 index 000000000..fb59cfeb3 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-ipp.html.tmpl @@ -0,0 +1,183 @@ +[%# 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 %] + +[% inline_style = BLOCK %] +#ipp_form th { + text-align: right; +} + +#ipp_form input[type="text"], #ipp_form textarea { + width: 100%; +} + +#ipp_form textarea { + font-family: inherit; + font-size: inherit; +} + +#standard_link { + margin-top: 2em; +} + +#standard_link img { + vertical-align: middle; +} + +#standard_link a { + cursor: pointer; +} + +[% END %] + +[% inline_javascript = BLOCK %] +function validateAndSubmit() { + var alert_text = ''; + if (!isFilledOut('component')) alert_text += 'Please select the "Area".\n'; + if (!isFilledOut('short_desc')) alert_text += 'Please enter a "Summary".\n'; + if (!isFilledOut('region')) alert_text += 'Please enter the "Region/Country".\n'; + if (!isFilledOut('desc')) alert_text += 'Please provide a "Description".\n'; + if (!isFilledOut('relevance')) alert_text += 'Please provide some "Relevance".\n'; + if (!isFilledOut('goal')) alert_text += 'Please enter the "Goal".\n'; + if (!isFilledOut('when')) alert_text += 'Please enter data for the "When" field.\n'; + if (alert_text != '') { + alert(alert_text); + return false; + } + return true; +} +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Internet Public Policy Issue" + style = inline_style + javascript = inline_javascript + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/field.js', 'js/util.js', 'js/bug.js' ] +%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +<h1>Internet Public Policy Issue</h1> + +<form method="post" action="post_bug.cgi" enctype="multipart/form-data" + onSubmit="return validateAndSubmit();"> + <input type="hidden" name="format" value="ipp"> + <input type="hidden" name="product" value="Internet Public Policy"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + +<table id="ipp_form"> + +<tr> + <th class="required">Area</th> + <td> + <select name="component" id="component"> + <option value="">Please select..</option> + [% FOREACH component = product.components %] + <option value="[% component.name FILTER html %]"> + [% component.name FILTER html %] + </option> + [% END %] + </select> + </td> +</tr> + +<tr> + <th class="required">Summary</th> + <td> + <input type="text" name="short_desc" id="short_desc" size="60" + placeholder="(Describe issue in one sentence)"> + </td> +</tr> + +<tr> + <th class="required">Region/Country</th> + <td> + <input type="text" name="region" id="region" size="60"> + </td> +</tr> + +<tr> + <th class="required">Description</th> + <td> + <textarea id="desc" name="desc" cols="50" rows="5" + placeholder="(Explain the legislative or policy activity which is happening)"></textarea> + </td> +</tr> + +<tr> + <th class="required">Relevance</th> + <td> + <textarea id="relevance" name="relevance" cols="50" rows="5" + placeholder="(Why should Mozilla care? What’s the impact on the open internet?)"></textarea> + </td> +</tr> + +<tr> + <th class="required">Goal</th> + <td> + <input type="text" name="goal" id="goal" size="60" + placeholder="(What would success look like for Mozilla?)"> + </td> +</tr> + +<tr> + <th class="required">When</th> + <td> + <input type="text" name="when" id="when" size="60" + placeholder="(Describe the timeline or due date)"> + </td> +</tr> + +<tr> + <th class="required">Urgency</th> + <td> + <select name="priority" id="priority"> + <option value="P1">Urgent</option> + <option value="P3">Needs Attention Soon</option> + <option value="P5" selected>When You Get To It</option> + </select> + </td> +</tr> + +<tr> + <th>Additional Information</th> + <td> + <textarea id="additional" name="additional" cols="50" rows="5" + placeholder="(Please supply links to relevant articles/websites/organizations)"></textarea> + </td> +</tr> + +<tr> + <td> </td> +</tr> + +<tr> + <td> </td> + <td><input type="submit" id="commit" value="Submit Issue"></td> +</tr> + +</table> +</form> + +[ <span class="required_star">*</span> <span class="required_explanation">Required Field</span> ] + +<div id="standard_link"> + <a href="enter_bug.cgi?format=__standard__&product=[% product.name FILTER uri %]"> + <img src="extensions/BMO/web/images/advanced.png" width="16" height="16" border="0"></a> + <a href="enter_bug.cgi?format=__standard__&product=[% product.name FILTER uri %]"> + Switch to the standard [% terms.bug %] entry form</a> +</div> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-itrequest.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-itrequest.html.tmpl new file mode 100644 index 000000000..57c11a08a --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-itrequest.html.tmpl @@ -0,0 +1,238 @@ +[%# 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 %] + +[% inline_javascript = BLOCK %] + function setsevdesc(theSelect) { + var theValue = theSelect.options[theSelect.selectedIndex].value; + if (theValue == 'blocker') { + document.getElementById('blockerdesc').style.display = 'block'; + document.getElementById('critdesc').style.display = 'none'; + } else if (theValue == 'critical') { + document.getElementById('blockerdesc').style.display = 'none'; + document.getElementById('critdesc').style.display = 'block'; + } else { + document.getElementById('blockerdesc').style.display = 'none'; + document.getElementById('critdesc').style.display = 'none'; + } + } + + var compdesc = new Array(); + [% FOREACH comp IN product.components %] + compdesc['[% comp.name FILTER js %]'] = '[% comp.description FILTER js %]'; + [% END %] + compdesc['Server Operations'] = 'System administration for the mozilla.org servers. ' + + 'Requests for Server Ops that don\'t fit in any of the ' + + 'other Server Ops components can go here.'; + + var serviceNowText = 'Use <a href="https://mozilla.service-now.com/">Service Now</a> to:<br>' + + 'Request an LDAP/E-mail/etc. account<br>' + + 'Desktop/Laptop/Printer/Phone/Tablet/License problem/order/request'; + + function setcompdesc(theRadio) { + if (theRadio.id == 'component_service_desk') { + [%# helpdesk issue/request %] + document.getElementById('main_form').style.display = 'none'; + document.getElementById('service_now_form').style.display = ''; + document.getElementById('compdescription').innerHTML = serviceNowText; + } else { + document.getElementById('main_form').style.display = ''; + document.getElementById('service_now_form').style.display = 'none'; + var theValue = theRadio.value; + var compDescText = compdesc[theValue]; + // If 'Server Operations', product must be changed to 'mozilla.org' + // otherwise set to 'Infrastructure & Operations' + if (theRadio.id == 'component_server_ops') { + compDescText = compDescText + '<br><br>' + serviceNowText; + document.getElementById('product').value = 'mozilla.org'; + } + else { + document.getElementById('product').value = 'Infrastructure & Operations'; + } + document.getElementById('compdescription').innerHTML = compDescText; + } + } + + function on_submit() { + if (document.getElementById('componentsd').checked) { + [%# redirect desktop issues to service-now #%] + document.location.href = 'https://mozilla.service-now.com/'; + return false; + } + return true; + } + + YAHOO.util.Event.onDOMReady(function() { + var comps = document.getElementsByName('component'); + for (var i = 0, l = comps.length; i < l; i++) { + if (comps[i].checked) { + setcompdesc(comps[i]); + break; + } + } + }); +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Mozilla Corporation/Foundation IT Requests" + javascript = inline_javascript + javascript_urls = [ 'js/field.js' ] + yui = [ 'autocomplete' ] +%] + +[% USE Bugzilla %] + +<p><strong>Please use this form for IT requests only!</strong></p> +<p>If you have a [% terms.bug %] to file, go <a href="enter_bug.cgi">here</a>.</p> + +<form method="post" action="post_bug.cgi" id="itRequestForm" enctype="multipart/form-data" + onsubmit="return on_submit()"> + <input type="hidden" id="product" name="product" value="Infrastructure & Operations"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="priority" value="--"> + <input type="hidden" name="version" value="other"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + <table> + <tr> + + <td align="right"> + <strong>Urgency:</strong> + </td> + + <td> + <select id="bug_severity" name="bug_severity" onchange="setsevdesc(this)"> + <option value="blocker">All work for IT stops until this is done</option> + <option value="critical">IT should work on it soon as possible (urgent)</option> + <option value="major">IT should get to it within 24 hours</option> + <option value="normal">IT should get to it within the next week</option> + <option value="minor" selected="selected">No rush, but hopefully IT can get to it soon</option> + <option value="trivial">Whenever IT can get around to it</option> + <option value="enhancement">This is just an idea, filing it so we don't forget</option> + </select> + </td> + <td> + <div id="blockerdesc" style="color:red;display:none">This will page the on-call sysadmin if not handled within 30 minutes.</div> + <div id="critdesc" style="color:red;display:none">This will page the on-call sysadmin if not handled within 8 hours.</div> + </td> + + </tr> + <tr> + <td align="right"><strong>Request Type:</strong></td> + <td style="white-space: nowrap;"> + <input type="radio" name="component" id="component_service_desk" onclick="setcompdesc(this)" value="Desktop Issues"> + <label for="component_service_desk">Service Desk issue/request</label><br> + <input type="radio" name="component" id="component_relops" onclick="setcompdesc(this)" value="RelOps"> + <label for="component_relops">Report a problem with a tinderbox machine</label><br> + <input type="radio" name="component" id="component_webops_other" onclick="setcompdesc(this)" value="WebOps: Other"> + <label for="component_webops_other">Report a problem with a Mozilla website, or to request a change or push</label><br> + <input type="radio" name="component" id="component_netops_acl" onclick="setcompdesc(this)" value="NetOps: DC Other"> + <label for="component_netops_acl">Request a firewall change</label><br> + <input type="radio" name="component" id="component_server_ops" onclick="setcompdesc(this)" value="Server Operations"> + <label for="component_server_ops">Any other issue</label><br> + Mailing list requests should be filed <a href="[% ulrbase FILTER none %]enter_bug.cgi?product=mozilla.org&format=mozlist">here</a> instead. + </td> + <td id="compdescription" align="left" style="color: green; padding-left: 1em"> + </td> + </tr> + + <tbody id="main_form"> + + <tr> + <td align="right"><strong>Summary:</strong></td> + <td colspan="3"> + <input name="short_desc" size="60" value="[% short_desc FILTER html %]"> + </td> + </tr> + + <tr> + <td align="right"><strong>CC (optional):</strong></td> + <td colspan="3"> + [% INCLUDE global/userselect.html.tmpl + id => "cc" + name => "cc" + value => cc + size => 60 + multiple => 5 + %] + </td> + </tr> + + <tr><td align="right" valign="top"><strong>Description:</strong></td> + <td colspan="3"> + <textarea name="comment" rows="10" cols="80"> + [% comment FILTER html %]</textarea> + <br> + </td> + </tr> + + <tr> + <td align="right"><strong>URL (optional):</strong></td> + <td colspan="3"> + <input name="bug_file_loc" size="60" + value="[% bug_file_loc FILTER html %]"> + </td> + </tr> + + <tr><td colspan="4"> </td></tr> + + <tr> + <td colspan="4"> + <strong>Attachment (optional):</strong> + </td> + </tr> + + <tr> + <td align="right">File:</td> + <td colspan="3"> + <em>Enter the path to the file on your computer.</em><br> + <input type="file" id="data" name="data" size="50"> + <input type="hidden" name="contenttypemethod" value="autodetect" /> + </td> + </tr> + + <tr> + <td align="right">Description:</td> + <td colspan="3"> + <em>Describe the attachment briefly.</em><br> + <input type="text" id="description" name="description" size="60" maxlength="200"> + </td> + </tr> + + <tr> + <td> </td> + <td> + <br> + <!-- infra --> + <input type="checkbox" name="groups" id="groups" value="infra" checked="checked"> + <label for="groups"><strong>This is an internal issue which should not be publicly visible.</strong></label><br> + (please uncheck this box if it isn't)<br> + <br> + <input type="submit" id="commit" value="Submit Request"><br> + <br> + Thanks for contacting us. You will be notified by email of any progress made in resolving your request. + </td> + </tr> + + </tbody> + + <tbody id="service_now_form" style="display:none"> + <tr> + <td> </td> + <td> + <br> + <input type="submit" value="Go to Service Now"> + </td> + </tr> + </tbody> + </table> +</form> + + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-legal.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-legal.html.tmpl new file mode 100644 index 000000000..5abe79597 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-legal.html.tmpl @@ -0,0 +1,226 @@ +[%# 1.0@bugzilla.org %] +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Mozilla Corporation. + # Portions created by Mozilla are Copyright (C) 2008 Mozilla + # Corporation. All Rights Reserved. + # + # Contributor(s): Mark Smith <mark@mozilla.com> + # Reed Loden <reed@mozilla.com> + #%] + +[% PROCESS global/variables.none.tmpl %] + +[% PROCESS global/header.html.tmpl + title = "Mozilla Corporation Legal Requests" + style_urls = [ 'skins/standard/attachment.css' ] + javascript_urls = [ 'js/attachment.js', 'js/field.js' ] + yui = [ 'autocomplete' ] +%] + +[% IF user.in_group("mozilla-employee-confidential") + OR user.in_group("mozilla-messaging-confidential") + OR user.in_group("mozilla-foundation-confidential") %] + +<div style='text-align: center; width: 98%; font-size: 2em; font-weight: bold; margin: 10px;'>MoLegal</div> + +<p><strong>Welcome to MoLegal.</strong> For legal help please fill in the form below completely.</p> + +<p>Legal [% terms.bugs %] are only visible to the reporter, members of the legal team, and those on the +CC list. This is necessary to maintain attorney-client privilege. Please do not add non- +employees to the cc list.</p> + +<p><strong>All Submissions, And Information Provided In Response To This Request, +Are Confidential And Subject To The Attorney-Client Privilege And Work Product Doctrine.</strong></p> + +<p>If you are requesting legal review of a new product or service, a new feature of an existing product + or service, or any type of contract, please go + <a href="[% urlbase FILTER none %]enter_bug.cgi?product=mozilla.org&format=moz-project-review">here</a> + to kick-off review of your project. If you are requesting another type of legal action, e.g patent analysis, + trademark misuse investigation, HR issue, or standards work, please use this form.</p> + +<form method="post" action="post_bug.cgi" id="legalRequestForm" enctype="multipart/form-data"> + <input type="hidden" name="product" value="Legal"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="priority" value="--"> + <input type="hidden" name="bug_severity" value="normal"> + <input type="hidden" name="format" value="legal"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + [% IF user.in_group('canconfirm') %] + <input type="hidden" name="bug_status" value="NEW"> + [% END %] + +<table> + +<tr> + <td align="right" width="170px"><strong>Request Type:</strong></td> + <td> + <select name="component"> + [%- FOREACH c = product.components %] + [% NEXT IF NOT c.is_active %] + <option value="[% c.name FILTER html %]" + [% " selected=\"selected\"" IF c.name == "General" %]> + [% c.name FILTER html -%] + </option> + [%- END %] + </select> + </td> +</tr> + +<tr> + <td align="right" valign="top"> + <strong>Goal:</strong> + </td> + <td colspan="3"> + <em>Identify the company goal this request maps to.</em><br> + <input name="goal" id="goal" size="60" value="[% goal FILTER html %]"> + </td> +</tr> + +<tr> + <td align="right"> + <strong>Priority to your Team:</strong> + </td> + <td> + <select id="teampriority" name="teampriority"> + <option value="High">High</option> + <option value="Medium">Medium</option> + <option value="Low" selected="selected">Low</option> + </select> + </td> +</tr> + +<tr> + <td align="right"> + <strong>Timeframe for Completion:</strong> + </td> + <td> + <select id="timeframe" name="timeframe"> + <option value="2 days">2 days</option> + <option value="a week">a week</option> + <option value="2-4 weeks">2-4 weeks</option> + <option value="this will take a while, but please get started soon"> + this will take a while, but please get started soon</option> + <option value="no rush" selected="selected">no rush</option> + </select> + </td> +</tr> + +<tr> + <td align="right" valign="top"> + <strong>Summary:</strong> + </td> + <td colspan="3"> + <em>Include the name of the vendor, partner, product, or other identifier.</em><br> + <input name="short_desc" size="60" value="[% short_desc FILTER html %]"> + </td> +</tr> + +<tr> + <td align="right"> + <strong>CC (optional):</strong> + </td> + <td colspan="3"> + [% INCLUDE global/userselect.html.tmpl + id => "cc" + name => "cc" + value => cc + size => 60 + multiple => 5 + %] + </td> +</tr> + +<tr> + <td align="right" valign="top"> + <strong>Name of Other Party:</strong> + </td> + <td> + <em>If applicable, include full legal entity name, address, and any other relevant contact information.</em><br> + <textarea id="otherparty" name="otherparty" rows="3" cols="80"></textarea> + </td> +</tr> + +<tr> + <td align="right"> + <strong>Business Objective:</strong> + </td> + <td> + <input type="text" name="busobj" id="busobj" value="" size="60" /> + </td> +</tr> + +<tr> + <td align="right" valign="top"> + <strong>Description:</strong> + </td> + <td colspan="3"> + <em>Describe your question, what you want and/or provide any relevant deal terms, restrictions,<br> + or provisions that are applicable. Also provide context and background.</em><br> + <textarea id="comment" name="comment" rows="10" cols="80"> + [% comment FILTER html %]</textarea> + </td> +</tr> + +<tr> + <td align="right"><strong>URL (optional):</strong></td> + <td colspan="3"> + <input name="bug_file_loc" size="60" + value="[% bug_file_loc FILTER html %]"> + </td> +</tr> + +<tr> + <td></td> + <td colspan=2><strong>Attachment (this is optional)</strong></td> +</tr> + +<tr> + <td align="right" valign="top"> + <strong><label for="data">File:</label></strong> + </td> + <td> + <em>Enter the path to the file on your computer.</em><br> + <input type="file" id="data" name="data" size="50"> + <input type="hidden" name="contenttypemethod" value="autodetect" /> + </td> +</tr> + +<tr> + <td align="right" valign="top"> + <strong><label for="description">Description:</label></strong> + </td> + <td> + <em>Describe the attachment briefly.</em><br> + <input type="text" id="description" name="description" size="60" maxlength="200"> + </td> +</tr> + +</table> + +<br> + + <input type="submit" id="commit" value="Submit Request"> +</form> + +<p>Thanks for contacting us. You will be notified by email of any progress made in resolving your request.</p> + +[% ELSE %] + +<p>Sorry, you do not have access to this page.</p> + +[% END %] + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-mdn.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-mdn.html.tmpl new file mode 100644 index 000000000..f79363c99 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-mdn.html.tmpl @@ -0,0 +1,279 @@ +[%# 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 %] + +[% inline_style = BLOCK %] +strong.required:before { + content: "* "; + color: red; +} +#yui-history-iframe { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; + visibility: hidden; +} +#standard { + margin-top: 2em; +} +#standard img { + vertical-align: middle; +} +#standard a { + cursor: pointer; +} +[% END %] +[% inline_javascript = BLOCK %] + var Dom = YAHOO.util.Dom; + var Event = YAHOO.util.Event; + var History = YAHOO.util.History; + var mdn = { + _initial_state: 'initial', + _current_state: 'initial', + _current_type: 'Bug', + _required_fields: { + 'Bug': { + 'bug_actions': 'Please enter some text for "What did you do?"', + 'bug_actual_results': 'Please enter some text for "What happened?"', + 'bug_expected_results': 'Please enter some text for "What should have happened?"', + }, + 'Feature': { + 'feature_problem_solving': 'Please enter some text for "What problems would this solve?"', + 'feature_audience': 'Please enter some text for "Who would use this?"', + 'feature_interface': 'Please enter some text for "What would users see?"', + 'feature_process': 'Please enter some text for "What would users do? What would happen as a result?"', + }, + 'Change': { + 'change_feature': 'Please enter some text for "What feature should be changed? Please provide the URL of the feature if possible"', + 'change_problem_solving': 'Please enter some text for "What problems would this solve?"', + 'change_audience': 'Please enter some text for "Who would use this?"', + 'change_interface': 'Please enter some text for "What would users see?"', + 'change_process': 'Please enter some text for "What would users do? What would happen as a result?"', + } + }, + setState: function(state, request_type, no_set_history) { + if (state == 'detail') { + request_type = request_type || this._getRadioValueByClass('request_type'); + request_type = request_type.toLowerCase(); + if (request_type == 'bug') { + Dom.get('detail_header').innerHTML = '<h2>[% terms.Bug %] Report</h2>'; + Dom.get('secure_type').innerHTML = 'report'; + } + if (request_type == 'feature') { + Dom.get('detail_header').innerHTML = '<h2>Feature Request</h2>'; + Dom.get('secure_type').innerHTML = 'request'; + } + if (request_type == 'change') { + Dom.get('detail_header').innerHTML = '<h2>Change Request</h2>'; + Dom.get('secure_type').innerHTML = 'request'; + } + Dom.addClass('detail_' + this._current_type, 'bz_default_hidden'); + Dom.removeClass('detail_' + request_type, 'bz_default_hidden'); + this._current_type = request_type; + } + Dom.addClass(this._current_state + '_form', 'bz_default_hidden'); + Dom.removeClass(state + '_form', 'bz_default_hidden'); + this._current_state = state; + if (History && !no_set_history) { + History.navigate('h', state + + (request_type ? '|' + request_type : '')); + } + return true; + }, + validateAndSubmit: function() { + var request_type = this._getRadioValueByClass('request_type'); + var alert_text = ''; + if (!isFilledOut('short_desc')) alert_text += 'Please enter a "Summary".\n'; + for (require_type in this._required_fields) { + if (require_type == request_type) { + for (field in this._required_fields[require_type]) { + if (!isFilledOut(field)) + alert_text += this._required_fields[require_type][field] + "\n"; + } + } + } + if (alert_text != '') { + alert(alert_text); + return false; + } + var whiteboard = Dom.get('status_whiteboard'); + whiteboard.value = "[specification][type:" + request_type.toLowerCase() + "]"; + return true; + }, + _getRadioValueByClass: function(class_name) { + var elements = Dom.getElementsByClassName(class_name); + for (var i = 0, l = elements.length; i < l; i++) { + if (elements[i].checked) return elements[i].value; + } + }, + init: function() { + var bookmarked_state = History.getBookmarkedState('h'); + this._initial_state = bookmarked_state || 'initial'; + try { + History.register('h', this._initial_state, mdn.onStateChange); + History.initialize('yui-history-field', 'yui-history-iframe'); + History.onReady(function () { + mdn.onStateChange(History.getCurrentState('h'), true); + }); + } + catch(e) { + console.log('error initializing history: ' + e); + History = false; + } + }, + onStateChange: function(state, no_set_history) { + var state_data = state.split('|'); + mdn.setState(state_data[0], state_data[1], no_set_history); + } + }; + Event.on('show_detail', 'click', function() { mdn.setState('detail'); }); +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Mozilla Developer Network Feedback" + style = inline_style + javascript = inline_javascript + yui = [ 'history' ] + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js' ] +%] + +<iframe id="yui-history-iframe" src="extensions/BMO/web/yui-history-iframe.txt"></iframe> +<input id="yui-history-field" type="hidden"> + +<h1>Mozilla Developer Network Feedback</h1> + +<form method="post" action="post_bug.cgi" enctype="multipart/form-data" + onSubmit="return mdn.validateAndSubmit();"> + <input type="hidden" name="format" value="mdn"> + <input type="hidden" name="product" value="Mozilla Developer Network"> + <input type="hidden" name="component" value="General"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + <input type="hidden" name="status_whiteboard" id="status_whiteboard" value=""> + + <div id="initial_form"> + <p> + <input type="radio" name="request_type" class="request_type" + id="request_type_bug" value="Bug" checked="checked"> + <label for="request_type_bug">Report a [% terms.bug %]</label><br> + <input type="radio" name="request_type" class="request_type" + id="request_type_feature" value="Feature"> + <label for="request_type_feature">Request a new feature</label><br> + <input type="radio" name="request_type" class="request_type" + id="request_type_change" value="Change"> + <label for="request_type_change">Request a change to an existing feature</label><br> + <br> + <input id="show_detail" type="button" value="Next"> + </p> + </div> + + <div id="detail_form" class="bz_default_hidden"> + <p id="detail_header"></p> + + <p id="detail_summary"> + <strong class="required">Summary</strong><br> + <input type="text" name="short_desc" id="short_desc" size="60"> + </p> + + <div id="detail_bug" class="bz_default_hidden"> + <p> + <strong class="required">What did you do?</strong><br> + <textarea name="bug_actions" id="bug_actions" rows="5" cols="60"> +1. +2. +3. </textarea> + </p> + <p> + <strong class="required">What happened?</strong><br> + <textarea name="bug_actual_results" id="bug_actual_results" rows="5" cols="60"></textarea> + </p> + <p> + <strong class="required">What should have happened?</strong><br> + <textarea name="bug_expected_results" id="bug_expected_results" rows="5" cols="60"></textarea> + </p> + </div> + + <div id="detail_feature" class="bz_default_hidden"> + <p> + <strong class="required">What problems would this solve?</strong><br> + <textarea name="feature_problem_solving" id="feature_problem_solving" rows="5" cols="60"></textarea> + </p> + <p> + <strong class="required">Who would use this?</strong><br> + <textarea name="feature_audience" id="feature_audience" rows="5" cols="60"></textarea> + </p> + <p> + <strong class="required">What would users see?</strong><br> + <textarea name="feature_interface" id="feature_interface" rows="5" cols="60"></textarea> + </p> + <p> + <strong class="required">What would users do? What would happen as a result?</strong><br> + <textarea name="feature_process" id="feature_process" rows="5" cols="60"></textarea> + </p> + </div> + + <div id="detail_change" class="bz_default_hidden"> + <p> + <strong class="required">What feature should be changed? Please provide the URL of the feature if possible.</strong><br> + <textarea name="change_feature" id="change_feature" rows="5" cols="60"></textarea> + </p> + <p> + <strong class="required">What problems would this solve?</strong><br> + <textarea name="change_problem_solving" id="change_problem_solving" rows="5" cols="60"></textarea> + </p> + <p> + <strong class="required">Who would use this?</strong><br> + <textarea name="change_audience" id="change_audience" rows="5" cols="60"></textarea> + </p> + <p> + <strong class="required">What would users see?</strong><br> + <textarea name="change_interface" id="change_interface" rows="5" cols="60"></textarea> + </p> + <p> + <strong class="required">What would users do? What would happen as a result?</strong><br> + <textarea name="change_process" id="change_process" rows="5" cols="60"></textarea> + </p> + </div> + + <p id="detail_description"> + <strong>Is there anything else we should know?</strong><br> + <textarea name="description" id="description" rows="5" cols="60"></textarea> + </p> + + <p id="detail_secure"> + <input type="checkbox" name="groups" id="groups" + value="[% product.default_security_group FILTER html %]"> + <label for="groups"> + <strong>This <span id="secure_type">report</span> is about a problem + that is putting users at risk. It should be kept hidden from the public + until it is resolved.</strong> + </label> + </p> + + <input type="submit" id="commit" value="Submit"></td> + </div> +</form> + +<div id="standard"> + <a href="enter_bug.cgi?format=__standard__&product=[% product.name FILTER uri %]"> + <img src="extensions/BMO/web/images/advanced.png" width="16" height="16" border="0"></a> + <a href="enter_bug.cgi?format=__standard__&product=[% product.name FILTER uri %]"> + Switch to the standard [% terms.bug %] entry form</a> +</div> + +<script> + mdn.init(); +</script> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-mobile-compat.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-mobile-compat.html.tmpl new file mode 100644 index 000000000..a9f0fd2cc --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-mobile-compat.html.tmpl @@ -0,0 +1,201 @@ +[%# 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 %] + +[% inline_style = BLOCK %] +#bug_form th { + text-align: right; + vertical-align: middle; +} + +#bug_form input[type="text"], #bug_form textarea { + width: 100%; +} + +#bug_form textarea { + font-family: inherit; + font-size: inherit; +} + +#standard_link { + margin-top: 2em; +} + +#standard_link img { + vertical-align: middle; +} + +#standard_link a { + cursor: pointer; +} + +[% END %] + +[% inline_javascript = BLOCK %] +function validateAndSubmit() { + var field_errors = { + 'op_sys': "Please tell us which product you are using.", + 'software_version': "Please tell us which version of the product you are using.", + 'bug_file_loc': "Please give the URL of the broken page.", + 'short_desc': "Please enter a summary of the problem.", + 'desc': "Please tell us how to reproduce the problem.", + 'expected_result': "Please tell us what you expected to happen.", + 'actual_result': "Please tell us what actually happened.", + }; + + var alert_text = ''; + + for (key in field_errors) { + if (!isFilledOut(key)) { + alert_text += field_errors[key] + '\n'; + } + } + + if (alert_text != '') { + alert(alert_text); + return false; + } + + return true; +} +[% END %] + +[% title = "Mobile Web Compatibility Problem" %] + +[% PROCESS global/header.html.tmpl + title = title + style = inline_style + javascript = inline_javascript + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js'] +%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +<h1>[% title FILTER none %]</h1> + +<form method="post" action="post_bug.cgi" enctype="multipart/form-data" + onSubmit="return validateAndSubmit();"> + <input type="hidden" name="format" value="mobile-compat"> + <input type="hidden" name="product" value="Tech Evangelism"> + <input type="hidden" name="component" value="Mobile"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="bug_status" value="UNCONFIRMED"> + <input type="hidden" name="rep_platform" value="Other"> + <input type="hidden" name="bug_severity" value="normal"> + <input type="hidden" name="status_whiteboard" value="[mobile-compat-form]"> + <input type="hidden" name="user_agent" value="[% cgi.user_agent() FILTER html %]"> + + <input type="hidden" name="token" value="[% token FILTER html %]"> + +[% IF NOT cgi.user_agent("Mobile") %] +<p>If possible, it's best to file [% terms.bugs %] using your device's browser. Visit and bookmark <<a href="https://bugzilla.mozilla.org/form.mobile.compat">https://bugzilla.mozilla.org/form.mobile.compat</a>>.</p> +[% END %] + +<table id="bug_form"> + +<tr> + <th class="required">Product</th> + <td> + <select name="op_sys" id="op_sys"> + <option value="">Please select...</option> + <option value="Gonk (Firefox OS)">Firefox OS</option> + <option value="Android">Firefox for Android</option> + </select> + </td> +</tr> + +<tr> + <th class="required">Product Version</th> + <td> + <input type="text" name="software_version" id="software_version" size="60" + placeholder="Software version - see About box or Preferences"> + </td> +</tr> + +<tr> + <th class="required">Full Web Page Address</th> + <td> + <input type="text" name="bug_file_loc" id="bug_file_loc" size="60" + placeholder="e.g. http://www.example.com/page.html"> + </td> +</tr> + +<tr> + <td> </td> +</tr> + +<tr> + <th class="required">Problem Summary</th> + <td> + <input type="text" name="short_desc" id="short_desc" size="60" + placeholder="Describe the specific problem with the page in one sentence"> + </td> +</tr> + +<tr> + <th class="required">Steps To Reproduce</th> + <td> + <textarea id="desc" name="desc" cols="50" rows="5">1. +2. +3. +...</textarea> + </td> +</tr> + +<tr> + <th class="required">Expected Result</th> + <td> + <input type="text" id="expected_result" name="expected_result" size="60" + placeholder="What were you expecting to happen?"> + </td> +</tr> + +<tr> + <th class="required">Actual Result</th> + <td> + <input type="text" name="actual_result" id="actual_result" size="60" + placeholder="What happened instead?"> + </td> +</tr> + +<tr> + <td> </td> +</tr> + +<tr> + <th>Device Information</th> + <td> + <input type="text" name="device" id="device" size="60" + placeholder="Make and model"> + </td> +</tr> + +<tr> + <td> </td> +</tr> + +<tr> + <td> </td> + <td><input type="submit" id="commit" value="Submit Issue"></td> +</tr> + +</table> +</form> + +[ <span class="required_star">*</span> <span class="required_explanation">Required Field</span> ] + +<div id="standard_link"> + <a href="enter_bug.cgi?format=__standard__&product=[% product.name FILTER uri %]"> + <img src="extensions/BMO/web/images/advanced.png" width="16" height="16" border="0"></a> + <a href="enter_bug.cgi?format=__standard__&product=[% product.name FILTER uri %]"> + Switch to the standard [% terms.bug %] entry form</a> +</div> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-mozlist.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-mozlist.html.tmpl new file mode 100644 index 000000000..38c08c72f --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-mozlist.html.tmpl @@ -0,0 +1,177 @@ +[%# 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 = "Mozilla Discussion Forum" + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/field.js' ] + yui = [ 'autocomplete' ] + style = ".mandatory{color:red;font-size:80%;}" +%] + +<script type="text/javascript"> +<!-- + function trySubmit() { + var alert_text = ""; + + if (!isFilledOut('listName')) { + alert_text += "Please enter the list name\n"; + } + + if (!isValidEmail(document.getElementById('listAdmin').value)) { + alert_text += "Please enter a valid email address for the list administrator\n"; + } + + if (alert_text) { + alert(alert_text); + return false; + } + + var listName = document.getElementById('listName').value; + document.getElementById('short_desc').value = "Discussion Forum: " + listName; + + return true; + } +// --> +</script> + +<p> + <b>Create a Mozilla Discussion Forum</b><br> + This option gives you a Mozilla <a + href="https://www.mozilla.org/about/forums/">Discussion Forum</a>. + These are the normal mechanism for public discussion in the Mozilla + project. They are made up of a mailing list on + <b>lists.mozilla.org</b>, a newsgroup on <b>news.mozilla.org</b> and + a <b>Google Group</b> (which maintains the list archives), all linked + together. Users can add and remove themselves. +</p> + +<div id="message"> + <b>Note:</b> + You must use <a href="https://mozilla.service-now.com/"><b>Service Now</b></a> + to request a distribution list or a standard mailing list. +</div> +<br> + +<form method="post" action="post_bug.cgi" id="mozListRequestForm" + enctype="multipart/form-data" onSubmit="return trySubmit();"> + <input type="hidden" id="format" name="format" value="mozlist"> + <input type="hidden" id="product" name="product" value="mozilla.org"> + <input type="hidden" id="rep_platform" name="rep_platform" value="All"> + <input type="hidden" id="op_sys" name="op_sys" value="Other"> + <input type="hidden" id="priority" name="priority" value="--"> + <input type="hidden" id="version" name="version" value="other"> + <input type="hidden" id="short_desc" name="short_desc" value=""> + <input type="hidden" id="component" name="component" value="Discussion Forums"> + <input type="hidden" id="bug_severity" name="bug_severity" value="normal"> + <input type="hidden" id="token" name="token" value="[% token FILTER html %]"> + + <table> + <tr> + <th class="field_label"> + <span class="mandatory" title="Required">*</span> List Name: + </th> + <td> + The desired name for the newsgroup. Should start with 'mozilla.' and fit somewhere + in the hierarchy described <a href="https://www.mozilla.org/about/forums/">here</a>.<br> + <input name="listName" id="listName" size="60" value="[% listName FILTER html %]"> + </td> + </tr> + <tr> + <th class="field_label"> + <span class="mandatory" title="Required">*</span> List Administrator: + </th> + <td> + <b>Note:</b>The list administrator is also initially considered to be the list moderator + and will be responsible for moderation tasks unless delegated to someone else. For + convenience, [% terms.Bugzilla %] user accounts will autocomplete but it does not have + to be a [% terms.Bugzilla %] account.<br> + [% INCLUDE global/userselect.html.tmpl + id => "listAdmin" + name => "listAdmin" + value => "" + size => 60 + multiple => 5 + %] + </td> + </tr> + <tr> + <td class="field_label">Short Description:</th> + <td> + This will be shown to users on the index of lists on the server.<br> + <input name="listShortDesc" id="listShortDesc" size="60" value="[% listShortDesc FILTER html %]"> + </td> + </tr> + <tr> + <td class="field_label">Long Description:</th> + <td> + This will be shown at the top of the list's listinfo page.<br> + [% INCLUDE global/textarea.html.tmpl + name = 'listLongDesc' + id = 'listLongDesc' + minrows = 10 + maxrows = 25 + cols = constants.COMMENT_COLS + defaultcontent = listLongDesc + %] + </td> + </tr> + <tr> + <th class="field_label">Additional Comments:</th> + <td> + Justification for the list, special instructions, etc.<br> + [% INCLUDE global/textarea.html.tmpl + name = 'comment' + id = 'comment' + minrows = 10 + maxrows = 25 + cols = constants.COMMENT_COLS + defaultcontent = comment + %] + </td> + </tr> + <tr> + <th class="field_label">CC:</th> + <td> + [% INCLUDE global/userselect.html.tmpl + id => "cc" + name => "cc" + value => cc + size => 60 + multiple => 5 + %] + </td> + </tr> + <tr> + <th class="field_label">URL:</th> + <td colspan="3"> + <input name="bug_file_loc" size="60" + value="[% bug_file_loc FILTER html %]"> + </td> + </tr> + <tr> + <td align="right"> + <input type="checkbox" name="groups" id="group_35" value="infra"> + </td> + <td> + <label for="group_35"><b>This is an internal issue which should not be publicly visible.</b></label> + </td> + </tr> + </table> + + <input type="submit" id="commit" value="Submit Request"> + + <p> + Thanks for contacting us. You will be notified by email of any progress made + in resolving your request. + </p> +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-mozpr.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-mozpr.html.tmpl new file mode 100644 index 000000000..f231ea3b9 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-mozpr.html.tmpl @@ -0,0 +1,683 @@ +[%# 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 %] + +[% inline_style = BLOCK %] +#pr_form { + padding: 10px; + width: 600px; +} + +#pr_form input[type="text"], #pr_form textarea { + width: 100%; + margin-bottom: 2px; +} + +#pr_form .calendar { + width: 100px; +} + +#pr_form .user { + width: 300px; +} + +#pr_form select { + width: 200px; +} + +#pr_form .required:after { + content: " *"; + color: red; +} + +#pr_form .missing { + box-shadow: 0px 0px 5px red; +} + +#pr_form label { + font-weight: bold; + display: block; +} + +#pr_form label.normal { + font-weight: normal; + display: inline; +} + +#pr_form .calendar_button { + margin-top: 0.5em; +} + +#pr_form .desc { + padding-bottom: 3px; +} + +#pr_form .field { + margin-bottom: 10px; +} + +#pr_form .indent { + margin-left: 30px; +} + +#pr_form textarea { + font-family: inherit; + font-size: inherit; +} + +#pr_form .head { + font-weight: bold; + border-top: 1px solid silver; + border-bottom: 1px solid silver; + padding: 5px; + margin: 1em 0; + background: #ddd; +} + +#pr_form fieldset { + border: none; +} + +#pr_form .extra { + font-style: italic; +} + +#pr_form .extra a { + text-decoration: underline; +} + +#pr_form #commit { + margin-top: 20px; +} + +#pr_form .linked { + display: block; + margin-top: 2px; + width: 300px; +} + +[% END %] + +[% inline_javascript = BLOCK %] +var pr_inited = false; + +function init_listener(id, event, fn) { + YAHOO.util.Event.addListener(id, event, fn); + bz_fireEvent(document.getElementById(id), event); +} + +function toggle_linked(id, value, suffix) { + var el = document.getElementById(id); + var show = el.type == 'checkbox' ? el.checked : el.value == value; + if (show) { + var linked = document.getElementById(id + suffix); + YAHOO.util.Dom.addClass(linked, 'linked'); + YAHOO.util.Dom.removeClass(linked, 'bz_default_hidden'); + if (pr_inited && linked.nodeName == "INPUT") { + linked.focus(); + linked.select(); + } + } + else { + YAHOO.util.Dom.addClass(id + suffix, 'bz_default_hidden'); + } +} + +function init_other(id) { + init_listener(id, 'change', function(o) { + toggle_linked(id, 'Other:', '_other'); + }); +} + +YAHOO.util.Event.onDOMReady(function() { + init_listener('metrica', 'change', function(o) { + toggle_linked('metrica', 'Yes', '_extra'); + }); + init_listener('budget', 'change', function(o) { + toggle_linked('budget', 'Extra', '_extra'); + }); + init_listener('proj_mat_online', 'click', function(o) { + toggle_linked('proj_mat_online', 0, '_extra'); + }); + init_listener('proj_mat_file', 'click', function(o) { + toggle_linked('proj_mat_file', 0, '_extra'); + }); + init_listener('pr_mat_online', 'click', function(o) { + toggle_linked('pr_mat_online', 0, '_extra'); + }); + init_listener('pr_mat_file', 'click', function(o) { + toggle_linked('pr_mat_file', 0, '_extra'); + }); + + init_other('pr_owner'); + init_other('group_focus'); + init_other('project_type'); + init_other('region'); + init_other('press_center'); + init_other('internal_resources'); + init_other('external_resources'); + init_other('localization'); + init_other('audience'); + + pr_inited = true; +}); + +function validate_other(id, value, suffix) { + var el = document.getElementById(id); + if (!value) value = 'Other:'; + if (!suffix) suffix = '_other'; + if (!el) { + console.error('Failed to find element: ' + elem_id); + return false; + } + if (el.type == 'checkbox') { + if (!el.checked) return true; + } + else if (el.value != value) { + return true; + } + return isFilledOut(id + suffix); +} + +function validate_form() { + var Dom = YAHOO.util.Dom; + + var old_missing = Dom.getElementsByClassName('missing'); + for (var i = 0, il = old_missing.length; i < il; i++) { + Dom.removeClass(old_missing[i], 'missing'); + } + + var missing = []; + if (!isFilledOut('short_desc')) missing.push(['short_desc', 'Project Title']); + if (!isFilledOut('desc')) missing.push(['desc', 'Project Description and Scope']); + + if (!isFilledOut('start_date')) missing.push(['start_date', 'Start Date']); + if (!isFilledOut('announce_date')) missing.push(['announce_date', 'Announcement Date']); + if (!isFilledOut('deadline')) missing.push(['deadline', 'Internal Deadline']); + + if (!isFilledOut('pr_owner')) missing.push(['pr_owner', 'Project PR Owner']); + if (!isFilledOut('owner')) missing.push(['owner', 'Project Owner']); + + if (!isFilledOut('rasci_a')) missing.push(['rasci_a', 'RASCI Approver']); + + if (!isFilledOut('tier')) missing.push(['tier', 'Tier']); + if (!isFilledOut('project_type')) missing.push(['project_type', 'Project Type']); + if (!isFilledOut('pr_approach')) missing.push(['pr_approach', 'PR Approach']); + if (!isFilledOut('group_focus')) missing.push(['group_focus', 'Product Group Focus']); + if (!validate_other('group_focus')) missing.push(['group_focus', 'Product Group Focus - Other']); + if (!isFilledOut('region')) missing.push(['region', 'Region']); + if (!validate_other('region')) missing.push(['region', 'Region - Other']); + + if (!isFilledOut('project_goals')) missing.push(['project_goals', 'Project Goals']); + if (!isFilledOut('pr_goals')) missing.push(['pr_goals', 'PR Goals']); + if (!isFilledOut('company_goal')) missing.push(['company_goal', 'Company Goal']); + if (!isOneChecked(document.forms.pr_form.audience)) + missing.push(['audience_group', 'Audiences']); + if (!validate_other('audience')) missing.push(['audience', 'Audience - Other']); + if (!isFilledOut('key_messages')) missing.push(['key_messages', 'Key Messages']); + + if (Dom.get('proj_mat_online').checked) { + if (!isFilledOut('proj_mat_online_desc')) missing.push(['proj_mat_online_desc', 'Project Materials - Online Description']); + if (!isFilledOut('proj_mat_online_link')) missing.push(['proj_mat_online_link', 'Project Materials - Online Link']); + } + if (Dom.get('proj_mat_file').checked) { + if (!isFilledOut('proj_mat_file_desc')) missing.push(['proj_mat_file_desc', 'Project Materials - Upload Description']); + if (!isFilledOut('proj_mat_file_attach')) missing.push(['proj_mat_file_attach', 'Project Materials - Upload File']); + } + if (Dom.get('pr_mat_online').checked) { + if (!isFilledOut('pr_mat_online_desc')) missing.push(['pr_mat_online_desc', 'PR Project Materials - Online Description']); + if (!isFilledOut('pr_mat_online_link')) missing.push(['pr_mat_online_link', 'PR Project Materials - Online Link']); + } + if (Dom.get('pr_mat_file').checked) { + if (!isFilledOut('pr_mat_file_desc')) missing.push(['pr_mat_file_desc', 'PR Project Materials - Upload Description']); + if (!isFilledOut('pr_mat_file_attach')) missing.push(['pr_mat_file_attach', 'PR Project Materials - Upload File']); + } + + if (!validate_other('press_center')) missing.push(['press_center', 'Press Center Update - Other']); + if (!validate_other('internal_resources')) missing.push(['internal_resources', 'Internal Resources Needed - Other']); + if (!validate_other('external_resources')) missing.push(['external_resources', 'External Resources Needed - Other']); + if (!validate_other('localization')) missing.push(['localization', 'Localization Needed - Other']); + + if (!isFilledOut('budget')) missing.push(['budget', 'Budget']); + if (!validate_other('budget', 'Extra', '_extra')) missing.push(['budget', 'Budget - Extra']); + + if (missing.length) { + var missing_text = []; + for (var i = 0, il = missing.length; i < il; i++) { + Dom.addClass(missing[i][0], 'missing'); + missing_text.push(missing[i][1]); + } + if (missing_text.length == 1) { + alert("The field '" + missing_text[0] + "' is required."); + } + else { + alert("The following fields are required:\n- " + missing_text.join("\n- ")); + } + return false; + } + + return true; +} + +[% END %] + +[% PROCESS global/header.html.tmpl + title = "PR Project Form" + style = inline_style + javascript = inline_javascript + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/field.js', 'js/util.js' ] + yui = [ "autocomplete", "calendar" ] +%] + +[% UNLESS user.in_group('pr-private') %] + <div id="error_msg" class="throw_error"> + This form is only available to members of the Mozilla PR team. + </div> + [% PROCESS global/footer.html.tmpl %] + [% RETURN %] +[% END %] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +<form id="pr_form" name="pr_form" method="post" action="post_bug.cgi" + enctype="multipart/form-data" onSubmit="return validate_form()"> +<input type="hidden" name="format" value="mozpr"> +<input type="hidden" name="product" value="Mozilla PR"> +<input type="hidden" name="component" value="Projects"> +<input type="hidden" name="rep_platform" value="All"> +<input type="hidden" name="op_sys" value="Other"> +<input type="hidden" name="version" value="unspecified"> +<input type="hidden" name="bug_severity" value="normal"> +<input type="hidden" name="group" value="pr-private"> +<input type="hidden" name="assigned_to" id="assigned_to" value="nobody@mozilla.org"> +<input type="hidden" name="token" value="[% token FILTER html %]"> + +<div class="head"> + PR Project Form +</div> + +<div class="field"> + <label for="short_desc" class="required">Project Title</label> + <input type="text" name="short_desc" id="short_desc" placeholder="Your project's title"> +</div> + +<div class="field"> + <label for="desc" class="required">Project Description and Scope</label> + <textarea name="desc" id="desc" placeholder="A short description of your PR project"></textarea> +</div> + +<div class="head"> + Timings +</div> + +<div class="field"> + <label for="start_date" class="required">Start Date</label> + <input name="start_date" id="start_date" value="" class="calendar" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" id="button_calendar_start_date" + onclick="showCalendar('start_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_start_date"></div> + <script type="text/javascript"> + createCalendar('start_date') + </script> +</div> + +<div class="field"> + <label for="announce_date" class="required">Announcement Date</label> + <input name="announce_date" id="announce_date" value="" class="calendar" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" id="button_calendar_announce_date" + onclick="showCalendar('announce_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_announce_date"></div> + <script type="text/javascript"> + createCalendar('announce_date') + </script> +</div> + +<div class="field"> + <label for="deadline" class="required">Internal Deadline</label> + <input name="deadline" id="deadline" value="" class="calendar" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" id="button_calendar_deadline" + onclick="showCalendar('deadline')"> + <span>Calendar</span> + </button> + <div id="con_calendar_deadline"></div> + <script type="text/javascript"> + createCalendar('deadline') + </script> +</div> + +<div class="head"> + Owners +</div> + +<div class="field"> + <label for="pr_owner" class="required">Project PR Owner</label> + <select name="pr_owner" id="pr_owner"> + <option value=""></option> + <option value="ahubert@mozilla.com">Aurelien Hubert</option> + <option value="bhueppe@mozilla.com">Barbara Hüppe</option> + <option value="ej@mozilla.com">Erica Jostedt</option> + <option value="jokelly@mozilla.com">Justin O'Kelly</option> + <option value="kshaw@mozilla.com">Karolina Shaw</option> + <option value="kshaw@mozilla.com">Mike Manning</option> + <option value="lnapoli@mozilla.com">Laura Napoli</option> + <option value="pjarratt@mozilla.com">Paul Jarratt</option> + <option value="tnitot@mozilla.com">Tristan Nitot</option> + <option value="vponell@mozilla.com">Valerie Ponell</option> + <option value="Other:">Other:</option> + </select> + <input name="pr_owner_other" id="pr_owner_other" class="bz_default_hidden"> +</div> + +<div class="field"> + <label for="owner" class="required">Project Owner</label> + <input name="owner" id="owner" class="user"> +</div> + +<div class="head"> + RASCI +</div> + +<div class="field"> + <label for="rasci_r">Responsible</label> + <input name="rasci_r" id="rasci_r" class="user"> +</div> + +<div class="field"> + <label for="rasci_a" class="required">Approver</label> + <input name="rasci_a" id="rasci_a" class="user"> +</div> + +<div class="field"> + <label for="rasci_s">Supporter</label> + <input name="rasci_s" id="rasci_s" class="user"> +</div> + +<div class="field"> + <label for="rasci_c">Consultant</label> + <input name="rasci_c" id="rasci_c" class="user"> +</div> + +<div class="field"> + <label for="rasci_i">Informed</label> + <input name="rasci_i" id="rasci_i" class="user"> +</div> + +<div class="head"> + Details +</div> + +<div class="field"> + <label for="tier" class="required">Tier</label> + <select name="tier" id="tier"> + <option value=""></option> + <option value="1">1</option> + <option value="2">2</option> + <option value="3">3</option> + </select> +</div> + +<div class="field"> + <label for="project_type" class="required">Project Type</label> + <select name="project_type" id="project_type"> + <option value=""></option> + <option>Announcement</option> + <option>Speaking and Events</option> + <option>Planning</option> + <option>Messaging and Materials</option> + <option>Campaign</option> + <option>Other:</option> + </select> + <input name="project_type_other" id="project_type_other" class="bz_default_hidden"> +</div> + +<div class="field"> + <label for="pr_approach" class="required">PR Approach</label> + <select name="pr_approach" id="pr_approach"> + <option value=""></option> + <option value="Proactive">Proactive</option> + <option value="Reactive">Reactive</option> + </select> +</div> + +<div class="field"> + <label for="group_focus" class="required">Product Group Focus</label> + <select name="group_focus" id="group_focus"> + <option value=""></option> + <option>Firefox Desktop</option> + <option>Firefox for Android</option> + <option>Marketplace</option> + <option>Developer Tools</option> + <option>Cloud</option> + <option>Firefox OS</option> + <option>Corporate / Business Support</option> + <option>Other:</option> + </select> + <input name="group_focus_other" id="group_focus_other" class="bz_default_hidden"> +</div> + +<div class="field"> + <label for="region" class="required">Region</label> + <select name="region" id="region"> + <option value=""></option> + <option>Global</option> + <option>US</option> + <option>LatAm</option> + <option>Europe</option> + <option>Africa</option> + <option>Asia</option> + <option>Other:</option> + </select> + <input name="region_other" id="region_other" class="bz_default_hidden"> +</div> + +<div class="head"> + Goals, Audience, and Messages +</div> + +<div class="field"> + <label for="project_goals" class="required">Project Goals</label> + <textarea name="project_goals" id="project_goals"></textarea> +</div> + +<div class="field"> + <label for="pr_goals" class="required">PR Goals</label> + <textarea name="pr_goals" id="pr_goals"></textarea> +</div> + +<div class="field"> + <label for="company_goal" class="required">Company Goal</label> + <select name="company_goal" id="company_goal"> + <option value=""></option> + <option>Scale Firefox OS</option> + <option>Add Services to our Product Lines</option> + <option>Get Firefox on a Growth Trajectory</option> + <option>Invest in Sustainability</option> + <option>Risk Mitigation</option> + </select> +</div> + +<div class="field" id="audience_group"> + <label class="required">Audiences</label> + <input type="checkbox" name="audience" id="audience_business" value="Business Press"> + <label for="audience_business" class="normal">Business Press</label><br> + <input type="checkbox" name="audience" id="audience_con_tech" value="Consumer Tech"> + <label for="audience_con_tech" class="normal">Consumer Tech</label><br> + <input type="checkbox" name="audience" id="audience_con" value="Consumer"> + <label for="audience_con" class="normal">Consumer</label><br> + <input type="checkbox" name="audience" id="audience_dev" value="Developer"> + <label for="audience_dev" class="normal">Developer</label><br> + <input type="checkbox" name="audience" id="audience" value="Other:"> + <label for="audience" class="normal">Other:</label><br> + <input name="audience_other" id="audience_other" class="bz_default_hidden indent"> +</div> + +<div class="field"> + <label for="key_messages" class="required">Key Messages</label> + <textarea name="key_messages" id="key_messages" placeholder="State (draft) key messages of what we would like to get across to + press for this project."></textarea> +</div> + +<div class="head"> + Materials +</div> + +<div class="field"> + <label>Project Materials</label> + <div> + <input type="checkbox" name="proj_mat_online" id="proj_mat_online"> + <label class="normal" for="proj_mat_online"> + Online Documentation (e.g. WAVE Dashboard) + </label> + <div id="proj_mat_online_extra" class="bz_default_hidden indent"> + <label for="proj_mat_online_desc" class="required">Material Description</label> + <input type="text" name="proj_mat_online_desc" id="proj_mat_online_desc"> + <label for="proj_mat_online_link" class="required">Material Link</label> + <input type="text" name="proj_mat_online_link" id="proj_mat_online_link"> + </div> + </div> + <div> + <input type="checkbox" name="proj_mat_file" id="proj_mat_file"> + <label class="normal" for="proj_mat_file"> + Upload File + </label> + <div id="proj_mat_file_extra" class="bz_default_hidden indent"> + <label for="proj_mat_file_desc" class="required">File Description</label> + <input type="text" name="proj_mat_file_desc" id="proj_mat_file_desc"> + <label for="proj_mat_file_attach" class="required">File Upload</label> + <input type="file" name="proj_mat_file_attach" id="proj_mat_file_attach"> + </div> + </div> +</div> + +<div class="field"> + <label>PR Project Materials</label> + <div> + <input type="checkbox" name="pr_mat_online" id="pr_mat_online"> + <label class="normal" for="pr_mat_online"> + Online Documentation (e.g. comms plan) + </label> + <div id="pr_mat_online_extra" class="bz_default_hidden indent"> + <label for="pr_mat_online_desc" class="required">Material Description</label> + <input type="text" name="pr_mat_online_desc" id="pr_mat_online_desc"> + <label for="pr_mat_online_link" class="required">Material Link</label> + <input type="text" name="pr_mat_online_link" id="pr_mat_online_link"> + </div> + </div> + <div> + <input type="checkbox" name="pr_mat_file" id="pr_mat_file"> + <label class="normal" for="pr_mat_file"> + Upload File + </label> + <div id="pr_mat_file_extra" class="bz_default_hidden indent"> + <label for="pr_mat_file_desc" class="required">File Description</label> + <input type="text" name="pr_mat_file_desc" id="pr_mat_file_desc"> + <label for="pr_mat_file_attach" class="required">File Upload</label> + <input type="file" name="pr_mat_file_attach" id="pr_mat_file_attach"> + </div> + </div> +</div> + +<div class="head"> + Requirements +</div> + +<div class="field"> + <label for="metrica">Metrica Coverage Reporting Scope</label> + <select name="metrica" id="metrica"> + <option>No</option> + <option>Yes</option> + </select> + <div id="metrica_extra" class="bz_default_hidden extra"> + Please fill out the + <a href="https://basecamp.com/2256351/projects/2980983/messages/12008835" target="_blank">Metrica form</a> + and submit to Metrica no later than a week before project starts. + </div> +</div> + +<div class="field" id="press_center_group"> + <label>Press Center Update</label> + <input type="checkbox" name="press_center" id="press_center_post" value="Post on press pages"> + <label for="press_center_post" class="normal">Post on press pages</label><br> + <input type="checkbox" name="press_center" id="press_center_library" value="Media Library update"> + <label for="press_center_library" class="normal">Media Library update</label><br> + <input type="checkbox" name="press_center" id="press_center" value="Other:"> + <label for="press_center" class="normal">Other:</label><br> + <input name="press_center_other" id="press_center_other" class="bz_default_hidden indent"> +</div> + +<div class="field" id="internal_resources_group"> + <label>Internal Resources Needed</label> + <input type="text" name="resources" id="resources"> + <input type="checkbox" name="internal_resources" id="internal_resources_spokesperson" value="Spokesperson"> + <label for="internal_resources_spokesperson" class="normal">Spokesperson</label><br> + <input type="checkbox" name="internal_resources" id="internal_resources_staff" value="Demo Staff"> + <label for="internal_resources_staff" class="normal">Demo Staff</label><br> + <input type="checkbox" name="internal_resources" id="internal_resources_support" value="Creative Support"> + <label for="internal_resources_support" class="normal">Creative Support</label><br> + <input type="checkbox" name="internal_resources" id="internal_resources" value="Other:"> + <label for="internal_resources" class="normal">Other:</label><br> + <input name="internal_resources_other" id="internal_resources_other" class="bz_default_hidden indent"> +</div> + +<div class="field" id="external_resources_group"> + <label>External Resources Needed</label> + <input type="checkbox" name="external_resources" id="external_resources_pr" value="PR Agency Support"> + <label for="external_resources_pr" class="normal">PR Agency Support</label><br> + <input type="checkbox" name="external_resources" id="external_resources_design" value="Design Support"> + <label for="external_resources_design" class="normal">Design Support</label><br> + <input type="checkbox" name="external_resources" id="external_resources_community" value="Community Support"> + <label for="external_resources_community" class="normal">Community Support</label><br> + <input type="checkbox" name="external_resources" id="external_resources" value="Other:"> + <label for="external_resources" class="normal">Other:</label><br> + <input name="external_resources_other" id="external_resources_other" class="bz_default_hidden indent"> +</div> + +<div class="field"> + <label for="localization">Localization Needed</label> + <select name="localization" id="localization"> + <option>None</option> + <option>Yes - Agency Localization</option> + <option>Yes - Community Localization</option> + <option>Other:</option> + </select> + <input name="localization_other" id="localization_other" class="bz_default_hidden"> +</div> + +<div class="head"> + Budget +</div> + +<div class="field"> + <label for="budget" class="required">Budget</label> + <select name="budget" id="budget"> + <option value=""></option> + <option value="Covered">Covered in budget</option> + <option value="Extra">Extra budget required:</option> + </select> + <input name="budget_extra" id="budget_extra" class="bz_default_hidden"> +</div> + +<input type="submit" id="commit" value="Submit"> + +<p> + [ <span class="required_star">*</span> <span class="required_explanation">Required Field</span> ] +</p> + +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-poweredby.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-poweredby.html.tmpl new file mode 100644 index 000000000..e231cd9d5 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-poweredby.html.tmpl @@ -0,0 +1,87 @@ +[%# 1.0@bugzilla.org %] +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): Gervase Markham <gerv@gerv.net> + # Ville SkyttŠ <ville.skytta@iki.fi> + # John Hoogstrate <hoogstrate@zeelandnet.nl> + #%] + +[% PROCESS global/variables.none.tmpl %] + +[% PROCESS global/header.html.tmpl + title = "Powered by Mozilla Logo Requests" +%] + +[% USE Bugzilla %] + +<p>If you are interested in using the <a href="http://www.mozilla.org/poweredby">Powered by Mozilla logo</a>, +please provide some information about your application or product.</p> + +<p><strong>Please use this form for Powered by Mozilla logo requests only.</strong></p> + +<form method="post" action="post_bug.cgi" id="tmRequestForm"> + + <input type="hidden" name="product" value="Marketing"> + <input type="hidden" name="component" value="Trademark Permissions"> + <input type="hidden" name="bug_severity" value="enhancement"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="priority" value="--"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="assigned_to" value="dboswell@mozilla.com"> + <input type="hidden" name="cc" value="liz@mozilla.com"> + <input type="hidden" name="groups" value="marketing-private"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + + <table> + <tr> + <td align="right"><strong>Application or Product Name:</strong></td> + <td colspan="3"> + <input name="short_desc" size="60" value="Powered by Mozilla request for: [% short_desc FILTER html %]"> + </td> + </tr> + + <tr> + <td align="right"><strong>URL (optional):</strong></td> + <td colspan="3"> + <input name="bug_file_loc" size="60" + value="[% bug_file_loc FILTER html %]"> + </td> + </tr> + + <tr><td align="right" valign="top"><strong>Comments (optional):</strong></td> + <td colspan="3"> + <textarea name="comment" rows="10" cols="80"> + [% comment FILTER html %]</textarea> + <br> + </td> + </tr> + + </table> + + <br> + + <input type="submit" id="commit" value="Submit Request"> +</form> + +<p>Thanks for contacting us. + You will be notified by email of any progress made in resolving your + request. +</p> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-presentation.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-presentation.html.tmpl new file mode 100644 index 000000000..7819818b3 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-presentation.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. + #%] + +[% PROCESS global/redirect.html.tmpl + url = "https://air.mozilla.org/requests" +%] diff --git a/extensions/BMO/template/en/default/bug/create/create-privacy-data.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-privacy-data.html.tmpl new file mode 100644 index 000000000..b99953282 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-privacy-data.html.tmpl @@ -0,0 +1,219 @@ +[%# 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 %] + +[% inline_style = BLOCK %] + #bug_form input[type=text], #bug_form input[type=file], #cc_autocomplete, #bug_form textarea { + width: 100%; + } +[% END %] + +[% inline_js = BLOCK %] + function onSubmit() { + var error = ''; + if (!isFilledOut('short_desc')) error += 'Please enter a summary.\n'; + if (!isFilledOut('attachment')) error += 'Please attach the data set/representative sample.\n'; + if (!isFilledOut('source')) error += 'Please enter the data source.\n'; + if (!isFilledOut('data_desc')) error += 'Please enter the data description.\n'; + if (!isFilledOut('release')) error += 'Please enter the parts of data you want released.\n'; + if (!isFilledOut('why')) error += 'Please enter why you want to release this data.\n'; + if (!isFilledOut('when')) error += 'Please enter when you would like to release this data.\n'; + + if (error) { + alert(error); + return false; + } + + return true; + } +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Privacy - Data Release Proposal" + style = inline_style + style_urls = [ 'skins/standard/enter_bug.css' ] + javascript = inline_js + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/attachment.js', 'js/field.js', 'js/util.js' ] + yui = [ 'autocomplete' ] +%] + +<h2>Privacy - Data Release Proposal</h2> + +<p> + Before filling out this form, please look at the + <a href="https://wiki.mozilla.org/Privacy/How_To/Deidentify" target="_blank">guide</a> + for releasing info about people. +</p> + +<p> + All fields except for CC are required. +</p> + +<form method="post" action="post_bug.cgi" id="bug_form" class="enter_bug_form" + enctype="multipart/form-data" onSubmit="return onSubmit()"> +<input type="hidden" name="format" value="privacy-data"> +<input type="hidden" name="product" value="Privacy"> +<input type="hidden" name="component" value="Data Release Proposal"> +<input type="hidden" name="rep_platform" value="All"> +<input type="hidden" name="op_sys" value="Other"> +<input type="hidden" name="priority" value="--"> +<input type="hidden" name="version" value="unspecified"> +<input type="hidden" name="bug_severity" id="bug_severity" value="normal"> +<input type="hidden" name="comment" id="comment" value=""> +<input type="hidden" name="groups" id="groups" value="privacy"> +<input type="hidden" name="token" value="[% token FILTER html %]"> + +<table> + +<tr> + <th> + <label for="short_desc">Summary:</label> + </th> + <td> + <input type="text" name="short_desc" id="short_desc" value="" size="60"> + </td> +</tr> + +<tr> + <th> + <label for="cc">CC:</label> + </th> + <td> + [% INCLUDE global/userselect.html.tmpl + id => "cc" + name => "cc" + value => cc + size => 60 + multiple => 5 + %] + </td> + <td> + <i> Optional</i> + </td> +</tr> + +<tr> + <th> + <label for="attachment">Data Set:</label> + </th> + <td> + <i>Please attach the data set, or a representative sample.</i> + <div> + <input type="file" id="attachment" name="data" size="50"> + <input type="hidden" name="contenttypemethod" value="autodetect"> + <input type="hidden" name="description" value="Data Set"> + </div> + </td> +</tr> + +<tr> + <th> + <label for="source">Source:</label> + </th> + <td> + <i>Where does this data come from?</i> + <div> + <textarea name="source" id="source" rows="5" cols="40"></textarea> + </div> + </td> +</tr> + +<tr> + <th> + <label for="data_desc">Data Description:</label> + </th> + <td> + <i>What people and things does this data describe, and what fields does it contain?</i> + <div> + <textarea name="data_desc" id="data_desc" rows="5" cols="40"></textarea> + </div> + </td> +</tr> + +<tr> + <th> + <label for="release">Release:</label> + </th> + <td> + <i>What parts of this data do you want to release?</i> + <div> + <textarea name="release" id="release" rows="5" cols="40"></textarea> + </div> + </td> +</tr> + +<tr> + <th> + <label for="why">Why:</label> + </th> + <td> + <i>Why are we releasing this data, and what do we hope people will do with it?</i> + <div> + <textarea name="why" id="why" rows="5" cols="40"></textarea> + </div> + </td> +</tr> + +<tr> + <th> + <label for="when">Release Time:</label> + </th> + <td> + <i>Is there a particular time by which you would like to release this data?</i> + <div> + <input type="text" name="when" id="when" value="" size="60"> + </div> + </td> +</tr> + +<tr> + <td colspan="2"> + Expect to discover that you've missed a few of things, so plan for a couple weeks to get them corrected. + </td> +</tr> + +<tr> + <td> </td> + <td> + <input type="submit" id="commit" value="Submit Request"> + </td> +</tr> +</table> + +</form> + +<script type="text/javascript"> +function trySubmit() { + var topic = document.getElementById('topic').value; + var date = document.getElementById('date').value; + var time = document.getElementById('time_hour').value + ':' + + document.getElementById('time_minute').value + + document.getElementById('ampm').value + " " + + document.getElementById('time_zone').value; + var location = document.getElementById('location').value; + var shortdesc = 'Event - (' + date + ' ' + time + ') - ' + location + ' - ' + topic; + document.getElementById('short_desc').value = shortdesc; + + // If intended audience is employees only, add mozilla-employee-confidential group + var audience = document.getElementById('audience').value; + if (audience == 'Employees Only') { + var brownbagRequestForm = document.getElementById('brownbagRequestForm'); + var groups = document.createElement('input'); + groups.type = 'hidden'; + groups.name = 'groups'; + groups.value = 'mozilla-employee-confidential'; + brownbagRequestForm.appendChild(groups); + } + + return true; +} +</script> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-recoverykey.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-recoverykey.html.tmpl new file mode 100644 index 000000000..ffe9b3482 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-recoverykey.html.tmpl @@ -0,0 +1,70 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the BMO Bugzilla Extension. + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): + # David Lawrence <dkl@mozilla.com> + #%] + +[% PROCESS global/variables.none.tmpl %] + +[% PROCESS global/header.html.tmpl + title = "Mozilla Corporation/Foundation Encryption Recovery Key" +%] + +<p>Please complete the following information as you are encrypting your laptop.</p> + +<ul> + <li>The Recovery Key will be displayed during the encryption process + (<a href="https://mana.mozilla.org/wiki/display/INFRASEC/Desktop+Security#DesktopSecurity-DiskencryptionFileVault">more info</a>) + </li> + <li>The asset tag number is located on a sticker typically on the bottom of the device.</li> +</ul> + +<form method="post" action="post_bug.cgi" id="recoveryKeyForm" enctype="multipart/form-data"> + <input type="hidden" name="product" value="mozilla.org"> + <input type="hidden" name="component" value="Server Operations: Desktop Issues"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="All"> + <input type="hidden" name="priority" value="--"> + <input type="hidden" name="version" value="other"> + <input type="hidden" name="bug_severity" value="normal"> + <input type="hidden" name="groups" value="mozilla-employee-confidential"> + <input type="hidden" name="groups" value="infra"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + <input type="hidden" name="cc" value="tfairfield@mozilla.com, ghuerta@mozilla.com"> + <input type="hidden" name="short_desc" value="Encryption Recovery Key for [% user.name || user.login FILTER html %]"> + <input type="hidden" name="format" value="recoverykey"> + <table> + <tr> + <td align="right"><strong>Recovery Key:</strong></td> + <td> + <input name="recoverykey" size="60" value="[% recoverykey FILTER html %]"> + </td> + </tr> + <tr> + <td align="right"><strong>Asset Tag Number:</strong></td> + <td> + <input name="assettag" size="60" value="[% assettag FILTER html %]"> + </td> + </tr> + <tr> + <td></td> + <td><input type="submit" id="commit" value="Submit"></td> + </tr> + </table> +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-swag.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-swag.html.tmpl new file mode 100644 index 000000000..89f4c8987 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-swag.html.tmpl @@ -0,0 +1,903 @@ +[%# 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 %] + +[% +items = [ + { id => '' , name => 'Splendidest Gear' }, + { id => '#155752' , name => 'Moleskine Notebook' }, + { id => '#155749' , name => 'Rickshaw Messenger Bag' }, + { id => '#155415S', name => 'Champion Hooded Sweatshirt S' }, + { id => '#155415M', name => 'Champion Hooded Sweatshirt M' }, + { id => '#155415L', name => 'Champion Hooded Sweatshirt L' }, + { id => '#155415X', name => 'Champion Hooded Sweatshirt XL' }, + { id => '#1554152', name => 'Champion Hooded Sweatshirt 2XL' }, + { id => '#157454S', name => 'Very Splendid Package, Men\'s S' }, + { id => '#157454M', name => 'Very Splendid Package, Men\'s M' }, + { id => '#157454L', name => 'Very Splendid Package, Men\'s L' }, + { id => '#157454X', name => 'Very Splendid Package, Men\'s XL' }, + { id => '#157452S', name => 'Very Splendid Package, Ladies S' }, + { id => '#157452M', name => 'Very Splendid Package, Ladies M' }, + { id => '#157452L', name => 'Very Splendid Package, Ladies L' }, + { id => '#157452X', name => 'Very Splendid Package, Ladies XL' }, + { id => '#157451S', name => 'Most Splendid Package, S' }, + { id => '#157451M', name => 'Most Splendid Package, M' }, + { id => '#157451L', name => 'Most Splendid Package, L' }, + { id => '#157451X', name => 'Most Splendid Package, XL' }, + { id => '' , name => 'Splendider' }, + { id => '#155341M', name => 'Unisex T-shirt Poppy M' }, + { id => '#155341X', name => 'Unisex T-shirt Poppy XL' }, + { id => '#1553412', name => 'Unisex T-shirt Poppy 2XL' }, + { id => '#155344S', name => 'Ladies\' T-shirt Poppy S' }, + { id => '#155344M', name => 'Ladies\' T-shirt Poppy M' }, + { id => '#155344L', name => 'Ladies\' T-shirt Poppy L' }, + { id => '#190928S', name => 'Unisex T-shirt Navy S' }, + { id => '#190928M', name => 'Unisex T-shirt Navy M' }, + { id => '#190928L', name => 'Unisex T-shirt Navy L' }, + { id => '#190928X', name => 'Unisex T-shirt Navy XL' }, + { id => '#190929L', name => 'Unisex T-shirt Lapis L' }, + { id => '#1553422', name => 'Unisex T-shirt Navy 2XL' }, + { id => '#1553423', name => 'Unisex T-shirt Navy 3XL' }, + { id => '#155413S', name => 'Ladies\' T-shirt Navy S' }, + { id => '#155413M', name => 'Ladies\' T-shirt Navy M' }, + { id => '#155413L', name => 'Ladies\' T-shirt Navy L' }, + { id => '#155413X', name => 'Ladies\' T-shirt Navy XL' }, + { id => '#155343M', name => 'Unisex T-shirt Lapis M' }, + { id => '#155343X', name => 'Unisex T-shirt Lapis XL' }, + { id => '#155414S', name => 'Ladies\' T-shirt Lapis S' }, + { id => '#155414M', name => 'Ladies\' T-shirt Lapis M' }, + { id => '#155414L', name => 'Ladies\' T-shirt Lapis L' }, + { id => '#155339' , name => 'Black Cap with Tote' }, + { id => '#155340' , name => 'Beanie' }, + { id => '#155751' , name => 'Drawstring Tote' }, + { id => '#155758' , name => 'Glossy Finish Ceramic Mug' }, + { id => '' , name => 'Splendid' }, + { id => '#155754' , name => 'Mozilla Lanyard with Bulldog Clip' }, + { id => '#155755' , name => 'Vertical Laminated Badge' }, + { id => '#155756' , name => 'Silicone Wristband' }, + { id => '#155757' , name => 'Custom Tattoos - Pkg50' }, + { id => '#192150' , name => '1.25" Firefox Button-PKG25' }, + { id => '' , name => 'Firefox OS items' }, + { id => '#197158' , name => '3" Round Sticker, Firefox logo' }, + { id => '#185686' , name => '3" Firefox OS Sticker Look Ahead' }, + { id => '#189674' , name => '3" Firefox OS Mobilizer' }, + { id => '#187062' , name => 'OS Lanyard w/ Bulldog Clip' }, + { id => '#180589' , name => 'Sunglasses Firefox OS' }, + { id => '#180595' , name => 'Rubber Grip Pens Firefox OS' }, + { id => '#180593' , name => 'Firefox OS Moleskine Notebook' }, +] + +mozspaces = [ + { + name => 'Beijing', + address1 => 'Mozilla Online Ltd, International Club Office Tower 800A', + address2 => '21 Jian Guo Men Wai Avenue', + city => 'Beijing', + state => 'Chaoyang District', + country => 'China', + postcode => '100020', + }, + { + name => 'Berlin', + address1 => 'MZ Denmark ApS - Germany', + address2 => 'Rungestrasse 22 - 24', + city => 'Berlin', + state => 'Germany', + country => 'Germany', + postcode => '10179', + }, + { + name => 'London', + address1 => 'Mozilla London', + address2 => '101 St. Martin\'s Lane, 3rd Floor', + city => 'London', + state => 'Greater London', + country => 'UK', + postcode => 'WC2N 4AZ', + }, + { + name => 'Mountain View', + address1 => 'Mozilla', + address2 => '650 Castro St., Suite 300', + city => 'Mountain View', + state => 'CA', + country => 'USA', + postcode => '94041-2072', + }, + { + name => 'Paris', + address1 => 'Mozilla', + address2 => '16 bis Boulevard Montmartre', + city => 'Paris', + state => 'Paris', + country => 'France', + postcode => '75009', + }, + { + name => 'Portland', + address1 => 'Mozilla Portland', + address2 => 'Brewery Block 2, 1120 NW Couch St. Suite 320', + city => 'Portland', + state => 'OR', + country => 'USA', + postcode => '97209', + }, + { + name => 'San Francisco', + address1 => 'Mozilla', + address2 => '2 Harrison Street, Suite 175', + city => 'San Francisco', + state => 'CA', + country => 'USA', + postcode => '94105', + }, + { + name => 'Taipei', + address1 => '4F-A1, No. 106, Sec.5, Xinyi Rd', + address2 => '', + city => 'Taipei City', + state => 'Xinyi District', + country => 'Taiwan', + postcode => '11047', + }, + { + name => 'Tokyo', + address1 => '7-5-6 Roppongi', + address2 => '', + city => 'Minato-ku', + state => 'Tokyo', + country => 'Japan', + postcode => '106-0032', + }, + { + name => 'Toronto', + address1 => 'Mozilla Canada', + address2 => '366 Adelaide Street W, Suite 500', + city => 'Toronto', + state => 'Ontario', + country => 'Canada', + postcode => 'M5V 1R9', + }, + { + name => 'Vancouver', + address1 => 'Mozilla Canada', + address2 => '163 West Hastings Street, Suite 209', + city => 'Vancouver', + state => 'BC', + country => 'Canada', + postcode => 'V6B 1H5', + }, +] + +cost_centers = [ + 'Accounting (1210)', + 'Add Ons (7500)', + 'Advanced Techology Lab (6400)', + 'Brand Engagement (2400)', + 'Business Affairs (1100)', + 'Business Development (1150)', + 'Business Development Programs (7700)', + 'Business Support Services (1000)', + 'Cloud & Services (3000)', + 'Community Engagement (2300)', + 'Design (4400)', + 'Dev Infra (3130)', + 'Engagement (2000)', + 'Engineering Platform (8000)', + 'Engineering Program Management (8300)', + 'Facilities (1250)', + 'Finance Planning & Analysis (1211)', + 'Firefox (5000)', + 'Firefox Android Engineering (5310)', + 'Firefox Android Product Management (5330)', + 'Firefox Android UX (5320)', + 'Firefox Desktop (5200)', + 'Firefox Desktop Engineering (5210)', + 'Firefox Desktop Platform Integration (5240)', + 'Firefox Desktop Product Management (5230)', + 'Firefox Desktop UX (5220)', + 'Firefox Dev Tools (5400)', + 'Firefox Mobile (5300)', + 'Firefox OS Engineering I (6110)', + 'Firefox OS Engineering II (6120)', + 'Firefox OS Product Management (6200)', + 'Firefox OS UX (6300)', + 'Identity Eng (3210)', + 'Identity Infra (3110)', + 'Infrastructure (servers) (3100)', + 'Insights and Strategy (4000)', + 'IT & Network (1400)', + 'Labs (7600)', + 'Legal (1120)', + 'Localization (L10n) (5100)', + 'Location Engineering (3230)', + 'Location Infra (3150)', + 'Marketplace (7000)', + 'Marketplace Apps Engineering (7110)', + 'Marketplace Bus. Development (7400)', + 'Marketplace Engineering / AMO (7120)', + 'Marketplace Engineering /Dev Ecosystem (7130)', + 'Marketplace Product Management (7200)', + 'Marketplace UX (7300)', + 'Market Strategy (4200)', + 'Metrics (4300)', + 'Misc Infra (3160)', + 'Mobile (6000)', + 'Mobile Business Development (1130)', + 'Mobile Engineering (6130)', + 'Operations (3600)', + 'People (1340)', + 'People Ops (1320)', + 'Platform Accessibility (8490)', + 'Platform Content (8440)', + 'Platform Integration (8480)', + 'Platform Network (8410)', + 'Platform Network & Security (8400)', + 'Platform Performance (8470)', + 'Platform Rendering & Media (8450)', + 'Platform Security (8420)', + 'Platform Security Assurance (8430)', + 'Platform Stability & Plugin (8460)', + 'PR (2200)', + 'Product Marketing (2100)', + 'QA (8500)', + 'QA Android (8550)', + 'QA Automation (8520)', + 'QA Firefox Desktop (8510)', + 'QA FirefoxOS (8560)', + 'QA Mobile (8540)', + 'QA Services (8570)', + 'QA Web (8530)', + 'Release Engineering (8100)', + 'Release Management (5010)', + 'Research (6900)', + 'Services Engineering (3200)', + 'Services Product Management (3400)', + 'Services UX (3300)', + 'SUMO (2600)', + 'Sync Engineering (3220)', + 'Sync Infra (3120)', + 'UP (5500)', + 'User Research (4100)', + 'Web Engineering (8200)', + 'WebRTC (1160)', + 'WebRTC Infra (3140)', + 'Web Security and Security Automation (3500)', + 'Websites & Developer Engagement (2500)', +] + +%] + +[% inline_style = BLOCK %] +#gear_form th { + text-align: right; + font-weight: normal; +} + +#gear_form .heading { + text-align: left; + font-weight: bold; + border-top: 2px dotted #969696; +} + +.mandatory { + color: red; +} +[% END %] + +[% inline_javascript = BLOCK %] +var Dom = YAHOO.util.Dom; +var needed = { +[% FOREACH item = items %] + [% NEXT UNLESS item.id %] + '[% item.id FILTER js %]': 0[% ',' UNLESS loop.last %] +[% END %] +}; +var needed_freeform = []; + +var gear = [ +[% FOREACH item = items %] + { id: '[% item.id FILTER js %]', name: '[% item.name FILTER js %]' } + [% ',' UNLESS loop.last %] +[% END %] +]; + +var mozspaces = { +[% FOREACH space = mozspaces %] + '[% space.name FILTER js %]': { + [% FOREACH key = space.keys.sort %] + '[% key FILTER js %]': '[% space.$key FILTER js %]'[% ',' UNLESS loop.last %] + [% END %] + }[% ',' UNLESS loop.last %] +[% END %] +}; + +[%# implemented this way to allow for dynamic updating of mandatory fields #%] +var fields = [ + { id: 'firstname', mandatory: true }, + { id: 'lastname', mandatory: true }, + { id: 'email', mandatory: true }, + { id: 'mozspace', mandatory: false }, + { id: 'teamcode', mandatory: true }, + { id: 'purpose', mandatory: true }, + { id: 'purpose_other', mandatory: false }, + { id: 'date_required', mandatory: false }, + { id: 'items', mandatory: true }, + { id: 'shiptofirstname', mandatory: true }, + { id: 'shiptolastname', mandatory: true }, + { id: 'shiptoemail', mandatory: true }, + { id: 'shiptoaddress1', mandatory: true }, + { id: 'shiptoaddress2', mandatory: false }, + { id: 'shiptocity', mandatory: true }, + { id: 'shiptostate', mandatory: true }, + { id: 'shiptocountry', mandatory: true }, + { id: 'shiptopostcode', mandatory: true }, + { id: 'shiptophone', mandatory: true }, + { id: 'shiptoidrut', mandatory: false }, + { id: 'comment', mandatory: false } +]; + +function initFields() { + [%# find fields in the form, and update the fields array #%] + var rows = Dom.get('gear_form').getElementsByTagName('TR'); + for (var i = 0, l = rows.length; i < l; i++) { + var row = rows[i]; + var field = firstChild(row, 'INPUT') || firstChild(row, 'SELECT') || firstChild(row, 'TEXTAREA'); + if (!field || field.type == 'submit') continue; + var id = field.id; + var label = firstChild(row, 'TH'); + for (var j = 0, m = fields.length; j < m; j++) { + if (fields[j].id == id) { + fields[j].field = field; + fields[j].label = label; + fields[j].caption = label.textContent; + break; + } + } + } + createCalendar('date_required'); +} + +function tagMandatoryFields() { + [%# add or remove the "* mandatory" marker from fields #%] + for (var i = 0, l = fields.length; i < l; i++) { + var f = fields[i]; + if (!f.label) continue; + var caption = f.caption; + if (f.mandatory) + caption = caption + ' <span class="mandatory">*</span>'; + f.label.innerHTML = caption; + } +} + +function validateAndSubmit() { + var alert_text = ''; + for(var i = 0, l = fields.length; i < l; i++) { + var f = fields[i]; + if (f.mandatory && !isFilledOut(f.id)) + if (f.field.nodeName == 'SELECT') { + alert_text += 'Please select the ' + f.caption + ".\n"; + } else { + alert_text += 'Please enter the ' + f.caption + ".\n"; + } + } + if (isFilledOut('email') && !isValidEmail(Dom.get('email').value)) + alert_text += "Please enter a valid Email Address.\n"; + if (isFilledOut('shiptoemail') && !isValidEmail(Dom.get('shiptoemail').value)) + alert_text += "Please enter a valid Shipping Email Address.\n"; + + if (alert_text != '') { + alert(alert_text); + return false; + } + + Dom.get('short_desc').value = 'Mozilla Gear - ' + Dom.get('firstname').value + ' ' + Dom.get('lastname').value; + return true; +} + +function onPurposeChange() { + var value = Dom.get('purpose').value; + var other = Dom.get('purpose_other'); + + if (value == 'Event') { + getField('purpose_other').mandatory = true; + other.placeholder = 'link to wiki' + Dom.removeClass('purpose_other_row', 'bz_default_hidden'); + Dom.addClass('recognition_row', 'bz_default_hidden'); + + } else if (value == 'Gear Space Stock') { + getField('purpose_other').mandatory = true; + other.placeholder = 'indicate space' + Dom.removeClass('purpose_other_row', 'bz_default_hidden'); + Dom.addClass('recognition_row', 'bz_default_hidden'); + + } else if (value == 'Other') { + getField('purpose_other').mandatory = true; + other.placeholder = 'more information'; + Dom.removeClass('purpose_other_row', 'bz_default_hidden'); + Dom.addClass('recognition_row', 'bz_default_hidden'); + + } else if (value == 'Mozillian Recognition') { + getField('purpose_other').mandatory = false; + Dom.addClass('purpose_other_row', 'bz_default_hidden'); + Dom.removeClass('recognition_row', 'bz_default_hidden'); + onRecognitionChange(); + + } else { + getField('purpose_other').mandatory = false; + Dom.addClass('purpose_other_row', 'bz_default_hidden'); + Dom.addClass('recognition_row', 'bz_default_hidden'); + } + + onRecognitionChange(); +} + +function onRecognitionChange() { + var mandatory = Dom.get('purpose').value != 'Mozillian Recognition' + || !Dom.get('recognition_shipping').checked; + getField('shiptoaddress1').mandatory = mandatory; + getField('shiptocity').mandatory = mandatory; + getField('shiptostate').mandatory = mandatory; + getField('shiptocountry').mandatory = mandatory; + getField('shiptopostcode').mandatory = mandatory; + getField('shiptophone').mandatory = mandatory; + tagMandatoryFields(); +} + +function onMozSpaceChange() { + if (Dom.get('mozspace').value) { + Dom.removeClass('shipto_mozspace_container', 'bz_default_hidden'); + } else { + Dom.addClass('shipto_mozspace_container', 'bz_default_hidden'); + } + onShipToMozSpaceClick(); +} + +function onShipToMozSpaceClick() { + var address1 = address2 = city = state = country = postcode = ''; + if (Dom.get('shipto_mozspace').checked) { + var space = Dom.get('mozspace').value; + address1 = mozspaces[space].address1; + address2 = mozspaces[space].address2; + city = mozspaces[space].city; + state = mozspaces[space].state; + country = mozspaces[space].country; + postcode = mozspaces[space].postcode; + } + Dom.get('shiptoaddress1').value = address1; + Dom.get('shiptoaddress2').value = address2; + Dom.get('shiptocity').value = city; + Dom.get('shiptostate').value = state; + Dom.get('shiptocountry').value = country; + Dom.get('shiptopostcode').value = postcode; + Dom.get('shiptophone').value = ''; + Dom.get('shiptoidrut').value = ''; + onRecognitionChange(); +} + +function onAddGearChange(focusInput) { + var add_gear = Dom.get('add_gear').value; + var isFreeform = add_gear == 'custom' || add_gear == 'other'; + if (isFreeform) { + Dom.addClass('quantity', 'bz_default_hidden'); + resetFreeform(); + Dom.get('freeform_quantity').value = Dom.get('quantity').value; + Dom.removeClass('freeform_quantity', 'bz_default_hidden'); + Dom.removeClass('add_freeform', 'bz_default_hidden'); + if (focusInput) + Dom.get('freeform_add').focus(); + } else { + Dom.get('quantity').value = Dom.get('freeform_quantity').value; + Dom.removeClass('quantity', 'bz_default_hidden'); + Dom.addClass('freeform_quantity', 'bz_default_hidden'); + Dom.addClass('add_freeform', 'bz_default_hidden'); + } +} + +function firstChild(parent, name) { + var a = parent.getElementsByTagName(name); + return a.length == 0 ? false : a[0]; +} + +function getField(id) { + for(var i = 0, l = fields.length; i < l; i++) { + if (fields[i].id == id) + return fields[i]; + } + return false; +} + +function addGear() { + var id = Dom.get('add_gear').value; + if (id == 'custom' || id == 'other') { + var quantity = parseInt(Dom.get('freeform_quantity').value, 10); + var name = Dom.get('freeform_add').value; + if (!quantity || !name) return; + needed_freeform.push({ 'type': id, 'quantity': quantity, 'name': name }); + Dom.get('add_gear').value = ''; + resetFreeform(); + onAddGearChange(); + } else { + var quantity = parseInt(Dom.get('quantity').value, 10); + if (!quantity || !id) return; + needed[id] += quantity; + } + showGear(); +} + +function resetFreeform() { + Dom.get('freeform_quantity').value = '1'; + Dom.get('freeform_add').value = ''; +} + +function removeGear(id) { + if (!id) return; + needed[id] = 0; + showGear(); +} + +function removeFreeform(index) { + needed_freeform.splice(index, 1); + showGear(); +} + +function showGear() { + var html = '<table border="0" cellpadding="2" cellspacing="0">'; + var text = ''; + var count = 0; + for (var i = 0, l = gear.length; i < l; i++) { + var item = gear[i]; + var id = item.id; + if (!id) continue; + if (!needed[id]) continue; + count += needed[id]; + html += '<tr>' + + '<td>' + needed[id] + ' x </td>' + + '<td>' + YAHOO.lang.escapeHTML(item.name) + '</td>' + + '<td><button onclick="removeGear(\'' + id + '\');return false">Remove</button></td>' + + '</tr>'; + text += needed[id] + ' x ' + id + ' ' + item.name + "\n"; + } + for (var i = 0, l = needed_freeform.length; i < l; i++) { + var item = needed_freeform[i]; + count += item.quantity; + html += '<tr>' + + '<td>' + item.quantity + ' x </td>' + + '<td>(' + item.type + ') ' + YAHOO.lang.escapeHTML(item.name) + '</td>' + + '<td><button onclick="removeFreeform(\'' + i + '\');return false">Remove</button></td>' + + '</tr>'; + text += item.quantity + ' x (' + item.type + ') ' + item.name + "\n"; + } + if (!count) + html += '<tr><td><i>No gear selected.</i></td></tr>'; + html += '</table>'; + Dom.get('gear_container').innerHTML = html; + Dom.get('items').value = text; +} + +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Mozilla Gear" + style = inline_style + javascript = inline_javascript + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/field.js', 'js/util.js' ] + yui = [ 'autocomplete', 'calendar' ] +%] + +<h1>Mozilla Gear</h1> + +<p> + Want gear? Follow the steps below and click Submit Request. +</p> +<p> + Requests are reviewed and processed on Monday morning (US/Pacific). Any + requests received after 9am Monday will be processed the following week. +</p> +<ul> + <li> + If approved, your request will either be sent to our gear partner, Staples, + for shipment or it will be available for pick-up from your Mozilla space. + </li> + <li> + If your request is not approved, we will let you know why or possibly ask + for more information. + </li> +</ul> + +<p> + Check <a href="https://wiki.mozilla.org/GearStore" target="_blank">the gear + wiki</a> for more information about gear, including approved uses and the + list of available gear. +</p> + +<p> + Gear requests for Rep-driven events and campaigns should continue to be + submitted through <a href="https://wiki.mozilla.org/ReMo/Tools_and_Resources" + target="_blank">their existing process</a>. +</p> + +<form method="post" action="post_bug.cgi" id="swagRequestForm" enctype="multipart/form-data" + onSubmit="return validateAndSubmit();"> + <input type="hidden" name="format" value="swag"> + <input type="hidden" name="product" value="Marketing"> + <input type="hidden" name="component" value="Swag Requests"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="priority" value="--"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="short_desc" id="short_desc" value=""> + <input type="hidden" name="groups" value="mozilla-engagement"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + +<table id="gear_form"> + +<tr> + <td> </td> +</tr> +<tr> + <th class="heading" colspan="2">Tell Us What You Want</th> +</tr> + +<tr> + <th>Purpose of Gear</th> + <td> + <select name="purpose" id="purpose" onchange="onPurposeChange()"> + <option value="">Please select..</option> + <option value="Campaign">Campaign</option> + <option value="Event">Event</option> + <option value="Gear Space Stock">Gear Space Stock</option> + <option value="Mozillian Recognition">Mozillian Recognition</option> + <option value="Onboarding">Onboarding</option> + <option value="Press">Press</option> + <option value="Recruiting">Recruiting</option> + <option value="VIP">VIP</option> + <option value="Other">Other</option> + </select> + </td> +</tr> + +<tr id="purpose_other_row" class="bz_default_hidden"> + <th>Purpose Text</th> + <td> + <input name="purpose_other" id="purpose_other" size="50"> + </td> +</tr> + +<tr id="recognition_row" class="bz_default_hidden"> + <th> </th> + <td> + <input type="checkbox" name="recognition_shipping" id="recognition_shipping" value="Yes" + onclick="onRecognitionChange()"> + <label for="recognition_shipping"> + This [% terms.bug %] needs recipient shipping information + </label><br> + <input type="checkbox" name="recognition_sizing" id="recognition_sizing" value="Yes"> + <label for="recognition_sizing"> + This [% terms.bug %] needs recipient size information + </label><br> + </td> +</tr> + +<tr> + <th>Date Required</th> + <td> + <input name="date_required" id="date_required" size="25" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" id="button_calendar_date_required" + onclick="showCalendar('date_required')"><span>cal</span></button> + <div id="con_calendar_date_required"></div> + </td> +</tr> + +<tr> + <th>Gear Needed</th> + <td> + <input type="hidden" name="items" id="items" value=""> + <a href="https://wiki.mozilla.org/GearStore/Gearavailable" target="_blank"> + View the current inventory</a>, then add your selection(s):<br> + + <input type="text" size="2" id="quantity" value="1" + onblur="this.value = parseInt(this.value, 10) ? Math.floor(parseInt(this.value, 10)) : 1"> + <select id="add_gear" onChange="onAddGearChange(true)"> + <option value="">Please select..</option> + [% first_group = 1 %] + [% FOREACH item = items %] + [% IF item.id == "" %] + [% "</optgroup>" UNLESS first_group %] + [% first_group = 0 %] + <optgroup label="[% item.name FILTER html %]"> + [% ELSE %] + <option value="[% item.id FILTER html %]">[% item.name FILTER html %]</option> + [% END %] + [% END %] + [% "</optgroup>" UNLESS first_group %] + <optgroup label="otherwise"> + <option value="custom">custom</option> + <option value="other">other</option> + </optgroup> + </select> + <span id="add_freeform" class="bz_default_hidden"> + <br> + Tell us how many and what you are looking for here. Add details in the + comments field below. + <br> + <input type="text" size="2" id="freeform_quantity" value="1" + onblur="this.value = parseInt(this.value, 10) ? Math.floor(parseInt(this.value, 10)) : 1"> + <input type="text" id="freeform_add" size="40"> + </span> + <button onclick="addGear();return false">Add</button> + <br> + + <div id="gear_container"></div> + </td> +</tr> + +<tr> + <td> </td> +</tr> +<tr> + <th class="heading" colspan="2">Tell Us About You</th> +</tr> + +<tr> + <th>First Name</th> + <td><input name="firstname" id="firstname" size="50" maxlength="30"></td> +</tr> + +<tr> + <th>Last Name</th> + <td><input name="lastname" id="lastname" size="50" maxlength="30"></td> +</tr> + +<tr> + <th>Email Address</th> + <td><input name="email" id="email" size="50" maxlength="50"></td> +</tr> + +<tr> + <th>My Mozilla Space</th> + <td> + <select name="mozspace" id="mozspace" onchange="onMozSpaceChange()"> + <option value="">Please select..</option> + [% FOREACH space = mozspaces %] + <option value="[% space.name FILTER html %]">[% space.name FILTER html %]</option> + [% END %] + </select> + <i>(if applicable)</i> + <div id="shipto_mozspace_container" class="bz_default_hidden"> + <input type="checkbox" id="shipto_mozspace" onclick="onShipToMozSpaceClick()"> + <label for="shipto_mozspace">Ship to this address</label> + </div> +</tr> + +<tr> + <th>Team + Department Code</th> + <td> + <select name="teamcode" id="teamcode"> + <option value="">Please select..</option> + [% FOREACH cost IN cost_centers %] + <option value="[% cost FILTER html %]">[% cost FILTER html %]</option> + [% END %] + </select> + </td> +</tr> + +<tr> + <td> </td> +</tr> +<tr> + <th class="heading" colspan="2">Tell Us Where To Send It</th> +</tr> + +<tr> + <td colspan="2"> + Please be aware that shipping can cost as much as, if not more than, your + item. And, items shipped internationally incur customs fees that can be + 100%+ the cost of the package. When possible, requests will be filled from + gear at your Mozilla space. + </td> +</tr> + +<tr> + <th>First Name</th> + <td><input name="shiptofirstname" id="shiptofirstname" size="50" maxlength="50"></td> +</tr> +<tr> + <th>Last Name</th> + <td><input name="shiptolastname" id="shiptolastname" size="50" maxlength="50"></td> +</tr> +<tr> + <th>Email Address</th> + <td><input name="shiptoemail" id="shiptoemail" size="50" maxlength="50"></td> +</tr> +<tr> + <th>Address</th> + <td><input name="shiptoaddress1" id="shiptoaddress1" size="50" maxlength="50"></td> +</tr> +<tr> + <th>Address 2</th> + <td><input name="shiptoaddress2" id="shiptoaddress2" size="50" maxlength="50"></td> +</tr> +<tr> + <th>City</th> + <td><input name="shiptocity" id="shiptocity" size="50" maxlength="50"></td> +</tr> +<tr> + <th>State</th> + <td><input name="shiptostate" id="shiptostate" size="50" maxlength="50"></td> +</tr> +<tr> + <th>Country</th> + <td><input name="shiptocountry" id="shiptocountry" size="50" maxlength="50"></td> +</tr> +<tr> + <th>Postal Code</th> + <td><input name="shiptopostcode" id="shiptopostcode" size="50" maxlength="50"></td> +</tr> +<tr> + <th>Recipient Telephone</th> + <td> + <input name="shiptophone" id="shiptophone" size="50" maxlength="50"> + <i>(include country code if outside of the US)</i> + </td> +</tr> +<tr> + <th>Personal ID/RUT</th> + <td> + <input name="shiptoidrut" id="shiptoidrut" size="50" maxlength="50"> + <i>(if your country requires this)</i> + </td> +</tr> + +<tr> + <td> </td> +</tr> +<tr> + <th class="heading" colspan="2">Tell Us Anything Else</th> +</tr> + +<tr> + <th>Additional Comments</th> + <td><textarea id="comment" name="comment" rows="5" cols="50"></textarea></td> +</tr> + +<tr> + <td> </td> +</tr> + +<tr> + <td> </td> + <td><input type="submit" id="commit" value="Submit Request"></td> +</tr> + +</table> +</form> + +<p> + <span class="mandatory">*</span> Required Field +</p> + +<p> + Requests will only be visible to the person who submitted it, authorized + members of the Mozilla Engagement team, and our Staples Customer Service rep. + We do this to help protect the personal identifying information in this [% terms.bugs %]. +</p> + +<script> + initFields(); + onPurposeChange(); + onAddGearChange(); + tagMandatoryFields(); + showGear(); +</script> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-trademark.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-trademark.html.tmpl new file mode 100644 index 000000000..977ad00d4 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-trademark.html.tmpl @@ -0,0 +1,87 @@ +[%# 1.0@bugzilla.org %] +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): Gervase Markham <gerv@gerv.net> + # Ville Skyttä <ville.skytta@iki.fi> + # John Hoogstrate <hoogstrate@zeelandnet.nl> + #%] + +[% PROCESS global/variables.none.tmpl %] + +[% PROCESS global/header.html.tmpl + title = "Trademark Usage Requests" +%] + +[% USE Bugzilla %] + +<p> + If, after reading + <a href="http://www.mozilla.org/foundation/trademarks/">the trademark policy + documents</a>, you know you need permission to use a certain trademark, this + is the place to be. +</p> + +<p><strong>Please use this form for trademark requests only!</strong></p> + +<form method="post" action="post_bug.cgi" id="tmRequestForm"> + + <input type="hidden" name="product" value="Marketing"> + <input type="hidden" name="component" value="Trademark Permissions"> + <input type="hidden" name="bug_severity" value="enhancement"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="priority" value="P3"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="groups" value="marketing-private"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + + <table> + <tr> + <td align="right"><strong>Summary:</strong></td> + <td colspan="3"> + <input name="short_desc" size="60" value="[% short_desc FILTER html %]"> + </td> + </tr> + + <tr><td align="right" valign="top"><strong>Description:</strong></td> + <td colspan="3"> + <textarea name="comment" rows="10" cols="80"> + [% comment FILTER html %]</textarea> + <br> + </td> + </tr> + <tr> + <td align="right"><strong>URL (optional):</strong></td> + <td colspan="3"> + <input name="bug_file_loc" size="60" + value="[% bug_file_loc FILTER html %]"> + </td> + </tr> + </table> + + <br> + + <input type="submit" id="commit" value="Submit Request"> +</form> + +<p>Thanks for contacting us. + You will be notified by email of any progress made in resolving your + request. +</p> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-user-engagement.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-user-engagement.html.tmpl new file mode 100644 index 000000000..f523b205b --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-user-engagement.html.tmpl @@ -0,0 +1,219 @@ +[%# 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 %] + +[% inline_style = BLOCK %] +#engagement_form { + padding: 10px; +} +#engagement_form .required:after { + content: " *"; + color: red; +} +#engagement_form .field_label { + font-weight: bold; +} +#engagement_form .field_desc { + padding-bottom: 3px; +} +#engagement_form .field_desc, +#engagement_form .head_desc { + width: 600px; + word-wrap: normal; +} +#engagement_form .head_desc { + padding-top: 5px; + padding-bottom: 12px; +} +#engagement_form .form_section { + margin-bottom: 10px; +} +#engagement_form textarea { + font-family: inherit; + font-size: inherit; +} +#engagement_form em { + font-size: 1em; +} +.yui-calcontainer { + z-index: 2; +} +[% END %] + +[% inline_javascript = BLOCK %] +function validateAndSubmit() { + var alert_text = ''; + if (!isFilledOut('goals')) alert_text += 'Please enter a value for project goals.\n'; + if (!isFilledOut('short_desc')) alert_text += 'Please enter a value for project title.\n'; + if (!isFilledOut('audience')) alert_text += 'Please enter a value for who you are trying to reach.\n'; + if (!isFilledOut('timing_date')) alert_text += 'Please enter a value for project timing.\n'; + if (!isFilledOut('localization')) alert_text += 'Please enter a value for project localization.\n'; + if (!isFilledOut('success')) alert_text += 'Please enter a value for project success\n'; + if (!isFilledOut('bug_file_loc')) alert_text += 'Please enter a value for project destination url.\n'; + if (!isFilledOut('mozilla_goal')) alert_text += 'Please enter a value for Mozilla goal.\n'; + if (alert_text != '') { + alert(alert_text); + return false; + } + return true; +} +function toggleGoalOther() { + var goal_select = YAHOO.util.Dom.get('goal'); + if (goal_select.options[goal_select.selectedIndex].value == 'Other') { + YAHOO.util.Dom.removeClass('goal_other','bz_default_hidden'); + } + else { + YAHOO.util.Dom.addClass('goal_other','bz_default_hidden'); + } +} +[% END %] + +[% PROCESS global/header.html.tmpl + title = "User Engagement Form" + style = inline_style + javascript = inline_javascript + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/field.js', 'js/util.js' ] + yui = [ "autocomplete", "calendar" ] +%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +<form id="engagement_form" method="post" action="post_bug.cgi" enctype="multipart/form-data" + onSubmit="return validateAndSubmit();"> + <input type="hidden" name="format" value="user-engagement"> + <input type="hidden" name="product" value="Marketing"> + <input type="hidden" name="component" value="User Engagement"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + +<img title="User Engagement Form" src="extensions/BMO/web/images/user-engagement.png"> + +<div class="head_desc"> + Have something that you think our users should know about? Is there a campaign that you + think may benefit from promotion on Mozilla’s User Engagement channels?<br> + <br> + Please use this form to help us understand the goals of your project or campaign. + We’ll use this data to recommend a promotional plan that will meet your needs. +</div> + +<div class="form_section"> + <label for="short_desc" class="field_label required">Project / Request Title</label> + <div class="field_desc"> + Please tell us about your request in a few words + </div> + <input type="text" name="short_desc" id="short_desc" size="80"> +</div> + +<div class="form_section"> + <label for="goals" class="field_label required">Project Goals</label> + <div class="field_desc"> + Here’s where you tell us all the juicy details, especially your GOALS for this project. + Please tell us “I want to achieve this awesome goal†(ie. increase sign ups for this initiative, + get 1 million users to do X, etc.) rather than “I want a promotion on this specific channel.†+ </div> + <textarea id="goals" name="goals" cols="80" rows="5"></textarea> +</div> + +<div class="form_section"> + <label for="audience" class="field_label required">Who are you trying to reach?</label> + <div class="field_desc"> + Use this section to explain the type of user you’re targeting. Who is the audience? Consumers? + Early adopters? Developers? Be specific. + </div> + <textarea id="audience" name="audience" cols="80" rows="5"></textarea> +</div> + +<div class="form_section"> + <label for="localization" class="field_label required">Localization</label> + <div class="field_desc"> + Please tell us if your content needs to be localized, and in what languages. + Is the landing page localized? + </div> + <input type="text" name="localization" id="localization" size="80"> +</div> + +<div class="form_section"> + <label for="bug_file_loc" class="field_label required">Destination URL</label> + <div class="field_desc"> + Where would the user be sent when they click on the promotion? + </div> + <input type="text" name="bug_file_loc" id="bug_file_loc" size="80"> +</div> + +<div class="form_section"> + <label for="timing_date" class="field_label required">Timing</label> + <div class="field_desc"> + Here’s where you tell us when the initiative will launch. The content calendar + is determined at least 6 weeks in advance (to accommodate localization, etc.) + so the more notice we have, the better we’ll be able to help you meet your goals. + </div> + <input name="timing_date" size="20" id="timing_date" value="" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_timing_date" + onclick="showCalendar('timing_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_timing_date"></div> + <script type="text/javascript"> + createCalendar('timing_date') + </script> +</div> + +<div class="form_section"> + <label for="success" class="field_label required">Success</label> + <div class="field_desc"> + In a few words, tell us how you will define success from promotion to our consumers? + (example: Success is 1000 people clicking on this link.) + </div> + <textarea id="success" name="success" cols="80" rows="5"></textarea> +</div> + +<div class="form_section"> + <label for="mozilla_goal" class="field_label required">Mozilla Goal</label> + <div class="field_desc"> + What high-level Mozilla goal does this achieve? + </div> + <input type="text" name="mozilla_goal" id="mozilla_goal" size="80"> +</div> + +<div class="form_section"> + <label for="cc" class="field_label">Points of Contact</label> + <div class="field_desc"> + Who should be cc’d on this [% terms.bug %] and kept informed of updates? + </div> + [% INCLUDE global/userselect.html.tmpl + id => "cc" + name => "cc" + value => "" + size => 80 + classes => ["bz_userfield"] + multiple => 5 + %] +</div> + +<div class="head_desc"> + Once your form has been submitted, a tracking [% terms.bug %] will be created. We will + then reach out for additional info and next steps. Thanks! +</div> + +<input type="submit" id="commit" value="Submit"> + +<p> + [ <span class="required_star">*</span> <span class="required_explanation">Required Field</span> ] +</p> + +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-web-bounty.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-web-bounty.html.tmpl new file mode 100644 index 000000000..d76d57298 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-web-bounty.html.tmpl @@ -0,0 +1,142 @@ +[%# 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 %] + +[% inline_style = BLOCK %] +#web_bounty_form { + padding: 10px; +} +#web_bounty_form .required:after { + content: " *"; + color: red; +} +#web_bounty_form .field_label { + font-weight: bold; +} +#web_bounty_form .field_desc { + padding-bottom: 3px; +} +#web_bounty_form .field_desc, +#web_bounty_form .head_desc { + width: 600px; + word-wrap: normal; +} +#web_bounty_form .head_desc { + padding-top: 5px; + padding-bottom: 12px; +} +#web_bounty_form .form_section { + margin-bottom: 10px; +} +#web_bounty_form textarea { + font-family: inherit; + font-size: inherit; + margin: 0 !important; +} +#web_bounty_form em { + font-size: 1em; +} +[% END %] + +[% inline_javascript = BLOCK %] +function validateAndSubmit() { + var alert_text = ''; + if (!isFilledOut('short_desc')) alert_text += 'Please enter a value for summary.\n'; + if (!isFilledOut('comment')) alert_text += 'Please enter a value for comment.\n'; + if (alert_text != '') { + alert(alert_text); + return false; + } + return true; +} +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Web Bounty Form" + style = inline_style + javascript = inline_javascript + javascript_urls = [ 'extensions/BMO/web/js/form_validate.js', + 'js/field.js', 'js/util.js' ] +%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +<form id="web_bounty_form" method="post" action="post_bug.cgi" enctype="multipart/form-data" + onSubmit="return validateAndSubmit();"> + <input type="hidden" name="product" value="Websites"> + <input type="hidden" name="component" value="Other"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="All"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="priority" id="priority" value="--"> + <input type="hidden" name="target_milestone" id="target_milestone" value="---"> + <input type="hidden" name="status_whiteboard" id="status_whiteboard" value="[reporter-external] [web-bounty-form] [verif?]"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="groups" id="group_52" value="websites-security"> + <input type="hidden" name="flag_type-803" id="flag_type-803" value="?"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + +<div class="head_desc"> + <a href="https://developer.mozilla.org/en-US/docs/Mozilla/QA/Bug_writing_guidelines?redirectlocale=en-US&redirectslug=Bug_writing_guidelines"> + [% terms.Bug %] writing guidelines</a> +</div> + +<div class="form_section"> + <label for="short_desc" class="field_label required">Summary / Title</label> + <div class="field_desc"> + A short description of the issue being reported including the host name + for the website on which it exists (example xss in blarg.foo.mozilla.org) + </div> + <input type="text" name="short_desc" id="short_desc" size="80"> +</div> + +<div class="form_section"> + <label for="comment" class="field_label required">Comment</label> + <div class="field_desc"> + How was this issue discovered, include the steps, tools or other information that + will help reproduce and diagnose the issue. A good primer on what to include can + be found <a href="https://developer.mozilla.org/en-US/docs/Mozilla/QA">here</a>. + </div> + <textarea id="comment" name="comment" cols="80" rows="5"></textarea> +</div> + +<div class="form_section"> + <label for="bug_file_loc" class="field_label">URL</label> + <div class="field_desc"> + The full URL (hostname/subpage) where the issue exists (if the URL is especially long + please just include it in the comments) + </div> + <input type="text" name="bug_file_loc" id="bug_file_loc" size="80"> +</div> + +<div class="form_section"> + <label for="data" class="field_label">Attachment</label> + <div class="field_desc"> + A file that can add context to the report, such as a screen shot or code block for + reproduction purposes. + </div> + <input type="file" id="data" name="data" size="50"> + <input type="hidden" name="contenttypemethod" value="autodetect" /> + <div class="field_desc"> + <label for="description">Description</label> + </div> + <input type="text" id="description" name="description" size="80"> +</div> + +<input type="submit" id="commit" value="Submit"> + +<p> + [ <span class="required_star">*</span> <span class="required_explanation">Required Field</span> ] +</p> + +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/create-winqual.html.tmpl b/extensions/BMO/template/en/default/bug/create/create-winqual.html.tmpl new file mode 100644 index 000000000..fd21ed4ed --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/create-winqual.html.tmpl @@ -0,0 +1,831 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): Gervase Markham <gerv@gerv.net> + # Ville Skyttä <ville.skytta@iki.fi> + # Shane H. W. Travis <travis@sedsystems.ca> + # Marc Schumann <wurblzap@gmail.com> + # Akamai Technologies <bugzilla-dev@akamai.com> + # Max Kanat-Alexander <mkanat@bugzilla.org> + # Frédéric Buclin <LpSolit@gmail.com> + #%] + +[% PROCESS "global/field-descs.none.tmpl" %] + +[% title = BLOCK %]Enter [% terms.Bug %]: [% product.name FILTER html %][% END %] + +[% PROCESS global/header.html.tmpl + title = title + yui = [ 'autocomplete', 'calendar', 'datatable', 'button' ] + style_urls = [ 'skins/standard/attachment.css', + 'skins/standard/enter_bug.css', + 'skins/custom/create_bug.css' ] + javascript_urls = [ "js/attachment.js", "js/util.js", + "js/field.js", "js/TUI.js", "js/bug.js", + "js/create_bug.js" ] + onload = "init();" +%] + +<script type="text/javascript"> +<!-- + +function init() { + set_assign_to(); + hideElementById('attachment_true'); + showElementById('attachment_false'); + showElementById('btn_no_attachment'); + initCrashSignatureField(); + init_take_handler('[% user.login FILTER js %]'); +} + +function initCrashSignatureField() { + var el = document.getElementById('cf_crash_signature'); + if (!el) return; + [% IF cf_crash_signature.length %] + YAHOO.util.Dom.addClass('cf_crash_signature_container', 'bz_default_hidden'); + [% ELSE %] + hideEditableField('cf_crash_signature_container','cf_crash_signature_input', + 'cf_crash_signature_action', 'cf_crash_signature'); + [% END %] +} + +var initialowners = new Array(); +var last_initialowner; +var initialccs = new Array(); +var components = new Array(); +var comp_desc = new Array(); +var flags = new Array(); +[% IF Param("useqacontact") %] + var initialqacontacts = new Array([% product.components.size %]); + var last_initialqacontact; +[% END %] +[% count = 0 %] +[%- FOREACH c = product.components %] + [% NEXT IF NOT c.is_active %] + [% NEXT IF c.name != 'WinQual Reports' %] + components[[% count %]] = "[% c.name FILTER js %]"; + comp_desc[[% count %]] = "[% c.description FILTER html_light FILTER js %]"; + initialowners[[% count %]] = "[% c.default_assignee.login FILTER js %]"; + [% flag_list = [] %] + [% FOREACH f = c.flag_types(is_active=>1).bug %] + [% flag_list.push(f.id) %] + [% END %] + [% FOREACH f = c.flag_types(is_active=>1).attachment %] + [% flag_list.push(f.id) %] + [% END %] + flags[[% count %]] = [[% flag_list.join(",") FILTER js %]]; + [% IF Param("useqacontact") %] + initialqacontacts[[% count %]] = "[% c.default_qa_contact.login FILTER js %]"; + [% END %] + + [% SET initial_cc_list = [] %] + [% FOREACH cc_user = c.initial_cc %] + [% initial_cc_list.push(cc_user.login) %] + [% END %] + initialccs[[% count %]] = "[% initial_cc_list.join(', ') FILTER js %]"; + + [% count = count + 1 %] +[%- END %] + +function set_assign_to() { + // Based on the selected component, fill the "Assign To:" field + // with the default component owner, and the "QA Contact:" field + // with the default QA Contact. It also selectively enables flags. + var form = document.Create; + var assigned_to = form.assigned_to.value; + +[% IF Param("useqacontact") %] + var qa_contact = form.qa_contact.value; +[% END %] + + var index = -1; + if (form.component.type == 'select-one') { + index = form.component.selectedIndex; + } else if (form.component.type == 'hidden') { + // Assume there is only one component in the list + index = 0; + } + if (index != -1) { + var owner = initialowners[index]; + var component = components[index]; + if (assigned_to == last_initialowner + || assigned_to == owner + || assigned_to == '') { + form.assigned_to.value = owner; + last_initialowner = owner; + } + + document.getElementById('initial_cc').innerHTML = initialccs[index]; + document.getElementById('comp_desc').innerHTML = comp_desc[index]; + + if (initialccs[index]) { + showElementById('initial_cc_label'); + showElementById('initial_cc'); + } else { + hideElementById('initial_cc_label'); + hideElementById('initial_cc'); + } + + [% IF Param("useqacontact") %] + var contact = initialqacontacts[index]; + if (qa_contact == last_initialqacontact + || qa_contact == contact + || qa_contact == '') { + form.qa_contact.value = contact; + last_initialqacontact = contact; + } + [% END %] + + // First, we disable all flags. Then we re-enable those + // which are available for the selected component. + var inputElements = document.getElementsByTagName("select"); + var inputElement, flagField; + for ( var i=0 ; i<inputElements.length ; i++ ) { + inputElement = inputElements.item(i); + if (inputElement.name.search(/^flag_type-(\d+)$/) != -1) { + var id = inputElement.name.replace(/^flag_type-(\d+)$/, "$1"); + inputElement.disabled = true; + // Also hide the requestee field, if it exists. + inputElement = document.getElementById("requestee_type-" + id); + if (inputElement) + YAHOO.util.Dom.addClass(inputElement.parentNode, 'bz_default_hidden'); + } + } + // Now enable flags available for the selected component. + for (var i = 0; i < flags[index].length; i++) { + flagField = document.getElementById("flag_type-" + flags[index][i]); + // Do not enable flags the user cannot set nor request. + if (flagField && flagField.options.length > 1) { + flagField.disabled = false; + // Re-enabling the requestee field depends on the status + // of the flag. + toggleRequesteeField(flagField, 1); + } + } + } +} + +var status_comment_required = new Array(); +[% FOREACH status = bug_status %] + status_comment_required['[% status.name FILTER js %]'] = + [% status.comment_required_on_change_from() ? 'true' : 'false' %] +[% END %] + +TUI_alternates['expert_fields'] = 'Show Advanced Fields'; +// Hide the Advanced Fields by default, unless the user has a cookie +// that specifies otherwise. +TUI_hide_default('expert_fields'); + +--> +</script> + +<form name="Create" id="Create" method="post" action="post_bug.cgi" + class="enter_bug_form" enctype="multipart/form-data" + onsubmit="return validateEnterBug(this)"> + <input type="hidden" name="product" value="Firefox"> + <input type="hidden" name="component" value="WinQual Reports"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + <input type="hidden" name="groups" value="winqual-data"> + +<table> +<tbody> + <tr> + <td colspan="4"> + [%# Migration note: The following file corresponds to the old Param + # 'entryheaderhtml' + #%] + [% PROCESS 'bug/create/user-message.html.tmpl' %] + </td> + </tr> + + <tr> + <td colspan="2"> + <input type="button" id="expert_fields_controller" + value="Hide Advanced Fields" onClick="toggleAdvancedFields()"> + [%# Show the link if the browser supports JS %] + <script type="text/javascript"> + YAHOO.util.Dom.removeClass('expert_fields_controller', + 'bz_default_hidden'); + </script> + </td> + <td colspan="2"> + (<span class="required_star">*</span> = + <span class="required_explanation">Required Field</span>) + </td> + </tr> + + <tr> + [% INCLUDE bug/field.html.tmpl + bug = default, field = bug_fields.product, editable = 0, + value = product.name %] + [% INCLUDE bug/field.html.tmpl + bug = default, field = bug_fields.reporter, editable = 0, + value = user.login %] + </tr> + + [%# We can't use the select block in these two cases for various reasons. %] + <tr> + [% component_desc_url = BLOCK -%] + describecomponents.cgi?product=[% product.name FILTER uri %] + [% END %] + [% INCLUDE "bug/field-label.html.tmpl" + field = bug_fields.component editable = 1 + desc_url = component_desc_url + %] + <td id="field_container_component"> + [% INCLUDE bug/field.html.tmpl + bug = default, field = bug_fields.component, editable = 0, + value = "WinQual Reports", no_tds = 1 %] + <script type="text/javascript"> + <!-- + [%+ INCLUDE "bug/field-events.js.tmpl" + field = bug_fields.component %] + YAHOO.util.Event.onDOMReady(set_assign_to); + //--> + </script> + </td> + + <td colspan="2" id="comp_desc_container"> + [%# Enclose the fieldset in a nested table so that its width changes based + # on the length on the component description. %] + <table> + <tr> + <td> + <fieldset> + <legend>Component Description</legend> + <div id="comp_desc" class="comment"></div> + </fieldset> + </td> + </tr> + </table> + </td> + </tr> + + <tr> + [% INCLUDE "bug/field-label.html.tmpl" + field = bug_fields.version editable = 1 rowspan = 3 + %] + <td rowspan="3"> + <select name="version" id="version" size="5"> + [%- FOREACH v = version %] + [% NEXT IF NOT v.is_active %] + <option value="[% v.name FILTER html %]" + [% ' selected="selected"' IF v.name == default.version %]>[% v.name FILTER html -%] + </option> + [%- END %] + </select> + </td> + + [% INCLUDE bug/field.html.tmpl + bug = default, field = bug_fields.bug_severity, editable = 1, + value = default.bug_severity %] + </tr> + + <tr> + [% INCLUDE bug/field.html.tmpl + bug = default, field = bug_fields.rep_platform, editable = 1, + value = default.rep_platform %] + </tr> + + <tr> + [% INCLUDE bug/field.html.tmpl + bug = default, field = bug_fields.op_sys, editable = 1, + value = default.op_sys %] + </tr> + [% IF !Param('defaultplatform') || !Param('defaultopsys') %] + <tr> + <th colspan="3"> </th> + <td id="os_guess_note" class="comment"> + <div>We've made a guess at your + [% IF Param('defaultplatform') %] + operating system. Please check it + [% ELSIF Param('defaultopsys') %] + platform. Please check it + [% ELSE %] + operating system and platform. Please check them + [% END %] + and make any corrections if necessary.</div> + </td> + </tr> + [% END %] +</tbody> + +<tbody class="expert_fields"> + <tr> + [% IF Param('usetargetmilestone') && Param('letsubmitterchoosemilestone') %] + [% INCLUDE select field = bug_fields.target_milestone %] + [% ELSE %] + <td colspan="2"> </td> + [% END %] + + [% IF Param('letsubmitterchoosepriority') %] + [% INCLUDE bug/field.html.tmpl + bug = default, field = bug_fields.priority, editable = 1, + value = default.priority %] + [% ELSE %] + <td colspan="2"> </td> + [% END %] + </tr> +</tbody> + +<tbody class="expert_fields"> + <tr> + <td colspan="4"> </td> + </tr> + + <tr> + [% INCLUDE bug/field.html.tmpl + bug = default, field = bug_fields.bug_status, + editable = (bug_status.size > 1), value = default.bug_status + override_legal_values = bug_status %] + </tr> + + <tr> + [% INCLUDE "bug/field-label.html.tmpl" + field = bug_fields.assigned_to editable = 1 + %] + <td> + [% INCLUDE global/userselect.html.tmpl + id => "assigned_to" + name => "assigned_to" + value => assigned_to + disabled => assigned_to_disabled + size => 30 + emptyok => 1 + custom_userlist => assignees_list + %] + [% UNLESS assigned_to_disabled %] + <span id="take_bug"> + (<a title="Assign to yourself" href="#" + onclick="return take_bug('[% user.login FILTER js %]')">take</a>) + </span> + [% END %] + <noscript>(Leave blank to assign to component's default assignee)</noscript> + </td> + +[% IF Param("useqacontact") %] + [% INCLUDE "bug/field-label.html.tmpl" + field = bug_fields.qa_contact editable = 1 + %] + <td> + [% INCLUDE global/userselect.html.tmpl + id => "qa_contact" + name => "qa_contact" + value => qa_contact + disabled => qa_contact_disabled + size => 30 + emptyok => 1 + custom_userlist => qa_contacts_list + %] + <noscript>(Leave blank to assign to default qa contact)</noscript> + </td> + </tr> +[% END %] + + <tr> + [% INCLUDE "bug/field-label.html.tmpl" + field = bug_fields.cc editable = 1 + %] + <td> + [% INCLUDE global/userselect.html.tmpl + id => "cc" + name => "cc" + value => cc + disabled => cc_disabled + size => 30 + multiple => 5 + %] + </td> + <th> + <span id="initial_cc_label" class="bz_default_hidden"> + Default [% field_descs.cc FILTER html %]: + </span> + </th> + <td> + <span id="initial_cc"></span> + </td> + </tr> + + <tr> + <td colspan="3"> </td> + </tr> + +[% IF Param("usebugaliases") %] + <tr> + [% INCLUDE "bug/field-label.html.tmpl" + field = bug_fields.alias editable = 1 + %] + <td colspan="2"> + <input name="alias" size="20" value="[% alias FILTER html %]"> + </td> + </tr> +[% END %] +</tbody> + +<tbody> + <tr> + [% INCLUDE "bug/field-label.html.tmpl" + field = bug_fields.short_desc editable = 1 + %] + <td colspan="3" class="field_value"> + <input name="short_desc" size="70" value="[% short_desc FILTER html %]" + maxlength="255" spellcheck="true" aria-required="true" + class="required text_input" id="short_desc"> + </td> + </tr> + + [% IF feature_enabled('jsonrpc') AND !cloned_bug_id %] + <tr id="possible_duplicates_container" class="bz_default_hidden"> + <th>Possible<br>Duplicates:</th> + <td colspan="3"> + <div id="possible_duplicates"></div> + <script type="text/javascript"> + var dt_columns = [ + { key: "id", label: "[% field_descs.bug_id FILTER js %]", + formatter: YAHOO.bugzilla.dupTable.formatBugLink }, + { key: "summary", + label: "[% field_descs.short_desc FILTER js %]", + formatter: "text" }, + { key: "status", + label: "[% field_descs.bug_status FILTER js %]", + formatter: YAHOO.bugzilla.dupTable.formatStatus }, + { key: "update_token", label: '', + formatter: YAHOO.bugzilla.dupTable.formatCcButton } + ]; + YAHOO.bugzilla.dupTable.addCcMessage = "Add Me to the CC List"; + YAHOO.bugzilla.dupTable.init({ + container: 'possible_duplicates', + columns: dt_columns, + product_name: '[% product.name FILTER js %]', + summary_field: 'short_desc', + options: { + MSG_LOADING: 'Searching for possible duplicates...', + MSG_EMPTY: 'No possible duplicates found.', + SUMMARY: 'Possible Duplicates' + } + }); + </script> + </td> + </tr> + [% END %] + + <tr> + <th>Description:</th> + <td colspan="3"> + + [% defaultcontent = BLOCK %] + [% IF cloned_bug_id %] ++++ This [% terms.bug %] was initially created as a clone of [% terms.Bug %] #[% cloned_bug_id FILTER html %] +++ + + + [% END %] + [%-# We are within a BLOCK. The comment will be correctly HTML-escaped + # by global/textarea.html.tmpl. So we must not escape the comment here. %] + [% comment FILTER none %] + [%- END %] + [% INCLUDE global/textarea.html.tmpl + name = 'comment' + id = 'comment' + minrows = 10 + maxrows = 25 + cols = constants.COMMENT_COLS + defaultcontent = defaultcontent + %] + <br> + </td> + </tr> + +<tbody class="expert_fields"> + <tr> + [% INCLUDE "bug/field-label.html.tmpl" + field = bug_fields.bug_file_loc editable = 1 + %] + <td colspan="3" class="field_value"> + <input name="bug_file_loc" id="bug_file_loc" class="text_input" + size="40" value="[% bug_file_loc FILTER html %]"> + </td> + </tr> +</tbody> + +<tbody> + [% IF Param("maxattachmentsize") %] + <tr> + <th>Attachment:</th> + <td colspan="3"> + <div id="attachment_false" class="bz_default_hidden"> + <input type="button" value="Add an attachment" onClick="handleWantsAttachment(true)"> + </div> + + <div id="attachment_true"> + <input type="button" id="btn_no_attachment" value="Don't add an attachment" + class="bz_default_hidden" onClick="handleWantsAttachment(false)"> + <fieldset> + <legend>Add an attachment</legend> + <table class="attachment_entry"> + [% PROCESS attachment/createformcontents.html.tmpl + flag_types = product.flag_types(is_active=>1).attachment + any_flags_requesteeble = 1 + flag_table_id ="attachment_flags" %] + </table> + + [% IF user.is_insider %] + <input type="checkbox" id="comment_is_private" name="comment_is_private" + [% ' checked="checked"' IF comment_is_private %] + onClick="updateCommentTagControl(this, 'comment')"> + <label for="comment_is_private"> + Make this attachment and [% terms.bug %] description private (visible only + to members of the <strong>[% Param('insidergroup') FILTER html %]</strong> group) + </label> + [% END %] + </fieldset> + </div> + </td> + </tr> + [% END %] +</tbody> + +<tbody class="expert_fields"> + [% IF user.in_group('editbugs', product.id) %] + <tr> + [% INCLUDE "bug/field-label.html.tmpl" + field = bug_fields.dependson editable = 1 + %] + <td> + <input name="dependson" accesskey="d" value="[% dependson FILTER html %]" size="30"> + </td> + [% INCLUDE "bug/field-label.html.tmpl" + field = bug_fields.blocked editable = 1 + %] + <td> + <input name="blocked" accesskey="b" value="[% blocked FILTER html %]" size="30"> + </td> + </tr> + + [% IF use_keywords %] + <tr> + [% INCLUDE bug/field.html.tmpl + bug = default, field = bug_fields.keywords, editable = 1, + value = keywords, desc_url = "describekeywords.cgi", + value_span = 3 + %] + </tr> + [% END %] + + <tr> + <th>Status Whiteboard:</th> + <td colspan="3" class="field_value"> + <input id="status_whiteboard" name="status_whiteboard" size="70" + value="[% status_whiteboard FILTER html %]" class="text_input"> + </td> + </tr> + [% END %] + + [% IF user.is_timetracker %] + <tr> + [% INCLUDE "bug/field-label.html.tmpl" + field = bug_fields.estimated_time editable = 1 + %] + <td> + <input name="estimated_time" size="6" maxlength="6" value="[% estimated_time FILTER html %]"> + </td> + [% INCLUDE bug/field.html.tmpl + bug = default, field = bug_fields.deadline, value = deadline, editable = 1 + %] + </tr> + [% END %] +</tbody> + +<tbody> +[%# non-tracking flags custom fields %] +[% FOREACH field = Bugzilla.active_custom_fields(product=>product,type=>1) %] + [% NEXT IF field.type == constants.FIELD_TYPE_EXTENSION %] + [% NEXT UNLESS field.enter_bug %] + [%# crash-signature gets custom handling %] + [% IF field.name == 'cf_crash_signature' %] + [% show_crash_signature = 1 %] + [% NEXT %] + [% END %] + [% SET value = ${field.name}.defined ? ${field.name} : "" %] + <tr [% 'class="expert_fields"' IF !field.is_mandatory %]> + [% INCLUDE bug/field.html.tmpl + bug = default, field = field, value = value, editable = 1, + value_span = 3 %] + </tr> +[% END %] +</tbody> + +[%# crash-signature handling %] +[% IF show_crash_signature %] +<tbody class="expert_fields"> + <tr> + <th id="field_label_cf_crash_signature" class="field_label"> + <label for="cf_crash_signature"> Crash Signature: </label> + </th> + <td colspan="3"> + <span id="cf_crash_signature_container"> + <span id="cf_crash_signature_nonedit_display"><i>None</i></span> + (<a id="cf_crash_signature_action" href="#">edit</a>) + </span> + <span id="cf_crash_signature_input"> + <textarea id="cf_crash_signature" name="cf_crash_signature" rows="4" cols="60" + >[% cf_crash_signature FILTER html %]</textarea> + </span> + </td> + </tr> +</tbody> +[% END %] + +[% old_tracking_flags = [] %] +[% old_project_flags = [] %] +[% FOREACH field = Bugzilla.active_custom_fields(product=>product,type=>2) %] + [% NEXT IF field.type == constants.FIELD_TYPE_EXTENSION %] + [% NEXT UNLESS field.enter_bug %] + [% IF cf_is_project_flag(field.name) %] + [% old_project_flags.push(field) %] + [% ELSE %] + [% old_tracking_flags.push(field) %] + [% END %] +[% END %] + +[% display_flags = 0 %] +[% any_flags_requesteeble = 0 %] +[% FOREACH flag_type = product.flag_types.bug %] + [% display_flags = 1 %] + [% SET any_flags_requesteeble = 1 IF flag_type.is_requestable && flag_type.is_requesteeble %] + [% LAST IF display_flags && any_flags_requesteeable %] +[% END %] + +[% IF old_project_flags.size || old_tracking_flags.size || display_flags %] + <tbody class="expert_fields"> + <tr> + <th>Flags:</th> + <td colspan="3"> + <div id="bug_flags_false" class="bz_default_hidden"> + <input type="button" value="Set [% terms.bug FILTER html %] flags" onClick="handleWantsBugFlags(true)"> + </div> + + <div id="bug_flags_true"> + <input type="button" id="btn_no_bug_flags" value="Don't set [% terms.bug %] flags" + class="bz_default_hidden" onClick="handleWantsBugFlags(false)"> + + <fieldset> + <legend>Set [% terms.bug %] flags</legend> + + <table cellpadding="0" cellspacing="0"> + <tr> + [% IF old_tracking_flags.size %] + <td [% IF project_flags.size %]rowspan="2"[% END %]> + <table class="tracking_flags"> + <tr> + <th colspan="2" style="text-align:left">Tracking Flags:</th> + </tr> + [% FOREACH field = old_tracking_flags %] + [% SET value = ${field.name}.defined ? ${field.name} : "" %] + <tr> + [% INCLUDE bug/field.html.tmpl + bug = default + field = field + value = value + editable = 1 + value_span = 3 + %] + </tr> + [% END %] + [% Hook.process('tracking_flags_end') %] + </table> + </td> + [% END %] + [% IF old_project_flags.size %] + <td> + <table class="tracking_flags"> + <tr> + <th colspan="2" style="text-align:left">Project Flags:</th> + </tr> + [% FOREACH field = old_project_flags %] + [% SET value = ${field.name}.defined ? ${field.name} : "" %] + <tr> + [% INCLUDE bug/field.html.tmpl + bug = default + field = field + value = value + editable = 1 + value_span = 3 + %] + </tr> + [% END %] + [% Hook.process('project_flags_end') %] + </table> + </td> + </tr> + <tr> + [% END %] + [% IF display_flags %] + <td> + [% PROCESS "flag/list.html.tmpl" flag_types = product.flag_types.bug + any_flags_requesteeble = any_flags_requesteeble + flag_table_id = "bug_flags" + %] + </td> + [% END %] + </tr> + [% Hook.process('bug_flags_end') %] + </table> + </fieldset> + </div> + </td> + </tr> + </tbody> +[% END %] + +<tbody> + [%# Form controls for entering additional data about the bug being created. %] + [% Hook.process("form") %] + + <tr> + <th> </th> + <td colspan="3"> + <input type="submit" id="commit" value="Submit [% terms.Bug %]"> + + <input type="submit" name="maketemplate" id="maketemplate" + value="Remember values as bookmarkable template" + onclick="bz_no_validate_enter_bug=true" class="expert_fields"> + </td> + </tr> +</tbody> + [%# "status whiteboard" and "qa contact" are the longest labels + # add them here to avoid shifting the page when toggling advanced fields %] + <tr> + <th class="hidden_text">Status Whiteboard:</th> + <td> </td> + <th class="hidden_text">QA Contact:</th> + </tr> + </table> + <input type="hidden" name="form_name" value="enter_bug"> +</form> + +[%# Links or content with more information about the bug being created. %] +[% Hook.process("end") %] + +<div id="guided"> + <a id="guided_img" href="enter_bug.cgi?format=guided&product=[% product.name FILTER uri %]"><img + src="extensions/BMO/web/images/guided.png" width="16" height="16" border="0" align="absmiddle"></a> + <a id="guided_link" href="enter_bug.cgi?format=guided&product=[% product.name FILTER uri %]" + >Switch to the [% terms.Bugzilla %] Helper</a> +</div> + +[% PROCESS global/footer.html.tmpl %] + +[%############################################################################%] +[%# Block for SELECT fields #%] +[%############################################################################%] + +[% BLOCK select %] + + [% INCLUDE "bug/field-label.html.tmpl" + field = field editable = 1 + %] + <td> + <select name="[% field.name FILTER html %]" + id="[% field.name FILTER html %]"> + [%- FOREACH x = ${field.name} %] + [% NEXT IF NOT x.is_active %] + <option value="[% x.name FILTER html %]" + [% " selected=\"selected\"" IF x.name == default.${field.name} %]> + [% display_value(field.name, x.name) FILTER html %] + </option> + [% END %] + </select> + </td> +[% END %] + +[% BLOCK build_userlist %] + [% user_found = 0 %] + [% default_login = default_user.login %] + [% RETURN UNLESS default_login %] + + [% FOREACH user = userlist %] + [% IF user.login == default_login %] + [% user_found = 1 %] + [% LAST %] + [% END %] + [% END %] + + [% userlist.push({login => default_login, + identity => default_user.identity, + visible => 1}) + UNLESS user_found %] +[% END %] diff --git a/extensions/BMO/template/en/default/bug/create/created-fxos-betaprogram.html.tmpl b/extensions/BMO/template/en/default/bug/create/created-fxos-betaprogram.html.tmpl new file mode 100644 index 000000000..145c976cd --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/created-fxos-betaprogram.html.tmpl @@ -0,0 +1,30 @@ +[%# 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 = "Firefox OS Beta Bug Submission" +%] + +<h1>Thank you!</h1> + +<p> + Thank you for submitting feedback about Firefox OS. +</p> + +<p> + We'll link any [% terms.bugs %] we file (or are already filed) as a result of + this feedback to this report so you can be notified about their progress. +</p> + +<p style="font-size: x-small"> + Reference: <a href="show_bug.cgi?id=[% id FILTER uri %]">#[% id FILTER html %]</a> +</p> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/bug/create/custom_forms.none.tmpl b/extensions/BMO/template/en/default/bug/create/custom_forms.none.tmpl new file mode 100644 index 000000000..25af4fa47 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/custom_forms.none.tmpl @@ -0,0 +1,173 @@ +[%# 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. + #%] + +[%# link => url (can be relative to bugzilla.mozilla.org, or full url) + # title => visible title + # group => optional group name, if present the form won't be show to + # users not in this group + # hide => optional boolean, if true the form will not be shown on + # enter_bug (but will be visible on the custom forms list) + #%] + +[% +custom_forms = { + "mozilla.org" => [ + { + link => "form.moz.project.review", + title => "Mozilla Project Review", + group => "mozilla-employee-confidential", + }, + { + link => "form.trademark", + title => "Trademark Usage Requests", + }, + { + link => "form.gear", + title => "Mozilla Gear Request", + group => "mozilla-employee-confidential", + }, + { + link => "form.poweredby", + title => "Powered by Mozilla Logo Requests", + }, + { + link => "form.mozlist", + title => "Mozilla Discussion Forum Requests", + group => "mozilla-employee-confidential", + }, + ], + "Marketing" => [ + { + link => "form.user.engagement", + title => "User Engagement Initiation Form", + group => "mozilla-employee-confidential", + }, + { + link => "form.gear", + title => "Mozilla Gear Request", + group => "mozilla-employee-confidential", + }, + { + link => "form.creative", + title => "Brand Engagement Initiation Form", + group => "mozilla-employee-confidential", + }, + { + link => "form.poweredby", + title => "Powered by Mozilla Logo Requests", + }, + ], + "Finance" => [ + { + link => "form.finance", + title => "Finance Request", + group => "mozilla-employee-confidential", + }, + ], + "Privacy" => [ + { + link => "form.privacy.data", + title => "Privacy - Data Release Proposal", + group => "mozilla-employee-confidential", + }, + ], + "Mozilla PR" => [ + { + link => "form.mozpr", + title => "PR Project Form", + group => "pr-private", + }, + ], + "Infrastructure & Operations" => [ + { + link => "form.itrequest", + title => "IT Request Form", + group => "mozilla-employee-confidential", + }, + { + link => "form.mozlist", + title => "Mozilla Discussion Forum Requests", + group => "mozilla-employee-confidential", + }, + ], + "Tech Evangelism" => [ + { + link => "form.mobile.compat", + title => "Mobile Web Compatibility Problem", + }, + ], + "Air Mozilla" => [ + { + link => "https://air.mozilla.org/requests/", + title => "Air Mozilla/Brown Bag Request", + group => "mozilla-employee-confidential", + }, + ], + "Websites" => [ + { + link => "form.web.bounty", + title => "Web Bounty Form", + }, + ], + "Firefox OS" => [ + { + link => "form.fxos.feature", + title => "Firefox OS Feature Request Form", + }, + { + link => "form.fxos.mcts.waiver", + title => "Firefox OS MCTS Waiver Form", + }, + { + link => "form.fxos.partner", + title => "Firefox OS Partner Bug Submission", + hide => 1, + }, + { + link => "form.fxos.preload.app", + title => "Firefox OS Pre-load App", + hide => 1, + }, + { + link => "form.fxos.betaprogram", + title => "Firefox OS Beta Program Bug Submission", + hide => 1, + }, + ], + "Testing" => [ + { + link => "form.automative", + title => "Automation Request Form", + }, + ], + "Developer Engagement" => [ + { + link => "form.dev.engagement.event", + title => "Developer Events Request Form", + }, + ], + "Mozilla Developer Network" => [ + { + link => "form.mdn", + title => "Mozilla Developer Network Feedback", + }, + ], + "Internet Public Policy" => [ + { + link => "form.ipp", + title => "Internet Public Policy Issue", + }, + ], + "Marketplace" => [ + { + link => "form.fxos.preload.app", + title => "Firefox OS Pre-load App", + }, + ], +} +%] diff --git a/extensions/BMO/template/en/default/bug/create/user-message.html.tmpl b/extensions/BMO/template/en/default/bug/create/user-message.html.tmpl new file mode 100644 index 000000000..52014ae15 --- /dev/null +++ b/extensions/BMO/template/en/default/bug/create/user-message.html.tmpl @@ -0,0 +1,49 @@ +[%# 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 %] + +<p> + [% UNLESS cloned_bug_id %] + Consider using the + <a href="enter_bug.cgi?product=[% product.name FILTER html %]&format=guided" + ><img src="extensions/BMO/web/images/guided.png" width="16" height="16" align="absmiddle" border="0"> + [%+ terms.Bugzilla %] Helper</a> instead of this form. + [% END +%] + Before reporting a [% terms.bug %], make sure you've read our + <a href="http://www.mozilla.org/quality/bug-writing-guidelines.html"> + [% terms.bug %] writing guidelines</a> and double checked that your [% terms.bug %] hasn't already + been reported. Consult our list of <a href="https://bugzilla.mozilla.org/duplicates.cgi"> + most frequently reported [% terms.bugs %]</a> and <a href="https://bugzilla.mozilla.org/query.cgi"> + search through descriptions</a> of previously reported [% terms.bugs %]. +</p> + +[% + PROCESS bug/create/custom_forms.none.tmpl; + visible_forms = []; + FOREACH form = custom_forms.${product.name}; + NEXT IF form.hide; + NEXT IF form.group && !user.in_group(form.group); + visible_forms.push(form); + END; + RETURN UNLESS visible_forms.size; +%] + +<div id="custom_form_list"> + <img src="extensions/BMO/web/images/notice.png" width="48" height="48" id="custom_form_list_image"> + <div id="custom_form_list_text"> + This product has task-specific [% terms.bug %] forms that should be used if + appropriate: + + <ul> + [% FOREACH form = visible_forms.sort("title") %] + <li><a href="[% form.link FILTER none %]">[% form.title FILTER html %]</a></li> + [% END %] + </ul> + </div> +</div> diff --git a/extensions/BMO/template/en/default/email/bugmail.html.tmpl b/extensions/BMO/template/en/default/email/bugmail.html.tmpl new file mode 100644 index 000000000..7a628ec7f --- /dev/null +++ b/extensions/BMO/template/en/default/email/bugmail.html.tmpl @@ -0,0 +1,204 @@ +[%# 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/field-descs.none.tmpl" %] +[% PROCESS "global/reason-descs.none.tmpl" %] + +[% isnew = bug.lastdiffed ? 0 : 1 %] +<html> +<head> + <base href="[% urlbase FILTER html %]"> + <style> + .comment { font-size: 100% !important; } + </style> +</head> +<body style="font-family: sans-serif"> + + [% IF !to_user.in_group('editbugs') %] + <div id="noreply" style="font-size: 90%; color: #666666"> + Do not reply to this email. You can add comments to this [% terms.bug %] at + [%# using the bug_link filter here causes a weird template error %] + <a href="[% urlbase FILTER html %]show_bug.cgi?id=[% bug.id FILTER none %]"> + [% urlbase FILTER html %]show_bug.cgi?id=[% bug.id FILTER none %]</a> + </div> + <br> + [% END %] + + [% IF isnew %] + [% PROCESS generate_new %] + [% ELSE %] + [% PROCESS generate_diffs %] + [% END %] + + [% IF new_comments.size %] + <div id="comments"> + [% FOREACH comment = new_comments.reverse %] + <div> + [% IF comment.count %] + <b> + [% "Comment # ${comment.count}" + FILTER bug_link(bug, { comment_num => comment.count, full_url => 1 }) FILTER none %] + on [% "$terms.Bug $bug.id" FILTER bug_link(bug, { full_url => 1 }) FILTER none %] + from [% INCLUDE global/user.html.tmpl user = to_user, who = comment.author %] + at [% comment.creation_ts FILTER time(undef, to_user.timezone) %] + </b> + [% END %] + <pre class="comment" style="font-size: 120%">[% comment.body_full({ wrap => 1 }) FILTER quoteUrls(bug, comment) %]</pre> + </div> + [% END %] + </div> + <br> + [% END %] + + [% IF referenced_bugs.size %] + <div id="referenced"> + <hr style="border: 1px dashed #969696"> + <b>Referenced [% terms.Bugs %]:</b> + <ul> + [% FOREACH ref = referenced_bugs %] + <li> + [<a href="[% urlbase FILTER html %]show_bug.cgi?id=[% ref.id FILTER none %]"> + [% terms.Bug %] [% ref.id FILTER none %]</a>] [% ref.short_desc FILTER html %] + </li> + [% END %] + </ul> + </div> + <br> + [% END %] + + <div id="bug_details" style="font-size: 90%; color: #666666"> + <hr style="border: 1px dashed #969696"> + Product/Component: [% bug.product FILTER html %] :: [% bug.component FILTER html %]<br> + [% "You are mentoring this " _ terms.bug IF bug.is_mentor(to_user) %] + </div> + + [% seen_header = 0 %] + [% FOREACH flag = tracking_flags %] + [% NEXT IF bug.${flag.name} == "---" %] + [% IF !seen_header %] + [% seen_header = 1 %] + <div id="tracking" style="font-size: 90%; color: #666666"> + <hr style="border: 1px dashed #969696"> + <b>Tracking Flags:</b> + <ul> + [% END %] + <li>[% flag.description FILTER html %]:[% bug.${flag.name} FILTER html %]</li> + [% END %] + [% IF seen_header %] + </ul> + </div> + [% END %] + + <div id="reason" style="font-size: 90%; color: #666666"> + <hr style="border: 1px dashed #969696"> + <b>You are receiving this mail because:</b> + <ul> + [% FOREACH reason = reasons %] + [% IF reason_descs.$reason %] + <li>[% reason_descs.$reason FILTER html %]</li> + [% END %] + [% END %] + [% FOREACH reason = reasons_watch %] + [% IF watch_reason_descs.$reason %] + <li>[% watch_reason_descs.$reason FILTER html %]</li> + [% END %] + [% END %] + </ul> + </div> + + @@body-headers@@ +</body> +</html> + +[% BLOCK generate_new %] + <div class="new"> + <table border="0" cellspacing="0" cellpadding="3"> + [% FOREACH change = diffs %] + [% PROCESS "email/bugmail-common.txt.tmpl" %] + <tr> + <td class="c1" style="border-right: 1px solid #969696" nowrap><b>[% field_label FILTER html %]</b></td> + <td class="c2"> + [% IF change.field_name == "bug_id" %] + [% new_value FILTER bug_link(bug, full_url => 1) FILTER none %] + [% ELSE %] + [% new_value FILTER html %] + [% END %] + </td> + </tr> + [% END %] + </table> + </div> + <br> +[% END %] + +[% BLOCK generate_diffs %] + [% SET in_table = 0 %] + [% last_changer = 0 %] + [% FOREACH change = diffs %] + [% PROCESS "email/bugmail-common.txt.tmpl" %] + [% IF changer.id != last_changer %] + [% last_changer = changer.id %] + [% IF in_table == 1 %] + </table> + </div> + <br> + [% SET in_table = 0 %] + [% END %] + + <b> + [% IF change.blocker %] + [% "${terms.Bug} ${bug.id}" FILTER bug_link(bug, full_url => 1) FILTER none %] + depends on + <a href="[% urlbase FILTER html %]show_bug.cgi?id=[% change.blocker.id FILTER none %]"> + [% terms.Bug %] [% change.blocker.id FILTER none %]</a>, + which changed state.<br> + [% ELSE %] + [% INCLUDE global/user.html.tmpl user = to_user, who = change.who %] changed + [%+ "${terms.Bug} ${bug.id}" FILTER bug_link(bug, full_url => 1) FILTER none %] + at [% change.bug_when FILTER time(undef, to_user.timezone) %]</b>:<br> + [% END %] + </b> + + [% IF in_table == 0 %] + <br> + <div class="diffs"> + <table border="0" cellspacing="0" cellpadding="5"> + [% SET in_table = 1 %] + [% END %] + <tr class="head"> + <td class="c1" style="border-bottom: 1px solid #969696; border-right: 1px solid #969696"><b>What</b></td> + <td class="c2" style="border-bottom: 1px solid #969696; border-right: 1px solid #969696"><b>Removed</b></td> + <td class="c3" style="border-bottom: 1px solid #969696"><b>Added</b></td> + </tr> + [% END %] + + <tr> + <td class="c1" style="border-right: 1px solid #969696" nowrap>[% field_label FILTER html %]</td> + <td class="c2" style="border-right: 1px solid #969696"> + [% IF old_value %] + [% old_value FILTER html %] + [% ELSE %] + + [% END %] + </td> + <td> + [% IF new_value %] + [% new_value FILTER html %] + [% ELSE %] + + [% END %] + </td> + </tr> + [% END %] + [% IF in_table %] + </table> + </div> + <br> + [% END %] +[% END %] + diff --git a/extensions/BMO/template/en/default/email/bugmail.txt.tmpl b/extensions/BMO/template/en/default/email/bugmail.txt.tmpl new file mode 100644 index 000000000..9cb020b02 --- /dev/null +++ b/extensions/BMO/template/en/default/email/bugmail.txt.tmpl @@ -0,0 +1,92 @@ +[%# 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/field-descs.none.tmpl" %] +[% PROCESS "global/reason-descs.none.tmpl" %] + +[% isnew = bug.lastdiffed ? 0 : 1 %] + +[% IF !to_user.in_group('editbugs') %] +Do not reply to this email. You can add comments to this [% terms.bug %] at +[% END %] +[%+ PROCESS generate_diffs -%] + +[% FOREACH comment = new_comments %] + +[%- IF comment.count %] +--- Comment #[% comment.count %] from [% comment.author.identity %] [%+ comment.creation_ts FILTER time(undef, to_user.timezone) %] --- +[% END %] +[%+ comment.body_full({ is_bugmail => 1, wrap => 1 }) %] +[% END %] +[% IF referenced_bugs.size %] + +Referenced [% terms.Bugs %]: + +[% FOREACH ref = referenced_bugs %] +[%+ urlbase %]show_bug.cgi?id=[% ref.id %] +[%+ "[" _ terms.Bug _ " " _ ref.id _ "] " _ ref.short_desc FILTER wrap_comment(76) %] +[% END %] +[% END %] + +-- [%# Protect the trailing space of the signature marker %] +Configure [% terms.bug %]mail: [% urlbase %]userprefs.cgi?tab=email + +------------------------------- +Product/Component: [%+ bug.product +%] :: [%+ bug.component %] +[%+ "You are mentoring this " _ terms.bug IF bug.is_mentor(to_user) %] + +[% seen_header = 0 %] +[% FOREACH flag = tracking_flags %] + [% NEXT IF bug.${flag.name} == "---" %] + [% IF !seen_header %] + [% seen_header = 1 %] +------- Tracking Flags: ------- + [% END %] +[%+ flag.description %]:[% bug.${flag.name} %] +[% END %] + +------- You are receiving this mail because: ------- +[% SET reason_lines = [] %] +[% FOREACH reason = reasons %] + [% reason_lines.push(reason_descs.$reason) IF reason_descs.$reason %] +[% END %] +[% FOREACH reason = reasons_watch %] + [% reason_lines.push(watch_reason_descs.$reason) + IF watch_reason_descs.$reason %] +[% END %] +[%+ reason_lines.join("\n") %] + +@@body-headers@@ + +[% BLOCK generate_diffs %] + [% urlbase %]show_bug.cgi?id=[% bug.id %] + +[%+ last_changer = 0 %] + [% FOREACH change = diffs %] + [% IF !isnew && changer.id != last_changer %] + [% last_changer = changer.id %] + [% IF change.blocker %] + [% terms.Bug %] [%+ bug.id %] depends on [% terms.bug %] [%+ change.blocker.id %], which changed state. + +[%+ terms.Bug %] [%+ change.blocker.id %] Summary: [% change.blocker.short_desc %] +[%+ urlbase %]show_bug.cgi?id=[% change.blocker.id %] + [% ELSE %] + [%~ changer.identity %] changed: + [% END %] + + What |Removed |Added +---------------------------------------------------------------------------- +[%+ END %][%# End of IF. This indentation is intentional! ~%] + [% PROCESS "email/bugmail-common.txt.tmpl"%] + [%~ IF isnew %] + [% format_columns(2, field_label _ ":", new_value) -%] + [% ELSE %] + [% format_columns(3, field_label, old_value, new_value) -%] + [% END %] + [% END -%] +[% END %] diff --git a/extensions/BMO/template/en/default/global/choose-product.html.tmpl b/extensions/BMO/template/en/default/global/choose-product.html.tmpl new file mode 100644 index 000000000..eb7581d4e --- /dev/null +++ b/extensions/BMO/template/en/default/global/choose-product.html.tmpl @@ -0,0 +1,228 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): Gervase Markham <gerv@gerv.net> + #%] + +[%# INTERFACE: + # classifications: array of hashes, with an 'object' key representing a + # classification object and 'products' the list of + # product objects the user can enter bugs into. + # target: the script that displays this template. + # cloned_bug_id: ID of the bug being cloned. + # format: the desired format to display the target. + #%] + +[% PROCESS global/variables.none.tmpl %] + +[% style_urls = [ "extensions/BMO/web/styles/choose_product.css" ] %] + +[% IF target == "enter_bug.cgi" %] + [% title = "Enter $terms.Bug" %] + [% h2 = "Which product is affected by the problem you would like to report?" %] +[% ELSIF target == "describecomponents.cgi" %] + [% title = "Browse" %] + [% h2 = "Which product would you like to have described?" %] +[% END %] + +[% javascript_urls = [ "js/yui3/yui/yui-min.js", + "extensions/ProdCompSearch/web/js/prod_comp_search.js" ] +%] +[% onload = "document.getElementById('prod_comp_search').focus();" %] +[% style_urls.push("extensions/ProdCompSearch/web/styles/prod_comp_search.css") %] + +[% DEFAULT title = "Choose a Product" %] +[% PROCESS global/header.html.tmpl %] + +<div id="choose_product"> + +<hr> +<p> + Looking for technical support or help getting your site to work with Mozilla? + <a href="http://www.mozilla.org/support/">Visit the mozilla.org support page</a> + before filing [% terms.bugs %]. +</p> +<hr> + +<h2>[% h2 FILTER html %]</h2> + +<div id="prod_comp_search_main"> + [% PROCESS prodcompsearch/form.html.tmpl + input_label = "Find product:" + format = format + cloned_bug_id = cloned_bug_id + script_name = target %] +</div> + +<h2>or choose from the following selections</h2> + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] +[% SET classification = cgi.param('classification') %] +[% IF NOT ((cgi.param("full")) OR (user.settings.product_chooser.value == 'full_product_chooser')) %] + +<table align="center" border="0" width="600" cellpadding="5" cellspacing="0"> +[% INCLUDE easyproduct + name="Core" + icon="component.png" +%] +[% INCLUDE easyproduct + name="Firefox" + icon="firefox.png" +%] +[% INCLUDE easyproduct + name="Firefox OS" + icon="firefox_os.png" +%] +[% INCLUDE easyproduct + name="Firefox for Android" + icon="firefox_android.png" +%] +[% INCLUDE easyproduct + name="Marketplace" + icon="marketplace.png" +%] +[% INCLUDE easyproduct + name="Webmaker" + icon="webmaker.png" +%] +[% INCLUDE easyproduct + name="Toolkit" + icon="component.png" +%] +[% INCLUDE easyproduct + name="Thunderbird" + icon="thunderbird.png" +%] +[% INCLUDE easyproduct + name="SeaMonkey" + icon="seamonkey.png" +%] +[% INCLUDE easyproduct + name="Mozilla Localizations" + icon="localization.png" +%] +[% INCLUDE easyproduct + name="Mozilla Services" + icon="sync.png" +%] +<tr> + <td><a href="[% target FILTER uri %]?full=1 + [%- IF cloned_bug_id %]&cloned_bug_id=[% cloned_bug_id FILTER uri %][% END -%] + [%- IF classification %]&classification=[% classification FILTER uri %][% END -%] + [%- IF format %]&format=[% format FILTER uri %][% END %]"> + <img src="extensions/BMO/web/producticons/other.png" height="64" width="64" border="0"></a></td> + <td><h2 align="left" style="margin-bottom: 0px;"><a href="[% target FILTER uri %]?full=1 + [%- IF cloned_bug_id %]&cloned_bug_id=[% cloned_bug_id FILTER uri %][% END -%] + [%- IF classification %]&classification=[% classification FILTER uri %][% END -%] + [%- IF format %]&format=[% format FILTER uri %][% END %]"> + Other Products</a></h2> + <p style="margin-top: 0px;">Other Mozilla products which aren't listed here</p> + </td> +</tr> +</table> +[% ELSE %] + +<table> + +[% FOREACH c = classifications %] + [% IF c.object %] + <tr> + <td align="right"><h2>[% c.object.name FILTER html %]</h2></td> + <td><strong>[%+ c.object.description FILTER html_light %]</strong></td> + </tr> + [% END %] + + [% FOREACH p = c.products %] + [% class = "" %] + [% has_entry_groups = 0 %] + [% FOREACH gid = p.group_controls.keys %] + [% IF p.group_controls.$gid.entry %] + [% has_entry_groups = 1 %] + [% class = class _ " group_$gid" %] + [% END %] + [% END %] + <tr class="[% "group_secure" IF has_entry_groups +%] [% class FILTER html %]" + [%- IF has_entry_groups %] title="This product requires one or more + group memberships in order to enter [% terms.bugs %] in it. You have them, but be + aware not everyone else does."[% END %]> + <th align="right" valign="top"> + [% IF p.name == "Mozilla PR" AND target == "enter_bug.cgi" AND NOT format AND NOT cgi.param("debug") %] + <a href="[% target FILTER uri %]?product=[% p.name FILTER uri -%] + [%- IF cloned_bug_id %]&cloned_bug_id=[% cloned_bug_id FILTER uri %][% END %]&format=mozpr"> + [% p.name FILTER html FILTER no_break %]</a>: + [% ELSE %] + <a href="[% target FILTER uri %]?product=[% p.name FILTER uri -%] + [%- IF cloned_bug_id %]&cloned_bug_id=[% cloned_bug_id FILTER uri %][% END -%] + [%- IF format %]&format=[% format FILTER uri %][% END %]"> + [% p.name FILTER html FILTER no_break %]</a>: + [% END %] + </th> + <td valign="top">[% p.description FILTER html_light %]</td> + </tr> + [% END %] +[% END %] + +</table> + +<br> +[% IF target == "enter_bug.cgi" AND user.settings.product_chooser.value != 'full_product_chooser' %] +<p>You can choose to get this screen by default when you click "New [% terms.Bug %]" +by changing your <a href="userprefs.cgi?tab=settings">preferences</a>.</p> +[% END %] +[% END %] +<br> + +</div> + +<div id="guided"> + <a id="guided_img" href="enter_bug.cgi?format=guided"><img + src="extensions/BMO/web/images/guided.png" width="16" height="16" border="0" align="absmiddle"></a> + <a id="guided_link" href="enter_bug.cgi?format=guided" + >Switch to the [% terms.Bugzilla %] Helper</a> + | <a href="page.cgi?id=custom_forms.html">Custom [% terms.bug %] entry forms</a> +</div> + +[% PROCESS global/footer.html.tmpl %] + +[%###########################################################################%] +[%# Block for "easy" product sections #%] +[%###########################################################################%] + +[% BLOCK easyproduct %] + [% FOREACH c = classifications %] + [% FOREACH p = c.products %] + [% IF p.name == name %] + <tr> + <td><a href="[% target FILTER uri %]?product=[% p.name FILTER uri %] + [%- IF cloned_bug_id %]&cloned_bug_id=[% cloned_bug_id FILTER uri %][% END -%] + [%- IF format %]&format=[% format FILTER uri %][% END %]"> + <img src="extensions/BMO/web/producticons/[% icon FILTER uri %]" height="64" width="64" border="0"></a></td> + <td><h2 align="left" style="margin-bottom: 0px"><a href="[% target FILTER uri %]?product=[% p.name FILTER uri %] + [%- IF cloned_bug_id %]&cloned_bug_id=[% cloned_bug_id FILTER uri %][% END -%] + [%- IF format %]&format=[% format FILTER uri %][% END %]"> + [% caption || name FILTER html FILTER no_break %]</a>:</h2> + [% IF p.description %] + <p style="margin-top: 0px;">[% p.description FILTER html_light %]</p> + [% END %] + </td> + </tr> + [% LAST %] + [% END %] + [% END %] + [% END %] +[% END %] diff --git a/extensions/BMO/template/en/default/global/prod-comp-search.html.tmpl b/extensions/BMO/template/en/default/global/prod-comp-search.html.tmpl new file mode 100644 index 000000000..2f1d67bec --- /dev/null +++ b/extensions/BMO/template/en/default/global/prod-comp-search.html.tmpl @@ -0,0 +1,43 @@ +[%# 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. + #%] + +<div id="prod_comp_search_main"> + <div id="prod_comp_search_autocomplete"> + <div id="prod_comp_search_label"> + Type to find product and component by name or description: + <img id="prod_comp_throbber" src="extensions/BMO/web/images/throbber.gif" + class="hidden" width="16" height="11"> + </div> + <input id="prod_comp_search" type="text" size="60"> + <div id="prod_comp_search_autocomplete_container"></div> + </div> +</div> +<script type="text/javascript"> + if(typeof(YAHOO.bugzilla.prodCompSearch) !== 'undefined' + && YAHOO.bugzilla.prodCompSearch != null) + { + YAHOO.bugzilla.prodCompSearch.init( + "prod_comp_search", + "prod_comp_search_autocomplete_container", + "[% format FILTER js %]", + "[% cloned_bug_id FILTER js %]"); + [% IF target == "describecomponents.cgi" %] + YAHOO.bugzilla.prodCompSearch.autoComplete.itemSelectEvent.subscribe(function (e, args) { + var oData = args[2]; + var url = "describecomponents.cgi?product=" + encodeURIComponent(oData[0]) + + "&component=" + encodeURIComponent(oData[1]) + + "#" + encodeURIComponent(oData[1]); + var format = YAHOO.bugzilla.prodCompSearch.format; + if (format) { + url += "&format=" + encodeURIComponent(format); + } + window.location.href = url; + }); + [% END %] + } +</script> diff --git a/extensions/BMO/template/en/default/global/redirect.html.tmpl b/extensions/BMO/template/en/default/global/redirect.html.tmpl new file mode 100644 index 000000000..67561d8fa --- /dev/null +++ b/extensions/BMO/template/en/default/global/redirect.html.tmpl @@ -0,0 +1,25 @@ +[%# 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. + #%] + +<!doctype html> +<html> +<head> + <title>Moved</title> + <meta http-equiv="refresh" content="0;URL=[% url FILTER none %]"> + <style> + body { + font-family: sans-serif; + font-size: small; + background: url('extensions/BMO/web/images/background.png') repeat-x; + } + </style> +</head> +<body> + Redirecting to <a href="[% url FILTER none %]">[% url FILTER html %]</a> +</body> +</html> diff --git a/extensions/BMO/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl b/extensions/BMO/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl new file mode 100644 index 000000000..39f063464 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl @@ -0,0 +1,13 @@ +[%# 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 panel.name == "groupsecurity" %] + [% panel.param_descs.delete_comments_group = + 'The name of the group of users who can delete comments by using the "deleted" comment tag.' + %] +[% END -%] diff --git a/extensions/BMO/template/en/default/hook/attachment/createformcontents-mimetypes.html.tmpl b/extensions/BMO/template/en/default/hook/attachment/createformcontents-mimetypes.html.tmpl new file mode 100644 index 000000000..3dc727b87 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/attachment/createformcontents-mimetypes.html.tmpl @@ -0,0 +1,2 @@ +[% mimetypes.push({type => "image/svg+xml", desc => "SVG image"}) %] +[% mimetypes.push({type => "application/vnd.mozilla.xul+xml", desc => "XUL"}) %]
\ No newline at end of file diff --git a/extensions/BMO/template/en/default/hook/attachment/createformcontents-patch_notes.html.tmpl b/extensions/BMO/template/en/default/hook/attachment/createformcontents-patch_notes.html.tmpl new file mode 100644 index 000000000..ea80fdc5e --- /dev/null +++ b/extensions/BMO/template/en/default/hook/attachment/createformcontents-patch_notes.html.tmpl @@ -0,0 +1 @@ +<em>You can <a href="http://developer.mozilla.org/en/docs/Getting_your_patch_in_the_tree">read about the patch submission and approval process</a>.</em><br> diff --git a/extensions/BMO/template/en/default/hook/bug/comments-a_comment-end.html.tmpl b/extensions/BMO/template/en/default/hook/bug/comments-a_comment-end.html.tmpl new file mode 100644 index 000000000..caf7acca7 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/bug/comments-a_comment-end.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 user.id && comment.author.login_name == 'tbplbot@gmail.com' %] + [% has_tbpl_comment = 1 %] + <script> + var id = [% count FILTER none %]; + tbpl_comment_ids.push(id); + collapse_comment( + document.getElementById('comment_link_' + id), + document.getElementById('comment_text_' + id) + ); + </script> +[% END %] diff --git a/extensions/BMO/template/en/default/hook/bug/comments-aftercomments.html.tmpl b/extensions/BMO/template/en/default/hook/bug/comments-aftercomments.html.tmpl new file mode 100644 index 000000000..65bf77967 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/bug/comments-aftercomments.html.tmpl @@ -0,0 +1,69 @@ +[%# This Source Code Form is subject to the terms of the Mozilla Public + # License, v. 2.0. If a copy of the MPL was not distributed with this + # file, You can obtain one at http://mozilla.org/MPL/2.0/. + # + # This Source Code Form is "Incompatible With Secondary Licenses", as + # defined by the Mozilla Public License, v. 2.0. + #%] + +[% IF has_tbpl_comment %] + [% expand_caption = 'Expand TinderboxPushlog Comments' %] + [% collapse_caption = 'Collapse TinderboxPushlog Comments' %] + [% show_caption = 'Show TinderboxPushlog Comments' %] + [% hide_caption = 'Hide TinderboxPushlog Comments' %] + <script> + YAHOO.util.Event.onDOMReady(function () { + var ul = document.getElementsByClassName('bz_collapse_expand_comments'); + if (ul.length == 0) + return; + + var li = document.createElement('li'); + var a = document.createElement('a'); + Dom.setAttribute(a, 'href', 'javascript:void(0)'); + Dom.setAttribute(a, 'id', 'tbpl_toggle_collapse'); + a.innerHTML = '[% expand_caption FILTER js %]'; + YAHOO.util.Event.on(a, 'click', function() { + var a = document.getElementById('tbpl_toggle_collapse'); + var do_expand = a.innerHTML == '[% expand_caption FILTER js %]'; + for (var i = 0, n = tbpl_comment_ids.length; i < n; i++) { + var id = tbpl_comment_ids[i]; + var link = document.getElementById('comment_link_' + id); + var text = document.getElementById('comment_text_' + id); + if (do_expand) { + expand_comment(link, text); + } else { + collapse_comment(link, text); + } + } + a.innerHTML = do_expand + ? '[% collapse_caption FILTER js %]' + : '[% expand_caption FILTER js %]'; + }); + li.appendChild(a); + ul[0].appendChild(li); + + li = document.createElement('li'); + a = document.createElement('a'); + Dom.setAttribute(a, 'href', 'javascript:void(0)'); + Dom.setAttribute(a, 'id', 'tbpl_toggle_visible'); + a.innerHTML = '[% hide_caption FILTER js %]'; + YAHOO.util.Event.on(a, 'click', function() { + var a = document.getElementById('tbpl_toggle_visible'); + var do_show = a.innerHTML == '[% show_caption FILTER js %]'; + for (var i = 0, n = tbpl_comment_ids.length; i < n; i++) { + var id = tbpl_comment_ids[i]; + if (do_show) { + Dom.removeClass('c' + id, 'bz_default_hidden'); + } else { + Dom.addClass('c' + id, 'bz_default_hidden'); + } + } + a.innerHTML = do_show + ? '[% hide_caption FILTER js %]' + : '[% show_caption FILTER js %]'; + }); + li.appendChild(a); + ul[0].appendChild(li); + }); + </script> +[% END %] diff --git a/extensions/BMO/template/en/default/hook/bug/comments-comment_banner.html.tmpl b/extensions/BMO/template/en/default/hook/bug/comments-comment_banner.html.tmpl new file mode 100644 index 000000000..2ae367456 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/bug/comments-comment_banner.html.tmpl @@ -0,0 +1,13 @@ +[%# *** Disclaimer for Legal bugs *** %] +[% IF bug.product == "Legal" %] + <div id="legal_disclaimer"> + The material and information contained herein is Confidential and + subject to Attorney-Client Privilege and Work Product Doctrine. + </div> +[% END %] + +[%# Needed for collapsing TinderboxPushlog comments %] +[% has_tbpl_comment = 0 %] +<script> + var tbpl_comment_ids = new Array(); +</script> diff --git a/extensions/BMO/template/en/default/hook/bug/create/create-after_custom_fields.html.tmpl b/extensions/BMO/template/en/default/hook/bug/create/create-after_custom_fields.html.tmpl new file mode 100644 index 000000000..47d86bd58 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/bug/create/create-after_custom_fields.html.tmpl @@ -0,0 +1,30 @@ +[%# 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. + #%] + +[%# crash-signature handling %] +[% IF show_crash_signature %] + <tbody class="expert_fields"> + <tr> + <th id="field_label_cf_crash_signature" class="field_label"> + <label for="cf_crash_signature"> Crash Signature: </label> + </th> + <td colspan="3"> + <span id="cf_crash_signature_container"> + <span id="cf_crash_signature_nonedit_display"><i>None</i></span> + (<a id="cf_crash_signature_action" href="#">edit</a>) + </span> + <span id="cf_crash_signature_input"> + <textarea id="cf_crash_signature" name="cf_crash_signature" rows="4" cols="60" + >[% cf_crash_signature FILTER html %]</textarea> + </span> + </td> + </tr> + </tbody> +[% END %] + + diff --git a/extensions/BMO/template/en/default/hook/bug/create/create-custom_field.html.tmpl b/extensions/BMO/template/en/default/hook/bug/create/create-custom_field.html.tmpl new file mode 100644 index 000000000..afbb2947c --- /dev/null +++ b/extensions/BMO/template/en/default/hook/bug/create/create-custom_field.html.tmpl @@ -0,0 +1,13 @@ +[%# 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. + #%] + +[%# crash-signature gets custom handling %] +[% IF field.name == 'cf_crash_signature' %] + [% field.hidden = 1 %] + [% show_crash_signature = 1 %] +[% END %] diff --git a/extensions/BMO/template/en/default/hook/bug/create/create-end.html.tmpl b/extensions/BMO/template/en/default/hook/bug/create/create-end.html.tmpl new file mode 100644 index 000000000..a152527ba --- /dev/null +++ b/extensions/BMO/template/en/default/hook/bug/create/create-end.html.tmpl @@ -0,0 +1,33 @@ +[%# 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 product.name == 'Bugzilla' && + (user.in_group('mozilla-corporation') || user.in_group('mozilla-foundation')) %] + +<div id="bug_create_warning"> + <div id="bug_create_warning_image"> + <img src="extensions/BMO/web/images/sign_warning.png" width="32" height="32"> + </div> + <div id="bug_create_warning_text"> + <b>Mozilla employees</b><br> + This is <i>not</i> the place to request configuration, permission, or + account changes to this installation of [% terms.Bugzilla %] (bugzilla.mozilla.org).<br> + This includes, but is not limited to: + <ul> + <li>New or updates to products and components</li> + <li>Changes to the values of existing fields (versions, milestones, etc)</li> + </ul> + Instead, please file such changes under + <a href="enter_bug.cgi?product=bugzilla.mozilla.org;component=Administration"> + <b> + the Administration component in the bugzilla.mozilla.org + </b> + </a> + product. + </div> +</div> diff --git a/extensions/BMO/template/en/default/hook/bug/create/create-form.html.tmpl b/extensions/BMO/template/en/default/hook/bug/create/create-form.html.tmpl new file mode 100644 index 000000000..562ebfbdd --- /dev/null +++ b/extensions/BMO/template/en/default/hook/bug/create/create-form.html.tmpl @@ -0,0 +1,47 @@ + <tr> + <th>Security:</th> + <td colspan="3"> + [% IF user.in_group(product.default_security_group) %] + [% PROCESS group_checkbox + name = product.default_security_group + desc = "Restrict access to this " _ terms.bug _ " to members of " _ + "the \"" _ product.default_security_group_obj.description _ "\" group." + %] + [% ELSE %] + [% PROCESS group_checkbox + name = product.default_security_group + desc = "Many users could be harmed by this security problem: " _ + "it should be kept hidden from the public until it is resolved." + %] + [% END %] + [% IF user.in_group('partner-confidential-visible') %] + [% PROCESS group_checkbox + name = 'partner-confidential' + desc = "Restrict the visibility of this " _ terms.bug _ " to " _ + "the assignee, QA contact, and CC list only." + %] + [% END %] + [% IF user.in_group('mozilla-employee-confidential-visible') + && !user.in_group('mozilla-employee-confidential') %] + [% PROCESS group_checkbox + name = 'mozilla-employee-confidential' + desc = "Restrict the visibility of this " _ terms.bug _ " to " _ + "Mozilla Employees and Contractors only." + %] + [% END %] + <br> + </td> + </tr> + +[% BLOCK group_checkbox %] + <input type="checkbox" name="groups" + value="[% name FILTER none %]" id="group_[% name FILTER html %]" + [% FOREACH group = product.groups_available %] + [% IF group.name == name %] + [% ' checked="checked"' IF default.groups.contains(group.name) OR group.is_default %] + [% LAST %] + [% END %] + [% END %] + > + <label for="group_[% name FILTER html %]">[% desc FILTER html %]</label><br> +[% END %] diff --git a/extensions/BMO/template/en/default/hook/bug/edit-after_importance.html.tmpl b/extensions/BMO/template/en/default/hook/bug/edit-after_importance.html.tmpl new file mode 100644 index 000000000..d7c0d58a8 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/bug/edit-after_importance.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. + #%] + +[%# Display product and component descriptions after their respective fields %] +<script type="text/javascript"> + var Event = YAHOO.util.Event; + var Dom = YAHOO.util.Dom; + Event.onDOMReady(function() { + // Display product description if user requests it + var prod_desc = '[% bug.product_obj.description FILTER html_light FILTER js %]'; + if (prod_desc) { + var field_container = Dom.get('field_container_product'); + var toggle_container = document.createElement('span'); + Dom.setAttribute(toggle_container, 'id', 'toggle_prod_desc'); + toggle_container.appendChild(document.createTextNode(' (')); + var toggle_link = document.createElement('a'); + Dom.setAttribute(toggle_link, 'id', 'toggle_prod_desc_link'); + Dom.setAttribute(toggle_link, 'href', 'javascript:void(0);') + toggle_link.appendChild(document.createTextNode('show info')); + toggle_container.appendChild(toggle_link); + toggle_container.appendChild(document.createTextNode(')')); + field_container.appendChild(toggle_container); + var desc_container = document.createElement('div'); + Dom.setAttribute(desc_container, 'id', 'prod_desc_container'); + Dom.addClass(desc_container, 'bz_default_hidden'); + desc_container.innerHTML = prod_desc; + field_container.appendChild(desc_container); + Event.addListener(toggle_link, 'click', function () { + if (Dom.hasClass('prod_desc_container', 'bz_default_hidden')) { + Dom.get('toggle_prod_desc_link').innerHTML = 'hide info'; + Dom.removeClass('prod_desc_container', 'bz_default_hidden'); + } + else { + Dom.get('toggle_prod_desc_link').innerHTML = 'show info'; + Dom.addClass('prod_desc_container', 'bz_default_hidden'); + } + }); + } + + // Display component description if user requests it + var comp_desc = '[% bug.component_obj.description FILTER html_light FILTER js %]'; + if (comp_desc) { + var field_container = Dom.get('field_container_component'); + var toggle_container = document.createElement('span'); + Dom.setAttribute(toggle_container, 'id', 'toggle_comp_desc'); + toggle_container.appendChild(document.createTextNode(' (')); + var toggle_link = document.createElement('a'); + Dom.setAttribute(toggle_link, 'id', 'toggle_comp_desc_link'); + Dom.setAttribute(toggle_link, 'href', 'javascript:void(0);') + toggle_link.appendChild(document.createTextNode('show info')); + toggle_container.appendChild(toggle_link); + toggle_container.appendChild(document.createTextNode(')')); + field_container.appendChild(toggle_container); + var desc_container = document.createElement('div'); + Dom.setAttribute(desc_container, 'id', 'comp_desc_container'); + Dom.addClass(desc_container, 'bz_default_hidden'); + desc_container.innerHTML = comp_desc; + field_container.appendChild(desc_container); + Event.addListener(toggle_link, 'click', function () { + if (Dom.hasClass('comp_desc_container', 'bz_default_hidden')) { + Dom.get('toggle_comp_desc_link').innerHTML = 'hide info'; + Dom.removeClass('comp_desc_container', 'bz_default_hidden'); + } + else { + Dom.get('toggle_comp_desc_link').innerHTML = 'show info'; + Dom.addClass('comp_desc_container', 'bz_default_hidden'); + } + }); + } + }); +</script> diff --git a/extensions/BMO/template/en/default/hook/bug/edit-before_restrict_visibility.html.tmpl b/extensions/BMO/template/en/default/hook/bug/edit-before_restrict_visibility.html.tmpl new file mode 100644 index 000000000..880ab58f7 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/bug/edit-before_restrict_visibility.html.tmpl @@ -0,0 +1,25 @@ +[%# 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 IF + bug.in_group(bug.product_obj.default_security_group_obj) + || user.in_group(bug.product_obj.default_security_group) + || (user.id != bug.reporter.id && !user.in_group('editbugs')) + %] + +<div class="bz_group_visibility_section"> + <input type="checkbox" name="groups" + value="[% bug.product_obj.default_security_group FILTER none %]" + id="group_[% bug.product_obj.default_security_group_obj.id FILTER html %]" + onchange="if (this.checked) document.getElementById('addselfcc').checked = true" + > + <label for="group_[% bug.product_obj.default_security_group_obj.id FILTER html %]" + title="This [% terms.bug %] is security sensitive and should be hidden from the public until it is resolved"> + Restrict access to this [% terms.bug %] + </label> +</div><br> diff --git a/extensions/BMO/template/en/default/hook/bug/field-help-end.none.tmpl b/extensions/BMO/template/en/default/hook/bug/field-help-end.none.tmpl new file mode 100644 index 000000000..dda75a9c6 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/bug/field-help-end.none.tmpl @@ -0,0 +1,96 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the BMO Extension + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): + # Dave Lawrence <dkl@mozilla.com> + #%] + +[% USE Bugzilla %] +[% IF Bugzilla.request_cache.bmo_fields_page %] + [% + vars.help_html.priority = + "This field describes the importance and order in which $terms.abug + should be fixed compared to other ${terms.bugs}. This field is utilized + by the programmers/engineers to prioritize their work to be done where + P1 is considered the highest and P5 is the lowest." + + vars.help_html.bug_severity = + "This field describes the impact of ${terms.abug}. + <table> + <tr> + <th>blocker</th> + <td>Blocks development and/or testing work</td> + </tr> + <tr> + <th>critical</th> + <td>crashes, loss of data, severe memory leak</td> + </tr> + <tr> + <th>major</th> + <td>major loss of function</td> + </tr> + <tr> + <th>normal</th> + <td>regular issue, some loss of functionality under specific circumstances</td> + </tr> + <tr> + <th>minor</th> + <td>minor loss of function, or other problem where easy + workaround is present</td> + </tr> + <tr> + <th>trivial</th> + <td>cosmetic problem like misspelled words or misaligned + text</td> + </tr> + <tr> + <th>enhancement</th> + <td>Request for enhancement</td> + </table>" + + vars.help_html.rep_platform = + "This is the hardware platform against which the $terms.bug was reported. + Legal platforms include: + <ul> + <li>All (happens on all platforms; cross-platform ${terms.bug})</li> + <li>x86_64</li> + <li>ARM</li> + </ul> + <b>Note:</b> When searching, selecting the option + <em>All</em> does not + select $terms.bugs assigned against any platform. It merely selects + $terms.bugs that are marked as occurring on all platforms, i.e. are + designated <em>All</em>.", + + vars.help_html.op_sys = + "This is the operating system against which the $terms.bug was + reported. Legal operating systems include: + <ul> + <li>All (happens on all operating systems; cross-platform ${terms.bug})</li> + <li>Windows 7</li> + <li>Mac OS X</li> + <li>Linux</li> + </ul> + Sometimes the operating system implies the platform, but not + always. For example, Linux can run on x86_64, ARM, and others.", + + vars.help_html.assigned_to = + "This is the person in charge of resolving the ${terms.bug}. Every time + this field changes, the status changes to + <b>NEW</b> to make it + easy to see which new $terms.bugs have appeared on a person's list.</p>", + %] +[% END %] diff --git a/extensions/BMO/template/en/default/hook/bug/process/header-title.html.tmpl b/extensions/BMO/template/en/default/hook/bug/process/header-title.html.tmpl new file mode 100644 index 000000000..a99b4f9f6 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/bug/process/header-title.html.tmpl @@ -0,0 +1,9 @@ +[%# 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. + #%] + +[% title = title.replace('^' _ terms.Bug _ ' ', '') %] diff --git a/extensions/BMO/template/en/default/hook/bug/show-header-end.html.tmpl b/extensions/BMO/template/en/default/hook/bug/show-header-end.html.tmpl new file mode 100644 index 000000000..5ab189045 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/bug/show-header-end.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. + #%] + +[% style_urls.push('extensions/BMO/web/styles/edit_bug.css') %] +[% javascript_urls.push('extensions/BMO/web/js/edit_bug.js') %] +[% title = "$bug.bug_id – " %] +[% IF bug.alias != '' %] + [% title = title _ "($bug.alias) " %] +[% END %] +[% title = title _ filtered_desc %] +[% javascript = javascript _ + "document.title = document.title.replace(/^" _ terms.Bug _ " /, '');" +%] diff --git a/extensions/BMO/template/en/default/hook/global/field-descs-end.none.tmpl b/extensions/BMO/template/en/default/hook/global/field-descs-end.none.tmpl new file mode 100644 index 000000000..8c543b35d --- /dev/null +++ b/extensions/BMO/template/en/default/hook/global/field-descs-end.none.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. + #%] + +[% IF in_template_var %] + [% vars.field_descs.cc_count = "CC Count" %] + [% vars.field_descs.dupe_count = "Duplicate Count" %] +[% END %] diff --git a/extensions/BMO/template/en/default/hook/global/footer-end.html.tmpl b/extensions/BMO/template/en/default/hook/global/footer-end.html.tmpl new file mode 100644 index 000000000..f14b34acb --- /dev/null +++ b/extensions/BMO/template/en/default/hook/global/footer-end.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. + #%] + +<div id="privacy-policy"> + <a href="https://www.mozilla.org/privacy/websites/" target="_blank">Privacy Policy</a> +</div> diff --git a/extensions/BMO/template/en/default/hook/global/header-additional_header.html.tmpl b/extensions/BMO/template/en/default/hook/global/header-additional_header.html.tmpl new file mode 100644 index 000000000..f1896dccc --- /dev/null +++ b/extensions/BMO/template/en/default/hook/global/header-additional_header.html.tmpl @@ -0,0 +1,54 @@ +[%# + # The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the BMOHeader Bugzilla Extension. + # + # The Initial Developer of the Original Code is Reed Loden. + # Portions created by the Initial Developer are Copyright (C) 2010 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): + # Reed Loden <reed@reedloden.com> + #%] + +<link rel="shortcut icon" href="extensions/BMO/web/images/favicon.ico"> +[% IF bug %] +<link id="shorturl" rev="canonical" href="https://bugzil.la/[% bug.bug_id FILTER uri %]"> +[% END %] + +[%# *** Bug List Navigation *** %] +[% IF bug %] + [% SET my_search = user.recent_search_for(bug) %] + [% IF my_search %] + [% SET last_bug_list = my_search.bug_list %] + [% SET this_bug_idx = lsearch(last_bug_list, bug.id) %] + <link rel="Up" href="buglist.cgi?regetlastlist= + [%- my_search.id FILTER uri %]"> + <link rel="First" href="show_bug.cgi?id= + [%- last_bug_list.first FILTER uri %]&list_id= + [%- my_search.id FILTER uri %]"> + <link rel="Last" href="show_bug.cgi?id= + [%- last_bug_list.last FILTER uri %]&list_id= + [%- my_search.id FILTER uri %]"> + [% IF this_bug_idx > 0 %] + [% prev_bug = this_bug_idx - 1 %] + <link rel="Prev" href="show_bug.cgi?id= + [%- last_bug_list.$prev_bug FILTER uri %]&list_id= + [%- my_search.id FILTER uri %]"> + [% END %] + [% IF this_bug_idx + 1 < last_bug_list.size %] + [% next_bug = this_bug_idx + 1 %] + <link rel="Next" href="show_bug.cgi?id= + [%- last_bug_list.$next_bug FILTER uri %]&list_id= + [%- my_search.id FILTER uri %]"> + [% END %] + [% END %] +[% END %] diff --git a/extensions/BMO/template/en/default/hook/global/header-start.html.tmpl b/extensions/BMO/template/en/default/hook/global/header-start.html.tmpl new file mode 100644 index 000000000..2b2642148 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/global/header-start.html.tmpl @@ -0,0 +1,41 @@ +[% IF !javascript_urls %] + [% javascript_urls = [] %] +[% END %] + +[% IF template.name == 'list/list.html.tmpl' %] + [% javascript_urls.push('extensions/BMO/web/js/sorttable.js') %] +[% END %] + +[% IF !bodyclasses %] + [% bodyclasses = [] %] +[% END %] + +[%# Change the background/border for bugs/attachments in certain bug groups %] +[% IF template.name == 'attachment/edit.html.tmpl' + || template.name == 'attachment/create.html.tmpl' + || template.name == 'attachment/diff-header.html.tmpl' %] + [% style_urls.push("skins/custom/bug_groups.css") %] + + [% IF template.name == 'attachment/edit.html.tmpl' + || template.name == 'attachment/diff-header.html.tmpl' %] + [% IF bodyclasses == 'no_javascript' %] + [% bodyclasses = ['no_javascript'] %] + [% END %] + [% FOREACH group = attachment.bug.groups_in %] + [% bodyclasses.push("bz_group_$group.name") %] + [% END %] + [% END %] + + [% IF template.name == 'attachment/create.html.tmpl' %] + [% FOREACH group = bug.groups_in %] + [% bodyclasses.push("bz_group_$group.name") %] + [% END %] + [% END %] +[% END %] + +[%# BMO - add user context menu %] +[% IF user.id %] + [% yui.push('container', 'menu') %] + [% style_urls.push('js/yui/assets/skins/sam/menu.css') %] + [% javascript_urls.push('extensions/BMO/web/js/edituser_menu.js') %] +[% END %] diff --git a/extensions/BMO/template/en/default/hook/global/messages-messages.html.tmpl b/extensions/BMO/template/en/default/hook/global/messages-messages.html.tmpl new file mode 100644 index 000000000..0c90b97b9 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/global/messages-messages.html.tmpl @@ -0,0 +1,5 @@ +[% IF message_tag == "employee_incident_creation_failed" %] + The [% terms.bug %] was created successfully, but the dependent + Employee Incident [% terms.bug %] creation failed. The error has + been logged and no further action is required at this time. +[% END %] diff --git a/extensions/BMO/template/en/default/hook/global/setting-descs-settings.none.tmpl b/extensions/BMO/template/en/default/hook/global/setting-descs-settings.none.tmpl new file mode 100644 index 000000000..57e20108d --- /dev/null +++ b/extensions/BMO/template/en/default/hook/global/setting-descs-settings.none.tmpl @@ -0,0 +1,14 @@ +[%# 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. + #%] + +[% + setting_descs.product_chooser = "Product chooser to use when entering bugs", + setting_descs.pretty_product_chooser = "Pretty chooser with common products and icons", + setting_descs.full_product_chooser = "Full chooser with all products", + setting_descs.headers_in_body = "Include X-Bugzilla- headers in BugMail body", +%] diff --git a/extensions/BMO/template/en/default/hook/global/user-error-auth_failure_object.html.tmpl b/extensions/BMO/template/en/default/hook/global/user-error-auth_failure_object.html.tmpl new file mode 100644 index 000000000..bf46ed895 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/global/user-error-auth_failure_object.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. + #%] + +[% IF object == 'group_admins' %] + the group administrators report +[% ELSIF object == 'email_queue' %] + the email queue status report +[% ELSIF object == 'product_security' %] + the product security report +[% ELSIF object == 'comments' %] + comments +[% END %] diff --git a/extensions/BMO/template/en/default/hook/global/user-error-error_message.html.tmpl b/extensions/BMO/template/en/default/hook/global/user-error-error_message.html.tmpl new file mode 100644 index 000000000..c7fb31009 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/global/user-error-error_message.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 == 'illegal_change' || error == 'illegal_change_deps' %] + <p> + If you are attempting to confirm an unconfirmed [% terms.bug %] or edit the + fields of a [% terms.bug %], <a href="page.cgi?id=get_permissions.html">find + out how to get the necessary permissions</a>. + </p> +[% END %] + +[% IF error == 'entry_access_denied' && product == 'Legal' %] + <p> + Unfortunately, we need to keep [% terms.bugs %] in the Legal product + restricted to employees, in order to preserve attorney-client privilege and + protect confidentiality. Due to the way [% terms.Bugzilla %] works, this + means we can't let you file such [% terms.abug %] yourself. However, you + can contact Mozilla Legal through either legal-notices@mozilla.com or + trademarks@mozilla.com, as appropriate. + </p> +[% END %] diff --git a/extensions/BMO/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/BMO/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..aaf23fff5 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,40 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the BMO Extension + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): + # Byron Jones <bjones@mozilla.com> + #%] + +[% IF error == "user_activity_missing_username" %] + [% title = "Missing Username" %] + You must provide at least one email address to report on. + +[% ELSIF error == "report_invalid_date" %] + [% title = "Invalid Date" %] + The date '[% date FILTER html %]' is invalid. + +[% ELSIF error == "report_invalid_parameter" %] + [% title = "Invalid Parameter" %] + The value for parameter [% name FILTER html %] is invalid. + +[% ELSIF error == "invalid_object" %] + Invalid [% object FILTER html %]: "[% value FILTER html %]" + +[% ELSIF error == "report_too_many_bugs" %] + [% title = "Too Many Bugs" %] + Too many [% terms.bugs %] matched your selection criteria. + +[% END %] diff --git a/extensions/BMO/template/en/default/hook/global/user-error.html.tmpl/auth_failure/permissions.html.tmpl b/extensions/BMO/template/en/default/hook/global/user-error.html.tmpl/auth_failure/permissions.html.tmpl new file mode 100644 index 000000000..346e02373 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/global/user-error.html.tmpl/auth_failure/permissions.html.tmpl @@ -0,0 +1,29 @@ +<!-- 1.0@bugzilla.org --> +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): Gervase Markham <gerv@gerv.net> + # Reed Loden <reed@reedloden.com> + #%] + +[% IF (group == "canconfirm" OR group == "editbugs") AND !reason %] + <p> + If you are attempting to confirm an unconfirmed [% terms.bug %] or edit the fields of a [% terms.bug %], + <a href="http://www.gerv.net/hacking/before-you-mail-gerv.html#bugzilla-permissions">find + out how to get the necessary permissions</a>. + </p> +[% END %] diff --git a/extensions/BMO/template/en/default/hook/global/variables-end.none.tmpl b/extensions/BMO/template/en/default/hook/global/variables-end.none.tmpl new file mode 100644 index 000000000..89eef6fc4 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/global/variables-end.none.tmpl @@ -0,0 +1,3 @@ +[% + terms.BugzillaTitle = "Bugzilla@Mozilla" +%] diff --git a/extensions/BMO/template/en/default/hook/index-additional_links.html.tmpl b/extensions/BMO/template/en/default/hook/index-additional_links.html.tmpl new file mode 100644 index 000000000..cfc224fcd --- /dev/null +++ b/extensions/BMO/template/en/default/hook/index-additional_links.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. + #%] + +<li> +| +<a href="page.cgi?id=etiquette.html"> + [%- terms.Bugzilla %] Etiquette</a> +</li> +<li> +| +<a href="https://developer.mozilla.org/en/Bug_writing_guidelines"> + [%- terms.Bug %] Writing Guidelines</a> +</li> diff --git a/extensions/BMO/template/en/default/hook/index-intro.html.tmpl b/extensions/BMO/template/en/default/hook/index-intro.html.tmpl new file mode 100644 index 000000000..d81d91491 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/index-intro.html.tmpl @@ -0,0 +1,2 @@ +<a id="get_help" class="bz_common_actions" + href="page.cgi?id=get_help.html"><span>Get Help</span></a>
\ No newline at end of file diff --git a/extensions/BMO/template/en/default/hook/list/list-links.html.tmpl b/extensions/BMO/template/en/default/hook/list/list-links.html.tmpl new file mode 100644 index 000000000..fda2b43a9 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/list/list-links.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. + #%] + +<a href="rest/bug?include_fields=id,summary,status& + [% IF quicksearch ~%] + quicksearch=[% quicksearch FILTER uri %] + [% ELSE %] + [% cgi.canonicalise_query('list_id', 'query_format') FILTER none %]" + [% END %]" + title="Query as a REST API request">REST</a> | diff --git a/extensions/BMO/template/en/default/hook/list/table-before_table.html.tmpl b/extensions/BMO/template/en/default/hook/list/table-before_table.html.tmpl new file mode 100644 index 000000000..35d7cd3f3 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/list/table-before_table.html.tmpl @@ -0,0 +1,9 @@ +[%# 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. + #%] +[% abbrev.product.maxlength = 20 %] +[% abbrev.component.maxlength = 20 %] diff --git a/extensions/BMO/template/en/default/hook/pages/fields-resolution.html.tmpl b/extensions/BMO/template/en/default/hook/pages/fields-resolution.html.tmpl new file mode 100644 index 000000000..4d12ab345 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/pages/fields-resolution.html.tmpl @@ -0,0 +1,13 @@ +<dt> + [% display_value("resolution", "INCOMPLETE") FILTER html %] +</dt> +<dd> + The problem is vaguely described with no steps to reproduce, + or is a support request. The reporter should be directed to the + product's support page for help diagnosing the issue. If there + are only a few comments in the [% terms.bug %], it may be reopened only if + the original reporter provides more info, or confirms someone + else's steps to reproduce. If the [% terms.bug %] is long, when enough info + is provided a new [% terms.bug %] should be filed and the original [% terms.bug %] + marked as a duplicate of it. +</dd> diff --git a/extensions/BMO/template/en/default/hook/reports/menu-end.html.tmpl b/extensions/BMO/template/en/default/hook/reports/menu-end.html.tmpl new file mode 100644 index 000000000..93f04c4fa --- /dev/null +++ b/extensions/BMO/template/en/default/hook/reports/menu-end.html.tmpl @@ -0,0 +1,59 @@ +[%# 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. + #%] + +<h2>Other Reports</h2> + +<ul> + <li> + <strong> + <a href="[% urlbase FILTER none %]page.cgi?id=user_activity.html">User Changes</a> + </strong> - Show changes made by an individual user. + </li> + <li> + <strong> + <a href="[% urlbase FILTER none %]page.cgi?id=triage_reports.html">Triage Report</a> + </strong> - Report on UNCONFIRMED [% terms.bugs %] to assist triage. + </li> + <li> + <strong> + <a href="[% urlbase FILTER none %]page.cgi?id=release_tracking_report.html">Release Tracking Report</a> + </strong> - For triaging release-train flag information. + </li> + [% IF user.in_group('editusers') || user.in_group('infrasec') %] + <li> + <strong> + <a href="[% urlbase FILTER none %]page.cgi?id=group_admins.html">Group Admins</a> + </strong> - Lists the administrators of each group. + </li> + <li> + <strong> + <a href="[% urlbase FILTER none %]page.cgi?id=group_membership.html">Group Membership Report</a> + </strong> - Lists the groups a user is a member of. + </li> + <li> + <strong> + <a href="[% urlbase FILTER none %]page.cgi?id=group_members.html">Group Members Report</a> + </strong> - Lists the users of groups. + </li> + [% END %] + [% IF user.in_group('admin') || user.in_group('infrasec') %] + <li> + <strong> + <a href="[% urlbase FILTER none %]page.cgi?id=product_security_report.html">Product Security Report</a> + </strong> - Show each product's default security group and visibility. + </li> + [% END %] + [% IF user.in_group('admin') || user.in_group('infra') %] + <li> + <strong> + <a href="[% urlbase FILTER none %]page.cgi?id=email_queue.html">Email Queue</a> + </strong> - TheSchwartz queue + </li> + [% END %] +</ul> + diff --git a/extensions/BMO/template/en/default/list/list.microsummary.tmpl b/extensions/BMO/template/en/default/list/list.microsummary.tmpl new file mode 100644 index 000000000..8925db8dd --- /dev/null +++ b/extensions/BMO/template/en/default/list/list.microsummary.tmpl @@ -0,0 +1,29 @@ +[%# 1.0@bugzilla.org %] +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): Ronaldo Maia <rmaia@everythingsolved.com> + #%] + +[% PROCESS global/variables.none.tmpl %] + + +[% IF searchname %] + [% searchname FILTER html %] ([% bugs.size %]) +[% ELSE %] + [% terms.Bug %] List ([% bugs.size %]) +[% END %] diff --git a/extensions/BMO/template/en/default/list/server-push.html.tmpl b/extensions/BMO/template/en/default/list/server-push.html.tmpl new file mode 100644 index 000000000..1c1f3cf36 --- /dev/null +++ b/extensions/BMO/template/en/default/list/server-push.html.tmpl @@ -0,0 +1,52 @@ +[%# 1.0@bugzilla.org %] +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): Myk Melez <myk@mozilla.org> + #%] + +[%# INTERFACE: + # debug: boolean. True if we want the search displayed while we wait. + # query: string. The SQL query which makes the buglist. + #%] + +[% PROCESS global/variables.none.tmpl %] + +<html> + <head> + <title>[% terms.Bugzilla %] is pondering your search</title> + </head> + <body> + <div style="margin-top: 15%; text-align: center;"> + <center><img src="extensions/BMO/web/images/mozchomp.gif" alt="" + width="160" height="87"></center> + <h1>Please wait while your [% terms.bugs %] are retrieved.</h1> + </div> + + [% IF debug %] + <p> + [% FOREACH debugline = debugdata %] + <code>[% debugline FILTER html %]</code><br> + [% END %] + </p> + <p> + <code>[% query FILTER html %]</code> + </p> + [% END %] + + </body> +</html> diff --git a/extensions/BMO/template/en/default/pages/bug-writing.html.tmpl b/extensions/BMO/template/en/default/pages/bug-writing.html.tmpl new file mode 100644 index 000000000..21ed3b040 --- /dev/null +++ b/extensions/BMO/template/en/default/pages/bug-writing.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. + #%] + +[% PROCESS global/redirect.html.tmpl + url = "https://developer.mozilla.org/en/Bug_writing_guidelines" +%] diff --git a/extensions/BMO/template/en/default/pages/custom_forms.html.tmpl b/extensions/BMO/template/en/default/pages/custom_forms.html.tmpl new file mode 100644 index 000000000..d484d730c --- /dev/null +++ b/extensions/BMO/template/en/default/pages/custom_forms.html.tmpl @@ -0,0 +1,40 @@ +[%# 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/header.html.tmpl + title = "Custom Bug Entry Forms" +%] + +[% + visible_forms = {}; + PROCESS bug/create/custom_forms.none.tmpl; + FOREACH product = custom_forms.keys; + product_forms = []; + FOREACH form = custom_forms.$product; + NEXT IF form.group && !user.in_group(form.group); + product_forms.push(form); + END; + NEXT UNLESS product_forms.size; + visible_forms.$product = product_forms; + END; +%] + +<h1>Custom [% terms.Bug %] Entry Forms</h1> + +[% FOREACH product = visible_forms.keys.sort %] + <h3>[% product FILTER html %]</h3> + <ul> + [% FOREACH form = visible_forms.$product.sort("title") %] + <li> + <a href="[% form.link FILTER none %]">[% form.title FILTER html %]</a> + </li> + [% END %] + </ul> +[% END %] + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/pages/email_queue.html.tmpl b/extensions/BMO/template/en/default/pages/email_queue.html.tmpl new file mode 100644 index 000000000..f0c750129 --- /dev/null +++ b/extensions/BMO/template/en/default/pages/email_queue.html.tmpl @@ -0,0 +1,67 @@ +[%# 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 = "Job Queue Status" + style_urls = [ "extensions/BMO/web/styles/reports.css" ] +%] + +[% IF jobs.size %] + + <p><i>[% jobs.size FILTER none %] email(s) in the queue.</i></p> + + <table id="report" class="hover" cellspacing="0" border="0" width="100%"> + <tr id="report-header"> + <th>Insert Time</th> + <th>Run Time</th> + <th>Age</th> + <th>Error Count</th> + <th>Last Error</th> + <th>Error Message</th> + </tr> + [% FOREACH job IN jobs %] + <tr class="report item [% loop.count % 2 == 1 ? "report_row_odd" : "report_row_even" %]"> + <td nowrap>[% time2str("%Y-%m-%d %H:%M:%S %Z", job.insert_time) FILTER html %]</td> + <td nowrap>[% time2str("%Y-%m-%d %H:%M:%S %Z", job.run_time) FILTER html %]</td> + <td nowrap> + [% age = now - job.insert_time %] + [% IF age < 60 %] + [% age FILTER none %]s + [% ELSIF age < 60 * 60 %] + [% age / 60 FILTER format('%.0f') %]m + [% ELSE %] + [% age / (60 * 60) FILTER format('%.0f') %]h + [% END %] + </td> + <td nowrap>[% job.error_count FILTER html %]</td> + <td nowrap> + [% IF job.error_count %] + [% time2str("%Y-%m-%d %H:%M:%S %Z", job.error_time) FILTER html %] + [% ELSE %] + - + [% END %] + </td> + <td> + [% job.error_count ? job.error_message : '-' FILTER html %] + </td> + </tr> + [% IF job.subject %] + <tr class="report item [% loop.count % 2 == 1 ? "report_row_odd" : "report_row_even" %]"> + <td colspan="6"> [% job.subject FILTER html %]</td> + </tr> + [% END %] + [% END %] + </table> + +[% ELSE %] + +<p><i>The email queue is empty.</i></p> + +[% END %] + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/pages/etiquette.html.tmpl b/extensions/BMO/template/en/default/pages/etiquette.html.tmpl new file mode 100644 index 000000000..78cc0bad7 --- /dev/null +++ b/extensions/BMO/template/en/default/pages/etiquette.html.tmpl @@ -0,0 +1,146 @@ +<!-- 1.0@bugzilla.org --> +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): Stefan Seifert <nine@detonation.org> + # Gervase Markham <gerv@gerv.net> + #%] + +[% PROCESS global/header.html.tmpl + title = "Bugzilla Etiquette" + style = "li { margin: 5px } .heading { font-weight: bold }" %] + +<p> + There's a number of <i lang="fr">faux pas</i> you can commit when using + [%+ terms.Bugzilla %]. At the very + least, these will make Mozilla contributors upset at you; if committed enough + times they will cause those contributors to demand the disabling of your + [%+ terms.Bugzilla %] account. So, ignore this advice at your peril. +</p> + +<p> + That said, Mozilla developers are generally a friendly bunch, and will be + friendly towards you as long as you follow these guidelines. +</p> + +<h3>1. Commenting</h3> + +<p> + This is the most important section. +</p> + +<ol> + <li> + <span class="heading">No pointless comments</span>. + Unless you have something constructive and helpful to say, do not add a + comment to a [% terms.bug %]. In [% terms.bugs %] where there is a heated debate going on, you + should be even more + inclined not to add a comment. Unless you have something new to contribute, + then the [% terms.bug %] owner is aware of all the issues, and will make a judgement + as to what to do. If you agree the [% terms.bug %] should be fixed, vote for it. + Additional "I see this too" or "It works for me" comments are unnecessary + unless they are on a different platform or a significantly different build. + Constructive and helpful thoughts unrelated to the topic of the [% terms.bug %] + should go in the appropriate + <a href="http://www.mozilla.org/about/forums/">newsgroup</a>. + </li> + + <li> + <span class="heading">No obligation</span>. + "Open Source" is not the same as "the developers must do my bidding." + Everyone here wants to help, but no one else has any <i>obligation</i> to fix + the [% terms.bugs %] you want fixed. Therefore, you should not act as if you + expect someone to fix a [% terms.bug %] by a particular date or release. + Aggressive or repeated demands will not be received well and will almost + certainly diminish the impact and interest in your suggestions. + </li> + + <li> + <span class="heading">No abusing people</span>. + Constant and intense critique is one of the reasons we build great products. + It's harder to fall into group-think if there is always a healthy amount of + dissent. We want to encourage vibrant debate inside of the Mozilla + community, we want you to disagree with us, and we want you to effectively + argue your case. However, we require that in the process, you attack + <i>things</i>, not <i>people</i>. Examples of things include: interfaces, + algorithms, and schedules. Examples of people include: developers, + designers and users. <b>Attacking a person may result in you being banned + from [% terms.Bugzilla %].</b> + </li> + + <li> + <span class="heading">No private email</span>. + Unless the [% terms.bug %] owner or another respected project contributor has asked you + to email them with specific information, please place all information + relating to [% terms.bugs %] + in the [% terms.bug %] itself. Do not send them by private email; no-one else can read + them if you do that, and they'll probably just get ignored. If a file + is too big for [% terms.Bugzilla %], add a comment giving the file size and contents + and ask what to do. + </li> +</ol> + +<h3>2. Changing Fields</h3> + +<ol> + <li> + <span class="heading">No messing with other people's [% terms.bugs %]</span>. + Unless you are the [% terms.bug %] assignee, or have some say over the use of their + time, never change the Priority or Target Milestone fields. If in doubt, + do not change the fields of [% terms.bugs %] you do not own - add a comment + instead, suggesting the change. + </li> + + <li> + <span class="heading">No whining about decisions</span>. + If a respected project contributor has marked a [% terms.bug %] as INVALID, then it is + invalid. Someone filing another duplicate of it does not change this. Unless + you have further important evidence, do not post a comment arguing that an + INVALID or WONTFIX [% terms.bug %] should be reopened. + </li> + +</ol> + +<h3>3. Applicability</h3> + +<ol> + <li> + Some of these rules may not apply to you. If they do not, you will know + exactly which ones do not, and why they do not apply. If you are not + sure, then they definitely all apply to you. + </li> +</ol> + +<p> + If you see someone not following these rules, the first step is, as an exception + to guideline 1.4, to make them aware of this document by <em>private</em> mail. + Flaming people publically in [% terms.bugs %] violates guidelines 1.1 and 1.3. In the case of + persistent offending you should ping an administrator on Mozilla IRC in channel #bmo and ask them + to look into it. +</p> + +<p> + This entire document can be summed up in one sentence: + do unto others as you would have them do unto you. +</p> + +<p> + Other useful documents: + <a href="page.cgi?id=bug-writing.html">The [% terms.Bug %] Writing Guidelines</a>. +</p> + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/pages/get_help.html.tmpl b/extensions/BMO/template/en/default/pages/get_help.html.tmpl new file mode 100644 index 000000000..70ff0a12b --- /dev/null +++ b/extensions/BMO/template/en/default/pages/get_help.html.tmpl @@ -0,0 +1,42 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): David Miller <justdave@bugzilla.org> + #%] + +[% PROCESS global/variables.none.tmpl %] +[% INCLUDE global/header.html.tmpl title = "Get Help with Mozilla Products" %] + +<div id="steps"> +<h2>Got a problem?</h2> + +<ul> +<li><a href="http://www.mozilla.org/support/">Get help with your mozilla.org product</a></li> +<li><a href="http://hendrix.mozilla.org/">Leave quick feedback</a></li> +<li><a href="http://input.mozilla.com/feedback">Report a broken website</a></li> +<li><a href="enter_bug.cgi">Report a [% terms.bug %]</a> - latest release only + [% IF NOT user.id %] + (you'll need an + <a href="createaccount.cgi">account</a>) + [% END %] +</li> +</ul> +</div> + +<br> + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/pages/get_permissions.html.tmpl b/extensions/BMO/template/en/default/pages/get_permissions.html.tmpl new file mode 100644 index 000000000..b70aa488f --- /dev/null +++ b/extensions/BMO/template/en/default/pages/get_permissions.html.tmpl @@ -0,0 +1,44 @@ +[%# 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/header.html.tmpl + title = "Upgrade Permissions" +%] + +<h3>How to apply for upgraded permissions</h3> + +<p> + If you want <kbd>canconfirm</kbd>, email <a href="mailto:bmo-perms@mozilla.org"> + bmo-perms@mozilla.org</a> the URLs of three good [% terms.bug %] reports you have filed. +</p> + +<p> + If you want <kbd>editbugs</kbd>, email <a href="mailto:bmo-perms@mozilla.org"> + bmo-perms@mozilla.org</a> either: + <ul> + <li> + The URLs of two [% terms.bugs %] to which you have attached patches + or testcases; or + </li> + <li> + The URLs of the relevant comment on three [% terms.bugs %] which you + wanted to change, but couldn't, and so added a comment instead. + </li> + </ul> +</p> + +<p> + <kbd>editbugs</kbd> implies <kbd>canconfirm</kbd>; there's no need to apply for both. +</p> + +<p> + Don't forget to include your [% terms.Bugzilla %] ID if it's not the email address + you are emailing from. +</p> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/pages/group_admins.html.tmpl b/extensions/BMO/template/en/default/pages/group_admins.html.tmpl new file mode 100644 index 000000000..01bb744c4 --- /dev/null +++ b/extensions/BMO/template/en/default/pages/group_admins.html.tmpl @@ -0,0 +1,54 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the BMO Extension + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): + # David Lawrence <dkl@mozilla.com> + #%] + +[% INCLUDE global/header.html.tmpl + title = "Group Admins Report" + style_urls = [ "extensions/BMO/web/styles/reports.css" ] + yui = [ "datasource" ] +%] + +[% IF groups.size > 0 %] + <table border="0" cellspacing="0" id="report" class="hover" width="100%"> + <tr id="report-header"> + <th align="left">Name</th> + <th align="left">Admins</th> + </tr> + + [% FOREACH group = groups %] + [% count = loop.count() %] + <tr class="report_item [% count % 2 == 1 ? "report_row_odd" : "report_row_even" %]"> + <td> + [% group.name FILTER html %] + </td> + <td> + [% FOREACH admin = group.admins %] + [% INCLUDE global/user.html.tmpl who = admin %][% ", " UNLESS loop.last %] + [% END %] + </td> + </tr> + [% END %] + </table> +[% ELSE %] + <p> + No groups found. + </p> +[% END %] + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/pages/group_members.html.tmpl b/extensions/BMO/template/en/default/pages/group_members.html.tmpl new file mode 100644 index 000000000..daf4d5b0d --- /dev/null +++ b/extensions/BMO/template/en/default/pages/group_members.html.tmpl @@ -0,0 +1,97 @@ +[%# 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 = "Group Members Report" + style_urls = [ "extensions/BMO/web/styles/reports.css" ] +%] + +<form method="GET" action="page.cgi"> + <input type="hidden" name="id" value="group_members.html"> + + <table id="parameters"> + <tr> + <th>Group</th> + <td> + <select name="group"> + [% FOREACH group_name = groups %] + <option value="[% group_name FILTER html %]" + [% "selected" IF group_name == group %]> + [% group_name FILTER html %]</option> + [% END %] + </select> + <input type="checkbox" name="include_disabled" id="include_disabled" + value="1" [% "checked" IF include_disabled %]> + <label for="include_disabled"> + Include disabled users + </label> + <input type="submit" value="Generate"> + </td> + </tr> + </table> +</form> + +[% IF group != '' %] + + <p> + Members of the <b>[% group FILTER html %]</b> group: + </p> + + [% IF types.size > 0 %] + <table border="0" cellspacing="0" id="report" class="nohover" width="100%"> + <tr id="report-header"> + <th>Type</th> + <th>Count</th> + <th>Members</th> + <th class="right">Last Seen (days ago)</th> + </tr> + + [% FOREACH type = types %] + [% count = loop.count() %] + <tr class="report_item [% count % 2 == 1 ? "report_row_odd" : "report_row_even" %]"> + <td valign="top"> + [% "via " UNLESS type.name == 'direct' %] + [% type.name FILTER html %] + </td> + <td valign="top" align="right"> + [% type.members.size FILTER html %] + </td> + <td valign="top" width="100%" colspan="2"> + <table cellspacing="0" class="hoverrow"> + [% FOREACH member = type.members %] + <tr> + <td width="100%"> + <a href="editusers.cgi?action=edit&userid=[% member.id FILTER none %]" + target="_blank"> + <span [% 'class="bz_inactive"' UNLESS member.is_enabled %]> + [% member.name FILTER html %] <[% member.email FILTER email FILTER html %]> + </span> + </a> + </td> + <td align="right" nowrap> + [% member.lastseen FILTER html %] + </td> + </tr> + [% END %] + </table> + </td> + </tr> + [% END %] + </table> + + <a href="page.cgi?id=group_members.json&group=[% group FILTER uri %] + [% IF include_disabled %]&include_disabled=1[% END %]">JSON</a> + [% ELSE %] + <p> + <i>This group is empty.</i> + </p> + [% END %] + +[% END %] + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/pages/group_members.json.tmpl b/extensions/BMO/template/en/default/pages/group_members.json.tmpl new file mode 100644 index 000000000..8cbb2a23a --- /dev/null +++ b/extensions/BMO/template/en/default/pages/group_members.json.tmpl @@ -0,0 +1,32 @@ +[%# 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. + #%] + +[ + [% SET count = 0 %] + [% FOREACH type = types %] + [% SET count = count + type.members.size %] + [% END %] + [% SET i = 0 %] + [% FOREACH type = types %] + [% FOREACH member = type.members %] + [% SET i = i + 1 %] + { "login": "[% member.login FILTER email FILTER js %]", + [% IF type.name == "direct" %] + "membership": "direct", + [% ELSE %] + "membership": "indirect", + "group": "[% type.name FILTER js %]", + [% END %] + [% IF include_disabled %] + "disabled": "[% member.is_enabled ? "false" : "true" %]", + [% END %] + "lastseen": "[% member.lastseen FILTER js %]" + }[% "," UNLESS i == count %] + [% END %] + [% END %] +] diff --git a/extensions/BMO/template/en/default/pages/group_membership.html.tmpl b/extensions/BMO/template/en/default/pages/group_membership.html.tmpl new file mode 100644 index 000000000..32484b13f --- /dev/null +++ b/extensions/BMO/template/en/default/pages/group_membership.html.tmpl @@ -0,0 +1,75 @@ +[%# 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/header.html.tmpl + title = "Group Membership Report" + yui = [ 'autocomplete' ] + style_urls = [ "extensions/BMO/web/styles/reports.css" ] + javascript_urls = [ "js/field.js" ] +%] + +<form method="GET" action="page.cgi"> +<input type="hidden" name="id" id="id" value="group_membership.html"> + +<table id="parameters"> +<tr> + <th>User(s):</th> + <td> + [% INCLUDE global/userselect.html.tmpl + id => "who" + name => "who" + value => who.join(', ') + size => 40 + classes => ["bz_userfield"] + multiple => 5 + field_title => "One or more email address (comma delimited)" + %] + </td> + <td> </td> + <td> + <select name="output" + onchange="document.getElementById('id').value = 'group_membership.' + this.value"> + <option value="html" [% 'selected' IF output == 'html' %]>HTML</option> + <option value="txt" [% 'selected' IF output == 'txt' %]>Text</option> + </select> + </td> + <td> + <input type="submit" value="Generate"> + </td> +</tr> +</table> + +</form> + +[% IF users.size %] + + <table border="0" cellspacing="0" id="report" class="hover" width="100%"> + [% FOREACH u = users %] + <tr> + <th colspan="3">[% u.user.identity FILTER html %]</th> + </tr> + [% FOREACH g = u.groups %] + <tr> + <td> </td> + <td>[% g.name FILTER html %]</td> + <td>[% g.desc FILTER html %]</td> + <td> + [% IF g.via == '' %] + direct + [% ELSE %] + <i>[% g.via FILTER html %]</i> + [% END %] + </td> + </tr> + [% END %] + [% END %] + </table> + +[% END %] + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/pages/group_membership.txt.tmpl b/extensions/BMO/template/en/default/pages/group_membership.txt.tmpl new file mode 100644 index 000000000..9958f0877 --- /dev/null +++ b/extensions/BMO/template/en/default/pages/group_membership.txt.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. + #%] + +[% FOREACH u = users %] +[% u.user.login FILTER none%]: + [% FOREACH g = u.groups %] + [% g.name FILTER none %] + [% ',' UNLESS loop.last %] + [% END %] + [% "\n" %] +[% END %] diff --git a/extensions/BMO/template/en/default/pages/product_security_report.html.tmpl b/extensions/BMO/template/en/default/pages/product_security_report.html.tmpl new file mode 100644 index 000000000..f5e1c05c8 --- /dev/null +++ b/extensions/BMO/template/en/default/pages/product_security_report.html.tmpl @@ -0,0 +1,60 @@ +[%# 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 = "Product Security Report" + style_urls = [ "extensions/BMO/web/styles/reports.css" ] +%] + +<table border="0" cellspacing="0" id="report" class="nohover" width="100%"> + <tr id="report-header"> + <th>Product</th> + <th>Default Security Group</th> + <th>Default Group Visibility</th> + <th>mozilla-employee-confidential Enabled</th> + </tr> + + [% count = 0 %] + [% FOREACH product = products %] + [% count = count + 1 %] + <tr class="report_item [% count % 2 == 1 ? "report_row_odd" : "report_row_even" %]"> + <td> + <a href="editproducts.cgi?action=editgroupcontrols&product=[% product.name FILTER uri %]" target="_blank"> + [% product.name FILTER html %] + </a> + </td> + [% IF product.group_problem %] + <td class="problem"> + <span title="[% product.group_problem FILTER html %]"> + [% product.default_security_group FILTER html %] + </span> + </td> + [% ELSE %] + <td> + [% product.default_security_group FILTER html %] + </td> + [% END %] + [% IF product.visibility_problem %] + <td class="problem"> + <span title="[% product.visibility_problem FILTER html %]"> + [% product.group_visibility FILTER html %] + </span> + </td> + [% ELSE %] + <td> + [% product.group_visibility FILTER html %] + </td> + [% END %] + <td> + [% product.moco ? 'Yes' : 'No' FILTER none %] + </td> + </tr> + [% END %] +</table> + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/pages/query_database.html.tmpl b/extensions/BMO/template/en/default/pages/query_database.html.tmpl new file mode 100644 index 000000000..97f5c0a25 --- /dev/null +++ b/extensions/BMO/template/en/default/pages/query_database.html.tmpl @@ -0,0 +1,47 @@ +[%# 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 = "Query Database" + style_urls = [ "extensions/BMO/web/styles/reports.css" ] +%] + +<form method="post" action="page.cgi"> +<input type="hidden" name="id" value="query_database.html"> +<textarea cols="80" rows="10" name="query">[% query FILTER html %]</textarea><br> +<input type="submit" value="Execute"> +</form> + +[% IF executed %] + <hr> + + [% IF sql_error %] + <b>[% sql_error FILTER html %]</b> + [% ELSIF rows.size %] + <table border="0" cellspacing="0" id="report"> + <tr> + [% FOREACH column = columns %] + <th>[% column FILTER html %]</th> + [% END %] + </tr> + [% FOREACH row = rows %] + [% tr_class = loop.count % 2 ? 'report_row_even' : 'report_row_odd' %] + <tr class="[% tr_class FILTER html %]"> + [% FOREACH field = row %] + <td>[% field FILTER html %]</td> + [% END %] + </tr> + [% END %] + </table> + [% ELSE %] + <i>no results</i> + [% END %] + +[% END %] + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/pages/release_tracking_report.html.tmpl b/extensions/BMO/template/en/default/pages/release_tracking_report.html.tmpl new file mode 100644 index 000000000..71228014a --- /dev/null +++ b/extensions/BMO/template/en/default/pages/release_tracking_report.html.tmpl @@ -0,0 +1,103 @@ +[%# 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 = "Release Tracking Report" + style_urls = [ "extensions/BMO/web/styles/reports.css" ] + javascript_urls = [ "extensions/BMO/web/js/release_tracking_report.js" ] +%] + +<noscript> +<h1>JavaScript is required to use this report.</h1> +</noscript> + +<script> +var flags_data = [% flags_json FILTER none %]; +var products_data = [% products_json FILTER none %]; +var fields_data = [% fields_json FILTER none %]; +var default_query = '[% default_query FILTER js %]'; +</script> + +<form action="page.cgi" method="get" onSubmit="return onFormSubmit()"> +<input type="hidden" name="id" value="release_tracking_report.html"> +<input type="hidden" name="q" id="q" value=""> +<table> + +<tr> + <th>Approval:</th> + <td> + Show [% terms.bugs %] where + <select id="flag" onChange="onFlagChange()"> + [% FOREACH flag_name = flag_names %] + <option value="[% flag_name FILTER html %]">[% flag_name FILTER html %]</option> + [% END %] + </select> + + was changed to (and is currently) + <select id="flag_value"> + <option value="?">?</option> + <option value="-">-</option> + <option value="+">+</option> + </select> + + between + <select id="range" onChange="serialiseForm()"> + [% FOREACH range = ranges %] + <option value="[% range.value FILTER html %]"> + [% range.label FILTER html %] + </option> + [% END %] + </select> + </td> +</tr> + +<tr> + <th>Status:</th> + <td> + for the product + <select id="product" onChange="onProductChange()"> + </select> + </td> +</tr> + +<tr> + <td> </td> + <td> + <select id="op" onChange="serialiseForm()"> + <option value="and">All selected tracking fields (AND)</option> + <option value="or">Any selected tracking fields (OR)</option> + </select> + [ + <a href="javascript:void(0)" onClick="selectAllFields()">All</a> | + <a href="javascript:void(0)" onClick="selectNoFields()">None</a> + ] + [ + <a href="javascript:void(0)" onClick="invertFields()">Invert</a> + ] + <br> + <span id="tracking_span"> + </span> + </td> +</tr> + +<tr> + <td> </td> + <td colspan="2"> + <input type="submit" value="Search"> + <input type="submit" value="Reset" onClick="onFormReset(); return false"> + <a href="?" id="bookmark">Bookmarkable Link</a> + </td> +</tr> +</table> +</form> + +<p> + <i>"fixed" in the status field checks for the "verified" status as well as "fixed".</i> +</p> + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/pages/triage_reports.html.tmpl b/extensions/BMO/template/en/default/pages/triage_reports.html.tmpl new file mode 100644 index 000000000..a7f26e86d --- /dev/null +++ b/extensions/BMO/template/en/default/pages/triage_reports.html.tmpl @@ -0,0 +1,199 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the BMO Extension + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): + # Byron Jones <bjones@mozilla.com> + #%] + +[% PROCESS global/variables.none.tmpl %] + +[% js_data = BLOCK %] +var useclassification = false; +var first_load = true; +var last_sel = []; +var cpts = new Array(); +[% n = 1 %] +[% FOREACH p = user.get_selectable_products %] + cpts['[% n FILTER js %]'] = [ + [%- FOREACH c = p.components %]'[% c.name FILTER js %]'[% ", " UNLESS loop.last %] [%- END -%] ]; + [% n = n+1 %] +[% END %] + +var selected_components = [ + [%- FOREACH c = input.component %]'[% c FILTER js %]' + [%- ',' UNLESS loop.last %] [%- END ~%] ]; + +[% END %] + +[% INCLUDE global/header.html.tmpl + title = "Triage Reports" + yui = [ 'autocomplete', 'calendar' ] + javascript = js_data + javascript_urls = [ "js/util.js", "js/field.js", "js/productform.js", + "extensions/BMO/web/js/triage_reports.js" ] + style_urls = [ "skins/standard/buglist.css", + "extensions/BMO/web/styles/triage_reports.css" ] +%] + +<noscript> +<h2>Javascript is required to use this report.</h2> +</noscript> + +[% PROCESS "global/field-descs.none.tmpl" %] + +<form id="activity_form" name="activity_form" action="page.cgi" method="get" + onSubmit="return onGenerateReport()"> +<input type="hidden" name="id" value="triage_reports.html"> +<input type="hidden" name="action" value="run"> + +Show UNCONFIRMED [% terms.bugs %] with: +<table id="triage_form"> + +<tr> + <th>Product:</th> + <td> + <select name="product" id="product" onChange="onSelectProduct()"> + <option value=""></option> + [% FOREACH p = user.get_selectable_products %] + <option value="[% p.name FILTER html %]" + [% " selected" IF input.product == p.name %]> + [% p.name FILTER html %] + </option> + [% END %] + </select> + </td> + <td rowspan="2" valign="top"> + <b>Comment:</b><br> + + <input type="checkbox" name="filter_commenter" id="filter_commenter" value="1" + [% 'checked' IF input.filter_commenter %]> + <label for="filter_commenter">where the last commenter</label> + <select name="commenter" id="commenter" onChange="onCommenterChange()"> + <option value="reporter" [% 'selected' IF input.commenter == 'reporter' %]>is the reporter</option> + <option value="noconfirm" [% 'selected' IF input.commenter == 'noconfirm' %]>does not have canconfirm</option> + <option value="is" [% 'selected' IF input.commenter == 'is' %]>is</option> + </select> + [%+ INCLUDE global/userselect.html.tmpl + id => "commenter_is" + name => "commenter_is" + value => input.commenter_is + size => 20 + emptyok => 0 + classes = input.commenter == "is" ? "" : "hidden" + %] + <br> + + <input type="checkbox" name="filter_last" id="filter_last" value="1" + [% 'checked' IF input.filter_last %]> + <label for="filter_last">where the last comment is older than</label> + <select name="last" id="last" onChange="onLastChange()"> + <option value="30" [% 'selected' IF input.last == '30' %]>30 days</option> + <option value="60" [% 'selected' IF input.last == '60' %]>60 days</option> + <option value="90" [% 'selected' IF input.last == '90' %]>90 days</option> + <option value="365" [% 'selected' IF input.last == '365' %]>one year</option> + <option value="is" [% 'selected' IF input.last == 'is' %]>the date</option> + </select> + <span id="last_is_span" class="[% 'hidden' IF input.last != 'is' %]"> + <input type="text" id="last_is" name="last_is" size="11" maxlength="10" + value="[% input.last_is FILTER html %]" + onChange="updateCalendarLastIs(this)"> + <button type="button" class="calendar_button" id="button_calendar_last_is" + onClick="showCalendar('last_is')"><span>Calendar</span> + </button> + <div id="con_calendar_last_is"></div> + </span> + <br> + </td> +</tr> + +<tr> + <th>Component:</th> + <td> + <select name="component" id="component" multiple size="5"> + </select> + </td> +</tr> + +<tr> + <td> </td> + <td> + <input type="submit" value="Generate Report"> + </td> +</tr> + +</table> + +</form> +<script> + createCalendar('last_is'); +</script> + +[% IF input.action == 'run' %] +<hr> +[% IF bugs.size > 0 %] + <p> + Found [% bugs.size %] [%+ terms.bug %][% 's' IF bugs.size != 1 %]: + </p> + <table border="0" cellspacing="0" id="report" width="100%"> + <tr id="report-header"> + <th>[% terms.Bug %] / Date</th> + <th>Summary</th> + <th>Reporter / Commenter</th> + <th>Comment Date</th> + <th>Last Comment</th> + </tr> + + [% FOREACH bug = bugs %] + [% count = loop.count() %] + <tr class="bz_bugitem [% count % 2 == 1 ? "bz_row_odd" : "bz_row_even" %]"> + <td> + [% bug.id FILTER bug_link(bug.id) FILTER none %]<br> + [% bug.creation_ts.replace(' .*' '') FILTER html FILTER no_break %] + </td> + <td> + [% bug.summary FILTER html %] + </td> + <td> + [% INCLUDE global/user.html.tmpl who = bug.reporter %] + [% IF bug.commenter.id != bug.reporter.id %] + <br>[% INCLUDE global/user.html.tmpl who = bug.commenter %] + [% END %] + </td> + <td> + [% bug.comment_ts FILTER html FILTER no_break %] + </td> + <td> + [% bug.comment FILTER html %] + </td> + </tr> + [% END %] + </table> + + <p> + <a href="buglist.cgi?bug_id= + [%- FOREACH bug = bugs %][% bug.id FILTER uri %],[% END -%] + ">Show as a [% terms.Bug %] List</a> + </p> + +[% ELSE %] + <p> + No [% terms.bugs %] found. + </p> +[% END %] + +[% END %] + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/pages/upgrade-3.6.html.tmpl b/extensions/BMO/template/en/default/pages/upgrade-3.6.html.tmpl new file mode 100644 index 000000000..8fa944ae6 --- /dev/null +++ b/extensions/BMO/template/en/default/pages/upgrade-3.6.html.tmpl @@ -0,0 +1,304 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): David Miller <justdave@bugzilla.org> + # Reed Loden <reed@reedloden.com> + #%] + +[% PROCESS global/variables.none.tmpl %] +[% INCLUDE global/header.html.tmpl + title = "Bugzilla 3.6 Upgrade" +%] +[% USE date %] + +<p><b>Last Updated:</b> [% date.format(template.modtime, "%d-%b-%Y %H:%M %Z") %]</p> + +<p>On Friday, July 9, 2010, at 11:40pm PDT (0640 UTC), bugzilla.mozilla.org was + <a href="show_bug.cgi?id=558044">upgraded</a> to Bugzilla 3.6.1+. Please + <a href="enter_bug.cgi?product=mozilla.org&component=Bugzilla:+Other+b.m.o+Issues&blocked=bmo-regressions">file + any regressions</a> for tracking purposes.</p> + +<h3>Known Issues</h3> + +<p>The following is a list of issues which are known to be broken or incomplete with this upgrade so far.</p> + +<ul> + +<li>The <a href="https://bugzilla.mozilla.org/showdependencytree.cgi?id=577801&hide_resolved=1">stuff filed in Bugzilla</a>.</li> + +</ul> + +<h3>What's New</h3> + +<h4>Custom bugzilla.mozilla.org Changes</h4> + +<ul> + <li>Addition of autocomplete support for all user-related fields (assignee, + QA contact, and CC list) and the keywords field.</li> + <li>New attachment details UI.</li> + <li>New icons for the front page.</li> + <li>Removal of unused "Patches" column from buglist.</li> + <li>Initial support for <a href="http://en.wikipedia.org/wiki/Strict_Transport_Security">Strict-Transport-Security</a> (STS) header.</li> +</ul> + +<h4>General Usability Improvements</h4> + +<p>A <a href="https://wiki.mozilla.org/Bugzilla:CMU_HCI_Research_2008">scientific + usability study</a> was done on [% terms.Bugzilla %] by researchers + from Carnegie-Mellon University. As a result of this study, + <a href="https://bugzilla.mozilla.org/showdependencytree.cgi?id=490786&hide_resolved=0">several + usability issues</a> were prioritized to be fixed, based on specific data + from the study.</p> + +<p>As a result, you will see many small improvements in [% terms.Bugzilla %]'s + usability, such as using Javascript to validate certain forms before + they are submitted, standardizing the words that we use in the user interface, + being clearer about what [% terms.Bugzilla %] needs from the user, + and other changes, all of which are also listed individually in this New + Features section.</p> + +<p>Work continues on improving usability for the next release of + [%+ terms.Bugzilla %], but the results of the research have already + had an impact on this 3.6 release.</p> + +<h4>Improved Quicksearch</h4> + +<p>The "quicksearch" box that appears on the front page of + [%+ terms.Bugzilla %] and in the header/footer of every page + is now simplified and made more powerful. There is a + <kbd>[?]</kbd> link next to the box that will take you to + the simplified <a href="page.cgi?id=quicksearch.html">Quicksearch Help</a>, + which describes every single feature of the system in a simple layout, + including new features such as the ability to use partial field names + when searching.</p> + +<p>Quicksearch should also be much faster than it was before, particularly + on large installations.</p> + +<p>Note that in order to implement the new quicksearch, certain old + and rarely-used features had to be removed: + +<ul> + <li><b>+</b> as a prefix to mean "search additional resolutions", and + <b>+</b> as a prefix to mean "search just the summary". You can + instead use <kbd>summary:</kbd> to explicitly search summaries.</li> + <li>Searching the Severity field if you type something that matches + the first few characters of a severity. You can explicitly search + the Severity field if you want to find [% terms.bugs %] by severity.</li> + <li>Searching the Priority field if you typed something that exactly + matched the name of a priority. You can explicitly search the + Priority field if you want to find [% terms.bugs %] by priority.</li> + <li>Searching the Platform and OS fields if you typed in one of a + certain hard-coded list of strings (like "pc", "windows", etc.). + You can explicitly search these fields, instead, if you want to + find [% terms.bugs %] with a specific Platform or OS set.</li> +</ul> + +<h4>Simple "Browse" Interface</h4> + +<p>There is now a "Browse" link in the header of each [% terms.Bugzilla %] + page that presents a very basic interface that allows users to simply + browse through all open [% terms.bugs %] in particular components.</p> + +<h4>JSON-RPC Interface</h4> + +<p>[% terms.Bugzilla %] now has support for the + <a href="http://json-rpc.org/">JSON-RPC</a> WebServices protocol via + <a href="[% docs_urlbase FILTER html %]api/Bugzilla/WebService/Server/JSONRPC.html">jsonrpc.cgi</a>. + The JSON-RPC interface is experimental in this release--if you want any + fundamental changes in how it works, + <a href="http://www.bugzilla.org/developers/reporting_bugs.html">let us + know</a>, for the next release of [% terms.Bugzilla %].</p> + +<h3>New Features</h3> + +<h4>Enhancements for Users</h4> + +<ul> + <li><b>[% terms.Bug %] Filing:</b> When filing [% terms.abug %], + [%+ terms.Bugzilla %] now visually indicates which fields are + mandatory.</li> + <li><b>[% terms.Bug %] Filing:</b> "Bookmarkable templates" now + support the "alias" and "estimated hours" fields.</li> + + <li><b>[% terms.Bug %] Editing:</b> In previous versions of + [%+ terms.Bugzilla %], if you added a private comment to [% terms.abug %], + then <em>none</em> of the changes that you made at that time were + sent to users who couldn't see the private comment. Now, for users + who can't see private comments, public changes are sent, but the private + comment is excluded from their email notification.</li> + <li><b>[% terms.Bug %] Editing:</b> The controls for groups now + appear to the right of the attachment and time-tracking tables, + when editing [% terms.abug %].</li> + <li><b>[% terms.Bug %] Editing:</b> The "Collapse All Comments" + and "Expand All Comments" links now appear to the right of the + comment list instead of above it.</li> + <li><b>[% terms.Bug %] Editing:</b> The See Also field now supports + URLs for Google Code Issues and the Debian B[% %]ug-Tracking System.</li> + <li><b>[% terms.Bug %] Editing:</b> There have been significant performance + improvements in <kbd>show_bug.cgi</kbd> (the script that displays the + [% terms.bug %]-editing form), particularly for [% terms.bugs %] that + have lots of comments or attachments.</li> + + <li><b>Attachments:</b> The "Details" page of an attachment + now displays itself as uneditable if you can't edit the fields + there.</li> + <li><b>Attachments:</b> We now make sure that there is + a Description specified for an attachment, using JavaScript, before + the form is submitted.</li> + <li><b>Attachments:</b> There is now a link back to the [% terms.bug %] + at the bottom of the "Details" page for an attachment.</li> + <li><b>Attachments:</b> When you click on an "attachment 12345" link + in a comment, if the attachment is a patch, you will now see the + formatted "Diff" view instead of the raw patch.</li> + <li><b>Attachments</b>: For text attachments, we now let the browser + auto-detect the character encoding, instead of forcing the browser to + always assume the attachment is in UTF-8.</li> + + <li><b>Search:</b> You can now display [% terms.bug %] flags as a column + in search results.</li> + <li><b>Search:</b> When viewing search results, you can see which columns are + being sorted on, and which direction the sort is on, as indicated + by arrows next to the column headers.</li> + <li><b>Search:</b> You can now search the Deadline field using relative + dates (like "1d", "2w", etc.).</li> + <li><b>Search:</b> The iCalendar format of search results now includes + a PRIORITY field.</li> + <li><b>Search:</b> It is no longer an error to enter an invalid search + order in a search URL--[% terms.Bugzilla %] will simply warn you that + some of your order options are invalid.</li> + <li><b>Search:</b> When there are no search results, some helpful + links are displayed, offering actions you might want to take.</li> + <li><b>Search:</b> For those who like to make their own + <kbd>buglist.cgi</kbd> URLs (and for people working on customizations), + <kbd>buglist.cgi</kbd> now accepts nearly every valid field in + [%+ terms.Bugzilla %] as a direct URL parameter, like + <kbd>&field=value</kbd>.</li> + + <li><b>Requests:</b> When viewing the "My Requests" page, you can now + see the lists as a normal search result by clicking a link at the + bottom of each table.</li> + <li><b>Requests:</b> When viewing the "My Requests" page, if you are + using Classifications, the Product drop-down will be grouped by + Classification.</li> + + <li>If there are multiple languages available for your + [%+ terms.Bugzilla %], you can now select what language you want + [%+ terms.Bugzilla %] displayed in using links at the top of every + page.</li> + <li>When creating a new account, you will be automatically logged in + after setting your password.</li> + <li>There is no longer a maximum password length for accounts.</li> + <li>In the Dusk skin, it's now easier to see links.</li> + <li>In the Whining system, you can now choose to receive emails even + if there are no [% terms.bugs %] that match your searches.</li> + <li>The arrows in dependency graphs now point the other way, so that + [%+ terms.bugs %] point at their dependencies.</li> + + <li><b>New Charts:</b> You can now convert an existing Saved Search + into a data series for New Charts.</li> + <li><b>New Charts:</b> There is now an interface that allows you to + delete data series.</li> + <li><b>New Charts:</b> When deleting a product, you now have the option + to delete the data series that are associated with that product.</li> +</ul> + +<h4>Enhancements for Administrators and Developers</h4> + +<ul> + <li>Depending on how your workflow is set up, it is now possible to + have both UNCONFIRMED and REOPENED show up as status choices for + a closed [% terms.bug %]. If you only want one or the other to + show up, you should edit your status workflow appropriately + (possibly by removing or disabling the REOPENED status).</li> + <li>You can now "disable" field values so that they don't show + up as choices on [% terms.abug %] unless they are already set as + the value for that [% terms.bug %]. This doesn't work for the + per-product field values (component, target_milestone, and version) + yet, though.</li> + <li>Users are now locked out of their accounts for 30 minutes after + trying five bad passwords in a row during login. Every time a + user is locked out like this, the user in the "maintainer" parameter + will get an email.</li> + <li>The minimum length allowed for a password is now 6 characters.</li> + <li>The <kbd>UNCONFIRMED</kbd> status being enabled in a product + is now unrelated to the voting parameters. Instead, there is a checkbox + to enable the <kbd>UNCONFIRMED</kbd> status in a product.</li> + <li>Information about duplicates is now stored in the database instead + of being stored in the <kbd>data/</kbd> directory. On large installations + this could save several hundred megabytes of disk space.</li> + + <li>When editing a group, you can now specify that members of a group + are allowed to grant others membership in that group itself.</li> + <li>The ability to compress BMP attachments to PNGs is now an Extension. + To enable the feature, remove the file + <kbd>extensions/BmpConvert/disabled</kbd> and then run checksetup.pl.</li> + <li>The default list of values for the Priority field are now clear English + words instead of P1, P2, etc.</li> + <li><kbd>config.cgi</kbd> now returns an ETag header and understands + the If-None-Match header in HTTP requests.</li> + <li>The XML format of <kbd>show_bug.cgi</kbd> now returns more information: + the numeric id of each comment, whether an attachment is a URL, + the modification time of an attachment, the numeric id of a flag, + and the numeric id of a flag's type.</li> +</ul> + +<h4>WebService Changes</h4> + +<ul> + <li>The WebService now returns all dates and times in the UTC timezone. + <kbd>B[% %]ugzilla.time</kbd> now acts as though the [% terms.Bugzilla %] + server were in the UTC timezone, always. If you want to write clients + that are compatible across all [% terms.Bugzilla %] versions, + check the timezone from <kbd>B[% %]ugzilla.timezone</kbd> or + <kbd>B[% %]ugzilla.time</kbd>, and always input times in that timezone + and expect times to be returned in that format.</li> + <li>You can now log in by passing <kbd>Bugzilla_login</kbd> and + <kbd>Bugzilla_password</kbd> as arguments to any WebService function. + See the + <a href="[% docs_urlbase FILTER html %]api/Bugzilla/WebService.html#LOGGING_IN">Bugzilla::WebService</a> + documentation for details.</li> + <li>New Method: + <a href="[% docs_urlbase FILTER html %]api/Bugzilla/WebService/Bug.html#attachments">B[% %]ug.attachments</a> + which allows getting information about attachments.</li> + <li>New Method: + <a href="[% docs_urlbase FILTER html %]api/Bugzilla/WebService/Bug.html#fields">B[% %]ug.fields</a>, + which gets information about all the fields that [% terms.abug %] can have + in [% terms.Bugzilla %], include custom fields and legal values for + all fields. The <kbd>B[% %]ug.legal_values</kbd> method is now deprecated.</li> + <li>In the <kbd>B[% %]ug.add_comment</kbd> method, the "private" parameter + has been renamed to "is_private" (for consistency with other methods). + You can still use "private", though, for backwards-compatibility.</li> + <li>The WebService now has Perl's "taint mode" turned on. This means that + it validates all data passed in before sending it to the database. + Also, all parameter names are validated, and if you pass in a parameter + whose name contains anything other than letters, numbers, or underscores, + that parameter will be ignored. Mostly this just affects + customizers--[% terms.Bugzilla %]'s WebService is not functionally + affected by these changes.</li> + <li>In previous versions of [% terms.Bugzilla %], error messages were + sent word-wrapped to the client, from the WebService. Error messages + are now sent as one unbroken line.</li> +</ul> + +<h3>Last Ten Commits</h3> + +<pre>[% bzr_history.join('') FILTER html %]</pre> + +<br> + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/BMO/template/en/default/pages/user_activity.html.tmpl b/extensions/BMO/template/en/default/pages/user_activity.html.tmpl new file mode 100644 index 000000000..c9b46b2eb --- /dev/null +++ b/extensions/BMO/template/en/default/pages/user_activity.html.tmpl @@ -0,0 +1,230 @@ +[%# 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 who %] +[% who_title = ' (' _ who _ ')' %] +[% ELSE %] +[% who_title = '' %] +[% END %] + +[% INCLUDE global/header.html.tmpl + title = "User Activity Report" _ who_title + yui = [ 'autocomplete', 'calendar' ] + javascript_urls = [ "js/util.js", "js/field.js" ] + style_urls = [ "extensions/BMO/web/styles/reports.css" ] + +%] + +[% PROCESS "global/field-descs.none.tmpl" %] +[% PROCESS bug/time.html.tmpl %] + +<form id="activity_form" name="activity_form" action="page.cgi" method="get"> +<input type="hidden" name="id" value="user_activity.html"> +<input type="hidden" name="action" value="run"> +<table id="parameters"> + +<tr> + <th> + Who: + </th> + <td> + [% INCLUDE global/userselect.html.tmpl + id => "who" + name => "who" + value => who + size => 40 + emptyok => 0 + title => "One or more email address (comma delimited)" + %] + + </td> + <th> + Period: + </th> + <td> + <input type="text" id="from" name="from" size="11" + align="right" value="[% from FILTER html %]" maxlength="10" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" id="button_calendar_from" + onclick="showCalendar('from')"><span>Calendar</span> + </button> + <div id="con_calendar_from"></div> + to + <input type="text" name="to" size="11" id="to" + align="right" value ="[% to FILTER html %]" maxlength="10" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" id="button_calendar_to" + onclick="showCalendar('to')"><span>Calendar</span> + </button> + <div id="con_calendar_to"></div> + </td> + <th> + Group by: + </th> + <td> + <select name="group"> + <option value="when" [% 'selected' IF group == 'when' %]>When</option> + <option value="bug" [% 'selected' IF group == 'bug' %]>[% terms.Bug %]</option> + </select> + </td> + <td> + <input type="submit" id="run" value="Generate Report"> + </td> +</tr> + +</table> +[% IF debug_sql %] + <input type="hidden" name="debug" value="1"> +[% END %] +</form> + +<script type="text/javascript"> + createCalendar('from'); + createCalendar('to'); +</script> + +[% IF action == 'run' %] + +[% IF debug_sql %] + <pre>[% debug_sql FILTER html %]</pre> +[% END %] + +[% IF incomplete_data %] + <p> + There used to be an issue in <a href="http://www.bugzilla.org/">Bugzilla</a> + which caused activity data to be lost if there were a large number of cc's + or dependencies. That has been fixed, but some data was already lost in + your activity table that could not be regenerated. The changes that + could not reliably determine are prefixed by '?'. + </p> +[% END %] + +[% IF operations.size > 0 %] + <br> + <table border="1" cellpadding="4" cellspacing="0" id="report" class="hover"> + <tr id="report-header"> + [% IF who_count > 1 %] + <th>Who</th> + [% END %] + [% IF group == 'when' %] + <th class="sorted">[% INCLUDE group_when_link %]</th> + <th>[% INCLUDE group_bug_link %]</th> + [% ELSE %] + <th class="sorted">[% INCLUDE group_bug_link %]</th> + <th>[% INCLUDE group_when_link %]</th> + [% END %] + <th>What</th> + <th>Removed</th> + <th>Added</th> + </tr> + + [% FOREACH operation = operations %] + [% tr_class = loop.count % 2 ? 'report_row_even' : 'report_row_odd' %] + [% FOREACH change = operation.changes %] + <tr class="[% tr_class FILTER none %]"> + [% IF loop.count == 1 %] + [% IF who_count > 1 %] + <td>[% operation.who FILTER email FILTER html %]</td> + [% END %] + [% IF group == 'when' %] + <td>[% change.when FILTER time FILTER no_break %]</td> + <td>[% operation.bug FILTER bug_link(operation.bug) FILTER none %]</td> + [% ELSE %] + <td>[% operation.bug FILTER bug_link(operation.bug) FILTER none %]</td> + <td>[% change.when FILTER time FILTER no_break %]</td> + [% END %] + [% ELSE %] + [% IF who_count > 1 %] + <td> </td> + [% END %] + <td> </td> + [% IF group == 'when' %] + <td> </td> + [% ELSE %] + <td>[% change.when FILTER time FILTER no_break %]</td> + [% END %] + [% END %] + <td> + [% IF change.attachid %] + <a href="attachment.cgi?id=[% change.attachid FILTER uri %]" + title="[% change.attach.description FILTER html %] + [%- %] - [% change.attach.filename FILTER html %]" + >Attachment #[% change.attachid FILTER html %]</a> + [% END %] + [%IF change.comment.defined && change.fieldname == 'longdesc' %] + [% "Comment $change.comment.count" + FILTER bug_link(operation.bug, comment_num => change.comment.count) + FILTER none %] + [% ELSIF change.comment.defined && change.fieldname == 'comment_tag' %] + [% "Comment $change.comment.count Tagged" + FILTER bug_link(operation.bug, comment_num => change.comment.count) + FILTER none %] + [% ELSE %] + [%+ field_descs.${change.fieldname} FILTER html %] + [% END %] + </td> + [% PROCESS change_column change_type = change.removed %] + [% PROCESS change_column change_type = change.added %] + </tr> + [% END %] + [% END %] + </table> + <p> + <a href="buglist.cgi?bug_id=[% bug_ids.join(',') FILTER uri %]"> + Show as a [% terms.Bug %] List</a> + </p> + +[% ELSE %] + <p> + No changes. + </p> +[% END %] + +[% BLOCK change_column %] + <td> + [% IF change_type.defined %] + [% IF change.fieldname == 'estimated_time' || + change.fieldname == 'remaining_time' || + change.fieldname == 'work_time' %] + [% PROCESS formattimeunit time_unit=change_type %] + [% ELSIF change.fieldname == 'blocked' || + change.fieldname == 'dependson' %] + [% change_type FILTER bug_list_link FILTER none %] + [% ELSIF change.fieldname == 'assigned_to' || + change.fieldname == 'reporter' || + change.fieldname == 'qa_contact' || + change.fieldname == 'cc' || + change.fieldname == 'flagtypes.name' %] + [% display_value(change.fieldname, change_type) FILTER email FILTER html %] + [% ELSE %] + [% display_value(change.fieldname, change_type) FILTER html %] + [% END %] + [% ELSE %] + + [% END %] + </td> +[% END %] +[% END %] + +[% INCLUDE global/footer.html.tmpl %] + +[% BLOCK group_when_link %] + <a href="page.cgi?id=user_activity.html&action=run& + [%~%]who=[% who FILTER uri %]& + [%~%]from=[% from FILTER uri %]& + [%~%]to=[% to FILTER uri %]& + [%~%]group=when">When</a> +[% END %] + +[% BLOCK group_bug_link %] + <a href="page.cgi?id=user_activity.html&action=run& + [%~%]who=[% who FILTER uri %]& + [%~%]from=[% from FILTER uri %]& + [%~%]to=[% to FILTER uri %]& + [%~%]group=bug">[% terms.Bug %]</a> +[% END %] diff --git a/extensions/BMO/template/en/default/search/search-plugin.xml.tmpl b/extensions/BMO/template/en/default/search/search-plugin.xml.tmpl new file mode 100644 index 000000000..0c52c1a58 --- /dev/null +++ b/extensions/BMO/template/en/default/search/search-plugin.xml.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. + #%] + +[% PROCESS global/variables.none.tmpl %] +<?xml version="1.0" encoding="UTF-8"?> +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"> +<ShortName>[% terms.BugzillaTitle %]</ShortName> +<Description>[% terms.BugzillaTitle %] Quick Search</Description> +<InputEncoding>UTF-8</InputEncoding> +<Image height="16" width="16" type="image/vnd.microsoft.icon">https://bugzilla.mozilla.org/extensions/BMO/web/images/favicon.ico</Image> +<Url type="text/html" method="GET" template="[% urlbase FILTER xml %]buglist.cgi?quicksearch={searchTerms}"/> +</OpenSearchDescription> diff --git a/extensions/BMO/web/core.png b/extensions/BMO/web/core.png Binary files differnew file mode 100644 index 000000000..b9c5053f6 --- /dev/null +++ b/extensions/BMO/web/core.png diff --git a/extensions/BMO/web/images/advanced.png b/extensions/BMO/web/images/advanced.png Binary files differnew file mode 100644 index 000000000..71a3fcb78 --- /dev/null +++ b/extensions/BMO/web/images/advanced.png diff --git a/extensions/BMO/web/images/background.png b/extensions/BMO/web/images/background.png Binary files differnew file mode 100644 index 000000000..eb254aab9 --- /dev/null +++ b/extensions/BMO/web/images/background.png diff --git a/extensions/BMO/web/images/bugzilla.png b/extensions/BMO/web/images/bugzilla.png Binary files differnew file mode 100644 index 000000000..4b7c10284 --- /dev/null +++ b/extensions/BMO/web/images/bugzilla.png diff --git a/extensions/BMO/web/images/creative.png b/extensions/BMO/web/images/creative.png Binary files differnew file mode 100644 index 000000000..2aeec79ae --- /dev/null +++ b/extensions/BMO/web/images/creative.png diff --git a/extensions/BMO/web/images/favicon.ico b/extensions/BMO/web/images/favicon.ico Binary files differnew file mode 100644 index 000000000..c14fec40a --- /dev/null +++ b/extensions/BMO/web/images/favicon.ico diff --git a/extensions/BMO/web/images/groups/bugzilla-approvers.png b/extensions/BMO/web/images/groups/bugzilla-approvers.png Binary files differnew file mode 100644 index 000000000..d2414e041 --- /dev/null +++ b/extensions/BMO/web/images/groups/bugzilla-approvers.png diff --git a/extensions/BMO/web/images/groups/calendar-drivers.png b/extensions/BMO/web/images/groups/calendar-drivers.png Binary files differnew file mode 100644 index 000000000..fc2c1d1e5 --- /dev/null +++ b/extensions/BMO/web/images/groups/calendar-drivers.png diff --git a/extensions/BMO/web/images/guided.png b/extensions/BMO/web/images/guided.png Binary files differnew file mode 100644 index 000000000..46ba060f8 --- /dev/null +++ b/extensions/BMO/web/images/guided.png diff --git a/extensions/BMO/web/images/mozchomp.gif b/extensions/BMO/web/images/mozchomp.gif Binary files differnew file mode 100644 index 000000000..ac6549527 --- /dev/null +++ b/extensions/BMO/web/images/mozchomp.gif diff --git a/extensions/BMO/web/images/mozilla-tab.png b/extensions/BMO/web/images/mozilla-tab.png Binary files differnew file mode 100644 index 000000000..417f6a5c6 --- /dev/null +++ b/extensions/BMO/web/images/mozilla-tab.png diff --git a/extensions/BMO/web/images/notice.png b/extensions/BMO/web/images/notice.png Binary files differnew file mode 100644 index 000000000..e436c22ae --- /dev/null +++ b/extensions/BMO/web/images/notice.png diff --git a/extensions/BMO/web/images/presshat.png b/extensions/BMO/web/images/presshat.png Binary files differnew file mode 100644 index 000000000..a61de59e5 --- /dev/null +++ b/extensions/BMO/web/images/presshat.png diff --git a/extensions/BMO/web/images/sign_warning.png b/extensions/BMO/web/images/sign_warning.png Binary files differnew file mode 100644 index 000000000..30963f47d --- /dev/null +++ b/extensions/BMO/web/images/sign_warning.png diff --git a/extensions/BMO/web/images/stop-sign.gif b/extensions/BMO/web/images/stop-sign.gif Binary files differnew file mode 100644 index 000000000..9b420ec6c --- /dev/null +++ b/extensions/BMO/web/images/stop-sign.gif diff --git a/extensions/BMO/web/images/throbber.gif b/extensions/BMO/web/images/throbber.gif Binary files differnew file mode 100644 index 000000000..bc4fa6561 --- /dev/null +++ b/extensions/BMO/web/images/throbber.gif diff --git a/extensions/BMO/web/images/user-engagement.png b/extensions/BMO/web/images/user-engagement.png Binary files differnew file mode 100644 index 000000000..11fdbc000 --- /dev/null +++ b/extensions/BMO/web/images/user-engagement.png diff --git a/extensions/BMO/web/js/edit_bug.js b/extensions/BMO/web/js/edit_bug.js new file mode 100644 index 000000000..41a71935e --- /dev/null +++ b/extensions/BMO/web/js/edit_bug.js @@ -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. */ + +function init_clone_bug_menu(el, bug_id, product, component) { + var diff_url = 'enter_bug.cgi?format=__default__&cloned_bug_id=' + bug_id; + var cur_url = diff_url + + '&product=' + encodeURIComponent(product) + + '&component=' + encodeURIComponent(component); + var menu = new YAHOO.widget.Menu('clone_bug_menu', { position : 'dynamic' }); + menu.addItems([ + { text: 'Clone to the current product', url: cur_url }, + { text: 'Clone to a different product', url: diff_url } + ]); + menu.render(document.body); + YAHOO.util.Event.addListener(el, 'click', show_clone_bug_menu, menu); +} + +function show_clone_bug_menu(event, menu) { + menu.cfg.setProperty('xy', YAHOO.util.Event.getXY(event)); + menu.show(); + event.preventDefault(); +} + +// -- make attachment table, comments, new comment textarea equal widths + +YAHOO.util.Event.onDOMReady(function() { + var comment_tables = Dom.getElementsByClassName('bz_comment_table', 'table', 'comments'); + if (comment_tables.length) { + var comment_width = comment_tables[0].getElementsByTagName('td')[0].clientWidth + 'px'; + var attachment_table = Dom.get('attachment_table'); + if (attachment_table) + attachment_table.style.width = comment_width; + var new_comment = Dom.get('comment'); + if (new_comment) + new_comment.style.width = comment_width; + } +}); diff --git a/extensions/BMO/web/js/edituser_menu.js b/extensions/BMO/web/js/edituser_menu.js new file mode 100644 index 000000000..383418430 --- /dev/null +++ b/extensions/BMO/web/js/edituser_menu.js @@ -0,0 +1,33 @@ +var usermenu_widget; + +YAHOO.util.Event.onDOMReady(function() { + usermenu_widget = new YAHOO.widget.Menu('usermenu_widget', { position : 'dynamic' }); + usermenu_widget.addItems([ + { text: 'Profile', url: '#', target: '_blank' }, + { text: 'Activity', url: '#', target: '_blank' }, + { text: 'Mail', url: '#', target: '_blank' }, + { text: 'Edit', url: '#', target: '_blank' } + ]); + usermenu_widget.render(document.body); +}); + +function show_usermenu(event, id, email, show_edit) { + if (!usermenu_widget) + return true; + if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) + return true; + usermenu_widget.getItem(0).cfg.setProperty('url', + 'user_profile?login=' + encodeURIComponent(email)); + usermenu_widget.getItem(1).cfg.setProperty('url', + 'page.cgi?id=user_activity.html&action=run&from=-14d&who=' + encodeURIComponent(email)); + usermenu_widget.getItem(2).cfg.setProperty('url', 'mailto:' + encodeURIComponent(email)); + if (show_edit) { + usermenu_widget.getItem(3).cfg.setProperty('url', 'editusers.cgi?action=edit&userid=' + id); + } else { + usermenu_widget.removeItem(3); + } + usermenu_widget.cfg.setProperty('xy', YAHOO.util.Event.getXY(event)); + usermenu_widget.show(); + return false; +} + diff --git a/extensions/BMO/web/js/form_validate.js b/extensions/BMO/web/js/form_validate.js new file mode 100644 index 000000000..7e9746a5c --- /dev/null +++ b/extensions/BMO/web/js/form_validate.js @@ -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. */ + +/** + * Form Validation and Interaction + **/ + +//Makes sure that there is an '@' in the address with a '.' +//somewhere after it (and at least one character in between them) +function isValidEmail(email) { + var at_index = email.indexOf("@"); + var last_dot = email.lastIndexOf("."); + return at_index > 0 && last_dot > (at_index + 1); +} + +//Takes a DOM element id and makes sure that it is filled out +function isFilledOut(elem_id) { + var el = document.getElementById(elem_id); + if (!el) { + console.error('Failed to find element: ' + elem_id); + return false; + } + var str = el.value; + return str.length > 0 && str != "noneselected"; +} + +function isChecked(elem_id) { + return document.getElementById(elem_id).checked; +} + +function isOneChecked(form_nodelist) { + for (var i = 0, il = form_nodelist.length; i < il; i++) { + if (form_nodelist[i].checked) + return true; + } + return false; +} diff --git a/extensions/BMO/web/js/release_tracking_report.js b/extensions/BMO/web/js/release_tracking_report.js new file mode 100644 index 000000000..840b57df1 --- /dev/null +++ b/extensions/BMO/web/js/release_tracking_report.js @@ -0,0 +1,203 @@ +/* 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 flagEl; +var productEl; +var trackingEl; +var selectedFields; + +// events + +function onFieldToggle(cbEl, id) { + if (cbEl.checked) { + Dom.removeClass('field_' + id + '_td', 'disabled'); + selectedFields['field_' + id] = id; + } else { + Dom.addClass('field_' + id + '_td', 'disabled'); + selectedFields['field_' + id] = false; + } + Dom.get('field_' + id + '_select').disabled = !cbEl.checked; + serialiseForm(); +} + +function onProductChange() { + var product = productEl.value; + var productData = product == '0' ? getFlagByName(flagEl.value) : getProductById(product); + var html = ''; + selectedFields = new Array(); + + if (productData) { + // update status fields + html = '<table>'; + for(var i = 0, l = productData.fields.length; i < l; i++) { + var field = getFieldById(productData.fields[i]); + selectedFields['field_' + field.id] = false; + html += '<tr>' + + '<td>' + + '<input type="checkbox" id="field_' + field.id + '_cb" ' + + 'onClick="onFieldToggle(this,' + field.id + ')">' + + '</td>' + + '<td class="disabled" id="field_' + field.id + '_td">' + + '<label for="field_' + field.id + '_cb">' + + YAHOO.lang.escapeHTML(field.name) + ':</label>' + + '</td>' + + '<td>' + + '<select disabled id="field_' + field.id + '_select">' + + '<option value="+">fixed</option>' + + '<option value="-">not fixed</option>' + + '</select>' + + '</td>' + + '</tr>'; + } + html += '</table>'; + } + trackingEl.innerHTML = html; + serialiseForm(); +} + +function onFlagChange() { + var flag = flagEl.value; + var flagData = getFlagByName(flag); + productEl.options.length = 0; + + if (flagData) { + // update product select + var currentProduct = productEl.value; + productEl.options[0] = new Option('(Any Product)', '0'); + for(var i = 0, l = flagData.products.length; i < l; i++) { + var product = getProductById(flagData.products[i]); + var n = productEl.length; + productEl.options[n] = new Option(product.name, product.id); + productEl.options[n].selected = product.id == currentProduct; + } + } + onProductChange(); +} + +// form + +function selectAllFields() { + for(var i = 0, l = fields_data.length; i < l; i++) { + var cb = Dom.get('field_' + fields_data[i].id + '_cb'); + cb.checked = true; + onFieldToggle(cb, fields_data[i].id); + } + serialiseForm(); +} + +function selectNoFields() { + for(var i = 0, l = fields_data.length; i < l; i++) { + var cb = Dom.get('field_' + fields_data[i].id + '_cb'); + cb.checked = false; + onFieldToggle(cb, fields_data[i].id); + } + serialiseForm(); +} + +function invertFields() { + for(var i = 0, l = fields_data.length; i < l; i++) { + var el = Dom.get('field_' + fields_data[i].id + '_select'); + if (el.value == '+') { + el.options[1].selected = true; + } else { + el.options[0].selected = true; + } + } + serialiseForm(); +} + +function onFormSubmit() { + serialiseForm(); + return true; +} + +function onFormReset() { + deserialiseForm(''); +} + +function serialiseForm() { + var q = flagEl.value + ':' + + Dom.get('flag_value').value + ':' + + Dom.get('range').value + ':' + + productEl.value + ':' + + Dom.get('op').value + ':'; + + for(var id in selectedFields) { + if (selectedFields[id]) { + q += selectedFields[id] + Dom.get(id + '_select').value + ':'; + } + } + + Dom.get('q').value = q; + Dom.get('bookmark').href = 'page.cgi?id=release_tracking_report.html&q=' + + encodeURIComponent(q); +} + +function deserialiseForm(q) { + var parts = q.split(/:/); + selectValue(flagEl, parts[0]); + onFlagChange(); + selectValue(Dom.get('flag_value'), parts[1]); + selectValue(Dom.get('range'), parts[2]); + selectValue(productEl, parts[3]); + onProductChange(); + selectValue(Dom.get('op'), parts[4]); + for(var i = 5, l = parts.length; i < l; i++) { + var part = parts[i]; + if (part.length) { + var value = part.substr(part.length - 1, 1); + var id = part.substr(0, part.length - 1); + var cb = Dom.get('field_' + id + '_cb'); + cb.checked = true; + onFieldToggle(cb, id); + selectValue(Dom.get('field_' + id + '_select'), value); + } + } + serialiseForm(); +} + +// utils + +YAHOO.util.Event.onDOMReady(function() { + flagEl = Dom.get('flag'); + productEl = Dom.get('product'); + trackingEl = Dom.get('tracking_span'); + onFlagChange(); + deserialiseForm(default_query); +}); + +function getFlagByName(name) { + for(var i = 0, l = flags_data.length; i < l; i++) { + if (flags_data[i].name == name) + return flags_data[i]; + } +} + +function getProductById(id) { + for(var i = 0, l = products_data.length; i < l; i++) { + if (products_data[i].id == id) + return products_data[i]; + } +} + +function getFieldById(id) { + for(var i = 0, l = fields_data.length; i < l; i++) { + if (fields_data[i].id == id) + return fields_data[i]; + } +} + +function selectValue(el, value) { + for(var i = 0, l = el.options.length; i < l; i++) { + if (el.options[i].value == value) { + el.options[i].selected = true; + return; + } + } + el.options[0].selected = true; +} diff --git a/extensions/BMO/web/js/sorttable.js b/extensions/BMO/web/js/sorttable.js new file mode 100644 index 000000000..0873dc20a --- /dev/null +++ b/extensions/BMO/web/js/sorttable.js @@ -0,0 +1,709 @@ +/* + SortTable + version 2 + 7th April 2007 + Stuart Langridge, http://www.kryogenix.org/code/browser/sorttable/ + + Instructions: + Download this file + Add <script src="sorttable.js"></script> to your HTML + Add class="sortable" to any table you'd like to make sortable + Click on the headers to sort + + Thanks to many, many people for contributions and suggestions. + Licenced as X11: http://www.kryogenix.org/code/browser/licence.html + This basically means: do what you want with it. +*/ + +var stIsIE = /*@cc_on!@*/false; + +sorttable = { + init: function() { + // quit if this function has already been called + if (arguments.callee.done) return; + // flag this function so we don't do the same thing twice + arguments.callee.done = true; + // kill the timer + if (_timer) clearInterval(_timer); + + if (!document.createElement || !document.getElementsByTagName) return; + + sorttable.DATE_RE = /^(\d\d?)[\/\.-](\d\d?)[\/\.-]((\d\d)?\d\d)$/; + + forEach(document.getElementsByTagName('table'), function(table) { + if (table.className.search(/\bsortable\b/) != -1) { + sorttable.makeSortable(table); + } + }); + + }, + + /* + * Prepares the table so that it can be sorted + * + */ + makeSortable: function(table) { + + if (table.getElementsByTagName('thead').length == 0) { + // table doesn't have a tHead. Since it should have, create one and + // put the first table row in it. + the = document.createElement('thead'); + the.appendChild(table.rows[0]); + table.insertBefore(the,table.firstChild); + } + // Safari doesn't support table.tHead, sigh + if (table.tHead == null) table.tHead = table.getElementsByTagName('thead')[0]; + + //if (table.tHead.rows.length != 1) return; // can't cope with two header rows + + // Sorttable v1 put rows with a class of "sortbottom" at the bottom (as + // "total" rows, for example). This is B&R, since what you're supposed + // to do is put them in a tfoot. So, if there are sortbottom rows, + // for backwards compatibility, move them to tfoot (creating it if needed). + sortbottomrows = []; + for (var i=0; i<table.rows.length; i++) { + if (table.rows[i].className.search(/\bsortbottom\b/) != -1) { + sortbottomrows[sortbottomrows.length] = table.rows[i]; + } + } + + if (sortbottomrows) { + if (table.tFoot == null) { + // table doesn't have a tfoot. Create one. + tfo = document.createElement('tfoot'); + table.appendChild(tfo); + } + for (var i=0; i<sortbottomrows.length; i++) { + tfo.appendChild(sortbottomrows[i]); + } + delete sortbottomrows; + } + + sorttable._walk_through_headers(table); + }, + + /* + * Helper function for preparing the table + * + */ + _walk_through_headers: function(table) { + // First, gather some information we need to sort the table. + var bodies = []; + var table_rows = []; + var body_size = table.tBodies[0].rows.length; + + // We need to get all the rows + for (var i=0; i<table.tBodies.length; i++) { + if (!table.tBodies[i].className.match(/\bsorttable_body\b/)) + continue; + + bodies[bodies.length] = table.tBodies[i]; + for (j=0; j<table.tBodies[i].rows.length; j++) { + table_rows[table_rows.length] = table.tBodies[i].rows[j]; + } + } + + table.sorttable_rows = table_rows; + table.sorttable_body_size = body_size; + table.sorttable_bodies = bodies; + + + // work through each column and calculate its type + + // For each row in the header.. + for (var row_index=0; row_index < table.tHead.rows.length; row_index++) { + + headrow = table.tHead.rows[row_index].cells; + // ... Walk through each column and calculate the type. + for (var i=0; i<headrow.length; i++) { + // Don't sort this column, please + if (headrow[i].className.match(/\bsorttable_nosort\b/)) continue; + + // Override sort column index. + column_index = i; + mtch = headrow[i].className.match(/\bsortable_column_([a-z0-9]+)\b/); + if (mtch) column_index = mtch[1]; + + + // Manually override the type with a sorttable_type attribute + // Override sort function + mtch = headrow[i].className.match(/\bsorttable_([a-z0-9]+)\b/); + if (mtch) override = mtch[1]; + + if (mtch && typeof sorttable["sort_"+override] == 'function') { + headrow[i].sorttable_sortfunction = sorttable["sort_"+override]; + } else { + headrow[i].sorttable_sortfunction = sorttable.guessType(table, column_index); + } + + // make it clickable to sort + headrow[i].sorttable_columnindex = column_index; + headrow[i].table = table; + + // If the header contains a link, clear the href. + for (var k=0; k<headrow[i].childNodes.length; k++) { + if (headrow[i].childNodes[k].tagName == 'A') { + headrow[i].childNodes[k].href = "javascript:void(0);"; + } + } + + dean_addEvent(headrow[i], "click", sorttable._on_column_header_clicked); + + } // inner for (var i=0; i<headrow.length; i++) + } // outer for + }, + + + + + /* + * Helper function for the _on_column_header_clicked handler + * + */ + + _remove_sorted_classes: function(header) { + // For each row in the header.. + for (var j=0; j< header.rows.length; j++) { + // ... Walk through each column and calculate the type. + row = header.rows[j].cells; + + for (var i=0; i<row.length; i++) { + cell = row[i]; + if (cell.nodeType != 1) return; // an element + + mtch = cell.className.match(/\bsorted_([0-9]+)\b/); + if (mtch) { + cell.className = cell.className.replace('sorted_'+mtch[1], + 'sorted_'+(parseInt(mtch[1])+1)); + } + + cell.className = cell.className.replace('sorttable_sorted_reverse',''); + cell.className = cell.className.replace('sorttable_sorted',''); + } + } + }, + + _check_already_sorted: function(cell) { + if (cell.className.search(/\bsorttable_sorted\b/) != -1) { + // if we're already sorted by this column, just + // reverse the table, which is quicker + sorttable.reverse_table(cell); + + sorttable._mark_column_as_sorted(cell, '▼', 1); + return 1; + } + + if (cell.className.search(/\bsorttable_sorted_reverse\b/) != -1) { + // if we're already sorted by this column in reverse, just + // re-reverse the table, which is quicker + sorttable.reverse_table(cell); + + sorttable._mark_column_as_sorted(cell, '▲', 0); + + return 1; + } + + return 0; + }, + + /* Visualy mark the cell as sorted. + * + * @param cell: the cell being marked + * @param text: the text being used to mark. you can use html + * @param reversed: whether the column is reversed or not. + * + */ + _mark_column_as_sorted: function(cell, text, reversed) { + // remove eventual class + cell.className = cell.className.replace('sorttable_sorted', ''); + cell.className = cell.className.replace('sorttable_sorted_reverse', ''); + + // the column is reversed + if (reversed) { + cell.className += ' sorttable_sorted_reverse'; + } + else { + // remove eventual class + cell.className += ' sorttable_sorted'; + } + + sorttable._remove_sorting_marker(); + + marker = document.createElement('span'); + marker.id = "sorttable_sort_mark"; + marker.className = "bz_sort_order_primary"; + marker.innerHTML = text; + cell.appendChild(marker); + }, + + _remove_sorting_marker: function() { + mark = document.getElementById('sorttable_sort_mark'); + if (mark) { mark.parentNode.removeChild(mark); } + els = sorttable._getElementsByClassName('bz_sort_order_primary'); + for(var i=0,j=els.length; i<j; i++) { + els[i].parentNode.removeChild(els[i]); + } + els = sorttable._getElementsByClassName('bz_sort_order_secondary'); + for(var i=0,j=els.length; i<j; i++) { + els[i].parentNode.removeChild(els[i]); + } + }, + + _getElementsByClassName: function(classname, node) { + if(!node) node = document.getElementsByTagName("body")[0]; + var a = []; + var re = new RegExp('\\b' + classname + '\\b'); + var els = node.getElementsByTagName("*"); + for(var i=0,j=els.length; i<j; i++) + if(re.test(els[i].className))a.push(els[i]); + return a; + }, + + /* + * This is the callback for when the table header is clicked. + * + * @param evt: the event that triggered this callback + */ + _on_column_header_clicked: function(evt) { + + // The table is already sorted by this column. Just reverse it. + if (sorttable._check_already_sorted(this)) + return; + + + // First, remove sorttable_sorted classes from the other header + // that is currently sorted and its marker (the simbol indicating + // that its sorted. + sorttable._remove_sorted_classes(this.table.tHead); + mtch = this.className.match(/\bsorted_([0-9]+)\b/); + if (mtch) { + this.className = this.className.replace('sorted_'+mtch[1], ''); + } + this.className += ' sorted_0 '; + + // This is the text that indicates that the column is sorted. + sorttable._mark_column_as_sorted(this, '▼', 0); + + sorttable.sort_table(this); + + }, + + sort_table: function(cell) { + // build an array to sort. This is a Schwartzian transform thing, + // i.e., we "decorate" each row with the actual sort key, + // sort based on the sort keys, and then put the rows back in order + // which is a lot faster because you only do getInnerText once per row + col = cell.sorttable_columnindex; + rows = cell.table.sorttable_rows; + + var BUGLIST = ''; + + for (var j = 0; j < cell.table.sorttable_rows.length; j++) { + rows[j].sort_data = sorttable.getInnerText(rows[j].cells[col]); + } + + /* If you want a stable sort, uncomment the following line */ + sorttable.shaker_sort(rows, cell.sorttable_sortfunction); + /* and comment out this one */ + //rows.sort(cell.sorttable_sortfunction); + + // Rebuild the table, using he sorted rows. + tb = cell.table.sorttable_bodies[0]; + body_size = cell.table.sorttable_body_size; + body_index = 0; + + for (var j=0; j<rows.length; j++) { + if (j % 2) + rows[j].className = rows[j].className.replace('bz_row_even', + 'bz_row_odd'); + else + rows[j].className = rows[j].className.replace('bz_row_odd', + 'bz_row_even'); + + tb.appendChild(rows[j]); + var bug_id = sorttable.getInnerText(rows[j].cells[0].childNodes[1]); + BUGLIST = BUGLIST ? BUGLIST+':'+bug_id : bug_id; + + if (j % body_size == body_size-1) { + body_index++; + if (body_index < cell.table.sorttable_bodies.length) { + tb = cell.table.sorttable_bodies[body_index]; + } + } + } + + document.cookie = 'BUGLIST='+BUGLIST; + + cell.table.sorttable_rows = rows; + }, + + reverse_table: function(cell) { + oldrows = cell.table.sorttable_rows; + newrows = []; + + for (var i=0; i < oldrows.length; i++) { + newrows[newrows.length] = oldrows[i]; + } + + tb = cell.table.sorttable_bodies[0]; + body_size = cell.table.sorttable_body_size; + body_index = 0; + + var BUGLIST = ''; + + cell.table.sorttable_rows = []; + for (var i = newrows.length-1; i >= 0; i--) { + if (i % 2) + newrows[i].className = newrows[i].className.replace('bz_row_even', + 'bz_row_odd'); + else + newrows[i].className = newrows[i].className.replace('bz_row_odd', + 'bz_row_even'); + + tb.appendChild(newrows[i]); + cell.table.sorttable_rows.push(newrows[i]); + + var bug_id = sorttable.getInnerText(newrows[i].cells[0].childNodes[1]); + BUGLIST = BUGLIST ? BUGLIST+':'+bug_id : bug_id; + + if ((newrows.length-1-i) % body_size == body_size-1) { + body_index++; + if (body_index < cell.table.sorttable_bodies.length) { + tb = cell.table.sorttable_bodies[body_index]; + } + } + + } + + document.cookie = 'BUGLIST='+BUGLIST; + + delete newrows; + }, + + guessType: function(table, column) { + // guess the type of a column based on its first non-blank row + sortfn = sorttable.sort_alpha; + for (var i=0; i<table.sorttable_bodies[0].rows.length; i++) { + text = sorttable.getInnerText(table.sorttable_bodies[0].rows[i].cells[column]); + if (text != '') { + if (text.match(/^-?[£$¤]?[\d,.]+%?$/)) { + return sorttable.sort_numeric; + } + // check for a date: dd/mm/yyyy or dd/mm/yy + // can have / or . or - as separator + // can be mm/dd as well + possdate = text.match(sorttable.DATE_RE) + if (possdate) { + // looks like a date + first = parseInt(possdate[1]); + second = parseInt(possdate[2]); + if (first > 12) { + // definitely dd/mm + return sorttable.sort_ddmm; + } else if (second > 12) { + return sorttable.sort_mmdd; + } else { + // looks like a date, but we can't tell which, so assume + // that it's dd/mm (English imperialism!) and keep looking + sortfn = sorttable.sort_ddmm; + } + } + } + } + return sortfn; + }, + + getInnerText: function(node) { + // gets the text we want to use for sorting for a cell. + // strips leading and trailing whitespace. + // this is *not* a generic getInnerText function; it's special to sorttable. + // for example, you can override the cell text with a customkey attribute. + // it also gets .value for <input> fields. + + hasInputs = (typeof node.getElementsByTagName == 'function') && + node.getElementsByTagName('input').length; + + if (typeof node.getAttribute != 'undefined' && node.getAttribute("sorttable_customkey") != null) { + return node.getAttribute("sorttable_customkey"); + } + else if (typeof node.textContent != 'undefined' && !hasInputs) { + return node.textContent.replace(/^\s+|\s+$/g, ''); + } + else if (typeof node.innerText != 'undefined' && !hasInputs) { + return node.innerText.replace(/^\s+|\s+$/g, ''); + } + else if (typeof node.text != 'undefined' && !hasInputs) { + return node.text.replace(/^\s+|\s+$/g, ''); + } + else { + switch (node.nodeType) { + case 3: + if (node.nodeName.toLowerCase() == 'input') { + return node.value.replace(/^\s+|\s+$/g, ''); + } + case 4: + return node.nodeValue.replace(/^\s+|\s+$/g, ''); + break; + case 1: + case 11: + var innerText = ''; + for (var i = 0; i < node.childNodes.length; i++) { + innerText += sorttable.getInnerText(node.childNodes[i]); + } + return innerText.replace(/^\s+|\s+$/g, ''); + break; + default: + return ''; + } + } + }, + + /* sort functions + each sort function takes two parameters, a and b + you are comparing a.sort_data and b.sort_data */ + sort_numeric: function(a,b) { + aa = parseFloat(a.sort_data.replace(/[^0-9.-]/g,'')); + if (isNaN(aa)) aa = 0; + bb = parseFloat(b.sort_data.replace(/[^0-9.-]/g,'')); + if (isNaN(bb)) bb = 0; + return aa-bb; + }, + + sort_alpha: function(a,b) { + if (a.sort_data.toLowerCase()==b.sort_data.toLowerCase()) return 0; + if (a.sort_data.toLowerCase()<b.sort_data.toLowerCase()) return -1; + return 1; + }, + + sort_ddmm: function(a,b) { + mtch = a.sort_data.match(sorttable.DATE_RE); + y = mtch[3]; m = mtch[2]; d = mtch[1]; + if (m.length == 1) m = '0'+m; + if (d.length == 1) d = '0'+d; + dt1 = y+m+d; + mtch = b.sort_data.match(sorttable.DATE_RE); + y = mtch[3]; m = mtch[2]; d = mtch[1]; + if (m.length == 1) m = '0'+m; + if (d.length == 1) d = '0'+d; + dt2 = y+m+d; + if (dt1==dt2) return 0; + if (dt1<dt2) return -1; + return 1; + }, + + sort_mmdd: function(a,b) { + mtch = a.sort_data.match(sorttable.DATE_RE); + y = mtch[3]; d = mtch[2]; m = mtch[1]; + if (m.length == 1) m = '0'+m; + if (d.length == 1) d = '0'+d; + dt1 = y+m+d; + mtch = b.sort_data.match(sorttable.DATE_RE); + y = mtch[3]; d = mtch[2]; m = mtch[1]; + if (m.length == 1) m = '0'+m; + if (d.length == 1) d = '0'+d; + dt2 = y+m+d; + if (dt1==dt2) return 0; + if (dt1<dt2) return -1; + return 1; + }, + + shaker_sort: function(list, comp_func) { + // A stable sort function to allow multi-level sorting of data + // see: http://en.wikipedia.org/wiki/Cocktail_sort + // thanks to Joseph Nahmias + var b = 0; + var t = list.length - 1; + var swap = true; + + while(swap) { + swap = false; + for(var i = b; i < t; ++i) { + if ( comp_func(list[i], list[i+1]) > 0 ) { + var q = list[i]; list[i] = list[i+1]; list[i+1] = q; + swap = true; + } + } // for + t--; + + if (!swap) break; + + for(var i = t; i > b; --i) { + if ( comp_func(list[i], list[i-1]) < 0 ) { + var q = list[i]; list[i] = list[i-1]; list[i-1] = q; + swap = true; + } + } // for + b++; + + } // while(swap) + } +} + +/* ****************************************************************** + Supporting functions: bundled here to avoid depending on a library + ****************************************************************** */ + +// Dean Edwards/Matthias Miller/John Resig + +/* for Mozilla/Opera9 */ +if (document.addEventListener) { + document.addEventListener("DOMContentLoaded", sorttable.init, false); +} + +/* for Internet Explorer */ +/*@cc_on @*/ +/*@if (@_win32) + // IE doesn't have a way to test if the DOM is loaded + // doing a deferred script load with onReadyStateChange checks is + // problematic, so poll the document until it is scrollable + // http://blogs.atlassian.com/developer/2008/03/when_ie_says_dom_is_ready_but.html + var loadTestTimer = function() { + try { + if (document.readyState != "loaded" && document.readyState != "complete") { + document.documentElement.doScroll("left"); + } + sorttable.init(); // call the onload handler + } catch(error) { + setTimeout(loadTestTimer, 100); + } + }; + loadTestTimer(); +/*@end @*/ + +/* for Safari */ +if (/WebKit/i.test(navigator.userAgent)) { // sniff + var _timer = setInterval(function() { + if (/loaded|complete/.test(document.readyState)) { + sorttable.init(); // call the onload handler + } + }, 10); +} + +/* for other browsers */ +window.onload = sorttable.init; + +// written by Dean Edwards, 2005 +// with input from Tino Zijdel, Matthias Miller, Diego Perini + +// http://dean.edwards.name/weblog/2005/10/add-event/ + +function dean_addEvent(element, type, handler) { + if (element.addEventListener) { + element.addEventListener(type, handler, false); + } else { + // assign each event handler a unique ID + if (!handler.$$guid) handler.$$guid = dean_addEvent.guid++; + // create a hash table of event types for the element + if (!element.events) element.events = {}; + // create a hash table of event handlers for each element/event pair + var handlers = element.events[type]; + if (!handlers) { + handlers = element.events[type] = {}; + // store the existing event handler (if there is one) + if (element["on" + type]) { + handlers[0] = element["on" + type]; + } + } + // store the event handler in the hash table + handlers[handler.$$guid] = handler; + // assign a global event handler to do all the work + element["on" + type] = handleEvent; + } +}; +// a counter used to create unique IDs +dean_addEvent.guid = 1; + +function removeEvent(element, type, handler) { + if (element.removeEventListener) { + element.removeEventListener(type, handler, false); + } else { + // delete the event handler from the hash table + if (element.events && element.events[type]) { + delete element.events[type][handler.$$guid]; + } + } +}; + +function handleEvent(event) { + var returnValue = true; + // grab the event object (IE uses a global event object) + event = event || fixEvent(((this.ownerDocument || this.document || this).parentWindow || window).event); + // get a reference to the hash table of event handlers + var handlers = this.events[event.type]; + // execute each event handler + for (var i in handlers) { + this.$$handleEvent = handlers[i]; + if (this.$$handleEvent(event) === false) { + returnValue = false; + } + } + return returnValue; +}; + +function fixEvent(event) { + // add W3C standard event methods + event.preventDefault = fixEvent.preventDefault; + event.stopPropagation = fixEvent.stopPropagation; + return event; +}; +fixEvent.preventDefault = function() { + this.returnValue = false; +}; +fixEvent.stopPropagation = function() { + this.cancelBubble = true; +} + +// Dean's forEach: http://dean.edwards.name/base/forEach.js +/* + forEach, version 1.0 + Copyright 2006, Dean Edwards + License: http://www.opensource.org/licenses/mit-license.php +*/ + +// array-like enumeration +if (!Array.forEach) { // mozilla already supports this + Array.forEach = function(array, block, context) { + for (var i = 0; i < array.length; i++) { + block.call(context, array[i], i, array); + } + }; +} + +// generic enumeration +Function.prototype.forEach = function(object, block, context) { + for (var key in object) { + if (typeof this.prototype[key] == "undefined") { + block.call(context, object[key], key, object); + } + } +}; + +// character enumeration +String.forEach = function(string, block, context) { + Array.forEach(string.split(""), function(chr, index) { + block.call(context, chr, index, string); + }); +}; + +// globally resolve forEach enumeration +var forEach = function(object, block, context) { + if (object) { + var resolve = Object; // default + if (object instanceof Function) { + // functions have a "length" property + resolve = Function; + } else if (object.forEach instanceof Function) { + // the object implements a custom forEach method so use that + object.forEach(block, context); + return; + } else if (typeof object == "string") { + // the object is a string + resolve = String; + } else if (typeof object.length == "number") { + // the object is array-like + resolve = Array; + } + resolve.forEach(object, block, context); + } +}; + diff --git a/extensions/BMO/web/js/swag.js b/extensions/BMO/web/js/swag.js new file mode 100644 index 000000000..cd9561b54 --- /dev/null +++ b/extensions/BMO/web/js/swag.js @@ -0,0 +1,60 @@ +/** + * Swag Request Form Functions + * Form Interal Swag Request Form + * dtran + * 7/6/09 + **/ + + +function evalToNumber(numberString) { + if(numberString=='') return 0; + return parseInt(numberString); +} + +function evalToNumberString(numberString) { + if(numberString=='') return '0'; + return numberString; +} +//item_array should be an array of DOM element ids +function getTotal(item_array) { + var total = 0; + for(var i in item_array) { + total += evalToNumber(document.getElementById(item_array[i]).value); + } + return total; +} + +function calculateTotalSwag() { + document.getElementById('Totalswag').value = + getTotal( new Array('Lanyards', + 'Stickers', + 'Bracelets', + 'Tattoos', + 'Buttons', + 'Posters')); + +} + + +function calculateTotalMensShirts() { + document.getElementById('mens_total').value = + getTotal( new Array('mens_s', + 'mens_m', + 'mens_l', + 'mens_xl', + 'mens_xxl', + 'mens_xxxl')); + +} + + +function calculateTotalWomensShirts() { + document.getElementById('womens_total').value = + getTotal( new Array('womens_s', + 'womens_m', + 'womens_l', + 'womens_xl', + 'womens_xxl', + 'womens_xxxl')); + +} diff --git a/extensions/BMO/web/js/triage_reports.js b/extensions/BMO/web/js/triage_reports.js new file mode 100644 index 000000000..855b577d7 --- /dev/null +++ b/extensions/BMO/web/js/triage_reports.js @@ -0,0 +1,83 @@ +var Dom = YAHOO.util.Dom; + +function onSelectProduct() { + var component = Dom.get('component'); + if (Dom.get('product').value == '') { + bz_clearOptions(component); + return; + } + selectProduct(Dom.get('product'), component); + // selectProduct only supports __Any__ on both elements + // we only want it on component, so add it back in + try { + component.add(new Option('__Any__', ''), component.options[0]); + } catch(e) { + // support IE + component.add(new Option('__Any__', ''), 0); + } + component.value = ''; +} + +function onCommenterChange() { + var commenter_is = Dom.get('commenter_is'); + if (Dom.get('commenter').value == 'is') { + Dom.removeClass(commenter_is, 'hidden'); + } else { + Dom.addClass(commenter_is, 'hidden'); + } +} + +function onLastChange() { + var last_is_span = Dom.get('last_is_span'); + if (Dom.get('last').value == 'is') { + Dom.removeClass(last_is_span, 'hidden'); + } else { + Dom.addClass(last_is_span, 'hidden'); + } +} + +function onGenerateReport() { + if (Dom.get('product').value == '') { + alert('You must select a product.'); + return false; + } + if (Dom.get('component').value == '' && !Dom.get('component').options[0].selected) { + alert('You must select at least one component.'); + return false; + } + if (!(Dom.get('filter_commenter').checked || Dom.get('filter_last').checked)) { + alert('You must select at least one comment filter.'); + return false; + } + if (Dom.get('filter_commenter').checked + && Dom.get('commenter').value == 'is' + && Dom.get('commenter_is').value == '') + { + alert('You must specify the last commenter\'s email address.'); + return false; + } + if (Dom.get('filter_last').checked + && Dom.get('last').value == 'is' + && Dom.get('last_is').value == '') + { + alert('You must specify the "comment is older than" date.'); + return false; + } + return true; +} + +YAHOO.util.Event.onDOMReady(function() { + onSelectProduct(); + onCommenterChange(); + onLastChange(); + + var component = Dom.get('component'); + if (selected_components.length == 0) + return; + component.options[0].selected = false; + for (var i = 0, n = selected_components.length; i < n; i++) { + var index = bz_optionIndex(component, selected_components[i]); + if (index != -1) + component.options[index].selected = true; + } +}); diff --git a/extensions/BMO/web/js/webtrends.js b/extensions/BMO/web/js/webtrends.js new file mode 100644 index 000000000..fd0aca29e --- /dev/null +++ b/extensions/BMO/web/js/webtrends.js @@ -0,0 +1,213 @@ +function WebTrends(options){var that=this;this.dcsid="dcsis0ifv10000gg3ag82u4rf_7b1e";this.rate=100;this.fpcdom=".mozilla.org";this.trackevents=false;if(typeof(options)!="undefined") +{if(typeof(options.dcsid)!="undefined")this.dcsid=options.dcsid;if(typeof(options.rate)!="undefined")this.rate=options.rate;if(typeof(options.fpcdom)!="undefined")this.fpcdom=options.fpcdom;if(typeof(this.fpcdom)!="undefined"&&this.fpcdom.substring(0,1)!='.')this.fpcdom='.'+this.fpcdom;if(typeof(options.trackevents)!="undefined")this.trackevents=options.trackevents;} +this.domain="statse.webtrendslive.com";this.timezone=0;this.onsitedoms="";this.downloadtypes="xls,doc,pdf,txt,csv,zip,dmg,exe";this.navigationtag="div,table";this.enabled=true;this.i18n=false;this.fpc="WT_FPC";this.paidsearchparams="gclid";this.splitvalue="";this.preserve=true;this.DCSdir={};this.DCS={};this.WT={};this.DCSext={};this.images=[];this.index=0;this.exre=(function() +{return(window.RegExp?new RegExp("dcs(uri)|(ref)|(aut)|(met)|(sta)|(sip)|(pro)|(byt)|(dat)|(p3p)|(cfg)|(redirect)|(cip)","i"):"");})();this.re=(function() +{return(window.RegExp?(that.i18n?{"%25":/\%/g,"%26":/\&/g}:{"%09":/\t/g,"%20":/ /g,"%23":/\#/g,"%26":/\&/g,"%2B":/\+/g,"%3F":/\?/g,"%5C":/\\/g,"%22":/\"/g,"%7F":/\x7F/g,"%A0":/\xA0/g}):"");})();} +WebTrends.prototype.dcsGetId=function(){if(this.enabled&&(document.cookie.indexOf(this.fpc+"=")==-1)&&(document.cookie.indexOf("WTLOPTOUT=")==-1)){document.write("<scr"+"ipt type='text/javascript' src='"+"http"+(window.location.protocol.indexOf('https:')==0?'s':'')+"://"+this.domain+"/"+this.dcsid+"/wtid.js"+"'><\/scr"+"ipt>");}} +WebTrends.prototype.dcsGetCookie=function(name) +{var cookies=document.cookie.split("; ");var cmatch=[];var idx=0;var i=0;var namelen=name.length;var clen=cookies.length;for(i=0;i<clen;i++) +{var c=cookies[i];if((c.substring(0,namelen+1))==(name+"=")){cmatch[idx++]=c;}} +var cmatchCount=cmatch.length;if(cmatchCount>0) +{idx=0;if((cmatchCount>1)&&(name==this.fpc)) +{var dLatest=new Date(0);for(i=0;i<cmatchCount;i++) +{var lv=parseInt(this.dcsGetCrumb(cmatch[i],"lv"));var dLst=new Date(lv);if(dLst>dLatest) +{dLatest.setTime(dLst.getTime());idx=i;}}} +return unescape(cmatch[idx].substring(namelen+1));} +else +{return null;}} +WebTrends.prototype.dcsGetCrumb=function(cval,crumb,sep){var aCookie=cval.split(sep||":");for(var i=0;i<aCookie.length;i++){var aCrumb=aCookie[i].split("=");if(crumb==aCrumb[0]){return aCrumb[1];}} +return null;} +WebTrends.prototype.dcsGetIdCrumb=function(cval,crumb){var id=cval.substring(0,cval.indexOf(":lv="));var aCrumb=id.split("=");for(var i=0;i<aCrumb.length;i++){if(crumb==aCrumb[0]){return aCrumb[1];}} +return null;} +WebTrends.prototype.dcsIsFpcSet=function(name,id,lv,ss){var c=this.dcsGetCookie(name);if(c){return((id==this.dcsGetIdCrumb(c,"id"))&&(lv==this.dcsGetCrumb(c,"lv"))&&(ss==this.dcsGetCrumb(c,"ss")))?0:3;} +return 2;} +WebTrends.prototype.dcsFPC=function(){if(document.cookie.indexOf("WTLOPTOUT=")!=-1){return;} +var WT=this.WT;var name=this.fpc;var dCur=new Date();var adj=(dCur.getTimezoneOffset()*60000)+(this.timezone*3600000);dCur.setTime(dCur.getTime()+adj);var dExp=new Date(dCur.getTime()+315360000000);var dSes=new Date(dCur.getTime());WT.co_f=WT.vtid=WT.vtvs=WT.vt_f=WT.vt_f_a=WT.vt_f_s=WT.vt_f_d=WT.vt_f_tlh=WT.vt_f_tlv="";if(document.cookie.indexOf(name+"=")==-1){if((typeof(gWtId)!="undefined")&&(gWtId!="")){WT.co_f=gWtId;} +else if((typeof(gTempWtId)!="undefined")&&(gTempWtId!="")){WT.co_f=gTempWtId;WT.vt_f="1";} +else{WT.co_f="2";var curt=dCur.getTime().toString();for(var i=2;i<=(32-curt.length);i++){WT.co_f+=Math.floor(Math.random()*16.0).toString(16);} +WT.co_f+=curt;WT.vt_f="1";} +if(typeof(gWtAccountRollup)=="undefined"){WT.vt_f_a="1";} +WT.vt_f_s=WT.vt_f_d="1";WT.vt_f_tlh=WT.vt_f_tlv="0";} +else{var c=this.dcsGetCookie(name);var id=this.dcsGetIdCrumb(c,"id");var lv=parseInt(this.dcsGetCrumb(c,"lv"));var ss=parseInt(this.dcsGetCrumb(c,"ss"));if((id==null)||(id=="null")||isNaN(lv)||isNaN(ss)){return;} +WT.co_f=id;var dLst=new Date(lv);WT.vt_f_tlh=Math.floor((dLst.getTime()-adj)/1000);dSes.setTime(ss);if((dCur.getTime()>(dLst.getTime()+1800000))||(dCur.getTime()>(dSes.getTime()+28800000))){WT.vt_f_tlv=Math.floor((dSes.getTime()-adj)/1000);dSes.setTime(dCur.getTime());WT.vt_f_s="1";} +if((dCur.getDay()!=dLst.getDay())||(dCur.getMonth()!=dLst.getMonth())||(dCur.getYear()!=dLst.getYear())){WT.vt_f_d="1";}} +WT.co_f=escape(WT.co_f);WT.vtid=(typeof(this.vtid)=="undefined")?WT.co_f:(this.vtid||"");WT.vtvs=(dSes.getTime()-adj).toString();var expiry="; expires="+dExp.toGMTString();var cur=dCur.getTime().toString();var ses=dSes.getTime().toString();document.cookie=name+"="+"id="+WT.co_f+":lv="+cur+":ss="+ses+expiry+"; path=/"+(((this.fpcdom!=""))?("; domain="+this.fpcdom):(""));var rc=this.dcsIsFpcSet(name,WT.co_f,cur,ses);if(rc!=0){WT.co_f=WT.vtvs=WT.vt_f_s=WT.vt_f_d=WT.vt_f_tlh=WT.vt_f_tlv="";if(typeof(this.vtid)=="undefined"){WT.vtid="";} +WT.vt_f=WT.vt_f_a=rc;}} +WebTrends.prototype.dcsIsOnsite=function(host){if(host.length>0){host=host.toLowerCase();if(host==window.location.hostname.toLowerCase()){return true;} +if(typeof(this.onsitedoms.test)=="function"){return this.onsitedoms.test(host);} +else if(this.onsitedoms.length>0){var doms=this.dcsSplit(this.onsitedoms);var len=doms.length;for(var i=0;i<len;i++){if(host==doms[i]){return true;}}}} +return false;} +WebTrends.prototype.dcsTypeMatch=function(pth,typelist){var type=pth.toLowerCase().substring(pth.lastIndexOf(".")+1,pth.length);var types=this.dcsSplit(typelist);var tlen=types.length;for(var i=0;i<tlen;i++){if(type==types[i]){return true;}} +return false;} +WebTrends.prototype.dcsEvt=function(evt,tag){var e=evt.target||evt.srcElement;while(e.tagName&&(e.tagName.toLowerCase()!=tag.toLowerCase())){e=e.parentElement||e.parentNode;} +return e;} +WebTrends.prototype.dcsNavigation=function(evt){var id="";var cname="";var elems=this.dcsSplit(this.navigationtag);var elen=elems.length;var i,e,elem;for(i=0;i<elen;i++) +{elem=elems[i];if(elem.length) +{e=this.dcsEvt(evt,elem);id=(e.getAttribute&&e.getAttribute("id"))?e.getAttribute("id"):"";cname=e.className||"";if(id.length||cname.length){break;}}} +return id.length?id:cname;} +WebTrends.prototype.dcsBind=function(event,func){if((typeof(func)=="function")&&document.body){if(document.body.addEventListener){document.body.addEventListener(event,func.wtbind(this),true);} +else if(document.body.attachEvent){document.body.attachEvent("on"+event,func.wtbind(this));}}} +WebTrends.prototype.dcsET=function(){var e=(navigator.appVersion.indexOf("MSIE")!=-1)?"click":"mousedown";this.dcsBind(e,this.dcsDownload);this.dcsBind("contextmenu",this.dcsRightClick);this.dcsBind(e,this.dcsLinkTrack);} +WebTrends.prototype.dcsMultiTrack=function(){var args=dcsMultiTrack.arguments?dcsMultiTrack.arguments:arguments;if(args.length%2==0){this.dcsSaveProps(args);this.dcsSetProps(args);var dCurrent=new Date();this.DCS.dcsdat=dCurrent.getTime();this.dcsFPC();this.dcsTag();this.dcsRestoreProps();}} +WebTrends.prototype.dcsLinkTrack=function(evt) +{evt=evt||(window.event||"");if(evt&&((typeof(evt.which)!="number")||(evt.which==1))) +{var e=this.dcsEvt(evt,"A");var f=this.dcsEvt(evt,"IMG");if(e.href&&e.protocol&&e.protocol.indexOf("http")!=-1&&!this.dcsLinkTrackException(e)) +{if((navigator.appVersion.indexOf("MSIE")==-1)&&((e.onclick)||(e.onmousedown))) +{this.dcsSetVarCap(e);} +var hn=e.hostname?(e.hostname.split(":")[0]):"";var qry=e.search?e.search.substring(e.search.indexOf("?")+1,e.search.length):"";var pth=e.pathname?((e.pathname.indexOf("/")!=0)?"/"+e.pathname:e.pathname):"/";var ti='';if(f.alt) +{ti=f.alt;} +else +{if(document.all) +{ti=e.title||e.innerText||e.innerHTML||"";} +else +{ti=e.title||e.text||e.innerHTML||"";}} +hn=this.DCS.setvar_dcssip||hn;pth=this.DCS.setvar_dcsuri||pth;qry=this.DCS.setvar_dcsqry||qry;ti=this.WT.setvar_ti||ti;ti=this.dcsTrim(ti);this.WT.mc_id=this.WT.setvar_mc_id||"";this.WT.sp=this.WT.ad=this.DCS.setvar_dcsuri=this.DCS.setvar_dcssip=this.DCS.setvar_dcsqry=this.WT.setvar_ti=this.WT.setvar_mc_id="";this.dcsMultiTrack("DCS.dcssip",hn,"DCS.dcsuri",pth,"DCS.dcsqry",this.trimoffsiteparams?"":qry,"DCS.dcsref",window.location,"WT.ti","Link:"+ti,"WT.dl","1","WT.nv",this.dcsNavigation(evt),"WT.sp","","WT.ad","","WT.AutoLinkTrack","1");this.DCS.dcssip=this.DCS.dcsuri=this.DCS.dcsqry=this.DCS.dcsref=this.WT.ti=this.WT.dl=this.WT.nv="";}}} +WebTrends.prototype.dcsTrim=function(sString) +{while(sString.substring(0,1)==' ') +{sString=sString.substring(1,sString.length);} +while(sString.substring(sString.length-1,sString.length)==' ') +{sString=sString.substring(0,sString.length-1);} +return sString;} +WebTrends.prototype.dcsSetVarCap=function(e) +{if(e.onclick) +var gCap=e.onclick.toString();else if(e.onmousedown) +var gCap=e.onmousedown.toString();var gStart=gCap.substring(gCap.indexOf("dcsSetVar(")+10,gCap.length)||gCap.substring(gCap.indexOf("_tag.dcsSetVar(")+16,gCap.length);var gEnd=gStart.substring(0,gStart.indexOf(");")).replace(/\s"/gi,"").replace(/"/gi,"");var gSplit=gEnd.split(",");if(gSplit.length!=-1) +{for(var i=0;i<gSplit.length;i+=2) +{if(gSplit[i].indexOf('WT.')==0) +{if(this.dcsSetVarValidate(gSplit[i])) +{this.WT["setvar_"+gSplit[i].substring(3)]=gSplit[i+1];} +else +{this.WT[gSplit[i].substring(3)]=gSplit[i+1];}} +else if(gSplit[i].indexOf('DCS.')==0) +{if(this.dcsSetVarValidate(gSplit[i])) +{this.DCS["setvar_"+gSplit[i].substring(4)]=gSplit[i+1];} +else +{this.DCS[gSplit[i].substring(4)]=gSplit[i+1];}} +else if(gSplit[i].indexOf('DCSext.')==0) +{if(this.dcsSetVarValidate(gSplit[i])) +{this.DCSext["setvar_"+gSplit[i].substring(7)]=gSplit[i+1];} +else +{this.DCSext[gSplit[i].substring(7)]=gSplit[i+1];}} +else if(gSplit[i].indexOf('DCSdir.')==0) +{if(this.dcsSetVarValidate(gSplit[i])) +{this.DCSdir["setvar_"+gSplit[i].substring(7)]=gSplit[i+1];} +else +{this.DCSdir[gSplit[i].substring(7)]=gSplit[i+1];}}}}} +WebTrends.prototype.dcsSetVarValidate=function(validate) +{var wtParamList="DCS.dcssip,DCS.dcsuri,DCS.dcsqry,WT.ti,WT.mc_id".split(",");for(var i=0;i<wtParamList.length;i++) +{if(wtParamList[i]==validate) +{return 1;}} +return 0;} +WebTrends.prototype.dcsSetVar=function() +{var args=dcsSetVar.arguments?dcsSetVar.arguments:arguments;if((args.length%2==0)&&(navigator.appVersion.indexOf("MSIE")!=-1)){for(var i=0;i<args.length;i+=2){if(args[i].indexOf('WT.')==0){if(this.dcsSetVarValidate(args[i])){this.WT["setvar_"+args[i].substring(3)]=args[i+1];} +else{this.WT[args[i].substring(3)]=args[i+1];}} +else if(args[i].indexOf('DCS.')==0){if(this.dcsSetVarValidate(args[i])){this.DCS["setvar_"+args[i].substring(4)]=args[i+1];} +else{this.DCS[args[i].substring(4)]=args[i+1];}} +else if(args[i].indexOf('DCSext.')==0){if(this.dcsSetVarValidate(args[i])){this.DCSext["setvar_"+args[i].substring(7)]=args[i+1];} +else{this.DCSext[args[i].substring(7)]=args[i+1];}} +else if(args[i].indexOf('DCSdir.')==0){if(this.dcsSetVarValidate(args[i])){this.DCSdir["setvar_"+args[i].substring(7)]=args[i+1];} +else{this.DCSdir[args[i].substring(7)]=args[i+1];}}}}} +WebTrends.prototype.dcsLinkTrackException=function(n) +{try +{var b=0;if(this.DCSdir.gTrackExceptions) +{var e=this.DCSdir.gTrackExceptions.split(",");while(b!=1) +{if(n.tagName&&n.tagName=="body") +{b=1;return false} +else +{if(n.className) +{var f=String(n.className).split(" ");for(var c=0;c<e.length;c++)for(var d=0;d<f.length;d++) +{if(f[d]==e[c]) +{b=1;return true}}}} +n=n.parentNode}} +else +{return false;}} +catch(g){}} +WebTrends.prototype.dcsCleanUp=function(){this.DCS={};this.WT={};this.DCSext={};if(arguments.length%2==0){this.dcsSetProps(arguments);}} +WebTrends.prototype.dcsSetProps=function(args){for(var i=0;i<args.length;i+=2){if(args[i].indexOf('WT.')==0){this.WT[args[i].substring(3)]=args[i+1];} +else if(args[i].indexOf('DCS.')==0){this.DCS[args[i].substring(4)]=args[i+1];} +else if(args[i].indexOf('DCSext.')==0){this.DCSext[args[i].substring(7)]=args[i+1];}}} +WebTrends.prototype.dcsSaveProps=function(args){var i,key,param;if(this.preserve){this.args=[];for(i=0;i<args.length;i+=2){param=args[i];if(param.indexOf('WT.')==0){key=param.substring(3);this.args[i]=param;this.args[i+1]=this.WT[key]||"";} +else if(param.indexOf('DCS.')==0){key=param.substring(4);this.args[i]=param;this.args[i+1]=this.DCS[key]||"";} +else if(param.indexOf('DCSext.')==0){key=param.substring(7);this.args[i]=param;this.args[i+1]=this.DCSext[key]||"";}}}} +WebTrends.prototype.dcsRestoreProps=function(){if(this.preserve){this.dcsSetProps(this.args);this.args=[];}} +WebTrends.prototype.dcsSplit=function(list){var items=list.toLowerCase().split(",");var len=items.length;for(var i=0;i<len;i++){items[i]=items[i].replace(/^\s*/,"").replace(/\s*$/,"");} +return items;} +WebTrends.prototype.dcsDownload=function(evt){evt=evt||(window.event||"");if(evt&&((typeof(evt.which)!="number")||(evt.which==1))){var e=this.dcsEvt(evt,"A");if(e.href){var hn=e.hostname?(e.hostname.split(":")[0]):"";if(this.dcsIsOnsite(hn)&&this.dcsTypeMatch(e.pathname,this.downloadtypes)){var qry=e.search?e.search.substring(e.search.indexOf("?")+1,e.search.length):"";var pth=e.pathname?((e.pathname.indexOf("/")!=0)?"/"+e.pathname:e.pathname):"/";var ttl="";var text=document.all?e.innerText:e.text;var img=this.dcsEvt(evt,"IMG");if(img.alt){ttl=img.alt;} +else if(text){ttl=text;} +else if(e.innerHTML){ttl=e.innerHTML;} +this.dcsMultiTrack("DCS.dcssip",hn,"DCS.dcsuri",pth,"DCS.dcsqry",e.search||"","WT.ti","Download:"+ttl,"WT.dl","20","WT.nv",this.dcsNavigation(evt));}}}} +WebTrends.prototype.dcsRightClick=function(evt){evt=evt||(window.event||"");if(evt){var btn=evt.which||evt.button;if((btn!=1)||(navigator.userAgent.indexOf("Safari")!=-1)){var e=this.dcsEvt(evt,"A");if((typeof(e.href)!="undefined")&&e.href){if((typeof(e.protocol)!="undefined")&&e.protocol&&(e.protocol.indexOf("http")!=-1)){if((typeof(e.pathname)!="undefined")&&this.dcsTypeMatch(e.pathname,this.downloadtypes)){var pth=e.pathname?((e.pathname.indexOf("/")!=0)?"/"+e.pathname:e.pathname):"/";var hn=e.hostname?(e.hostname.split(":")[0]):"";this.dcsMultiTrack("DCS.dcssip",hn,"DCS.dcsuri",pth,"DCS.dcsqry","","WT.ti","RightClick:"+pth,"WT.dl","25");}}}}}} +WebTrends.prototype.dcsAdv=function(){if(this.trackevents&&(typeof(this.dcsET)=="function")){if(window.addEventListener){window.addEventListener("load",this.dcsET.wtbind(this),false);} +else if(window.attachEvent){window.attachEvent("onload",this.dcsET.wtbind(this));}} +this.dcsFPC();} +WebTrends.prototype.dcsVar=function(){var dCurrent=new Date();var WT=this.WT;var DCS=this.DCS;WT.tz=parseInt(dCurrent.getTimezoneOffset()/60*-1)||"0";WT.bh=dCurrent.getHours()||"0";WT.ul=navigator.appName=="Netscape"?navigator.language:navigator.userLanguage;if(typeof(screen)=="object"){WT.cd=navigator.appName=="Netscape"?screen.pixelDepth:screen.colorDepth;WT.sr=screen.width+"x"+screen.height;} +if(typeof(navigator.javaEnabled())=="boolean"){WT.jo=navigator.javaEnabled()?"Yes":"No";} +if(document.title){if(window.RegExp){var tire=new RegExp("^"+window.location.protocol+"//"+window.location.hostname+"\\s-\\s");WT.ti=document.title.replace(tire,"");} +else{WT.ti=document.title;}} +WT.js="Yes";WT.jv=(function(){var agt=navigator.userAgent.toLowerCase();var major=parseInt(navigator.appVersion);var mac=(agt.indexOf("mac")!=-1);var ff=(agt.indexOf("firefox")!=-1);var ff0=(agt.indexOf("firefox/0.")!=-1);var ff10=(agt.indexOf("firefox/1.0")!=-1);var ff15=(agt.indexOf("firefox/1.5")!=-1);var ff20=(agt.indexOf("firefox/2.0")!=-1);var ff3up=(ff&&!ff0&&!ff10&!ff15&!ff20);var nn=(!ff&&(agt.indexOf("mozilla")!=-1)&&(agt.indexOf("compatible")==-1));var nn4=(nn&&(major==4));var nn6up=(nn&&(major>=5));var ie=((agt.indexOf("msie")!=-1)&&(agt.indexOf("opera")==-1));var ie4=(ie&&(major==4)&&(agt.indexOf("msie 4")!=-1));var ie5up=(ie&&!ie4);var op=(agt.indexOf("opera")!=-1);var op5=(agt.indexOf("opera 5")!=-1||agt.indexOf("opera/5")!=-1);var op6=(agt.indexOf("opera 6")!=-1||agt.indexOf("opera/6")!=-1);var op7up=(op&&!op5&&!op6);var jv="1.1";if(ff3up){jv="1.8";} +else if(ff20){jv="1.7";} +else if(ff15){jv="1.6";} +else if(ff0||ff10||nn6up||op7up){jv="1.5";} +else if((mac&&ie5up)||op6){jv="1.4";} +else if(ie5up||nn4||op5){jv="1.3";} +else if(ie4){jv="1.2";} +return jv;})();WT.ct="unknown";if(document.body&&document.body.addBehavior){try{document.body.addBehavior("#default#clientCaps");WT.ct=document.body.connectionType||"unknown";document.body.addBehavior("#default#homePage");WT.hp=document.body.isHomePage(location.href)?"1":"0";} +catch(e){}} +if(document.all){WT.bs=document.body?document.body.offsetWidth+"x"+document.body.offsetHeight:"unknown";} +else{WT.bs=window.innerWidth+"x"+window.innerHeight;} +WT.fv=(function(){var i,flash;if(window.ActiveXObject){for(i=15;i>0;i--){try{flash=new ActiveXObject("ShockwaveFlash.ShockwaveFlash."+i);return i+".0";} +catch(e){}}} +else if(navigator.plugins&&navigator.plugins.length){for(i=0;i<navigator.plugins.length;i++){if(navigator.plugins[i].name.indexOf('Shockwave Flash')!=-1){return navigator.plugins[i].description.split(" ")[2];}}} +return"Not enabled";})();WT.slv=(function(){var slv="Not enabled";try{if(navigator.userAgent.indexOf('MSIE')!=-1){var sli=new ActiveXObject('AgControl.AgControl');if(sli){slv="Unknown";}} +else if(navigator.plugins["Silverlight Plug-In"]){slv="Unknown";}} +catch(e){} +if(slv!="Not enabled"){var i,m,M,F;if((typeof(Silverlight)=="object")&&(typeof(Silverlight.isInstalled)=="function")){for(i=9;i>0;i--){M=i;if(Silverlight.isInstalled(M+".0")){break;} +if(slv==M){break;}} +for(m=9;m>=0;m--){F=M+"."+m;if(Silverlight.isInstalled(F)){slv=F;break;} +if(slv==F){break;}}}} +return slv;})();if(this.i18n){if(typeof(document.defaultCharset)=="string"){WT.le=document.defaultCharset;} +else if(typeof(document.characterSet)=="string"){WT.le=document.characterSet;} +else{WT.le="unknown";}} +WT.tv="9.3.0";WT.sp=this.splitvalue;WT.dl="0";WT.ssl=(window.location.protocol.indexOf('https:')==0)?"1":"0";DCS.dcsdat=dCurrent.getTime();DCS.dcssip=window.location.hostname;DCS.dcsuri=window.location.pathname;WT.es=DCS.dcssip+DCS.dcsuri;if(window.location.search){DCS.dcsqry=window.location.search;} +if(DCS.dcsqry){var dcsqry=DCS.dcsqry.toLowerCase();var params=this.paidsearchparams.length?this.paidsearchparams.toLowerCase().split(","):[];for(var i=0;i<params.length;i++){if(dcsqry.indexOf(params[i]+"=")!=-1){WT.srch="1";break;}}} +if((window.document.referrer!="")&&(window.document.referrer!="-")){if(!(navigator.appName=="Microsoft Internet Explorer"&&parseInt(navigator.appVersion)<4)){DCS.dcsref=window.document.referrer;}}} +WebTrends.prototype.dcsEscape=function(S,REL){if(REL!=""){S=S.toString();for(var R in REL){if(REL[R]instanceof RegExp){S=S.replace(REL[R],R);}} +return S;} +else{return escape(S);}} +WebTrends.prototype.dcsA=function(N,V){if(this.i18n&&(this.exre!="")&&!this.exre.test(N)){if(N=="dcsqry"){var newV="";var params=V.substring(1).split("&");for(var i=0;i<params.length;i++){var pair=params[i];var pos=pair.indexOf("=");if(pos!=-1){var key=pair.substring(0,pos);var val=pair.substring(pos+1);if(i!=0){newV+="&";} +newV+=key+"="+this.dcsEncode(val);}} +V=V.substring(0,1)+newV;} +else{V=this.dcsEncode(V);}} +return"&"+N+"="+this.dcsEscape(V,this.re);} +WebTrends.prototype.dcsEncode=function(S){return(typeof(encodeURIComponent)=="function")?encodeURIComponent(S):escape(S);} +WebTrends.prototype.dcsCreateImage=function(dcsSrc){if(document.images){this.images[this.index]=new Image();this.images[this.index].src=dcsSrc;this.index++;} +else{document.write('<img alt="" border="0" name="DCSIMG" width="1" height="1" src="'+dcsSrc+'">');}} +WebTrends.prototype.dcsMeta=function(){var elems;if(document.documentElement){elems=document.getElementsByTagName("meta");} +else if(document.all){elems=document.all.tags("meta");} +if(typeof(elems)!="undefined"){var length=elems.length;for(var i=0;i<length;i++){var name=elems.item(i).name;var content=elems.item(i).content;var equiv=elems.item(i).httpEquiv;if(name.length>0){if(name.toUpperCase().indexOf("WT.")==0){this.WT[name.substring(3)]=content;} +else if(name.toUpperCase().indexOf("DCSEXT.")==0){this.DCSext[name.substring(7)]=content;} +else if(name.toUpperCase().indexOf("DCSDIR.")==0){this.DCSdir[name.substring(7)]=content;} +else if(name.toUpperCase().indexOf("DCS.")==0){this.DCS[name.substring(4)]=content;}}}}} +WebTrends.prototype.dcsTag=function(){if(document.cookie.indexOf("WTLOPTOUT=")!=-1||!this.dcsChk()){return;} +var WT=this.WT;var DCS=this.DCS;var DCSext=this.DCSext;var i18n=this.i18n;var P="http"+(window.location.protocol.indexOf('https:')==0?'s':'')+"://"+this.domain+(this.dcsid==""?'':'/'+this.dcsid)+"/dcs.gif?";if(i18n){WT.dep="";} +for(var N in DCS){if(DCS[N]&&(typeof DCS[N]!="function")){P+=this.dcsA(N,DCS[N]);}} +for(N in WT){if(WT[N]&&(typeof WT[N]!="function")){P+=this.dcsA("WT."+N,WT[N]);}} +for(N in DCSext){if(DCSext[N]&&(typeof DCSext[N]!="function")){if(i18n){WT.dep=(WT.dep.length==0)?N:(WT.dep+";"+N);} +P+=this.dcsA(N,DCSext[N]);}} +if(i18n&&(WT.dep.length>0)){P+=this.dcsA("WT.dep",WT.dep);} +if(P.length>2048&&navigator.userAgent.indexOf('MSIE')>=0){P=P.substring(0,2040)+"&WT.tu=1";} +this.dcsCreateImage(P);this.WT.ad="";} +WebTrends.prototype.dcsDebug=function(){var t=this;var i=t.images[0].src;var q=i.indexOf("?");var r=i.substring(0,q).split("/");var m="<b>Protocol</b><br><code>"+r[0]+"<br></code>";m+="<b>Domain</b><br><code>"+r[2]+"<br></code>";m+="<b>Path</b><br><code>/"+r[3]+"/"+r[4]+"<br></code>";m+="<b>Query Params</b><code>"+i.substring(q+1).replace(/\&/g,"<br>")+"</code>";m+="<br><b>Cookies</b><br><code>"+document.cookie.replace(/\;/g,"<br>")+"</code>";if(t.w&&!t.w.closed){t.w.close();} +t.w=window.open("","dcsDebug","width=500,height=650,scrollbars=yes,resizable=yes");t.w.document.write(m);t.w.focus();} +WebTrends.prototype.dcsCollect=function(){if(this.enabled){this.dcsVar();this.dcsMeta();this.dcsAdv();this.dcsBounce();if(typeof(this.dcsCustom)=="function"){this.dcsCustom();} +this.dcsTag();}} +function dcsMultiTrack(){if(typeof(_tag)!="undefined"){return(_tag.dcsMultiTrack());}} +function dcsSetVar(){if(typeof(_tag)!="undefined"){return(_tag.dcsSetVar());}} +function dcsDebug(){if(typeof(_tag)!="undefined"){return(_tag.dcsDebug());}} +Function.prototype.wtbind=function(obj){var method=this;var temp=function(){return method.apply(obj,arguments);};return temp;} +WebTrends.prototype.dcsBounce=function(){if(typeof(this.WT.vt_f_s)!="undefined"&&this.WT.vt_f_s==1){this.WT.z_bounce="1";}else{this.WT.z_bounce="0";}} +WebTrends.prototype.dcsChk=function() +{if(this.rate==100){return"true";} +var cname='wtspl';cval=this.dcsGetCookie(cname);if(cval==null) +{cval=Math.floor(Math.random()*1000000);var date=new Date();date.setTime(date.getTime()+(30*24*60*60*1000));document.cookie=cname+"="+cval+"; expires="+date.toGMTString()+"; path=/; domain="+this.fpcdom+";";} +return((cval%1000)<(this.rate*10));}
\ No newline at end of file diff --git a/extensions/BMO/web/producticons/component.png b/extensions/BMO/web/producticons/component.png Binary files differnew file mode 100644 index 000000000..b9c5053f6 --- /dev/null +++ b/extensions/BMO/web/producticons/component.png diff --git a/extensions/BMO/web/producticons/dino.png b/extensions/BMO/web/producticons/dino.png Binary files differnew file mode 100644 index 000000000..9e0470a07 --- /dev/null +++ b/extensions/BMO/web/producticons/dino.png diff --git a/extensions/BMO/web/producticons/firefox.png b/extensions/BMO/web/producticons/firefox.png Binary files differnew file mode 100644 index 000000000..3ba536ed2 --- /dev/null +++ b/extensions/BMO/web/producticons/firefox.png diff --git a/extensions/BMO/web/producticons/firefox_android.png b/extensions/BMO/web/producticons/firefox_android.png Binary files differnew file mode 100644 index 000000000..7f9329082 --- /dev/null +++ b/extensions/BMO/web/producticons/firefox_android.png diff --git a/extensions/BMO/web/producticons/firefox_os.png b/extensions/BMO/web/producticons/firefox_os.png Binary files differnew file mode 100644 index 000000000..5f08dc4f9 --- /dev/null +++ b/extensions/BMO/web/producticons/firefox_os.png diff --git a/extensions/BMO/web/producticons/input.png b/extensions/BMO/web/producticons/input.png Binary files differnew file mode 100644 index 000000000..81f355d85 --- /dev/null +++ b/extensions/BMO/web/producticons/input.png diff --git a/extensions/BMO/web/producticons/localization.png b/extensions/BMO/web/producticons/localization.png Binary files differnew file mode 100644 index 000000000..df3eac2d0 --- /dev/null +++ b/extensions/BMO/web/producticons/localization.png diff --git a/extensions/BMO/web/producticons/marketplace.png b/extensions/BMO/web/producticons/marketplace.png Binary files differnew file mode 100644 index 000000000..62025a2a8 --- /dev/null +++ b/extensions/BMO/web/producticons/marketplace.png diff --git a/extensions/BMO/web/producticons/other.png b/extensions/BMO/web/producticons/other.png Binary files differnew file mode 100644 index 000000000..e436c22ae --- /dev/null +++ b/extensions/BMO/web/producticons/other.png diff --git a/extensions/BMO/web/producticons/seamonkey.png b/extensions/BMO/web/producticons/seamonkey.png Binary files differnew file mode 100644 index 000000000..fcb261ae1 --- /dev/null +++ b/extensions/BMO/web/producticons/seamonkey.png diff --git a/extensions/BMO/web/producticons/sync.png b/extensions/BMO/web/producticons/sync.png Binary files differnew file mode 100644 index 000000000..b42125ef6 --- /dev/null +++ b/extensions/BMO/web/producticons/sync.png diff --git a/extensions/BMO/web/producticons/thunderbird.png b/extensions/BMO/web/producticons/thunderbird.png Binary files differnew file mode 100644 index 000000000..f3523183a --- /dev/null +++ b/extensions/BMO/web/producticons/thunderbird.png diff --git a/extensions/BMO/web/producticons/webmaker.png b/extensions/BMO/web/producticons/webmaker.png Binary files differnew file mode 100644 index 000000000..d576a5f01 --- /dev/null +++ b/extensions/BMO/web/producticons/webmaker.png diff --git a/extensions/BMO/web/styles/choose_product.css b/extensions/BMO/web/styles/choose_product.css new file mode 100644 index 000000000..053af542f --- /dev/null +++ b/extensions/BMO/web/styles/choose_product.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. */ + +#choose_product h2, +#choose_product p { + text-align: center; +} + +#choose_product td h2, +#choose_product td p { + text-align: left; +} diff --git a/extensions/BMO/web/styles/create_account.css b/extensions/BMO/web/styles/create_account.css new file mode 100644 index 000000000..0ab527629 --- /dev/null +++ b/extensions/BMO/web/styles/create_account.css @@ -0,0 +1,62 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the Bugzilla Bug Tracking System. + * + * The Initial Developer of the Original Code is + * the Mozilla Foundation. + * Portions created by the Initial Developer are Copyright (C) 2011 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Byron Jones <glob@mozilla.com> + * + * ***** END LICENSE BLOCK ***** */ + +#create-account h2 { + margin: 0px; +} + +.column-header { + padding: 20px 20px 20px 0px; +} + +#create-account-left { + border-right: 2px solid #888888; + padding-right: 10px; +} + +#product-list td { + padding-top: 10px; +} + +#product-list img { + padding-right: 10px; +} + +#create-account-right { + padding-left: 10px; +} + +#right-blurb { + font-size: large; +} + +#right-blurb li { + padding-bottom: 1em; +} + +#create-account-right { + padding-bottom: 5em; +} + diff --git a/extensions/BMO/web/styles/edit_bug.css b/extensions/BMO/web/styles/edit_bug.css new file mode 100644 index 000000000..24212270d --- /dev/null +++ b/extensions/BMO/web/styles/edit_bug.css @@ -0,0 +1,49 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the BMO Bugzilla Extension; + * + * The Initial Developer of the Original Code is the Mozilla Foundation. + * Portions created by the Initial Developer are Copyright (C) 2011 the + * Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Byron Jones <glob@mozilla.com> + * + * ***** END LICENSE BLOCK ***** + */ + +#project-flags, +#custom-flags { + width: auto; +} + +.bz_hidden { + display: none; +} + +.bz_collapse_comment { + font-family: monospace; +} + +#prod_desc_container, +#comp_desc_container { + overflow: auto; + color: green; + padding: 2px; +} + +#toggle_prod_desc, +#toggle_comp_desc { + white-space: nowrap; +} diff --git a/extensions/BMO/web/styles/reports.css b/extensions/BMO/web/styles/reports.css new file mode 100644 index 000000000..06ae52d68 --- /dev/null +++ b/extensions/BMO/web/styles/reports.css @@ -0,0 +1,75 @@ +.hidden { + display: none; +} + +#product, #component { + width: 20em; +} + +#parameters th { + text-align: left; + vertical-align: middle !important; +} + +#report tr.bugitem:hover { + background: #ccccff; +} + +#report td, #report th { + padding: 3px 10px 3px 3px; +} + +#report th { + text-align: left; +} + +#report th.right { + text-align: right; +} + +#report th.sorted { + text-decoration: underline; +} + +#report-header { + background-color: #cccccc; +} + +.report_subheader { + background-color: #dddddd; +} + +.report_row_odd { + background-color: #eeeeee; + color: #000000; +} + +.report_row_even { + background-color: #ffffff; + color: #000000; +} + +#report.hover tr:hover { + background-color: #ccccff; +} + +#report { + border: 1px solid #888888; +} + +#report th, #report td { + border: 0px; +} + +.disabled { + color: #888888; +} + +.hoverrow tr:hover { + background-color: #ccccff; +} + +.problem { + color: #aa2222; +} + diff --git a/extensions/BMO/web/styles/triage_reports.css b/extensions/BMO/web/styles/triage_reports.css new file mode 100644 index 000000000..6190fd32c --- /dev/null +++ b/extensions/BMO/web/styles/triage_reports.css @@ -0,0 +1,23 @@ +.hidden { + display: none; +} + +#triage_form th { + text-align: left; +} + +#product, #component { + width: 20em; +} + +#report tr.bugitem:hover { + background: #ccccff; +} + +#report td { + padding: 1px 10px 1px 10px; +} + +#report-header { + background: #dddddd; +} diff --git a/extensions/BmpConvert/disabled b/extensions/BMO/web/yui-history-iframe.txt index e69de29bb..e69de29bb 100644 --- a/extensions/BmpConvert/disabled +++ b/extensions/BMO/web/yui-history-iframe.txt diff --git a/extensions/Bitly/Config.pm b/extensions/Bitly/Config.pm new file mode 100644 index 000000000..aff9d4b4b --- /dev/null +++ b/extensions/Bitly/Config.pm @@ -0,0 +1,45 @@ +# 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::Bitly; +use strict; + +use Bugzilla::Install::Util qw(vers_cmp); + +use constant NAME => 'Bitly'; + +sub REQUIRED_MODULES { + my @required; + push @required, { + package => 'LWP', + module => 'LWP', + version => 5, + }; + # LWP 6 split https support into a separate package + if (Bugzilla::Install::Requirements::have_vers({ + package => 'LWP', + module => 'LWP', + version => 6, + })) { + push @required, { + package => 'LWP-Protocol-https', + module => 'LWP::Protocol::https', + version => 0 + }; + } + return \@required; +} + +use constant OPTIONAL_MODULES => [ + { + package => 'Mozilla-CA', + module => 'Mozilla::CA', + version => 0 + }, +]; + +__PACKAGE__->NAME; diff --git a/extensions/Bitly/Extension.pm b/extensions/Bitly/Extension.pm new file mode 100644 index 000000000..a368b20fe --- /dev/null +++ b/extensions/Bitly/Extension.pm @@ -0,0 +1,31 @@ +# 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::Bitly; +use strict; +use warnings; + +use base qw(Bugzilla::Extension); +our $VERSION = '1'; + +use Bugzilla; + +sub webservice { + my ($self, $args) = @_; + $args->{dispatch}->{Bitly} = "Bugzilla::Extension::Bitly::WebService"; +} + +sub config_modify_panels { + my ($self, $args) = @_; + push @{ $args->{panels}->{advanced}->{params} }, { + name => 'bitly_token', + type => 't', + default => '', + }; +} + +__PACKAGE__->NAME; diff --git a/extensions/Bitly/lib/WebService.pm b/extensions/Bitly/lib/WebService.pm new file mode 100644 index 000000000..e721103b0 --- /dev/null +++ b/extensions/Bitly/lib/WebService.pm @@ -0,0 +1,141 @@ +# 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::Bitly::WebService; + +use strict; +use warnings; + +use base qw(Bugzilla::WebService); + +use Bugzilla::CGI; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Search; +use Bugzilla::Search::Quicksearch; +use Bugzilla::Util 'correct_urlbase'; +use Bugzilla::WebService::Util 'validate'; +use JSON; +use LWP::UserAgent; +use URI; +use URI::Escape; +use URI::QueryParam; + +sub _validate_uri { + my ($self, $params) = @_; + + # extract url from params + if (!defined $params->{url}) { + ThrowCodeError( + 'param_required', + { function => 'Bitly.shorten', param => 'url' } + ); + } + my $url = ref($params->{url}) ? $params->{url}->[0] : $params->{url}; + + # only allow buglist queries for this bugzilla install + my $uri = URI->new($url); + $uri->query(undef); + $uri->fragment(undef); + if ($uri->as_string ne correct_urlbase() . 'buglist.cgi') { + ThrowUserError('bitly_unsupported'); + } + + return URI->new($url); +} + +sub shorten { + my ($self) = shift; + my $uri = $self->_validate_uri(@_); + + # the list_id is user-specific, remove it + $uri->query_param_delete('list_id'); + + return $self->_bitly($uri); +} + +sub list { + my ($self) = shift; + my $uri = $self->_validate_uri(@_); + + # map params to cgi vars, converting quicksearch if required + my $params = $uri->query_param('quicksearch') + ? Bugzilla::CGI->new(quicksearch($uri->query_param('quicksearch')))->Vars + : Bugzilla::CGI->new($uri->query)->Vars; + + # execute the search + my $search = Bugzilla::Search->new( + params => $params, + fields => ['bug_id'], + limit => Bugzilla->params->{max_search_results}, + ); + my $data = $search->data; + + # form a bug_id only url, sanity check the length + $uri = URI->new(correct_urlbase() . 'buglist.cgi?bug_id=' . join(',', map { $_->[0] } @$data)); + if (length($uri->as_string) > CGI_URI_LIMIT) { + ThrowUserError('bitly_failure', { message => "Too many bugs returned by search" }); + } + + # shorten + return $self->_bitly($uri); +} + +sub _bitly { + my ($self, $uri) = @_; + + # form request url + # http://dev.bitly.com/links.html#v3_shorten + my $bitly_url = sprintf( + 'https://api-ssl.bitly.com/v3/shorten?access_token=%s&longUrl=%s', + Bugzilla->params->{bitly_token}, + uri_escape($uri->as_string) + ); + + # is Mozilla::CA isn't installed, skip certificate verification + eval { require Mozilla::CA }; + $ENV{PERL_LWP_SSL_VERIFY_HOSTNAME} = $@ ? 0 : 1; + + # request + my $ua = LWP::UserAgent->new(agent => 'Bugzilla'); + $ua->timeout(10); + $ua->protocols_allowed(['http', 'https']); + if (my $proxy_url = Bugzilla->params->{proxy_url}) { + $ua->proxy(['http', 'https'], $proxy_url); + } + else { + $ua->env_proxy(); + } + my $response = $ua->get($bitly_url); + if ($response->is_error) { + ThrowUserError('bitly_failure', { message => $response->message }); + } + my $result = decode_json($response->decoded_content); + if ($result->{status_code} != 200) { + ThrowUserError('bitly_failure', { message => $result->{status_txt} }); + } + + # return just the short url + return { url => $result->{data}->{url} }; +} + +sub rest_resources { + return [ + qr{^/bitly/shorten$}, { + GET => { + method => 'shorten', + }, + }, + qr{^/bitly/list$}, { + GET => { + method => 'list', + }, + }, + ] +} + +1; diff --git a/extensions/Bitly/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl b/extensions/Bitly/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl new file mode 100644 index 000000000..2e0f58bc4 --- /dev/null +++ b/extensions/Bitly/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl @@ -0,0 +1,13 @@ +[%# 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 panel.name == "advanced" %] + [% panel.param_descs.bitly_token = + 'Bitly Generic Access Token' + %] +[% END -%] diff --git a/extensions/Bitly/template/en/default/hook/global/header-start.html.tmpl b/extensions/Bitly/template/en/default/hook/global/header-start.html.tmpl new file mode 100644 index 000000000..12ab7b20f --- /dev/null +++ b/extensions/Bitly/template/en/default/hook/global/header-start.html.tmpl @@ -0,0 +1,13 @@ +[%# 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 template.name == 'list/list.html.tmpl' %] + +[% style_urls.push('extensions/Bitly/web/styles/bitly.css') %] +[% javascript_urls.push('extensions/Bitly/web/js/bitly.js') %] +[% yui.push('container') %] diff --git a/extensions/Bitly/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/Bitly/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..edf0b0724 --- /dev/null +++ b/extensions/Bitly/template/en/default/hook/global/user-error-errors.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. + #%] + +[% IF error == "bitly_failure" %] + [% title = "Failed to generate short URL" %] + [% message FILTER html %] + +[% ELSIF error == "bitly_unsupported" %] + [% title = "Unsupported URL" %] + The requested URL is not supported. + +[% END %] diff --git a/extensions/Bitly/template/en/default/hook/list/list-links.html.tmpl b/extensions/Bitly/template/en/default/hook/list/list-links.html.tmpl new file mode 100644 index 000000000..836c017ed --- /dev/null +++ b/extensions/Bitly/template/en/default/hook/list/list-links.html.tmpl @@ -0,0 +1,24 @@ +[%# 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.id && Bugzilla.params.bitly_token %] + +<div id="bitly_overlay"> + <div class="bd"> + <select id="bitly_type" onchange="YAHOO.bitly.execute()"> + <option value="shorten">Share a link to this search</option> + <option value="list">Share a link to this list of [% terms.bugs %]</option> + </select> + <input id="bitly_url" readonly placeholder="Generating short link..."> + </div> + <div class="ft"> + <button id="bitly_close" class="notransition">Close</button> + </div> +</div> +<a id="bitly_shorten" href="#" onclick="YAHOO.bitly.toggle();return false">Short URL</a> +| [%# using nbsp because tt always trims trailing whitespace from templates %] diff --git a/extensions/Bitly/web/js/bitly.js b/extensions/Bitly/web/js/bitly.js new file mode 100644 index 000000000..62c49b650 --- /dev/null +++ b/extensions/Bitly/web/js/bitly.js @@ -0,0 +1,100 @@ +/* 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'; + var Dom = YAHOO.util.Dom; + YAHOO.namespace('bitly'); + var bitly = YAHOO.bitly; + + bitly.dialog = false; + bitly.url = { shorten: '', list: '' }; + + bitly.shorten = function() { + if (this.dialog) { + this.dialog.show(); + var el = Dom.get('bitly_url'); + el.select(); + el.focus(); + return; + } + this.dialog = new YAHOO.widget.Overlay('bitly_overlay', { + visible: true, + close: false, + underlay: 'shadow', + width: '400px', + context: [ 'bitly_shorten', 'bl', 'tl', ['windowResize'], [0, -10] ] + }); + this.dialog.render(document.body); + + YAHOO.util.Event.addListener('bitly_close', 'click', function() { + YAHOO.bitly.dialog.hide(); + }); + YAHOO.util.Event.addListener('bitly_url', 'keypress', function(o) { + if (o.keyCode == 27 || o.keyCode == 13) + YAHOO.bitly.dialog.hide(); + }); + this.execute(); + Dom.get('bitly_url').focus(); + }; + + bitly.execute = function() { + Dom.get('bitly_url').value = ''; + + var type = Dom.get('bitly_type').value; + if (this.url[type]) { + this.set(this.url[type]); + return; + } + + var url = 'rest/bitly/' + type + '?url=' + encodeURIComponent(document.location); + YAHOO.util.Connect.initHeader("Accept", "application/json"); + YAHOO.util.Connect.asyncRequest('GET', url, { + success: function(o) { + var response = YAHOO.lang.JSON.parse(o.responseText); + if (response.error) { + bitly.set(response.message); + } + else { + bitly.url[type] = response.url; + bitly.set(response.url); + } + }, + failure: function(o) { + try { + var response = YAHOO.lang.JSON.parse(o.responseText); + if (response.error) { + bitly.set(response.message); + } + else { + bitly.set(o.statusText); + } + } catch (ex) { + bitly.set(o.statusText); + } + } + }); + }; + + bitly.set = function(value) { + var el = Dom.get('bitly_url'); + el.value = value; + el.select(); + el.focus(); + }; + + bitly.toggle = function() { + if (this.dialog + && YAHOO.util.Dom.get('bitly_overlay').style.visibility == 'visible') + { + this.dialog.hide(); + } + else { + this.shorten(); + } + }; +})(); diff --git a/extensions/Bitly/web/styles/bitly.css b/extensions/Bitly/web/styles/bitly.css new file mode 100644 index 000000000..110a6bef4 --- /dev/null +++ b/extensions/Bitly/web/styles/bitly.css @@ -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. */ + +#bitly_overlay { + position: absolute; + background: #eee; + border: 1px solid black; + padding: 5px; + margin: 10px; + visibility: collapse; + box-shadow: 3px 3px 6px #888; + -moz-box-shadow: 3px 3px 6px #888; +} + +#bitly_url { + margin: 2px 0; + display: block; + width: 100%; +} diff --git a/extensions/BugmailFilter/Config.pm b/extensions/BugmailFilter/Config.pm new file mode 100644 index 000000000..9932afb40 --- /dev/null +++ b/extensions/BugmailFilter/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::BugmailFilter; +use strict; + +use constant NAME => 'BugmailFilter'; +use constant REQUIRED_MODULES => []; +use constant OPTIONAL_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/BugmailFilter/Extension.pm b/extensions/BugmailFilter/Extension.pm new file mode 100644 index 000000000..ebfc1f851 --- /dev/null +++ b/extensions/BugmailFilter/Extension.pm @@ -0,0 +1,478 @@ +# 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::BugmailFilter; +use strict; +use warnings; + +use base qw(Bugzilla::Extension); +our $VERSION = '1'; + +use Bugzilla::BugMail; +use Bugzilla::Component; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Extension::BugmailFilter::Constants; +use Bugzilla::Extension::BugmailFilter::FakeField; +use Bugzilla::Extension::BugmailFilter::Filter; +use Bugzilla::Field; +use Bugzilla::Product; +use Bugzilla::User; +use Bugzilla::Util qw(template_var); +use Encode; +use Sys::Syslog qw(:DEFAULT); + +# +# preferences +# + +sub user_preferences { + my ($self, $args) = @_; + return unless $args->{current_tab} eq 'bugmail_filter'; + + if ($args->{save_changes}) { + my $input = Bugzilla->input_params; + + if ($input->{add_filter}) { + + # add a new filter + + my $params = { + user_id => Bugzilla->user->id, + }; + $params->{field_name} = $input->{field} || IS_NULL; + if ($params->{field_name} eq '~') { + $params->{field_name} = '~' . $input->{field_contains}; + } + $params->{relationship} = $input->{relationship} || IS_NULL; + if ($input->{changer}) { + Bugzilla::User::match_field({ changer => { type => 'single'} }); + $params->{changer_id} = Bugzilla::User->check({ + name => $input->{changer}, + cache => 1, + })->id; + } + else { + $params->{changer_id} = IS_NULL; + } + if (my $product_name = $input->{product}) { + my $product = Bugzilla::Product->check({ + name => $product_name, cache => 1 + }); + $params->{product_id} = $product->id; + + if (my $component_name = $input->{component}) { + $params->{component_id} = Bugzilla::Component->check({ + name => $component_name, product => $product, + cache => 1 + })->id; + } + else { + $params->{component_id} = IS_NULL; + } + } + else { + $params->{product_id} = IS_NULL; + $params->{component_id} = IS_NULL; + } + + if (@{ Bugzilla::Extension::BugmailFilter::Filter->match($params) }) { + ThrowUserError('bugmail_filter_exists'); + } + $params->{action} = $input->{action} eq 'Exclude' ? 1 : 0; + foreach my $name (keys %$params) { + $params->{$name} = undef + if $params->{$name} eq IS_NULL; + } + Bugzilla::Extension::BugmailFilter::Filter->create($params); + } + + elsif ($input->{remove_filter}) { + + # remove filter(s) + + my $ids = ref($input->{remove}) ? $input->{remove} : [ $input->{remove} ]; + my $dbh = Bugzilla->dbh; + $dbh->bz_start_transaction; + foreach my $id (@$ids) { + if (my $filter = Bugzilla::Extension::BugmailFilter::Filter->new($id)) { + $filter->remove_from_db(); + } + } + $dbh->bz_commit_transaction; + } + } + + my $vars = $args->{vars}; + my $field_descs = template_var('field_descs'); + + # load all fields into a hash for easy manipulation + my %fields = + map { $_->name => $field_descs->{$_->name} } + @{ Bugzilla->fields({ obsolete => 0 }) }; + + # remove time trackinger fields + if (!Bugzilla->user->is_timetracker) { + foreach my $field (TIMETRACKING_FIELDS) { + delete $fields{$field}; + } + } + + # remove fields which don't make any sense to filter on + foreach my $field (IGNORE_FIELDS) { + delete $fields{$field}; + } + + # remove all tracking flag fields. these change too frequently to be of + # value, so they only add noise to the list. + foreach my $field (Bugzilla->tracking_flag_names) { + delete $fields{$field}; + } + + # add tracking flag types instead + foreach my $field ( + @{ Bugzilla::Extension::BugmailFilter::FakeField->tracking_flag_fields() } + ) { + $fields{$field->name} = $field->description; + } + + # adjust the description for selected fields + foreach my $field (keys %{ FIELD_DESCRIPTION_OVERRIDE() }) { + $fields{$field} = FIELD_DESCRIPTION_OVERRIDE->{$field}; + } + + # some fields are present in the changed-fields x-header but are not real + # bugzilla fields + foreach my $field ( + @{ Bugzilla::Extension::BugmailFilter::FakeField->fake_fields() } + ) { + $fields{$field->name} = $field->description; + } + + $vars->{fields} = \%fields; + $vars->{field_list} = [ + sort { lc($a->{description}) cmp lc($b->{description}) } + map { { name => $_, description => $fields{$_} } } + keys %fields + ]; + + $vars->{relationships} = FILTER_RELATIONSHIPS(); + + $vars->{filters} = [ + sort { + $a->product_name cmp $b->product_name + || $a->component_name cmp $b->component_name + || $a->field_name cmp $b->field_name + } + @{ Bugzilla::Extension::BugmailFilter::Filter->match({ + user_id => Bugzilla->user->id, + }) } + ]; + + # set field_description + foreach my $filter (@{ $vars->{filters} }) { + my $field_name = $filter->field_name; + if (!$field_name) { + $filter->field_description('Any'); + } + elsif (substr($field_name, 0, 1) eq '~') { + $filter->field_description('~ ' . substr($field_name, 1)); + } + else { + $filter->field_description($fields{$field_name} || $filter->field->description); + } + } + + # build a list of tracking-flags, grouped by type + require Bugzilla::Extension::TrackingFlags::Constants; + require Bugzilla::Extension::TrackingFlags::Flag; + my %flag_types = + map { $_->{name} => $_->{description} } + @{ Bugzilla::Extension::TrackingFlags::Constants::FLAG_TYPES() }; + my %tracking_flags_by_type; + foreach my $flag (Bugzilla::Extension::TrackingFlags::Flag->get_all) { + my $type = $flag_types{$flag->flag_type}; + $tracking_flags_by_type{$type} //= []; + push @{ $tracking_flags_by_type{$type} }, $flag; + } + my @tracking_flags_by_type; + foreach my $type (sort keys %tracking_flags_by_type) { + push @tracking_flags_by_type, { + name => $type, + flags => $tracking_flags_by_type{$type}, + }; + } + $vars->{tracking_flags_by_type} = \@tracking_flags_by_type; + + ${ $args->{handled} } = 1; +} + +# +# hooks +# + +sub user_wants_mail { + my ($self, $args) = @_; + + my ($user, $wants_mail, $diffs, $comments) + = @$args{qw( user wants_mail fieldDiffs comments )}; + + return unless $$wants_mail; + + my $cache = Bugzilla->request_cache->{bugmail_filters} //= {}; + my $filters = $cache->{$user->id} //= + Bugzilla::Extension::BugmailFilter::Filter->match({ + user_id => $user->id + }); + return unless @$filters; + + my $fields = [ + map { { + filter_field => $_->{field_name}, # filter's field_name + field_name => $_->{field_name}, # raw bugzilla field_name + } } + @$diffs + ]; + + # insert fake fields for new attachments and comments + if (@$comments) { + if (grep { $_->type == CMT_ATTACHMENT_CREATED } @$comments) { + push @$fields, { filter_field => 'attachment.created' }; + } + if (grep { $_->type != CMT_ATTACHMENT_CREATED } @$comments) { + push @$fields, { filter_field => 'comment.created' }; + } + } + + # set filter_field on tracking flags to tracking.$type + require Bugzilla::Extension::TrackingFlags::Flag; + my @tracking_flags = Bugzilla->tracking_flags; + foreach my $field (@$fields) { + next unless my $field_name = $field->{field_name}; + foreach my $tracking_flag (@tracking_flags) { + if ($field_name eq $tracking_flag->name) { + $field->{filter_field} = 'tracking.'. $tracking_flag->flag_type; + } + } + } + + if (_should_drop($fields, $filters, $args)) { + $$wants_mail = 0; + openlog('apache', 'cons,pid', 'local4'); + syslog('notice', encode_utf8(sprintf( + '[bugmail] %s (filtered) bug-%s %s', + $args->{user}->login, + $args->{bug}->id, + $args->{bug}->short_desc, + ))); + closelog(); + } +} + +sub _should_drop { + my ($fields, $filters, $args) = @_; + + # calculate relationships + + my ($user, $bug, $relationship, $changer) = @$args{qw( user bug relationship changer )}; + my ($user_id, $login) = ($user->id, $user->login); + my $bit_direct = Bugzilla::BugMail::BIT_DIRECT; + my $bit_watching = Bugzilla::BugMail::BIT_WATCHING; + my $bit_compwatch = 15; # from Bugzilla::Extension::ComponentWatching + + # the index of $rel_map corresponds to the values in FILTER_RELATIONSHIPS + my @rel_map; + $rel_map[1] = $bug->assigned_to->id == $user_id; + $rel_map[2] = !$rel_map[1]; + $rel_map[3] = $bug->reporter->id == $user_id; + $rel_map[4] = !$rel_map[3]; + if ($bug->qa_contact) { + $rel_map[5] = $bug->qa_contact->id == $user_id; + $rel_map[6] = !$rel_map[6]; + } + $rel_map[7] = $bug->cc + ? grep { $_ eq $login } @{ $bug->cc } + : 0; + $rel_map[8] = !$rel_map[8]; + $rel_map[9] = ( + $relationship & $bit_watching + or $relationship & $bit_compwatch + ); + $rel_map[10] = !$rel_map[9]; + $rel_map[11] = $bug->is_mentor($user); + $rel_map[12] = !$rel_map[11]; + foreach my $bool (@rel_map) { + $bool = $bool ? 1 : 0; + } + + # exclusions + # drop email where we are excluding all changed fields + + my $params = { + product_id => $bug->product_id, + component_id => $bug->component_id, + rel_map => \@rel_map, + changer_id => $changer->id, + }; + + foreach my $field (@$fields) { + $params->{field} = $field; + foreach my $filter (grep { $_->is_exclude } @$filters) { + if ($filter->matches($params)) { + $field->{exclude} = 1; + last; + } + } + } + + # no need to process includes if nothing was excluded + if (!grep { $_->{exclude} } @$fields) { + return 0; + } + + # inclusions + # flip the bit for fields that should be included + + foreach my $field (@$fields) { + $params->{field} = $field; + foreach my $filter (grep { $_->is_include } @$filters) { + if ($filter->matches($params)) { + $field->{exclude} = 0; + last; + } + } + } + + # drop if all fields are still excluded + return !(grep { !$_->{exclude} } @$fields); +} + +# catch when fields are renamed, and update the field_name entires +sub object_end_of_update { + my ($self, $args) = @_; + my $object = $args->{object}; + + return unless $object->isa('Bugzilla::Field') + || $object->isa('Bugzilla::Extension::TrackingFlags::Flag'); + + return unless exists $args->{changes}->{name}; + + my $old_name = $args->{changes}->{name}->[0]; + my $new_name = $args->{changes}->{name}->[1]; + + Bugzilla->dbh->do( + "UPDATE bugmail_filters SET field_name=? WHERE field_name=?", + undef, + $new_name, $old_name); +} + +sub reorg_move_component { + my ($self, $args) = @_; + my $new_product = $args->{new_product}; + my $component = $args->{component}; + + Bugzilla->dbh->do( + "UPDATE bugmail_filters SET product_id=? WHERE component_id=?", + undef, + $new_product->id, $component->id, + ); +} + +# +# schema / install +# + +sub db_schema_abstract_schema { + my ($self, $args) = @_; + $args->{schema}->{bugmail_filters} = { + FIELDS => [ + id => { + TYPE => 'INTSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + user_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE' + }, + }, + field_name => { + # due to fake fields, this can't be field_id + TYPE => 'VARCHAR(64)', + NOTNULL => 0, + }, + product_id => { + TYPE => 'INT2', + NOTNULL => 0, + REFERENCES => { + TABLE => 'products', + COLUMN => 'id', + DELETE => 'CASCADE' + }, + }, + component_id => { + TYPE => 'INT2', + NOTNULL => 0, + REFERENCES => { + TABLE => 'components', + COLUMN => 'id', + DELETE => 'CASCADE' + }, + }, + changer_id => { + TYPE => 'INT3', + NOTNULL => 0, + REFERENCES => { + TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE' + }, + }, + relationship => { + TYPE => 'INT2', + NOTNULL => 0, + }, + action => { + TYPE => 'INT1', + NOTNULL => 1, + }, + ], + INDEXES => [ + bugmail_filters_unique_idx => { + FIELDS => [ qw( user_id field_name product_id component_id + relationship ) ], + TYPE => 'UNIQUE', + }, + bugmail_filters_user_idx => [ + 'user_id', + ], + ], + }; +} + +sub install_update_db { + Bugzilla->dbh->bz_add_column( + 'bugmail_filters', + 'changer_id', + { + TYPE => 'INT3', + NOTNULL => 0, + } + ); +} + +sub db_sanitize { + my $dbh = Bugzilla->dbh; + print "Deleting bugmail filters...\n"; + $dbh->do("DELETE FROM bugmail_filters"); +} + +__PACKAGE__->NAME; diff --git a/extensions/BugmailFilter/lib/Constants.pm b/extensions/BugmailFilter/lib/Constants.pm new file mode 100644 index 000000000..20e5480d0 --- /dev/null +++ b/extensions/BugmailFilter/lib/Constants.pm @@ -0,0 +1,120 @@ +# 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::BugmailFilter::Constants; +use strict; + +use base qw(Exporter); + +our @EXPORT = qw( + FAKE_FIELD_NAMES + IGNORE_FIELDS + FIELD_DESCRIPTION_OVERRIDE + FILTER_RELATIONSHIPS +); + +use Bugzilla::Constants; + +# these are field names which are inserted into X-Bugzilla-Changed-Field-Names +# header but are not real fields + +use constant FAKE_FIELD_NAMES => [ + { + name => 'comment.created', + description => 'Comment created', + }, + { + name => 'attachment.created', + description => 'Attachment created', + }, +]; + +# these fields don't make any sense to filter on + +use constant IGNORE_FIELDS => qw( + assignee_last_login + attach_data.thedata + attachments.submitter + cf_last_resolved + commenter + comment_tag + creation_ts + days_elapsed + delta_ts + everconfirmed + last_visit_ts + longdesc + longdescs.count + owner_idle_time + reporter + reporter_accessible + setters.login_name + tag + votes +); + +# override the description of some fields + +use constant FIELD_DESCRIPTION_OVERRIDE => { + bug_id => 'Bug Created', +}; + +# relationship / int mappings +# _should_drop() also needs updating when this const is changed + +use constant FILTER_RELATIONSHIPS => [ + { + name => 'Assignee', + value => 1, + }, + { + name => 'Not Assignee', + value => 2, + }, + { + name => 'Reporter', + value => 3, + }, + { + name => 'Not Reporter', + value => 4, + }, + { + name => 'QA Contact', + value => 5, + }, + { + name => 'Not QA Contact', + value => 6, + }, + { + name => "CC'ed", + value => 7, + }, + { + name => "Not CC'ed", + value => 8, + }, + { + name => 'Watching', + value => 9, + }, + { + name => 'Not Watching', + value => 10, + }, + { + name => 'Mentoring', + value => 11, + }, + { + name => 'Not Mentoring', + value => 12, + }, +]; + +1; diff --git a/extensions/BugmailFilter/lib/FakeField.pm b/extensions/BugmailFilter/lib/FakeField.pm new file mode 100644 index 000000000..88e4ac1ca --- /dev/null +++ b/extensions/BugmailFilter/lib/FakeField.pm @@ -0,0 +1,57 @@ +# 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::BugmailFilter::FakeField; + +use strict; +use warnings; + +use Bugzilla::Extension::BugmailFilter::Constants; + +# object + +sub new { + my ($class, $params) = @_; + return bless($params, $class); +} + +sub name { $_[0]->{name} } +sub description { $_[0]->{description} } + +# static methods + +sub fake_fields { + my $cache = Bugzilla->request_cache->{bugmail_filter}; + if (!$cache->{fake_fields}) { + my @fields; + foreach my $rh (@{ FAKE_FIELD_NAMES() }) { + push @fields, Bugzilla::Extension::BugmailFilter::FakeField->new($rh); + } + $cache->{fake_fields} = \@fields; + } + return $cache->{fake_fields}; +} + +sub tracking_flag_fields { + my $cache = Bugzilla->request_cache->{bugmail_filter}; + if (!$cache->{tracking_flag_fields}) { + require Bugzilla::Extension::TrackingFlags::Constants; + my @fields; + my $tracking_types = Bugzilla::Extension::TrackingFlags::Constants::FLAG_TYPES(); + foreach my $tracking_type (@$tracking_types) { + push @fields, Bugzilla::Extension::BugmailFilter::FakeField->new({ + name => 'tracking.' . $tracking_type->{name}, + description => $tracking_type->{description}, + sortkey => $tracking_type->{sortkey}, + }); + } + $cache->{tracking_flag_fields} = \@fields; + } + return $cache->{tracking_flag_fields}; +} + +1; diff --git a/extensions/BugmailFilter/lib/Filter.pm b/extensions/BugmailFilter/lib/Filter.pm new file mode 100644 index 000000000..6246f51d9 --- /dev/null +++ b/extensions/BugmailFilter/lib/Filter.pm @@ -0,0 +1,212 @@ +# 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::BugmailFilter::Filter; + +use base qw(Bugzilla::Object); + +use strict; +use warnings; + +use Bugzilla::Component; +use Bugzilla::Error; +use Bugzilla::Extension::BugmailFilter::Constants; +use Bugzilla::Extension::BugmailFilter::FakeField; +use Bugzilla::Field; +use Bugzilla::Product; +use Bugzilla::User; +use Bugzilla::Util qw(trim); + +use constant DB_TABLE => 'bugmail_filters'; + +use constant DB_COLUMNS => qw( + id + user_id + product_id + component_id + field_name + relationship + changer_id + action +); + +use constant LIST_ORDER => 'id'; + +use constant UPDATE_COLUMNS => (); + +use constant VALIDATORS => { + user_id => \&_check_user, + field_name => \&_check_field_name, + action => \&Bugzilla::Object::check_boolean, +}; +use constant VALIDATOR_DEPENDENCIES => { + component_id => [ 'product_id' ], +}; + +use constant AUDIT_CREATES => 0; +use constant AUDIT_UPDATES => 0; +use constant AUDIT_REMOVES => 0; +use constant USE_MEMCACHED => 0; + +# getters + +sub user { + my ($self) = @_; + return Bugzilla::User->new({ id => $self->{user_id}, cache => 1 }); +} + +sub product { + my ($self) = @_; + return $self->{product_id} + ? Bugzilla::Product->new({ id => $self->{product_id}, cache => 1 }) + : undef; +} + +sub product_name { + my ($self) = @_; + return $self->{product_name} //= $self->{product_id} ? $self->product->name : ''; +} + +sub component { + my ($self) = @_; + return $self->{component_id} + ? Bugzilla::Component->new({ id => $self->{component_id}, cache => 1 }) + : undef; +} + +sub component_name { + my ($self) = @_; + return $self->{component_name} //= $self->{component_id} ? $self->component->name : ''; +} + +sub field_name { + return $_[0]->{field_name} //= ''; +} + +sub field_description { + my ($self, $value) = @_; + $self->{field_description} = $value if defined($value); + return $self->{field_description}; +} + +sub field { + my ($self) = @_; + return unless $self->{field_name}; + if (!$self->{field}) { + if (substr($self->{field_name}, 0, 1) eq '~') { + # this should never happen + die "not implemented"; + } + foreach my $field ( + @{ Bugzilla::Extension::BugmailFilter::FakeField->fake_fields() }, + @{ Bugzilla::Extension::BugmailFilter::FakeField->tracking_flag_fields() }, + ) { + if ($field->{name} eq $self->{field_name}) { + return $self->{field} = $field; + } + } + $self->{field} = Bugzilla::Field->new({ name => $self->{field_name}, cache => 1 }); + } + return $self->{field}; +} + +sub relationship { + return $_[0]->{relationship}; +} + +sub changer_id { + return $_[0]->{changer_id}; +} + +sub changer { + my ($self) = @_; + return $self->{changer_id} + ? Bugzilla::User->new({ id => $self->{changer_id}, cache => 1 }) + : undef; +} + +sub relationship_name { + my ($self) = @_; + foreach my $rel (@{ FILTER_RELATIONSHIPS() }) { + return $rel->{name} + if $rel->{value} == $self->{relationship}; + } + return '?'; +} + +sub is_exclude { + return $_[0]->{action} == 1; +} + +sub is_include { + return $_[0]->{action} == 0; +} + +# validators + +sub _check_user { + my ($class, $user) = @_; + $user || ThrowCodeError('param_required', { param => 'user' }); +} + +sub _check_field_name { + my ($class, $field_name) = @_; + return undef unless $field_name; + if (substr($field_name, 0, 1) eq '~') { + $field_name = lc(trim($field_name)); + $field_name =~ /^~[a-z0-9_\.\-]+$/ + || ThrowUserError('bugmail_filter_invalid'); + length($field_name) <= 64 + || ThrowUserError('bugmail_filter_too_long'); + return $field_name; + } + foreach my $rh (@{ FAKE_FIELD_NAMES() }) { + return $field_name if $rh->{name} eq $field_name; + } + return $field_name + if $field_name =~ /^tracking\./; + Bugzilla::Field->check({ name => $field_name, cache => 1}); + return $field_name; +} + +# methods + +sub matches { + my ($self, $args) = @_; + + if (my $field_name = $self->{field_name}) { + if ($args->{field}->{field_name} && substr($field_name, 0, 1) eq '~') { + my $substring = quotemeta(substr($field_name, 1)); + if ($args->{field}->{field_name} !~ /$substring/i) { + return 0; + } + } + elsif ($field_name ne $args->{field}->{filter_field}) { + return 0; + } + } + + if ($self->{product_id} && $self->{product_id} != $args->{product_id}) { + return 0; + } + + if ($self->{component_id} && $self->{component_id} != $args->{component_id}) { + return 0; + } + + if ($self->{relationship} && !$args->{rel_map}->[$self->{relationship}]) { + return 0; + } + + if ($self->{changer_id} && $self->{changer_id} != $args->{changer_id}) { + return 0; + } + + return 1; +} + +1; diff --git a/extensions/BugmailFilter/template/en/default/account/prefs/bugmail_filter.html.tmpl b/extensions/BugmailFilter/template/en/default/account/prefs/bugmail_filter.html.tmpl new file mode 100644 index 000000000..e7e0ed749 --- /dev/null +++ b/extensions/BugmailFilter/template/en/default/account/prefs/bugmail_filter.html.tmpl @@ -0,0 +1,392 @@ +[%# 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. + #%] + +<link href="[% "extensions/BugmailFilter/web/style/bugmail-filter.css" FILTER mtime %]" + rel="stylesheet" type="text/css"> +<script type="text/javascript" + src="[% "extensions/BugmailFilter/web/js/bugmail-filter.js" FILTER mtime %]"></script> + +[% SET selectable_products = user.get_selectable_products %] +[% SET dont_show_button = 1 %] + +<script> +var useclassification = false; +var first_load = true; +var last_sel = []; +var cpts = new Array(); +[% n = 1 %] +[% FOREACH prod = selectable_products %] + cpts['[% n %]'] = [ + [%- FOREACH comp = prod.components %]'[% comp.name FILTER js %]'[% ", " UNLESS loop.last %] [%- END -%] ]; + [% n = n + 1 %] +[% END %] +</script> +<script type="text/javascript" src="[% 'js/productform.js' FILTER mtime FILTER html %]"> +</script> + +<hr> +<b>Bugmail Filtering</b> + +<p> + You can instruct [% terms.Bugzilla %] to filter bugmail based on the field + that was changed. +</p> + +<table id="add_filter_table"> +<tr> + <th>Field:</th> + <td> + <select name="field" id="field"> + <option value="">__Any__</option> + [% FOREACH field = field_list %] + <option value="[% field.name FILTER html %]"> + [% field.description FILTER html %] + </option> + [% END %] + <option value="~">Contains:</option> + </select> + </td> + <td class="blurb"> + the field that was changed + </td> +</tr> +<tr id="field_contains_row" class="bz_default_hidden"> + <td> </td> + <td> + <input name="field_contains" id="field_contains" + placeholder="field name" maxlength="63"> + </td> +</tr> +<tr> + <th>Product:</th> + <td> + <select name="product" id="product"> + <option value="">__Any__</option> + [% FOREACH product IN selectable_products %] + <option>[% product.name FILTER html %]</option> + [% END %] + </select> + </td> + <td class="blurb"> + the [% terms.bug %]'s current product + </td> +</tr> +<tr> + <th>Component:</th> + <td> + <select name="component" id="component"> + <option value="">__Any__</option> + [% FOREACH product IN selectable_products %] + [% FOREACH component IN product.components %] + <option>[% component.name FILTER html %]</option> + [% END %] + [% END %] + </select> + </td> + <td class="blurb"> + the [% terms.bug %]'s current component + </td> +</tr> +<tr> + <th>Relationship:</th> + <td> + <select name="relationship" id="relationship"> + <option value="">__Any__</option> + [% FOREACH rel IN relationships %] + <option value="[% rel.value FILTER html %]"> + [% rel.name FILTER html %] + </option> + [% END %] + </select> + </td> + <td class="blurb"> + your relationship with the [% terms.bug %] + </td> +</tr> +<tr> + <th>Changer:</th> + <td> + [% INCLUDE global/userselect.html.tmpl + id => "changer" + name => "changer" + size => 32 + emptyok => 1 + %] + </td> + <td class="blurb"> + the person who made the change (leave empty for "anyone") + </td> +</tr> +<tr> + <th>Action:</th> + <td> + <select name="action" id="action"> + <option></option> + <option>Exclude</option> + <option>Include</option> + </select> + </td> + <td class="blurb"> + action to take when all conditions match + </td> +</tr> +<tr> + <td></td> + <td><input type="submit" id="add_filter" name="add_filter" value="Add"></td> +</tr> +</table> + +<hr> +<p> + You are currently filtering on: +</p> + +[% IF filters.size %] + + <table id="filters_table"> + <tr> + <td></td> + <th>Product</th> + <th>Component</th> + <th>Field</th> + <th>Relationship</th> + <th>Changer</th> + <th>Action</th> + </tr> + [% FOREACH filter = filters %] + <tr class="[% "row_odd" UNLESS loop.count % 2 %]"> + <td> + <input type="checkbox" name="remove" value="[% filter.id FILTER none %]" + onChange="onFilterRemoveChange()"> + </td> + <td>[% filter.product ? filter.product.name : 'Any' FILTER html %]</td> + <td>[% filter.component ? filter.component.name : 'Any' FILTER html %]</td> + <td>[% filter.field_description FILTER html %]</td> + <td>[% filter.relationship ? filter.relationship_name : 'Any' FILTER html %]</td> + <td> + [% IF filter.changer %] + <span title="[% filter.changer.name FILTER html %]"> + [% filter.changer.login FILTER html %] + </span> + [% ELSE %] + Anyone + [% END %] + </td> + <td>[% filter.action ? 'Exclude' : 'Include' %]</td> + </tr> + [% END %] + <tr> + <td></td> + <td><input id="remove" name="remove_filter" type="submit" value="Remove Selected"></td> + </tr> + </table> + +[% ELSE %] + + <p> + <i>You do not have any filters configured.</i> + </p> + +[% END %] + +<hr> +<p> + This feature provides fine-grained control over what changes to [% terms.bugs + %] will result in an email notification. These filters are applied + <b>after</b> the rules configured on the + <a href="userprefs.cgi?tab=email">Email Preferences</a> tab. +</p> +<p> + If multiple filters are applicable to the same [% terms.bug %] change, + <b>include</b> filters override <b>exclude</b> filters. +</p> + +<hr> +<h4>Field Groups</h4> + +<p> + Some fields are grouped into a single entry in the "field" list. + Following is a list of the groups and all the fields they contain: +</p> + +[% FOREACH type = tracking_flags_by_type %] + [% type.name FILTER html %]: + <blockquote> + [% flag_count = type.flags.size %] + [% FOREACH flag = type.flags %] + [% IF flag_count > 10 && loop.count == 10 %] + <span id="show_all"> + … + (<a href="#" onclick="showAllFlags(); return false">show all</a>) + </span> + <span id="all_flags" class="bz_default_hidden"> + [% END %] + [% flag.description FILTER html FILTER no_break %] + [% ", " UNLESS loop.last %] + [% IF loop.last && flag_count > 10 %] + </span> + [% END %] + [% END %] + </blockquote> +[% END %] + +<hr> +<h4>Examples</h4> +<p> + To never receive changes made to the "QA Whiteboard" field for [% terms.bugs %] + where you are not the assignee:<br> +</p> +<table class="example_filter_table"> + <tr> + <th>Field:</th> + <td>QA Whiteboard</td> + </tr> + <tr> + <th>Product:</th> + <td>__Any__</td> + </tr> + <tr> + <th>Component:</th> + <td>__Any__</td> + </tr> + <tr> + <th>Relationship:</th> + <td>Not Assignee</td> + </tr> + <tr> + <th>Changer:</th> + <td>(empty)</td> + </tr> + <tr> + <th>Action:</th> + <td>Exclude</td> + </tr> +</table> + +<p> + To never receive email for any change made by webops-kanban@mozilla.bugs: +</p> +<table class="example_filter_table"> + <tr> + <th>Field:</th> + <td>__Any__</td> + </tr> + <tr> + <th>Product:</th> + <td>__Any__</td> + </tr> + <tr> + <th>Component:</th> + <td>__Any__</td> + </tr> + <tr> + <th>Relationship:</th> + <td>__Any__</td> + </tr> + <tr> + <th>Changer:</th> + <td>webops-kanban@mozilla.bugs</td> + </tr> + <tr> + <th>Action:</th> + <td>Exclude</td> + </tr> +</table> + +<p> + To receive notifications of new [% terms.bugs %] in Firefox's "New Tab Page" + component, and no other changes, you require three filters. First an + <b>exclude</b> filter to drop all changes made to [% terms.bugs %] in that + component:<br> +</p> +<table class="example_filter_table"> + <tr> + <th>Field:</th> + <td>__Any__</td> + </tr> + <tr> + <th>Product:</th> + <td>Firefox</td> + </tr> + <tr> + <th>Component:</th> + <td>New Tab Page</td> + </tr> + <tr> + <th>Relationship:</th> + <td>__Any__</td> + </tr> + <tr> + <th>Changer:</th> + <td>(empty)</td> + </tr> + <tr> + <th>Action:</th> + <td>Exclude</td> + </tr> +</table> +<p> + Then an <b>include</b> filter to indicate that you want to receive + notifications when a [% terms.bug %] is created: +</p> +<table class="example_filter_table"> + <tr> + <th>Field:</th> + <td>[% terms.Bug %] Created</td> + </tr> + <tr> + <th>Product:</th> + <td>Firefox</td> + </tr> + <tr> + <th>Component:</th> + <td>New Tab Page</td> + </tr> + <tr> + <th>Relationship:</th> + <td>__Any__</td> + </tr> + <tr> + <th>Changer:</th> + <td>(empty)</td> + </tr> + <tr> + <th>Action:</th> + <td>Include</td> + </tr> +</table> +<p> + And finally another <b>include</b> filter to catch when a [% terms.bug %] is + moved into the "New Tab Page" component. +</p> +<table class="example_filter_table"> + <tr> + <th>Field:</th> + <td>Component</td> + </tr> + <tr> + <th>Product:</th> + <td>Firefox</td> + </tr> + <tr> + <th>Component:</th> + <td>New Tab Page</td> + </tr> + <tr> + <th>Relationship:</th> + <td>__Any__</td> + </tr> + <tr> + <th>Changer:</th> + <td>(empty)</td> + </tr> + <tr> + <th>Action:</th> + <td>Include</td> + </tr> +</table> diff --git a/extensions/BugmailFilter/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl b/extensions/BugmailFilter/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl new file mode 100644 index 000000000..95ffdee99 --- /dev/null +++ b/extensions/BugmailFilter/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl @@ -0,0 +1,14 @@ +[%# 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. + #%] + +[% tabs = tabs.import([{ + name => "bugmail_filter", + label => "Bugmail Filtering", + link => "userprefs.cgi?tab=bugmail_filter", + saveable => 1 + }]) %] diff --git a/extensions/BugmailFilter/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/BugmailFilter/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..a0ec2125f --- /dev/null +++ b/extensions/BugmailFilter/template/en/default/hook/global/user-error-errors.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. + #%] + +[% IF error == "bugmail_filter_exists" %] + [% title = "Filter Already Exists" %] + A filter already exists with the selected criteria. + +[% ELSIF error == "bugmail_filter_too_long" %] + [% title = "Invalid Field Name" %] + The field name to filter on is too long (must be 63 character or fewer). + +[% ELSIF error == "bugmail_filter_invalid" %] + [% title = "Invalid Field Name" %] + The field name contains invalid characters (alpha-numeric, underscore, + hyphen, and period only). + +[% END %] diff --git a/extensions/BugmailFilter/web/js/bugmail-filter.js b/extensions/BugmailFilter/web/js/bugmail-filter.js new file mode 100644 index 000000000..c24528861 --- /dev/null +++ b/extensions/BugmailFilter/web/js/bugmail-filter.js @@ -0,0 +1,60 @@ +/* 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; + +function onFilterFieldChange() { + if (Dom.get('field').value == '~') { + Dom.removeClass('field_contains_row', 'bz_default_hidden'); + Dom.get('field_contains').focus(); + Dom.get('field_contains').select(); + } + else { + Dom.addClass('field_contains_row', 'bz_default_hidden'); + } +} + +function onFilterProductChange() { + selectProduct(Dom.get('product'), Dom.get('component'), null, null, '__Any__'); + Dom.get('component').disabled = Dom.get('product').value == ''; +} + +function setFilterAddEnabled() { + Dom.get('add_filter').disabled = + ( + Dom.get('field').value == '~' + && Dom.get('field_contains').value == '' + ) + || Dom.get('action').value == ''; +} + +function onFilterRemoveChange() { + var cbs = Dom.get('filters_table').getElementsByTagName('input'); + for (var i = 0, l = cbs.length; i < l; i++) { + if (cbs[i].checked) { + Dom.get('remove').disabled = false; + return; + } + } + Dom.get('remove').disabled = true; +} + +function showAllFlags() { + Dom.addClass('show_all', 'bz_default_hidden'); + Dom.removeClass('all_flags', 'bz_default_hidden'); +} + +YAHOO.util.Event.onDOMReady(function() { + YAHOO.util.Event.on('field', 'change', onFilterFieldChange); + YAHOO.util.Event.on('field_contains', 'keyup', setFilterAddEnabled); + YAHOO.util.Event.on('product', 'change', onFilterProductChange); + YAHOO.util.Event.on('action', 'change', setFilterAddEnabled); + onFilterFieldChange(); + onFilterProductChange(); + onFilterRemoveChange(); + setFilterAddEnabled(); +}); diff --git a/extensions/BugmailFilter/web/style/bugmail-filter.css b/extensions/BugmailFilter/web/style/bugmail-filter.css new file mode 100644 index 000000000..193cf4469 --- /dev/null +++ b/extensions/BugmailFilter/web/style/bugmail-filter.css @@ -0,0 +1,44 @@ +/* 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. */ + +#add_filter_table th { + text-align: right; +} + +.example_filter_table { + margin-left: 2em; +} + +.example_filter_table th { + text-align: right; + font-weight: normal; +} + +.example_filter_table td { + padding-left: 1em; +} + +#add_filter_table .blurb { + font-style: italic; + padding-left: 2em; +} + +#filters_table { + margin-bottom: 1em; + border-spacing: 0; +} + +#filters_table th, #filters_table td { + text-align: left; + padding-right: 1em; + padding: 2px; +} + +#filters_table .row_odd { + background-color: #eeeeee; + color: #000000; +} diff --git a/extensions/BzAPI/Config.pm b/extensions/BzAPI/Config.pm new file mode 100644 index 000000000..89b8c1e02 --- /dev/null +++ b/extensions/BzAPI/Config.pm @@ -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. + +package Bugzilla::Extension::BzAPI; + +use strict; + +use constant NAME => 'BzAPI'; + +use constant REQUIRED_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/BzAPI/Extension.pm b/extensions/BzAPI/Extension.pm new file mode 100644 index 000000000..cd08369b0 --- /dev/null +++ b/extensions/BzAPI/Extension.pm @@ -0,0 +1,267 @@ +# 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::BzAPI; + +use strict; +use base qw(Bugzilla::Extension); + +use Bugzilla::Extension::BzAPI::Constants; +use Bugzilla::Extension::BzAPI::Util qw(fix_credentials filter_wants_nocache); + +use Bugzilla::Error; +use Bugzilla::Util qw(trick_taint datetime_from); +use Bugzilla::Constants; +use Bugzilla::Install::Filesystem; + +use File::Basename; + +our $VERSION = '0.1'; + +################ +# Installation # +################ + +sub install_filesystem { + my ($self, $args) = @_; + my $files = $args->{'files'}; + + my $extensionsdir = bz_locations()->{'extensionsdir'}; + my $scriptname = $extensionsdir . "/" . __PACKAGE__->NAME . "/bin/rest.cgi"; + + $files->{$scriptname} = { + perms => Bugzilla::Install::Filesystem::WS_EXECUTE + }; +} + +################## +# Template Hooks # +################## + +sub template_before_process { + my ($self, $args) = @_; + my $vars = $args->{'vars'}; + my $file = $args->{'file'}; + + if ($file =~ /config\.json\.tmpl$/) { + $vars->{'initial_status'} = Bugzilla::Status->can_change_to; + $vars->{'status_objects'} = [ Bugzilla::Status->get_all ]; + } +} + +############## +# Code Hooks # +############## + +sub bug_start_of_update { + my ($self, $args) = @_; + my $old_bug = $args->{old_bug}; + my $params = Bugzilla->input_params; + + return if !Bugzilla->request_cache->{bzapi}; + + # Check for a mid-air collision. Currently this only works when updating + # an individual bug and if last_changed_time is provided. Otherwise it + # allows the changes. + my $delta_ts = $params->{last_change_time} || ''; + + if ($delta_ts && exists $params->{ids} && @{ $params->{ids} } == 1) { + _midair_check($delta_ts, $old_bug->delta_ts); + } +} + +sub object_end_of_set_all { + my ($self, $args) = @_; + my $object = $args->{object}; + my $params = Bugzilla->input_params; + + return if !Bugzilla->request_cache->{bzapi}; + return if !$object->isa('Bugzilla::Attachment'); + + # Check for a mid-air collision. Currently this only works when updating + # an individual attachment and if last_changed_time is provided. Otherwise it + # allows the changes. + my $stash = Bugzilla->request_cache->{bzapi_stash} ||= {}; + my $delta_ts = $stash->{last_change_time}; + + _midair_check($delta_ts, $object->modification_time) if $delta_ts; +} + +sub _midair_check { + my ($delta_ts, $old_delta_ts) = @_; + my $delta_ts_z = datetime_from($delta_ts) + || ThrowCodeError('invalid_timestamp', { timestamp => $delta_ts }); + my $old_delta_tz_z = datetime_from($old_delta_ts); + if ($old_delta_tz_z ne $delta_ts_z) { + ThrowUserError('bzapi_midair_collision'); + } +} + +sub webservice_error_codes { + my ($self, $args) = @_; + my $error_map = $args->{error_map}; + $error_map->{'bzapi_midair_collision'} = 400; +} + +sub webservice_fix_credentials { + my ($self, $args) = @_; + my $rpc = $args->{rpc}; + my $params = $args->{params}; + return if !Bugzilla->request_cache->{bzapi}; + fix_credentials($params); +} + +sub webservice_rest_request { + my ($self, $args) = @_; + my $rpc = $args->{rpc}; + my $params = $args->{params}; + my $cache = Bugzilla->request_cache; + + return if !$cache->{bzapi}; + + # Stash certain values for later use + $cache->{bzapi_rpc} = $rpc; + + # Internal websevice method being used + $cache->{bzapi_rpc_method} = $rpc->path_info . "." . $rpc->bz_method_name; + + # Load the appropriate request handler based on path and type + if (my $handler = _find_handler($rpc, 'request')) { + &$handler($params); + } +} + +sub webservice_rest_response { + my ($self, $args) = @_; + my $rpc = $args->{rpc}; + my $result = $args->{result}; + my $response = $args->{response}; + my $cache = Bugzilla->request_cache; + + # Stash certain values for later use + $cache->{bzapi_rpc} ||= $rpc; + + return if !Bugzilla->request_cache->{bzapi} + || ref $$result ne 'HASH'; + + if (exists $$result->{error}) { + $$result->{documentation} = BZAPI_DOC; + return; + } + + # Load the appropriate response handler based on path and type + if (my $handler = _find_handler($rpc, 'response')) { + &$handler($result, $response); + } +} + +sub webservice_rest_resources { + my ($self, $args) = @_; + my $rpc = $args->{rpc}; + my $resources = $args->{resources}; + + return if !Bugzilla->request_cache->{bzapi}; + + _add_resources($rpc, $resources); +} + +##################### +# Utility Functions # +##################### + +sub _find_handler { + my ($rpc, $type) = @_; + + my $path_info = $rpc->cgi->path_info; + my $request_method = $rpc->request->method; + + my $module = $rpc->bz_class_name || ''; + $module =~ s/^Bugzilla::WebService:://; + + my $cache = _preload_handlers(); + + return undef if !exists $cache->{$module}; + + # Make a copy of the handler array so + # as to not alter the actual cached data. + my @handlers = @{ $cache->{$module} }; + + while (my $regex = shift @handlers) { + my $data = shift @handlers; + next if ref $data ne 'HASH'; + if ($path_info =~ $regex + && exists $data->{$request_method} + && exists $data->{$request_method}->{$type}) + { + return $data->{$request_method}->{$type}; + } + } + + return undef; +} + +sub _add_resources { + my ($rpc, $native_resources) = @_; + + my $cache = _preload_handlers(); + + foreach my $module (keys %$cache) { + my $native_module = "Bugzilla::WebService::$module"; + next if !$native_resources->{$native_module}; + + # Make a copy of the handler array so + # as to not alter the actual cached data. + my @handlers = @{ $cache->{$module} }; + + my @ext_resources = (); + while (my $regex = shift @handlers) { + my $data = shift @handlers; + next if ref $data ne 'HASH'; + my $new_data = {}; + foreach my $request_method (keys %$data) { + next if !exists $data->{$request_method}->{resource}; + $new_data->{$request_method} = $data->{$request_method}->{resource}; + } + push(@ext_resources, $regex, $new_data); + } + + # Places the new resources at the beginning of the list + # so we can capture specific paths before the native resources + unshift(@{$native_resources->{$native_module}}, @ext_resources); + } +} + +sub _resource_modules { + my $extdir = bz_locations()->{extensionsdir}; + return map { basename($_, '.pm') } glob("$extdir/" . __PACKAGE__->NAME . "/lib/Resources/*.pm"); +} + +# preload all handlers into cache +# since we don't want to parse all +# this multiple times +sub _preload_handlers { + my $cache = Bugzilla->request_cache; + + if (!exists $cache->{rest_handlers}) { + my $all_handlers = {}; + foreach my $module (_resource_modules()) { + my $resource_class = "Bugzilla::Extension::BzAPI::Resources::$module"; + trick_taint($resource_class); + eval("require $resource_class"); + warn $@ if $@; + next if ($@ || !$resource_class->can('rest_handlers')); + my $handlers = $resource_class->rest_handlers; + next if (ref $handlers ne 'ARRAY' || scalar @$handlers % 2 != 0); + $all_handlers->{$module} = $handlers; + } + $cache->{rest_handlers} = $all_handlers; + } + + return $cache->{rest_handlers}; +} + +__PACKAGE__->NAME; diff --git a/extensions/BzAPI/bin/rest.cgi b/extensions/BzAPI/bin/rest.cgi new file mode 100755 index 000000000..37cbab437 --- /dev/null +++ b/extensions/BzAPI/bin/rest.cgi @@ -0,0 +1,34 @@ +#!/usr/bin/perl -wT +# 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 lib qw(../../.. ../../../lib); + +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::WebService::Constants; +BEGIN { + if (!Bugzilla->feature('rest') + || !Bugzilla->feature('jsonrpc')) + { + ThrowUserError('feature_disabled', { feature => 'rest' }); + } +} + +# Set request_cache bzapi value to true in order to enable the +# BzAPI extension functionality +Bugzilla->request_cache->{bzapi} = 1; + +use Bugzilla::WebService::Server::REST; +Bugzilla->usage_mode(USAGE_MODE_REST); +local @INC = (bz_locations()->{extensionsdir}, @INC); +my $server = new Bugzilla::WebService::Server::REST; +$server->version('1.1'); +$server->handle(); diff --git a/extensions/BzAPI/lib/Constants.pm b/extensions/BzAPI/lib/Constants.pm new file mode 100644 index 000000000..65ae00480 --- /dev/null +++ b/extensions/BzAPI/lib/Constants.pm @@ -0,0 +1,155 @@ +# 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::BzAPI::Constants; + +use strict; + +use base qw(Exporter); +our @EXPORT = qw( + USER_FIELDS + BUG_FIELD_MAP + BOOLEAN_TYPE_MAP + ATTACHMENT_FIELD_MAP + DEFAULT_BUG_FIELDS + DEFAULT_ATTACHMENT_FIELDS + + BZAPI_DOC +); + +# These are fields that are normally exported as a single value such +# as the user's email. BzAPI needs to convert them to user objects +# where possible. +use constant USER_FIELDS => (qw( + assigned_to + cc + creator + qa_contact + reporter +)); + +# Convert old field names from old to new +use constant BUG_FIELD_MAP => { + 'opendate' => 'creation_time', # query + 'creation_ts' => 'creation_time', + 'changeddate' => 'last_change_time', # query + 'delta_ts' => 'last_change_time', + 'bug_id' => 'id', + 'rep_platform' => 'platform', + 'bug_severity' => 'severity', + 'bug_status' => 'status', + 'short_desc' => 'summary', + 'bug_file_loc' => 'url', + 'status_whiteboard' => 'whiteboard', + 'reporter' => 'creator', + 'reporter_realname' => 'creator_realname', + 'cclist_accessible' => 'is_cc_accessible', + 'reporter_accessible' => 'is_creator_accessible', + 'everconfirmed' => 'is_confirmed', + 'dependson' => 'depends_on', + 'blocked' => 'blocks', + 'attachment' => 'attachments', + 'flag' => 'flags', + 'flagtypes.name' => 'flag', + 'bug_group' => 'group', + 'group' => 'groups', + 'longdesc' => 'comment', + 'bug_file_loc_type' => 'url_type', + 'bugidtype' => 'id_mode', + 'longdesc_type' => 'comment_type', + 'short_desc_type' => 'summary_type', + 'status_whiteboard_type' => 'whiteboard_type', + 'emailassigned_to1' => 'email1_assigned_to', + 'emailassigned_to2' => 'email2_assigned_to', + 'emailcc1' => 'email1_cc', + 'emailcc2' => 'email2_cc', + 'emailqa_contact1' => 'email1_qa_contact', + 'emailqa_contact2' => 'email2_qa_contact', + 'emailreporter1' => 'email1_creator', + 'emailreporter2' => 'email2_creator', + 'emaillongdesc1' => 'email1_comment_creator', + 'emaillongdesc2' => 'email2_comment_creator', + 'emailtype1' => 'email1_type', + 'emailtype2' => 'email2_type', + 'chfieldfrom' => 'changed_after', + 'chfieldto' => 'changed_before', + 'chfield' => 'changed_field', + 'chfieldvalue' => 'changed_field_to', + 'deadlinefrom' => 'deadline_after', + 'deadlineto' => 'deadline_before', + 'attach_data.thedata' => 'attachment.data', + 'longdescs.isprivate' => 'comment.is_private', + 'commenter' => 'comment.creator', + 'requestees.login_name' => 'flag.requestee', + 'setters.login_name' => 'flag.setter', + 'days_elapsed' => 'idle', + 'owner_idle_time' => 'assignee_idle', + 'dup_id' => 'dupe_of', + 'isopened' => 'is_open', + 'flag_type' => 'flag_types', + 'attachments.submitter' => 'attachment.attacher', + 'attachments.filename' => 'attachment.file_name', + 'attachments.description' => 'attachment.description', + 'attachments.delta_ts' => 'attachment.last_change_time', + 'attachments.isobsolete' => 'attachment.is_obsolete', + 'attachments.ispatch' => 'attachment.is_patch', + 'attachments.isprivate' => 'attachment.is_private', + 'attachments.mimetype' => 'attachment.content_type', + 'attachments.date' => 'attachment.creation_time', + 'attachments.attachid' => 'attachment.id', + 'attachments.flag' => 'attachment.flags', + 'attachments.token' => 'attachment.update_token' +}; + +# Convert from old boolean chart type names to new names +use constant BOOLEAN_TYPE_MAP => { + 'equals' => 'equals', + 'not_equals' => 'notequals', + 'equals_any' => 'anyexact', + 'contains' => 'substring', + 'not_contains' => 'notsubstring', + 'case_contains' => 'casesubstring', + 'contains_any' => 'anywordssubstr', + 'not_contains_any' => 'nowordssubstr', + 'contains_all' => 'allwordssubstr', + 'contains_any_words' => 'anywords', + 'not_contains_any_words' => 'nowords', + 'contains_all_words' => 'allwords', + 'regex' => 'regexp', + 'not_regex' => 'notregexp', + 'less_than' => 'lessthan', + 'greater_than' => 'greaterthan', + 'changed_before' => 'changedbefore', + 'changed_after' => 'changedafter', + 'changed_from' => 'changedfrom', + 'changed_to' => 'changedto', + 'changed_by' => 'changedby', + 'matches' => 'matches' +}; + +# Convert old attachment field names from old to new +use constant ATTACHMENT_FIELD_MAP => { + 'submitter' => 'attacher', + 'description' => 'description', + 'filename' => 'file_name', + 'delta_ts' => 'last_change_time', + 'isobsolete' => 'is_obsolete', + 'ispatch' => 'is_patch', + 'isprivate' => 'is_private', + 'mimetype' => 'content_type', + 'contenttypeentry' => 'content_type', + 'date' => 'creation_time', + 'attachid' => 'id', + 'desc' => 'description', + 'flag' => 'flags', + 'type' => 'content_type', +}; + +# A base link to the current BzAPI Documentation. +use constant BZAPI_DOC => 'https://wiki.mozilla.org/Bugzilla:BzAPI'; + +1; diff --git a/extensions/BzAPI/lib/Resources/Bug.pm b/extensions/BzAPI/lib/Resources/Bug.pm new file mode 100644 index 000000000..77b567421 --- /dev/null +++ b/extensions/BzAPI/lib/Resources/Bug.pm @@ -0,0 +1,867 @@ +# 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::BzAPI::Resources::Bug; + +use 5.10.1; +use strict; + +use Bugzilla::Bug; +use Bugzilla::Error; +use Bugzilla::Token qw(issue_hash_token); +use Bugzilla::Util qw(trick_taint diff_arrays); +use Bugzilla::WebService::Constants; + +use Bugzilla::Extension::BzAPI::Util; +use Bugzilla::Extension::BzAPI::Constants; + +use List::MoreUtils qw(uniq); +use List::Util qw(max); + +################# +# REST Handlers # +################# + +BEGIN { + require Bugzilla::WebService::Bug; + *Bugzilla::WebService::Bug::get_bug_count = \&get_bug_count_resource; +} + +sub rest_handlers { + my $rest_handlers = [ + qr{^/bug$}, { + GET => { + request => \&search_bugs_request, + response => \&search_bugs_response + }, + POST => { + request => \&create_bug_request, + response => \&create_bug_response + } + }, + qr{^/bug/([^/]+)$}, { + GET => { + response => \&get_bug_response + }, + PUT => { + request => \&update_bug_request, + response => \&update_bug_response + } + }, + qr{^/bug/([^/]+)/comment$}, { + GET => { + response => \&get_comments_response + }, + POST => { + request => \&add_comment_request, + response => \&add_comment_response + } + }, + qr{^/bug/([^/]+)/history$}, { + GET => { + response => \&get_history_response + } + }, + qr{^/bug/([^/]+)/attachment$}, { + GET => { + response => \&get_attachments_response + }, + POST => { + request => \&add_attachment_request, + response => \&add_attachment_response + } + }, + qr{^/bug/attachment/([^/]+)$}, { + GET => { + response => \&get_attachment_response + }, + PUT => { + request => \&update_attachment_request, + response => \&update_attachment_response + } + }, + qr{^/attachment/([^/]+)$}, { + GET => { + response => \&get_attachment_response + }, + PUT => { + request => \&update_attachment_request, + response => \&update_attachment_response + } + }, + qr{^/bug/([^/]+)/flag$}, { + GET => { + resource => { + method => 'get', + params => sub { + return { ids => [ $_[0] ], + include_fields => ['flags'] }; + } + }, + response => \&get_bug_flags_response, + } + }, + qr{^/count$}, { + GET => { + resource => { + method => 'get_bug_count' + } + } + }, + qr{^/attachment/([^/]+)$}, { + GET => { + resource => { + method => 'attachments', + params => sub { + return { attachment_ids => [ $_[0] ] }; + } + } + }, + PUT => { + resource => { + method => 'update_attachment', + params => sub { + return { ids => [ $_[0] ] }; + } + } + } + } + ]; + return $rest_handlers; +} + +######################### +# REST Resource Methods # +######################### + +# Return bug counts based on row/col/table fields +# FIXME Borrowed a lot of code from report.cgi, eventually +# this should be broken into it's own module so that report.cgi +# and here can share the same code. +sub get_bug_count_resource { + my ($self, $params) = @_; + + Bugzilla->switch_to_shadow_db(); + + my $col_field = $params->{x_axis_field} || ''; + my $row_field = $params->{y_axis_field} || ''; + my $tbl_field = $params->{z_axis_field} || ''; + + my $dimensions = $col_field ? + $row_field ? + $tbl_field ? 3 : 2 : 1 : 0; + + if ($dimensions == 0) { + $col_field = "bug_status"; + $params->{x_axis_field} = "bug_status"; + } + + # Valid bug fields that can be reported on. + my $valid_columns = Bugzilla::Search::REPORT_COLUMNS; + + # Convert external names to internal if necessary + $params = Bugzilla::Bug::map_fields($params); + $row_field = Bugzilla::Bug::FIELD_MAP->{$row_field} || $row_field; + $col_field = Bugzilla::Bug::FIELD_MAP->{$col_field} || $col_field; + $tbl_field = Bugzilla::Bug::FIELD_MAP->{$tbl_field} || $tbl_field; + + # Validate the values in the axis fields or throw an error. + !$row_field + || ($valid_columns->{$row_field} && trick_taint($row_field)) + || ThrowCodeError("report_axis_invalid", { fld => "x", val => $row_field }); + !$col_field + || ($valid_columns->{$col_field} && trick_taint($col_field)) + || ThrowCodeError("report_axis_invalid", { fld => "y", val => $col_field }); + !$tbl_field + || ($valid_columns->{$tbl_field} && trick_taint($tbl_field)) + || ThrowCodeError("report_axis_invalid", { fld => "z", val => $tbl_field }); + + my @axis_fields = grep { $_ } ($row_field, $col_field, $tbl_field); + + my $search = new Bugzilla::Search( + fields => \@axis_fields, + params => $params, + allow_unlimited => 1, + ); + + my ($results, $extra_data) = $search->data; + + # We have a hash of hashes for the data itself, and a hash to hold the + # row/col/table names. + my %data; + my %names; + + # Read the bug data and count the bugs for each possible value of row, column + # and table. + # + # We detect a numerical field, and sort appropriately, if all the values are + # numeric. + my $col_isnumeric = 1; + my $row_isnumeric = 1; + my $tbl_isnumeric = 1; + + foreach my $result (@$results) { + # handle empty dimension member names + my $row = check_value($row_field, $result); + my $col = check_value($col_field, $result); + my $tbl = check_value($tbl_field, $result); + + $data{$tbl}{$col}{$row}++; + $names{"col"}{$col}++; + $names{"row"}{$row}++; + $names{"tbl"}{$tbl}++; + + $col_isnumeric &&= ($col =~ /^-?\d+(\.\d+)?$/o); + $row_isnumeric &&= ($row =~ /^-?\d+(\.\d+)?$/o); + $tbl_isnumeric &&= ($tbl =~ /^-?\d+(\.\d+)?$/o); + } + + my @col_names = get_names($names{"col"}, $col_isnumeric, $col_field); + my @row_names = get_names($names{"row"}, $row_isnumeric, $row_field); + my @tbl_names = get_names($names{"tbl"}, $tbl_isnumeric, $tbl_field); + + push(@tbl_names, "-total-") if (scalar(@tbl_names) > 1); + + my @data; + foreach my $tbl (@tbl_names) { + my @tbl_data; + foreach my $row (@row_names) { + my @col_data; + foreach my $col (@col_names) { + $data{$tbl}{$col}{$row} = $data{$tbl}{$col}{$row} || 0; + push(@col_data, $data{$tbl}{$col}{$row}); + if ($tbl ne "-total-") { + # This is a bit sneaky. We spend every loop except the last + # building up the -total- data, and then last time round, + # we process it as another tbl, and push() the total values + # into the image_data array. + $data{"-total-"}{$col}{$row} += $data{$tbl}{$col}{$row}; + } + } + push(@tbl_data, \@col_data); + } + push(@data, \@tbl_data); + } + + my $result = {}; + if ($dimensions == 0) { + my $sum = 0; + + # If the search returns no results, we just get an 0-byte file back + # and so there is no data at all. + if (@data) { + foreach my $value (@{ $data[0][0] }) { + $sum += $value; + } + } + + $result = { + 'data' => $sum + }; + } + elsif ($dimensions == 1) { + $result = { + 'x_labels' => \@col_names, + 'data' => $data[0][0] || [] + }; + } + elsif ($dimensions == 2) { + $result = { + 'x_labels' => \@col_names, + 'y_labels' => \@row_names, + 'data' => $data[0] || [[]] + }; + } + elsif ($dimensions == 3) { + if (@data > 1 && $tbl_names[-1] eq "-total-") { + # Last table is a total, which we discard + pop(@data); + pop(@tbl_names); + } + + $result = { + 'x_labels' => \@col_names, + 'y_labels' => \@row_names, + 'z_labels' => \@tbl_names, + 'data' => @data ? \@data : [[[]]] + }; + } + + return $result; +} + +sub get_names { + my ($names, $isnumeric, $field_name) = @_; + my ($field, @sorted); + # XXX - This is a hack to handle the actual_time/work_time field, + # because it's named 'actual_time' in Search.pm but 'work_time' in Field.pm. + $_[2] = $field_name = 'work_time' if $field_name eq 'actual_time'; + + # _realname fields aren't real Bugzilla::Field objects, but they are a + # valid axis, so we don't vailidate them as Bugzilla::Field objects. + $field = Bugzilla::Field->check($field_name) + if ($field_name && $field_name !~ /_realname$/); + + if ($field && $field->is_select) { + foreach my $value (@{$field->legal_values}) { + push(@sorted, $value->name) if $names->{$value->name}; + } + unshift(@sorted, '---') if $field_name eq 'resolution'; + @sorted = uniq @sorted; + } + elsif ($isnumeric) { + # It's not a field we are preserving the order of, so sort it + # numerically... + @sorted = sort { $a <=> $b } keys %$names; + } + else { + # ...or alphabetically, as appropriate. + @sorted = sort keys %$names; + } + + return @sorted; +} + +sub check_value { + my ($field, $result) = @_; + + my $value; + if (!defined $field) { + $value = ''; + } + elsif ($field eq '') { + $value = ' '; + } + else { + $value = shift @$result; + $value = ' ' if (!defined $value || $value eq ''); + $value = '---' if ($field eq 'resolution' && $value eq ' '); + } + return $value; +} + +######################## +# REST Request Methods # +######################## + +sub search_bugs_request { + my ($params) = @_; + + if (defined $params->{changed_field} + && $params->{changed_field} eq "creation_time") + { + $params->{changed_field} = "[Bug creation]"; + } + + my $FIELD_NEW_TO_OLD = { reverse %{ BUG_FIELD_MAP() } }; + + # Update values of various forms. + foreach my $key (keys %$params) { + # First, search types. These are found in the value of any field ending + # _type, and the value of any field matching type\d-\d-\d. + if ($key =~ /^type(\d+)-(\d+)-(\d+)$|_type$/) { + $params->{$key} + = BOOLEAN_TYPE_MAP->{$params->{$key}} || $params->{$key}; + } + + # Field names hiding in values instead of keys: changed_field, boolean + # charts and axis names. + if ($key =~ /^(field\d+-\d+-\d+| + changed_field| + (x|y|z)_axis_field)$ + /x) { + $params->{$key} + = $FIELD_NEW_TO_OLD->{$params->{$key}} || $params->{$key}; + } + } + + # Update field names + foreach my $field (keys %$FIELD_NEW_TO_OLD) { + if (defined $params->{$field}) { + $params->{$FIELD_NEW_TO_OLD->{$field}} = delete $params->{$field}; + } + } + + if (exists $params->{bug_id_type}) { + $params->{bug_id_type} + = BOOLEAN_TYPE_MAP->{$params->{bug_id_type}} || $params->{bug_id_type}; + } + + # Time field names are screwy, and got reused. We can't put this mapping + # in NEW2OLD as everything will go haywire. actual_time has to be queried + # as work_time even though work_time is the submit-only field for _adding_ + # to actual_time, which can't be arbitrarily manipulated. + if (defined $params->{work_time}) { + $params->{actual_time} = delete $params->{work_time}; + } + + # Other convenience search ariables used by BzAPI + my @field_ids = grep(/^f(\d+)$/, keys %$params); + my $last_field_id = @field_ids ? max @field_ids + 1 : 1; + foreach my $field (qw(setters.login_name requestees.login_name)) { + if (my $value = delete $params->{$field}) { + $params->{"f${last_field_id}"} = $FIELD_NEW_TO_OLD->{$field} || $field; + $params->{"o${last_field_id}"} = 'equals'; + $params->{"v${last_field_id}"} = $value; + $last_field_id++; + } + } +} + +sub create_bug_request { + my ($params) = @_; + + # User roles such as assigned_to and qa_contact should be just the + # email (login) of the user you want to set to. + foreach my $field (qw(assigned_to qa_contact)) { + if (exists $params->{$field}) { + $params->{$field} = $params->{$field}->{name}; + } + } + + # CC should just be a list of bugzilla logins + if (exists $params->{cc}) { + $params->{cc} = [ map { $_->{name} } @{ $params->{cc} } ]; + } + + # Comment + if (exists $params->{comments}) { + $params->{comment_is_private} = $params->{comments}->[0]->{is_private}; + $params->{description} = $params->{comments}->[0]->{text}; + delete $params->{comments}; + } + + # Some fields are not supported by Bugzilla::Bug->create but are supported + # by Bugzilla::Bug->update :( + my $cache = Bugzilla->request_cache->{bzapi_bug_create_extra} ||= {}; + foreach my $field (qw(remaining_time)) { + next if !exists $params->{$field}; + $cache->{$field} = delete $params->{$field}; + } + + # remove username/password + delete $params->{username}; + delete $params->{password}; +} + +sub update_bug_request { + my ($params) = @_; + + my $bug_id = ref $params->{ids} ? $params->{ids}->[0] : $params->{ids}; + my $bug = Bugzilla::Bug->check($bug_id); + + # Convert groups to proper add/remove lists + if (exists $params->{groups}) { + my @new_groups = map { $_->{name} } @{ $params->{groups} }; + my @old_groups = map { $_->name } @{ $bug->groups_in }; + my ($removed, $added) = diff_arrays(\@old_groups, \@new_groups); + if (@$added || @$removed) { + my $groups_data = {}; + $groups_data->{add} = $added if @$added; + $groups_data->{remove} = $removed if @$removed; + $params->{groups} = $groups_data; + } + else { + delete $params->{groups}; + } + } + + # Other fields such as keywords, blocks depends_on + # support 'set' which will make the list exactly what + # the user passes in. + foreach my $field (qw(blocks depends_on dependson keywords)) { + if (exists $params->{$field}) { + $params->{$field} = { set => $params->{$field} }; + } + } + + # User roles such as assigned_to and qa_contact should be just the + # email (login) of the user you want to change to. Also if defined + # but set to NULL then we reset them to default + foreach my $field (qw(assigned_to qa_contact)) { + if (exists $params->{$field}) { + if (!$params->{$field}) { + $params->{"reset_$field"} = 1; + delete $params->{$field}; + } + else { + $params->{$field} = $params->{$field}->{name}; + } + } + } + + # CC is treated like groups in that we need 'add' and 'remove' keys + if (exists $params->{cc}) { + my $new_cc = [ map { $_->{name} } @{ $params->{cc} } ]; + my ($removed, $added) = diff_arrays($bug->cc, $new_cc); + if (@$added || @$removed) { + my $cc_data = {}; + $cc_data->{add} = $added if @$added; + $cc_data->{remove} = $removed if @$removed; + $params->{cc} = $cc_data; + } + else { + delete $params->{cc}; + } + } + + # see_also is treated like groups in that we need 'add' and 'remove' keys + if (exists $params->{see_also}) { + my $old_see_also = [ map { $_->name } @{ $bug->see_also } ]; + my ($removed, $added) = diff_arrays($old_see_also, $params->{see_also}); + if (@$added || @$removed) { + my $data = {}; + $data->{add} = $added if @$added; + $data->{remove} = $removed if @$removed; + $params->{see_also} = $data; + } + else { + delete $params->{see_also}; + } + } + + # BzAPI allows for adding comments by appending to the list of current + # comments and passing the whole list back. + # 1. If a comment id is specified, the user can update the comment privacy + # 2. If no id is specified it is considered a new comment but only the last + # one will be accepted. + my %comment_is_private; + foreach my $comment (@{ $params->{'comments'} }) { + if (my $id = $comment->{'id'}) { + # Existing comment; tweak privacy flags if necessary + $comment_is_private{$id} + = ($comment->{'is_private'} && $comment->{'is_private'} eq "true") ? 1 : 0; + } + else { + # New comment to be added + # If multiple new comments are specified, only the last one will be + # added. + $params->{comment} = { + body => $comment->{text}, + is_private => ($comment->{'is_private'} && + $comment->{'is_private'} eq "true") ? 1 : 0 + }; + } + } + $params->{comment_is_private} = \%comment_is_private if %comment_is_private; + + # Remove setter and convert requestee to just name + if (exists $params->{flags}) { + foreach my $flag (@{ $params->{flags} }) { + delete $flag->{setter}; # Always use logged in user + if (exists $flag->{requestee} && ref $flag->{requestee}) { + $flag->{requestee} = $flag->{requestee}->{name}; + } + # If no flag id provided, assume it is new + if (!exists $flag->{id}) { + $flag->{new} = 1; + } + } + } +} + +sub add_comment_request { + my ($params) = @_; + $params->{comment} = delete $params->{text} if $params->{text}; +} + +sub add_attachment_request { + my ($params) = @_; + + # Bug.add_attachment uses 'summary' for description. + if ($params->{description}) { + $params->{summary} = $params->{description}; + delete $params->{description}; + } + + # Remove setter and convert requestee to just name + if (exists $params->{flags}) { + foreach my $flag (@{ $params->{flags} }) { + delete $flag->{setter}; # Always use logged in user + if (exists $flag->{requestee} && ref $flag->{requestee}) { + $flag->{requestee} = $flag->{requestee}->{name}; + } + } + } + + # Add comment if one is provided + if (exists $params->{comments} && scalar @{ $params->{comments} }) { + $params->{comment} = $params->{comments}->[0]->{text}; + delete $params->{comments}; + } +} + +sub update_attachment_request { + my ($params) = @_; + + # Stash away for midair checking later + if ($params->{last_change_time}) { + my $stash = Bugzilla->request_cache->{bzapi_stash} ||= {}; + $stash->{last_change_time} = delete $params->{last_change_time}; + } + + # Immutable values + foreach my $key (qw(attacher bug_id bug_ref creation_time + encoding id ref size update_token)) { + delete $params->{$key}; + } + + # Convert setter and requestee to standard values + if (exists $params->{flags}) { + foreach my $flag (@{ $params->{flags} }) { + delete $flag->{setter}; # Always use logged in user + if (exists $flag->{requestee} && ref $flag->{requestee}) { + $flag->{requestee} = $flag->{requestee}->{name}; + } + } + } + + # Add comment if one is provided + if (exists $params->{comments} && scalar @{ $params->{comments} }) { + $params->{comment} = $params->{comments}->[0]->{text}; + delete $params->{comments}; + } +} + +######################### +# REST Response Methods # +######################### + +sub search_bugs_response { + my ($result, $response) = @_; + my $cache = Bugzilla->request_cache; + my $params = Bugzilla->input_params; + + return if !exists $$result->{bugs}; + + my $bug_objs = $cache->{bzapi_search_bugs}; + + my @fixed_bugs; + foreach my $bug_data (@{$$result->{bugs}}) { + my $bug_obj = shift @$bug_objs; + my $fixed = fix_bug($bug_data, $bug_obj); + + # CC count and Dupe count + if (filter_wants_nocache($params, 'cc_count')) { + $fixed->{cc_count} = scalar @{ $bug_obj->cc } + if $bug_obj->cc; + } + if (filter_wants_nocache($params, 'dupe_count')) { + $fixed->{dupe_count} = scalar @{ $bug_obj->duplicate_ids } + if $bug_obj->duplicate_ids; + } + + push(@fixed_bugs, $fixed); + } + + $$result->{bugs} = \@fixed_bugs; +} + +sub create_bug_response { + my ($result, $response) = @_; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + + return if !exists $$result->{id}; + my $bug_id = $$result->{id}; + + $$result->{ref} = $rpc->type('string', ref_urlbase() . "/bug/$bug_id"); + $response->code(STATUS_CREATED); +} + +sub get_bug_response { + my ($result) = @_; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + + return if !exists $$result->{bugs}; + my $bug_data = $$result->{bugs}->[0]; + + my $bug_id = $rpc->bz_rest_params->{ids}->[0]; + my $bug_obj = Bugzilla::Bug->check($bug_id); + my $fixed = fix_bug($bug_data, $bug_obj); + + $$result = $fixed; +} + +sub update_bug_response { + my ($result) = @_; + return if !exists $$result->{bugs} + || !scalar @{$$result->{bugs}}; + $$result = { ok => 1 }; +} + +# Get all comments for a bug +sub get_comments_response { + my ($result) = @_; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + my $params = Bugzilla->input_params; + + return if !exists $$result->{bugs}; + + my $bug_id = $rpc->bz_rest_params->{ids}->[0]; + my $bug = Bugzilla::Bug->check($bug_id); + + my $comment_objs = $bug->comments({ order => 'oldest_to_newest', + after => $params->{new_since} }); + my @filtered_comment_objs; + foreach my $comment (@$comment_objs) { + next if $comment->is_private && !Bugzilla->user->is_insider; + push(@filtered_comment_objs, $comment); + } + + my $comments_data = $$result->{bugs}->{$bug_id}->{comments}; + + my @fixed_comments; + foreach my $comment_data (@$comments_data) { + my $comment_obj = shift @filtered_comment_objs; + my $fixed = fix_comment($comment_data, $comment_obj); + + if (exists $fixed->{creator}) { + # /bug/<ID>/comment returns full login for creator but not for /bug/<ID>?include_fields=comments :( + $fixed->{creator}->{name} = $rpc->type('string', $comment_obj->author->login); + # /bug/<ID>/comment does not return real_name for creator but returns ref + $fixed->{creator}->{'ref'} = $rpc->type('string', ref_urlbase() . "/user/" . $comment_obj->author->login); + delete $fixed->{creator}->{real_name}; + } + + push(@fixed_comments, filter($params, $fixed)); + } + + $$result = { comments => \@fixed_comments }; +} + +# Format the return response on successful comment creation +sub add_comment_response { + my ($result, $response) = @_; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + + return if !exists $$result->{id}; + my $bug_id = $rpc->bz_rest_params->{id}; + + $$result = { ref => $rpc->type('string', ref_urlbase() . "/bug/$bug_id/comment") }; + $response->code(STATUS_CREATED); +} + +# Get the history for a bug +sub get_history_response { + my ($result) = @_; + my $params = Bugzilla->input_params; + + return if !exists $$result->{bugs}; + my $history = $$result->{bugs}->[0]->{history}; + + my @new_history; + foreach my $changeset (@$history) { + $changeset = fix_changeset($changeset); + push(@new_history, filter($params, $changeset)); + } + + $$result = { history => \@new_history }; +} + +# Get all attachments for a bug +sub get_attachments_response { + my ($result) = @_; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + my $params = Bugzilla->input_params; + + return if !exists $$result->{bugs}; + my $bug_id = $rpc->bz_rest_params->{ids}->[0]; + my $bug = Bugzilla::Bug->check($bug_id); + my $attachment_objs = $bug->attachments; + + my $attachments_data = $$result->{bugs}->{$bug_id}; + + my @fixed_attachments; + foreach my $attachment (@$attachments_data) { + my $attachment_obj = shift @$attachment_objs; + my $fixed = fix_attachment($attachment, $attachment_obj); + + if ((filter_wants_nocache($params, 'data', 'extra') + || filter_wants_nocache($params, 'encoding', 'extra') + || $params->{attachmentdata})) + { + if (!$fixed->{data}) { + $fixed->{data} = $rpc->type('base64', $attachment_obj->data); + $fixed->{encoding} = $rpc->type('string', 'base64'); + } + } + else { + delete $fixed->{data}; + delete $fixed->{encoding}; + } + + push(@fixed_attachments, filter($params, $fixed)); + } + + $$result = { attachments => \@fixed_attachments }; +} + +# Format the return response on successful attachment creation +sub add_attachment_response { + my ($result, $response) = @_; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + + my ($attach_id) = keys %{ $$result->{attachments} }; + + $$result = { ref => $rpc->type('string', ref_urlbase() . "/attachment/$attach_id"), id => $attach_id }; + $response->code(STATUS_CREATED); +} + +# Update an attachment's metadata +sub update_attachment_response { + my ($result) = @_; + $$result = { ok => 1 }; +} + +# Get a single attachment by attachment_id +sub get_attachment_response { + my ($result) = @_; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + my $params = Bugzilla->input_params; + + return if !exists $$result->{attachments}; + my $attach_id = $rpc->bz_rest_params->{attachment_ids}->[0]; + my $attachment_data = $$result->{attachments}->{$attach_id}; + my $attachment_obj = Bugzilla::Attachment->new($attach_id); + my $fixed = fix_attachment($attachment_data, $attachment_obj); + + if ((filter_wants_nocache($params, 'data', 'extra') + || filter_wants_nocache($params, 'encoding', 'extra') + || $params->{attachmentdata})) + { + if (!$fixed->{data}) { + $fixed->{data} = $rpc->type('base64', $attachment_obj->data); + $fixed->{encoding} = $rpc->type('string', 'base64'); + } + } + else { + delete $fixed->{data}; + delete $fixed->{encoding}; + } + + $fixed = filter($params, $fixed); + + $$result = $fixed; +} + +# Get a list of flags for a bug +sub get_bug_flags_response { + my ($result) = @_; + my $params = Bugzilla->input_params; + + return if !exists $$result->{bugs}; + my $flags = $$result->{bugs}->[0]->{flags}; + + my @new_flags; + foreach my $flag (@$flags) { + push(@new_flags, fix_flag($flag)); + } + + $$result = { flags => \@new_flags }; +} + +1; diff --git a/extensions/BzAPI/lib/Resources/Bugzilla.pm b/extensions/BzAPI/lib/Resources/Bugzilla.pm new file mode 100644 index 000000000..96b07297e --- /dev/null +++ b/extensions/BzAPI/lib/Resources/Bugzilla.pm @@ -0,0 +1,138 @@ +# 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::BzAPI::Resources::Bugzilla; + +use 5.10.1; +use strict; + +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Keyword; +use Bugzilla::Product; +use Bugzilla::Status; +use Bugzilla::Field; +use Bugzilla::Util qw(correct_urlbase); + +use Bugzilla::Extension::BzAPI::Constants; + +use Digest::MD5 qw(md5_base64); + +######################### +# REST Resource Methods # +######################### + +BEGIN { + require Bugzilla::WebService::Bugzilla; + *Bugzilla::WebService::Bugzilla::get_configuration = \&get_configuration; + *Bugzilla::WebService::Bugzilla::get_empty = \&get_empty; +} + +sub rest_handlers { + my $rest_handlers = [ + qr{^/$}, { + GET => { + resource => { + method => 'get_empty' + } + } + }, + qr{^/configuration$}, { + GET => { + resource => { + method => 'get_configuration' + } + } + } + ]; + return $rest_handlers; +} + +sub get_configuration { + my ($self) = @_; + my $user = Bugzilla->user; + my $params = Bugzilla->input_params; + + # Get data from the shadow DB as they don't change very often. + Bugzilla->switch_to_shadow_db; + + # Pass a bunch of Bugzilla configuration to the templates. + my $vars = {}; + $vars->{'priority'} = get_legal_field_values('priority'); + $vars->{'severity'} = get_legal_field_values('bug_severity'); + $vars->{'platform'} = get_legal_field_values('rep_platform'); + $vars->{'op_sys'} = get_legal_field_values('op_sys'); + $vars->{'keyword'} = [ map($_->name, Bugzilla::Keyword->get_all) ]; + $vars->{'resolution'} = get_legal_field_values('resolution'); + $vars->{'status'} = get_legal_field_values('bug_status'); + $vars->{'custom_fields'} = + [ grep {$_->is_select} Bugzilla->active_custom_fields ]; + + # Include a list of product objects. + if ($params->{'product'}) { + my @products = $params->{'product'}; + foreach my $product_name (@products) { + my $product = new Bugzilla::Product({ name => $product_name }); + if ($product && $user->can_see_product($product->name)) { + push (@{$vars->{'products'}}, $product); + } + } + } else { + $vars->{'products'} = $user->get_selectable_products; + } + + # We set the 2nd argument to 1 to also preload flag types. + Bugzilla::Product::preload($vars->{'products'}, 1, { is_active => 1 }); + + # Allow consumers to specify whether or not they want flag data. + if (defined $params->{'flags'}) { + $vars->{'show_flags'} = $params->{'flags'}; + } + else { + # We default to sending flag data. + $vars->{'show_flags'} = 1; + } + + # Create separate lists of open versus resolved statuses. This should really + # be made part of the configuration. + my @open_status; + my @closed_status; + foreach my $status (@{$vars->{'status'}}) { + is_open_state($status) ? push(@open_status, $status) + : push(@closed_status, $status); + } + $vars->{'open_status'} = \@open_status; + $vars->{'closed_status'} = \@closed_status; + + # Generate a list of fields that can be queried. + my @fields = @{Bugzilla::Field->match({obsolete => 0})}; + # Exclude fields the user cannot query. + if (!Bugzilla->user->is_timetracker) { + @fields = grep { $_->name !~ /^(estimated_time|remaining_time|work_time|percentage_complete|deadline)$/ } @fields; + } + $vars->{'field'} = \@fields; + + my $json; + Bugzilla->template->process('config.json.tmpl', $vars, \$json); + my $result = {}; + if ($json) { + $result = $self->json->decode($json); + } + return $result; +} + +sub get_empty { + my ($self) = @_; + return { + ref => $self->type('string', correct_urlbase() . "bzapi/"), + documentation => $self->type('string', BZAPI_DOC), + version => $self->type('string', BUGZILLA_VERSION) + }; +} + +1; diff --git a/extensions/BzAPI/lib/Resources/User.pm b/extensions/BzAPI/lib/Resources/User.pm new file mode 100644 index 000000000..7fbcdb871 --- /dev/null +++ b/extensions/BzAPI/lib/Resources/User.pm @@ -0,0 +1,79 @@ +# 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::BzAPI::Resources::User; + +use 5.10.1; +use strict; + +use Bugzilla::Extension::BzAPI::Util; + +sub rest_handlers { + my $rest_handlers = [ + qr{/user$}, { + GET => { + response => \&get_users, + }, + }, + qr{/user/([^/]+)$}, { + GET => { + response => \&get_user, + }, + } + ]; + return $rest_handlers; +} + +sub get_users { + my ($result) = @_; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + my $params = Bugzilla->input_params; + + return if !exists $$result->{users}; + + my @users; + foreach my $user (@{$$result->{users}}) { + my $object = Bugzilla::User->new( + { id => $user->{id}, cache => 1 }); + + $user = fix_user($user, $object); + + # Use userid instead of email for 'ref' for /user calls + $user->{'ref'} = $rpc->type('string', ref_urlbase . "/user/" . $object->id); + + # Emails are not filtered even if user is not logged in + $user->{name} = $rpc->type('string', $object->login); + + push(@users, filter($params, $user)); + } + + $$result->{users} = \@users; +} + +sub get_user { + my ($result) = @_; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + my $params = Bugzilla->input_params; + + return if !exists $$result->{users}; + my $user = $$result->{users}->[0] || return; + my $object = Bugzilla::User->new({ id => $user->{id}, cache => 1 }); + + $user = fix_user($user, $object); + + # Use userid instead of email for 'ref' for /user calls + $user->{'ref'} = $rpc->type('string', ref_urlbase . "/user/" . $object->id); + + # Emails are not filtered even if user is not logged in + $user->{name} = $rpc->type('string', $object->login); + + $user = filter($params, $user); + + $$result = $user; +} + +1; diff --git a/extensions/BzAPI/lib/Util.pm b/extensions/BzAPI/lib/Util.pm new file mode 100644 index 000000000..e783a1584 --- /dev/null +++ b/extensions/BzAPI/lib/Util.pm @@ -0,0 +1,459 @@ +# 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. +# 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::BzAPI::Util; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Bug; +use Bugzilla::Constants; +use Bugzilla::Extension::BzAPI::Constants; +use Bugzilla::Token; +use Bugzilla::Util qw(correct_urlbase email_filter); +use Bugzilla::WebService::Util qw(filter_wants); + +use MIME::Base64; + +use base qw(Exporter); +our @EXPORT = qw( + ref_urlbase + fix_bug + fix_user + fix_flag + fix_comment + fix_changeset + fix_attachment + fix_group + filter_wants_nocache + filter + fix_credentials + filter_email +); + +# Return an URL base appropriate for constructing a ref link +# normally required by REST API calls. +sub ref_urlbase { + return correct_urlbase() . "bzapi"; +} + +# convert certain fields within a bug object +# from a simple scalar value to their respective objects +sub fix_bug { + my ($data, $bug) = @_; + my $dbh = Bugzilla->dbh; + my $params = Bugzilla->input_params; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + my $method = Bugzilla->request_cache->{bzapi_rpc_method}; + + $bug = ref $bug ? $bug : Bugzilla::Bug->check($bug || $data->{id}); + + # Add REST API reference to the individual bug + if (filter_wants_nocache($params, 'ref')) { + $data->{'ref'} = ref_urlbase() . "/bug/" . $bug->id; + } + + # User fields + foreach my $field (USER_FIELDS) { + next if !exists $data->{$field}; + if ($field eq 'cc') { + my @new_cc; + foreach my $cc (@{ $bug->cc_users }) { + my $cc_data = { name => filter_email($cc->email) }; + push(@new_cc, fix_user($cc_data, $cc)); + } + $data->{$field} = \@new_cc; + } + else { + my $field_name = $field; + if ($field eq 'creator') { + $field_name = 'reporter'; + } + $data->{$field} + = fix_user($data->{"${field}_detail"}, $bug->$field_name); + delete $data->{$field}->{id}; + delete $data->{$field}->{email}; + $data->{$field} = filter($params, $data->{$field}, undef, $field); + } + + # Get rid of extra detail hash if exists since redundant + delete $data->{"${field}_detail"} if exists $data->{"${field}_detail"}; + } + + # Groups + if (filter_wants_nocache($params, 'groups')) { + my @new_groups; + foreach my $group (@{ $data->{groups} }) { + push(@new_groups, fix_group($group)); + } + $data->{groups} = \@new_groups; + } + + # Flags + if (exists $data->{flags}) { + my @new_flags; + foreach my $flag (@{ $data->{flags} }) { + push(@new_flags, fix_flag($flag)); + } + $data->{flags} = \@new_flags; + } + + # Attachment metadata is included by default but not data + if (filter_wants_nocache($params, 'attachments')) { + my $attachment_params = { ids => $bug->id }; + if (!filter_wants_nocache($params, 'data', 'extra', 'attachments') + && !$params->{attachmentdata}) + { + $attachment_params->{exclude_fields} = ['data']; + } + + my $attachments = $rpc->attachments($attachment_params); + + my @fixed_attachments; + foreach my $attachment (@{ $attachments->{bugs}->{$bug->id} }) { + my $fixed = fix_attachment($attachment); + push(@fixed_attachments, filter($params, $fixed, undef, 'attachments')); + } + + $data->{attachments} = \@fixed_attachments; + } + + # Comments and history are not part of _default and have to be requested + + # Comments + if (filter_wants_nocache($params, 'comments', 'extra', 'comments')) { + my $comments = $rpc->comments({ ids => $bug->id }); + $comments = $comments->{bugs}->{$bug->id}->{comments}; + my @new_comments; + foreach my $comment (@$comments) { + $comment = fix_comment($comment); + push(@new_comments, filter($params, $comment, 'extra', 'comments')); + } + $data->{comments} = \@new_comments; + } + + # History + if (filter_wants_nocache($params, 'history', 'extra', 'history')) { + my $history = $rpc->history({ ids => [ $bug->id ] }); + my @new_history; + foreach my $changeset (@{ $history->{bugs}->[0]->{history} }) { + push(@new_history, fix_changeset($changeset, $bug)); + } + $data->{history} = \@new_history; + } + + # Add in all custom fields even if not set or visible on this bug + my $custom_fields = Bugzilla->fields({ custom => 1, obsolete => 0 }); + foreach my $field (@$custom_fields) { + my $name = $field->name; + next if !filter_wants_nocache($params, $name, ['default','custom']); + if ($field->type == FIELD_TYPE_BUG_ID) { + $data->{$name} = $rpc->type('int', $bug->$name); + } + elsif ($field->type == FIELD_TYPE_DATETIME + || $field->type == FIELD_TYPE_DATE) + { + $data->{$name} = $rpc->type('dateTime', $bug->$name); + } + elsif ($field->type == FIELD_TYPE_MULTI_SELECT) { + # Bug.search, when include_fields=_all, returns array, otherwise return as comma delimited string :( + if ($method eq 'Bug.search' && !grep($_ eq '_all', @{ $params->{include_fields} })) { + $data->{$name} = $rpc->type('string', join(', ', @{ $bug->$name })); + } + else { + my @values = map { $rpc->type('string', $_) } @{ $bug->$name }; + $data->{$name} = \@values; + } + } + else { + $data->{$name} = $rpc->type('string', $bug->$name); + } + } + + # Remove empty values in some cases + foreach my $key (keys %$data) { + # QA Contact is null if single bug or "" if doing search + if ($key eq 'qa_contact' && !$data->{$key}->{name}) { + if ($method eq 'Bug.search') { + $data->{$key}->{name} = $rpc->type('string', ''); + } + next; + } + + next if $method eq 'Bug.search' && $key eq 'url'; # Return url even if empty + next if $method eq 'Bug.search' && $key eq 'keywords'; # Return keywords even if empty + next if $method eq 'Bug.search' && $key eq 'whiteboard'; # Return whiteboard even if empty + next if $method eq 'Bug.get' && grep($_ eq $key, TIMETRACKING_FIELDS); + + next if ($method eq 'Bug.search' + && $key =~ /^(resolution|cc_count|dupe_count)$/ + && !grep($_ eq '_all', @{ $params->{include_fields} })); + + if (!ref $data->{$key}) { + delete $data->{$key} if !$data->{$key}; + } + else { + if (ref $data->{$key} eq 'ARRAY' && !@{$data->{$key}}) { + # Return empty string if blocks or depends_on is empty + if ($method eq 'Bug.search' && ($key eq 'depends_on' || $key eq 'blocks')) { + $data->{$key} = ''; + } + else { + delete $data->{$key}; + } + } + elsif (ref $data->{$key} eq 'HASH' && !%{$data->{$key}}) { + delete $data->{$key}; + } + } + } + + return $data; +} + +# convert a user related field from being just login +# names to user objects +sub fix_user { + my ($data, $object) = @_; + my $user = Bugzilla->user; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + + return { name => undef } if !$data; + + if (!ref $data) { + $data = { + name => filter_email($object->login) + }; + $data->{real_name} = $rpc->type('string', $object->name); + } + else { + $data->{name} = filter_email($data->{name}); + } + + if ($user->id) { + $data->{ref} = $rpc->type('string', ref_urlbase . "/user/" . $object->login); + } + + return $data; +} + +# convert certain attributes of a comment to objects +# and also remove other unwanted key/values. +sub fix_comment { + my ($data, $object) = @_; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + my $method = Bugzilla->request_cache->{bzapi_rpc_method}; + + $object ||= Bugzilla::Comment->new({ id => $data->{id}, cache => 1 }); + + if (exists $data->{creator}) { + $data->{creator} = fix_user($data->{creator}, $object->author); + } + + if ($data->{attachment_id} && $method ne 'Bug.search') { + $data->{attachment_ref} = $rpc->type('string', ref_urlbase() . + "/attachment/" . $object->extra_data); + } + else { + delete $data->{attachment_id}; + } + + delete $data->{author}; + delete $data->{time}; + delete $data->{raw_text}; + + return $data; +} + +# convert certain attributes of a changeset object from +# scalar values to related objects. Also remove other unwanted +# key/values. +sub fix_changeset { + my ($data, $object) = @_; + my $user = Bugzilla->user; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + + if ($data->{who}) { + $data->{changer} = { + name => $rpc->type('string', $data->{who}), + ref => $rpc->type('string', ref_urlbase() . "/user/" . $data->{who}) + }; + delete $data->{who}; + } + + if ($data->{when}) { + $data->{change_time} = $rpc->type('dateTime', $data->{when}); + delete $data->{when}; + } + + foreach my $change (@{ $data->{changes} }) { + $change->{field_name} = 'flag' if $change->{field_name} eq 'flagtypes.name'; + } + + return $data; +} + +# convert certain attributes of an attachment object from +# scalar values to related objects. Also add in additional +# key/values. +sub fix_attachment { + my ($data, $object) = @_; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + my $method = Bugzilla->request_cache->{bzapi_rpc_method}; + my $params = Bugzilla->input_params; + my $user = Bugzilla->user; + + $object ||= Bugzilla::Attachment->new({ id => $data->{id}, cache => 1 }); + + if (exists $data->{attacher}) { + $data->{attacher} = fix_user($data->{attacher}, $object->attacher); + if ($method eq 'Bug.search') { + delete $data->{attacher}->{real_name}; + } + else { + $data->{attacher}->{real_name} = $rpc->type('string', $object->attacher->name); + } + } + + if (exists $data->{data}) { + $data->{encoding} = $rpc->type('string', 'base64'); + if ($params->{attachmentdata} + || filter_wants_nocache($params, 'attachments.data')) + { + $data->{encoding} = $rpc->type('string', 'base64'); + } + else { + delete $data->{data}; + } + } + + if (exists $data->{bug_id}) { + $data->{bug_ref} = $rpc->type('string', ref_urlbase() . "/bug/" . $object->bug_id); + } + + # Upstream API returns these as integers where bzapi returns as booleans + if (exists $data->{is_patch}) { + $data->{is_patch} = $rpc->type('boolean', $data->{is_patch}); + } + if (exists $data->{is_obsolete}) { + $data->{is_obsolete} = $rpc->type('boolean', $data->{is_obsolete}); + } + if (exists $data->{is_private}) { + $data->{is_private} = $rpc->type('boolean', $data->{is_private}); + } + + if (exists $data->{flags} && @{ $data->{flags} }) { + my @new_flags; + foreach my $flag (@{ $data->{flags} }) { + push(@new_flags, fix_flag($flag)); + } + $data->{flags} = \@new_flags; + } + else { + delete $data->{flags}; + } + + $data->{ref} = $rpc->type('string', ref_urlbase() . "/attachment/" . $object->id); + + # Add update token if we are getting an attachment outside of Bug.get and user is logged in + if ($user->id && ($method eq 'Bug.attachments'|| $method eq 'Bug.search')) { + $data->{update_token} = issue_hash_token([ $object->id, $object->modification_time ]); + } + + delete $data->{creator}; + delete $data->{summary}; + + return $data; +} + +# convert certain attributes of a flag object from +# scalar values to related objects. Also remove other unwanted +# key/values. +sub fix_flag { + my ($data, $object) = @_; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + + $object ||= Bugzilla::Flag->new({ id => $data->{id}, cache => 1 }); + + if (exists $data->{setter}) { + $data->{setter} = fix_user($data->{setter}, $object->setter); + delete $data->{setter}->{real_name}; + } + + if (exists $data->{requestee}) { + $data->{requestee} = fix_user($data->{requestee}, $object->requestee); + delete $data->{requestee}->{real_name}; + } + + return $data; +} + +# convert certain attributes of a group object from scalar +# values to related objects +sub fix_group { + my ($group, $object) = @_; + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + + $object ||= Bugzilla::Group->new({ name => $group }); + + if ($object) { + $group = { + id => $rpc->type('int', $object->id), + name => $rpc->type('string', $object->name), + }; + } + + return $group; +} + +# Calls Bugzilla::WebService::Util::filter_wants but disables caching +# as we make several webservice calls in a single REST call and the +# caching can cause unexpected results. +sub filter_wants_nocache { + my ($params, $field, $types, $prefix) = @_; + delete Bugzilla->request_cache->{filter_wants}; + return filter_wants($params, $field, $types, $prefix); +} + +sub filter { + my ($params, $hash, $types, $prefix) = @_; + my %newhash = %$hash; + foreach my $key (keys %$hash) { + delete $newhash{$key} if !filter_wants_nocache($params, $key, $types, $prefix); + } + return \%newhash; +} + +sub fix_credentials { + my ($params) = @_; + # Allow user to pass in username=foo&password=bar to be compatible + $params->{'Bugzilla_login'} = $params->{'login'} = delete $params->{'username'} + if exists $params->{'username'}; + $params->{'Bugzilla_password'} = $params->{'password'} if exists $params->{'password'}; + + # Allow user to pass userid=1&cookie=3iYGuKZdyz for compatibility with BzAPI + if (exists $params->{'userid'} && exists $params->{'cookie'}) { + my $userid = delete $params->{'userid'}; + my $cookie = delete $params->{'cookie'}; + $params->{'Bugzilla_token'} = "${userid}-${cookie}"; + } +} + +# Filter email addresses by default ignoring the system +# webservice_email_filter setting +sub filter_email { + my $rpc = Bugzilla->request_cache->{bzapi_rpc}; + return $rpc->type('string', email_filter($_[0])); +} + +1; diff --git a/extensions/BzAPI/template/en/default/config.json.tmpl b/extensions/BzAPI/template/en/default/config.json.tmpl new file mode 100644 index 000000000..993b34915 --- /dev/null +++ b/extensions/BzAPI/template/en/default/config.json.tmpl @@ -0,0 +1,302 @@ +[%# This Source Code Form is subject to the terms of the Mozilla Public + # License, v. 2.0. If a copy of the MPL was not distributed with this + # file, You can obtain one at http://mozilla.org/MPL/2.0/. + # + # This Source Code Form is "Incompatible With Secondary Licenses", as + # defined by the Mozilla Public License, v. 2.0. + #%] +[% + # Pinched from Bugzilla/API/Model/Utils.pm in BzAPI - need to keep in sync +OLD2NEW = { + 'opendate' => 'creation_time', # query + 'creation_ts' => 'creation_time', + 'changeddate' => 'last_change_time', # query + 'delta_ts' => 'last_change_time', + 'bug_id' => 'id', + 'rep_platform' => 'platform', + 'bug_severity' => 'severity', + 'bug_status' => 'status', + 'short_desc' => 'summary', + 'short_short_desc' => 'summary', + 'bug_file_loc' => 'url', + 'status_whiteboard' => 'whiteboard', + 'reporter' => 'creator', + 'reporter_realname' => 'creator_realname', + 'cclist_accessible' => 'is_cc_accessible', + 'reporter_accessible' => 'is_creator_accessible', + 'everconfirmed' => 'is_confirmed', + 'dependson' => 'depends_on', + 'blocked' => 'blocks', + 'attachment' => 'attachments', + 'flag' => 'flags', + 'flagtypes.name' => 'flag', + 'bug_group' => 'group', + 'group' => 'groups', + 'longdesc' => 'comment', + 'bug_file_loc_type' => 'url_type', + 'bugidtype' => 'id_mode', + 'longdesc_type' => 'comment_type', + 'short_desc_type' => 'summary_type', + 'status_whiteboard_type' => 'whiteboard_type', + 'emailassigned_to1' => 'email1_assigned_to', + 'emailassigned_to2' => 'email2_assigned_to', + 'emailcc1' => 'email1_cc', + 'emailcc2' => 'email2_cc', + 'emailqa_contact1' => 'email1_qa_contact', + 'emailqa_contact2' => 'email2_qa_contact', + 'emailreporter1' => 'email1_creator', + 'emailreporter2' => 'email2_creator', + 'emaillongdesc1' => 'email1_comment_creator', + 'emaillongdesc2' => 'email2_comment_creator', + 'emailtype1' => 'email1_type', + 'emailtype2' => 'email2_type', + 'chfieldfrom' => 'changed_after', + 'chfieldto' => 'changed_before', + 'chfield' => 'changed_field', + 'chfieldvalue' => 'changed_field_to', + 'deadlinefrom' => 'deadline_after', + 'deadlineto' => 'deadline_before', + 'attach_data.thedata' => 'attachment.data', + 'longdescs.isprivate' => 'comment.is_private', + 'commenter' => 'comment.creator', + 'flagtypes.name' => 'flag', + 'requestees.login_name' => 'flag.requestee', + 'setters.login_name' => 'flag.setter', + 'days_elapsed' => 'idle', + 'owner_idle_time' => 'assignee_idle', + 'dup_id' => 'dupe_of', + 'isopened' => 'is_open', + 'flag_type' => 'flag_types', + 'token' => 'update_token' +}; + +OLDATTACH2NEW = { + 'submitter' => 'attacher', + 'description' => 'description', + 'filename' => 'file_name', + 'delta_ts' => 'last_change_time', + 'isobsolete' => 'is_obsolete', + 'ispatch' => 'is_patch', + 'isprivate' => 'is_private', + 'mimetype' => 'content_type', + 'contenttypeentry' => 'content_type', + 'date' => 'creation_time', + 'attachid' => 'id', + 'desc' => 'description', + 'flag' => 'flags', + 'type' => 'content_type', + 'token' => 'update_token' +}; + +%] + +[%# Add attachment stuff to the main hash - but with right prefix. (This is + # the way the code is structured in BzAPI, and changing it makes it harder + # to keep the two in sync.) + #%] +[% FOREACH entry IN OLDATTACH2NEW %] + [% newkey = 'attachments.' _ entry.key %] + [% OLD2NEW.${newkey} = 'attachment.' _ OLDATTACH2NEW.${entry.key} %] +[% END %] + +[% all_visible_flag_types = {} %] + +{ + "version": "[% constants.BUGZILLA_VERSION FILTER json %]", + "maintainer": "[% Param('maintainer') FILTER json %]", + "announcement": "[% Param('announcehtml') FILTER json %]", + "max_attachment_size": [% (Param('maxattachmentsize') * 1000) FILTER json %], + +[% IF Param('useclassification') %] + [% cl_name_for = {} %] + "classification": { + [% FOREACH cl IN user.get_selectable_classifications() %] + [% cl_name_for.${cl.id} = cl.name %] + "[% cl.name FILTER json %]": { + "id": [% cl.id FILTER json %], + "description": "[% cl.description FILTER json %]", + "products": [ + [% FOREACH product IN user.get_selectable_products(cl.id) %] + "[% product.name FILTER json %]"[% ',' UNLESS loop.last() %] + [% END %] + ] + }[% ',' UNLESS loop.last() %] + [% END %] + }, +[% END %] + + "product": { + [% FOREACH product = products %] + "[% product.name FILTER json %]": { + "id": [% product.id FILTER json %], + "description": "[% product.description FILTER json %]", + "is_active": [% product.isactive ? "true" : "false" %], + "is_permitting_unconfirmed": [% product.allows_unconfirmed ? "true" : "false" %], +[% IF Param('useclassification') %] + "classification": "[% cl_name_for.${product.classification_id} FILTER json %]", +[% END %] + "component": { + [% FOREACH component = product.components %] + "[% component.name FILTER json %]": { + "id": [% component.id FILTER json %], +[% IF show_flags %] + "flag_type": [ + [% flag_types = + component.flag_types(is_active=>1).bug.merge(component.flag_types(is_active=>1).attachment) %] + [%-# "first" flag used to get commas right; can't use loop.last() in case + # last flag is inactive %] + [% first = 1 %] + [% FOREACH flag_type = flag_types %] + [% all_visible_flag_types.${flag_type.id} = flag_type %] + [% ',' UNLESS first %][% flag_type.id FILTER json %][% first = 0 %] + [% END %]], +[% END %] + "description": "[% component.description FILTER json %]" + } [% ',' UNLESS loop.last() %] + [% END %] + }, + "version": [ + [% FOREACH version = product.versions %] + "[% version.name FILTER json %]"[% ',' UNLESS loop.last() %] + [% END %] + ], + +[% IF Param('usetargetmilestone') %] + "default_target_milestone": "[% product.defaultmilestone FILTER json %]", + "target_milestone": [ + [% FOREACH milestone = product.milestones %] + "[% milestone.name FILTER json %]"[% ',' UNLESS loop.last() %] + [% END %] + ], +[% END %] + + "group": [ + [% FOREACH group = product.groups_valid %] + [% group.id FILTER json %][% ',' UNLESS loop.last() %] + [% END %] + ] + }[% ',' UNLESS loop.last() %] + [% END %] + }, + + "group": { + [% FOREACH group = product.groups_valid %] + "[% group.id FILTER json %]": { + "name": "[% group.name FILTER json %]", + "description": "[% group.description FILTER json %]", + "is_accepting_bugs": [% group.is_bug_group ? 'true' : 'false' %], + "is_active": [% group.is_active ? 'true' : 'false' %] + }[% ',' UNLESS loop.last() %] + [% END %] + }, + +[% IF show_flags %] + "flag_type": { + [% FOREACH flag_type = all_visible_flag_types.values.sort('name') %] + "[%+ flag_type.id FILTER json %]": { + "name": "[% flag_type.name FILTER json %]", + "description": "[% flag_type.description FILTER json %]", + [% IF user.in_group("editcomponents") %] + [% IF flag_type.request_group_id %] + "request_group": [% flag_type.request_group_id FILTER json %], + [% END %] + [% IF flag_type.grant_group_id %] + "grant_group": [% flag_type.grant_group_id FILTER json %], + [% END %] + [% END %] + "is_for_bugs": [% flag_type.target_type == "bug" ? 'true' : 'false' %], + "is_requestable": [% flag_type.is_requestable ? 'true' : 'false' %], + "is_specifically_requestable": [% flag_type.is_requesteeble ? 'true' : 'false' %], + "is_multiplicable": [% flag_type.is_multiplicable ? 'true' : 'false' %] + }[% ',' UNLESS loop.last() %] + [% END %] + }, +[% END %] + + [% PROCESS "global/field-descs.none.tmpl" %] + + [%# Put custom field value data where below loop expects to find it %] + [% FOREACH cf = custom_fields %] + [% ${cf.name} = [] %] + [% FOREACH value = cf.legal_values %] + [% ${cf.name}.push(value.name) %] + [% END %] + [% END %] + + [%# Built-in fields do not have type IDs. There aren't ID values for all + # the types of the built-in fields, but we do what we can, and leave the + # rest as "0" (unknown). + #%] + [% type_id_for = { + "id" => 6, + "summary" => 1, + "classification" => 2, + "version" => 2, + "url" => 1, + "whiteboard" => 1, + "keywords" => 3, + "component" => 2, + "attachment.description" => 1, + "attachment.file_name" => 1, + "attachment.content_type" => 1, + "target_milestone" => 2, + "comment" => 4, + "alias" => 1, + "deadline" => 5, + } %] + + "field": { + [% FOREACH item = field %] + [% newname = OLD2NEW.${item.name} || item.name %] + "[% newname FILTER json %]": { + "description": "[% (field_descs.${item.name} OR + item.description) FILTER json %]", + "is_active": [% field.obsolete ? "false" : "true" %], + [% blacklist = ["version", "group", "product", "component"] %] + [% IF ${newname} AND NOT blacklist.contains(newname) %] + "values": [ + [% FOREACH value = ${newname} %] + "[% value FILTER json %]"[% ',' UNLESS loop.last() %] + [% END %] + ], + [% END %] + [% paramname = newname.replace("_", "") %] [%# For op_sys... %] + [% IF paramname != "query" AND Param('default' _ paramname) %] + "default": "[% Param('default' _ paramname) %]", + [% END %] + [%-# The 'status' hash has a lot of extra stuff %] + [% IF newname == "status" %] + "open": [ + [% FOREACH value = open_status %] + "[% value FILTER json %]"[% ',' UNLESS loop.last() %] + [% END %] + ], + "closed": [ + [% FOREACH value = closed_status %] + "[% value FILTER json %]"[% ',' UNLESS loop.last() %] + [% END %] + ], + "transitions": { + "{Start}": [ + [% FOREACH target = initial_status %] + "[% target.name FILTER json %]"[% ',' UNLESS loop.last() %] + [% END %] + ], + [% FOREACH status = status_objects %] + [% targets = status.can_change_to() %] + "[% status.name FILTER json %]": [ + [% FOREACH target = targets %] + "[% target.name FILTER json %]"[% ',' UNLESS loop.last() %] + [% END %] + ][% ',' UNLESS loop.last() %] + [% END %] + }, + [% END %] + [% IF newname.match("^cf_") %] + "is_on_bug_entry": [% item.enter_bug ? 'true' : 'false' %], + [% END %] + "type": [% item.type || type_id_for.$newname || 0 FILTER json %] + }[% ',' UNLESS loop.last() %] + [% END %] + } +} diff --git a/extensions/BzAPI/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/BzAPI/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..1ffe03d22 --- /dev/null +++ b/extensions/BzAPI/template/en/default/hook/global/user-error-errors.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 error == "bzapi_midair_collision" %] + Mid-air collision +[% END %] diff --git a/extensions/ComponentWatching/Config.pm b/extensions/ComponentWatching/Config.pm new file mode 100644 index 000000000..560b5c3c5 --- /dev/null +++ b/extensions/ComponentWatching/Config.pm @@ -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. + +package Bugzilla::Extension::ComponentWatching; +use strict; +use constant NAME => 'ComponentWatching'; + +__PACKAGE__->NAME; diff --git a/extensions/ComponentWatching/Extension.pm b/extensions/ComponentWatching/Extension.pm new file mode 100644 index 000000000..627fb12fd --- /dev/null +++ b/extensions/ComponentWatching/Extension.pm @@ -0,0 +1,626 @@ +# 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::ComponentWatching; +use strict; +use base qw(Bugzilla::Extension); + +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Group; +use Bugzilla::User; +use Bugzilla::User::Setting; +use Bugzilla::Util qw(trim trick_taint); + +our $VERSION = '2'; + +use constant REL_COMPONENT_WATCHER => 15; + +# +# installation +# + +sub db_schema_abstract_schema { + my ($self, $args) = @_; + $args->{'schema'}->{'component_watch'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + user_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE', + } + }, + component_id => { + TYPE => 'INT2', + NOTNULL => 0, + REFERENCES => { + TABLE => 'components', + COLUMN => 'id', + DELETE => 'CASCADE', + } + }, + product_id => { + TYPE => 'INT2', + NOTNULL => 0, + REFERENCES => { + TABLE => 'products', + COLUMN => 'id', + DELETE => 'CASCADE', + } + }, + component_prefix => { + TYPE => 'VARCHAR(64)', + NOTNULL => 0, + }, + ], + }; +} + +sub install_update_db { + my $dbh = Bugzilla->dbh; + $dbh->bz_add_column( + 'components', + 'watch_user', + { + TYPE => 'INT3', + REFERENCES => { + TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'SET NULL', + } + } + ); + $dbh->bz_add_column( + 'component_watch', + 'id', + { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + ); + $dbh->bz_add_column( + 'component_watch', + 'component_prefix', + { + TYPE => 'VARCHAR(64)', + NOTNULL => 0, + } + ); +} + +# +# templates +# + +sub template_before_create { + my ($self, $args) = @_; + my $config = $args->{config}; + my $constants = $config->{CONSTANTS}; + $constants->{REL_COMPONENT_WATCHER} = REL_COMPONENT_WATCHER; +} + +# +# user-watch +# + +BEGIN { + *Bugzilla::Component::watch_user = \&_component_watch_user; +} + +sub _component_watch_user { + my ($self) = @_; + return unless $self->{watch_user}; + $self->{watch_user_object} ||= Bugzilla::User->new($self->{watch_user}); + return $self->{watch_user_object}; +} + +sub object_columns { + my ($self, $args) = @_; + my $class = $args->{class}; + my $columns = $args->{columns}; + return unless $class->isa('Bugzilla::Component'); + + push(@$columns, 'watch_user'); +} + +sub object_update_columns { + my ($self, $args) = @_; + my $object = $args->{object}; + my $columns = $args->{columns}; + return unless $object->isa('Bugzilla::Component'); + + push(@$columns, 'watch_user'); + + # editcomponents.cgi doesn't call set_all, so we have to do this here + my $input = Bugzilla->input_params; + $object->set('watch_user', $input->{watch_user}); +} + +sub object_validators { + my ($self, $args) = @_; + my $class = $args->{class}; + my $validators = $args->{validators}; + return unless $class->isa('Bugzilla::Component'); + + $validators->{watch_user} = \&_check_watch_user; +} + +sub object_before_create { + my ($self, $args) = @_; + my $class = $args->{class}; + my $params = $args->{params}; + return unless $class->isa('Bugzilla::Component'); + + my $input = Bugzilla->input_params; + $params->{watch_user} = $input->{watch_user}; +} + +sub object_end_of_update { + my ($self, $args) = @_; + my $object = $args->{object}; + my $old_object = $args->{old_object}; + my $changes = $args->{changes}; + return unless $object->isa('Bugzilla::Component'); + + my $old_id = $old_object->watch_user ? $old_object->watch_user->id : 0; + my $new_id = $object->watch_user ? $object->watch_user->id : 0; + return if $old_id == $new_id; + + $changes->{watch_user} = [ $old_id ? $old_id : undef, $new_id ? $new_id : undef ]; +} + +sub _check_watch_user { + my ($self, $value, $field) = @_; + $value = trim($value || ''); + if ($value eq '') { + ThrowUserError('component_watch_missing_watch_user'); + } + if ($value !~ /\.bugs$/i) { + ThrowUserError('component_watch_invalid_watch_user'); + } + return Bugzilla::User->check($value)->id; +} + +# +# preferences +# + +sub user_preferences { + my ($self, $args) = @_; + my $tab = $args->{'current_tab'}; + return unless $tab eq 'component_watch'; + + my $save = $args->{'save_changes'}; + my $handled = $args->{'handled'}; + my $vars = $args->{'vars'}; + my $user = Bugzilla->user; + my $input = Bugzilla->input_params; + + if ($save) { + if ($input->{'add'} && $input->{'add_product'}) { + # add watch + + # load product and verify access + my $productName = $input->{'add_product'}; + my $product = Bugzilla::Product->new({ name => $productName, cache => 1 }); + unless ($product && $user->can_access_product($product)) { + ThrowUserError('product_access_denied', { product => $productName }); + } + + # starting-with + if (my $prefix = $input->{add_starting}) { + _addPrefixWatch($user, $product, $prefix); + + } else { + my $ra_componentNames = $input->{'add_component'}; + $ra_componentNames = [$ra_componentNames || ''] unless ref($ra_componentNames); + + if (grep { $_ eq '' } @$ra_componentNames) { + # watching a product + _addProductWatch($user, $product); + + } else { + # watching specific components + foreach my $componentName (@$ra_componentNames) { + my $component = Bugzilla::Component->new({ + name => $componentName, product => $product, cache => 1 + }); + unless ($component) { + ThrowUserError('product_access_denied', { product => $productName }); + } + _addComponentWatch($user, $component); + } + } + } + + _addDefaultSettings($user); + + } else { + # remove watch(s) + + my $delete = ref $input->{del_watch} + ? $input->{del_watch} + : [ $input->{del_watch} ]; + foreach my $id (@$delete) { + _deleteWatch($user, $id); + } + } + + } + + $vars->{'add_product'} = $input->{'product'}; + $vars->{'add_component'} = $input->{'component'}; + $vars->{'watches'} = _getWatches($user); + $vars->{'user_watches'} = _getUserWatches($user); + + $$handled = 1; +} + +# +# bugmail +# + +sub bugmail_recipients { + my ($self, $args) = @_; + my $bug = $args->{'bug'}; + my $recipients = $args->{'recipients'}; + my $diffs = $args->{'diffs'}; + + my ($oldProductId, $newProductId) = ($bug->product_id, $bug->product_id); + my ($oldComponentId, $newComponentId) = ($bug->component_id, $bug->component_id); + + # notify when the product/component is switch from one being watched + if (@$diffs) { + # we need the product to process the component, so scan for that first + my $product; + foreach my $ra (@$diffs) { + next if !(exists $ra->{'old'} + && exists $ra->{'field_name'}); + if ($ra->{'field_name'} eq 'product') { + $product = Bugzilla::Product->new({ name => $ra->{'old'}, cache => 1 }); + $oldProductId = $product->id; + } + } + if (!$product) { + $product = Bugzilla::Product->new({ id => $oldProductId, cache => 1 }); + } + foreach my $ra (@$diffs) { + next if !(exists $ra->{'old'} + && exists $ra->{'field_name'}); + if ($ra->{'field_name'} eq 'component') { + my $component = Bugzilla::Component->new({ + name => $ra->{'old'}, product => $product, cache => 1 + }); + $oldComponentId = $component->id; + } + } + } + + # add component watchers + my $dbh = Bugzilla->dbh; + my $sth = $dbh->prepare(" + SELECT user_id + FROM component_watch + WHERE ((product_id = ? OR product_id = ?) AND component_id IS NULL) + OR (component_id = ? OR component_id = ?) + UNION + SELECT user_id + FROM component_watch + INNER JOIN components ON components.product_id = component_watch.product_id + WHERE component_prefix IS NOT NULL + AND (component_watch.product_id = ? OR component_watch.product_id = ?) + AND components.name LIKE CONCAT(component_prefix, '%') + "); + $sth->execute( + $oldProductId, $newProductId, + $oldComponentId, $newComponentId, + $oldProductId, $newProductId + ); + while (my ($uid) = $sth->fetchrow_array) { + if (!exists $recipients->{$uid}) { + $recipients->{$uid}->{+REL_COMPONENT_WATCHER} = Bugzilla::BugMail::BIT_WATCHING(); + } + } + + # add component watchers from watch-users + my $uidList = join(',', keys %$recipients); + $sth = $dbh->prepare(" + SELECT component_watch.user_id + FROM components + INNER JOIN component_watch ON component_watch.component_id = components.id + WHERE components.watch_user in ($uidList) + "); + $sth->execute(); + while (my ($uid) = $sth->fetchrow_array) { + if (!exists $recipients->{$uid}) { + $recipients->{$uid}->{+REL_COMPONENT_WATCHER} = Bugzilla::BugMail::BIT_WATCHING(); + } + } + + # add watch-users from component watchers + $sth = $dbh->prepare(" + SELECT watch_user + FROM components + WHERE (id = ? OR id = ?) + AND (watch_user IS NOT NULL) + "); + $sth->execute($oldComponentId, $newComponentId); + while (my ($uid) = $sth->fetchrow_array) { + if (!exists $recipients->{$uid}) { + $recipients->{$uid}->{+REL_COMPONENT_WATCHER} = Bugzilla::BugMail::BIT_DIRECT(); + } + } +} + +sub bugmail_relationships { + my ($self, $args) = @_; + my $relationships = $args->{relationships}; + $relationships->{+REL_COMPONENT_WATCHER} = 'Component-Watcher'; +} + +# +# db +# + +sub _getWatches { + my ($user) = @_; + my $dbh = Bugzilla->dbh; + + my $sth = $dbh->prepare(" + SELECT id, product_id, component_id, component_prefix + FROM component_watch + WHERE user_id = ? + "); + $sth->execute($user->id); + my @watches; + while (my ($id, $productId, $componentId, $prefix) = $sth->fetchrow_array) { + my $product = Bugzilla::Product->new({ id => $productId, cache => 1 }); + next unless $product && $user->can_access_product($product); + + my %watch = ( + id => $id, + product => $product, + product_name => $product->name, + component_name => '', + component_prefix => $prefix, + ); + if ($componentId) { + my $component = Bugzilla::Component->new({ id => $componentId, cache => 1 }); + next unless $component; + $watch{'component'} = $component; + $watch{'component_name'} = $component->name; + } + + push @watches, \%watch; + } + + @watches = sort { + $a->{'product_name'} cmp $b->{'product_name'} + || $a->{'component_name'} cmp $b->{'component_name'} + || $a->{'component_prefix'} cmp $b->{'component_prefix'} + } @watches; + + return \@watches; +} + +sub _getUserWatches { + my ($user) = @_; + my $dbh = Bugzilla->dbh; + + my $sth = $dbh->prepare(" + SELECT components.product_id, components.id as component, profiles.login_name + FROM watch + INNER JOIN components ON components.watch_user = watched + INNER JOIN profiles ON profiles.userid = watched + WHERE watcher = ? + "); + $sth->execute($user->id); + my @watches; + while (my ($productId, $componentId, $login) = $sth->fetchrow_array) { + my $product = Bugzilla::Product->new({ id => $productId, cache => 1 }); + next unless $product && $user->can_access_product($product); + + my %watch = ( + product => $product, + component => Bugzilla::Component->new({ id => $componentId, cache => 1 }), + user => Bugzilla::User->check($login), + ); + push @watches, \%watch; + } + + @watches = sort { + $a->{'product'}->name cmp $b->{'product'}->name + || $a->{'component'}->name cmp $b->{'component'}->name + } @watches; + + return \@watches; +} + +sub _addProductWatch { + my ($user, $product) = @_; + my $dbh = Bugzilla->dbh; + + my $sth = $dbh->prepare(" + SELECT 1 + FROM component_watch + WHERE user_id = ? AND product_id = ? AND component_id IS NULL + "); + $sth->execute($user->id, $product->id); + return if $sth->fetchrow_array; + + $sth = $dbh->prepare(" + DELETE FROM component_watch + WHERE user_id = ? AND product_id = ? + "); + $sth->execute($user->id, $product->id); + + $sth = $dbh->prepare(" + INSERT INTO component_watch(user_id, product_id) + VALUES (?, ?) + "); + $sth->execute($user->id, $product->id); +} + +sub _addComponentWatch { + my ($user, $component) = @_; + my $dbh = Bugzilla->dbh; + + my $sth = $dbh->prepare(" + SELECT 1 + FROM component_watch + WHERE user_id = ? + AND (component_id = ? OR (product_id = ? AND component_id IS NULL)) + "); + $sth->execute($user->id, $component->id, $component->product_id); + return if $sth->fetchrow_array; + + $sth = $dbh->prepare(" + INSERT INTO component_watch(user_id, product_id, component_id) + VALUES (?, ?, ?) + "); + $sth->execute($user->id, $component->product_id, $component->id); +} + +sub _addPrefixWatch { + my ($user, $product, $prefix) = @_; + my $dbh = Bugzilla->dbh; + + trick_taint($prefix); + my $sth = $dbh->prepare(" + SELECT 1 + FROM component_watch + WHERE user_id = ? + AND ( + (product_id = ? AND component_prefix = ?) + OR (product_id = ? AND component_id IS NULL) + ) + "); + $sth->execute( + $user->id, + $product->id, $prefix, + $product->id + ); + return if $sth->fetchrow_array; + + $sth = $dbh->prepare(" + INSERT INTO component_watch(user_id, product_id, component_prefix) + VALUES (?, ?, ?) + "); + $sth->execute($user->id, $product->id, $prefix); +} + +sub _deleteWatch { + my ($user, $id) = @_; + my $dbh = Bugzilla->dbh; + + trick_taint($id); + $dbh->do("DELETE FROM component_watch WHERE id=?", undef, $id); +} + +sub _addDefaultSettings { + my ($user) = @_; + my $dbh = Bugzilla->dbh; + + my $sth = $dbh->prepare(" + SELECT 1 + FROM email_setting + WHERE user_id = ? AND relationship = ? + "); + $sth->execute($user->id, REL_COMPONENT_WATCHER); + return if $sth->fetchrow_array; + + my @defaultEvents = ( + EVT_OTHER, + EVT_COMMENT, + EVT_ATTACHMENT, + EVT_ATTACHMENT_DATA, + EVT_PROJ_MANAGEMENT, + EVT_OPENED_CLOSED, + EVT_KEYWORD, + EVT_DEPEND_BLOCK, + EVT_BUG_CREATED, + ); + foreach my $event (@defaultEvents) { + $dbh->do( + "INSERT INTO email_setting(user_id,relationship,event) VALUES (?,?,?)", + undef, + $user->id, REL_COMPONENT_WATCHER, $event + ); + } +} + +sub reorg_move_component { + my ($self, $args) = @_; + my $new_product = $args->{new_product}; + my $component = $args->{component}; + + Bugzilla->dbh->do( + "UPDATE component_watch SET product_id=? WHERE component_id=?", + undef, + $new_product->id, $component->id, + ); +} + +sub sanitycheck_check { + my ($self, $args) = @_; + my $status = $args->{status}; + + $status->('component_watching_check'); + + my ($count) = Bugzilla->dbh->selectrow_array(" + SELECT COUNT(*) + FROM component_watch + INNER JOIN components ON components.id = component_watch.component_id + WHERE component_watch.product_id <> components.product_id + "); + if ($count) { + $status->('component_watching_alert', undef, 'alert'); + $status->('component_watching_repair'); + } +} + +sub sanitycheck_repair { + my ($self, $args) = @_; + return unless Bugzilla->cgi->param('component_watching_repair'); + + my $status = $args->{'status'}; + my $dbh = Bugzilla->dbh; + $status->('component_watching_repairing'); + + my $rows = $dbh->selectall_arrayref(" + SELECT DISTINCT component_watch.product_id AS bad_product_id, + components.product_id AS good_product_id, + component_watch.component_id + FROM component_watch + INNER JOIN components ON components.id = component_watch.component_id + WHERE component_watch.product_id <> components.product_id + ", + { Slice => {} } + ); + foreach my $row (@$rows) { + $dbh->do(" + UPDATE component_watch + SET product_id=? + WHERE product_id=? AND component_id=? + ", undef, + $row->{good_product_id}, + $row->{bad_product_id}, + $row->{component_id}, + ); + } +} + +__PACKAGE__->NAME; diff --git a/extensions/ComponentWatching/template/en/default/account/prefs/component_watch.html.tmpl b/extensions/ComponentWatching/template/en/default/account/prefs/component_watch.html.tmpl new file mode 100644 index 000000000..5e27c1247 --- /dev/null +++ b/extensions/ComponentWatching/template/en/default/account/prefs/component_watch.html.tmpl @@ -0,0 +1,261 @@ +[%# 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. + #%] + +[%# initialise product to component mapping #%] + +[% SET selectable_products = user.get_selectable_products %] +[% SET dont_show_button = 1 %] + +<style> +#add_compwatch input[type=text], +#add_compwatch select +{ + width: 30em; +} + +#component[disabled] { + color: silver; +} +</style> + +<script> +var Dom = YAHOO.util.Dom; +var useclassification = false; +var first_load = true; +var last_sel = []; +var cpts = new Array(); +var watch_users = new Array(); +[% n = 0 %] +[% FOREACH prod = selectable_products %] + cpts['[% n %]'] = [ + [%- FOREACH comp = prod.components %]'[% comp.name FILTER js %]'[% ", " UNLESS loop.last %] [%- END -%] ]; + [% n = n + 1 %] + [% FOREACH comp = prod.components %] + [% IF comp.watch_user %] + if (!watch_users['[% prod.name FILTER js %]']) + watch_users['[% prod.name FILTER js %]'] = new Array(); + watch_users['[% prod.name FILTER js %]']['[% comp.name FILTER js %]'] = '[% comp.watch_user.login FILTER js %]'; + [% END %] + [% END %] +[% END %] +</script> +<script type="text/javascript" src="[% 'js/productform.js' FILTER mtime FILTER html %]"> +</script> + +<script> +function onSelectProduct() { + var component = Dom.get('component'); + selectProduct(Dom.get('product'), component); + // selectProduct only supports __Any__ on both elements + // we only want it on component, so add it back in + try { + component.add(new Option('__Any__', ''), component.options[0]); + } catch(e) { + // support IE + component.add(new Option('__Any__', ''), 0); + } + if ('[% add_component FILTER js %]' != '' + && bz_valueSelected(Dom.get('product'), '[% add_product FILTER js %]') + ) { + var index = bz_optionIndex(Dom.get('component'), '[% add_component FILTER js %]'); + if (index != -1) + Dom.get('component').options[index].selected = true; + } + onSelectComponent(); +} + +function onSelectComponent() { + var product_select = Dom.get('product'); + var product = product_select.options[product_select.selectedIndex].value; + var component = Dom.get('component').value; + if (component && watch_users[product] && watch_users[product][component]) { + Dom.get('watch-user-email').innerHTML = watch_users[product][component]; + Dom.get('watch-user-div').style.display = ''; + } else { + Dom.get('watch-user-div').style.display = 'none'; + } + Dom.get('add').disabled = Dom.get('component').selectedIndex == -1; +} + +function onStartingWith(el) { + var value = el.value.replace(/(^\s*|\s*$)/g, ''); + if (value == '') { + Dom.get('component').disabled = false; + onSelectProduct(); + } else { + Dom.get('component').selectedIndex = -1; + Dom.get('watch-user-div').style.display = 'none'; + Dom.get('component').disabled = true; + Dom.get('add').disabled = false; + } +} + +YAHOO.util.Event.onDOMReady(onSelectProduct); + +function onRemoveChange() { + var cbs = Dom.get('remove_table').getElementsByTagName('input'); + for (var i = 0, l = cbs.length; i < l; i++) { + if (cbs[i].checked) { + Dom.get('remove').disabled = false; + return; + } + } + Dom.get('remove').disabled = true; +} + +YAHOO.util.Event.onDOMReady(onRemoveChange); + +</script> + +<p> + Select the components you want to watch.<br> + To watch all components in a product, watch "__Any__".<br> + Watching components starting with "Developer Tools" is the same as watching + "Developer Tools", "Developer Tools: Console", "Developer Tools: Debugger", + etc. If a new component is added which starts with "Developer Tools", you'll + automatically start watching that too. + <br> + Use <a href="userprefs.cgi?tab=email">Email Preferences</a> to filter which + notification emails you receive. +</p> + +<table border="0" cellpadding="3" cellspacing="0" id="add_compwatch"> +<tr> + <th align="right">Product:</th> + <td colspan="2"> + <select name="add_product" id="product" onChange="onSelectProduct()"> + [% FOREACH product IN selectable_products %] + <option [% 'selected' IF add_product == product.name %]> + [%~ product.name FILTER html %]</option> + [% END %] + </select> + </td> +</tr> +<tr> + <th align="right" valign="top">Component:</th> + <td>Select the component(s) to add to your watch list:</td> +</tr> +<tr> + <td></td> + <td> + <select name="add_component" id="component" multiple size="5" onChange="onSelectComponent()"> + <option value="">__Any__</option> + [% FOREACH product IN selectable_products %] + [% FOREACH component IN product.components %] + <option [% 'selected' IF add_component == component.name %]> + [%~ component.name FILTER html %]</option> + [% END %] + [% END %] + </select><br> + Or watch components starting with:<br> + <input type="text" name="add_starting" id="add_starting" maxlength="64" + onKeyUp="onStartingWith(this)" onBlur="onStartingWith(this)"> + </td> + <td valign="top"> + <div id="watch-user-div" + title="You can also watch a component by following this user. [% ~%] + CC'ing this user on a [% terms.bug %] will trigger notifications to all watchers of this component." + style="cursor:help"> + Watch User: <span id="watch-user-email"></span> + </div> + </td> +</tr> +<tr> + <td> </td> + <td><input type="submit" id="add" name="add" value="Add"></td> +</tr> +</table> + +<hr> +<p> + You are currently watching: +</p> + +[% IF watches.size %] + + <table border="0" cellpadding="3" cellspacing="0" id="remove_table"> + <tr> + <td> </td> + <td><b>Product</b></td> + <td> <b>Component</b></td> + </tr> + [% FOREACH watch IN watches %] + <tr> + <td> + <input type="checkbox" onChange="onRemoveChange()" + id="cwdel_[% watch.id FILTER none %]" + name="del_watch" value="[% watch.id FILTER none %]"> + </td> + <td> + <label for="cwdel_[% watch.id FILTER none %]"> + [% watch.product.name FILTER html %] + </label> + </td> + <td> + [% IF (watch.component) %] + <a href="buglist.cgi?product=[% watch.product.name FILTER uri ~%] + &component=[% watch.component.name FILTER uri %]&resolution=---"> + [% watch.component.name FILTER html %] + </a> + [% ELSIF watch.component_prefix %] + <i>starts with:</i> [% watch.component_prefix FILTER html %] + [% ELSE %] + <a href="describecomponents.cgi?product=[% watch.product.name FILTER uri %]"> + __Any__ + </a> + [% END %] + </td> + </tr> + [% END %] + </table> + + <input id="remove" type="submit" value="Remove Selected"> + +[% ELSE %] + + <p> + <i>You are not watching any components directly.</i> + </p> + +[% END %] + +[% IF user_watches.size %] + + <hr> + <p> + [% watches.size ? "In addition," : "However," %] + you are watching the following components by watching users: + </p> + + <table border="0" cellpadding="3" cellspacing="0"> + <tr> + <td><b>User</b></td> + <td> <b>Product</b></td> + <td> <b>Component</b></td> + </tr> + [% FOREACH watch IN user_watches %] + <tr> + <td>[% watch.user.login FILTER html %]</td> + <td> [% watch.component.product.name FILTER html %]</td> + <td> + <a href="buglist.cgi?product=[% watch.product.name FILTER uri ~%] + &component=[% watch.component.name FILTER uri %]&resolution=---"> + [% watch.component.name FILTER html %] + </a> + </td> + </tr> + [% END %] + </table> + + <p> + Use <a href="userprefs.cgi?tab=email#new_watched_by_you">Email Preferences</a> + to manage this list. + </p> + +[% END %] + diff --git a/extensions/ComponentWatching/template/en/default/hook/account/prefs/email-relationships.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/account/prefs/email-relationships.html.tmpl new file mode 100644 index 000000000..69ab53751 --- /dev/null +++ b/extensions/ComponentWatching/template/en/default/hook/account/prefs/email-relationships.html.tmpl @@ -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. + #%] + +[% relationships.push({ id = constants.REL_COMPONENT_WATCHER, description = "Component" }) %] +[% no_added_removed.push(constants.REL_COMPONENT_WATCHER) %] diff --git a/extensions/ComponentWatching/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl new file mode 100644 index 000000000..9af22ed39 --- /dev/null +++ b/extensions/ComponentWatching/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl @@ -0,0 +1,14 @@ +[%# 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. + #%] + +[% tabs = tabs.import([{ + name => "component_watch", + label => "Component Watching", + link => "userprefs.cgi?tab=component_watch", + saveable => 1 + }]) %] diff --git a/extensions/ComponentWatching/template/en/default/hook/admin/components/edit-common-rows.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/admin/components/edit-common-rows.html.tmpl new file mode 100644 index 000000000..4f92097ff --- /dev/null +++ b/extensions/ComponentWatching/template/en/default/hook/admin/components/edit-common-rows.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"><label for="watch_user">Watch User:</label></th> + <td> + [% INCLUDE global/userselect.html.tmpl + name => "watch_user" + id => "watch_user" + value => comp.watch_user.login + size => 64 + emptyok => 1 + %] + </td> +</tr> diff --git a/extensions/ComponentWatching/template/en/default/hook/admin/components/list-before_table.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/admin/components/list-before_table.html.tmpl new file mode 100644 index 000000000..ed8d6e350 --- /dev/null +++ b/extensions/ComponentWatching/template/en/default/hook/admin/components/list-before_table.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. + #%] + +[% CALL columns.splice(5, 0, { name => 'watch_user', heading => 'Watch User' }) %] + +[% FOREACH my_component = product.components %] + [% overrides.watch_user.name.${my_component.name} = { + override_content => 1 + content => my_component.watch_user.login + } + %] +[% END %] diff --git a/extensions/ComponentWatching/template/en/default/hook/admin/sanitycheck/messages-statuses.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/admin/sanitycheck/messages-statuses.html.tmpl new file mode 100644 index 000000000..c22ffacaa --- /dev/null +++ b/extensions/ComponentWatching/template/en/default/hook/admin/sanitycheck/messages-statuses.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. + #%] + +[% IF san_tag == "component_watching_repair" %] + <a href="sanitycheck.cgi?component_watching_repair=1&token= + [%- issue_hash_token(['sanitycheck']) FILTER uri %]" + >Repair invalid product_id values in the component_watch table</a> + +[% ELSIF san_tag == "component_watching_check" %] + Checking component_watch table for bad values of product_id. + +[% ELSIF san_tag == "component_watching_alert" %] + Bad values for product_id found in the component_watch table. + +[% ELSIF san_tag == "component_watching_repairing" %] + OK, now fixing bad product_id values in the component_watch table. + +[% END %] diff --git a/extensions/ComponentWatching/template/en/default/hook/global/messages-component_updated_fields.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/global/messages-component_updated_fields.html.tmpl new file mode 100644 index 000000000..38c7e8c8a --- /dev/null +++ b/extensions/ComponentWatching/template/en/default/hook/global/messages-component_updated_fields.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 changes.watch_user.defined %] + [% IF comp.watch_user %] + <li>Watch User updated to '[% comp.watch_user.login FILTER html %]'</li> + [% ELSE %] + <li>Watch User deleted</li> + [% END %] +[% END %] diff --git a/extensions/ComponentWatching/template/en/default/hook/global/reason-descs-end.none.tmpl b/extensions/ComponentWatching/template/en/default/hook/global/reason-descs-end.none.tmpl new file mode 100644 index 000000000..8cd67bdff --- /dev/null +++ b/extensions/ComponentWatching/template/en/default/hook/global/reason-descs-end.none.tmpl @@ -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. + #%] + +[% watch_reason_descs.${constants.REL_COMPONENT_WATCHER} = + "You are watching the component for the ${terms.bug}." %] diff --git a/extensions/ComponentWatching/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..01dbb5114 --- /dev/null +++ b/extensions/ComponentWatching/template/en/default/hook/global/user-error-errors.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. + #%] + +[% IF error == "component_watch_invalid_watch_user" %] + [% title = "Invalid Watch User" %] + The "Watch User" must be a <b>.bugs</b> email address.<br> + For example: <i>accessibility-apis@core.bugs</i> +[% ELSIF error == "component_watch_missing_watch_user" %] + [% title = "Missing Watch User" %] + You must provide a <b>.bugs</b> email address for the "Watch User".<br> + For example: <i>accessibility-apis@core.bugs</i> +[% END %] diff --git a/extensions/ContributorEngagement/Config.pm b/extensions/ContributorEngagement/Config.pm new file mode 100644 index 000000000..3984dd60e --- /dev/null +++ b/extensions/ContributorEngagement/Config.pm @@ -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. + +package Bugzilla::Extension::ContributorEngagement; +use strict; + +use constant NAME => 'ContributorEngagement'; + +use constant REQUIRED_MODULES => [ +]; + +use constant OPTIONAL_MODULES => [ +]; + +__PACKAGE__->NAME; diff --git a/extensions/ContributorEngagement/Extension.pm b/extensions/ContributorEngagement/Extension.pm new file mode 100644 index 000000000..def41b6ea --- /dev/null +++ b/extensions/ContributorEngagement/Extension.pm @@ -0,0 +1,123 @@ +# 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::ContributorEngagement; + +use strict; +use warnings; + +use base qw(Bugzilla::Extension); + +use Bugzilla::User; +use Bugzilla::Util qw(format_time); +use Bugzilla::Mailer; +use Bugzilla::Install::Util qw(indicate_progress); + +use Bugzilla::Extension::ContributorEngagement::Constants; + +our $VERSION = '2.0'; + +BEGIN { + *Bugzilla::User::first_patch_reviewed_id = \&_first_patch_reviewed_id; +} + +sub _first_patch_reviewed_id { return $_[0]->{'first_patch_reviewed_id'}; } + +sub install_update_db { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + + if ($dbh->bz_column_info('profiles', 'first_patch_approved_id')) { + $dbh->bz_drop_column('profiles', 'first_patch_approved_id'); + } + if (!$dbh->bz_column_info('profiles', 'first_patch_reviewed_id')) { + $dbh->bz_add_column('profiles', 'first_patch_reviewed_id', { TYPE => 'INT3' }); + _populate_first_reviewed_ids(); + } +} + +sub _populate_first_reviewed_ids { + my $dbh = Bugzilla->dbh; + + my $sth = $dbh->prepare('UPDATE profiles SET first_patch_reviewed_id = ? WHERE userid = ?'); + my $ra = $dbh->selectall_arrayref("SELECT attachments.submitter_id, + attachments.attach_id + FROM attachments + INNER JOIN flags ON attachments.attach_id = flags.attach_id + INNER JOIN flagtypes ON flags.type_id = flagtypes.id + WHERE flagtypes.name LIKE 'review%' AND flags.status = '+' + ORDER BY flags.modification_date"); + my $count = 1; + my $total = scalar @$ra; + my %user_seen; + foreach my $ra_row (@$ra) { + my ($user_id, $attach_id) = @$ra_row; + indicate_progress({ current => $count++, total => $total, every => 25 }); + next if $user_seen{$user_id}; + $sth->execute($attach_id, $user_id); + $user_seen{$user_id} = 1; + } + + print "done\n"; +} + +sub object_columns { + my ($self, $args) = @_; + my ($class, $columns) = @$args{qw(class columns)}; + if ($class->isa('Bugzilla::User')) { + push(@$columns, 'first_patch_reviewed_id'); + } +} + +sub flag_end_of_update { + my ($self, $args) = @_; + my ($object, $timestamp, $new_flags) = @$args{qw(object timestamp new_flags)}; + + if ($object->isa('Bugzilla::Attachment') + && @$new_flags + && !$object->attacher->first_patch_reviewed_id + && grep($_ eq $object->bug->product, ENABLED_PRODUCTS)) + { + my $attachment = $object; + + foreach my $orig_change (@$new_flags) { + my $change = $orig_change; + $change =~ s/^[^:]+://; # get rid of setter + $change =~ s/\([^\)]+\)$//; # get rid of requestee + my ($name, $value) = $change =~ /^(.+)(.)$/; + + # Only interested in review flags set to + + next unless $name =~ /^review/ && $value eq '+'; + + _send_mail($attachment, $timestamp); + + Bugzilla->dbh->do("UPDATE profiles SET first_patch_reviewed_id = ? WHERE userid = ?", + undef, $attachment->id, $attachment->attacher->id); + Bugzilla->memcached->clear({ table => 'profiles', id => $attachment->attacher->id }); + last; + } + } +} + +sub _send_mail { + my ($attachment, $timestamp) = @_; + + my $vars = { + date => format_time($timestamp, '%a, %d %b %Y %T %z', 'UTC'), + attachment => $attachment, + from_user => EMAIL_FROM, + }; + + my $msg; + my $template = Bugzilla->template_inner($attachment->attacher->setting('lang')); + $template->process("contributor/email.txt.tmpl", $vars, \$msg) + || ThrowTemplateError($template->error()); + + MessageToMTA($msg); +} + +__PACKAGE__->NAME; diff --git a/extensions/ContributorEngagement/lib/Constants.pm b/extensions/ContributorEngagement/lib/Constants.pm new file mode 100644 index 000000000..346e00c35 --- /dev/null +++ b/extensions/ContributorEngagement/lib/Constants.pm @@ -0,0 +1,31 @@ +# 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::ContributorEngagement::Constants; + +use strict; + +use base qw(Exporter); + +our @EXPORT = qw( + EMAIL_FROM + ENABLED_PRODUCTS +); + +use constant EMAIL_FROM => 'bugzilla-daemon@mozilla.org'; + +use constant ENABLED_PRODUCTS => ( + "Core", + "Firefox", + "Firefox for Android", + "Firefox for Metro", + "Mozilla Services", + "Testing", + "Toolkit", +); + +1; diff --git a/extensions/ContributorEngagement/template/en/default/contributor/email.txt.tmpl b/extensions/ContributorEngagement/template/en/default/contributor/email.txt.tmpl new file mode 100644 index 000000000..915ebd912 --- /dev/null +++ b/extensions/ContributorEngagement/template/en/default/contributor/email.txt.tmpl @@ -0,0 +1,49 @@ +[%# 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" %] +From: [% from_user FILTER none %] +To: [% attachment.attacher.email FILTER none %] +Subject: Congratulations on having your first patch approved +Date: [% date FILTER none %] +X-Bugzilla-Type: contributor-engagement + +Congratulations on having your first patch approved, and thank you for your +contribution to Mozilla. + +[%+ urlbase %]attachment.cgi?id=[% attachment.id FILTER uri %]&action=edit + +The next step is to get the patch actually checked in to our repository. For +more information about how to make that happen, check out this post: + +https://developer.mozilla.org/en-US/docs/Mercurial_FAQ#How_can_I_generate_a_patch_for_somebody_else_to_check-in_for_me.3F + +While you are going through those final steps, if you're looking for a new +project to take on, have a look at our list of 'mentored' [% terms.bugs %] ([% terms.bugs %] where +someone is specifically available to help you): + +https://bugzil.la/sw:mentor + +Alternatively, you could join us on our IRC chat server in the #introduction +channel and ask for suggestions about what would be a good [% terms.bugs %] to work on. +There's more about using our chat server at: + +http://irc.mozilla.org/ + +If you haven't done so already, this is also a good time to sign up to the +Mozilla Contributor Directory and create a profile for yourself. Doing this +will give you access to community members' profiles so you can reach out and +connect with other Mozillians. You will need someone to 'vouch for' your +profile; if you don't know any other Mozillians well, why not contact the +person who approved your patch? + +The directory is here: + +https://mozillians.org/ + +Thanks again for your help :-) +Josh, Kyle, Dietrich and Brian; Coding Stewards diff --git a/extensions/EditComments/Config.pm b/extensions/EditComments/Config.pm new file mode 100644 index 000000000..dae675001 --- /dev/null +++ b/extensions/EditComments/Config.pm @@ -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. + +package Bugzilla::Extension::EditComments; + +use 5.10.1; +use strict; + +use constant NAME => 'EditComments'; + +use constant REQUIRED_MODULES => []; + +use constant OPTIONAL_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/EditComments/Extension.pm b/extensions/EditComments/Extension.pm new file mode 100644 index 000000000..fef1b7693 --- /dev/null +++ b/extensions/EditComments/Extension.pm @@ -0,0 +1,270 @@ +# 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::EditComments; + +use 5.10.1; +use strict; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Bug; +use Bugzilla::Util; +use Bugzilla::Error; +use Bugzilla::Config::Common; +use Bugzilla::Config::GroupSecurity; +use Bugzilla::WebService::Bug; +use Bugzilla::WebService::Util qw(filter_wants); + +our $VERSION = '0.01'; + +################ +# Installation # +################ + +sub db_schema_abstract_schema { + my ($self, $args) = @_; + my $schema = $args->{schema}; + + $schema->{'longdescs_activity'} = { + FIELDS => [ + comment_id => {TYPE => 'INT', NOTNULL => 1, + REFERENCES => {TABLE => 'longdescs', + COLUMN => 'comment_id', + DELETE => 'CASCADE'}}, + who => {TYPE => 'INT3', NOTNULL => 1, + REFERENCES => {TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE'}}, + change_when => {TYPE => 'DATETIME', NOTNULL => 1}, + old_comment => {TYPE => 'LONGTEXT', NOTNULL => 1}, + ], + INDEXES => [ + longdescs_activity_comment_id_idx => ['comment_id'], + longdescs_activity_change_when_idx => ['change_when'], + longdescs_activity_comment_id_change_when_idx => [qw(comment_id change_when)], + ], + }; +} + +sub install_update_db { + my $dbh = Bugzilla->dbh; + $dbh->bz_add_column('longdescs', 'edit_count', { TYPE => 'INT3', DEFAULT => 0 }); +} + +#################### +# Template Methods # +#################### + +sub page_before_template { + my ($self, $args) = @_; + + return if $args->{'page_id'} ne 'editcomments.html'; + + my $vars = $args->{'vars'}; + my $user = Bugzilla->user; + my $params = Bugzilla->input_params; + + # validate group membership + my $edit_comments_group = Bugzilla->params->{edit_comments_group}; + if (!$edit_comments_group || !$user->in_group($edit_comments_group)) { + ThrowUserError('auth_failure', { group => $edit_comments_group, + action => 'view', + object => 'editcomments' }); + } + + my $bug_id = $params->{bug_id}; + my $bug = Bugzilla::Bug->check($bug_id); + + my $comment_id = $params->{comment_id}; + + my ($comment) = grep($_->id == $comment_id, @{ $bug->comments }); + if (!$comment + || ($comment->is_private && !$user->is_insider)) + { + ThrowUserError("edit_comment_invalid_comment_id", { comment_id => $comment_id }); + } + + $vars->{'bug'} = $bug; + $vars->{'comment'} = $comment; +} + +################## +# Object Methods # +################## + +BEGIN { + no warnings 'redefine'; + *Bugzilla::Comment::activity = \&_get_activity; + *Bugzilla::Comment::edit_count = \&_edit_count; + *Bugzilla::WebService::Bug::_super_translate_comment = \&Bugzilla::WebService::Bug::_translate_comment; + *Bugzilla::WebService::Bug::_translate_comment = \&_new_translate_comment; +} + +sub _new_translate_comment { + my ($self, $comment, $filters) = @_; + + my $comment_hash = $self->_super_translate_comment($comment, $filters); + + if (filter_wants $filters, 'raw_text') { + $comment_hash->{raw_text} = $self->type('string', $comment->body); + } + + return $comment_hash; +} + +sub _edit_count { return $_[0]->{'edit_count'}; } + +sub _get_activity { + my ($self, $activity_sort_order) = @_; + + return $self->{'activity'} if $self->{'activity'}; + + my $dbh = Bugzilla->dbh; + my $query = 'SELECT longdescs_activity.comment_id AS id, profiles.userid, ' . + $dbh->sql_date_format('longdescs_activity.change_when', '%Y.%m.%d %H:%i:%s') . ' + AS time, longdescs_activity.old_comment AS old + FROM longdescs_activity + INNER JOIN profiles + ON profiles.userid = longdescs_activity.who + WHERE longdescs_activity.comment_id = ?'; + $query .= " ORDER BY longdescs_activity.change_when DESC"; + my $sth = $dbh->prepare($query); + $sth->execute($self->id); + + # We are shifting each comment activity body 1 back. The reason this + # has to be done is that the longdescs_activity table stores the comment + # body that the comment was before the edit, not the actual new version + # of the comment. + my @activity; + my $new_comment; + my $last_old_comment; + my $count = 0; + while (my $change_ref = $sth->fetchrow_hashref()) { + my %change = %$change_ref; + $change{'author'} = Bugzilla::User->new({ id => $change{'userid'}, cache => 1 }); + if ($count == 0) { + $change{new} = $self->body; + } + else { + $change{new} = $new_comment; + } + $new_comment = $change{old}; + $last_old_comment = $change{old}; + push (@activity, \%change); + $count++; + } + + return [] if !@activity; + + # Store the original comment as the first or last entry + # depending on sort order + push(@activity, { + author => $self->author, + body => $last_old_comment, + time => $self->creation_ts, + original => 1 + }); + + $activity_sort_order + ||= Bugzilla->user->settings->{'comment_sort_order'}->{'value'}; + + if ($activity_sort_order eq "oldest_to_newest") { + @activity = reverse @activity; + } + + $self->{'activity'} = \@activity; + + return $self->{'activity'}; +} + +######### +# Hooks # +######### + +sub object_columns { + my ($self, $args) = @_; + my ($class, $columns) = @$args{qw(class columns)}; + if ($class->isa('Bugzilla::Comment')) { + push(@$columns, 'edit_count'); + } +} + +sub bug_end_of_update { + my ($self, $args) = @_; + + # Silently return if not in the proper group + # or if editing comments is disabled + my $user = Bugzilla->user; + my $edit_comments_group = Bugzilla->params->{edit_comments_group}; + return if (!$edit_comments_group || !$user->in_group($edit_comments_group)); + + my $bug = $args->{bug}; + my $timestamp = $args->{timestamp}; + my $params = Bugzilla->input_params; + my $dbh = Bugzilla->dbh; + + my $updated = 0; + foreach my $param (grep(/^edit_comment_textarea_/, keys %$params)) { + my ($comment_id) = $param =~ /edit_comment_textarea_(\d+)$/; + next if !detaint_natural($comment_id); + + # The comment ID must belong to this bug. + my ($comment_obj) = grep($_->id == $comment_id, @{ $bug->comments}); + next if (!$comment_obj || ($comment_obj->is_private && !$user->is_insider)); + + my $new_comment = $comment_obj->_check_thetext($params->{$param}); + + my $old_comment = $comment_obj->body; + next if $old_comment eq $new_comment; + + trick_taint($new_comment); + $dbh->do("UPDATE longdescs SET thetext = ?, edit_count = edit_count + 1 + WHERE comment_id = ?", + undef, $new_comment, $comment_id); + Bugzilla->memcached->clear({ table => 'longdescs', id => $comment_id }); + + # Log old comment to the longdescs activity table + $timestamp ||= $dbh->selectrow_array("SELECT NOW()"); + $dbh->do("INSERT INTO longdescs_activity " . + "(comment_id, who, change_when, old_comment) " . + "VALUES (?, ?, ?, ?)", + undef, ($comment_id, $user->id, $timestamp, $old_comment)); + + $comment_obj->{thetext} = $new_comment; + + $updated = 1; + } + + $bug->_sync_fulltext( update_comments => 1 ) if $updated; +} + +sub config_modify_panels { + my ($self, $args) = @_; + push @{ $args->{panels}->{groupsecurity}->{params} }, { + name => 'edit_comments_group', + type => 's', + choices => \&Bugzilla::Config::GroupSecurity::_get_all_group_names, + default => 'admin', + checker => \&check_group + }; +} + +sub webservice { + my ($self, $args) = @_; + my $dispatch = $args->{dispatch}; + $dispatch->{EditComments} = "Bugzilla::Extension::EditComments::WebService"; +} + +sub db_sanitize { + my $dbh = Bugzilla->dbh; + print "Deleting edited comment histories...\n"; + $dbh->do("DELETE FROM longdescs_activity"); + $dbh->do("UPDATE longdescs SET edit_count=0"); +} + +__PACKAGE__->NAME; diff --git a/extensions/EditComments/lib/WebService.pm b/extensions/EditComments/lib/WebService.pm new file mode 100644 index 000000000..9213f0407 --- /dev/null +++ b/extensions/EditComments/lib/WebService.pm @@ -0,0 +1,170 @@ +# 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::EditComments::WebService; + +use strict; +use warnings; + +use base qw(Bugzilla::WebService); + +use Bugzilla::Error; +use Bugzilla::Util qw(trim); +use Bugzilla::WebService::Util qw(validate); + +sub comments { + my ($self, $params) = validate(@_, 'comment_ids'); + my $dbh = Bugzilla->switch_to_shadow_db(); + my $user = Bugzilla->user; + + if (!defined $params->{comment_ids}) { + ThrowCodeError('param_required', + { function => 'Bug.comments', + param => 'comment_ids' }); + } + + my @ids = map { trim($_) } @{ $params->{comment_ids} || [] }; + my $comment_data = Bugzilla::Comment->new_from_list(\@ids); + + # See if we were passed any invalid comment ids. + my %got_ids = map { $_->id => 1 } @$comment_data; + foreach my $comment_id (@ids) { + if (!$got_ids{$comment_id}) { + ThrowUserError('comment_id_invalid', { id => $comment_id }); + } + } + + # Now make sure that we can see all the associated bugs. + my %got_bug_ids = map { $_->bug_id => 1 } @$comment_data; + $user->visible_bugs([ keys %got_bug_ids ]); # preload cache for visibility check + Bugzilla::Bug->check($_) foreach (keys %got_bug_ids); + + my %comments; + foreach my $comment (@$comment_data) { + if ($comment->is_private && !$user->is_insider) { + ThrowUserError('comment_is_private', { id => $comment->id }); + } + $comments{$comment->id} = $comment->body; + } + + return { comments => \%comments }; +} + +sub rest_resources { + return [ + qr{^/editcomments/comment/(\d+)$}, { + GET => { + method => 'comments', + params => sub { + return { comment_ids => $_[0] }; + }, + }, + }, + qr{^/editcomments/comment$}, { + GET => { + method => 'comments', + }, + }, + ]; +}; + + +1; + +__END__ + +=head1 NAME + +Bugzilla::Extension::EditComments::Webservice - The EditComments WebServices API + +=head1 DESCRIPTION + +This module contains API methods that are useful to user's of bugzilla.mozilla.org. + +=head1 METHODS + +=head2 comments + +B<EXPERIMENTAL> + +=over + +=item B<Description> + +This allows you to get the raw comment text about comments, given a list of comment ids. + +=item B<REST> + +To get all comment text for a list of comment ids: + +GET /bug/editcomments/comment?comment_ids=1234&comment_ids=5678... + +To get comment text for a specific comment based on the comment ID: + +GET /bug/editcomments/comment/<comment_id> + +The returned data format is the same as below. + +=item B<Params> + +=over + +=item C<comment_ids> (required) + +C<array> An array of integer comment_ids. These comments will be +returned individually, separate from any other comments in their +respective bugs. + +=item B<Returns> + +1 item is returned: + +=over + +=item C<comments> + +Each individual comment requested in C<comment_ids> is returned here, +in a hash where the numeric comment id is the key, and the value +is the comment's raw text. + +=back + +=item B<Errors> + +In addition to standard Bug.get type errors, this method can throw the +following additional errors: + +=over + +=item 110 (Comment Is Private) + +You specified the id of a private comment in the C<comment_ids> +argument, and you are not in the "insider group" that can see +private comments. + +=item 111 (Invalid Comment ID) + +You specified an id in the C<comment_ids> argument that is invalid--either +you specified something that wasn't a number, or there is no comment with +that id. + +=back + +=item B<History> + +=over + +=item Added in BMO Bugzilla B<4.2>. + +=back + +=back + +=back + +See L<Bugzilla::WebService> for a description of how parameters are passed, +and what B<STABLE>, B<UNSTABLE>, and B<EXPERIMENTAL> mean. diff --git a/extensions/EditComments/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl b/extensions/EditComments/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl new file mode 100644 index 000000000..01ca7bbb7 --- /dev/null +++ b/extensions/EditComments/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl @@ -0,0 +1,13 @@ +[%# 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 panel.name == "groupsecurity" %] + [% panel.param_descs.edit_comments_group = + 'The name of the group of users who can edit comments. Leave blank to disable comment editing.' + %] +[% END -%] diff --git a/extensions/EditComments/template/en/default/hook/bug/comments-a_comment-end.html.tmpl b/extensions/EditComments/template/en/default/hook/bug/comments-a_comment-end.html.tmpl new file mode 100644 index 000000000..89249efdf --- /dev/null +++ b/extensions/EditComments/template/en/default/hook/bug/comments-a_comment-end.html.tmpl @@ -0,0 +1,49 @@ +[%# 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 Param('edit_comments_group') + && user.in_group(Param('edit_comments_group')) + && (comment.type == 0 || comment.type == 5) + && comment.body != '' +%] + <span id="edit_comment_link_[% comment.count FILTER html %]"> + [<a href="javascript:void(0);" id="edit_comment_edit_link_[% comment.count FILTER html %]" + onclick="editComment('[% comment.count FILTER js %]','[% comment.id FILTER js %]');">edit</a> + [% IF comment.edit_count %] + | <a href="page.cgi?id=editcomments.html&bug_id=[% bug.id FILTER uri %]&comment_id=[% comment.id FILTER uri %]">history</a> + ([% comment.edit_count FILTER html %]) + [% END %]] + </span> + <div id="edit_comment_[% comment.count FILTER html %]"> + <div class="bz_comment_text bz_default_hidden" id="edit_comment_loading_[% comment.count FILTER html %]">Loading...</div> + [% INCLUDE global/textarea.html.tmpl + name = "edit_comment_textarea_${comment.id}" + id = "edit_comment_textarea_${comment.count}" + minrows = 10 + maxrows = 25 + classes = "edit_comment_textarea bz_default_hidden" + cols = constants.COMMENT_COLS + disabled = 1 + %] + </div> + <script> + YAHOO.util.Event.onDOMReady(function() { + // Insert edit links near other comment actions such as reply + var comment_div = YAHOO.util.Dom.get('c[% comment.count FILTER js %]'); + var bz_comment_actions = YAHOO.util.Dom.getElementsByClassName('bz_comment_actions', 'span', comment_div)[0]; + var edit_comment_link = YAHOO.util.Dom.get('edit_comment_link_[% comment.count FILTER js %]'); + bz_comment_actions.insertBefore(edit_comment_link, bz_comment_actions.firstChild); + + // Insert blank textarea right below formatted comment + var comment_div = YAHOO.util.Dom.get('c[% comment.count FILTER js %]'); + var comment_pre = YAHOO.util.Dom.get('comment_text_[% comment.count FILTER js %]'); + var edit_comment_div = YAHOO.util.Dom.get('edit_comment_[% comment.count FILTER js %]'); + comment_div.insertBefore(edit_comment_div, comment_pre); + }); + </script> +[% END %] diff --git a/extensions/EditComments/template/en/default/hook/bug/show-header-end.html.tmpl b/extensions/EditComments/template/en/default/hook/bug/show-header-end.html.tmpl new file mode 100644 index 000000000..331d7e6df --- /dev/null +++ b/extensions/EditComments/template/en/default/hook/bug/show-header-end.html.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. + #%] + +[% IF Param('edit_comments_group') && user.in_group(Param('edit_comments_group')) %] + [% style_urls.push('extensions/EditComments/web/styles/editcomments.css') %] + [% javascript_urls.push('extensions/EditComments/web/js/editcomments.js') %] +[% END %] diff --git a/extensions/EditComments/template/en/default/hook/global/user-error-auth_failure_object.html.tmpl b/extensions/EditComments/template/en/default/hook/global/user-error-auth_failure_object.html.tmpl new file mode 100644 index 000000000..4325aab30 --- /dev/null +++ b/extensions/EditComments/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 == 'editcomments' %] + edit comments +[% END %] diff --git a/extensions/EditComments/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/EditComments/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..bc02b52f0 --- /dev/null +++ b/extensions/EditComments/template/en/default/hook/global/user-error-errors.html.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. + #%] + +[% IF error == "edit_comment_invalid_comment_id" %] + [% title = "Invalid Comment ID" %] + The comment id '[% comment_id FILTER html %]' is invalid. +[% END %] diff --git a/extensions/EditComments/template/en/default/pages/editcomments.html.tmpl b/extensions/EditComments/template/en/default/pages/editcomments.html.tmpl new file mode 100644 index 000000000..8b3b90c9e --- /dev/null +++ b/extensions/EditComments/template/en/default/pages/editcomments.html.tmpl @@ -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. + #%] + +[% PROCESS global/variables.none.tmpl %] + +[% PROCESS global/header.html.tmpl + title = "Comment changes made to $terms.bug $bug.id, comment $comment.id" + header = "Activity log for $terms.bug $bug.id, comment $comment.id" + %] + +<script type="text/javascript"> +/* The functions below expand and collapse comments */ +function toggle_comment_display(link, comment_id) { + if (YAHOO.util.Dom.hasClass('comment_text_' + comment_id, 'collapsed')) { + expand_comment(link, comment); + } + else { + collapse_comment(link, comment); + } +} + +function toggle_all_comments(action) { + var num_comments = [% comment.activity.size FILTER html %]; + + // If for some given ID the comment doesn't exist, this doesn't mean + // there are no more comments, but that the comment is private and + // the user is not allowed to view it. + + for (var id = 0; id < num_comments; id++) { + var comment = document.getElementById('comment_text_' + id); + if (!comment) { + continue; + } + + var link = document.getElementById('comment_link_' + id); + if (action == 'collapse') { + collapse_comment(link, comment); + } + else { + expand_comment(link, comment); + } + } +} + +function collapse_comment(link, comment) { + link.innerHTML = "[+]"; + link.title = "Expand the comment."; + YAHOO.util.Dom.addClass(comment, 'collapsed'); +} + +function expand_comment(link, comment) { + link.innerHTML = "[-]"; + link.title = "Collapse the comment"; + YAHOO.util.Dom.removeClass(comment, 'collapsed'); +} +</script> + +<p> + [% "Back to $terms.bug $bug.id" FILTER bug_link(bug.id) FILTER none %] +</p> + +<p> + <strong>Note</strong>: The actual edited comment in the [% terms.bug %] view page will always show the original commentor's name and original timestamp. +</p> + +<p> + <a href="#" onclick="toggle_all_comments('collapse'); return false;">Collapse All Changes</a> - + <a href="#" onclick="toggle_all_comments('expand'); return false;">Expand All Changes</a> +</p> + +[% count = 0 %] +[% FOREACH a = comment.activity %] + <div class="bz_comment"> + <div class="bz_comment_head"> + <i> + [% IF a.original %] + Original comment by [% (a.author.name || "Need Real Name") FILTER html %] + <span class="vcard"> + (<a class="fn email" href="mailto:[% a.author.email FILTER html %]"> + [%- a.author.email FILTER html -%]</a>) + </span> + on [%+ a.time FILTER time %] + [% ELSE %] + Revision by [% (a.author.name || "Need Real Name") FILTER html %] + <span class="vcard"> + (<a class="fn email" href="mailto:[% a.author.email FILTER html %]"> + [%- a.author.email FILTER html -%]</a>) + </span> + on [%+ a.time FILTER time %] + [% END %] + </i> + <a href="#" id="comment_link_[% count FILTER html %]" + onclick="toggle_comment_display(this, '[% count FILTER html FILTER js %]'); return false;" + title="Collapse the comment.">[-]</a> + </div> + [% IF a.original %] + [% wrapped_comment = a.body FILTER wrap_comment %] + [% ELSE %] + [% wrapped_comment = a.new FILTER wrap_comment %] + [% END %] +[%# Don't indent the <pre> block, since then the spaces are displayed in the + # generated HTML %] +<pre class="bz_comment_text" id="comment_text_[% count FILTER html %]"> + [%- wrapped_comment FILTER quoteUrls(bug) -%] +</pre> + </div> + [% count = count + 1 %] +[% END %] + +[% IF comment.activity.size > 0 %] + <p> + [% "Back to $terms.bug $bug.id" FILTER bug_link(bug.id) FILTER none %] + </p> +[% END %] + +[% PROCESS global/footer.html.tmpl %] + diff --git a/extensions/EditComments/web/js/editcomments.js b/extensions/EditComments/web/js/editcomments.js new file mode 100644 index 000000000..91763fa62 --- /dev/null +++ b/extensions/EditComments/web/js/editcomments.js @@ -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. + */ + +function editComment(comment_count, comment_id) { + if (!comment_count || !comment_id) return; + + var edit_comment_textarea = YAHOO.util.Dom.get('edit_comment_textarea_' + comment_count); + if (!YAHOO.util.Dom.hasClass(edit_comment_textarea, 'bz_default_hidden')) { + hideEditCommentField(comment_count); + return; + } + + // Show the loading indicator + toggleCommentLoading(comment_count); + + YAHOO.util.Connect.setDefaultPostHeader('application/json', true); + YAHOO.util.Connect.asyncRequest( + 'POST', + 'jsonrpc.cgi', + { + success: function(res) { + // Hide the loading indicator + toggleCommentLoading(comment_count); + data = YAHOO.lang.JSON.parse(res.responseText); + if (data.error) { + alert("Get [% comment failed: " + data.error.message); + } + else if (data.result.comments[comment_id]) { + var comment_text = data.result.comments[comment_id]; + showEditCommentField(comment_count, comment_text); + } + }, + failure: function(res) { + // Hide the loading indicator + toggleCommentLoading(comment_count); + if (res.responseText) { + alert("Get comment failed: " + res.responseText); + } + } + }, + YAHOO.lang.JSON.stringify({ + version: "1.1", + method: "EditComments.comments", + id: comment_id, + params: { comment_ids: [ comment_id ] } + }) + ); +} + +function hideEditCommentField(comment_count) { + var comment_text_pre = YAHOO.util.Dom.get('comment_text_' + comment_count); + YAHOO.util.Dom.removeClass(comment_text_pre, 'bz_default_hidden'); + + var edit_comment_textarea = YAHOO.util.Dom.get('edit_comment_textarea_' + comment_count); + YAHOO.util.Dom.addClass(edit_comment_textarea, 'bz_default_hidden'); + edit_comment_textarea.disabled = true; + + YAHOO.util.Dom.get("edit_comment_edit_link_" + comment_count).innerHTML = "edit"; +} + +function showEditCommentField(comment_count, comment_text) { + var comment_text_pre = YAHOO.util.Dom.get('comment_text_' + comment_count); + YAHOO.util.Dom.addClass(comment_text_pre, 'bz_default_hidden'); + + var edit_comment_textarea = YAHOO.util.Dom.get('edit_comment_textarea_' + comment_count); + YAHOO.util.Dom.removeClass(edit_comment_textarea, 'bz_default_hidden'); + edit_comment_textarea.disabled = false; + edit_comment_textarea.value = comment_text; + + YAHOO.util.Dom.get("edit_comment_edit_link_" + comment_count).innerHTML = "unedit"; +} + +function toggleCommentLoading(comment_count, hide) { + var comment_div = 'comment_text_' + comment_count; + var loading_div = 'edit_comment_loading_' + comment_count; + if (YAHOO.util.Dom.hasClass(loading_div, 'bz_default_hidden')) { + YAHOO.util.Dom.addClass(comment_div, 'bz_default_hidden'); + YAHOO.util.Dom.removeClass(loading_div, 'bz_default_hidden'); + } + else { + YAHOO.util.Dom.removeClass(comment_div, 'bz_default_hidden'); + YAHOO.util.Dom.addClass(loading_div, 'bz_default_hidden'); + } +} + diff --git a/extensions/EditComments/web/styles/editcomments.css b/extensions/EditComments/web/styles/editcomments.css new file mode 100644 index 000000000..911896ac8 --- /dev/null +++ b/extensions/EditComments/web/styles/editcomments.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. */ + +.edit_comment_textarea { + width: 845px; +} diff --git a/extensions/EditTable/Config.pm b/extensions/EditTable/Config.pm new file mode 100644 index 000000000..d601951a4 --- /dev/null +++ b/extensions/EditTable/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::EditTable; +use strict; + +use constant NAME => 'EditTable'; +use constant REQUIRED_MODULES => []; +use constant OPTIONAL_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/EditTable/Extension.pm b/extensions/EditTable/Extension.pm new file mode 100644 index 000000000..a10a30e57 --- /dev/null +++ b/extensions/EditTable/Extension.pm @@ -0,0 +1,180 @@ +# 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. + + +# this is a quick and dirty table editor, designed to allow admins to quickly +# maintain tables. +# +# each table must be defined via the editable_tables hook +# +# this extension doesn't currently provide any ability to modify or validate +# values. use with caution! + +package Bugzilla::Extension::EditTable; + +use strict; +use warnings; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Error; +use Bugzilla::Hook; +use Bugzilla::Util qw(trick_taint); +use JSON; +use Storable qw(dclone); + +our $VERSION = '1'; + +# definitions for tables which we can edit with the quick-and-dirty editor +# +# $table_name => { +# id_field => name of the "id" field +# order_by => the field to sort rows by (optional, defaults to the id_field) +# blurb => text which describes the table +# group => group required to edit this table (optional, defaults to "admin") +# } +# +# example: +# 'antispam_domain_blocklist' => { +# id_field => 'id', +# order_by => 'domain', +# blurb => 'List of fully qualified domain names to block at account creation time.', +# group => 'can_configure_antispam', +# }, + +sub EDITABLE_TABLES { + my $tables = {}; + Bugzilla::Hook::process("editable_tables", { tables => $tables }); + return $tables; +} + +sub page_before_template { + my ($self, $args) = @_; + my ($vars, $page) = @$args{qw(vars page_id)}; + return unless $page eq 'edit_table.html'; + my $input = Bugzilla->input_params; + + # we only support editing a particular set of tables + my $table_name = $input->{table}; + exists $self->EDITABLE_TABLES()->{$table_name} + || ThrowUserError('edittable_unsupported', { table => $table_name } ); + my $table = $self->EDITABLE_TABLES()->{$table_name}; + my $id_field = $table->{id_field}; + my $order_by = $table->{order_by} || $id_field; + my $group = $table->{group} || 'admin'; + trick_taint($table_name); + + Bugzilla->user->in_group($group) + || ThrowUserError('auth_failure', { group => $group, + action => 'edit', + object => 'tables' }); + + # load columns + my $dbh = Bugzilla->dbh; + my @fields = sort + grep { $_ ne $id_field && $_ ne $order_by; } + $dbh->bz_table_columns($table_name); + if ($order_by ne $id_field) { + unshift @fields, $order_by; + } + + # update table + my $data = $input->{table_data}; + my $edits = []; + if ($data) { + $data = from_json($data)->{data}; + $edits = dclone($data); + eval { + $dbh->bz_start_transaction; + + foreach my $row (@$data) { + map { trick_taint($_) } @$row; + if ($row->[0] eq '-') { + # add + shift @$row; + next unless grep { $_ ne '' } @$row; + my $placeholders = join(',', split(//, '?' x scalar(@fields))); + $dbh->do( + "INSERT INTO $table_name(" . join(',', @fields) . ") " . + "VALUES ($placeholders)", + undef, + @$row + ); + } + elsif ($row->[0] < 0) { + # delete + $dbh->do( + "DELETE FROM $table_name WHERE $id_field=?", + undef, + -$row->[0] + ); + } + else { + # update + my $id = shift @$row; + $dbh->do( + "UPDATE $table_name " . + "SET " . join(',', map { "$_ = ?" } @fields) . " " . + "WHERE $id_field = ?", + undef, + @$row, $id + ); + } + } + + $dbh->bz_commit_transaction; + $vars->{updated} = 1; + $edits = []; + }; + if ($@) { + my $error = $@; + $error =~ s/^DBD::[^:]+::db do failed: //; + $error =~ s/^(.+) \[for Statement ".+$/$1/s; + $vars->{error} = $error; + $dbh->bz_rollback_transaction; + } + } + + # load data from table + unshift @fields, $id_field; + $data = $dbh->selectall_arrayref( + "SELECT " . join(',', @fields) . " FROM $table_name ORDER BY $order_by" + ); + + # we don't support nulls currently + foreach my $row (@$data) { + if (grep { !defined($_) } @$row) { + ThrowUserError('edittable_nulls', { table => $table_name } ); + } + } + + # apply failed edits + foreach my $edit (@$edits) { + if ($edit->[0] eq '-') { + push @$data, $edit; + } + else { + my $id = $edit->[0]; + foreach my $row (@$data) { + if ($row->[0] == $id) { + @$row = @$edit; + last; + } + } + } + } + + $vars->{table_name} = $table_name; + $vars->{blurb} = $table->{blurb}; + $vars->{table_data} = to_json({ + fields => \@fields, + id_field => $id_field, + data => $data, + }); +} + +__PACKAGE__->NAME; diff --git a/extensions/EditTable/template/en/default/hook/global/user-error-auth_failure_object.html.tmpl b/extensions/EditTable/template/en/default/hook/global/user-error-auth_failure_object.html.tmpl new file mode 100644 index 000000000..f86fb4c86 --- /dev/null +++ b/extensions/EditTable/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 == 'tables' %] + tables +[% END %] diff --git a/extensions/EditTable/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/EditTable/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..d87270b98 --- /dev/null +++ b/extensions/EditTable/template/en/default/hook/global/user-error-errors.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. + #%] + +[% IF error == "edittable_unsupported" %] + [% title = "Unsupported Table" %] + You cannot edit the table '[% table FILTER html %]'. + +[% ELSIF error == "edittable_nulls" %] + [% title = "Table Contains NULLs" %] + EditTable cannot edit the table '[% table FILTER html %]' as it contains NULL + values. +[% END %] diff --git a/extensions/EditTable/template/en/default/pages/edit_table.html.tmpl b/extensions/EditTable/template/en/default/pages/edit_table.html.tmpl new file mode 100644 index 000000000..d81291640 --- /dev/null +++ b/extensions/EditTable/template/en/default/pages/edit_table.html.tmpl @@ -0,0 +1,43 @@ +[%# 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 = "Edit Table" + javascript_urls = [ 'extensions/EditTable/web/js/edit_table.js' ] + style_urls = [ "extensions/EditTable/web/styles/edit_table.css" ] +%] + +<h2>[% table_name FILTER html %]</h2> + +[% IF updated %] + <p id="message">Table [% table_name FILTER html %] updated.</p> +[% ELSIF error %] + <p class="throw_error">[% error FILTER html FILTER html_line_break %]</p> +[% END %] + +<p>[% blurb FILTER html FILTER html_line_break %]</p> + +<div id="edit_table"></div> +<br> +<form method="post" action="page.cgi" enctype="multipart/form-data" + onsubmit="editTable.to_json('table_data')"> +<input type="hidden" name="id" value="edit_table.html"> +<input type="hidden" name="table" value="[% table_name FILTER html %]"> +<input type="hidden" name="table_data" id="table_data"> +<input type="submit" value="Commit Changes" id="commit_btn" class="bz_default_hidden"> +</form> + +<script> + var table_data = [% table_data FILTER none %]; + var editTable = new EditTable('edit_table', table_data); + editTable.render(); +</script> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/EditTable/web/js/edit_table.js b/extensions/EditTable/web/js/edit_table.js new file mode 100644 index 000000000..ae239759b --- /dev/null +++ b/extensions/EditTable/web/js/edit_table.js @@ -0,0 +1,131 @@ +/* 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 EditTable(parent_el, table_data) { + this.parent_el = YAHOO.util.Dom.get(parent_el); + this.table_data = table_data; + this.field_count = table_data.fields.length; + if (!JSON) JSON = YAHOO.lang.JSON; + + this.render = function() { + // create table + this.parent_el.innerHTML = ''; + var table = document.createElement('table'); + + // header + var tr = document.createElement('tr'); + for (var i = 0; i < this.field_count; i++) { + var th = document.createElement('th'); + th.appendChild(document.createTextNode(this.table_data.fields[i])); + tr.appendChild(th); + } + var td = document.createElement('td'); + td.innerHTML = ' '; + tr.appendChild(td); + table.appendChild(tr); + + // rows + for (var i = 0; i < table_data.data.length; i++) { + // skip deleted rows + if (this.table_data.data[i][0] < 0) + continue; + var tr = document.createElement('tr'); + for (var j = 0; j < this.field_count; j++) { + var td = document.createElement('td'); + td.appendChild(document.createTextNode(this.table_data.data[i][j])); + tr.appendChild(td); + + if (this.table_data.fields[j] != this.table_data.id_field) { + td.className = 'editable'; + td.contentEditable = true; + YAHOO.util.Event.addListener(td, 'keydown', this._edit_keydown, this); + YAHOO.util.Event.addListener(td, 'blur', this._save, this); + d = td; + } + } + var td = document.createElement('td'); + var a = document.createElement('a'); + a.href = '#'; + a.innerHTML = 'x'; + YAHOO.util.Event.addListener(a, 'click', this._remove_row, this); + td.appendChild(a); + td.className = 'action'; + tr.appendChild(td); + table.appendChild(tr); + } + + this.parent_el.appendChild(table); + + var add_btn = document.createElement('button'); + add_btn.innerHTML = 'Add'; + YAHOO.util.Event.addListener(add_btn, 'click', this._add_row, this); + this.parent_el.appendChild(add_btn); + }, + + this.to_json = function(target) { + YAHOO.util.Dom.get(target).value = JSON.stringify(this.table_data); + }, + + this._add_row = function(event, obj) { + var row = []; + for (var i = 0; i < obj.field_count; i++) { + row.push(obj.table_data.fields[i] == obj.table_data.id_field ? '-' : ''); + } + obj.table_data.data.push(row); + obj.render(); + YAHOO.util.Dom.removeClass('commit_btn', 'bz_default_hidden'); + event.preventDefault(); + }, + + this._remove_row = function(event, obj) { + var row = event.target.parentElement.parentElement.rowIndex - 1; + if (obj.table_data.data[row][0] == '-') { + // removing a newly added row + obj.table_data.data.splice(row, 1); + } + else { + // to remove a db row we set its id to negative + // it'll be skipped by render, and the update script knows which id to delete + obj.table_data.data[row][0] = -obj.table_data.data[row][0]; + } + obj.render(); + YAHOO.util.Dom.removeClass('commit_btn', 'bz_default_hidden'); + event.preventDefault(); + }, + + this._save = function(event, obj) { + var row = event.target.parentElement.rowIndex - 1; + var col = event.target.cellIndex; + var value = event.target.textContent; + if (obj.table_data.data[row][col] != event.target.textContent) { + obj.table_data.data[row][col] = event.target.textContent; + YAHOO.util.Dom.removeClass('commit_btn', 'bz_default_hidden'); + } + }, + + this._revert = function(event, obj) { + var row = event.target.parentElement.rowIndex - 1; + var col = event.target.cellIndex; + event.target.replaceChild( + document.createTextNode(obj.table_data.data[row][col]), + event.target.firstChild + ); + }, + + this._edit_keydown = function(event, obj) { + if (event.keyCode == 13) { + event.preventDefault(); + obj._save(event, obj); + document.activeElement.blur(event.target); + } + else if (event.keyCode == 27) { + event.preventDefault(); + obj._revert(event, obj); + } + } +}; diff --git a/extensions/EditTable/web/styles/edit_table.css b/extensions/EditTable/web/styles/edit_table.css new file mode 100644 index 000000000..0b1c72db6 --- /dev/null +++ b/extensions/EditTable/web/styles/edit_table.css @@ -0,0 +1,39 @@ +/* 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. */ + + +#edit_table table { + border-spacing: 0; + border-collapse: collapse; + margin-bottom: 1em; +} + +#edit_table td, #edit_table th { + padding: 5px; +} + +#edit_table th { + background: #ccc; + text-align: left; +} + +#edit_table .editable { + background: #fff; +} + +#edit_table tr:hover { + background: #eee; +} + +#edit_table .action { + display: none; +} + +#edit_table tr:hover .action { + display: block; +} + diff --git a/extensions/Ember/Config.pm b/extensions/Ember/Config.pm new file mode 100644 index 000000000..e3405146d --- /dev/null +++ b/extensions/Ember/Config.pm @@ -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. + +package Bugzilla::Extension::Ember; + +use 5.10.1; +use strict; + +use constant NAME => 'Ember'; + +use constant REQUIRED_MODULES => []; + +use constant OPTIONAL_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/Ember/Extension.pm b/extensions/Ember/Extension.pm new file mode 100644 index 000000000..1c8b8b4e9 --- /dev/null +++ b/extensions/Ember/Extension.pm @@ -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. + +package Bugzilla::Extension::Ember; + +use 5.10.1; +use strict; +use parent qw(Bugzilla::Extension); + +our $VERSION = '0.01'; + +sub webservice { + my ($self, $args) = @_; + my $dispatch = $args->{dispatch}; + $dispatch->{Ember} = "Bugzilla::Extension::Ember::WebService"; +} + +__PACKAGE__->NAME; diff --git a/extensions/Voting/disabled b/extensions/Ember/disabled index e69de29bb..e69de29bb 100644 --- a/extensions/Voting/disabled +++ b/extensions/Ember/disabled diff --git a/extensions/Ember/lib/FakeBug.pm b/extensions/Ember/lib/FakeBug.pm new file mode 100644 index 000000000..46fef4ea7 --- /dev/null +++ b/extensions/Ember/lib/FakeBug.pm @@ -0,0 +1,78 @@ +# 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::Ember::FakeBug; + +use 5.10.1; +use strict; +use warnings; + +use Bugzilla::Bug; + +our $AUTOLOAD; + +sub new { + my $class = shift; + my $self = shift; + bless $self, $class; + return $self; +} + +sub AUTOLOAD { + my $self = shift; + my $name = $AUTOLOAD; + $name =~ s/.*://; + return exists $self->{$name} ? $self->{$name} : undef; +} + +sub check_can_change_field { + return Bugzilla::Bug::check_can_change_field(@_); +} + +sub id { return undef; } +sub product_obj { return $_[0]->{product_obj}; } +sub reporter { return Bugzilla->user; } + +sub choices { + my $self = shift; + return $self->{'choices'} if exists $self->{'choices'}; + return {} if $self->{'error'}; + my $user = Bugzilla->user; + + my @products = @{ $user->get_enterable_products }; + # The current product is part of the popup, even if new bugs are no longer + # allowed for that product + if (!grep($_->name eq $self->product_obj->name, @products)) { + unshift(@products, $self->product_obj); + } + + my @statuses = @{ Bugzilla::Status->can_change_to }; + + # UNCONFIRMED is only a valid status if it is enabled in this product. + if (!$self->product_obj->allows_unconfirmed) { + @statuses = grep { $_->name ne 'UNCONFIRMED' } @statuses; + } + + my %choices = ( + bug_status => \@statuses, + product => \@products, + component => $self->product_obj->components, + version => $self->product_obj->versions, + target_milestone => $self->product_obj->milestones, + ); + + my $resolution_field = new Bugzilla::Field({ name => 'resolution' }); + # Don't include the empty resolution in drop-downs. + my @resolutions = grep($_->name, @{ $resolution_field->legal_values }); + $choices{'resolution'} = \@resolutions; + + $self->{'choices'} = \%choices; + return $self->{'choices'}; +} + +1; + diff --git a/extensions/Ember/lib/WebService.pm b/extensions/Ember/lib/WebService.pm new file mode 100644 index 000000000..7a037e654 --- /dev/null +++ b/extensions/Ember/lib/WebService.pm @@ -0,0 +1,995 @@ +# 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::Ember::WebService; + +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::WebService + Bugzilla::WebService::Bug + Bugzilla::WebService::Product); + +use Bugzilla::Bug; +use Bugzilla::Component; +use Bugzilla::Product; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Field; +use Bugzilla::Util qw(trick_taint); + +use Bugzilla::Extension::Ember::FakeBug; + +use Scalar::Util qw(blessed); +use Storable qw(dclone); + +use constant DATE_FIELDS => { + show => ['last_updated'], +}; + +use constant FIELD_TYPE_MAP => { + 0 => 'unknown', + 1 => 'freetext', + 2 => 'single_select', + 3 => 'multiple_select', + 4 => 'textarea', + 5 => 'datetime', + 6 => 'date', + 7 => 'bug_id', + 8 => 'bug_urls', + 9 => 'keywords', + 99 => 'extension' +}; + +use constant NON_EDIT_FIELDS => qw( + assignee_accessible + bug_group + bug_id + commenter + cclist_accessible + content + creation_ts + days_elapsed + everconfirmed + qacontact_accessible + reporter + reporter_accessible + restrict_comments + tag + votes +); + +use constant BUG_CHOICE_FIELDS => qw( + bug_status + component + product + resolution + target_milestone + version +); + +use constant DEFAULT_VALUE_MAP => { + op_sys => 'defaultopsys', + rep_platform => 'defaultplatform', + priority => 'defaultpriority', + bug_severity => 'defaultseverity' +}; + +sub API_NAMES { + # Internal field names converted to the API equivalents + my %api_names = reverse %{ Bugzilla::Bug::FIELD_MAP() }; + return \%api_names; +} + +############### +# API Methods # +############### + +sub create { + my ($self, $params) = @_; + + Bugzilla->login(LOGIN_REQUIRED); + Bugzilla->switch_to_shadow_db(); + + my $product = delete $params->{product}; + $product || ThrowCodeError('params_required', + { function => 'Ember.create', params => ['product'] }); + + my $product_obj = Bugzilla::Product->check($product); + + my $fake_bug = Bugzilla::Extension::Ember::FakeBug->new( + { product_obj => $product_obj, reporter_id => Bugzilla->user->id }); + + my @fields = $self->_get_fields($fake_bug); + + return { + fields => \@fields + }; +} + +sub show { + my ($self, $params) = @_; + my (@fields, $attachments, $comments, $data); + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + + Bugzilla->switch_to_shadow_db(); + + # Throw error if token was provided and user is not logged + # in meaning token was invalid/expired. + if (exists $params->{token} && !$user->id) { + ThrowUserError('invalid_token'); + } + + my $bug_id = delete $params->{id}; + $bug_id || ThrowCodeError('params_required', + { function => 'Ember.show', params => ['id'] }); + + my $bug = Bugzilla::Bug->check($bug_id); + + my $bug_hash = $self->_bug_to_hash($bug, $params); + + # Only return changes since last_updated if provided + my $last_updated = delete $params->{last_updated}; + if ($last_updated) { + trick_taint($last_updated); + + my $updated_fields = + $dbh->selectcol_arrayref('SELECT fieldid FROM bugs_activity + WHERE bug_when > ? AND bug_id = ?', + undef, ($last_updated, $bug->id)); + + if (@$updated_fields) { + # Also add in the delta_ts value which is in the bugs_activity + # entries + push(@$updated_fields, get_field_id('delta_ts')); + @fields = $self->_get_fields($bug, $updated_fields); + } + } + # Return all the things + else { + @fields = $self->_get_fields($bug); + } + + # Place the fields current value along with the field definition + foreach my $field (@fields) { + if (($field->{name} eq 'depends_on' + || $field->{name} eq 'blocks') + && scalar @{ $bug_hash->{$field->{name}} }) + { + my $bug_ids = delete $bug_hash->{$field->{name}}; + $user->visible_bugs($bug_ids); + my $bug_objs = Bugzilla::Bug->new_from_list($bug_ids); + + my @new_list; + foreach my $bug (@$bug_objs) { + my $data; + if ($user->can_see_bug($bug)) { + $data = { + id => $bug->id, + status => $bug->bug_status, + summary => $bug->short_desc + }; + } + else { + $data = { id => $bug->id }; + } + push(@new_list, $data); + } + $field->{current_value} = \@new_list; + } + else { + $field->{current_value} = delete $bug_hash->{$field->{name}} || ''; + } + } + + # Any left over bug values will be added to the field list + # These are extra fields that do not have a corresponding + # Field.pm object + if (!$last_updated) { + foreach my $key (keys %$bug_hash) { + my $field = { + name => $key, + current_value => $bug_hash->{$key} + }; + my $name = Bugzilla::Bug::FIELD_MAP()->{$key} || $key; + $field->{can_edit} = $self->_can_change_field($name, $bug); + push(@fields, $field); + } + } + + # Complete the return data + my $data = { id => $bug->id, fields => \@fields }; + + return $data; +} + +sub search { + my ($self, $params) = @_; + + my $total; + if (exists $params->{offset} && exists $params->{limit}) { + my $count_params = dclone($params); + delete $count_params->{offset}; + delete $count_params->{limit}; + $count_params->{count_only} = 1; + $total = $self->SUPER::search($count_params); + } + + my $result = $self->SUPER::search($params); + $result->{total} = defined $total ? $total : scalar(@{ $result->{bugs} }); + return $result; +} + +sub bug { + my ($self, $params) = @_; + my $dbh = Bugzilla->dbh; + + my $bug_id = delete $params->{id}; + $bug_id || ThrowCodeError('param_required', + { function => 'Ember.bug', param => 'id' }); + + my ($comments, $attachments) = ([], []); + my $bug = $self->get({ ids => [ $bug_id ] }); + $bug = $bug->{bugs}->[0]; + + # Only return changes since last_updated if provided + my $last_updated = delete $params->{last_updated}; + if ($last_updated) { + trick_taint($last_updated); + my $updated_fields = $dbh->selectcol_arrayref('SELECT fielddefs.name + FROM fielddefs INNER JOIN bugs_activity + ON fielddefs.id = bugs_activity.fieldid + WHERE bugs_activity.bug_when > ? + AND bugs_activity.bug_id = ?', + undef, ($last_updated, $bug->{id})); + + my %field_map = reverse %{ Bugzilla::Bug::FIELD_MAP() }; + $field_map{'flagtypes.name'} = 'flags'; + + my $changed_bug = {}; + foreach my $field (@$updated_fields) { + my $field_name = $field_map{$field} || $field; + if ($bug->{$field_name}) { + $changed_bug->{$field_name} = $bug->{$field_name}; + } + } + $bug = $changed_bug; + + # Find any comments created since the last_updated date + $comments = $self->comments({ ids => $bug_id, new_since => $last_updated }); + + # Find any new attachments or modified attachments since the + # last_updated date + my $updated_attachments = + $dbh->selectcol_arrayref('SELECT attach_id FROM attachments + WHERE (creation_ts > ? OR modification_time > ?) + AND bug_id = ?', + undef, ($last_updated, $last_updated, $bug->{id})); + if ($updated_attachments) { + $attachments = $self->_get_attachments({ attachment_ids => $updated_attachments, + exclude_fields => ['data'] }); + } + } + else { + $comments = $self->comments({ ids => [ $bug_id ] }); + $attachments = $self->_get_attachments({ ids => [ $bug_id ], + exclude_fields => ['data'] }); + + } + + $comments = $comments->{bugs}->{$bug_id}->{comments}; + + return { + bug => $bug, + comments => $comments, + attachments => $attachments, + }; +} + +sub get_attachments { + my ($self, $params) = @_; + my $attachments = $self->_get_attachments($params); + my $flag_types = []; + my $bug; + if ($params->{ids}) { + $bug = Bugzilla::Bug->check($params->{ids}->[0]); + $flag_types = $self->_get_flag_types_bug($bug, 'attachment'); + } + elsif ($params->{attachment_ids} && @$attachments) { + $bug = Bugzilla::Bug->check($attachments->[0]->{bug_id}); + $flag_types = $self->_get_flag_types_all($bug, 'attachment')->{attachment}; + } + if (@$flag_types) { + @$flag_types = map { $self->_flagtype_to_hash($_, $bug) } @$flag_types; + } + return { + attachments => $attachments, + flag_types => $flag_types + }; +} + +################### +# Private Methods # +################### + +sub _get_attachments { + my ($self, $params) = @_; + my $user = Bugzilla->user; + + my $attachments = $self->attachments($params); + + if ($params->{ids}) { + $attachments = [ map { @{ $attachments->{bugs}->{$_} } } + keys %{ $attachments->{bugs} } ]; + } + elsif ($params->{attachment_ids}) { + $attachments = [ map { $attachments->{attachments}->{$_} } + keys %{ $attachments->{attachments} } ]; + } + + foreach my $attachment (@$attachments) { + $attachment->{can_edit} + = ($user->login eq $attachment->{creator} || $user->in_group('editbugs')) ? 1 : 0; + } + + return $attachments; +} + +sub _get_fields { + my ($self, $bug, $field_ids) = @_; + my $user = Bugzilla->user; + + # Load the field objects we need + my @field_objs; + if ($field_ids) { + # Load just the fields that match the ids provided + @field_objs = @{ Bugzilla::Field->match({ id => $field_ids }) }; + + } + else { + # load up standard fields + @field_objs = @{ Bugzilla->fields({ custom => 0 }) }; + + # Load custom fields + my $cf_params = { product => $bug->product_obj }; + $cf_params->{component} = $bug->component_obj if $bug->can('component_obj'); + $cf_params->{bug_id} = $bug->id if $bug->id; + push(@field_objs, Bugzilla->active_custom_fields($cf_params)); + } + + my $return_groups = my $return_flags = $field_ids ? 0 : 1; + my @fields; + foreach my $field (@field_objs) { + $return_groups = 1 if $field->name eq 'bug_group'; + $return_flags = 1 if $field->name eq 'flagtypes.name'; + + # Skip any special fields containing . in the name such as + # for attachments.*, etc. + next if $field->name =~ /\./; + + # Remove time tracking fields if the user is privileged + next if (grep($field->name eq $_, TIMETRACKING_FIELDS) + && !Bugzilla->user->is_timetracker); + + # These fields should never be set by the user + next if grep($field->name eq $_, NON_EDIT_FIELDS); + + # We already selected a product so no need to display all choices + # Might as well skip classification for new bugs as well. + next if (!$bug->id && ($field->name eq 'product' || $field->name eq 'classification')); + + # Skip assigned_to and qa_contact for new bugs if user not in + # editbugs group + next if (!$bug->id + && ($field->name eq 'assigned_to' || $field->name eq 'qa_contact') + && !$user->in_group('editbugs', $bug->product_obj->id)); + + # Do not display obsolete fields or fields that should be displayed for create bug form + next if (!$bug->id && $field->custom + && ($field->obsolete || !$field->enter_bug)); + + push(@fields, $self->_field_to_hash($field, $bug)); + } + + # Add group information as separate field + if ($return_groups) { + push(@fields, { + description => $self->type('string', 'Groups'), + is_custom => $self->type('boolean', 0), + is_mandatory => $self->type('boolean', 0), + name => $self->type('string', 'groups'), + values => [ map { $self->_group_to_hash($_, $bug) } + @{ $bug->product_obj->groups_available } ] + }); + } + + # Add flag information as separate field + if ($return_flags) { + my $flag_hash; + if ($bug->id) { + foreach my $flag_type ('bug', 'attachment') { + $flag_hash->{$flag_type} = $self->_get_flag_types_bug($bug, $flag_type); + } + } + else { + $flag_hash = $self->_get_flag_types_all($bug); + } + my @flag_values; + foreach my $flag_type ('bug', 'attachment') { + foreach my $flag (@{ $flag_hash->{$flag_type} }) { + push(@flag_values, $self->_flagtype_to_hash($flag, $bug)); + } + } + + push(@fields, { + description => $self->type('string', 'Flags'), + is_custom => $self->type('boolean', 0), + is_mandatory => $self->type('boolean', 0), + name => $self->type('string', 'flags'), + values => \@flag_values + }); + } + + return @fields; +} + +sub _get_flag_types_all { + my ($self, $bug, $type) = @_; + my $params = { is_active => 1 }; + $params->{target_type} = $type if $type; + return $bug->product_obj->flag_types($params); +} + +sub _get_flag_types_bug { + my ($self, $bug, $type) = @_; + my $params = { + target_type => $type, + product_id => $bug->product_obj->id, + component_id => $bug->component_obj->id, + bug_id => $bug->id, + active_or_has_flags => $bug->id, + }; + return Bugzilla::Flag->_flag_types($params); +} + +sub _group_to_hash { + my ($self, $group, $bug) = @_; + + my $data = { + description => $self->type('string', $group->description), + name => $self->type('string', $group->name) + }; + + if ($group->name eq $bug->product_obj->default_security_group) { + $data->{security_default} = $self->type('boolean', 1); + } + + return $data; +} + +sub _field_to_hash { + my ($self, $field, $bug) = @_; + + my $data = { + is_custom => $self->type('boolean', $field->custom), + description => $self->type('string', $field->description), + is_mandatory => $self->type('boolean', $field->is_mandatory), + }; + + if ($field->custom) { + $data->{type} = $self->type('string', FIELD_TYPE_MAP->{$field->type}); + } + + # Use the API name if one is present instead of the internal field name + my $field_name = $field->name; + $field_name = API_NAMES->{$field_name} || $field_name; + + if ($field_name eq 'longdesc') { + $field_name = $bug->id ? 'comment' : 'description'; + } + + $data->{name} = $self->type('string', $field_name); + + # Set can_edit true or false if we are editing a current bug + if ($bug->id) { + # 'delta_ts's can_edit is incorrectly set in fielddefs + $data->{can_edit} = $field->name eq 'delta_ts' + ? $self->type('boolean', 0) + : $self->_can_change_field($field, $bug); + } + + # description for creating a new bug, otherwise comment + + # FIXME 'version' and 'target_milestone' types are incorrectly set in fielddefs + if ($field->is_select || $field->name eq 'version' || $field->name eq 'target_milestone') { + $data->{values} = [ $self->_get_field_values($field, $bug) ]; + } + + # Add default values for specific fields if new bug + if (!$bug->id && DEFAULT_VALUE_MAP->{$field->name}) { + my $default_value = Bugzilla->params->{DEFAULT_VALUE_MAP->{$field->name}}; + $data->{default_value} = $default_value; + } + + return $data; +} + +sub _value_to_hash { + my ($self, $value, $bug) = @_; + + my $data = { name=> $self->type('string', $value->name) }; + + if ($bug->{bug_id}) { + $data->{is_active} = $self->type('boolean', $value->is_active); + } + + if ($value->can('sortkey')) { + $data->{sort_key} = $self->type('int', $value->sortkey || 0); + } + + if ($value->isa('Bugzilla::Component')) { + $data->{default_assignee} = $self->_user_to_hash($value->default_assignee); + $data->{initial_cc} = [ map { $self->_user_to_hash($_) } @{ $value->initial_cc } ]; + if (Bugzilla->params->{useqacontact} && $value->default_qa_contact) { + $data->{default_qa_contact} = $self->_user_to_hash($value->default_qa_contact); + } + } + + if ($value->can('description')) { + $data->{description} = $self->type('string', $value->description); + } + + return $data; +} + +sub _user_to_hash { + my ($self, $user) = @_; + + my $data = { + real_name => $self->type('string', $user->name) + }; + + if (Bugzilla->user->id) { + $data->{email} = $self->type('string', $user->email); + } + + return $data; +} + +sub _get_field_values { + my ($self, $field, $bug) = @_; + + # Certain fields are special and should use $bug->choices + # to determine editability and not $bug->check_can_change_field + my @values; + if (grep($field->name eq $_, BUG_CHOICE_FIELDS)) { + @values = @{ $bug->choices->{$field->name} }; + } + else { + # We need to get the values from the product for + # component, version, and milestones. + if ($field->name eq 'component') { + @values = @{ $bug->product_obj->components }; + } + elsif ($field->name eq 'target_milestone') { + @values = @{ $bug->product_obj->milestones }; + } + elsif ($field->name eq 'version') { + @values = @{ $bug->product_obj->versions }; + } + else { + @values = @{ $field->legal_values }; + } + } + + my @filtered_values; + foreach my $value (@values) { + next if !$bug->id && !$value->is_active; + next if $bug->id && !$self->_can_change_field($field, $bug, $value->name); + push(@filtered_values, $value); + } + + return map { $self->_value_to_hash($_, $bug) } @filtered_values; +} + +sub _can_change_field { + my ($self, $field, $bug, $value) = @_; + my $user = Bugzilla->user; + my $field_name = blessed $field ? $field->name : $field; + + # Cannot set resolution on bug creation + return $self->type('boolean', 0) if ($field_name eq 'resolution' && !$bug->{bug_id}); + + # Cannot edit an obsolete or inactive custom field + return $self->type('boolean', 0) if (blessed $field && $field->custom && $field->obsolete); + + # If not a multi-select or single-select, value is not provided + # and we just check if the field itself is editable by the user. + if (!defined $value) { + return $self->type('boolean', $bug->check_can_change_field($field_name, 0, 1)); + } + + return $self->type('boolean', $bug->check_can_change_field($field_name, '', $value)); +} + +sub _flag_to_hash { + my ($self, $flag) = @_; + + my $data = { + id => $self->type('int', $flag->id), + name => $self->type('string', $flag->name), + type_id => $self->type('int', $flag->type_id), + creation_date => $self->type('dateTime', $flag->creation_date), + modification_date => $self->type('dateTime', $flag->modification_date), + status => $self->type('string', $flag->status) + }; + + foreach my $field (qw(setter requestee)) { + my $field_id = $field . "_id"; + $data->{$field} = $self->_user_to_hash($flag->$field) if $flag->$field_id; + } + + $data->{type} = $flag->attach_id ? 'attachment' : 'bug'; + $data->{attach_id} = $flag->attach_id if $flag->attach_id; + + return $data; +} + +sub _flagtype_to_hash { + my ($self, $flagtype, $bug) = @_; + my $user = Bugzilla->user; + + my $cansetflag = $user->can_set_flag($flagtype); + my $canrequestflag = $user->can_request_flag($flagtype); + + my $data = { + 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_requestable => $self->type('boolean', $flagtype->is_requestable), + is_requesteeble => $self->type('boolean', $flagtype->is_requesteeble), + is_multiplicable => $self->type('boolean', $flagtype->is_multiplicable), + can_set_flag => $self->type('boolean', $cansetflag), + can_request_flag => $self->type('boolean', $canrequestflag) + }; + + my @values; + foreach my $value ('?','+','-') { + push(@values, $self->type('string', $value)); + } + $data->{values} = \@values; + + # if we're creating a bug, we need to return all valid flags for + # this product, as well as inclusions & exclusions so ember can + # display relevant flags once the component is selected + if (!$bug->id) { + my $inclusions = $self->_flagtype_clusions_to_hash($flagtype->inclusions, $bug->product_obj->id); + my $exclusions = $self->_flagtype_clusions_to_hash($flagtype->exclusions, $bug->product_obj->id); + # if we have both inclusions and exclusions, the exclusions are redundant + $exclusions = [] if @$inclusions && @$exclusions; + # no need to return anything if there's just "any component" + $data->{inclusions} = $inclusions if @$inclusions && $inclusions->[0] ne ''; + $data->{exclusions} = $exclusions if @$exclusions && $exclusions->[0] ne ''; + } + + return $data; +} + +sub _flagtype_clusions_to_hash { + my ($self, $clusions, $product_id) = @_; + my $result = []; + foreach my $key (keys %$clusions) { + my ($prod_id, $comp_id) = split(/:/, $clusions->{$key}, 2); + if ($prod_id == 0 || $prod_id == $product_id) { + if ($comp_id) { + my $component = Bugzilla::Component->new({ id => $comp_id, cache => 1 }); + push @$result, $component->name; + } + else { + return [ '' ]; + } + } + } + return $result; +} + +sub rest_resources { + return [ + # create page - single product name + qr{^/ember/create/(.*)$}, { + GET => { + method => 'create', + params => sub { + return { product => $_[0] }; + } + } + }, + # create page - one or more products + qr{^/ember/create$}, { + GET => { + method => 'create' + } + }, + # show bug page - single bug id + qr{^/ember/show/(\d+)$}, { + GET => { + method => 'show', + params => sub { + return { id => $_[0] }; + } + } + }, + # search - wrapper around SUPER::search which also includes the total + # number of bugs when using pagination + qr{^/ember/search$}, { + GET => { + method => 'search', + }, + }, + # get current bug attributes without field information - single bug id + qr{^/ember/bug/(\d+)$}, { + GET => { + method => 'bug', + params => sub { + return { id => $_[0] }; + } + } + }, + # attachments - wrapper around SUPER::attachments that also includes + # can_edit attribute + qr{^/ember/bug/(\d+)/attachments$}, { + GET => { + method => 'get_attachments', + params => sub { + return { ids => $_[0] }; + } + } + }, + qr{^/ember/bug/attachments/(\d+)$}, { + GET => { + method => 'get_attachments', + params => sub { + return { attachment_ids => $_[0] }; + } + } + } + ]; +}; + +1; + +__END__ + +=head1 NAME + +Bugzilla::Extension::Ember::Webservice - The BMO Ember WebServices API + +=head1 DESCRIPTION + +This module contains API methods that are useful to user's of the Bugzilla Ember +based UI. + +=head1 METHODS + +See L<Bugzilla::WebService> for a description of how parameters are passed, +and what B<STABLE>, B<UNSTABLE>, and B<EXPERIMENTAL> mean. + +=head2 create + +B<UNSTABLE> + +=over + +=item B<Description> + +This method returns the necessary information for the Bugzilla Ember UI to generate a +bug creation page. + +=item B<Params> + +You pass a field called C<product> that must be a valid Bugzilla product name. + +=over + +=item C<product> (string) - The Bugzilla product name. + +=back + +=item B<Returns> + +=over + +=back + +=item B<Errors> + +=over + +=back + +=item B<History> + +=over + +=item Added in BMO Bugzilla B<4.2>. + +=back + +=back + +=head2 show + +B<UNSTABLE> + +=over + +=item B<Description> + +This method returns the necessary information for the Bugzilla Ember UI to properly +generate a page to edit current bugs. + +=item B<Params> + +You pass a field called C<id> that is the current bug id. + +=over + +=item C<id> (int) - A bug id. + +=back + +=item B<Returns> + +=over + +=back + +=item B<Errors> + +=over + +=back + +=item B<History> + +=over + +=item Added in BMO Bugzilla B<4.0>. + +=back + +=back + +=head2 search + +B<UNSTABLE> + +=over + +=item B<Description> + +A wrapper around Bugzilla's C<search> method which also returns the total of +bugs matching a query, even if the limit and offset parameters are supplied. + +=item B<Params> + +As per Bugzilla::WebService::Bug::search() + +=item B<Returns> + +=over + +=back + +=item B<Errors> + +=over + +=back + +=item B<History> + +=over + +=back + +=back + +=head2 bug + +B<UNSTABLE> + +=over + +=item B<Description> + +This method returns just the current bug values, comments, and attachments without +all of the field information. + +=item B<Params> + +You pass a field called C<id> that is a valid bug ids. + +=over + +=item C<id> (integer) - A valid bug id + +=item C<last_updated> - (dateTime) An optional timestamp that includes only fields, +attachments, or comments that have been changed or added since. + +=back + +=item B<Returns> + +=over + +=back + +=item B<Errors> + +=over + +=back + +=item B<History> + +=over + +=item Added in BMO Bugzilla B<4.2>. + +=back + +=back + +=head2 get_attachments + +B<UNSTABLE> + +=over + +=item B<Description> + +This method returns the current attachment data and flag types for a given +bug id or attachment id. + +=item B<Params> + +You pass a field called C<id> that is a valid bug id or an C<attachment_id> which +is a valid attachment id. + +=over + +=item C<id> (integer) - A valid bug id. + +=item C<attachment_id> (integer) - A valid attachment id. + +=back + +=item B<Returns> + +=over + +=back + +=item B<Errors> + +=over + +=back + +=item B<History> + +=over + +=item Added in BMO Bugzilla B<4.2>. + +=back + +=back diff --git a/extensions/Ember/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/Ember/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..c438af283 --- /dev/null +++ b/extensions/Ember/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,4 @@ +[% IF error == "invalid_token" %] + [% title = "Invalid Token Provided" %] + The token provided is either invalid or expired. You must log in again. +[% END %] diff --git a/extensions/Example/Extension.pm b/extensions/Example/Extension.pm index c0b3c6210..a42f87b9e 100644 --- a/extensions/Example/Extension.pm +++ b/extensions/Example/Extension.pm @@ -44,6 +44,20 @@ use constant REL_EXAMPLE => -127; our $VERSION = '1.0'; +sub admin_editusers_action { + my ($self, $args) = @_; + my ($vars, $action, $user) = @$args{qw(vars action user)}; + my $template = Bugzilla->template; + + if ($action eq 'my_action') { + # Allow to restrict the search to any group the user is allowed to bless. + $vars->{'restrictablegroups'} = $user->bless_groups(); + $template->process('admin/users/search.html.tmpl', $vars) + || ThrowTemplateError($template->error()); + exit; + } +} + sub attachment_process_data { my ($self, $args) = @_; my $type = $args->{attributes}->{mimetype}; @@ -80,6 +94,44 @@ sub auth_verify_methods { } } +sub bug_check_can_change_field { + my ($self, $args) = @_; + + my ($bug, $field, $new_value, $old_value, $priv_results) + = @$args{qw(bug field new_value old_value priv_results)}; + + my $user = Bugzilla->user; + + # Disallow a bug from being reopened if currently closed unless user + # is in 'admin' group + if ($field eq 'bug_status' && $bug->product_obj->name eq 'Example') { + if (!is_open_state($old_value) && is_open_state($new_value) + && !$user->in_group('admin')) + { + push(@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); + return; + } + } + + # Disallow a bug's keywords from being edited unless user is the + # reporter of the bug + if ($field eq 'keywords' && $bug->product_obj->name eq 'Example' + && $user->login ne $bug->reporter->login) + { + push(@$priv_results, PRIVILEGES_REQUIRED_REPORTER); + return; + } + + # Allow updating of priority even if user cannot normally edit the bug + # and they are in group 'engineering' + if ($field eq 'priority' && $bug->product_obj->name eq 'Example' + && $user->in_group('engineering')) + { + push(@$priv_results, PRIVILEGES_REQUIRED_NONE); + return; + } +} + sub bug_columns { my ($self, $args) = @_; my $columns = $args->{'columns'}; @@ -116,6 +168,42 @@ sub bug_end_of_create_validators { # $bug_params->{cc} = []; } +sub bug_start_of_update { + my ($self, $args) = @_; + + # This code doesn't actually *do* anything, it's just here to show you + # how to use this hook. + my ($bug, $old_bug, $timestamp, $changes) = + @$args{qw(bug old_bug timestamp changes)}; + + foreach my $field (keys %$changes) { + my $used_to_be = $changes->{$field}->[0]; + my $now_it_is = $changes->{$field}->[1]; + } + + my $old_summary = $old_bug->short_desc; + + my $status_message; + if (my $status_change = $changes->{'bug_status'}) { + my $old_status = new Bugzilla::Status({ name => $status_change->[0] }); + my $new_status = new Bugzilla::Status({ name => $status_change->[1] }); + if ($new_status->is_open && !$old_status->is_open) { + $status_message = "Bug re-opened!"; + } + if (!$new_status->is_open && $old_status->is_open) { + $status_message = "Bug closed!"; + } + } + + my $bug_id = $bug->id; + my $num_changes = scalar keys %$changes; + my $result = "There were $num_changes changes to fields on bug $bug_id" + . " at $timestamp."; + # Uncomment this line to see $result in your webserver's error log whenever + # you update a bug. + # warn $result; +} + sub bug_end_of_update { my ($self, $args) = @_; @@ -678,10 +766,12 @@ sub _check_short_desc { my $invocant = shift; my $value = $invocant->$original(@_); if ($value !~ /example/i) { - # Uncomment this line to make Bugzilla throw an error every time + # Use this line to make Bugzilla throw an error every time # you try to file a bug or update a bug without the word "example" # in the summary. - #ThrowUserError('example_short_desc_invalid'); + if (0) { + ThrowUserError('example_short_desc_invalid'); + } } return $value; } @@ -697,6 +787,12 @@ sub page_before_template { } } +sub path_info_whitelist { + my ($self, $args) = @_; + my $whitelist = $args->{whitelist}; + push(@$whitelist, "page.cgi"); +} + sub post_bug_after_creation { my ($self, $args) = @_; @@ -825,58 +921,6 @@ sub template_before_process { } } -sub bug_check_can_change_field { - my ($self, $args) = @_; - - my ($bug, $field, $new_value, $old_value, $priv_results) - = @$args{qw(bug field new_value old_value priv_results)}; - - my $user = Bugzilla->user; - - # Disallow a bug from being reopened if currently closed unless user - # is in 'admin' group - if ($field eq 'bug_status' && $bug->product_obj->name eq 'Example') { - if (!is_open_state($old_value) && is_open_state($new_value) - && !$user->in_group('admin')) - { - push(@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); - return; - } - } - - # Disallow a bug's keywords from being edited unless user is the - # reporter of the bug - if ($field eq 'keywords' && $bug->product_obj->name eq 'Example' - && $user->login ne $bug->reporter->login) - { - push(@$priv_results, PRIVILEGES_REQUIRED_REPORTER); - return; - } - - # Allow updating of priority even if user cannot normally edit the bug - # and they are in group 'engineering' - if ($field eq 'priority' && $bug->product_obj->name eq 'Example' - && $user->in_group('engineering')) - { - push(@$priv_results, PRIVILEGES_REQUIRED_NONE); - return; - } -} - -sub admin_editusers_action { - my ($self, $args) = @_; - my ($vars, $action, $user) = @$args{qw(vars action user)}; - my $template = Bugzilla->template; - - if ($action eq 'my_action') { - # Allow to restrict the search to any group the user is allowed to bless. - $vars->{'restrictablegroups'} = $user->bless_groups(); - $template->process('admin/users/search.html.tmpl', $vars) - || ThrowTemplateError($template->error()); - exit; - } -} - sub user_preferences { my ($self, $args) = @_; my $tab = $args->{current_tab}; @@ -911,5 +955,70 @@ sub webservice_error_codes { $error_map->{'example_my_error'} = 10001; } +sub webservice_before_call { + my ($self, $args) = @_; + + # This code doesn't actually *do* anything, it's just here to show you + # how to use this hook. + my $method = $args->{method}; + my $full_method = $args->{full_method}; + + # Uncomment this line to see a line in your webserver's error log whenever + # a webservice call is made + #warn "RPC call $full_method made by ", Bugzilla->user->login, "\n"; +} + +sub webservice_fix_credentials { + my ($self, $args) = @_; + my $rpc = $args->{'rpc'}; + my $params = $args->{'params'}; + # Allow user to pass in username=foo&password=bar + if (exists $params->{'username'} && exists $params->{'password'}) { + $params->{'Bugzilla_login'} = $params->{'username'}; + $params->{'Bugzilla_password'} = $params->{'password'}; + } +} + +sub webservice_rest_request { + my ($self, $args) = @_; + my $rpc = $args->{'rpc'}; + my $params = $args->{'params'}; + # Internally we may have a field called 'cf_test_field' but we allow users + # to use the shorter 'test_field' name. + if (exists $params->{'test_field'}) { + $params->{'test_field'} = delete $params->{'cf_test_field'}; + } +} + +sub webservice_rest_resources { + my ($self, $args) = @_; + my $rpc = $args->{'rpc'}; + my $resources = $args->{'resources'}; + # Add a new resource that allows for /rest/example/hello + # to call Example.hello + $resources->{'Bugzilla::Extension::Example::WebService'} = [ + qr{^/example/hello$}, { + GET => { + method => 'hello', + } + } + ]; +} + +sub webservice_rest_response { + my ($self, $args) = @_; + my $rpc = $args->{'rpc'}; + my $result = $args->{'result'}; + my $response = $args->{'response'}; + # Convert a list of bug hashes to a single bug hash if only one is + # being returned. + if (ref $$result eq 'HASH' + && exists $$result->{'bugs'} + && scalar @{ $$result->{'bugs'} } == 1) + { + $$result = $$result->{'bugs'}->[0]; + } +} + # This must be the last line of your extension. __PACKAGE__->NAME; diff --git a/extensions/FlagDefaultRequestee/Config.pm b/extensions/FlagDefaultRequestee/Config.pm new file mode 100644 index 000000000..70c5ca33a --- /dev/null +++ b/extensions/FlagDefaultRequestee/Config.pm @@ -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. + +package Bugzilla::Extension::FlagDefaultRequestee; + +use strict; + +use constant NAME => 'FlagDefaultRequestee'; + +use constant REQUIRED_MODULES => []; +use constant OPTIONAL_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/FlagDefaultRequestee/Extension.pm b/extensions/FlagDefaultRequestee/Extension.pm new file mode 100644 index 000000000..958a1bb85 --- /dev/null +++ b/extensions/FlagDefaultRequestee/Extension.pm @@ -0,0 +1,173 @@ +# 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::FlagDefaultRequestee; + +use strict; +use base qw(Bugzilla::Extension); + +use Bugzilla::Error; +use Bugzilla::FlagType; +use Bugzilla::User; +use Bugzilla::Util 'trim'; + +use Bugzilla::Extension::FlagDefaultRequestee::Constants; + +our $VERSION = '1'; + +################ +# Installation # +################ + +sub install_update_db { + my $dbh = Bugzilla->dbh; + $dbh->bz_add_column('flagtypes', 'default_requestee', { + TYPE => 'INT3', + NOTNULL => 0, + REFERENCES => { TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'SET NULL' } + }); +} + +############# +# Templates # +############# + +sub template_before_process { + my ($self, $args) = @_; + return unless Bugzilla->user->id; + my ($vars, $file) = @$args{qw(vars file)}; + return unless grep { $_ eq $file } FLAGTYPE_TEMPLATES; + + my $flag_types = []; + if (exists $vars->{bug} || exists $vars->{attachment}) { + my $bug; + if (exists $vars->{bug}) { + $bug = $vars->{'bug'}; + } elsif (exists $vars->{'attachment'}) { + $bug = $vars->{'attachment'}->{bug}; + } + + $flag_types = Bugzilla::FlagType::match({ + 'target_type' => ($file =~ /^bug/ ? 'bug' : 'attachment'), + 'product_id' => $bug->product_id, + 'component_id' => $bug->component_id, + 'bug_id' => $bug->id, + 'active_or_has_flags' => $bug->id, + }); + + $vars->{flag_currently_requested} ||= {}; + foreach my $type (@$flag_types) { + my $flags = Bugzilla::Flag->match({ + type_id => $type->id, + bug_id => $bug->id, + status => '?' + }); + map { $vars->{flag_currently_requested}->{$_->id} = 1 } @$flags; + } + } + elsif ($file =~ /^bug\/create/ && exists $vars->{product}) { + my $bug_flags = $vars->{product}->flag_types->{bug}; + my $attachment_flags = $vars->{product}->flag_types->{attachment}; + $flag_types = [ map { $_ } (@$bug_flags, @$attachment_flags) ]; + } + + return if !@$flag_types; + + $vars->{flag_default_requestees} ||= {}; + foreach my $type (@$flag_types) { + next if !$type->default_requestee; + $vars->{flag_default_requestees}->{$type->id} = $type->default_requestee->login; + } +} + +################## +# Object Methods # +################## + +BEGIN { + *Bugzilla::FlagType::default_requestee = \&_default_requestee; +} + +sub object_columns { + my ($self, $args) = @_; + my ($class, $columns) = @$args{qw(class columns)}; + if ($class->isa('Bugzilla::FlagType')) { + push(@$columns, 'default_requestee'); + } +} + +sub object_update_columns { + my ($self, $args) = @_; + my $object = $args->{object}; + return unless $object->isa('Bugzilla::FlagType'); + + my $columns = $args->{columns}; + push(@$columns, 'default_requestee'); + + # editflagtypes.cgi doesn't call set_all, so we have to do this here + my $input = Bugzilla->input_params; + $object->set('default_requestee', $input->{default_requestee}) + if exists $input->{default_requestee}; +} + +sub object_validators { + my ($self, $args) = @_; + my $class = $args->{class}; + return unless $class->isa('Bugzilla::FlagType'); + + my $validators = $args->{validators}; + $validators->{default_requestee} = \&_check_default_requestee; +} + +sub object_before_create { + my ($self, $args) = @_; + my $class = $args->{class}; + return unless $class->isa('Bugzilla::FlagType'); + + my $params = $args->{params}; + my $input = Bugzilla->input_params; + $params->{default_requestee} = $input->{default_requestee} + if exists $params->{default_requestee}; +} + +sub object_end_of_update { + my ($self, $args) = @_; + my $object = $args->{object}; + return unless $object->isa('Bugzilla::FlagType'); + + my $old_object = $args->{old_object}; + my $changes = $args->{changes}; + my $old_id = $old_object->default_requestee + ? $old_object->default_requestee->id + : 0; + my $new_id = $object->default_requestee + ? $object->default_requestee->id + : 0; + return if $old_id == $new_id; + + $changes->{default_requestee} = [ $old_id, $new_id ]; +} + +sub _check_default_requestee { + my ($self, $value, $field) = @_; + $value = trim($value // ''); + return undef if $value eq ''; + ThrowUserError("flag_default_requestee_review") + if $self->name eq 'review'; + return Bugzilla::User->check($value)->id; +} + +sub _default_requestee { + my ($self) = @_; + return $self->{default_requestee} + ? Bugzilla::User->new({ id => $self->{default_requestee}, cache => 1 }) + : undef; +} + +__PACKAGE__->NAME; diff --git a/extensions/FlagDefaultRequestee/lib/Constants.pm b/extensions/FlagDefaultRequestee/lib/Constants.pm new file mode 100644 index 000000000..467028423 --- /dev/null +++ b/extensions/FlagDefaultRequestee/lib/Constants.pm @@ -0,0 +1,25 @@ +# 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::FlagDefaultRequestee::Constants; + +use strict; + +use base qw(Exporter); + +our @EXPORT = qw( + FLAGTYPE_TEMPLATES +); + +use constant FLAGTYPE_TEMPLATES => ( + "attachment/edit.html.tmpl", + "attachment/createformcontents.html.tmpl", + "bug/edit.html.tmpl", + "bug/create/create.html.tmpl" +); + +1; diff --git a/extensions/FlagDefaultRequestee/template/en/default/flag/default_requestees.html.tmpl b/extensions/FlagDefaultRequestee/template/en/default/flag/default_requestees.html.tmpl new file mode 100644 index 000000000..db728c168 --- /dev/null +++ b/extensions/FlagDefaultRequestee/template/en/default/flag/default_requestees.html.tmpl @@ -0,0 +1,105 @@ +[%# 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 flag_default_requestees.keys.size %] + <script type="text/javascript"> + var currently_requested = new Array(); + var default_requestees = new Array(); + [% FOREACH id = flag_currently_requested.keys %] + currently_requested.push('[% id FILTER js %]'); + [% END %] + [% FOREACH id = flag_default_requestees.keys %] + default_requestees['id_[% id FILTER js %]'] = '[% flag_default_requestees.$id FILTER js %]'; + [% END %] + + function fdrSetDefaultRequestee(field, default_requestee) { + field.value = default_requestee; + field.focus(); + field.select(); + } + + function fdrOnChange(ev) { + var parts = ev.target.id.split('-'); + var flag = parts[0]; + var id = parts[1]; + var state = ev.target.value; + var requestee_field; + + if (flag.search(/_type/) == -1) { + for (var i = 0; i < currently_requested.length; i++) { + if (id == currently_requested[i]) { + return; + } + } + requestee_field = YAHOO.util.Dom.get('requestee-' + id); + parts = ev.target.className.split('-'); + id = parts[1]; + } + else { + requestee_field = YAHOO.util.Dom.get('requestee_type-' + id); + } + if (!requestee_field) return; + + var current_requestee = requestee_field.value; + var default_requestee = default_requestees['id_' + id]; + if (!default_requestee) return; + + if (state == '?' && !current_requestee && default_requestee) { + fdrSetDefaultRequestee(requestee_field, default_requestees['id_' + id]); + } + else if (state == '?' && current_requestee != default_requestee) { + fdrShowDefaultLink(requestee_field, id); + } + } + + YAHOO.util.Event.onDOMReady(function() { + var selects = YAHOO.util.Dom.getElementsByClassName('flag_select'); + for (var i = 0; i < selects.length; i++) { + YAHOO.util.Event.on(selects[i], 'change', fdrOnChange); + } + + for (var i = 0; i < currently_requested.length; i++) { + var flag_id = currently_requested[i]; + var flag_field = YAHOO.util.Dom.get('flag-' + flag_id); + var requestee_field = YAHOO.util.Dom.get('requestee-' + flag_id); + if (!requestee_field) continue; + var parts = flag_field.className.split('-'); + var type_id = parts[1]; + var current_requestee = requestee_field.value; + var default_requestee = default_requestees['id_' + type_id]; + if (!default_requestee) continue; + if (current_requestee != default_requestee) { + fdrShowDefaultLink(requestee_field, type_id, flag_id); + } + } + }); + + function fdrHideDefaultLink (flag_id) { + YAHOO.util.Dom.addClass('default_requestee_' + flag_id, 'bz_default_hidden'); + } + + function fdrShowDefaultLink (requestee_field, type_id, flag_id) { + var default_requestee = default_requestees['id_' + type_id]; + + var default_link = document.createElement('a'); + YAHOO.util.Dom.setAttribute(default_link, 'href', 'javascript:void(0)'); + default_link.appendChild(document.createTextNode('default requestee')); + YAHOO.util.Event.addListener(default_link, 'click', function() { + fdrSetDefaultRequestee(requestee_field, default_requestee); + fdrHideDefaultLink(flag_id); + }); + + var default_span = document.createElement('span'); + YAHOO.util.Dom.setAttribute(default_span, 'id', 'default_requestee_' + flag_id); + default_span.appendChild(document.createTextNode("\u00a0(")); + default_span.appendChild(default_link); + default_span.appendChild(document.createTextNode(')')); + requestee_field.parentNode.parentNode.appendChild(default_span); + } + </script> +[% END %] diff --git a/extensions/FlagDefaultRequestee/template/en/default/hook/admin/flag-type/edit-rows.html.tmpl b/extensions/FlagDefaultRequestee/template/en/default/hook/admin/flag-type/edit-rows.html.tmpl new file mode 100644 index 000000000..edefca370 --- /dev/null +++ b/extensions/FlagDefaultRequestee/template/en/default/hook/admin/flag-type/edit-rows.html.tmpl @@ -0,0 +1,21 @@ +[%# 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>Default Requestee:</th> + <td> + If flag is specifically requestable, this user will be entered in the + requestee field by default unless the user changes it.<br> + [% INCLUDE global/userselect.html.tmpl + name => 'default_requestee' + id => 'default_requestee' + value => type.default_requestee.login + classes => ['requestee'] + %] + </td> +</tr> diff --git a/extensions/FlagDefaultRequestee/template/en/default/hook/attachment/create-end.html.tmpl b/extensions/FlagDefaultRequestee/template/en/default/hook/attachment/create-end.html.tmpl new file mode 100644 index 000000000..20b2526d0 --- /dev/null +++ b/extensions/FlagDefaultRequestee/template/en/default/hook/attachment/create-end.html.tmpl @@ -0,0 +1,9 @@ +[%# 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 flag/default_requestees.html.tmpl %] diff --git a/extensions/FlagDefaultRequestee/template/en/default/hook/attachment/edit-end.html.tmpl b/extensions/FlagDefaultRequestee/template/en/default/hook/attachment/edit-end.html.tmpl new file mode 100644 index 000000000..20b2526d0 --- /dev/null +++ b/extensions/FlagDefaultRequestee/template/en/default/hook/attachment/edit-end.html.tmpl @@ -0,0 +1,9 @@ +[%# 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 flag/default_requestees.html.tmpl %] diff --git a/extensions/FlagDefaultRequestee/template/en/default/hook/bug/create/create-form.html.tmpl b/extensions/FlagDefaultRequestee/template/en/default/hook/bug/create/create-form.html.tmpl new file mode 100644 index 000000000..20b2526d0 --- /dev/null +++ b/extensions/FlagDefaultRequestee/template/en/default/hook/bug/create/create-form.html.tmpl @@ -0,0 +1,9 @@ +[%# 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 flag/default_requestees.html.tmpl %] diff --git a/extensions/FlagDefaultRequestee/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl b/extensions/FlagDefaultRequestee/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl new file mode 100644 index 000000000..20b2526d0 --- /dev/null +++ b/extensions/FlagDefaultRequestee/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl @@ -0,0 +1,9 @@ +[%# 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 flag/default_requestees.html.tmpl %] diff --git a/extensions/FlagDefaultRequestee/template/en/default/hook/global/messages-flag_type_updated_fields.html.tmpl b/extensions/FlagDefaultRequestee/template/en/default/hook/global/messages-flag_type_updated_fields.html.tmpl new file mode 100644 index 000000000..478718d3c --- /dev/null +++ b/extensions/FlagDefaultRequestee/template/en/default/hook/global/messages-flag_type_updated_fields.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 changes.default_requestee.defined %] + [% IF flagtype.default_requestee %] + <li>Default requestee updated to '[% flagtype.default_requestee.login FILTER html %]'</li> + [% ELSE %] + <li>Default requestee deleted</li> + [% END %] +[% END %] diff --git a/extensions/FlagDefaultRequestee/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/FlagDefaultRequestee/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..3fbd0458c --- /dev/null +++ b/extensions/FlagDefaultRequestee/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,13 @@ +[%# 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 == "flag_default_requestee_review" %] + [% title = "Review flag not supported" %] + You cannot use the 'Default Requestee' field for the review flag. + Instead set the 'Suggested Reviewers' on the Product or Component edit forms. +[% END %] diff --git a/extensions/FlagTypeComment/Config.pm b/extensions/FlagTypeComment/Config.pm new file mode 100644 index 000000000..e20be10e3 --- /dev/null +++ b/extensions/FlagTypeComment/Config.pm @@ -0,0 +1,29 @@ +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the FlagTypeComment Bugzilla Extension. +# +# The Initial Developer of the Original Code is Alex Keybl +# Portions created by the Initial Developer are Copyright (C) 2011 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Alex Keybl <akeybl@mozilla.com> +# byron jones <glob@mozilla.com> + +package Bugzilla::Extension::FlagTypeComment; +use strict; + +use constant NAME => 'FlagTypeComment'; + +use constant REQUIRED_MODULES => []; +use constant OPTIONAL_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/FlagTypeComment/Extension.pm b/extensions/FlagTypeComment/Extension.pm new file mode 100644 index 000000000..8da6101ad --- /dev/null +++ b/extensions/FlagTypeComment/Extension.pm @@ -0,0 +1,200 @@ +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the FlagTypeComment Bugzilla Extension. +# +# The Initial Developer of the Original Code is Alex Keybl +# Portions created by the Initial Developer are Copyright (C) 2011 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Alex Keybl <akeybl@mozilla.com> +# byron jones <glob@mozilla.com> + +package Bugzilla::Extension::FlagTypeComment; +use strict; +use base qw(Bugzilla::Extension); + +use Bugzilla::Extension::FlagTypeComment::Constants; + +use Bugzilla::FlagType; +use Bugzilla::Util qw(trick_taint); +use Scalar::Util qw(blessed); + +our $VERSION = '1'; + +################ +# Installation # +################ + +sub db_schema_abstract_schema { + my ($self, $args) = @_; + $args->{'schema'}->{'flagtype_comments'} = { + FIELDS => [ + type_id => { + TYPE => 'SMALLINT(6)', + NOTNULL => 1, + REFERENCES => { + TABLE => 'flagtypes', + COLUMN => 'id', + DELETE => 'CASCADE' + } + }, + on_status => { + TYPE => 'CHAR(1)', + NOTNULL => 1 + }, + comment => { + TYPE => 'MEDIUMTEXT', + NOTNULL => 1 + }, + ], + INDEXES => [ + flagtype_comments_idx => ['type_id'], + ], + }; +} + +############# +# Templates # +############# + +sub template_before_process { + my ($self, $args) = @_; + my ($vars, $file) = @$args{qw(vars file)}; + + return unless Bugzilla->user->id; + if (grep { $_ eq $file } FLAGTYPE_COMMENT_TEMPLATES) { + _set_ftc_states($file, $vars); + } +} + +sub _set_ftc_states { + my ($file, $vars) = @_; + my $dbh = Bugzilla->dbh; + + my $ftc_flags; + my $db_result; + if ($file =~ /^admin\//) { + # admin + my $type = $vars->{'type'} || return; + my ($target_type, $id); + if (blessed($type)) { + ($target_type, $id) = ($type->target_type, $type->id); + } else { + ($target_type, $id) = ($type->{target_type}, $type->{id}); + trick_taint($id) if $id; + } + if ($target_type eq 'bug') { + return unless FLAGTYPE_COMMENT_BUG_FLAGS; + } else { + return unless FLAGTYPE_COMMENT_ATTACHMENT_FLAGS; + } + if ($id) { + $db_result = $dbh->selectall_arrayref( + "SELECT type_id AS flagtype, on_status AS state, comment AS text + FROM flagtype_comments + WHERE type_id = ?", + { Slice => {} }, $id); + } + } else { + # creating/editing attachment / viewing bug + my $bug; + if (exists $vars->{'bug'}) { + $bug = $vars->{'bug'}; + } elsif (exists $vars->{'attachment'}) { + $bug = $vars->{'attachment'}->{bug}; + } else { + return; + } + + my $flag_types = Bugzilla::FlagType::match({ + 'target_type' => ($file =~ /^bug/ ? 'bug' : 'attachment'), + 'product_id' => $bug->product_id, + 'component_id' => $bug->component_id, + 'bug_id' => $bug->id, + 'active_or_has_flags' => $bug->id, + }); + + my $types = join(',', map { $_->id } @$flag_types); + my $states = "'" . join("','", FLAGTYPE_COMMENT_STATES) . "'"; + $db_result = $dbh->selectall_arrayref( + "SELECT type_id AS flagtype, on_status AS state, comment AS text + FROM flagtype_comments + WHERE type_id IN ($types) AND on_status IN ($states)", + { Slice => {} }); + } + + foreach my $row (@$db_result) { + $ftc_flags->{$row->{'flagtype'}} ||= {}; + $ftc_flags->{$row->{'flagtype'}}{$row->{'state'}} = $row->{text}; + } + + $vars->{'ftc_states'} = [ FLAGTYPE_COMMENT_STATES ]; + $vars->{'ftc_flags'} = $ftc_flags; +} + +######### +# Admin # +######### + +sub flagtype_end_of_create { + my ($self, $args) = @_; + _set_flagtypes($args->{type}); +} + +sub flagtype_end_of_update { + my ($self, $args) = @_; + _set_flagtypes($args->{type}); +} + +sub _set_flagtypes { + my $flag_type = shift; + my $flagtype_id = $flag_type->id; + my $input = Bugzilla->input_params; + my $dbh = Bugzilla->dbh; + + foreach my $state (FLAGTYPE_COMMENT_STATES) { + next if (!defined $input->{"ftc_${flagtype_id}_$state"} + && !defined $input->{"ftc_new_$state"}); + + my $text = $input->{"ftc_${flagtype_id}_$state"} || $input->{"ftc_new_$state"} || ''; + $text =~ s/\r\n/\n/g; + trick_taint($text); + + if ($text ne '') { + if ($dbh->selectrow_array( + "SELECT 1 FROM flagtype_comments WHERE type_id=? AND on_status=?", + undef, + $flagtype_id, $state) + ) { + $dbh->do( + "UPDATE flagtype_comments SET comment=? + WHERE type_id=? AND on_status=?", + undef, + $text, $flagtype_id, $state); + } else { + $dbh->do( + "INSERT INTO flagtype_comments(type_id, on_status, comment) + VALUES (?, ?, ?)", + undef, + $flagtype_id, $state, $text); + } + + } else { + $dbh->do( + "DELETE FROM flagtype_comments WHERE type_id=? AND on_status=?", + undef, + $flagtype_id, $state); + } + } +} + +__PACKAGE__->NAME; diff --git a/extensions/FlagTypeComment/lib/Constants.pm b/extensions/FlagTypeComment/lib/Constants.pm new file mode 100644 index 000000000..e1a99e5b3 --- /dev/null +++ b/extensions/FlagTypeComment/lib/Constants.pm @@ -0,0 +1,50 @@ +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the FlagTypeComment Bugzilla Extension. +# +# The Initial Developer of the Original Code is Alex Keybl +# Portions created by the Initial Developer are Copyright (C) 2011 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Alex Keybl <akeybl@mozilla.com> +# byron jones <glob@mozilla.com> + +package Bugzilla::Extension::FlagTypeComment::Constants; +use strict; + +use base qw(Exporter); +our @EXPORT = qw( + FLAGTYPE_COMMENT_TEMPLATES + FLAGTYPE_COMMENT_STATES + FLAGTYPE_COMMENT_BUG_FLAGS + FLAGTYPE_COMMENT_ATTACHMENT_FLAGS +); + +use constant FLAGTYPE_COMMENT_STATES => ("?", "+", "-"); +use constant FLAGTYPE_COMMENT_BUG_FLAGS => 0; +use constant FLAGTYPE_COMMENT_ATTACHMENT_FLAGS => 1; + +sub FLAGTYPE_COMMENT_TEMPLATES { + my @result = ("admin/flag-type/edit.html.tmpl"); + if (FLAGTYPE_COMMENT_BUG_FLAGS) { + push @result, ("bug/comments.html.tmpl"); + } + if (FLAGTYPE_COMMENT_ATTACHMENT_FLAGS) { + push @result, ( + "attachment/edit.html.tmpl", + "attachment/createformcontents.html.tmpl", + ); + } + return @result; +} + +1; diff --git a/extensions/FlagTypeComment/template/en/default/flag/type_comment.html.tmpl b/extensions/FlagTypeComment/template/en/default/flag/type_comment.html.tmpl new file mode 100644 index 000000000..95c0cb283 --- /dev/null +++ b/extensions/FlagTypeComment/template/en/default/flag/type_comment.html.tmpl @@ -0,0 +1,54 @@ +[%# The contents of this file are subject to the Mozilla Public License Version + # 1.1 (the "License"); you may not use this file except in compliance with + # the License. You may obtain a copy of the License at + # http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS IS" basis, + # WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + # for the specific language governing rights and limitations under the + # License. + # + # The Original Code is FlagTypeComment Bugzilla Extension. + # + # The Initial Developer of the Original Code is + # the Mozilla Foundation. + # Portions created by the Initial Developer are Copyright (C) 2011 + # the Initial Developer. All Rights Reserved. + # + # Contributor(s): + # Alex Keybl <akeybl@mozilla.com> + # byron jones <glob@mozilla.com> + #%] + +[% IF ftc_flags.keys.size %] + <script type="text/javascript"> + YAHOO.util.Event.onDOMReady(function() { + var selects = YAHOO.util.Dom.getElementsByClassName('flag_select'); + for (var i = 0; i < selects.length; i++) { + YAHOO.util.Event.on(selects[i], 'change', ftc_on_change); + } + }); + + function ftc_on_change(ev) { + var id = ev.target.id.split('-')[1]; + var state = ev.target.value; + var commentEl = document.getElementById('comment'); + if (!commentEl) return; + [% FOREACH type_id = ftc_flags.keys %] + [% FOREACH state = ftc_states %] + if ([% type_id FILTER none %] == id && '[% state FILTER js %]' == state) { + var text = '[% ftc_flags.$type_id.$state FILTER js %]'; + var value = commentEl.value; + if (value == text) { + return; + } else if (value == '') { + commentEl.value = text; + } else { + commentEl.value = text + "\n\n" + value; + } + } + [% END %] + [% END %] + } + </script> +[% END %] diff --git a/extensions/FlagTypeComment/template/en/default/hook/admin/flag-type/edit-rows.html.tmpl b/extensions/FlagTypeComment/template/en/default/hook/admin/flag-type/edit-rows.html.tmpl new file mode 100644 index 000000000..3ca5e8aa7 --- /dev/null +++ b/extensions/FlagTypeComment/template/en/default/hook/admin/flag-type/edit-rows.html.tmpl @@ -0,0 +1,45 @@ +[%# The contents of this file are subject to the Mozilla Public License Version + # 1.1 (the "License"); you may not use this file except in compliance with + # the License. You may obtain a copy of the License at + # http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS IS" basis, + # WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + # for the specific language governing rights and limitations under the + # License. + # + # The Original Code is FlagTypeComment Bugzilla Extension. + # + # The Initial Developer of the Original Code is + # the Mozilla Foundation. + # Portions created by the Initial Developer are Copyright (C) 2011 + # the Initial Developer. All Rights Reserved. + # + # Contributor(s): + # Alex Keybl <akeybl@mozilla.com> + # byron jones <glob@mozilla.com> + #%] + +[% IF ftc_states %] + <tr> + <th>Flag Comments:</th> + <td>add text into the comment box when flag is changed to a state</td> + </tr> + + [% FOREACH state = ftc_states %] + [% ftc_type_id = "ftc_${type.id}_$state" %] + [% IF action == 'insert' %] + [% ftc_type_id = "ftc_new_$state" %] + [% END %] + <tr> + <td> </td> + <td> + for [% state FILTER html %]<br> + <textarea + id="[% ftc_type_id FILTER html %]" + name="[% ftc_type_id FILTER html %]" + cols="50" rows="2">[% ftc_flags.${type.id}.$state FILTER html %]</textarea> + </td> + </tr> + [% END %] +[% END %] diff --git a/extensions/FlagTypeComment/template/en/default/hook/attachment/create-end.html.tmpl b/extensions/FlagTypeComment/template/en/default/hook/attachment/create-end.html.tmpl new file mode 100644 index 000000000..dfa010d7c --- /dev/null +++ b/extensions/FlagTypeComment/template/en/default/hook/attachment/create-end.html.tmpl @@ -0,0 +1,23 @@ +[%# The contents of this file are subject to the Mozilla Public License Version + # 1.1 (the "License"); you may not use this file except in compliance with + # the License. You may obtain a copy of the License at + # http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS IS" basis, + # WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + # for the specific language governing rights and limitations under the + # License. + # + # The Original Code is FlagTypeComment Bugzilla Extension. + # + # The Initial Developer of the Original Code is + # the Mozilla Foundation. + # Portions created by the Initial Developer are Copyright (C) 2011 + # the Initial Developer. All Rights Reserved. + # + # Contributor(s): + # Alex Keybl <akeybl@mozilla.com> + # byron jones <glob@mozilla.com> + #%] + +[% INCLUDE flag/type_comment.html.tmpl %] diff --git a/extensions/FlagTypeComment/template/en/default/hook/attachment/edit-end.html.tmpl b/extensions/FlagTypeComment/template/en/default/hook/attachment/edit-end.html.tmpl new file mode 100644 index 000000000..dfa010d7c --- /dev/null +++ b/extensions/FlagTypeComment/template/en/default/hook/attachment/edit-end.html.tmpl @@ -0,0 +1,23 @@ +[%# The contents of this file are subject to the Mozilla Public License Version + # 1.1 (the "License"); you may not use this file except in compliance with + # the License. You may obtain a copy of the License at + # http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS IS" basis, + # WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + # for the specific language governing rights and limitations under the + # License. + # + # The Original Code is FlagTypeComment Bugzilla Extension. + # + # The Initial Developer of the Original Code is + # the Mozilla Foundation. + # Portions created by the Initial Developer are Copyright (C) 2011 + # the Initial Developer. All Rights Reserved. + # + # Contributor(s): + # Alex Keybl <akeybl@mozilla.com> + # byron jones <glob@mozilla.com> + #%] + +[% INCLUDE flag/type_comment.html.tmpl %] diff --git a/extensions/FlagTypeComment/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl b/extensions/FlagTypeComment/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl new file mode 100644 index 000000000..dfa010d7c --- /dev/null +++ b/extensions/FlagTypeComment/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl @@ -0,0 +1,23 @@ +[%# The contents of this file are subject to the Mozilla Public License Version + # 1.1 (the "License"); you may not use this file except in compliance with + # the License. You may obtain a copy of the License at + # http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS IS" basis, + # WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + # for the specific language governing rights and limitations under the + # License. + # + # The Original Code is FlagTypeComment Bugzilla Extension. + # + # The Initial Developer of the Original Code is + # the Mozilla Foundation. + # Portions created by the Initial Developer are Copyright (C) 2011 + # the Initial Developer. All Rights Reserved. + # + # Contributor(s): + # Alex Keybl <akeybl@mozilla.com> + # byron jones <glob@mozilla.com> + #%] + +[% INCLUDE flag/type_comment.html.tmpl %] diff --git a/extensions/Gravatar/Config.pm b/extensions/Gravatar/Config.pm new file mode 100644 index 000000000..e15a41ee8 --- /dev/null +++ b/extensions/Gravatar/Config.pm @@ -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. + +package Bugzilla::Extension::Gravatar; + +use strict; + +use constant NAME => 'Gravatar'; +use constant REQUIRED_MODULES => []; +use constant OPTIONAL_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/Gravatar/Extension.pm b/extensions/Gravatar/Extension.pm new file mode 100644 index 000000000..050a0c27d --- /dev/null +++ b/extensions/Gravatar/Extension.pm @@ -0,0 +1,43 @@ +# 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::Gravatar; + +use strict; +use warnings; + +use base qw(Bugzilla::Extension); + +use Bugzilla::User::Setting; +use Digest::MD5 qw(md5_hex); + +use constant DEFAULT_URL => 'extensions/Gravatar/web/default.jpg'; + +BEGIN { + *Bugzilla::User::gravatar = \&_user_gravatar; +} + +sub _user_gravatar { + my ($self, $size) = @_; + if ($self->setting('show_my_gravatar') eq 'Off') { + return DEFAULT_URL; + } + if (!$self->{gravatar}) { + $self->{gravatar} = 'https://secure.gravatar.com/avatar/' . + md5_hex(lc($self->email)) . '?d=mm'; + } + $size ||= 64; + return $self->{gravatar} . "&size=$size"; +} + +sub install_before_final_checks { + my ($self, $args) = @_; + add_setting('show_gravatars', ['On', 'Off'], 'Off'); + add_setting('show_my_gravatar', ['On', 'Off'], 'On'); +} + +__PACKAGE__->NAME; diff --git a/extensions/Gravatar/template/en/default/hook/bug/comments-user-image.html.tmpl b/extensions/Gravatar/template/en/default/hook/bug/comments-user-image.html.tmpl new file mode 100644 index 000000000..66714748b --- /dev/null +++ b/extensions/Gravatar/template/en/default/hook/bug/comments-user-image.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 user.settings.show_gravatars.value == 'On' %] + [% IF who.last_activity_ts %] + [% IF user.id %] + <a href="user_profile?login=[% who.login FILTER uri %]"> + [% ELSE %] + <a href="user_profile?user_id=[% who.id FILTER uri %]"> + [% END %] + [% END %] + <img align="middle" src="[% who.gravatar FILTER none %]" width="32" height="32" border="0"> + [% "</a>" IF who.last_activity_ts %] +[% END %] diff --git a/extensions/Gravatar/template/en/default/hook/bug/show-header-end.html.tmpl b/extensions/Gravatar/template/en/default/hook/bug/show-header-end.html.tmpl new file mode 100644 index 000000000..0f1130976 --- /dev/null +++ b/extensions/Gravatar/template/en/default/hook/bug/show-header-end.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 user.settings.show_gravatars.value == 'On' %] + [% bodyclasses.push('bz_gravatar') %] +[% END %] diff --git a/extensions/Gravatar/template/en/default/hook/global/setting-descs-settings.none.tmpl b/extensions/Gravatar/template/en/default/hook/global/setting-descs-settings.none.tmpl new file mode 100644 index 000000000..697cfef99 --- /dev/null +++ b/extensions/Gravatar/template/en/default/hook/global/setting-descs-settings.none.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. + #%] + +[% + setting_descs.show_gravatars = "Show gravatar images when viewing $terms.bugs" + setting_descs.show_my_gravatar = "Show my gravatar image to other users" +%] diff --git a/extensions/Gravatar/web/default.jpg b/extensions/Gravatar/web/default.jpg Binary files differnew file mode 100644 index 000000000..98dc1fa87 --- /dev/null +++ b/extensions/Gravatar/web/default.jpg diff --git a/extensions/GuidedBugEntry/Config.pm b/extensions/GuidedBugEntry/Config.pm new file mode 100644 index 000000000..e4bc9c70b --- /dev/null +++ b/extensions/GuidedBugEntry/Config.pm @@ -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. + +package Bugzilla::Extension::GuidedBugEntry; +use strict; + +use constant NAME => 'GuidedBugEntry'; + +use constant REQUIRED_MODULES => [ +]; + +use constant OPTIONAL_MODULES => [ +]; + +__PACKAGE__->NAME; diff --git a/extensions/GuidedBugEntry/Extension.pm b/extensions/GuidedBugEntry/Extension.pm new file mode 100644 index 000000000..127a93a8e --- /dev/null +++ b/extensions/GuidedBugEntry/Extension.pm @@ -0,0 +1,118 @@ +# 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::GuidedBugEntry; +use strict; +use base qw(Bugzilla::Extension); + +use Bugzilla::Token; +use Bugzilla::Error; +use Bugzilla::Status; +use Bugzilla::Util 'url_quote'; +use Bugzilla::UserAgent; +use Bugzilla::Extension::BMO::Data; + +our $VERSION = '1'; + +sub enter_bug_start { + my ($self, $args) = @_; + my $vars = $args->{vars}; + my $template = Bugzilla->template; + my $cgi = Bugzilla->cgi; + my $user = Bugzilla->user; + + # hack for skipping old guided code when enabled + $vars->{'disable_guided'} = 1; + + # force guided format for new users + my $format = $cgi->param('format') || ''; + if ( + $format eq 'guided' || + ( + $format eq '' && + !$user->in_group('canconfirm') + ) + ) { + # skip the first step if a product is provided + if ($cgi->param('product')) { + print $cgi->redirect('enter_bug.cgi?format=guided#h=dupes' . + '|' . url_quote($cgi->param('product')) . + '|' . url_quote($cgi->param('component') || '') + ); + exit; + } + + $self->_init_vars($vars); + print $cgi->header(); + $template->process('guided/guided.html.tmpl', $vars) + || ThrowTemplateError($template->error()); + exit; + } + + # we use the __default__ format to bypass the guided entry + # it isn't understood upstream, so remove it once a product + # has been selected. + if ( + ($cgi->param('format') && $cgi->param('format') eq "__default__") + && ($cgi->param('product') && $cgi->param('product') ne '') + ) { + $cgi->delete('format'); + } +} + +sub _init_vars { + my ($self, $vars) = @_; + my $user = Bugzilla->user; + + my @enterable_products = @{$user->get_enterable_products}; + ThrowUserError('no_products') unless scalar(@enterable_products); + + my @classifications = ({object => undef, products => \@enterable_products}); + + my $class; + foreach my $product (@enterable_products) { + $class->{$product->classification_id}->{'object'} ||= + new Bugzilla::Classification($product->classification_id); + push(@{$class->{$product->classification_id}->{'products'}}, $product); + } + @classifications = + sort { + $a->{'object'}->sortkey <=> $b->{'object'}->sortkey + || lc($a->{'object'}->name) cmp lc($b->{'object'}->name) + } (values %$class); + $vars->{'classifications'} = \@classifications; + + my @open_states = BUG_STATE_OPEN(); + $vars->{'open_states'} = \@open_states; + + $vars->{'token'} = issue_session_token('create_bug'); + + $vars->{'platform'} = detect_platform(); + $vars->{'op_sys'} = detect_op_sys(); +} + +sub page_before_template { + my ($self, $args) = @_; + my $page = $args->{'page_id'}; + my $vars = $args->{'vars'}; + + return unless $page eq 'guided_products.js'; + + # import data from the BMO ext + + $vars->{'product_sec_groups'} = \%product_sec_groups; + + my %bug_formats; + foreach my $product (keys %create_bug_formats) { + if (my $format = Bugzilla::Extension::BMO::forced_format($product)) { + $bug_formats{$product} = $format; + } + } + $vars->{'create_bug_formats'} = \%bug_formats; +} + +__PACKAGE__->NAME; diff --git a/extensions/GuidedBugEntry/template/en/default/bug/create/comment-guided.txt.tmpl b/extensions/GuidedBugEntry/template/en/default/bug/create/comment-guided.txt.tmpl new file mode 100644 index 000000000..6b0de9466 --- /dev/null +++ b/extensions/GuidedBugEntry/template/en/default/bug/create/comment-guided.txt.tmpl @@ -0,0 +1,25 @@ +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] +User Agent: [% cgi.param('user_agent') %] +[% IF cgi.param('build_id') %] +Build ID: [% cgi.param('build_id') %][% END %] + +[% IF cgi.param('bug_steps') %] +Steps to reproduce: + +[%+ cgi.param('bug_steps') %] +[% END %] + +[% IF cgi.param('actual') %] + +Actual results: + +[%+ cgi.param('actual') %] +[% END %] + +[% IF cgi.param('expected') %] + +Expected results: + +[%+ cgi.param('expected') %] +[% END %] diff --git a/extensions/GuidedBugEntry/template/en/default/guided/guided.html.tmpl b/extensions/GuidedBugEntry/template/en/default/guided/guided.html.tmpl new file mode 100644 index 000000000..5b57a0900 --- /dev/null +++ b/extensions/GuidedBugEntry/template/en/default/guided/guided.html.tmpl @@ -0,0 +1,529 @@ +[%# 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 %] + +[% js_urls = [ 'js/yui3/yui/yui-min.js', + 'extensions/GuidedBugEntry/web/js/products.js', + 'extensions/GuidedBugEntry/web/js/guided.js', + 'extensions/ProdCompSearch/web/js/prod_comp_search.js', + 'js/field.js', 'js/TUI.js', 'js/bug.js' ] %] + +[% yui_modules = [ 'history', 'datatable', 'container' ] %] +[% yui_modules.push('autocomplete') %] + +[% PROCESS global/header.html.tmpl + title = "Enter A Bug" + javascript_urls = js_urls + style_urls = [ 'extensions/GuidedBugEntry/web/style/guided.css', + 'js/yui/assets/skins/sam/container.css' ] + yui = yui_modules +%] + +<iframe id="yui-history-iframe" src="extensions/GuidedBugEntry/web/yui-history-iframe.txt"></iframe> +<input id="yui-history-field" type="hidden"> + +<noscript> +You require JavaScript to use this [% terms.bug %] entry form.<br><br> +Please use the <a href="enter_bug.cgi?format=__default__">advanced [% terms.bug %] entry form</a>. +</noscript> + +<div id="loading" class="hidden"> +Please wait... +</div> +<script type="text/javascript"> +YAHOO.util.Dom.removeClass('loading', 'hidden'); +</script> + +<div id="steps"> +[% INCLUDE product_step %] +[% INCLUDE otherProducts_step %] +[% INCLUDE dupes_step %] +[% INCLUDE bugForm_step %] +</div> + +<div id="advanced"> + <a id="advanced_img" href="enter_bug.cgi?format=__default__"><img + src="extensions/GuidedBugEntry/web/images/advanced.png" width="16" height="16" border="0"></a> + <a id="advanced_link" href="enter_bug.cgi?format=__default__">Switch to the advanced [% terms.bug %] entry form</a> +</div> + +<script type="text/javascript"> +YAHOO.util.Dom.addClass('loading', 'hidden'); +guided.init(); +guided.detectedPlatform = '[% platform FILTER js %]'; +guided.detectedOpSys = '[% op_sys FILTER js %]'; +guided.currentUser = '[% user.login FILTER js %]'; +guided.openStates = [ +[% FOREACH state = open_states %] + '[% state FILTER js%]' + [%- "," UNLESS loop.last %] +[% END %] +]; +dupes.setLabels( + { + id: "[% field_descs.bug_id FILTER js %]", + summary: "[% field_descs.short_desc FILTER js %]", + component: "[% field_descs.component FILTER js %]", + status: "[% field_descs.bug_status FILTER js %]" + } +); +</script> +<script type="text/javascript" src="page.cgi?id=guided_products.js"></script> +[% PROCESS global/footer.html.tmpl %] + +[%############################################################################%] +[%# page title #%] +[%############################################################################%] + +[% BLOCK page_title %] + <div id="page_title"> + <h2>Enter A [% terms.Bug %]</h2> + <h3>Step [% step_number FILTER html %] of 3</h3> + </div> +[% END %] + +[%############################################################################%] +[%# product step #%] +[%############################################################################%] + +[% BLOCK product_step %] +<div id="product_step" class="step hidden"> + +[% INCLUDE page_title + step_number = "1" +%] + +[% INCLUDE exits + show = "all" +%] + +<table id="products"> +[% INCLUDE 'guided/products.html.tmpl' %] +[% INCLUDE product_block + name="Other Products" + icon="other.png" + desc="Other Mozilla products which aren't listed here" + onclick="guided.setStep('otherProducts')" +%] +</table> + +<h3> + Or search for a Product: +</h3> + +<div id="prod_comp_search_main"> + [% PROCESS prodcompsearch/form.html.tmpl + input_label = "Find product:" + format = "guided" + script_name = "enter_bug.cgi" %] +</div> + +</div> +[% END %] + +[% BLOCK product_block %] + [% IF !caption %] + [% caption = name %] + [% END %] + [% IF !desc %] + [% FOREACH c = classifications %] + [% FOREACH p = c.products %] + [% IF p.name == name %] + [% desc = p.description %] + [% LAST %] + [% END %] + [% END %] + [% END %] + [% END %] + <tr> + <td class="product_img"> + <a href="javascript:void(0)" + [% IF onclick %] + onclick="[% onclick FILTER html %]" + [% ELSE %] + onclick="product.select('[% name FILTER js %]')" + [% END %] + ><img src="extensions/BMO/web/producticons/[% icon FILTER uri %]" width="64" height="64" + ></a> + </td> + <td> + <h2> + <a href="javascript:void(0)" + [% IF onclick %] + onclick="[% onclick FILTER html %]" + [% ELSE %] + onclick="product.select('[% name FILTER js %]')" + [% END %] + >[% caption FILTER html %]</a> + </h2> + <p> + [% desc FILTER html_light %] + </p> + </td> + </tr> +[% END %] + +[%############################################################################%] +[%# other products step #%] +[%############################################################################%] + +[% BLOCK otherProducts_step %] +<div id="otherProducts_step" class="step hidden"> + +[% INCLUDE page_title + step_number = "1" +%] + +[% INCLUDE exits + show = "all" +%] + +<table id="other_products"> +[% FOREACH c = classifications %] + [% IF c.object %] + <tr class="classification"> + <th align="right" valign="top"> + [% c.object.name FILTER html %]: + </th> + <td> + [% c.object.description FILTER html_light %] + </td> + </tr> + [% END %] + [% FOREACH p = c.products %] + <tr> + <th align="right" valign="top"> + <a href="javascript:void(0)" onclick="product.select('[% p.name FILTER js %]')"> + [% p.name FILTER html FILTER no_break %]</a>: + </th> + + <td valign="top">[% p.description FILTER html_light %]</td> + </tr> + [% END %] + <tr> + <td> </td> + </tr> +[% END %] +</table> + +</div> +[% END %] + +[%############################################################################%] +[%# exits (support/input) #%] +[%############################################################################%] + +[% BLOCK exits %] +<table class="exits"> + <tr> + <td> + <div class="exit_img"> + <a href="http://www.mozilla.org/support/" + ><img src="extensions/GuidedBugEntry/web/images/support.png" width="32" height="32" + ></a> + </div> + </td> + <td class="exit_text"> + <a href="http://www.mozilla.org/support/">I need technical support</a><br> + For technical support or help getting your site to work with Mozilla. + </td> + </tr> + <tr> + <td> + <div class="exit_img"> + <a href="http://input.mozilla.org/feedback/" + ><img src="extensions/GuidedBugEntry/web/images/input.png" width="32" height="32" + ></a> + </div> + </td> + <td class="exit_text"> + <a href="http://input.mozilla.org/feedback/#sad">Offer us ideas on how to make Firefox better</a><br> + <a href="http://input.mozilla.org/feedback/">Provide feedback about Firefox</a><br> + </td> + </tr> + <tr> + <td> + <div class="exit_img"> + <img src="extensions/GuidedBugEntry/web/images/webbug.png" width="32" height="32"> + </div> + </td> + <td class="exit_text_last"> + <a href="enter_bug.cgi?format=guided&product=Core">Report an issue with Firefox on a site that I've developed</a><br> + <a href="http://input.mozilla.org/feedback/#sad">Report an issue with a web site that I use</a><br> + </td> + </tr> +</table> + +<h3> + None of the above; my [% terms.bug %] is in: +</h3> +[% END %] + +[% BLOCK exit_block %] + <tr> + <td> + <div class="exit_img"> + <a href="[% href FILTER none %]" + ><img src="extensions/GuidedBugEntry/web/images/[% icon FILTER uri %]" width="32" height="32" + ></a> + </div> + </td> + <td width="100%"> + <h2> + <a href="[% href FILTER none %]">[% name FILTER html %]</a> + </h2> + [% desc FILTER html %] + </td> + </tr> +[% END %] + +[%############################################################################%] +[%# duplicates step #%] +[%############################################################################%] + +[% BLOCK dupes_step %] +<div id="dupes_step" class="step hidden"> + +[% INCLUDE page_title + step_number = "2" +%] + +<p> +Product: <b><span id="dupes_product_name">?</span></b>: +(<a href="javascript:void(0)" onclick="guided.setStep('product')">Change</a>) +</p> + +<table border="0" cellpadding="5" cellspacing="0" id="product_support" class="hidden"> +<tr> +<td> + <img src="extensions/GuidedBugEntry/web/images/message.png" width="24" height="24"> +</td> +<td id="product_support_message"> </td> +</table> + +<div id="dupe_form"> + <p> + Please summarise your issue or request in one sentence: + </p> + <input id="dupes_summary" value="Short summary of issue" spellcheck="true" placeholder="Short summary of issue"> + <button id="dupes_search">Find similar issues</button> + <button id="dupes_continue_button_top" onclick="guided.setStep('bugForm')">My issue is not listed</button> +</div> + +<div id="dupes_list"></div> +<div id="dupes_continue"> +<button id="dupes_continue_button_bottom" onclick="guided.setStep('bugForm')">My issue is not listed</button> +</div> + +</div> +[% END %] + +[%############################################################################%] +[%# bug form step #%] +[%############################################################################%] + +[% BLOCK bugForm_step %] +<div id="bugForm_step" class="step hidden"> + +[% INCLUDE page_title + step_number = "3" +%] + +<form method="post" action="post_bug.cgi" enctype="multipart/form-data" onsubmit="return bugForm.validate()"> +<input type="hidden" name="token" value="[% token FILTER html %]"> +<input type="hidden" name="product" id="product" value=""> +<input type="hidden" name="component" id="component" value=""> +<input type="hidden" name="bug_severity" value="normal"> +<input type="hidden" name="rep_platform" id="rep_platform" value="All"> +<input type="hidden" name="priority" value="--"> +<input type="hidden" name="op_sys" id="op_sys" value="All"> +<input type="hidden" name="version" id="version" value=""> +<input type="hidden" name="comment" id="comment" value=""> +<input type="hidden" name="format" value="guided"> +<input type="hidden" name="user_agent" id="user_agent" value=""> +<input type="hidden" name="build_id" id="build_id" value=""> + +<ul> +<li>Please fill out this form clearly, precisely and in as much detail as you can manage.</li> +<li>Please report only a single problem at a time.</li> +<li><a href="https://developer.mozilla.org/en/Bug_writing_guidelines" target="_blank">These guidelines</a> +explain how to write effective [% terms.bug %] reports.</li> +</ul> + +<table id="bugForm" cellspacing="0"> + +<tr class="odd"> + <td class="label">Summary:</td> + <td width="100%" colspan="2"> + <input name="short_desc" id="short_desc" class="textInput" spellcheck="true"> + </td> + <td valign="top"> + [% PROCESS help id="summary_help" %] + <div id="summary_help" class="hidden help"> + A sentence which summarises the problem. Please be descriptive and use lots of keywords.<br> + <br> + <span class="help-bad">Bad example</span>: mail crashed<br> + <span class="help-good">Good example</span>: crash if I close the mail window while checking for new POP mail + </div> + </td> +</tr> + +<tr class="even"> + <td class="label">Product:</td> + <td id="productTD"> + <span id="product_label"></span> + (<a href="javascript:void(0)" onclick="guided.setStep('product')">Change</a>) + </td> + <td id="versionTD" class="hidden"> + <span class="label">Version: + <select id="version_select" onchange="bugForm.onVersionChange(this.value)"> + </select> + </td> + <td valign="top"> + [% PROCESS help id="product_help" %] + <div id="product_help" class="hidden help"> + The Product and Version you are reporting the issue with. + </div> +</tr> + +<tr class="odd" id="componentTR"> + <td valign="top"> + <div class="label"> + Component: + </div> + (<a id="list_comp" href="describecomponents.cgi" target="_blank" + title="Show a list of all components and descriptions (in a new window)." + >List</a>) + </td> + <td valign="top" colspan="2"> + <select id="component_select" onchange="bugForm.onComponentChange(this.value)" class="mandatory"> + </select> + <div id="component_description"></div> + </td> + <td valign="top"> + [% PROCESS help id="component_help" %] + <div id="component_help" class="hidden help"> + The area where the problem occurs.<br> + <br> + If you are unsure which component to use, select a 'General' component. + </div> +</tr> + +<tr class="even"> + <td class="label" colspan="3">What did you do? (steps to reproduce)</td> + <td valign="top"> + [% PROCESS help id="steps_help" %] + <div id="steps_help" class="hidden help"> + Please be as specific as possible about what what you did + to cause the problem. Providing step-by-step instructions + would be ideal.<br> + <br> + Include any relevant URLs and special setup steps.<br> + <br> + <span class="help-bad">Bad example</span>: Mozilla crashed. You suck!<br> + <span class="help-good">Good example</span>: After a crash which happened + when I was sorting in the Bookmark Manager, all of my top-level bookmark + folders beginning with the letters Q to Z are no longer present. + </div> + </td> +</tr> +<tr class="even"> + <td colspan="3"><textarea id="bug_steps" name="bug_steps" rows="5"></textarea></td> + <td> </td> +</tr> + +<tr class="odd"> + <td class="label" colspan="3">What happened? (actual results)</td> + <td valign="top"> + [% PROCESS help id="actual_help" %] + <div id="actual_help" class="hidden help"> + What happened after you performed the steps above? + </div> +</tr> +<tr class="odd"> + <td colspan="3"><textarea id="actual" name="actual" rows="5"></textarea></td> + <td> </td> +</tr> + +<tr class="even"> + <td class="label" colspan="3">What should have happened? (expected results)</td> + <td valign="top"> + [% PROCESS help id="expected_help" %] + <div id="expected_help" class="hidden help"> + What should the software have done instead? + </div> +</tr> +<tr class="even"> + <td colspan="3"><textarea id="expected" name="expected" rows="5"></textarea></td> + <td> </td> +</tr> + +<tr class="odd"> + <td class="label">Attach a file:</td> + <td colspan="2"> + <input type="file" name="data" id="data" size="50" onchange="bugForm.onFileChange()"> + <input type="hidden" name="contenttypemethod" value="autodetect"> + <button id="reset_data" onclick="return bugForm.onFileClear()" disabled>Clear</button> + </td> + <td valign="top"> + [% PROCESS help id="file_help" %] + <div id="file_help" class="hidden help"> + If a file helps explain the issue better, such as a screenshot, please + attach one here. + </div> + </td> +</tr> +<tr class="odd"> + <td class="label">File Description:</td> + <td colspan="2"><input type="text" name="description" id="data_description" class="textInput" disabled></td> + <td> </td> +</tr> + +<tr class="even"> + <td class="label">Security:</td> + <td colspan="2"> + <table border="0" cellpadding="0" cellspacing="0"> + <tr> + <td> + <input type="checkbox" name="groups" value="core-security" id="groups"> + </td> + <td> + <label for="groups">Many users could be harmed by this security problem: + it should be kept hidden from the public until it is resolved.</label> + </td> + </tr> + </table> + </td> + <td> </td> +</tr> + +<tr class="odd"> + <td> </td> + <td colspan="2" id="submitTD"> + <input type="submit" id="submit" value="Submit [% terms.Bug %]"> + </td> + <td> </td> +</tr> + +</table> + +</form> + +</div> +[% END %] + +[%############################################################################%] +[%# help block #%] +[%############################################################################%] + +[% BLOCK help %] +<img src="extensions/GuidedBugEntry/web/images/help.png" width="16" height="16" class="help_image" + helpid="[% id FILTER html %]" onMouseOver="bugForm.showHelp(this)" onMouseOut="bugForm.hideHelp(this)" + > +[% END %] diff --git a/extensions/GuidedBugEntry/template/en/default/guided/products.html.tmpl b/extensions/GuidedBugEntry/template/en/default/guided/products.html.tmpl new file mode 100644 index 000000000..f4e7b81ff --- /dev/null +++ b/extensions/GuidedBugEntry/template/en/default/guided/products.html.tmpl @@ -0,0 +1,50 @@ +[%# 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 product_block + name="Firefox" + icon="firefox.png" +%] +[% INCLUDE product_block + name="Firefox OS" + icon="firefox_os.png" +%] +[% INCLUDE product_block + name="Firefox for Android" + icon="firefox_android.png" +%] +[% INCLUDE product_block + name="Marketplace" + icon="marketplace.png" +%] +[% INCLUDE product_block + name="Webmaker" + icon="webmaker.png" +%] +[% INCLUDE product_block + name="Thunderbird" + icon="thunderbird.png" +%] +[% INCLUDE product_block + name="SeaMonkey" + icon="seamonkey.png" +%] +[% INCLUDE product_block + name="Core" + icon="component.png" +%] +[% INCLUDE product_block + name="Mozilla Localizations" + icon="localization.png" + caption="Localizations" +%] +[% INCLUDE product_block + name="Mozilla Services" + icon="sync.png" + caption="Services" +%] diff --git a/extensions/GuidedBugEntry/template/en/default/pages/guided_products.js.tmpl b/extensions/GuidedBugEntry/template/en/default/pages/guided_products.js.tmpl new file mode 100644 index 000000000..b58df8298 --- /dev/null +++ b/extensions/GuidedBugEntry/template/en/default/pages/guided_products.js.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. + #%] + +[%# this file allows us to pull in data defined in the BMO ext %] + +[% IF create_bug_formats %] + [% FOREACH product = create_bug_formats %] + if (!products['[% product.key FILTER js %]']) [% ~%] + products['[% product.key FILTER js %]'] = {}; + products['[% product.key FILTER js %]'].format = '[% product.value FILTER js %]'; + [% END %] +[% END %] + +[% IF product_sec_groups %] + [% FOREACH product = product_sec_groups %] + if (!products['[% product.key FILTER js %]']) [% ~%] + products['[% product.key FILTER js %]'] = {}; + products['[% product.key FILTER js %]'].secgroup = '[% product.value FILTER js %]'; + [% END %] +[% END %] + diff --git a/extensions/GuidedBugEntry/web/images/advanced.png b/extensions/GuidedBugEntry/web/images/advanced.png Binary files differnew file mode 100644 index 000000000..71a3fcb78 --- /dev/null +++ b/extensions/GuidedBugEntry/web/images/advanced.png diff --git a/extensions/GuidedBugEntry/web/images/help.png b/extensions/GuidedBugEntry/web/images/help.png Binary files differnew file mode 100644 index 000000000..5c870176d --- /dev/null +++ b/extensions/GuidedBugEntry/web/images/help.png diff --git a/extensions/GuidedBugEntry/web/images/input.png b/extensions/GuidedBugEntry/web/images/input.png Binary files differnew file mode 100644 index 000000000..34c10e989 --- /dev/null +++ b/extensions/GuidedBugEntry/web/images/input.png diff --git a/extensions/GuidedBugEntry/web/images/message.png b/extensions/GuidedBugEntry/web/images/message.png Binary files differnew file mode 100644 index 000000000..55b6add19 --- /dev/null +++ b/extensions/GuidedBugEntry/web/images/message.png diff --git a/extensions/GuidedBugEntry/web/images/sumo.png b/extensions/GuidedBugEntry/web/images/sumo.png Binary files differnew file mode 100644 index 000000000..d5773647c --- /dev/null +++ b/extensions/GuidedBugEntry/web/images/sumo.png diff --git a/extensions/GuidedBugEntry/web/images/support.png b/extensions/GuidedBugEntry/web/images/support.png Binary files differnew file mode 100644 index 000000000..2320ea74a --- /dev/null +++ b/extensions/GuidedBugEntry/web/images/support.png diff --git a/extensions/GuidedBugEntry/web/images/throbber.gif b/extensions/GuidedBugEntry/web/images/throbber.gif Binary files differnew file mode 100644 index 000000000..bc4fa6561 --- /dev/null +++ b/extensions/GuidedBugEntry/web/images/throbber.gif diff --git a/extensions/GuidedBugEntry/web/images/warning.png b/extensions/GuidedBugEntry/web/images/warning.png Binary files differnew file mode 100644 index 000000000..86bed170d --- /dev/null +++ b/extensions/GuidedBugEntry/web/images/warning.png diff --git a/extensions/GuidedBugEntry/web/images/webbug.png b/extensions/GuidedBugEntry/web/images/webbug.png Binary files differnew file mode 100644 index 000000000..949cfbc59 --- /dev/null +++ b/extensions/GuidedBugEntry/web/images/webbug.png diff --git a/extensions/GuidedBugEntry/web/js/guided.js b/extensions/GuidedBugEntry/web/js/guided.js new file mode 100644 index 000000000..a3888783b --- /dev/null +++ b/extensions/GuidedBugEntry/web/js/guided.js @@ -0,0 +1,927 @@ +/* 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. */ + +// global + +var Dom = YAHOO.util.Dom; +var Event = YAHOO.util.Event; +var History = YAHOO.util.History; + +var guided = { + _currentStep: '', + detectedPlatform: '', + detectedOpSys: '', + currentUser: '', + openStates: [], + updateStep: true, + + setStep: function(newStep, noSetHistory) { + // initialise new step + this.updateStep = true; + switch(newStep) { + case 'product': + product.onShow(); + break; + case 'otherProducts': + otherProducts.onShow(); + break; + case 'dupes': + dupes.onShow(); + break; + case 'bugForm': + bugForm.onShow(); + break; + default: + guided.setStep('product'); + return; + } + + if (!this.updateStep) + return; + + // change visibility of _step div + if (this._currentStep) + Dom.addClass(this._currentStep + '_step', 'hidden'); + this._currentStep = newStep; + Dom.removeClass(this._currentStep + '_step', 'hidden'); + + // scroll to top of page to mimic real navigation + scroll(0,0); + + // update history + if (History && !noSetHistory) { + History.navigate('h', newStep + '|' + product.getName() + + (product.getPreselectedComponent() ? '|' + product.getPreselectedComponent() : '') + ); + } + }, + + init: function() { + // init history manager + try { + History.register('h', History.getBookmarkedState('h') || 'product', + this._onStateChange); + History.initialize("yui-history-field", "yui-history-iframe"); + History.onReady(function () { + guided._onStateChange(History.getCurrentState('h'), true); + }); + } catch(err) { + History = false; + } + + // init steps + product.onInit(); + dupes.onInit(); + bugForm.onInit(); + }, + + _onStateChange: function(state, noSetHistory) { + state = state.split('|'); + product.setName(state[1] || ''); + product.setPreselectedComponent(state[2] || ''); + guided.setStep(state[0], noSetHistory); + }, + + setAdvancedLink: function() { + href = 'enter_bug.cgi?format=__default__' + + '&product=' + encodeURIComponent(product.getName()) + + '&short_desc=' + encodeURIComponent(dupes.getSummary()); + Dom.get('advanced_img').href = href; + Dom.get('advanced_link').href = href; + } +}; + +// product step + +var product = { + details: false, + _counter: 0, + _loaded: '', + _preselectedComponent: '', + + onInit: function() { }, + + onShow: function() { + Dom.removeClass('advanced', 'hidden'); + }, + + select: function(productName) { + // called when a product is selected + this.setName(productName); + dupes.reset(); + guided.setStep('dupes'); + }, + + getName: function() { + return Dom.get('product').value; + }, + + getPreselectedComponent: function() { + return this._preselectedComponent; + }, + + setPreselectedComponent: function(value) { + this._preselectedComponent = value; + }, + + _getNameAndRelated: function() { + var result = []; + + var name = this.getName(); + result.push(name); + + if (products[name] && products[name].related) { + for (var i = 0, n = products[name].related.length; i < n; i++) { + result.push(products[name].related[i]); + } + } + + return result; + }, + + setName: function(productName) { + if (productName == this.getName() && this.details) + return; + + // display the product name + Dom.get('product').value = productName; + Dom.get('product_label').innerHTML = YAHOO.lang.escapeHTML(productName); + Dom.get('dupes_product_name').innerHTML = YAHOO.lang.escapeHTML(productName); + Dom.get('list_comp').href = 'describecomponents.cgi?product=' + encodeURIComponent(productName); + guided.setAdvancedLink(); + + if (productName == '') { + Dom.addClass("product_support", "hidden"); + return; + } + + // use the correct security group + if (products[productName] && products[productName].secgroup) { + Dom.get('groups').value = products[productName].secgroup; + } else { + Dom.get('groups').value = products['_default'].secgroup; + } + + // use the correct platform & op_sys + if (products[productName] && products[productName].detectPlatform) { + Dom.get('rep_platform').value = guided.detectedPlatform; + Dom.get('op_sys').value = guided.detectedOpSys; + } else { + Dom.get('rep_platform').value = 'All'; + Dom.get('op_sys').value = 'All'; + } + + // show support message + if (products[productName] && products[productName].support) { + Dom.get("product_support_message").innerHTML = products[productName].support; + Dom.removeClass("product_support", "hidden"); + } else { + Dom.addClass("product_support", "hidden"); + } + + // show/hide component selection row + if (products[productName] && products[productName].noComponentSelection) { + if (!Dom.hasClass('componentTR', 'hidden')) { + Dom.addClass('componentTR', 'hidden'); + bugForm.toggleOddEven(); + } + } else { + if (Dom.hasClass('componentTR', 'hidden')) { + Dom.removeClass('componentTR', 'hidden'); + bugForm.toggleOddEven(); + } + } + + if (this._loaded == productName) + return; + + // grab the product information + this.details = false; + this._loaded = productName; + YAHOO.util.Connect.setDefaultPostHeader('application/json; charset=UTF-8'); + YAHOO.util.Connect.asyncRequest( + 'POST', + 'jsonrpc.cgi', + { + success: function(res) { + try { + data = YAHOO.lang.JSON.parse(res.responseText); + if (data.error) + throw(data.error.message); + if (data.result.products.length == 0) + document.location.href = 'enter_bug.cgi?format=guided'; + product.details = data.result.products[0]; + bugForm.onProductUpdated(); + } catch (err) { + product.details = false; + bugForm.onProductUpdated(); + if (err) { + alert('Failed to retreive components for product "' + + productName + '":' + "\n\n" + err); + if (console) + console.error(err); + } + } + }, + failure: function(res) { + this._loaded = ''; + product.details = false; + bugForm.onProductUpdated(); + if (res.responseText) { + alert('Failed to retreive components for product "' + + productName + '":' + "\n\n" + res.responseText); + if (console) + console.error(res); + } + } + }, + YAHOO.lang.JSON.stringify({ + version: "1.1", + method: "Product.get", + id: ++this._counter, + params: { + names: [productName], + exclude_fields: ['internals', 'milestones'] + } + } + ) + ); + } +}; + +// other products step + +var otherProducts = { + onInit: function() { }, + + onShow: function() { + Dom.removeClass('advanced', 'hidden'); + } +}; + +// duplicates step + +var dupes = { + _counter: 0, + _dataTable: null, + _dataTableColumns: null, + _elSummary: null, + _elSearch: null, + _elList: null, + _currentSearchQuery: '', + + onInit: function() { + this._elSummary = Dom.get('dupes_summary'); + this._elSearch = Dom.get('dupes_search'); + this._elList = Dom.get('dupes_list'); + + Event.onBlur(this._elSummary, this._onSummaryBlur); + Event.addListener(this._elSummary, 'input', this._onSummaryBlur); + Event.addListener(this._elSummary, 'keydown', this._onSummaryKeyDown); + Event.addListener(this._elSummary, 'keyup', this._onSummaryKeyUp); + Event.addListener(this._elSearch, 'click', this._doSearch); + }, + + setLabels: function(labels) { + this._dataTableColumns = [ + { key: "id", label: labels.id, formatter: this._formatId }, + { key: "summary", label: labels.summary, formatter: "text" }, + { key: "component", label: labels.component, formatter: "text" }, + { key: "status", label: labels.status, formatter: this._formatStatus }, + { key: "update_token", label: '', formatter: this._formatCc } + ]; + }, + + _initDataTable: function() { + var dataSource = new YAHOO.util.XHRDataSource("jsonrpc.cgi"); + dataSource.connTimeout = 15000; + dataSource.connMethodPost = true; + dataSource.connXhrMode = "cancelStaleRequests"; + dataSource.maxCacheEntries = 3; + dataSource.responseSchema = { + resultsList : "result.bugs", + metaFields : { error: "error", jsonRpcId: "id" } + }; + // DataSource can't understand a JSON-RPC error response, so + // we have to modify the result data if we get one. + dataSource.doBeforeParseData = + function(oRequest, oFullResponse, oCallback) { + if (oFullResponse.error) { + oFullResponse.result = {}; + oFullResponse.result.bugs = []; + if (console) + console.error("JSON-RPC error:", oFullResponse.error); + } + return oFullResponse; + }; + dataSource.subscribe('dataErrorEvent', + function() { + dupes._currentSearchQuery = ''; + } + ); + + this._dataTable = new YAHOO.widget.DataTable( + 'dupes_list', + this._dataTableColumns, + dataSource, + { + initialLoad: false, + MSG_EMPTY: 'No similar issues found.', + MSG_ERROR: 'An error occurred while searching for similar issues,' + + ' please try again.' + } + ); + }, + + _formatId: function(el, oRecord, oColumn, oData) { + el.innerHTML = '<a href="show_bug.cgi?id=' + oData + + '" target="_blank">' + oData + '</a>'; + }, + + _formatStatus: function(el, oRecord, oColumn, oData) { + var resolution = oRecord.getData('resolution'); + var bug_status = display_value('bug_status', oData); + if (resolution) { + el.innerHTML = bug_status + ' ' + + display_value('resolution', resolution); + } else { + el.innerHTML = bug_status; + } + }, + + _formatCc: function(el, oRecord, oColumn, oData) { + var cc = oRecord.getData('cc'); + var isCCed = false; + for (var i = 0, n = cc.length; i < n; i++) { + if (cc[i] == guided.currentUser) { + isCCed = true; + break; + } + } + dupes._buildCcHTML(el, oRecord.getData('id'), oRecord.getData('status'), + isCCed); + }, + + _buildCcHTML: function(el, id, bugStatus, isCCed) { + while (el.childNodes.length > 0) + el.removeChild(el.firstChild); + + var isOpen = false; + for (var i = 0, n = guided.openStates.length; i < n; i++) { + if (guided.openStates[i] == bugStatus) { + isOpen = true; + break; + } + } + + if (!isOpen && !isCCed) { + // you can't cc yourself to a closed bug here + return; + } + + var button = document.createElement('button'); + button.setAttribute('type', 'button'); + if (isCCed) { + button.innerHTML = 'Stop following'; + button.onclick = function() { + dupes.updateFollowing(el, id, bugStatus, button, false); return false; + }; + } else { + button.innerHTML = 'Follow bug'; + button.onclick = function() { + dupes.updateFollowing(el, id, bugStatus, button, true); return false; + }; + } + el.appendChild(button); + }, + + updateFollowing: function(el, bugID, bugStatus, button, follow) { + button.disabled = true; + button.innerHTML = 'Updating...'; + + var ccObject; + if (follow) { + ccObject = { add: [ guided.currentUser ] }; + } else { + ccObject = { remove: [ guided.currentUser ] }; + } + + YAHOO.util.Connect.setDefaultPostHeader('application/json; charset=UTF-8'); + YAHOO.util.Connect.asyncRequest( + 'POST', + 'jsonrpc.cgi', + { + success: function(res) { + data = YAHOO.lang.JSON.parse(res.responseText); + if (data.error) + throw(data.error.message); + dupes._buildCcHTML(el, bugID, bugStatus, follow); + }, + failure: function(res) { + dupes._buildCcHTML(el, bugID, bugStatus, !follow); + if (res.responseText) + alert("Update failed:\n\n" + res.responseText); + } + }, + YAHOO.lang.JSON.stringify({ + version: "1.1", + method: "Bug.update", + id: ++this._counter, + params: { + ids: [ bugID ], + cc : ccObject + } + }) + ); + }, + + reset: function() { + this._elSummary.value = ''; + Dom.addClass(this._elList, 'hidden'); + Dom.addClass('dupes_continue', 'hidden'); + this._elList.innerHTML = ''; + this._showProductSupport(); + this._currentSearchQuery = ''; + }, + + _showProductSupport: function() { + var elSupport = Dom.get('product_support_' + + product.getName().replace(' ', '_').toLowerCase()); + var supportElements = Dom.getElementsByClassName('product_support'); + for (var i = 0, n = supportElements.length; i < n; i++) { + if (supportElements[i] == elSupport) { + Dom.removeClass(elSupport, 'hidden'); + } else { + Dom.addClass(supportElements[i], 'hidden'); + } + } + }, + + onShow: function() { + this._showProductSupport(); + this._onSummaryBlur(); + + // hide the advanced form and top continue button entry until + // a search has happened + Dom.addClass('advanced', 'hidden'); + Dom.addClass('dupes_continue_button_top', 'hidden'); + + if (!this._elSearch.disabled && this.getSummary().length >= 4) { + // do an immediate search after a page refresh if there's a query + this._doSearch(); + + } else { + // prepare for a search + this.reset(); + } + }, + + _onSummaryBlur: function() { + dupes._elSearch.disabled = dupes._elSummary.value == ''; + guided.setAdvancedLink(); + }, + + _onSummaryKeyDown: function(e) { + // map <enter> to doSearch() + if (e && (e.keyCode == 13)) { + dupes._doSearch(); + Event.stopPropagation(e); + } + }, + + _onSummaryKeyUp: function(e) { + // disable search button until there's a query + dupes._elSearch.disabled = YAHOO.lang.trim(dupes._elSummary.value) == ''; + }, + + _doSearch: function() { + if (dupes.getSummary().length < 4) { + alert('The summary must be at least 4 characters long.'); + return; + } + dupes._elSummary.blur(); + + // don't query if we already have the results (or they are pending) + if (dupes._currentSearchQuery == dupes.getSummary()) + return; + dupes._currentSearchQuery = dupes.getSummary(); + + // initialise the datatable as late as possible + dupes._initDataTable(); + + try { + // run the search + Dom.removeClass(dupes._elList, 'hidden'); + + dupes._dataTable.showTableMessage( + 'Searching for similar issues... ' + + '<img src="extensions/GuidedBugEntry/web/images/throbber.gif"' + + ' width="16" height="11">', + YAHOO.widget.DataTable.CLASS_LOADING + ); + var json_object = { + version: "1.1", + method: "Bug.possible_duplicates", + id: ++dupes._counter, + params: { + product: product._getNameAndRelated(), + summary: dupes.getSummary(), + limit: 12, + include_fields: [ "id", "summary", "status", "resolution", + "update_token", "cc", "component" ] + } + }; + + dupes._dataTable.getDataSource().sendRequest( + YAHOO.lang.JSON.stringify(json_object), + { + success: dupes._onDupeResults, + failure: dupes._onDupeResults, + scope: dupes._dataTable, + argument: dupes._dataTable.getState() + } + ); + + Dom.get('dupes_continue_button_top').disabled = true; + Dom.get('dupes_continue_button_bottom').disabled = true; + Dom.removeClass('dupes_continue', 'hidden'); + } catch(err) { + if (console) + console.error(err.message); + } + }, + + _onDupeResults: function(sRequest, oResponse, oPayload) { + Dom.removeClass('advanced', 'hidden'); + Dom.removeClass('dupes_continue_button_top', 'hidden'); + Dom.get('dupes_continue_button_top').disabled = false; + Dom.get('dupes_continue_button_bottom').disabled = false; + dupes._dataTable.onDataReturnInitializeTable(sRequest, oResponse, + oPayload); + }, + + getSummary: function() { + var summary = YAHOO.lang.trim(this._elSummary.value); + // work around chrome bug + if (summary == dupes._elSummary.getAttribute('placeholder')) { + return ''; + } else { + return summary; + } + } +}; + +// bug form step + +var bugForm = { + _visibleHelpPanel: null, + _mandatoryFields: [], + + onInit: function() { + var user_agent = navigator.userAgent; + Dom.get('user_agent').value = navigator.userAgent; + if (navigator.buildID && navigator.buildID != navigator.userAgent) { + Dom.get('build_id').value = navigator.buildID; + } + Event.addListener(Dom.get('short_desc'), 'blur', function() { + Dom.get('dupes_summary').value = Dom.get('short_desc').value; + guided.setAdvancedLink(); + }); + }, + + onShow: function() { + // check for a forced format + var productName = product.getName(); + if (products[productName] && products[productName].format) { + Dom.addClass('advanced', 'hidden'); + document.location.href = 'enter_bug.cgi?format=' + encodeURIComponent(products[productName].format) + + '&product=' + encodeURIComponent(productName) + + '&short_desc=' + encodeURIComponent(dupes.getSummary()); + guided.updateStep = false; + return; + } + Dom.removeClass('advanced', 'hidden'); + // default the summary to the dupes query + Dom.get('short_desc').value = dupes.getSummary(); + this.resetSubmitButton(); + if (Dom.get('component_select').length == 0) + this.onProductUpdated(); + this.onFileChange(); + for (var i = 0, n = this._mandatoryFields.length; i < n; i++) { + Dom.removeClass(this._mandatoryFields[i], 'missing'); + } + }, + + resetSubmitButton: function() { + Dom.get('submit').disabled = false; + Dom.get('submit').value = 'Submit Bug'; + }, + + onProductUpdated: function() { + var productName = product.getName(); + + // init + var elComponents = Dom.get('component_select'); + Dom.addClass('component_description', 'hidden'); + elComponents.options.length = 0; + + var elVersions = Dom.get('version_select'); + elVersions.length = 0; + + // product not loaded yet, bail out + if (!product.details) { + Dom.addClass('versionTH', 'hidden'); + Dom.addClass('versionTD', 'hidden'); + Dom.get('productTD').colSpan = 2; + Dom.get('submit').disabled = true; + return; + } + Dom.get('submit').disabled = false; + + // filter components + if (products[productName] && products[productName].componentFilter) { + product.details.components = products[productName].componentFilter(product.details.components); + } + + // build components + + var elComponent = Dom.get('component'); + if (products[productName] && products[productName].noComponentSelection) { + + elComponent.value = products[productName].defaultComponent; + bugForm._mandatoryFields = [ 'short_desc', 'version_select' ]; + + } else { + + bugForm._mandatoryFields = [ 'short_desc', 'component_select', 'version_select' ]; + + // check for the default component + var defaultRegex; + if (product.getPreselectedComponent()) { + defaultRegex = new RegExp('^' + quoteMeta(product.getPreselectedComponent()) + '$', 'i') + } else if(products[productName] && products[productName].defaultComponent) { + defaultRegex = new RegExp('^' + quoteMeta(products[productName].defaultComponent) + '$', 'i') + } else { + defaultRegex = new RegExp('General', 'i'); + } + + var preselectedComponent = false; + for (var i = 0, n = product.details.components.length; i < n; i++) { + var component = product.details.components[i]; + if (component.is_active == '1') { + if (defaultRegex.test(component.name)) { + preselectedComponent = component.name; + break; + } + } + } + + // if there isn't a default component, default to blank + if (!preselectedComponent) { + elComponents.options[elComponents.options.length] = new Option('', ''); + } + + // build component select + for (var i = 0, n = product.details.components.length; i < n; i++) { + var component = product.details.components[i]; + if (component.is_active == '1') { + elComponents.options[elComponents.options.length] = + new Option(component.name, component.name); + } + } + + var validComponent = false; + for (var i = 0, n = elComponents.options.length; i < n && !validComponent; i++) { + if (elComponents.options[i].value == elComponent.value) + validComponent = true; + } + if (!validComponent) + elComponent.value = ''; + if (elComponent.value == '' && preselectedComponent) + elComponent.value = preselectedComponent; + if (elComponent.value != '') { + elComponents.value = elComponent.value; + this.onComponentChange(elComponent.value); + } + + } + + // build versions + var defaultVersion = ''; + var currentVersion = Dom.get('version').value; + for (var i = 0, n = product.details.versions.length; i < n; i++) { + var version = product.details.versions[i]; + if (version.is_active == '1') { + elVersions.options[elVersions.options.length] = + new Option(version.name, version.name); + if (currentVersion == version.name) + defaultVersion = version.name; + } + } + + if (!defaultVersion) { + // try to detect version on a per-product basis + if (products[productName] && products[productName].version) { + var detectedVersion = products[productName].version(); + var options = elVersions.options; + for (var i = 0, n = options.length; i < n; i++) { + if (options[i].value == detectedVersion) { + defaultVersion = detectedVersion; + break; + } + } + } + } + if (!defaultVersion) { + // load last selected version + defaultVersion = YAHOO.util.Cookie.get('VERSION-' + productName); + } + + if (elVersions.length > 1) { + // more than one version, show select + Dom.get('productTD').colSpan = 1; + Dom.removeClass('versionTH', 'hidden'); + Dom.removeClass('versionTD', 'hidden'); + + } else { + // if there's only one version, we don't need to ask the user + Dom.addClass('versionTH', 'hidden'); + Dom.addClass('versionTD', 'hidden'); + Dom.get('productTD').colSpan = 2; + defaultVersion = elVersions.options[0].value; + } + + if (defaultVersion) { + elVersions.value = defaultVersion; + + } else { + // no default version, select an empty value to force a decision + var opt = new Option('', ''); + try { + // standards + elVersions.add(opt, elVersions.options[0]); + } catch(ex) { + // ie only + elVersions.add(opt, 0); + } + elVersions.value = ''; + } + bugForm.onVersionChange(elVersions.value); + }, + + onComponentChange: function(componentName) { + // show the component description + Dom.get('component').value = componentName; + var elComponentDesc = Dom.get('component_description'); + elComponentDesc.innerHTML = ''; + for (var i = 0, n = product.details.components.length; i < n; i++) { + var component = product.details.components[i]; + if (component.name == componentName) { + elComponentDesc.innerHTML = component.description; + break; + } + } + Dom.removeClass(elComponentDesc, 'hidden'); + }, + + onVersionChange: function(version) { + Dom.get('version').value = version; + }, + + onFileChange: function() { + // toggle ui enabled when a file is uploaded or cleared + var elFile = Dom.get('data'); + var elReset = Dom.get('reset_data'); + var elDescription = Dom.get('data_description'); + var filename = bugForm._getFilename(); + if (filename) { + elReset.disabled = false; + elDescription.value = filename; + elDescription.disabled = false; + } else { + elReset.disabled = true; + elDescription.value = ''; + elDescription.disabled = true; + } + }, + + onFileClear: function() { + Dom.get('data').value = ''; + this.onFileChange(); + return false; + }, + + toggleOddEven: function() { + var rows = Dom.get('bugForm').getElementsByTagName('TR'); + var doToggle = false; + for (var i = 0, n = rows.length; i < n; i++) { + if (doToggle) { + rows[i].className = rows[i].className == 'odd' ? 'even' : 'odd'; + } else { + doToggle = rows[i].id == 'componentTR'; + } + } + }, + + _getFilename: function() { + var filename = Dom.get('data').value; + if (!filename) + return ''; + filename = filename.replace(/^.+[\\\/]/, ''); + return filename; + }, + + _mandatoryMissing: function() { + var result = new Array(); + for (var i = 0, n = this._mandatoryFields.length; i < n; i++ ) { + id = this._mandatoryFields[i]; + el = Dom.get(id); + + if (el.type.toString() == "checkbox") { + value = el.checked; + } else { + value = el.value.replace(/^\s\s*/, '').replace(/\s\s*$/, ''); + el.value = value; + } + + if (value == '') { + Dom.addClass(id, 'missing'); + result.push(id); + } else { + Dom.removeClass(id, 'missing'); + } + } + return result; + }, + + validate: function() { + + // check mandatory fields + + var missing = bugForm._mandatoryMissing(); + if (missing.length) { + var message = 'The following field' + + (missing.length == 1 ? ' is' : 's are') + ' required:\n\n'; + for (var i = 0, n = missing.length; i < n; i++ ) { + var id = missing[i]; + if (id == 'short_desc') message += ' Summary\n'; + if (id == 'component_select') message += ' Component\n'; + if (id == 'version_select') message += ' Version\n'; + } + alert(message); + return false; + } + + if (Dom.get('data').value && !Dom.get('data_description').value) + Dom.get('data_description').value = bugForm._getFilename(); + + Dom.get('submit').disabled = true; + Dom.get('submit').value = 'Submitting Bug...'; + + return true; + }, + + _initHelp: function(el) { + var help_id = el.getAttribute('helpid'); + if (!el.panel) { + if (!el.id) + el.id = help_id + '_parent'; + el.panel = new YAHOO.widget.Panel( + help_id, + { + width: "320px", + visible: false, + close: false, + context: [el.id, 'tl', 'tr', null, [5, 0]] + } + ); + el.panel.render(); + Dom.removeClass(help_id, 'hidden'); + } + }, + + showHelp: function(el) { + this._initHelp(el); + if (this._visibleHelpPanel) + this._visibleHelpPanel.hide(); + el.panel.show(); + this._visibleHelpPanel = el.panel; + }, + + hideHelp: function(el) { + if (!el.panel) + return; + if (this._visibleHelpPanel) + this._visibleHelpPanel.hide(); + el.panel.hide(); + this._visibleHelpPanel = null; + } +} + +function quoteMeta(value) { + return value.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); +} diff --git a/extensions/GuidedBugEntry/web/js/products.js b/extensions/GuidedBugEntry/web/js/products.js new file mode 100644 index 000000000..dfc830d0f --- /dev/null +++ b/extensions/GuidedBugEntry/web/js/products.js @@ -0,0 +1,118 @@ +/* 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. */ + +/* Product-specifc configuration for guided bug entry + * + * related: array of product names which will also be searched for duplicates + * version: function which returns a version (eg. detected from UserAgent) + * support: string which is displayed at the top of the duplicates page + * secgroup: the group to place confidential bugs into + * defaultComponent: the default compoent to select. Defaults to 'General' + * noComponentSelection: when true, the default component will always be + * used. Defaults to 'false'; + * detectPlatform: when true the platform and op_sys will be set from the + * browser's user agent. when false, these will be set to All + */ + +var products = { + + "Firefox": { + related: [ "Core", "Toolkit" ], + version: function() { + var re = /Firefox\/(\d+)\.(\d+)/i; + var match = re.exec(navigator.userAgent); + if (match) { + var maj = match[1]; + var min = match[2]; + if (maj * 1 >= 5) { + return maj + " Branch"; + } else { + return maj + "." + min + " Branch"; + } + } else { + return false; + } + }, + defaultComponent: "Untriaged", + noComponentSelection: true, + detectPlatform: true, + support: + 'If you are new to Firefox or Bugzilla, please consider checking ' + + '<a href="http://support.mozilla.com/">' + + '<img src="extensions/GuidedBugEntry/web/images/sumo.png" width="16" height="16" align="absmiddle">' + + ' <b>Firefox Help</b></a> instead of creating a bug.' + }, + + "Firefox for Android": { + related: [ "Core", "Toolkit" ], + detectPlatform: true, + support: + 'If you are new to Firefox or Bugzilla, please consider checking ' + + '<a href="http://support.mozilla.com/">' + + '<img src="extensions/GuidedBugEntry/web/images/sumo.png" width="16" height="16" align="absmiddle">' + + ' <b>Firefox Help</b></a> instead of creating a bug.' + }, + + "SeaMonkey": { + related: [ "Core", "Toolkit", "MailNews Core" ], + detectPlatform: true, + version: function() { + var re = /SeaMonkey\/(\d+)\.(\d+)/i; + var match = re.exec(navigator.userAgent); + if (match) { + var maj = match[1]; + var min = match[2]; + return "SeaMonkey " + maj + "." + min + " Branch"; + } else { + return false; + } + } + }, + + "Camino": { + related: [ "Core", "Toolkit" ], + detectPlatform: true + }, + + "Core": { + detectPlatform: true + }, + + "Thunderbird": { + related: [ "Core", "Toolkit", "MailNews Core" ], + detectPlatform: true, + defaultComponent: "Untriaged", + componentFilter : function(components) { + var index = -1; + for (var i = 0, l = components.length; i < l; i++) { + if (components[i].name == 'General') { + index = i; + break; + } + } + if (index != -1) { + components.splice(index, 1); + } + return components; + } + }, + + "Penelope": { + related: [ "Core", "Toolkit", "MailNews Core" ] + }, + + "Bugzilla": { + support: + 'Please use <a href="http://landfill.bugzilla.org/">Bugzilla Landfill</a> to file "test bugs".' + }, + + "bugzilla.mozilla.org": { + related: [ "Bugzilla" ], + support: + 'Please use <a href="http://landfill.bugzilla.org/">Bugzilla Landfill</a> to file "test bugs".' + } +} diff --git a/extensions/GuidedBugEntry/web/style/guided.css b/extensions/GuidedBugEntry/web/style/guided.css new file mode 100644 index 000000000..f06715eab --- /dev/null +++ b/extensions/GuidedBugEntry/web/style/guided.css @@ -0,0 +1,237 @@ +/* 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. */ + +/* global */ + +#page_title { +} + +#page_title h2 { + margin-bottom: 0px; +} + +#page_title h3 { + margin-top: 0px; +} + +.hidden { + display: none; +} + +#yui-history-iframe { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; + visibility: hidden; +} + +.step { + margin-left: 20px; + margin-bottom: 25px; +} + +#steps a img { + border: none; +} + +#advanced { + margin-top: 50px; +} + +#advanced img { + vertical-align: middle; +} + +#advanced a { + cursor: pointer; +} + +/* remove the shaded background from data_table header + it looks out of place */ +.yui-skin-sam .yui-dt th { + background: #f0f0f0; +} + +/* products and other_products step */ + +.exits { + width: 600px; + margin-bottom: 10px; + border: 1px solid #aaa; + border-radius: 5px; +} + +.exits td { + padding: 5px; +} + +.exits h2 { + margin: 0px; + font-size: 90%; +} + +.exit_img { + width: 64px; + text-align: right; +} + +.exit_text, .exit_text_last { + width: 100%; +} + +.exit_text { + border-bottom: 1px dotted silver; +} + +#prod_comp_search_main { + width: 400px; +} + +#prod_comp_search_label { + margin-bottom: 1px; +} + +#prod_comp_search_main li.yui-ac-highlight a { + text-decoration: none; + color: #FFFFFF; + display: block; +} + +#products { + width: 600px; +} + +#products td { + padding: 5px; + padding-bottom: 10px; +} + +#products h2 { + margin-bottom: 0px; +} + +#products p { + margin-top: 0px; +} + +.product_img { + width: 64px; +} + +#other_products .classification { + font-weight: bold; +} + +#other_products .classification th { + font-size: large; +} + +/* duplicates step */ + +#dupes_summary { + width: 500px; +} + +#dupes_list { + margin-top: 1em; + margin-bottom: 1em; +} + +#product_support { + border: 1px solid #dddddd; +} + +/* bug form step */ + +#bugForm { + width: 600px; + border: 4px solid #e0e0e0; + -moz-border-radius: 5px; + border-radius: 5px; +} + +#bugForm th, #bugForm td { + padding: 5px; +} + +#bugForm .even th, #bugForm .even td { + background: #e0e0e0; +} + +#bugForm .label { + text-align: left; + font-weight: bold; + white-space: nowrap +} + +#bugzilla-body #bugForm th { + vertical-align: middle; +} + +#bugForm .textInput { + width: 450px; +} + +#bugForm textarea { + font-family: Verdana, sans-serif; + font-size: small; + width: 590px; +} + +#bugForm .mandatory_mark { + color: red; + font-size: 80%; +} + +#bugForm .mandatory { +} + +#bugForm .textInput[disabled] { + background: transparent; + border: 1px solid #dddddd; +} + +#versionTD { + text-align: right; + white-space: nowrap +} + +#component_select { + width: 450px; +} + +#component_description { + padding: 5px; +} + +#bugForm .missing { + border: 1px solid red; + box-shadow: 0px 0px 4px #ff0000; + -webkit-box-shadow: 0px 0px 4px #ff0000; + -moz-box-shadow: 0px 0px 4px #ff0000; +} + +#submitTD { + text-align: right; +} + +.help { + position: absolute; + background: #ffffff; + padding: 2px; + cursor: default; +} + +.help-bad { + color: #990000; +} + +.help-good { + color: #009900; +} diff --git a/extensions/GuidedBugEntry/web/yui-history-iframe.txt b/extensions/GuidedBugEntry/web/yui-history-iframe.txt new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/extensions/GuidedBugEntry/web/yui-history-iframe.txt diff --git a/extensions/InlineHistory/Config.pm b/extensions/InlineHistory/Config.pm new file mode 100644 index 000000000..3834bd81d --- /dev/null +++ b/extensions/InlineHistory/Config.pm @@ -0,0 +1,13 @@ +# 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::InlineHistory; +use strict; + +use constant NAME => 'InlineHistory'; + +__PACKAGE__->NAME; diff --git a/extensions/InlineHistory/Extension.pm b/extensions/InlineHistory/Extension.pm new file mode 100644 index 000000000..803262517 --- /dev/null +++ b/extensions/InlineHistory/Extension.pm @@ -0,0 +1,215 @@ +# 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::InlineHistory; +use strict; +use base qw(Bugzilla::Extension); + +use Bugzilla::User::Setting; +use Bugzilla::Constants; +use Bugzilla::Attachment; + +our $VERSION = '1.5'; + +# don't show inline history for bugs with lots of changes +use constant MAXIMUM_ACTIVITY_COUNT => 500; + +# don't show really long values +use constant MAXIMUM_VALUE_LENGTH => 256; + +sub template_before_create { + my ($self, $args) = @_; + $args->{config}->{FILTERS}->{ih_short_value} = sub { + my ($str) = @_; + return length($str) <= MAXIMUM_VALUE_LENGTH + ? $str + : substr($str, 0, MAXIMUM_VALUE_LENGTH - 3) . '...'; + }; +} + +sub template_before_process { + my ($self, $args) = @_; + my $file = $args->{'file'}; + my $vars = $args->{'vars'}; + + return if $file ne 'bug/edit.html.tmpl'; + + my $user = Bugzilla->user; + my $dbh = Bugzilla->dbh; + return unless $user->id && $user->settings->{'inline_history'}->{'value'} eq 'on'; + + # note: bug/edit.html.tmpl doesn't support multiple bugs + my $bug = exists $vars->{'bugs'} ? $vars->{'bugs'}[0] : $vars->{'bug'}; + my $bug_id = $bug->id; + + # build bug activity + my ($activity) = $bug->can('get_activity') + ? $bug->get_activity() + : Bugzilla::Bug::GetBugActivity($bug_id); + $activity = _add_duplicates($bug_id, $activity); + + if (scalar @$activity > MAXIMUM_ACTIVITY_COUNT) { + $activity = []; + $vars->{'ih_activity'} = 0; + $vars->{'ih_activity_max'} = 1; + return; + } + + # allow other extensions to alter history + Bugzilla::Hook::process('inline_history_activtiy', { activity => $activity }); + + my %attachment_cache; + foreach my $attachment (@{$bug->attachments}) { + $attachment_cache{$attachment->id} = $attachment; + } + + # build a list of bugs we need to check visibility of, so we can check with a single query + my %visible_bug_ids; + + # augment and tweak + foreach my $operation (@$activity) { + # make operation.who an object + $operation->{who} = + Bugzilla::User->new({ name => $operation->{who}, cache => 1 }); + + for (my $i = 0; $i < scalar(@{$operation->{changes}}); $i++) { + my $change = $operation->{changes}->[$i]; + + # make an attachment object + if ($change->{attachid}) { + $change->{attach} = $attachment_cache{$change->{attachid}}; + } + + # empty resolutions are displayed as --- by default + # make it explicit here to enable correct display of the change + if ($change->{fieldname} eq 'resolution') { + $change->{removed} = '---' if $change->{removed} eq ''; + $change->{added} = '---' if $change->{added} eq ''; + } + + # make boolean fields true/false instead of 1/0 + my ($table, $field) = ('bugs', $change->{fieldname}); + if ($field =~ /^([^\.]+)\.(.+)$/) { + ($table, $field) = ($1, $2); + } + my $column = $dbh->bz_column_info($table, $field); + if ($column && $column->{TYPE} eq 'BOOLEAN') { + $change->{removed} = ''; + $change->{added} = $change->{added} ? 'true' : 'false'; + } + + my $field_obj; + if ($change->{fieldname} =~ /^cf_/) { + $field_obj = Bugzilla::Field->new({ name => $change->{fieldname}, cache => 1 }); + } + + # identify buglist changes + if ($change->{fieldname} eq 'blocked' || + $change->{fieldname} eq 'dependson' || + $change->{fieldname} eq 'dupe' || + ($field_obj && $field_obj->type == FIELD_TYPE_BUG_ID) + ) { + $change->{buglist} = 1; + foreach my $what (qw(removed added)) { + my @buglist = split(/[\s,]+/, $change->{$what}); + foreach my $id (@buglist) { + if ($id && $id =~ /^\d+$/) { + $visible_bug_ids{$id} = 1; + } + } + } + } + + # split multiple flag changes (must be processed last) + if ($change->{fieldname} eq 'flagtypes.name') { + my @added = split(/, /, $change->{added}); + my @removed = split(/, /, $change->{removed}); + next if scalar(@added) <= 1 && scalar(@removed) <= 1; + # remove current change + splice(@{$operation->{changes}}, $i, 1); + # restructure into added/removed for each flag + my %flags; + foreach my $added (@added) { + my ($value, $name) = $added =~ /^((.+).)$/; + $flags{$name}{added} = $value; + $flags{$name}{removed} |= ''; + } + foreach my $removed (@removed) { + my ($value, $name) = $removed =~ /^((.+).)$/; + $flags{$name}{added} |= ''; + $flags{$name}{removed} = $value; + } + # clone current change, modify and insert + foreach my $flag (sort keys %flags) { + my $flag_change = {}; + foreach my $key (keys %$change) { + $flag_change->{$key} = $change->{$key}; + } + $flag_change->{removed} = $flags{$flag}{removed}; + $flag_change->{added} = $flags{$flag}{added}; + splice(@{$operation->{changes}}, $i, 0, $flag_change); + } + $i--; + } + } + } + + $user->visible_bugs([keys %visible_bug_ids]); + + $vars->{'ih_activity'} = $activity; +} + +sub _add_duplicates { + # insert 'is a dupe of this bug' comment to allow js to display + # as activity + + my ($bug_id, $activity) = @_; + + # we're ignoring pre-bugzilla 3.0 ".. has been marked as a duplicate .." + # comments because searching each comment's text is expensive. these + # legacy comments will not be visible at all in the bug's comment/activity + # stream. bug 928786 deals with migrating those comments to be stored as + # CMT_HAS_DUPE instead. + + my $dbh = Bugzilla->dbh; + my $sth = $dbh->prepare(" + SELECT profiles.login_name, " . + $dbh->sql_date_format('bug_when', '%Y.%m.%d %H:%i:%s') . ", + extra_data + FROM longdescs + INNER JOIN profiles ON profiles.userid = longdescs.who + WHERE bug_id = ? AND type = ? + ORDER BY bug_when + "); + $sth->execute($bug_id, CMT_HAS_DUPE); + + while (my($who, $when, $dupe_id) = $sth->fetchrow_array) { + my $entry = { + 'when' => $when, + 'who' => $who, + 'changes' => [ + { + 'removed' => '', + 'added' => $dupe_id, + 'attachid' => undef, + 'fieldname' => 'dupe', + 'dupe' => 1, + } + ], + }; + push @$activity, $entry; + } + + return [ sort { $a->{when} cmp $b->{when} } @$activity ]; +} + +sub install_before_final_checks { + my ($self, $args) = @_; + add_setting('inline_history', ['on', 'off'], 'off'); +} + +__PACKAGE__->NAME; diff --git a/extensions/InlineHistory/README b/extensions/InlineHistory/README new file mode 100644 index 000000000..f5aaf163f --- /dev/null +++ b/extensions/InlineHistory/README @@ -0,0 +1,10 @@ +InlineHistory inserts bug activity inline with the comments when viewing a bug. +It was derived from the Bugzilla Tweaks Addon by Ehasn Akhgari. + +For technical and performance reasons it is only available to logged in users, +and is enabled by a User Preference. + +It works with an unmodified install of Bugzilla 4.0 and 4.2. + +If you have modified your show_bug template, the javascript in +web/inline-history.js may need to be updated to suit your installation. diff --git a/extensions/InlineHistory/template/en/default/hook/bug/comments-aftercomments.html.tmpl b/extensions/InlineHistory/template/en/default/hook/bug/comments-aftercomments.html.tmpl new file mode 100644 index 000000000..3c4d4a202 --- /dev/null +++ b/extensions/InlineHistory/template/en/default/hook/bug/comments-aftercomments.html.tmpl @@ -0,0 +1,160 @@ +[%# 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 ih_activity %] +[%# this div exists to allow bugzilla-tweaks to detect when we're active %] +<div id="inline-history-ext"></div> + +<script> + var ih_activity = new Array(); + var ih_activity_flags = new Array(); + var ih_activity_sort_order = '[% user.settings.comment_sort_order.value FILTER js %]'; + [% FOREACH operation = ih_activity %] + var html = ''; + [% has_cc = 0 %] + [% has_flag = 0 %] + [% changer_identity = operation.who.identity %] + [% changer_login = operation.who.login %] + [% change_date = operation.when FILTER time %] + + [% FOREACH change = operation.changes %] + [%# track flag changes %] + [% IF change.fieldname == 'flagtypes.name' && change.added != '' %] + [% new_flags = change.added.split('[ ,]+') %] + [% FOREACH new_flag IN new_flags %] + var item = new Array(5); + item[0] = '[% changer_login FILTER js %]'; + item[1] = '[% change_date FILTER js %]'; + item[2] = '[% change.attachid FILTER js %]'; + item[3] = '[% new_flag FILTER js %]'; + item[4] = '[% changer_identity FILTER js %]'; + ih_activity_flags.push(item); + [% has_flag = 1 %] + [% END %] + [% END %] + + [%# wrap CC changes in a span for toggling visibility %] + [% IF change.fieldname == 'cc' %] + html += '<span class="ih_cc">'; + [% has_cc = 1 %] + [% END %] + + [%# make attachment changes better %] + [% IF change.attachid %] + html += '<a ' + + 'href="attachment.cgi?id=[% change.attachid FILTER none %]&action=edit" ' + + 'title="[% change.attach.description FILTER html FILTER js %]" ' + + 'class="[% "bz_obsolete" IF change.attach.isobsolete %]"' + + '>Attachment #[% change.attachid FILTER none %]</a> - '; + [% END %] + + [%# buglists need to be displayed differently, as we shouldn't use strike-out %] + [% IF change.buglist %] + [% IF change.dupe %] + [% label = 'Duplicate of this ' _ terms.bug %] + [% ELSE %] + [% label = field_descs.${change.fieldname} %] + [% END %] + [% IF change.added != '' %] + html += '[% label FILTER js %]: '; + [% PROCESS add_change value = change.added %] + [% END %] + [% IF change.removed != '' %] + [% "html += '<br>';" IF change.added != '' %] + html += 'No longer [% label FILTER lcfirst FILTER js %]: '; + [% PROCESS add_change value = change.removed %] + [% END %] + [% ELSE %] + [% IF change.fieldname == 'longdescs.isprivate' %] + [%# reference the comment that was made private/public in the field label %] + html += '<a href="#c[% change.comment.count FILTER js %]">' + + 'Comment [% change.comment.count FILTER js %]</a> is private: '; + [% ELSE %] + [%# normal label %] + html += '[% field_descs.${change.fieldname} FILTER js %]: '; + [% END %] + [% IF change.removed != '' %] + [% IF change.added == '' %] + html += '<span class="ih_deleted">'; + [% END %] + [% PROCESS add_change value = change.removed %] + [% IF change.added == '' %] + html += '</span>'; + [% ELSE %] + html += ' → '; + [% END %] + [% END %] + [% PROCESS add_change value = change.added %] + [% END %] + [% "html += '<br>';" UNLESS loop.last %] + + [% IF change.fieldname == 'cc' %] + html += '</span>'; + [% END %] + [% END %] + + [% changer_id = operation.who.id %] + [% UNLESS user_cache.$changer_id %] + [% user_cache.$changer_id = BLOCK %] + [% INCLUDE global/user.html.tmpl who = operation.who %] + [% END %] + [% END %] + + var user_image = ' + [%~ who = operation.who %] + [% Hook.process('user-image', 'bug/comments.html.tmpl') FILTER js %]'; + + var item = new Array(7); + item[0] = '[% changer_login FILTER js %]'; + item[1] = '[% change_date FILTER js %]'; + item[2] = html; + item[3] = '<div class="bz_comment_head">' + + '<span class="bz_comment_user">' + + user_image + + ' [% user_cache.$changer_id FILTER js %]' + + '</span>' + + '<span class="bz_comment_time"> ' + item[1] + ' </span>' + + '</div>'; + item[4] = [% IF has_cc && (operation.changes.size == 1) %]true[% ELSE %]false[% END %]; + item[5] = [% IF change.dupe %][% change.added FILTER js %][% ELSE %]0[% END %]; + item[6] = [% IF has_flag %]true[% ELSE %]false[% END %]; + ih_activity[[% loop.index %]] = item; + [% END %] + inline_history.init(); +</script> + +[% BLOCK add_change %] + html += '[%~%] + [% IF change.fieldname == 'estimated_time' || + change.fieldname == 'remaining_time' || + change.fieldname == 'work_time' %] + [% PROCESS formattimeunit time_unit = value FILTER html FILTER js %] + [% ELSIF change.buglist %] + [% value FILTER bug_list_link FILTER js %] + [% ELSIF change.fieldname == 'bug_file_loc' %] + [%~%]<a href="[% value FILTER html FILTER js %]" target="_blank" + [%~ ' onclick="return inline_history.confirmUnsafeUrl(this.href)"' + UNLESS is_safe_url(value) %]> + [%~%][% value FILTER ih_short_value FILTER html FILTER js %]</a> + [% ELSIF change.fieldname == 'see_also' %] + [% FOREACH see_also = value.split(', ') %] + [%~%]<a href="[% see_also FILTER html FILTER js %]" target="_blank"> + [%~%][% see_also FILTER html FILTER js %]</a> + [%- ", " IF NOT loop.last %] + [% END %] + [% ELSIF change.fieldname == 'assigned_to' || + change.fieldname == 'reporter' || + change.fieldname == 'qa_contact' || + change.fieldname == 'cc' || + change.fieldname == 'flagtypes.name' %] + [% value FILTER email FILTER js %] + [% ELSE %] + [% value FILTER ih_short_value FILTER html FILTER js %] + [% END %] + [%~ %]'; +[% END %] diff --git a/extensions/InlineHistory/template/en/default/hook/bug/comments-comment_banner.html.tmpl b/extensions/InlineHistory/template/en/default/hook/bug/comments-comment_banner.html.tmpl new file mode 100644 index 000000000..133005f4f --- /dev/null +++ b/extensions/InlineHistory/template/en/default/hook/bug/comments-comment_banner.html.tmpl @@ -0,0 +1,13 @@ +[%# 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 ih_activity_max %] +<p> + <i>This [% terms.bug %] contains too many changes to be displayed inline.</i> +</p> +[% END %] diff --git a/extensions/InlineHistory/template/en/default/hook/bug/show-header-end.html.tmpl b/extensions/InlineHistory/template/en/default/hook/bug/show-header-end.html.tmpl new file mode 100644 index 000000000..7e54b8380 --- /dev/null +++ b/extensions/InlineHistory/template/en/default/hook/bug/show-header-end.html.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. + #%] + +[% IF user.id && user.settings.inline_history.value == "on" %] + [% style_urls.push('extensions/InlineHistory/web/style.css') %] + [% javascript_urls.push('extensions/InlineHistory/web/inline-history.js') %] +[% END %] diff --git a/extensions/InlineHistory/template/en/default/hook/global/setting-descs-settings.none.tmpl b/extensions/InlineHistory/template/en/default/hook/global/setting-descs-settings.none.tmpl new file mode 100644 index 000000000..e1ff4c0f6 --- /dev/null +++ b/extensions/InlineHistory/template/en/default/hook/global/setting-descs-settings.none.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. + #%] + +[% + setting_descs.inline_history = "When viewing a $terms.bug, show all $terms.bug activity", +%] diff --git a/extensions/InlineHistory/web/inline-history.js b/extensions/InlineHistory/web/inline-history.js new file mode 100644 index 000000000..a1bfeca23 --- /dev/null +++ b/extensions/InlineHistory/web/inline-history.js @@ -0,0 +1,417 @@ +/* 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 inline_history = { + _ccDivs: null, + _hasAttachmentFlags: false, + _hasBugFlags: false, + + init: function() { + Dom = YAHOO.util.Dom; + + var reDuplicate = /^\*\*\* \S+ \d+ has been marked as a duplicate of this/; + var reBugId = /show_bug\.cgi\?id=(\d+)/; + var reHours = /Additional hours worked: \d+\.\d+/; + + var comments = Dom.getElementsByClassName("bz_comment", 'div', 'comments'); + for (var i = 1, il = comments.length; i < il; i++) { + // remove 'has been marked as a duplicate of this bug' comments + var textDiv = Dom.getElementsByClassName('bz_comment_text', 'pre', comments[i]); + if (textDiv) { + var match = reDuplicate.exec(textDiv[0].textContent || textDiv[0].innerText); + if (match) { + // grab the comment and bug number from the element + var comment = comments[i]; + var number = comment.id.substr(1); + var time = this.trim(Dom.getElementsByClassName('bz_comment_time', 'span', comment)[0].innerHTML); + var dupeId = 0; + match = reBugId.exec(Dom.get('comment_text_' + number).innerHTML); + if (match) + dupeId = match[1]; + // remove the element + comment.parentNode.removeChild(comment); + comments[i] = false; + // update the html for the history item to include the comment number + if (dupeId == 0) + continue; + for (var j = 0, jl = ih_activity.length; j < jl; j++) { + var item = ih_activity[j]; + if (item[5] == dupeId && item[1] == time) { + // insert comment number and link into the header + item[3] = item[3].substr(0, item[3].length - 6) // remove trailing </div> + // add comment number + + '<span class="bz_comment_number" id="c' + number + '">' + + '<a href="#c' + number + '">Comment ' + number + '</a>' + + '</span>' + + '</div>'; + break; + } + } + } + } + if (!comments[i]) + continue; + + // remove 'Additional hours worked: ' comments + var commentNodes = comments[i].childNodes; + for (var j = 0, jl = commentNodes.length; j < jl; j++) { + if (reHours.exec(commentNodes[j].textContent)) { + comments[i].removeChild(commentNodes[j]); + // Remove the <br> before it + comments[i].removeChild(commentNodes[j-1]); + break; + } + } + } + + // remove deleted comments + for (var i = 0; i < comments.length; i++) { + if (!comments[i]) { + comments.splice(i, 1); + i--; + } + } + + // ensure new items are placed immediately after the last comment + if (!comments.length) return; + var lastCommentDiv = comments[comments.length - 1]; + + // insert activity into the correct location + var commentTimes = Dom.getElementsByClassName('bz_comment_time', 'span', 'comments'); + for (var i = 0, il = ih_activity.length; i < il; i++) { + var item = ih_activity[i]; + // item[0] : who + // item[1] : when + // item[2] : change html + // item[3] : header html + // item[4] : bool; cc-only + // item[5] : int; dupe bug id (or 0) + // item[6] : bool; is flag + var user = item[0]; + var time = item[1]; + + var reachedEnd = false; + var start_index = ih_activity_sort_order == 'newest_to_oldest_desc_first' ? 1 : 0; + for (var j = start_index, jl = commentTimes.length; j < jl; j++) { + var commentHead = commentTimes[j].parentNode; + var mainUser = Dom.getElementsByClassName('email', 'a', commentHead)[0].href.substr(7); + var text = commentTimes[j].textContent || commentTimes[j].innerText; + var mainTime = this.trim(text); + + if (ih_activity_sort_order == 'oldest_to_newest' ? time > mainTime : time < mainTime) { + if (j < commentTimes.length - 1) { + continue; + } else { + reachedEnd = true; + } + } + + var inline = (mainUser == user && time == mainTime); + var currentDiv = document.createElement("div"); + + // place ih_cc class on parent container if it's the only child + var containerClass = ''; + if (item[4]) { + item[2] = item[2].replace('"ih_cc"', '""'); + containerClass = 'ih_cc'; + } + + if (inline) { + // assume that the change was made by the same user + commentHead.parentNode.appendChild(currentDiv); + currentDiv.innerHTML = item[2]; + Dom.addClass(currentDiv, 'ih_inlinehistory'); + Dom.addClass(currentDiv, containerClass); + if (item[6]) + this.setFlagChangeID(item, commentHead.parentNode.id); + + } else { + // the change was made by another user + if (!reachedEnd) { + var parentDiv = commentHead.parentNode; + var previous = this.previousElementSibling(parentDiv); + if (previous && previous.className.indexOf("ih_history") >= 0) { + currentDiv = this.previousElementSibling(parentDiv); + } else { + parentDiv.parentNode.insertBefore(currentDiv, parentDiv); + } + } else { + var parentDiv = commentHead.parentNode; + var next = this.nextElementSibling(parentDiv); + if (next && next.className.indexOf("ih_history") >= 0) { + currentDiv = this.nextElementSibling(parentDiv); + } else { + lastCommentDiv.parentNode.insertBefore(currentDiv, lastCommentDiv.nextSibling); + } + } + + var itemContainer = document.createElement('div'); + itemContainer.className = 'ih_history_item ' + containerClass; + itemContainer.id = 'h' + i; + itemContainer.innerHTML = item[3] + '<div class="ih_history_change">' + item[2] + '</div>'; + + if (ih_activity_sort_order == 'oldest_to_newest') { + currentDiv.appendChild(itemContainer); + } else { + currentDiv.insertBefore(itemContainer, currentDiv.firstChild); + } + currentDiv.setAttribute("class", "bz_comment ih_history"); + if (item[6]) + this.setFlagChangeID(item, 'h' + i); + } + break; + } + } + + // find comment blocks which only contain cc changes, shift the ih_cc + var historyDivs = Dom.getElementsByClassName('ih_history', 'div', 'comments'); + for (var i = 0, il = historyDivs.length; i < il; i++) { + var historyDiv = historyDivs[i]; + var itemDivs = Dom.getElementsByClassName('ih_history_item', 'div', historyDiv); + var ccOnly = true; + for (var j = 0, jl = itemDivs.length; j < jl; j++) { + if (!Dom.hasClass(itemDivs[j], 'ih_cc')) { + ccOnly = false; + break; + } + } + if (ccOnly) { + for (var j = 0, jl = itemDivs.length; j < jl; j++) { + Dom.removeClass(itemDivs[j], 'ih_cc'); + } + Dom.addClass(historyDiv, 'ih_cc'); + } + } + + if (this._hasAttachmentFlags) + this.linkAttachmentFlags(); + if (this._hasBugFlags) + this.linkBugFlags(); + + ih_activity = undefined; + ih_activity_flags = undefined; + + this._ccDivs = Dom.getElementsByClassName('ih_cc', '', 'comments'); + this.hideCC(); + YAHOO.util.Event.onDOMReady(this.addCCtoggler); + }, + + setFlagChangeID: function(changeItem, id) { + // put the ID for the change into ih_activity_flags + for (var i = 0, il = ih_activity_flags.length; i < il; i++) { + var flagItem = ih_activity_flags[i]; + // flagItem[0] : who.login + // flagItem[1] : when + // flagItem[2] : attach id + // flagItem[3] : flag + // flagItem[4] : who.identity + // flagItem[5] : change div id + if (flagItem[0] == changeItem[0] && flagItem[1] == changeItem[1]) { + // store the div + flagItem[5] = id; + // tag that we have flags to process + if (flagItem[2]) { + this._hasAttachmentFlags = true; + } else { + this._hasBugFlags = true; + } + if (flagItem[3].match(/^needinfo\?/)) { + this.lastNeedinfo = flagItem; + } + // don't break as there may be multiple flag changes at once + } + } + }, + + linkAttachmentFlags: function() { + var rows = Dom.get('attachment_table').getElementsByTagName('tr'); + for (var i = 0, il = rows.length; i < il; i++) { + + // deal with attachments with flags only + var tr = rows[i]; + if (!tr.id || tr.id == 'a0') + continue; + var attachFlagTd = Dom.getElementsByClassName('bz_attach_flags', 'td', tr); + if (attachFlagTd.length == 0) + continue; + attachFlagTd = attachFlagTd[0]; + + // get the attachment id + var attachId = 0; + var anchors = tr.getElementsByTagName('a'); + for (var j = 0, jl = anchors.length; j < jl; j++) { + var match = anchors[j].href.match(/attachment\.cgi\?id=(\d+)/); + if (match) { + attachId = match[1]; + break; + } + } + if (!attachId) + continue; + + var html = ''; + + // there may be multiple flags, split by <br> + var attachFlags = attachFlagTd.innerHTML.split('<br>'); + for (var j = 0, jl = attachFlags.length; j < jl; j++) { + var match = attachFlags[j].match(/^\s*(<span.+\/span>):([^\?\-\+]+[\?\-\+])([\s\S]*)/); + if (!match) continue; + var setterSpan = match[1]; + var flag = this.trim(match[2].replace('\u2011', '-', 'g')); + var requestee = this.trim(match[3]); + var requesteeLogin = ''; + + match = setterSpan.match(/title="([^"]+)"/); + if (!match) continue; + var setterIdentity = this.htmlDecode(match[1]); + + if (requestee) { + match = requestee.match(/title="([^"]+)"/); + if (!match) continue; + requesteeLogin = this.htmlDecode(match[1]); + match = requesteeLogin.match(/<([^>]+)>/); + if (match) + requesteeLogin = match[1]; + } + + var flagValue = requestee ? flag + '(' + requesteeLogin + ')' : flag; + // find the id for this change + var found = false; + for (var k = 0, kl = ih_activity_flags.length; k < kl; k++) { + flagItem = ih_activity_flags[k]; + if ( + flagItem[2] == attachId + && flagItem[3] == flagValue + && flagItem[4] == setterIdentity + ) { + html += + setterSpan + ': ' + + '<a href="#' + flagItem[5] + '">' + flag + '</a> ' + + requestee + '<br>'; + found = true; + break; + } + } + if (!found) { + // something went wrong, insert the flag unlinked + html += attachFlags[j] + '<br>'; + } + } + + if (html) + attachFlagTd.innerHTML = html; + } + }, + + linkBugFlags: function() { + var flags = Dom.get('flags'); + if (!flags) return; + var rows = flags.getElementsByTagName('tr'); + for (var i = 0, il = rows.length; i < il; i++) { + var cells = rows[i].getElementsByTagName('td'); + if (!cells[1]) continue; + + var match = cells[0].innerHTML.match(/title="([^"]+)"/); + if (!match) continue; + var setterIdentity = this.htmlDecode(match[1]); + + var flagValue = cells[2].getElementsByTagName('select'); + if (!flagValue.length) continue; + flagValue = flagValue[0].value; + + var flagLabel = cells[1].getElementsByTagName('label'); + if (!flagLabel.length) continue; + flagLabel = flagLabel[0]; + var flagName = this.trim(flagLabel.innerHTML).replace('\u2011', '-', 'g'); + + for (var j = 0, jl = ih_activity_flags.length; j < jl; j++) { + flagItem = ih_activity_flags[j]; + if ( + !flagItem[2] + && flagItem[3] == flagName + flagValue + && flagItem[4] == setterIdentity + ) { + flagLabel.innerHTML = + '<a href="#' + flagItem[5] + '">' + flagName + '</a>'; + break; + } + } + } + }, + + hideCC: function() { + Dom.addClass(this._ccDivs, 'ih_hidden'); + }, + + showCC: function() { + Dom.removeClass(this._ccDivs, 'ih_hidden'); + }, + + addCCtoggler: function() { + var ul = Dom.getElementsByClassName('bz_collapse_expand_comments'); + if (ul.length == 0) + return; + ul = ul[0]; + var a = document.createElement('a'); + a.href = 'javascript:void(0)'; + a.id = 'ih_toggle_cc'; + YAHOO.util.Event.addListener(a, 'click', function(e) { + if (Dom.get('ih_toggle_cc').innerHTML == 'Show CC Changes') { + a.innerHTML = 'Hide CC Changes'; + inline_history.showCC(); + } else { + a.innerHTML = 'Show CC Changes'; + inline_history.hideCC(); + } + }); + a.innerHTML = 'Show CC Changes'; + var li = document.createElement('li'); + li.appendChild(a); + ul.appendChild(li); + }, + + confirmUnsafeUrl: function(url) { + return confirm( + 'This is considered an unsafe URL and could possibly be harmful.\n' + + 'The full URL is:\n\n' + url + '\n\nContinue?'); + }, + + previousElementSibling: function(el) { + if (el.previousElementSibling) + return el.previousElementSibling; + while (el = el.previousSibling) { + if (el.nodeType == 1) + return el; + } + }, + + nextElementSibling: function(el) { + if (el.nextElementSibling) + return el.nextElementSibling; + while (el = el.nextSibling) { + if (el.nodeType == 1) + return el; + } + }, + + getNeedinfoDiv: function () { + if (this.lastNeedinfo && this.lastNeedinfo[5]) { + return this.lastNeedinfo[5]; + } + }, + + htmlDecode: function(v) { + if (!v.match(/&/)) return v; + var e = document.createElement('textarea'); + e.innerHTML = v; + return e.value; + }, + + trim: function(s) { + return s.replace(/^\s+|\s+$/g, ''); + } +}; diff --git a/extensions/InlineHistory/web/style.css b/extensions/InlineHistory/web/style.css new file mode 100644 index 000000000..af76eba82 --- /dev/null +++ b/extensions/InlineHistory/web/style.css @@ -0,0 +1,35 @@ +/* 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. */ + +.ih_history { + background: none !important; + color: #444; +} + +.ih_inlinehistory { + font-weight: normal; + font-size: small; + color: #444; + border-top: 1px dotted #C8C8BA; + padding-top: 5px; +} + +.bz_comment.ih_history { + padding: 5px 5px 0px 5px +} + +.ih_history_item { + margin-bottom: 5px; +} + +.ih_hidden { + display: none; +} + +.ih_deleted { + text-decoration: line-through; +} diff --git a/extensions/LastResolved/Config.pm b/extensions/LastResolved/Config.pm new file mode 100644 index 000000000..f763167e2 --- /dev/null +++ b/extensions/LastResolved/Config.pm @@ -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. + +package Bugzilla::Extension::LastResolved; + +use strict; + +use constant NAME => 'LastResolved'; + +use constant REQUIRED_MODULES => [ +]; + +use constant OPTIONAL_MODULES => [ +]; + +__PACKAGE__->NAME; diff --git a/extensions/LastResolved/Extension.pm b/extensions/LastResolved/Extension.pm new file mode 100644 index 000000000..ad0519387 --- /dev/null +++ b/extensions/LastResolved/Extension.pm @@ -0,0 +1,113 @@ +# 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::LastResolved; + +use strict; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Bug qw(LogActivityEntry); +use Bugzilla::Util qw(format_time); +use Bugzilla::Constants; +use Bugzilla::Field; +use Bugzilla::Install::Util qw(indicate_progress); + +our $VERSION = '0.01'; + +sub install_update_db { + my ($self, $args) = @_; + my $last_resolved = Bugzilla::Field->new({'name' => 'cf_last_resolved'}); + if (!$last_resolved) { + Bugzilla::Field->create({ + name => 'cf_last_resolved', + description => 'Last Resolved', + type => FIELD_TYPE_DATETIME, + mailhead => 0, + enter_bug => 0, + obsolete => 0, + custom => 1, + buglist => 1, + }); + _migrate_last_resolved(); + } +} + +sub _migrate_last_resolved { + my $dbh = Bugzilla->dbh; + my $field_id = get_field_id('bug_status'); + my $resolved_activity = $dbh->selectall_arrayref( + "SELECT bugs_activity.bug_id, bugs_activity.bug_when, bugs_activity.who + FROM bugs_activity + WHERE bugs_activity.fieldid = ? + AND bugs_activity.added = 'RESOLVED' + ORDER BY bugs_activity.bug_when", + undef, $field_id); + + my $count = 1; + my $total = scalar @$resolved_activity; + my %current_last_resolved; + foreach my $activity (@$resolved_activity) { + indicate_progress({ current => $count++, total => $total, every => 25 }); + my ($id, $new, $who) = @$activity; + my $old = $current_last_resolved{$id} ? $current_last_resolved{$id} : ""; + $dbh->do("UPDATE bugs SET cf_last_resolved = ? WHERE bug_id = ?", undef, $new, $id); + LogActivityEntry($id, 'cf_last_resolved', $old, $new, $who, $new); + $current_last_resolved{$id} = $new; + } +} + +sub bug_check_can_change_field { + my ($self, $args) = @_; + my ($field, $priv_results) = @$args{qw(field priv_results)}; + if ($field eq 'cf_last_resolved') { + push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); + } +} + +sub bug_end_of_update { + my ($self, $args) = @_; + my $dbh = Bugzilla->dbh; + my ($bug, $old_bug, $timestamp, $changes) = + @$args{qw(bug old_bug timestamp changes)}; + if ($changes->{'bug_status'}) { + # If the bug has been resolved then update the cf_last_resolved + # value to the current timestamp if cf_last_resolved exists + if ($bug->bug_status eq 'RESOLVED') { + $dbh->do("UPDATE bugs SET cf_last_resolved = ? WHERE bug_id = ?", + undef, $timestamp, $bug->id); + my $old_value = $bug->cf_last_resolved || ''; + LogActivityEntry($bug->id, 'cf_last_resolved', $old_value, + $timestamp, Bugzilla->user->id, $timestamp); + } + } +} + +sub bug_fields { + my ($self, $args) = @_; + my $fields = $args->{'fields'}; + push (@$fields, 'cf_last_resolved') +} + +sub object_columns { + my ($self, $args) = @_; + my ($class, $columns) = @$args{qw(class columns)}; + if ($class->isa('Bugzilla::Bug')) { + push(@$columns, 'cf_last_resolved'); + } +} + +sub buglist_columns { + my ($self, $args) = @_; + my $columns = $args->{columns}; + $columns->{'cf_last_resolved'} = { + name => 'bugs.cf_last_resolved', + title => 'Last Resolved', + }; +} + +__PACKAGE__->NAME; diff --git a/extensions/LastResolved/template/en/default/hook/bug/edit-custom_field.html.tmpl b/extensions/LastResolved/template/en/default/hook/bug/edit-custom_field.html.tmpl new file mode 100644 index 000000000..27366b01f --- /dev/null +++ b/extensions/LastResolved/template/en/default/hook/bug/edit-custom_field.html.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. + #%] + +[%# Do not display the last resolved value in the UI %] +[% IF field.name == 'cf_last_resolved' %] + [% field.hidden = 1 %] +[% END %] diff --git a/extensions/LastResolved/template/en/default/hook/global/field-descs-end.none.tmpl b/extensions/LastResolved/template/en/default/hook/global/field-descs-end.none.tmpl new file mode 100644 index 000000000..4457ccd9b --- /dev/null +++ b/extensions/LastResolved/template/en/default/hook/global/field-descs-end.none.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 in_template_var %] + [% vars.field_descs.cf_last_resolved = "Last Resolved" %] +[% END %] diff --git a/extensions/LastResolved/template/en/default/hook/list/edit-multiple-custom_field.html.tmpl b/extensions/LastResolved/template/en/default/hook/list/edit-multiple-custom_field.html.tmpl new file mode 100644 index 000000000..31e645c32 --- /dev/null +++ b/extensions/LastResolved/template/en/default/hook/list/edit-multiple-custom_field.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 field.name == "cf_last_resolved" %] + [% field.hidden = 1 %] +[% END %] diff --git a/extensions/LimitedEmail/Config.pm b/extensions/LimitedEmail/Config.pm new file mode 100644 index 000000000..ea05f363c --- /dev/null +++ b/extensions/LimitedEmail/Config.pm @@ -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. + +package Bugzilla::Extension::LimitedEmail; + +use strict; +use constant NAME => 'LimitedEmail'; +use constant REQUIRED_MODULES => [ ]; +use constant OPTIONAL_MODULES => [ ]; + +use constant FILTERS => [ + qr/^(?:glob|dkl|justdave|shyam)\@mozilla\.com$/i, + qr/^byron\.jones\@gmail\.com$/i, + qr/^gerv\@mozilla\.org$/i, + qr/^reed\@reedloden\.com$/i, +]; + +__PACKAGE__->NAME; diff --git a/extensions/LimitedEmail/Extension.pm b/extensions/LimitedEmail/Extension.pm new file mode 100644 index 000000000..35cc83567 --- /dev/null +++ b/extensions/LimitedEmail/Extension.pm @@ -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. + +package Bugzilla::Extension::LimitedEmail; +use strict; +use base qw(Bugzilla::Extension); + +our $VERSION = '2'; + +use FileHandle; +use Date::Format; +use Encode qw(encode_utf8); +use Bugzilla::Constants qw(bz_locations); + +sub mailer_before_send { + my ($self, $args) = @_; + my $email = $args->{email}; + my $header = $email->{header}; + return if $header->header('to') eq ''; + + my $blocked = ''; + if (!deliver_to($header->header('to'))) { + $blocked = $header->header('to'); + $header->header_set(to => ''); + } + + my $log_filename = bz_locations->{'datadir'} . '/mail.log'; + my $fh = FileHandle->new(">>$log_filename"); + if ($fh) { + print $fh encode_utf8(sprintf( + "[%s] %s%s %s : %s\n", + time2str('%D %T', time), + ($blocked eq '' ? '' : '(blocked) '), + ($blocked eq '' ? $header->header('to') : $blocked), + $header->header('X-Bugzilla-Reason') || '-', + $header->header('subject') + )); + $fh->close(); + } +} + +sub deliver_to { + my $email = address_of(shift); + my $ra_filters = Bugzilla::Extension::LimitedEmail::FILTERS; + foreach my $re (@$ra_filters) { + if ($email =~ $re) { + return 1; + } + } + return 0; +} + +sub address_of { + my $email = shift; + return $email =~ /<([^>]+)>/ ? $1 : $email; +} + +__PACKAGE__->NAME; diff --git a/extensions/LimitedEmail/disabled b/extensions/LimitedEmail/disabled new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/extensions/LimitedEmail/disabled diff --git a/extensions/MozProjectReview/Config.pm b/extensions/MozProjectReview/Config.pm new file mode 100644 index 000000000..5a9d2b730 --- /dev/null +++ b/extensions/MozProjectReview/Config.pm @@ -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. +package Bugzilla::Extension::MozProjectReview; + +use strict; + +use constant NAME => 'MozProjectReview'; + +use constant REQUIRED_MODULES => [ +]; + +use constant OPTIONAL_MODULES => [ +]; + +__PACKAGE__->NAME; diff --git a/extensions/MozProjectReview/Extension.pm b/extensions/MozProjectReview/Extension.pm new file mode 100644 index 000000000..4f132af21 --- /dev/null +++ b/extensions/MozProjectReview/Extension.pm @@ -0,0 +1,284 @@ +# 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::MozProjectReview; + +use strict; + +use base qw(Bugzilla::Extension); + +use Bugzilla::User; +use Bugzilla::Group; +use Bugzilla::Error; +use Bugzilla::Constants; + +our $VERSION = '0.01'; + +# these users will be cc'd to the parent bug when the corresponding child bug +# is created, as well as the child bug. +our %auto_cc = ( + 'legal' => ['liz@mozilla.com'], + 'sec-review' => ['curtisk@mozilla.com'], + 'finance' => ['waoieong@mozilla.com', 'mcristobal@mozilla.com', 'echoe@mozilla.com'], + 'privacy-vendor' => ['smartin@mozilla.com'], + 'privacy-project' => ['ahua@mozilla.com'], + 'privacy-tech' => ['ahua@mozilla.com'], + 'policy-business-partner' => ['smartin@mozilla.com'] +); + +sub post_bug_after_creation { + my ($self, $args) = @_; + my $vars = $args->{'vars'}; + my $bug = $vars->{'bug'}; + my $timestamp = $args->{'timestamp'}; + my $user = Bugzilla->user; + my $params = Bugzilla->input_params; + my $template = Bugzilla->template; + + return if !($params->{format} + && $params->{format} eq 'moz-project-review' + && $bug->component eq 'Project Review'); + + # do a match if applicable + Bugzilla::User::match_field({ + 'legal_cc' => { 'type' => 'multi' } + }); + + my ($do_sec_review, $do_legal, $do_finance, $do_privacy_vendor, + $do_privacy_tech, $do_privacy_policy); + + if ($params->{'mozilla_data'} eq 'Yes') { + $do_legal = 1; + $do_privacy_policy = 1; + $do_privacy_tech = 1; + $do_sec_review = 1; + } + + if ($params->{'separate_party'} eq 'Yes') { + if ($params->{'relationship_type'} ne 'Hardware Purchase') { + $do_legal = 1; + } + + if ($params->{'data_access'} eq 'Yes') { + $do_privacy_policy = 1; + $do_legal = 1; + $do_sec_review = 1; + } + + if ($params->{'data_access'} eq 'Yes' + && $params->{'privacy_policy_vendor_user_data'} eq 'Yes') + { + $do_privacy_vendor = 1; + } + + if ($params->{'vendor_cost'} eq '> $25,000' + || ($params->{'vendor_cost'} eq '<= $25,000' + && $params->{'po_needed'} eq 'Yes')) + { + $do_finance = 1; + } + } + + my ($sec_review_bug, $legal_bug, $finance_bug, $privacy_vendor_bug, + $privacy_tech_bug, $privacy_policy_bug, $error, @dep_comment, + @dep_errors, @send_mail); + + # Common parameters always passed to _file_child_bug + # bug_data and template_suffix will be different for each bug + my $child_params = { + parent_bug => $bug, + template_vars => $vars, + dep_comment => \@dep_comment, + dep_errors => \@dep_errors, + send_mail => \@send_mail, + }; + + if ($do_sec_review) { + $child_params->{'bug_data'} = { + short_desc => 'Security Review: ' . $bug->short_desc, + product => 'mozilla.org', + component => 'Security Assurance: Review Request', + bug_severity => 'normal', + groups => [ 'mozilla-employee-confidential' ], + op_sys => 'All', + rep_platform => 'All', + version => 'other', + blocked => $bug->bug_id, + }; + $child_params->{'template_suffix'} = 'sec-review'; + _file_child_bug($child_params); + } + + if ($do_legal) { + my $component = 'General'; + + if ($params->{separate_party} eq 'Yes' + && $params->{relationship_type}) + { + $component = ($params->{relationship_type} eq 'Other' + || $params->{relationship_type} eq 'Hardware Purchase') + ? 'General' + : $params->{relationship_type}; + } + + my $legal_summary = "Legal Review: "; + $legal_summary .= $params->{legal_other_party} . " - " if $params->{legal_other_party}; + $legal_summary .= $bug->short_desc; + + $child_params->{'bug_data'} = { + short_desc => $legal_summary, + product => 'Legal', + component => $component, + bug_severity => 'normal', + priority => '--', + groups => [ 'legal' ], + op_sys => 'All', + rep_platform => 'All', + version => 'unspecified', + blocked => $bug->bug_id, + cc => $params->{'legal_cc'}, + }; + $child_params->{'template_suffix'} = 'legal'; + _file_child_bug($child_params); + } + + if ($do_finance) { + $child_params->{'bug_data'} = { + short_desc => 'Finance Review: ' . $bug->short_desc, + product => 'Finance', + component => 'Purchase Request Form', + bug_severity => 'normal', + priority => '--', + groups => [ 'finance' ], + op_sys => 'All', + rep_platform => 'All', + version => 'unspecified', + blocked => $bug->bug_id, + }; + $child_params->{'template_suffix'} = 'finance'; + _file_child_bug($child_params); + } + + if ($do_privacy_tech) { + $child_params->{'bug_data'} = { + short_desc => 'Privacy-Technical Review: ' . $bug->short_desc, + product => 'mozilla.org', + component => 'Security Assurance: Review Request', + bug_severity => 'normal', + priority => '--', + keywords => 'privacy-review-needed', + groups => [ 'mozilla-employee-confidential' ], + op_sys => 'All', + rep_platform => 'All', + version => 'other', + blocked => $bug->bug_id, + }; + $child_params->{'template_suffix'} = 'privacy-tech'; + _file_child_bug($child_params); + } + + if ($do_privacy_policy) { + $child_params->{'bug_data'} = { + short_desc => 'Privacy-Policy Review: ' . $bug->short_desc, + product => 'Privacy', + component => 'Product Review', + bug_severity => 'normal', + priority => '--', + groups => [ 'mozilla-employee-confidential' ], + op_sys => 'All', + rep_platform => 'All', + version => 'unspecified', + blocked => $bug->bug_id, + }; + $child_params->{'template_suffix'} = 'privacy-policy'; + _file_child_bug($child_params); + } + + if ($do_privacy_vendor) { + $child_params->{'bug_data'} = { + short_desc => 'Privacy / Vendor Review: ' . $bug->short_desc, + product => 'Privacy', + component => 'Vendor Review', + bug_severity => 'normal', + priority => '--', + groups => [ 'mozilla-employee-confidential' ], + op_sys => 'All', + rep_platform => 'All', + version => 'unspecified', + blocked => $bug->bug_id, + }; + $child_params->{'template_suffix'} = 'privacy-vendor'; + _file_child_bug($child_params); + } + + if (scalar @dep_errors) { + warn "[Bug " . $bug->id . "] Failed to create additional moz-project-review bugs:\n" . + join("\n", @dep_errors); + $vars->{'message'} = 'moz_project_review_creation_failed'; + } + + if (scalar @dep_comment) { + my $comment = join("\n", @dep_comment); + if (scalar @dep_errors) { + $comment .= "\n\nSome errors occurred creating dependent bugs and have been recorded"; + } + $bug->add_comment($comment); + $bug->update($bug->creation_ts); + } + + foreach my $bug_id (@send_mail) { + Bugzilla::BugMail::Send($bug_id, { changer => Bugzilla->user }); + } +} + +sub _file_child_bug { + my ($params) = @_; + my ($parent_bug, $template_vars, $template_suffix, $bug_data, $dep_comment, $dep_errors, $send_mail) + = @$params{qw(parent_bug template_vars template_suffix bug_data dep_comment dep_errors send_mail)}; + + my $old_error_mode = Bugzilla->error_mode; + Bugzilla->error_mode(ERROR_MODE_DIE); + + my $new_bug; + eval { + my $comment; + my $full_template = "bug/create/comment-moz-project-review-$template_suffix.txt.tmpl"; + Bugzilla->template->process($full_template, $template_vars, \$comment) + || ThrowTemplateError(Bugzilla->template->error()); + $bug_data->{'comment'} = $comment; + if (exists $auto_cc{$template_suffix}) { + $bug_data->{'cc'} = $auto_cc{$template_suffix}; + } + if ($new_bug = Bugzilla::Bug->create($bug_data)) { + my $set_all = { + dependson => { add => [ $new_bug->bug_id ] } + }; + if (exists $auto_cc{$template_suffix}) { + $set_all->{'cc'} = { add => $auto_cc{$template_suffix} }; + } + $parent_bug->set_all($set_all); + $parent_bug->update($parent_bug->creation_ts); + } + }; + + if ($@ || !($new_bug && $new_bug->{'bug_id'})) { + push(@$dep_comment, "Error creating $template_suffix review bug"); + push(@$dep_errors, "$template_suffix : $@") if $@; + # Since we performed Bugzilla::Bug::create in an eval block, we + # need to manually rollback the commit as this is not done + # in Bugzilla::Error automatically for eval'ed code. + Bugzilla->dbh->bz_rollback_transaction(); + } + else { + push(@$send_mail, $new_bug->id); + push(@$dep_comment, "Bug " . $new_bug->id . " - " . $new_bug->short_desc); + } + + undef $@; + Bugzilla->error_mode($old_error_mode); +} + +__PACKAGE__->NAME; diff --git a/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-finance.txt.tmpl b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-finance.txt.tmpl new file mode 100644 index 000000000..eaa626a7e --- /dev/null +++ b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-finance.txt.tmpl @@ -0,0 +1,30 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi %] + +[% PROCESS "bug/create/comment-moz-project-review.txt.tmpl" %] + +Finance Questions: + +Vendor: [% cgi.param('finance_purchase_vendor') %] +Is this line item in budget?: +[%+ cgi.param('finance_purchase_inbudget') %] +What is the purchase for?: +[%+ cgi.param('finance_purchase_what') %] +Why is the purchase needed?: +[%+ cgi.param('finance_purchase_why') %] +What is the risk if not purchased?: +[%+ cgi.param('finance_purchase_risk') %] +What is the alternative?: +[%+ cgi.param('finance_purchase_alternative') %] +What is the urgency?: [% cgi.param('finance_purchase_urgency') %] +What is the shipping address?: +[%+ cgi.param('finance_shipment_address') %] +Total Cost: [% cgi.param('finance_purchase_cost') %] diff --git a/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-legal.txt.tmpl b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-legal.txt.tmpl new file mode 100644 index 000000000..9856736d1 --- /dev/null +++ b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-legal.txt.tmpl @@ -0,0 +1,51 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi %] + +[% PROCESS "bug/create/comment-moz-project-review.txt.tmpl" %] + +Legal Questions: + +Priority: [% cgi.param('legal_priority') %] +Time Frame For Completion of Legal Portion?: [% cgi.param('legal_timeframe') %] +Other Party: [% cgi.param('legal_other_party') %] +What help do you need from Legal?: +[%+ cgi.param('legal_help_from_legal') %] +[% IF cgi.param('legal_vendor_services_where') %] +Vendor Services from Where: +[% IF cgi.param('legal_vendor_services_where') == 'A single country' %] +[%- cgi.param('legal_vendor_single_country') %] +[% ELSE %] +[%- cgi.param('legal_vendor_services_where') %] +[% END %] +[% END %] +[% IF cgi.param('separate_party') == 'Yes' && cgi.param('relationship_type') == 'Vendor/Services' %] +SOW Details: +Legal Vendor Name: [% cgi.param('legal_sow_vendor_name') %] +Vendor Address: +[%+ cgi.param('legal_sow_vendor_address') %] +Vendor Email for Notices: [% cgi.param('legal_sow_vendor_email') %] +Mozilla Contact: [% cgi.param('legal_sow_vendor_mozcontact') %] +Vendor Contact and Email Address: [% cgi.param('legal_sow_vendor_contact') %] +Description of Services: +[%+ cgi.param('legal_sow_vendor_services') %] +Description of Deliverables: +[%+ cgi.param('legal_sow_vendor_deliverables') %] +Start Date: [% cgi.param('legal_sow_start_date') %] +End Date: [% cgi.param('legal_sow_end_date') %] +Rate of Pay: [% cgi.param('legal_sow_vendor_payment') %] +Basis for Payment: [% cgi.param('legal_sow_vendor_payment_basis') %] +Average/Maximum Hours: [% cgi.param('legal_sow_vendor_hours') %] +Payment Schedule: [% cgi.param('legal_sow_vendor_payment_schedule') %] +Total Not to Exceed Amount: [% cgi.param('legal_sow_vendor_total_max') %] +Special Terms: +[%+ cgi.param('legal_sow_vendor_special_terms') %] +Product Line: [% cgi.param('legal_sow_vendor_product_line') %] +[% END %] diff --git a/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-privacy-policy.txt.tmpl b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-privacy-policy.txt.tmpl new file mode 100644 index 000000000..ff3f5adb6 --- /dev/null +++ b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-privacy-policy.txt.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. + #%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +[% PROCESS "bug/create/comment-moz-project-review.txt.tmpl" %] + +Is there a privacy policy for this new feature/product?: +[%+ cgi.param('privacy_policy_project_link') %] +What assistance do you need from the privacy team (if any)?: +[%+ cgi.param('privacy_policy_project_assistance') %] diff --git a/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-privacy-tech.txt.tmpl b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-privacy-tech.txt.tmpl new file mode 100644 index 000000000..7b72cf1bc --- /dev/null +++ b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-privacy-tech.txt.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. + #%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +[% PROCESS "bug/create/comment-moz-project-review.txt.tmpl" %] diff --git a/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-privacy-vendor.txt.tmpl b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-privacy-vendor.txt.tmpl new file mode 100644 index 000000000..eaf9f12e3 --- /dev/null +++ b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-privacy-vendor.txt.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. + #%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +[% PROCESS "bug/create/comment-moz-project-review.txt.tmpl" %] + +Privacy Policy: [% cgi.param('privacy_policy_vendor_user_data') %] +Vendor's Privacy Policy: [% cgi.param('privacy_policy_vendor_link') %] +Privacy Questionnaire: [% cgi.param('privacy_policy_vendor_questionnaire') %] diff --git a/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-sec-review.txt.tmpl b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-sec-review.txt.tmpl new file mode 100644 index 000000000..029f6df48 --- /dev/null +++ b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review-sec-review.txt.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. + #%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +[% PROCESS "bug/create/comment-moz-project-review.txt.tmpl" %] + +Security Review Questions: + +Affects Products: [% cgi.param('sec_affects_products') %] +Review Due Date: [% cgi.param('sec_review_date') %] +Review Invitees: [% cgi.param('sec_review_invitees') %] +Extra Information: +[%+ cgi.param('sec_review_extra') %] diff --git a/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review.txt.tmpl b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review.txt.tmpl new file mode 100644 index 000000000..07d5fa5ad --- /dev/null +++ b/extensions/MozProjectReview/template/en/default/bug/create/comment-moz-project-review.txt.tmpl @@ -0,0 +1,33 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi %] + +Initial Questions: + +Project/Feature Name: [% cgi.param('short_desc') %] +Tracking [% terms.Bug %] ID:[% cgi.param('tracking_id') %] +Description: +[%+ cgi.param('description') %] +Additional Information: +[%+ cgi.param('additional') %] +Key Initiative: [% cgi.param('key_initiative') == 'Other' + ? cgi.param('key_initiative_other') + : cgi.param('key_initiative') %] +Release Date: [% cgi.param('release_date') %] +Project Status: [% cgi.param('project_status') %] +Mozilla Data: [% cgi.param('mozilla_data') %] +Mozilla Related: [% cgi.param('mozilla_related') %] +Separate Party: [% cgi.param('separate_party') %] +[% IF cgi.param('separate_party') == 'Yes' %] +Type of Relationship: [% cgi.param('relationship_type') %] +Data Access: [% cgi.param('data_access') %] +Privacy Policy: [% cgi.param('privacy_policy') %] +Vendor Cost: [% cgi.param('vendor_cost') %] +[% END %] diff --git a/extensions/MozProjectReview/template/en/default/bug/create/create-moz-project-review.html.tmpl b/extensions/MozProjectReview/template/en/default/bug/create/create-moz-project-review.html.tmpl new file mode 100644 index 000000000..0dd3da5a8 --- /dev/null +++ b/extensions/MozProjectReview/template/en/default/bug/create/create-moz-project-review.html.tmpl @@ -0,0 +1,711 @@ +[%# 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 = "Mozilla Project Review" + style_urls = [ 'extensions/MozProjectReview/web/style/moz_project_review.css' ] + javascript_urls = [ 'js/field.js', 'js/util.js', + 'extensions/MozProjectReview/web/js/moz_project_review.js' ] + yui = [ 'autocomplete', 'calendar' ] +%] + +<p> + <strong>Please use this form for submitting a Mozilla Project Review</strong> + If you have a [% terms.bug %] to file, go <a href="enter_bug.cgi">here</a>. +</p> + +<p> + (<span class="required_star">*</span> = + <span class="required_explanation">Required Field</span>) +</p> + +<form method="post" action="post_bug.cgi" id="mozProjectForm" enctype="multipart/form-data" + onSubmit="return MPR.validateAndSubmit();"> + <input type="hidden" id="product" name="product" value="mozilla.org"> + <input type="hidden" id="component" name="component" value="Project Review"> + <input type="hidden" id="rep_platform" name="rep_platform" value="All"> + <input type="hidden" id="op_sys" name="op_sys" value="All"> + <input type="hidden" id="priority" name="priority" value="--"> + <input type="hidden" id="version" name="version" value="other"> + <input type="hidden" id="format" name="format" value="moz-project-review"> + <input type="hidden" id="bug_severity" name="bug_severity" value="normal"> + <input type="hidden" id="token" name="token" value="[% token FILTER html %]"> + + <div id="initial_questions"> + <div class="header">Initial Questions</div> + + <div id="project_feature_summary_row" class="field_row"> + <span class="field_label required">Project/Feature Name:</span> + <span class="field_data"> + <div class="field_description">Be brief yet descriptive as possible. Include name of product, + feature, or name of vendor involved as well if appropriate.</div> + <input type="text" name="short_desc" id="short_desc" size="60" maxsize="255"> + </span> + </div> + + <div id="visibility_row" class="field_row"> + <span class="field_label required">Project Visibility:</span> + <span class="field_data"> + <div class="field_description"> + Whether project itself is a secret or not (dependent [% terms.bugs %], + e.g. Finance and Sec Review, will be made secure whatever you choose). + </div> + <select name="visibility" id="visibility"> + <option value="">Select One</option> + <option value="public">Public</option> + <option value="private">MoCo Confidentional</option> + </select> + </span> + </div> + + <div id="tracking_id_row" class="field_row"> + <span class="field_label">Tracking [% terms.Bug %] ID:</span> + <span class="field_data"> + <div class="field_description">Master tracking [% terms.bug %] number (if it exists)?</div> + <input type="text" name="tracking_id" id="tracking_id" size="60"> + </span> + </div> + + <div id="contacts_row" class="field_row"> + <span class="field_label required">Points of Contact:</span> + <span class="field_data"> + <div class="field_description">Who are the points of contact for this review?</div> + [% INCLUDE global/userselect.html.tmpl + id => "cc" + name => "cc" + value => "" + size => 60 + classes => ["bz_userfield"] + multiple => 5 + %] + </span> + </div> + + <div id="description_row" class="field_row"> + <span class="field_label required">Description:</span> + <span class="field_data"> + <div class="field_description">Please provide a short description of the feature / application / project / + business relationship (e.g. problem solved, use cases, etc.)</div> + <textarea name="description" id="description" rows="10" cols="80"></textarea> + </span> + </div> + + <div id="additional_row" class="field_row"> + <span class="field_label">Additional Information:</span> + <span class="field_data"> + <div class="field_description">Please provide links to additional information (e.g. feature page, wiki) + if available and not yet included in feature description.)</div> + <textarea name="additional" id="additional" rows="10" cols="80"></textarea> + </span> + </div> + + <div id="key_initiative_row" class="field_row"> + <span class="field_label required">Key Initiative:</span> + <span class="field_data"> + <div class="field_description">Which key initiative does this support?</div> + <select name="key_initiative" id="key_initiative"> + <option value="">Select One</option> + <option value="Firefox Desktop">Firefox Desktop</option> + <option value="Firefox Mobile">Firefox Mobile</option> + <option value="Firefox OS">Firefox OS</option> + <option value="Firefox Platform">Firefox Platform</option> + <option value="Marketplace / Apps">Marketplace / Apps</option> + <option value="Services: Persona">Services: Persona</option> + <option value="Services: WebRTC">Services: WebRTC</option> + <option value="Services: UP">Services: UP</option> + <option value="Services: Social API">Services: Social API</option> + <option value="Labs / Research / H3">Labs / Research / H3</option> + <option value="Business Support">Business Support</option> + <option value="Other">Other</option> + </select> + </span> + </div> + + <div id="key_initiative_other_row" class="field_row bz_default_hidden"> + <span class="field_label"> </span> + <span class="field_data"> + <input type="text" name="key_initiative_other" id="key_initiative_other" size="60"> + </span> + </div> + + <div id="release_date_row" class="field_row"> + <span class="field_label required">Release Date:</span> + <span class="field_data"> + <div class="field_description">What is your overall key release / launch date / go live date?</div> + <input name="release_date" size="20" id="release_date" value="" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_release_date" + onclick="showCalendar('release_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_release_date"></div> + <script type="text/javascript"> + createCalendar('release_date') + </script> + </span> + </div> + + <div id="project_status_row" class="field_row"> + <span class="field_label required">Project Status:</span> + <span class="field_data"> + <div class="field_description">What is the current state of your project?</div> + <select name="project_status" id="project_status"> + <option value="">Select One</option> + <option value="future">Future project under discussion</option> + <option value="active">Active planning</option> + <option value="development">Development</option> + <option value="ready">Ready to launch/commit</option> + <option value="launched">Already launched/committed</option> + </select> + </span> + </div> + + <div id="mozilla_data_row" class="field_row"> + <span class="field_label required">Mozilla Data:</span> + <span class="field_data"> + <div class="field_description">Does this product/service/project access, interact with, or store Mozilla + (customer, contributor, user, employee) data? Example of such data includes + email addresses, first and last name, addresses, phone numbers, credit card data.)</div> + <select name="mozilla_data" id="mozilla_data"> + <option value="">Select One</option> + <option value="Yes">Yes</option> + <option value="No">No</option> + </select> + </span> + </div> + + <div id="mozilla_related_row" class="field_row"> + <span class="field_label">Mozilla Related:</span> + <span class="field_data"> + <div class="field_description">What Mozilla products/services/projects does this product/service/project + integrate with or relate to?</div> + <input type="text" name="mozilla_related" id="mozilla_related" size="60"> + </span> + </div> + + <div id="separate_party_row" class="field_row"> + <span class="field_label required">Separate Party:</span> + <span class="field_data"> + <div class="field_description">Hardware Purchases, + Vendor agreements, NDAs, Contracts etc</div> + <select name="separate_party" id="separate_party"> + <option value="">Select One</option> + <option value="Yes">Yes</option> + <option value="No">No</option> + </select> + </span> + </div> + + <div id="initial_separate_party_questions" class="bz_default_hidden"> + <div id="relation_type_row" class="field_row"> + <span class="field_label required">Type of Relationship:</span> + <span class="field_data"> + <div class="field_description">What type of relationship?</div> + <select name="relationship_type" id="relationship_type"> + <option value="">Select One</option> + <option value="Hardware Purchase">Hardware Purchase</option> + <option value="Vendor/Services">Vendor/Services</option> + <option value="Distribution/Bundling">Distribution/Bundling</option> + <option value="Search">Search</option> + <option value="NDA">NDA</option> + <option value="Other">Other</option> + </select> + </span> + </div> + + <div id="data_access_row" class="field_row"> + <span class="field_label required">Data Access:</span> + <span class="field_data"> + <div class="field_description">Will the other party have access to Mozilla (customer, contributor, user, + employee) data? (If this is for an NDA, choose no)</div> + <select name="data_access" id="data_access"> + <option value="">Select One</option> + <option value="Yes">Yes</option> + <option value="No">No</option> + </select> + </span> + </div> + + <div id="privacy_policy_row" class="field_row"> + <span class="field_label">Privacy Policy:</span> + <span class="field_data"> + <div class="field_description">What is the url for their privacy policy?</div> + <input type="text" name="privacy_policy" id="privacy_policy" size="60"> + </span> + </div> + + <div id="vendor_cost_row" class="field_row"> + <span class="field_label required">Vendor Cost:</span> + <span class="field_data"> + <div class="field_description">What is the anticipated cost of the vendor relationship? + (Entire Contract Cost, not monthly cost)</div> + <select name="vendor_cost" id="vendor_cost"> + <option value="">Select One</option> + <option value="N/A">N/A</option> + <option value="<= $25,000"><= $25,000</option> + <option value="> $25,000">> $25,000</option> + </select> + </span> + </div> + + <div id="po_needed_row" class="field_row bz_default_hidden"> + <span class="field_label required">PO Needed?:</span> + <span class="field_data"> + <select name="po_needed" id="po_needed"> + <option value="">Select One</option> + <option value="Yes">Yes</option> + <option value="No">No</option> + </select> + </span> + </div> + </div> + </div> + + <div id="sec_review_questions" class="bz_default_hidden"> + <div class="header">Security Review</div> + + <div id="sec_review_affects_products_row"> + <span class="field_label">Affects Products:</span> + <span class="field_data"> + <div class="field_description">Does this feature or code change affect Firefox, Thunderbird or any + product or service the Mozilla ships to end users?</div> + <select name="sec_affects_products" id="sec_affects_products"> + <option value="">Select One</option> + <option value="Yes">Yes</option> + <option value="No">No</option> + </select> + </span> + </div> + + <div id="sec_review_date_row" class="field_row"> + <span class="field_label">Review Due Date:</span> + <span class="field_data"> + <div class="field_description">When would you like the review to be completed? + (<a href="https://mail.mozilla.com/home/ckoenig@mozilla.com/Security%20Review.html" + target="_blank">more info</a>)</div> + <input name="sec_review_date" size="20" id="sec_review_date" value="" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_sec_review_date" + onclick="showCalendar('sec_review_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_sec_review_date"></div> + <script type="text/javascript"> + createCalendar('sec_review_date') + </script> + </span> + </div> + + <div id="sec_review_invitees_row" class="field_row"> + <span class="field_label">Review Invitees:</span> + <span class="field_data"> + <div class="field_description">Whom should be invited to the review?</div> + <input type="text" name="sec_review_invitees" id="sec_review_invitees" size="60"> + </span> + </div> + + <div id="sec_review_extra_row" class="field_row"> + <span class="field_label">Extra Information:</span> + <span class="field_data"> + <div class="field_description">If you feel something is missing here or you would like to provide other + kind of feedback, feel free to do so here?</div> + <textarea name="sec_review_extra" id="sec_review_extra" rows="10" cols="80"></textarea> + </span> + </div> + </div> + + <div id="privacy_policy_project_questions" class="bz_default_hidden"> + <div class="header">Privacy (Policy/Project)</div> + + <div id="privacy_policy_project_link_row" class="field_row"> + <span class="field_label">Is there a privacy policy for<br>this new feature/product?:</span> + <span class="field_data"> + <div class="field_description">If yes, please enter a url to the policy.</div> + <input type="text" name="privacy_policy_project_link" id="privacy_policy_project_link" size="60"> + </span> + </div> + + <div id="privacy_policy_project_assistance_row" class="field_row"> + <span class="field_label required">What assistance do you need from the privacy team (if any)?:</span> + <span class="field_data"> + <textarea name="privacy_policy_project_assistance" id="privacy_policy_project_assistance" rows="10" cols="80"></textarea> + </span> + </div> + </div> + + <div id="privacy_policy_vendor_questions" class="bz_default_hidden"> + <div class="header">Privacy (Policy/Vendor)</div> + + <div id="privacy_policy_vendor_user_data_row" class="field_row"> + <span class="field_label">Privacy Policy:</span> + <span class="field_data"> + <div class="field_description">Will the vendor have access to Mozilla (customer, contributor, user, employee) data?</div> + <select name="privacy_policy_vendor_user_data" id="privacy_policy_vendor_user_data"> + <option value="">Select One</option> + <option value="Yes">Yes</option> + <option value="No">No</option> + </select> + </span> + </div> + + <div id="privacy_policy_vendor_extra" class="bz_default_hidden"> + <div id="privacy_policy_vendor_link_row" class="field_row"> + <span class="field_label">Vendor's Privacy Policy:</span> + <span class="field_data"> + <div class="field_description">Please provide link to vendor's privacy policy</div> + <input type="text" name="privacy_policy_vendor_link" id="privacy_policy_vendor_link" size="60"> + </span> + </div> + + <div id="privacy_policy_vendor_questionnaire_row" class="field_row"> + <span class="field_label">Privacy Questionnaire:</span> + <span class="field_data"> + <div class="field_description">Has vendor completed Mozilla Vendor Privacy Questionnaire?</div> + <select name="privacy_policy_vendor_questionnaire" id="privacy_policy_vendor_questionnaire"> + <option value="">Select One</option> + <option value="Yes">Yes</option> + <option value="No">No</option> + </select> + </span> + </div> + </div> + </div> + + <div id="legal_questions" class="bz_default_hidden"> + <div class="header">Legal</div> + + <div id="legal_priority_row" class="field_row"> + <span class="field_label required">Priority:</span> + <span class="field_data"> + <div class="field_description">Priority to your team</div> + <select name="legal_priority" id="legal_priority"> + <option value="">Select One</option> + <option value="high">High</option> + <option value="medium">Medium</option> + <option value="low">Low</option> + </select> + </span> + </div> + + <div id="legal_timeframe_row" class="field_row"> + <span class="field_label required">Time Frame For Completion<br>of Legal Portion?:</span> + <span class="field_data"> + <div class="field_description">What is the desired time frame to have the legal component/involvement completed.</div> + <select name="legal_timeframe" id="legal_timeframe"> + <option value="2 days">2 days</option> + <option value="a week">a week</option> + <option value="2-4 weeks">2-4 weeks</option> + <option value="will take a while but please start soon"> + will take a while but please start soon</option> + <option value="no rush" selected>no rush</option> + </select> + </span> + </div> + + <div id="legal_cc_row" class="field_row"> + <span class="field_label">Cc:</span> + <span class="field_data"> + [% INCLUDE global/userselect.html.tmpl + id => "legal_cc" + name => "legal_cc" + value => "" + size => 60 + classes => ["bz_userfield"] + multiple => 5 + %] + </span> + </div> + + <div id="legal_other_party_row" class="field_row"> + <span class="field_label">Other Party:</span> + <span class="field_data"> + <div class="field_description">Name of other party involved</div> + <input type="text" name="legal_other_party" id="legal_other_party" size="60"> + </span> + </div> + + <div id="legal_help_from_legal_row" class="field_row"> + <span class="field_label required">What help do you<br>need from Legal?</span> + <span class="field_data"> + <div class="field_description"> + Please explain specifically what help you need from Legal. If none, put "No Legal help needed."</div> + <textarea name="legal_help_from_legal" id="legal_help_from_legal" rows="10" cols="80"></textarea> + </span> + </div> + + <div id="legal_sow_questions" class="bz_default_hidden"> + <div class=field_row"> + <span class="field_label">SOW Details:</span> + <span class="field_data"> + Please provide the following information for the SOW + </span> + </div> + + <div id="legal_sow_vendor_name_row" class="field_row"> + <span class="field_label required">Legal Vendor Name:</span> + <span class="field_data"> + <input type="text" name="legal_sow_vendor_name" id="legal_sow_vendor_name" size="60"> + </span> + </div> + + <div id="legal_sow_vendor_address_row" class="field_row"> + <span class="field_label required">Vendor Address:</span> + <span class="field_data"> + <textarea name="legal_sow_vendor_address" id="legal_sow_vendor_address" rows="10" cols="80"></textarea> + </span> + </div> + + <div id="legal_sow_vendor_email_row" class="field_row"> + <span class="field_label required">Vendor Email for Notices:</span> + <span class="field_data"> + <input type="text" name="legal_sow_vendor_email" id="legal_sow_vendor_email" size="60"> + </span> + </div> + + <div id="legal_sow_vendor_mozcontact_row" class="field_row"> + <span class="field_label required">Main Mozilla Contact:</span> + <span class="field_data"> + [% INCLUDE global/userselect.html.tmpl + id => "legal_sow_vendor_mozcontact" + name => "legal_sow_vendor_mozcontact" + value => "" + size => 60 + classes => ["bz_userfield"] + multiple => 5 + %] + </span> + </div> + + <div id="legal_sow_vendor_contact_row" class="field_row"> + <span class="field_label required">Main Vendor Contact and Email:</span> + <span class="field_data"> + <input type="text" name="legal_sow_vendor_contact" id="legal_sow_vendor_contact" size="60"> + </span> + </div> + + <div id="legal_sow_vendor_services_row" class="field_row"> + <span class="field_label required">Vendor Services to be Provided:</span> + <span class="field_data"> + <textarea name="legal_sow_vendor_services" id="legal_sow_vendor_services" rows="10" cols="80"></textarea> + </span> + </div> + + <div id="legal_sow_vendor_deliverables_row" class="field_row"> + <span class="field_label required">Description of Deliverables:</span> + <span class="field_data"> + <textarea name="legal_sow_vendor_deliverables" id="legal_sow_vendor_deliverables" rows="10" cols="80"></textarea> + </span> + </div> + + <div id="legal_sow_start_date_row" class="field_row"> + <span class="field_label required">Start Date:</span> + <span class="field_data"> + <input name="legal_sow_start_date" size="20" id="legal_sow_start_date" value="" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_legal_sow_start_date" + onclick="showCalendar('legal_sow_start_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_legal_sow_start_date"></div> + <script type="text/javascript"> + createCalendar('legal_sow_start_date') + </script> + </span> + </div> + + <div id="legal_sow_end_date_row" class="field_row"> + <span class="field_label required">End Date:</span> + <span class="field_data"> + <input name="legal_sow_end_date" size="20" id="legal_sow_end_date" value="" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_legal_sow_end_date" + onclick="showCalendar('legal_sow_end_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_legal_sow_end_date"></div> + <script type="text/javascript"> + createCalendar('legal_sow_end_date') + </script> + </span> + </div> + + <div id="legal_sow_vendor_payment_row" class="field_row"> + <span class="field_label required">Rate of Pay:</span> + <span class="field_data"> + <div class="field_description">Include currency</div> + <input type="text" name="legal_sow_vendor_payment" id="legal_sow_vendor_payment" size="60"> + </span> + </div> + + <div id="legal_sow_vendor_payment_basis_row" class="field_row"> + <span class="field_label required">Basis for Payment:</span> + <span class="field_data"> + <div class="field_description">hourly, flat fee, per deliverable, etc.</div> + <input type="text" name="legal_sow_vendor_payment_basis" id="legal_sow_vendor_payment_basis" size="60"> + </span> + </div> + + <div id="legal_sow_vendor_hours_row" class="field_row"> + <span class="field_label">Average/Max Hours:</span> + <span class="field_data"> + <div class="field_description">If hourly, either average or maximum hours per week/month</div> + <input type="text" name="legal_sow_vendor_hours" id="legal_sow_vendor_hours" size="60"> + </span> + </div> + + <div id="legal_sow_vendor_payment_schedule_row" class="field_row"> + <span class="field_label required">Payment Schedule:</span> + <span class="field_data"> + <div class="field_description">"When will we make payments? E.g. every 30 days; half due up front, + half on completion; following acceptance of each deliverable, etc.</div> + <input type="text" name="legal_sow_vendor_payment_schedule" id="legal_sow_vendor_payment_schedule" size="60"> + </span> + </div> + + <div id="legal_sow_vendor_total_max_row" class="field_row"> + <span class="field_label required">Total Not to Exceed Amount:</span> + <span class="field_data"> + <input type="text" name="legal_sow_vendor_total_max" id="legal_sow_vendor_total_max" size="60"> + </span> + </div> + + <div id="legal_sow_vendor_special_terms_row" class="field_row"> + <span class="field_label">Any Special Terms:</span> + <span class="field_data"> + <textarea name="legal_sow_vendor_special_terms" id="legal_sow_vendor_special_terms" rows="10" cols="80"></textarea> + </span> + </div> + + <div id="legal_sow_vendor_product_line_row" class="field_row"> + <span class="field_label required">Product Line:</span> + <span class="field_data"> + <select id="legal_sow_vendor_product_line" name="legal_sow_vendor_product_line"> + <option value="">Select One</option> + <option value="Firefox OS">Firefox OS</option> + <option value="Firefox Desktop">Firefox Desktop</option> + <option value="Firefox Mobile">Firefox Mobile</option> + <option value="Firefox Platform">Firefox Platform</option> + <option value="Marketplace/Apps">Marketplace/Apps</option> + <option value="Lab/Research">Lab/Research</option> + <option value="Services">Services</option> + <option value="Product Support">Product Support</option> + <option value="Corp Support">Corp Support</option> + </select> + </span> + </div> + </div> + + <div id="legal_vendor_services_where_row" class="field_row bz_default_hidden"> + <span class="field_label required">Vendor Services Location:</span> + <span class="field_data"> + <div class="field_description">Where will the services primarily be provided?</div> + <select name="legal_vendor_services_where" id="legal_vendor_services_where"> + <option value="">Select One</option> + <option value="U.S.">U.S.</option> + <option value="Europe">Europe</option> + <option value="Canada">Canada</option> + <option value="Global">Global</option> + <option value="Another region of the world">Another region of the world</option> + <option value="A single country">A single country</option> + </select> + <br> + <input class="bz_default_hidden" type="text" + name="legal_vendor_single_country" id="legal_vendor_single_country" size="60"> + </span> + </div> + </div> + + <div id="finance_questions" class="bz_default_hidden"> + <div class="header">Finance</div> + + <div id="finance_purchase_vendor_row" class="field_row"> + <span class="field_label required">Vendor:</span> + <span class="field_data"> + <input type="text" name="finance_purchase_vendor" maxsize="255" size="60" id="finance_purchase_vendor"> + </span> + </div> + + <div id="finance_purchase_inbudget_row" class="field_row"> + <span class="field_label required">Is this line item in budget?:</span> + <span class="field_data"> + <div class="field_description">If not, please explain purchase need and why not in budget.</div> + <textarea name="finance_purchase_inbudget" id="finance_purchase_inbudget" rows="10" cols="80"></textarea> + </span> + </div> + + <div id="finance_purchase_what_row" class="field_row"> + <span class="field_label required">What is the purchase for?:</span> + <span class="field_data"> + <textarea name="finance_purchase_what" id="finance_purchase_what" rows="10" cols="80"></textarea> + </span> + </div> + + <div id="finance_purchase_why_row" class="field_row"> + <span class="field_label required">Why is the purchase needed?:</span> + <span class="field_data"> + <textarea name="finance_purchase_why" id="finance_purchase_why" rows="10" cols="80"></textarea> + </span> + </div> + + <div id="finance_purchase_risk_row" class="field_row"> + <span class="field_label required">What is the risk<br>if not purchased?:</span> + <span class="field_data"> + <textarea name="finance_purchase_risk" id="finance_purchase_risk" rows="10" cols="80"></textarea> + </span> + </div> + + <div id="finance_purchase_alternative_row" class="field_row"> + <span class="field_label required">What is the alternative?:</span> + <span class="field_data"> + <textarea name="finance_purchase_alternative" id="finance_purchase_alternative" rows="10" cols="80"></textarea> + </span> + </div> + + <div id="finance_purchase_urgency_row" class="field_row"> + <span class="field_label required">When do the items need<br>to be purchased by?:</span> + <span class="field_data"> + <select name="finance_purchase_urgency" id="finance_purchase_urgency"> + <option value="3 to 5 days">3 to 5 days</option> + <option value="7 to 10 days">7 to 10 days</option> + <option value="two weeks">two weeks</option> + <option value="no rush" selected>no rush</option> + </select> + </span> + </div> + + <div id="finance_shipment_address_row" class="field_row"> + <span class="field_label">What is the shipment address<br>(if applicable)?:</span> + <span class="field_data"> + <div class="field_description">Please enter the full address.</div> + <textarea name="finance_shipment_address" id="finance_shipment_address" rows="10" cols="80"></textarea> + </span> + </div> + + <div id="finance_purchase_cost_row" class="field_row"> + <span class="field_label required">Total Cost:</span> + <span class="field_data"> + <div class="field_description">Please include currency type (e.g. USD, EUR)</div> + <input type="text" name="finance_purchase_cost" id="finance_purchase_cost" size="60"> + </span> + </div> + </div> + + <input type="submit" id="commit" value="Submit Review"> +</form> + +<p> + Thanks for contacting us. You will be notified by email of any progress made in resolving your request. +</p> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/MozProjectReview/template/en/default/hook/global/messages-messages.html.tmpl b/extensions/MozProjectReview/template/en/default/hook/global/messages-messages.html.tmpl new file mode 100644 index 000000000..ac7c1f6c7 --- /dev/null +++ b/extensions/MozProjectReview/template/en/default/hook/global/messages-messages.html.tmpl @@ -0,0 +1,13 @@ +[%# 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 message_tag == "moz_project_review_creation_failed" %] + The parent [% terms.bug %] was created successfully, but creation of + the dependent [% terms.bugs %] failed. The error has been logged + and no further action is required at this time. +[% END %] diff --git a/extensions/MozProjectReview/web/js/moz_project_review.js b/extensions/MozProjectReview/web/js/moz_project_review.js new file mode 100644 index 000000000..22e79f707 --- /dev/null +++ b/extensions/MozProjectReview/web/js/moz_project_review.js @@ -0,0 +1,267 @@ +/* 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 MPR = { + required_fields: { + "initial_questions": { + "short_desc": "Please enter a value for project or feature name in the initial questions section", + "visibility": "Please select a value for project visibility in the initial questions section", + "cc": "Please enter a value for points of contact in the initial questions section", + "key_initiative": "Please select a value for key initiative in the initial questions section", + "release_date": "Please enter a value for release date in the initial questions section", + "project_status": "Please select a value for project status in the initial questions section", + "mozilla_data": "Please select a value for mozilla data in the initial questions section", + "separate_party": "Please select a value for separate party in the initial questions section" + }, + "finance_questions": { + "finance_purchase_vendor": "Please enter a value for vendor in the finance questions section", + "finance_purchase_what": "Please enter a value for what in the finance questions section", + "finance_purchase_why": "Please enter a value for why in the finance questions section", + "finance_purchase_risk": "Please enter a value for risk in the finance questions section", + "finance_purchase_alternative": "Please enter a value for alternative in the finance questions section", + "finance_purchase_inbudget": "Please enter a value for in budget in the finance questions section", + "finance_purchase_urgency": "Please select a value for urgency in the finance questions section", + "finance_purchase_cost": "Please enter a value for total cost in the finance questions section" + }, + "legal_questions": { + "legal_priority": "Please select a value for priority in the legal questions section", + "legal_timeframe": "Please select a value for timeframe in the legal questions section", + "legal_help_from_legal": "Please describe the help needed from the Legal department" + }, + "legal_sow_questions": { + "legal_sow_vendor_name": "Please enter a value for SOW legal vendor name", + "legal_sow_vendor_address": "Please enter a value for SOW vendor address", + "legal_sow_vendor_email": "Please enter a value for SOW vendor email for notices", + "legal_sow_vendor_mozcontact": "Please enter a value for SOW Mozilla contact", + "legal_sow_vendor_contact": "Please enter a value for SOW vendor contact and email address", + "legal_sow_vendor_services": "Please enter a value for SOW vendor services description", + "legal_sow_vendor_deliverables": "Please enter a value for SOW vendor deliverables description", + "legal_sow_start_date": "Please enter a value for SOW vendor start date", + "legal_sow_end_date": "Please enter a value for SOW vendor end date", + "legal_sow_vendor_payment": "Please enter a value for SOW vendor payment amount", + "legal_sow_vendor_payment_basis": "Please enter a value for SOW vendor payment basis", + "legal_sow_vendor_payment_schedule": "Please enter a value for SOW vendor payment schedule", + "legal_sow_vendor_total_max": "Please enter a value for SOW vendor maximum total to be paid", + "legal_sow_vendor_product_line": "Please enter a value for SOW vendor product line" + }, + "privacy_policy_project_questions": { + "privacy_policy_project_assistance": "Please enter a value for any assistance needed in the privacy policy project questions section", + "privacy_policy_project_link": "Please enter a value for project link in the privacy policy project questions section" + } + }, + + select_inputs: [ + 'key_initiative', + 'project_status', + 'mozilla_data', + 'separate_party', + 'relationship_type', + 'data_access', + 'vendor_cost', + 'po_needed', + 'sec_affects_products', + 'privacy_policy_vendor_user_data', + 'privacy_policy_vendor_questionnaire', + 'legal_priority', + 'legal_sow_vendor_product_line', + 'legal_vendor_services_where', + 'finance_purchase_urgency' + ], + + init: function () { + // Bind the updateSections function to each of the inputs desired + for (var i = 0, l = this.select_inputs.length; i < l; i++) { + Event.on(this.select_inputs[i], 'change', MPR.updateSections); + } + MPR.updateSections(); + }, + + fieldValue: function (id) { + var field = Dom.get(id); + if (!field) return ''; + if (field.type == 'text' + || field.type == 'textarea') + { + return field.value; + } + return field.options[field.selectedIndex].value; + }, + + updateSections: function () { + // Sections that will be hidden/shown based on the input values + // Start out as all false except for initial questions which is always visible + var page_sections = { + initial_questions: true, + key_initiative_other_row: false, + initial_separate_party_questions: false, + finance_questions: false, + po_needed_row: false, + legal_questions: false, + legal_sow_questions: false, + legal_vendor_single_country: false, + legal_vendor_services_where_row: false, + sec_review_questions: false, + privacy_policy_project_questions: false, + privacy_policy_vendor_questions: false, + privacy_policy_vendor_extra: false + }; + + if (MPR.fieldValue('key_initiative') == 'Other') { + page_sections.key_initiative_other_row = true; + } + + if (MPR.fieldValue('mozilla_data') == 'Yes') { + page_sections.legal_questions = true; + page_sections.privacy_policy_project_questions = true; + page_sections.sec_review_questions = true; + } + + if (MPR.fieldValue('separate_party') == 'Yes') { + page_sections.initial_separate_party_questions = true; + + if (MPR.fieldValue('relationship_type') + && MPR.fieldValue('relationship_type') != 'Hardware Purchase') + { + page_sections.legal_questions = true; + } + + if (MPR.fieldValue('relationship_type') == 'Vendor/Services' + || MPR.fieldValue('relationship_type') == 'Distribution/Bundling') + { + page_sections.legal_sow_questions = true; + page_sections.legal_vendor_services_where_row = true; + } + + if (MPR.fieldValue('relationship_type') == 'Hardware Purchase') { + page_sections.finance_questions = true; + } + + if (MPR.fieldValue('data_access') == 'Yes') { + page_sections.legal_questions = true; + page_sections.sec_review_questions = true; + page_sections.privacy_policy_vendor_questions = true; + } + + if (MPR.fieldValue('vendor_cost') == '<= $25,000') { + page_sections.po_needed_row = true; + } + + if (MPR.fieldValue('po_needed') == 'Yes') { + page_sections.finance_questions = true; + } + + if (MPR.fieldValue('vendor_cost') == '> $25,000') { + page_sections.finance_questions = true; + } + } + + if (MPR.fieldValue('legal_vendor_services_where') == 'A single country') { + page_sections.legal_vendor_single_country = true; + } + + if (MPR.fieldValue('privacy_policy_vendor_user_data') == 'Yes') { + page_sections.privacy_policy_vendor_extra = true; + } + + // Toggle the individual page_sections + for (section in page_sections) { + MPR.toggleShowSection(section, page_sections[section]); + } + }, + + toggleShowSection: function (section, show) { + if (show) { + Dom.removeClass(section, 'bz_default_hidden'); + } + else { + Dom.addClass(section ,'bz_default_hidden'); + } + }, + + validateAndSubmit: function () { + var alert_text = ''; + var section = ''; + for (section in this.required_fields) { + if (!Dom.hasClass(section, 'bz_default_hidden')) { + var field = ''; + for (field in MPR.required_fields[section]) { + if (!MPR.isFilledOut(field)) { + alert_text += this.required_fields[section][field] + "\n"; + } + } + } + } + + // Special case checks + if (MPR.fieldValue('relationship_type') == 'Vendor/Services' + && MPR.fieldValue('legal_vendor_services_where') == '') + { + alert_text += "Please select a value for vendor services where\n"; + } + + if (MPR.fieldValue('relationship_type') == 'Vendor/Services' + && MPR.fieldValue('legal_vendor_services_where') == 'A single country' + && MPR.fieldValue('legal_vendor_single_country') == '') + { + alert_text += "Please select a value for vendor services where single country\n"; + } + + if (MPR.fieldValue('key_initiative') == 'Other') { + if (!MPR.isFilledOut('key_initiative_other')) { + alert_text += "Please enter a value for key initiative in the initial questions section\n"; + } + } + + if (MPR.fieldValue('separate_party') == 'Yes') { + if (!MPR.isFilledOut('relationship_type')) { + alert_text += "Please select a value for type of relationship\n"; + } + if (!MPR.isFilledOut('data_access')) { + alert_text += "Please select a value for data access\n"; + } + if (!MPR.isFilledOut('vendor_cost')) { + alert_text += "Please select a value for vendor cost\n"; + } + } + + if (MPR.fieldValue('vendor_cost') == '<= $25,000' + && MPR.fieldValue('po_needed') == '') + { + alert_text += "Please select whether a PO is needed or not\n"; + } + + if (alert_text) { + alert(alert_text); + return false; + } + + var visibility = MPR.fieldValue('visibility'); + if (visibility == 'private') { + var groups = document.createElement('input'); + groups.type = 'hidden'; + groups.name = 'groups'; + groups.value = 'mozilla-employee-confidential'; + Dom.get('mozProjectForm').appendChild(groups); + } + + return true; + }, + + //Takes a DOM element id and makes sure that it is filled out + isFilledOut: function (elem_id) { + var str = MPR.fieldValue(elem_id); + return str.length > 0 ? true : false; + } +}; + +Event.onDOMReady(function () { + MPR.init(); +}); diff --git a/extensions/MozProjectReview/web/style/moz_project_review.css b/extensions/MozProjectReview/web/style/moz_project_review.css new file mode 100644 index 000000000..cf1c3a8b8 --- /dev/null +++ b/extensions/MozProjectReview/web/style/moz_project_review.css @@ -0,0 +1,48 @@ +/* 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. */ + +.header { + width: 95%; + border-bottom: 1px solid rgb(116,126,147); + font-size: 1.5em; + color: rgb(102, 100, 88); + padding-bottom: 5px; + margin-bottom: 5px; + margin-top: 12px; +} + +.field_row { + width: 100%; + min-width: 700px; + clear: both; +} + +.field_label { + float: left; + width: 20%; +} + +.field_data { + float: left; + width: 75%; + margin-left: 5px; + margin-bottom: 5px; +} + +.field_description { + font-style: italic; + font-size: 90%; + color: rgb(102, 100, 88); +} + +span.required:before { + content: "* "; +} + +span.required:before, span.required_star { + color: red; +} diff --git a/extensions/MyDashboard/Config.pm b/extensions/MyDashboard/Config.pm new file mode 100644 index 000000000..7c14936ff --- /dev/null +++ b/extensions/MyDashboard/Config.pm @@ -0,0 +1,14 @@ +# 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::MyDashboard; + +use strict; + +use constant NAME => 'MyDashboard'; + +__PACKAGE__->NAME; diff --git a/extensions/MyDashboard/Extension.pm b/extensions/MyDashboard/Extension.pm new file mode 100644 index 000000000..082f1c562 --- /dev/null +++ b/extensions/MyDashboard/Extension.pm @@ -0,0 +1,126 @@ +# 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::MyDashboard; + +use strict; + +use base qw(Bugzilla::Extension); + +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Search::Saved; + +use Bugzilla::Extension::MyDashboard::Queries qw(QUERY_DEFS); + +our $VERSION = BUGZILLA_VERSION; + +################ +# Installation # +################ + +sub db_schema_abstract_schema { + my ($self, $args) = @_; + + my $schema = $args->{schema}; + + $schema->{'mydashboard'} = { + FIELDS => [ + namedquery_id => {TYPE => 'INT3', NOTNULL => 1, + REFERENCES => {TABLE => 'namedqueries', + COLUMN => 'id', + DELETE => 'CASCADE'}}, + user_id => {TYPE => 'INT3', NOTNULL => 1, + REFERENCES => {TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE'}}, + ], + INDEXES => [ + mydashboard_namedquery_id_idx => {FIELDS => [qw(namedquery_id user_id)], + TYPE => 'UNIQUE'}, + mydashboard_user_id_idx => ['user_id'], + ], + }; +} + +########### +# Objects # +########### + +BEGIN { + *Bugzilla::Search::Saved::in_mydashboard = \&_in_mydashboard; +} + +sub _in_mydashboard { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + return $self->{'in_mydashboard'} if exists $self->{'in_mydashboard'}; + $self->{'in_mydashboard'} = $dbh->selectrow_array(" + SELECT 1 FROM mydashboard WHERE namedquery_id = ? AND user_id = ?", + undef, $self->id, Bugzilla->user->id); + return $self->{'in_mydashboard'}; +} + +############# +# Templates # +############# + +sub page_before_template { + my ($self, $args) = @_; + my $page = $args->{'page_id'}; + my $vars = $args->{'vars'}; + + return if $page ne 'mydashboard.html'; + + # require user to be logged in for this page + Bugzilla->login(LOGIN_REQUIRED); + + $vars->{queries} = [ QUERY_DEFS ]; +} + +######### +# Hooks # +######### + +sub user_preferences { + my ($self, $args) = @_; + my $tab = $args->{'current_tab'}; + return unless $tab eq 'saved-searches'; + + my $save = $args->{'save_changes'}; + my $handled = $args->{'handled'}; + my $vars = $args->{'vars'}; + + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + my $params = Bugzilla->input_params; + + if ($save) { + my $sth_insert_fp = $dbh->prepare('INSERT INTO mydashboard + (namedquery_id, user_id) + VALUES (?, ?)'); + my $sth_delete_fp = $dbh->prepare('DELETE FROM mydashboard + WHERE namedquery_id = ? + AND user_id = ?'); + foreach my $q (@{$user->queries}) { + if (defined $params->{'in_mydashboard_' . $q->id}) { + $sth_insert_fp->execute($q->id, $user->id) if !$q->in_mydashboard; + } + else { + $sth_delete_fp->execute($q->id, $user->id) if $q->in_mydashboard; + } + } + } +} + +sub webservice { + my ($self, $args) = @_; + my $dispatch = $args->{dispatch}; + $dispatch->{MyDashboard} = "Bugzilla::Extension::MyDashboard::WebService"; +} + +__PACKAGE__->NAME; diff --git a/extensions/MyDashboard/lib/Queries.pm b/extensions/MyDashboard/lib/Queries.pm new file mode 100644 index 000000000..9dff5abe4 --- /dev/null +++ b/extensions/MyDashboard/lib/Queries.pm @@ -0,0 +1,313 @@ +# 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::MyDashboard::Queries; + +use strict; + +use Bugzilla; +use Bugzilla::Bug; +use Bugzilla::CGI; +use Bugzilla::Search; +use Bugzilla::Flag; +use Bugzilla::Status qw(is_open_state); +use Bugzilla::Util qw(format_time datetime_from); + +use Bugzilla::Extension::MyDashboard::Util qw(open_states quoted_open_states); +use Bugzilla::Extension::MyDashboard::TimeAgo qw(time_ago); + +use DateTime; + +use base qw(Exporter); +our @EXPORT = qw( + QUERY_ORDER + SELECT_COLUMNS + QUERY_DEFS + query_bugs + query_flags +); + +# Default sort order +use constant QUERY_ORDER => ("changeddate desc", "bug_id"); + +# List of columns that we will be selecting. In the future this should be configurable +# Share with buglist.cgi? +use constant SELECT_COLUMNS => qw( + bug_id + bug_status + short_desc + changeddate +); + +sub QUERY_DEFS { + my $user = Bugzilla->user; + + my @query_defs = ( + { + name => 'assignedbugs', + heading => 'Assigned to You', + description => 'The bug has been assigned to you, and it is not resolved or closed.', + params => { + 'bug_status' => ['__open__'], + 'emailassigned_to1' => 1, + 'emailtype1' => 'exact', + 'email1' => $user->login + } + }, + { + name => 'newbugs', + heading => 'New Reported by You', + description => 'You reported the bug; it\'s unconfirmed or new. No one has assigned themselves to fix it yet.', + params => { + 'bug_status' => ['UNCONFIRMED', 'NEW'], + 'emailreporter1' => 1, + 'emailtype1' => 'exact', + 'email1' => $user->login + } + }, + { + name => 'inprogressbugs', + heading => "In Progress Reported by You", + description => 'A developer accepted your bug and is working on it. (It has someone in the "Assigned to" field.)', + params => { + 'bug_status' => [ map { $_->name } grep($_->name ne 'UNCONFIRMED' && $_->name ne 'NEW', open_states()) ], + 'emailreporter1' => 1, + 'emailtype1' => 'exact', + 'email1' => $user->login + } + }, + { + name => 'openccbugs', + heading => "You Are CC'd On", + description => 'You are in the CC list of the bug, so you are watching it.', + params => { + 'bug_status' => ['__open__'], + 'emailcc1' => 1, + 'emailtype1' => 'exact', + 'email1' => $user->login + } + }, + { + name => 'mentorbugs', + heading => "You Are a Mentor", + description => 'You are one of the mentors for the bug.', + params => { + 'bug_status' => ['__open__'], + 'emailbug_mentor1' => 1, + 'emailtype1' => 'exact', + 'email1' => $user->login + } + }, + { + name => 'lastvisitedbugs', + heading => 'Updated Since Last Visit', + description => 'Bugs updated since last visited', + params => { + o1 => 'lessthan', + v1 => '%last_changed%', + f1 => 'last_visit_ts', + }, + }, + { + name => 'nevervisitbugs', + heading => 'Involved with and Never Visited', + description => "Bugs you've never visited, but are involved with", + params => { + query_format => "advanced", + bug_status => ['__open__'],, + o1 => "isempty", + f1 => "last_visit_ts", + j2 => "OR", + f2 => "OP", + f3 => "assigned_to", + o3 => "equals", + v3 => $user->login, + o4 => "equals", + f4 => "reporter", + v4 => $user->login, + v5 => $user->login, + f5 => "qa_contact", + o5 => "equals", + o6 => "equals", + f6 => "cc", + v6 => $user->login, + f7 => "bug_mentor", + o7 => "equals", + v7 => $user->login, + f9 => "CP", + }, + }, + ); + + if (Bugzilla->params->{'useqacontact'}) { + push(@query_defs, { + name => 'qacontactbugs', + heading => 'You Are QA Contact', + description => 'You are the qa contact on this bug, and it is not resolved or closed.', + params => { + 'bug_status' => ['__open__'], + 'emailqa_contact1' => 1, + 'emailtype1' => 'exact', + 'email1' => $user->login + } + }); + } + + if ($user->showmybugslink) { + my $query = Bugzilla->params->{mybugstemplate}; + my $login = $user->login; + $query =~ s/%userid%/$login/; + $query =~ s/^buglist.cgi\?//; + push(@query_defs, { + name => 'mybugs', + heading => "My Bugs", + saved => 1, + params => $query, + }); + } + + foreach my $q (@{$user->queries}) { + next if !$q->in_mydashboard; + push(@query_defs, { name => $q->name, + saved => 1, + params => $q->url }); + } + + return @query_defs; +} + +sub query_bugs { + my $qdef = shift; + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + my $datetime_now = DateTime->now(time_zone => $user->timezone); + + ## HACK to remove POST + delete $ENV{REQUEST_METHOD}; + + my $params = new Bugzilla::CGI($qdef->{params}); + + my $search = new Bugzilla::Search( fields => [ SELECT_COLUMNS ], + params => scalar $params->Vars, + order => [ QUERY_ORDER ]); + my $data = $search->data; + + my @bugs; + foreach my $row (@$data) { + my $bug = {}; + foreach my $column (SELECT_COLUMNS) { + $bug->{$column} = shift @$row; + if ($column eq 'changeddate') { + my $datetime = datetime_from($bug->{$column}); + $datetime->set_time_zone($user->timezone); + $bug->{$column} = $datetime->strftime('%Y-%m-%d %T %Z'); + $bug->{'changeddate_fancy'} = time_ago($datetime, $datetime_now); + + # Provide a version for use by Bug.history and also for looking up last comment. + # We have to set to server's timezone and also subtract one second. + $datetime->set_time_zone(Bugzilla->local_timezone); + $datetime->subtract(seconds => 1); + $bug->{changeddate_api} = $datetime->strftime('%Y-%m-%d %T'); + } + } + push(@bugs, $bug); + } + + return (\@bugs, $params->canonicalise_query()); +} + +sub query_flags { + my ($type) = @_; + my $user = Bugzilla->user; + my $dbh = Bugzilla->dbh; + my $datetime_now = DateTime->now(time_zone => $user->timezone); + + ($type ne 'requestee' || $type ne 'requester') + || ThrowCodeError('param_required', { param => 'type' }); + + my $match_params = { status => '?' }; + + if ($type eq 'requestee') { + $match_params->{'requestee_id'} = $user->id; + } + else { + $match_params->{'setter_id'} = $user->id; + } + + my $matched = Bugzilla::Flag->match($match_params); + + return [] if !@$matched; + + my @unfiltered_flags; + my %all_bugs; # Use hash to filter out duplicates + foreach my $flag (@$matched) { + next if ($flag->attach_id && $flag->attachment->isprivate && !$user->is_insider); + + my $data = { + id => $flag->id, + type => $flag->type->name, + status => $flag->status, + attach_id => $flag->attach_id, + is_patch => $flag->attach_id ? $flag->attachment->ispatch : 0, + bug_id => $flag->bug_id, + requester => $flag->setter->login, + requestee => $flag->requestee ? $flag->requestee->login : '', + updated => $flag->modification_date, + }; + push(@unfiltered_flags, $data); + + # Record bug id for later retrieval of status/summary + $all_bugs{$flag->{'bug_id'}}++; + } + + # Filter the bug list based on permission to see the bug + my %visible_bugs = map { $_ => 1 } @{ $user->visible_bugs([ keys %all_bugs ]) }; + + return [] if !scalar keys %visible_bugs; + + # Get all bug statuses and summaries in one query instead of loading + # many separate bug objects + my $bug_rows = $dbh->selectall_arrayref("SELECT bug_id, bug_status, short_desc + FROM bugs + WHERE " . $dbh->sql_in('bug_id', [ keys %visible_bugs ]), + { Slice => {} }); + foreach my $row (@$bug_rows) { + $visible_bugs{$row->{'bug_id'}} = { + bug_status => $row->{'bug_status'}, + short_desc => $row->{'short_desc'} + }; + } + + # Now drop out any flags for bugs the user cannot see + # or if the user did not want to see closed bugs + my @filtered_flags; + foreach my $flag (@unfiltered_flags) { + # Skip this flag if the bug is not visible to the user + next if !$visible_bugs{$flag->{'bug_id'}}; + + # Include bug status and summary with each flag + $flag->{'bug_status'} = $visible_bugs{$flag->{'bug_id'}}->{'bug_status'}; + $flag->{'bug_summary'} = $visible_bugs{$flag->{'bug_id'}}->{'short_desc'}; + + # Format the updated date specific to the user's timezone + # and add the fancy human readable version + my $datetime = datetime_from($flag->{'updated'}); + $datetime->set_time_zone($user->timezone); + $flag->{'updated'} = $datetime->strftime('%Y-%m-%d %T %Z'); + $flag->{'updated_epoch'} = $datetime->epoch; + $flag->{'updated_fancy'} = time_ago($datetime, $datetime_now); + + push(@filtered_flags, $flag); + } + + return [] if !@filtered_flags; + + # Sort by most recently updated + return [ sort { $b->{'updated_epoch'} <=> $a->{'updated_epoch'} } @filtered_flags ]; +} + +1; diff --git a/extensions/MyDashboard/lib/TimeAgo.pm b/extensions/MyDashboard/lib/TimeAgo.pm new file mode 100644 index 000000000..0206bfebd --- /dev/null +++ b/extensions/MyDashboard/lib/TimeAgo.pm @@ -0,0 +1,179 @@ +package Bugzilla::Extension::MyDashboard::TimeAgo; + +use strict; +use utf8; +use DateTime; +use Carp; +use Exporter qw(import); + +use if $ENV{ARCH_64BIT}, 'integer'; + +our @EXPORT_OK = qw(time_ago); + +our $VERSION = '0.06'; + +my @ranges = ( + [ -1, 'in the future' ], + [ 60, 'just now' ], + [ 900, 'a few minutes ago'], # 15*60 + [ 3000, 'less than an hour ago'], # 50*60 + [ 4500, 'about an hour ago'], # 75*60 + [ 7200, 'more than an hour ago'], # 2*60*60 + [ 21600, 'several hours ago'], # 6*60*60 + [ 86400, 'today', sub { # 24*60*60 + my $time = shift; + my $now = shift; + if ( $time->day < $now->day + or $time->month < $now->month + or $time->year < $now->year + ) { + return 'yesterday' + } + if ($time->hour < 5) { + return 'tonight' + } + if ($time->hour < 10) { + return 'this morning' + } + if ($time->hour < 15) { + return 'today' + } + if ($time->hour < 19) { + return 'this afternoon' + } + return 'this evening' + }], + [ 172800, 'yesterday'], # 2*24*60*60 + [ 604800, 'this week'], # 7*24*60*60 + [ 1209600, 'last week'], # 2*7*24*60*60 + [ 2678400, 'this month', sub { # 31*24*60*60 + my $time = shift; + my $now = shift; + if ($time->year == $now->year and $time->month == $now->month) { + return 'this month' + } + return 'last month' + }], + [ 5356800, 'last month'], # 2*31*24*60*60 + [ 24105600, 'several months ago'], # 9*31*24*60*60 + [ 31536000, 'about a year ago'], # 365*24*60*60 + [ 34214400, 'last year'], # (365+31)*24*60*60 + [ 63072000, 'more than a year ago'], # 2*365*24*60*60 + [ 283824000, 'several years ago'], # 9*365*24*60*60 + [ 315360000, 'about a decade ago'], # 10*365*24*60*60 + [ 630720000, 'last decade'], # 20*365*24*60*60 + [ 2838240000, 'several decades ago'], # 90*365*24*60*60 + [ 3153600000, 'about a century ago'], # 100*365*24*60*60 + [ 6307200000, 'last century'], # 200*365*24*60*60 + [ 6622560000, 'more than a century ago'], # 210*365*24*60*60 + [ 28382400000, 'several centuries ago'], # 900*365*24*60*60 + [ 31536000000, 'about a millenium ago'], # 1000*365*24*60*60 + [ 63072000000, 'more than a millenium ago'], # 2000*365*24*60*60 +); + +sub time_ago { + my ($time, $now) = @_; + + if (not defined $time or not $time->isa('DateTime')) { + croak('DateTime::Duration::Fuzzy::time_ago needs a DateTime object as first parameter') + } + if (not defined $now) { + $now = DateTime->now(); + } + if (not $now->isa('DateTime')) { + croak('Invalid second parameter provided to DateTime::Duration::Fuzzy::time_ago; it must be a DateTime object if provided') + } + + my $dur = $now->subtract_datetime_absolute($time)->in_units('seconds'); + + foreach my $range ( @ranges ) { + if ( $dur <= $range->[0] ) { + if ( $range->[2] ) { + return $range->[2]->($time, $now) + } + return $range->[1] + } + } + + return 'millenia ago' +} + +1 + +__END__ + +=head1 NAME + +DateTime::Duration::Fuzzy -- express dates as fuzzy human-friendly strings + +=head1 SYNOPSIS + + use DateTime::Duration::Fuzzy qw(time_ago); + use DateTime; + + my $now = DateTime->new( + year => 2010, month => 12, day => 12, + hour => 19, minute => 59, + ); + my $then = DateTime->new( + year => 2010, month => 12, day => 12, + hour => 15, + ); + print time_ago($then, $now); + # outputs 'several hours ago' + + print time_ago($then); + # $now taken from C<time> function + +=head1 DESCRIPTION + +DateTime::Duration::Fuzzy is inspired from the timeAgo jQuery module +L<http://timeago.yarp.com/>. + +It takes two DateTime objects -- first one representing a moment in the past +and second optional one representine the present, and returns a human-friendly +fuzzy expression of the time gone. + +=head2 functions + +=over 4 + +=item time_ago($then, $now) + +The only exportable function. + +First obligatory parameter is a DateTime object. + +Second optional parameter is also a DateTime object. +If it's not provided, then I<now> as the C<time> function returns is +substituted. + +Returns a string expression of the interval between the two DateTime +objects, like C<several hours ago>, C<yesterday> or <last century>. + +=back + +=head2 performance + +On 64bit machines, it is asvisable to 'use integer', which makes +the calculations faster. You can turn this on by setting the +C<ARCH_64BIT> environmental variable to a true value. + +If you do this on a 32bit machine, you will get wrong results for +intervals starting with "several decades ago". + +=head1 AUTHOR + +Jan Oldrich Kruza, C<< <sixtease at cpan.org> >> + +=head1 LICENSE AND COPYRIGHT + +Copyright 2010 Jan Oldrich Kruza. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See http://dev.perl.org/licenses/ for more information. + +=cut diff --git a/extensions/MyDashboard/lib/Util.pm b/extensions/MyDashboard/lib/Util.pm new file mode 100644 index 000000000..fa7cf83b0 --- /dev/null +++ b/extensions/MyDashboard/lib/Util.pm @@ -0,0 +1,50 @@ +# 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::MyDashboard::Util; + +use strict; + +use Bugzilla::CGI; +use Bugzilla::Search; +use Bugzilla::Status; + +use base qw(Exporter); +@Bugzilla::Extension::MyDashboard::Util::EXPORT = qw( + open_states + closed_states + quoted_open_states + quoted_closed_states +); + +our $_open_states; +sub open_states { + $_open_states ||= Bugzilla::Status->match({ is_open => 1, isactive => 1 }); + return wantarray ? @$_open_states : $_open_states; +} + +our $_quoted_open_states; +sub quoted_open_states { + my $dbh = Bugzilla->dbh; + $_quoted_open_states ||= [ map { $dbh->quote($_->name) } open_states() ]; + return wantarray ? @$_quoted_open_states : $_quoted_open_states; +} + +our $_closed_states; +sub closed_states { + $_closed_states ||= Bugzilla::Status->match({ is_open => 0, isactive => 1 }); + return wantarray ? @$_closed_states : $_closed_states; +} + +our $_quoted_closed_states; +sub quoted_closed_states { + my $dbh = Bugzilla->dbh; + $_quoted_closed_states ||= [ map { $dbh->quote($_->name) } closed_states() ]; + return wantarray ? @$_quoted_closed_states : $_quoted_closed_states; +} + +1; diff --git a/extensions/MyDashboard/lib/WebService.pm b/extensions/MyDashboard/lib/WebService.pm new file mode 100644 index 000000000..87061eabe --- /dev/null +++ b/extensions/MyDashboard/lib/WebService.pm @@ -0,0 +1,145 @@ +# 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::MyDashboard::WebService; + +use strict; +use warnings; + +use base qw(Bugzilla::WebService Bugzilla::WebService::Bug); + +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Util qw(detaint_natural trick_taint template_var datetime_from); +use Bugzilla::WebService::Util qw(validate); + +use Bugzilla::Extension::MyDashboard::Queries qw(QUERY_DEFS query_bugs query_flags); + +use constant READ_ONLY => qw( + run_bug_query + run_flag_query +); + +sub run_last_changes { + my ($self, $params) = @_; + + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->login(LOGIN_REQUIRED); + + trick_taint($params->{changeddate_api}); + trick_taint($params->{bug_id}); + + my $last_comment_sql = " + SELECT comment_id + FROM longdescs + WHERE bug_id = ? AND bug_when > ?"; + if (!$user->is_insider) { + $last_comment_sql .= " AND isprivate = 0"; + } + $last_comment_sql .= " LIMIT 1"; + my $last_comment_sth = $dbh->prepare($last_comment_sql); + + my $last_changes = {}; + my $activity = $self->history({ ids => [ $params->{bug_id} ], + new_since => $params->{changeddate_api} }); + if (@{$activity->{bugs}[0]{history}}) { + my $change_set = $activity->{bugs}[0]{history}[0]; + $last_changes->{activity} = $change_set->{changes}; + foreach my $change (@{ $last_changes->{activity} }) { + $change->{field_desc} + = template_var('field_descs')->{$change->{field_name}} || $change->{field_name}; + } + $last_changes->{email} = $change_set->{who}; + my $datetime = datetime_from($change_set->{when}); + $datetime->set_time_zone($user->timezone); + $last_changes->{when} = $datetime->strftime('%Y-%m-%d %T %Z'); + } + my $last_comment_id = $dbh->selectrow_array( + $last_comment_sth, undef, $params->{bug_id}, $params->{changeddate_api}); + if ($last_comment_id) { + my $comments = $self->comments({ comment_ids => [ $last_comment_id ] }); + my $comment = $comments->{comments}{$last_comment_id}; + $last_changes->{comment} = $comment->{text}; + $last_changes->{email} = $comment->{creator} if !$last_changes->{email}; + my $datetime = datetime_from($comment->{creation_time}); + $datetime->set_time_zone($user->timezone); + $last_changes->{when} = $datetime->strftime('%Y-%m-%d %T %Z'); + } + + return { results => [ {last_changes => $last_changes } ] }; +} + +sub run_bug_query { + my($self, $params) = @_; + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->login(LOGIN_REQUIRED); + + defined $params->{query} + || ThrowCodeError('param_required', + { function => 'MyDashboard.run_bug_query', + param => 'query' }); + + my $result; + foreach my $qdef (QUERY_DEFS) { + next if $qdef->{name} ne $params->{query}; + my ($bugs, $query_string) = query_bugs($qdef); + + # Add last changes to each bug + foreach my $b (@$bugs) { + # Set the data type properly for webservice clients + # for non-string values. + $b->{bug_id} = $self->type('int', $b->{bug_id}); + } + + $query_string =~ s/^POSTDATA=&//; + $qdef->{bugs} = $bugs; + $qdef->{buffer} = $query_string; + $result = $qdef; + last; + } + + return { result => $result }; +} + +sub run_flag_query { + my ($self, $params) =@_; + my $user = Bugzilla->login(LOGIN_REQUIRED); + + my $type = $params->{type}; + $type || ThrowCodeError('param_required', + { function => 'MyDashboard.run_flag_query', + param => 'type' }); + + my $results = query_flags($type); + + # Set the data type properly for webservice clients + # for non-string values. + foreach my $flag (@$results) { + $flag->{id} = $self->type('int', $flag->{id}); + $flag->{attach_id} = $self->type('int', $flag->{attach_id}); + $flag->{bug_id} = $self->type('int', $flag->{bug_id}); + $flag->{is_patch} = $self->type('boolean', $flag->{is_patch}); + } + + return { result => { $type => $results }}; +} + +1; + +__END__ + +=head1 NAME + +Bugzilla::Extension::MyDashboard::Webservice - The MyDashboard WebServices API + +=head1 DESCRIPTION + +This module contains API methods that are useful to user's of bugzilla.mozilla.org. + +=head1 METHODS + +See L<Bugzilla::WebService> for a description of how parameters are passed, +and what B<STABLE>, B<UNSTABLE>, and B<EXPERIMENTAL> mean. diff --git a/extensions/MyDashboard/template/en/default/hook/account/prefs/saved-searches-saved-header.html.tmpl b/extensions/MyDashboard/template/en/default/hook/account/prefs/saved-searches-saved-header.html.tmpl new file mode 100644 index 000000000..c822ab040 --- /dev/null +++ b/extensions/MyDashboard/template/en/default/hook/account/prefs/saved-searches-saved-header.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. + #%] + +<th> + My Dashboard +</th> diff --git a/extensions/MyDashboard/template/en/default/hook/account/prefs/saved-searches-saved-row.html.tmpl b/extensions/MyDashboard/template/en/default/hook/account/prefs/saved-searches-saved-row.html.tmpl new file mode 100644 index 000000000..cd6a36705 --- /dev/null +++ b/extensions/MyDashboard/template/en/default/hook/account/prefs/saved-searches-saved-row.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. + #%] + +<td align="center"> + <input type="checkbox" + name="in_mydashboard_[% q.id FILTER html %]" + value="1" + alt="[% q.name FILTER html %]" + [% " checked" IF q.in_mydashboard %]> +</td> diff --git a/extensions/MyDashboard/template/en/default/hook/global/common-links-action-links.html.tmpl b/extensions/MyDashboard/template/en/default/hook/global/common-links-action-links.html.tmpl new file mode 100644 index 000000000..518743ccf --- /dev/null +++ b/extensions/MyDashboard/template/en/default/hook/global/common-links-action-links.html.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. + #%] + +[% IF user.login %] + <li><span class="separator"> | </span> + <a href="[% urlbase FILTER none %]page.cgi?id=mydashboard.html">My Dashboard</a></li> +[% END %] diff --git a/extensions/MyDashboard/template/en/default/pages/mydashboard.html.tmpl b/extensions/MyDashboard/template/en/default/pages/mydashboard.html.tmpl new file mode 100644 index 000000000..16f363f49 --- /dev/null +++ b/extensions/MyDashboard/template/en/default/pages/mydashboard.html.tmpl @@ -0,0 +1,156 @@ +[%# 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 = "My Dashboard" + style_urls = [ "extensions/MyDashboard/web/styles/mydashboard.css", + "extensions/ProdCompSearch/web/styles/prod_comp_search.css" ] + yui = ["json", "connection"], + javascript_urls = [ "js/yui3/yui/yui-min.js", + "extensions/MyDashboard/web/js/query.js", + "extensions/MyDashboard/web/js/flags.js", + "extensions/ProdCompSearch/web/js/prod_comp_search.js", + "js/bug.js" ] +%] + +[% standard_queries = [] %] +[% saved_queries = [] %] +[% FOREACH q = queries %] + [% standard_queries.push(q) IF !q.saved %] + [% saved_queries.push(q) IF q.saved %] +[% END %] + +<script id="last-changes-stub" type="text/x-handlebars-template"> + <div id="last_changes_stub_{{bug_id}}">Loading...</div> +</script> +<script id="last-changes-template" type="text/x-handlebars-template"> + <div id="last_changes_{{bug_id}}"> + {{#if email}} + <div id="last_changes_header"> + Last Changes :: {{email}} :: {{when}} + </div> + {{#if activity}} + <table id="activity"> + {{#each activity}} + <tr> + <td class="field_label">{{field_desc}}:</td> + <td class="field_data"> + {{#if removed}} + {{#unless added}} + Removed: + {{/unless}} + {{removed}} + {{/if}} + {{#if added}} + {{#if removed}} + → + {{/if}} + {{/if}} + {{#if added}} + {{#unless removed}} + Added: + {{/unless}} + {{added}} + {{/if}} + </td> + </tr> + {{/each}} + </table> + {{/if}} + {{#if comment}} + <pre class='bz_comment_text'>{{comment}}</pre> + {{/if}} + {{else}} + This is a new [% terms.bug %] and no changes have been made yet. + {{/if}} + </div> +</script> + +<script type="text/javascript"> + [% IF Param('splinter_base') %] + MyDashboard.splinter_base = '[% Bugzilla.splinter_review_base FILTER js %]'; + [% END %] +</script> + +<div id="mydashboard"> + <div class="yui3-skin-sam"> + <div id="left"> + <div id="query_list_container"> + Choose query: + <select id="query" name="query"> + <optgroup id="standard_queries" label="Standard"> + [% FOREACH r = standard_queries %] + <option value="[% r.name FILTER html %]">[% r.heading || r.name FILTER html %]</option> + [% END%] + </optgroup> + <optgroup id="saved_queries" label="Saved"> + [% FOREACH r = saved_queries %] + <option value="[% r.name FILTER html %]">[% r.heading || r.name FILTER html %]</option> + [% END %] + </optgroup> + </select> + <small> + (<a href="userprefs.cgi?tab=saved-searches">add or remove saved searches</a>) + </small> + </div> + + <div id="query_container"> + <div class="query_heading"></div> + <div class="query_description"></div> + <span id="query_count_refresh" class="bz_default_hidden"> + <span class="items_found" id="query_bugs_found">0 [% terms.bugs %] found</span> + | <a class="refresh" href="javascript:void(0);" id="query_refresh">Refresh</a> + | <a class="markvisited" href="javascript:void(0);" id="query_markvisited">Mark Visited</a> + <span class="markvisited bz_default_hidden" id="query_markvisited_text">Mark Visited</span> + | <a class="buglist" href="javascript:void(0);" id="query_buglist">Buglist</a> + </span> + <div id="query_pagination_top"></div> + <div id="query_table"></div> + </div> + </div> + + <div id="right"> + <div id="prod_comp_search_main"> + [% PROCESS prodcompsearch/form.html.tmpl + input_label = "File a $terms.Bug:" + script_name = "enter_bug.cgi" + new_tab = 1 + %] + </div> + + <div id="requestee_container"> + <div class="query_heading"> + Flags Requested of You + </div> + <span id="requestee_count_refresh" class="bz_default_hidden"> + <span class="items_found" id="requestee_flags_found">0 flags found</span> + | <a class="refresh" href="javascript:void(0);" id="requestee_refresh">Refresh</a> + | <a class="buglist" href="javascript:void(0);" id="requestee_buglist">Buglist</a> + </span> + <div id="requestee_table"></div> + </div> + + <div id="requester_container"> + <div class="query_heading"> + Flags You Have Requested + </div> + <span id="requester_count_refresh" class="bz_default_hidden"> + <span class="items_found" id="requester_flags_found">0 flags found</span> + | <a class="refresh" href="javascript:void(0);" id="requester_refresh">Refresh</a> + | <a class="buglist" href="javascript:void(0);" id="requester_buglist">Buglist</a> + </span> + <div id="requester_table"></div> + </div> + </div> + <div style="clear:both;"></div> + </div> +</div> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/MyDashboard/web/js/flags.js b/extensions/MyDashboard/web/js/flags.js new file mode 100644 index 000000000..0fcf75618 --- /dev/null +++ b/extensions/MyDashboard/web/js/flags.js @@ -0,0 +1,228 @@ +/* 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. + */ + +// Flag tables +YUI({ + base: 'js/yui3/', + combine: false +}).use("node", "datatable", "datatable-sort", "json-stringify", "escape", + "datatable-datasource", "datasource-io", "datasource-jsonschema", function(Y) { + // Common + var counter = 0; + var dataSource = { + requestee: null, + requester: null + }; + var dataTable = { + requestee: null, + requester: null + }; + + var updateFlagTable = function(type) { + if (!type) return; + + counter = counter + 1; + + var callback = { + success: function(e) { + if (e.response) { + Y.one('#' + type + '_count_refresh').removeClass('bz_default_hidden'); + Y.one("#" + type + "_flags_found").setHTML( + e.response.results.length + ' flags found'); + dataTable[type].set('data', e.response.results); + } + }, + failure: function(o) { + if (o.error) { + alert("Failed to load flag list from Bugzilla:\n\n" + o.error.message); + } else { + alert("Failed to load flag list from Bugzilla."); + } + } + }; + + var json_object = { + version: "1.1", + method: "MyDashboard.run_flag_query", + id: counter, + params: { type : type } + }; + + var stringified = Y.JSON.stringify(json_object); + + Y.one('#' + type + '_count_refresh').addClass('bz_default_hidden'); + + dataTable[type].set('data', []); + dataTable[type].render("#" + type + "_table"); + dataTable[type].showMessage('loadingMessage'); + + dataSource[type].sendRequest({ + request: stringified, + cfg: { + method: "POST", + headers: { 'Content-Type': 'application/json' } + }, + callback: callback + }); + }; + + var loadBugList = function(type) { + if (!type) return; + var data = dataTable[type].data; + var ids = []; + for (var i = 0, l = data.size(); i < l; i++) { + ids.push(data.item(i).get('bug_id')); + } + var url = 'buglist.cgi?bug_id=' + ids.join('%2C'); + window.open(url, '_blank'); + }; + + var bugLinkFormatter = function(o) { + var bug_closed = ""; + if (o.data.bug_status == 'RESOLVED' || o.data.bug_status == 'VERIFIED') { + bug_closed = "bz_closed"; + } + return '<a href="show_bug.cgi?id=' + encodeURIComponent(o.value) + + '" target="_blank" ' + 'title="' + Y.Escape.html(o.data.bug_status) + ' - ' + + Y.Escape.html(o.data.bug_summary) + '" class="' + Y.Escape.html(bug_closed) + + '">' + o.value + '</a>'; + }; + + var updatedFormatter = function(o) { + return '<span title="' + Y.Escape.html(o.value) + '">' + + Y.Escape.html(o.data.updated_fancy) + '</span>'; + }; + + var requesteeFormatter = function(o) { + return o.value + ? Y.Escape.html(o.value) + : '<i>anyone</i>'; + }; + + var flagNameFormatter = function(o) { + if (parseInt(o.data.attach_id) + && parseInt(o.data.is_patch) + && MyDashboard.splinter_base) + { + return '<a href="' + MyDashboard.splinter_base + + (MyDashboard.splinter_base.indexOf('?') == -1 ? '?' : '&') + + 'bug=' + encodeURIComponent(o.data.bug_id) + + '&attachment=' + encodeURIComponent(o.data.attach_id) + + '" target="_blank" title="Review this patch">' + + Y.Escape.html(o.value) + '</a>'; + } + else { + return Y.Escape.html(o.value); + } + }; + + // Requestee + dataSource.requestee = new Y.DataSource.IO({ source: 'jsonrpc.cgi' }); + dataSource.requestee.on('error', function(e) { + try { + var response = Y.JSON.parse(e.data.responseText); + if (response.error) + e.error.message = response.error.message; + } catch(ex) { + // ignore + } + }); + dataTable.requestee = new Y.DataTable({ + columns: [ + { key: "requester", label: "Requester", sortable: true }, + { key: "type", label: "Flag", sortable: true, + formatter: flagNameFormatter, allowHTML: true }, + { key: "bug_id", label: "Bug", sortable: true, + formatter: bugLinkFormatter, allowHTML: true }, + { key: "updated", label: "Updated", sortable: true, + formatter: updatedFormatter, allowHTML: true } + ], + strings: { + emptyMessage: 'No flag data found.', + } + }); + + dataTable.requestee.plug(Y.Plugin.DataTableSort); + + dataTable.requestee.plug(Y.Plugin.DataTableDataSource, { + datasource: dataSource.requestee + }); + + dataSource.requestee.plug(Y.Plugin.DataSourceJSONSchema, { + schema: { + resultListLocator: "result.result.requestee", + resultFields: ["requester", "type", "attach_id", "is_patch", "bug_id", + "bug_status", "bug_summary", "updated", "updated_fancy"] + } + }); + + dataTable.requestee.render("#requestee_table"); + + Y.one('#requestee_refresh').on('click', function(e) { + updateFlagTable('requestee'); + }); + Y.one('#requestee_buglist').on('click', function(e) { + loadBugList('requestee'); + }); + + // Requester + dataSource.requester = new Y.DataSource.IO({ source: 'jsonrpc.cgi' }); + dataSource.requester.on('error', function(e) { + try { + var response = Y.JSON.parse(e.data.responseText); + if (response.error) + e.error.message = response.error.message; + } catch(ex) { + // ignore + } + }); + dataTable.requester = new Y.DataTable({ + columns: [ + { key:"requestee", label:"Requestee", sortable:true, + formatter: requesteeFormatter, allowHTML: true }, + { key:"type", label:"Flag", sortable:true, + formatter: flagNameFormatter, allowHTML: true }, + { key:"bug_id", label:"Bug", sortable:true, + formatter: bugLinkFormatter, allowHTML: true }, + { key: "updated", label: "Updated", sortable: true, + formatter: updatedFormatter, allowHTML: true } + ], + strings: { + emptyMessage: 'No flag data found.', + } + }); + + dataTable.requester.plug(Y.Plugin.DataTableSort); + + dataTable.requester.plug(Y.Plugin.DataTableDataSource, { + datasource: dataSource.requester + }); + + dataSource.requester.plug(Y.Plugin.DataSourceJSONSchema, { + schema: { + resultListLocator: "result.result.requester", + resultFields: ["requestee", "type", "attach_id", "is_patch", "bug_id", + "bug_status", "bug_summary", "updated", "updated_fancy"] + } + }); + + // Initial load + Y.on("contentready", function (e) { + updateFlagTable("requestee"); + }, "#requestee_table"); + Y.on("contentready", function (e) { + updateFlagTable("requester"); + }, "#requester_table"); + + Y.one('#requester_refresh').on('click', function(e) { + updateFlagTable('requester'); + }); + Y.one('#requester_buglist').on('click', function(e) { + loadBugList('requester'); + }); +}); diff --git a/extensions/MyDashboard/web/js/query.js b/extensions/MyDashboard/web/js/query.js new file mode 100644 index 000000000..4a6b64157 --- /dev/null +++ b/extensions/MyDashboard/web/js/query.js @@ -0,0 +1,265 @@ +/* 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 (typeof(MyDashboard) == 'undefined') { + var MyDashboard = {}; +} + +// Main query code +YUI({ + base: 'js/yui3/', + combine: false, + groups: { + gallery: { + combine: false, + base: 'js/yui3/', + patterns: { 'gallery-': {} } + } + } +}).use("node", "datatable", "datatable-sort", "datatable-message", "json-stringify", + "datatable-datasource", "datasource-io", "datasource-jsonschema", "cookie", + "gallery-datatable-row-expansion-bmo", "handlebars", "escape", function(Y) { + var counter = 0, + bugQueryTable = null, + bugQuery = null, + lastChangesQuery = null, + lastChangesCache = {}, + default_query = "assignedbugs"; + + // Grab last used query name from cookie or use default + var query_cookie = Y.Cookie.get("my_dashboard_query"); + if (query_cookie) { + var cookie_value_found = 0; + Y.one("#query").get("options").each( function() { + if (this.get("value") == query_cookie) { + this.set('selected', true); + default_query = query_cookie; + cookie_value_found = 1; + } + }); + if (!cookie_value_found) { + Y.Cookie.set("my_dashboard_query", ""); + } + } + + var bugQuery = new Y.DataSource.IO({ source: 'jsonrpc.cgi' }); + + bugQuery.plug(Y.Plugin.DataSourceJSONSchema, { + schema: { + resultListLocator: "result.result.bugs", + resultFields: ["bug_id", "changeddate", "changeddate_fancy", + "bug_status", "short_desc", "changeddate_api" ], + metaFields: { + description: "result.result.description", + heading: "result.result.heading", + buffer: "result.result.buffer" + } + } + }); + + bugQuery.on('error', function(e) { + try { + var response = Y.JSON.parse(e.data.responseText); + if (response.error) + e.error.message = response.error.message; + } catch(ex) { + // ignore + } + }); + + var bugQueryCallback = { + success: function(e) { + if (e.response) { + Y.one('#query_count_refresh').removeClass('bz_default_hidden'); + Y.one("#query_container .query_description").setHTML(e.response.meta.description); + Y.one("#query_container .query_heading").setHTML(e.response.meta.heading); + Y.one("#query_bugs_found").setHTML( + '<a href="buglist.cgi?' + e.response.meta.buffer + + '" target="_blank">' + e.response.results.length + ' bugs found</a>'); + bugQueryTable.set('data', e.response.results); + } + }, + failure: function(o) { + if (o.error) { + alert("Failed to load bug list from Bugzilla:\n\n" + o.error.message); + } else { + alert("Failed to load bug list from Bugzilla."); + } + } + }; + + var updateQueryTable = function(query_name) { + if (!query_name) return; + + counter = counter + 1; + lastChangesCache = {}; + + Y.one('#query_markvisited').removeClass('bz_default_hidden'); + Y.one('#query_markvisited_text').addClass('bz_default_hidden'); + Y.one('#query_count_refresh').addClass('bz_default_hidden'); + bugQueryTable.set('data', []); + bugQueryTable.render("#query_table"); + bugQueryTable.showMessage('loadingMessage'); + + var bugQueryParams = { + version: "1.1", + method: "MyDashboard.run_bug_query", + id: counter, + params: { query : query_name } + }; + + bugQuery.sendRequest({ + request: Y.JSON.stringify(bugQueryParams), + cfg: { + method: "POST", + headers: { 'Content-Type': 'application/json' } + }, + callback: bugQueryCallback + }); + }; + + var updatedFormatter = function(o) { + return '<span title="' + Y.Escape.html(o.value) + '">' + + Y.Escape.html(o.data.changeddate_fancy) + '</span>'; + }; + + + lastChangesQuery = new Y.DataSource.IO({ source: 'jsonrpc.cgi' }); + + lastChangesQuery.plug(Y.Plugin.DataSourceJSONSchema, { + schema: { + resultListLocator: "result.results", + resultFields: ["last_changes"], + } + }); + + lastChangesQuery.on('error', function(e) { + try { + var response = Y.JSON.parse(e.data.responseText); + if (response.error) + e.error.message = response.error.message; + } catch(ex) { + // ignore + } + }); + + bugQueryTable = new Y.DataTable({ + columns: [ + { key: Y.Plugin.DataTableRowExpansion.column_key, label: ' ', sortable: false }, + { key: "bug_id", label: "Bug", allowHTML: true, sortable: true, + formatter: '<a href="show_bug.cgi?id={value}" target="_blank">{value}</a>' }, + { key: "changeddate", label: "Updated", formatter: updatedFormatter, + allowHTML: true, sortable: true }, + { key: "bug_status", label: "Status", sortable: true }, + { key: "short_desc", label: "Summary", sortable: true }, + ], + }); + + var last_changes_source = Y.one('#last-changes-template').getHTML(), + last_changes_template = Y.Handlebars.compile(last_changes_source); + + var stub_source = Y.one('#last-changes-stub').getHTML(), + stub_template = Y.Handlebars.compile(stub_source); + + + bugQueryTable.plug(Y.Plugin.DataTableRowExpansion, { + uniqueIdKey: 'bug_id', + template: function(data) { + var bug_id = data.bug_id; + + var lastChangesCallback = { + success: function(e) { + if (e.response) { + var last_changes = e.response.results[0].last_changes; + last_changes['bug_id'] = bug_id; + lastChangesCache[bug_id] = last_changes; + Y.one('#last_changes_stub_' + bug_id).setHTML(last_changes_template(last_changes)); + } + }, + failure: function(o) { + if (o.error) { + alert("Failed to load last changes from Bugzilla:\n\n" + o.error.message); + } else { + alert("Failed to load last changes from Bugzilla."); + } + } + }; + + if (!lastChangesCache[bug_id]) { + var lastChangesParams = { + version: "1.1", + method: "MyDashboard.run_last_changes", + params: { bug_id: data.bug_id, changeddate_api: data.changeddate_api } + }; + + lastChangesQuery.sendRequest({ + request: Y.JSON.stringify(lastChangesParams), + cfg: { + method: "POST", + headers: { 'Content-Type': 'application/json' } + }, + callback: lastChangesCallback + }); + + return stub_template({bug_id: bug_id}); + } + else { + return last_changes_template(lastChangesCache[bug_id]); + } + + } + }); + + bugQueryTable.plug(Y.Plugin.DataTableSort); + + bugQueryTable.plug(Y.Plugin.DataTableDataSource, { + datasource: bugQuery + }); + + // Initial load + Y.on("contentready", function (e) { + updateQueryTable(default_query); + }, "#query_table"); + + Y.one('#query').on('change', function(e) { + var index = e.target.get('selectedIndex'); + var selected_value = e.target.get("options").item(index).getAttribute('value'); + updateQueryTable(selected_value); + Y.Cookie.set("my_dashboard_query", selected_value, { expires: new Date("January 12, 2025") }); + }); + + Y.one('#query_refresh').on('click', function(e) { + var query_select = Y.one('#query'); + var index = query_select.get('selectedIndex'); + var selected_value = query_select.get("options").item(index).getAttribute('value'); + updateQueryTable(selected_value); + }); + + Y.one('#query_markvisited').on('click', function(e) { + var data = bugQueryTable.data; + var bug_ids = []; + + Y.one('#query_markvisited').addClass('bz_default_hidden'); + Y.one('#query_markvisited_text').removeClass('bz_default_hidden'); + + for (var i = 0, l = data.size(); i < l; i++) { + bug_ids.push(data.item(i).get('bug_id')); + } + YAHOO.bugzilla.bugUserLastVisit.update(bug_ids); + }); + + Y.one('#query_buglist').on('click', function(e) { + var data = bugQueryTable.data; + var ids = []; + for (var i = 0, l = data.size(); i < l; i++) { + ids.push(data.item(i).get('bug_id')); + } + var url = 'buglist.cgi?bug_id=' + ids.join('%2C'); + window.open(url, '_blank'); + }); +}); diff --git a/extensions/MyDashboard/web/styles/mydashboard.css b/extensions/MyDashboard/web/styles/mydashboard.css new file mode 100644 index 000000000..2ce19d96b --- /dev/null +++ b/extensions/MyDashboard/web/styles/mydashboard.css @@ -0,0 +1,74 @@ +/* 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. */ + +#mydashboard { + min-width: 900px; +} + +#mydashboard .yui3-skin-sam .yui3-datatable-table { + width: 100%; +} + +.yui3-datatable-col-changeddate, +.yui3-datatable-col-created { + white-space: nowrap; +} + +.query_heading { + font-size: 18px; + font-weight: strong; + padding-bottom: 5px; + padding-top: 5px; + color: rgb(72, 72, 72); +} + +.query_description { + font-size: 90%; + font-style: italic; + padding-bottom: 5px; + color: rgb(109, 117, 129); +} + +#mydashboard_container { + margin: 0 auto; +} + +#left { + float: left; + width: 58%; +} + +#right { + float: right; + width: 40%; +} + +.items_found, .refresh, .buglist, .markvisited { + + font-size: 80%; +} + +#query_list_container { + text-align:center; +} + +#query_list_container, +#prod_comp_search_main { + padding: 20px !important; + height: 40px; +} + +#last_changes_header { + font-size: 12px; + font-weight: bold; + padding-bottom: 5px; + border-bottom: 1px solid rgb(200, 200, 186); +} + +#last_changes .field_label { + text-align: left; +} diff --git a/extensions/Needinfo/Config.pm b/extensions/Needinfo/Config.pm new file mode 100644 index 000000000..86c99ec59 --- /dev/null +++ b/extensions/Needinfo/Config.pm @@ -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. +package Bugzilla::Extension::Needinfo; +use strict; + +use constant NAME => 'Needinfo'; + +use constant REQUIRED_MODULES => [ +]; + +use constant OPTIONAL_MODULES => [ +]; + +__PACKAGE__->NAME; diff --git a/extensions/Needinfo/Extension.pm b/extensions/Needinfo/Extension.pm new file mode 100644 index 000000000..2a4bfa3b3 --- /dev/null +++ b/extensions/Needinfo/Extension.pm @@ -0,0 +1,180 @@ +# 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::Needinfo; + +use strict; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Error; +use Bugzilla::Flag; +use Bugzilla::FlagType; +use Bugzilla::User; + +our $VERSION = '0.01'; + +sub install_update_db { + my ($self, $args) = @_; + my $dbh = Bugzilla->dbh; + + if (@{ Bugzilla::FlagType::match({ name => 'needinfo' }) }) { + return; + } + + print "Creating needinfo flag ... " . + "enable the Needinfo feature by editing the flag's properties.\n"; + + # Initially populate the list of exclusions as __Any__:__Any__ to + # allow admin to decide which products to enable the flag for. + my $flagtype = Bugzilla::FlagType->create({ + name => 'needinfo', + description => "Set this flag when the bug is in need of additional information", + target_type => 'bug', + cc_list => '', + sortkey => 1, + is_active => 1, + is_requestable => 1, + is_requesteeble => 1, + is_multiplicable => 0, + request_group => '', + grant_group => '', + inclusions => [], + exclusions => ['0:0'], + }); +} + +# Clear the needinfo? flag if comment is being given by +# requestee or someone used the override flag. +sub bug_start_of_update { + my ($self, $args) = @_; + my $bug = $args->{bug}; + my $old_bug = $args->{old_bug}; + + my $user = Bugzilla->user; + my $cgi = Bugzilla->cgi; + my $params = Bugzilla->input_params; + + if ($params->{needinfo}) { + # do a match if applicable + Bugzilla::User::match_field({ + 'needinfo_from' => { 'type' => 'multi' } + }); + } + + # Set needinfo_done param to true so as to not loop back here + return if $params->{needinfo_done}; + $params->{needinfo_done} = 1; + Bugzilla->input_params($params); + + my $add_needinfo = delete $params->{needinfo}; + my $needinfo_from = delete $params->{needinfo_from}; + my $needinfo_role = delete $params->{needinfo_role}; + my $is_private = $params->{'comment_is_private'}; + + my @needinfo_overrides; + foreach my $key (grep(/^needinfo_override_/, keys %$params)) { + my ($id) = $key =~ /(\d+)$/; + # Should always be true if key exists (checkbox) but better to be sure + push(@needinfo_overrides, $id) if $id && $params->{$key}; + } + + # Set the needinfo flag if user is requesting more information + my @new_flags; + my $needinfo_requestee; + + if ($add_needinfo) { + foreach my $type (@{ $bug->flag_types }) { + next if $type->name ne 'needinfo'; + my %requestees; + + # Allow anyone to be the requestee + if (!$needinfo_role) { + $requestees{'anyone'} = 1; + } + # Use assigned_to as requestee + elsif ($needinfo_role eq 'assigned_to') { + $requestees{$bug->assigned_to->login} = 1; + } + # Use reporter as requestee + elsif ($needinfo_role eq 'reporter') { + $requestees{$bug->reporter->login} = 1; + } + # Use qa_contact as requestee + elsif ($needinfo_role eq 'qa_contact') { + $requestees{$bug->qa_contact->login} = 1; + } + # Use current user as requestee + elsif ($needinfo_role eq 'user') { + $requestees{$user->login} = 1; + } + # Use user specified requestee + elsif ($needinfo_role eq 'other' && $needinfo_from) { + my @needinfo_from_list = ref $needinfo_from + ? @$needinfo_from : + ($needinfo_from); + foreach my $requestee (@needinfo_from_list) { + my $requestee_obj = Bugzilla::User->check($requestee); + $requestees{$requestee_obj->login} = 1; + } + } + + # Find out if the requestee has already been used and skip if so + my $requestee_found; + foreach my $flag (@{ $type->{flags} }) { + if (!$flag->requestee && $requestees{'anyone'}) { + delete $requestees{'anyone'}; + } + if ($flag->requestee && $requestees{$flag->requestee->login}) { + delete $requestees{$flag->requestee->login}; + } + } + + foreach my $requestee (keys %requestees) { + my $needinfo_flag = { type_id => $type->id, status => '?' }; + if ($requestee ne 'anyone') { + $needinfo_flag->{requestee} = $requestee; + } + push(@new_flags, $needinfo_flag); + } + } + } + + my @flags; + foreach my $flag (@{ $bug->flags }) { + next if $flag->type->name ne 'needinfo'; + # Clear if somehow the flag has been set to +/- + # or if the "clear needinfo" override checkbox is selected + if ($flag->status ne '?' + or grep { $_ == $flag->id } @needinfo_overrides) + { + push(@flags, { id => $flag->id, status => 'X' }); + } + } + + if (@flags || @new_flags) { + $bug->set_flags(\@flags, \@new_flags); + } +} + +sub object_before_delete { + my ($self, $args) = @_; + my $object = $args->{object}; + return unless $object->isa('Bugzilla::Flag') + && $object->type->name eq 'needinfo'; + my $user = Bugzilla->user; + + # Require canconfirm to clear requests targetted at someone else + if ($object->setter_id != $user->id + && $object->requestee + && $object->requestee->id != $user->id + && !$user->in_group('canconfirm')) + { + ThrowUserError('needinfo_illegal_change'); + } +} + +__PACKAGE__->NAME; diff --git a/extensions/Needinfo/template/en/default/bug/needinfo.html.tmpl b/extensions/Needinfo/template/en/default/bug/needinfo.html.tmpl new file mode 100644 index 000000000..5edd70f72 --- /dev/null +++ b/extensions/Needinfo/template/en/default/bug/needinfo.html.tmpl @@ -0,0 +1,199 @@ +[%# 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. + #%] + +[% needinfo_flagtype = "" %] +[% needinfo_flags = [] %] + +[% FOREACH type = bug.flag_types %] + [% IF type.name == 'needinfo' %] + [% needinfo_flagtype = type %] + [% FOREACH flag = type.flags %] + [% IF flag.status == '?' %] + [% needinfo_flags.push(flag) %] + [% END %] + [% END %] + [% LAST IF needinfo_flagtype %] + [% END %] +[% END %] + +[% + BLOCK needinfo_comment_div; + match = needinfo_flags.last.creation_date.match('^(\d{4})\.(\d{2})\.(\d{2})(.+)$'); + date = "$match.0-$match.1-$match.2$match.3"; + FOREACH comment IN bug.comments; + NEXT IF comment.is_private AND NOT (user.is_insider || user.id == comment.author.id); + IF comment.creation_ts == date; + GET "c$comment.count"; + LAST; + END; + END; + END; +%] + +[% IF needinfo_flagtype %] + <div id="needinfo_container"> + [% IF needinfo_flags.size > 0 %] + [%# Displays NEEDINFO tag in bug header %] + <script> + var summary_container = document.getElementById('static_bug_status'); + if (summary_container) { + var needinfo_comment_div = '[% INCLUDE needinfo_comment_div FILTER js %]'; + if (document.getElementById('inline-history-ext')) { + needinfo_comment_div = inline_history.getNeedinfoDiv(); + } + + if (needinfo_comment_div) { + var a = document.createElement('a'); + a.id = 'needinfo-lnk'; + a.href = "#" + needinfo_comment_div; + a.appendChild(document.createTextNode('NEEDINFO')); + summary_container.appendChild(document.createTextNode('[')); + summary_container.appendChild(a); + summary_container.appendChild(document.createTextNode(']')); + } + else { + summary_container.appendChild(document.createTextNode('[NEEDINFO]')); + } + } + </script> + [% END %] + <table> + [% FOREACH flag = needinfo_flags %] + <tr> + [% IF !flag.requestee || flag.requestee.id == user.id %] + [%# needinfo targetted at the current user, or anyone %] + <td align="center"> + <input type="checkbox" id="needinfo_override_[% flag.id FILTER html %]" + name="needinfo_override_[% flag.id FILTER html %]" value="1" + [% " checked" IF flag.requestee || user.in_group("canconfirm") %]> + </td> + <td> + <label for="needinfo_override_[% flag.id FILTER html %]"> + Clear the needinfo request for + <em>[% IF !flag.requestee %]anyone[% ELSE %][% flag.requestee.login FILTER html %][% END %]</em>. + </label> + </td> + [% ELSIF user.in_group("canconfirm") || flag.setter_id == user.id %] + [%# needinfo targetted at someone else, but the user can clear %] + <td align="center"> + <input type="checkbox" id="needinfo_override_[% flag.id FILTER html %]" + name="needinfo_override_[% flag.id FILTER html %]" value="1"> + </td> + <td> + <label for="needinfo_override_[% flag.id FILTER html %]"> + I am providing the requested information for <em>[% flag.requestee.login FILTER html %]</em> + (clears the needinfo request). + </label> + </td> + [% ELSE %] + [%# current user does not have permissions to clear needinfo %] + <td> </td> + <td> + Needinfo requested from <em>[% flag.requestee.login FILTER html %]</em>. + </td> + [% END %] + </tr> + [% END %] + [% IF needinfo_flags.size == 0 || needinfo_flagtype.is_multiplicable %] + <tr> + <td align="center"> + <script> + function needinfo_init() { + needinfo_visibility(); + [% FOREACH flag = needinfo_flags %] + YAHOO.util.Event.on('requestee-[% flag.id FILTER none %]', 'blur', function(e) { + YAHOO.util.Dom.get('needinfo_override_[% flag.id FILTER none %]').checked = + e.target.value == '[% flag.requestee.login FILTER js %]'; + }); + [% END %] + } + + function needinfo_visibility() { + var role = YAHOO.util.Dom.get('needinfo_role').value; + if (role == 'other') { + YAHOO.util.Dom.removeClass('needinfo_from_container', 'bz_default_hidden'); + YAHOO.util.Dom.get('needinfo_from').disabled = false; + YAHOO.util.Dom.get('needinfo_role_identity').innerHTML = ''; + } else { + YAHOO.util.Dom.addClass('needinfo_from_container', 'bz_default_hidden'); + YAHOO.util.Dom.get('needinfo_from').disabled = true; + var identity = ''; + if (role == 'reporter') { + identity = '[% bug.reporter.realname || bug.reporter.login FILTER html FILTER js %]'; + } else if (role == 'assigned_to') { + identity = '[% bug.assigned_to.realname || bug.assigned_to.login FILTER html FILTER js %]'; + } else if (role == 'qa_contact') { + identity = '[% bug.qa_contact.realname || bug.qa_contact.login FILTER html FILTER js %]'; + } else if (role == 'user') { + identity = '[% user.realname || user.login FILTER html FILTER js %]'; + [% FOREACH mentor = bug.mentors %] + } else if (role == '[% mentor.login FILTER js %]') { + identity = '[% mentor.realname || mentor.login FILTER html FILTER js +%] [%+ IF bug.mentors.size > 1 %](mentor)[% END %]'; + [% END %] + } + YAHOO.util.Dom.get('needinfo_role_identity').innerHTML = identity; + } + } + + function needinfo_focus() { + if (YAHOO.util.Dom.get('needinfo').checked + && YAHOO.util.Dom.get('needinfo_role').value == 'other') + { + YAHOO.util.Dom.get('needinfo_from').focus(); + YAHOO.util.Dom.get('needinfo_from').select(); + } + } + + function needinfo_role_changed() { + YAHOO.util.Dom.get('needinfo').checked = true; + needinfo_visibility(); + needinfo_focus(); + } + + function needinfo_other_changed() { + YAHOO.util.Dom.get('needinfo').checked = YAHOO.util.Dom.get('needinfo_from').value != ''; + } + + YAHOO.util.Event.onDOMReady(needinfo_init); + </script> + <input type="checkbox" name="needinfo" value="1" id="needinfo" onchange="needinfo_focus()"> + </td> + <td> + <label for="needinfo">Need more information from</label> + <select name="needinfo_role" id="needinfo_role" onchange="needinfo_role_changed()"> + <option value="other">other</option> + <option value="reporter">reporter</option> + <option value="assigned_to">assignee</option> + [% IF Param('useqacontact') && bug.qa_contact.login != "" %] + <option value="qa_contact">qa contact</option> + [% END %] + <option value="user">myself</option> + [% FOREACH mentor = bug.mentors %] + <option [% IF bug.mentors.size > 1 %]title="mentor"[% END %] value="[% mentor.login FILTER html %]"> + [% bug.mentors.size == 1 ? "mentor" : mentor.login FILTER html %] + </option> + [% END %] + </select> + <span id="needinfo_from_container"> + [% INCLUDE global/userselect.html.tmpl + id => "needinfo_from" + name => "needinfo_from" + value => "" + size => 30 + multiple => 5 + onchange => "needinfo_other_changed()" + field_title => "Enter one or more comma separated users to request more information from" + %] + </span> + <span id="needinfo_role_identity"></span> + </td> + </tr> + [% END %] + </table> + </div> +[% END %] diff --git a/extensions/Needinfo/template/en/default/hook/attachment/create-form_before_submit.html.tmpl b/extensions/Needinfo/template/en/default/hook/attachment/create-form_before_submit.html.tmpl new file mode 100644 index 000000000..81b6d57ea --- /dev/null +++ b/extensions/Needinfo/template/en/default/hook/attachment/create-form_before_submit.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. + #%] + +<tr> + <td> </td> + <td> + [% PROCESS bug/needinfo.html.tmpl + bug => bug + %] + </td> +</tr> diff --git a/extensions/Needinfo/template/en/default/hook/attachment/edit-after_comment_textarea.html.tmpl b/extensions/Needinfo/template/en/default/hook/attachment/edit-after_comment_textarea.html.tmpl new file mode 100644 index 000000000..b14de653a --- /dev/null +++ b/extensions/Needinfo/template/en/default/hook/attachment/edit-after_comment_textarea.html.tmpl @@ -0,0 +1,25 @@ +[%# 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 bug/needinfo.html.tmpl + bug => attachment.bug +%] +<script type="text/javascript"> + document.getElementById('editButton').addEventListener('click', function() { + document.getElementById('attachment_view_window') + .appendChild(document.getElementById('needinfo_container')); + }); + document.getElementById('redoEditButton').addEventListener('click', function() { + document.getElementById('attachment_view_window') + .appendChild(document.getElementById('needinfo_container')); + }); + document.getElementById('undoEditButton').addEventListener('click', function() { + document.getElementById('smallCommentFrame') + .appendChild(document.getElementById('needinfo_container')); + }); +</script> diff --git a/extensions/Needinfo/template/en/default/hook/bug/edit-after_comment_commit_button.html.tmpl b/extensions/Needinfo/template/en/default/hook/bug/edit-after_comment_commit_button.html.tmpl new file mode 100644 index 000000000..90f0cc584 --- /dev/null +++ b/extensions/Needinfo/template/en/default/hook/bug/edit-after_comment_commit_button.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. + #%] + +[% PROCESS bug/needinfo.html.tmpl + bug = bug +%] diff --git a/extensions/Needinfo/template/en/default/hook/global/header-start.html.tmpl b/extensions/Needinfo/template/en/default/hook/global/header-start.html.tmpl new file mode 100644 index 000000000..7f2095e3d --- /dev/null +++ b/extensions/Needinfo/template/en/default/hook/global/header-start.html.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. + #%] + +[% IF template.name == 'attachment/create.html.tmpl' + || template.name == 'attachment/edit.html.tmpl' %] + [% style_urls.push('extensions/Needinfo/web/styles/needinfo.css') %] +[% END %] diff --git a/extensions/Needinfo/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/Needinfo/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..f1241bc61 --- /dev/null +++ b/extensions/Needinfo/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,13 @@ +[%# 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 == "needinfo_illegal_change" %] + [% title = 'Needinfo Illegal Change' %] + Only the requestee or a user with the required permissions can clear a + needinfo flag. +[% END %] diff --git a/extensions/Needinfo/template/en/default/hook/request/email-after_summary.txt.tmpl b/extensions/Needinfo/template/en/default/hook/request/email-after_summary.txt.tmpl new file mode 100644 index 000000000..6a302b76a --- /dev/null +++ b/extensions/Needinfo/template/en/default/hook/request/email-after_summary.txt.tmpl @@ -0,0 +1,13 @@ +[%# 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 flag && flag.type.name == 'needinfo' && flag.status == '?' %] +--- +--- This request has set a needinfo flag on the [% terms.bug %]. +--- You can clear it by logging in and replying in a comment. +--- diff --git a/extensions/Needinfo/web/styles/needinfo.css b/extensions/Needinfo/web/styles/needinfo.css new file mode 100644 index 000000000..e375ba610 --- /dev/null +++ b/extensions/Needinfo/web/styles/needinfo.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. */ + +#needinfo_container label { + font-weight: normal !important; +} diff --git a/extensions/OpenGraph/Config.pm b/extensions/OpenGraph/Config.pm new file mode 100644 index 000000000..9204db234 --- /dev/null +++ b/extensions/OpenGraph/Config.pm @@ -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. + +package Bugzilla::Extension::OpenGraph; + +use strict; + +use constant NAME => 'OpenGraph'; +use constant REQUIRED_MODULES => [ ]; +use constant OPTIONAL_MODULES => [ ]; + +__PACKAGE__->NAME; diff --git a/extensions/OpenGraph/Extension.pm b/extensions/OpenGraph/Extension.pm new file mode 100644 index 000000000..f278a8958 --- /dev/null +++ b/extensions/OpenGraph/Extension.pm @@ -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. + +package Bugzilla::Extension::OpenGraph; + +use strict; + +use base qw(Bugzilla::Extension); + +our $VERSION = '1'; + +__PACKAGE__->NAME; diff --git a/extensions/OpenGraph/template/en/default/hook/global/header-start.html.tmpl b/extensions/OpenGraph/template/en/default/hook/global/header-start.html.tmpl new file mode 100644 index 000000000..2a6ab37df --- /dev/null +++ b/extensions/OpenGraph/template/en/default/hook/global/header-start.html.tmpl @@ -0,0 +1,13 @@ +[%# 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 Bugzilla %] +<meta property="og:type" content="website"> +<meta property="og:image" content="[% urlbase FILTER none %]extensions/OpenGraph/web/bugzilla.png"> +<meta property="og:title" content="[% title FILTER none %]"> +<meta property="og:url" content="[% Bugzilla.cgi.self_url FILTER none %]"> diff --git a/extensions/OpenGraph/web/bugzilla.png b/extensions/OpenGraph/web/bugzilla.png Binary files differnew file mode 100644 index 000000000..55ee01210 --- /dev/null +++ b/extensions/OpenGraph/web/bugzilla.png diff --git a/extensions/OrangeFactor/Config.pm b/extensions/OrangeFactor/Config.pm new file mode 100644 index 000000000..9fb0d74ef --- /dev/null +++ b/extensions/OrangeFactor/Config.pm @@ -0,0 +1,13 @@ +# 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::OrangeFactor; +use strict; + +use constant NAME => 'OrangeFactor'; + +__PACKAGE__->NAME; diff --git a/extensions/OrangeFactor/Extension.pm b/extensions/OrangeFactor/Extension.pm new file mode 100644 index 000000000..af629e323 --- /dev/null +++ b/extensions/OrangeFactor/Extension.pm @@ -0,0 +1,43 @@ +# 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::OrangeFactor; +use strict; +use base qw(Bugzilla::Extension); + +use Bugzilla::User::Setting; +use Bugzilla::Constants; +use Bugzilla::Attachment; + +our $VERSION = '1.0'; + +sub template_before_process { + my ($self, $args) = @_; + my $file = $args->{'file'}; + my $vars = $args->{'vars'}; + + my $user = Bugzilla->user; + + return unless ($file eq 'bug/show-header.html.tmpl' + || $file eq 'bug/edit.html.tmpl'); + return unless ($user->id + && $user->settings->{'orange_factor'}->{'value'} eq 'on'); + + # in the header we just need to set the var, + # to ensure the css and javascript get included + my $bug = exists $vars->{'bugs'} ? $vars->{'bugs'}[0] : $vars->{'bug'}; + if ($bug && grep($_->name eq 'intermittent-failure', @{ $bug->keyword_objects })) { + $vars->{'orange_factor'} = 1; + } +} + +sub install_before_final_checks { + my ($self, $args) = @_; + add_setting('orange_factor', ['on', 'off'], 'off'); +} + +__PACKAGE__->NAME; diff --git a/extensions/OrangeFactor/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl b/extensions/OrangeFactor/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl new file mode 100644 index 000000000..a41188a63 --- /dev/null +++ b/extensions/OrangeFactor/template/en/default/hook/bug/edit-after_custom_fields.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. + #%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +[% IF orange_factor %] + <tr> + <th class="field_label" valign="top"> + Orange Factor: + </th> + <td> + [% IF cgi.user_agent.match('(?i)gecko') %] + <canvas id="orange-graph" class="bz_default_hidden"></canvas> + <span id="orange-count"></span> + [% END %] + (<a href="https://brasstacks.mozilla.com/orangefactor/?display=Bug&bugid=[% bug.bug_id FILTER uri %]" + title="Click to load Orange Factor page for this [% terms.bug %]">link</a>) + </td> + </tr> +[% END %] diff --git a/extensions/OrangeFactor/template/en/default/hook/bug/show-header-end.html.tmpl b/extensions/OrangeFactor/template/en/default/hook/bug/show-header-end.html.tmpl new file mode 100644 index 000000000..b41431dcf --- /dev/null +++ b/extensions/OrangeFactor/template/en/default/hook/bug/show-header-end.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. + #%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +[% IF orange_factor && cgi.user_agent.match('(?i)gecko') %] + [% style_urls.push('extensions/OrangeFactor/web/style/orangefactor.css') %] + [% javascript_urls.push('extensions/OrangeFactor/web/js/sparklines.min.js') %] + [% javascript_urls.push('extensions/OrangeFactor/web/js/orange_factor.js') %] +[% END %] + diff --git a/extensions/OrangeFactor/template/en/default/hook/global/setting-descs-settings.none.tmpl b/extensions/OrangeFactor/template/en/default/hook/global/setting-descs-settings.none.tmpl new file mode 100644 index 000000000..21a525deb --- /dev/null +++ b/extensions/OrangeFactor/template/en/default/hook/global/setting-descs-settings.none.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. + #%] + +[% + setting_descs.orange_factor = "When viewing a $terms.bug, show its corresponding Orange Factor page" +%] diff --git a/extensions/OrangeFactor/web/js/AUTHORS.processing.js b/extensions/OrangeFactor/web/js/AUTHORS.processing.js new file mode 100644 index 000000000..e1244b717 --- /dev/null +++ b/extensions/OrangeFactor/web/js/AUTHORS.processing.js @@ -0,0 +1,35 @@ +John Resig +Alistair MacDonald +David Humphrey +Corban Brook +Anna Sobiepanek +Andor Salga +Daniel Hodgin +Scott Downe +Yuri Delendik +Mike Kamermans +Chris Lonnen +Mickael Medel +Matthew Lam +Jon Buckley +Dominic Baranski +Elijah Grey +Thomas Saunders +Abel Allison +Andrew Grimo +Donghui Liu +Edward Sin +Alex Londono +Robert O'Rourke +Thanh Dao +Zhibin Huang +John Turner +Tom Brown +Minoo Ziaei +Ricard Marxer +Matt Postill +Tiago Moreira +Jonathan Brodsky +Roger Sodre +James Boelen +Michal Ejdys` diff --git a/extensions/OrangeFactor/web/js/LICENSE.processing.js b/extensions/OrangeFactor/web/js/LICENSE.processing.js new file mode 100644 index 000000000..404e5d5eb --- /dev/null +++ b/extensions/OrangeFactor/web/js/LICENSE.processing.js @@ -0,0 +1,22 @@ +Copyright (C) 2008 John Resig +Copyright (C) 2009-2011; see the AUTHORS file for authors and +copyright holders. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/extensions/OrangeFactor/web/js/LICENSE.sparklines.js b/extensions/OrangeFactor/web/js/LICENSE.sparklines.js new file mode 100644 index 000000000..73aaca832 --- /dev/null +++ b/extensions/OrangeFactor/web/js/LICENSE.sparklines.js @@ -0,0 +1,20 @@ +Copyright (C) 2008 Will Larson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/extensions/OrangeFactor/web/js/orange_factor.js b/extensions/OrangeFactor/web/js/orange_factor.js new file mode 100644 index 000000000..da993580d --- /dev/null +++ b/extensions/OrangeFactor/web/js/orange_factor.js @@ -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. + */ + +YAHOO.namespace('OrangeFactor'); + +var OrangeFactor = YAHOO.OrangeFactor; + +OrangeFactor.dayMs = 24 * 60 * 60 * 1000, +OrangeFactor.limit = 7; + +OrangeFactor.getOrangeCount = function (data) { + data = data.oranges; + var total = 0, + days = [], + date = OrangeFactor.getCurrentDateMs() - OrangeFactor.limit * OrangeFactor.dayMs; + for(var i = 0; i < OrangeFactor.limit; i++) { + var iso = OrangeFactor.dateString(new Date(date)); + var count = data[iso] ? data[iso].orangecount : 0; + days.push(count); + total += count; + date += OrangeFactor.dayMs; + } + OrangeFactor.displayGraph(days); + OrangeFactor.displayCount(total); +} + +OrangeFactor.displayGraph = function (dayCounts) { + var max = dayCounts.reduce(function(max, count) { + return count > max ? count : max; + }); + var graphContainer = YAHOO.util.Dom.get('orange-graph'); + Dom.removeClass(graphContainer, 'bz_default_hidden'); + YAHOO.util.Dom.setAttribute(graphContainer, 'title', + 'failures over the past week, max in a day: ' + max); + var opts = { + "percentage_lines":[0.25, 0.5, 0.75], + "fill_between_percentage_lines": false, + "left_padding": 0, + "right_padding": 0, + "top_padding": 0, + "bottom_padding": 0, + "background": "#D0D0D0", + "stroke": "#000000", + "percentage_fill_color": "#CCCCFF", + "scale_from_zero": true, + }; + new Sparkline('orange-graph', dayCounts, opts).draw(); +} + +OrangeFactor.displayCount = function (count) { + var countContainer = YAHOO.util.Dom.get('orange-count'); + countContainer.innerHTML = encodeURIComponent(count) + + ' failures on trunk in the past week'; +} + +OrangeFactor.dateString = function (date) { + function norm(part) { + return JSON.stringify(part).length == 2 ? part : '0' + part; + } + return date.getFullYear() + + "-" + norm(date.getMonth() + 1) + + "-" + norm(date.getDate()); +} + +OrangeFactor.getCurrentDateMs = function () { + var d = new Date; + return d.getTime(); +} + +OrangeFactor.orangify = function () { + var bugId = document.forms['changeform'].id.value; + var url = "https://brasstacks.mozilla.com/orangefactor/api/count?" + + "bugid=" + encodeURIComponent(bugId) + + "&tree=trunk" + + "&callback=OrangeFactor.getOrangeCount"; + var script = document.createElement('script'); + Dom.setAttribute(script, 'src', url); + Dom.setAttribute(script, 'type', 'text/javascript'); + var head = document.getElementsByTagName('head')[0]; + head.appendChild(script); + var countContainer = YAHOO.util.Dom.get('orange-count'); + Dom.removeClass(countContainer, 'bz_default_hidden'); + countContainer.innerHTML = 'Loading...';a +} + +YAHOO.util.Event.onDOMReady(OrangeFactor.orangify); diff --git a/extensions/OrangeFactor/web/js/sparklines.min.js b/extensions/OrangeFactor/web/js/sparklines.min.js new file mode 100644 index 000000000..f1043c55e --- /dev/null +++ b/extensions/OrangeFactor/web/js/sparklines.min.js @@ -0,0 +1,133 @@ +/* Sparklines.js - Will Larson (http://lethain.com) + * This code is distributed under the MIT license. + * See LICENSE.sparklines.js + * More information: https://github.com/lethain/sparklines.js + * + * Processing.js - John Resig (http://ejohn.org/) + * See LICENSE.processing.js and AUTHORS.processing.js + * More information: http://processingjs.org/ + */ +(function(){this.Processing=function Processing(aElement,aCode){if(typeof aElement=="string") +aElement=document.getElementById(aElement);var p=buildProcessing(aElement);if(aCode) +p.init(aCode);return p;};function log(){try{console.log.apply(console,arguments);}catch(e){try{opera.postError.apply(opera,arguments);}catch(e){}}} +var parse=Processing.parse=function parse(aCode,p){aCode=aCode.replace(/\/\/ .*\n/g,"\n");aCode=aCode.replace(/([^\s])%([^\s])/g,"$1 % $2");aCode=aCode.replace(/(?:static )?(\w+ )(\w+)\s*(\([^\)]*\)\s*{)/g,function(all,type,name,args){if(name=="if"||name=="for"||name=="while"){return all;}else{return"Processing."+name+" = function "+name+args;}});aCode=aCode.replace(/\.length\(\)/g,".length");aCode=aCode.replace(/([\(,]\s*)(\w+)((?:\[\])+| )\s*(\w+\s*[\),])/g,"$1$4");aCode=aCode.replace(/([\(,]\s*)(\w+)((?:\[\])+| )\s*(\w+\s*[\),])/g,"$1$4");aCode=aCode.replace(/new (\w+)((?:\[([^\]]*)\])+)/g,function(all,name,args){return"new ArrayList("+args.slice(1,-1).split("][").join(", ")+")";});aCode=aCode.replace(/(?:static )?\w+\[\]\s*(\w+)\[?\]?\s*=\s*{.*?};/g,function(all){return all.replace(/{/g,"[").replace(/}/g,"]");});var intFloat=/(\n\s*(?:int|float)(?:\[\])?(?:\s*|[^\(]*?,\s*))([a-z]\w*)(;|,)/i;while(intFloat.test(aCode)){aCode=aCode.replace(new RegExp(intFloat),function(all,type,name,sep){return type+" "+name+" = 0"+sep;});} +aCode=aCode.replace(/(?:static )?(\w+)((?:\[\])+| ) *(\w+)\[?\]?(\s*[=,;])/g,function(all,type,arr,name,sep){if(type=="return") +return all;else +return"var "+name+sep;});aCode=aCode.replace(/=\s*{((.|\s)*?)};/g,function(all,data){return"= ["+data.replace(/{/g,"[").replace(/}/g,"]")+"]";});aCode=aCode.replace(/static\s*{((.|\n)*?)}/g,function(all,init){return init;});aCode=aCode.replace(/super\(/g,"superMethod(");var classes=["int","float","boolean","string"];function ClassReplace(all,name,extend,vars,last){classes.push(name);var static="";vars=vars.replace(/final\s+var\s+(\w+\s*=\s*.*?;)/g,function(all,set){static+=" "+name+"."+set;return"";});return"function "+name+"() {with(this){\n "+ +(extend?"var __self=this;function superMethod(){extendClass(__self,arguments,"+extend+");}\n":"")+ +vars.replace(/,\s?/g,";\n this.").replace(/\b(var |final |public )+\s*/g,"this.").replace(/this.(\w+);/g,"this.$1 = null;")+ +(extend?"extendClass(this, "+extend+");\n":"")+"<CLASS "+name+" "+static+">"+(typeof last=="string"?last:name+"(");} +var matchClasses=/(?:public |abstract |static )*class (\w+)\s*(?:extends\s*(\w+)\s*)?{\s*((?:.|\n)*?)\b\1\s*\(/g;var matchNoCon=/(?:public |abstract |static )*class (\w+)\s*(?:extends\s*(\w+)\s*)?{\s*((?:.|\n)*?)(Processing)/g;aCode=aCode.replace(matchClasses,ClassReplace);aCode=aCode.replace(matchNoCon,ClassReplace);var matchClass=/<CLASS (\w+) (.*?)>/,m;while((m=aCode.match(matchClass))){var left=RegExp.leftContext,allRest=RegExp.rightContext,rest=nextBrace(allRest),className=m[1],staticVars=m[2]||"";allRest=allRest.slice(rest.length+1);rest=rest.replace(new RegExp("\\b"+className+"\\(([^\\)]*?)\\)\\s*{","g"),function(all,args){args=args.split(/,\s*?/);if(args[0].match(/^\s*$/)) +args.shift();var fn="if ( arguments.length == "+args.length+" ) {\n";for(var i=0;i<args.length;i++){fn+=" var "+args[i]+" = arguments["+i+"];\n";} +return fn;});rest=rest.replace(/(?:public )?Processing.\w+ = function (\w+)\((.*?)\)/g,function(all,name,args){return"ADDMETHOD(this, '"+name+"', function("+args+")";});var matchMethod=/ADDMETHOD([\s\S]*?{)/,mc;var methods="";while((mc=rest.match(matchMethod))){var prev=RegExp.leftContext,allNext=RegExp.rightContext,next=nextBrace(allNext);methods+="addMethod"+mc[1]+next+"});" +rest=prev+allNext.slice(next.length+1);} +rest=methods+rest;aCode=left+rest+"\n}}"+staticVars+allRest;} +aCode=aCode.replace(/Processing.\w+ = function addMethod/g,"addMethod");function nextBrace(right){var rest=right;var position=0;var leftCount=1,rightCount=0;while(leftCount!=rightCount){var nextLeft=rest.indexOf("{");var nextRight=rest.indexOf("}");if(nextLeft<nextRight&&nextLeft!=-1){leftCount++;rest=rest.slice(nextLeft+1);position+=nextLeft+1;}else{rightCount++;rest=rest.slice(nextRight+1);position+=nextRight+1;}} +return right.slice(0,position-1);} +aCode=aCode.replace(/\(int\)/g,"0|");aCode=aCode.replace(new RegExp("\\(("+classes.join("|")+")(\\[\\])?\\)","g"),"");aCode=aCode.replace(/(\d+)f/g,"$1");aCode=aCode.replace(/('[a-zA-Z0-9]')/g,"$1.charCodeAt(0)");aCode=aCode.replace(/#([a-f0-9]{6})/ig,function(m,hex){var num=toNumbers(hex);return"color("+num[0]+","+num[1]+","+num[2]+")";});function toNumbers(str){var ret=[];str.replace(/(..)/g,function(str){ret.push(parseInt(str,16));});return ret;} +return aCode;};function buildProcessing(curElement){var p={};p.PI=Math.PI;p.TWO_PI=2*p.PI;p.HALF_PI=p.PI/2;p.P3D=3;p.CORNER=0;p.RADIUS=1;p.CENTER_RADIUS=1;p.CENTER=2;p.POLYGON=2;p.QUADS=5;p.TRIANGLES=6;p.POINTS=7;p.LINES=8;p.TRIANGLE_STRIP=9;p.TRIANGLE_FAN=4;p.QUAD_STRIP=3;p.CORNERS=10;p.CLOSE=true;p.RGB=1;p.HSB=2;p.LEFT=1;p.CENTER=2;p.RIGHT=3;var curContext=curElement.getContext("2d");var doFill=true;var doStroke=true;var loopStarted=false;var hasBackground=false;var doLoop=true;var looping=0;var curRectMode=p.CORNER;var curEllipseMode=p.CENTER;var inSetup=false;var inDraw=false;var curBackground="rgba(204,204,204,1)";var curFrameRate=1000;var curShape=p.POLYGON;var curShapeCount=0;var curvePoints=[];var curTightness=0;var opacityRange=255;var redRange=255;var greenRange=255;var blueRange=255;var pathOpen=false;var mousePressed=false;var keyPressed=false;var firstX,firstY,secondX,secondY,prevX,prevY;var curColorMode=p.RGB;var curTint=-1;var curTextSize=12;var curTextFont="Arial";var getLoaded=false;var start=(new Date).getTime();p.pmouseX=0;p.pmouseY=0;p.mouseX=0;p.mouseY=0;p.mouseButton=0;p.mouseDragged=undefined;p.mouseMoved=undefined;p.mousePressed=undefined;p.mouseReleased=undefined;p.keyPressed=undefined;p.keyReleased=undefined;p.draw=undefined;p.setup=undefined;p.width=curElement.width-0;p.height=curElement.height-0;p.frameCount=0;p.color=function color(aValue1,aValue2,aValue3,aValue4){var aColor="";if(arguments.length==3){aColor=p.color(aValue1,aValue2,aValue3,opacityRange);}else if(arguments.length==4){var a=aValue4/opacityRange;a=isNaN(a)?1:a;if(curColorMode==p.HSB){var rgb=HSBtoRGB(aValue1,aValue2,aValue3);var r=rgb[0],g=rgb[1],b=rgb[2];}else{var r=getColor(aValue1,redRange);var g=getColor(aValue2,greenRange);var b=getColor(aValue3,blueRange);} +aColor="rgba("+r+","+g+","+b+","+a+")";}else if(typeof aValue1=="string"){aColor=aValue1;if(arguments.length==2){var c=aColor.split(",");c[3]=(aValue2/opacityRange)+")";aColor=c.join(",");}}else if(arguments.length==2){aColor=p.color(aValue1,aValue1,aValue1,aValue2);}else if(typeof aValue1=="number"){aColor=p.color(aValue1,aValue1,aValue1,opacityRange);}else{aColor=p.color(redRange,greenRange,blueRange,opacityRange);} +function HSBtoRGB(h,s,b){h=(h/redRange)*100;s=(s/greenRange)*100;b=(b/blueRange)*100;if(s==0){return[b,b,b];}else{var hue=h%360;var f=hue%60;var br=Math.round(b/100*255);var p=Math.round((b*(100-s))/10000*255);var q=Math.round((b*(6000-s*f))/600000*255);var t=Math.round((b*(6000-s*(60-f)))/600000*255);switch(Math.floor(hue/60)){case 0:return[br,t,p];case 1:return[q,br,p];case 2:return[p,br,t];case 3:return[p,q,br];case 4:return[t,p,br];case 5:return[br,p,q];}}} +function getColor(aValue,range){return Math.round(255*(aValue/range));} +return aColor;} +p.nf=function(num,pad){var str=""+num;while(pad-str.length) +str="0"+str;return str;};p.AniSprite=function(prefix,frames){this.images=[];this.pos=0;for(var i=0;i<frames;i++){this.images.push(prefix+p.nf(i,(""+frames).length)+".gif");} +this.display=function(x,y){p.image(this.images[this.pos],x,y);if(++this.pos>=frames) +this.pos=0;};this.getWidth=function(){return getImage(this.images[0]).width;};this.getHeight=function(){return getImage(this.images[0]).height;};};function buildImageObject(obj){var pixels=obj.data;var data=p.createImage(obj.width,obj.height);if(data.__defineGetter__&&data.__lookupGetter__&&!data.__lookupGetter__("pixels")){var pixelsDone;data.__defineGetter__("pixels",function(){if(pixelsDone) +return pixelsDone;pixelsDone=[];for(var i=0;i<pixels.length;i+=4){pixelsDone.push(p.color(pixels[i],pixels[i+1],pixels[i+2],pixels[i+3]));} +return pixelsDone;});}else{data.pixels=[];for(var i=0;i<pixels.length;i+=4){data.pixels.push(p.color(pixels[i],pixels[i+1],pixels[i+2],pixels[i+3]));}} +return data;} +p.createImage=function createImage(w,h,mode){var data={};data.width=w;data.height=h;data.data=[];if(curContext.createImageData){data=curContext.createImageData(w,h);} +data.pixels=new Array(w*h);data.get=function(x,y){return this.pixels[w*y+x];};data._mask=null;data.mask=function(img){this._mask=img;};data.loadPixels=function(){};data.updatePixels=function(){};return data;};p.createGraphics=function createGraphics(w,h){var canvas=document.createElement("canvas");var ret=buildProcessing(canvas);ret.size(w,h);ret.canvas=canvas;return ret;};p.beginDraw=function beginDraw(){};p.endDraw=function endDraw(){};p.tint=function tint(rgb,a){curTint=a;};function getImage(img){if(typeof img=="string"){return document.getElementById(img);} +if(img.img||img.canvas){return img.img||img.canvas;} +for(var i=0,l=img.pixels.length;i<l;i++){var pos=i*4;var c=(img.pixels[i]||"rgba(0,0,0,1)").slice(5,-1).split(",");img.data[pos]=parseInt(c[0]);img.data[pos+1]=parseInt(c[1]);img.data[pos+2]=parseInt(c[2]);img.data[pos+3]=parseFloat(c[3])*100;} +var canvas=document.createElement("canvas") +canvas.width=img.width;canvas.height=img.height;var context=canvas.getContext("2d");context.putImageData(img,0,0);img.canvas=canvas;return canvas;} +p.image=function image(img,x,y,w,h){x=x||0;y=y||0;var obj=getImage(img);if(curTint>=0){var oldAlpha=curContext.globalAlpha;curContext.globalAlpha=curTint/opacityRange;} +if(arguments.length==3){curContext.drawImage(obj,x,y);}else{curContext.drawImage(obj,x,y,w,h);} +if(curTint>=0){curContext.globalAlpha=oldAlpha;} +if(img._mask){var oldComposite=curContext.globalCompositeOperation;curContext.globalCompositeOperation="darker";p.image(img._mask,x,y);curContext.globalCompositeOperation=oldComposite;}};p.exit=function exit(){clearInterval(looping);};p.save=function save(file){};p.loadImage=function loadImage(file){var img=document.getElementById(file);if(!img) +return;var h=img.height,w=img.width;var canvas=document.createElement("canvas");canvas.width=w;canvas.height=h;var context=canvas.getContext("2d");context.drawImage(img,0,0);var data=buildImageObject(context.getImageData(0,0,w,h));data.img=img;return data;};p.loadFont=function loadFont(name){return{name:name,width:function(str){if(curContext.mozMeasureText) +return curContext.mozMeasureText(typeof str=="number"?String.fromCharCode(str):str)/curTextSize;else +return 0;}};};p.textFont=function textFont(name,size){curTextFont=name;p.textSize(size);};p.textSize=function textSize(size){if(size){curTextSize=size;}};p.textAlign=function textAlign(){};p.text=function text(str,x,y){if(str&&curContext.mozDrawText){curContext.save();curContext.mozTextStyle=curTextSize+"px "+curTextFont.name;curContext.translate(x,y);curContext.mozDrawText(typeof str=="number"?String.fromCharCode(str):str);curContext.restore();}};p.char=function char(key){return key;};p.println=function println(){};p.map=function map(value,istart,istop,ostart,ostop){return ostart+(ostop-ostart)*((value-istart)/(istop-istart));};String.prototype.replaceAll=function(re,replace){return this.replace(new RegExp(re,"g"),replace);};p.Point=function Point(x,y){this.x=x;this.y=y;this.copy=function(){return new Point(x,y);}};p.Random=function(){var haveNextNextGaussian=false;var nextNextGaussian;this.nextGaussian=function(){if(haveNextNextGaussian){haveNextNextGaussian=false;return nextNextGaussian;}else{var v1,v2,s;do{v1=2*p.random(1)-1;v2=2*p.random(1)-1;s=v1*v1+v2*v2;}while(s>=1||s==0);var multiplier=Math.sqrt(-2*Math.log(s)/s);nextNextGaussian=v2*multiplier;haveNextNextGaussian=true;return v1*multiplier;}};};p.ArrayList=function ArrayList(size,size2,size3){var array=new Array(0|size);if(size2){for(var i=0;i<size;i++){array[i]=[];for(var j=0;j<size2;j++){var a=array[i][j]=size3?new Array(size3):0;for(var k=0;k<size3;k++){a[k]=0;}}}}else{for(var i=0;i<size;i++){array[i]=0;}} +array.size=function(){return this.length;};array.get=function(i){return this[i];};array.remove=function(i){return this.splice(i,1);};array.add=function(item){return this.push(item);};array.clone=function(){var a=new ArrayList(size);for(var i=0;i<size;i++){a[i]=this[i];} +return a;};array.isEmpty=function(){return!this.length;};array.clear=function(){this.length=0;};return array;};p.colorMode=function colorMode(mode,range1,range2,range3,range4){curColorMode=mode;if(arguments.length>=4){redRange=range1;greenRange=range2;blueRange=range3;} +if(arguments.length==5){opacityRange=range4;} +if(arguments.length==2){p.colorMode(mode,range1,range1,range1,range1);}};p.beginShape=function beginShape(type){curShape=type;curShapeCount=0;curvePoints=[];};p.endShape=function endShape(close){if(curShapeCount!=0){if(close||doFill) +curContext.lineTo(firstX,firstY);if(doFill) +curContext.fill();if(doStroke) +curContext.stroke();curContext.closePath();curShapeCount=0;pathOpen=false;} +if(pathOpen){if(doFill) +curContext.fill();if(doStroke) +curContext.stroke();curContext.closePath();curShapeCount=0;pathOpen=false;}};p.vertex=function vertex(x,y,x2,y2,x3,y3){if(curShapeCount==0&&curShape!=p.POINTS){pathOpen=true;curContext.beginPath();curContext.moveTo(x,y);firstX=x;firstY=y;}else{if(curShape==p.POINTS){p.point(x,y);}else if(arguments.length==2){if(curShape!=p.QUAD_STRIP||curShapeCount!=2) +curContext.lineTo(x,y);if(curShape==p.TRIANGLE_STRIP){if(curShapeCount==2){p.endShape(p.CLOSE);pathOpen=true;curContext.beginPath();curContext.moveTo(prevX,prevY);curContext.lineTo(x,y);curShapeCount=1;} +firstX=prevX;firstY=prevY;} +if(curShape==p.TRIANGLE_FAN&&curShapeCount==2){p.endShape(p.CLOSE);pathOpen=true;curContext.beginPath();curContext.moveTo(firstX,firstY);curContext.lineTo(x,y);curShapeCount=1;} +if(curShape==p.QUAD_STRIP&&curShapeCount==3){curContext.lineTo(prevX,prevY);p.endShape(p.CLOSE);pathOpen=true;curContext.beginPath();curContext.moveTo(prevX,prevY);curContext.lineTo(x,y);curShapeCount=1;} +if(curShape==p.QUAD_STRIP){firstX=secondX;firstY=secondY;secondX=prevX;secondY=prevY;}}else if(arguments.length==4){if(curShapeCount>1){curContext.moveTo(prevX,prevY);curContext.quadraticCurveTo(firstX,firstY,x,y);curShapeCount=1;}}else if(arguments.length==6){curContext.bezierCurveTo(x,y,x2,y2,x3,y3);curShapeCount=-1;}} +prevX=x;prevY=y;curShapeCount++;if(curShape==p.LINES&&curShapeCount==2||(curShape==p.TRIANGLES)&&curShapeCount==3||(curShape==p.QUADS)&&curShapeCount==4){p.endShape(p.CLOSE);}};p.curveVertex=function(x,y,x2,y2){if(curvePoints.length<3){curvePoints.push([x,y]);}else{var b=[],s=1-curTightness;curvePoints.push([x,y]);b[0]=[curvePoints[1][0],curvePoints[1][1]];b[1]=[curvePoints[1][0]+(s*curvePoints[2][0]-s*curvePoints[0][0])/6,curvePoints[1][1]+(s*curvePoints[2][1]-s*curvePoints[0][1])/6];b[2]=[curvePoints[2][0]+(s*curvePoints[1][0]-s*curvePoints[3][0])/6,curvePoints[2][1]+(s*curvePoints[1][1]-s*curvePoints[3][1])/6];b[3]=[curvePoints[2][0],curvePoints[2][1]];if(!pathOpen){p.vertex(b[0][0],b[0][1]);}else{curShapeCount=1;} +p.vertex(b[1][0],b[1][1],b[2][0],b[2][1],b[3][0],b[3][1]);curvePoints.shift();}};p.curveTightness=function(tightness){curTightness=tightness;};p.bezierVertex=p.vertex;p.rectMode=function rectMode(aRectMode){curRectMode=aRectMode;};p.imageMode=function(){};p.ellipseMode=function ellipseMode(aEllipseMode){curEllipseMode=aEllipseMode;};p.dist=function dist(x1,y1,x2,y2){return Math.sqrt(Math.pow(x2-x1,2)+Math.pow(y2-y1,2));};p.year=function year(){return(new Date).getYear()+1900;};p.month=function month(){return(new Date).getMonth();};p.day=function day(){return(new Date).getDay();};p.hour=function hour(){return(new Date).getHours();};p.minute=function minute(){return(new Date).getMinutes();};p.second=function second(){return(new Date).getSeconds();};p.millis=function millis(){return(new Date).getTime()-start;};p.ortho=function ortho(){};p.translate=function translate(x,y){curContext.translate(x,y);};p.scale=function scale(x,y){curContext.scale(x,y||x);};p.rotate=function rotate(aAngle){curContext.rotate(aAngle);};p.pushMatrix=function pushMatrix(){curContext.save();};p.popMatrix=function popMatrix(){curContext.restore();};p.redraw=function redraw(){if(hasBackground){p.background();} +p.frameCount++;inDraw=true;p.pushMatrix();p.draw();p.popMatrix();inDraw=false;};p.loop=function loop(){if(loopStarted) +return;looping=setInterval(function(){try{p.redraw();} +catch(e){clearInterval(looping);throw e;}},1000/curFrameRate);loopStarted=true;};p.frameRate=function frameRate(aRate){curFrameRate=aRate;};p.background=function background(img){if(arguments.length){if(img&&img.img){curBackground=img;}else{curBackground=p.color.apply(this,arguments);}} +if(curBackground.img){p.image(curBackground,0,0);}else{var oldFill=curContext.fillStyle;curContext.fillStyle=curBackground+"";curContext.fillRect(0,0,p.width,p.height);curContext.fillStyle=oldFill;}};p.sq=function sq(aNumber){return aNumber*aNumber;};p.sqrt=function sqrt(aNumber){return Math.sqrt(aNumber);};p.int=function int(aNumber){return Math.floor(aNumber);};p.min=function min(aNumber,aNumber2){return Math.min(aNumber,aNumber2);};p.max=function max(aNumber,aNumber2){return Math.max(aNumber,aNumber2);};p.ceil=function ceil(aNumber){return Math.ceil(aNumber);};p.floor=function floor(aNumber){return Math.floor(aNumber);};p.float=function float(aNumber){return typeof aNumber=="string"?p.float(aNumber.charCodeAt(0)):parseFloat(aNumber);};p.byte=function byte(aNumber){return aNumber||0;};p.random=function random(aMin,aMax){return arguments.length==2?aMin+(Math.random()*(aMax-aMin)):Math.random()*aMin;};p.noise=function(x,y,z){return arguments.length>=2?PerlinNoise_2D(x,y):PerlinNoise_2D(x,x);};function Noise(x,y){var n=x+y*57;n=(n<<13)^n;return Math.abs(1.0-(((n*((n*n*15731)+789221)+1376312589)&0x7fffffff)/1073741824.0));};function SmoothedNoise(x,y){var corners=(Noise(x-1,y-1)+Noise(x+1,y-1)+Noise(x-1,y+1)+Noise(x+1,y+1))/16;var sides=(Noise(x-1,y)+Noise(x+1,y)+Noise(x,y-1)+Noise(x,y+1))/8;var center=Noise(x,y)/4;return corners+sides+center;};function InterpolatedNoise(x,y){var integer_X=Math.floor(x);var fractional_X=x-integer_X;var integer_Y=Math.floor(y);var fractional_Y=y-integer_Y;var v1=SmoothedNoise(integer_X,integer_Y);var v2=SmoothedNoise(integer_X+1,integer_Y);var v3=SmoothedNoise(integer_X,integer_Y+1);var v4=SmoothedNoise(integer_X+1,integer_Y+1);var i1=Interpolate(v1,v2,fractional_X);var i2=Interpolate(v3,v4,fractional_X);return Interpolate(i1,i2,fractional_Y);} +function PerlinNoise_2D(x,y){var total=0;var p=0.25;var n=3;for(var i=0;i<=n;i++){var frequency=Math.pow(2,i);var amplitude=Math.pow(p,i);total=total+InterpolatedNoise(x*frequency,y*frequency)*amplitude;} +return total;} +function Interpolate(a,b,x){var ft=x*p.PI;var f=(1-p.cos(ft))*.5;return a*(1-f)+b*f;} +p.red=function(aColor){return parseInt(aColor.slice(5));};p.green=function(aColor){return parseInt(aColor.split(",")[1]);};p.blue=function(aColor){return parseInt(aColor.split(",")[2]);};p.alpha=function(aColor){return parseInt(aColor.split(",")[3]);};p.abs=function abs(aNumber){return Math.abs(aNumber);};p.cos=function cos(aNumber){return Math.cos(aNumber);};p.sin=function sin(aNumber){return Math.sin(aNumber);};p.pow=function pow(aNumber,aExponent){return Math.pow(aNumber,aExponent);};p.constrain=function constrain(aNumber,aMin,aMax){return Math.min(Math.max(aNumber,aMin),aMax);};p.sqrt=function sqrt(aNumber){return Math.sqrt(aNumber);};p.atan2=function atan2(aNumber,aNumber2){return Math.atan2(aNumber,aNumber2);};p.radians=function radians(aAngle){return(aAngle/180)*p.PI;};p.size=function size(aWidth,aHeight){var fillStyle=curContext.fillStyle;var strokeStyle=curContext.strokeStyle;curElement.width=p.width=aWidth;curElement.height=p.height=aHeight;curContext.fillStyle=fillStyle;curContext.strokeStyle=strokeStyle;};p.noStroke=function noStroke(){doStroke=false;};p.noFill=function noFill(){doFill=false;};p.smooth=function smooth(){};p.noLoop=function noLoop(){doLoop=false;};p.fill=function fill(){doFill=true;curContext.fillStyle=p.color.apply(this,arguments);};p.stroke=function stroke(){doStroke=true;curContext.strokeStyle=p.color.apply(this,arguments);};p.strokeWeight=function strokeWeight(w){curContext.lineWidth=w;};p.point=function point(x,y){var oldFill=curContext.fillStyle;curContext.fillStyle=curContext.strokeStyle;curContext.fillRect(Math.round(x),Math.round(y),1,1);curContext.fillStyle=oldFill;};p.get=function get(x,y){if(arguments.length==0){var c=p.createGraphics(p.width,p.height);c.image(curContext,0,0);return c;} +if(!getLoaded){getLoaded=buildImageObject(curContext.getImageData(0,0,p.width,p.height));} +return getLoaded.get(x,y);};p.set=function set(x,y,obj){if(obj&&obj.img){p.image(obj,x,y);}else{var oldFill=curContext.fillStyle;var color=obj;curContext.fillStyle=color;curContext.fillRect(Math.round(x),Math.round(y),1,1);curContext.fillStyle=oldFill;}};p.arc=function arc(x,y,width,height,start,stop){if(width<=0) +return;if(curEllipseMode==p.CORNER){x+=width/2;y+=height/2;} +curContext.beginPath();curContext.moveTo(x,y);curContext.arc(x,y,curEllipseMode==p.CENTER_RADIUS?width:width/2,start,stop,false);if(doFill) +curContext.fill();if(doStroke) +curContext.stroke();curContext.closePath();};p.line=function line(x1,y1,x2,y2){curContext.lineCap="round";curContext.beginPath();curContext.moveTo(x1||0,y1||0);curContext.lineTo(x2||0,y2||0);curContext.stroke();curContext.closePath();};p.bezier=function bezier(x1,y1,x2,y2,x3,y3,x4,y4){curContext.lineCap="butt";curContext.beginPath();curContext.moveTo(x1,y1);curContext.bezierCurveTo(x2,y2,x3,y3,x4,y4);curContext.stroke();curContext.closePath();};p.triangle=function triangle(x1,y1,x2,y2,x3,y3){p.beginShape();p.vertex(x1,y1);p.vertex(x2,y2);p.vertex(x3,y3);p.endShape();};p.quad=function quad(x1,y1,x2,y2,x3,y3,x4,y4){p.beginShape();p.vertex(x1,y1);p.vertex(x2,y2);p.vertex(x3,y3);p.vertex(x4,y4);p.endShape();};p.rect=function rect(x,y,width,height){if(width==0&&height==0) +return;curContext.beginPath();var offsetStart=0;var offsetEnd=0;if(curRectMode==p.CORNERS){width-=x;height-=y;} +if(curRectMode==p.RADIUS){width*=2;height*=2;} +if(curRectMode==p.CENTER||curRectMode==p.RADIUS){x-=width/2;y-=height/2;} +curContext.rect(Math.round(x)-offsetStart,Math.round(y)-offsetStart,Math.round(width)+offsetEnd,Math.round(height)+offsetEnd);if(doFill) +curContext.fill();if(doStroke) +curContext.stroke();curContext.closePath();};p.ellipse=function ellipse(x,y,width,height){x=x||0;y=y||0;if(width<=0&&height<=0) +return;curContext.beginPath();if(curEllipseMode==p.RADIUS){width*=2;height*=2;} +var offsetStart=0;if(width==height) +curContext.arc(x-offsetStart,y-offsetStart,width/2,0,Math.PI*2,false);if(doFill) +curContext.fill();if(doStroke) +curContext.stroke();curContext.closePath();};p.link=function(href,target){window.location=href;};p.loadPixels=function(){p.pixels=buildImageObject(curContext.getImageData(0,0,p.width,p.height)).pixels;};p.updatePixels=function(){var colors=/(\d+),(\d+),(\d+),(\d+)/;var pixels={};pixels.width=p.width;pixels.height=p.height;pixels.data=[];if(curContext.createImageData){pixels=curContext.createImageData(p.width,p.height);} +var data=pixels.data;var pos=0;for(var i=0,l=p.pixels.length;i<l;i++){var c=(p.pixels[i]||"rgba(0,0,0,1)").match(colors);data[pos]=parseInt(c[1]);data[pos+1]=parseInt(c[2]);data[pos+2]=parseInt(c[3]);data[pos+3]=parseFloat(c[4])*100;pos+=4;} +curContext.putImageData(pixels,0,0);};p.extendClass=function extendClass(obj,args,fn){if(arguments.length==3){fn.apply(obj,args);}else{args.call(obj);}};p.addMethod=function addMethod(object,name,fn){if(object[name]){var args=fn.length;var oldfn=object[name];object[name]=function(){if(arguments.length==args) +return fn.apply(this,arguments);else +return oldfn.apply(this,arguments);};}else{object[name]=fn;}};p.init=function init(code){p.stroke(0);p.fill(255);curContext.translate(0.5,0.5);if(code){(function(Processing){with(p){eval(parse(code,p));}})(p);} +if(p.setup){inSetup=true;p.setup();} +inSetup=false;if(p.draw){if(!doLoop){p.redraw();}else{p.loop();}} +attach(curElement,"mousemove",function(e){var scrollX=window.scrollX!=null?window.scrollX:window.pageXOffset;var scrollY=window.scrollY!=null?window.scrollY:window.pageYOffset;p.pmouseX=p.mouseX;p.pmouseY=p.mouseY;p.mouseX=e.clientX-curElement.offsetLeft+scrollX;p.mouseY=e.clientY-curElement.offsetTop+scrollY;if(p.mouseMoved){p.mouseMoved();} +if(mousePressed&&p.mouseDragged){p.mouseDragged();}});attach(curElement,"mousedown",function(e){mousePressed=true;p.mouseButton=e.which;if(typeof p.mousePressed=="function"){p.mousePressed();}else{p.mousePressed=true;}});attach(curElement,"contextmenu",function(e){e.preventDefault();e.stopPropagation();});attach(curElement,"mouseup",function(e){mousePressed=false;if(typeof p.mousePressed!="function"){p.mousePressed=false;} +if(p.mouseReleased){p.mouseReleased();}});attach(document,"keydown",function(e){keyPressed=true;p.key=e.keyCode+32;if(e.shiftKey){p.key=String.fromCharCode(p.key).toUpperCase().charCodeAt(0);} +if(typeof p.keyPressed=="function"){p.keyPressed();}else{p.keyPressed=true;}});attach(document,"keyup",function(e){keyPressed=false;if(typeof p.keyPressed!="function"){p.keyPressed=false;} +if(p.keyReleased){p.keyReleased();}});function attach(elem,type,fn){if(elem.addEventListener) +elem.addEventListener(type,fn,false);else +elem.attachEvent("on"+type,fn);}};return p;}})();if(!Array.prototype.map) +{Array.prototype.map=function(fun) +{var len=this.length;if(typeof fun!="function") +throw new TypeError();var res=new Array(len);var thisp=arguments[1];for(var i=0;i<len;i++) +{if(i in this) +res[i]=fun.call(thisp,this[i],i,this);} +return res;};} +var BaseSparkline=function(){this.init=function(id,data,mixins){this.background=50;this.stroke="rgba(230,230,230,0.70);";this.percentage_color="#5555FF";this.percentage_fill_color=75;this.value_line_color="#7777FF";this.value_line_fill_color=85;this.canvas=document.getElementById(id);this.data=data;this.scale_from=undefined;this.scale_to=undefined;this.top_padding=10;this.bottom_padding=10;this.left_padding=10;this.right_padding=10;this.percentage_lines=[];this.fill_between_percentage_lines=false;this.value_lines=[];this.fill_between_value_lines=false;for(var property in mixins)this[property]=mixins[property];};this.parse_height=function(x){return x;};this.heights=function(){return this.data.map(this.parse_height);};this.max=function(){var vals=this.heights();var max=vals[0];var l=vals.length;for(var i=1;i<l;i++)max=Math.max(max,vals[i]);return max;};this.min=function(){var vals=this.heights();var min=vals[0];var l=vals.length;for(var i=1;i<l;i++)min=Math.min(min,vals[i]);return min;};this.height=function(){return this.canvas.height-this.top_padding-this.bottom_padding;};this.width=function(){return this.canvas.width-this.left_padding-this.right_padding;};this.scale_values=function(values,max){if(!max)max=this.max();var p=this.top_padding;var h=this.height();var top=(this.scale_to!=undefined)?this.scale_to:max;var bottom=(this.scale_from!=undefined)?this.scale_from:this.min();var range=Math.abs(top-bottom);var scale=function(x){var percentage=((x-bottom)*1.0)/range;return h-(h*percentage)+p;};return values.map(scale,this);};this.calc_value_lines=function(){var scaled=this.scale_values(this.value_lines);scaled.sort(function(a,b){return a-b;});return scaled;};this.calc_percentages=function(){var sorted=this.heights();sorted.sort(function(a,b){return a-b;});var points=[];var n=sorted.length;var l=this.percentage_lines.length;for(var i=0;i<l;i++){var percentage=this.percentage_lines[i];var position=Math.round(percentage*(n+1));points.push(sorted[position]);} +var max=sorted[n-1];var raws=this.scale_values(points,max);raws.sort(function(a,b){return a-b;});return raws;};this.scale_height=function(){return this.scale_values(this.heights());};this.segment_width=function(){var w=this.width();var l=this.data.length;return(w*1.0)/(l-1);};this.scale_width=function(){var widths=[];var l=this.data.length;var segment_width=this.segment_width();for(var i=0;i<l;i++){widths.push((i*segment_width)+this.left_padding);} +return widths;};this.scale_data=function(){var heights=this.scale_height();var widths=this.scale_width();var l=heights.length;var data=[];for(var i=0;i<l;i++) +data.push({'y':heights[i],'x':widths[i]});return data;};this.draw=function(){var sl=this;with(Processing(sl.canvas)){setup=function(){};draw=function(){background(sl.background);scaled=sl.scale_data();var l=scaled.length;var percentages=sl.calc_percentages();if(sl.fill_between_percentage_lines&&percentages.length>1){noStroke();fill(sl.percentage_fill_color);var height=percentages[percentages.length-1]-percentages[0];var width=scaled[l-1].x-scaled[0].x;rect(scaled[0].x,percentages[0],width,height);} +var value_lines=sl.calc_value_lines();if(sl.fill_between_value_lines&&value_lines.length>1){noStroke();fill(sl.value_line_fill_color);var height=value_lines[value_lines.length-1]-value_lines[0];var width=scaled[l-1].x-scaled[0].x;rect(scaled[0].x,value_lines[0],width,height);} +stroke(sl.value_line_color);for(var h=0;h<value_lines.length;h++){var y=value_lines[h];line(scaled[0].x,y,scaled[l-1].x,y);} +stroke(sl.percentage_color);for(var j=0;j<percentages.length;j++){var y=percentages[j];line(scaled[0].x,y,scaled[l-1].x,y);} +stroke(sl.stroke);for(var i=1;i<l;i++){var curr=scaled[i];var previous=scaled[i-1];line(previous.x,previous.y,curr.x,curr.y);} +this.exit();};init();};};};var Sparkline=function(id,data,mixins){this.init(id,data,mixins);} +Sparkline.prototype=new BaseSparkline();var BarSparkline=function(id,data,mixins){if(!mixins)mixins={};this.marking_padding=5;this.padding_between_bars=5;this.extend_markings=true;if(!mixins.hasOwnProperty('scale_from'))mixins.scale_from=0;this.init(id,data,mixins);this.segment_width=function(){var l=this.data.length;var w=this.width();return((w*1.0)-((l-1)*this.padding_between_bars))/l;};this.scale_width=function(){var widths=[];var l=this.data.length;var segment_width=this.segment_width();for(var i=0;i<l;i++){widths.push((i*segment_width)+(this.padding_between_bars*i)+this.left_padding);} +return widths;};this.draw=function(){var sl=this;with(Processing(sl.canvas)){draw=function(){background(sl.background);var scaled=sl.scale_data();var l=scaled.length;var sw=sl.segment_width();var gap=sl.padding_between_bars;var mp=sl.marking_padding;var value_lines=sl.calc_value_lines();if(sl.fill_between_value_lines&&value_lines.length>1){noStroke();fill(sl.percentage_fill_color);var height=value_lines[value_lines.length-1]-value_lines[0];var width=scaled[l-1].x-scaled[0].x+sw;if(sl.extend_markings){width+=2*mp;rect(scaled[0].x-mp,value_lines[0],width,height);} +else rect(scaled[0].x,value_lines[0],width,height);} +stroke(sl.value_line_color);for(var h=0;h<value_lines.length;h++){var y=value_lines[h];if(sl.extend_markings){line(scaled[0].x-mp,y,scaled[l-1].x+mp+sw,y);} +else line(scaled[0].x,y,scaled[l-1].x+sw,y);} +var percentages=sl.calc_percentages();if(sl.fill_between_percentage_lines&&percentages.length>1){noStroke();fill(sl.percentage_fill_color);var height=percentages[percentages.length-1]-percentages[0];var width=scaled[l-1].x-scaled[0].x+sw;if(sl.extend_markings){width+=2*mp;rect(scaled[0].x-mp,percentages[0],width,height);} +else rect(scaled[0].x,percentages[0],width,height);} +stroke(sl.percentage_color);for(var j=0;j<percentages.length;j++){var y=percentages[j];if(sl.extend_markings){line(scaled[0].x-mp,y,scaled[l-1].x+mp+sw,y);} +else line(scaled[0].x,y,scaled[l-1].x+sw,y);} +stroke(sl.stroke);fill(sl.stroke);var width=sl.segment_width();var height=sl.height();for(var i=0;i<l;i++){var d=scaled[i];rect(d.x,d.y,width,height-d.y);};this.exit();};init();};};} +BarSparkline.prototype=new BaseSparkline(); diff --git a/extensions/OrangeFactor/web/style/orangefactor.css b/extensions/OrangeFactor/web/style/orangefactor.css new file mode 100644 index 000000000..211ad575e --- /dev/null +++ b/extensions/OrangeFactor/web/style/orangefactor.css @@ -0,0 +1,13 @@ +/* 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. */ + +#orange-graph { + display: block; + width: 180px; + height: 38px; + margin: 0 .5em .5em 0; +} diff --git a/extensions/Persona/Config.pm b/extensions/Persona/Config.pm new file mode 100644 index 000000000..8709655d1 --- /dev/null +++ b/extensions/Persona/Config.pm @@ -0,0 +1,29 @@ +# 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::Persona; +use strict; + +use constant NAME => 'Persona'; + +use constant REQUIRED_MODULES => [ + { + package => 'JSON', + module => 'JSON', + version => 0, + }, + { + package => 'libwww-perl', + module => 'LWP::UserAgent', + version => 0, + }, +]; + +use constant OPTIONAL_MODULES => [ +]; + +__PACKAGE__->NAME; diff --git a/extensions/Persona/Extension.pm b/extensions/Persona/Extension.pm new file mode 100644 index 000000000..f288702e8 --- /dev/null +++ b/extensions/Persona/Extension.pm @@ -0,0 +1,73 @@ +# 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::Persona; +use strict; +use base qw(Bugzilla::Extension); + +use Bugzilla::Config qw(SetParam write_params); + +our $VERSION = '0.01'; + +sub install_update_db { + # The extension changed from BrowserID to Persona + # so we need to update user_info_class if this system + # was using BrowserID for verification. + my $params = Bugzilla->params || Bugzilla::Config::read_param_file(); + my $user_info_class = $params->{'user_info_class'}; + if ($user_info_class =~ /BrowserID/) { + $user_info_class =~ s/BrowserID/Persona/; + SetParam('user_info_class', $user_info_class); + write_params(); + } +} + +sub auth_login_methods { + my ($self, $args) = @_; + my $modules = $args->{'modules'}; + if (exists($modules->{'Persona'})) { + $modules->{'Persona'} = 'Bugzilla/Extension/Persona/Login.pm'; + } +} + +sub config_modify_panels { + my ($self, $args) = @_; + my $panels = $args->{'panels'}; + my $auth_panel_params = $panels->{'auth'}->{'params'}; + + my ($user_info_class) = + grep { $_->{'name'} eq 'user_info_class' } @$auth_panel_params; + + if ($user_info_class) { + push(@{ $user_info_class->{'choices'} }, "Persona,CGI"); + } + + # The extension changed from BrowserID to Persona + # so we need to retain the current values for the new + # params that will be created. + my $params = Bugzilla->params || Bugzilla::Config::read_param_file(); + my $verify_url = $params->{'browserid_verify_url'}; + my $includejs_url = $params->{'browserid_includejs_url'}; + if ($verify_url && $includejs_url) { + foreach my $param (@{ $panels->{'persona'}->{'params'} }) { + if ($param->{'name'} eq 'persona_verify_url') { + $param->{'default'} = $verify_url; + } + if ($param->{'name'} eq 'persona_includejs_url') { + $param->{'default'} = $includejs_url; + } + } + } +} + +sub config_add_panels { + my ($self, $args) = @_; + my $modules = $args->{panel_modules}; + $modules->{Persona} = "Bugzilla::Extension::Persona::Config"; +} + +__PACKAGE__->NAME; diff --git a/extensions/Persona/TODO b/extensions/Persona/TODO new file mode 100644 index 000000000..ac94a3c42 --- /dev/null +++ b/extensions/Persona/TODO @@ -0,0 +1,19 @@ +ToDo: + +* Cache the LWP::UserAgent in Login.pm? + +* Fix Bugzilla::Auth::Login::Stack to allow failure part way down the chain + (currently, it seems that both CGI and BrowserID have to be last in order + to report login failures correctly.) + +* JS inclusions noticeably slow page load. Do we want a local copy of + browserid.js? Do the browserid folks object to that? How can we get good + performance? How can we avoid including it in every logged-in page? Can we + do demand loading onclick, and/or load-on-reveal? + +* Fix -8px margin-bottom hack in login-small-additional_methods.html.tmpl + + + + + diff --git a/extensions/Persona/lib/Config.pm b/extensions/Persona/lib/Config.pm new file mode 100644 index 000000000..9c483cb51 --- /dev/null +++ b/extensions/Persona/lib/Config.pm @@ -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. + +package Bugzilla::Extension::Persona::Config; + +use strict; +use warnings; + +use Bugzilla::Config::Common; + +our $sortkey = 1350; + +sub get_param_list { + my ($class) = @_; + + my @param_list = ( + { + name => 'persona_verify_url', + type => 't', + default => 'https://verifier.login.persona.org/verify', + }, + { + name => 'persona_includejs_url', + type => 't', + default => 'https://login.persona.org/include.js', + }, + { + name => 'persona_proxy_url', + type => 't', + default => '', + }, + ); + + return @param_list; +} + +1; diff --git a/extensions/Persona/lib/Login.pm b/extensions/Persona/lib/Login.pm new file mode 100644 index 000000000..ece92a3c0 --- /dev/null +++ b/extensions/Persona/lib/Login.pm @@ -0,0 +1,127 @@ +# 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::Persona::Login; +use strict; +use base qw(Bugzilla::Auth::Login); + +use Bugzilla::Constants; +use Bugzilla::Util; +use Bugzilla::Error; +use Bugzilla::Token; + +use JSON; +use LWP::UserAgent; + +use constant requires_verification => 0; +use constant is_automatic => 1; +use constant user_can_create_account => 1; + +sub get_login_info { + my ($self) = @_; + + my $cgi = Bugzilla->cgi; + + my $assertion = $cgi->param("persona_assertion"); + # Avoid the assertion being copied into any 'echoes' of the current URL + # in the page. + $cgi->delete('persona_assertion'); + + if (!$assertion || !Bugzilla->params->{persona_verify_url}) { + return { failure => AUTH_NODATA }; + } + + my $token = $cgi->param("token"); + $cgi->delete('token'); + check_hash_token($token, ['login']); + + my $urlbase = new URI(correct_urlbase()); + my $audience = $urlbase->scheme . "://" . $urlbase->host_port; + + my $ua = new LWP::UserAgent( timeout => 10 ); + if (Bugzilla->params->{persona_proxy_url}) { + $ua->proxy('https', Bugzilla->params->{persona_proxy_url}); + } + + my $response = $ua->post(Bugzilla->params->{persona_verify_url}, + [ assertion => $assertion, + audience => $audience ]); + if ($response->is_error) { + return { failure => AUTH_ERROR, + user_error => 'persona_server_fail', + details => { reason => $response->message }}; + } + + my $info; + eval { + $info = decode_json($response->decoded_content()); + }; + if ($@) { + return { failure => AUTH_ERROR, + user_error => 'persona_server_fail', + details => { reason => 'Received a malformed response.' }}; + } + if ($info->{'status'} eq 'failure') { + return { failure => AUTH_ERROR, + user_error => 'persona_server_fail', + details => { reason => $info->{reason} }}; + } + + if ($info->{'status'} eq "okay" && + $info->{'audience'} eq $audience && + ($info->{'expires'} / 1000) > time()) + { + my $login_data = { + 'username' => $info->{'email'} + }; + + my $result = Bugzilla::Auth::Verify->create_or_update_user($login_data); + return $result if $result->{'failure'}; + + my $user = $result->{'user'}; + + # You can restrict people in a particular group from logging in using + # Persona by making that group a member of a group called + # "no-browser-id". + # + # If you have your "createemailregexp" set up in such a way that a + # newly-created account is a member of "no-browser-id", this code will + # create an account for them and then fail their login. Which isn't + # great, but they can still use normal-Bugzilla-login password + # recovery. + if ($user->in_group('no-browser-id')) { + return { failure => AUTH_ERROR, + user_error => 'persona_account_too_powerful' }; + } + + $login_data->{'user'} = $user; + $login_data->{'user_id'} = $user->id; + + return $login_data; + } + else { + return { failure => AUTH_LOGINFAILED }; + } +} + +# Pinched from Bugzilla::Auth::Login::CGI +sub fail_nodata { + my ($self) = @_; + my $cgi = Bugzilla->cgi; + my $template = Bugzilla->template; + + if (Bugzilla->usage_mode != USAGE_MODE_BROWSER) { + ThrowUserError('login_required'); + } + + print $cgi->header(); + $template->process("account/auth/login.html.tmpl", { 'target' => $cgi->url(-relative=>1) }) + || ThrowTemplateError($template->error()); + exit; +} + +1; diff --git a/extensions/Persona/template/en/default/admin/params/browserid.html.tmpl b/extensions/Persona/template/en/default/admin/params/browserid.html.tmpl new file mode 100644 index 000000000..379d12058 --- /dev/null +++ b/extensions/Persona/template/en/default/admin/params/browserid.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. + #%] + +[% + title = "Persona" + desc = "Configure Persona Authentication" +%] + +[% param_descs = { + persona_verify_url => "This is the URL for the Persona authority that the " _ + "user will be verified against. " _ + "Example: <kbd>https://verifier.login.persona.org/verify</kbd>.", + persona_includejs_url => "This is the URL needed by Persona to load the necessary " _ + "javascript library for authentication. " _ + "Example: <kbd>https://persona.org/include.js</kbd>." + } +%] diff --git a/extensions/Persona/template/en/default/admin/params/persona.html.tmpl b/extensions/Persona/template/en/default/admin/params/persona.html.tmpl new file mode 100644 index 000000000..ef3cf32d2 --- /dev/null +++ b/extensions/Persona/template/en/default/admin/params/persona.html.tmpl @@ -0,0 +1,24 @@ +[%# 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. + #%] + +[% + title = "Persona" + desc = "Configure Persona Authentication" +%] + +[% param_descs = { + persona_verify_url => "This is the URL for the Persona authority that the " _ + "user will be verified against. " _ + "Example: <kbd>https://verifier.login.persona.org/verify</kbd>.", + persona_includejs_url => "This is the URL needed by Persona to load the necessary " _ + "javascript library for authentication. " _ + "Example: <kbd>https://login.persona.org/include.js</kbd>." + persona_proxy_url => "The URL of a HTTPS proxy server (optional). " _ + "Example: <kbd>http://proxy.example.com:3128</kbd>." + } +%] diff --git a/extensions/Persona/template/en/default/hook/account/auth/login-additional_methods.html.tmpl b/extensions/Persona/template/en/default/hook/account/auth/login-additional_methods.html.tmpl new file mode 100644 index 000000000..5be7910ad --- /dev/null +++ b/extensions/Persona/template/en/default/hook/account/auth/login-additional_methods.html.tmpl @@ -0,0 +1,6 @@ +[% IF Param('user_info_class').split(',').contains('Persona') + && Param('persona_includejs_url') %] +<p> + <img src="extensions/Persona/web/images/persona_sign_in.png" width="185" height="25" onclick="persona_sign_in()"> +</p> +[% END %] diff --git a/extensions/Persona/template/en/default/hook/account/auth/login-small-additional_methods.html.tmpl b/extensions/Persona/template/en/default/hook/account/auth/login-small-additional_methods.html.tmpl new file mode 100644 index 000000000..5d8503d73 --- /dev/null +++ b/extensions/Persona/template/en/default/hook/account/auth/login-small-additional_methods.html.tmpl @@ -0,0 +1,17 @@ +[% IF Param('user_info_class').split(',').contains('Persona') + && Param('persona_includejs_url') %] +<script type="text/javascript"> + YAHOO.util.Event.addListener('login_link[% qs_suffix FILTER js %]','click', function () { + var login_link = YAHOO.util.Dom.get('persona_mini_login[% qs_suffix FILTER js %]'); + YAHOO.util.Dom.removeClass(login_link, 'bz_default_hidden'); + }); + YAHOO.util.Event.addListener('hide_mini_login[% qs_suffix FILTER js %]','click', function () { + var login_link = YAHOO.util.Dom.get('persona_mini_login[% qs_suffix FILTER js %]'); + YAHOO.util.Dom.addClass(login_link, 'bz_default_hidden'); + }); +</script> +<span id="persona_mini_login[% qs_suffix FILTER html %]" class="bz_default_hidden"> + <img src="extensions/Persona/web/images/sign_in.png" height="22" width="75" align="absmiddle" + title="Sign in with Persona" onclick="persona_sign_in()"> or +</span> +[% END %] diff --git a/extensions/Persona/template/en/default/hook/account/create-additional_methods.html.tmpl b/extensions/Persona/template/en/default/hook/account/create-additional_methods.html.tmpl new file mode 100644 index 000000000..355ce3629 --- /dev/null +++ b/extensions/Persona/template/en/default/hook/account/create-additional_methods.html.tmpl @@ -0,0 +1,13 @@ +[%# 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 Param('user_info_class').split(',').contains('Persona') %] + +Or, use your Persona account: +<img src="extensions/Persona/web/images/sign_in.png" onclick="persona_sign_in()" + width="95" height="25" align="absmiddle"> diff --git a/extensions/Persona/template/en/default/hook/global/header-additional_header.html.tmpl b/extensions/Persona/template/en/default/hook/global/header-additional_header.html.tmpl new file mode 100644 index 000000000..786010a34 --- /dev/null +++ b/extensions/Persona/template/en/default/hook/global/header-additional_header.html.tmpl @@ -0,0 +1,87 @@ +[%# 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 Param('persona_includejs_url') + && Param('user_info_class').split(',').contains('Persona') %] + +[%# for now don't inject persona javascript on authenticated users. + # we've seen sessions being logged out unexpectedly + # we should only inject this code for users who used persona to authenicate %] +[% RETURN IF user.id %] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +<script defer src="[% Param('persona_includejs_url') %]" type="text/javascript"></script> +<script type="text/javascript"> + +function createHidden(name, value, form) { + var field = document.createElement('input'); + field.type = 'hidden'; + field.name = name; + field.value = value;; + form.appendChild(field); +} + +[% login_target = cgi.url("-relative" => 1, "-query" => 1) %] +[% IF !login_target + OR login_target.match("^token\.cgi") + OR login_target.match("^createaccount\.cgi") %] + [% login_target = "index.cgi" %] +[% END %] +[% login_target = urlbase _ login_target %] + +[%# we only want to honour explicit login requests %] +var persona_ignore_login = true; + +function persona_onlogin(assertion) { + if (persona_ignore_login) + return; + [% IF !user.id %] + var form = document.createElement('form'); + form.action = '[% login_target FILTER js %]'; + form.method = 'POST'; + form.style.display = 'none'; + + createHidden('token', '[% issue_hash_token(['login']) FILTER js %]', form); + createHidden('Bugzilla_remember', 'on', form); + createHidden('persona_assertion', assertion, form); + + [% FOREACH field = cgi.param() %] + [% NEXT IF field.search("^(Bugzilla_(login|password|restrictlogin)|token|persona_assertion)$") %] + [% NEXT UNLESS cgi.param(field).can('slice') %] + [% FOREACH mvalue = cgi.param(field).slice(0) %] + createHidden('[% field FILTER js %]', '[% mvalue FILTER html_linebreak FILTER js %]', form); + [% END %] + [% END %] + + document.body.appendChild(form); + form.submit(); + [% END %] +} + +YAHOO.util.Event.on(window, 'load', persona_init); +function persona_init() { + navigator.id.watch({ + [%# we can't set loggedInUser to user.login as this causes cgi authenticated + sessions to be logged out by persona %] + loggedInUser: null, + onlogin: persona_onlogin, + onlogout: function () { + [%# this should be redirecting to index.cgi?logout=1 however there's a + persona bug which causes this to break chrome and safari logins. + https://github.com/mozilla/browserid/issues/2423 %] + } + }); +} + +function persona_sign_in() { + persona_ignore_login = false; + navigator.id.request({ siteName: '[% terms.BugzillaTitle FILTER js %]' }); +} +</script> diff --git a/extensions/Persona/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/Persona/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..f2e5bda24 --- /dev/null +++ b/extensions/Persona/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,12 @@ +[% IF error == "persona_account_too_powerful" %] + [% title = "Account Too Powerful" %] + Your account is a member of a group which is not permitted to use Persona to + log in. Please log in with your [% terms.Bugzilla %] username and password. + <br><br> + (Persona logins are disabled for accounts which are members of certain + particularly sensitive groups, while we gain experience with the technology.) +[% ELSIF error == "persona_server_fail" %] + An error occurred during communication with the Persona servers: + <br> + [% reason FILTER html %] +[% END %] diff --git a/extensions/Persona/web/images/persona_sign_in.png b/extensions/Persona/web/images/persona_sign_in.png Binary files differnew file mode 100644 index 000000000..ab88a7154 --- /dev/null +++ b/extensions/Persona/web/images/persona_sign_in.png diff --git a/extensions/Persona/web/images/sign_in.png b/extensions/Persona/web/images/sign_in.png Binary files differnew file mode 100644 index 000000000..82594ba82 --- /dev/null +++ b/extensions/Persona/web/images/sign_in.png diff --git a/extensions/ProdCompSearch/Config.pm b/extensions/ProdCompSearch/Config.pm new file mode 100644 index 000000000..c28b6d8f6 --- /dev/null +++ b/extensions/ProdCompSearch/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::ProdCompSearch; +use strict; + +use constant NAME => 'ProdCompSearch'; +use constant REQUIRED_MODULES => []; +use constant OPTIONAL_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/ProdCompSearch/Extension.pm b/extensions/ProdCompSearch/Extension.pm new file mode 100644 index 000000000..a5955fd8b --- /dev/null +++ b/extensions/ProdCompSearch/Extension.pm @@ -0,0 +1,21 @@ +# 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::ProdCompSearch; +use strict; +use base qw(Bugzilla::Extension); + +our $VERSION = '1'; + +sub webservice { + my ($self, $args) = @_; + my $dispatch = $args->{dispatch}; + $dispatch->{PCS} = "Bugzilla::Extension::ProdCompSearch::WebService"; +} + + +__PACKAGE__->NAME; diff --git a/extensions/ProdCompSearch/lib/WebService.pm b/extensions/ProdCompSearch/lib/WebService.pm new file mode 100644 index 000000000..d668809f6 --- /dev/null +++ b/extensions/ProdCompSearch/lib/WebService.pm @@ -0,0 +1,121 @@ +# 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::ProdCompSearch::WebService; + +use strict; +use warnings; + +use base qw(Bugzilla::WebService); + +use Bugzilla::Error; +use Bugzilla::Util qw(detaint_natural trick_taint trim); + +sub prod_comp_search { + my ($self, $params) = @_; + my $user = Bugzilla->user; + my $dbh = Bugzilla->switch_to_shadow_db(); + + my $search = trim($params->{'search'} || ''); + $search || ThrowCodeError('param_required', + { function => 'PCS.prod_comp_search', param => 'search' }); + + my $limit = detaint_natural($params->{'limit'}) + ? $dbh->sql_limit($params->{'limit'}) + : ''; + + # We do this in the DB directly as we want it to be fast and + # not have the overhead of loading full product objects + + # All products which the user has "Entry" access to. + my $enterable_ids = $dbh->selectcol_arrayref( + 'SELECT products.id FROM products + LEFT JOIN group_control_map + ON group_control_map.product_id = products.id + AND group_control_map.entry != 0 + AND group_id NOT IN (' . $user->groups_as_string . ') + WHERE group_id IS NULL + AND products.isactive = 1'); + + if (scalar @$enterable_ids) { + # And all of these products must have at least one component + # and one version. + $enterable_ids = $dbh->selectcol_arrayref( + 'SELECT DISTINCT products.id FROM products + WHERE ' . $dbh->sql_in('products.id', $enterable_ids) . + ' AND products.id IN (SELECT DISTINCT components.product_id + FROM components + WHERE components.isactive = 1) + AND products.id IN (SELECT DISTINCT versions.product_id + FROM versions + WHERE versions.isactive = 1)'); + } + + return { products => [] } if !scalar @$enterable_ids; + + trick_taint($search); + my @terms; + my @order; + + if ($search =~ /^(.*?)::(.*)$/) { + my ($product, $component) = (trim($1), trim($2)); + push @terms, _build_terms($product, 1, 0); + push @terms, _build_terms($component, 0, 1); + push @order, "products.name != " . $dbh->quote($product) if $product ne ''; + push @order, "components.name != " . $dbh->quote($component) if $component ne ''; + push @order, "products.name"; + push @order, "components.name"; + } else { + push @terms, _build_terms($search, 1, 1); + push @order, "products.name != " . $dbh->quote($search); + push @order, "components.name != " . $dbh->quote($search); + push @order, "products.name"; + push @order, "components.name"; + } + return { products => [] } if !scalar @terms; + + # To help mozilla staff file bmo administration bugs into the right + # component, sort bmo first when searching for 'bugzilla' + if ($search =~ /bugzilla/i && $search !~ /^bugzilla\s*::/i + && ($user->in_group('mozilla-corporation') || $user->in_group('mozilla-foundation'))) + { + unshift @order, "products.name != 'bugzilla.mozilla.org'"; + } + + my $products = $dbh->selectall_arrayref(" + SELECT products.name AS product, + components.name AS component + FROM products + INNER JOIN components ON products.id = components.product_id + WHERE (" . join(" AND ", @terms) . ") + AND products.id IN (" . join(",", @$enterable_ids) . ") + AND components.isactive = 1 + ORDER BY " . join(", ", @order) . " $limit", + { Slice => {} }); + + return { products => $products }; +} + +sub _build_terms { + my ($query, $product, $component) = @_; + my $dbh = Bugzilla->dbh(); + + my @fields; + push @fields, 'products.name', 'products.description' if $product; + push @fields, 'components.name', 'components.description' if $component; + # note: CONCAT_WS is MySQL specific + my $field = "CONCAT_WS(' ', ". join(',', @fields) . ")"; + + my @terms; + foreach my $word (split(/[\s,]+/, $query)) { + push(@terms, $dbh->sql_iposition($dbh->quote($word), $field) . " > 0") + if $word ne ''; + } + return @terms; +} + +1; diff --git a/extensions/ProdCompSearch/template/en/default/pages/prodcompsearch.html.tmpl b/extensions/ProdCompSearch/template/en/default/pages/prodcompsearch.html.tmpl new file mode 100644 index 000000000..5b39315b5 --- /dev/null +++ b/extensions/ProdCompSearch/template/en/default/pages/prodcompsearch.html.tmpl @@ -0,0 +1,25 @@ +[%# 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 = "File a $terms.Bug" + javascript_urls = [ "js/yui3/yui/yui-min.js", + "extensions/ProdCompSearch/web/js/prod_comp_search.js" ] + style_urls = [ "extensions/ProdCompSearch/web/styles/prod_comp_search.css" ] +%] + +<div id="prod_comp_search_main"> + [% PROCESS prodcompsearch/form.html.tmpl + query_header = "File a $terms.Bug:" + script_name = "enter_bug.cgi" + %] +</div> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/ProdCompSearch/template/en/default/prodcompsearch/form.html.tmpl b/extensions/ProdCompSearch/template/en/default/prodcompsearch/form.html.tmpl new file mode 100644 index 000000000..38f87dc1a --- /dev/null +++ b/extensions/ProdCompSearch/template/en/default/prodcompsearch/form.html.tmpl @@ -0,0 +1,40 @@ +[%# 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. + #%] + +[% DEFAULT max_results = 100 %] +<script type="text/javascript"> + [% IF script_name %] + ProdCompSearch.script_name = '[% script_name FILTER js %]'; + [% END %] + [% IF format %] + ProdCompSearch.format = '[% format FILTER js %]'; + [% END %] + [% IF cloned_bug_id %] + ProdCompSearch.cloned_bug_id = '[% cloned_bug_id FILTER js %]'; + [% END %] + [% IF new_tab %] + ProdCompSearch.new_tab = true; + [% END %] + ProdCompSearch.max_results = [% max_results FILTER js %]; +</script> + +<div id="prod_comp_search_form" class="yui3-skin-sam"> + <div id="prod_comp_search_header"> + [% input_label FILTER none %] + <img id="prod_comp_throbber" src="extensions/ProdCompSearch/web/images/throbber.gif" + class="bz_default_hidden" width="16" height="11"> + <span id="prod_comp_no_components" class="bz_default_hidden"> + No components found</span> + <span id="prod_comp_too_many_components" class="bz_default_hidden"> + Result limited to [% max_results FILTER html %] components + <span id="prod_comp_error" class="bz_default_hidden"> + An error occured</span> + </div> + <input id="prod_comp_search" type="text" size="50" + placeholder="Search by product and component keywords"> +</div> diff --git a/extensions/ProdCompSearch/web/images/throbber.gif b/extensions/ProdCompSearch/web/images/throbber.gif Binary files differnew file mode 100644 index 000000000..bc4fa6561 --- /dev/null +++ b/extensions/ProdCompSearch/web/images/throbber.gif diff --git a/extensions/ProdCompSearch/web/js/prod_comp_search.js b/extensions/ProdCompSearch/web/js/prod_comp_search.js new file mode 100644 index 000000000..f294994e3 --- /dev/null +++ b/extensions/ProdCompSearch/web/js/prod_comp_search.js @@ -0,0 +1,144 @@ +/* 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. */ + +// Product and component search to file a new bug + +var ProdCompSearch = { + script_name: 'enter_bug.cgi', + script_choices: ['enter_bug.cgi', 'describecomponents.cgi'], + format: null, + cloned_bug_id: null, + new_tab: null, + max_results: 100 +}; + +YUI({ + base: 'js/yui3/', + combine: false +}).use("node", "json-stringify", "autocomplete", "escape", + "datasource-io", "datasource-jsonschema", function(Y) { + Y.on("domready", function() { + var counter = 0, + dataSource = null, + autoComplete = null; + + var resultListFormat = function(query, results) { + return Y.Array.map(results, function(result) { + var data = result.raw; + result.text = data.product + ' :: ' + data.component; + return Y.Escape.html(result.text); + }); + }; + + var requestTemplate = function(query) { + counter = counter + 1; + var json_object = { + version: "1.1", + method : "PCS.prod_comp_search", + id : counter, + params : { search: query, limit: ProdCompSearch.max_results } + }; + return Y.JSON.stringify(json_object); + }; + + var dataSource = new Y.DataSource.IO({ + source: 'jsonrpc.cgi', + ioConfig: { + method: "POST", + headers: { 'Content-Type': 'application/json' } + }, + on: { + error: function(e) { + if (console.error && e.response.meta.error) { + console.error(e.response.meta.error.message); + } + Y.one("#prod_comp_throbber").addClass('bz_default_hidden'); + Y.one("#prod_comp_error").removeClass('bz_default_hidden'); + } + } + }); + + dataSource.plug(Y.Plugin.DataSourceJSONSchema, { + schema: { + resultListLocator : "result.products", + resultFields : [ "product", "component" ], + metaFields : { error : 'error' } + } + }); + + var input = Y.one('#prod_comp_search'); + + input.plug(Y.Plugin.AutoComplete, { + activateFirstItem: false, + enableCache: true, + source: dataSource, + minQueryLength: 3, + queryDelay: 0.05, + resultFormatter: resultListFormat, + suppressInputUpdate: true, + maxResults: ProdCompSearch.max_results, + scrollIntoView: true, + requestTemplate: requestTemplate, + on: { + query: function(e) { + Y.one("#prod_comp_throbber").removeClass('bz_default_hidden'); + Y.one("#prod_comp_no_components").addClass('bz_default_hidden'); + Y.one("#prod_comp_too_many_components").addClass('bz_default_hidden'); + Y.one("#prod_comp_error").addClass('bz_default_hidden'); + }, + results: function(e) { + Y.one("#prod_comp_throbber").addClass('bz_default_hidden'); + input.ac.set('activateFirstItem', e.results.length == 1); + if (e.results.length == 0) { + Y.one("#prod_comp_no_components").removeClass('bz_default_hidden'); + } + else if (e.results.length + 1 > ProdCompSearch.max_results) { + Y.one("#prod_comp_too_many_components").removeClass('bz_default_hidden'); + } + }, + select: function(e) { + // Only redirect if the script_name is a valid choice + if (Y.Array.indexOf(ProdCompSearch.script_choices, ProdCompSearch.script_name) == -1) + return; + + var data = e.result.raw; + var url = ProdCompSearch.script_name + + "?product=" + encodeURIComponent(data.product) + + "&component=" + encodeURIComponent(data.component); + if (ProdCompSearch.script_name == 'enter_bug.cgi') { + if (ProdCompSearch.format) + url += "&format=" + encodeURIComponent(ProdCompSearch.format); + if (ProdCompSearch.cloned_bug_id) + url += "&cloned_bug_id=" + encodeURIComponent(ProdCompSearch.cloned_bug_id); + } + if (ProdCompSearch.script_name == 'describecomponents.cgi') { + url += "#" + encodeURIComponent(data.component); + } + if (ProdCompSearch.new_tab) { + window.open(url, '_blank'); + } + else { + window.location.href = url; + } + } + }, + after: { + select: function(e) { + if (ProdCompSearch.new_tab) { + input.set('value',''); + } + } + } + }); + + input.on('focus', function (e) { + if (e.target.value && e.target.value.length > 3) { + dataSource.load(e.target.value); + } + }); + }); +}); diff --git a/extensions/ProdCompSearch/web/styles/prod_comp_search.css b/extensions/ProdCompSearch/web/styles/prod_comp_search.css new file mode 100644 index 000000000..ccb5887c4 --- /dev/null +++ b/extensions/ProdCompSearch/web/styles/prod_comp_search.css @@ -0,0 +1,27 @@ +/* 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. */ + +#prod_comp_search_main { + width: 400px; + margin-right: auto; + margin-left: auto; +} + +#prod_comp_search_form .yui3-aclist-input { + width: 360px; +} + +#prod_comp_search_form .yui3-aclist-content { + max-height: 500px; + overflow: auto; +} + +#prod_comp_no_components, +#prod_comp_error, +#prod_comp_too_many_components { + color: red; +} diff --git a/extensions/ProductDashboard/Config.pm b/extensions/ProductDashboard/Config.pm new file mode 100644 index 000000000..3a4654974 --- /dev/null +++ b/extensions/ProductDashboard/Config.pm @@ -0,0 +1,14 @@ +# 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::ProductDashboard; + +use strict; + +use constant NAME => 'ProductDashboard'; + +__PACKAGE__->NAME; diff --git a/extensions/ProductDashboard/Extension.pm b/extensions/ProductDashboard/Extension.pm new file mode 100644 index 000000000..1e6ddffe9 --- /dev/null +++ b/extensions/ProductDashboard/Extension.pm @@ -0,0 +1,200 @@ +# 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::ProductDashboard; + +use strict; + +use base qw(Bugzilla::Extension); + +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Util; +use Bugzilla::Error; +use Bugzilla::Product; +use Bugzilla::Field; + +use Bugzilla::Extension::ProductDashboard::Queries; +use Bugzilla::Extension::ProductDashboard::Util; + +our $VERSION = BUGZILLA_VERSION; + +sub page_before_template { + my ($self, $args) = @_; + + my $page = $args->{page_id}; + my $vars = $args->{vars}; + + if ($page =~ m{^productdashboard\.}) { + _page_dashboard($vars); + } +} + +sub _page_dashboard { + my $vars = shift; + + my $cgi = Bugzilla->cgi; + my $input = Bugzilla->input_params; + my $user = Bugzilla->user; + + # Switch to shadow db since we are just reading information + Bugzilla->switch_to_shadow_db(); + + # All pages point to the same part of the documentation. + $vars->{'doc_section'} = 'bugreports.html'; + + # Forget any previously selected product + $cgi->send_cookie(-name => 'PRODUCT_DASHBOARD', + -value => 'X', + -expires => "Fri, 01-Jan-1970 00:00:00 GMT"); + + # If the user cannot enter bugs in any product, stop here. + scalar @{$user->get_selectable_products} + || ThrowUserError('no_products'); + + # Create data structures representing each classification + my @classifications = (); + foreach my $c (@{$user->get_selectable_classifications}) { + # Create hash to hold attributes for each classification. + my %classification = ( + 'name' => $c->name, + 'products' => [ @{$user->get_selectable_products($c->id)} ] + ); + # Assign hash back to classification array. + push @classifications, \%classification; + } + $vars->{'classifications'} = \@classifications; + + my $product_name = trim($input->{'product'} || ''); + + if (!$product_name && $cgi->cookie('PRODUCT_DASHBOARD')) { + $product_name = $cgi->cookie('PRODUCT_DASHBOARD'); + } + + return if !$product_name; + + # Do not use Bugzilla::Product::check_product() here, else the user + # could know whether the product doesn't exist or is not accessible. + my $product = new Bugzilla::Product({'name' => $product_name}); + + # We need to check and make sure that the user has permission + # to enter a bug against this product. + if (!$product || !$user->can_enter_product($product->name)) { + return; + } + + # Remember selected product + $cgi->send_cookie(-name => 'PRODUCT_DASHBOARD', + -value => $product->name, + -expires => "Fri, 01-Jan-2038 00:00:00 GMT"); + + my $current_tab_name = $input->{'tab'} || "summary"; + trick_taint($current_tab_name); + $vars->{'current_tab_name'} = $current_tab_name; + + my $bug_status = trim($input->{'bug_status'} || 'open'); + + $vars->{'bug_status'} = $bug_status; + $vars->{'product'} = $product; + $vars->{'bug_link_all'} = bug_link_all($product); + $vars->{'bug_link_open'} = bug_link_open($product); + $vars->{'bug_link_closed'} = bug_link_closed($product); + $vars->{'total_bugs'} = total_bugs($product); + $vars->{'total_open_bugs'} = total_open_bugs($product); + $vars->{'total_closed_bugs'} = total_closed_bugs($product); + $vars->{'severities'} = get_legal_field_values('bug_severity'); + + if ($vars->{'total_bugs'}) { + $vars->{'open_bugs_percentage'} + = int($vars->{'total_open_bugs'} / $vars->{'total_bugs'} * 100); + $vars->{'closed_bugs_percentage'} + = int($vars->{'total_closed_bugs'} / $vars->{'total_bugs'} * 100); + } + else { + $vars->{'open_bugs_percentage'} = 0; + $vars->{'closed_bugs_percentage'} = 0; + } + + if ($current_tab_name eq 'summary') { + $vars->{'by_priority'} = by_priority($product, $bug_status); + $vars->{'by_severity'} = by_severity($product, $bug_status); + $vars->{'by_assignee'} = by_assignee($product, $bug_status, 50); + $vars->{'by_status'} = by_status($product, $bug_status); + } + + if ($current_tab_name eq 'recents') { + my $recent_days = $input->{'recent_days'} || 7; + (detaint_natural($recent_days) && $recent_days > 0 && $recent_days < 101) + || ThrowUserError('product_dashboard_invalid_recent_days'); + + my $params = { + product => $product, + days => $recent_days, + date_from => $input->{'date_from'} || '', + date_to => $input->{'date_to'} || '', + }; + + $vars->{'recently_opened'} = recently_opened($params); + $vars->{'recently_closed'} = recently_closed($params); + $vars->{'recent_days'} = $recent_days; + $vars->{'date_from'} = $input->{'date_from'}; + $vars->{'date_to'} = $input->{'date_to'}; + } + + if ($current_tab_name eq 'components') { + if ($input->{'component'}) { + $vars->{'summary'} = by_value_summary($product, 'component', $input->{'component'}, $bug_status); + $vars->{'summary'}{'type'} = 'component'; + $vars->{'summary'}{'value'} = $input->{'component'}; + } + elsif ($input->{'version'}) { + $vars->{'summary'} = by_value_summary($product, 'version', $input->{'version'}, $bug_status); + $vars->{'summary'}{'type'} = 'version'; + $vars->{'summary'}{'value'} = $input->{'version'}; + } + elsif ($input->{'target_milestone'} && Bugzilla->params->{'usetargetmilestone'}) { + $vars->{'summary'} = by_value_summary($product, 'target_milestone', $input->{'target_milestone'}, $bug_status); + $vars->{'summary'}{'type'} = 'target_milestone'; + $vars->{'summary'}{'value'} = $input->{'target_milestone'}; + } + else { + $vars->{'by_component'} = by_component($product, $bug_status); + $vars->{'by_version'} = by_version($product, $bug_status); + if (Bugzilla->params->{'usetargetmilestone'}) { + $vars->{'by_milestone'} = by_milestone($product, $bug_status); + } + } + } + + if ($current_tab_name eq 'duplicates') { + $vars->{'by_duplicate'} = by_duplicate($product, $bug_status); + } + + if ($current_tab_name eq 'popularity') { + $vars->{'by_popularity'} = by_popularity($product, $bug_status); + } + + if ($current_tab_name eq 'roadmap') { + foreach my $milestone (@{$product->milestones}){ + my %milestone_stats; + $milestone_stats{'name'} = $milestone->name; + $milestone_stats{'total_bugs'} = total_bug_milestone($product, $milestone); + $milestone_stats{'open_bugs'} = bug_milestone_by_status($product, $milestone, 'open'); + $milestone_stats{'closed_bugs'} = bug_milestone_by_status($product, $milestone, 'closed'); + $milestone_stats{'link_total'} = bug_milestone_link_total($product, $milestone); + $milestone_stats{'link_open'} = bug_milestone_link_open($product, $milestone); + $milestone_stats{'link_closed'} = bug_milestone_link_closed($product, $milestone); + $milestone_stats{'percentage'} = $milestone_stats{'total_bugs'} + ? int(($milestone_stats{'closed_bugs'} / $milestone_stats{'total_bugs'}) * 100) + : 0; + push (@{$vars->{'by_roadmap'}}, \%milestone_stats); + } + } +} + +__PACKAGE__->NAME; + diff --git a/extensions/ProductDashboard/lib/Queries.pm b/extensions/ProductDashboard/lib/Queries.pm new file mode 100644 index 000000000..ec27d3c6c --- /dev/null +++ b/extensions/ProductDashboard/lib/Queries.pm @@ -0,0 +1,476 @@ +# 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::ProductDashboard::Queries; + +use strict; + +use base qw(Exporter); +@Bugzilla::Extension::ProductDashboard::Queries::EXPORT = qw( + total_bugs + total_open_bugs + total_closed_bugs + by_version + by_value_summary + by_milestone + by_priority + by_severity + by_component + by_assignee + by_status + by_duplicate + by_popularity + recently_opened + recently_closed + total_bug_milestone + bug_milestone_by_status +); + +use Bugzilla::CGI; +use Bugzilla::Error; +use Bugzilla::User; +use Bugzilla::Util; +use Bugzilla::Component; +use Bugzilla::Version; +use Bugzilla::Milestone; + +use Bugzilla::Extension::ProductDashboard::Util qw(open_states closed_states + quoted_open_states quoted_closed_states); + +sub total_bugs { + my $product = shift; + my $dbh = Bugzilla->dbh; + + return $dbh->selectrow_array("SELECT COUNT(bug_id) + FROM bugs + WHERE product_id = ?", undef, $product->id); +} + +sub total_open_bugs { + my $product = shift; + my $bug_status = shift; + my $dbh = Bugzilla->dbh; + + return $dbh->selectrow_array("SELECT COUNT(bug_id) + FROM bugs + WHERE bug_status IN (" . join(',', quoted_open_states()) . ") + AND product_id = ?", undef, $product->id); +} + +sub total_closed_bugs { + my $product = shift; + my $dbh = Bugzilla->dbh; + + return $dbh->selectrow_array("SELECT COUNT(bug_id) + FROM bugs + WHERE bug_status IN (" . join(',', quoted_closed_states()) . ") + AND product_id = ?", undef, $product->id); +} + +sub bug_link_all { + my $product = shift; + + return correct_urlbase() . 'buglist.cgi?product=' . url_quote($product->name); +} + +sub bug_link_open { + my $product = shift; + + return correct_urlbase() . 'buglist.cgi?product=' . url_quote($product->name) . "&bug_status=__open__"; +} + +sub bug_link_closed { + my $product = shift; + + return correct_urlbase() . 'buglist.cgi?product=' . url_quote($product->name) . "&bug_status=__closed__"; +} + +sub by_version { + my ($product, $bug_status) = @_; + my $dbh = Bugzilla->dbh; + my $extra = ''; + + $extra = "AND bugs.bug_status IN (" . join(',', quoted_open_states()) . ")" if $bug_status eq 'open'; + $extra = "AND bugs.bug_status IN (" . join(',', quoted_closed_states()) . ")" if $bug_status eq 'closed'; + + return $dbh->selectall_arrayref("SELECT version, COUNT(bug_id), + ROUND(((COUNT(bugs.bug_id) / ( SELECT COUNT(*) FROM bugs WHERE bugs.product_id = ? $extra)) * 100)) + FROM bugs + WHERE product_id = ? + $extra + GROUP BY version + ORDER BY COUNT(bug_id) DESC", + undef, $product->id, $product->id); +} + +sub by_milestone { + my ($product, $bug_status) = @_; + my $dbh = Bugzilla->dbh; + my $extra = ''; + + $extra = "AND bugs.bug_status IN (" . join(',', quoted_open_states()) . ")" if $bug_status eq 'open'; + $extra = "AND bugs.bug_status IN (" . join(',', quoted_closed_states()) . ")" if $bug_status eq 'closed'; + + return $dbh->selectall_arrayref("SELECT target_milestone, COUNT(bug_id), + ROUND(((COUNT(bugs.bug_id) / ( SELECT COUNT(*) FROM bugs WHERE bugs.product_id = ? $extra)) * 100)) + FROM bugs + WHERE product_id = ? + $extra + GROUP BY target_milestone + ORDER BY COUNT(bug_id) DESC", + undef, $product->id, $product->id); +} + +sub by_priority { + my ($product, $bug_status) = @_; + my $dbh = Bugzilla->dbh; + my $extra = ''; + + $extra = "AND bugs.bug_status IN (" . join(',', quoted_open_states()) . ")" if $bug_status eq 'open'; + $extra = "AND bugs.bug_status IN (" . join(',', quoted_closed_states()) . ")" if $bug_status eq 'closed'; + + return $dbh->selectall_arrayref("SELECT priority, COUNT(bug_id), + ROUND(((COUNT(bugs.bug_id) / ( SELECT COUNT(*) FROM bugs WHERE bugs.product_id = ? $extra)) * 100)) + FROM bugs + WHERE product_id = ? + $extra + GROUP BY priority + ORDER BY COUNT(bug_id) DESC", + undef, $product->id, $product->id); +} + +sub by_severity { + my ($product, $bug_status) = @_; + my $dbh = Bugzilla->dbh; + my $extra = ''; + + $extra = "AND bugs.bug_status IN (" . join(',', quoted_open_states()) . ")" if $bug_status eq 'open'; + $extra = "AND bugs.bug_status IN (" . join(',', quoted_closed_states()) . ")" if $bug_status eq 'closed'; + + return $dbh->selectall_arrayref("SELECT bug_severity, COUNT(bug_id), + ROUND(((COUNT(bugs.bug_id) / ( SELECT COUNT(*) FROM bugs WHERE bugs.product_id = ? $extra)) * 100)) + FROM bugs + WHERE product_id = ? + $extra + GROUP BY bug_severity + ORDER BY COUNT(bug_id) DESC", + undef, $product->id, $product->id); +} + +sub by_component { + my ($product, $bug_status) = @_; + my $dbh = Bugzilla->dbh; + my $extra = ''; + + $extra = "AND bugs.bug_status IN (" . join(',', quoted_open_states()) . ")" if $bug_status eq 'open'; + $extra = "AND bugs.bug_status IN (" . join(',', quoted_closed_states()) . ")" if $bug_status eq 'closed'; + + return $dbh->selectall_arrayref("SELECT components.name, COUNT(bugs.bug_id), + ROUND(((COUNT(bugs.bug_id) / ( SELECT COUNT(*) FROM bugs WHERE bugs.product_id = ? $extra)) * 100)) + FROM bugs INNER JOIN components ON bugs.component_id = components.id + WHERE bugs.product_id = ? + $extra + GROUP BY components.name + ORDER BY COUNT(bugs.bug_id) DESC", + undef, $product->id, $product->id); +} + +sub by_value_summary { + my ($product, $type, $value, $bug_status) = @_; + my $dbh = Bugzilla->dbh; + + my $query = "SELECT bugs.bug_id AS id, + bugs.bug_status AS status, + bugs.version AS version, + components.name AS component, + bugs.bug_severity AS severity, + bugs.short_desc AS summary + FROM bugs, components + WHERE bugs.product_id = ? + AND bugs.component_id = components.id "; + + if ($type eq 'component') { + Bugzilla::Component->check({ product => $product, name => $value }); + $query .= "AND components.name = ? " if $type eq 'component'; + } + elsif ($type eq 'version') { + Bugzilla::Version->check({ product => $product, name => $value }); + $query .= "AND bugs.version = ? " if $type eq 'version'; + } + elsif ($type eq 'target_milestone') { + Bugzilla::Milestone->check({ product => $product, name => $value }); + $query .= "AND bugs.target_milestone = ? " if $type eq 'target_milestone'; + } + + $query .= "AND bugs.bug_status IN (" . join(',', quoted_open_states()) . ") " if $bug_status eq 'open'; + $query .= "AND bugs.bug_status IN (" . join(',', quoted_closed_states()) . ") " if $bug_status eq 'closed'; + + trick_taint($value); + + my $past_due_bugs = $dbh->selectall_arrayref($query . + "AND (bugs.deadline IS NOT NULL AND bugs.deadline != '') + AND bugs.deadline < now() ORDER BY bugs.deadline LIMIT 10", + {'Slice' => {}}, $product->id, $value); + + my $updated_recently_bugs = $dbh->selectall_arrayref($query . + "AND bugs.delta_ts != bugs.creation_ts " . + "ORDER BY bugs.delta_ts DESC LIMIT 10", + {'Slice' => {}}, $product->id, $value); + + my $timestamp = $dbh->selectrow_array("SELECT " . $dbh->sql_date_format("LOCALTIMESTAMP(0)", "%Y-%m-%d")); + + return { + timestamp => $timestamp, + past_due => _filter_bugs($past_due_bugs), + updated_recently => _filter_bugs($updated_recently_bugs), + }; +} + +sub by_assignee { + my ($product, $bug_status, $limit) = @_; + my $dbh = Bugzilla->dbh; + my $extra = ''; + + $limit = ($limit && detaint_natural($limit)) ? $dbh->sql_limit($limit) : ""; + + $extra = "AND bugs.bug_status IN (" . join(',', quoted_open_states()) . ")" if $bug_status eq 'open'; + $extra = "AND bugs.bug_status IN (" . join(',', quoted_closed_states()) . ")" if $bug_status eq 'closed'; + + my @result = map { [ Bugzilla::User->new($_->[0]), $_->[1], $_->[2] ] } + @{$dbh->selectall_arrayref("SELECT bugs.assigned_to AS userid, COUNT(bugs.bug_id), + ROUND(((COUNT(bugs.bug_id) / ( SELECT COUNT(*) FROM bugs WHERE bugs.product_id = ? $extra)) * 100)) + FROM bugs, profiles + WHERE bugs.product_id = ? + AND bugs.assigned_to = profiles.userid + $extra + GROUP BY profiles.login_name + ORDER BY COUNT(bugs.bug_id) DESC $limit", + undef, $product->id, $product->id)}; + + return \@result; +} + +sub by_status { + my ($product, $bug_status) = @_; + my $dbh = Bugzilla->dbh; + my $extra = ''; + + $extra = "AND bugs.bug_status IN (" . join(',', quoted_open_states()) . ")" if $bug_status eq 'open'; + $extra = "AND bugs.bug_status IN (" . join(',', quoted_closed_states()) . ")" if $bug_status eq 'closed'; + + return $dbh->selectall_arrayref("SELECT bugs.bug_status, COUNT(bugs.bug_id), + ROUND(((COUNT(bugs.bug_id) / ( SELECT COUNT(*) FROM bugs WHERE bugs.product_id = ? $extra)) * 100)) + FROM bugs + WHERE bugs.product_id = ? + $extra + GROUP BY bugs.bug_status + ORDER BY COUNT(bugs.bug_id) DESC", + undef, $product->id, $product->id); +} + +sub total_bug_milestone { + my ($product, $milestone) = @_; + my $dbh = Bugzilla->dbh; + + return $dbh->selectrow_array("SELECT COUNT(bug_id) + FROM bugs + WHERE target_milestone = ? + AND product_id = ?", + undef, $milestone->name, $product->id); +} + +sub bug_milestone_by_status { + my ($product, $milestone, $bug_status) = @_; + my $dbh = Bugzilla->dbh; + my $extra = ''; + + $extra = "AND bugs.bug_status IN (" . join(',', quoted_open_states()) . ")" if $bug_status eq 'open'; + $extra = "AND bugs.bug_status IN (" . join(',', quoted_closed_states()) . ")" if $bug_status eq 'closed'; + + return $dbh->selectrow_array("SELECT COUNT(bug_id) + FROM bugs + WHERE target_milestone = ? + AND product_id = ? $extra", + undef, + $milestone->name, + $product->id); + +} + +sub by_duplicate { + my ($product, $bug_status, $limit) = @_; + my $dbh = Bugzilla->dbh; + $limit = ($limit && detaint_natural($limit)) ? $dbh->sql_limit($limit) : ""; + + my $extra = ''; + $extra = "AND bugs.bug_status IN (" . join(',', quoted_open_states()) . ")" if $bug_status eq 'open'; + $extra = "AND bugs.bug_status IN (" . join(',', quoted_closed_states()) . ")" if $bug_status eq 'closed'; + + my $unfiltered_bugs = $dbh->selectall_arrayref("SELECT bugs.bug_id AS id, + bugs.bug_status AS status, + bugs.version AS version, + components.name AS component, + bugs.bug_severity AS severity, + bugs.short_desc AS summary, + COUNT(duplicates.dupe) AS dupe_count + FROM bugs, duplicates, components + WHERE bugs.product_id = ? + AND bugs.component_id = components.id + AND bugs.bug_id = duplicates.dupe_of + $extra + GROUP BY bugs.bug_id, bugs.bug_status, components.name, + bugs.bug_severity, bugs.short_desc + HAVING COUNT(duplicates.dupe) > 1 + ORDER BY COUNT(duplicates.dupe) DESC $limit", + {'Slice' => {}}, $product->id); + + return _filter_bugs($unfiltered_bugs); +} + +sub by_popularity { + my ($product, $bug_status, $limit) = @_; + my $dbh = Bugzilla->dbh; + $limit = ($limit && detaint_natural($limit)) ? $dbh->sql_limit($limit) : ""; + + my $extra = ''; + $extra = "AND bugs.bug_status IN (" . join(',', quoted_open_states()) . ")" if $bug_status eq 'open'; + $extra = "AND bugs.bug_status IN (" . join(',', quoted_closed_states()) . ")" if $bug_status eq 'closed'; + + my $unfiltered_bugs = $dbh->selectall_arrayref("SELECT bugs.bug_id AS id, + bugs.bug_status AS status, + bugs.version AS version, + components.name AS component, + bugs.bug_severity AS severity, + bugs.short_desc AS summary, + bugs.votes AS votes + FROM bugs, components + WHERE bugs.product_id = ? + AND bugs.component_id = components.id + AND bugs.votes > 1 + $extra + ORDER BY bugs.votes DESC $limit", + {'Slice' => {}}, $product->id); + + return _filter_bugs($unfiltered_bugs); +} + +sub recently_opened { + my ($params) = @_; + my $dbh = Bugzilla->dbh; + + my $product = $params->{'product'}; + my $days = $params->{'days'}; + my $limit = $params->{'limit'}; + my $date_from = $params->{'date_from'}; + my $date_to = $params->{'date_to'}; + + $days ||= 7; + $limit = ($limit && detaint_natural($limit)) ? $dbh->sql_limit($limit) : ""; + + my @values = ($product->id); + + my $date_part; + if ($date_from && $date_to) { + validate_date($date_from) + || ThrowUserError('illegal_date', { date => $date_from, + format => 'YYYY-MM-DD' }); + validate_date($date_to) + || ThrowUserError('illegal_date', { date => $date_to, + format => 'YYYY-MM-DD' }); + $date_part = "AND bugs.creation_ts >= ? AND bugs.creation_ts <= ?"; + push(@values, trick_taint($date_from), trick_taint($date_to)); + } + else { + $date_part = "AND bugs.creation_ts >= CURRENT_DATE() - INTERVAL ? DAY"; + push(@values, $days); + } + + my $unfiltered_bugs = $dbh->selectall_arrayref("SELECT bugs.bug_id AS id, + bugs.bug_status AS status, + bugs.version AS version, + components.name AS component, + bugs.bug_severity AS severity, + bugs.short_desc AS summary + FROM bugs, components + WHERE bugs.product_id = ? + AND bugs.component_id = components.id + AND bugs.bug_status IN (" . join(',', quoted_open_states()) . ") + $date_part + ORDER BY bugs.bug_id DESC $limit", + {'Slice' => {}}, @values); + + return _filter_bugs($unfiltered_bugs); +} + +sub recently_closed { + my ($params) = @_; + my $dbh = Bugzilla->dbh; + + my $product = $params->{'product'}; + my $days = $params->{'days'}; + my $limit = $params->{'limit'}; + my $date_from = $params->{'date_from'}; + my $date_to = $params->{'date_to'}; + + $days ||= 7; + $limit = ($limit && detaint_natural($limit)) ? $dbh->sql_limit($limit) : ""; + + my @values = ($product->id); + + my $date_part; + if ($date_from && $date_to) { + validate_date($date_from) + || ThrowUserError('illegal_date', { date => $date_from, + format => 'YYYY-MM-DD' }); + validate_date($date_to) + || ThrowUserError('illegal_date', { date => $date_to, + format => 'YYYY-MM-DD' }); + $date_part = "AND bugs_activity.bug_when >= ? AND bugs_activity.bug_when <= ?"; + push(@values, trick_taint($date_from), trick_taint($date_to)); + } + else { + $date_part = "AND bugs_activity.bug_when >= CURRENT_DATE() - INTERVAL ? DAY"; + push(@values, $days); + } + + my $unfiltered_bugs = $dbh->selectall_arrayref("SELECT DISTINCT bugs.bug_id AS id, + bugs.bug_status AS status, + bugs.version AS version, + components.name AS component, + bugs.bug_severity AS severity, + bugs.short_desc AS summary + FROM bugs, components, bugs_activity + WHERE bugs.product_id = ? + AND bugs.component_id = components.id + AND bugs.bug_status IN (" . join(',', quoted_closed_states()) . ") + AND bugs.bug_id = bugs_activity.bug_id + AND bugs_activity.added IN (" . join(',', quoted_closed_states()) . ") + $date_part + ORDER BY bugs.bug_id DESC $limit", + {'Slice' => {}}, @values); + + return _filter_bugs($unfiltered_bugs); +} + +sub _filter_bugs { + my ($unfiltered_bugs) = @_; + my $dbh = Bugzilla->dbh; + + return [] if !$unfiltered_bugs; + + my @unfiltered_bug_ids = map { $_->{'id'} } @$unfiltered_bugs; + my %filtered_bug_ids = map { $_ => 1 } @{ Bugzilla->user->visible_bugs(\@unfiltered_bug_ids) }; + + my @filtered_bugs; + foreach my $bug (@$unfiltered_bugs) { + next if !$filtered_bug_ids{$bug->{'id'}}; + push(@filtered_bugs, $bug); + } + + return \@filtered_bugs; +} + +1; diff --git a/extensions/ProductDashboard/lib/Util.pm b/extensions/ProductDashboard/lib/Util.pm new file mode 100644 index 000000000..5d9c161ef --- /dev/null +++ b/extensions/ProductDashboard/lib/Util.pm @@ -0,0 +1,95 @@ +# 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::ProductDashboard::Util; + +use strict; + +use base qw(Exporter); +@Bugzilla::Extension::ProductDashboard::Util::EXPORT = qw( + bug_link_all + bug_link_open + bug_link_closed + open_states + closed_states + quoted_open_states + quoted_closed_states + bug_milestone_link_total + bug_milestone_link_open + bug_milestone_link_closed +); + +use Bugzilla::Status; +use Bugzilla::Util; + +our $_open_states; +sub open_states { + $_open_states ||= Bugzilla::Status->match({ is_open => 1, isactive => 1 }); + return wantarray ? @$_open_states : $_open_states; +} + +our $_quoted_open_states; +sub quoted_open_states { + my $dbh = Bugzilla->dbh; + $_quoted_open_states ||= [ map { $dbh->quote($_->name) } open_states() ]; + return wantarray ? @$_quoted_open_states : $_quoted_open_states; +} + +our $_closed_states; +sub closed_states { + $_closed_states ||= Bugzilla::Status->match({ is_open => 0, isactive => 1 }); + return wantarray ? @$_closed_states : $_closed_states; +} + +our $_quoted_closed_states; +sub quoted_closed_states { + my $dbh = Bugzilla->dbh; + $_quoted_closed_states ||= [ map { $dbh->quote($_->name) } closed_states() ]; + return wantarray ? @$_quoted_closed_states : $_quoted_closed_states; +} + +sub bug_link_all { + my $product = shift; + + return correct_urlbase() . 'buglist.cgi?product=' . url_quote($product->name); +} + +sub bug_link_open { + my $product = shift; + + return correct_urlbase() . 'buglist.cgi?product=' . url_quote($product->name) . + "&bug_status=__open__"; +} + +sub bug_link_closed { + my $product = shift; + + return correct_urlbase() . 'buglist.cgi?product=' . url_quote($product->name) . + "&bug_status=__closed__"; +} + +sub bug_milestone_link_total { + my ($product, $milestone) = @_; + + return correct_urlbase() . 'buglist.cgi?product=' . url_quote($product->name) . + "&target_milestone=" . url_quote($milestone->name); +} + +sub bug_milestone_link_open { + my ($product, $milestone) = @_; + + return correct_urlbase() . 'buglist.cgi?product=' . url_quote($product->name) . + "&target_milestone=" . url_quote($milestone->name) . "&bug_status=__open__"; +} + +sub bug_milestone_link_closed { + my ($product, $milestone) = @_; + + return correct_urlbase() . 'buglist.cgi?product=' . url_quote($product->name) . + "&target_milestone=" . url_quote($milestone->name) . "&bug_status=__closed__"; +} + +1; diff --git a/extensions/ProductDashboard/template/en/default/hook/global/common-links-action-links.html.tmpl b/extensions/ProductDashboard/template/en/default/hook/global/common-links-action-links.html.tmpl new file mode 100644 index 000000000..e9be8a13d --- /dev/null +++ b/extensions/ProductDashboard/template/en/default/hook/global/common-links-action-links.html.tmpl @@ -0,0 +1,9 @@ +[%# 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. + #%] + + <li><span class="separator"> | </span><a href="page.cgi?id=productdashboard.html">Product Dashboard</a></li> diff --git a/extensions/ProductDashboard/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/ProductDashboard/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..d8af64d31 --- /dev/null +++ b/extensions/ProductDashboard/template/en/default/hook/global/user-error-errors.html.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. + #%] + +[% IF error == "product_dashboard_invalid_recent_days" %] + [% title = "Invalid Recent Days" %] + Invalid value for recent days. +[% END %] diff --git a/extensions/ProductDashboard/template/en/default/pages/productdashboard.html.tmpl b/extensions/ProductDashboard/template/en/default/pages/productdashboard.html.tmpl new file mode 100644 index 000000000..ac588ac26 --- /dev/null +++ b/extensions/ProductDashboard/template/en/default/pages/productdashboard.html.tmpl @@ -0,0 +1,237 @@ +[%# 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 %] + +[% javascript_urls = [ "js/yui3/yui/yui-min.js", + "js/util.js", + "js/field.js" ] +%] + +[% IF current_tab_name == 'summary' %] + [% javascript_urls.push("extensions/ProductDashboard/web/js/summary.js") %] + [% ELSIF current_tab_name == 'recents' %] + [% yui = [ "calendar" ] %] + [% javascript_urls.push("js/field.js") %] + [% javascript_urls.push("js/util.js") %] + [% javascript_urls.push("extensions/ProductDashboard/web/js/recents.js") %] +[% ELSIF current_tab_name == 'components' %] + [% javascript_urls.push("extensions/ProductDashboard/web/js/components.js") %] +[% ELSIF current_tab_name == 'duplicates' %] + [% javascript_urls.push("extensions/ProductDashboard/web/js/duplicates.js") %] +[% ELSIF current_tab_name == 'popularity' %] + [% javascript_urls.push("extensions/ProductDashboard/web/js/popularity.js") %] +[% ELSIF current_tab_name == 'roadmap' && Param('usetargetmilestone') %] + [% javascript_urls.push("extensions/ProductDashboard/web/js/roadmap.js") %] +[% END %] + +[% filtered_product = product.name FILTER html %] +[% PROCESS global/header.html.tmpl + title = "Product Dashboard: $filtered_product" + style_urls = [ "skins/standard/buglist.css", + "js/yui/assets/skins/sam/paginator.css", + "extensions/ProductDashboard/web/styles/productdashboard.css" ] +%] + +<script type="text/javascript"> +<!-- + PD = {}; + [%# Set up severities list for proper sorting %] + PD.severities = new Array(); + [% sort_count = 0 %] + [% FOREACH s = severities %] + PD.severities['[% s FILTER js %]'] = [% sort_count FILTER js %]; + [% sort_count = sort_count + 1 %] + [% END %] +--> +</script> + +[% url_filtered_product = product.name FILTER uri %] +[% url_filtered_status = bug_status FILTER uri %] + +[% tabs = [ + { + name => "summary", + label => "Summary", + link => "page.cgi?id=productdashboard.html&product=$url_filtered_product&bug_status=$url_filtered_status&tab=summary" + }, + { + name => "recents", + label => "Recents", + link => "page.cgi?id=productdashboard.html&product=$url_filtered_product&bug_status=$url_filtered_status&tab=recents" + }, + { + name => "components", + label => "Components/Versions", + link => "page.cgi?id=productdashboard.html&product=$url_filtered_product&bug_status=$url_filtered_status&tab=components" + }, + { + name => "duplicates", + label => "Duplicates", + link => "page.cgi?id=productdashboard.html&product=$url_filtered_product&bug_status=$url_filtered_status&tab=duplicates" + }, + { + name => "roadmap", + label => "Road Map", + link => "page.cgi?id=productdashboard.html&product=$url_filtered_product&bug_status=$url_filtered_status&tab=roadmap" + }, + ] +%] + +[% IF product.votesperuser %] + [% + tabs.push({ + name => "popularity", + label => "Popularity", + link => "page.cgi?id=productdashboard.html&product=$url_filtered_product&bug_status=$url_filtered_status&tab=popularity" + }) + %] +[% END %] + +[% FOREACH tab IN tabs %] + [% IF tab.name == current_tab_name %] + [% current_tab = tab %] + [% LAST %] + [% END %] +[% END %] + +[% full_bug_count = 0 %] +[% IF bug_status == 'open' %] + [% full_bug_count = total_open_bugs %] +[% ELSIF bug_status == 'closed' %] + [% full_bug_count = total_closed_bugs %] +[% ELSE %] + [% full_bug_count = total_bugs %] +[% END %] + +[% bug_link = bug_link_all %] +[% IF bug_status == 'open' %] + [% bug_link = bug_link_open %] +[% ELSIF bug_status == 'closed' %] + [% bug_link = bug_link_closed %] +[% END %] + +<div class="yui3-skin-sam"> + <a name="top"></a> + + <form action="page.cgi" method="get"> + <input type="hidden" name="id" value="productdashboard.html"> + <input type="hidden" name="tab" value="[% current_tab.name FILTER html %]"> + + [% IF summary.keys %] + <input type="hidden" name="[% summary.type FILTER html %]" value="[% summary.value FILTER html %]"> + [% END %] + + [% IF product %] + <span id="product_dashboard_links"> + <ul> + <li><a href="[% urlbase FILTER none %]enter_bug.cgi?product=[% product.name FILTER uri %]"> + Create a new [% terms.bug %] in this product</a></li> + <li><a href="[% urlbase FILTER none %]describecomponents.cgi?product=[% product.name FILTER uri %]"> + Show full component descriptions for this product</a></li> + </ul> + </span> + [% END %] + + <strong>Choose product:</strong> + <select name="product"> + [% FOREACH c = classifications %] + <optgroup label="[% c.name FILTER html %]"> + [% FOREACH p = c.products %] + <option value="[% p.name FILTER html %]" + [% IF p.name == product.name %]selected="selected"[% END %]> + [% p.name FILTER html %]</option> + [% END %]</optgroup> + [% END %] + </select> + <select name="bug_status" id="bug_status"> + [% statuses = [ { name = 'open', label = "Open $terms.Bugs" }, + { name = 'closed', label = "Closed $terms.Bugs" }, + { name = 'all', label = "All $terms.Bugs" } ] %] + [% FOREACH status = statuses %] + <option value="[% status.name FILTER html %]" + [% " selected" IF bug_status == "${status.name}" %]> + [% status.label FILTER html %] + </option> + [% END %] + </select> + + <input type="submit" value="[% IF product %]Change[% ELSE %]Submit[% END %]"> + + [% IF product %] + <div class="product_name"> + [% product.name FILTER html %] + </div> + + <div class="product_description"> + [% product.description FILTER none %] + </div> + + [% WRAPPER global/tabs.html.tmpl + tabs = tabs + current_tab = current_tab + %] + + [% IF current_tab.name == 'summary' %] + [% PROCESS pages/productdashboard/summary.html.tmpl %] + [% END %] + + [% IF current_tab.name == 'recents' %] + [% PROCESS pages/productdashboard/recents.html.tmpl %] + [% END %] + + [% IF current_tab.name == 'components' %] + [% PROCESS pages/productdashboard/components.html.tmpl %] + [% END %] + + [% IF current_tab.name == 'duplicates' %] + [% PROCESS pages/productdashboard/duplicates.html.tmpl %] + [% END %] + + [% IF current_tab.name == 'popularity' %] + [% PROCESS pages/productdashboard/popularity.html.tmpl %] + [% END %] + + [% IF current_tab.name == 'roadmap' && Param('usetargetmilestone') %] + [% PROCESS pages/productdashboard/roadmap.html.tmpl %] + [% END %] + + [% END %][%# END WRAPPER %] + [% END %] + + </form> +</div> + +[% PROCESS global/footer.html.tmpl %] + +[% BLOCK bar_graph %] + [% IF full_bug_count > 0 %][%# No divide by zero %] + [% percentage_bugs = (count / full_bug_count) * 100 FILTER format('%02.2f') %] + [% ELSE %] + [% percentage_bugs = 0 %] + [% END %] + <div class="bar_graph"> + <table cellpadding="0" cellspacing="0" width="300px"> + <tr> + <td width="[% percentage_bugs FILTER html %]%"> + <table cellpadding="0" cellspacing="0" width="100%"> + <tr> + <td bgcolor="#3c78b5"> + <a title="[% percentage_bugs FILTER html %]%"> + <img src="extensions/ProductDashboard/web/images/spacer.gif" height=10 width="100%" title="[% percentage_bugs FILTER html %]%"> + </a> + </td> + </tr> + </table> + </td> + <td width="[% 100 - percentage_bugs FILTER html %]%"> [% percentage_bugs FILTER html %]%</td> + </tr> + </table> + </div> +[% END %] + diff --git a/extensions/ProductDashboard/template/en/default/pages/productdashboard/components.html.tmpl b/extensions/ProductDashboard/template/en/default/pages/productdashboard/components.html.tmpl new file mode 100644 index 000000000..6b0e7240a --- /dev/null +++ b/extensions/ProductDashboard/template/en/default/pages/productdashboard/components.html.tmpl @@ -0,0 +1,146 @@ +[%# 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 summary.keys %] + +<h3>Summary for [% summary.type FILTER html %]: [% summary.value FILTER html %]</h3> + +<script> +<!-- + // Past due + [% IF user.is_timetracker %] + PD.past_due = [ + [% FOREACH bug = summary.past_due %] + { + id: '[% bug.id FILTER js %]', + bug_status: '[% bug.status FILTER js %]', + version: '[% bug.version FILTER js %]', + component: '[% bug.component FILTER js %]', + severity: '[% bug.severity FILTER js %]', + summary: '[% bug.summary FILTER js %]' + }, + [% END %] + ]; + [% END %] + + // Updated recently + PD.updated_recently = [ + [% FOREACH bug = summary.updated_recently %] + { + id: '[% bug.id FILTER js %]', + bug_status: '[% bug.status FILTER js %]', + version: '[% bug.version FILTER js %]', + component: '[% bug.component FILTER js %]', + severity: '[% bug.severity FILTER js %]', + summary: '[% bug.summary FILTER js %]' + }, + [% END %] + ]; +--> +</script> + +[% IF user.is_timetracker %] + <p> + <a href="#past_due">Past Due</a> | + <a href="#updated_recently">Updated Recently</a> + </p> +[% END %] + +<div class="yui3-skin-sam"> + + [% IF user.is_timetracker %] + <a name="past_due"></a> + <b>[% summary.past_due.size FILTER html %] Past Due [% terms.Bugs %]</b> (deadline is before today's date) + (<a href="[% bug_link FILTER html %]&[% summary.type FILTER uri %]=[% summary.value FILTER uri %]&field0-0-0=deadline&type0-0-0=lessthan&value0-0-0=[% summary.timestamp FILTER uri %]&order=deadline">full list</a>) + <div id="past_due"></div> + <br> + [% END %] + + <a name="updated_recently"></a> + <b>[% summary.updated_recently.size FILTER html %] Most Recently Updated [% terms.Bugs %]</b> + [% IF user.is_timetracker %](<a href="#top">back to top</a>)[% END %] + (<a href="[% bug_link FILTER html %]&[% summary.type FILTER uri %]=[% summary.value FILTER uri %]&order=changeddate DESC">full list</a>) + <div id="updated_recently"></div> +</div> + +[% ELSE %] + +<script type="text/javascript"> +<!-- + PD.product_name = '[% product.name FILTER js %]'; + PD.bug_status = '[% bug_status FILTER js %]'; + + // Component counts + PD.component_counts = [ + [% FOREACH col = by_component %] + { + name: "[% col.0 FILTER js %]", + count: [% col.1 || 0 FILTER js %], + percentage: [% col.2 || 0 FILTER js %], + link: '<a href="[% bug_link FILTER html %]&component=[% col.0 FILTER uri %]">Link</a>' + }, + [% END %] + ]; + + // Version counts + PD.version_counts = [ + [% FOREACH col = by_version %] + { + name: "[% col.0 FILTER js %]", + count: [% col.1 || 0 FILTER js %], + percentage: [% col.2 || 0 FILTER js %], + link: '<a href="[% bug_link FILTER html %]&version=[% col.0 FILTER uri %]">Link</a>' + }, + [% END %] + ]; + + [% IF Param('usetargetmilestone') %] + // Milestone counts + PD.milestone_counts = [ + [% FOREACH col = by_milestone %] + { + name: "[% col.0 FILTER js %]", + count: [% col.1 || 0 FILTER js %], + percentage: [% col.2 || 0 FILTER js %], + link: '<a href="[% bug_link FILTER html %]&target_milestone=[% col.0 FILTER uri %]">Link</a>' + }, + [% END %] + ]; + [% END %] +--> +</script> + +<h3>[% terms.Bug %] counts per component, version and milestone.</h3> + +<p> + <a href="#component">Component</a> | + <a href="#version">Version</a> | + <a href="#milestone">Milestone</a> +</p> + +<p>Click on a value to show a list of most recently updated [% terms.bugs %].</p> + +<div class="yui3-skin-sam"> + <a name="component"></a> + <b>Component</b> + <div id="component_counts"></div> + <br> + <a name="version"></a> + <b>Version</b> + (<a href="#top">back to top</a>) + <div id="version_counts"></div> + [% IF Param('usetargetmilestone') %] + <br> + <a name="milestone"></a> + <b>Milestone</b> + (<a href="#top">back to top</a>) + <div id="milestone_counts"></div> + [% END %] +</div> + +[% END %] diff --git a/extensions/ProductDashboard/template/en/default/pages/productdashboard/duplicates.html.tmpl b/extensions/ProductDashboard/template/en/default/pages/productdashboard/duplicates.html.tmpl new file mode 100644 index 000000000..585cdc829 --- /dev/null +++ b/extensions/ProductDashboard/template/en/default/pages/productdashboard/duplicates.html.tmpl @@ -0,0 +1,34 @@ +[%# 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 type="text/javascript"> + PD.duplicates = [ + [% FOREACH bug = by_duplicate %] + { + id: '[% bug.id FILTER js %]', + count: '[% bug.dupe_count FILTER js %]', + status: '[% bug.status FILTER js %]', + version: '[% bug.version FILTER js %]', + component: '[% bug.component FILTER js %]', + severity: '[% bug.severity FILTER js %]', + summary: '[% bug.summary FILTER js %]' + }, + [% END %] + ]; +</script> + +<h3>Most duplicated [% terms.bugs %]</h3> + +[% IF by_duplicate.size %] + <b>[% by_duplicate.size FILTER html %] [% terms.Bugs %] Found</b> + <div class="yui3-skin-sam"> + <div id="duplicates"></div> + </div> +[% ELSE %] + <b>No duplicate [% terms.bugs %] found.</b> +[% END %] diff --git a/extensions/ProductDashboard/template/en/default/pages/productdashboard/popularity.html.tmpl b/extensions/ProductDashboard/template/en/default/pages/productdashboard/popularity.html.tmpl new file mode 100644 index 000000000..933f26c81 --- /dev/null +++ b/extensions/ProductDashboard/template/en/default/pages/productdashboard/popularity.html.tmpl @@ -0,0 +1,38 @@ +[%# 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. + #%] + +<style> + .yui-skin-sam .yui-dt table {width:100%;} +</style> + +<script type="text/javascript"> + PD.popularity = [ + [% FOREACH bug = by_popularity %] + { + id: '[% bug.id FILTER js %]', + count: '[% bug.votes FILTER js %]', + status: '[% bug.status FILTER js %]', + version: '[% bug.version FILTER js %]', + component: '[% bug.component FILTER js %]', + severity: '[% bug.severity FILTER js %]', + summary: '[% bug.summary FILTER js %]' + }, + [% END %] + ]; +</script> + +<h3>Most voted on [% terms.bugs %]</h3> + +[% IF by_popularity.size %] + <b>[% by_popularity.size FILTER html %] [% terms.Bugs %] Found</b> + <div class="yui3-skin-sam"> + <div id="popularity"></div> + </div> +[% ELSE %] + <b>No [% terms.bugs %] found.</b> +[% END %] diff --git a/extensions/ProductDashboard/template/en/default/pages/productdashboard/recents.html.tmpl b/extensions/ProductDashboard/template/en/default/pages/productdashboard/recents.html.tmpl new file mode 100644 index 000000000..66320e174 --- /dev/null +++ b/extensions/ProductDashboard/template/en/default/pages/productdashboard/recents.html.tmpl @@ -0,0 +1,87 @@ +[%# 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 type="text/javascript"> + PD.recents = {}; + + // Recently opened + PD.recents.opened = [ + [% FOREACH bug = recently_opened %] + { + id: '[% bug.id FILTER js %]', + status: '[% bug.status FILTER js %]', + version: '[% bug.version FILTER js %]', + component: '[% bug.component FILTER js %]', + severity: '[% bug.severity FILTER js %]', + summary: '[% bug.summary FILTER js %]' + }, + [% END %] + ]; + + // Recently closed + PD.recents.closed = [ + [% FOREACH bug = recently_closed %] + { + id: '[% bug.id FILTER js %]', + status: '[% bug.status FILTER js %]', + version: '[% bug.version FILTER js %]', + component: '[% bug.component FILTER js %]', + severity: '[% bug.severity FILTER js %]', + summary: '[% bug.summary FILTER js %]' + }, + [% END %] + ]; +</script> + +<h3>Most recently opened and closed [% terms.bugs %]</h3> + +<p> + Activity within the last <input type="text" size="4" name="recent_days" + value="[% recent_days FILTER html %]"> + days (between 1 and 100) or from + <input name="date_from" size="10" id="date_from" + value="[% date_from FILTER html %]" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_date_from" + onclick="showCalendar('date_from')"> + <span>Calendar</span> + </button> + <span id="con_calendar_date_from"></span> + to + <input name="date_to" size="10" id="date_to" + value="[% date_to FILTER html %]" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_date_to" + onclick="showCalendar('date_to')"> + <span>Calendar</span> + </button> + <span id="con_calendar_date_to"></span> + <script type="text/javascript"> + createCalendar('date_from') + createCalendar('date_to') + </script> + <input type="submit" name="change" value="Change"> +</p> +<p> + <a href="#recently_opened">Recently Opened</a> + <span class="separator"> | </span> + <a href="#recently_closed">Recently Closed</a> +</p> + +<div class="yui-skin-sam"> + <a name="recently_opened"></a> + <b>[% recently_opened.size FILTER html %] Recently Opened [% terms.Bugs %]</b> + <div id="recently_opened"></div> + <br> + <a name="recently_closed"></a> + <b>[% recently_closed.size FILTER html %] Recently Closed [% terms.Bugs %]</b> + (<a href="#top">back to top</a>) + <div id="recently_closed"></div> +</div> diff --git a/extensions/ProductDashboard/template/en/default/pages/productdashboard/roadmap.html.tmpl b/extensions/ProductDashboard/template/en/default/pages/productdashboard/roadmap.html.tmpl new file mode 100644 index 000000000..b31827fbd --- /dev/null +++ b/extensions/ProductDashboard/template/en/default/pages/productdashboard/roadmap.html.tmpl @@ -0,0 +1,27 @@ +[%# 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 type="text/javascript"> +<!-- + PD.roadmap = [ + [% FOREACH milestone = by_roadmap %] + { + name: '[% milestone.name FILTER js %]', + percentage: '[% milestone.percentage FILTER js %]', + link: '<a href="[% milestone.link_closed FILTER html %]">[% milestone.closed_bugs FILTER html %]</a> of <a href="[% milestone.link_total FILTER html %]"> [% milestone.total_bugs FILTER html %]</a> [% terms.bugs %] have been closed', + }, + [% END %] + ]; +--> +</script> + +<h3>Percentage of [% terms.bug %] closure per milestone</h3> + +<div class="yui3-skin-sam"> + <div id="bug_milestones"></div> +</div> diff --git a/extensions/ProductDashboard/template/en/default/pages/productdashboard/summary.html.tmpl b/extensions/ProductDashboard/template/en/default/pages/productdashboard/summary.html.tmpl new file mode 100644 index 000000000..30b6f3dca --- /dev/null +++ b/extensions/ProductDashboard/template/en/default/pages/productdashboard/summary.html.tmpl @@ -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. + #%] + +<script> + PD.summary = {}; + + // global counts + PD.summary.bug_counts = [ + { + name: "Total [% terms.Bugs %]", + count: [% total_bugs || 0 FILTER js %], + percentage: [% total_bugs ? "100" : "0" %], + link: '<a href="[% bug_link_all FILTER js %]">Link</a>', + }, + { + name: "Open [% terms.Bugs %]", + count: [% total_open_bugs || 0 FILTER js %], + percentage: [% open_bugs_percentage FILTER js %], + link: '<a href="[% bug_link_open FILTER js %]">Link</a>', + }, + { + name: "Closed [% terms.Bugs %]", + count: [% total_closed_bugs || 0 FILTER js %], + percentage: [% closed_bugs_percentage FILTER js %], + link: '<a href="[% bug_link_closed FILTER js %]">Link</a>', + } + ]; + + // Status counts + PD.summary.status_counts = [ + [% FOREACH col = by_status %] + [% NEXT IF col.0 == 'CLOSED' %] + { + name: "[% col.0 FILTER js %]", + count: [% col.1 || 0 FILTER js %], + percentage: [% col.2 || 0 FILTER js %], + link: '<a href="[% bug_link_all FILTER js %]&bug_status=[% col.0 FILTER uri FILTER js %]">Link</a>' + }, + [% END %] + ]; + + // Priority counts + PD.summary.priority_counts = [ + [% FOREACH col = by_priority %] + { + name: "[% col.0 FILTER js %]", + count: [% col.1 || 0 FILTER js %], + percentage: [% col.2 || 0 FILTER js %], + link: '<a href="[% bug_link FILTER js %]&priority=[% col.0 FILTER uri FILTER js %]">Link</a>' + }, + [% END %] + ]; + + // Severity counts + PD.summary.severity_counts = [ + [% FOREACH col = by_severity %] + { + name: "[% col.0 FILTER js %]", + count: [% col.1 || 0 FILTER js %], + percentage: [% col.2 || 0 FILTER js %], + link: '<a href="[% bug_link FILTER js %]&bug_severity=[% col.0 FILTER uri FILTER js %]">Link</a>' + }, + [% END %] + ]; + + // Assignee counts + PD.summary.assignee_counts = [ + [% FOREACH col = by_assignee %] + { + name: "[% IF user.id %][% col.0.email FILTER js %][% ELSE %][% col.0.realname || 'No Name' FILTER js %][% END %]", + count: [% col.1 || 0 FILTER js %], + percentage: [% col.2 || 0 FILTER js %], + link: '[% IF user.id %]<a href="[% bug_link FILTER js %]&emailassigned_to1=1&emailtype1=exact&email1=[% col.0.email FILTER uri FILTER js %]">Link</a>[% END %]' + }, + [% END %] + ]; +</script> + +<h3>Summary of [% terms.bug %] counts</h3> + +<p> + <a href="#counts">Counts</a> + <span class="separator"> | </span> + <a href="#status">Status</a> + <span class="separator"> | </span> + <a href="#priority">Priority</a> + <span class="separator"> | </span> + <a href="#severity">Severity</a> + <span class="separator"> | </span> + <a href="#assignee">Assignee</a> +</p> + +<div class="yui3-skin-sam"> + <a name="counts"></a> + <b>[% terms.Bug %] Counts</b> + <div id="bug_counts"></div> + <br> + <a name="status"></a> + <b>Status</b> + (<a href="#top">back to top</a>) + <div id="status_counts"></div> + <br> + <a name="priority"></a> + <b>Priority</b> + (<a href="#top">back to top</a>) + <div id="priority_counts"></div> + <br> + <a name="severity"></a> + <b>Severity</b> + (<a href="#top">back to top</a>) + <div id="severity_counts"></div> + <br> + <a name="assignee"></a> + <b>Assignee</b> + (<a href="#top">back to top</a>) + <div id="assignee_counts"></div> +</div> diff --git a/extensions/ProductDashboard/web/images/spacer.gif b/extensions/ProductDashboard/web/images/spacer.gif Binary files differnew file mode 100644 index 000000000..fc2560981 --- /dev/null +++ b/extensions/ProductDashboard/web/images/spacer.gif diff --git a/extensions/ProductDashboard/web/js/components.js b/extensions/ProductDashboard/web/js/components.js new file mode 100644 index 000000000..8b0d28587 --- /dev/null +++ b/extensions/ProductDashboard/web/js/components.js @@ -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. + */ + +YUI({ + base: 'js/yui3/', + combine: false +}).use("datatable", "datatable-sort", "escape", function(Y) { + if (typeof PD.updated_recently != 'undefined') { + var columns = [ + { key:"id", label:"ID", sortable:true, allowHTML: true, + formatter: '<a href="show_bug.cgi?id={value}" target="_blank">{value}</a>' }, + { key:"bug_status", label:"Status", sortable:true }, + { key:"version", label:"Version", sortable:true }, + { key:"component", label:"Component", sortable:true }, + { key:"severity", label:"Severity", sortable:true }, + { key:"summary", label:"Summary", sortable:false }, + ]; + + var updatedRecentlyDataTable = new Y.DataTable({ + columns: columns, + data: PD.updated_recently + }); + updatedRecentlyDataTable.render("#updated_recently"); + + if (typeof PD.past_due != 'undefined') { + var pastDueDataTable = new Y.DataTable({ + columns: columns, + data: PD.past_due + }); + pastDueDataTable.render('#past_due'); + } + } + + if (typeof PD.component_counts != 'undefined') { + var summary_url = '<a href="page.cgi?id=productdashboard.html&product=' + + encodeURIComponent(PD.product_name) + '&bug_status=' + + encodeURIComponent(PD.bug_status) + '&tab=components'; + + var columns = [ + { key:"name", label:"Name", sortable:true, allowHTML: true, + formatter: function (o) { + return summary_url + '&component=' + + encodeURIComponent(o.value) + '">' + + Y.Escape.html(o.value) + '</a>' + } + }, + { key:"count", label:"Count", sortable:true }, + { key:"percentage", label:"Percentage", sortable:false, allowHTML: true, + formatter: '<div class="percentage"><div class="bar" style="width:{value}%"></div><div class="percent">{value}%</div></div>' }, + { key:"link", label:"Link", sortable:false, allowHTML: true } + ]; + + var componentsDataTable = new Y.DataTable({ + columns: columns, + data: PD.component_counts + }); + componentsDataTable.render("#component_counts"); + + columns[0].formatter = function (o) { + return summary_url + '&version=' + + encodeURIComponent(o.value) + '">' + + Y.Escape.html(o.value) + '</a>'; + }; + + var versionsDataTable = new Y.DataTable({ + columns: columns, + data: PD.version_counts + }); + versionsDataTable.render('#version_counts'); + + if (typeof PD.milestone_counts != 'undefined') { + columns[0].formatter = function (o) { + return summary_url + '&target_milestone=' + + encodeURIComponent(o.value) + '">' + + Y.Escape.html(o.value) + '</a>'; + }; + + var milestonesDataTable = new Y.DataTable({ + columns: columns, + data: PD.milestone_counts + }); + milestonesDataTable.render('#milestone_counts'); + } + } +}); diff --git a/extensions/ProductDashboard/web/js/duplicates.js b/extensions/ProductDashboard/web/js/duplicates.js new file mode 100644 index 000000000..5e3193a65 --- /dev/null +++ b/extensions/ProductDashboard/web/js/duplicates.js @@ -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. + */ + +YUI({ + base: 'js/yui3/', + combine: false +}).use("datatable", "datatable-sort", function (Y) { + var column_defs = [ + { key:"id", label:"ID", sortable:true, allowHTML: true, + formatter: '<a href="show_bug.cgi?id={value}" target="_blank">{value}</a>' }, + { key:"count", label:"Count", sortable:true }, + { key:"status", label:"Status", sortable:true }, + { key:"version", label:"Version", sortable:true }, + { key:"component", label:"Component", sortable:true }, + { key:"severity", label:"Severity", sortable:true }, + { key:"summary", label:"Summary", sortable:false }, + ]; + + var duplicatesDataTable = new Y.DataTable({ + columns: column_defs, + data: PD.duplicates + }).render('#duplicates'); +}); diff --git a/extensions/ProductDashboard/web/js/popularity.js b/extensions/ProductDashboard/web/js/popularity.js new file mode 100644 index 000000000..b78b67867 --- /dev/null +++ b/extensions/ProductDashboard/web/js/popularity.js @@ -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. + */ + +YUI({ + base: 'js/yui3/', + combine: false +}).use("datatable", "datatable-sort", function (Y) { + var column_defs = [ + { key:"id", label:"ID", sortable:true, allowHTML: true, + formatter: '<a href="show_bug.cgi?id={value}" target="_blank">{value}</a>' }, + { key:"count", label:"Count", sortable:true }, + { key:"status", label:"Status", sortable:true }, + { key:"version", label:"Version", sortable:true }, + { key:"component", label:"Component", sortable:true }, + { key:"severity", label:"Severity", sortable:true }, + { key:"summary", label:"Summary", sortable:false }, + ]; + + var popularityDataTable = new Y.DataTable({ + columns: column_defs, + data: PD.popularity + }).render('#popularity'); +}); diff --git a/extensions/ProductDashboard/web/js/recents.js b/extensions/ProductDashboard/web/js/recents.js new file mode 100644 index 000000000..84e1758b6 --- /dev/null +++ b/extensions/ProductDashboard/web/js/recents.js @@ -0,0 +1,32 @@ +/* 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. + */ + +YUI({ + base: 'js/yui3/', + combine: false +}).use("datatable", "datatable-sort", function (Y) { + var column_defs = [ + { key:"id", label:"ID", sortable:true, allowHTML: true, + formatter: '<a href="show_bug.cgi?id={value}" target="_blank">{value}</a>' }, + { key:"status", label:"Status", sortable:true }, + { key:"version", label:"Version", sortable:true }, + { key:"component", label:"Component", sortable:true }, + { key:"severity", label:"Severity", sortable:true }, + { key:"summary", label:"Summary", sortable:false }, + ]; + + var recentlyOpenedDataTable = new Y.DataTable({ + columns: column_defs, + data: PD.recents.opened + }).render('#recently_opened'); + + var recentlyClosedDataTable = new Y.DataTable({ + columns: column_defs, + data: PD.recents.closed + }).render('#recently_closed'); +}); diff --git a/extensions/ProductDashboard/web/js/roadmap.js b/extensions/ProductDashboard/web/js/roadmap.js new file mode 100644 index 000000000..1bef5b091 --- /dev/null +++ b/extensions/ProductDashboard/web/js/roadmap.js @@ -0,0 +1,24 @@ +/* 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. + */ + +YUI({ + base: 'js/yui3/', + combine: false +}).use("datatable", "datatable-sort", function (Y) { + var column_defs = [ + { key: 'name', label: 'Name', sortable: true }, + { key: 'percentage', label: 'Percentage', sortable: false, allowHTML: true, + formatter: '<div class="percentage"><div class="bar" style="width:{value}%"></div><div class="percent">{value}%</div></div>' }, + { key: 'link', label: 'Links', allowHTML: true, sortable: false } + ]; + + var roadmapDataTable = new Y.DataTable({ + columns: column_defs, + data: PD.roadmap, + }).render('#bug_milestones'); +}); diff --git a/extensions/ProductDashboard/web/js/summary.js b/extensions/ProductDashboard/web/js/summary.js new file mode 100644 index 000000000..59d000d7b --- /dev/null +++ b/extensions/ProductDashboard/web/js/summary.js @@ -0,0 +1,45 @@ +/* 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. + */ + +YUI({ + base: 'js/yui3/', + combine: false +}).use("datatable", "datatable-sort", function (Y) { + var column_defs = [ + { key: 'name', label: 'Name', sortable: true }, + { key: 'count', label: 'Count', sortable: true }, + { key: 'percentage', label: 'Percentage', sortable: true, allowHTML: true, + formatter: '<div class="percentage"><div class="bar" style="width:{value}%"></div><div class="percent">{value}%</div></div>' }, + { key: 'link', label: 'Link', allowHTML: true } + ]; + + var bugsCountDataTable = new Y.DataTable({ + columns: column_defs, + data: PD.summary.bug_counts + }).render('#bug_counts'); + + var statusCountsDataTable = new Y.DataTable({ + columns: column_defs, + data: PD.summary.status_counts + }).render('#status_counts'); + + var priorityCountsDataTable = new Y.DataTable({ + columns: column_defs, + data: PD.summary.priority_counts + }).render('#priority_counts'); + + var severityCountsDataTable = new Y.DataTable({ + columns: column_defs, + data: PD.summary.severity_counts + }).render('#severity_counts'); + + var assigneeCountsDataTable = new Y.DataTable({ + columns: column_defs, + data: PD.summary.assignee_counts + }).render('#assignee_counts'); +}); diff --git a/extensions/ProductDashboard/web/styles/productdashboard.css b/extensions/ProductDashboard/web/styles/productdashboard.css new file mode 100644 index 000000000..c0c45cf38 --- /dev/null +++ b/extensions/ProductDashboard/web/styles/productdashboard.css @@ -0,0 +1,45 @@ +/* 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. */ + +#product_dashboard_links { + float: right; + padding-right: 25px; + border: 1px solid rgb(116, 126, 147); +} + +.product_name { + font-size: 2em; + margin: 10px 0 10px 0; + color: rgb(109, 117, 129); +} + +.product_description { + font-size: 90%; + font-style: italic; + padding-bottom: 5px; + margin-bottom: 10px; +} + +.percentage { + position:relative; + width: 200px; + border: 1px solid rgb(203, 203, 203); + position: relative; + padding: 3px; +} + +.bar{ + background-color: #00ff00; + height: 20px; +} + +.percent{ + position: absolute; + display: inline-block; + top: 3px; + left: 48%; +} diff --git a/extensions/Profanivore/Config.pm b/extensions/Profanivore/Config.pm new file mode 100644 index 000000000..354325c58 --- /dev/null +++ b/extensions/Profanivore/Config.pm @@ -0,0 +1,40 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Profanivore Bugzilla Extension. +# +# The Initial Developer of the Original Code is the Mozilla Foundation. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Gervase Markham <gerv@gerv.net> + +package Bugzilla::Extension::Profanivore; +use strict; + +use constant NAME => 'Profanivore'; + +use constant REQUIRED_MODULES => [ + { + package => 'Regexp-Common', + module => 'Regexp::Common', + version => 0 + }, + { + package => 'HTML-Tree', + module => 'HTML::Tree', + version => 0, + } +]; + +__PACKAGE__->NAME; diff --git a/extensions/Profanivore/Extension.pm b/extensions/Profanivore/Extension.pm new file mode 100644 index 000000000..cdec6e1c6 --- /dev/null +++ b/extensions/Profanivore/Extension.pm @@ -0,0 +1,169 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Profanivore Bugzilla Extension. +# +# The Initial Developer of the Original Code is the Mozilla Foundation. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Gervase Markham <gerv@gerv.net> + +package Bugzilla::Extension::Profanivore; +use strict; +use base qw(Bugzilla::Extension); + +use Regexp::Common 'RE_ALL'; + +use Bugzilla::Util qw(is_7bit_clean); + +our $VERSION = '0.01'; + +sub bug_format_comment { + my ($self, $args) = @_; + my $regexes = $args->{'regexes'}; + my $comment = $args->{'comment'}; + + # Censor profanities if the comment author is not reasonably trusted. + # However, allow people to see their own profanities, which might stop + # them immediately noticing and trying to go around the filter. (I.e. + # it tries to stop an arms race starting.) + if ($comment && + !$comment->author->in_group('editbugs') && + $comment->author->id != Bugzilla->user->id) + { + push (@$regexes, { + match => RE_profanity('-i'), + replace => \&_replace_profanity + }); + } +} + +sub _replace_profanity { + # We don't have access to the actual profanity. + return "****"; +} + +sub mailer_before_send { + my ($self, $args) = @_; + my $email = $args->{'email'}; + + my $author = $email->header("X-Bugzilla-Who"); + my $recipient = $email->header("To"); + + if ($author && $recipient && lc($author) ne lc($recipient)) { + my $email_suffix = Bugzilla->params->{'emailsuffix'}; + if ($email_suffix ne '') { + $recipient =~ s/\Q$email_suffix\E$//; + $author =~ s/\Q$email_suffix\E$//; + } + + $author = new Bugzilla::User({ name => $author }); + + if ($author && + $author->id && + !$author->in_group('editbugs')) + { + # Multipart emails + if (scalar $email->parts > 1) { + $email->walk_parts(sub { + my ($part) = @_; + return if $part->parts > 1; # Top-level + # do not filter attachments such as patches, etc. + if ($part->header('Content-Disposition') + && $part->header('Content-Disposition') =~ /attachment/) + { + return; + } + _fix_encoding($part); + my $body = $part->body_str; + my $new_body; + if ($part->content_type =~ /^text\/html/) { + $new_body = _filter_html($body); + if ($new_body ne $body) { + # HTML::Tree removes unnecessary whitespace, + # resulting in very long lines. We need to use + # quoted-printable encoding to avoid exceeding + # email's maximum line length. + $part->encoding_set('quoted-printable'); + } + } + elsif ($part->content_type =~ /^text\/plain/) { + $new_body = _filter_text($body); + } + if ($new_body && $new_body ne $body) { + $part->body_str_set($new_body); + } + }); + } + # Single part email + else { + _fix_encoding($email); + $email->body_str_set(_filter_text($email->body_str)); + } + } + } +} + +sub _fix_encoding { + my $part = shift; + my $body = $part->body; + if (Bugzilla->params->{'utf8'}) { + $part->charset_set('UTF-8'); + # encoding_set works only with bytes, not with utf8 strings. + my $raw = $part->body_raw; + if (utf8::is_utf8($raw)) { + utf8::encode($raw); + $part->body_set($raw); + } + } + $part->encoding_set('quoted-printable') if !is_7bit_clean($body); +} + +sub _filter_text { + my $text = shift; + my $offensive = RE_profanity('-i'); + $text =~ s/$offensive/****/g; + return $text; +} + +sub _filter_html { + my $html = shift; + my $tree = HTML::Tree->new->parse_content($html); + my $comments_div = $tree->look_down( _tag => 'div', id => 'comments' ); + return $html if !$comments_div; + my @comments = $comments_div->look_down( _tag => 'pre' ); + my $dirty = 0; + foreach my $comment (@comments) { + _filter_html_node($comment, \$dirty); + } + return $dirty ? $tree->as_HTML : $html; +} + +sub _filter_html_node { + my ($node, $dirty) = @_; + my $content = [ $node->content_list ]; + foreach my $item_r ($node->content_refs_list) { + if (ref $$item_r) { + _filter_html_node($$item_r); + } else { + my $new_text = _filter_text($$item_r); + if ($new_text ne $$item_r) { + $$item_r = $new_text; + $$dirty = 1; + } + } + } +} + +__PACKAGE__->NAME; diff --git a/extensions/Profanivore/README b/extensions/Profanivore/README new file mode 100644 index 000000000..5ccab103f --- /dev/null +++ b/extensions/Profanivore/README @@ -0,0 +1,14 @@ +Profanivore 'eats' English profanities in comments, leaving behind instead a +trail of droppings ('****'). It finds its food using a standard library Perl +regexp. The profanity is only eaten where the comment was written by a user +who does not have the global 'editbugs' privilege. The digestion happens at +display time, so the comment in the database is unaltered. + +However, it does not eat profanities when showing people their own comments; +the aim here is to prevent people immediately noticing they are being +censored, and getting 'creative'. + +The purpose of Profanivore is to make it a little harder for trolls to +vandalise public Bugzilla installations. + +It does not currently affect fields other than comments. diff --git a/extensions/Push/Config.pm b/extensions/Push/Config.pm new file mode 100644 index 000000000..11e3502fd --- /dev/null +++ b/extensions/Push/Config.pm @@ -0,0 +1,56 @@ +# 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::Push; + +use strict; + +use constant NAME => 'Push'; + +use constant REQUIRED_MODULES => [ + { + package => 'Daemon-Generic', + module => 'Daemon::Generic', + version => '0' + }, + { + package => 'JSON-XS', + module => 'JSON::XS', + version => '2.0' + }, + { + package => 'Crypt-CBC', + module => 'Crypt::CBC', + version => '0' + }, + { + package => 'Crypt-DES', + module => 'Crypt::DES', + version => '0' + }, + { + package => 'Crypt-DES_EDE3', + module => 'Crypt::DES_EDE3', + version => '0' + }, +]; + +use constant OPTIONAL_MODULES => [ + # connectors need the ability to extend this + { + package => 'Net-SFTP', + module => 'Net::SFTP', + version => '0' + }, + { + package => 'XML-Simple', + module => 'XML::Simple', + version => '0' + }, +]; + +__PACKAGE__->NAME; diff --git a/extensions/Push/Extension.pm b/extensions/Push/Extension.pm new file mode 100644 index 000000000..1d6ec5b62 --- /dev/null +++ b/extensions/Push/Extension.pm @@ -0,0 +1,658 @@ +# 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::Push; + +use strict; +use warnings; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Constants; +use Bugzilla::Comment; +use Bugzilla::Error; +use Bugzilla::Extension::Push::Admin; +use Bugzilla::Extension::Push::Connectors; +use Bugzilla::Extension::Push::Logger; +use Bugzilla::Extension::Push::Message; +use Bugzilla::Extension::Push::Push; +use Bugzilla::Extension::Push::Serialise; +use Bugzilla::Extension::Push::Util; +use Bugzilla::Install::Filesystem; + +use Encode; +use Scalar::Util 'blessed'; +use Storable 'dclone'; + +our $VERSION = '1'; + +$Carp::CarpInternal{'CGI::Carp'} = 1; + +# +# monkey patch for convience +# + +BEGIN { + *Bugzilla::push_ext = \&_get_instance; +} + +sub _get_instance { + my $cache = Bugzilla->request_cache; + if (!$cache->{'push.instance'}) { + my $instance = Bugzilla::Extension::Push::Push->new(); + $cache->{'push.instance'} = $instance; + $instance->logger(Bugzilla::Extension::Push::Logger->new()); + $instance->connectors(Bugzilla::Extension::Push::Connectors->new()); + } + return $cache->{'push.instance'}; +} + +# +# enabled +# + +sub _enabled { + my ($self) = @_; + if (!exists $self->{'enabled'}) { + my $push = Bugzilla->push_ext; + $self->{'enabled'} = $push->config->{enabled} eq 'Enabled'; + if ($self->{'enabled'}) { + # if no connectors are enabled, no need to push anything + $self->{'enabled'} = 0; + foreach my $connector (Bugzilla->push_ext->connectors->list) { + if ($connector->enabled) { + $self->{'enabled'} = 1; + last; + } + } + } + } + return $self->{'enabled'}; +} + +# +# deal with creation and updated events +# + +sub _object_created { + my ($self, $args) = @_; + + my $object = _get_object_from_args($args); + return unless $object; + return unless _should_push($object); + + $self->_push_object('create', $object, change_set_id(), { timestamp => $args->{'timestamp'} }); +} + +sub _object_modified { + my ($self, $args) = @_; + + my $object = _get_object_from_args($args); + return unless $object; + return unless _should_push($object); + + my $changes = $args->{'changes'} || {}; + return unless scalar keys %$changes; + + my $change_set = change_set_id(); + + # detect when a bug changes from public to private (or back), so connectors + # can remove now-private bugs if required. + if ($object->isa('Bugzilla::Bug')) { + # we can't use user->can_see_bug(old_bug) as that works on IDs, and the + # bug has already been updated, so for now assume that a bug without + # groups is public. + my $old_bug = $args->{'old_bug'}; + my $is_public = is_public($object); + my $was_public = $old_bug ? !@{$old_bug->groups_in} : $is_public; + + if (!$is_public && $was_public) { + # bug is changing from public to private + # push a fake update with the just is_private change + my $private_changes = { + timestamp => $args->{'timestamp'}, + changes => [ + { + field => 'is_private', + removed => '0', + added => '1', + }, + ], + }; + # note we're sending the old bug object so we don't leak any + # security sensitive information. + $self->_push_object('modify', $old_bug, $change_set, $private_changes); + } elsif ($is_public && !$was_public) { + # bug is changing from private to public + # push a fake update with the just is_private change + my $private_changes = { + timestamp => $args->{'timestamp'}, + changes => [ + { + field => 'is_private', + removed => '1', + added => '0', + }, + ], + }; + # it's ok to send the new bug state here + $self->_push_object('modify', $object, $change_set, $private_changes); + } + } + + # make flagtypes changes easier to process + if (exists $changes->{'flagtypes.name'}) { + _split_flagtypes($changes); + } + + # TODO split group changes? + + # restructure the changes hash + my $changes_data = { + timestamp => $args->{'timestamp'}, + changes => [], + }; + foreach my $field_name (sort keys %$changes) { + my $new_field_name = $field_name; + $new_field_name =~ s/isprivate/is_private/; + + push @{$changes_data->{'changes'}}, { + field => $new_field_name, + removed => $changes->{$field_name}[0], + added => $changes->{$field_name}[1], + }; + } + + $self->_push_object('modify', $object, $change_set, $changes_data); +} + +sub _get_object_from_args { + my ($args) = @_; + return get_first_value($args, qw(object bug flag group)); +} + +sub _should_push { + my ($object_or_class) = @_; + my $class = blessed($object_or_class) || $object_or_class; + return grep { $_ eq $class } qw(Bugzilla::Bug Bugzilla::Attachment Bugzilla::Comment); +} + +# changes to bug flags are presented in a single field 'flagtypes.name' split +# into individual fields +sub _split_flagtypes { + my ($changes) = @_; + + my @removed = _split_flagtype($changes->{'flagtypes.name'}->[0]); + my @added = _split_flagtype($changes->{'flagtypes.name'}->[1]); + delete $changes->{'flagtypes.name'}; + + foreach my $ra (@removed, @added) { + $changes->{$ra->[0]} = ['', '']; + } + foreach my $ra (@removed) { + my ($name, $value) = @$ra; + $changes->{$name}->[0] = $value; + } + foreach my $ra (@added) { + my ($name, $value) = @$ra; + $changes->{$name}->[1] = $value; + } +} + +sub _split_flagtype { + my ($value) = @_; + my @result; + foreach my $change (split(/, /, $value)) { + my $requestee = ''; + if ($change =~ s/\(([^\)]+)\)$//) { + $requestee = $1; + } + my ($name, $value) = $change =~ /^(.+)(.)$/; + $value .= " ($requestee)" if $requestee; + push @result, [ "flag.$name", $value ]; + } + return @result; +} + +# changes to attachment flags come in via flag_end_of_update which has a +# completely different structure for reporting changes than +# object_end_of_update. this morphs flag to object updates. +sub _morph_flag_updates { + my ($args) = @_; + + my @removed = _morph_flag_update($args->{'old_flags'}); + my @added = _morph_flag_update($args->{'new_flags'}); + + my $changes = {}; + foreach my $ra (@removed, @added) { + $changes->{$ra->[0]} = ['', '']; + } + foreach my $ra (@removed) { + my ($name, $value) = @$ra; + $changes->{$name}->[0] = $value; + } + foreach my $ra (@added) { + my ($name, $value) = @$ra; + $changes->{$name}->[1] = $value; + } + + foreach my $flag (keys %$changes) { + if ($changes->{$flag}->[0] eq $changes->{$flag}->[1]) { + delete $changes->{$flag}; + } + } + + $args->{'changes'} = $changes; +} + +sub _morph_flag_update { + my ($values) = @_; + my @result; + foreach my $orig_change (@$values) { + my $change = $orig_change; # work on a copy + $change =~ s/^[^:]+://; + my $requestee = ''; + if ($change =~ s/\(([^\)]+)\)$//) { + $requestee = $1; + } + my ($name, $value) = $change =~ /^(.+)(.)$/; + $value .= " ($requestee)" if $requestee; + push @result, [ "flag.$name", $value ]; + } + return @result; +} + +# +# serialise and insert into the table +# + +sub _push_object { + my ($self, $message_type, $object, $change_set, $changes) = @_; + my $rh; + + # serialise the object + my ($rh_object, $name) = Bugzilla::Extension::Push::Serialise->instance->object_to_hash($object); + + if (!$rh_object) { + warn "empty hash from serialiser ($message_type $object)\n"; + return; + } + $rh->{$name} = $rh_object; + + # add in the events hash + my $rh_event = Bugzilla::Extension::Push::Serialise->instance->changes_to_event($changes); + return unless $rh_event; + $rh_event->{'action'} = $message_type; + $rh_event->{'target'} = $name; + $rh_event->{'change_set'} = $change_set; + $rh_event->{'routing_key'} = "$name.$message_type"; + if (exists $rh_event->{'changes'}) { + $rh_event->{'routing_key'} .= ':' . join(',', map { $_->{'field'} } @{$rh_event->{'changes'}}); + } + $rh->{'event'} = $rh_event; + + # create message object + my $message = Bugzilla::Extension::Push::Message->new_transient({ + payload => to_json($rh), + change_set => $change_set, + routing_key => $rh_event->{'routing_key'}, + }); + + # don't hit the database unless there are interested connectors + my $should_push = 0; + foreach my $connector (Bugzilla->push_ext->connectors->list) { + next unless $connector->enabled; + next unless $connector->should_send($message); + $should_push = 1; + last; + } + return unless $should_push; + + # insert into push table + $message->create_from_transient(); +} + +# +# update/create hooks +# + +sub object_end_of_create { + my ($self, $args) = @_; + return unless $self->_enabled; + + # it's better to process objects from a non-generic end_of_create where + # possible; don't process them here to avoid duplicate messages + my $object = _get_object_from_args($args); + return if !$object || + $object->isa('Bugzilla::Bug') || + blessed($object) =~ /^Bugzilla::Extension/; + + $self->_object_created($args); +} + +sub object_end_of_update { + my ($self, $args) = @_; + + # User objects are updated with every page load (to touch the session + # token). Because we ignore user objects, there's no need to create an + # instance of Push to check if we're enabled. + my $object = _get_object_from_args($args); + return if !$object || $object->isa('Bugzilla::User'); + + return unless $self->_enabled; + + # it's better to process objects from a non-generic end_of_update where + # possible; don't process them here to avoid duplicate messages + return if $object->isa('Bugzilla::Bug') || + $object->isa('Bugzilla::Flag') || + blessed($object) =~ /^Bugzilla::Extension/; + + $self->_object_modified($args); +} + +# process bugs once they are fully formed +# object_end_of_update is triggered while a bug is being created +sub bug_end_of_create { + my ($self, $args) = @_; + return unless $self->_enabled; + $self->_object_created($args); +} + +sub bug_end_of_update { + my ($self, $args) = @_; + return unless $self->_enabled; + $self->_object_modified($args); +} + +sub flag_end_of_update { + my ($self, $args) = @_; + return unless $self->_enabled; + _morph_flag_updates($args); + $self->_object_modified($args); + delete $args->{changes}; +} + +# comments in bugzilla 4.0 doesn't aren't included in the bug_end_of_* hooks, +# this code uses custom hooks to trigger +sub bug_comment_create { + my ($self, $args) = @_; + return unless $self->_enabled; + + return unless _should_push('Bugzilla::Comment'); + my $bug = $args->{'bug'} or return; + my $timestamp = $args->{'timestamp'} or return; + + my $comments = Bugzilla::Comment->match({ bug_id => $bug->id, bug_when => $timestamp }); + + foreach my $comment (@$comments) { + if ($comment->body ne '') { + $self->_push_object('create', $comment, change_set_id(), { timestamp => $timestamp }); + } + } +} + +sub bug_comment_update { + my ($self, $args) = @_; + return unless $self->_enabled; + + return unless _should_push('Bugzilla::Comment'); + my $bug = $args->{'bug'} or return; + my $timestamp = $args->{'timestamp'} or return; + + my $comment_id = $args->{'comment_id'}; + if ($comment_id) { + # XXX this should set changes. only is_private changes will trigger this event + my $comment = Bugzilla::Comment->new($comment_id); + $self->_push_object('update', $comment, change_set_id(), { timestamp => $timestamp }); + + } else { + # when a bug is created, an update is also triggered; we don't want to sent + # update messages for the initial comment, or for empty comments + my $comments = Bugzilla::Comment->match({ bug_id => $bug->id, bug_when => $timestamp }); + foreach my $comment (@$comments) { + if ($comment->body ne '' && $comment->count) { + $self->_push_object('create', $comment, change_set_id(), { timestamp => $timestamp }); + } + } + } +} + +# +# admin hooks +# + +sub page_before_template { + my ($self, $args) = @_; + my $page = $args->{'page_id'}; + my $vars = $args->{'vars'}; + + if ($page eq 'push_config.html') { + Bugzilla->user->in_group('admin') + || ThrowUserError('auth_failure', + { group => 'admin', + action => 'access', + object => 'administrative_pages' }); + admin_config($vars); + + } elsif ($page eq 'push_queues.html' + || $page eq 'push_queues_view.html' + ) { + Bugzilla->user->in_group('admin') + || ThrowUserError('auth_failure', + { group => 'admin', + action => 'access', + object => 'administrative_pages' }); + admin_queues($vars, $page); + + } elsif ($page eq 'push_log.html') { + Bugzilla->user->in_group('admin') + || ThrowUserError('auth_failure', + { group => 'admin', + action => 'access', + object => 'administrative_pages' }); + admin_log($vars); + } +} + +# +# installation/config hooks +# + +sub db_schema_abstract_schema { + my ($self, $args) = @_; + $args->{'schema'}->{'push'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + push_ts => { + TYPE => 'DATETIME', + NOTNULL => 1, + }, + payload => { + TYPE => 'LONGTEXT', + NOTNULL => 1, + }, + change_set => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + routing_key => { + TYPE => 'VARCHAR(64)', + NOTNULL => 1, + }, + ], + }; + $args->{'schema'}->{'push_backlog'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + message_id => { + TYPE => 'INT3', + NOTNULL => 1, + }, + push_ts => { + TYPE => 'DATETIME', + NOTNULL => 1, + }, + payload => { + TYPE => 'LONGTEXT', + NOTNULL => 1, + }, + change_set => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + routing_key => { + TYPE => 'VARCHAR(64)', + NOTNULL => 1, + }, + connector => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + attempt_ts => { + TYPE => 'DATETIME', + }, + attempts => { + TYPE => 'INT2', + NOTNULL => 1, + }, + last_error => { + TYPE => 'MEDIUMTEXT', + }, + ], + INDEXES => [ + push_backlog_idx => { + FIELDS => ['message_id', 'connector'], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'push_backoff'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + connector => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + next_attempt_ts => { + TYPE => 'DATETIME', + }, + attempts => { + TYPE => 'INT2', + NOTNULL => 1, + }, + ], + INDEXES => [ + push_backoff_idx => { + FIELDS => ['connector'], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'push_options'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + connector => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + option_name => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + option_value => { + TYPE => 'VARCHAR(255)', + NOTNULL => 1, + }, + ], + INDEXES => [ + push_options_idx => { + FIELDS => ['connector', 'option_name'], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'push_log'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + message_id => { + TYPE => 'INT3', + NOTNULL => 1, + }, + change_set => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + routing_key => { + TYPE => 'VARCHAR(64)', + NOTNULL => 1, + }, + connector => { + TYPE => 'VARCHAR(32)', + NOTNULL => 1, + }, + push_ts => { + TYPE => 'DATETIME', + NOTNULL => 1, + }, + processed_ts => { + TYPE => 'DATETIME', + NOTNULL => 1, + }, + result => { + TYPE => 'INT1', + NOTNULL => 1, + }, + data => { + TYPE => 'MEDIUMTEXT', + }, + ], + }; +} + +sub install_filesystem { + my ($self, $args) = @_; + my $files = $args->{'files'}; + + my $extensionsdir = bz_locations()->{'extensionsdir'}; + my $scriptname = $extensionsdir . "/Push/bin/bugzilla-pushd.pl"; + + $files->{$scriptname} = { + perms => Bugzilla::Install::Filesystem::WS_EXECUTE + }; +} + +sub db_sanitize { + my $dbh = Bugzilla->dbh; + print "Deleting push extension logs and messages...\n"; + $dbh->do("DELETE FROM push"); + $dbh->do("DELETE FROM push_backlog"); + $dbh->do("DELETE FROM push_backoff"); + $dbh->do("DELETE FROM push_log"); + $dbh->do("DELETE FROM push_options"); +} + +__PACKAGE__->NAME; diff --git a/extensions/Push/bin/bugzilla-pushd.pl b/extensions/Push/bin/bugzilla-pushd.pl new file mode 100755 index 000000000..f048df157 --- /dev/null +++ b/extensions/Push/bin/bugzilla-pushd.pl @@ -0,0 +1,54 @@ +#!/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; + +use FindBin '$RealBin'; +use lib "$RealBin/../../.."; +use lib "$RealBin/../../../lib"; +use lib "$RealBin/../lib"; + +BEGIN { + use Bugzilla; + Bugzilla->extensions; +} + +use Bugzilla::Extension::Push::Daemon; +Bugzilla::Extension::Push::Daemon->start(); + +=head1 NAME + +bugzilla-push.pl - Pushes changes queued by the Push extension to connectors. + +=head1 SYNOPSIS + + bugzilla-push.pl [OPTIONS] COMMAND + + OPTIONS: + -f Run in the foreground (don't detach) + -d Output a lot of debugging information + -p file Specify the file where bugzilla-push.pl should store its current + process id. Defaults to F<data/bugzilla-push.pl.pid>. + -n name What should this process call itself in the system log? + Defaults to the full path you used to invoke the script. + + COMMANDS: + start Starts a new bugzilla-push daemon if there isn't one running already + stop Stops a running bugzilla-push daemon + restart Stops a running bugzilla-push if one is running, and then + starts a new one. + check Report the current status of the daemon. + install On some *nix systems, this automatically installs and + configures bugzilla-push.pl as a system service so that it will + start every time the machine boots. + uninstall Removes the system service for bugzilla-push.pl. + help Display this usage info + + diff --git a/extensions/Push/bin/nagios_push_checker.pl b/extensions/Push/bin/nagios_push_checker.pl new file mode 100755 index 000000000..f022f584d --- /dev/null +++ b/extensions/Push/bin/nagios_push_checker.pl @@ -0,0 +1,54 @@ +#!/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; + +use FindBin '$RealBin'; +use lib "$RealBin/../../.."; +use lib "$RealBin/../../../lib"; +use lib "$RealBin/../lib"; + +use Bugzilla; +use Bugzilla::Constants; + +Bugzilla->usage_mode(USAGE_MODE_CMDLINE); + +# Number of jobs required in the queue before we alert + +use constant WARN_COUNT => 500; +use constant ALARM_COUNT => 750; + +use constant NAGIOS_OK => 0; +use constant NAGIOS_WARNING => 1; +use constant NAGIOS_CRITICAL => 2; + +my $connector = shift + || die "Syntax: $0 connector\neg. $0 TCL\n"; +$connector = uc($connector); + +my $sql = <<EOF; + SELECT COUNT(*) + FROM push_backlog + WHERE connector = ? +EOF + +my $dbh = Bugzilla->switch_to_shadow_db; +my ($count) = @{ $dbh->selectcol_arrayref($sql, undef, $connector) }; + +if ($count < WARN_COUNT) { + print "push $connector OK: $count messages found.\n"; + exit NAGIOS_OK; +} elsif ($count < ALARM_COUNT) { + print "push $connector WARNING: $count messages found.\n"; + exit NAGIOS_WARNING; +} else { + print "push $connector CRITICAL: $count messages found.\n"; + exit NAGIOS_CRITICAL; +} diff --git a/extensions/Push/lib/Admin.pm b/extensions/Push/lib/Admin.pm new file mode 100644 index 000000000..f579409bd --- /dev/null +++ b/extensions/Push/lib/Admin.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::Push::Admin; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Error; +use Bugzilla::Extension::Push::Util; +use Bugzilla::Util qw(trim detaint_natural trick_taint); + +use base qw(Exporter); +our @EXPORT = qw( + admin_config + admin_queues + admin_log +); + +sub admin_config { + my ($vars) = @_; + my $push = Bugzilla->push_ext; + my $input = Bugzilla->input_params; + + if ($input->{save}) { + my $dbh = Bugzilla->dbh; + $dbh->bz_start_transaction(); + _update_config_from_form('global', $push->config); + foreach my $connector ($push->connectors->list) { + _update_config_from_form($connector->name, $connector->config); + } + $push->set_config_last_modified(); + $dbh->bz_commit_transaction(); + $vars->{message} = 'push_config_updated'; + } + + $vars->{push} = $push; + $vars->{connectors} = $push->connectors; +} + +sub _update_config_from_form { + my ($name, $config) = @_; + my $input = Bugzilla->input_params; + + # read values from form + my $values = {}; + foreach my $option ($config->options) { + my $option_name = $option->{name}; + $values->{$option_name} = trim($input->{$name . ".$option_name"}); + } + + # validate + if ($values->{enabled} eq 'Enabled') { + eval { + $config->validate($values); + }; + if ($@) { + ThrowUserError('push_error', { error_message => clean_error($@) }); + } + } + + # update + foreach my $option ($config->options) { + my $option_name = $option->{name}; + trick_taint($values->{$option_name}); + $config->{$option_name} = $values->{$option_name}; + } + $config->update(); +} + +sub admin_queues { + my ($vars, $page) = @_; + my $push = Bugzilla->push_ext; + my $input = Bugzilla->input_params; + + if ($page eq 'push_queues.html') { + $vars->{push} = $push; + + } elsif ($page eq 'push_queues_view.html') { + my $queue; + if ($input->{connector}) { + my $connector = $push->connectors->by_name($input->{connector}) + || ThrowUserError('push_error', { error_message => 'Invalid connector' }); + $queue = $connector->backlog; + } else { + $queue = $push->queue; + } + $vars->{queue} = $queue; + + my $id = $input->{message} || 0; + detaint_natural($id) + || ThrowUserError('push_error', { error_message => 'Invalid message ID' }); + my $message = $queue->by_id($id) + || ThrowUserError('push_error', { error_message => 'Invalid message ID' }); + + if ($input->{delete}) { + $message->remove_from_db(); + $vars->{message} = 'push_message_deleted'; + + } else { + $vars->{message_obj} = $message; + eval { + $vars->{json} = to_json($message->payload_decoded, 1); + }; + } + } +} + +sub admin_log { + my ($vars) = @_; + my $push = Bugzilla->push_ext; + my $input = Bugzilla->input_params; + + $vars->{push} = $push; +} + +1; diff --git a/extensions/Push/lib/BacklogMessage.pm b/extensions/Push/lib/BacklogMessage.pm new file mode 100644 index 000000000..cd40ebefb --- /dev/null +++ b/extensions/Push/lib/BacklogMessage.pm @@ -0,0 +1,150 @@ +# 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::Push::BacklogMessage; + +use strict; +use warnings; + +use base 'Bugzilla::Object'; + +use constant AUDIT_CREATES => 0; +use constant AUDIT_UPDATES => 0; +use constant AUDIT_REMOVES => 0; +use constant USE_MEMCACHED => 0; + +use Bugzilla; +use Bugzilla::Error; +use Bugzilla::Extension::Push::Util; +use Bugzilla::Util; +use Encode; + +# +# initialisation +# + +use constant DB_TABLE => 'push_backlog'; +use constant DB_COLUMNS => qw( + id + message_id + push_ts + payload + change_set + routing_key + connector + attempt_ts + attempts + last_error +); +use constant UPDATE_COLUMNS => qw( + attempt_ts + attempts + last_error +); +use constant LIST_ORDER => 'push_ts'; +use constant VALIDATORS => { + payload => \&_check_payload, + change_set => \&_check_change_set, + routing_key => \&_check_routing_key, + connector => \&_check_connector, + attempts => \&_check_attempts, +}; + +# +# constructors +# + +sub create_from_message { + my ($class, $message, $connector) = @_; + my $self = $class->create({ + message_id => $message->id, + push_ts => $message->push_ts, + payload => $message->payload, + change_set => $message->change_set, + routing_key => $message->routing_key, + connector => $connector->name, + attempt_ts => undef, + attempts => 0, + last_error => undef, + }); + return $self; +} + +# +# accessors +# + +sub message_id { return $_[0]->{'message_id'} } +sub push_ts { return $_[0]->{'push_ts'}; } +sub payload { return $_[0]->{'payload'}; } +sub change_set { return $_[0]->{'change_set'}; } +sub routing_key { return $_[0]->{'routing_key'}; } +sub connector { return $_[0]->{'connector'}; } +sub attempt_ts { return $_[0]->{'attempt_ts'}; } +sub attempts { return $_[0]->{'attempts'}; } +sub last_error { return $_[0]->{'last_error'}; } + +sub payload_decoded { + my ($self) = @_; + return from_json($self->{'payload'}); +} + +sub attempt_time { + my ($self) = @_; + if (!exists $self->{'attempt_time'}) { + $self->{'attempt_time'} = datetime_from($self->attempt_ts)->epoch; + } + return $self->{'attempt_time'}; +} + +# +# mutators +# + +sub inc_attempts { + my ($self, $error) = @_; + $self->{attempt_ts} = Bugzilla->dbh->selectrow_array('SELECT NOW()'); + $self->{attempts} = $self->{attempts} + 1; + $self->{last_error} = $error; + $self->update; +} + +# +# validators +# + +sub _check_payload { + my ($invocant, $value) = @_; + length($value) || ThrowCodeError('push_invalid_payload'); + return $value; +} + +sub _check_change_set { + my ($invocant, $value) = @_; + (defined($value) && length($value)) || ThrowCodeError('push_invalid_change_set'); + return $value; +} + +sub _check_routing_key { + my ($invocant, $value) = @_; + (defined($value) && length($value)) || ThrowCodeError('push_invalid_routing_key'); + return $value; +} + +sub _check_connector { + my ($invocant, $value) = @_; + Bugzilla->push_ext->connectors->exists($value) || ThrowCodeError('push_invalid_connector'); + return $value; +} + +sub _check_attempts { + my ($invocant, $value) = @_; + return $value || 0; +} + +1; + diff --git a/extensions/Push/lib/BacklogQueue.pm b/extensions/Push/lib/BacklogQueue.pm new file mode 100644 index 000000000..79b9b72ee --- /dev/null +++ b/extensions/Push/lib/BacklogQueue.pm @@ -0,0 +1,127 @@ +# 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::Push::BacklogQueue; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Extension::Push::BacklogMessage; + +sub new { + my ($class, $connector) = @_; + my $self = {}; + bless($self, $class); + $self->{connector} = $connector; + return $self; +} + +sub count { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + return $dbh->selectrow_array(" + SELECT COUNT(*) + FROM push_backlog + WHERE connector = ?", + undef, + $self->{connector}); +} + +sub oldest { + my ($self) = @_; + my @messages = $self->list( + limit => 1, + filter => 'AND ((next_attempt_ts IS NULL) OR (next_attempt_ts <= NOW()))', + ); + return scalar(@messages) ? $messages[0] : undef; +} + +sub by_id { + my ($self, $id) = @_; + my @messages = $self->list( + limit => 1, + filter => "AND (log.id = $id)", + ); + return scalar(@messages) ? $messages[0] : undef; +} + +sub list { + my ($self, %args) = @_; + $args{limit} ||= 10; + $args{filter} ||= ''; + my @result; + my $dbh = Bugzilla->dbh; + + my $filter_sql = $args{filter} || ''; + my $sth = $dbh->prepare(" + SELECT log.id, message_id, push_ts, payload, change_set, routing_key, attempt_ts, log.attempts + FROM push_backlog log + LEFT JOIN push_backoff off ON off.connector = log.connector + WHERE log.connector = ? ". + $args{filter} . " + ORDER BY push_ts " . + $dbh->sql_limit($args{limit}) + ); + $sth->execute($self->{connector}); + while (my $row = $sth->fetchrow_hashref()) { + push @result, Bugzilla::Extension::Push::BacklogMessage->new({ + id => $row->{id}, + message_id => $row->{message_id}, + push_ts => $row->{push_ts}, + payload => $row->{payload}, + change_set => $row->{change_set}, + routing_key => $row->{routing_key}, + connector => $self->{connector}, + attempt_ts => $row->{attempt_ts}, + attempts => $row->{attempts}, + }); + } + return @result; +} + +# +# backoff +# + +sub backoff { + my ($self) = @_; + if (!$self->{backoff}) { + my $ra = Bugzilla::Extension::Push::Backoff->match({ + connector => $self->{connector} + }); + if (@$ra) { + $self->{backoff} = $ra->[0]; + } else { + $self->{backoff} = Bugzilla::Extension::Push::Backoff->create({ + connector => $self->{connector} + }); + } + } + return $self->{backoff}; +} + +sub reset_backoff { + my ($self) = @_; + my $backoff = $self->backoff; + $backoff->reset(); + $backoff->update(); +} + +sub inc_backoff { + my ($self) = @_; + my $backoff = $self->backoff; + $backoff->inc(); + $backoff->update(); +} + +sub connector { + my ($self) = @_; + return $self->{connector}; +} + +1; diff --git a/extensions/Push/lib/Backoff.pm b/extensions/Push/lib/Backoff.pm new file mode 100644 index 000000000..55552e5e1 --- /dev/null +++ b/extensions/Push/lib/Backoff.pm @@ -0,0 +1,110 @@ +# 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::Push::Backoff; + +use strict; +use warnings; + +use base 'Bugzilla::Object'; + +use constant AUDIT_CREATES => 0; +use constant AUDIT_UPDATES => 0; +use constant AUDIT_REMOVES => 0; +use constant USE_MEMCACHED => 0; + +use Bugzilla; +use Bugzilla::Util; + +# +# initialisation +# + +use constant DB_TABLE => 'push_backoff'; +use constant DB_COLUMNS => qw( + id + connector + next_attempt_ts + attempts +); +use constant UPDATE_COLUMNS => qw( + next_attempt_ts + attempts +); +use constant VALIDATORS => { + connector => \&_check_connector, + next_attempt_ts => \&_check_next_attempt_ts, + attempts => \&_check_attempts, +}; +use constant LIST_ORDER => 'next_attempt_ts'; + +# +# accessors +# + +sub connector { return $_[0]->{'connector'}; } +sub next_attempt_ts { return $_[0]->{'next_attempt_ts'}; } +sub attempts { return $_[0]->{'attempts'}; } + +sub next_attempt_time { + my ($self) = @_; + if (!exists $self->{'next_attempt_time'}) { + $self->{'next_attempt_time'} = datetime_from($self->next_attempt_ts)->epoch; + } + return $self->{'next_attempt_time'}; +} + +# +# mutators +# + +sub reset { + my ($self) = @_; + $self->{next_attempt_ts} = Bugzilla->dbh->selectrow_array('SELECT NOW()'); + $self->{attempts} = 0; + Bugzilla->push_ext->logger->debug( + sprintf("resetting backoff for %s", $self->connector) + ); +} + +sub inc { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + + my $attempts = $self->attempts + 1; + my $seconds = $attempts <= 4 ? 5 ** $attempts : 15 * 60; + my ($date) = $dbh->selectrow_array("SELECT " . $dbh->sql_date_math('NOW()', '+', $seconds, 'SECOND')); + + $self->{next_attempt_ts} = $date; + $self->{attempts} = $attempts; + Bugzilla->push_ext->logger->debug( + sprintf("setting next attempt for %s to %s (attempt %s)", $self->connector, $date, $attempts) + ); +} + +# +# validators +# + +sub _check_connector { + my ($invocant, $value) = @_; + Bugzilla->push_ext->connectors->exists($value) || ThrowCodeError('push_invalid_connector'); + return $value; +} + +sub _check_next_attempt_ts { + my ($invocant, $value) = @_; + return $value || Bugzilla->dbh->selectrow_array('SELECT NOW()'); +} + +sub _check_attempts { + my ($invocant, $value) = @_; + return $value || 0; +} + +1; + diff --git a/extensions/Push/lib/Config.pm b/extensions/Push/lib/Config.pm new file mode 100644 index 000000000..7033b4195 --- /dev/null +++ b/extensions/Push/lib/Config.pm @@ -0,0 +1,215 @@ +# 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::Push::Config; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Option; +use Crypt::CBC; + +sub new { + my ($class, $name, @options) = @_; + my $self = { + _name => $name + }; + bless($self, $class); + + $self->{_options} = [@options]; + unshift @{$self->{_options}}, { + name => 'enabled', + label => 'Status', + help => '', + type => 'select', + values => [ 'Enabled', 'Disabled' ], + default => 'Disabled', + }; + + return $self; +} + +sub options { + my ($self) = @_; + return @{$self->{_options}}; +} + +sub option { + my ($self, $name) = @_; + foreach my $option ($self->options) { + return $option if $option->{name} eq $name; + } + return undef; +} + +sub load { + my ($self) = @_; + my $config = {}; + my $logger = Bugzilla->push_ext->logger; + + # prime $config with defaults + foreach my $rh ($self->options) { + $config->{$rh->{name}} = $rh->{default}; + } + + # override defaults with values from database + my $options = Bugzilla::Extension::Push::Option->match({ + connector => $self->{_name}, + }); + foreach my $option (@$options) { + my $option_config = $self->option($option->name) + || next; + if ($option_config->{type} eq 'password') { + $config->{$option->name} = $self->_decrypt($option->value); + } else { + $config->{$option->name} = $option->value; + } + } + + # validate when running from the daemon + if (Bugzilla->push_ext->is_daemon) { + $self->_validate_config($config); + } + + # done, update self + foreach my $name (keys %$config) { + my $value = $self->option($name)->{type} eq 'password' ? '********' : $config->{$name}; + $logger->debug(sprintf("%s: set %s=%s\n", $self->{_name}, $name, $value || '')); + $self->{$name} = $config->{$name}; + } +} + +sub validate { + my ($self, $config) = @_; + $self->_validate_mandatory($config); + $self->_validate_config($config); +} + +sub update { + my ($self) = @_; + + my @valid_options = map { $_->{name} } $self->options; + + my %options; + my $options_list = Bugzilla::Extension::Push::Option->match({ + connector => $self->{_name}, + }); + foreach my $option (@$options_list) { + $options{$option->name} = $option; + } + + # delete options which are no longer valid + foreach my $name (keys %options) { + if (!grep { $_ eq $name } @valid_options) { + $options{$name}->remove_from_db(); + delete $options{$name}; + } + } + + # update options + foreach my $name (keys %options) { + my $option = $options{$name}; + if ($self->option($name)->{type} eq 'password') { + $option->set_value($self->_encrypt($self->{$name})); + } else { + $option->set_value($self->{$name}); + } + $option->update(); + } + + # add missing options + foreach my $name (@valid_options) { + next if exists $options{$name}; + Bugzilla::Extension::Push::Option->create({ + connector => $self->{_name}, + option_name => $name, + option_value => $self->{$name}, + }); + } +} + +sub _remove_invalid_options { + my ($self, $config) = @_; + my @names; + foreach my $rh ($self->options) { + push @names, $rh->{name}; + } + foreach my $name (keys %$config) { + if ($name =~ /^_/ || !grep { $_ eq $name } @names) { + delete $config->{$name}; + } + } +} + +sub _validate_mandatory { + my ($self, $config) = @_; + $self->_remove_invalid_options($config); + + my @missing; + foreach my $option ($self->options) { + next unless $option->{required}; + my $name = $option->{name}; + if (!exists $config->{$name} || !defined($config->{$name}) || $config->{$name} eq '') { + push @missing, $option; + } + } + if (@missing) { + my $connector = $self->{_name}; + @missing = map { $_->{label} } @missing; + if (scalar @missing == 1) { + die "The option '$missing[0]' for the connector '$connector' is mandatory\n"; + } else { + die "The following options for the connector '$connector' are mandatory:\n " + . join("\n ", @missing) . "\n"; + } + } +} + +sub _validate_config { + my ($self, $config) = @_; + $self->_remove_invalid_options($config); + + my @errors; + foreach my $option ($self->options) { + my $name = $option->{name}; + next unless exists $config->{$name} && exists $option->{validate}; + eval { + $option->{validate}->($config->{$name}, $config); + }; + push @errors, $@ if $@; + } + die join("\n", @errors) if @errors; + + if ($self->{_name} ne 'global') { + my $class = 'Bugzilla::Extension::Push::Connector::' . $self->{_name}; + $class->options_validate($config); + } +} + +sub _cipher { + my ($self) = @_; + $self->{_cipher} ||= Crypt::CBC->new( + -key => Bugzilla->localconfig->{'site_wide_secret'}, + -cipher => 'DES_EDE3'); + return $self->{_cipher}; +} + +sub _decrypt { + my ($self, $value) = @_; + my $result; + eval { $result = $self->_cipher->decrypt_hex($value) }; + return $@ ? '' : $result; +} + +sub _encrypt { + my ($self, $value) = @_; + return $self->_cipher->encrypt_hex($value); +} + +1; diff --git a/extensions/Push/lib/Connector.disabled/AMQP.pm b/extensions/Push/lib/Connector.disabled/AMQP.pm new file mode 100644 index 000000000..7b7d4aa72 --- /dev/null +++ b/extensions/Push/lib/Connector.disabled/AMQP.pm @@ -0,0 +1,230 @@ +# 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::Push::Connector::AMQP; + +use strict; +use warnings; + +use base 'Bugzilla::Extension::Push::Connector::Base'; + +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Constants; +use Bugzilla::Extension::Push::Util; +use Bugzilla::Util qw(generate_random_password); +use DateTime; + +sub init { + my ($self) = @_; + $self->{mq} = 0; + $self->{channel} = 1; + + if ($self->config->{queue}) { + $self->{queue_name} = $self->config->{queue}; + } else { + my $queue_name = Bugzilla->params->{'urlbase'}; + $queue_name =~ s#^https?://##; + $queue_name =~ s#/$#|#; + $queue_name .= generate_random_password(16); + $self->{queue_name} = $queue_name; + } +} + +sub options { + return ( + { + name => 'host', + label => 'AMQP Hostname', + type => 'string', + default => 'localhost', + required => 1, + }, + { + name => 'port', + label => 'AMQP Port', + type => 'string', + default => '5672', + required => 1, + validate => sub { + $_[0] =~ /\D/ && die "Invalid port (must be numeric)\n"; + }, + }, + { + name => 'username', + label => 'Username', + type => 'string', + default => 'guest', + required => 1, + }, + { + name => 'password', + label => 'Password', + type => 'password', + default => 'guest', + required => 1, + }, + { + name => 'vhost', + label => 'Virtual Host', + type => 'string', + default => '/', + required => 1, + }, + { + name => 'exchange', + label => 'Exchange', + type => 'string', + default => '', + required => 1, + }, + { + name => 'queue', + label => 'Queue', + type => 'string', + }, + ); +} + +sub stop { + my ($self) = @_; + if ($self->{mq}) { + Bugzilla->push_ext->logger->debug('AMQP: disconnecting'); + $self->{mq}->disconnect(); + $self->{mq} = 0; + } +} + +sub _connect { + my ($self) = @_; + my $logger = Bugzilla->push_ext->logger; + my $config = $self->config; + + $self->stop(); + + $logger->debug('AMQP: Connecting to RabbitMQ ' . $config->{host} . ':' . $config->{port}); + require Net::RabbitMQ; + my $mq = Net::RabbitMQ->new(); + $mq->connect( + $config->{host}, + { + port => $config->{port}, + user => $config->{username}, + password => $config->{password}, + } + ); + $self->{mq} = $mq; + + $logger->debug('AMQP: Opening channel ' . $self->{channel}); + $self->{mq}->channel_open($self->{channel}); + + $logger->debug('AMQP: Declaring queue ' . $self->{queue_name}); + $self->{mq}->queue_declare( + $self->{channel}, + $self->{queue_name}, + { + passive => 0, + durable => 1, + exclusive => 0, + auto_delete => 0, + }, + ); +} + +sub _bind { + my ($self, $message) = @_; + my $logger = Bugzilla->push_ext->logger; + my $config = $self->config; + + # bind to queue (also acts to verify the connection is still valid) + if ($self->{mq}) { + eval { + $logger->debug('AMQP: binding queue(' . $self->{queue_name} . ') with exchange(' . $config->{exchange} . ')'); + $self->{mq}->queue_bind( + $self->{channel}, + $self->{queue_name}, + $config->{exchange}, + $message->routing_key, + ); + }; + if ($@) { + $logger->debug('AMQP: ' . clean_error($@)); + $self->{mq} = 0; + } + } + +} + +sub should_send { + my ($self, $message) = @_; + my $logger = Bugzilla->push_ext->logger; + + my $payload = $message->payload_decoded(); + my $target = $payload->{event}->{target}; + my $is_private = $payload->{$target}->{is_private} ? 1 : 0; + if (!$is_private && exists $payload->{$target}->{bug}) { + $is_private = $payload->{$target}->{bug}->{is_private} ? 1 : 0; + } + + if ($is_private) { + # we only want to push the is_private message from the change_set, as + # this is guaranteed to contain public information only + if ($message->routing_key !~ /\.modify:is_private$/) { + $logger->debug('AMQP: Ignoring private message'); + return 0; + } + $logger->debug('AMQP: Sending change of message to is_private'); + } + return 1; +} + +sub send { + my ($self, $message) = @_; + my $logger = Bugzilla->push_ext->logger; + my $config = $self->config; + + # don't push comments to pulse + if ($message->routing_key =~ /^comment\./) { + $logger->debug('AMQP: Ignoring comment'); + return PUSH_RESULT_IGNORED; + } + + # don't push private data + $self->should_push($message) + || return PUSH_RESULT_IGNORED; + + $self->_bind($message); + + eval { + # reconnect if required + if (!$self->{mq}) { + $self->_connect(); + } + + # send message + $logger->debug('AMQP: Publishing message'); + $self->{mq}->publish( + $self->{channel}, + $message->routing_key, + $message->payload, + { + exchange => $config->{exchange}, + }, + { + content_type => 'text/plain', + content_encoding => '8bit', + }, + ); + }; + if ($@) { + return (PUSH_RESULT_TRANSIENT, clean_error($@)); + } + + return PUSH_RESULT_OK; +} + +1; + diff --git a/extensions/Push/lib/Connector.disabled/ServiceNow.pm b/extensions/Push/lib/Connector.disabled/ServiceNow.pm new file mode 100644 index 000000000..832cc9262 --- /dev/null +++ b/extensions/Push/lib/Connector.disabled/ServiceNow.pm @@ -0,0 +1,434 @@ +# 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::Push::Connector::ServiceNow; + +use strict; +use warnings; + +use base 'Bugzilla::Extension::Push::Connector::Base'; + +use Bugzilla::Attachment; +use Bugzilla::Bug; +use Bugzilla::Component; +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Constants; +use Bugzilla::Extension::Push::Serialise; +use Bugzilla::Extension::Push::Util; +use Bugzilla::Field; +use Bugzilla::Mailer; +use Bugzilla::Product; +use Bugzilla::User; +use Bugzilla::Util qw(trim trick_taint); +use Email::MIME; +use FileHandle; +use LWP; +use MIME::Base64; +use Net::LDAP; + +use constant SEND_COMPONENTS => ( + { + product => 'mozilla.org', + component => 'Server Operations: Desktop Issues', + }, +); + +sub options { + return ( + { + name => 'bugzilla_user', + label => 'Bugzilla Service-Now User', + type => 'string', + default => 'service.now@bugzilla.tld', + required => 1, + validate => sub { + Bugzilla::User->new({ name => $_[0] }) + || die "Invalid Bugzilla user ($_[0])\n"; + }, + }, + { + name => 'ldap_scheme', + label => 'Mozilla LDAP Scheme', + type => 'select', + values => [ 'LDAP', 'LDAPS' ], + default => 'LDAPS', + required => 1, + }, + { + name => 'ldap_host', + label => 'Mozilla LDAP Host', + type => 'string', + default => '', + required => 1, + }, + { + name => 'ldap_user', + label => 'Mozilla LDAP Bind Username', + type => 'string', + default => '', + required => 1, + }, + { + name => 'ldap_pass', + label => 'Mozilla LDAP Password', + type => 'password', + default => '', + required => 1, + }, + { + name => 'ldap_poll', + label => 'Mozilla LDAP Poll Frequency', + type => 'string', + default => '3', + required => 1, + help => 'minutes', + validate => sub { + $_[0] =~ /\D/ + && die "LDAP Poll Frequency must be an integer\n"; + $_[0] == 0 + && die "LDAP Poll Frequency cannot be less than one minute\n"; + }, + }, + { + name => 'service_now_url', + label => 'Service Now JSON URL', + type => 'string', + default => 'https://mozilladev.service-now.com', + required => 1, + help => "Must start with https:// and end with ?JSON", + validate => sub { + $_[0] =~ m#^https://[^\.\/]+\.service-now\.com\/# + || die "Invalid Service Now JSON URL\n"; + $_[0] =~ m#\?JSON$# + || die "Invalid Service Now JSON URL (must end with ?JSON)\n"; + }, + }, + { + name => 'service_now_user', + label => 'Service Now JSON Username', + type => 'string', + default => '', + required => 1, + }, + { + name => 'service_now_pass', + label => 'Service Now JSON Password', + type => 'password', + default => '', + required => 1, + }, + ); +} + +sub options_validate { + my ($self, $config) = @_; + my $host = $config->{ldap_host}; + trick_taint($host); + my $scheme = lc($config->{ldap_scheme}); + eval { + my $ldap = Net::LDAP->new($host, scheme => $scheme, onerror => 'die', timeout => 5) + or die $!; + $ldap->bind($config->{ldap_user}, password => $config->{ldap_pass}); + }; + if ($@) { + die sprintf("Failed to connect to %s://%s/: %s\n", $scheme, $host, $@); + } +} + +my $_instance; + +sub init { + my ($self) = @_; + $_instance = $self; +} + +sub load_config { + my ($self) = @_; + $self->SUPER::load_config(@_); + $self->{bugzilla_user} ||= Bugzilla::User->new({ name => $self->config->{bugzilla_user} }); +} + +sub should_send { + my ($self, $message) = @_; + + my $data = $message->payload_decoded; + my $bug_data = $self->_get_bug_data($data) + || return 0; + + # we don't want to send the initial comment in a separate message + # because we inject it into the inital message + if (exists $data->{comment} && $data->{comment}->{number} == 0) { + return 0; + } + + my $target = $data->{event}->{target}; + unless ($target eq 'bug' || $target eq 'comment' || $target eq 'attachment') { + return 0; + } + + # ensure the service-now user can see the bug + if (!$self->{bugzilla_user} || !$self->{bugzilla_user}->is_enabled) { + return 0; + } + $self->{bugzilla_user}->can_see_bug($bug_data->{id}) + || return 0; + + # don't push changes made by the service-now account + $data->{event}->{user}->{id} == $self->{bugzilla_user}->id + && return 0; + + # filter based on the component + my $bug = Bugzilla::Bug->new($bug_data->{id}); + my $send = 0; + foreach my $rh (SEND_COMPONENTS) { + if ($bug->product eq $rh->{product} && $bug->component eq $rh->{component}) { + $send = 1; + last; + } + } + return $send; +} + +sub send { + my ($self, $message) = @_; + my $logger = Bugzilla->push_ext->logger; + my $config = $self->config; + + # should_send intiailises bugzilla_user; make sure we return a useful error message + if (!$self->{bugzilla_user}) { + return (PUSH_RESULT_TRANSIENT, "Invalid bugzilla-user (" . $self->config->{bugzilla_user} . ")"); + } + + # load the bug + my $data = $message->payload_decoded; + my $bug_data = $self->_get_bug_data($data); + my $bug = Bugzilla::Bug->new($bug_data->{id}); + + if ($message->routing_key eq 'bug.create') { + # inject the comment into the data for new bugs + my $comment = shift @{ $bug->comments }; + if ($comment->body ne '') { + $bug_data->{comment} = Bugzilla::Extension::Push::Serialise->instance->object_to_hash($comment, 1); + } + + } elsif ($message->routing_key eq 'attachment.create') { + # inject the attachment payload + my $attachment = Bugzilla::Attachment->new($data->{attachment}->{id}); + $data->{attachment}->{data} = encode_base64($attachment->data); + } + + # map bmo login to ldap login and insert into json payload + $self->_add_ldap_logins($data, {}); + + # flatten json data + $self->_flatten($data); + + # add sysparm_action + $data->{sysparm_action} = 'insert'; + + if ($logger->debugging) { + $logger->debug(to_json(ref($data) ? $data : from_json($data), 1)); + } + + # send to service-now + my $request = HTTP::Request->new(POST => $self->config->{service_now_url}); + $request->content_type('application/json'); + $request->content(to_json($data)); + $request->authorization_basic($self->config->{service_now_user}, $self->config->{service_now_pass}); + + $self->{lwp} ||= LWP::UserAgent->new(agent => Bugzilla->params->{urlbase}); + my $result = $self->{lwp}->request($request); + + # http level errors + if (!$result->is_success) { + # treat these as transient + return (PUSH_RESULT_TRANSIENT, $result->status_line); + } + + # empty response + if (length($result->content) == 0) { + # malformed request, treat as transient to allow code to fix + # may also be misconfiguration on servicenow, also transient + return (PUSH_RESULT_TRANSIENT, "Empty response"); + } + + # json errors + my $result_data; + eval { + $result_data = from_json($result->content); + }; + if ($@) { + return (PUSH_RESULT_TRANSIENT, clean_error($@)); + } + if ($logger->debugging) { + $logger->debug(to_json($result_data, 1)); + } + if (exists $result_data->{error}) { + return (PUSH_RESULT_ERROR, $result_data->{error}); + }; + + # malformed/unexpected json response + if (!exists $result_data->{records} + || ref($result_data->{records}) ne 'ARRAY' + || scalar(@{$result_data->{records}}) == 0 + ) { + return (PUSH_RESULT_ERROR, "Malformed JSON response from ServiceNow: missing or empty 'records' array"); + } + + my $record = $result_data->{records}->[0]; + if (ref($record) ne 'HASH') { + return (PUSH_RESULT_ERROR, "Malformed JSON response from ServiceNow: 'records' array does not contain an object"); + } + + # sys_id is the unique identifier for this action + if (!exists $record->{sys_id} || $record->{sys_id} eq '') { + return (PUSH_RESULT_ERROR, "Malformed JSON response from ServiceNow: 'records object' does not contain a valid sys_id"); + } + + # success + return (PUSH_RESULT_OK, "sys_id: " . $record->{sys_id}); +} + +sub _get_bug_data { + my ($self, $data) = @_; + my $target = $data->{event}->{target}; + if ($target eq 'bug') { + return $data->{bug}; + } elsif (exists $data->{$target}->{bug}) { + return $data->{$target}->{bug}; + } else { + return; + } +} + +sub _flatten { + # service-now expects a flat json object + my ($self, $data) = @_; + + my $target = $data->{event}->{target}; + + # delete unnecessary deep objects + if ($target eq 'comment' || $target eq 'attachment') { + $data->{$target}->{bug_id} = $data->{$target}->{bug}->{id}; + delete $data->{$target}->{bug}; + } + delete $data->{event}->{changes}; + + $self->_flatten_hash($data, $data, 'u'); +} + +sub _flatten_hash { + my ($self, $base_hash, $hash, $prefix) = @_; + foreach my $key (keys %$hash) { + if (ref($hash->{$key}) eq 'HASH') { + $self->_flatten_hash($base_hash, $hash->{$key}, $prefix . "_$key"); + } elsif (ref($hash->{$key}) ne 'ARRAY') { + $base_hash->{$prefix . "_$key"} = $hash->{$key}; + } + delete $hash->{$key}; + } +} + +sub _add_ldap_logins { + my ($self, $rh, $cache) = @_; + if (exists $rh->{login}) { + my $login = $rh->{login}; + $cache->{$login} ||= $self->_bmo_to_ldap($login); + Bugzilla->push_ext->logger->debug("BMO($login) --> LDAP(" . $cache->{$login} . ")"); + $rh->{ldap} = $cache->{$login}; + } + foreach my $key (keys %$rh) { + next unless ref($rh->{$key}) eq 'HASH'; + $self->_add_ldap_logins($rh->{$key}, $cache); + } +} + +sub _bmo_to_ldap { + my ($self, $login) = @_; + my $ldap = $self->_ldap_cache(); + + return '' unless $login =~ /\@mozilla\.(?:com|org)$/; + + foreach my $check ($login, canon_email($login)) { + # check for matching bugmail entry + foreach my $mail (keys %$ldap) { + next unless $ldap->{$mail}{bugmail_canon} eq $check; + return $mail; + } + + # check for matching mail + if (exists $ldap->{$check}) { + return $check; + } + + # check for matching email alias + foreach my $mail (sort keys %$ldap) { + next unless grep { $check eq $_ } @{$ldap->{$mail}{aliases}}; + return $mail; + } + } + + return ''; +} + +sub _ldap_cache { + my ($self) = @_; + my $logger = Bugzilla->push_ext->logger; + my $config = $self->config; + + # cache of all ldap entries; updated infrequently + if (!$self->{ldap_cache_time} || (time) - $self->{ldap_cache_time} > $config->{ldap_poll} * 60) { + $logger->debug('refreshing LDAP cache'); + + my $cache = {}; + + my $host = $config->{ldap_host}; + trick_taint($host); + my $scheme = lc($config->{ldap_scheme}); + my $ldap = Net::LDAP->new($host, scheme => $scheme, onerror => 'die') + or die $!; + $ldap->bind($config->{ldap_user}, password => $config->{ldap_pass}); + foreach my $ldap_base ('o=com,dc=mozilla', 'o=org,dc=mozilla') { + my $result = $ldap->search( + base => $ldap_base, + scope => 'sub', + filter => '(mail=*)', + attrs => ['mail', 'bugzillaEmail', 'emailAlias', 'cn', 'employeeType'], + ); + foreach my $entry ($result->entries) { + my ($name, $bugMail, $mail, $type) = + map { $entry->get_value($_) || '' } + qw(cn bugzillaEmail mail employeeType); + next if $type eq 'DISABLED'; + $mail = lc $mail; + $bugMail = '' if $bugMail !~ /\@/; + $bugMail = trim($bugMail); + if ($bugMail =~ / /) { + $bugMail = (grep { /\@/ } split / /, $bugMail)[0]; + } + $name =~ s/\s+/ /g; + $cache->{$mail}{name} = trim($name); + $cache->{$mail}{bugmail} = $bugMail; + $cache->{$mail}{bugmail_canon} = canon_email($bugMail); + $cache->{$mail}{aliases} = []; + foreach my $alias ( + @{$entry->get_value('emailAlias', asref => 1) || []} + ) { + push @{$cache->{$mail}{aliases}}, canon_email($alias); + } + } + } + + $self->{ldap_cache} = $cache; + $self->{ldap_cache_time} = (time); + } + + return $self->{ldap_cache}; +} + +1; + diff --git a/extensions/Push/lib/Connector/Base.pm b/extensions/Push/lib/Connector/Base.pm new file mode 100644 index 000000000..290ea9740 --- /dev/null +++ b/extensions/Push/lib/Connector/Base.pm @@ -0,0 +1,106 @@ +# 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::Push::Connector::Base; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Extension::Push::Config; +use Bugzilla::Extension::Push::BacklogMessage; +use Bugzilla::Extension::Push::BacklogQueue; +use Bugzilla::Extension::Push::Backoff; + +sub new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + ($self->{name}) = $class =~ /^.+:(.+)$/; + $self->init(); + return $self; +} + +sub name { + my $self = shift; + return $self->{name}; +} + +sub init { + my ($self) = @_; + # abstract + # perform any initialisation here + # will be run when created by the web pages or by the daemon + # and also when the configuration needs to be reloaded +} + +sub stop { + my ($self) = @_; + # abstract + # run from the daemon only; disconnect from remote hosts, etc +} + +sub should_send { + my ($self, $message) = @_; + # abstract + # return boolean indicating if the connector will be sending the message. + # this will be called each message, and should be a very quick simple test. + # the connector can perform a more exhaustive test in the send() method. + return 0; +} + +sub send { + my ($self, $message) = @_; + # abstract + # deliver the message, daemon only +} + +sub options { + my ($self) = @_; + # abstract + # return an array of configuration variables + return (); +} + +sub options_validate { + my ($class, $config) = @_; + # abstract, static + # die if a combination of options in $config is invalid +} + +# +# +# + +sub config { + my ($self) = @_; + if (!$self->{config}) { + $self->load_config(); + } + return $self->{config}; +} + +sub load_config { + my ($self) = @_; + my $config = Bugzilla::Extension::Push::Config->new($self->name, $self->options); + $config->load(); + $self->{config} = $config; +} + +sub enabled { + my ($self) = @_; + return $self->config->{enabled} eq 'Enabled'; +} + +sub backlog { + my ($self) = @_; + $self->{backlog} ||= Bugzilla::Extension::Push::BacklogQueue->new($self->name); + return $self->{backlog}; +} + +1; + diff --git a/extensions/Push/lib/Connector/File.pm b/extensions/Push/lib/Connector/File.pm new file mode 100644 index 000000000..2a8f4193d --- /dev/null +++ b/extensions/Push/lib/Connector/File.pm @@ -0,0 +1,68 @@ +# 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::Push::Connector::File; + +use strict; +use warnings; + +use base 'Bugzilla::Extension::Push::Connector::Base'; + +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Constants; +use Bugzilla::Extension::Push::Util; +use Encode; +use FileHandle; + +sub init { + my ($self) = @_; +} + +sub options { + return ( + { + name => 'filename', + label => 'Filename', + type => 'string', + default => 'push.log', + required => 1, + validate => sub { + my $filename = shift; + $filename =~ m#^/# + && die "Absolute paths are not permitted\n"; + }, + }, + ); +} + +sub should_send { + my ($self, $message) = @_; + return 1; +} + +sub send { + my ($self, $message) = @_; + + # pretty-format json payload + my $payload = $message->payload_decoded; + $payload = to_json($payload, 1); + + my $filename = bz_locations()->{'datadir'} . '/' . $self->config->{filename}; + Bugzilla->push_ext->logger->debug("File: Appending to $filename"); + my $fh = FileHandle->new(">>$filename"); + $fh->binmode(':utf8'); + $fh->print( + "[" . scalar(localtime) . "]\n" . + $payload . "\n\n" + ); + $fh->close; + + return PUSH_RESULT_OK; +} + +1; + diff --git a/extensions/Push/lib/Connector/ReviewBoard.pm b/extensions/Push/lib/Connector/ReviewBoard.pm new file mode 100644 index 000000000..b5d1a9214 --- /dev/null +++ b/extensions/Push/lib/Connector/ReviewBoard.pm @@ -0,0 +1,187 @@ +# 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::Push::Connector::ReviewBoard; + +use strict; +use warnings; + +use base 'Bugzilla::Extension::Push::Connector::Base'; + +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Constants; +use Bugzilla::Extension::Push::Util; +use Bugzilla::Bug; +use Bugzilla::Attachment; +use Bugzilla::Extension::Push::Connector::ReviewBoard::Client; + +use JSON 'decode_json'; +use DateTime; +use Scalar::Util 'blessed'; + +use constant RB_CONTENT_TYPE => 'text/x-review-board-request'; + +sub client { + my $self = shift; + + $self->{client} //= Bugzilla::Extension::Push::Connector::ReviewBoard::Client->new( + base_uri => $self->config->{base_uri}, + username => $self->config->{username}, + password => $self->config->{password}, + $self->config->{proxy} ? (proxy => $self->config->{proxy}) : (), + ); + + return $self->{client}; +} + +sub options { + return ( + { + name => 'base_uri', + label => 'Base URI for ReviewBoard', + type => 'string', + default => 'https://reviewboard.allizom.org', + required => 1, + }, + { + name => 'username', + label => 'Username', + type => 'string', + default => 'guest', + required => 1, + }, + { + name => 'password', + label => 'Password', + type => 'password', + default => 'guest', + required => 1, + }, + { + name => 'proxy', + label => 'Proxy', + type => 'string', + }, + ); +} + +sub stop { + my ($self) = @_; +} + +sub should_send { + my ($self, $message) = @_; + + if ($message->routing_key =~ /^(?:attachment|bug)\.modify:.*\bis_private\b/) { + my $payload = $message->payload_decoded(); + my $target = $payload->{event}->{target}; + + if ($target ne 'bug' && exists $payload->{$target}->{bug}) { + return 0 if $payload->{$target}->{bug}->{is_private}; + return 0 if $payload->{$target}->{content_type} ne RB_CONTENT_TYPE; + } + + return $payload->{$target}->{is_private} ? 1 : 0; + } + else { + # We're not interested in the message. + return 0; + } +} + +sub send { + my ($self, $message) = @_; + my $logger = Bugzilla->push_ext->logger; + my $config = $self->config; + + eval { + my $payload = $message->payload_decoded(); + my $target = $payload->{event}->{target}; + + if (my $method = $self->can("_process_$target")) { + $self->$method($payload->{$target}); + } + }; + if ($@) { + return (PUSH_RESULT_TRANSIENT, clean_error($@)); + } + + return PUSH_RESULT_OK; +} + +sub _process_attachment { + my ($self, $payload_target) = @_; + my $logger = Bugzilla->push_ext->logger; + my $attachment = blessed($payload_target) + ? $payload_target + : Bugzilla::Attachment->new({ id => $payload_target->{id}, cache => 1 }); + + if ($attachment) { + my $content = $attachment->data; + my $base_uri = quotemeta($self->config->{base_uri}); + if (my ($id) = $content =~ m|$base_uri/r/([0-9]+)|) { + my $resp = $self->client->review_request->delete($id); + my $content = $resp->decoded_content; + my $status = $resp->code; + my $result = $content && decode_json($content) ; + + if ($status == 204) { + # Success, review request deleted! + $logger->debug("Deleted review request $id"); + } + elsif ($status == 404) { + # API error 100 - Does Not Exist + $logger->debug("Does Not Exist: Review Request $id does not exist"); + } + elsif ($status == 403) { + # API error 101 - Permission Denied + $logger->error("Permission Denied: ReviewBoard Push Connector may be misconfigured"); + die $result->{err}{msg}; + } + elsif ($status == 401) { + # API error 103 - Not logged in + $logger->error("Not logged in: ReviewBoard Push Connector may be misconfigured"); + die $result->{err}{msg}; + } + else { + if ($result) { + my $code = $result->{err}{code}; + my $msg = $result->{err}{msg}; + $logger->error("Unexpected API Error: ($code) $msg"); + die $msg; + } + else { + $logger->error("Unexpected HTTP Response $status"); + die "HTTP Status: $status"; + } + } + } + else { + $logger->error("Cannot find link: ReviewBoard Push Connector may be misconfigured"); + die "Unable to find link in $content"; + } + } + else { + $logger->error("Cannot find attachment with id = $payload_target->{id}"); + } +} + +sub _process_bug { + my ($self, $payload_target) = @_; + + Bugzilla->set_user(Bugzilla::User->super_user); + my $bug = Bugzilla::Bug->new({ id => $payload_target->{id}, cache => 1 }); + my @attachments = @{ $bug->attachments }; + Bugzilla->logout; + + foreach my $attachment (@attachments) { + next if $attachment->contenttype ne RB_CONTENT_TYPE; + $self->_process_attachment($attachment); + } +} + +1; diff --git a/extensions/Push/lib/Connector/ReviewBoard/Client.pm b/extensions/Push/lib/Connector/ReviewBoard/Client.pm new file mode 100644 index 000000000..7ec4938d2 --- /dev/null +++ b/extensions/Push/lib/Connector/ReviewBoard/Client.pm @@ -0,0 +1,67 @@ +# 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::Push::Connector::ReviewBoard::Client; + +use 5.10.1; +use strict; +use warnings; + +use Carp qw(croak); +use LWP::UserAgent; +use Scalar::Util qw(blessed); +use URI; + +use Bugzilla::Extension::Push::Connector::ReviewBoard::ReviewRequest; + +sub new { + my ($class, %params) = @_; + + croak "->new() is a class method" if blessed($class); + return bless(\%params, $class); +} + +sub username { $_[0]->{username} } +sub password { $_[0]->{password} } +sub base_uri { $_[0]->{base_uri} } +sub realm { $_[0]->{realm} // 'Web API' } +sub proxy { $_[0]->{proxy} } + +sub _netloc { + my $self = shift; + + my $uri = URI->new($self->base_uri); + return $uri->host . ':' . $uri->port; +} + +sub useragent { + my $self = shift; + + unless ($self->{useragent}) { + my $ua = LWP::UserAgent->new(agent => Bugzilla->params->{urlbase}); + $ua->credentials( + $self->_netloc, + $self->realm, + $self->username, + $self->password, + ); + $ua->proxy('https', $self->proxy) if $self->proxy; + $ua->timeout(10); + + $self->{useragent} = $ua; + } + + return $self->{useragent}; +} + +sub review_request { + my $self = shift; + + return Bugzilla::Extension::Push::Connector::ReviewBoard::ReviewRequest->new(client => $self, @_); +} + +1; diff --git a/extensions/Push/lib/Connector/ReviewBoard/Resource.pm b/extensions/Push/lib/Connector/ReviewBoard/Resource.pm new file mode 100644 index 000000000..3f8d434ce --- /dev/null +++ b/extensions/Push/lib/Connector/ReviewBoard/Resource.pm @@ -0,0 +1,38 @@ +# 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::Push::Connector::ReviewBoard::Resource; + +use 5.10.1; +use strict; +use warnings; + +use URI; +use Carp qw(croak confess); +use Scalar::Util qw(blessed); + +sub new { + my ($class, %params) = @_; + + croak "->new() is a class method" if blessed($class); + return bless(\%params, $class); +} + +sub client { $_[0]->{client} } + +sub path { confess 'Unimplemented'; } + +sub uri { + my ($self, @path) = @_; + + my $uri = URI->new($self->client->base_uri); + $uri->path(join('/', $self->path, @path) . '/'); + + return $uri; +} + +1; diff --git a/extensions/Push/lib/Connector/ReviewBoard/ReviewRequest.pm b/extensions/Push/lib/Connector/ReviewBoard/ReviewRequest.pm new file mode 100644 index 000000000..32bebfbe8 --- /dev/null +++ b/extensions/Push/lib/Connector/ReviewBoard/ReviewRequest.pm @@ -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. + +package Bugzilla::Extension::Push::Connector::ReviewBoard::ReviewRequest; + +use 5.10.1; +use strict; +use warnings; + +use base 'Bugzilla::Extension::Push::Connector::ReviewBoard::Resource'; + +# Reference: http://www.reviewboard.org/docs/manual/dev/webapi/2.0/resources/review-request/ + +sub path { + return '/api/review-requests'; +} + +sub delete { + my ($self, $id) = @_; + + return $self->client->useragent->delete($self->uri($id)); +} + +1; diff --git a/extensions/Push/lib/Connector/TCL.pm b/extensions/Push/lib/Connector/TCL.pm new file mode 100644 index 000000000..16ebb0319 --- /dev/null +++ b/extensions/Push/lib/Connector/TCL.pm @@ -0,0 +1,352 @@ +# 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::Push::Connector::TCL; + +use strict; +use warnings; + +use base 'Bugzilla::Extension::Push::Connector::Base'; + +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Constants; +use Bugzilla::Extension::Push::Serialise; +use Bugzilla::Extension::Push::Util; +use Bugzilla::User; +use Bugzilla::Attachment; + +use Digest::MD5 qw(md5_hex); +use Encode qw(encode_utf8); + +sub options { + return ( + { + name => 'tcl_user', + label => 'Bugzilla TCL User', + type => 'string', + default => 'tcl@bugzilla.tld', + required => 1, + validate => sub { + Bugzilla::User->new({ name => $_[0] }) + || die "Invalid Bugzilla user ($_[0])\n"; + }, + }, + { + name => 'sftp_host', + label => 'SFTP Host', + type => 'string', + default => '', + required => 1, + }, + { + name => 'sftp_port', + label => 'SFTP Port', + type => 'string', + default => '22', + required => 1, + validate => sub { + $_[0] =~ /\D/ && die "SFTP Port must be an integer\n"; + }, + }, + { + name => 'sftp_user', + label => 'SFTP Username', + type => 'string', + default => '', + required => 1, + }, + { + name => 'sftp_pass', + label => 'SFTP Password', + type => 'password', + default => '', + required => 1, + }, + { + name => 'sftp_remote_path', + label => 'SFTP Remote Path', + type => 'string', + default => '', + required => 0, + }, + ); +} + +my $_instance; + +sub init { + my ($self) = @_; + $_instance = $self; +} + +sub load_config { + my ($self) = @_; + $self->SUPER::load_config(@_); +} + +sub should_send { + my ($self, $message) = @_; + + my $data = $message->payload_decoded; + my $bug_data = $self->_get_bug_data($data) + || return 0; + + # sanity check user + $self->{tcl_user} ||= Bugzilla::User->new({ name => $self->config->{tcl_user} }); + if (!$self->{tcl_user} || !$self->{tcl_user}->is_enabled) { + return 0; + } + + # only send bugs created by the tcl user + unless ($bug_data->{reporter}->{id} == $self->{tcl_user}->id) { + return 0; + } + + # don't push changes made by the tcl user + if ($data->{event}->{user}->{id} == $self->{tcl_user}->id) { + return 0; + } + + # send comments + if ($data->{event}->{routing_key} eq 'comment.create') { + return 0 if $data->{comment}->{is_private}; + return 1; + } + + # send status and resolution updates + foreach my $change (@{ $data->{event}->{changes} }) { + return 1 if $change->{field} eq 'bug_status' + || $change->{field} eq 'resolution' + || $change->{field} eq 'cf_blocking_b2g'; + } + + # send attachments + if ($data->{event}->{routing_key} =~ /^attachment\./) { + return 0 if $data->{attachment}->{is_private}; + return 1; + } + + # and nothing else + return 0; +} + +sub send { + my ($self, $message) = @_; + my $logger = Bugzilla->push_ext->logger; + my $config = $self->config; + + require XML::Simple; + require Net::SFTP; + + $self->{tcl_user} ||= Bugzilla::User->new({ name => $self->config->{tcl_user} }); + if (!$self->{tcl_user}) { + return (PUSH_RESULT_TRANSIENT, "Invalid bugzilla-user (" . $self->config->{tcl_user} . ")"); + } + + # load the bug + my $data = $message->payload_decoded; + my $bug_data = $self->_get_bug_data($data); + + # build payload + my $attachment; + my %xml = ( + Mozilla_ID => $bug_data->{id}, + When => $data->{event}->{time}, + Who => $data->{event}->{user}->{login}, + Status => $bug_data->{status}->{name}, + Resolution => $bug_data->{resolution}, + Blocking_B2G => $bug_data->{cf_blocking_b2g}, + ); + if ($data->{event}->{routing_key} eq 'comment.create') { + $xml{Comment} = $data->{comment}->{body}; + } elsif ($data->{event}->{routing_key} =~ /^attachment\.(\w+)/) { + my $is_update = $1 eq 'modify'; + if (!$is_update) { + $attachment = Bugzilla::Attachment->new($data->{attachment}->{id}); + } + $xml{Attach} = { + Attach_ID => $data->{attachment}->{id}, + Filename => $data->{attachment}->{file_name}, + Description => $data->{attachment}->{description}, + ContentType => $data->{attachment}->{content_type}, + IsPatch => $data->{attachment}->{is_patch} ? 'true' : 'false', + IsObsolete => $data->{attachment}->{is_obsolete} ? 'true' : 'false', + IsUpdate => $is_update ? 'true' : 'false', + }; + } + + # convert to xml + my $xml = XML::Simple::XMLout( + \%xml, + NoAttr => 1, + RootName => 'sync', + XMLDecl => 1, + ); + $xml = encode_utf8($xml); + + # generate md5 + my $md5 = md5_hex($xml); + + # build filename + my ($sec, $min, $hour, $day, $mon, $year) = localtime(time); + my $change_set = $data->{event}->{change_set}; + $change_set =~ s/\.//g; + my $filename = sprintf( + '%04s%02d%02d%02d%02d%02d%s', + $year + 1900, + $mon + 1, + $day, + $hour, + $min, + $sec, + $change_set, + ); + + # create temp files; + my $temp_dir = File::Temp::Directory->new(); + my $local_dir = $temp_dir->dirname; + _write_file("$local_dir/$filename.sync", $xml); + _write_file("$local_dir/$filename.sync.check", $md5); + _write_file("$local_dir/$filename.done", ''); + if ($attachment) { + _write_file("$local_dir/$filename.sync.attach", $attachment->data); + } + + my $remote_dir = $self->config->{sftp_remote_path} eq '' + ? '' + : $self->config->{sftp_remote_path} . '/'; + + # send files via sftp + $logger->debug("Connecting to " . $self->config->{sftp_host} . ":" . $self->config->{sftp_port}); + my $sftp = Net::SFTP->new( + $self->config->{sftp_host}, + ssh_args => { + port => $self->config->{sftp_port}, + }, + user => $self->config->{sftp_user}, + password => $self->config->{sftp_pass}, + ); + + $logger->debug("Uploading $local_dir/$filename.sync"); + $sftp->put("$local_dir/$filename.sync", "$remote_dir$filename.sync") + or return (PUSH_RESULT_ERROR, "Failed to upload $local_dir/$filename.sync"); + + $logger->debug("Uploading $local_dir/$filename.sync.check"); + $sftp->put("$local_dir/$filename.sync.check", "$remote_dir$filename.sync.check") + or return (PUSH_RESULT_ERROR, "Failed to upload $local_dir/$filename.sync.check"); + + if ($attachment) { + $logger->debug("Uploading $local_dir/$filename.sync.attach"); + $sftp->put("$local_dir/$filename.sync.attach", "$remote_dir$filename.sync.attach") + or return (PUSH_RESULT_ERROR, "Failed to upload $local_dir/$filename.sync.attach"); + } + + $logger->debug("Uploading $local_dir/$filename.done"); + $sftp->put("$local_dir/$filename.done", "$remote_dir$filename.done") + or return (PUSH_RESULT_ERROR, "Failed to upload $local_dir/$filename.done"); + + # success + return (PUSH_RESULT_OK, "uploaded $filename.sync"); +} + +sub _get_bug_data { + my ($self, $data) = @_; + my $target = $data->{event}->{target}; + if ($target eq 'bug') { + return $data->{bug}; + } elsif (exists $data->{$target}->{bug}) { + return $data->{$target}->{bug}; + } else { + return; + } +} + +sub _write_file { + my ($filename, $content) = @_; + open(my $fh, ">$filename") or die "Failed to write to $filename: $!\n"; + binmode($fh); + print $fh $content; + close($fh) or die "Failed to write to $filename: $!\n"; +} + +1; + +# File::Temp->newdir() requires a newer version of File::Temp than we have on +# production, so here's a small inline package which performs the same task. + +package File::Temp::Directory; + +use strict; +use warnings; + +use File::Temp; +use File::Path qw(rmtree); +use File::Spec; + +my @chars; + +sub new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + + @chars = qw/ A B C D E F G H I J K L M N O P Q R S T U V W X Y Z + a b c d e f g h i j k l m n o p q r s t u v w x y z + 0 1 2 3 4 5 6 7 8 9 _ + /; + + $self->{TEMPLATE} = File::Spec->catdir(File::Spec->tmpdir, 'X' x 10); + $self->{DIRNAME} = $self->_mktemp(); + return $self; +} + +sub _mktemp { + my ($self) = @_; + my $path = $self->_random_name(); + while(1) { + if (mkdir($path, 0700)) { + # in case of odd umask + chmod(0700, $path); + return $path; + } else { + # abort with error if the reason for failure was anything except eexist + die "Could not create directory $path: $!\n" unless ($!{EEXIST}); + # loop round for another try + } + $path = $self->_random_name(); + } + + return $path; +} + +sub _random_name { + my ($self) = @_; + my $path = $self->{TEMPLATE}; + $path =~ s/X/$chars[int(rand(@chars))]/ge; + return $path; +} + +sub dirname { + my ($self) = @_; + return $self->{DIRNAME}; +} + +sub DESTROY { + my ($self) = @_; + local($., $@, $!, $^E, $?); + if (-d $self->{DIRNAME}) { + # Some versions of rmtree will abort if you attempt to remove the + # directory you are sitting in. We protect that and turn it into a + # warning. We do this because this occurs during object destruction and + # so can not be caught by the user. + eval { rmtree($self->{DIRNAME}, 0, 0); }; + warn $@ if ($@ && $^W); + } +} + +1; + diff --git a/extensions/Push/lib/Connectors.pm b/extensions/Push/lib/Connectors.pm new file mode 100644 index 000000000..026d3f7f1 --- /dev/null +++ b/extensions/Push/lib/Connectors.pm @@ -0,0 +1,116 @@ +# 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::Push::Connectors; + +use strict; +use warnings; + +use Bugzilla::Extension::Push::Util; +use Bugzilla::Constants; +use Bugzilla::Util qw(trick_taint); +use File::Basename; + +sub new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + + $self->{names} = []; + $self->{objects} = {}; + $self->{path} = bz_locations->{'extensionsdir'} . '/Push/lib/Connector'; + + my $logger = Bugzilla->push_ext->logger; + foreach my $file (glob($self->{path} . '/*.pm')) { + my $name = basename($file); + $name =~ s/\.pm$//; + next if $name eq 'Base'; + if (length($name) > 32) { + $logger->info("Ignoring connector '$name': Name longer than 32 characters"); + } + push @{$self->{names}}, $name; + $logger->debug("Found connector '$name'"); + } + + return $self; +} + +sub _load { + my ($self) = @_; + return if scalar keys %{$self->{objects}}; + + my $logger = Bugzilla->push_ext->logger; + foreach my $name (@{$self->{names}}) { + next if exists $self->{objects}->{$name}; + my $file = $self->{path} . "/$name.pm"; + trick_taint($file); + require $file; + my $package = "Bugzilla::Extension::Push::Connector::$name"; + + $logger->debug("Loading connector '$name'"); + my $old_error_mode = Bugzilla->error_mode; + Bugzilla->error_mode(ERROR_MODE_DIE); + eval { + my $connector = $package->new(); + $connector->load_config(); + $self->{objects}->{$name} = $connector; + }; + if ($@) { + $logger->error("Connector '$name' failed to load: " . clean_error($@)); + } + Bugzilla->error_mode($old_error_mode); + } +} + +sub stop { + my ($self) = @_; + my $logger = Bugzilla->push_ext->logger; + foreach my $connector ($self->list) { + next unless $connector->enabled; + $logger->debug("Stopping '" . $connector->name . "'"); + eval { + $connector->stop(); + }; + if ($@) { + $logger->error("Connector '" . $connector->name . "' failed to stop: " . clean_error($@)); + $logger->debug("Connector '" . $connector->name . "' failed to stop: $@"); + } + } +} + +sub reload { + my ($self) = @_; + $self->stop(); + $self->{objects} = {}; + $self->_load(); +} + +sub names { + my ($self) = @_; + return @{$self->{names}}; +} + +sub list { + my ($self) = @_; + $self->_load(); + return sort { $a->name cmp $b->name } values %{$self->{objects}}; +} + +sub exists { + my ($self, $name) = @_; + $self->by_name($name) ? 1 : 0; +} + +sub by_name { + my ($self, $name) = @_; + $self->_load(); + return unless exists $self->{objects}->{$name}; + return $self->{objects}->{$name}; +} + +1; + diff --git a/extensions/Push/lib/Constants.pm b/extensions/Push/lib/Constants.pm new file mode 100644 index 000000000..18b12d511 --- /dev/null +++ b/extensions/Push/lib/Constants.pm @@ -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. + +package Bugzilla::Extension::Push::Constants; + +use strict; +use base 'Exporter'; + +our @EXPORT = qw( + PUSH_RESULT_OK + PUSH_RESULT_IGNORED + PUSH_RESULT_TRANSIENT + PUSH_RESULT_ERROR + PUSH_RESULT_UNKNOWN + push_result_to_string + + POLL_INTERVAL_SECONDS +); + +use constant PUSH_RESULT_OK => 1; +use constant PUSH_RESULT_IGNORED => 2; +use constant PUSH_RESULT_TRANSIENT => 3; +use constant PUSH_RESULT_ERROR => 4; +use constant PUSH_RESULT_UNKNOWN => 5; + +sub push_result_to_string { + my ($result) = @_; + return 'OK' if $result == PUSH_RESULT_OK; + return 'OK-IGNORED' if $result == PUSH_RESULT_IGNORED; + return 'TRANSIENT-ERROR' if $result == PUSH_RESULT_TRANSIENT; + return 'FATAL-ERROR' if $result == PUSH_RESULT_ERROR; + return 'UNKNOWN' if $result == PUSH_RESULT_UNKNOWN; +} + +use constant POLL_INTERVAL_SECONDS => 30; + +1; diff --git a/extensions/Push/lib/Daemon.pm b/extensions/Push/lib/Daemon.pm new file mode 100644 index 000000000..66e15783e --- /dev/null +++ b/extensions/Push/lib/Daemon.pm @@ -0,0 +1,96 @@ +# 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::Push::Daemon; + +use strict; +use warnings; + +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Push; +use Bugzilla::Extension::Push::Logger; +use Carp qw(confess); +use Daemon::Generic; +use File::Basename; +use Pod::Usage; + +sub start { + newdaemon(); +} + +# +# daemon::generic config +# + +sub gd_preconfig { + my $self = shift; + my $pidfile = $self->{gd_args}{pidfile}; + if (!$pidfile) { + $pidfile = bz_locations()->{datadir} . '/' . $self->{gd_progname} . ".pid"; + } + return (pidfile => $pidfile); +} + +sub gd_getopt { + my $self = shift; + $self->SUPER::gd_getopt(); + if ($self->{gd_args}{progname}) { + $self->{gd_progname} = $self->{gd_args}{progname}; + } else { + $self->{gd_progname} = basename($0); + } + $self->{_original_zero} = $0; + $0 = $self->{gd_progname}; +} + +sub gd_postconfig { + my $self = shift; + $0 = delete $self->{_original_zero}; +} + +sub gd_more_opt { + my $self = shift; + return ( + 'pidfile=s' => \$self->{gd_args}{pidfile}, + 'n=s' => \$self->{gd_args}{progname}, + ); +} + +sub gd_usage { + pod2usage({ -verbose => 0, -exitval => 'NOEXIT' }); + return 0; +}; + +sub gd_redirect_output { + my $self = shift; + + my $filename = bz_locations()->{datadir} . '/' . $self->{gd_progname} . ".log"; + open(STDERR, ">>$filename") or (print "could not open stderr: $!" && exit(1)); + close(STDOUT); + open(STDOUT, ">&STDERR") or die "redirect STDOUT -> STDERR: $!"; + $SIG{HUP} = sub { + close(STDERR); + open(STDERR, ">>$filename") or (print "could not open stderr: $!" && exit(1)); + }; +} + +sub gd_setup_signals { + my $self = shift; + $self->SUPER::gd_setup_signals(); + $SIG{TERM} = sub { $self->gd_quit_event(); } +} + +sub gd_run { + my $self = shift; + $::SIG{__DIE__} = \&Carp::confess if $self->{debug}; + my $push = Bugzilla->push_ext; + $push->logger->{debug} = $self->{debug}; + $push->is_daemon(1); + $push->start(); +} + +1; diff --git a/extensions/Push/lib/Log.pm b/extensions/Push/lib/Log.pm new file mode 100644 index 000000000..6faabea97 --- /dev/null +++ b/extensions/Push/lib/Log.pm @@ -0,0 +1,45 @@ +# 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::Push::Log; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Extension::Push::Message; + +sub new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + return $self; +} + +sub count { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + return $dbh->selectrow_array("SELECT COUNT(*) FROM push_log"); +} + +sub list { + my ($self, %args) = @_; + $args{limit} ||= 10; + $args{filter} ||= ''; + my @result; + my $dbh = Bugzilla->dbh; + + my $ids = $dbh->selectcol_arrayref(" + SELECT id + FROM push_log + ORDER BY processed_ts DESC " . + $dbh->sql_limit(100) + ); + return Bugzilla::Extension::Push::LogEntry->new_from_list($ids); +} + +1; diff --git a/extensions/Push/lib/LogEntry.pm b/extensions/Push/lib/LogEntry.pm new file mode 100644 index 000000000..848df0480 --- /dev/null +++ b/extensions/Push/lib/LogEntry.pm @@ -0,0 +1,71 @@ +# 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::Push::LogEntry; + +use strict; +use warnings; + +use base 'Bugzilla::Object'; + +use constant AUDIT_CREATES => 0; +use constant AUDIT_UPDATES => 0; +use constant AUDIT_REMOVES => 0; +use constant USE_MEMCACHED => 0; + +use Bugzilla; +use Bugzilla::Error; +use Bugzilla::Extension::Push::Constants; + +# +# initialisation +# + +use constant DB_TABLE => 'push_log'; +use constant DB_COLUMNS => qw( + id + message_id + change_set + routing_key + connector + push_ts + processed_ts + result + data +); +use constant VALIDATORS => { + data => \&_check_data, +}; +use constant NAME_FIELD => ''; +use constant LIST_ORDER => 'processed_ts DESC'; + +# +# accessors +# + +sub message_id { return $_[0]->{'message_id'}; } +sub change_set { return $_[0]->{'change_set'}; } +sub routing_key { return $_[0]->{'routing_key'}; } +sub connector { return $_[0]->{'connector'}; } +sub push_ts { return $_[0]->{'push_ts'}; } +sub processed_ts { return $_[0]->{'processed_ts'}; } +sub result { return $_[0]->{'result'}; } +sub data { return $_[0]->{'data'}; } + +sub result_string { return push_result_to_string($_[0]->result) } + +# +# validators +# + +sub _check_data { + my ($invocant, $value) = @_; + return $value eq '' ? undef : $value; +} + +1; + diff --git a/extensions/Push/lib/Logger.pm b/extensions/Push/lib/Logger.pm new file mode 100644 index 000000000..68cec1e69 --- /dev/null +++ b/extensions/Push/lib/Logger.pm @@ -0,0 +1,70 @@ +# 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::Push::Logger; + +use strict; +use warnings; + +use Apache2::Log; +use Bugzilla::Extension::Push::Constants; +use Bugzilla::Extension::Push::LogEntry; + +sub new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + return $self; +} + +sub info { shift->_log_it('INFO', @_) } +sub error { shift->_log_it('ERROR', @_) } +sub debug { shift->_log_it('DEBUG', @_) } + +sub debugging { + my ($self) = @_; + return $self->{debug}; +} + +sub _log_it { + my ($self, $method, $message) = @_; + return if $method eq 'DEBUG' && !$self->debugging; + chomp $message; + if ($ENV{MOD_PERL}) { + Apache2::ServerRec::warn("Push $method: $message"); + } elsif ($ENV{SCRIPT_FILENAME}) { + print STDERR "Push $method: $message\n"; + } else { + print STDERR '[' . localtime(time) ."] $method: $message\n"; + } +} + +sub result { + my ($self, $connector, $message, $result, $data) = @_; + $data ||= ''; + + $self->info(sprintf( + "%s: Message #%s: %s %s", + $connector->name, + $message->message_id, + push_result_to_string($result), + $data + )); + + Bugzilla::Extension::Push::LogEntry->create({ + message_id => $message->message_id, + change_set => $message->change_set, + routing_key => $message->routing_key, + connector => $connector->name, + push_ts => $message->push_ts, + processed_ts => Bugzilla->dbh->selectrow_array('SELECT NOW()'), + result => $result, + data => $data, + }); +} + +1; diff --git a/extensions/Push/lib/Message.pm b/extensions/Push/lib/Message.pm new file mode 100644 index 000000000..6d2ed2531 --- /dev/null +++ b/extensions/Push/lib/Message.pm @@ -0,0 +1,104 @@ +# 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::Push::Message; + +use strict; +use warnings; + +use base 'Bugzilla::Object'; + +use constant AUDIT_CREATES => 0; +use constant AUDIT_UPDATES => 0; +use constant AUDIT_REMOVES => 0; +use constant USE_MEMCACHED => 0; + +use Bugzilla; +use Bugzilla::Error; +use Bugzilla::Extension::Push::Util; +use Encode; + +# +# initialisation +# + +use constant DB_TABLE => 'push'; +use constant DB_COLUMNS => qw( + id + push_ts + payload + change_set + routing_key +); +use constant LIST_ORDER => 'push_ts'; +use constant VALIDATORS => { + push_ts => \&_check_push_ts, + payload => \&_check_payload, + change_set => \&_check_change_set, + routing_key => \&_check_routing_key, +}; + +# this creates an object which doesn't exist on the database +sub new_transient { + my $invocant = shift; + my $class = ref($invocant) || $invocant; + my $object = shift; + bless($object, $class) if $object; + return $object; +} + +# take a transient object and commit +sub create_from_transient { + my ($self) = @_; + return $self->create($self); +} + +# +# accessors +# + +sub push_ts { return $_[0]->{'push_ts'}; } +sub payload { return $_[0]->{'payload'}; } +sub change_set { return $_[0]->{'change_set'}; } +sub routing_key { return $_[0]->{'routing_key'}; } +sub message_id { return $_[0]->id; } + +sub payload_decoded { + my ($self) = @_; + return from_json($self->{'payload'}); +} + +# +# validators +# + +sub _check_push_ts { + my ($invocant, $value) = @_; + $value ||= Bugzilla->dbh->selectrow_array('SELECT NOW()'); + return $value; +} + +sub _check_payload { + my ($invocant, $value) = @_; + length($value) || ThrowCodeError('push_invalid_payload'); + return $value; +} + +sub _check_change_set { + my ($invocant, $value) = @_; + (defined($value) && length($value)) || ThrowCodeError('push_invalid_change_set'); + return $value; +} + +sub _check_routing_key { + my ($invocant, $value) = @_; + (defined($value) && length($value)) || ThrowCodeError('push_invalid_routing_key'); + return $value; +} + +1; + diff --git a/extensions/Push/lib/Option.pm b/extensions/Push/lib/Option.pm new file mode 100644 index 000000000..25d529f98 --- /dev/null +++ b/extensions/Push/lib/Option.pm @@ -0,0 +1,66 @@ +# 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::Push::Option; + +use strict; +use warnings; + +use base 'Bugzilla::Object'; + +use Bugzilla; +use Bugzilla::Error; +use Bugzilla::Util; + +# +# initialisation +# + +use constant DB_TABLE => 'push_options'; +use constant DB_COLUMNS => qw( + id + connector + option_name + option_value +); +use constant UPDATE_COLUMNS => qw( + option_value +); +use constant VALIDATORS => { + connector => \&_check_connector, +}; +use constant LIST_ORDER => 'connector'; + +# +# accessors +# + +sub connector { return $_[0]->{'connector'}; } +sub name { return $_[0]->{'option_name'}; } +sub value { return $_[0]->{'option_value'}; } + +# +# mutators +# + +sub set_value { $_[0]->{'option_value'} = $_[1]; } + +# +# validators +# + +sub _check_connector { + my ($invocant, $value) = @_; + $value eq '*' + || $value eq 'global' + || Bugzilla->push_ext->connectors->exists($value) + || ThrowCodeError('push_invalid_connector'); + return $value; +} + +1; + diff --git a/extensions/Push/lib/Push.pm b/extensions/Push/lib/Push.pm new file mode 100644 index 000000000..aaac0bbd6 --- /dev/null +++ b/extensions/Push/lib/Push.pm @@ -0,0 +1,264 @@ +# 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::Push::Push; + +use strict; +use warnings; + +use Bugzilla::Extension::Push::BacklogMessage; +use Bugzilla::Extension::Push::Config; +use Bugzilla::Extension::Push::Connectors; +use Bugzilla::Extension::Push::Constants; +use Bugzilla::Extension::Push::Log; +use Bugzilla::Extension::Push::Logger; +use Bugzilla::Extension::Push::Message; +use Bugzilla::Extension::Push::Option; +use Bugzilla::Extension::Push::Queue; +use Bugzilla::Extension::Push::Util; +use DateTime; + +sub new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + $self->{is_daemon} = 0; + return $self; +} + +sub is_daemon { + my ($self, $value) = @_; + if (defined $value) { + $self->{is_daemon} = $value ? 1 : 0; + } + return $self->{is_daemon}; +} + +sub start { + my ($self) = @_; + my $connectors = $self->connectors; + $self->{config_last_modified} = $self->get_config_last_modified(); + $self->{config_last_checked} = (time); + + foreach my $connector ($connectors->list) { + $connector->backlog->reset_backoff(); + } + + while(1) { + if ($self->_dbh_check()) { + $self->_reload(); + $self->push(); + } + sleep(POLL_INTERVAL_SECONDS); + } +} + +sub push { + my ($self) = @_; + my $logger = $self->logger; + my $connectors = $self->connectors; + + my $enabled = 0; + foreach my $connector ($connectors->list) { + if ($connector->enabled) { + $enabled = 1; + last; + } + } + return unless $enabled; + + $logger->debug("polling"); + + # process each message + while(my $message = $self->queue->oldest) { + foreach my $connector ($connectors->list) { + next unless $connector->enabled; + next unless $connector->should_send($message); + $logger->debug("pushing to " . $connector->name); + + my $is_backlogged = $connector->backlog->count; + + if (!$is_backlogged) { + # connector isn't backlogged, immediate send + $logger->debug("immediate send"); + my ($result, $data); + eval { + ($result, $data) = $connector->send($message); + }; + if ($@) { + $result = PUSH_RESULT_TRANSIENT; + $data = clean_error($@); + } + if (!$result) { + $logger->error($connector->name . " failed to return a result code"); + $result = PUSH_RESULT_UNKNOWN; + } + $logger->result($connector, $message, $result, $data); + + if ($result == PUSH_RESULT_TRANSIENT) { + $is_backlogged = 1; + } + } + + # if the connector is backlogged, push to the backlog queue + if ($is_backlogged) { + $logger->debug("backlogged"); + my $backlog = Bugzilla::Extension::Push::BacklogMessage->create_from_message($message, $connector); + } + } + + # message processed + $message->remove_from_db(); + } + + # process backlog + foreach my $connector ($connectors->list) { + next unless $connector->enabled; + my $message = $connector->backlog->oldest(); + next unless $message; + + $logger->debug("processing backlog for " . $connector->name); + while ($message) { + my ($result, $data); + eval { + ($result, $data) = $connector->send($message); + }; + if ($@) { + $result = PUSH_RESULT_TRANSIENT; + $data = $@; + } + $message->inc_attempts($result == PUSH_RESULT_OK ? '' : $data); + if (!$result) { + $logger->error($connector->name . " failed to return a result code"); + $result = PUSH_RESULT_UNKNOWN; + } + $logger->result($connector, $message, $result, $data); + + if ($result == PUSH_RESULT_TRANSIENT) { + # connector is still down, stop trying + $connector->backlog->inc_backoff(); + last; + } + + # message was processed + $message->remove_from_db(); + + $message = $connector->backlog->oldest(); + } + } +} + +sub _reload { + my ($self) = @_; + + # check for updated config every 60 seconds + my $now = (time); + if ($now - $self->{config_last_checked} < 60) { + return; + } + $self->{config_last_checked} = $now; + + $self->logger->debug('Checking for updated configuration'); + if ($self->get_config_last_modified eq $self->{config_last_modified}) { + return; + } + $self->{config_last_modified} = $self->get_config_last_modified(); + + $self->logger->debug('Configuration has been updated'); + $self->connectors->reload(); +} + +sub get_config_last_modified { + my ($self) = @_; + my $options_list = Bugzilla::Extension::Push::Option->match({ + connector => '*', + option_name => 'last-modified', + }); + if (@$options_list) { + return $options_list->[0]->value; + } else { + return $self->set_config_last_modified(); + } +} + +sub set_config_last_modified { + my ($self) = @_; + my $options_list = Bugzilla::Extension::Push::Option->match({ + connector => '*', + option_name => 'last-modified', + }); + my $now = DateTime->now->datetime(); + if (@$options_list) { + $options_list->[0]->set_value($now); + $options_list->[0]->update(); + } else { + Bugzilla::Extension::Push::Option->create({ + connector => '*', + option_name => 'last-modified', + option_value => $now, + }); + } + return $now; +} + +sub config { + my ($self) = @_; + if (!$self->{config}) { + $self->{config} = Bugzilla::Extension::Push::Config->new( + 'global', + { + name => 'log_purge', + label => 'Purge logs older than (days)', + type => 'string', + default => '7', + required => '1', + validate => sub { $_[0] =~ /\D/ && die "Invalid purge duration (must be numeric)\n"; }, + }, + ); + $self->{config}->load(); + } + return $self->{config}; +} + +sub logger { + my ($self, $value) = @_; + $self->{logger} = $value if $value; + return $self->{logger}; +} + +sub connectors { + my ($self, $value) = @_; + $self->{connectors} = $value if $value; + return $self->{connectors}; +} + +sub queue { + my ($self) = @_; + $self->{queue} ||= Bugzilla::Extension::Push::Queue->new(); + return $self->{queue}; +} + +sub log { + my ($self) = @_; + $self->{log} ||= Bugzilla::Extension::Push::Log->new(); + return $self->{log}; +} + +sub _dbh_check { + my ($self) = @_; + eval { + Bugzilla->dbh->selectrow_array("SELECT 1 FROM push"); + }; + if ($@) { + $self->logger->error(clean_error($@)); + return 0; + } else { + return 1; + } +} + +1; diff --git a/extensions/Push/lib/Queue.pm b/extensions/Push/lib/Queue.pm new file mode 100644 index 000000000..d89cb23c3 --- /dev/null +++ b/extensions/Push/lib/Queue.pm @@ -0,0 +1,72 @@ +# 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::Push::Queue; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Extension::Push::Message; + +sub new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + return $self; +} + +sub count { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + return $dbh->selectrow_array("SELECT COUNT(*) FROM push"); +} + +sub oldest { + my ($self) = @_; + my @messages = $self->list(limit => 1); + return scalar(@messages) ? $messages[0] : undef; +} + +sub by_id { + my ($self, $id) = @_; + my @messages = $self->list( + limit => 1, + filter => "AND (push.id = $id)", + ); + return scalar(@messages) ? $messages[0] : undef; +} + +sub list { + my ($self, %args) = @_; + $args{limit} ||= 10; + $args{filter} ||= ''; + my @result; + my $dbh = Bugzilla->dbh; + + my $sth = $dbh->prepare(" + SELECT id, push_ts, payload, change_set, routing_key + FROM push + WHERE (1 = 1) " . + $args{filter} . " + ORDER BY push_ts " . + $dbh->sql_limit($args{limit}) + ); + $sth->execute(); + while (my $row = $sth->fetchrow_hashref()) { + push @result, Bugzilla::Extension::Push::Message->new({ + id => $row->{id}, + push_ts => $row->{push_ts}, + payload => $row->{payload}, + change_set => $row->{change_set}, + routing_key => $row->{routing_key}, + }); + } + return @result; +} + +1; diff --git a/extensions/Push/lib/Serialise.pm b/extensions/Push/lib/Serialise.pm new file mode 100644 index 000000000..94f33c754 --- /dev/null +++ b/extensions/Push/lib/Serialise.pm @@ -0,0 +1,318 @@ +# 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::Push::Serialise; + +use strict; +use warnings; + +use Bugzilla::Constants; +use Bugzilla::Extension::Push::Util; +use Bugzilla::Version; + +use Scalar::Util 'blessed'; +use JSON (); + +my $_instance; +sub instance { + $_instance ||= Bugzilla::Extension::Push::Serialise->_new(); + return $_instance; +} + +sub _new { + my ($class) = @_; + my $self = {}; + bless($self, $class); + return $self; +} + +# given an object, serliase to a hash +sub object_to_hash { + my ($self, $object, $is_shallow) = @_; + + my $method = lc(blessed($object)); + $method =~ s/::/_/g; + $method =~ s/^bugzilla//; + return unless $self->can($method); + (my $name = $method) =~ s/^_//; + + # check for a cached hash + my $cache = Bugzilla->request_cache; + my $cache_id = "push." . ($is_shallow ? 'shallow.' : 'deep.') . $object; + if (exists($cache->{$cache_id})) { + return wantarray ? ($cache->{$cache_id}, $name) : $cache->{$cache_id}; + } + + # call the right method to serialise to a hash + my $rh = $self->$method($object, $is_shallow); + + # store in cache + if ($cache_id) { + $cache->{$cache_id} = $rh; + } + + return wantarray ? ($rh, $name) : $rh; +} + +# given a changes hash, return an event hash +sub changes_to_event { + my ($self, $changes) = @_; + + my $event = {}; + + # create common (created and modified) fields + $event->{'user'} = $self->object_to_hash(Bugzilla->user); + my $timestamp = + $changes->{'timestamp'} + || Bugzilla->dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); + $event->{'time'} = datetime_to_timestamp($timestamp); + + foreach my $change (@{$changes->{'changes'}}) { + if (exists $change->{'field'}) { + # map undef to emtpy + hash_undef_to_empty($change); + + # custom_fields change from undef to empty, ignore these changes + return if ($change->{'added'} || "") eq "" && + ($change->{'removed'} || "") eq ""; + + # use saner field serialisation + my $field = $change->{'field'}; + $change->{'field'} = $field; + + if ($field eq 'priority' || $field eq 'target_milestone') { + $change->{'added'} = _select($change->{'added'}); + $change->{'removed'} = _select($change->{'removed'}); + + } elsif ($field =~ /^cf_/) { + $change->{'added'} = _custom_field($field, $change->{'added'}); + $change->{'removed'} = _custom_field($field, $change->{'removed'}); + } + + $event->{'changes'} = [] unless exists $event->{'changes'}; + push @{$event->{'changes'}}, $change; + } + } + + return $event; +} + +# bugzilla returns '---' or '--' for single-select fields that have no value +# selected. it makes more sense to return an empty string. +sub _select { + my ($value) = @_; + return '' if $value eq '---' or $value eq '--'; + return $value; +} + +# return an object which serialises to a json boolean, but still acts as a perl +# boolean +sub _boolean { + my ($value) = @_; + return $value ? JSON::true : JSON::false; +} + +sub _string { + my ($value) = @_; + return defined($value) ? $value : ''; +} + +sub _time { + my ($value) = @_; + return defined($value) ? datetime_to_timestamp($value) : undef; +} + +sub _integer { + my ($value) = @_; + return defined($value) ? $value + 0 : undef; +} + +sub _array { + my ($value) = @_; + return defined($value) ? $value : []; +} + +sub _custom_field { + my ($field, $value) = @_; + $field = Bugzilla::Field->new({ name => $field }) unless blessed $field; + + if ($field->type == FIELD_TYPE_DATETIME) { + return _time($value); + + } elsif ($field->type == FIELD_TYPE_SINGLE_SELECT) { + return _select($value); + + } elsif ($field->type == FIELD_TYPE_MULTI_SELECT) { + return _array($value); + + } else { + return _string($value); + } +} + +# +# class mappings +# automatically derrived from the class name +# Bugzilla::Bug --> _bug, Bugzilla::User --> _user, etc +# + +sub _bug { + my ($self, $bug) = @_; + + my $version = $bug->can('version_obj') + ? $bug->version_obj + : Bugzilla::Version->new({ name => $bug->version, product => $bug->product_obj }); + + my $milestone; + if (_select($bug->target_milestone) ne '') { + $milestone = $bug->can('target_milestone_obj') + ? $bug->target_milestone_obj + : Bugzilla::Milestone->new({ name => $bug->target_milestone, product => $bug->product_obj }); + } + + my $status = $bug->can('status_obj') + ? $bug->status_obj + : Bugzilla::Status->new({ name => $bug->bug_status }); + + my $rh = { + id => _integer($bug->bug_id), + alias => _string($bug->alias), + assigned_to => $self->_user($bug->assigned_to), + classification => _string($bug->classification), + component => $self->_component($bug->component_obj), + creation_time => _time($bug->creation_ts || $bug->delta_ts), + flags => (mapr { $self->_flag($_) } $bug->flags), + is_private => _boolean(!is_public($bug)), + keywords => (mapr { _string($_->name) } $bug->keyword_objects), + last_change_time => _time($bug->delta_ts), + operating_system => _string($bug->op_sys), + platform => _string($bug->rep_platform), + priority => _select($bug->priority), + product => $self->_product($bug->product_obj), + qa_contact => $self->_user($bug->qa_contact), + reporter => $self->_user($bug->reporter), + resolution => _string($bug->resolution), + severity => _string($bug->bug_severity), + status => $self->_status($status), + summary => _string($bug->short_desc), + target_milestone => $self->_milestone($milestone), + url => _string($bug->bug_file_loc), + version => $self->_version($version), + whiteboard => _string($bug->status_whiteboard), + }; + + # add custom fields + my @custom_fields = Bugzilla->active_custom_fields( + { product => $bug->product_obj, component => $bug->component_obj }); + foreach my $field (@custom_fields) { + my $name = $field->name; + $rh->{$name} = _custom_field($field, $bug->$name); + } + + return $rh; +} + +sub _user { + my ($self, $user) = @_; + return undef unless $user; + return { + id => _integer($user->id), + login => _string($user->login), + real_name => _string($user->name), + }; +} + +sub _component { + my ($self, $component) = @_; + return { + id => _integer($component->id), + name => _string($component->name), + }; +} + +sub _attachment { + my ($self, $attachment, $is_shallow) = @_; + my $rh = { + id => _integer($attachment->id), + content_type => _string($attachment->contenttype), + creation_time => _time($attachment->attached), + description => _string($attachment->description), + file_name => _string($attachment->filename), + flags => (mapr { $self->_flag($_) } $attachment->flags), + is_obsolete => _boolean($attachment->isobsolete), + is_patch => _boolean($attachment->ispatch), + is_private => _boolean(!is_public($attachment)), + last_change_time => _time($attachment->modification_time), + }; + if (!$is_shallow) { + $rh->{bug} = $self->_bug($attachment->bug); + } + return $rh; +} + +sub _comment { + my ($self, $comment, $is_shallow) = @_; + my $rh = { + id => _integer($comment->bug_id), + body => _string($comment->body), + creation_time => _time($comment->creation_ts), + is_private => _boolean($comment->is_private), + number => _integer($comment->count), + }; + if (!$is_shallow) { + $rh->{bug} = $self->_bug($comment->bug); + } + return $rh; +} + +sub _product { + my ($self, $product) = @_; + return { + id => _integer($product->id), + name => _string($product->name), + }; +} + +sub _flag { + my ($self, $flag) = @_; + my $rh = { + id => _integer($flag->id), + name => _string($flag->type->name), + value => _string($flag->status), + }; + if ($flag->requestee) { + $rh->{'requestee'} = $self->_user($flag->requestee); + } + return $rh; +} + +sub _version { + my ($self, $version) = @_; + return { + id => _integer($version->id), + name => _string($version->name), + }; +} + +sub _milestone { + my ($self, $milestone) = @_; + return undef unless $milestone; + return { + id => _integer($milestone->id), + name => _string($milestone->name), + }; +} + +sub _status { + my ($self, $status) = @_; + return { + id => _integer($status->id), + name => _string($status->name), + }; +} + +1; diff --git a/extensions/Push/lib/Util.pm b/extensions/Push/lib/Util.pm new file mode 100644 index 000000000..f52db6936 --- /dev/null +++ b/extensions/Push/lib/Util.pm @@ -0,0 +1,162 @@ +# 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::Push::Util; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Util qw(datetime_from trim); +use Data::Dumper; +use Encode; +use JSON (); +use Scalar::Util qw(blessed); +use Time::HiRes; + +use base qw(Exporter); +our @EXPORT = qw( + datetime_to_timestamp + debug_dump + get_first_value + hash_undef_to_empty + is_public + mapr + clean_error + change_set_id + canon_email + to_json from_json +); + +# returns true if the specified object is public +sub is_public { + my ($object) = @_; + + my $default_user = Bugzilla::User->new(); + + if ($object->isa('Bugzilla::Bug')) { + return unless $default_user->can_see_bug($object->bug_id); + return 1; + + } elsif ($object->isa('Bugzilla::Comment')) { + return if $object->is_private; + return unless $default_user->can_see_bug($object->bug_id); + return 1; + + } elsif ($object->isa('Bugzilla::Attachment')) { + return if $object->isprivate; + return unless $default_user->can_see_bug($object->bug_id); + return 1; + + } else { + warn "Unsupported class " . blessed($object) . " passed to is_public()\n"; + } + + return 1; +} + +# return the first existing value from the hashref for the given list of keys +sub get_first_value { + my ($rh, @keys) = @_; + foreach my $field (@keys) { + return $rh->{$field} if exists $rh->{$field}; + } + return; +} + +# wrapper for map that works on array references +sub mapr(&$) { + my ($filter, $ra) = @_; + my @result = map(&$filter, @$ra); + return \@result; +} + + +# convert datetime string (from db) to a UTC json friendly datetime +sub datetime_to_timestamp { + my ($datetime_string) = @_; + return '' unless $datetime_string; + return datetime_from($datetime_string, 'UTC')->datetime(); +} + +# replaces all undef values in a hashref with an empty string (deep) +sub hash_undef_to_empty { + my ($rh) = @_; + foreach my $key (keys %$rh) { + my $value = $rh->{$key}; + if (!defined($value)) { + $rh->{$key} = ''; + } elsif (ref($value) eq 'HASH') { + hash_undef_to_empty($value); + } + } +} + +# debugging methods +sub debug_dump { + my ($object) = @_; + local $Data::Dumper::Sortkeys = 1; + my $output = Dumper($object); + $output =~ s/</</g; + print "<pre>$output</pre>"; +} + +# removes stacktrace and "at /some/path ..." from errors +sub clean_error { + my ($error) = @_; + my $path = bz_locations->{'extensionsdir'}; + $error = $1 if $error =~ /^(.+?) at \Q$path/s; + $path = '/loader/0x'; + $error = $1 if $error =~ /^(.+?) at \Q$path/s; + $error =~ s/(^\s+|\s+$)//g; + return $error; +} + +# generate a new change_set id +sub change_set_id { + return "$$." . Time::HiRes::time(); +} + +# remove guff from email addresses +sub clean_email { + my $email = shift; + $email = trim($email); + $email = $1 if $email =~ /^(\S+)/; + $email =~ s/@/@/; + $email = lc $email; + return $email; +} + +# resolve to canonised email form +# eg. glob+bmo@mozilla.com --> glob@mozilla.com +sub canon_email { + my $email = shift; + $email = clean_email($email); + $email =~ s/^([^\+]+)\+[^\@]+(\@.+)$/$1$2/; + return $email; +} + +# json helpers +sub to_json { + my ($object, $pretty) = @_; + if ($pretty) { + return decode('utf8', JSON->new->utf8(1)->pretty(1)->encode($object)); + } else { + return JSON->new->ascii(1)->shrink(1)->encode($object); + } +} + +sub from_json { + my ($json) = @_; + if (utf8::is_utf8($json)) { + $json = encode('utf8', $json); + } + return JSON->new->utf8(1)->decode($json); +} + +1; diff --git a/extensions/Push/t/ReviewBoard.t b/extensions/Push/t/ReviewBoard.t new file mode 100644 index 000000000..f2a508f59 --- /dev/null +++ b/extensions/Push/t/ReviewBoard.t @@ -0,0 +1,224 @@ +#!/usr/bin/perl -T +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. +use strict; +use warnings; +use lib qw( . lib ); + +use Test::More; +use Bugzilla; +use Bugzilla::Extension; +use Bugzilla::Attachment; +use Scalar::Util 'blessed'; +use YAML; + +BEGIN { + eval { + require Test::LWP::UserAgent; + require Test::MockObject; + }; + if ($@) { + plan skip_all => + 'Tests require Test::LWP::UserAgent and Test::MockObject'; + exit; + } +} + +BEGIN { + Bugzilla->extensions; # load all of them + use_ok 'Bugzilla::Extension::Push::Connector::ReviewBoard::Client'; + use_ok 'Bugzilla::Extension::Push::Constants'; +} + +my ($push) = grep { blessed($_) eq 'Bugzilla::Extension::Push' } @{Bugzilla->extensions }; +my $connectors = $push->_get_instance->connectors; +my $con = $connectors->by_name('ReviewBoard'); + +my $ua_204 = Test::LWP::UserAgent->new; +$ua_204->map_response( + qr{https://reviewboard-dev\.allizom\.org/api/review-requests/\d+}, + HTTP::Response->new('204')); + +my $ua_404 = Test::LWP::UserAgent->new; +$ua_404->map_response( + qr{https://reviewboard-dev\.allizom\.org/api/review-requests/\d+}, + HTTP::Response->new('404', undef, undef, q[{ "err": { "code": 100, "msg": "Object does not exist" }, "stat": "fail" }])); + +# forbidden +my $ua_403 = Test::LWP::UserAgent->new; +$ua_403->map_response( + qr{https://reviewboard-dev\.allizom\.org/api/review-requests/\d+}, + HTTP::Response->new('403', undef, undef, q[ {"err":{"code":101,"msg":"You don't have permission for this"},"stat":"fail"}])); + +# not logged in +my $ua_401 = Test::LWP::UserAgent->new; +$ua_401->map_response( + qr{https://reviewboard-dev\.allizom\.org/api/review-requests/\d+}, + HTTP::Response->new('401', undef, undef, q[ { "err": { "code": 103, "msg": "You are not logged in" }, "stat": "fail" } ])); + +# not logged in +my $ua_500 = Test::LWP::UserAgent->new; +$ua_500->map_response( + qr{https://reviewboard-dev\.allizom\.org/api/review-requests/\d+}, + HTTP::Response->new('500')); + +$con->client->{useragent} = $ua_204; +$con->config->{base_uri} = 'https://reviewboard-dev.allizom.org'; +$con->client->{base_uri} = 'https://reviewboard-dev.allizom.org'; + +{ + my $msg = message( + event => { + routing_key => 'attachment.modify:is_private', + target => 'attachment', + }, + attachment => { + is_private => 1, + content_type => 'text/plain', + bug => { id => 1, is_private => 0 }, + }, + ); + + ok(not($con->should_send($msg)), "text/plain message should not be sent"); +} + +my $data = slurp("extensions/Push/t/rblink.txt"); +Bugzilla::User::DEFAULT_USER->{userid} = 42; +Bugzilla->set_user(Bugzilla::User->super_user); +diag " " . Bugzilla::User->super_user->id; + +my $dbh = Bugzilla->dbh; +$dbh->bz_start_transaction; +my $timestamp = $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); +my $bug = Bugzilla::Bug->new({id => 9000}); +my $attachment = Bugzilla::Attachment->create( + { bug => $bug, + creation_ts => $timestamp, + data => $data, + filesize => length $data, + description => "rblink.txt", + filename => "rblink.txt", + isprivate => 1, ispatch => 0, + mimetype => 'text/x-review-board-request'}); +diag "".$attachment->id; +$dbh->bz_commit_transaction; + +{ + my $msg = message( + event => { + routing_key => 'attachment.modify:cc,is_private', + target => 'attachment', + }, + attachment => { + id => $attachment->id, + is_private => 1, + content_type => 'text/x-review-board-request', + bug => { id => $bug->id, is_private => 0 }, + }, + ); + ok($con->should_send($msg), "rb attachment should be sent"); + + { + my ($rv, $err) = $con->send($msg); + is($rv, PUSH_RESULT_OK, "good push result"); + diag $err if $err; + } + + { + local $con->client->{useragent} = $ua_404; + my ($rv, $err) = $con->send($msg); + is($rv, PUSH_RESULT_OK, "good push result for 404"); + diag $err if $err; + } + + + { + local $con->client->{useragent} = $ua_403; + my ($rv, $err) = $con->send($msg); + is($rv, PUSH_RESULT_TRANSIENT, "transient error on 403"); + diag $err if $err; + } + + + { + local $con->client->{useragent} = $ua_401; + my ($rv, $err) = $con->send($msg); + is($rv, PUSH_RESULT_TRANSIENT, "transient error on 401"); + diag $err if $err; + } + + { + local $con->client->{useragent} = $ua_500; + my ($rv, $err) = $con->send($msg); + is($rv, PUSH_RESULT_TRANSIENT, "transient error on 500"); + diag $err if $err; + } +} + +{ + my $msg = message( + event => { + routing_key => 'bug.modify:is_private', + target => 'bug', + }, + bug => { + is_private => 1, + id => $bug->id, + }, + ); + + ok($con->should_send($msg), "rb attachment should be sent"); + my ($rv, $err) = $con->send($msg); + is($rv, PUSH_RESULT_OK, "good push result"); + + { + local $con->client->{useragent} = $ua_404; + my ($rv, $err) = $con->send($msg); + is($rv, PUSH_RESULT_OK, "good push result for 404"); + } + + { + local $con->client->{useragent} = $ua_403; + my ($rv, $err) = $con->send($msg); + is($rv, PUSH_RESULT_TRANSIENT, "transient error on 404"); + diag $err if $err; + } + + + { + local $con->client->{useragent} = $ua_401; + my ($rv, $err) = $con->send($msg); + is($rv, PUSH_RESULT_TRANSIENT, "transient error on 401"); + diag $err if $err; + } + + { + local $con->client->{useragent} = $ua_401; + my ($rv, $err) = $con->send($msg); + is($rv, PUSH_RESULT_TRANSIENT, "transient error on 401"); + diag $err if $err; + } +} + +sub message { + my $msg_data = { @_ }; + + return Test::MockObject->new + ->set_always( routing_key => $msg_data->{event}{routing_key} ) + ->set_always( payload_decoded => $msg_data ); +} + +sub slurp { + my $file = shift; + local $/ = undef; + open my $fh, '<', $file or die "unable to open $file"; + my $s = readline $fh; + close $fh; + return $s; +} + +done_testing; diff --git a/extensions/Push/template/en/default/hook/admin/admin-end_links_right.html.tmpl b/extensions/Push/template/en/default/hook/admin/admin-end_links_right.html.tmpl new file mode 100644 index 000000000..78e314ab2 --- /dev/null +++ b/extensions/Push/template/en/default/hook/admin/admin-end_links_right.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 user.in_group('admin') %] + <dt id="push"> + Push + </dt> + <dd> + <a href="page.cgi?id=push_config.html">Configuration</a><br> + <a href="page.cgi?id=push_queues.html">Queues</a><br> + <a href="page.cgi?id=push_log.html">Log</a><br> + </dd> +[% END %] diff --git a/extensions/Push/template/en/default/hook/global/code-error-errors.html.tmpl b/extensions/Push/template/en/default/hook/global/code-error-errors.html.tmpl new file mode 100644 index 000000000..515f00fa8 --- /dev/null +++ b/extensions/Push/template/en/default/hook/global/code-error-errors.html.tmpl @@ -0,0 +1,25 @@ +[%# 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 == "push_invalid_payload" %] + [% title = "Invalid payload" %] + An invalid or empty payload was passed to Push. + +[% ELSIF error == "push_invalid_change_set" %] + [% title = "Invalid change_set" %] + An invalid or empty change_set was passed to Push. + +[% ELSIF error == "push_invalid_routing_key" %] + [% title = "Invalid routing_key" %] + An invalid or empty routing_key was passed to Push. + +[% ELSIF error == "push_invalid_connector" %] + [% title = "Invalid connector" %] + An invalid connector was passed to Push. + +[% END %] diff --git a/extensions/Push/template/en/default/hook/global/messages-messages.html.tmpl b/extensions/Push/template/en/default/hook/global/messages-messages.html.tmpl new file mode 100644 index 000000000..e4a016aee --- /dev/null +++ b/extensions/Push/template/en/default/hook/global/messages-messages.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. + #%] + +[% IF message_tag == "push_config_updated" %] + Changes to the configuration have been saved. + Please allow up to 60 seconds for the change to be active. + +[% ELSIF message_tag == "push_message_deleted" %] + The message has been deleted. + +[% END %] diff --git a/extensions/Push/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/Push/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..2b8a1c4e0 --- /dev/null +++ b/extensions/Push/template/en/default/hook/global/user-error-errors.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 error == "push_error" %] + [% error_message FILTER html %] +[% END %] diff --git a/extensions/Push/template/en/default/pages/push_config.html.tmpl b/extensions/Push/template/en/default/pages/push_config.html.tmpl new file mode 100644 index 000000000..6e6507a39 --- /dev/null +++ b/extensions/Push/template/en/default/pages/push_config.html.tmpl @@ -0,0 +1,134 @@ +[%# 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/header.html.tmpl + title = "Push Administration: Configuration" + javascript_urls = [ 'extensions/Push/web/admin.js' ] + style_urls = [ 'extensions/Push/web/admin.css' ] +%] + +<script> +var push_defaults = new Array(); +[% FOREACH option = push.config.options %] + [% IF option.name != 'enabled' && option.default != '' %] + push_defaults['global_[% option.name FILTER js %]'] = '[% option.default FILTER js %]'; + [% END %] +[% END %] +[% FOREACH connector = connectors.list %] + [% FOREACH option = connector.config.options %] + [% IF option.name != 'enabled' && option.default != '' %] + push_defaults['[% connector.name FILTER js %]_[% option.name FILTER js %]'] = '[% option.default FILTER js %]'; + [% END %] + [% END %] +[% END %] +</script> + +<form method="POST" action="page.cgi"> +<input type="hidden" name="id" value="push_config.html"> +<input type="hidden" name="save" value="1"> + +<table border="0" cellspacing="0" cellpadding="5" width="100%"> + +[% PROCESS options + name = 'global', + config = push.config +%] + +[% FOREACH connector = connectors.list %] + [% PROCESS options + name = connector.name + config = connector.config + %] +[% END %] + +<tr> + <td> </td> + <td colspan="2"><hr></td> +</tr> + +<tr> + <td> </td> + <td colspan="2"> + <input type="submit" value="Submit Changes"> + <input type="submit" value="Reset to Defaults" onclick="reset_to_defaults(); return false"> + </td> +</tr> + + +<tr> + <td style="min-width: 10em"> </td> + <td> </td> + <td width="100%"> </td> +</tr> + +</table> + +</form> + +[% INCLUDE global/footer.html.tmpl %] + +[% BLOCK options %] + <tr class="connector"> + <th>[% name FILTER ucfirst FILTER html %]</th> + <td colspan="2"><hr></td> + </tr> + [% FOREACH option = config.options %] + [% class = name _ '_tr' IF option.name != 'enabled' %] + <tr class="[% class FILTER html %] option"> + <th> + [% IF option.required %] + <span class="required_option" title="Mandatory option">*</span> + [% END %] + [% option.label FILTER html %] + </th> + <td> + [% IF option.type == 'string' %] + <input type="text" name="[% name FILTER html %].[% option.name FILTER html %]" + value="[% config.${option.name} FILTER html %]" size="60" + id="[% name FILTER html %]_[% option.name FILTER html %]"> + + [% ELSIF option.type == 'password' %] + <input type="password" name="[% name FILTER html %].[% option.name FILTER html %]" + value="[% config.${option.name} FILTER html %]" size="60" + id="[% name FILTER html %]_[% option.name FILTER html %]"> + + [% ELSIF option.type == 'select' %] + <select name="[% name FILTER html %].[% option.name FILTER html %]" + id="[% name FILTER html %]_[% option.name FILTER html %]" + [% IF option.name == 'enabled' && name != 'global' %] + onchange="toggle_options(this.value == 'Enabled', '[% name FILTER js %]')" + [% END %] + > + [% IF option.name != 'enabled' && !option.required %] + <option value=""" + [% ' selected' IF config.${option.name} == "" %]></option> + [% END %] + [% FOREACH value = option.values %] + <option value="[% value FILTER html %]" + [% ' selected' IF config.${option.name} == value %]>[% value FILTER html %]</option> + [% END %] + </select> + + [% ELSE %] + unsupported option type '[% option.type FILTER html %]' + [% END %] + </td> + [% IF option.help %] + <td class="help">[% option.help FILTER html %]</td> + [% ELSE %] + <td> </td> + [% END %] + </tr> + [% END %] + [% IF name != 'global' %] + <script> + var is_enabled = document.getElementById('[% name FILTER js %]_enabled').value == 'Enabled'; + toggle_options(is_enabled, '[% name FILTER js %]'); + </script> + [% END %] +[% END %] diff --git a/extensions/Push/template/en/default/pages/push_log.html.tmpl b/extensions/Push/template/en/default/pages/push_log.html.tmpl new file mode 100644 index 000000000..a51cb22cf --- /dev/null +++ b/extensions/Push/template/en/default/pages/push_log.html.tmpl @@ -0,0 +1,45 @@ +[%# 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/header.html.tmpl + title = "Push Administration: Logs" + javascript_urls = [ 'extensions/Push/web/admin.js' ] + style_urls = [ 'extensions/Push/web/admin.css' ] +%] +[% logs = push.log %] + +<table id="report" cellspacing="0"> + +[% IF logs.count %] + <tr class="report-subheader"> + <th nowrap>Connector</th> + <th nowrap>Event Timestamp</th> + <th nowrap>Processed Timestamp</th> + <th nowrap>Status</th> + <th nowrap>Message</th> + </tr> +[% END %] + +[% FOREACH log = logs.list %] + <tr class="row [% loop.count % 2 == 1 ? "report_row_odd" : "report_row_even" %]"> + <td nowrap>[% log.connector FILTER html %]</td> + <td nowrap>[% log.push_ts FILTER time FILTER html %]</td> + <td nowrap>[% log.processed_ts FILTER time FILTER html %]</td> + <td nowrap>[% log.result_string FILTER html %]</td> + <td>[% log.data FILTER html %]</td> + </tr> +[% END %] + +<tr> + <td colspan="5"> </td> +</tr> + +</table> + +[% INCLUDE global/footer.html.tmpl %] + diff --git a/extensions/Push/template/en/default/pages/push_queues.html.tmpl b/extensions/Push/template/en/default/pages/push_queues.html.tmpl new file mode 100644 index 000000000..d1985c89a --- /dev/null +++ b/extensions/Push/template/en/default/pages/push_queues.html.tmpl @@ -0,0 +1,102 @@ +[%# 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/header.html.tmpl + title = "Push Administration: Queues" + javascript_urls = [ 'extensions/Push/web/admin.js' ] + style_urls = [ 'extensions/Push/web/admin.css' ] +%] + +<table id="report" cellspacing="0"> + +[% PROCESS show_queue + queue = push.queue + title = 'Pending' + pending = 1 +%] + +[% FOREACH connector = push.connectors.list %] + [% NEXT UNLESS connector.enabled %] + [% PROCESS show_queue + queue = connector.backlog + title = connector.name _ ' Backlog' + pending = 0 + %] +[% END %] + +</table> + +[% INCLUDE global/footer.html.tmpl %] + +[% BLOCK show_queue %] + [% count = queue.count %] + <tr class="report-header"> + <th colspan="2"> + [% title FILTER html %] Queue ([% count FILTER html %]) + </th> + [% IF queue.backoff && count %] + <th class="rhs" colspan="5"> + Next Attempt: [% queue.backoff.next_attempt_ts FILTER time %] + </th> + [% ELSE %] + <th colspan="5"> </td> + [% END %] + </tr> + + [% IF count %] + <tr class="report-subheader"> + <th nowrap>Timestamp</th> + <th nowrap>Change Set</th> + [% IF pending %] + <th nowrap colspan="4">Routing Key</th> + [% ELSE %] + <th nowrap>Routing Key</th> + <th nowrap>Last Attempt</th> + <th nowrap>Attempts</th> + <th nowrap>Last Error</th> + [% END %] + <th> </th> + </tr> + [% END %] + + [% FOREACH message = queue.list('limit', 10) %] + <tr class="row [% loop.count % 2 == 1 ? "report_row_odd" : "report_row_even" %]"> + <td nowrap>[% message.push_ts FILTER html %]</td> + <td nowrap>[% message.change_set FILTER html %]</td> + [% IF pending %] + <td nowrap colspan="4">[% message.routing_key FILTER html %]</td> + [% ELSE %] + <td nowrap>[% message.routing_key FILTER html %]</td> + [% IF message.attempt_ts %] + <td nowrap>[% message.attempt_ts FILTER time %]</td> + <td nowrap>[% message.attempts FILTER html %]</td> + <td width="100%"> + [% IF message.last_error.length > 40 %] + [% last_error = message.last_error.substr(0, 40) _ '...' %] + [% ELSE %] + [% last_error = message.last_error %] + [% END %] + [% last_error FILTER html %]</td> + [% ELSE %] + <td>-</td> + <td>-</td> + <td width="100%">-</td> + [% END %] + [% END %] + <td class="rhs"> + <a href="?id=push_queues_view.html&[% ~%] + message=[% message.id FILTER uri %]&[% ~%] + connector=[% queue.connector FILTER uri %]">View</a> + </td> + </tr> + [% END %] + + <tr> + <td colspan="7"> </td> + </tr> +[% END %] diff --git a/extensions/Push/template/en/default/pages/push_queues_view.html.tmpl b/extensions/Push/template/en/default/pages/push_queues_view.html.tmpl new file mode 100644 index 000000000..6330d8ae4 --- /dev/null +++ b/extensions/Push/template/en/default/pages/push_queues_view.html.tmpl @@ -0,0 +1,80 @@ +[%# 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/header.html.tmpl + title = "Push Administration: Queues: Payload" + javascript_urls = [ 'extensions/Push/web/admin.js' ] + style_urls = [ 'extensions/Push/web/admin.css' ] +%] + +[% IF !message_obj %] + <a href="?id=push_queues.html">Return</a> + [% RETURN %] +[% END %] + +<table id="report" cellspacing="0"> + +<tr> + <th class="report-header" nowrap>Connector</th> + <td width="100%">[% message_obj.connector || '-' FILTER html %]</td> +</tr> +<tr> + <th class="report-header" nowrap>Message ID</th> + <td width="100%">[% message_obj.message_id FILTER html %]</td> +</tr> +<tr> + <th class="report-header" nowrap>Push Time</th> + <td width="100%">[% message_obj.push_ts FILTER time FILTER html %]</td> +</tr> +<tr> + <th class="report-header" nowrap>Change Set</th> + <td width="100%">[% message_obj.change_set FILTER html %]</td> +</tr> +<tr> + <th class="report-header" nowrap>Routing Key</th> + <td width="100%">[% message_obj.routing_key FILTER html %]</td> +</tr> + +[% IF message_obj.attempts %] + <tr> + <th class="report-header" nowrap>Attempts</th> + <td width="100%">[% message_obj.attempts FILTER html %]</td> + </tr> + <tr> + <th class="report-header" nowrap>Last Attempt Time</th> + <td width="100%">[% message_obj.attempt_ts FILTER time FILTER html %]</td> + </tr> + <tr> + <th class="report-header" nowrap>Last Error</th> + <td width="100%"><b>[% message_obj.last_error FILTER html %]</b></td> + </tr> +[% END %] + +<tr> + <td colspan="2"> + [% IF json %] + <pre>[% json FILTER html %]</pre> + [% ELSE %] + <pre>[% message_obj.payload FILTER html %]</pre> + [% END %] + </td> +</tr> + +<tr class="report-header"> + <th colspan="2"> + <a href="?id=push_queues.html">Return</a> | + <a onclick="return confirm('Are you sure you want to delete this message forever (a long time)?')" + href="?id=push_queues_view.html&delete=1 + [%- %]&message=[% message_obj.id FILTER uri %] + [%- %]&connector=[% message_obj.connector FILTER uri %]">Delete</a> + </th> +</tr> + +</table> + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/Push/template/en/default/setup/strings.txt.pl b/extensions/Push/template/en/default/setup/strings.txt.pl new file mode 100644 index 000000000..bb135f5bb --- /dev/null +++ b/extensions/Push/template/en/default/setup/strings.txt.pl @@ -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. + +%strings = ( + feature_push_amqp => 'Push: AMQP Support', + feature_push_stomp => 'Push: STOMP Support', +); diff --git a/extensions/Push/web/admin.css b/extensions/Push/web/admin.css new file mode 100644 index 000000000..c204fa62a --- /dev/null +++ b/extensions/Push/web/admin.css @@ -0,0 +1,71 @@ +/* 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. */ + +.connector th { + text-align: left; + vertical-align: middle !important; +} + +.option th { + text-align: right; + font-weight: normal !important; + vertical-align: middle !important; +} + +.option .help { + font-style: italic; +} + +.hidden { + display: none; +} + +.required_option { + color: red; + cursor: help; +} + +#report { + border: 1px solid #888888; + width: 100%; +} + +#report td, #report th { + padding: 3px 10px 3px 3px; + border: 0px; +} + +#report th { + text-align: left; +} + +.report-header { + background: #cccccc; +} + +.report-subheader { + background: #ffffff; +} + +.report_row_odd { + background-color: #eeeeee; + color: #000000; +} + +.report_row_even { + background-color: #ffffff; + color: #000000; +} + +#report tr.row:hover { + background-color: #ccccff; +} + +.rhs { + text-align: right !important; +} + diff --git a/extensions/Push/web/admin.js b/extensions/Push/web/admin.js new file mode 100644 index 000000000..599bfd742 --- /dev/null +++ b/extensions/Push/web/admin.js @@ -0,0 +1,37 @@ +/* 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; + +function toggle_options(visible, name) { + var rows = Dom.getElementsByClassName(name + '_tr'); + for (var i = 0, l = rows.length; i < l; i++) { + if (visible) { + Dom.removeClass(rows[i], 'hidden'); + } else { + Dom.addClass(rows[i], 'hidden'); + } + } +} + +function reset_to_defaults() { + if (!push_defaults) return; + for (var id in push_defaults) { + var el = Dom.get(id); + if (!el) continue; + if (el.nodeName == 'INPUT') { + el.value = push_defaults[id]; + } else if (el.nodeName == 'SELECT') { + for (var i = 0, l = el.options.length; i < l; i++) { + if (el.options[i].value == push_defaults[id]) { + el.options[i].selected = true; + break; + } + } + } + } +} diff --git a/extensions/REMO/Config.pm b/extensions/REMO/Config.pm new file mode 100644 index 000000000..625e2afd9 --- /dev/null +++ b/extensions/REMO/Config.pm @@ -0,0 +1,34 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the REMO Bugzilla Extension. +# +# The Initial Developer of the Original Code is Mozilla Foundation +# Portions created by the Initial Developer are Copyright (C) 2011 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Byron Jones <glob@mozilla.com> +# David Lawrence <dkl@mozilla.com> + +package Bugzilla::Extension::REMO; +use strict; + +use constant NAME => 'REMO'; + +use constant REQUIRED_MODULES => [ +]; + +use constant OPTIONAL_MODULES => [ +]; + +__PACKAGE__->NAME; diff --git a/extensions/REMO/Extension.pm b/extensions/REMO/Extension.pm new file mode 100644 index 000000000..b436d09d3 --- /dev/null +++ b/extensions/REMO/Extension.pm @@ -0,0 +1,309 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the REMO Bugzilla Extension. +# +# The Initial Developer of the Original Code is Mozilla Foundation +# Portions created by the Initial Developer are Copyright (C) 2011 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Byron Jones <glob@mozilla.com> +# David Lawrence <dkl@mozilla.com> + +package Bugzilla::Extension::REMO; +use strict; +use base qw(Bugzilla::Extension); + +use Bugzilla::Constants; +use Bugzilla::Util qw(trick_taint trim detaint_natural); +use Bugzilla::Token; +use Bugzilla::Error; + +our $VERSION = '0.01'; + +sub page_before_template { + my ($self, $args) = @_; + my $page = $args->{'page_id'}; + my $vars = $args->{'vars'}; + + if ($page eq 'remo-form-payment.html') { + _remo_form_payment($vars); + } +} + +sub _remo_form_payment { + my ($vars) = @_; + my $input = Bugzilla->input_params; + + my $user = Bugzilla->login(LOGIN_REQUIRED); + + if ($input->{'action'} eq 'commit') { + my $template = Bugzilla->template; + my $cgi = Bugzilla->cgi; + my $dbh = Bugzilla->dbh; + + my $bug_id = $input->{'bug_id'}; + detaint_natural($bug_id); + my $bug = Bugzilla::Bug->check($bug_id); + + # Detect if the user already used the same form to submit again + my $token = trim($input->{'token'}); + if ($token) { + my ($creator_id, $date, $old_attach_id) = Bugzilla::Token::GetTokenData($token); + if (!$creator_id + || $creator_id != $user->id + || $old_attach_id !~ "^remo_form_payment:") + { + # The token is invalid. + ThrowUserError('token_does_not_exist'); + } + + $old_attach_id =~ s/^remo_form_payment://; + if ($old_attach_id) { + ThrowUserError('remo_payment_cancel_dupe', + { bugid => $bug_id, attachid => $old_attach_id }); + } + } + + # Make sure the user can attach to this bug + if (!$bug->user->{'canedit'}) { + ThrowUserError("remo_payment_bug_edit_denied", + { bug_id => $bug->id }); + } + + # Make sure the bug is under the correct product/component + if ($bug->product ne 'Mozilla Reps' + || $bug->component ne 'Budget Requests') + { + ThrowUserError('remo_payment_invalid_product'); + } + + my ($timestamp) = $dbh->selectrow_array("SELECT NOW()"); + + $dbh->bz_start_transaction; + + # Create the comment to be added based on the form fields from rep-payment-form + my $comment; + $template->process("pages/comment-remo-form-payment.txt.tmpl", $vars, \$comment) + || ThrowTemplateError($template->error()); + $bug->add_comment($comment, { isprivate => 0 }); + + # Attach expense report + # FIXME: Would be nice to be able to have the above prefilled comment and + # the following attachments all show up under a single comment. But the longdescs + # table can only handle one attach_id per comment currently. At least only one + # email is sent the way it is done below. + my $attachment; + if (defined $cgi->upload('expenseform')) { + # Determine content-type + my $content_type = $cgi->uploadInfo($cgi->param('expenseform'))->{'Content-Type'}; + + $attachment = Bugzilla::Attachment->create( + { bug => $bug, + creation_ts => $timestamp, + data => $cgi->upload('expenseform'), + description => 'Expense Form', + filename => scalar $cgi->upload('expenseform'), + ispatch => 0, + isprivate => 0, + mimetype => $content_type, + }); + + # Insert comment for attachment + $bug->add_comment('', { isprivate => 0, + type => CMT_ATTACHMENT_CREATED, + extra_data => $attachment->id }); + } + + # Attach receipts file + if (defined $cgi->upload("receipts")) { + # Determine content-type + my $content_type = $cgi->uploadInfo($cgi->param("receipts"))->{'Content-Type'}; + + $attachment = Bugzilla::Attachment->create( + { bug => $bug, + creation_ts => $timestamp, + data => $cgi->upload('receipts'), + description => "Receipts", + filename => scalar $cgi->upload("receipts"), + ispatch => 0, + isprivate => 0, + mimetype => $content_type, + }); + + # Insert comment for attachment + $bug->add_comment('', { isprivate => 0, + type => CMT_ATTACHMENT_CREATED, + extra_data => $attachment->id }); + } + + $bug->update($timestamp); + + if ($token) { + trick_taint($token); + $dbh->do('UPDATE tokens SET eventdata = ? WHERE token = ?', undef, + ("remo_form_payment:" . $attachment->id, $token)); + } + + $dbh->bz_commit_transaction; + + # Define the variables and functions that will be passed to the UI template. + $vars->{'attachment'} = $attachment; + $vars->{'bugs'} = [ new Bugzilla::Bug($bug_id) ]; + $vars->{'header_done'} = 1; + $vars->{'contenttypemethod'} = 'autodetect'; + + my $recipients = { 'changer' => $user }; + $vars->{'sent_bugmail'} = Bugzilla::BugMail::Send($bug_id, $recipients); + + print $cgi->header(); + # Generate and return the UI (HTML page) from the appropriate template. + $template->process("attachment/created.html.tmpl", $vars) + || ThrowTemplateError($template->error()); + exit; + } + else { + $vars->{'token'} = issue_session_token('remo_form_payment:'); + } +} + +my %CSV_COLUMNS = ( + "Date Required" => { pos => 1, value => '%cf_due_date' }, + "Requester" => { pos => 2, value => 'Konstantina Papadea' }, + "Email 1" => { pos => 3, value => 'kpapadea@mozilla.com' }, + "Mozilla Space" => { pos => 4, value => 'Remote' }, + "Team" => { pos => 5, value => 'Community Engagement' }, + "Department Code" => { pos => 6, value => '2300' }, + "Purpose" => { pos => 7, value => 'Rep event: %eventpage' }, + "Item 1" => { pos => 8 }, + "Item 2" => { pos => 9 }, + "Item 3" => { pos => 10 }, + "Item 4" => { pos => 11 }, + "Item 5" => { pos => 12 }, + "Item 6" => { pos => 13 }, + "Item 7" => { pos => 14 }, + "Item 8" => { pos => 15 }, + "Item 9" => { pos => 16 }, + "Item 10" => { pos => 17 }, + "Item 11" => { pos => 18 }, + "Item 12" => { pos => 19 }, + "Item 13" => { pos => 20 }, + "Item 14" => { pos => 21 }, + "Recipient Name" => { pos => 22, value => '%shiptofirstname %shiptolastname' }, + "Email 2" => { pos => 23, value => sub { Bugzilla->user->email } }, + "Address 1" => { pos => 24, value => '%shiptoaddress1' }, + "Address 2" => { pos => 25, value => '%shiptoaddress2' }, + "City" => { pos => 26, value => '%shiptocity' }, + "State" => { pos => 27, value => '%shiptostate' }, + "Zip" => { pos => 28, value => '%shiptopcode' }, + "Country" => { pos => 29, value => '%shiptocountry' }, + "Phone number" => { pos => 30, value => '%shiptophone' }, + "Notes" => { pos => 31, value => '%shipadditional' }, +); + +sub _expand_value { + my $value = shift; + if (ref $value && ref $value eq 'CODE') { + return $value->(); + } + else { + my $cgi = Bugzilla->cgi; + $value =~ s/%(\w+)/$cgi->param($1)/ge; + return $value; + } +} + +sub _csv_quote { + my $s = shift; + $s =~ s/"/""/g; + return qq{"$s"}; +} + +sub _csv_line { + return join(",", map { _csv_quote($_) } @_); +} + +sub _csv_encode { + return join("\r\n", map { _csv_line(@$_) } @_) . "\r\n"; +} + +sub post_bug_after_creation { + my ($self, $args) = @_; + my $vars = $args->{vars}; + my $bug = $vars->{bug}; + my $template = Bugzilla->template; + + if (Bugzilla->input_params->{format} + && Bugzilla->input_params->{format} eq 'remo-swag') + { + # If the attachment cannot be successfully added to the bug, + # we notify the user, but we don't interrupt the bug creation process. + my $error_mode_cache = Bugzilla->error_mode; + Bugzilla->error_mode(ERROR_MODE_DIE); + + my @attachments; + eval { + my $xml; + $template->process("bug/create/create-remo-swag.xml.tmpl", {}, \$xml) + || ThrowTemplateError($template->error()); + + push @attachments, Bugzilla::Attachment->create( + { bug => $bug, + creation_ts => $bug->creation_ts, + data => $xml, + description => 'Remo Swag Request (XML)', + filename => 'remo-swag.xml', + ispatch => 0, + isprivate => 0, + mimetype => 'text/xml', + }); + + my @columns_raw = sort { $CSV_COLUMNS{$a}{pos} <=> $CSV_COLUMNS{$b}{pos} } keys %CSV_COLUMNS; + my @data = map { _expand_value( $CSV_COLUMNS{$_}{value} ) } @columns_raw; + my @columns = map { s/^(Item|Email) \d+$/$1/g; $_ } @columns_raw; + my $csv = _csv_encode(\@columns, \@data); + + push @attachments, Bugzilla::Attachment->create({ + bug => $bug, + creation_ts => $bug->creation_ts, + data => $csv, + description => 'Remo Swag Request (CSV)', + filename => 'remo-swag.csv', + ispatch => 0, + isprivate => 0, + mimetype => 'text/csv', + }); + }; + if ($@) { + warn "$@"; + } + + if (@attachments) { + # Insert comment for attachment + foreach my $attachment (@attachments) { + $bug->add_comment('', { isprivate => 0, + type => CMT_ATTACHMENT_CREATED, + extra_data => $attachment->id }); + } + $bug->update($bug->creation_ts); + delete $bug->{attachments}; + } + else { + $vars->{'message'} = 'attachment_creation_failed'; + } + + Bugzilla->error_mode($error_mode_cache); + } +} + +__PACKAGE__->NAME; diff --git a/extensions/REMO/template/en/default/bug/create/comment-mozreps.txt.tmpl b/extensions/REMO/template/en/default/bug/create/comment-mozreps.txt.tmpl new file mode 100644 index 000000000..95ab1c3e4 --- /dev/null +++ b/extensions/REMO/template/en/default/bug/create/comment-mozreps.txt.tmpl @@ -0,0 +1,95 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the REMO Bugzilla Extension. + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): Byron Jones <glob@mozilla.com> + #%] +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] +First Name: +[%+ cgi.param('first_name') %] + +Last Name: +[%+ cgi.param('last_name') %] + +Under 18 years old: +[%+ IF cgi.param('underage') %]Yes[% ELSE %]No[% END %] + +Sex: +[%+ cgi.param('sex') %] + +City: +[%+ cgi.param('city') %] + +Country: +[%+ cgi.param('country') %] + +Local Community: +[% IF cgi.param('community') %] +[%+ cgi.param('community') %] +[% ELSE %] +- +[% END %] + +IM: +[% IF cgi.param('im') %] +[%+ cgi.param('im') %] +[% ELSE %] +- +[% END %] + +Mozillians.org Account: +[% IF cgi.param('mozillian') %] +[%+ cgi.param('mozillian') %] +[% ELSE %] +- +[% END %] + +References: +[% IF cgi.param('references') %] +[%+ cgi.param('references') %] +[% ELSE %] +- +[% END %] + +Currently Involved with Mozilla: +[% IF cgi.param('involved') %] +[%+ cgi.param('involved') %] +[% ELSE %] +- +[% END %] + +When First Contributed: +[% IF cgi.param('firstcontribute') %] +[%+ cgi.param('firstcontribute') %] +[% ELSE %] +- +[% END %] + +Languages Spoken: +[%+ cgi.param('languages') %] + +How did you learn about Mozilla Reps: +[%+ cgi.param('learn') %] + +What motivates you most about joining Mozilla Reps: +[%+ cgi.param('motivation') %] + +Comments: +[% IF cgi.param('comments') %] +[%+ cgi.param('comments') %] +[% ELSE %] +- +[% END %] diff --git a/extensions/REMO/template/en/default/bug/create/comment-remo-budget.txt.tmpl b/extensions/REMO/template/en/default/bug/create/comment-remo-budget.txt.tmpl new file mode 100644 index 000000000..40b08331b --- /dev/null +++ b/extensions/REMO/template/en/default/bug/create/comment-remo-budget.txt.tmpl @@ -0,0 +1,57 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): Gervase Markham <gerv@gerv.net> + #%] +[%# INTERFACE: + # This template has no interface. + # + # Form variables from a bug submission (i.e. the fields on a template from + # enter_bug.cgi) can be access via Bugzilla.cgi.param. It can be used to + # pull out various custom fields and format an initial Description entry + # from them. + #%] +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +Requester info: + +Requester: [% cgi.param('firstname') %] [%+ cgi.param('lastname') %] +Profile page: [% cgi.param('profilepage') %] +Event page: [% cgi.param('eventpage') %] +Event hosted by a Firefox Student Ambassador(s)?: [% cgi.param('ambassador_hosted') %] +Main audience of the event are Firefox Student Ambassadors: [% cgi.param('ambassador_audience') %] +Mentor Email: [% cgi.param('mentoremail') %] +Paypal Account: [% cgi.param('paypal') %] +Country You Reside: [% cgi.param('country') %] +Advance payment needed: [% IF cgi.param('advancepayment') %]Yes[% ELSE %]No[% END %] + +Budget breakdown: + +Total amount requested in $USD: [% cgi.param('budgettotal') %] +Costs per service: +Service 1: [% cgi.param('service1') %] Cost: [% cgi.param('cost1') %] +Service 2: [% cgi.param('service2') %] Cost: [% cgi.param('cost2') %] +Service 3: [% cgi.param('service3') %] Cost: [% cgi.param('cost3') %] +Service 4: [% cgi.param('service4') %] Cost: [% cgi.param('cost4') %] +Service 5: [% cgi.param('service5') %] Cost: [% cgi.param('cost5') %] + +Additional costs: (add comment box) +[% cgi.param('costadditional') %] + +[%+ cgi.param("comment") IF cgi.param("comment") %] + diff --git a/extensions/REMO/template/en/default/bug/create/comment-remo-it.txt.tmpl b/extensions/REMO/template/en/default/bug/create/comment-remo-it.txt.tmpl new file mode 100644 index 000000000..7e95dd017 --- /dev/null +++ b/extensions/REMO/template/en/default/bug/create/comment-remo-it.txt.tmpl @@ -0,0 +1,79 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi %] + +Name: +[%+ cgi.param('name') %] + +Mozillians.org Profile: +[%+ cgi.param('mozillian') %] + +Reps Profile: +[%+ cgi.param('reps') || "-" %] + +Community Name: +[%+ cgi.param('community') %] + +[% FOREACH item = cgi.param('items') %] +[% IF item == "apps_email" || item == "domain" || item == "ssl" %] +[% IF item == "apps_email" %] +[% domain_title = domain_title _ ":: Google Apps Emails\n" %] +[% END %] +[% IF item == "domain" %] +[% domain_title = domain_title _ ":: Domain Name\n" %] +[% END %] +[% IF item == "ssl" %] +[% domain_title = domain_title _ ":: SSL\n" %] +[% END %] +[% END %] +[% END %] + +[% FOREACH item = cgi.param('items') %] +[% IF item == "hosting" %] +:: Hosting + +Expected visits per month: +[%+ cgi.param('hosting_visits') %] + +What will run on the hosting: +[%+ cgi.param('hosting_running') %] + +Hosting data: +[%+ cgi.param('hosting_data') || "-" %] + +[% ELSIF (item == "apps_email" || item == "domain" || item == "ssl") + && domain_title %] +[% domain_title FILTER html %] +[% domain_title = "" %] +Domain Name: +[%+ cgi.param('domain_name') %] + +[% ELSIF item == "discourse" %] +:: Discourse Category + +Category Names: +[%+ cgi.param('discourse_names') %] + +Moderators: +[%+ cgi.param('discourse_mods') %] + +Background Hex Code: +[%+ cgi.param('discourse_bg') || "-" %] + +[% ELSIF item == "other" %] +:: Other + +[%+ cgi.param('other_value') %] + +[% END %] +[% END %] + +Comments: +[%+ cgi.param('comments') || "-" %] diff --git a/extensions/REMO/template/en/default/bug/create/comment-remo-swag.txt.tmpl b/extensions/REMO/template/en/default/bug/create/comment-remo-swag.txt.tmpl new file mode 100644 index 000000000..ef7419dc9 --- /dev/null +++ b/extensions/REMO/template/en/default/bug/create/comment-remo-swag.txt.tmpl @@ -0,0 +1,73 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is Netscape Communications + # Corporation. Portions created by Netscape are + # Copyright (C) 1998 Netscape Communications Corporation. All + # Rights Reserved. + # + # Contributor(s): Gervase Markham <gerv@gerv.net> + #%] +[%# INTERFACE: + # This template has no interface. + # + # Form variables from a bug submission (i.e. the fields on a template from + # enter_bug.cgi) can be access via Bugzilla.cgi.param. It can be used to + # pull out various custom fields and format an initial Description entry + # from them. + #%] +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +Requester info: + +First name: [% cgi.param('firstname') %] +Last name: [% cgi.param('lastname') %] +Profile page: [% cgi.param('profilepage') %] +Event name: [% cgi.param('eventname') %] +Event page: [% cgi.param('eventpage') %] +Estimated attendance: [% cgi.param('attendance') %] +Event hosted by a Firefox Student Ambassador(s)?: [% cgi.param('ambassador_hosted') %] +Main audience of the event are Firefox Student Ambassadors: [% cgi.param('ambassador_audience') %] + +Shipping details: + +Ship swag before: [% cgi.param('cf_due_date') %] + +First name: [% cgi.param("shiptofirstname") %] +Last name: [% cgi.param("shiptolastname") %] +Address line 1: [% cgi.param("shiptoaddress1") %] +Address line 2: [% cgi.param("shiptoaddress2") %] +City: [% cgi.param("shiptocity") %] +State/Region: [% cgi.param("shiptostate") %] +Postal code: [% cgi.param("shiptopcode") %] +Country: [% cgi.param("shiptocountry") %] +Phone: [% cgi.param("shiptophone") %] +[%+ IF cgi.param("shiptoidrut") %]Custom reference: [% cgi.param("shiptoidrut") %][% END %] + +Addition information for delivery person: +[%+ cgi.param('shipadditional') %] + +Swag requested: + +Stickers: [% IF cgi.param('stickers') %]Yes[% ELSE %]No[% END %] +Buttons: [% IF cgi.param('buttons') %]Yes[% ELSE %]No[% END %] +Lanyards: [% IF cgi.param('lanyards') %]Yes[% ELSE %]No[% END %] +T-shirts: [% IF cgi.param('tshirts') %]Yes[% ELSE %]No[% END %] +Roll-up banners: [% IF cgi.param('rollupbanners') %]Yes[% ELSE %]No[% END %] +Horizontal banner: [% IF cgi.param('horizontalbanner') %]Yes[% ELSE %]No[% END %] +Booth cloth: [% IF cgi.param('boothcloth') %]Yes[% ELSE %]No[% END %] +Pens: [% IF cgi.param('pens') %]Yes[% ELSE %]No[% END %] +Other: [% IF cgi.param('otherswag') %][% cgi.param('otherswag') %][% ELSE %]No[% END %] + +[%+ cgi.param("comment") IF cgi.param("comment") %] + diff --git a/extensions/REMO/template/en/default/bug/create/create-mozreps.html.tmpl b/extensions/REMO/template/en/default/bug/create/create-mozreps.html.tmpl new file mode 100644 index 000000000..be461c795 --- /dev/null +++ b/extensions/REMO/template/en/default/bug/create/create-mozreps.html.tmpl @@ -0,0 +1,247 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the REMO Bugzilla Extension. + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): Byron Jones <glob@mozilla.com> + #%] + +[% PROCESS global/variables.none.tmpl %] + +[% PROCESS global/header.html.tmpl + title = "Mozilla Reps - Application Form" + style_urls = [ "extensions/REMO/web/styles/moz_reps.css" ] +%] + +[% USE Bugzilla %] +[% mandatory = '<span class="mandatory" title="Required">*</span>' %] + +<script type="text/javascript"> +var Dom = YAHOO.util.Dom; + +function mandatory(ids) { + result = true; + for (i in ids) { + id = ids[i]; + el = Dom.get(id); + + if (el.type.toString() == "checkbox") { + value = el.checked; + } else { + value = el.value.replace(/^\s\s*/, '').replace(/\s\s*$/, ''); + el.value = value; + } + + if (value == '') { + Dom.addClass(id, 'missing'); + result = false; + } else { + Dom.removeClass(id, 'missing'); + } + } + return result; +} + +function underageWarning (el) { + if (el.checked) { + Dom.removeClass('underage_warning', 'bz_default_hidden'); + Dom.get('submit').disabled = true; + } + else { + Dom.addClass('underage_warning', 'bz_default_hidden'); + Dom.get('submit').disabled = false; + } +} + +function submitForm() { + if (!mandatory([ 'first_name', 'last_name', 'sex', 'city', 'country', + 'mozillian', 'languages', 'learn', 'motivation', 'privacy' ]) + ) { + alert('Please enter all the required fields.'); + return false; + } + + Dom.get('short_desc').value = + "Application Form: " + Dom.get('first_name').value + ' ' + Dom.get('last_name').value; + + return true; +} + +</script> + +<noscript> +<h1>Javascript is required to use this form.</h1> +</noscript> + +<h1>Mozilla Reps - Application Form</h1> + +<p> + If you have questions while completing this form, please contact the + <a href="mailto:reps-council@lists.mozilla.org">Reps Council</a> for + assistance. +</p> + +<form method="post" action="post_bug.cgi" id="tmRequestForm"> +<input type="hidden" name="product" value="Mozilla Reps"> +<input type="hidden" name="component" value="Mentorship"> +<input type="hidden" name="bug_severity" value="normal"> +<input type="hidden" name="rep_platform" value="All"> +<input type="hidden" name="priority" value="--"> +<input type="hidden" name="op_sys" value="Other"> +<input type="hidden" name="version" value="unspecified"> +<input type="hidden" name="groups" value="mozilla-reps"> +<input type="hidden" name="format" value="[% format FILTER html %]"> +<input type="hidden" name="created-format" value="[% format FILTER html %]"> +<input type="hidden" name="comment" id="comment" value=""> +<input type="hidden" name="short_desc" id="short_desc" value=""> +<input type="hidden" name="token" value="[% token FILTER html %]"> + +<table id="reps-form"> + +<tr class="odd"> + <th>First Name:[% mandatory FILTER none %]</th> + <td><input id="first_name" name="first_name" size="40" placeholder="John"></td> +</tr> + +<tr class="even"> + <th>Last Name:[% mandatory FILTER none %]</th> + <td><input id="last_name" name="last_name" size="40" placeholder="Doe"></td> +</tr> + +<tr class="odd"> + <th>Are you under 18 years old?:</th> + <td> + <input type="checkbox" id="underage" name="underage" + value="1" onclick="underageWarning(this);"><br> + </td> +</tr> + +<tr id="underage_warning" class="odd bz_default_hidden"> + <td colspan="2"> + Mozilla Reps program is not currently accepting people under 18 years old. + Sorry for the inconvenience. In the meantime please check with your local Mozilla + group for other contribution opportunities + </td> +</tr> + +<tr class="even"> + <th>Sex:[% mandatory FILTER none %]</th> + <td> + <select id="sex" name="sex"> + <option value="Male">Male</option> + <option value="Female">Female</option> + <option value="Other">Other</option> + </select> + </td> +</tr> + +<tr class="odd"> + <th>City:[% mandatory FILTER none %]</th> + <td><input id="city" name="city" size="40" placeholder="Your city"></td> +</tr> + +<tr class="even"> + <th>Country:[% mandatory FILTER none %]</th> + <td><input id="country" name="country" size="40" placeholder="Your country"></td> +</tr> + +<tr class="odd"> + <th>Local Community you participate in:</th> + <td><input id="community" name="community" size="40" placeholder="Name of your community"></td> +</tr> + +<tr class="even"> + <th>IM (specify service):</th> + <td><input id="im" name="im" size="40"></td> +</tr> + +<tr class="odd"> + <th>Mozillians.org Account:[% mandatory FILTER none %]</th> + <td><input id="mozillian" name="mozillian" size="40"></td> +</tr> + +<tr class="even"> + <th colspan="2"> + References: + </th> +</tr> +<tr class="even"> + <td colspan="2"> + <textarea id="references" name="references" rows="4" + placeholder="Add contact info of people referencing you."></textarea> + </td> +</tr> + +<tr class="odd"> + <th colspan="2"> + How are you involved with Mozilla? + </th> +</tr> +<tr class="odd"> + <td colspan="2"> + <textarea id="involved" name="involved" rows="4" placeholder="Add-ons, l10n, SUMO, QA, ..."></textarea> + </td> +</tr> + +<tr class="even"> + <th> + When did you first start contributing to Mozilla? + </th> + <td><input id="firstcontribute" name="firstcontribute" size="40"></td> +</tr> + +<tr class="odd"> + <th>Languages Spoken:[% mandatory FILTER none %]</th> + <td><input id="languages" name="languages" size="40"></td> +</tr> + +<tr class="even"> + <th>How did you learn about Mozilla Reps?[% mandatory FILTER none %]</th> + <td><input id="learn" name="learn" size="40"></td> +</tr> + +<tr class="odd"> + <th colspan="2">What motivates you most about joining Mozilla Reps?[% mandatory FILTER none %]</th> +</tr> +<tr class="odd"> + <td colspan="2"><textarea id="motivation" name="motivation" rows="4"></textarea></td> +</tr> + +<tr class="even"> + <th colspan="2">Comments:</th> +</tr> +<tr class="even"> + <td colspan="2"><textarea id="comments" name="comments" rows="4"></textarea></td> +</tr> + +<tr class="odd"> + <th> + I have read the + <a href="http://www.mozilla.com/en-US/privacy-policy" target="_blank">Mozilla Privacy Policy</a>:[% mandatory FILTER none %] + </th> + <td><input id="privacy" type="checkbox"></td> +</tr> + +<tr class="even"> + <td> </td> + <td align="right"> + <input id="submit" type="submit" value="Submit" onclick="return submitForm()"> + </td> +</tr> + +</table> + +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/REMO/template/en/default/bug/create/create-remo-budget.html.tmpl b/extensions/REMO/template/en/default/bug/create/create-remo-budget.html.tmpl new file mode 100644 index 000000000..6e393612c --- /dev/null +++ b/extensions/REMO/template/en/default/bug/create/create-remo-budget.html.tmpl @@ -0,0 +1,295 @@ +[%# 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 = "Mozilla Reps Budget Request Form" + style_urls = [ 'extensions/REMO/web/styles/moz_reps.css' ] + javascript_urls = [ 'extensions/REMO/web/js/form_validate.js', + 'js/util.js', + 'js/field.js' ] + yui = [ 'autocomplete', 'calendar' ] +%] + +[% IF user.in_group("mozilla-reps") %] + +<p>These requests will only be visible to the person who submitted the request, +any persons designated in the CC line, and authorized members of the Mozilla +Rep team.</p> + +<script language="javascript" type="text/javascript"> +function trySubmit() { + var firstname = document.getElementById('firstname').value; + var lastname = document.getElementById('lastname').value; + var eventpage = document.getElementById('eventpage').value; + var shortdesc = 'Budget Request - ' + firstname + ' ' + lastname + ' - ' + eventpage; + document.getElementById('short_desc').value = shortdesc; + document.getElementById('cc').value = document.getElementById('mentoremail').value; + return true; +} + +function validateAndSubmit() { + var alert_text = ''; + if(!isFilledOut('firstname')) alert_text += "Please enter your first name\n"; + if(!isFilledOut('lastname')) alert_text += "Please enter your last name\n"; + if(!isFilledOut('profilepage')) alert_text += "Please enter a Mozilla Reps profile page.\n"; + if(!isFilledOut('eventpage')) alert_text += "Please enter an event page address.\n"; + if(!isFilledOut('cf_due_date')) alert_text += "Please enter an event date.\n"; + if(!isFilledOut('ambassador_hosted')) alert_text += "Please select whether this event is hosted by ambassadors.\n"; + if(!isFilledOut('ambassador_audience')) alert_text += "Please select whether this event's main audience is ambassadors.\n"; + if(!isFilledOut('mentoremail')) alert_text += "Please enter a valid [% terms.Bugzilla %] email for mentor.\n"; + if(!isFilledOut('country')) alert_text += "Please enter a valid value for country.\n"; + if(!isFilledOut('budgettotal')) alert_text += "Please enter the total budget for the event.\n"; + if(!isFilledOut('service1') || !isFilledOut('cost1')) alert_text += "Please enter at least one service and cost value.\n"; + + //Everything required is filled out..try to submit the form! + if(alert_text == '') { + return trySubmit(); + } + + //alert text, stay here on the pagee + alert(alert_text); + return false; +} +</script> + +<h1>Mozilla Reps - Budget Request Form</h1> + +<p> + If your request is Community IT related please file it + <a href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Mozilla%20Reps;component=Community%20IT%20Requests">here</a>. +</p> + +<p> + <span class="required_star">*</span> - <span class="required_explanation">Required Fields</span> +</p> + +<form method="post" action="post_bug.cgi" id="swagRequestForm" enctype="multipart/form-data" + onSubmit="return validateAndSubmit();"> + + <input type="hidden" name="format" value="remo-budget"> + <input type="hidden" name="created-format" value="remo-budget"> + <input type="hidden" name="product" value="Mozilla Reps"> + <input type="hidden" name="component" value="Budget Requests"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="priority" value="--"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="short_desc" id="short_desc" value=""> + <input type="hidden" name="cc" id="cc" value=""> + <input type="hidden" name="groups" value="mozilla-reps"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + +<table id="reps-form"> + +<tr class="odd"> + <th class="field_label required">First Name:</th> + <td> + <input type="text" name="firstname" id="firstname" value="" size="40" placeholder="John"> + </td> +</tr> + +<tr class="even"> + <th class="field_label required">Last Name:</th> + <td> + <input type="text" name="lastname" id="lastname" value="" size="40" placeholder="Doe"> + </td> +</tr> + +<tr class="odd"> + <th class="field_label required">Mozilla Reps Profile Page:</th> + <td> + <input type="text" name="profilepage" id="profilepage" + value="" size="40" placeholder="https://reps.mozilla.org/u/JohnDoe"> + </td> +</tr> + +<tr class="even"> + <th class="field_label required">Event Page:</th> + <td> + <input type="text" name="eventpage" id="eventpage" + value="" size="40" placeholder="https://reps.mozilla.org/e/TestEvent"> + </td> +</tr> + +<tr class="odd"> + <th class="field_label required">Event Date:</th> + <td> + <input name="cf_due_date" size="20" id="cf_due_date" value="" + onchange="updateCalendarFromField(this)"> + <button type="button" class="calendar_button" + id="button_calendar_cf_due_date" + onclick="showCalendar('cf_due_date')"> + <span>Calendar</span> + </button> + <div id="con_calendar_cf_due_date"></div> + <script type="text/javascript"> + createCalendar('cf_due_date') + </script> + </td> +</tr> + +<tr class="even"> + <th class="field_label required"> + Is this event being hosted by a<br>Firefox Student Ambassador(s)?: + </th> + <td> + <select id="ambassador_hosted" name="ambassador_hosted"> + <option value="">Select One</option> + <option value="Yes">Yes</option> + <option value="No">No</option> + </select> + </td> +</tr> + +<tr class="odd"> + <th class="field_label required"> + Is the main audience of this event<br>Firefox Student Ambassadors?: + </th> + <td> + <select id="ambassador_audience" name="ambassador_audience"> + <option value="">Select One</option> + <option value="Yes">Yes</option> + <option value="No">No</option> + </select> + </td> +</tr> + +<tr class="even"> + <th class="field_label required">[% terms.Bugzilla %] Email of Your Mentor:</th> + <td> + [% INCLUDE global/userselect.html.tmpl + id => "mentoremail" + name => "mentoremail" + value => "" + size => 40 + %] + </td> +</tr> + +<tr class="odd"> + <th class="field_label">Paypal Account Email:</th> + <td> + <input type="text" name="paypal" id="paypal" + value="" size="40" placeholder=""><br> + <span style="font-size: smaller;"> + * Currently, you CANNOT make payments using other online payment services.</span> + </td> +</tr> + +<tr class="even"> + <th class="field_label required">Country You Reside:</th> + <td> + <input type="text" name="country" id="country" + value="" size="40" placeholder="USA"> + </td> +</tr> + +<tr class="odd"> + <th class="field_label">Is advance payment needed?</th> + <td> + <input type="checkbox" name="advancepayment" id="advancepayment" value="1"> + </td> +</tr> + +<tr class="even"> + <td><!--spacer--> </td> + <td><!--spacer--> </td> +</tr> + +<tr class="odd"> + <th colspan="2" class="field_label">Budget Request:</th> +</tr> + +<tr class="odd"> + <th class="field_label required">Total amount requested in $USD:</th> + <td> + <input type="text" name="budgettotal" id="budgettotal" value="" size="40"> + </td> + </tr> + +<tr class="odd"> + <th colspan="2" class="field_label">Costs per service:</th> +</tr> + +<tr class="odd"> + <td colspan="2"> + <table> + <tr> + <th class="field_label required">Service 1:</th> + <td><input type="text" id="service1" name="service1" size="30"></td> + <th class="field_label required">Cost 1:</th> + <td><input type="text" id="cost1" name="cost1" size="30"></td> + </tr> + <tr> + <th class="field_lable">Service 2:</th> + <td><input type="text" id="service2" name="service2" size="30"></td> + <th class="field_lable">Cost 2:</th> + <td><input type="text" id="cost2" name="cost2" size="30"></td> + </tr> + <tr> + <th class="field_lable">Service 3:</th> + <td><input type="text" id="service3" name="service3" size="30"></td> + <th class="field_lable">Cost 3:</th> + <td><input type="text" id="cost3" name="cost3" size="30"></td> + </tr> + <tr> + <th class="field_lable">Service 4:</th> + <td><input type="text" id="service4" name="service4" size="30"></td> + <th class="field_lable">Cost 4:</th> + <td><input type="text" id="cost4" name="cost4" size="30"></td> + </tr> + <tr> + <th class="field_lable">Service 5:</th> + <td><input type="text" id="service5" name="service5" size="30"></td> + <th class="field_lable">Cost 5:</th> + <td><input type="text" id="cost5" name="cost5" size="30"></td> + </tr> + </table> + </td> +</tr> + +<tr class="odd"> + <th colspan="2" class="field_label">Additional costs:</th> +</tr> + +<tr class="odd"> + <td colspan="2"> + <textarea id="costadditional" name="costadditional" rows="5" cols="50"></textarea> + </td> +</tr> + +<tr class="even"> + <td> </td> + <td align="right"> + <input type="submit" id="commit" value="Submit Request"> + </td> +</tr> + +</table> + +</form> + +<p style="font-weight:bold;"> + Budget requests received less than 3 weeks before the targeted launch date of the + event/activity in question will automatically be rejected (exceptions can be made + but only with council approval). This 3-week “buffer†guarantees that each budget + request undergoes the same thorough selection process. +</p> + +<p> + Thanks for contacting us. +</p> + +[% ELSE %] + <p>Sorry, you do not have access to this page.</p> +[% END %] + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/REMO/template/en/default/bug/create/create-remo-it.html.tmpl b/extensions/REMO/template/en/default/bug/create/create-remo-it.html.tmpl new file mode 100644 index 000000000..a1085ae97 --- /dev/null +++ b/extensions/REMO/template/en/default/bug/create/create-remo-it.html.tmpl @@ -0,0 +1,294 @@ +[%# 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 + onload = "init()" + title = "Community IT Request" + style_urls = [ "extensions/REMO/web/styles/moz_reps.css" ] +%] + +[% USE Bugzilla %] +[% mandatory = '<span class="mandatory" title="Required">*</span>' %] + +<script type="text/javascript"> +var Dom = YAHOO.util.Dom; + +function mandatory(elements) { + result = true; + for (i in elements) { + element = elements[i]; + + if (typeof(element) == "object") { + missing = true; + for (j = 0; j < element.length; j++) { + if (element[j].checked) { + missing = false; + break; + } + } + + if (missing) { + Dom.addClass(element[0].name, 'missing'); + } else { + Dom.removeClass(element[0].name, 'missing'); + } + } else { + el = Dom.get(element); + value = el.value.replace(/^\s\s*/, '').replace(/\s\s*$/, ''); + el.value = value; + + if (value == '') { + Dom.addClass(element, 'missing'); + result = false; + } else { + Dom.removeClass(element, 'missing'); + } + } + } + return result; +} + +function submitForm() { + fields = [ 'name', 'mozillian', 'community', document.forms.f.items ]; + if (Dom.get('item_hosting').checked) { + fields.push('hosting_visits'); + fields.push('hosting_running'); + } + if (Dom.get('item_domain').checked + || Dom.get('item_apps_email').checked + || Dom.get('item_ssl').checked + ) { + fields.push('domain_name'); + } + if (Dom.get('item_discourse').checked) { + fields.push('discourse_names'); + fields.push('discourse_mods'); + } + if (Dom.get('item_other').checked) { + fields.push('other_value'); + } + + if (!mandatory(fields)) { + alert('Please enter all the required fields.'); + return false; + } + + Dom.get('short_desc').value = + "IT Request: " + Dom.get('community').value + ' (' + Dom.get('name').value + ')'; + return true; +} + +function setItemVisibility() { + if (Dom.get('item_hosting').checked) { + Dom.removeClass('hosting', 'bz_default_hidden'); + } else { + Dom.addClass('hosting', 'bz_default_hidden'); + } + if (Dom.get('item_domain').checked + || Dom.get('item_apps_email').checked + || Dom.get('item_ssl').checked + ) { + var title = []; + if (Dom.get('item_apps_email').checked) + title.push('Google Apps Email'); + if (Dom.get('item_domain').checked) + title.push('Domain'); + if (Dom.get('item_ssl').checked) + title.push('SSL'); + Dom.get('domain_title').innerHTML = title.join(', '); + Dom.removeClass('domain', 'bz_default_hidden'); + } else { + Dom.addClass('domain', 'bz_default_hidden'); + } + if (Dom.get('item_discourse').checked) { + Dom.removeClass('discourse', 'bz_default_hidden'); + } else { + Dom.addClass('discourse', 'bz_default_hidden'); + } + if (Dom.get('item_other').checked) { + Dom.removeClass('other', 'bz_default_hidden'); + } else { + Dom.addClass('other', 'bz_default_hidden'); + } +} + +function init() { + items = document.forms.f.items; + for (i = 0; i < items.length; i++) { + YAHOO.util.Event.on(items[i], 'click', setItemVisibility); + } + setItemVisibility(); +} + +</script> + +<noscript> +<h1>Javascript is required to use this form.</h1> +</noscript> + +<h1>Community IT Request</h1> + +<form method="post" action="post_bug.cgi" id="tmRequestForm" name="f"> +<input type="hidden" name="product" value="Mozilla Reps"> +<input type="hidden" name="component" value="Community IT Requests"> +<input type="hidden" name="bug_severity" value="normal"> +<input type="hidden" name="rep_platform" value="All"> +<input type="hidden" name="priority" value="--"> +<input type="hidden" name="op_sys" value="Other"> +<input type="hidden" name="version" value="unspecified"> +<input type="hidden" name="groups" value="mozilla-reps"> +<input type="hidden" name="format" value="[% format FILTER html %]"> +<input type="hidden" name="comment" id="comment" value=""> +<input type="hidden" name="short_desc" id="short_desc" value=""> +<input type="hidden" name="token" value="[% token FILTER html %]"> + +<table id="reps-form"> + +<tr class="odd"> + <th>Your Name:[% mandatory FILTER none %]</th> + <td><input id="name" name="name" size="40" value="[% user.name FILTER html %]"></td> +</tr> + +<tr class="even"> + <th>Mozillians.org Profile:[% mandatory FILTER none %]</th> + <td><input id="mozillian" name="mozillian" size="40"></td> +</tr> + +<tr class="odd"> + <th>Reps Profile (if applicable):</th> + <td><input id="reps" name="reps" size="40"></td> +</tr> + +<tr class="even"> + <th>Your Community's Name:[% mandatory FILTER none %]</th> + <td><input id="community" name="community" size="40"></td> +</tr> + +<tr class="odd"> + <th> + Items Requesting:[% mandatory FILTER none %] + </th> + <td> + <div id="items"> + <div> + <input type="checkbox" name="items" value="hosting" id="item_hosting"> + <label for="item_hosting">Hosting</label> + </div> + <div> + <input type="checkbox" name="items" value="apps_email" id="item_apps_email"> + <label for="item_apps_email">Google Apps Emails</label> + </div> + <div> + <input type="checkbox" name="items" value="domain" id="item_domain"> + <label for="item_domain">Domain</label> + </div> + <div> + <input type="checkbox" name="items" value="discourse" id="item_discourse"> + <label for="item_discourse">Discourse Category</label> + </div> + <div> + <input type="checkbox" name="items" value="ssl" id="item_ssl"> + <label for="item_ssl">SSL</label> + </div> + <div> + <input type="checkbox" name="items" value="other" id="item_other"> + <label for="item_other">Other</label> + </div> + </div> + </td> +</tr> + +<tbody id="hosting"> +<tr class="even"> + <th colspan="2">Hosting</th> +</tr> +<tr class="odd"> + <th>Expected visits per month:[% mandatory FILTER none %]</th> + <td><input id="hosting_visits" name="hosting_visits" size="40"></td> +</tr> +<tr class="odd"> + <th>What will run on the hosting?:[% mandatory FILTER none %]</th> + <td><textarea id="hosting_running" name="hosting_running" class="small"></textarea></td> +</tr> +<tr class="odd"> + <th>Data:</td> + <td> + Any data we can use to help choose the best solution (traffic graphs etc).<br> + <textarea id="hosting_data" name="hosting_data" class="small"></textarea> + </td> +</tr> +</tbody> + +<tbody id="domain"> +<tr class="even"> + <th colspan="2" id="domain_title">Domain</th> +</tr> +<tr class="odd"> + <th>Domain Name:[% mandatory FILTER none %]</th> + <td><input id="domain_name" name="domain_name" size="40"></td> +</tr> +</tbody> + +<tbody id="discourse"> +<tr class="even"> + <th colspan="2">Discourse Category</th> +</tr> +<tr class="odd"> + <th>Discourse Category Names:[% mandatory FILTER none %]</th> + <td><input id="discourse_names" name="discourse_names" size="40"></td> +</tr> +<tr class="odd"> + <th>Moderators:[% mandatory FILTER none %]</th> + <td><input id="discourse_mods" name="discourse_mods" size="40"></td> +</tr> +<tr class="odd"> + <th>Hex code of background of category tag:</th> + <td><input id="discourse_bg" name="discourse_bg" size="40"></td> +</tr> +</tbody> + +<tbody id="other"> +<tr class="even"> + <th colspan="2">Other Item</th> +</tr> +<tr class="odd"> + <th>Other:[% mandatory FILTER none %]</th> + <td><input id="other_value" name="other_value" size="40"></td> +</tr> +</tbody> + +<tr class="even"> + <th colspan="2"> + Other Comments + </th> +</tr> +<tr class="even"> + <td colspan="2"> + Please explain why you'd like the hosting, and anything else this form does not include.<br> + <textarea id="comments" name="comments" rows="4"></textarea> + </td> +</tr> + +<tr class="even"> + <td colspan="2"> + <input id="submit" type="submit" value="Submit" onclick="return submitForm()"> + </td> +</tr> + +<tr class="even"> + <td width="35%"> </td> + <td width="65%"> </td> +</tr> + +</table> + +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/REMO/template/en/default/bug/create/create-remo-swag.html.tmpl b/extensions/REMO/template/en/default/bug/create/create-remo-swag.html.tmpl new file mode 100644 index 000000000..70fba6cb8 --- /dev/null +++ b/extensions/REMO/template/en/default/bug/create/create-remo-swag.html.tmpl @@ -0,0 +1,326 @@ +[%# 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 = "Mozilla Reps Swag Request Form" + javascript_urls = [ 'extensions/REMO/web/js/swag.js', + 'extensions/REMO/web/js/form_validate.js', + 'js/field.js', + 'js/util.js' ] + style_urls = [ "extensions/REMO/web/styles/moz_reps.css" ] + yui = [ 'calendar' ] +%] + +[% IF !user.in_group("mozilla-reps") %] + <p>Sorry, you do not have access to this page.</p> + [% RETURN %] +[% END %] + + +<p>These requests will only be visible to the person who submitted the request, +any persons designated in the CC line, and authorized members of the Mozilla Rep team.</p> + +<script language="javascript" type="text/javascript"> +function trySubmit() { + var eventname = document.getElementById('eventname').value; + var shortdesc = 'Swag Request - ' + eventname; + document.getElementById('short_desc').value = shortdesc; + return true; +} + +function validateAndSubmit() { + var alert_text = ''; + if(!isFilledOut('firstname')) alert_text += "Please enter your first name\n"; + if(!isFilledOut('lastname')) alert_text += "Please enter your last name\n"; + if(!isFilledOut('profilepage')) alert_text += "Please enter your Mozilla Reps profile page\n"; + if(!isFilledOut('eventname')) alert_text += "Please enter your event name\n"; + if(!isFilledOut('eventpage')) alert_text += "Please enter the event page.\n"; + if(!isFilledOut('attendance')) alert_text += "Please enter the estimated attendance.\n"; + if(!isFilledOut('ambassador_hosted')) alert_text += "Please select whether this event is hosted by ambassadors.\n"; + if(!isFilledOut('ambassador_audience')) alert_text += "Please select whether this event's main audience is ambassadors.\n"; + if(!isFilledOut('shiptofirstname')) alert_text += "Please enter the shipping first name\n"; + if(!isFilledOut('shiptolastname')) alert_text += "Please enter the shipping last name\n"; + if(!isFilledOut('shiptoaddress1')) alert_text += "Please enter the ship to address\n"; + if(!isFilledOut('shiptocity')) alert_text += "Please enter the ship to city\n"; + if(!isFilledOut('shiptocountry')) alert_text += "Please enter the ship to country\n"; + if(!isFilledOut('shiptopcode')) alert_text += "Please enter the ship to postal code\n"; + if(!isFilledOut('shiptophone')) alert_text += "Please enter the ship to contact number\n"; + + //Everything required is filled out..try to submit the form! + if(alert_text == '') { + return trySubmit(); + } + + //alert text, stay here on the pagee + alert(alert_text); + return false; +} + +</script> + +<h1>Mozilla Reps - Swag Request Form</h1> + +<p>Review the <a href="https://wiki.mozilla.org/ReMo/SOPs/Swag_Requests" target="_blank"> + Swag Requests SOP</a> before you complete this form.</p> + +<form method="post" action="post_bug.cgi" id="swagRequestForm" enctype="multipart/form-data" + onSubmit="return validateAndSubmit();"> + + <input type="hidden" name="format" value="remo-swag"> + <input type="hidden" name="product" value="Mozilla Reps"> + <input type="hidden" name="component" value="Swag Requests"> + <input type="hidden" name="rep_platform" value="All"> + <input type="hidden" name="op_sys" value="Other"> + <input type="hidden" name="priority" value="--"> + <input type="hidden" name="version" value="unspecified"> + <input type="hidden" name="bug_severity" id="bug_severity" value="normal"> + <input type="hidden" name="short_desc" id="short_desc" value=""> + <input type="hidden" name="groups" value="mozilla-reps"> + <input type="hidden" name="token" value="[% token FILTER html %]"> + +<table id="reps-form"> + +<tr class="odd"> + <td><strong>First Name: <span style="color: red;" title="Required">*</span></strong></td> + <td> + <input type="text" name="firstname" id="firstname" placeholder="John" size="40"> + </td> +</tr> + +<tr class="even"> + <td><strong>Last Name: <span style="color: red;" title="Required">*</span></strong></td> + <td> + <input type="text" name="lastname" id="lastname" placeholder="Doe" size="40"> + </td> +</tr> + +<tr class="odd"> + <td> + <strong>Mozilla Reps Profile Page: + <span style="color: red;" title="Required">*</span></strong> + </td> + <td> + <input type="text" name="profilepage" id="profilepage" size="40"> + </td> +</tr> + +<tr class="even"> + <td><strong>Event Name: <span style="color: red;" title="Required">*</span></strong></td> + <td> + <input type="text" name="eventname" id="eventname" size="40"> + </td> +</tr> + +<tr class="odd"> + <td><strong>Event Page: <span style="color: red;" title="Required">*</span></strong></td> + <td> + <input type="text" name="eventpage" id="eventpage" size="40"> + </td> +</tr> + +<tr class="even"> + <td><strong>Estimated Attendance: <span style="color: red;" title="Required">*</span></strong></td> + <td> + <select id="attendance" name="attendance"> + <option value="1-50">1-50</option> + <option value="51-200">51-200</option> + <option value="201-500">201-500</option> + <option value="501-1000+">501-1000+</option> + </select> + </td> +</tr> + +<tr class="odd"> + <td> + <strong>Is this event being hosted by a<br>Firefox Student Ambassador(s)?: + <span style="color: red;" title="Required">*</span></strong> + </td> + <td> + <select id="ambassador_hosted" name="ambassador_hosted"> + <option value="">Select One</option> + <option value="Yes">Yes</option> + <option value="No">No</option> + </select> + </td> +</tr> + +<tr class="even"> + <td> + <strong>Is the main audience of this event<br>Firefox Student Ambassadors?: + <span style="color: red;" title="Required">*</span></strong> + </td> + <td> + <select id="ambassador_audience" name="ambassador_audience"> + <option value="">Select One</option> + <option value="Yes">Yes</option> + <option value="No">No</option> + </select> + </td> +</tr> + +<tr class="odd"> + <td><!--spacer--> </td> + <td><!--spacer--> </td> +</tr> + +<tr class="even"> + <td colspan="2"><strong>Shipping Details:</strong></td> +</tr> + +<tr class="odd"> + <td><strong>Ship Before:</strong> + <td> + [% INCLUDE bug/field.html.tmpl + bug = default, + field = bug_fields.cf_due_date + value = default.cf_due_date, + editable = 1, + no_tds = 1 + %] + </td> +</tr> + +<tr class="even"> + <td><strong>First Name: <span style="color: red;" title="Required">*</span></strong></td> + <td><input name="shiptofirstname" id="shiptofirstname" placeholder="John" size="40"></td> +</tr> + +<tr class="odd"> + <td><strong>Last Name: <span style="color: red;" title="Required">*</span></strong></td> + <td><input name="shiptolastname" id="shiptolastname" placeholder="Doe" size="40"></td> +</tr> + +<tr class="even"> + <td><strong>Address Line 1: <span style="color: red;" title="Required">*</span></strong></td> + <td><input name="shiptoaddress1" id="shiptoaddress1" placeholder="123 Main St." size="40"></td> +</tr> + +<tr class="odd"> + <td><strong>Address Line 2:</strong></td> + <td><input name="shiptoaddress2" id="shiptoaddress2" size="40"></td> +</tr> + +<tr class="even"> + <td><strong>City: <span style="color: red;" title="Required">*</span></strong></td> + <td><input name="shiptocity" id="shiptocity" size="40" placeholder="Anytown"></td> +</tr> + +<tr class="odd"> + <td><strong>State/Region (if applicable):</strong></td> + <td><input name="shiptostate" id="shiptostate" placeholder="CA" size="40"></td> +</tr> + +<tr class="even"> + <td><strong>Country: <span style="color: red;" title="Required">*</span></strong></td> + <td><input name="shiptocountry" id="shiptocountry" placeholder="USA" size="40"></td> +</tr> + +<tr class="odd"> + <td><strong>Postal Code: <span style="color: red;" title="Required">*</span></strong></td> + <td><input name="shiptopcode" id="shiptopcode" placeholder="90210" size="40"></td> +</tr> + +<tr class="even"> + <td><strong>Phone (including country code): <span style="color: red;" title="Required">*</span></strong></td> + <td><input name="shiptophone" id="shiptophone" placeholder="919-555-1212" size="40"></td> +</tr> + +<tr class="odd"> + <td><strong>Custom Reference<br> + (Fiscal or VAT-number, if known):</strong><br><small>(if your country requires this)</small> + </td> + <td><input name="shiptoidrut" id="shiptoidrut" size="40"></td> +</tr> + +<tr class="even"> + <td colspan="2"> + <strong>Addition information for delivery person:</strong><br> + <textarea id="shipadditional" name="shipadditional" rows="4"></textarea> + </td> +</tr> + +<tr class="odd"> + <td><!--spacer--> </td> + <td><!--spacer--> </td> +</tr> + +<tr class="even"> + <td colspan="2"><strong>Swag Requested:</strong></td> +</tr> + +<tr class="odd"> + <td><strong>Stickers:</strong></td> + <td><input type="checkbox" id="stickers" name="stickers" value="1"></td> +</tr> + +<tr class="even"> + <td><strong>Buttons:</strong></td> + <td><input type="checkbox" id="buttons" name="buttons" value="1"></td> +</tr> + +<tr class="odd"> + <td><strong>Lanyards:</strong></td> + <td><input type="checkbox" id="lanyards" name="lanyards" value="1"></td> +</tr> + +<tr class="even"> + <td><strong>T-Shirts:</strong></td> + <td><input type="checkbox" id="tshirts" name="tshirts" value="1"></td> +</tr> + +<tr class="odd"> + <td><strong>Roll-Up Banners:</strong></td> + <td><input type="checkbox" id="rollupbanners" name="rollupbanners" value="1"></td> +</tr> + +<tr class="even"> + <td><strong>Horizontal Banner:</strong></td> + <td><input type="checkbox" id="horizontalbanner" name="horizontalbanner" value="1"></td> +</tr> + +<tr class="odd"> + <td><strong>Booth Cloth:</strong></td> + <td><input type="checkbox" id="boothcloth" name="boothcloth" value="1"></td> +</tr> + +<tr class="even"> + <td><strong>Pens:</strong></td> + <td><input type="checkbox" id="pens" name="pens" value="1"></td> +</tr> + +<tr class="odd"> + <td><strong>Other:</strong> (please specify)</td> + <td><input type="text" id="otherswag" name="otherswag" size="40"></td> +</tr> + +<tr class="even"> + <td> </td> + <td align="right"> + <input type="submit" id="commit" value="Submit Request"> + </td> +</tr> + +</table> + +<p> + Quantities of different swag items requested that will actually be shipped + depend on stock availability and number of attendees. Mozilla cannot guarantee + that all items requested will be in stock at the time of shipment and you will + be notified in case an item cannot be shipped. Please request swag at least 1 + month before desired delivery date. +</p> + +<p> + <strong><span style="color: red;">*</span></strong> - Required field<br /> + Thanks for contacting us. + You will be notified by email of any progress made in resolving your request. +</p> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/REMO/template/en/default/bug/create/create-remo-swag.xml.tmpl b/extensions/REMO/template/en/default/bug/create/create-remo-swag.xml.tmpl new file mode 100644 index 000000000..4308bc5ac --- /dev/null +++ b/extensions/REMO/template/en/default/bug/create/create-remo-swag.xml.tmpl @@ -0,0 +1,104 @@ +[%# 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 Bugzilla %] +[% cgi = Bugzilla.cgi %] +<?xml version="1.0" [% IF Param('utf8') %]encoding="UTF-8" [% END %]standalone="yes" ?> +<!DOCTYPE remoswag [ +<!ELEMENT remoswag (firstname, + lastname, + wikiprofile, + eventname, + wikipage, + attendance, + shipping, + swagrequested)> +<!ELEMENT firstname (#PCDATA)> +<!ELEMENT lastname (#PCDATA)> +<!ELEMENT wikiprofile (#PCDATA)> +<!ELEMENT eventname (#PCDATA)> +<!ELEMENT wikipage (#PCDATA)> +<!ELEMENT attendance (#PCDATA)> +<!ELEMENT shipping (shipbeforedate, + shiptofirstname, + shiptolastname, + shiptoaddress1, + shiptoaddress2, + shiptocity, + shiptostate, + shiptopcode, + shiptocountry, + shiptophone, + shiptoidrut, + shipadditional)> +<!ELEMENT shipbeforedate (#PCDATA)> +<!ELEMENT shiptofirstname (#PCDATA)> +<!ELEMENT shiptolastname (#PCDATA)> +<!ELEMENT shiptoaddress1 (#PCDATA)> +<!ELEMENT shiptoaddress2 (#PCDATA)> +<!ELEMENT shiptocity (#PCDATA)> +<!ELEMENT shiptostate (#PCDATA)> +<!ELEMENT shiptopcode (#PCDATA)> +<!ELEMENT shiptocountry (#PCDATA)> +<!ELEMENT shiptophone (#PCDATA)> +<!ELEMENT shiptoidrut (#PCDATA)> +<!ELEMENT shipadditional (#PCDATA)> +<!ELEMENT swagrequested (stickers, + buttons, + posters, + lanyards, + tshirts, + rollupbanners, + horizontalbanner, + boothcloth, + pens, + otherswag)> +<!ELEMENT stickers (#PCDATA)> +<!ELEMENT buttons (#PCDATA)> +<!ELEMENT posters (#PCDATA)> +<!ELEMENT lanyards (#PCDATA)> +<!ELEMENT tshirts (#PCDATA)> +<!ELEMENT rollupbanners (#PCDATA)> +<!ELEMENT horizontalbanners (#PCDATA)> +<!ELEMENT boothcloth (#PCDATA)> +<!ELEMENT pens (#PCDATA)> +<!ELEMENT otherswag (#PCDATA)>]> +<remoswag> + <firstname>[% cgi.param('firstname') FILTER xml %]</firstname> + <lastname>[% cgi.param('lastname') FILTER xml %]</lastname> + <wikiprofile>[% cgi.param('wikiprofile') FILTER xml %]</wikiprofile> + <eventname>[% cgi.param('eventname') FILTER xml %]</eventname> + <wikipage>[% cgi.param('wikipage') FILTER xml %]</wikipage> + <attendance> [% cgi.param('attendance') FILTER xml %]</attendance> + <shipping> + <shipbeforedate>[% cgi.param('cf_due_date') FILTER xml %]</shipbeforedate> + <shiptofirstname>[% cgi.param("shiptofirstname") FILTER xml %]</shiptofirstname> + <shiptolastname>[% cgi.param("shiptolastname") FILTER xml %]</shiptolastname> + <shiptoaddress1>[% cgi.param("shiptoaddress1") FILTER xml %]</shiptoaddress1> + <shiptoaddress2>[% cgi.param("shiptoaddress2") FILTER xml %]</shiptoaddress2> + <shiptocity>[% cgi.param("shiptocity") FILTER xml %]</shiptocity> + <shiptostate>[% cgi.param("shiptostate") FILTER xml %]</shiptostate> + <shiptopcode>[% cgi.param("shiptopcode") FILTER xml %]</shiptopcode> + <shiptocountry>[% cgi.param("shiptocountry") FILTER xml %]</shiptocountry> + <shiptophone>[% cgi.param("shiptophone") FILTER xml %]</shiptophone> + <shiptoidrut>[% cgi.param("shiptoidrut") FILTER xml %]</shiptoidrut> + <shipadditional>[% cgi.param('shipadditional') || '' FILTER xml %]</shipadditional> + </shipping> + <swagrequested> + <stickers>[% (cgi.param('stickers') ? 1 : 0) FILTER xml %]</stickers> + <buttons>[% (cgi.param('buttons') ? 1 : 0) FILTER xml %]</buttons> + <posters>[% (cgi.param('posters') ? 1 : 0) FILTER xml %]</posters> + <lanyards>[% (cgi.param('lanyards') ? 1 : 0) FILTER xml %]</lanyards> + <tshirts>[% (cgi.param('tshirts') ? 1 : 0) FILTER xml %]</tshirts> + <rollupbanners>[% (cgi.param('rollupbanners') ? 1 : 0) FILTER xml %]</rollupbanners> + <horizontalbanner>[% (cgi.param('horizontalbanner') ? 1 : 0) FILTER xml %]</horizontalbanner> + <boothcloth>[% (cgi.param('boothcloth') ? 1 : 0) FILTER xml %]</boothcloth> + <pens>[% (cgi.param('pens') ? 1 : 0) FILTER xml %]</pens> + <otherswag>[% cgi.param('otherswag') || '' FILTER xml %]</otherswag> + </swagrequested> +</remoswag> diff --git a/extensions/REMO/template/en/default/bug/create/created-mozreps.html.tmpl b/extensions/REMO/template/en/default/bug/create/created-mozreps.html.tmpl new file mode 100644 index 000000000..a8a3ca112 --- /dev/null +++ b/extensions/REMO/template/en/default/bug/create/created-mozreps.html.tmpl @@ -0,0 +1,38 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the REMO Bugzilla Extension. + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): Byron Jones <glob@mozilla.com> + #%] + +[% PROCESS global/variables.none.tmpl %] + +[% PROCESS global/header.html.tmpl + title = "Mozilla Reps - Application Form" + +%] + +<h1>Thank you!</h1> + +<p> +Thank you for submitting your Mozilla Reps Application Form. A Mozilla Rep +mentor will contact you shortly at your bugzilla email address. +</p> + +<p style="font-size: x-small"> +Reference: <a href="show_bug.cgi?id=[% id FILTER uri %]">#[% id FILTER html %]</a> +</p> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/REMO/template/en/default/bug/create/created-remo-budget.html.tmpl b/extensions/REMO/template/en/default/bug/create/created-remo-budget.html.tmpl new file mode 100644 index 000000000..62430bf9c --- /dev/null +++ b/extensions/REMO/template/en/default/bug/create/created-remo-budget.html.tmpl @@ -0,0 +1,27 @@ +[%# 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 = "Mozilla Reps Budget Request Form" +%] + +<h1>Thank you!</h1> + +<p> + Your budget request has been successfully submitted. Please make sure to + follow-up with your mentor so (s)he can verify your request. CC him/her + on the [% terms.bug %] if needed. +</p> + +<p style="font-size: x-small"> + Reference: <a href="show_bug.cgi?id=[% id FILTER uri %]">#[% id FILTER html %]</a> +</p> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/REMO/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/REMO/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..200e678be --- /dev/null +++ b/extensions/REMO/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,40 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the REMO Extension + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): + # Byron Jones <bjones@mozilla.com> + # David Lawrence <dkl@mozilla.com> + #%] + +[% IF error == "remo_payment_invalid_product" %] + [% title = "Mozilla Reps Payment Invalid Bug" %] + You can only attach budget payment information to [% terms.bugs %] under + the product 'Mozilla Reps' and component 'Budget Requests'. + +[% ELSIF error == "remo_payment_bug_edit_denied" %] + [% title = "Mozilla Reps Payment Bug Edit Denied" %] + You do not have permission to edit [% terms.bug %] '[% bug_id FILTER html %]'. + +[% ELSIF error == "remo_payment_cancel_dupe" %] + [% title = "Already filed payment request" %] + You already used the form to file + <a href="[% urlbase FILTER html %]attachment.cgi?id=[% attachid FILTER uri %]&action=edit"> + attachment [% attachid FILTER uri %]</a>.<br> + <br> + You can either <a href="[% urlbase FILTER html %]page.cgi?id=remo-form-payment.html"> + create a new payment request</a> or [% "go back to $terms.bug $bugid" FILTER bug_link(bugid) FILTER none %]. + +[% END %] diff --git a/extensions/REMO/template/en/default/pages/comment-remo-form-payment.txt.tmpl b/extensions/REMO/template/en/default/pages/comment-remo-form-payment.txt.tmpl new file mode 100644 index 000000000..95c0af6e8 --- /dev/null +++ b/extensions/REMO/template/en/default/pages/comment-remo-form-payment.txt.tmpl @@ -0,0 +1,37 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the REMO Extension + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): + # Dave Lawrence <dkl@mozilla.com> + #%] + +[% USE Bugzilla %] +[% cgi = Bugzilla.cgi %] + +Mozilla Reps Payment Request +---------------------------- + +Requester info: + +First name: [% cgi.param('firstname') %] +Last name: [% cgi.param('lastname') %] +Wiki user profile: [% cgi.param('wikiprofile') %] +Event wiki page: [% cgi.param('wikipage') %] +Budget request [% terms.bug %]: [% cgi.param('bug_id') %] +Have you already received payment for this event? [% IF cgi.param('receivedpayment') %]Yes[% ELSE %]No[% END %] + +[%+ cgi.param("comment") IF cgi.param("comment") %] + diff --git a/extensions/REMO/template/en/default/pages/remo-form-payment.html.tmpl b/extensions/REMO/template/en/default/pages/remo-form-payment.html.tmpl new file mode 100644 index 000000000..0f5f206d3 --- /dev/null +++ b/extensions/REMO/template/en/default/pages/remo-form-payment.html.tmpl @@ -0,0 +1,243 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the REMO Extension + # + # The Initial Developer of the Original Code is the Mozilla Foundation + # Portions created by the Initial Developers are Copyright (C) 2011 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): + # Dave Lawrence <dkl@mozilla.com> + #%] + +[% PROCESS global/variables.none.tmpl %] + +[% PROCESS global/header.html.tmpl + title = "Mozilla Reps Payment Form" + style_urls = [ 'extensions/REMO/web/styles/moz_reps.css' ] + javascript_urls = [ 'extensions/REMO/web/js/form_validate.js', + 'js/util.js', + 'js/field.js' ] + yui = ['connection', 'json'] +%] + +<script language="javascript" type="text/javascript"> + +var bug_cache = {}; + +function validateAndSubmit() { + var alert_text = ''; + if(!isFilledOut('firstname')) alert_text += "Please enter your first name\n"; + if(!isFilledOut('lastname')) alert_text += "Please enter your last name\n"; + if(!isFilledOut('wikiprofile')) alert_text += "Please enter a wiki user profile.\n"; + if(!isFilledOut('wikipage')) alert_text += "Please enter a wiki page address.\n"; + if(!isFilledOut('bug_id')) alert_text += "Please enter a valid [% terms.bug %] id to attach this additional information to.\n"; + if(!isFilledOut('expenseform')) alert_text += "Please enter an expense form to upload.\n"; + if(!isFilledOut('receipts')) alert_text += "Please enter a receipts file to upload.\n"; + + if (alert_text) { + alert(alert_text); + return false; + } + + return true; +} + +function togglePaymentInfo (e) { + var div = document.getElementById('paymentinfo'); + if (e.checked == false) { + div.style.display = 'block'; + } + else { + div.style.display = 'none'; + } +} + +function getBugInfo (e, div) { + var bug_id = e.value; + div = document.getElementById(div); + + if (!bug_id) { + div.innerHTML = ""; + return true; + } + + div.style.display = 'block'; + + if (bug_cache[bug_id]) { + div.innerHTML = bug_cache[bug_id]; + e.disabled = false; + return true; + } + + e.disabled = true; + div.innerHTML = 'Getting [% terms.bug %] info...'; + + YAHOO.util.Connect.setDefaultPostHeader('application/json', true); + YAHOO.util.Connect.asyncRequest( + 'POST', + 'jsonrpc.cgi', + { + success: function(res) { + var bug_message = ""; + data = YAHOO.lang.JSON.parse(res.responseText); + if (data.error) { + bug_message = "Get [% terms.bug %] failed: " + data.error.message; + } + else if (data.result) { + if (data.result.bugs[0].product !== 'Mozilla Reps' + || data.result.bugs[0].component !== 'Budget Requests') + { + bug_message = "You can only attach budget payment " + + "information to [% terms.bugs %] under the product " + + "'Mozilla Reps' and component 'Budget Requests'."; + } + else { + bug_message = "[% terms.Bug %] " + bug_id + " - " + data.result.bugs[0].status + + " - " + data.result.bugs[0].summary; + } + } + else { + bug_message = "Get [% terms.bug %] failed: " + res.responseText; + } + div.innerHTML = bug_message; + bug_cache[bug_id] = bug_message; + e.disabled = false; + }, + failure: function(res) { + if (res.responseText) { + div.innerHTML = "Get [% terms.bug %] failed: " + res.responseText; + } + } + }, + YAHOO.lang.JSON.stringify({ + version: "1.1", + method: "Bug.get", + id: bug_id, + params: { + ids: [ bug_id ], + include_fields: [ 'product', 'component', 'status', 'summary' ] + } + }) + ); +} + +</script> + +<h1>Mozilla Reps - Payment Form</h1> + +<form method="post" action="page.cgi" id="paymentForm" enctype="multipart/form-data" + onSubmit="return validateAndSubmit();"> +<input type="hidden" id="id" name="id" value="remo-form-payment.html"> +<input type="hidden" id="token" name="token" value="[% token FILTER html %]"> +<input type="hidden" id="action" name="action" value="commit"> + +<table id="reps-form"> + +<tr class="odd"> + <td width="25%"><strong>First Name: <span style="color: red;">*</span></strong></td> + <td> + <input type="text" name="firstname" id="firstname" value="" size="40" placeholder="John"> + </td> +</tr> + +<tr class="even"> + <td><strong>Last Name: <span style="color: red;">*</span></strong></td> + <td> + <input type="text" name="lastname" id="lastname" value="" size="40" placeholder="Doe"> + </td> +</tr> + +<tr class="odd"> + <td><strong>Wiki user profile:<span style="color: red;">*</span></strong></td> + <td> + <input type="text" name="wikiprofile" id="wikiprofile" value="" size="40" placeholder="JohnDoe"> + </td> +</tr> + +<tr class="even"> + <td><strong>Event wiki page: <span style="color: red;">*</span></strong></td> + <td> + <input type="text" name="wikipage" id="wikipage" value="" size="40"> + </td> +</tr> + +<tr class="odd"> + <td><strong>Budget request [% terms.bug %]: <span style="color: red;">*</span></strong></td> + <td> + <input type="text" name="bug_id" id="bug_id" value="" size="40" + onblur="getBugInfo(this,'bug_info');")> + </td> +</tr> + +<tr class="odd"> + <td colspan="2"> + <div id="bug_info" style="display:none;"></div> + </td> +</tr> + +<tr class="even"> + <td colspan="2"> + <strong>Have you already received payment for this event?</strong> + <input type="checkbox" name="receivedpayment" id="receivedpayment" value="1" + onchange="togglePaymentInfo(this);" checked="true"> + <div id="paymentinfo" style="display:none;"> + Please send an email to William at mozilla.com with all the information below:<br> + <br> + Payment information:<br> + Bank name:<br> + Bank address: <br> + IBAN:<br> + Swift code/BIC:<br> + Additional bank details (if necessary): + </div> + </td> +</tr> + +<tr class="odd"> + <td colspan="2"> + <strong>Expense form and scanned receipts/invoices:</strong> + </td> +</tr> + +<tr class="odd"> + <td>Expense Form: <span style="color: red;">*</span></td> + <td><input type="file" id="expenseform" name="expenseform" size="40"></td> +</tr> + +<tr class="odd"> + <td valign="top">Receipts File: <span style="color: red;">*</span></td> + <td> + <input type="file" id="receipts" name="receipts" size="40"><br> + <font style="color:red;"> + Please black out any bank account information included<br> + on receipts before attaching them. + </font> + </td> +</tr> + +<tr class="even"> + <td> </td> + <td align="right"> + <input type="submit" id="commit" value="Submit Request"> + </td> +</tr> + +</table> + +</form> + +<p> + <strong><span style="color: red;">*</span></strong> - Required field<br> + Thanks for contacting us. +</p> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/REMO/web/js/form_validate.js b/extensions/REMO/web/js/form_validate.js new file mode 100644 index 000000000..6c8fa6f07 --- /dev/null +++ b/extensions/REMO/web/js/form_validate.js @@ -0,0 +1,21 @@ +/** + * Some Form Validation and Interaction + **/ +//Makes sure that there is an '@' in the address with a '.' +//somewhere after it (and at least one character in between them + +function isValidEmail(email) { + var at_index = email.indexOf("@"); + var last_dot = email.lastIndexOf("."); + return at_index > 0 && last_dot > (at_index + 1); +} + +//Takes a DOM element id and makes sure that it is filled out +function isFilledOut(elem_id) { + var str = document.getElementById(elem_id).value; + return str.length>0 && str!="noneselected"; +} + +function isChecked(elem_id) { + return document.getElementById(elem_id).checked; +} diff --git a/extensions/REMO/web/js/swag.js b/extensions/REMO/web/js/swag.js new file mode 100644 index 000000000..3b69bbab8 --- /dev/null +++ b/extensions/REMO/web/js/swag.js @@ -0,0 +1,60 @@ +/** + * Swag Request Form Functions + * Form Interal Swag Request Form + * dtran + * 7/6/09 + **/ + + +function evalToNumber(numberString) { + if(numberString=='') return 0; + return parseInt(numberString); +} + +function evalToNumberString(numberString) { + if(numberString=='') return '0'; + return numberString; +} +//item_array should be an array of DOM element ids +function getTotal(item_array) { + var total = 0; + for(var i in item_array) { + total += evalToNumber(document.getElementById(item_array[i]).value); + } + return total; +} + +function calculateTotalSwag() { + document.getElementById('Totalswag').value = + getTotal( new Array('Lanyards', + 'Stickers', + 'Bracelets', + 'Tattoos', + 'Buttons', + 'Posters')); + +} + + +function calculateTotalMensShirts() { + document.getElementById('mens_total').value = + getTotal( new Array('mens_s', + 'mens_m', + 'mens_l', + 'mens_xl', + 'mens_xxl', + 'mens_xxxl')); + +} + + +function calculateTotalWomensShirts() { + document.getElementById('womens_total').value = + getTotal( new Array('womens_s', + 'womens_m', + 'womens_l', + 'womens_xl', + 'womens_xxl', + 'womens_xxxl')); + +} diff --git a/extensions/REMO/web/styles/moz_reps.css b/extensions/REMO/web/styles/moz_reps.css new file mode 100644 index 000000000..216bdd234 --- /dev/null +++ b/extensions/REMO/web/styles/moz_reps.css @@ -0,0 +1,53 @@ +#reps-form { + width: 700px; + border-spacing: 0px; + border: 4px solid #e0e0e0; +} + +#reps-form th, #reps-form td { + padding: 5px; + vertical-align: top; +} + +#reps-form .even th, #reps-form .even td { + background: #e0e0e0; +} + +#reps-form th { + text-align: left; +} + +#reps-form textarea { + font-family: Verdana, sans-serif; + font-size: small; + width: 590px; +} + +#reps-form textarea.small { + width: 295px; +} + +#reps-form .mandatory { + color: red; + font-size: 80%; +} + +#reps-form .missing { + box-shadow: #FF0000 0 0 1.5px 1px; +} + +#reps-form .hidden { + display: none; +} + +#reps-form .subTH { + padding-left: 2em; +} + +#reps-form .missing { + background: #FFC1C1; +} + +.yui-calcontainer { + z-index: 2; +} diff --git a/extensions/RequestNagger/Config.pm b/extensions/RequestNagger/Config.pm new file mode 100644 index 000000000..6b9488c80 --- /dev/null +++ b/extensions/RequestNagger/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::RequestNagger; +use strict; + +use constant NAME => 'RequestNagger'; +use constant REQUIRED_MODULES => [ ]; +use constant OPTIONAL_MODULES => [ ]; + +__PACKAGE__->NAME; diff --git a/extensions/RequestNagger/Extension.pm b/extensions/RequestNagger/Extension.pm new file mode 100644 index 000000000..2be828fd1 --- /dev/null +++ b/extensions/RequestNagger/Extension.pm @@ -0,0 +1,349 @@ +# 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::RequestNagger; + +use strict; +use warnings; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Extension::RequestNagger::TimeAgo qw(time_ago); +use Bugzilla::Flag; +use Bugzilla::Install::Filesystem; +use Bugzilla::User::Setting; +use Bugzilla::Util qw(datetime_from detaint_natural); +use DateTime; + +our $VERSION = '1'; + +BEGIN { + *Bugzilla::Flag::age = \&_flag_age; + *Bugzilla::Flag::deferred = \&_flag_deferred; + *Bugzilla::Product::nag_interval = \&_product_nag_interval; +} + +sub _flag_age { + return time_ago(datetime_from($_[0]->modification_date)); +} + +sub _flag_deferred { + my ($self) = @_; + if (!exists $self->{deferred}) { + my $dbh = Bugzilla->dbh; + my ($defer_until) = $dbh->selectrow_array( + "SELECT defer_until FROM nag_defer WHERE flag_id=?", + undef, + $self->id + ); + $self->{deferred} = $defer_until ? datetime_from($defer_until) : undef; + } + return $self->{deferred}; +} + +sub _product_nag_interval { $_[0]->{nag_interval} } + +sub object_columns { + my ($self, $args) = @_; + my ($class, $columns) = @$args{qw(class columns)}; + if ($class->isa('Bugzilla::Product')) { + push @$columns, 'nag_interval'; + } +} + +sub object_update_columns { + my ($self, $args) = @_; + my ($object, $columns) = @$args{qw(object columns)}; + if ($object->isa('Bugzilla::Product')) { + push @$columns, 'nag_interval'; + } +} + +sub object_before_create { + my ($self, $args) = @_; + my ($class, $params) = @$args{qw(class params)}; + return unless $class->isa('Bugzilla::Product'); + my $input = Bugzilla->input_params; + if (exists $input->{nag_interval}) { + my $interval = _check_nag_interval($input->{nag_interval}); + $params->{nag_interval} = $interval; + } +} + +sub object_end_of_set_all { + my ($self, $args) = @_; + my ($object, $params) = @$args{qw(object params)}; + return unless $object->isa('Bugzilla::Product'); + my $input = Bugzilla->input_params; + if (exists $input->{nag_interval}) { + my $interval = _check_nag_interval($input->{nag_interval}); + $object->set('nag_interval', $interval); + } +} + +sub _check_nag_interval { + my ($value) = @_; + detaint_natural($value) + || ThrowUserError('invalid_parameter', { name => 'request reminding interval', err => 'must be numeric' }); + return $value < 0 ? 0 : $value * 24; +} + +sub page_before_template { + my ($self, $args) = @_; + my ($vars, $page) = @$args{qw(vars page_id)}; + return unless $page eq 'request_defer.html'; + + my $user = Bugzilla->login(LOGIN_REQUIRED); + my $input = Bugzilla->input_params; + + # load flag + my $flag_id = scalar($input->{flag}) + || ThrowUserError('request_nagging_flag_invalid'); + detaint_natural($flag_id) + || ThrowUserError('request_nagging_flag_invalid'); + my $flag = Bugzilla::Flag->new({ id => $flag_id, cache => 1 }) + || ThrowUserError('request_nagging_flag_invalid'); + + # you can only defer flags directed at you + $user->can_see_bug($flag->bug->id) + || ThrowUserError("bug_access_denied", { bug_id => $flag->bug->id }); + $flag->status eq '?' + || ThrowUserError('request_nagging_flag_set'); + $flag->requestee + || ThrowUserError('request_nagging_flag_wind'); + $flag->requestee->id == $user->id + || ThrowUserError('request_nagging_flag_not_owned'); + + my $date = DateTime->now()->truncate(to => 'day'); + my $defer_until; + if ($input->{'defer-until'} + && $input->{'defer-until'} =~ /^(\d\d\d\d)-(\d\d)-(\d\d)$/) + { + $defer_until = DateTime->new(year => $1, month => $2, day => $3); + if ($defer_until > $date->clone->add(days => 7)) { + $defer_until = undef; + } + } + + if ($input->{save} && $defer_until) { + $self->_defer_until($flag_id, $defer_until); + $vars->{saved} = "1"; + $vars->{defer_until} = $defer_until; + } + else { + my @dates; + foreach my $i (1..7) { + $date->add(days => 1); + unshift @dates, { days => $i, date => $date->clone }; + } + $vars->{defer_until} = \@dates; + } + + $vars->{flag} = $flag; +} + +sub _defer_until { + my ($self, $flag_id, $defer_until) = @_; + my $dbh = Bugzilla->dbh; + + $dbh->bz_start_transaction(); + + my ($defer_id) = $dbh->selectrow_array("SELECT id FROM nag_defer WHERE flag_id=?", undef, $flag_id); + if ($defer_id) { + $dbh->do("UPDATE nag_defer SET defer_until=? WHERE id=?", undef, $defer_until->ymd, $flag_id); + } else { + $dbh->do("INSERT INTO nag_defer(flag_id, defer_until) VALUES (?, ?)", undef, $flag_id, $defer_until->ymd); + } + + $dbh->bz_commit_transaction(); +} + +# +# hooks +# + +sub object_end_of_update { + my ($self, $args) = @_; + if ($args->{object}->isa("Bugzilla::Flag") && exists $args->{changes}) { + # any change to the flag (setting, clearing, or retargetting) will clear the deferals + my $flag = $args->{object}; + Bugzilla->dbh->do("DELETE FROM nag_defer WHERE flag_id=?", undef, $flag->id); + } +} + +sub user_preferences { + my ($self, $args) = @_; + my $tab = $args->{'current_tab'}; + return unless $tab eq 'request_nagging'; + + my $save = $args->{'save_changes'}; + my $vars = $args->{'vars'}; + my $user = Bugzilla->user; + my $dbh = Bugzilla->dbh; + + my %watching = + map { $_ => 1 } + @{ $dbh->selectcol_arrayref( + "SELECT profiles.login_name + FROM nag_watch + INNER JOIN profiles ON nag_watch.nagged_id = profiles.userid + WHERE nag_watch.watcher_id = ? + ORDER BY profiles.login_name", + undef, + $user->id + ) }; + + if ($save) { + my $input = Bugzilla->input_params; + Bugzilla::User::match_field({ 'add_watching' => {'type' => 'multi'} }); + + $dbh->bz_start_transaction(); + + # user preference + if (my $value = $input->{request_nagging}) { + my $settings = $user->settings; + my $setting = new Bugzilla::User::Setting('request_nagging'); + if ($value eq 'default') { + $settings->{request_nagging}->reset_to_default; + } + else { + $setting->validate_value($value); + $settings->{request_nagging}->set($value); + } + } + + # watching + if ($input->{remove_watched_users}) { + my $del_watching = ref($input->{del_watching}) ? $input->{del_watching} : [ $input->{del_watching} ]; + foreach my $login (@$del_watching) { + my $u = Bugzilla::User->new({ name => $login, cache => 1 }) + || next; + next unless exists $watching{$u->login}; + $dbh->do( + "DELETE FROM nag_watch WHERE watcher_id=? AND nagged_id=?", + undef, + $user->id, $u->id + ); + delete $watching{$u->login}; + } + } + if ($input->{add_watching}) { + my $add_watching = ref($input->{add_watching}) ? $input->{add_watching} : [ $input->{add_watching} ]; + foreach my $login (@$add_watching) { + my $u = Bugzilla::User->new({ name => $login, cache => 1 }) + || next; + next if exists $watching{$u->login}; + $dbh->do( + "INSERT INTO nag_watch(watcher_id, nagged_id) VALUES(?, ?)", + undef, + $user->id, $u->id + ); + $watching{$u->login} = 1; + } + } + + $dbh->bz_commit_transaction(); + } + + $vars->{watching} = [ sort keys %watching ]; + + my $handled = $args->{'handled'}; + $$handled = 1; +} + +# +# installation +# + +sub db_schema_abstract_schema { + my ($self, $args) = @_; + $args->{'schema'}->{'nag_watch'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + nagged_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE', + } + }, + watcher_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE', + } + }, + ], + INDEXES => [ + nag_watch_idx => { + FIELDS => [ 'nagged_id', 'watcher_id' ], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'nag_defer'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + flag_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'flags', + COLUMN => 'id', + DELETE => 'CASCADE', + } + }, + defer_until => { + TYPE => 'DATETIME', + NOTNULL => 1, + }, + ], + INDEXES => [ + nag_defer_idx => { + FIELDS => [ 'flag_id' ], + TYPE => 'UNIQUE', + }, + ], + }; +} + +sub install_update_db { + my $dbh = Bugzilla->dbh; + $dbh->bz_add_column('products', 'nag_interval', { TYPE => 'INT2', NOTNULL => 1, DEFAULT => 7 * 24 }); +} + +sub install_filesystem { + my ($self, $args) = @_; + my $files = $args->{'files'}; + my $extensions_dir = bz_locations()->{'extensionsdir'}; + my $script_name = $extensions_dir . "/" . __PACKAGE__->NAME . "/bin/send-request-nags.pl"; + $files->{$script_name} = { + perms => Bugzilla::Install::Filesystem::WS_EXECUTE + }; +} + +sub install_before_final_checks { + my ($self, $args) = @_; + add_setting('request_nagging', ['on', 'off'], 'on'); +} + +__PACKAGE__->NAME; diff --git a/extensions/RequestNagger/bin/send-request-nags.pl b/extensions/RequestNagger/bin/send-request-nags.pl new file mode 100755 index 000000000..93265b9ee --- /dev/null +++ b/extensions/RequestNagger/bin/send-request-nags.pl @@ -0,0 +1,209 @@ +#!/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; + +use FindBin qw($RealBin); +use lib "$RealBin/../../.."; + +use Bugzilla; +BEGIN { Bugzilla->extensions() } + +use Bugzilla::Attachment; +use Bugzilla::Bug; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Extension::RequestNagger::Constants; +use Bugzilla::Extension::RequestNagger::Bug; +use Bugzilla::Mailer; +use Bugzilla::User; +use Bugzilla::Util qw(format_time); +use Email::MIME; +use Sys::Hostname; + +Bugzilla->usage_mode(USAGE_MODE_CMDLINE); + +my $DO_NOT_NAG = grep { $_ eq '-d' } @ARGV; + +my $dbh = Bugzilla->dbh; +my $date = $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); +$date = format_time($date, '%a, %d %b %Y %T %z', 'UTC'); + +# delete expired defers +$dbh->do("DELETE FROM nag_defer WHERE defer_until <= CURRENT_DATE()"); +Bugzilla->switch_to_shadow_db(); + +# send nags to requestees +send_nags( + sql => REQUESTEE_NAG_SQL, + template => 'requestee', + recipient_field => 'requestee_id', + date => $date, +); + +# send nags to watchers +send_nags( + sql => WATCHING_NAG_SQL, + template => 'watching', + recipient_field => 'watcher_id', + date => $date, +); + +sub send_nags { + my (%args) = @_; + my $rows = $dbh->selectall_arrayref($args{sql}, { Slice => {} }); + + # iterate over rows, sending email when the current recipient changes + my $requests = []; + my $current_recipient; + foreach my $request (@$rows) { + # send previous user's requests + if (!$current_recipient || $request->{$args{recipient_field}} != $current_recipient->id) { + send_email(%args, recipient => $current_recipient, requests => $requests); + $current_recipient = Bugzilla::User->new({ id => $request->{$args{recipient_field}}, cache => 1 }); + $requests = []; + } + + # check group membership + $request->{requestee} = Bugzilla::User->new({ id => $request->{requestee_id}, cache => 1 }); + my $group; + foreach my $type (FLAG_TYPES) { + next unless $type->{type} eq $request->{flag_type}; + $group = $type->{group}; + last; + } + next unless $request->{requestee}->in_group($group); + + # check bug visibility + next unless $current_recipient->can_see_bug($request->{bug_id}); + + # create objects + $request->{bug} = Bugzilla::Bug->new({ id => $request->{bug_id}, cache => 1 }); + $request->{requester} = Bugzilla::User->new({ id => $request->{requester_id}, cache => 1 }); + $request->{flag} = Bugzilla::Flag->new({ id => $request->{flag_id}, cache => 1 }); + if ($request->{attach_id}) { + $request->{attachment} = Bugzilla::Attachment->new({ id => $request->{attach_id}, cache => 1 }); + # check attachment visibility + next if $request->{attachment}->isprivate && !$current_recipient->is_insider; + } + if (exists $request->{watcher_id}) { + $request->{watcher} = Bugzilla::User->new({ id => $request->{watcher_id}, cache => 1 }); + } + + # add this request to the current user's list + push(@$requests, $request); + } + send_email(%args, recipient => $current_recipient, requests => $requests); +} + +sub send_email { + my (%vars) = @_; + my $vars = \%vars; + return unless $vars->{recipient} && @{ $vars->{requests} }; + + my $request_list = delete $vars->{requests}; + + # if securemail is installed, we need to encrypt or censor emails which + # contain non-public bugs + my $default_user = Bugzilla::User->new(); + my $securemail = $vars->{recipient}->can('public_key'); + my $has_key = $securemail && $vars->{recipient}->public_key; + # have to do this each time as objects are shared between requests + my $has_private_bug = 0; + foreach my $request (@{ $request_list }) { + # rebless bug objects into our subclass + bless($request->{bug}, 'Bugzilla::Extension::RequestNagger::Bug'); + # and tell that object to hide the summary if required + if ($securemail && !$default_user->can_see_bug($request->{bug})) { + $has_private_bug = 1; + $request->{bug}->{secure_bug} = !$has_key; + } + else { + $request->{bug}->{secure_bug} = 0; + } + } + my $encrypt = $securemail && $has_private_bug && $has_key; + + # restructure the list to group by requestee then flag type + my $requests = {}; + my %seen_types; + foreach my $request (@{ $request_list }) { + # by requestee + my $requestee_login = $request->{requestee}->login; + $requests->{$requestee_login} ||= { + requestee => $request->{requestee}, + types => {}, + typelist => [], + }; + + # by flag type + my $types = $requests->{$requestee_login}->{types}; + my $flag_type = $request->{flag_type}; + $types->{$flag_type} ||= []; + + push @{ $types->{$flag_type} }, $request; + $seen_types{$requestee_login}{$flag_type} = 1; + } + foreach my $requestee_login (keys %seen_types) { + my @flag_types; + foreach my $flag_type (map { $_->{type} } FLAG_TYPES) { + push @flag_types, $flag_type if $seen_types{$requestee_login}{$flag_type}; + } + $requests->{$requestee_login}->{typelist} = \@flag_types; + } + $vars->{requests} = $requests; + + # generate email + my $template = Bugzilla->template_inner($vars->{recipient}->setting('lang')); + my $template_file = $vars->{template}; + + my ($header, $text); + $template->process("email/request_nagging-$template_file-header.txt.tmpl", $vars, \$header) + || ThrowTemplateError($template->error()); + $header .= "\n"; + $template->process("email/request_nagging-$template_file.txt.tmpl", $vars, \$text) + || ThrowTemplateError($template->error()); + + my @parts = ( + Email::MIME->create( + attributes => { content_type => "text/plain" }, + body => $text, + ) + ); + if ($vars->{recipient}->setting('email_format') eq 'html') { + my $html; + $template->process("email/request_nagging-$template_file.html.tmpl", $vars, \$html) + || ThrowTemplateError($template->error()); + push @parts, Email::MIME->create( + attributes => { content_type => "text/html" }, + body => $html, + ); + } + + my $email = Email::MIME->new($header); + $email->header_set('X-Generated-By' => hostname()); + if (scalar(@parts) == 1) { + $email->content_type_set($parts[0]->content_type); + } else { + $email->content_type_set('multipart/alternative'); + } + $email->parts_set(\@parts); + if ($encrypt) { + $email->header_set('X-Bugzilla-Encrypt' => '1'); + } + + # send + if ($DO_NOT_NAG) { + print $email->as_string, "\n"; + } else { + MessageToMTA($email); + } +} + diff --git a/extensions/RequestNagger/lib/Bug.pm b/extensions/RequestNagger/lib/Bug.pm new file mode 100644 index 000000000..de6d5eae5 --- /dev/null +++ b/extensions/RequestNagger/lib/Bug.pm @@ -0,0 +1,30 @@ +# 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::RequestNagger::Bug; + +use strict; +use parent qw(Bugzilla::Bug); + +sub short_desc { + my ($self) = @_; + return $self->{secure_bug} ? '(Secure bug)' : $self->SUPER::short_desc; +} + +sub tooltip { + my ($self) = @_; + my $tooltip = $self->bug_status; + if ($self->bug_status eq 'RESOLVED') { + $tooltip .= '/' . $self->resolution; + } + if (!$self->{secure_bug}) { + $tooltip .= ' ' . $self->product . ' :: ' . $self->component; + } + return $tooltip; +} + +1; diff --git a/extensions/RequestNagger/lib/Constants.pm b/extensions/RequestNagger/lib/Constants.pm new file mode 100644 index 000000000..f61e616a7 --- /dev/null +++ b/extensions/RequestNagger/lib/Constants.pm @@ -0,0 +1,116 @@ +# 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::RequestNagger::Constants; + +use strict; +use base qw(Exporter); + +our @EXPORT = qw( + FLAG_TYPES + REQUESTEE_NAG_SQL + WATCHING_NAG_SQL +); + +# the order of this array determines the order used in email +use constant FLAG_TYPES => ( + { + type => 'review', # flag_type.name + group => 'everyone', # the user must be a member of this group to receive reminders + }, + { + type => 'superview', + group => 'everyone', + }, + { + type => 'feedback', + group => 'everyone', + }, + { + type => 'needinfo', + group => 'editbugs', + }, +); + +sub REQUESTEE_NAG_SQL { + my $dbh = Bugzilla->dbh; + my @flag_types_sql = map { $dbh->quote($_->{type}) } FLAG_TYPES; + + return " + SELECT + flagtypes.name AS flag_type, + flags.id AS flag_id, + flags.bug_id, + flags.attach_id, + flags.modification_date, + requester.userid AS requester_id, + requestee.userid AS requestee_id + FROM + flags + INNER JOIN flagtypes ON flagtypes.id = flags.type_id + INNER JOIN profiles AS requester ON requester.userid = flags.setter_id + INNER JOIN profiles AS requestee ON requestee.userid = flags.requestee_id + INNER JOIN bugs ON bugs.bug_id = flags.bug_id + INNER JOIN products ON products.id = bugs.product_id + LEFT JOIN attachments ON attachments.attach_id = flags.attach_id + LEFT JOIN profile_setting ON profile_setting.setting_name = 'request_nagging' + AND profile_setting.user_id = flags.requestee_id + LEFT JOIN nag_defer ON nag_defer.flag_id = flags.id + WHERE + " . $dbh->sql_in('flagtypes.name', \@flag_types_sql) . " + AND flags.status = '?' + AND products.nag_interval != 0 + AND TIMESTAMPDIFF(HOUR, flags.modification_date, CURRENT_DATE()) >= products.nag_interval + AND (profile_setting.setting_value IS NULL OR profile_setting.setting_value = 'on') + AND requestee.disable_mail = 0 + AND nag_defer.id IS NULL + ORDER BY + flags.requestee_id, + flagtypes.name, + flags.modification_date + "; +} + +sub WATCHING_NAG_SQL { + my $dbh = Bugzilla->dbh; + my @flag_types_sql = map { $dbh->quote($_->{type}) } FLAG_TYPES; + + return " + SELECT + nag_watch.watcher_id, + flagtypes.name AS flag_type, + flags.id AS flag_id, + flags.bug_id, + flags.attach_id, + flags.modification_date, + requester.userid AS requester_id, + requestee.userid AS requestee_id + FROM + flags + INNER JOIN flagtypes ON flagtypes.id = flags.type_id + INNER JOIN profiles AS requester ON requester.userid = flags.setter_id + INNER JOIN profiles AS requestee ON requestee.userid = flags.requestee_id + INNER JOIN bugs ON bugs.bug_id = flags.bug_id + INNER JOIN products ON products.id = bugs.product_id + LEFT JOIN attachments ON attachments.attach_id = flags.attach_id + LEFT JOIN nag_defer ON nag_defer.flag_id = flags.id + INNER JOIN nag_watch ON nag_watch.nagged_id = flags.requestee_id + INNER JOIN profiles AS watcher ON watcher.userid = nag_watch.watcher_id + WHERE + " . $dbh->sql_in('flagtypes.name', \@flag_types_sql) . " + AND flags.status = '?' + AND products.nag_interval != 0 + AND TIMESTAMPDIFF(HOUR, flags.modification_date, CURRENT_DATE()) >= products.nag_interval + AND watcher.disable_mail = 0 + ORDER BY + nag_watch.watcher_id, + flags.requestee_id, + flags.modification_date + "; +} + +1; diff --git a/extensions/RequestNagger/lib/TimeAgo.pm b/extensions/RequestNagger/lib/TimeAgo.pm new file mode 100644 index 000000000..3dfbbeaac --- /dev/null +++ b/extensions/RequestNagger/lib/TimeAgo.pm @@ -0,0 +1,186 @@ +# 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::RequestNagger::TimeAgo; + +use strict; +use utf8; +use DateTime; +use Carp; +use Exporter qw(import); + +use if $ENV{ARCH_64BIT}, 'integer'; + +our @EXPORT_OK = qw(time_ago); + +our $VERSION = '0.06'; + +my @ranges = ( + [ -1, 'in the future' ], + [ 60, 'just now' ], + [ 900, 'a few minutes ago'], # 15*60 + [ 3000, 'less than an hour ago'], # 50*60 + [ 4500, 'about an hour ago'], # 75*60 + [ 7200, 'more than an hour ago'], # 2*60*60 + [ 21600, 'several hours ago'], # 6*60*60 + [ 86400, 'today', sub { # 24*60*60 + my $time = shift; + my $now = shift; + if ( $time->day < $now->day + or $time->month < $now->month + or $time->year < $now->year + ) { + return 'yesterday' + } + if ($time->hour < 5) { + return 'tonight' + } + if ($time->hour < 10) { + return 'this morning' + } + if ($time->hour < 15) { + return 'today' + } + if ($time->hour < 19) { + return 'this afternoon' + } + return 'this evening' + }], + [ 172800, 'yesterday'], # 2*24*60*60 + [ 604800, 'this week'], # 7*24*60*60 + [ 1209600, 'last week'], # 2*7*24*60*60 + [ 2678400, 'this month', sub { # 31*24*60*60 + my $time = shift; + my $now = shift; + if ($time->year == $now->year and $time->month == $now->month) { + return 'this month' + } + return 'last month' + }], + [ 5356800, 'last month'], # 2*31*24*60*60 + [ 24105600, 'several months ago'], # 9*31*24*60*60 + [ 31536000, 'about a year ago'], # 365*24*60*60 + [ 34214400, 'last year'], # (365+31)*24*60*60 + [ 63072000, 'more than a year ago'], # 2*365*24*60*60 + [ 283824000, 'several years ago'], # 9*365*24*60*60 + [ 315360000, 'about a decade ago'], # 10*365*24*60*60 + [ 630720000, 'last decade'], # 20*365*24*60*60 + [ 2838240000, 'several decades ago'], # 90*365*24*60*60 + [ 3153600000, 'about a century ago'], # 100*365*24*60*60 + [ 6307200000, 'last century'], # 200*365*24*60*60 + [ 6622560000, 'more than a century ago'], # 210*365*24*60*60 + [ 28382400000, 'several centuries ago'], # 900*365*24*60*60 + [ 31536000000, 'about a millenium ago'], # 1000*365*24*60*60 + [ 63072000000, 'more than a millenium ago'], # 2000*365*24*60*60 +); + +sub time_ago { + my ($time, $now) = @_; + + if (not defined $time or not $time->isa('DateTime')) { + croak('DateTime::Duration::Fuzzy::time_ago needs a DateTime object as first parameter') + } + if (not defined $now) { + $now = DateTime->now(); + } + if (not $now->isa('DateTime')) { + croak('Invalid second parameter provided to DateTime::Duration::Fuzzy::time_ago; it must be a DateTime object if provided') + } + + my $dur = $now->subtract_datetime_absolute($time)->in_units('seconds'); + + foreach my $range ( @ranges ) { + if ( $dur <= $range->[0] ) { + if ( $range->[2] ) { + return $range->[2]->($time, $now) + } + return $range->[1] + } + } + + return 'millenia ago' +} + +1 + +__END__ + +=head1 NAME + +DateTime::Duration::Fuzzy -- express dates as fuzzy human-friendly strings + +=head1 SYNOPSIS + + use DateTime::Duration::Fuzzy qw(time_ago); + use DateTime; + + my $now = DateTime->new( + year => 2010, month => 12, day => 12, + hour => 19, minute => 59, + ); + my $then = DateTime->new( + year => 2010, month => 12, day => 12, + hour => 15, + ); + print time_ago($then, $now); + # outputs 'several hours ago' + + print time_ago($then); + # $now taken from C<time> function + +=head1 DESCRIPTION + +DateTime::Duration::Fuzzy is inspired from the timeAgo jQuery module +L<http://timeago.yarp.com/>. + +It takes two DateTime objects -- first one representing a moment in the past +and second optional one representine the present, and returns a human-friendly +fuzzy expression of the time gone. + +=head2 functions + +=over 4 + +=item time_ago($then, $now) + +The only exportable function. + +First obligatory parameter is a DateTime object. + +Second optional parameter is also a DateTime object. +If it's not provided, then I<now> as the C<time> function returns is +substituted. + +Returns a string expression of the interval between the two DateTime +objects, like C<several hours ago>, C<yesterday> or <last century>. + +=back + +=head2 performance + +On 64bit machines, it is asvisable to 'use integer', which makes +the calculations faster. You can turn this on by setting the +C<ARCH_64BIT> environmental variable to a true value. + +If you do this on a 32bit machine, you will get wrong results for +intervals starting with "several decades ago". + +=head1 AUTHOR + +Jan Oldrich Kruza, C<< <sixtease at cpan.org> >> + +=head1 LICENSE AND COPYRIGHT + +Copyright 2010 Jan Oldrich Kruza. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See http://dev.perl.org/licenses/ for more information. + +=cut diff --git a/extensions/RequestNagger/template/en/default/account/prefs/request_nagging.html.tmpl b/extensions/RequestNagger/template/en/default/account/prefs/request_nagging.html.tmpl new file mode 100644 index 000000000..34bba0064 --- /dev/null +++ b/extensions/RequestNagger/template/en/default/account/prefs/request_nagging.html.tmpl @@ -0,0 +1,56 @@ +[%# 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. + #%] + +<label for="request_nagging"> + Send me reminders for overdue requests: +</label> +<select name="request_nagging" id="request_nagging"> + <option value="default" [% "selected" IF user.settings.request_nagging.is_default %]> + Site Default (On) + </option> + <option value="on" [% "selected" IF !user.settings.request_nagging.is_default + && user.settings.request_nagging.value == "on" %]> + On + </option> + <option value="off" [% "selected" IF !user.settings.request_nagging.is_default + && user.settings.request_nagging.value == "off" %]> + Off + </option> +</select> + +<h4>User Request Reminder Watching</h4> + +<p> + If you watch a user, you will receive a report of their overdue + requests. +</p> + +<p> + [% IF watching.size %] + You are watching everyone in the following list:<br> + <select id="del_watching" name="del_watching" multiple="multiple" size="5"> + [% FOREACH u = watching %] + <option value="[% u FILTER html %]">[% u FILTER html %]</option> + [% END %] + </select><br> + <input type="checkbox" id="remove_watched_users" name="remove_watched_users"> + <label for="remove_watched_users">Remove selected users from my watch list</label> + [% ELSE %] + <i>You are currently not watching any users.</i> + [% END %] +</p> + +<p>Add users to my watch list (comma separated list): + [% INCLUDE global/userselect.html.tmpl + id => "add_watching" + name => "add_watching" + value => "" + size => 60 + multiple => 5 + %] +</p> diff --git a/extensions/RequestNagger/template/en/default/email/request_nagging-requestee-header.txt.tmpl b/extensions/RequestNagger/template/en/default/email/request_nagging-requestee-header.txt.tmpl new file mode 100644 index 000000000..8ad9d6cb1 --- /dev/null +++ b/extensions/RequestNagger/template/en/default/email/request_nagging-requestee-header.txt.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. + #%] + +[% PROCESS "global/field-descs.none.tmpl" %] +[% PROCESS "global/reason-descs.none.tmpl" %] +From: [% Param('mailfrom') %] +To: [% recipient.email %] +Subject: [[% terms.Bugzilla %]] Your Overdue Requests + ([% FOREACH type = requests.item(recipient.email).typelist %] + [%- requests.item(recipient.email).types.item(type).size %] [%+ type %] + [% ", " UNLESS loop.last %] + [% END %]) +Date: [% date %] +X-Bugzilla-Type: nag diff --git a/extensions/RequestNagger/template/en/default/email/request_nagging-requestee.html.tmpl b/extensions/RequestNagger/template/en/default/email/request_nagging-requestee.html.tmpl new file mode 100644 index 000000000..cc570e4c4 --- /dev/null +++ b/extensions/RequestNagger/template/en/default/email/request_nagging-requestee.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. + #%] + +[% PROCESS "global/field-descs.none.tmpl" %] + +<!doctype html> +<html> + +<head> + <title>[[% terms.Bugzilla %]] Your Overdue Requests</title> +</head> + +<body bgcolor="#ffffff"> + +<p> + The following is a list of requests people have made of you, which are + currently overdue. To avoid disappointing others, please deal with them as + quickly as possible. +</p> + +[% requests = requests.item(recipient.login) %] +[% FOREACH type = requests.typelist %] + + <h3> + [% type FILTER upper FILTER html %] requests + <span style="font-size: x-small; font-weight: normal"> + (<a href="[% urlbase FILTER none %]buglist.cgi?bug_id= + [% FOREACH request = requests.types.$type %] + [% request.bug.id FILTER none %] + [% "%2C" UNLESS loop.last %] + [% END %]">buglist</a>) + </span> + </h3> + + <ul> + [% FOREACH request = requests.types.$type %] + <li> + <a href="[% urlbase FILTER none %]show_bug.cgi?id=[% request.bug.id FILTER none %]" + title="[% request.bug.tooltip FILTER html %]"> + [% request.bug.id FILTER none %] - [% request.bug.short_desc FILTER html %] + </a><br> + <b>[%+ request.flag.age FILTER html %]</b> from [% request.requester.identity FILTER html %]<br> + <div style="font-size: x-small"> + [% IF request.attachment %] + <a href="[% urlbase FILTER none %]attachment.cgi?id=[% request.attachment.id FILTER none %]&action=edit">Details</a> + [% IF request.attachment.ispatch %] + | <a href="[% urlbase FILTER none %]attachment.cgi?id=[% request.attachment.id FILTER none %]&action=diff">Diff</a> + | <a href="[% urlbase FILTER none %]review?bug=[% request.bug.id FILTER none %]&attachment=[% request.attachment.id FILTER none %]">Review</a> + [% END %] + | + [% END %] + <a href="[% urlbase FILTER none %]request_defer?flag=[% request.flag.id FILTER none %]">Defer</a> + </div> + <br> + </li> + [% END %] + </ul> + +[% END %] + +<div> + <hr style="border: 1px dashed #969696"> + [% IF requests.types.item('review').size || requests.types.item('feedback').size %] + <a href="https://wiki.mozilla.org/BMO/Handling_Requests"> + Guidance on handling requests + </a><br> + [% END %] + <a href="[% urlbase FILTER none %]request.cgi?action=queue&requestee=[% recipient.login FILTER uri %]&group=type"> + See all your overdue requests + </a><br> + <a href="[% urlbase FILTER none %]userprefs.cgi#request_nagging"> + Opt out of these emails + </a><br> +</div> + +<div style="font-size: 90%; color: #666666"> + <hr style="border: 1px dashed #969696"> + <b>You are receiving this mail because:</b> + <ul> + <li>You have overdue requests.</li> + </ul> +</div> + +</body> +</html> diff --git a/extensions/RequestNagger/template/en/default/email/request_nagging-requestee.txt.tmpl b/extensions/RequestNagger/template/en/default/email/request_nagging-requestee.txt.tmpl new file mode 100644 index 000000000..2dae504e5 --- /dev/null +++ b/extensions/RequestNagger/template/en/default/email/request_nagging-requestee.txt.tmpl @@ -0,0 +1,45 @@ +[%# 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/field-descs.none.tmpl" %] + +The following is a list of requests people have made of you, which are +currently overdue. To avoid disappointing others, please deal with them as +quickly as possible. + +[% requests = requests.item(recipient.login) %] +[% FOREACH type = requests.typelist %] +:: [% type FILTER upper FILTER html %] requests + +[% FOREACH request = requests.types.$type %] +[[% terms.Bug %] [%+ request.bug.id %]] [% request.bug.short_desc %] + [%+ request.flag.age %] from [% request.requester.identity %] + [%+ urlbase %]show_bug.cgi?id=[% request.bug.id +%] + [% IF request.attachment && request.attachment.ispatch %] + Review: [% urlbase %]review?bug=[% request.bug.id %]&attachment=[% request.attachment.id %] + [% END %] + Defer: [% urlbase %]request_defer?flag=[% request.flag.id %] + +[% END %] +[% END %] + +:: + +[% IF requests.types.item('review').size || requests.types.item('feedback').size %] +Guidance on handling requests: + https://wiki.mozilla.org/BMO/Handling_Requests +[% END %] + +See all your overdue requests: + [%+ urlbase %]request.cgi?action=queue&requestee=[% recipient.login FILTER uri %]&group=type + +Opt out of these emails: + [%+ urlbase %]userprefs.cgi#request_nagging + +-- +You are receiving this mail because: you have overdue requests. diff --git a/extensions/RequestNagger/template/en/default/email/request_nagging-watching-header.txt.tmpl b/extensions/RequestNagger/template/en/default/email/request_nagging-watching-header.txt.tmpl new file mode 100644 index 000000000..261e92f13 --- /dev/null +++ b/extensions/RequestNagger/template/en/default/email/request_nagging-watching-header.txt.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. + #%] + +[% PROCESS "global/field-descs.none.tmpl" %] +[% PROCESS "global/reason-descs.none.tmpl" %] +From: [% Param('mailfrom') %] +To: [% recipient.email %] +Subject: [[% terms.Bugzilla %]] Overdue Requests Report +Date: [% date %] +X-Bugzilla-Type: nag-watch diff --git a/extensions/RequestNagger/template/en/default/email/request_nagging-watching.html.tmpl b/extensions/RequestNagger/template/en/default/email/request_nagging-watching.html.tmpl new file mode 100644 index 000000000..a3010f8a5 --- /dev/null +++ b/extensions/RequestNagger/template/en/default/email/request_nagging-watching.html.tmpl @@ -0,0 +1,104 @@ +[%# 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/field-descs.none.tmpl" %] + +<!doctype html> +<html> + +<head> + <title>[[% terms.Bugzilla %]] Overdue Requests Report</title> +</head> + +<body bgcolor="#ffffff"> + +<p> + The following is a list of people who you are watching that have overdue + requests. +</p> + +<hr> + +[% FOREACH login = requests.keys.sort %] + [% requestee = requests.$login.requestee %] + [% requestee.identity FILTER html %] + <ul> + <li> + [%+ FOREACH type = requests.$login.typelist %] + [% requests.$login.types.item(type).size %] [%+ type FILTER html %] + [% ", " UNLESS loop.last %] + [% END %] + </li> + </ul> +[% END %] + +[% FOREACH login = requests.keys.sort %] + [% requestee = requests.$login.requestee %] + + [% bug_ids = [] %] + [% FOREACH type = requests.$login.typelist %] + [% FOREACH request = requests.$login.types.$type %] + [% bug_ids.push(request.bug.id) %] + [% END %] + [% END %] + + <hr> + <h3> + [% requestee.identity FILTER html %] + <span style="font-size: x-small; font-weight: normal"> + (<a href="[% urlbase FILTER none %]buglist.cgi?bug_id=[% bug_ids.join(",") FILTER uri %]">buglist</a>) + </span><br> + <span style="font-size: x-small; font-weight: normal"> + [% FOREACH type = requests.$login.typelist %] + [% requests.$login.types.item(type).size %] [%+ type FILTER html %] + [% ", " UNLESS loop.last %] + [% END %] + </span> + </h3> + + [% FOREACH type = requests.$login.typelist %] + + <h3>[% type FILTER upper FILTER html %] requests</h3> + + <ul> + [% FOREACH request = requests.$login.types.$type %] + <li> + <a href="[% urlbase FILTER none %]show_bug.cgi?id=[% request.bug.id FILTER none %]" + title="[% request.bug.tooltip FILTER html %]"> + [% request.bug.id FILTER none %] - [% request.bug.short_desc FILTER html %] + </a><br> + <b>[%+ request.flag.age FILTER html %]</b> from [% request.requester.identity FILTER html %]<br> + [% IF request.flag.deferred %] + Deferred until [%+ request.flag.deferred.ymd FILTER html %]<br> + [% END %] + <br> + </li> + [% END %] + </ul> + + [% END %] + +[% END %] + +<div> + <hr style="border: 1px dashed #969696"> + <a href="[% urlbase FILTER none %]userprefs.cgi?tab=request_nagging"> + Change who you are watching + </a> +</div> + +<div style="font-size: 90%; color: #666666"> + <hr style="border: 1px dashed #969696"> + <b>You are receiving this mail because:</b> + <ul> + <li>you are watching someone with overdue requests.</li> + </ul> +</div> + +</body> +</html> diff --git a/extensions/RequestNagger/template/en/default/email/request_nagging-watching.txt.tmpl b/extensions/RequestNagger/template/en/default/email/request_nagging-watching.txt.tmpl new file mode 100644 index 000000000..e36224109 --- /dev/null +++ b/extensions/RequestNagger/template/en/default/email/request_nagging-watching.txt.tmpl @@ -0,0 +1,47 @@ +[%# 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/field-descs.none.tmpl" %] + +The following is a list of people who you are watching that have overdue +requests. + +[% FOREACH login = requests.keys.sort %] +[% requestee = requests.$login.requestee %] +:: +:: [% requestee.identity %] +:: [% FOREACH type = requests.$login.typelist %] + [%- requests.$login.types.item(type).size %] [%+ type %] + [% ", " UNLESS loop.last %] + [% END %] +:: + +[% FOREACH type = requests.$login.typelist %] +:: [% type FILTER upper FILTER html %] requests + +[% FOREACH request = requests.$login.types.$type %] +[[% terms.Bug %] [%+ request.bug.id %]] [% request.bug.short_desc %] + [%+ request.flag.age %] from [% request.requester.identity %] + [%+ urlbase %]show_bug.cgi?id=[% request.bug.id +%] + [% IF request.flag.deferred %] + Deferred until [%+ request.flag.deferred.ymd %] + [% END %] + +[% END %] +[% END %] + +[% END %] + +:: + +Change who you are watching + [%+ urlbase %]userprefs.cgi?tab=request_nagging + +-- +You are receiving this mail because: you are watching someone with overdue +requests. diff --git a/extensions/RequestNagger/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl b/extensions/RequestNagger/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl new file mode 100644 index 000000000..ed3e29c64 --- /dev/null +++ b/extensions/RequestNagger/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl @@ -0,0 +1,14 @@ +[%# 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. + #%] + +[% tabs = tabs.import([{ + name => "request_nagging", + label => "Request Reminders", + link => "userprefs.cgi?tab=request_nagging", + saveable => 1 + }]) %] diff --git a/extensions/RequestNagger/template/en/default/hook/admin/products/edit-common-rows.html.tmpl b/extensions/RequestNagger/template/en/default/hook/admin/products/edit-common-rows.html.tmpl new file mode 100644 index 000000000..6dcd58f67 --- /dev/null +++ b/extensions/RequestNagger/template/en/default/hook/admin/products/edit-common-rows.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. + #%] + +<tr> + <th align="right">Remind for overdue requests after:</th> + <td> + <input name="nag_interval" size="5" + value="[% product.id ? product.nag_interval / 24 : 7 %]"> + days (Setting this to 0 disables request reminding). + </td> +</tr> diff --git a/extensions/RequestNagger/template/en/default/hook/admin/products/updated-changes.html.tmpl b/extensions/RequestNagger/template/en/default/hook/admin/products/updated-changes.html.tmpl new file mode 100644 index 000000000..9baccce86 --- /dev/null +++ b/extensions/RequestNagger/template/en/default/hook/admin/products/updated-changes.html.tmpl @@ -0,0 +1,14 @@ +[%# 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.nag_interval.defined %] + <p> + Changed request reminder interval from '[% changes.nag_interval.0 / 24 FILTER html %]' to + '[% product.nag_interval / 24 FILTER html %]'. + </p> +[% END %] diff --git a/extensions/RequestNagger/template/en/default/hook/global/setting-descs-settings.none.tmpl b/extensions/RequestNagger/template/en/default/hook/global/setting-descs-settings.none.tmpl new file mode 100644 index 000000000..c421a47de --- /dev/null +++ b/extensions/RequestNagger/template/en/default/hook/global/setting-descs-settings.none.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. + #%] + +[% + setting_descs.request_nagging = "Send me reminders for overdue requests" +%] diff --git a/extensions/RequestNagger/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/RequestNagger/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..12ef38370 --- /dev/null +++ b/extensions/RequestNagger/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,25 @@ +[%# 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 == "request_nagging_flag_invalid" %] + [% title = "Invalid Flag" %] + Invalid or missing Flag ID + +[% ELSIF error == "request_nagging_flag_set" %] + [% title = "Flag Already Set" %] + The requested Flag has been set, and is no longer pending. + +[% ELSIF error == "request_nagging_flag_wind" %] + [% title = "No Requestee" %] + The requested Flag does not have a requestee, and cannot be deferred. + +[% ELSIF error == "request_nagging_flag_not_owned" %] + [% title = "Not The Requestee" %] + You cannot defer Flags unless you are the requestee. + +[% END %] diff --git a/extensions/RequestNagger/template/en/default/pages/request_defer.html.tmpl b/extensions/RequestNagger/template/en/default/pages/request_defer.html.tmpl new file mode 100644 index 000000000..e89409ce1 --- /dev/null +++ b/extensions/RequestNagger/template/en/default/pages/request_defer.html.tmpl @@ -0,0 +1,101 @@ +[%# 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/header.html.tmpl + title = "Defer Request Reminder" + style_urls = [ "extensions/RequestNagger/web/style/requestnagger.css" ] + javascript_urls = [ "js/util.js" , "extensions/RequestNagger/web/js/requestnagger.js" ] +%] + +<h2>Defer Request Reminder</h2> + +[% IF saved %] + <div id="message"> + Request reminder deferral has been saved. + </div> +[% END %] + +<form method="post" action="page.cgi"> +<input type="hidden" name="id" value="request_defer.html"> +<input type="hidden" name="flag" value="[% flag.id FILTER none %]"> +<input type="hidden" name="save" value="1"> + +<table class="edit_form"> +<tr><td> + + <div class="flag-bug"> + <a href="show_bug.cgi?id=[% flag.bug.id FILTER none %]"> + [% terms.Bug %] [%+ flag.bug.id FILTER none %] + </a> + - + <a href="show_bug.cgi?id=[% flag.bug.id FILTER none %]"> + [% flag.bug.short_desc FILTER html %] + </a> + </div> + + [% IF flag.attachment %] + <div class="flag-attach"> + <div class="flag-attach-desc"> + <a href="attachment.cgi?id=[% flag.attachment.id FILTER none %]&action=edit"> + [% flag.attachment.description FILTER html %] + </a> + </div> + <div class="flag-attach-details"> + [% flag.attachment.filename FILTER html %] ([% flag.attachment.contenttype FILTER html %]), + [% IF flag.attachment.datasize %] + [%+ flag.attachment.datasize FILTER unitconvert %] + [% ELSE %] + <em>deleted</em> + [% END %], + created by [%+ INCLUDE global/user.html.tmpl who = flag.attachment.attacher %] + </div> + [% IF flag.attachment.ispatch %] + <div class="flag-attach-actions"> + <a href="attachment.cgi?id=[% flag.attachment.id FILTER none ~%] + &action=diff">Diff</a> | + <a href="review?bug=[% flag.bug.id FILTER none ~%] + &attachment=[% flag.attachment.id FILTER none %]">Review</a> + </div> + [% END %] + </div> + [% END %] + + <div class="flag-details"> + <span class="flag-type"> + [% flag.type.name FILTER html %] + </span> + requested by [%+ INCLUDE global/user.html.tmpl who = flag.setter %] + [% flag.age FILTER html %] + </div> + + [% IF saved %] + <div class="deferred"> + Deferred until [% defer_until.ymd FILTER html %]. + </div> + [% ELSE %] + <div class="defer"> + Defer[% "ed" IF flag.deferred %] for + <select name="defer-until" id="defer-until"> + [% FOREACH defer = defer_until %] + <option value="[% defer.date.ymd FILTER html %]" + [%+ "selected" IF flag.deferred.ymd == defer.date.ymd %] + > + [% defer.days FILTER html %] Day[% "s" UNLESS defer.days == 1 %] + </option> + [% END %] + </select> + <span id="defer-date"></span> + </div> + <input type="submit" value="Submit"> + [% END %] +</td></tr> +</table> + +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/RequestNagger/web/js/requestnagger.js b/extensions/RequestNagger/web/js/requestnagger.js new file mode 100644 index 000000000..e5cc43deb --- /dev/null +++ b/extensions/RequestNagger/web/js/requestnagger.js @@ -0,0 +1,13 @@ +/* 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. */ + +YAHOO.util.Event.onDOMReady(function() { + YAHOO.util.Event.addListener('defer-until', 'change', function() { + YAHOO.util.Dom.get('defer-date').innerHTML = 'until ' + this.value; + }); + bz_fireEvent(YAHOO.util.Dom.get('defer-until'), 'change'); +}); diff --git a/extensions/RequestNagger/web/style/requestnagger.css b/extensions/RequestNagger/web/style/requestnagger.css new file mode 100644 index 000000000..c4870a08e --- /dev/null +++ b/extensions/RequestNagger/web/style/requestnagger.css @@ -0,0 +1,42 @@ +/* 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. */ + +.edit_form { + width: 100%; +} + +.flag-bug { + font-size: large; +} + +.flag-bug, .flag-attach, .flag-details { + margin-bottom: 1em; +} + +.flag-attach-details { + font-size: small; +} + +.flag-attach-actions { + font-size: small; +} + +.flag-attach-desc { + font-weight: bold; +} + +.flag-type { + font-weight: bold; +} + +.defer { + margin-bottom: 2em; +} + +.deferred { + font-weight: bold; +} diff --git a/extensions/RestrictComments/Config.pm b/extensions/RestrictComments/Config.pm new file mode 100644 index 000000000..bef472cc1 --- /dev/null +++ b/extensions/RestrictComments/Config.pm @@ -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. + +package Bugzilla::Extension::RestrictComments; + +use strict; + +use constant NAME => 'RestrictComments'; +use constant REQUIRED_MODULES => []; +use constant OPTIONAL_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/RestrictComments/Extension.pm b/extensions/RestrictComments/Extension.pm new file mode 100644 index 000000000..001332a8e --- /dev/null +++ b/extensions/RestrictComments/Extension.pm @@ -0,0 +1,95 @@ +# 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::RestrictComments; + +use strict; +use warnings; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Constants; + +BEGIN { + *Bugzilla::Bug::restrict_comments = \&_bug_restrict_comments; +} + +sub _bug_restrict_comments { + my ($self) = @_; + return $self->{restrict_comments}; +} + +sub bug_check_can_change_field { + my ($self, $args) = @_; + my ($bug, $priv_results) = @$args{qw(bug priv_results)}; + my $user = Bugzilla->user; + + if ($user->id + && $bug->restrict_comments + && !$user->in_group(Bugzilla->params->{'restrict_comments_group'})) + { + push(@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); + return; + } +} + +sub _can_restrict_comments { + my ($self, $object) = @_; + return unless $object->isa('Bugzilla::Bug'); + $self->{setter_group} ||= Bugzilla->params->{'restrict_comments_enable_group'}; + return Bugzilla->user->in_group($self->{setter_group}); +} + +sub object_end_of_set_all { + my ($self, $args) = @_; + my $object = $args->{object}; + if ($self->_can_restrict_comments($object)) { + my $input = Bugzilla->input_params; + $object->set('restrict_comments', $input->{restrict_comments} ? 1 : undef); + } +} + +sub object_update_columns { + my ($self, $args) = @_; + my ($object, $columns) = @$args{qw(object columns)}; + if ($self->_can_restrict_comments($object)) { + push(@$columns, 'restrict_comments'); + } +} + +sub object_columns { + my ($self, $args) = @_; + my ($class, $columns) = @$args{qw(class columns)}; + if ($class->isa('Bugzilla::Bug')) { + push(@$columns, 'restrict_comments'); + } +} + +sub bug_fields { + my ($self, $args) = @_; + my $fields = $args->{'fields'}; + push (@$fields, 'restrict_comments') +} + +sub config_add_panels { + my ($self, $args) = @_; + my $modules = $args->{panel_modules}; + $modules->{RestrictComments} = "Bugzilla::Extension::RestrictComments::Config"; +} + +sub install_update_db { + my $dbh = Bugzilla->dbh; + + my $field = new Bugzilla::Field({ name => 'restrict_comments' }); + if (!$field) { + Bugzilla::Field->create({ name => 'restrict_comments', description => 'Restrict Comments' }); + } + + $dbh->bz_add_column('bugs', 'restrict_comments', { TYPE => 'BOOLEAN' }); +} + +__PACKAGE__->NAME; diff --git a/extensions/RestrictComments/lib/Config.pm b/extensions/RestrictComments/lib/Config.pm new file mode 100644 index 000000000..33607e680 --- /dev/null +++ b/extensions/RestrictComments/lib/Config.pm @@ -0,0 +1,47 @@ +# 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::RestrictComments::Config; + +use strict; +use warnings; + +use Bugzilla::Config::Common; +use Bugzilla::Group; + +our $sortkey = 510; + +sub get_param_list { + my ($class) = @_; + + my @param_list = ( + { + name => 'restrict_comments_group', + type => 's', + choices => \&_get_all_group_names, + default => '', + checker => \&check_group + }, + { + name => 'restrict_comments_enable_group', + type => 's', + choices => \&_get_all_group_names, + default => '', + checker => \&check_group + }, + ); + + return @param_list; +} + +sub _get_all_group_names { + my @group_names = map {$_->name} Bugzilla::Group->get_all; + unshift(@group_names, ''); + return \@group_names; +} + +1; diff --git a/extensions/RestrictComments/template/en/default/admin/params/restrictcomments.html.tmpl b/extensions/RestrictComments/template/en/default/admin/params/restrictcomments.html.tmpl new file mode 100644 index 000000000..d2a050563 --- /dev/null +++ b/extensions/RestrictComments/template/en/default/admin/params/restrictcomments.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. + #%] + +[% + title = "Restrict Comments" + desc = "Edit Restrict Comments Configuration" +%] + +[% param_descs = +{ + restrict_comments_group => "Users must be a member of this group to " _ + "comment on bug with restricted commenting " _ + "enabled." + + restrict_comments_enable_group => "Members of this group can toggle " _ + "'restrict comments' on bugs." +} +%] diff --git a/extensions/RestrictComments/template/en/default/hook/bug/edit-after_comment_commit_button.html.tmpl b/extensions/RestrictComments/template/en/default/hook/bug/edit-after_comment_commit_button.html.tmpl new file mode 100644 index 000000000..c5250c8c2 --- /dev/null +++ b/extensions/RestrictComments/template/en/default/hook/bug/edit-after_comment_commit_button.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. + #%] + + +[% RETURN UNLESS user.in_group(Param('restrict_comments_enable_group')) %] + +[%# using a table to match alignment of the needinfo checkboxes %] +<table> +<tr> + <td> + <input type="checkbox" name="restrict_comments" id="restrict_comments" + [% " checked" IF bug.restrict_comments %]> + <label for="restrict_comments"> + Restrict commenting on this [% terms.bug %] to users in the + <b>[% Param('restrict_comments_group') FILTER html %]</b> group. + </label> + (<a href="page.cgi?id=restrict_comments_guidelines.html" + target="_blank">guidelines</a>) + </td> +</tr> +</table> diff --git a/extensions/RestrictComments/template/en/default/pages/restrict_comments_guidelines.html.tmpl b/extensions/RestrictComments/template/en/default/pages/restrict_comments_guidelines.html.tmpl new file mode 100644 index 000000000..694681ad7 --- /dev/null +++ b/extensions/RestrictComments/template/en/default/pages/restrict_comments_guidelines.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. + #%] + +[% USE Bugzilla %] + +[% PROCESS global/header.html.tmpl + title = "Restrict Comments - Guidelines" +%] + +<h3>Restricting Comments</h3> + +<p> + Some [% terms.bug %] reports are inundated with comments that make it + difficult for developers to conduct technical discussions. Restricting + comments provides the ability for users in the + [%+ Param('restrict_comments_enable_group') FILTER html %] group to prevent + users who are not in the [% Param('restrict_comments_group') FILTER html %] + from making additional comments. +</p> + +<h3>Guidelines</h3> + +<ul> + <li> + Restrictions may be applied to [% terms.bugs %] which are subject to high + volumes of off topic comments, or [% terms.bugs %] which contain high volumes + of violations of [% terms.Bugzilla %] + <a href="page.cgi?id=etiquette.html">etiquette guidelines</a>. + </li> + <li> + Restrictions should not be used as a preemptive measure against comments + which have not yet occurred. + </li> + <li> + Restrictions should not be used to privilege + [%+ Param('restrict_comments_group') FILTER html %] users over other users + in valid disputes/discussions. + </li> +</ul> + +<h3>Impact</h3> + +<ul> + <li> + Users who are not in the [% Param('restrict_comments_group') FILTER html %] + group will not be able to comment on the [% terms.bug %], nor will they be + able to change the value of any field. + </li> + <li> + All users will still be able to CC themselves to the [% terms.bug %]. + </li> + <li> + All users will still be able to vote for the [% terms.bug %]. + </li> +</ul> + +[% PROCESS global/footer.html.tmpl %] 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"> + (<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&requestee=[% user.login FILTER uri %]&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&component=Administration&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 + ' <' + user + '>' : 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%; +} diff --git a/extensions/SecureMail/Config.pm b/extensions/SecureMail/Config.pm new file mode 100644 index 000000000..f1975c1c1 --- /dev/null +++ b/extensions/SecureMail/Config.pm @@ -0,0 +1,49 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla SecureMail Extension +# +# The Initial Developer of the Original Code is Mozilla. +# Portions created by Mozilla are Copyright (C) 2008 Mozilla Corporation. +# All Rights Reserved. +# +# Contributor(s): Max Kanat-Alexander <mkanat@bugzilla.org> +# Gervase Markham <gerv@gerv.net> + +package Bugzilla::Extension::SecureMail; +use strict; + +use constant NAME => 'SecureMail'; + +use constant REQUIRED_MODULES => [ + { + package => 'Crypt-OpenPGP', + module => 'Crypt::OpenPGP', + # 1.02 added the ability for new() to take KeyRing objects for the + # PubRing argument. + version => '1.02', + # 1.04 hangs - https://rt.cpan.org/Public/Bug/Display.html?id=68018 + # blacklist => [ '1.04' ], + }, + { + package => 'Crypt-SMIME', + module => 'Crypt::SMIME', + version => 0, + }, + { + package => 'HTML-Tree', + module => 'HTML::Tree', + version => 0, + } +]; + +__PACKAGE__->NAME; diff --git a/extensions/SecureMail/Extension.pm b/extensions/SecureMail/Extension.pm new file mode 100644 index 000000000..687112955 --- /dev/null +++ b/extensions/SecureMail/Extension.pm @@ -0,0 +1,659 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla SecureMail Extension +# +# The Initial Developer of the Original Code is the Mozilla Foundation. +# Portions created by Mozilla are Copyright (C) 2008 Mozilla Foundation. +# All Rights Reserved. +# +# Contributor(s): Max Kanat-Alexander <mkanat@bugzilla.org> +# Gervase Markham <gerv@gerv.net> + +package Bugzilla::Extension::SecureMail; +use strict; +use base qw(Bugzilla::Extension); + +use Bugzilla::Attachment; +use Bugzilla::Comment; +use Bugzilla::Group; +use Bugzilla::Object; +use Bugzilla::User; +use Bugzilla::Util qw(correct_urlbase trim trick_taint is_7bit_clean); +use Bugzilla::Error; +use Bugzilla::Mailer; + +use Crypt::OpenPGP::Armour; +use Crypt::OpenPGP::KeyRing; +use Crypt::OpenPGP; +use Crypt::SMIME; +use Encode; +use HTML::Tree; + +our $VERSION = '0.5'; + +use constant SECURE_NONE => 0; +use constant SECURE_BODY => 1; +use constant SECURE_ALL => 2; + +############################################################################## +# Creating new columns +# +# secure_mail boolean in the 'groups' table - whether to send secure mail +# public_key text in the 'profiles' table - stores public key +############################################################################## +sub install_update_db { + my ($self, $args) = @_; + + my $dbh = Bugzilla->dbh; + $dbh->bz_add_column('groups', 'secure_mail', + {TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 0}); + $dbh->bz_add_column('profiles', 'public_key', { TYPE => 'LONGTEXT' }); +} + +############################################################################## +# Maintaining new columns +############################################################################## + +BEGIN { + *Bugzilla::Group::secure_mail = \&_group_secure_mail; + *Bugzilla::User::public_key = \&_user_public_key; +} + +sub _group_secure_mail { return $_[0]->{'secure_mail'}; } + +# We want to lazy-load the public_key. +sub _user_public_key { + my $self = shift; + if (!exists $self->{public_key}) { + ($self->{public_key}) = Bugzilla->dbh->selectrow_array( + "SELECT public_key FROM profiles WHERE userid = ?", + undef, + $self->id + ); + } + return $self->{public_key}; +} + +# Make sure generic functions know about the additional fields in the user +# and group objects. +sub object_columns { + my ($self, $args) = @_; + my $class = $args->{'class'}; + my $columns = $args->{'columns'}; + + if ($class->isa('Bugzilla::Group')) { + push(@$columns, 'secure_mail'); + } +} + +# Plug appropriate validators so we can check the validity of the two +# fields created by this extension, when new values are submitted. +sub object_validators { + my ($self, $args) = @_; + my %args = %{ $args }; + my ($invocant, $validators) = @args{qw(class validators)}; + + if ($invocant->isa('Bugzilla::Group')) { + $validators->{'secure_mail'} = \&Bugzilla::Object::check_boolean; + } + elsif ($invocant->isa('Bugzilla::User')) { + $validators->{'public_key'} = sub { + my ($self, $value) = @_; + $value = trim($value) || ''; + + return $value if $value eq ''; + + if ($value =~ /PUBLIC KEY/) { + # PGP keys must be ASCII-armoured. + if (!Crypt::OpenPGP::Armour->unarmour($value)) { + ThrowUserError('securemail_invalid_key', + { errstr => Crypt::OpenPGP::Armour->errstr }); + } + } + elsif ($value =~ /BEGIN CERTIFICATE/) { + # S/MIME Keys must be in PEM format (Base64-encoded X.509) + # + # Crypt::SMIME seems not to like tainted values - it claims + # they aren't scalars! + trick_taint($value); + + my $smime = Crypt::SMIME->new(); + eval { + $smime->setPublicKey([$value]); + }; + if ($@) { + ThrowUserError('securemail_invalid_key', + { errstr => $@ }); + } + } + else { + ThrowUserError('securemail_invalid_key'); + } + + return $value; + }; + } +} + +# When creating a 'group' object, set up the secure_mail field appropriately. +sub object_before_create { + my ($self, $args) = @_; + my $class = $args->{'class'}; + my $params = $args->{'params'}; + + if ($class->isa('Bugzilla::Group')) { + $params->{secure_mail} = Bugzilla->cgi->param('secure_mail'); + } +} + +# On update, make sure the updating process knows about our new columns. +sub object_update_columns { + my ($self, $args) = @_; + my $object = $args->{'object'}; + my $columns = $args->{'columns'}; + + if ($object->isa('Bugzilla::Group')) { + # This seems like a convenient moment to extract this value... + $object->set('secure_mail', Bugzilla->cgi->param('secure_mail')); + + push(@$columns, 'secure_mail'); + } + elsif ($object->isa('Bugzilla::User')) { + push(@$columns, 'public_key'); + } +} + +# Handle the setting and changing of the public key. +sub user_preferences { + my ($self, $args) = @_; + my $tab = $args->{'current_tab'}; + my $save = $args->{'save_changes'}; + my $handled = $args->{'handled'}; + my $vars = $args->{'vars'}; + my $params = Bugzilla->input_params; + + return unless $tab eq 'securemail'; + + # Create a new user object so we don't mess with the main one, as we + # don't know where it's been... + my $user = new Bugzilla::User(Bugzilla->user->id); + + if ($save) { + $user->set('public_key', $params->{'public_key'}); + $user->update(); + + # Send user a test email + if ($user->public_key) { + _send_test_email($user); + $vars->{'test_email_sent'} = 1; + } + } + + $vars->{'public_key'} = $user->public_key; + + # Set the 'handled' scalar reference to true so that the caller + # knows the panel name is valid and that an extension took care of it. + $$handled = 1; +} + +sub template_before_process { + my ($self, $args) = @_; + my $file = $args->{'file'}; + my $vars = $args->{'vars'}; + + # Bug dependency emails contain the subject of the dependent bug + # right before the diffs when a status has gone from open/closed + # or closed/open. We need to sanitize the subject of change.blocker + # similar to how we do referenced bugs + return unless + $file eq 'email/bugmail.html.tmpl' + || $file eq 'email/bugmail.txt.tmpl'; + + if (defined $vars->{diffs}) { + foreach my $change (@{ $vars->{diffs} }) { + next if !defined $change->{blocker}; + if (grep($_->secure_mail, @{ $change->{blocker}->groups_in })) { + $change->{blocker}->{short_desc} = "(Secure bug)"; + } + } + } +} + +sub _send_test_email { + my ($user) = @_; + my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'}); + + my $vars = { + to_user => $user->email, + }; + + my $msg = ""; + $template->process("account/email/securemail-test.txt.tmpl", $vars, \$msg) + || ThrowTemplateError($template->error()); + + MessageToMTA($msg); +} + +############################################################################## +# Encrypting the email +############################################################################## +sub mailer_before_send { + my ($self, $args) = @_; + + my $email = $args->{'email'}; + my $body = $email->body; + + # Decide whether to make secure. + # This is a bit of a hack; it would be nice if it were more clear + # what sort a particular email is. + my $is_bugmail = $email->header('X-Bugzilla-Status') || + $email->header('X-Bugzilla-Type') eq 'request'; + my $is_passwordmail = !$is_bugmail && ($body =~ /cfmpw.*cxlpw/s); + my $is_test_email = $email->header('X-Bugzilla-Type') =~ /securemail-test/ ? 1 : 0; + my $is_whine_email = $email->header('X-Bugzilla-Type') eq 'whine' ? 1 : 0; + my $encrypt_header = $email->header('X-Bugzilla-Encrypt') ? 1 : 0; + + if ($is_bugmail + || $is_passwordmail + || $is_test_email + || $is_whine_email + || $encrypt_header + ) { + # Convert the email's To address into a User object + my $login = $email->header('To'); + my $emailsuffix = Bugzilla->params->{'emailsuffix'}; + $login =~ s/$emailsuffix$//; + my $user = new Bugzilla::User({ name => $login }); + + # Default to secure. (Of course, this means if this extension has a + # bug, lots of people are going to get bugmail falsely claiming their + # bugs are secure and they need to add a key...) + my $make_secure = SECURE_ALL; + + if ($is_bugmail) { + # This is also a bit of a hack, but there's no header with the + # bug ID in. So we take the first number in the subject. + my ($bug_id) = ($email->header('Subject') =~ /\[\D+(\d+)\]/); + my $bug = new Bugzilla::Bug($bug_id); + if (!_should_secure_bug($bug)) { + $make_secure = SECURE_NONE; + } + # If the insider group has securemail enabled.. + my $insider_group = Bugzilla::Group->new({ name => Bugzilla->params->{'insidergroup'} }); + if ($insider_group + && $insider_group->secure_mail + && $make_secure == SECURE_NONE) + { + my $comment_is_private = Bugzilla->dbh->selectcol_arrayref( + "SELECT isprivate FROM longdescs WHERE bug_id=? ORDER BY bug_when", + undef, $bug_id); + # Encrypt if there are private comments on an otherwise public bug + while ($body =~ /[\r\n]--- Comment #(\d+)/g) { + my $comment_number = $1; + if ($comment_number && $comment_is_private->[$comment_number]) { + $make_secure = SECURE_BODY; + last; + } + } + # Encrypt if updating a private attachment without a comment + if ($email->header('X-Bugzilla-Changed-Fields') + && $email->header('X-Bugzilla-Changed-Fields') =~ /Attachment #(\d+)/) + { + my $attachment = Bugzilla::Attachment->new($1); + if ($attachment && $attachment->isprivate) { + $make_secure = SECURE_BODY; + } + } + } + } + elsif ($is_passwordmail) { + # Mail is made unsecure only if the user does not have a public + # key and is not in any security groups. So specifying a public + # key OR being in a security group means the mail is kept secure + # (but, as noted above, the check is the other way around because + # we default to secure). + if ($user && + !$user->public_key && + !grep($_->secure_mail, @{ $user->groups })) + { + $make_secure = SECURE_NONE; + } + } + elsif ($is_whine_email) { + # When a whine email has one or more secure bugs in the body, then + # encrypt the entire email body. Subject can be left alone as it + # comes from the whine settings. + $make_secure = _should_secure_whine($email) ? SECURE_BODY : SECURE_NONE; + } + elsif ($encrypt_header) { + # Templates or code may set the X-Bugzilla-Encrypt header to + # trigger encryption of emails. Remove that header from the email. + $email->header_set('X-Bugzilla-Encrypt'); + } + + # If finding the user fails for some reason, but we determine we + # should be encrypting, we want to make the mail safe. An empty key + # does that. + my $public_key = $user ? $user->public_key : ''; + + # Check if the new bugmail prefix should be added to the subject. + my $add_new = ($email->header('X-Bugzilla-Type') eq 'new' && + $user && + $user->settings->{'bugmail_new_prefix'}->{'value'} eq 'on') ? 1 : 0; + + if ($make_secure == SECURE_NONE) { + # Filter the bug_links in HTML email in case the bugs the links + # point are "secured" bugs and the user may not be able to see + # the summaries. + _filter_bug_links($email); + } + else { + _make_secure($email, $public_key, $is_bugmail && $make_secure == SECURE_ALL, $add_new); + } + } +} + +# Custom hook for bugzilla.mozilla.org (see bug 752400) +sub bugmail_referenced_bugs { + my ($self, $args) = @_; + # Sanitise subjects of referenced bugs. + my $referenced_bugs = $args->{'referenced_bugs'}; + # No need to sanitise subjects if the entire email will be secured. + return if _should_secure_bug($args->{'updated_bug'}); + # Replace the subject if required + foreach my $ref (@$referenced_bugs) { + if (grep($_->secure_mail, @{ $ref->{'bug'}->groups_in })) { + $ref->{'short_desc'} = "(Secure bug)"; + } + } +} + +sub _should_secure_bug { + my ($bug) = @_; + # If there's a problem with the bug, err on the side of caution and mark it + # as secure. + return + !$bug + || $bug->{'error'} + || grep($_->secure_mail, @{ $bug->groups_in }); +} + +sub _should_secure_whine { + my ($email) = @_; + my $should_secure = 0; + $email->walk_parts(sub { + my $part = shift; + my $content_type = $part->content_type; + return if !$content_type || $content_type !~ /^text\/plain/; + my $body = $part->body; + my @bugids = $body =~ /Bug (\d+):/g; + foreach my $id (@bugids) { + $id = trim($id); + next if !$id; + my $bug = new Bugzilla::Bug($id); + if ($bug && _should_secure_bug($bug)) { + $should_secure = 1; + last; + } + } + }); + return $should_secure ? 1 : 0; +} + +sub _make_secure { + my ($email, $key, $sanitise_subject, $add_new) = @_; + + # Add header showing this email has been secured + $email->header_set('X-Bugzilla-Secure-Email', 'Yes'); + + my $subject = $email->header('Subject'); + my ($bug_id) = $subject =~ /\[\D+(\d+)\]/; + + my $key_type = 0; + if ($key && $key =~ /PUBLIC KEY/) { + $key_type = 'PGP'; + } + elsif ($key && $key =~ /BEGIN CERTIFICATE/) { + $key_type = 'S/MIME'; + } + + if ($key_type eq 'PGP') { + ################## + # PGP Encryption # + ################## + + my $pubring = new Crypt::OpenPGP::KeyRing(Data => $key); + my $pgp = new Crypt::OpenPGP(PubRing => $pubring); + + if (scalar $email->parts > 1) { + my $old_boundary = $email->{ct}{attributes}{boundary}; + my $to_encrypt = "Content-Type: " . $email->content_type . "\n\n"; + + # We need to do some fix up of each part for proper encoding and then + # stringify all parts for encrypting. We have to retain the old + # boundaries as well so that the email client can reconstruct the + # original message properly. + $email->walk_parts(\&_fix_part); + + $email->walk_parts(sub { + my ($part) = @_; + if ($sanitise_subject) { + _insert_subject($part, $subject); + } + return if $part->parts > 1; # Top-level + $to_encrypt .= "--$old_boundary\n" . $part->as_string . "\n"; + }); + $to_encrypt .= "--$old_boundary--"; + + # Now create the new properly formatted PGP parts containing the + # encrypted original message + my @new_parts = ( + Email::MIME->create( + attributes => { + content_type => 'application/pgp-encrypted', + encoding => '7bit', + }, + body => "Version: 1\n", + ), + Email::MIME->create( + attributes => { + content_type => 'application/octet-stream', + filename => 'encrypted.asc', + disposition => 'inline', + encoding => '7bit', + }, + body => _pgp_encrypt($pgp, $to_encrypt) + ), + ); + $email->parts_set(\@new_parts); + my $new_boundary = $email->{ct}{attributes}{boundary}; + # Redo the old content type header with the new boundaries + # and other information needed for PGP + $email->header_set("Content-Type", + "multipart/encrypted; " . + "protocol=\"application/pgp-encrypted\"; " . + "boundary=\"$new_boundary\""); + } + else { + _fix_part($email); + if ($sanitise_subject) { + _insert_subject($email, $subject); + } + $email->body_set(_pgp_encrypt($pgp, $email->body)); + } + } + + elsif ($key_type eq 'S/MIME') { + ##################### + # S/MIME Encryption # + ##################### + + $email->walk_parts(\&_fix_part); + + if ($sanitise_subject) { + $email->walk_parts(sub { _insert_subject($_[0], $subject) }); + } + + my $smime = Crypt::SMIME->new(); + my $encrypted; + + eval { + $smime->setPublicKey([$key]); + $encrypted = $smime->encrypt($email->as_string()); + }; + + if (!$@) { + # We can't replace the Email::MIME object, so we have to swap + # out its component parts. + my $enc_obj = new Email::MIME($encrypted); + $email->header_obj_set($enc_obj->header_obj()); + $email->parts_set([]); + $email->body_set($enc_obj->body()); + $email->content_type_set('application/pkcs7-mime'); + $email->charset_set('UTF-8') if Bugzilla->params->{'utf8'}; + } + else { + $email->body_set('Error during Encryption: ' . $@); + } + } + else { + # No encryption key provided; send a generic, safe email. + my $template = Bugzilla->template; + my $message; + my $vars = { + 'urlbase' => correct_urlbase(), + 'bug_id' => $bug_id, + 'maintainer' => Bugzilla->params->{'maintainer'} + }; + + $template->process('account/email/encryption-required.txt.tmpl', + $vars, \$message) + || ThrowTemplateError($template->error()); + + $email->parts_set([]); + $email->content_type_set('text/plain'); + $email->body_set($message); + } + + if ($sanitise_subject) { + # This is designed to still work if the admin changes the word + # 'bug' to something else. However, it could break if they change + # the format of the subject line in another way. + my $new = $add_new ? ' New:' : ''; + my $product = $email->header('X-Bugzilla-Product'); + my $component = $email->header('X-Bugzilla-Component'); + # Note: the $bug_id is required within the parentheses in order to keep + # gmail's threading algorithm happy. + $subject =~ s/($bug_id\])\s+(.*)$/$1$new (Secure bug $bug_id in $product :: $component)/; + $email->header_set('Subject', $subject); + } +} + +sub _pgp_encrypt { + my ($pgp, $text) = @_; + # "@" matches every key in the public key ring, which is fine, + # because there's only one key in our keyring. + # + # We use the CAST5 cipher because the Rijndael (AES) module doesn't + # like us for some reason I don't have time to debug fully. + # ("key must be an untainted string scalar") + my $encrypted = $pgp->encrypt(Data => $text, + Recipients => "@", + Cipher => 'CAST5', + Armour => 1); + if (!defined $encrypted) { + return 'Error during Encryption: ' . $pgp->errstr; + } + return $encrypted; +} + +# Insert the subject into the part's body, as the subject of the message will +# be sanitised. +# XXX this incorrectly assumes all parts of the message are the body +# we should only alter parts who's parent is multipart/alternative +sub _insert_subject { + my ($part, $subject) = @_; + my $content_type = $part->content_type or return; + if ($content_type =~ /^text\/plain/) { + if (!is_7bit_clean($subject)) { + $part->encoding_set('quoted-printable'); + } + $part->body_str_set("Subject: $subject\015\012\015\012" . $part->body_str); + } + elsif ($content_type =~ /^text\/html/) { + my $tree = HTML::Tree->new->parse_content($part->body_str); + my $body = $tree->look_down(qw(_tag body)); + $body->unshift_content(['div', "Subject: $subject"], ['br']); + _set_body_from_tree($part, $tree); + } +} + +# Copied from Bugzilla/Mailer as this extension runs before +# this code there and Mailer.pm will no longer see the original +# message. +sub _fix_part { + my ($part) = @_; + return if $part->parts > 1; # Top-level + my $content_type = $part->content_type || ''; + $content_type =~ /charset=['"](.+)['"]/; + # If no charset is defined or is the default us-ascii, + # then we encode the email to UTF-8 if Bugzilla has utf8 enabled. + # XXX - This is a hack to workaround bug 723944. + if (!$1 || $1 eq 'us-ascii') { + my $body = $part->body; + if (Bugzilla->params->{'utf8'}) { + $part->charset_set('UTF-8'); + # encoding_set works only with bytes, not with utf8 strings. + my $raw = $part->body_raw; + if (utf8::is_utf8($raw)) { + utf8::encode($raw); + $part->body_set($raw); + } + } + $part->encoding_set('quoted-printable') if !is_7bit_clean($body); + } +} + +sub _filter_bug_links { + my ($email) = @_; + $email->walk_parts(sub { + my $part = shift; + my $content_type = $part->content_type; + return if !$content_type || $content_type !~ /text\/html/; + my $tree = HTML::Tree->new->parse_content($part->body); + my @links = $tree->look_down( _tag => q{a}, class => qr/bz_bug_link/ ); + my $updated = 0; + foreach my $link (@links) { + my $href = $link->attr('href'); + my ($bug_id) = $href =~ /\Qshow_bug.cgi?id=\E(\d+)/; + my $bug = new Bugzilla::Bug($bug_id); + if ($bug && _should_secure_bug($bug)) { + $link->attr('title', '(secure bug)'); + $link->attr('class', 'bz_bug_link'); + $updated = 1; + } + } + if ($updated) { + _set_body_from_tree($part, $tree); + } + }); +} + +sub _set_body_from_tree { + my ($part, $tree) = @_; + $part->body_set($tree->as_HTML); + $part->charset_set('UTF-8') if Bugzilla->params->{'utf8'}; + $part->encoding_set('quoted-printable'); +} + +__PACKAGE__->NAME; diff --git a/extensions/SecureMail/README b/extensions/SecureMail/README new file mode 100644 index 000000000..ac3484291 --- /dev/null +++ b/extensions/SecureMail/README @@ -0,0 +1,8 @@ +This extension should be placed in a directory called "SecureMail" in the +Bugzilla extensions/ directory. After installing it, remove the file +"disabled" (if present) and then run checksetup.pl. + +Instructions for user key formats: + +S/MIME Keys must be in PEM format - i.e. Base64-encoded text, with BEGIN CERTIFICATE +PGP keys must be ASCII-armoured - i.e. text, with BEGIN PGP PUBLIC KEY. diff --git a/extensions/SecureMail/template/en/default/account/email/encryption-required.txt.tmpl b/extensions/SecureMail/template/en/default/account/email/encryption-required.txt.tmpl new file mode 100644 index 000000000..f3710bb17 --- /dev/null +++ b/extensions/SecureMail/template/en/default/account/email/encryption-required.txt.tmpl @@ -0,0 +1,15 @@ +This email would have contained sensitive information, but you have not set +a PGP/GPG key or SMIME certificate in the "Secure Mail" section of your user +preferences. + +[% IF bug_id %] +In order to receive the full text of similar mails in the future, please +go to: +[%+ urlbase %]userprefs.cgi?tab=securemail +and provide a key or certificate. + +You can see this bug's current state at: +[%+ urlbase %]show_bug.cgi?id=[% bug_id %] +[% ELSE %] +You will have to contact [% maintainer %] to reset your password. +[% END %] diff --git a/extensions/SecureMail/template/en/default/account/email/securemail-test.txt.tmpl b/extensions/SecureMail/template/en/default/account/email/securemail-test.txt.tmpl new file mode 100644 index 000000000..e4f4c9242 --- /dev/null +++ b/extensions/SecureMail/template/en/default/account/email/securemail-test.txt.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. + #%] + +[% PROCESS global/variables.none.tmpl %] + +From: [% Param('mailfrom') %] +To: [% to_user %] +Subject: [% terms.Bugzilla %] SecureMail Test Email +X-Bugzilla-Type: securemail-test + +Congratulations! If you can read this, then your SecureMail encryption +key uploaded to [% terms.Bugzilla %] is working properly. + +To update your SecureMail preferences at any time, please go to: +[%+ urlbase %]userprefs.cgi?tab=securemail + +Sincerely, +Your Friendly [% terms.Bugzilla %] Administrator diff --git a/extensions/SecureMail/template/en/default/account/prefs/securemail.html.tmpl b/extensions/SecureMail/template/en/default/account/prefs/securemail.html.tmpl new file mode 100644 index 000000000..db595a23f --- /dev/null +++ b/extensions/SecureMail/template/en/default/account/prefs/securemail.html.tmpl @@ -0,0 +1,40 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is the Mozilla Corporation. + # Portions created by the Initial Developer are Copyright (C) 2008 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): Max Kanat-Alexander <mkanat@bugzilla.org> + #%] + +[% IF test_email_sent %] + <div id="message"> + An encrypted test email has been sent to your address. + </div> +[% END %] + +<p>Some [% terms.bugs %] in this [% terms.Bugzilla %] are in groups the administrator has +deemed 'secure'. This means emails containing information about those [% terms.bugs %] +will only be sent encrypted. Enter your PGP/GPG public key or +SMIME certificate here to receive full update emails for such [% terms.bugs %].</p> + +<p>If you are a member of a secure group, or if you enter a key here, your password reset email will also be sent to you encrypted. If you are a member of a secure group and do not enter a key, you will not be able to reset your password without the assistance of an administrator.</p> + +<p><a href="page.cgi?id=securemail/help.html">More help is available</a>.</p> + +[% Hook.process('moreinfo') %] + +<textarea id="public_key" name="public_key" cols="72" rows="12"> + [%- public_key FILTER html %]</textarea> + +<p>Submitting valid changes will automatically send an encrypted test email to your address.</p> diff --git a/extensions/SecureMail/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl b/extensions/SecureMail/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl new file mode 100644 index 000000000..70a40e592 --- /dev/null +++ b/extensions/SecureMail/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl @@ -0,0 +1,28 @@ +[%# -*- Mode: perl; indent-tabs-mode: nil -*- + # + # The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla SecureMail Extension + # + # The Initial Developer of the Original Code is Mozilla. + # Portions created by Mozilla are Copyright (C) 2008 Mozilla Corporation. + # All Rights Reserved. + # + # Contributor(s): Max Kanat-Alexander <mkanat@bugzilla.org> + # Gervase Markham <gerv@gerv.net> + #%] + +[% tabs = tabs.import([{ + name => "securemail", + label => "Secure Mail", + link => "userprefs.cgi?tab=securemail", + saveable => 1 + }]) %] diff --git a/extensions/SecureMail/template/en/default/hook/admin/groups/create-field.html.tmpl b/extensions/SecureMail/template/en/default/hook/admin/groups/create-field.html.tmpl new file mode 100644 index 000000000..27c644d02 --- /dev/null +++ b/extensions/SecureMail/template/en/default/hook/admin/groups/create-field.html.tmpl @@ -0,0 +1,25 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is the Mozilla Corporation. + # Portions created by the Initial Developer are Copyright (C) 2008 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): Max Kanat-Alexander <mkanat@bugzilla.org> + #%] +<tr> + <th>Secure Bugmail:</th> + <td colspan="3"> + <input type="checkbox" id="secure_mail" name="secure_mail" + [% ' checked="checked"' IF group.secure_mail %]> + </td> +</tr> diff --git a/extensions/SecureMail/template/en/default/hook/admin/groups/edit-field.html.tmpl b/extensions/SecureMail/template/en/default/hook/admin/groups/edit-field.html.tmpl new file mode 100644 index 000000000..253fed29e --- /dev/null +++ b/extensions/SecureMail/template/en/default/hook/admin/groups/edit-field.html.tmpl @@ -0,0 +1,27 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is the Mozilla Corporation. + # Portions created by the Initial Developer are Copyright (C) 2008 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): Max Kanat-Alexander <mkanat@bugzilla.org> + #%] +[% IF group.is_bug_group || group.name == Param('insidergroup') %] + <tr> + <th>Secure Bugmail:</th> + <td> + <input type="checkbox" id="secure_mail" name="secure_mail" + [% ' checked="checked"' IF group.secure_mail %]> + </td> + </tr> +[% END %] diff --git a/extensions/SecureMail/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/SecureMail/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..46b093674 --- /dev/null +++ b/extensions/SecureMail/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,27 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Bug Tracking System. + # + # The Initial Developer of the Original Code is the Mozilla Corporation. + # Portions created by the Initial Developer are Copyright (C) 2008 the + # Initial Developer. All Rights Reserved. + # + # Contributor(s): Max Kanat-Alexander <mkanat@bugzilla.org> + #%] + +[% IF error == "securemail_invalid_key" %] + [% title = "Invalid Public Key" %] + We were unable to read the public key that you entered. Make sure + that you are entering either an ASCII-armored PGP/GPG public key, + including the "BEGIN PGP PUBLIC KEY BLOCK" and "END PGP PUBLIC KEY BLOCK" + lines, or a PEM format (Base64-encoded X.509) S/MIME key, including the + BEGIN CERTIFICATE and END CERTIFICATE lines.<br><br>[% errstr FILTER html %] +[% END %] diff --git a/extensions/SecureMail/template/en/default/pages/securemail/help.html.tmpl b/extensions/SecureMail/template/en/default/pages/securemail/help.html.tmpl new file mode 100644 index 000000000..e6ef02927 --- /dev/null +++ b/extensions/SecureMail/template/en/default/pages/securemail/help.html.tmpl @@ -0,0 +1,130 @@ +[%# + # The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla SecureMail Extension. + # + # The Initial Developer of the Original Code is the Mozilla Foundation. + # Portions created by Mozilla are Copyright (C) 2008 Mozilla Foundation. + # All Rights Reserved. + # + # Contributor(s): Max Kanat-Alexander <mkanat@bugzilla.org> + # Gervase Markham <gerv@gerv.net> + # Dave Lawrence <dkl@mozilla.com> + #%] + +[% PROCESS global/header.html.tmpl + title = "SecureMail Help" +%] + +[% terms.Bugzilla %] considers certain groups as "secure". If a [% terms.bug %] is in one of those groups, [% terms.Bugzilla %] will not send unencrypted +email about it. To receive encrypted email rather than just a "something changed" placeholder, you must provide either +a S/MIME or a GPG/PGP key on the <a href="[% urlbase FILTER none %]userprefs.cgi?tab=securemail">SecureMail preferences tab</a>.<br> +<br> +In addition, if you have uploaded a S/MIME or GPG/PGP key using the <a href="[% urlbase FILTER none %]userprefs.cgi?tab=securemail"> +SecureMail preferences tab</a>, if you request your password to be reset, [% terms.Bugzilla %] will send the reset email encrypted and you will +be required to decrypt it to view the reset instructions. + +<h2>S/MIME</h2> + +<b>S/MIME Keys must be in PEM format - i.e. Base64-encoded text, with the first line containing BEGIN CERTIFICATE.</b></p> + +<p> +S/MIME certificates can be obtained from a number of providers. You can get a free one from <a href="https://www.startssl.com/?app=12">StartCom</a>. +Once you have it, <a href="https://www.startssl.com/?app=25#52">export it from your browser as a .p12 file and import it into your mail client</a>. +You'll need to provide a password when you export - pick a strong one, and then back up the .p12 file somewhere safe.</p> + +<p>Import on Thunderbird as follows:</p> + +<ul> +<li>Open Preferences in Thunderbird.</li> +<li>Activate the Advanced pane.</li> +<li>Activate the Certificates tab.</li> +<li>Press the button View Certificates.</li> +<li>Press the Import button.</li> +<li>Open your .p12 file.</li> +<li>Enter the password for unlocking the .p12 if asked.</li> +</ul> + +<p> +Then, you need to convert it to a .pem file. Here are two possible ways to do this.</p> + +<h3>Thunderbird</h3> + +<ul> +<li>Open Preferences in Thunderbird.</li> +<li>Activate the Advanced pane.</li> +<li>Activate the Certificates tab.</li> +<li>Press the button View Certificates.</li> +<li>Select the line in the tree widget that represents the certificate you imported.</li> +<li>Press the View button.</li> +<li>Activate the Details tab.</li> +<li>Press the Export button.</li> +<li>Choose where to save the .pem file.</li> +</ul> + +<p>Paste the contents of the .pem file into the SecureMail text field in [% terms.Bugzilla %].</p> + +<h3>OpenSSL</h3> + +<p>Or, if you have OpenSSL installed, do the following:</p> + +<p> +<code>openssl pkcs12 -in certificate.p12 -out certificate.pem -nodes -nokeys</code></p> + +<p> +Open the .pem file in a text editor. You can recognise the public key because +it starts "BEGIN CERTIFICATE" and ends "END CERTIFICATE" and +has an appropriate friendly name (e.g. "StartCom Free Certificate Member's StartCom Ltd. ID").</p> + +<p>Paste the contents of the .pem file into the SecureMail text field in [% terms.Bugzilla %].</p> + +<h2>PGP</h2> + +<b>PGP keys must be ASCII-armoured - i.e. text, with the first line containing BEGIN PGP PUBLIC KEY.</b></p> + +<p> +If you already have your own PGP key in a keyring, skip straight to step 3. Otherwise:</p> + +<ol> + +<li>Install the GPG suite of utilities for your operating system, either using your package manager or downloaded from <a href="http://www.gnupg.org/download/index.en.html">gnupg.org</a>.</p> + +<li><p>Generate a private key.</p> + +<p><code>gpg --gen-key</code></p> + +<p> +You’ll have to answer several questions:</p> + +<p> +<ul> + <li>What kind and size of key you want; the defaults are probably good enough.</li> + <li>How long the key should be valid; you can safely choose a non-expiring key.</li> + <li>Your real name and e-mail address; these are necessary for identifying your key in a larger set of keys.</li> + <li>A comment for your key; the comment can be empty.</li> + <li>A passphrase. Whatever you do, don’t forget it! Your key, and all your encrypted files, will be useless if you do.</li> +</ul> + +<li><p>Generate an ASCII version of your public key.</p> + +<p><code>gpg --armor --output pubkey.txt --export 'Your Name'</code></p> + +<p>Paste the contents of pubkey.txt into the SecureMail text field in [% terms.Bugzilla %]. + +<li>Configure your email client to use your associated private key to decrypt the encrypted emails. For Thunderbird, you need the <a href="https://addons.mozilla.org/en-us/thunderbird/addon/enigmail/">Enigmail</a> extension.</p> +</ol> + +<p> +Further reading: <a href="http://www.madboa.com/geek/gpg-quickstart">GPG Quickstart</a>. + +[% PROCESS global/footer.html.tmpl %] + + diff --git a/extensions/ShadowBugs/Config.pm b/extensions/ShadowBugs/Config.pm new file mode 100644 index 000000000..6999edaf3 --- /dev/null +++ b/extensions/ShadowBugs/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::ShadowBugs; +use strict; + +use constant NAME => 'ShadowBugs'; +use constant REQUIRED_MODULES => []; +use constant OPTIONAL_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/ShadowBugs/Extension.pm b/extensions/ShadowBugs/Extension.pm new file mode 100644 index 000000000..a9a1e0861 --- /dev/null +++ b/extensions/ShadowBugs/Extension.pm @@ -0,0 +1,99 @@ +# 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::ShadowBugs; + +use strict; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Bug; +use Bugzilla::Error; +use Bugzilla::Field; +use Bugzilla::User; + +our $VERSION = '1'; + +BEGIN { + *Bugzilla::is_cf_shadow_bug_hidden = \&_is_cf_shadow_bug_hidden; + *Bugzilla::Bug::cf_shadow_bug_obj = \&_cf_shadow_bug_obj; +} + +# Determine if the shadow-bug / shadowed-by fields are visibile on the +# specified bug. +sub _is_cf_shadow_bug_hidden { + my ($self, $bug) = @_; + + # completely hide unless you're a member of the right group + return 1 unless Bugzilla->user->in_group('can_shadow_bugs'); + + my $is_public = Bugzilla::User->new()->can_see_bug($bug->id); + if ($is_public) { + # hide on public bugs, unless it's shadowed + my $related = $bug->related_bugs(Bugzilla->process_cache->{shadow_bug_field}); + return 1 if !@$related; + } +} + +sub _cf_shadow_bug_obj { + my ($self) = @_; + return unless $self->cf_shadow_bug; + return $self->{cf_shadow_bug_obj} ||= Bugzilla::Bug->new($self->cf_shadow_bug); +} + +sub template_before_process { + my ($self, $args) = @_; + my $file = $args->{'file'}; + my $vars = $args->{'vars'}; + + Bugzilla->process_cache->{shadow_bug_field} ||= Bugzilla::Field->new({ name => 'cf_shadow_bug' }); + + return unless Bugzilla->user->in_group('can_shadow_bugs'); + return unless + $file eq 'bug/edit.html.tmpl' + || $file eq 'bug/show.html.tmpl' + || $file eq 'bug/show-header.html.tmpl'; + my $bug = exists $vars->{'bugs'} ? $vars->{'bugs'}[0] : $vars->{'bug'}; + return unless $bug && $bug->cf_shadow_bug; + $vars->{is_shadow_bug} = 1; + + if ($file eq 'bug/edit.html.tmpl') { + # load comments from other bug + $vars->{shadow_comments} = $bug->cf_shadow_bug_obj->comments; + } +} + +sub bug_end_of_update { + my ($self, $args) = @_; + + # don't allow shadowing non-public bugs + if (exists $args->{changes}->{cf_shadow_bug}) { + my ($old_id, $new_id) = @{ $args->{changes}->{cf_shadow_bug} }; + if ($new_id) { + if (!Bugzilla::User->new()->can_see_bug($new_id)) { + ThrowUserError('illegal_shadow_bug_public', { id => $new_id }); + } + } + } + + # if a shadow bug is made public, clear the shadow_bug field + if (exists $args->{changes}->{bug_group}) { + my $bug = $args->{bug}; + return unless my $shadow_id = $bug->cf_shadow_bug; + my $is_public = Bugzilla::User->new()->can_see_bug($bug->id); + if ($is_public) { + Bugzilla->dbh->do( + "UPDATE bugs SET cf_shadow_bug=NULL WHERE bug_id=?", + undef, $bug->id); + LogActivityEntry($bug->id, 'cf_shadow_bug', $shadow_id, '', + Bugzilla->user->id, $args->{timestamp}); + + } + } +} + +__PACKAGE__->NAME; diff --git a/extensions/ShadowBugs/disabled b/extensions/ShadowBugs/disabled new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/extensions/ShadowBugs/disabled diff --git a/extensions/ShadowBugs/template/en/default/hook/bug/comments-aftercomments.html.tmpl b/extensions/ShadowBugs/template/en/default/hook/bug/comments-aftercomments.html.tmpl new file mode 100644 index 000000000..d8dae521a --- /dev/null +++ b/extensions/ShadowBugs/template/en/default/hook/bug/comments-aftercomments.html.tmpl @@ -0,0 +1,70 @@ +[%# 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 is_shadow_bug %] + +[% public_bug = bug.cf_shadow_bug_obj %] +[% count = 0 %] +[% FOREACH comment = shadow_comments %] + [% IF count >= start_at %] + [% PROCESS a_comment %] + [% END %] + [% count = count + increment %] +[% END %] + +[% BLOCK a_comment %] + [% RETURN IF comment.is_private AND NOT (user.is_insider || user.id == comment.author.id) %] + [% comment_text = comment.body_full %] + [% RETURN IF comment_text == '' %] + + <div id="pc[% count %]" class="bz_comment[% " bz_private" IF comment.is_private %] + shadow_bug_comment bz_default_hidden + [% " bz_first_comment" IF count == description %]"> + [% IF count == description %] + [% class_name = "bz_first_comment_head" %] + [% comment_label = "Public Description" %] + [% ELSE %] + [% class_name = "bz_comment_head" %] + [% comment_label = "Public Comment " _ count %] + [% END %] + + <div class="[% class_name FILTER html %]"> + <span class="bz_comment_number"> + <a href="show_bug.cgi?id=[% public_bug.bug_id FILTER none %]#c[% count %]"> + [%- comment_label FILTER html %]</a> + </span> + + <span class="bz_comment_user"> + [% commenter_id = comment.author.id %] + [% UNLESS user_cache.$commenter_id %] + [% user_cache.$commenter_id = BLOCK %] + [% INCLUDE global/user.html.tmpl who = comment.author %] + [% END %] + [% END %] + [% user_cache.$commenter_id FILTER none %] + [% Hook.process('user', 'bug/comments.html.tmpl') %] + </span> + + <span class="bz_comment_user_images"> + [% FOREACH group = comment.author.groups_with_icon %] + <img src="[% group.icon_url FILTER html %]" + alt="[% group.name FILTER html %]" + title="[% group.name FILTER html %] - [% group.description FILTER html %]"> + [% END %] + </span> + + <span class="bz_comment_time"> + [%+ comment.creation_ts FILTER time %] + </span> + </div> + +<pre class="bz_comment_text"> + [%- comment_text FILTER quoteUrls(public_bug, comment) -%] +</pre> + </div> +[% END %] diff --git a/extensions/ShadowBugs/template/en/default/hook/bug/edit-after_comment_textarea.html.tmpl b/extensions/ShadowBugs/template/en/default/hook/bug/edit-after_comment_textarea.html.tmpl new file mode 100644 index 000000000..9873ea3d7 --- /dev/null +++ b/extensions/ShadowBugs/template/en/default/hook/bug/edit-after_comment_textarea.html.tmpl @@ -0,0 +1,13 @@ +[%# 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 is_shadow_bug %] + +<br> +<a href="show_bug.cgi?id=[% bug.cf_shadow_bug FILTER none %]#comment">Add public comment</a> + diff --git a/extensions/ShadowBugs/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl b/extensions/ShadowBugs/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl new file mode 100644 index 000000000..8e8327ef2 --- /dev/null +++ b/extensions/ShadowBugs/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl @@ -0,0 +1,27 @@ +[%# 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 IF Bugzilla.is_cf_shadow_bug_hidden(bug) %] +[% field = Bugzilla.process_cache.shadow_bug_field %] +[% shadowed_by = bug.related_bugs(field).pop %] +<tr> + [% IF shadowed_by && user.can_see_bug(shadowed_by) %] + <th class="field_label"> + [% field.reverse_desc FILTER html %]: + </th> + <td> + [% shadowed_by.id FILTER bug_link(shadowed_by, use_alias => 1) FILTER none %][% " " %] + </td> + [% ELSE %] + [% PROCESS bug/field.html.tmpl + value = bug.cf_shadow_bug + editable = bug.check_can_change_field(field.name, 0, 1) + no_tds = false + value_span = 2 %] + [% END %] +</tr> diff --git a/extensions/ShadowBugs/template/en/default/hook/bug/edit-custom_field.html.tmpl b/extensions/ShadowBugs/template/en/default/hook/bug/edit-custom_field.html.tmpl new file mode 100644 index 000000000..4389b27ad --- /dev/null +++ b/extensions/ShadowBugs/template/en/default/hook/bug/edit-custom_field.html.tmpl @@ -0,0 +1,9 @@ +[%# 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. + #%] + +[% field.hidden = field.name == 'cf_shadow_bug' %] diff --git a/extensions/ShadowBugs/template/en/default/hook/bug/show-header-end.html.tmpl b/extensions/ShadowBugs/template/en/default/hook/bug/show-header-end.html.tmpl new file mode 100644 index 000000000..5786b3df6 --- /dev/null +++ b/extensions/ShadowBugs/template/en/default/hook/bug/show-header-end.html.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. + #%] + +[% IF is_shadow_bug %] + [% style_urls.push('extensions/ShadowBugs/web/style.css') %] + [% javascript_urls.push('extensions/ShadowBugs/web/shadow-bugs.js') %] +[% END %] diff --git a/extensions/ShadowBugs/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/ShadowBugs/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..2e7695dbb --- /dev/null +++ b/extensions/ShadowBugs/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,14 @@ +[%# 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 == "illegal_shadow_bug_public" %] + [% title = "Invalid Shadow " _ terms.Bug %] + You cannot shadow [% terms.bug %] [%+ id FILTER html %] because it is not a + public [% terms.bug %]. +[% END %] + diff --git a/extensions/ShadowBugs/web/shadow-bugs.js b/extensions/ShadowBugs/web/shadow-bugs.js new file mode 100644 index 000000000..ff320e117 --- /dev/null +++ b/extensions/ShadowBugs/web/shadow-bugs.js @@ -0,0 +1,51 @@ +/* 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 shadow_bug = { + init: function() { + var Dom = YAHOO.util.Dom; + var comment_divs = Dom.getElementsByClassName('bz_comment', 'div', 'comments'); + var comments = new Array(); + for (var i = 0, l = comment_divs.length; i < l; i++) { + var time_spans = Dom.getElementsByClassName('bz_comment_time', 'span', comment_divs[i]); + if (!time_spans.length) continue; + var date = this.parse_date(time_spans[0].innerHTML); + if (!date) continue; + + var comment = {}; + comment.div = comment_divs[i]; + comment.date = date; + comment.shadow = Dom.hasClass(comment.div, 'shadow_bug_comment'); + comments.push(comment); + } + + for (var i = 0, l = comments.length; i < l; i++) { + if (!comments[i].shadow) continue; + for (var j = 0, jl = comments.length; j < jl; j++) { + if (comments[j].shadow) continue; + if (comments[j].date > comments[i].date) { + comments[j].div.parentNode.insertBefore(comments[i].div, comments[j].div); + break; + } + } + Dom.removeClass(comments[i].div, 'bz_default_hidden'); + } + + Dom.get('comment').placeholder = 'Add non-public comment'; + }, + + parse_date: function(date) { + var matches = date.match(/^\s*(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)/); + if (!matches) return; + return (matches[1] + matches[2] + matches[3] + matches[4] + matches[5] + matches[6]) + 0; + } +}; + + +YAHOO.util.Event.onDOMReady(function() { + shadow_bug.init(); +}); diff --git a/extensions/ShadowBugs/web/style.css b/extensions/ShadowBugs/web/style.css new file mode 100644 index 000000000..0c104130f --- /dev/null +++ b/extensions/ShadowBugs/web/style.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. */ + +.shadow_bug_comment { + background: transparent !important; +} diff --git a/extensions/SiteMapIndex/Config.pm b/extensions/SiteMapIndex/Config.pm new file mode 100644 index 000000000..e10d6ec8b --- /dev/null +++ b/extensions/SiteMapIndex/Config.pm @@ -0,0 +1,36 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Sitemap Bugzilla Extension. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander <mkanat@bugzilla.org> +# Dave Lawrence <dkl@mozilla.com> + +package Bugzilla::Extension::SiteMapIndex; +use strict; + +use constant NAME => 'SiteMapIndex'; + +use constant REQUIRED_MODULES => [ + { + package => 'IO-Compress-Gzip', + module => 'IO::Compress::Gzip', + version => 0, + } +]; + +__PACKAGE__->NAME; diff --git a/extensions/SiteMapIndex/Extension.pm b/extensions/SiteMapIndex/Extension.pm new file mode 100644 index 000000000..4cc384b48 --- /dev/null +++ b/extensions/SiteMapIndex/Extension.pm @@ -0,0 +1,157 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Sitemap Bugzilla Extension. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander <mkanat@bugzilla.org> +# Dave Lawrence <dkl@mozilla.com> + +package Bugzilla::Extension::SiteMapIndex; +use strict; +use base qw(Bugzilla::Extension); + +our $VERSION = '1.0'; + +use Bugzilla::Constants qw(bz_locations ON_WINDOWS); +use Bugzilla::Util qw(correct_urlbase get_text); +use Bugzilla::Install::Filesystem; + +use Bugzilla::Extension::SiteMapIndex::Constants; +use Bugzilla::Extension::SiteMapIndex::Util; + +use DateTime; +use IO::File; +use POSIX; + +######### +# Pages # +######### + +sub template_before_process { + my ($self, $args) = @_; + my ($vars, $file) = @$args{qw(vars file)}; + + return if $file ne 'global/header.html.tmpl'; + return unless (exists $vars->{bug} || exists $vars->{bugs}); + my $bugs = exists $vars->{bugs} ? $vars->{bugs} : [$vars->{bug}]; + return if ref $bugs ne 'ARRAY'; + + foreach my $bug (@$bugs) { + if (!bug_is_ok_to_index($bug)) { + $vars->{sitemap_noindex} = 1; + last; + } + } +} + +sub page_before_template { + my ($self, $args) = @_; + my $page = $args->{page_id}; + + if ($page =~ m{^sitemap/sitemap\.}) { + my $map = generate_sitemap(__PACKAGE__->NAME); + print Bugzilla->cgi->header('text/xml'); + print $map; + exit; + } +} + +################ +# Installation # +################ + +sub install_before_final_checks { + my ($self) = @_; + if (!correct_urlbase()) { + print STDERR get_text('sitemap_no_urlbase'), "\n"; + return; + } + if (Bugzilla->params->{'requirelogin'}) { + print STDERR get_text('sitemap_requirelogin'), "\n"; + return; + } + + $self->_fix_robots_txt(); +} + +sub install_filesystem { + my ($self, $args) = @_; + my $create_dirs = $args->{'create_dirs'}; + my $recurse_dirs = $args->{'recurse_dirs'}; + my $htaccess = $args->{'htaccess'}; + + # Create the sitemap directory to store the index and sitemap files + my $sitemap_path = bz_locations->{'datadir'} . "/" . __PACKAGE__->NAME; + + $create_dirs->{$sitemap_path} = Bugzilla::Install::Filesystem::DIR_CGI_WRITE + | Bugzilla::Install::Filesystem::DIR_ALSO_WS_SERVE; + + $recurse_dirs->{$sitemap_path} = { + files => Bugzilla::Install::Filesystem::CGI_WRITE + | Bugzilla::Install::Filesystem::DIR_ALSO_WS_SERVE, + dirs => Bugzilla::Install::Filesystem::DIR_CGI_WRITE + | Bugzilla::Install::Filesystem::DIR_ALSO_WS_SERVE + }; + + # Create a htaccess file that allows the sitemap files to be served out + $htaccess->{"$sitemap_path/.htaccess"} = { + perms => Bugzilla::Install::Filesystem::WS_SERVE, + contents => <<EOT +# Allow access to sitemap files created by the SiteMapIndex extension +<FilesMatch ^sitemap.*\\.xml(.gz)?\$> + Allow from all +</FilesMatch> +Deny from all +EOT + }; +} + +sub _fix_robots_txt { + my ($self) = @_; + my $cgi_path = bz_locations()->{'cgi_path'}; + my $robots_file = "$cgi_path/robots.txt"; + my $current_fh = new IO::File("$cgi_path/robots.txt", 'r'); + if (!$current_fh) { + warn "$robots_file: $!"; + return; + } + + my $current_contents; + { local $/; $current_contents = <$current_fh> } + $current_fh->close(); + + return if $current_contents =~ /^Sitemap:/m; + my $backup_name = "$cgi_path/robots.txt.old"; + print get_text('sitemap_fixing_robots', { current => $robots_file, + backup => $backup_name }), "\n"; + rename $robots_file, $backup_name or die "backup failed: $!"; + + my $new_fh = new IO::File($self->package_dir . '/robots.txt', 'r'); + $new_fh || die "Could not open new robots.txt template file: $!"; + my $new_contents; + { local $/; $new_contents = <$new_fh> } + $new_fh->close() || die "Could not close new robots.txt template file: $!"; + + my $sitemap_url = correct_urlbase() . SITEMAP_URL; + $new_contents =~ s/SITEMAP_URL/$sitemap_url/; + $new_fh = new IO::File("$cgi_path/robots.txt", 'w'); + $new_fh || die "Could not open new robots.txt file: $!"; + print $new_fh $new_contents; + $new_fh->close() || die "Could not close new robots.txt file: $!"; +} + +__PACKAGE__->NAME; diff --git a/extensions/SiteMapIndex/lib/Constants.pm b/extensions/SiteMapIndex/lib/Constants.pm new file mode 100644 index 000000000..fce858121 --- /dev/null +++ b/extensions/SiteMapIndex/lib/Constants.pm @@ -0,0 +1,47 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Sitemap Bugzilla Extension. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander <mkanat@bugzilla.org> + +package Bugzilla::Extension::SiteMapIndex::Constants; +use strict; +use base qw(Exporter); +our @EXPORT = qw( + SITEMAP_AGE + SITEMAP_MAX + SITEMAP_DELAY + SITEMAP_URL +); + +# This is the amount of hours a sitemap index and it's files are considered +# valid before needing to be regenerated. +use constant SITEMAP_AGE => 12; + +# This is the largest number of entries that can be in a single sitemap file, +# per the sitemaps.org standard. +use constant SITEMAP_MAX => 50_000; + +# We only show bugs that are at least 12 hours old, because if somebody +# files a bug that's a security bug but doesn't protect it, we want to give +# them time to fix that. +use constant SITEMAP_DELAY => 12; + +use constant SITEMAP_URL => 'page.cgi?id=sitemap/sitemap.xml'; + +1; diff --git a/extensions/SiteMapIndex/lib/Util.pm b/extensions/SiteMapIndex/lib/Util.pm new file mode 100644 index 000000000..5c02a5989 --- /dev/null +++ b/extensions/SiteMapIndex/lib/Util.pm @@ -0,0 +1,205 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Sitemap Bugzilla Extension. +# +# The Initial Developer of the Original Code is Everything Solved, Inc. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Max Kanat-Alexander <mkanat@bugzilla.org> +# Dave Lawrence <dkl@mozilla.com> + +package Bugzilla::Extension::SiteMapIndex::Util; +use strict; +use base qw(Exporter); +our @EXPORT = qw( + generate_sitemap + bug_is_ok_to_index +); + +use Bugzilla::Extension::SiteMapIndex::Constants; + +use Bugzilla::Util qw(correct_urlbase datetime_from url_quote); +use Bugzilla::Constants qw(bz_locations); + +use Scalar::Util qw(blessed); +use IO::Compress::Gzip qw(gzip $GzipError); + +sub too_young_date { + my $hours_ago = DateTime->now(time_zone => Bugzilla->local_timezone); + $hours_ago->subtract(hours => SITEMAP_DELAY); + return $hours_ago; +} + +sub bug_is_ok_to_index { + my ($bug) = @_; + return 1 unless blessed($bug) && $bug->isa('Bugzilla::Bug') && !$bug->{error}; + my $creation_ts = datetime_from($bug->creation_ts); + return ($creation_ts && $creation_ts lt too_young_date()) ? 1 : 0; +} + +# We put two things in the Sitemap: a list of Browse links for products, +# and links to bugs. +sub generate_sitemap { + my ($extension_name) = @_; + + # If file is less than SITEMAP_AGE hours old, then read in and send to caller. + # If greater, then regenerate and send the new version. + my $index_file = bz_locations->{'datadir'} . "/$extension_name/sitemap_index.xml"; + if (-e $index_file) { + my $index_mtime = (stat($index_file))[9]; + my $index_hours = sprintf("%d", (time() - $index_mtime) / 60 / 60); # in hours + if ($index_hours < SITEMAP_AGE) { + my $index_fh = new IO::File($index_file, 'r'); + $index_fh || die "Could not open current sitemap index: $!"; + my $index_xml; + { local $/; $index_xml = <$index_fh> } + $index_fh->close() || die "Could not close current sitemap index: $!"; + + return $index_xml; + } + } + + # Set the atime and mtime of the index file to the current time + # in case another request is made before we finish. + utime(undef, undef, $index_file); + + # Sitemaps must never contain private data. + Bugzilla->logout_request(); + my $user = Bugzilla->user; + my $products = $user->get_accessible_products; + + my $num_bugs = SITEMAP_MAX - scalar(@$products); + # We do this date math outside of the database because databases + # usually do better with a straight comparison value. + my $hours_ago = too_young_date(); + + # We don't use Bugzilla::Bug objects, because this could be a tremendous + # amount of data, and we only want a little. Also, we only display + # bugs that are not in any group. We show the last $num_bugs + # most-recently-updated bugs. + my $dbh = Bugzilla->dbh; + my $bug_sth = $dbh->prepare( + 'SELECT bugs.bug_id, bugs.delta_ts + FROM bugs + LEFT JOIN bug_group_map ON bugs.bug_id = bug_group_map.bug_id + WHERE bug_group_map.bug_id IS NULL AND creation_ts < ? + ' . $dbh->sql_limit($num_bugs, '?')); + + my $filecount = 1; + my $filelist = []; + my $offset = 0; + + while (1) { + my $bugs = []; + + $bug_sth->execute($hours_ago, $offset); + + while (my ($bug_id, $delta_ts) = $bug_sth->fetchrow_array()) { + push(@$bugs, { bug_id => $bug_id, delta_ts => $delta_ts }); + } + + last if !@$bugs; + + # We only need the product links in the first sitemap file + $products = [] if $filecount > 1; + + push(@$filelist, _generate_sitemap_file($extension_name, $filecount, $products, $bugs)); + + $filecount++; + $offset += $num_bugs; + } + + # Generate index file + return _generate_sitemap_index($extension_name, $filelist); +} + +sub _generate_sitemap_index { + my ($extension_name, $filelist) = @_; + + my $dbh = Bugzilla->dbh; + my $timestamp = $dbh->selectrow_array( + "SELECT " . $dbh->sql_date_format('NOW()', '%Y-%m-%d')); + + my $index_xml = <<END; +<?xml version="1.0" encoding="UTF-8"?> +<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> +END + + foreach my $filename (@$filelist) { + $index_xml .= " + <sitemap> + <loc>" . correct_urlbase() . "data/$extension_name/$filename</loc> + <lastmod>$timestamp</lastmod> + </sitemap> +"; + } + + $index_xml .= <<END; +</sitemapindex> +END + + my $index_file = bz_locations->{'datadir'} . "/$extension_name/sitemap_index.xml"; + my $index_fh = new IO::File($index_file, 'w'); + $index_fh || die "Could not open new sitemap index: $!"; + print $index_fh $index_xml; + $index_fh->close() || die "Could not close new sitemap index: $!"; + + return $index_xml; +} + +sub _generate_sitemap_file { + my ($extension_name, $filecount, $products, $bugs) = @_; + + my $bug_url = correct_urlbase() . 'show_bug.cgi?id='; + my $product_url = correct_urlbase() . 'describecomponents.cgi?product='; + + my $sitemap_xml = <<END; +<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> +END + + foreach my $product (@$products) { + $sitemap_xml .= " + <url> + <loc>" . $product_url . url_quote($product->name) . "</loc> + <changefreq>daily</changefreq> + <priority>0.4</priority> + </url> +"; + } + + foreach my $bug (@$bugs) { + $sitemap_xml .= " + <url> + <loc>" . $bug_url . $bug->{bug_id} . "</loc> + <lastmod>" . datetime_from($bug->{delta_ts}, 'UTC')->iso8601 . 'Z' . "</lastmod> + </url> +"; + } + + $sitemap_xml .= <<END; +</urlset> +END + + # Write the compressed sitemap data to a file in the cgi root so that they can + # be accessed by the search engines. + my $filename = "sitemap$filecount.xml.gz"; + gzip \$sitemap_xml => bz_locations->{'datadir'} . "/$extension_name/$filename" + || die "gzip failed: $GzipError\n"; + + return $filename; +} + +1; diff --git a/extensions/SiteMapIndex/robots.txt b/extensions/SiteMapIndex/robots.txt new file mode 100644 index 000000000..74cc63074 --- /dev/null +++ b/extensions/SiteMapIndex/robots.txt @@ -0,0 +1,10 @@ +User-agent: * +Disallow: /*.cgi +Disallow: /show_bug.cgi*ctype=* +Allow: /$ +Allow: /index.cgi +Allow: /page.cgi +Allow: /show_bug.cgi +Allow: /describecomponents.cgi +Allow: /data/SiteMapIndex/sitemap*.xml.gz +Sitemap: SITEMAP_URL diff --git a/extensions/SiteMapIndex/template/en/default/hook/global/header-additional_header.html.tmpl b/extensions/SiteMapIndex/template/en/default/hook/global/header-additional_header.html.tmpl new file mode 100644 index 000000000..682f6093f --- /dev/null +++ b/extensions/SiteMapIndex/template/en/default/hook/global/header-additional_header.html.tmpl @@ -0,0 +1,23 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Initial Developer of the Original Code is Everything Solved, Inc. + # Portions created by Everything Solved are Copyright (C) 2010 + # Everything Solved. All Rights Reserved. + # + # The Original Code is the Bugzilla Sitemap Extension. + # + # Contributor(s): + # Max Kanat-Alexander <mkanat@bugzilla.org> + #%] + +[% SET meta_robots = ['noarchive'] %] +[% meta_robots.push('noindex') IF sitemap_noindex %] +<meta name="robots" content="[% meta_robots.join(',') FILTER html %]"> diff --git a/extensions/SiteMapIndex/template/en/default/hook/global/messages-messages.html.tmpl b/extensions/SiteMapIndex/template/en/default/hook/global/messages-messages.html.tmpl new file mode 100644 index 000000000..0d0e9fd74 --- /dev/null +++ b/extensions/SiteMapIndex/template/en/default/hook/global/messages-messages.html.tmpl @@ -0,0 +1,37 @@ +[%# The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Initial Developer of the Original Code is Everything Solved, Inc. + # Portions created by Everything Solved are Copyright (C) 2010 + # Everything Solved. All Rights Reserved. + # + # The Original Code is the Bugzilla Sitemap Extension. + # + # Contributor(s): + # Max Kanat-Alexander <mkanat@bugzilla.org> + #%] + +[% IF message_tag == "sitemap_fixing_robots" %] + Replacing [% current FILTER html %]. (The old version will be saved + as "[% backup FILTER html %]". You can delete the old version if you + do not need its contents.) + +[% ELSIF message_tag == "sitemap_requirelogin" %] + Not updating search engines with your sitemap, because you have the + "requirelogin" parameter turned on, and so search engines will not be + able to access your sitemap. + +[% ELSIF message_tag == "sitemap_no_urlbase" %] + You have not yet set the "urlbase" parameter. We cannot update + search engines and inform them about your sitemap without a + urlbase. Please set the "urlbase" parameter and re-run + checksetup.pl. + +[% END %] diff --git a/extensions/Splinter/Config.pm b/extensions/Splinter/Config.pm new file mode 100644 index 000000000..d36a28922 --- /dev/null +++ b/extensions/Splinter/Config.pm @@ -0,0 +1,5 @@ +package Bugzilla::Extension::Splinter; +use strict; +use constant NAME => 'Splinter'; + +__PACKAGE__->NAME; diff --git a/extensions/Splinter/Extension.pm b/extensions/Splinter/Extension.pm new file mode 100644 index 000000000..a3d9fe181 --- /dev/null +++ b/extensions/Splinter/Extension.pm @@ -0,0 +1,148 @@ +package Bugzilla::Extension::Splinter; + +use strict; + +use base qw(Bugzilla::Extension); + +use Bugzilla; +use Bugzilla::Bug; +use Bugzilla::Template; +use Bugzilla::Attachment; +use Bugzilla::BugMail; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Field; +use Bugzilla::Util qw(trim detaint_natural); + +use Bugzilla::Extension::Splinter::Util; + +our $VERSION = '0.1'; + +BEGIN { + *Bugzilla::splinter_review_base = \&get_review_base; + *Bugzilla::splinter_review_url = \&_get_review_url; +} + +sub _get_review_url { + my ($class, $bug_id, $attach_id) = @_; + return get_review_url(Bugzilla::Bug->check({ id => $bug_id, cache => 1 }), $attach_id); +} + +sub page_before_template { + my ($self, $args) = @_; + my ($vars, $page) = @$args{qw(vars page_id)}; + + if ($page eq 'splinter.html') { + my $user = Bugzilla->user; + + # We can either provide just a bug id to see a list + # of prior reviews by the user, or just an attachment + # id to go directly to a review page for the attachment. + # If both are give they will be checked later to make + # sure they are connected. + + my $input = Bugzilla->input_params; + if ($input->{'bug'}) { + $vars->{'bug_id'} = $input->{'bug'}; + $vars->{'attach_id'} = $input->{'attachment'}; + $vars->{'bug'} = Bugzilla::Bug->check({ id => $input->{'bug'}, cache => 1 }); + } + + if ($input->{'attachment'}) { + my $attachment = Bugzilla::Attachment->check({ id => $input->{'attachment'} }); + + # Check to see if the user can see the bug this attachment is connected to. + Bugzilla::Bug->check($attachment->bug_id); + if ($attachment->isprivate + && $user->id != $attachment->attacher->id + && !$user->is_insider) + { + ThrowUserError('auth_failure', {action => 'access', + object => 'attachment'}); + } + + # If the user provided both a bug id and an attachment id, they must + # be connected to each other + if ($input->{'bug'} && $input->{'bug'} != $attachment->bug_id) { + ThrowUserError('bug_attach_id_mismatch'); + } + + # The patch is going to be displayed in a HTML page and if the utf8 + # param is enabled, we have to encode attachment data as utf8. + if (Bugzilla->params->{'utf8'}) { + $attachment->data; # load data + utf8::decode($attachment->{data}); + } + + $vars->{'attach_id'} = $attachment->id; + $vars->{'attach_data'} = $attachment->data; + $vars->{'attach_is_crlf'} = $attachment->{data} =~ /\012\015/ ? 1 : 0; + } + + my $field_object = new Bugzilla::Field({ name => 'attachments.status' }); + my $statuses; + if ($field_object) { + $statuses = [map { $_->name } @{ $field_object->legal_values }]; + } else { + $statuses = []; + } + $vars->{'attachment_statuses'} = $statuses; + } +} + + +sub bug_format_comment { + my ($self, $args) = @_; + + my $bug = $args->{'bug'}; + my $regexes = $args->{'regexes'}; + my $text = $args->{'text'}; + + # Add [review] link to the end of "Created attachment" comments + # + # We need to work around the way that the hook works, which is intended + # to avoid overlapping matches, since we *want* an overlapping match + # here (the normal handling of "Created attachment"), so we add in + # dummy text and then replace in the regular expression we return from + # the hook. + $$text =~ s~((?:^Created\ |\b)attachment\s*\#?\s*(\d+)(\s\[details\])?) + ~(push(@$regexes, { match => qr/__REVIEW__$2/, + replace => get_review_link("$2", "[review]") })) && + (attachment_id_is_patch($2) ? "$1 __REVIEW__$2" : $1) + ~egmx; + + # And linkify "Review of attachment", this is less of a workaround since + # there is no issue with overlap; note that there is an assumption that + # there is only one match in the text we are linkifying, since they all + # get the same link. + my $REVIEW_RE = qr/Review\s+of\s+attachment\s+(\d+)\s*:/; + + if ($$text =~ $REVIEW_RE) { + my $attach_id = $1; + my $review_link = get_review_link($attach_id, "Review"); + my $attach_link = Bugzilla::Template::get_attachment_link($attach_id, "attachment $attach_id"); + + push(@$regexes, { match => $REVIEW_RE, + replace => "$review_link of $attach_link:"}); + } +} + +sub config_add_panels { + my ($self, $args) = @_; + + my $modules = $args->{panel_modules}; + $modules->{Splinter} = "Bugzilla::Extension::Splinter::Config"; +} + +sub mailer_before_send { + my ($self, $args) = @_; + + # Post-process bug mail to add review links to bug mail. + # It would be nice to be able to hook in earlier in the + # process when the email body is being formatted in the + # style of the bug-format_comment link for HTML but this + # is the only hook available as of Bugzilla-3.4. + add_review_links_to_email($args->{'email'}); +} + +__PACKAGE__->NAME; diff --git a/extensions/Splinter/lib/Config.pm b/extensions/Splinter/lib/Config.pm new file mode 100644 index 000000000..95b9f5dfa --- /dev/null +++ b/extensions/Splinter/lib/Config.pm @@ -0,0 +1,46 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Example Plugin. +# +# The Initial Developer of the Original Code is Canonical Ltd. +# Portions created by Canonical Ltd. are Copyright (C) 2008 +# Canonical Ltd. All Rights Reserved. +# +# Contributor(s): Max Kanat-Alexander <mkanat@bugzilla.org> +# Bradley Baetz <bbaetz@acm.org> +# Owen Taylor <otaylor@redhat.com> + +package Bugzilla::Extension::Splinter::Config; + +use strict; +use warnings; + +use Bugzilla::Config::Common; + +our $sortkey = 1350; + +sub get_param_list { + my ($class) = @_; + + my @param_list = ( + { + name => 'splinter_base', + type => 't', + default => 'page.cgi?id=splinter.html', + }, + ); + + return @param_list; +} + +1; diff --git a/extensions/Splinter/lib/Util.pm b/extensions/Splinter/lib/Util.pm new file mode 100644 index 000000000..3c77239a9 --- /dev/null +++ b/extensions/Splinter/lib/Util.pm @@ -0,0 +1,163 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Splinter Bugzilla Extension. +# +# The Initial Developer of the Original Code is Red Hat, Inc. +# Portions created by Red Hat, Inc. are Copyright (C) 2009 +# Red Hat Inc. All Rights Reserved. +# +# Contributor(s): +# Owen Taylor <otaylor@fishsoup.net> + +package Bugzilla::Extension::Splinter::Util; + +use strict; + +use Bugzilla; +use Bugzilla::Util; + +use base qw(Exporter); + +@Bugzilla::Extension::Splinter::Util::EXPORT = qw( + attachment_is_visible + attachment_id_is_patch + get_review_base + get_review_url + get_review_link + add_review_links_to_email +); + +# Validates an attachment ID. +# Takes a parameter containing the ID to be validated. +# If the second parameter is true, the attachment ID will be validated, +# however the current user's access to the attachment will not be checked. +# Will return false if 1) attachment ID is not a valid number, +# 2) attachment does not exist, or 3) user isn't allowed to access the +# attachment. +# +# Returns an attachment object. +# Based on code from attachment.cgi +sub attachment_id_is_valid { + my ($attach_id, $dont_validate_access) = @_; + + # Validate the specified attachment id. + detaint_natural($attach_id) || return 0; + + # Make sure the attachment exists in the database. + my $attachment = new Bugzilla::Attachment({ id => $attach_id, cache => 1 }) + || return 0; + + return $attachment + if ($dont_validate_access || attachment_is_visible($attachment)); +} + +# Checks if the current user can see an attachment +# Based on code from attachment.cgi +sub attachment_is_visible { + my $attachment = shift; + + $attachment->isa('Bugzilla::Attachment') || return 0; + + return (Bugzilla->user->can_see_bug($attachment->bug->id) + && (!$attachment->isprivate + || Bugzilla->user->id == $attachment->attacher->id + || Bugzilla->user->is_insider)); +} + +sub attachment_id_is_patch { + my $attach_id = shift; + my $attachment = attachment_id_is_valid($attach_id); + + return ($attachment && $attachment->ispatch); +} + +sub get_review_base { + my $base = Bugzilla->params->{'splinter_base'}; + $base =~ s!/$!!; + my $urlbase = correct_urlbase(); + $urlbase =~ s!/$!! if $base =~ "^/"; + $base = $urlbase . $base; + return $base; +} + +sub get_review_url { + my ($bug, $attach_id) = @_; + my $base = get_review_base(); + my $bug_id = $bug->id; + return $base . ($base =~ /\?/ ? '&' : '?') . "bug=$bug_id&attachment=$attach_id"; +} + +sub get_review_link { + my ($attach_id, $link_text) = @_; + + my $attachment = attachment_id_is_valid($attach_id); + + if ($attachment && $attachment->ispatch) { + return "<a href='" . html_quote(get_review_url($attachment->bug, $attach_id)) . + "'>$link_text</a>"; + } + else { + return $link_text; + } +} + +sub munge_create_attachment { + my ($bug, $intro_text, $attach_id, $view_link) = @_; + + if (attachment_id_is_patch($attach_id)) { + return ("$intro_text" . + " View: $view_link\015\012" . + " Review: " . get_review_url($bug, $attach_id, 1) . "\015\012"); + } + else { + return ("$intro_text --> ($view_link)"); + } +} + +# This adds review links into a bug mail before we send it out. +# Since this is happening after newlines have been converted into +# RFC-2822 style \r\n, we need handle line ends carefully. +# (\015 and \012 are used because Perl \n is platform-dependent) +sub add_review_links_to_email { + my $email = shift; + my $body = $email->body; + my $new_body = 0; + my $bug; + + if ($email->header('Subject') =~ /^\[Bug\s+(\d+)\]/ + && Bugzilla->user->can_see_bug($1)) + { + $bug = Bugzilla::Bug->new({ id => $1, cache => 1 }); + } + + return unless defined $bug; + + if ($body =~ /Review\s+of\s+attachment\s+\d+\s*:/) { + $body =~ s~(Review\s+of\s+attachment\s+(\d+)\s*:) + ~"$1\015\012 --> (" . get_review_url($bug, $2, 1) . ")" + ~egx; + $new_body = 1; + } + + if ($body =~ /Created attachment \d+\015\012 --> /) { + $body =~ s~(Created\ attachment\ (\d+)\015\012) + \ -->\ \(([^\015\012]*)\)[^\015\012]* + ~munge_create_attachment($bug, $1, $2, $3) + ~egx; + $new_body = 1; + } + + $email->body_set($body) if $new_body; +} + +1; diff --git a/extensions/Splinter/template/en/default/admin/params/splinter.html.tmpl b/extensions/Splinter/template/en/default/admin/params/splinter.html.tmpl new file mode 100644 index 000000000..b28a4bd37 --- /dev/null +++ b/extensions/Splinter/template/en/default/admin/params/splinter.html.tmpl @@ -0,0 +1,38 @@ +[%# + # The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Bugzilla Example Plugin. + # + # The Initial Developer of the Original Code is Canonical Ltd. + # Portions created by Canonical Ltd. are Copyright (C) 2008 + # Canonical Ltd. All Rights Reserved. + # + # Contributor(s): Bradley Baetz <bbaetz@acm.org> + # Owen Taylor <otaylor@redhat.com> + #%] +[% + title = "Splinter Patch Review" + desc = "Configure Splinter" +%] + +[% param_descs = { + splinter_base => "This is the base URL for the Splinter patch review page; " _ + "the default value '/page.cgi?id=splinter.html' works without " _ + "further configuration, however you may want to internally forward " _ + "/review to that URL in your web server's configuration and then change " _ + "this parameter. For example, with the Apache HTTP server, you can add " _ + "the following lines to the .htaccess for Bugzilla: " _ + "<pre>" _ + "RewriteEngine On\n" _ + "RewriteRule ^review(.*) page.cgi?id=splinter.html\$1 [QSA]" _ + "</pre>" + } +%] diff --git a/extensions/Splinter/template/en/default/hook/attachment/edit-action.html.tmpl b/extensions/Splinter/template/en/default/hook/attachment/edit-action.html.tmpl new file mode 100644 index 000000000..7648e1d76 --- /dev/null +++ b/extensions/Splinter/template/en/default/hook/attachment/edit-action.html.tmpl @@ -0,0 +1,25 @@ +[%# + # The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Splinter Bugzilla Extension. + # + # The Initial Developer of the Original Code is Red Hat, Inc. + # Portions created by Red Hat, Inc. are Copyright (C) 2008 + # Red Hat, Inc. All Rights Reserved. + # + # Contributor(s): Owen Taylor <otaylor@redhat.com> + # David Lawrence <dkl@mozilla.com> + #%] + +[% IF attachment.ispatch %] +   | + <a href="[% Bugzilla.splinter_review_url(attachment.bug_id, attachment.id) FILTER none %]">Review</a> +[% END %] diff --git a/extensions/Splinter/template/en/default/hook/attachment/list-action.html.tmpl b/extensions/Splinter/template/en/default/hook/attachment/list-action.html.tmpl new file mode 100644 index 000000000..ee793b192 --- /dev/null +++ b/extensions/Splinter/template/en/default/hook/attachment/list-action.html.tmpl @@ -0,0 +1,25 @@ +[%# + # The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Splinter Bugzilla Extension. + # + # The Initial Developer of the Original Code is Red Hat, Inc. + # Portions created by Red Hat, Inc. are Copyright (C) 2008 + # Red Hat, Inc. All Rights Reserved. + # + # Contributor(s): Owen Taylor <otaylor@redhat.com> + # David Lawrence <dkl@mozilla.com> + #%] + +[% IF attachment.ispatch %] +   | + <a href="[% Bugzilla.splinter_review_url(bugid, attachment.id) FILTER none %]">Review</a> +[% END %] diff --git a/extensions/Splinter/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/Splinter/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..17ef5c08f --- /dev/null +++ b/extensions/Splinter/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,5 @@ +[% IF error == "bug_attach_id_mismatch" %] + [% title = "Bug ID and Attachment ID Mismatch" %] + The [% terms.bug %] id and attachment id you provided + are not connected to each other. +[% END %] diff --git a/extensions/Splinter/template/en/default/hook/request/email-after_summary.txt.tmpl b/extensions/Splinter/template/en/default/hook/request/email-after_summary.txt.tmpl new file mode 100644 index 000000000..159a63e36 --- /dev/null +++ b/extensions/Splinter/template/en/default/hook/request/email-after_summary.txt.tmpl @@ -0,0 +1,9 @@ +[% USE Bugzilla %] +[% IF flag && flag.status == '?' + && (flag.type.name == 'review' || flag.type.name == 'feedback') + && attachment && attachment.ispatch %] + +Review +[%+ Bugzilla.splinter_review_url(bug.bug_id, attachment.id) FILTER none %] +[%- END %] + diff --git a/extensions/Splinter/template/en/default/hook/request/queue-after_column.html.tmpl b/extensions/Splinter/template/en/default/hook/request/queue-after_column.html.tmpl new file mode 100644 index 000000000..a5fc61cea --- /dev/null +++ b/extensions/Splinter/template/en/default/hook/request/queue-after_column.html.tmpl @@ -0,0 +1,4 @@ +[% IF column == 'attachment' && request.ispatch %] + + <a href="[% Bugzilla.splinter_review_url(request.bug_id, request.attach_id) FILTER none %]">[review]</a> +[% END %] diff --git a/extensions/Splinter/template/en/default/pages/splinter.html.tmpl b/extensions/Splinter/template/en/default/pages/splinter.html.tmpl new file mode 100644 index 000000000..9b759ab6e --- /dev/null +++ b/extensions/Splinter/template/en/default/pages/splinter.html.tmpl @@ -0,0 +1,282 @@ +[%# + # The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Splinter Bugzilla Extension. + # + # The Initial Developer of the Original Code is Red Hat, Inc. + # Portions created by Red Hat, Inc. are Copyright (C) 2008 + # Red Hat, Inc. All Rights Reserved. + # + # Contributor(s): Owen Taylor <otaylor@redhat.com> + #%] + +[% bodyclasses = [] %] +[% FOREACH group = bug.groups_in %] + [% bodyclasses.push("bz_group_$group.name") %] +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Patch Review" + header = "Patch Review" + style_urls = [ "js/yui/assets/skins/sam/container.css", + "js/yui/assets/skins/sam/button.css", + "js/yui/assets/skins/sam/datatable.css", + "extensions/Splinter/web/splinter.css", + "skins/custom/bug_groups.css" ] + javascript_urls = [ "js/yui/element/element-min.js", + "js/yui/container/container-min.js", + "js/yui/button/button-min.js", + "js/yui/json/json-min.js", + "js/yui/datasource/datasource-min.js", + "js/yui/datatable/datatable-min.js", + "extensions/Splinter/web/splinter.js", + "js/field.js" ] + bodyclasses = bodyclasses + yui = ['autocomplete'] +%] + +[% can_edit = 0 %] + +<script type="text/javascript"> + Splinter.configBase = '[% Bugzilla.splinter_review_base FILTER js %]'; + Splinter.configBugUrl = '[% urlbase FILTER js %]'; + Splinter.configHaveExtension = true; + Splinter.configHelp = '[% urlbase FILTER js %]page.cgi?id=splinter/help.html'; + Splinter.configNote = ''; + Splinter.readOnly = [% user.id FILTER none %] == 0; + + Splinter.configAttachmentStatuses = [ + [% FOREACH status = attachment_statuses %] + '[% status FILTER js %]', + [% END %] + ]; + + Splinter.bugId = Splinter.Utils.isDigits('[% bug_id FILTER js %]') ? parseInt('[% bug_id FILTER js %]') : NaN; + Splinter.attachmentId = Splinter.Utils.isDigits('[% attach_id FILTER html %]') ? parseInt('[% attach_id FILTER js %]') : NaN; + + if (!isNaN(Splinter.bugId)) { + var theBug = new Splinter.Bug.Bug(); + theBug.id = parseInt('[% bug.id FILTER js %]'); + theBug.token = '[% update_token FILTER js %]'; + theBug.shortDesc = Splinter.Utils.strip('[% bug.short_desc FILTER js %]'); + theBug.creationDate = Splinter.Bug.parseDate('[% bug.creation_ts FILTER time("%Y-%m-%d %T %z") FILTER js %]'); + theBug.reporterEmail = Splinter.Utils.strip('[% bug.reporter.email FILTER js %]'); + theBug.reporterName = Splinter.Utils.strip('[% bug.reporter.name FILTER js %]'); + + [% FOREACH comment = bug.comments %] + [% NEXT IF comment.is_private && !user.is_insider %] + [% NEXT UNLESS comment.thetext.match('(?i)^\s*review\s+of\s+attachment\s+\d+\s*:') %] + var comment = new Splinter.Bug.Comment(); + comment.whoName = Splinter.Utils.strip('[% comment.author.name FILTER js %]'); + comment.whoEmail = Splinter.Utils.strip('[% comment.author.email FILTER js %]'); + comment.date = Splinter.Bug.parseDate('[% comment.creation_ts FILTER time("%Y-%m-%d %T %z") FILTER js %]'); + comment.text = '[% comment.thetext FILTER js %]'; + theBug.comments.push(comment); + [% END %] + + [% FOREACH attachment = bug.attachments %] + [% NEXT IF attachment.isprivate && !user.is_insider && attachment.attacher.id != user.id %] + [% NEXT IF !attachment.ispatch %] + var attachid = parseInt('[% attachment.id FILTER js %]'); + var attachment = new Splinter.Bug.Attachment('', attachid); + [% IF attachment.id == attach_id && attachment.ispatch %] + [% flag_types = attachment.flag_types %] + [% can_edit = attachment.validate_can_edit %] + attachment.data = '[% attach_data FILTER js %]'; + attachment.token = '[% issue_hash_token([attachment.id, attachment.modification_time]) FILTER js %]'; + [% END %] + attachment.description = Splinter.Utils.strip('[% attachment.description FILTER js %]'); + attachment.filename = Splinter.Utils.strip('[% attachment.filename FILTER js %]'); + attachment.contenttypeentry = Splinter.Utils.strip('[% attachment.contenttypeentry FILTER js %]'); + attachment.date = Splinter.Bug.parseDate('[% attachment.attached FILTER time("%Y-%m-%d %T %z") FILTER js %]'); + attachment.whoName = Splinter.Utils.strip('[% attachment.attacher.name FILTER js %]'); + attachment.whoEmail = Splinter.Utils.strip('[% attachment.attacher.email FILTER js %]'); + attachment.isPatch = [% attachment.ispatch ? 1 : 0 %]; + attachment.isObsolete = [% attachment.isobsolete ? 1 : 0 %]; + attachment.isPrivate = [% attachment.isprivate ? 1 : 0 %]; + attachment.isCRLF = [% attach_is_crlf FILTER none %]; + theBug.attachments.push(attachment); + [% END %] + + Splinter.theBug = theBug; + } +</script> + +<!--[if lt IE 7]> +<p style="border: 1px solid #880000; padding: 1em; background: #ffee88; font-size: 120%;"> + Splinter Patch Review requires a modern browser, such as + <a href="http://www.firefox.com">Firefox</a>, for correct operation. +</p> +<![endif]--> + +<div id="helpful-links"> + [% IF user.id %] + <a id="allReviewsLink" href="[% Bugzilla.splinter_review_base FILTER none %]"> + [reviews]</a> + [% END %] + <a id='helpLink' target='splinterHelp' + href="[% urlbase FILTER none %]page.cgi?id=splinter/help.html"> + [help]</a> +</div> + +<div id="bugInfo" style="display: none;"> + <b>[% terms.Bug %] <a id="bugLink"><span id="bugId"></span></a>:</b> + <span id="bugShortDesc"></span> - + <span id="bugReporter"></span> - + <span id="bugCreationDate"></span> +</div> + +<div id="attachInfo" style="display:none;"> + <span id="attachWarning"></span> + <b>Attachment <a id="attachLink"><span id="attachId"></span></a>:</b> + <span id="attachDesc"></span> - + <span id="attachCreator"></span> - + <span id="attachDate"></span> + [% IF feature_enabled('patch_viewer') %] + <a href="[% urlbase FILTER none %]attachment.cgi?id=[% attach_id FILTER uri %]&action=diff" + target="_blank">[diff]</a> + [% END %] + <a href="[% urlbase FILTER none %]attachment.cgi?id=[% attach_id FILTER uri %]&action=edit" + target="_blank">[details]</a> +</div> + +<div id="error" style="display: none;"> </div> + +<div id="enterBug" style="display: none;"> + [% terms.Bug %] to review: + <input id="enterBugInput" /> + <input id="enterBugGo" type="button" value="Go" /> + <div id="chooseReview" style="display: none;"> + Drafts and published reviews: + <div id="chooseReviewTable"></div> + </div> +</div> + +<div id="chooseAttachment" style="display: none;"> + <div id="chooseAttachmentTable"></div> +</div> + +<div id="quickHelpShow" style="display:none;"> + <p> + <a href="javascript:Splinter.quickHelpToggle();" title="Show the quick help section" id="quickHelpToggle"> + Show Quick Help</a> + </p> +</div> + +<div id="quickHelpContent" style="display:none;"> + <p> + <a href="javascript:Splinter.quickHelpToggle();" title="Hide the quick help section" id="quickHelpToggle">Close Quick Help</a> + </p> + <ul id="quickHelpList"> + <li>From the Overview page, you can add a more generic overview comment that will appear at the beginning of your review.</li> + <li>To comment on a specific lines in the patch, first select the filename from the file navigation links.</li> + <li>Then double click the line you want to review and a comment box will appear below the line.</li> + <li>When the review is complete and you publish it, the overview comment and all line specific comments with their context, + will be combined together into a single review comment on the [% terms.bug %] report.</li> + <li>For more detailed instructions, read the Splinter + <a id='helpLink' target='splinterHelp' href="[% urlbase FILTER none %]page.cgi?id=splinter/help.html">help page</a>. + </li> + </ul> +</div> + +<div id="navigationContainer" style="display: none;"> + <b>Navigation:</b> <span id="navigation"></span> +</div> + +<div id="overview" style="display: none;"> + <div id="patchIntro"></div> + <div> + <span id="restored" style="display: none;"> + (Restored from draft; last edited <span id="restoredLastModified"></span>) + </span> + </div> + [% IF user.id %] + <div> + <div id="myCommentFrame"> + <textarea id="myComment"></textarea> + <div id="emptyCommentNotice"><Overall Comment></div> + </div> + <div id="myPatchComments"></div> + <form id="publish" method="post" action="attachment.cgi" onsubmit="normalizeComments();"> + <input type="hidden" id="publish_token" name="token" value=""> + <input type="hidden" id="publish_action" name="action" value="update"> + <input type="hidden" id="publish_review" name="comment" value=""> + <input type="hidden" id="publish_attach_id" name="id" value=""> + <input type="hidden" id="publish_attach_desc" name="description" value=""> + <input type="hidden" id="publish_attach_filename" name="filename" value=""> + <input type="hidden" id="publish_attach_contenttype" name="contenttypeentry" value=""> + <input type="hidden" id="publish_attach_ispatch" name="ispatch" value=""> + <input type="hidden" id="publish_attach_isobsolete" name="isobsolete" value=""> + <input type="hidden" id="publish_attach_isprivate" name="isprivate" value=""> + <div id="attachment_flags"> + [% any_flags_requesteeble = 0 %] + [% FOREACH flag_type = flag_types %] + [% NEXT UNLESS flag_type.is_active %] + [% SET any_flags_requesteeble = 1 IF flag_type.is_requestable && flag_type.is_requesteeble %] + [% END %] + [% IF flag_types.size > 0 %] + [% PROCESS "flag/list.html.tmpl" bug_id = bug_id + attach_id = attach_d + flag_types = flag_types + read_only_flags = !can_edit + any_flags_requesteeble = any_flags_requesteeble + %] + [% END %] + <script> + [% FOREACH flag_type = flag_types %] + [% NEXT UNLESS flag_type.is_active %] + Event.addListener('flag_type-[% flag_type.id FILTER js %]', 'change', + function() { Splinter.flagChanged = 1; + Splinter.queueUpdateHaveDraft(); }); + [% FOREACH flag = flag_type.flags %] + Event.addListener('flag-[% flag.id FILTER js %]', 'change', + function() { Splinter.flagChanged = 1; + Splinter.queueUpdateHaveDraft(); }); + [% END %] + [% END %] + </script> + </div> + </form> + <div id="buttonBox"> + <span id="attachmentStatusSpan">Patch Status: + <select id="attachmentStatus"> </select> + </span> + <input id="publishButton" type="button" value="Publish" /> + <input id="cancelButton" type="button" value="Cancel" /> + </div> + <div class="clear"></div> + </div> + [% ELSE %] + <div> + You must be logged in to review patches. + </div> + [% END %] + <div id="oldReviews" style="display: none;"> + <div class="review-title"> + Previous Reviews + </div> + </div> +</div> + +<div id="splinter-files" style="display: none;"> + <div id="file-collapse-all" style="display:none;"> + <a href="javascript:void(0);" onclick="Splinter.toggleCollapsed('', 'none')">Collapse All</a> | + <a href="javascript:void(0);" onclick="Splinter.toggleCollapsed('', 'block')">Expand All</a> + </div> +</div> + +<div id="credits"> + Powered by <a href="http://fishsoup.net/software/splinter">Splinter</a> +</div> + +<div id="saveDraftNotice" style="display: none;"></div> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/Splinter/template/en/default/pages/splinter/help.html.tmpl b/extensions/Splinter/template/en/default/pages/splinter/help.html.tmpl new file mode 100644 index 000000000..dac513e56 --- /dev/null +++ b/extensions/Splinter/template/en/default/pages/splinter/help.html.tmpl @@ -0,0 +1,153 @@ +[%# + # The contents of this file are subject to the Mozilla Public + # License Version 1.1 (the "License"); you may not use this file + # except in compliance with the License. You may obtain a copy of + # the License at http://www.mozilla.org/MPL/ + # + # Software distributed under the License is distributed on an "AS + # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + # implied. See the License for the specific language governing + # rights and limitations under the License. + # + # The Original Code is the Splinter Bugzilla Extension. + # + # The Initial Developer of the Original Code is Red Hat, Inc. + # Portions created by Red Hat, Inc. are Copyright (C) 2008 + # Red Hat, Inc. All Rights Reserved. + # + # Contributor(s): Owen Taylor <otaylor@redhat.com> + #%] + +[% PROCESS global/header.html.tmpl + title = "Patch Review Help" + header = "Patch Review Help" +%] + +<h2>Splinter Patch Review</h2> +<p> + Splinter is an add-on for [% terms.Bugzilla %] to allow conveniently + reviewing patches that people have attached to + [%+ terms.Bugzilla %]. <a href="http://fishsoup.net/software/splinter">More + information about Splinter</a>. +</p> +<h3>The patch review view</h3> +<p> + If you get to Splinter by clicking on a link next to an + attachment in [% terms.Bugzilla %], you are presented with the patch + review view. This view has a number of different pages that can + be switched between with the links at the top of the screen. + The first page is the Overview page, the other pages correspond to + individual files changed by the review. +</p> +<p> + On the Overview page, from top to bottom are shown: +</p> +<ul> + <li>Introductory text to the patch. For a patch that was created + using 'git format-patch' this will be the Git commit + message.</li> + <li>Controls for creating a new review</li> + <li>Previous reviews that other people have written.</li> +</ul> +<p> + The pages for each file show a two-column view of the changes. + The left column is the previous contents of the file, + the right column is the new contents of the file. (If the file + is an entirely new file or an entirely deleted file, only one + column will be shown.) Red indicates lines that have been + removed, green lines that have been added, and blue lines that + were modified. +</p> +<p> + If people have previously made comments on individual lines of + the patch, they will show up both summarized on the Overview + page and also inline when looking at the files of the patch. +</p> +<h3>Reviewing an existing patch</h3> +<p> + There are three components to a review: +</p> +<ul> + <li> + An overall comment. The text area on the first page allows + you to enter your overall thoughts on the [% terms.bug %]. + </li> + <li> + Detailed comments on changes within the files. To comment on a + line in a patch, double click on it, and a text area will open + beneath that comment. When you are done, click the Save button + to save your comment or the Cancel button to throw your + comment away. You can double-click on a saved comment to start + editing it again and make further changes. + </li> + <li> + A change to the attachment status. (This is specific to + [%+ terms.Bugzilla %] instances that have attachment status, which is a + non-upstream patch. It's somewhat similar to attachment flags, + which splinter doesn't currently support displaying or + changing.) This allows you to mark a patch as read to commit + or needing additional work. This is done by changing the + drop-down next to the Publish button. + </li> +</ul> +<p> + Once you are done writing your review, go back to Overview page + and click the "Publish" button to submit it as a comment on the + [%+ terms.bug %]. The comment will have a link back to the review page so + that people can see your comments with the full context. +</p> +<h3>Saved drafts</h3> +<p> + Whenever you start making changes, a draft is automatically + saved. If you come back to the patch review page for the same + attachment, that draft will automatically be resumed. Drafts are + not visible to anybody else until published. +</p> +<p> + Note that saving drafts requires the your browser to have support + for the "DOM Storage" standard. At time of writing, this is + available only in a few very recent browsers, like Firefox + 3.5. Strict privacy protections like disabling cookies may also + disable DOM Storage, since it provides another mechanism for + sites to track information about their users. +</p> +<h3>Responding to someone's review</h3> +<p> + A response is treated just like any other review and created the + same way. A couple of features are helpful when responding: you + can double-click on an inline comment to respond to it. And on + the overview page, when you click on a detailed comment, you are + taken directly to the original location of the comment. +</p> +<h3>Uploading patches for review</h3> +<p> + Splinter doesn't really care how patches are provided to + [%+ terms.Bugzilla %], as long as they are well-formatted patches. If you are + using Git for version control, you can either format changes as + patches + using <a href="http://www.kernel.org/pub/software/scm/git/docs/git-format-patch.html">'git + format-patch</a> and attach them manually to the [% terms.bug %], or you + can + use <a href="http://fishsoup.net/software/git-bz">git-bz</a>. + git-bz is highly recommended; it automates most of the steps + that Splinter can't handle: it files new [% terms.bugs %], attaches updated + attachments to existing [% terms.bugs %], and closes [% terms.bugs %] when you push the + corresponding git commits to your central repository. +</p> +<h3>The [% terms.bug %] review view</h3> +<p> + Splinter also has a view where it shows all patches attached to + the [% terms.bug %] with their status and links to review them. You are + taken to this page after publishing a review. You can also get + to this page with the [% terms.bug %] link in the upper-right corner of the + patch review view. +</p> +<h3>Your reviews</h3> +<p> + Splinter can also show you a list of all your draft and + published reviews. Access this page with the "Your reviews" + link at the bottom of the [% terms.bug %] review view. In-progress drafts + are shown in bold. +</p> + +[% PROCESS global/footer.html.tmpl %] diff --git a/extensions/Splinter/web/splinter.css b/extensions/Splinter/web/splinter.css new file mode 100644 index 000000000..36e5fef31 --- /dev/null +++ b/extensions/Splinter/web/splinter.css @@ -0,0 +1,428 @@ +textarea:focus { + background: #f7f2d0; +} + +#note { + background: #ffee88; + padding: 0.5em; +} + +#error { + border: 1px solid black; + padding: 0.5em; + color: #bb0000; +} + +#chooseReview { + margin-top: 1em; +} + +.review-draft .review-desc, .review-draft .review-attachment { + font-weight: bold; +} + +#bugInfo, #attachInfo { + margin-top: 0.5em; + margin-bottom: 1em; +} + +#helpful-links { + float:right; +} + +#chooseAttachment table { + margin-bottom: 1em; +} + +#attachWarning { + font-weight: bold; + color: #c00000; +} + +.attachment-draft .attachment-id, .attachment-draft .attachment-desc { + font-weight: bold; +} + +.attachment-obsolete .attachment-desc { + text-decoration: line-through ; +} + +#navigation { + color: #888888; +} + +.navigation-link { + text-decoration: none; + white-space: nowrap; +} + +.navigation-link-selected { + color: black; +} + +#haveDraftNotice { + float: right; + color: #bb0000; + font-weight: bold; +} + +#overview { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +#patchIntro { + border: 1px solid #888888; + font-size: 90%; + margin-bottom: 1em; + padding: 0.5em; +} + +.reviewer-box { + padding: 0.5em; +} + +.reviewer-0 .reviewer-box { + border-left: 10px solid green; +} + +.reviewer-1 .reviewer-box { + border-left: 10px solid blue; +} + +.reviewer-2 .reviewer-box { + border-left: 10px solid red; +} + +.reviewer-3 .reviewer-box { + border-left: 10px solid yellow; +} + +.reviewer-4 .reviewer-box { + border-left: 10px solid purple; +} + +.reviewer-5 .reviewer-box { + border-left: 10px solid orange; +} + +.reviewer { + float: left; +} + +.review-date { + float: right; +} + +.review-info-bottom { + clear: both; +} + +.review { + border: 1px solid black; + font-size: 90%; + margin-top: 0.25em; + margin-bottom: 1em; +} + +.review-intro { + margin-top: 0.5em; +} + +.review-patch-file { + margin-top: 0.5em; + font-weight: bold; +} + +.review-patch-comment { + border: 1px solid white; + padding: 1px; + margin-top: 0.5em; + margin-bottom: 0.5em; + cursor: pointer; +} + +.review-patch-comment:hover { + border: 1px solid #8888ff; +} + +.review-patch-comment .file-table { + width: 50%; +} + +.review-patch-comment .file-table-changed { + width: 100%; +} + +.review-patch-comment-separator { + margin: 0.5em; + border-bottom: 1px solid #888888; +} + +div.review-patch-comment-text { + margin-left: 2em; +} + +.review-patch-comment .reviewer-box { + border-left-width: 4px; +} + +#restored { + color: #bb0000; + margin-bottom: 0.5em; +} + +#myCommentFrame { + margin-top: 0.25em; + position: relative; + border: 1px solid black; + padding-right: 8px; /* compensate for child's padding */ +} + +#myComment { + border: 0px solid black; + padding: 4px; + margin: 0px; + width: 100%; + height: 10em; +} + +#emptyCommentNotice { + position: absolute; + top: 4px; + left: 4px; + color: #888888; +} + +#myPatchComments { + border: 1px solid black; + border-top-width: 0px; + padding: 0.5em; + font-size: 90%; +} + +#buttonBox { + margin-top: 0.5em; + float: right; +} + +.clear { + clear: both; +} + +/* Used for IE <= 7, overridden for modern browsers */ +.pre-wrap { + white-space: pre; + word-wrap: break-word; +} + +.pre-wrap { + white-space: pre-wrap; +} + +#splinter-files { + position: relative; + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.file-label { + margin-top: 1em; + margin-bottom: 0.5em; +} + +.file-label-name { + font-weight: bold; +} + +.file-label-extra { + font-size: 90%; + font-style: italic; +} + +.hunk-header { + border: 1px solid #aaaaaa; +} + +.hunk-header td { + background: #ddccbb; + font-size: 80%; + font-weight: bold; +} + +.hunk-cell { + padding: 2px; +} + +.old-line, .new-line { + font-family: "DejaVu Sans Mono", monospace; + font-size: 80%; + white-space: pre-wrap; /* CSS 3 & 2.1 */ + white-space: -moz-pre-wrap; /* Gecko */ + white-space: -pre-wrap; /* Opera 4-6 */ + white-space: -o-pre-wrap; /* Opera 7 */ +} + +.removed-line { + background: #ffccaa;; +} + +.added-line { + background: #bbffbb; +} + +.changed-line { + background: #aaccff; +} + +.file-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.line-number { + font-size: 80%; + text-align: right; + padding-right: 0.25em; + color: #888888; + -moz-user-select: none; +} + +.line-number-column { + width: 2em; +} + +.file-table-wide-numbers .line-number-column { + width: 3em; +} + +.middle-column { + width: 3px; +} + +.file-table-changed .comment-removed { + width: 50%; + float: left; +} + +.file-table-changed .comment-changed { + margin-left: 25%; + margin-right: 25%; + clear: both; +} + +.file-table-changed .comment-added { + width: 50%; + float: right; +} + +.comment-frame { + border: 1px solid black; + margin-top: 5px; + margin-bottom: 5px; + margin-left: 2em; +} + +.file-table-wide-numbers .comment-frame { + margin-left: 3em; +} + +.comment .review-info { + margin-top: 0.5em; + font-size: 80%; +} + +#commentTextFrame { + border: 1px solid #ffeeaa; + margin-bottom: 5px; +} + +#commentEditor.focused #commentTextFrame { + border: 1px solid #8888bb; +} + +#commentEditorInner { + background: #ffeeaa; + padding: 0.5em; + margin-left: 2em; +} + +.file-table-wide-numbers #commentEditorInner { + margin-left: 3em; +} + +#commentEditor textarea { + width: 100%; + height: 10em; + border: 0px; +} + +#commentEditor textarea:focus { + background: white; +} + +#commentEditorLeftButtons { + float: left; +} + +#commentEditorLeftButtons input { + margin-right: 0.5em; +} + +#commentEditorRightButtons { + float: right; +} + +.comment-separator-removed { + clear: left; +} + +.comment-separator-added { + clear: right; +} + +#saveDraftNotice { + border: 1px solid black; + padding: 0.5em; + background: #ffccaa; + position: fixed; + bottom: 0px; + right: 0px; +} + +#credits { + font-size: 80%; + color: #888888; + padding: 10px; + text-align: center; +} + +#quickHelpShow a, #quickHelpContent a { + text-decoration: none; +} + +#quickHelpContent { + border: 1px solid #000; + -moz-border-radius: 10px; + border-radius: 10px; + padding-left: 0.5em; + margin-bottom: 10px; +} + +.file-label-collapse { + padding-right: 5px; + font-family: monospace; +} + +.file-review-label { + font-size: 80%; +} + +.file-reviewed-nav { + text-decoration: line-through; +} + +.trailing-whitespace { + background: #ffaaaa; +} diff --git a/extensions/Splinter/web/splinter.js b/extensions/Splinter/web/splinter.js new file mode 100644 index 000000000..b33fa778c --- /dev/null +++ b/extensions/Splinter/web/splinter.js @@ -0,0 +1,2700 @@ +// Splinter - patch review add-on for Bugzilla +// By Owen Taylor <otaylor@fishsoup.net> +// Copyright 2009, Red Hat, Inc. +// Licensed under MPL 1.1 or later, or GPL 2 or later +// http://git.fishsoup.net/cgit/splinter +// Converted to YUI by David Lawrence <dkl@mozilla.com> + +YAHOO.namespace('Splinter'); + +var Dom = YAHOO.util.Dom; +var Event = YAHOO.util.Event; +var Splinter = YAHOO.Splinter; +var Element = YAHOO.util.Element; + +Splinter.domCache = { + cache : [0], + expando : 'data' + new Date(), + data : function (elem) { + var cacheIndex = elem[Splinter.domCache.expando]; + var nextCacheIndex = Splinter.domCache.cache.length; + if (!cacheIndex) { + cacheIndex = elem[Splinter.domCache.expando] = nextCacheIndex; + Splinter.domCache.cache[cacheIndex] = {}; + } + return Splinter.domCache.cache[cacheIndex]; + } +}; + +Splinter.Utils = { + assert : function(condition) { + if (!condition) { + throw new Error("Assertion failed"); + } + }, + + assertNotReached : function() { + throw new Error("Assertion failed: should not be reached"); + }, + + strip : function(string) { + return (/^\s*([\s\S]*?)\s*$/).exec(string)[1]; + }, + + lstrip : function(string) { + return (/^\s*([\s\S]*)$/).exec(string)[1]; + }, + + rstrip : function(string) { + return (/^([\s\S]*?)\s*$/).exec(string)[1]; + }, + + formatDate : function(date, now) { + if (now == null) { + now = new Date(); + } + var daysAgo = (now.getTime() - date.getTime()) / (24 * 60 * 60 * 1000); + if (daysAgo < 0 && now.getDate() != date.getDate()) { + return date.toLocaleDateString(); + } else if (daysAgo < 1 && now.getDate() == date.getDate()) { + return date.toLocaleTimeString(); + } else if (daysAgo < 7 && now.getDay() != date.getDay()) { + return ['Sun', 'Mon','Tue','Wed','Thu','Fri','Sat'][date.getDay()] + " " + date.toLocaleTimeString(); + } else { + return date.toLocaleDateString(); + } + }, + + preWrapLines : function(el, text) { + while ((m = Splinter.LINE_RE.exec(text)) != null) { + var div = document.createElement("div"); + div.className = "pre-wrap"; + div.appendChild(document.createTextNode(m[1].length == 0 ? " " : m[1])); + el.appendChild(div); + } + }, + + isDigits : function (str) { + return str.match(/^[0-9]+$/); + } +}; + +Splinter.Bug = { + TIMEZONES : { + CEST: '200', + CET: '100', + BST: '100', + GMT: '000', + UTC: '000', + EDT: '-400', + EST: '-500', + CDT: '-500', + CST: '-600', + MDT: '-600', + MST: '-700', + PDT: '-700', + PST: '-800' + }, + + parseDate : function(d) { + var m = /^\s*(\d+)-(\d+)-(\d+)\s+(\d+):(\d+)(?::(\d+))?\s+(?:([A-Z]{3,})|([-+]\d{3,}))\s*$/.exec(d); + if (!m) { + return null; + } + + var year = parseInt(m[1], 10); + var month = parseInt(m[2] - 1, 10); + var day = parseInt(m[3], 10); + var hour = parseInt(m[4], 10); + var minute = parseInt(m[5], 10); + var second = m[6] ? parseInt(m[6], 10) : 0; + + var tzoffset = 0; + if (m[7]) { + if (m[7] in Splinter.Bug.TIMEZONES) { + tzoffset = Splinter.Bug.TIMEZONES[m[7]]; + } + } else { + tzoffset = parseInt(m[8], 10); + } + + var unadjustedDate = new Date(Date.UTC(m[1], m[2] - 1, m[3], m[4], m[5])); + + // 430 => 4:30. Easier to do this computation for only positive offsets + var sign = tzoffset < 0 ? -1 : 1; + tzoffset *= sign; + var adjustmentHours = Math.floor(tzoffset/100); + var adjustmentMinutes = tzoffset - adjustmentHours * 100; + + return new Date(unadjustedDate.getTime() - + sign * adjustmentHours * 3600000 - + sign * adjustmentMinutes * 60000); + }, + + _formatWho : function(name, email) { + if (name && email) { + return name + " <" + email + ">"; + } else if (name) { + return name; + } else { + return email; + } + } +}; + +Splinter.Bug.Attachment = function(bug, id) { + this._init(bug, id); +}; + +Splinter.Bug.Attachment.prototype = { + _init : function(bug, id) { + this.bug = bug; + this.id = id; + } +}; + +Splinter.Bug.Comment = function(bug) { + this._init(bug); +}; + +Splinter.Bug.Comment.prototype = { + _init : function(bug) { + this.bug = bug; + }, + + getWho : function() { + return Splinter.Bug._formatWho(this.whoName, this.whoEmail); + } +}; + +Splinter.Bug.Bug = function() { + this._init(); +}; + +Splinter.Bug.Bug.prototype = { + _init : function() { + this.attachments = []; + this.comments = []; + }, + + getAttachment : function(attachmentId) { + var i; + for (i = 0; i < this.attachments.length; i++) { + if (this.attachments[i].id == attachmentId) { + return this.attachments[i]; + } + } + return null; + }, + + getReporter : function() { + return Splinter.Bug._formatWho(this.reporterName, this.reporterEmail); + } +}; + +Splinter.Dialog = function() { + this._init.apply(this, arguments); +}; + +Splinter.Dialog.prototype = { + _init: function(prompt) { + this.buttons = []; + this.dialog = new YAHOO.widget.SimpleDialog('dialog', { + width: "300px", + fixedcenter: true, + visible: false, + modal: true, + draggable: false, + close: false, + hideaftersubmit: true, + constraintoviewport: true + }); + this.dialog.setHeader(prompt); + }, + + addButton : function (label, callback, isdefault) { + this.buttons.push({ text : label, + handler : function () { this.hide(); callback(); }, + isDefault : isdefault }); + this.dialog.cfg.queueProperty("buttons", this.buttons); + }, + + show : function () { + this.dialog.render(document.body); + this.dialog.show(); + } +}; + +Splinter.Patch = { + ADDED : 1 << 0, + REMOVED : 1 << 1, + CHANGED : 1 << 2, + NEW_NONEWLINE : 1 << 3, + OLD_NONEWLINE : 1 << 4, + + FILE_START_RE : new RegExp( + '^(?:' + // start of optional header + '(?:Index|index|===|RCS|diff)[^\\n]*\\n' + // header + '(?:(?:copy|rename) from [^\\n]+\\n)?' + // git copy/rename from + '(?:(?:copy|rename) to [^\\n]+\\n)?' + // git copy/rename to + ')*' + // end of optional header + '\\-\\-\\-[ \\t]*(\\S+).*\\n' + // --- line + '\\+\\+\\+[ \\t]*(\\S+).*\\n' + // +++ line + '(?=@@)', // @@ line + 'mg' + ), + HUNK_START1_RE: /^@@[ \t]+-(\d+),(\d+)[ \t]+\+(\d+),(\d+)[ \t]+@@(.*)\n/mg, // -l,s +l,s + HUNK_START2_RE: /^@@[ \t]+-(\d+),(\d+)[ \t]+\+(\d+)[ \t]+@@(.*)\n/mg, // -l,s +l + HUNK_START3_RE: /^@@[ \t]+-(\d+)[ \t]+\+(\d+),(\d+)[ \t]+@@(.*)\n/mg, // -l +l,s + HUNK_START4_RE: /^@@[ \t]+-(\d+)[ \t]+\+(\d+)[ \t]+@@(.*)\n/mg, // -l +l + HUNK_RE : /((?:(?!--- )[ +\\-].*(?:\n|$)|(?:\n|$))*)/mg, + + GIT_BINARY_RE : /^diff --git a\/(\S+).*\n(?:(new|deleted) file mode \d+\n)?(?:index.*\n)?GIT binary patch\n(delta )?/mg, + + _cleanIntro : function(intro) { + var m; + + intro = Splinter.Utils.strip(intro) + "\n\n"; + + // Git: remove binary diffs + var binary_re = /^(?:diff --git .*\n|literal \d+\n)(?:.+\n)+\n/mg; + m = binary_re.exec(intro); + while (m) { + intro = intro.substr(m.index + m[0].length); + binary_re.lastIndex = 0; + m = binary_re.exec(intro); + } + + // Git: remove leading 'From <commit_id> <date>' + m = /^From\s+[a-f0-9]{40}.*\n/.exec(intro); + if (m) { + intro = intro.substr(m.index + m[0].length); + } + + // Git: remove 'diff --stat' output from the end + m = /^---\n(?:^\s.*\n)+\s+\d+\s+files changed.*\n?(?!.)/m.exec(intro); + if (m) { + intro = intro.substr(0, m.index); + } + + return Splinter.Utils.strip(intro); + } +}; + +Splinter.Patch.Hunk = function(oldStart, oldCount, newStart, newCount, functionLine, text) { + this._init(oldStart, oldCount, newStart, newCount, functionLine, text); +}; + +Splinter.Patch.Hunk.prototype = { + _init : function(oldStart, oldCount, newStart, newCount, functionLine, text) { + var rawlines = text.split("\n"); + if (rawlines.length > 0 && Splinter.Utils.strip(rawlines[rawlines.length - 1]) == "") { + rawlines.pop(); // Remove trailing element from final \n + } + + this.oldStart = oldStart; + this.oldCount = oldCount; + this.newStart = newStart; + this.newCount = newCount; + this.functionLine = Splinter.Utils.strip(functionLine); + this.comment = null; + + var lines = []; + var totalOld = 0; + var totalNew = 0; + + var currentStart = -1; + var currentOldCount = 0; + var currentNewCount = 0; + + // A segment is a series of lines added/removed/changed with no intervening + // unchanged lines. We make the classification of Patch.ADDED/Patch.REMOVED/Patch.CHANGED + // in the flags for the entire segment + function startSegment() { + if (currentStart < 0) { + currentStart = lines.length; + } + } + + function endSegment() { + if (currentStart >= 0) { + if (currentOldCount > 0 && currentNewCount > 0) { + var j; + for (j = currentStart; j < lines.length; j++) { + lines[j][2] &= ~(Splinter.Patch.ADDED | Splinter.Patch.REMOVED); + lines[j][2] |= Splinter.Patch.CHANGED; + } + } + + currentStart = -1; + currentOldCount = 0; + currentNewCount = 0; + } + } + + var i; + for (i = 0; i < rawlines.length; i++) { + var line = rawlines[i]; + var op = line.substr(0, 1); + var strippedLine = line.substring(1); + var noNewLine = 0; + if (i + 1 < rawlines.length && rawlines[i + 1].substr(0, 1) == '\\') { + noNewLine = op == '-' ? Splinter.Patch.OLD_NONEWLINE : Splinter.Patch.NEW_NONEWLINE; + } + + if (op == ' ') { + endSegment(); + totalOld++; + totalNew++; + lines.push([strippedLine, strippedLine, 0]); + } else if (op == '-') { + totalOld++; + startSegment(); + lines.push([strippedLine, null, Splinter.Patch.REMOVED | noNewLine]); + currentOldCount++; + } else if (op == '+') { + totalNew++; + startSegment(); + if (currentStart + currentNewCount >= lines.length) { + lines.push([null, strippedLine, Splinter.Patch.ADDED | noNewLine]); + } else { + lines[currentStart + currentNewCount][1] = strippedLine; + lines[currentStart + currentNewCount][2] |= Splinter.Patch.ADDED | noNewLine; + } + currentNewCount++; + } + } + + // git mail-formatted patches end with --\n<git version> like a signature + // This is troublesome since it looks like a subtraction at the end + // of last hunk of the last file. Handle this specifically rather than + // generically stripping excess lines to be kind to hand-edited patches + if (totalOld > oldCount && + lines[lines.length - 1][1] == null && + lines[lines.length - 1][0].substr(0, 1) == '-') + { + lines.pop(); + currentOldCount--; + if (currentOldCount == 0 && currentNewCount == 0) { + currentStart = -1; + } + } + + endSegment(); + + this.lines = lines; + }, + + iterate : function(cb) { + var i; + var oldLine = this.oldStart; + var newLine = this.newStart; + for (i = 0; i < this.lines.length; i++) { + var line = this.lines[i]; + cb(this.location + i, oldLine, line[0], newLine, line[1], line[2], line); + if (line[0] != null) { + oldLine++; + } + if (line[1] != null) { + newLine++; + } + } + } +}; + +Splinter.Patch.File = function(filename, status, extra, hunks) { + this._init(filename, status, extra, hunks); +}; + +Splinter.Patch.File.prototype = { + _init : function(filename, status, extra, hunks) { + this.filename = filename; + this.status = status; + this.extra = extra; + this.hunks = hunks; + this.fileReviewed = false; + + var l = 0; + var i; + for (i = 0; i < this.hunks.length; i++) { + var hunk = this.hunks[i]; + hunk.location = l; + l += hunk.lines.length; + } + }, + + // A "location" is just a linear index into the lines of the patch in this file + getLocation : function(oldLine, newLine) { + var i; + for (i = 0; i < this.hunks.length; i++) { + var hunk = this.hunks[i]; + if (oldLine != null && hunk.oldStart > oldLine) { + continue; + } + if (newLine != null && hunk.newStart > newLine) { + continue; + } + + if ((oldLine != null && oldLine < hunk.oldStart + hunk.oldCount) || + (newLine != null && newLine < hunk.newStart + hunk.newCount)) + { + var location = -1; + hunk.iterate(function(loc, oldl, oldText, newl, newText, flags) { + if ((oldLine == null || oldl == oldLine) && + (newLine == null || newl == newLine)) + { + location = loc; + } + }); + + if (location != -1) { + return location; + } + } + } + + throw "Bad oldLine,newLine: " + oldLine + "," + newLine; + }, + + getHunk : function(location) { + var i; + for (i = 0; i < this.hunks.length; i++) { + var hunk = this.hunks[i]; + if (location >= hunk.location && location < hunk.location + hunk.lines.length) { + return hunk; + } + } + + throw "Bad location: " + location; + }, + + toString : function() { + return "Splinter.Patch.File(" + this.filename + ")"; + } +}; + +Splinter.Patch.Patch = function(text) { + this._init(text); +}; + +Splinter.Patch.Patch.prototype = { + // cf. parsing in Review.Review.parse() + _init : function(text) { + // Canonicalize newlines to simplify the following + if (/\r/.test(text)) { + text = text.replace(/(\r\n|\r|\n)/g, "\n"); + } + + this.files = []; + + var m = Splinter.Patch.FILE_START_RE.exec(text); + var bm = Splinter.Patch.GIT_BINARY_RE.exec(text); + if (m == null && bm == null) + throw "Not a patch"; + this.intro = m == null ? '' : Splinter.Patch._cleanIntro(text.substring(0, m.index)); + + // show binary files in the intro + + if (bm && this.intro.length) + this.intro += "\n\n"; + while (bm != null) { + if (bm[2]) { + // added or deleted file + this.intro += bm[2].charAt(0).toUpperCase() + bm[2].slice(1) + ' Binary File: ' + bm[1] + "\n"; + } else { + // delta + this.intro += 'Modified Binary File: ' + bm[1] + "\n"; + } + bm = Splinter.Patch.GIT_BINARY_RE.exec(text); + } + + while (m != null) { + // git shows a diff between a/foo/bar.c and b/foo/bar.c or between + // a/foo/bar.c and /dev/null for removals and the reverse for + // additions. + var filename; + var status = undefined; + var extra = undefined; + + if (/^a\//.test(m[1]) && /^b\//.test(m[2])) { + filename = m[2].substring(2); + status = Splinter.Patch.CHANGED; + } else if (/^a\//.test(m[1]) && /^\/dev\/null/.test(m[2])) { + filename = m[1].substring(2); + status = Splinter.Patch.REMOVED; + } else if (/^\/dev\/null/.test(m[1]) && /^b\//.test(m[2])) { + filename = m[2].substring(2); + status = Splinter.Patch.ADDED; + // Handle non-git cases as well + } else if (!/^\/dev\/null/.test(m[1]) && /^\/dev\/null/.test(m[2])) { + filename = m[1]; + status = Splinter.Patch.REMOVED; + } else if (/^\/dev\/null/.test(m[1]) && !/^\/dev\/null/.test(m[2])) { + filename = m[2]; + status = Splinter.Patch.ADDED; + } else { + filename = m[1]; + } + + // look for rename/copy + if (/^diff /.test(m[0])) { + // possibly git + var lines = m[0].split(/\n/); + for (var i = 0, il = lines.length; i < il && !extra; i++) { + var line = lines[i]; + if (line != '' && !/^(?:diff|---|\+\+\+) /.test(line)) { + if (/^copy from /.test(line)) + extra = 'copied from ' + m[1].substring(2); + if (/^rename from /.test(line)) + extra = 'renamed from ' + m[1].substring(2); + } + } + } else if (/^=== renamed /.test(m[0])) { + // bzr + filename = m[2]; + extra = 'renamed from ' + m[1]; + } + + var hunks = []; + var pos = Splinter.Patch.FILE_START_RE.lastIndex; + while (true) { + var found = false; + var oldStart, oldCount, newStart, newCount, context; + + // -l,s +l,s + var re = Splinter.Patch.HUNK_START1_RE; + re.lastIndex = pos; + var m2 = re.exec(text); + if (m2 != null && m2.index == pos) { + oldStart = parseInt(m2[1], 10); + oldCount = parseInt(m2[2], 10); + newStart = parseInt(m2[3], 10); + newCount = parseInt(m2[4], 10); + context = m2[5]; + found = true; + } + + if (!found) { + // -l,s +l + re = Splinter.Patch.HUNK_START2_RE; + re.lastIndex = pos; + m2 = re.exec(text); + if (m2 != null && m2.index == pos) { + oldStart = parseInt(m2[1], 10); + oldCount = parseInt(m2[2], 10); + newStart = parseInt(m2[3], 10); + newCount = 1; + context = m2[4]; + found = true; + } + } + + if (!found) { + // -l +l,s + re = Splinter.Patch.HUNK_START3_RE; + re.lastIndex = pos; + m2 = re.exec(text); + if (m2 != null && m2.index == pos) { + oldStart = parseInt(m2[1], 10); + oldCount = 1; + newStart = parseInt(m2[2], 10); + newCount = parseInt(m2[3], 10); + context = m2[4]; + found = true; + } + } + + if (!found) { + // -l +l + re = Splinter.Patch.HUNK_START4_RE; + re.lastIndex = pos; + m2 = re.exec(text); + if (m2 != null && m2.index == pos) { + oldStart = parseInt(m2[1], 10); + oldCount = 1; + newStart = parseInt(m2[2], 10); + newCount = 1; + context = m2[3]; + found = true; + } + } + + if (!found) + break; + + pos = re.lastIndex; + Splinter.Patch.HUNK_RE.lastIndex = pos; + var m3 = Splinter.Patch.HUNK_RE.exec(text); + if (m3 == null || m3.index != pos) { + break; + } + + pos = Splinter.Patch.HUNK_RE.lastIndex; + hunks.push(new Splinter.Patch.Hunk(oldStart, oldCount, newStart, newCount, context, m3[1])); + } + + if (status === undefined) { + // For non-Git we use assume patch was generated non-zero + // context and just look at the patch to detect added/removed. + // Bzr actually says added/removed in the diff, but SVN/CVS + // don't + if (hunks.length == 1 && hunks[0].oldCount == 0) { + status = Splinter.Patch.ADDED; + } else if (hunks.length == 1 && hunks[0].newCount == 0) { + status = Splinter.Patch.REMOVED; + } else { + status = Splinter.Patch.CHANGED; + } + } + + this.files.push(new Splinter.Patch.File(filename, status, extra, hunks)); + + Splinter.Patch.FILE_START_RE.lastIndex = pos; + m = Splinter.Patch.FILE_START_RE.exec(text); + } + }, + + getFile : function(filename) { + var i; + for (i = 0; i < this.files.length; i++) { + if (this.files[i].filename == filename) { + return this.files[i]; + } + } + + return null; + } +}; + +Splinter.Review = { + _removeFromArray : function(a, element) { + var i; + for (i = 0; i < a.length; i++) { + if (a[i] === element) { + a.splice(i, 1); + return; + } + } + }, + + _noNewLine : function(flags, flag) { + return ((flags & flag) != 0) ? "\n\\ No newline at end of file" : ""; + }, + + _lineInSegment : function(line) { + return (line[2] & (Splinter.Patch.ADDED | Splinter.Patch.REMOVED | Splinter.Patch.CHANGED)) != 0; + }, + + _compareSegmentLines : function(a, b) { + var op1 = a[0]; + var op2 = b[0]; + if (op1 == op2) { + return 0; + } else if (op1 == ' ') { + return -1; + } else if (op2 == ' ') { + return 1; + } else { + return op1 == '-' ? -1 : 1; + } + }, + + FILE_START_RE : /^:::[ \t]+(\S+)[ \t]*\n/mg, + HUNK_START_RE : /^@@[ \t]+(?:-(\d+),(\d+)[ \t]+)?(?:\+(\d+),(\d+)[ \t]+)?@@.*\n/mg, + HUNK_RE : /((?:(?!@@|:::).*\n?)*)/mg, + REVIEW_RE : /^\s*review\s+of\s+attachment\s+(\d+)\s*:\s*/i +}; + +Splinter.Review.Comment = function(file, location, type, comment) { + this._init(file, location, type, comment); +}; + +Splinter.Review.Comment.prototype = { + _init : function(file, location, type, comment) { + this.file = file; + this.type = type; + this.location = location; + this.comment = comment; + }, + + getHunk : function() { + return this.file.patchFile.getHunk(this.location); + }, + + getInReplyTo : function() { + var i; + var hunk = this.getHunk(); + var line = hunk.lines[this.location - hunk.location]; + for (i = 0; i < line.reviewComments.length; i++) { + var comment = line.reviewComments[i]; + if (comment === this) { + return null; + } + if (comment.type == this.type) { + return comment; + } + } + + return null; + }, + + remove : function() { + var hunk = this.getHunk(); + var line = hunk.lines[this.location - hunk.location]; + Splinter.Review._removeFromArray(this.file.comments, this); + Splinter.Review._removeFromArray(line.reviewComments, this); + } +}; + +Splinter.Review.File = function(review, patchFile) { + this._init(review, patchFile); +}; + +Splinter.Review.File.prototype = { + _init : function(review, patchFile) { + this.review = review; + this.patchFile = patchFile; + this.comments = []; + }, + + addComment : function(location, type, comment) { + var hunk = this.patchFile.getHunk(location); + var line = hunk.lines[location - hunk.location]; + comment = new Splinter.Review.Comment(this, location, type, comment); + if (line.reviewComments == null) { + line.reviewComments = []; + } + line.reviewComments.push(comment); + var i; + for (i = 0; i <= this.comments.length; i++) { + if (i == this.comments.length || + this.comments[i].location > location || + (this.comments[i].location == location && this.comments[i].type > type)) { + this.comments.splice(i, 0, comment); + break; + } else if (this.comments[i].location == location && + this.comments[i].type == type) { + throw "Two comments at the same location"; + } + } + + return comment; + }, + + getComment : function(location, type) { + var i; + for (i = 0; i < this.comments.length; i++) { + if (this.comments[i].location == location && + this.comments[i].type == type) + { + return this.comments[i]; + } + } + + return null; + }, + + toString : function() { + var str = "::: " + this.patchFile.filename + "\n"; + var first = true; + + var i; + for (i = 0; i < this.comments.length; i++) { + if (first) { + first = false; + } else { + str += '\n'; + } + var comment = this.comments[i]; + var hunk = comment.getHunk(); + + // Find the range of lines we might want to show. That's everything in the + // same segment as the commented line, plus up two two lines of non-comment + // diff before. + + var contextFirst = comment.location - hunk.location; + if (Splinter.Review._lineInSegment(hunk.lines[contextFirst])) { + while (contextFirst > 0 && Splinter.Review._lineInSegment(hunk.lines[contextFirst - 1])) { + contextFirst--; + } + } + + var j; + for (j = 0; j < 5; j++) { + if (contextFirst > 0 && !Splinter.Review._lineInSegment(hunk.lines[contextFirst - 1])) { + contextFirst--; + } + } + + // Now get the diff lines (' ', '-', '+' for that range of lines) + + var patchOldStart = null; + var patchNewStart = null; + var patchOldLines = 0; + var patchNewLines = 0; + var unchangedLines = 0; + var patchLines = []; + + function addOldLine(oldLine) { + if (patchOldLines == 0) { + patchOldStart = oldLine; + } + patchOldLines++; + } + + function addNewLine(newLine) { + if (patchNewLines == 0) { + patchNewStart = newLine; + } + patchNewLines++; + } + + hunk.iterate(function(loc, oldLine, oldText, newLine, newText, flags) { + if (loc >= hunk.location + contextFirst && loc <= comment.location) { + if ((flags & (Splinter.Patch.ADDED | Splinter.Patch.REMOVED | Splinter.Patch.CHANGED)) == 0) { + patchLines.push('> ' + oldText + Splinter.Review._noNewLine(flags, Splinter.Patch.OLD_NONEWLINE | Splinter.Patch.NEW_NONEWLINE)); + addOldLine(oldLine); + addNewLine(newLine); + unchangedLines++; + } else { + if ((comment.type == Splinter.Patch.REMOVED + || comment.type == Splinter.Patch.CHANGED) + && oldText != null) + { + patchLines.push('> -' + oldText + + Splinter.Review._noNewLine(flags, Splinter.Patch.OLD_NONEWLINE)); + addOldLine(oldLine); + } + if ((comment.type == Splinter.Patch.ADDED + || comment.type == Splinter.Patch.CHANGED) + && newText != null) + { + patchLines.push('> +' + newText + + Splinter.Review._noNewLine(flags, Splinter.Patch.NEW_NONEWLINE)); + addNewLine(newLine); + } + } + } + }); + + // Sort them into global order ' ', '-', '+' + patchLines.sort(Splinter.Review._compareSegmentLines); + + // Completely blank context isn't useful so remove it; however if we are commenting + // on blank lines at the start of a segment, we have to leave something or things break + while (patchLines.length > 1 && patchLines[0].match(/^\s*$/)) { + patchLines.shift(); + patchOldStart++; + patchNewStart++; + patchOldLines--; + patchNewLines--; + unchangedLines--; + } + + if (comment.type == Splinter.Patch.CHANGED) { + // For a CHANGED comment, we have to show the the start of the hunk - but to save + // in length we can trim unchanged context before it + + if (patchOldLines + patchNewLines - unchangedLines > 5) { + var toRemove = Math.min(unchangedLines, patchOldLines + patchNewLines - unchangedLines - 5); + patchLines.splice(0, toRemove); + patchOldStart += toRemove; + patchNewStart += toRemove; + patchOldLines -= toRemove; + patchNewLines -= toRemove; + unchangedLines -= toRemove; + } + + str += '@@ -' + patchOldStart + ',' + patchOldLines + ' +' + patchNewStart + ',' + patchNewLines + ' @@\n'; + + // We will use up to 10 lines more: + // 5 old lines or 4 old lines and a "... <N> more ... " line + // 5 new lines or 4 new lines and a "... <N> more ... " line + + var patchRemovals = patchOldLines - unchangedLines; + var showPatchRemovals = patchRemovals > 5 ? 4 : patchRemovals; + var patchAdditions = patchNewLines - unchangedLines; + var showPatchAdditions = patchAdditions > 5 ? 4 : patchAdditions; + + j = 0; + while (j < unchangedLines + showPatchRemovals) { + str += "> " + patchLines[j] + "\n"; + j++; + } + if (showPatchRemovals < patchRemovals) { + str += "> ... " + (patchRemovals - showPatchRemovals) + " more ...\n"; + j += patchRemovals - showPatchRemovals; + } + while (j < unchangedLines + patchRemovals + showPatchAdditions) { + str += "> " + patchLines[j] + "\n"; + j++; + } + if (showPatchAdditions < patchAdditions) { + str += "> ... " + (patchAdditions - showPatchAdditions) + " more ...\n"; + j += patchAdditions - showPatchAdditions; + } + } else { + // We limit Patch.ADDED/Patch.REMOVED comments strictly to 5 lines after the header + if (patchOldLines + patchNewLines - unchangedLines > 5) { + var toRemove = patchOldLines + patchNewLines - unchangedLines - 5; + patchLines.splice(0, toRemove); + patchOldStart += toRemove; + patchNewStart += toRemove; + patchOldLines -= toRemove; + patchNewLines -= toRemove; + } + + if (comment.type == Splinter.Patch.REMOVED) { + str += '@@ -' + patchOldStart + ',' + patchOldLines + ' @@\n'; + } else { + str += '@@ +' + patchNewStart + ',' + patchNewLines + ' @@\n'; + } + str += patchLines.join("\n") + "\n"; + } + str += "\n" + comment.comment + "\n"; + } + + return str; + } +}; + +Splinter.Review.Review = function(patch, who, date) { + this._init(patch, who, date); +}; + +Splinter.Review.Review.prototype = { + _init : function(patch, who, date) { + this.date = null; + this.patch = patch; + this.who = who; + this.date = date; + this.intro = null; + this.files = []; + + var i; + for (i = 0; i < patch.files.length; i++) { + this.files.push(new Splinter.Review.File(this, patch.files[i])); + } + }, + + // cf. parsing in Patch.Patch._init() + parse : function(text) { + Splinter.Review.FILE_START_RE.lastIndex = 0; + var m = Splinter.Review.FILE_START_RE.exec(text); + + var intro; + if (m != null) { + this.setIntro(text.substr(0, m.index)); + } else{ + this.setIntro(text); + return; + } + + while (m != null) { + var filename = m[1]; + var file = this.getFile(filename); + if (file == null) { + throw "Review.Review refers to filename '" + filename + "' not in reviewed Patch."; + } + + var pos = Splinter.Review.FILE_START_RE.lastIndex; + + while (true) { + Splinter.Review.HUNK_START_RE.lastIndex = pos; + var m2 = Splinter.Review.HUNK_START_RE.exec(text); + if (m2 == null || m2.index != pos) { + break; + } + + pos = Splinter.Review.HUNK_START_RE.lastIndex; + + var oldStart, oldCount, newStart, newCount; + if (m2[1]) { + oldStart = parseInt(m2[1], 10); + oldCount = parseInt(m2[2], 10); + } else { + oldStart = oldCount = null; + } + + if (m2[3]) { + newStart = parseInt(m2[3], 10); + newCount = parseInt(m2[4], 10); + } else { + newStart = newCount = null; + } + + var type; + if (oldStart != null && newStart != null) { + type = Splinter.Patch.CHANGED; + } else if (oldStart != null) { + type = Splinter.Patch.REMOVED; + } else if (newStart != null) { + type = Splinter.Patch.ADDED; + } else { + throw "Either old or new line numbers must be given"; + } + + var oldLine = oldStart; + var newLine = newStart; + + Splinter.Review.HUNK_RE.lastIndex = pos; + var m3 = Splinter.Review.HUNK_RE.exec(text); + if (m3 == null || m3.index != pos) { + break; + } + + pos = Splinter.Review.HUNK_RE.lastIndex; + + var rawlines = m3[1].split("\n"); + if (rawlines.length > 0 && rawlines[rawlines.length - 1].match('^/s+$')) { + rawlines.pop(); // Remove trailing element from final \n + } + + var commentText = null; + + var lastSegmentOld = 0; + var lastSegmentNew = 0; + var i; + for (i = 0; i < rawlines.length; i++) { + var line = rawlines[i]; + var count = 1; + if (i < rawlines.length - 1 && rawlines[i + 1].match(/^... \d+\s+/)) { + var m3 = /^\.\.\.\s+(\d+)\s+/.exec(rawlines[i + 1]); + count += parseInt(m3[1], 10); + i += 1; + } + // The check for /^$/ is because if Bugzilla is line-wrapping it also + // strips completely whitespace lines + if (line.match(/^>\s+/) || line.match(/^$/)) { + oldLine += count; + newLine += count; + lastSegmentOld = 0; + lastSegmentNew = 0; + } else if (line.match(/^(> )?-/)) { + oldLine += count; + lastSegmentOld += count; + } else if (line.match(/^(> )?\+/)) { + newLine += count; + lastSegmentNew += count; + } else if (line.match(/^\\/)) { + // '\ No newline at end of file' - ignore + } else { + if (console) + console.log("WARNING: Bad content in hunk: " + line); + if (line != 'NaN more ...') { + // Tack onto current comment even thou it's invalid + if (commentText == null) { + commentText = line; + } else { + commentText += "\n" + line; + } + } + } + + if ((oldStart == null || oldLine == oldStart + oldCount) && + (newStart == null || newLine == newStart + newCount)) + { + commentText = rawlines.slice(i + 1).join("\n"); + break; + } + } + + if (commentText == null) { + if (console) + console.log("WARNING: No comment found in hunk"); + commentText = ""; + } + + + var location; + try { + if (type == Splinter.Patch.CHANGED) { + if (lastSegmentOld >= lastSegmentNew) { + oldLine--; + } + if (lastSegmentOld <= lastSegmentNew) { + newLine--; + } + location = file.patchFile.getLocation(oldLine, newLine); + } else if (type == Splinter.Patch.REMOVED) { + oldLine--; + location = file.patchFile.getLocation(oldLine, null); + } else if (type == Splinter.Patch.ADDED) { + newLine--; + location = file.patchFile.getLocation(null, newLine); + } + } catch(e) { + if (console) + console.error(e); + location = 0; + } + file.addComment(location, type, Splinter.Utils.strip(commentText)); + } + + Splinter.Review.FILE_START_RE.lastIndex = pos; + m = Splinter.Review.FILE_START_RE.exec(text); + } + }, + + setIntro : function (intro) { + intro = Splinter.Utils.strip(intro); + this.intro = intro != "" ? intro : null; + }, + + getFile : function (filename) { + var i; + for (i = 0; i < this.files.length; i++) { + if (this.files[i].patchFile.filename == filename) { + return this.files[i]; + } + } + + return null; + }, + + // Making toString() serialize to our seriaization format is maybe a bit sketchy + // But the serialization format is designed to be human readable so it works + // pretty well. + toString : function () { + var str = ''; + if (this.intro != null) { + str += Splinter.Utils.strip(this.intro); + str += '\n'; + } + + var first = this.intro == null; + var i; + for (i = 0; i < this.files.length; i++) { + var file = this.files[i]; + if (file.comments.length > 0) { + if (first) { + first = false; + } else { + str += '\n'; + } + str += file.toString(); + } + } + + return str; + } +}; + +Splinter.ReviewStorage = {}; + +Splinter.ReviewStorage.LocalReviewStorage = function() { + this._init(); +}; + +Splinter.ReviewStorage.LocalReviewStorage.available = function() { + // The try is a workaround for + // https://bugzilla.mozilla.org/show_bug.cgi?id=517778 + // where if cookies are disabled or set to ask, then the first attempt + // to access the localStorage property throws a security error. + try { + return 'localStorage' in window && window.localStorage != null; + } catch (e) { + return false; + } +}; + +Splinter.ReviewStorage.LocalReviewStorage.prototype = { + _init : function() { + var reviewInfosText = localStorage.splinterReviews; + if (reviewInfosText == null) { + this._reviewInfos = []; + } else { + this._reviewInfos = YAHOO.lang.JSON.parse(reviewInfosText); + } + }, + + listReviews : function() { + return this._reviewInfos; + }, + + _reviewPropertyName : function(bug, attachment) { + return 'splinterReview_' + bug.id + '_' + attachment.id; + }, + + loadDraft : function(bug, attachment, patch) { + var propertyName = this._reviewPropertyName(bug, attachment); + var reviewText = localStorage[propertyName]; + if (reviewText != null) { + var review = new Splinter.Review.Review(patch); + review.parse(reviewText); + return review; + } else { + return null; + } + }, + + _findReview : function(bug, attachment) { + var i; + for (i = 0 ; i < this._reviewInfos.length; i++) { + if (this._reviewInfos[i].bugId == bug.id && this._reviewInfos[i].attachmentId == attachment.id) { + return i; + } + } + + return -1; + }, + + _updateOrCreateReviewInfo : function(bug, attachment, props) { + var reviewIndex = this._findReview(bug, attachment); + var reviewInfo; + + var nowTime = Date.now(); + if (reviewIndex >= 0) { + reviewInfo = this._reviewInfos[reviewIndex]; + this._reviewInfos.splice(reviewIndex, 1); + } else { + reviewInfo = { + bugId: bug.id, + bugShortDesc: bug.shortDesc, + attachmentId: attachment.id, + attachmentDescription: attachment.description, + creationTime: nowTime + }; + } + + reviewInfo.modificationTime = nowTime; + for (var prop in props) { + reviewInfo[prop] = props[prop]; + } + + this._reviewInfos.push(reviewInfo); + localStorage.splinterReviews = YAHOO.lang.JSON.stringify(this._reviewInfos); + }, + + _deleteReviewInfo : function(bug, attachment) { + var reviewIndex = this._findReview(bug, attachment); + if (reviewIndex >= 0) { + this._reviewInfos.splice(reviewIndex, 1); + localStorage.splinterReviews = YAHOO.lang.JSON.stringify(this._reviewInfos); + } + }, + + saveDraft : function(bug, attachment, review, extraProps) { + var propertyName = this._reviewPropertyName(bug, attachment); + if (!extraProps) { + extraProps = {}; + } + extraProps.isDraft = true; + this._updateOrCreateReviewInfo(bug, attachment, extraProps); + localStorage[propertyName] = "" + review; + }, + + deleteDraft : function(bug, attachment, review) { + var propertyName = this._reviewPropertyName(bug, attachment); + + this._deleteReviewInfo(bug, attachment); + delete localStorage[propertyName]; + }, + + draftPublished : function(bug, attachment) { + var propertyName = this._reviewPropertyName(bug, attachment); + + this._updateOrCreateReviewInfo(bug, attachment, { isDraft: false }); + delete localStorage[propertyName]; + } +}; + +Splinter.saveDraftNoticeTimeoutId = null; +Splinter.navigationLinks = {}; +Splinter.reviewers = {}; +Splinter.savingDraft = false; +Splinter.UPDATE_ATTACHMENT_SUCCESS = /<title>\s*Changes\s+Submitted/; +Splinter.LINE_RE = /(?!$)([^\r\n]*)(?:\r\n|\r|\n|$)/g; + +Splinter.displayError = function (msg) { + var el = new Element(document.createElement('p')); + el.appendChild(document.createTextNode(msg)); + Dom.get('error').appendChild(Dom.get(el)); + Dom.setStyle('error', 'display', 'block'); +}; + +Splinter.publishReview = function () { + Splinter.saveComment(); + Splinter.theReview.setIntro(Dom.get('myComment').value); + + if (Splinter.reviewStorage) { + Splinter.reviewStorage.draftPublished(Splinter.theBug, + Splinter.theAttachment); + } + + var publish_form = Dom.get('publish'); + var publish_token = Dom.get('publish_token'); + var publish_attach_id = Dom.get('publish_attach_id'); + var publish_attach_desc = Dom.get('publish_attach_desc'); + var publish_attach_filename = Dom.get('publish_attach_filename'); + var publish_attach_contenttype = Dom.get('publish_attach_contenttype'); + var publish_attach_ispatch = Dom.get('publish_attach_ispatch'); + var publish_attach_isobsolete = Dom.get('publish_attach_isobsolete'); + var publish_attach_isprivate = Dom.get('publish_attach_isprivate'); + var publish_attach_status = Dom.get('publish_attach_status'); + var publish_review = Dom.get('publish_review'); + + publish_token.value = Splinter.theAttachment.token; + publish_attach_id.value = Splinter.theAttachment.id; + publish_attach_desc.value = Splinter.theAttachment.description; + publish_attach_filename.value = Splinter.theAttachment.filename; + publish_attach_contenttype.value = Splinter.theAttachment.contenttypeentry; + publish_attach_ispatch.value = Splinter.theAttachment.isPatch; + publish_attach_isobsolete.value = Splinter.theAttachment.isObsolete; + publish_attach_isprivate.value = Splinter.theAttachment.isPrivate; + + // This is a "magic string" used to identify review comments + if (Splinter.theReview.toString()) { + var comment = "Review of attachment " + Splinter.theAttachment.id + ":\n" + + "-----------------------------------------------------------------\n\n" + + Splinter.theReview.toString(); + publish_review.value = comment; + } + + if (Splinter.theAttachment.status + && Dom.get('attachmentStatus').value != Splinter.theAttachment.status) + { + publish_attach_status.value = Dom.get('attachmentStatus').value; + } + + publish_form.submit(); +}; + +Splinter.doDiscardReview = function () { + if (Splinter.theAttachment.status) { + Dom.get('attachmentStatus').value = Splinter.theAttachment.status; + } + + Dom.get('myComment').value = ''; + Dom.setStyle('emptyCommentNotice', 'display', 'block'); + + var i; + for (i = 0; i < Splinter.theReview.files.length; i++) { + while (Splinter.theReview.files[i].comments.length > 0) { + Splinter.theReview.files[i].comments[0].remove(); + } + } + + Splinter.updateMyPatchComments(); + Splinter.updateHaveDraft(); + Splinter.saveDraft(); +}; + +Splinter.discardReview = function () { + var dialog = new Splinter.Dialog("Really discard your changes?"); + dialog.addButton('No', function() {}, true); + dialog.addButton('Yes', Splinter.doDiscardReview, false); + dialog.show(); +}; + +Splinter.haveDraft = function () { + if (Splinter.readOnly) { + return false; + } + + if (Splinter.theAttachment.status && Dom.get('attachmentStatus').value != Splinter.theAttachment.status) { + return true; + } + + if (Dom.get('myComment').value != '') { + return true; + } + + var i; + for (i = 0; i < Splinter.theReview.files.length; i++) { + if (Splinter.theReview.files[i].comments.length > 0) { + return true; + } + } + + for (i = 0; i < Splinter.thePatch.files.length; i++) { + if (Splinter.thePatch.files[i].fileReviewed) { + return true; + } + } + + if (Splinter.flagChanged == 1) { + return true; + } + + return false; +}; + +Splinter.updateHaveDraft = function () { + clearTimeout(Splinter.updateHaveDraftTimeoutId); + Splinter.updateHaveDraftTimeoutId = null; + + if (Splinter.haveDraft()) { + Dom.get('publishButton').removeAttribute('disabled'); + Dom.get('cancelButton').removeAttribute('disabled'); + Dom.setStyle('haveDraftNotice', 'display', 'block'); + } else { + Dom.get('publishButton').setAttribute('disabled', 'true'); + Dom.get('cancelButton').setAttribute('disabled', 'true'); + Dom.setStyle('haveDraftNotice', 'display', 'none'); + } +}; + +Splinter.queueUpdateHaveDraft = function () { + if (Splinter.updateHaveDraftTimeoutId == null) { + Splinter.updateHaveDraftTimeoutId = setTimeout(Splinter.updateHaveDraft, 0); + } +}; + +Splinter.hideSaveDraftNotice = function () { + clearTimeout(Splinter.saveDraftNoticeTimeoutId); + Splinter.saveDraftNoticeTimeoutId = null; + Dom.setStyle('saveDraftNotice', 'display', 'none'); +}; + +Splinter.saveDraft = function () { + if (Splinter.reviewStorage == null) { + return; + } + + clearTimeout(Splinter.saveDraftTimeoutId); + Splinter.saveDraftTimeoutId = null; + + Splinter.savingDraft = true; + Dom.get('saveDraftNotice').innerHTML = "Saving Draft..."; + Dom.setStyle('saveDraftNotice', 'display', 'block'); + clearTimeout(Splinter.saveDraftNoticeTimeoutId); + setTimeout(Splinter.hideSaveDraftNotice, 3000); + + if (Splinter.currentEditComment) { + Splinter.currentEditComment.comment = Splinter.Utils.strip(Dom.get("commentEditor").getElementsByTagName("textarea")[0].value); + // Messy, we don't want the empty comment in the saved draft, so remove it and + // then add it back. + if (!Splinter.currentEditComment.comment) { + Splinter.currentEditComment.remove(); + } + } + + Splinter.theReview.setIntro(Dom.get('myComment').value); + + var draftSaved = false; + if (Splinter.haveDraft()) { + var filesReviewed = {}; + for (var i = 0; i < Splinter.thePatch.files.length; i++) { + var file = Splinter.thePatch.files[i]; + if (file.fileReviewed) { + filesReviewed[file.filename] = true; + } + } + Splinter.reviewStorage.saveDraft(Splinter.theBug, Splinter.theAttachment, Splinter.theReview, + { 'filesReviewed' : filesReviewed }); + draftSaved = true; + } else { + Splinter.reviewStorage.deleteDraft(Splinter.theBug, Splinter.theAttachment, Splinter.theReview); + } + + if (Splinter.currentEditComment && !Splinter.currentEditComment.comment) { + Splinter.currentEditComment = Splinter.currentEditComment.file.addComment(Splinter.currentEditComment.location, + Splinter.currentEditComment.type, ""); + } + + Splinter.savingDraft = false; + if (draftSaved) { + Dom.get('saveDraftNotice').innerHTML = "Saved Draft"; + } else { + Splinter.hideSaveDraftNotice(); + } +}; + +Splinter.queueSaveDraft = function () { + if (Splinter.saveDraftTimeoutId == null) { + Splinter.saveDraftTimeoutId = setTimeout(Splinter.saveDraft, 10000); + } +}; + +Splinter.flushSaveDraft = function () { + if (Splinter.saveDraftTimeoutId != null) { + Splinter.saveDraft(); + } +}; + +Splinter.ensureCommentArea = function (row) { + var file = Splinter.domCache.data(row).patchFile; + var colSpan = file.status == Splinter.Patch.CHANGED ? 5 : 2; + + if (!row.nextSibling || row.nextSibling.className != "comment-area") { + var tr = new Element(document.createElement('tr')); + Dom.addClass(tr, 'comment-area'); + var td = new Element(document.createElement('td')); + Dom.setAttribute(td, 'colspan', colSpan); + td.appendTo(tr); + Dom.insertAfter(tr, row); + } + + return row.nextSibling.firstChild; +}; + +Splinter.getTypeClass = function (type) { + switch (type) { + case Splinter.Patch.ADDED: + return "comment-added"; + case Splinter.Patch.REMOVED: + return "comment-removed"; + case Splinter.Patch.CHANGED: + return "comment-changed"; + } + + return null; +}; + +Splinter.getSeparatorClass = function (type) { + switch (type) { + case Splinter.Patch.ADDED: + return "comment-separator-added"; + case Splinter.Patch.REMOVED: + return "comment-separator-removed"; + } + + return null; +}; + +Splinter.getReviewerClass = function (review) { + var reviewerIndex; + if (review == Splinter.theReview) { + reviewerIndex = 0; + } else { + reviewerIndex = (Splinter.reviewers[review.who] - 1) % 5 + 1; + } + + return "reviewer-" + reviewerIndex; +}; + +Splinter.addCommentDisplay = function (commentArea, comment) { + var review = comment.file.review; + + var separatorClass = Splinter.getSeparatorClass(comment.type); + if (separatorClass) { + var div = new Element(document.createElement('div')); + Dom.addClass(div, separatorClass); + Dom.addClass(div, Splinter.getReviewerClass(review)); + div.appendTo(commentArea); + } + + var commentDiv = new Element(document.createElement('div')); + Dom.addClass(commentDiv, 'comment'); + Dom.addClass(commentDiv, Splinter.getTypeClass(comment.type)); + Dom.addClass(commentDiv, Splinter.getReviewerClass(review)); + + Event.addListener(Dom.get(commentDiv), 'dblclick', function () { + Splinter.saveComment(); + Splinter.insertCommentEditor(commentArea, comment.file.patchFile, + comment.location, comment.type); + }); + + var commentFrame = new Element(document.createElement('div')); + Dom.addClass(commentFrame, 'comment-frame'); + commentFrame.appendTo(commentDiv); + + var reviewerBox = new Element(document.createElement('div')); + Dom.addClass(reviewerBox, 'reviewer-box'); + reviewerBox.appendTo(commentFrame); + + var commentText = new Element(document.createElement('div')); + Dom.addClass(commentText, 'comment-text'); + Splinter.Utils.preWrapLines(commentText, comment.comment); + commentText.appendTo(reviewerBox); + + commentDiv.appendTo(commentArea); + + if (review != Splinter.theReview) { + var reviewInfo = new Element(document.createElement('div')); + Dom.addClass(reviewInfo, 'review-info'); + + var reviewer = new Element(document.createElement('div')); + Dom.addClass(reviewer, 'reviewer'); + reviewer.appendChild(document.createTextNode(review.who)); + reviewer.appendTo(reviewInfo); + + var reviewDate = new Element(document.createElement('div')); + Dom.addClass(reviewDate, 'review-date'); + reviewDate.appendChild(document.createTextNode(Splinter.Utils.formatDate(review.date))); + reviewDate.appendTo(reviewInfo); + + var reviewInfoBottom = new Element(document.createElement('div')); + Dom.addClass(reviewInfoBottom, 'review-info-bottom'); + reviewInfoBottom.appendTo(reviewInfo); + + reviewInfo.appendTo(reviewerBox); + } + + comment.div = commentDiv; +}; + +Splinter.saveComment = function () { + var comment = Splinter.currentEditComment; + if (!comment) { + return; + } + + var commentEditor = Dom.get('commentEditor'); + var commentArea = commentEditor.parentNode; + var reviewFile = comment.file; + + var hunk = comment.getHunk(); + var line = hunk.lines[comment.location - hunk.location]; + + var value = Splinter.Utils.strip(commentEditor.getElementsByTagName('textarea')[0].value); + if (value != "") { + comment.comment = value; + Splinter.addCommentDisplay(commentArea, comment); + } else { + comment.remove(); + } + + if (line.reviewComments.length > 0) { + commentEditor.parentNode.removeChild(commentEditor); + var commentEditorSeparator = Dom.get('commentEditorSeparator'); + if (commentEditorSeparator) { + commentEditorSeparator.parentNode.removeChild(commentEditorSeparator); + } + } else { + var parentToRemove = commentArea.parentNode; + commentArea.parentNode.parentNode.removeChild(parentToRemove); + } + + Splinter.currentEditComment = null; + Splinter.saveDraft(); + Splinter.queueUpdateHaveDraft(); +}; + +Splinter.cancelComment = function (previousText) { + Dom.get("commentEditor").getElementsByTagName("textarea")[0].value = previousText; + Splinter.saveComment(); +}; + +Splinter.deleteComment = function () { + Dom.get('commentEditor').getElementsByTagName('textarea')[0].value = ""; + Splinter.saveComment(); +}; + +Splinter.insertCommentEditor = function (commentArea, file, location, type) { + Splinter.saveComment(); + + var reviewFile = Splinter.theReview.getFile(file.filename); + var comment = reviewFile.getComment(location, type); + if (!comment) { + comment = reviewFile.addComment(location, type, ""); + Splinter.queueUpdateHaveDraft(); + } + + var previousText = comment.comment; + + var typeClass = Splinter.getTypeClass(type); + var separatorClass = Splinter.getSeparatorClass(type); + + var nodes = Dom.getElementsByClassName('reviewer-0', 'div', commentArea); + var i; + for (i = 0; i < nodes.length; i++) { + if (separatorClass && Dom.hasClass(nodes[i], separatorClass)) { + nodes[i].parentNode.removeChild(nodes[i]); + } + if (Dom.hasClass(nodes[i], typeClass)) { + nodes[i].parentNode.removeChild(nodes[i]); + } + } + + if (separatorClass) { + var commentEditorSeparator = new Element(document.createElement('div')); + commentEditorSeparator.set('id', 'commentEditorSeparator'); + Dom.addClass(commentEditorSeparator, separatorClass); + commentEditorSeparator.appendTo(commentArea); + } + + var commentEditor = new Element(document.createElement('div')); + Dom.setAttribute(commentEditor, 'id', 'commentEditor'); + Dom.addClass(commentEditor, typeClass); + commentEditor.appendTo(commentArea); + + var commentEditorInner = new Element(document.createElement('div')); + Dom.setAttribute(commentEditorInner, 'id', 'commentEditorInner'); + commentEditorInner.appendTo(commentEditor); + + var commentTextFrame = new Element(document.createElement('div')); + Dom.setAttribute(commentTextFrame, 'id', 'commentTextFrame'); + commentTextFrame.appendTo(commentEditorInner); + + var commentTextArea = new Element(document.createElement('textarea')); + Dom.setAttribute(commentTextArea, 'id', 'commentTextArea'); + Dom.setAttribute(commentTextArea, 'tabindex', 1); + commentTextArea.appendChild(document.createTextNode(previousText)); + commentTextArea.appendTo(commentTextFrame); + Event.addListener('commentTextArea', 'keydown', function (e) { + if (e.which == 13 && e.ctrlKey) { + Splinter.saveComment(); + } else if (e.which == 27) { + var comment = Dom.get('commentTextArea').value; + if (previousText == comment || comment == '') { + Splinter.cancelComment(previousText); + } + } else { + Splinter.queueSaveDraft(); + } + }); + Event.addListener('commentTextArea', 'focusin', function () { Dom.addClass(commentEditor, 'focused'); }); + Event.addListener('commentTextArea', 'focusout', function () { Dom.removeClass(commentEditor, 'focused'); }); + Dom.get(commentTextArea).focus(); + + var commentEditorLeftButtons = new Element(document.createElement('div')); + commentEditorLeftButtons.set('id', 'commentEditorLeftButtons'); + commentEditorLeftButtons.appendTo(commentEditorInner); + + var commentCancel = new Element(document.createElement('input')); + commentCancel.set('id','commentCancel'); + commentCancel.set('type', 'button'); + commentCancel.set('value', 'Cancel'); + Dom.setAttribute(commentCancel, 'tabindex', 4); + commentCancel.appendTo(commentEditorLeftButtons); + Event.addListener('commentCancel', 'click', function () { Splinter.cancelComment(previousText); }); + + if (previousText) { + var commentDelete = new Element(document.createElement('input')); + commentDelete.set('id','commentDelete'); + commentDelete.set('type', 'button'); + commentDelete.set('value', 'Delete'); + Dom.setAttribute(commentDelete, 'tabindex', 3); + commentDelete.appendTo(commentEditorLeftButtons); + Event.addListener('commentDelete', 'click', Splinter.deleteComment); + } + + var commentEditorRightButtons = new Element(document.createElement('div')); + commentEditorRightButtons.set('id', 'commentEditorRightButtons'); + commentEditorRightButtons.appendTo(commentEditorInner); + + var commentSave = new Element(document.createElement('input')); + commentSave.set('id','commentSave'); + commentSave.set('type', 'button'); + commentSave.set('value', 'Save'); + Dom.setAttribute(commentSave, 'tabindex', 2); + commentSave.appendTo(commentEditorRightButtons); + Event.addListener('commentSave', 'click', Splinter.saveComment); + + var clear = new Element(document.createElement('div')); + Dom.addClass(clear, 'clear'); + clear.appendTo(commentEditorInner); + + Splinter.currentEditComment = comment; +}; + +Splinter.insertCommentForRow = function (clickRow, clickType) { + var file = Splinter.domCache.data(clickRow).patchFile; + var clickLocation = Splinter.domCache.data(clickRow).patchLocation; + + var row = clickRow; + var location = clickLocation; + var type = clickType; + + Splinter.saveComment(); + var commentArea = Splinter.ensureCommentArea(row); + Splinter.insertCommentEditor(commentArea, file, location, type); +}; + +Splinter.EL = function (element, cls, text, title) { + var e = document.createElement(element); + if (text != null) { + e.appendChild(document.createTextNode(text)); + } + if (cls) { + e.className = cls; + } + if (title) { + Dom.setAttribute(e, 'title', title); + } + + return e; +}; + +Splinter.textTD = function (cls, text, title) { + if (text == "") { + return Splinter.EL("td", cls, "\u00a0", title); + } + var m = text.match(/^(.*?)(\s+)$/); + if (m) { + var td = Splinter.EL("td", cls, m[1], title); + td.insertBefore(Splinter.EL("span", cls + " trailing-whitespace", m[2], title), null); + return td; + } else { + return Splinter.EL("td", cls, text, title); + } +} + +Splinter.getElementPosition = function (element) { + var left = element.offsetLeft; + var top = element.offsetTop; + var parent = element.offsetParent; + while (parent && parent != document.body) { + left += parent.offsetLeft; + top += parent.offsetTop; + parent = parent.offsetParent; + } + + return [left, top]; +}; + +Splinter.scrollToElement = function (element) { + var windowHeight; + if ('innerHeight' in window) { // Not IE + windowHeight = window.innerHeight; + } else { // IE + windowHeight = document.documentElement.clientHeight; + } + var pos = Splinter.getElementPosition(element); + var yCenter = pos[1] + element.offsetHeight / 2; + window.scrollTo(0, yCenter - windowHeight / 2); +}; + +Splinter.onRowDblClick = function (e) { + var file = Splinter.domCache.data(this).patchFile; + var type; + + if (file.status == Splinter.Patch.CHANGED) { + var pos = Splinter.getElementPosition(this); + var delta = e.pageX - (pos[0] + this.offsetWidth/2); + if (delta < - 20) { + type = Splinter.Patch.REMOVED; + } else if (delta < 20) { + // CHANGED comments disabled due to breakage + // type = Splinter.Patch.CHANGED; + type = Splinter.Patch.ADDED; + } else { + type = Splinter.Patch.ADDED; + } + } else { + type = file.status; + } + + Splinter.insertCommentForRow(this, type); +}; + +Splinter.appendPatchTable = function (type, maxLine, parentDiv) { + var fileTableContainer = new Element(document.createElement('div')); + Dom.addClass(fileTableContainer, 'file-table-container'); + fileTableContainer.appendTo(parentDiv); + + var fileTable = new Element(document.createElement('table')); + Dom.addClass(fileTable, 'file-table'); + fileTable.appendTo(fileTableContainer); + + var colQ = new Element(document.createElement('colgroup')); + colQ.appendTo(fileTable); + + var col1, col2; + if (type != Splinter.Patch.ADDED) { + col1 = new Element(document.createElement('col')); + Dom.addClass(col1, 'line-number-column'); + Dom.setAttribute(col1, 'span', '1'); + col1.appendTo(colQ); + col2 = new Element(document.createElement('col')); + Dom.addClass(col2, 'old-column'); + Dom.setAttribute(col2, 'span', '1'); + col2.appendTo(colQ); + } + if (type == Splinter.Patch.CHANGED) { + col1 = new Element(document.createElement('col')); + Dom.addClass(col1, 'middle-column'); + Dom.setAttribute(col1, 'span', '1'); + col1.appendTo(colQ); + } + if (type != Splinter.Patch.REMOVED) { + col1 = new Element(document.createElement('col')); + Dom.addClass(col1, 'line-number-column'); + Dom.setAttribute(col1, 'span', '1'); + col1.appendTo(colQ); + col2 = new Element(document.createElement('col')); + Dom.addClass(col2, 'new-column'); + Dom.setAttribute(col2, 'span', '1'); + col2.appendTo(colQ); + } + + if (type == Splinter.Patch.CHANGED) { + Dom.addClass(fileTable, 'file-table-changed'); + } + + if (maxLine >= 1000) { + Dom.addClass(fileTable, "file-table-wide-numbers"); + } + + var tbody = new Element(document.createElement('tbody')); + tbody.appendTo(fileTable); + + return tbody; +}; + +Splinter.appendPatchHunk = function (file, hunk, tableType, includeComments, clickable, tbody, filter) { + hunk.iterate(function(loc, oldLine, oldText, newLine, newText, flags, line) { + if (filter && !filter(loc)) { + return; + } + + var tr = document.createElement("tr"); + + var oldStyle = ""; + var newStyle = ""; + if ((flags & Splinter.Patch.CHANGED) != 0) { + oldStyle = newStyle = "changed-line"; + } else if ((flags & Splinter.Patch.REMOVED) != 0) { + oldStyle = "removed-line"; + } else if ((flags & Splinter.Patch.ADDED) != 0) { + newStyle = "added-line"; + } + + var title = "Double click the line to add a review comment"; + + if (tableType != Splinter.Patch.ADDED) { + if (oldText != null) { + tr.appendChild(Splinter.EL("td", "line-number", oldLine.toString(), title)); + tr.appendChild(Splinter.textTD("old-line " + oldStyle, oldText, title)); + oldLine++; + } else { + tr.appendChild(Splinter.EL("td", "line-number")); + tr.appendChild(Splinter.EL("td", "old-line")); + } + } + + if (tableType == Splinter.Patch.CHANGED) { + tr.appendChild(Splinter.EL("td", "line-middle")); + } + + if (tableType != Splinter.Patch.REMOVED) { + if (newText != null) { + tr.appendChild(Splinter.EL("td", "line-number", newLine.toString(), title)); + tr.appendChild(Splinter.textTD("new-line " + newStyle, newText, title)); + newLine++; + } else if (tableType == Splinter.Patch.CHANGED) { + tr.appendChild(Splinter.EL("td", "line-number")); + tr.appendChild(Splinter.EL("td", "new-line")); + } + } + + if (!Splinter.readOnly && clickable) { + Splinter.domCache.data(tr).patchFile = file; + Splinter.domCache.data(tr).patchLocation = loc; + Event.addListener(tr, 'dblclick', Splinter.onRowDblClick); + } + + tbody.appendChild(tr); + + if (includeComments && line.reviewComments != null) { + var k; + for (k = 0; k < line.reviewComments.length; k++) { + var commentArea = Splinter.ensureCommentArea(tr); + Splinter.addCommentDisplay(commentArea, line.reviewComments[k]); + } + } + }); +}; + +Splinter.addPatchFile = function (file) { + var fileDiv = new Element(document.createElement('div')); + Dom.addClass(fileDiv, 'file'); + fileDiv.appendTo(Dom.get('splinter-files')); + file.div = fileDiv; + + var statusString; + switch (file.status) { + case Splinter.Patch.ADDED: + statusString = " (new file)"; + break; + case Splinter.Patch.REMOVED: + statusString = " (removed)"; + break; + case Splinter.Patch.CHANGED: + statusString = ""; + break; + } + + var fileLabel = new Element(document.createElement('div')); + Dom.addClass(fileLabel, 'file-label'); + fileLabel.appendTo(fileDiv); + + var fileCollapseLink = new Element(document.createElement('a')); + Dom.addClass(fileCollapseLink, 'file-label-collapse'); + fileCollapseLink.appendChild(document.createTextNode('[-]')); + Dom.setAttribute(fileCollapseLink, 'href', 'javascript:void(0);') + Dom.setAttribute(fileCollapseLink, 'onclick', "Splinter.toggleCollapsed('" + + encodeURIComponent(file.filename) + "');"); + Dom.setAttribute(fileCollapseLink, 'title', 'Click to expand or collapse this file table'); + fileCollapseLink.appendTo(fileLabel); + + var fileLabelName = new Element(document.createElement('span')); + Dom.addClass(fileLabelName, 'file-label-name'); + fileLabelName.appendChild(document.createTextNode(file.filename)); + fileLabelName.appendTo(fileLabel); + + var fileLabelStatus = new Element(document.createElement('span')); + Dom.addClass(fileLabelStatus, 'file-label-status'); + fileLabelStatus.appendChild(document.createTextNode(statusString)); + fileLabelStatus.appendTo(fileLabel); + + if (!Splinter.readOnly) { + var fileReviewed = new Element(document.createElement('span')); + Dom.addClass(fileReviewed, 'file-review'); + Dom.setAttribute(fileReviewed, 'title', 'Indicates that a review has been completed for this file. ' + + 'This is for personal tracking purposes only and has no effect ' + + 'on the published review.'); + fileReviewed.appendTo(fileLabel); + + var fileReviewedInput = new Element(document.createElement('input')); + Dom.setAttribute(fileReviewedInput, 'type', 'checkbox'); + Dom.setAttribute(fileReviewedInput, 'id', 'file-review-checkbox-' + encodeURIComponent(file.filename)); + Dom.setAttribute(fileReviewedInput, 'onchange', "Splinter.toggleFileReviewed('" + + encodeURIComponent(file.filename) + "');"); + if (file.fileReviewed) { + Dom.setAttribute(fileReviewedInput, 'checked', 'true'); + } + fileReviewedInput.appendTo(fileReviewed); + + var fileReviewedLabel = new Element(document.createElement('label')); + Dom.addClass(fileReviewedLabel, 'file-review-label') + Dom.setAttribute(fileReviewedLabel, 'for', 'file-review-checkbox-' + encodeURIComponent(file.filename)); + fileReviewedLabel.appendChild(document.createTextNode(' Reviewed')); + fileReviewedLabel.appendTo(fileReviewed); + } + + if (file.extra) { + var extraContainer = new Element(document.createElement('div')); + Dom.addClass(extraContainer, 'file-extra-container'); + var extraMargin = new Element(document.createElement('span')); + Dom.addClass(extraMargin, 'file-label-collapse'); + extraMargin.appendChild(document.createTextNode('\u00a0\u00a0\u00a0')); + extraMargin.appendTo(extraContainer); + var extraLabel = new Element(document.createElement('span')); + Dom.addClass(extraLabel, 'file-label-extra'); + extraLabel.appendChild(document.createTextNode(file.extra)); + extraLabel.appendTo(extraContainer); + extraContainer.appendTo(fileLabel); + } + + if (file.hunks.length == 0) + return; + + var lastHunk = file.hunks[file.hunks.length - 1]; + var lastLine = Math.max(lastHunk.oldStart + lastHunk.oldCount - 1, + lastHunk.newStart + lastHunk.newCount - 1); + + var tbody = Splinter.appendPatchTable(file.status, lastLine, fileDiv); + + var i; + for (i = 0; i < file.hunks.length; i++) { + var hunk = file.hunks[i]; + if (hunk.oldStart > 1) { + var hunkHeader = Splinter.EL("tr", "hunk-header"); + tbody.appendChild(hunkHeader); + hunkHeader.appendChild(Splinter.EL("td")); // line number column + var hunkCell = Splinter.EL( + "td", + "hunk-cell", + "Lines " + hunk.oldStart + '-' + + Math.max(hunk.oldStart + hunk.oldCount - 1, hunk.newStart + hunk.newCount - 1) + + "\u00a0\u00a0" + hunk.functionLine + ); + hunkCell.colSpan = file.status == Splinter.Patch.CHANGED ? 4 : 1; + hunkHeader.appendChild(hunkCell); + } + + Splinter.appendPatchHunk(file, hunk, file.status, true, true, tbody); + } +}; + +Splinter.appendReviewComment = function (comment, parentDiv) { + var commentDiv = Splinter.EL("div", "review-patch-comment"); + Event.addListener(commentDiv, 'click', function() { + Splinter.showPatchFile(comment.file.patchFile); + if (comment.file.review == Splinter.theReview) { + // Immediately start editing the comment again + var commentDivParent = Dom.getAncestorByClassName(comment.div, 'comment-area'); + var commentArea = commentDivParent.getElementsByTagName('td')[0]; + Splinter.insertCommentEditor(commentArea, comment.file.patchFile, comment.location, comment.type); + Splinter.scrollToElement(Dom.get('commentEditor')); + } else { + // Just scroll to the comment, don't start a reply yet + Splinter.scrollToElement(Dom.get(comment.div)); + } + }); + + var inReplyTo = comment.getInReplyTo(); + if (inReplyTo) { + var div = new Element(document.createElement('div')); + Dom.addClass(div, Splinter.getReviewerClass(inReplyTo.file.review)); + div.appendTo(commentDiv); + + var reviewerBox = new Element(document.createElement('div')); + Dom.addClass(reviewerBox, 'reviewer-box'); + Splinter.Utils.preWrapLines(reviewerBox, inReplyTo.comment); + reviewerBox.appendTo(div); + + var reviewPatchCommentText = new Element(document.createElement('div')); + Dom.addClass(reviewPatchCommentText, 'review-patch-comment-text'); + Splinter.Utils.preWrapLines(reviewPatchCommentText, comment.comment); + reviewPatchCommentText.appendTo(commentDiv); + + } else { + var hunk = comment.getHunk(); + + var lastLine = Math.max(hunk.oldStart + hunk.oldCount- 1, + hunk.newStart + hunk.newCount- 1); + var tbody = Splinter.appendPatchTable(comment.type, lastLine, commentDiv); + + Splinter.appendPatchHunk(comment.file.patchFile, hunk, comment.type, false, false, tbody, + function(loc) { + return (loc <= comment.location && comment.location - loc < 5); + }); + + var tr = new Element(document.createElement('tr')); + var td = new Element(document.createElement('td')); + td.appendTo(tr); + td = new Element(document.createElement('td')); + Dom.addClass(td, 'review-patch-comment-text'); + Splinter.Utils.preWrapLines(td, comment.comment); + td.appendTo(tr); + tr.appendTo(tbody); + } + + parentDiv.appendChild(commentDiv); +}; + +Splinter.appendReviewComments = function (review, parentDiv) { + var i; + for (i = 0; i < review.files.length; i++) { + var file = review.files[i]; + + if (file.comments.length == 0) { + continue; + } + + parentDiv.appendChild(Splinter.EL("div", "review-patch-file", file.patchFile.filename)); + var firstComment = true; + var j; + for (j = 0; j < file.comments.length; j++) { + if (firstComment) { + firstComment = false; + } else { + parentDiv.appendChild(Splinter.EL("div", "review-patch-comment-separator")); + } + + Splinter.appendReviewComment(file.comments[j], parentDiv); + } + } +}; + +Splinter.updateMyPatchComments = function () { + var myPatchComments = Dom.get("myPatchComments"); + myPatchComments.innerHTML = ''; + Splinter.appendReviewComments(Splinter.theReview, myPatchComments); + if (Dom.getChildren(myPatchComments).length > 0) { + Dom.setStyle(myPatchComments, 'display', 'block'); + } else { + Dom.setStyle(myPatchComments, 'display', 'none'); + } +}; + +Splinter.selectNavigationLink = function (identifier) { + var navigationLinks = Dom.getElementsByClassName('navigation-link'); + var i; + for (i = 0; i < navigationLinks.length; i++) { + Dom.removeClass(navigationLinks[i], 'navigation-link-selected'); + } + Dom.addClass(Splinter.navigationLinks[identifier], 'navigation-link-selected'); +}; + +Splinter.addNavigationLink = function (identifier, title, callback, selected) { + var navigationDiv = Dom.get('navigation'); + if (Dom.getChildren(navigationDiv).length > 0) { + navigationDiv.appendChild(document.createTextNode(' | ')); + } + + var navigationLink = new Element(document.createElement('a')); + Dom.addClass(navigationLink, 'navigation-link'); + Dom.setAttribute(navigationLink, 'href', 'javascript:void(0);'); + Dom.setAttribute(navigationLink, 'id', 'switch-' + encodeURIComponent(identifier)); + Dom.setAttribute(navigationLink, 'title', identifier); + navigationLink.appendChild(document.createTextNode(title)); + navigationLink.appendTo(navigationDiv); + + // FIXME: Find out why I need to use an id here instead of just passing + // navigationLink to Event.addListener() + Event.addListener('switch-' + encodeURIComponent(identifier), 'click', function () { + if (!Dom.hasClass(this, 'navigation-link-selected')) { + callback(); + } + }); + + if (selected) { + Dom.addClass(navigationLink, 'navigation-link-selected'); + } + + Splinter.navigationLinks[identifier] = navigationLink; +}; + +Splinter.showOverview = function () { + Splinter.selectNavigationLink('__OVERVIEW__'); + Dom.setStyle('overview', 'display', 'block'); + Dom.getElementsByClassName('file', 'div', '', function (node) { + Dom.setStyle(node, 'display', 'none'); + }); + if (!Splinter.readOnly) + Splinter.updateMyPatchComments(); +}; + +Splinter.showAllFiles = function () { + Splinter.selectNavigationLink('__ALL__'); + Dom.setStyle('overview', 'display', 'none'); + Dom.setStyle('file-collapse-all', 'display', 'block'); + + var i; + for (i = 0; i < Splinter.thePatch.files.length; i++) { + var file = Splinter.thePatch.files[i]; + if (!file.div) { + Splinter.addPatchFile(file); + } else { + Dom.setStyle(file.div, 'display', 'block'); + } + } +} + +Splinter.toggleCollapsed = function (filename, display) { + filename = decodeURIComponent(filename); + var i; + for (i = 0; i < Splinter.thePatch.files.length; i++) { + var file = Splinter.thePatch.files[i]; + if (!filename || filename == file.filename) { + var fileTableContainer = file.div.getElementsByClassName('file-table-container')[0]; + var fileExtraContainer = file.div.getElementsByClassName('file-extra-container')[0]; + var fileCollapseLink = file.div.getElementsByClassName('file-label-collapse')[0]; + if (!display) { + display = Dom.getStyle(fileTableContainer, 'display') == 'block' ? 'none' : 'block'; + } + Dom.setStyle(fileTableContainer, 'display', display); + Dom.setStyle(fileExtraContainer, 'display', display); + fileCollapseLink.innerHTML = display == 'block' ? '[-]' : '[+]'; + } + } +} + +Splinter.toggleFileReviewed = function (filename) { + var checkbox = Dom.get('file-review-checkbox-' + filename); + if (checkbox) { + filename = decodeURIComponent(filename); + for (var i = 0; i < Splinter.thePatch.files.length; i++) { + var file = Splinter.thePatch.files[i]; + if (file.filename == filename) { + file.fileReviewed = checkbox.checked; + + Splinter.saveDraft(); + Splinter.queueUpdateHaveDraft(); + + // Strike through file names to show review was completed + var fileNavLink = Dom.get('switch-' + encodeURIComponent(filename)); + if (file.fileReviewed) { + Dom.addClass(fileNavLink, 'file-reviewed-nav'); + } + else { + Dom.removeClass(fileNavLink, 'file-reviewed-nav'); + } + } + } + } +} + +Splinter.showPatchFile = function (file) { + Splinter.selectNavigationLink(file.filename); + Dom.setStyle('overview', 'display', 'none'); + Dom.setStyle('file-collapse-all', 'display', 'none'); + + Dom.getElementsByClassName('file', 'div', '', function (node) { + Dom.setStyle(node, 'display', 'none'); + }); + + if (file.div) { + Dom.setStyle(file.div, 'display', 'block'); + } else { + Splinter.addPatchFile(file); + } +}; + +Splinter.addFileNavigationLink = function (file) { + var basename = file.filename.replace(/.*\//, ""); + Splinter.addNavigationLink(file.filename, basename, function() { + Splinter.showPatchFile(file); + }); +}; + +Splinter.start = function () { + Dom.setStyle('attachmentInfo', 'display', 'block'); + Dom.setStyle('navigationContainer', 'display', 'block'); + Dom.setStyle('overview', 'display', 'block'); + Dom.setStyle('splinter-files', 'display', 'block'); + Dom.setStyle('attachmentStatusSpan', 'display', 'none'); + + if (Splinter.thePatch.intro) { + Splinter.Utils.preWrapLines(Dom.get('patchIntro'), Splinter.thePatch.intro); + } else { + Dom.setStyle('patchIntro', 'display', 'none'); + } + + Splinter.addNavigationLink('__OVERVIEW__', "Overview", Splinter.showOverview, true); + Splinter.addNavigationLink('__ALL__', "All Files", Splinter.showAllFiles, false); + + var i; + for (i = 0; i < Splinter.thePatch.files.length; i++) { + Splinter.addFileNavigationLink(Splinter.thePatch.files[i]); + } + + var navigation = Dom.get('navigation'); + + var haveDraftNotice = new Element(document.createElement('div')); + Dom.setAttribute(haveDraftNotice, 'id', 'haveDraftNotice'); + haveDraftNotice.appendChild(document.createTextNode('Draft')); + haveDraftNotice.appendTo(navigation); + + var clear = new Element(document.createElement('div')); + Dom.addClass(clear, 'clear'); + clear.appendTo(navigation); + + var numReviewers = 0; + for (i = 0; i < Splinter.theBug.comments.length; i++) { + var comment = Splinter.theBug.comments[i]; + var m = Splinter.Review.REVIEW_RE.exec(comment.text); + + if (m && parseInt(m[1], 10) == Splinter.attachmentId) { + var review = new Splinter.Review.Review(Splinter.thePatch, comment.getWho(), comment.date); + review.parse(comment.text.substr(m[0].length)); + + var reviewerIndex; + if (review.who in Splinter.reviewers) { + reviewerIndex = Splinter.reviewers[review.who]; + } else { + reviewerIndex = ++numReviewers; + Splinter.reviewers[review.who] = reviewerIndex; + } + + var reviewDiv = new Element(document.createElement('div')); + Dom.addClass(reviewDiv, 'review'); + Dom.addClass(reviewDiv, Splinter.getReviewerClass(review)); + reviewDiv.appendTo(Dom.get('oldReviews')); + + var reviewerBox = new Element(document.createElement('div')); + Dom.addClass(reviewerBox, 'reviewer-box'); + reviewerBox.appendTo(reviewDiv); + + var reviewer = new Element(document.createElement('div')); + Dom.addClass(reviewer, 'reviewer'); + reviewer.appendChild(document.createTextNode(review.who)); + reviewer.appendTo(reviewerBox); + + var reviewDate = new Element(document.createElement('div')); + Dom.addClass(reviewDate, 'review-date'); + reviewDate.appendChild(document.createTextNode(Splinter.Utils.formatDate(review.date))); + reviewDate.appendTo(reviewerBox); + + var reviewInfoBottom = new Element(document.createElement('div')); + Dom.addClass(reviewInfoBottom, 'review-info-bottom'); + reviewInfoBottom.appendTo(reviewerBox); + + var reviewIntro = new Element(document.createElement('div')); + Dom.addClass(reviewIntro, 'review-intro'); + Splinter.Utils.preWrapLines(reviewIntro, review.intro? review.intro : ""); + reviewIntro.appendTo(reviewerBox); + + Dom.setStyle('oldReviews', 'display', 'block'); + + Splinter.appendReviewComments(review, reviewerBox); + } + } + + // We load the saved draft or create a new review *after* inserting the existing reviews + // so that the ordering comes out right. + + if (Splinter.reviewStorage) { + Splinter.theReview = Splinter.reviewStorage.loadDraft(Splinter.theBug, Splinter.theAttachment, Splinter.thePatch); + if (Splinter.theReview) { + var storedReviews = Splinter.reviewStorage.listReviews(); + Dom.setStyle('restored', 'display', 'block'); + for (i = 0; i < storedReviews.length; i++) { + if (storedReviews[i].bugId == Splinter.theBug.id && + storedReviews[i].attachmentId == Splinter.theAttachment.id) + { + Dom.get("restoredLastModified").innerHTML = Splinter.Utils.formatDate(new Date(storedReviews[i].modificationTime)); + // Restore file reviewed checkboxes + if (storedReviews[i].filesReviewed) { + for (var j = 0; j < Splinter.thePatch.files.length; j++) { + var file = Splinter.thePatch.files[j]; + if (storedReviews[i].filesReviewed[file.filename]) { + file.fileReviewed = true; + // Strike through file names to show that review was completed + var fileNavLink = Dom.get('switch-' + encodeURIComponent(file.filename)); + Dom.addClass(fileNavLink, 'file-reviewed-nav'); + } + } + } + } + } + } + } + + if (!Splinter.theReview) { + Splinter.theReview = new Splinter.Review.Review(Splinter.thePatch); + } + + if (Splinter.theReview.intro) { + Dom.setStyle('emptyCommentNotice', 'display', 'none'); + } + + if (!Splinter.readOnly) { + var myComment = Dom.get('myComment'); + myComment.value = Splinter.theReview.intro ? Splinter.theReview.intro : ""; + Event.addListener(myComment, 'focus', function () { + Dom.setStyle('emptyCommentNotice', 'display', 'none'); + }); + Event.addListener(myComment, 'blur', function () { + if (myComment.value == '') { + Dom.setStyle('emptyCommentNotice', 'display', 'block'); + } + }); + Event.addListener(myComment, 'keydown', function () { + Splinter.queueSaveDraft(); + Splinter.queueUpdateHaveDraft(); + }); + + Splinter.updateMyPatchComments(); + + Splinter.queueUpdateHaveDraft(); + + Event.addListener("publishButton", "click", Splinter.publishReview); + Event.addListener("cancelButton", "click", Splinter.discardReview); + } else { + Dom.setStyle('haveDraftNotice', 'display', 'none'); + } +}; + +Splinter.newPageUrl = function (newBugId, newAttachmentId) { + var newUrl = Splinter.configBase; + if (newBugId != null) { + newUrl += (newUrl.indexOf("?") < 0) ? "?" : "&"; + newUrl += "bug=" + escape("" + newBugId); + if (newAttachmentId != null) { + newUrl += "&attachment=" + escape("" + newAttachmentId); + } + } + + return newUrl; +}; + +Splinter.showNote = function () { + var noteDiv = Dom.get("note"); + if (noteDiv && Splinter.configNote) { + noteDiv.innerHTML = Splinter.configNote; + Dom.setStyle(noteDiv, 'display', 'block'); + } +}; + +Splinter.showEnterBug = function () { + Splinter.showNote(); + + Event.addListener("enterBugGo", "click", function () { + var newBugId = Splinter.Utils.strip(Dom.get("enterBugInput").value); + document.location = Splinter.newPageUrl(newBugId); + }); + + Dom.setStyle('enterBug', 'display', 'block'); + + if (!Splinter.reviewStorage) { + return; + } + + var storedReviews = Splinter.reviewStorage.listReviews(); + if (storedReviews.length == 0) { + return; + } + + var i; + var reviewData = []; + for (i = storedReviews.length - 1; i >= 0; i--) { + var reviewInfo = storedReviews[i]; + var modificationDate = Splinter.Utils.formatDate(new Date(reviewInfo.modificationTime)); + var extra = reviewInfo.isDraft ? "(draft)" : ""; + + reviewData.push([ + reviewInfo.bugId, + reviewInfo.bugId + ":" + reviewInfo.attachmentId + ":" + reviewInfo.attachmentDescription, + modificationDate, + extra + ]); + } + + var attachLink = function (elLiner, oRecord, oColumn, oData) { + var splitResult = oData.split(':', 3); + elLiner.innerHTML = "<a href=\"" + Splinter.newPageUrl(splitResult[0], splitResult[1]) + + "\">" + splitResult[1] + " - " + splitResult[2] + "</a>"; + }; + + var bugLink = function (elLiner, oRecord, oColumn, oData) { + elLiner.innerHTML = "<a href=\"" + Splinter.newPageUrl(oData) + + "\">" + oData + "</a>"; + }; + + dsConfig = { + responseType: YAHOO.util.DataSource.TYPE_JSARRAY, + responseSchema: { fields:["bug_id","attachment", "date", "extra"] } + }; + + var columnDefs = [ + { key: "bug_id", label: "Bug", formatter: bugLink }, + { key: "attachment", label: "Attachment", formatter: attachLink }, + { key: "date", label: "Date" }, + { key: "extra", label: "Extra" } + ]; + + var dataSource = new YAHOO.util.LocalDataSource(reviewData, dsConfig); + var dataTable = new YAHOO.widget.DataTable("chooseReviewTable", columnDefs, dataSource); + + Dom.setStyle('chooseReview', 'display', 'block'); +}; + +Splinter.showChooseAttachment = function () { + var drafts = {}; + var published = {}; + if (Splinter.reviewStorage) { + var storedReviews = Splinter.reviewStorage.listReviews(); + var j; + for (j = 0; j < storedReviews.length; j++) { + var reviewInfo = storedReviews[j]; + if (reviewInfo.bugId == Splinter.theBug.id) { + if (reviewInfo.isDraft) { + drafts[reviewInfo.attachmentId] = 1; + } else { + published[reviewInfo.attachmentId] = 1; + } + } + } + } + + var attachData = []; + + var i; + for (i = 0; i < Splinter.theBug.attachments.length; i++) { + var attachment = Splinter.theBug.attachments[i]; + + if (!attachment.isPatch || attachment.isObsolete) { + continue; + } + + var href = Splinter.newPageUrl(Splinter.theBug.id, attachment.id); + + var date = Splinter.Utils.formatDate(attachment.date); + var status = (attachment.status && attachment.status != 'none') ? attachment.status : ''; + + var extra = ''; + if (attachment.id in drafts) { + extra = '(draft)'; + } else if (attachment.id in published) { + extra = '(published)'; + } + + attachData.push([ attachment.id, attachment.description, attachment.date, extra ]); + } + + var attachLink = function (elLiner, oRecord, oColumn, oData) { + elLiner.innerHTML = "<a href=\"" + Splinter.newPageUrl(Splinter.theBug.id, oData) + + "\">" + oData + "</a>"; + }; + + dsConfig = { + responseType: YAHOO.util.DataSource.TYPE_JSARRAY, + responseSchema: { fields:["id","description","date", "extra"] } + }; + + var columnDefs = [ + { key: "id", label: "ID", formatter: attachLink }, + { key: "description", label: "Description" }, + { key: "date", label: "Date" }, + { key: "extra", label: "Extra" } + ]; + + var dataSource = new YAHOO.util.LocalDataSource(attachData, dsConfig); + var dataTable = new YAHOO.widget.DataTable("chooseAttachmentTable", columnDefs, dataSource); + + Dom.setStyle('chooseAttachment', 'display', 'block'); +}; + +Splinter.quickHelpToggle = function () { + var quickHelpShow = Dom.get('quickHelpShow'); + var quickHelpContent = Dom.get('quickHelpContent'); + var quickHelpToggle = Dom.get('quickHelpToggle'); + + if (quickHelpContent.style.display == 'none') { + quickHelpContent.style.display = 'block'; + quickHelpShow.style.display = 'none'; + } else { + quickHelpContent.style.display = 'none'; + quickHelpShow.style.display = 'block'; + } +}; + +Splinter.init = function () { + Splinter.showNote(); + + if (Splinter.ReviewStorage.LocalReviewStorage.available()) { + Splinter.reviewStorage = new Splinter.ReviewStorage.LocalReviewStorage(); + } + + if (Splinter.theBug == null) { + Splinter.showEnterBug(); + return; + } + + Dom.get("bugId").innerHTML = Splinter.theBug.id; + Dom.get("bugLink").setAttribute('href', Splinter.configBugUrl + "show_bug.cgi?id=" + Splinter.theBug.id); + Dom.get("bugShortDesc").innerHTML = YAHOO.lang.escapeHTML(Splinter.theBug.shortDesc); + Dom.get("bugReporter").appendChild(document.createTextNode(Splinter.theBug.getReporter())); + Dom.get("bugCreationDate").innerHTML = Splinter.Utils.formatDate(Splinter.theBug.creationDate); + Dom.setStyle('bugInfo', 'display', 'block'); + + if (Splinter.attachmentId) { + Splinter.theAttachment = Splinter.theBug.getAttachment(Splinter.attachmentId); + + if (Splinter.theAttachment == null) { + Splinter.displayError("Attachment " + Splinter.attachmentId + " is not an attachment to bug " + Splinter.theBug.id); + } + else if (!Splinter.theAttachment.isPatch) { + Splinter.displayError("Attachment " + Splinter.attachmentId + " is not a patch"); + Splinter.theAttachment = null; + } + } + + if (Splinter.theAttachment == null) { + Splinter.showChooseAttachment(); + + } else { + Dom.get("attachId").innerHTML = Splinter.theAttachment.id; + Dom.get("attachLink").setAttribute('href', Splinter.configBugUrl + "attachment.cgi?id=" + Splinter.theAttachment.id); + Dom.get("attachDesc").innerHTML = YAHOO.lang.escapeHTML(Splinter.theAttachment.description); + Dom.get("attachCreator").appendChild(document.createTextNode(Splinter.Bug._formatWho(Splinter.theAttachment.whoName, + Splinter.theAttachment.whoEmail))); + Dom.get("attachDate").innerHTML = Splinter.Utils.formatDate(Splinter.theAttachment.date); + var warnings = []; + if (Splinter.theAttachment.isObsolete) + warnings.push('OBSOLETE'); + if (Splinter.theAttachment.isCRLF) + warnings.push('WINDOWS PATCH'); + if (warnings.length > 0) + Dom.get("attachWarning").innerHTML = warnings.join(', '); + Dom.setStyle('attachInfo', 'display', 'block'); + + Dom.setStyle('quickHelpShow', 'display', 'block'); + + document.title = "Patch Review of Attachment " + Splinter.theAttachment.id + + " for Bug " + Splinter.theBug.id; + + Splinter.thePatch = new Splinter.Patch.Patch(Splinter.theAttachment.data); + if (Splinter.thePatch != null) { + Splinter.start(); + } + } +}; + +YAHOO.util.Event.addListener(window, 'load', Splinter.init); diff --git a/extensions/TagNewUsers/Config.pm b/extensions/TagNewUsers/Config.pm new file mode 100644 index 000000000..c2330afc8 --- /dev/null +++ b/extensions/TagNewUsers/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::TagNewUsers; +use strict; + +use constant NAME => 'TagNewUsers'; +use constant REQUIRED_MODULES => [ ]; +use constant OPTIONAL_MODULES => [ ]; + +__PACKAGE__->NAME; diff --git a/extensions/TagNewUsers/Extension.pm b/extensions/TagNewUsers/Extension.pm new file mode 100644 index 000000000..7f12445fb --- /dev/null +++ b/extensions/TagNewUsers/Extension.pm @@ -0,0 +1,263 @@ +# 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::TagNewUsers; +use strict; +use base qw(Bugzilla::Extension); +use Bugzilla::Field; +use Bugzilla::User; +use Bugzilla::Install::Util qw(indicate_progress); +use Bugzilla::WebService::Util qw(filter_wants); +use Date::Parse; +use Scalar::Util qw(blessed); + +# users younger than PROFILE_AGE days will be tagged as new +use constant PROFILE_AGE => 60; + +# users with fewer comments than COMMENT_COUNT will be tagged as new +use constant COMMENT_COUNT => 25; + +our $VERSION = '1'; + +# +# install +# + +sub install_update_db { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + + if (!$dbh->bz_column_info('profiles', 'comment_count')) { + $dbh->bz_add_column('profiles', 'comment_count', + {TYPE => 'INT3', NOTNULL => 1, DEFAULT => 0}); + my $sth = $dbh->prepare('UPDATE profiles SET comment_count=? WHERE userid=?'); + my $ra = $dbh->selectall_arrayref('SELECT who,COUNT(*) FROM longdescs GROUP BY who'); + my $count = 1; + my $total = scalar @$ra; + foreach my $ra_row (@$ra) { + indicate_progress({ current => $count++, total => $total, every => 25 }); + my ($user_id, $count) = @$ra_row; + $sth->execute($count, $user_id); + } + } + + if (!$dbh->bz_column_info('profiles', 'creation_ts')) { + $dbh->bz_add_column('profiles', 'creation_ts', + {TYPE => 'DATETIME'}); + my $creation_date_fieldid = get_field_id('creation_ts'); + my $sth = $dbh->prepare('UPDATE profiles SET creation_ts=? WHERE userid=?'); + my $ra = $dbh->selectall_arrayref(" + SELECT p.userid, a.profiles_when + FROM profiles p + LEFT JOIN profiles_activity a ON a.userid=p.userid + AND a.fieldid=$creation_date_fieldid + "); + my ($now) = Bugzilla->dbh->selectrow_array("SELECT NOW()"); + my $count = 1; + my $total = scalar @$ra; + foreach my $ra_row (@$ra) { + indicate_progress({ current => $count++, total => $total, every => 25 }); + my ($user_id, $when) = @$ra_row; + if (!$when) { + ($when) = $dbh->selectrow_array( + "SELECT bug_when FROM bugs_activity WHERE who=? ORDER BY bug_when " . + $dbh->sql_limit(1), + undef, $user_id + ); + } + if (!$when) { + ($when) = $dbh->selectrow_array( + "SELECT bug_when FROM longdescs WHERE who=? ORDER BY bug_when " . + $dbh->sql_limit(1), + undef, $user_id + ); + } + if (!$when) { + ($when) = $dbh->selectrow_array( + "SELECT creation_ts FROM bugs WHERE reporter=? ORDER BY creation_ts " . + $dbh->sql_limit(1), + undef, $user_id + ); + } + if (!$when) { + $when = $now; + } + + $sth->execute($when, $user_id); + } + } + + if (!$dbh->bz_column_info('profiles', 'first_patch_bug_id')) { + $dbh->bz_add_column('profiles', 'first_patch_bug_id', {TYPE => 'INT3'}); + my $sth_update = $dbh->prepare('UPDATE profiles SET first_patch_bug_id=? WHERE userid=?'); + my $sth_select = $dbh->prepare( + 'SELECT bug_id FROM attachments WHERE submitter_id=? AND ispatch=1 ORDER BY creation_ts ' . $dbh->sql_limit(1) + ); + my $ra = $dbh->selectcol_arrayref('SELECT DISTINCT submitter_id FROM attachments WHERE ispatch=1'); + my $count = 1; + my $total = scalar @$ra; + foreach my $user_id (@$ra) { + indicate_progress({ current => $count++, total => $total, every => 25 }); + $sth_select->execute($user_id); + my ($bug_id) = $sth_select->fetchrow_array; + $sth_update->execute($bug_id, $user_id); + } + } +} + +# +# objects +# + +sub object_columns { + my ($self, $args) = @_; + my ($class, $columns) = @$args{qw(class columns)}; + if ($class->isa('Bugzilla::User')) { + push(@$columns, qw(comment_count creation_ts first_patch_bug_id)); + } +} + +sub object_before_create { + my ($self, $args) = @_; + my ($class, $params) = @$args{qw(class params)}; + if ($class->isa('Bugzilla::User')) { + my ($timestamp) = Bugzilla->dbh->selectrow_array("SELECT NOW()"); + $params->{comment_count} = 0; + $params->{creation_ts} = $timestamp; + } elsif ($class->isa('Bugzilla::Attachment')) { + if ($params->{ispatch} && !Bugzilla->user->first_patch_bug_id) { + Bugzilla->user->first_patch_bug_id($params->{bug}->id); + } + } +} + +# +# Bugzilla::User methods +# + +BEGIN { + *Bugzilla::User::comment_count = \&_comment_count; + *Bugzilla::User::creation_ts = \&_creation_ts; + *Bugzilla::User::update_comment_count = \&_update_comment_count; + *Bugzilla::User::first_patch_bug_id = \&_first_patch_bug_id; + *Bugzilla::User::is_new = \&_is_new; + *Bugzilla::User::creation_age = \&_creation_age; +} + +sub _comment_count { return $_[0]->{comment_count} } +sub _creation_ts { return $_[0]->{creation_ts} } + +sub _update_comment_count { + my $self = shift; + my $dbh = Bugzilla->dbh; + + # no need to update this counter for users which are no longer new + return unless $self->is_new; + + my $id = $self->id; + my ($count) = $dbh->selectrow_array( + "SELECT COUNT(*) FROM longdescs WHERE who=?", + undef, $id + ); + return if $self->{comment_count} == $count; + $dbh->do( + 'UPDATE profiles SET comment_count=? WHERE userid=?', + undef, $count, $id + ); + Bugzilla->memcached->clear({ table => 'profiles', id => $id }); + $self->{comment_count} = $count; +} + +sub _first_patch_bug_id { + my ($self, $bug_id) = @_; + return $self->{first_patch_bug_id} unless defined $bug_id; + + Bugzilla->dbh->do( + 'UPDATE profiles SET first_patch_bug_id=? WHERE userid=?', + undef, $bug_id, $self->id + ); + Bugzilla->memcached->clear({ table => 'profiles', id => $self->id }); + $self->{first_patch_bug_id} = $bug_id; +} + +sub _is_new { + my ($self) = @_; + + if (!exists $self->{is_new}) { + if ($self->in_group('canconfirm')) { + $self->{is_new} = 0; + } else { + $self->{is_new} = ($self->comment_count <= COMMENT_COUNT) + || ($self->creation_age <= PROFILE_AGE); + } + } + + return $self->{is_new}; +} + +sub _creation_age { + my ($self) = @_; + + if (!exists $self->{creation_age}) { + my $age = sprintf("%.0f", (time() - str2time($self->creation_ts)) / 86400); + $self->{creation_age} = $age; + } + + return $self->{creation_age}; +} + +# +# hooks +# + +sub bug_end_of_create { + Bugzilla->user->update_comment_count(); +} + +sub bug_end_of_update { + Bugzilla->user->update_comment_count(); +} + +sub mailer_before_send { + my ($self, $args) = @_; + my $email = $args->{email}; + + my ($bug_id) = ($email->header('Subject') =~ /^[^\d]+(\d+)/); + my $changer_login = $email->header('X-Bugzilla-Who'); + my $changed_fields = $email->header('X-Bugzilla-Changed-Fields'); + + if ($bug_id + && $changer_login + && $changed_fields =~ /attachments.created/) + { + my $changer = Bugzilla::User->new({ name => $changer_login }); + if ($changer + && $changer->first_patch_bug_id + && $changer->first_patch_bug_id == $bug_id) + { + $email->header_set('X-Bugzilla-FirstPatch' => $bug_id); + } + } +} + +sub webservice_user_get { + my ($self, $args) = @_; + my ($webservice, $params, $users) = @$args{qw(webservice params users)}; + + return unless filter_wants($params, 'is_new'); + + foreach my $user (@$users) { + # Most of the time the hash values are XMLRPC::Data objects + my $email = blessed $user->{'email'} ? $user->{'email'}->value : $user->{'email'}; + if ($email) { + my $user_obj = Bugzilla::User->new({ name => $email }); + $user->{'is_new'} = $webservice->type('boolean', $user_obj->is_new ? 1 : 0); + } + } +} + +__PACKAGE__->NAME; diff --git a/extensions/TagNewUsers/template/en/default/hook/bug/comments-user.html.tmpl b/extensions/TagNewUsers/template/en/default/hook/bug/comments-user.html.tmpl new file mode 100644 index 000000000..81cfc776a --- /dev/null +++ b/extensions/TagNewUsers/template/en/default/hook/bug/comments-user.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. + #%] + +[% RETURN UNLESS user.in_group('canconfirm') %] +[% IF comment.author.is_new %] +<span class="new_user" title=" +[%- comment.author.comment_count FILTER html %] comment[% "s" IF comment.author.comment_count != 1 -%] +, created [% +IF comment.author.creation_age == 0 %]today[% +ELSIF comment.author.creation_age > 365 %]more than a year ago[% +ELSE %][% comment.author.creation_age FILTER html %] day[% "s" IF comment.author.creation_age != 1 %] ago[% END %]." + > +(New to [% terms.Bugzilla %]) +</span> +[% END %] +[% IF comment.is_about_attachment + && comment.author.first_patch_bug_id == bug.id + && comment.attachment.ispatch +%] +<span class="new_user">(First Patch)</span> +[% END %] diff --git a/extensions/TagNewUsers/template/en/default/hook/bug/show-header-end.html.tmpl b/extensions/TagNewUsers/template/en/default/hook/bug/show-header-end.html.tmpl new file mode 100644 index 000000000..bff73e963 --- /dev/null +++ b/extensions/TagNewUsers/template/en/default/hook/bug/show-header-end.html.tmpl @@ -0,0 +1,9 @@ +[%# 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. + #%] + +[% style_urls.push('extensions/TagNewUsers/web/style.css') IF user.in_group('canconfirm') %] diff --git a/extensions/TagNewUsers/web/style.css b/extensions/TagNewUsers/web/style.css new file mode 100644 index 000000000..842dca02e --- /dev/null +++ b/extensions/TagNewUsers/web/style.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. */ + +.new_user { + color: #448844; +} diff --git a/extensions/TrackingFlags/Config.pm b/extensions/TrackingFlags/Config.pm new file mode 100644 index 000000000..1854cb9fd --- /dev/null +++ b/extensions/TrackingFlags/Config.pm @@ -0,0 +1,24 @@ +# 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::TrackingFlags; +use strict; + +use constant NAME => 'TrackingFlags'; + +use constant REQUIRED_MODULES => [ + { + package => 'JSON-XS', + module => 'JSON::XS', + version => '2.0' + }, +]; + +use constant OPTIONAL_MODULES => [ +]; + +__PACKAGE__->NAME; diff --git a/extensions/TrackingFlags/Extension.pm b/extensions/TrackingFlags/Extension.pm new file mode 100644 index 000000000..a1b5a0ef6 --- /dev/null +++ b/extensions/TrackingFlags/Extension.pm @@ -0,0 +1,789 @@ +# 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::TrackingFlags; + +use strict; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Extension::TrackingFlags::Constants; +use Bugzilla::Extension::TrackingFlags::Flag; +use Bugzilla::Extension::TrackingFlags::Flag::Bug; +use Bugzilla::Extension::TrackingFlags::Admin; + +use Bugzilla::Bug; +use Bugzilla::Component; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Extension::BMO::Data; +use Bugzilla::Field; +use Bugzilla::Install::Filesystem; +use Bugzilla::Product; + +use JSON; + +our $VERSION = '1'; + +BEGIN { + *Bugzilla::tracking_flags = \&_tracking_flags; + *Bugzilla::tracking_flag_names = \&_tracking_flag_names; +} + +sub _tracking_flags { + return Bugzilla::Extension::TrackingFlags::Flag->get_all(); +} + +sub _tracking_flag_names { + return Bugzilla::Extension::TrackingFlags::Flag->get_all_names(); +} + +sub page_before_template { + my ($self, $args) = @_; + my $page = $args->{'page_id'}; + my $vars = $args->{'vars'}; + + if ($page eq 'tracking_flags_admin_list.html') { + Bugzilla->user->in_group('admin') + || ThrowUserError('auth_failure', + { group => 'admin', + action => 'access', + object => 'administrative_pages' }); + admin_list($vars); + + } elsif ($page eq 'tracking_flags_admin_edit.html') { + Bugzilla->user->in_group('admin') + || ThrowUserError('auth_failure', + { group => 'admin', + action => 'access', + object => 'administrative_pages' }); + admin_edit($vars); + } +} + +sub template_before_process { + my ($self, $args) = @_; + my $file = $args->{'file'}; + my $vars = $args->{'vars'}; + + if ($file eq 'bug/create/create.html.tmpl' + || $file eq 'bug/create/create-winqual.html.tmpl') + { + my $flags = Bugzilla::Extension::TrackingFlags::Flag->match({ + product => $vars->{'product'}->name, + enter_bug => 1, + is_active => 1, + }); + + $vars->{tracking_flags} = $flags; + $vars->{tracking_flags_json} = _flags_to_json($flags); + $vars->{tracking_flag_types} = FLAG_TYPES; + } + elsif ($file eq 'bug/edit.html.tmpl'|| $file eq 'bug/show.xml.tmpl' + || $file eq 'email/bugmail.html.tmpl' || $file eq 'email/bugmail.txt.tmpl') + { + # note: bug/edit.html.tmpl doesn't support multiple bugs + my $bug = exists $vars->{'bugs'} ? $vars->{'bugs'}[0] : $vars->{'bug'}; + + if ($bug && !$bug->{error}) { + my $flags = Bugzilla::Extension::TrackingFlags::Flag->match({ + product => $bug->product, + component => $bug->component, + bug_id => $bug->id, + is_active => 1, + }); + + $vars->{tracking_flags} = $flags; + $vars->{tracking_flags_json} = _flags_to_json($flags); + } + + $vars->{'tracking_flag_types'} = FLAG_TYPES; + } + elsif ($file eq 'list/edit-multiple.html.tmpl' && $vars->{'one_product'}) { + $vars->{'tracking_flags'} = Bugzilla::Extension::TrackingFlags::Flag->match({ + product => $vars->{'one_product'}->name, + is_active => 1 + }); + } +} + +sub _flags_to_json { + my ($flags) = @_; + + my $json = { + flags => {}, + types => [], + comments => {}, + }; + + my %type_map = map { $_->{name} => $_ } @{ FLAG_TYPES() }; + foreach my $flag (@$flags) { + my $flag_type = $flag->flag_type; + + $json->{flags}->{$flag_type}->{$flag->name} = $flag->bug_flag->value; + + if ($type_map{$flag_type}->{collapsed} + && !grep { $_ eq $flag_type } @{ $json->{types} }) + { + push @{ $json->{types} }, $flag_type; + } + + foreach my $value (@{ $flag->values }) { + if (defined($value->comment) && $value->comment ne '') { + $json->{comments}->{$flag->name}->{$value->value} = $value->comment; + } + } + } + + return encode_json($json); +} + +sub db_schema_abstract_schema { + my ($self, $args) = @_; + $args->{'schema'}->{'tracking_flags'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + field_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'fielddefs', + COLUMN => 'id', + DELETE => 'CASCADE' + } + }, + name => { + TYPE => 'varchar(64)', + NOTNULL => 1, + }, + description => { + TYPE => 'varchar(64)', + NOTNULL => 1, + }, + type => { + TYPE => 'varchar(64)', + NOTNULL => 1, + }, + sortkey => { + TYPE => 'INT2', + NOTNULL => 1, + DEFAULT => '0', + }, + enter_bug => { + TYPE => 'BOOLEAN', + NOTNULL => 1, + DEFAULT => 'TRUE', + }, + is_active => { + TYPE => 'BOOLEAN', + NOTNULL => 1, + DEFAULT => 'TRUE', + }, + ], + INDEXES => [ + tracking_flags_idx => { + FIELDS => ['name'], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'tracking_flags_values'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + tracking_flag_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'tracking_flags', + COLUMN => 'id', + DELETE => 'CASCADE', + }, + }, + setter_group_id => { + TYPE => 'INT3', + NOTNULL => 0, + REFERENCES => { + TABLE => 'groups', + COLUMN => 'id', + DELETE => 'SET NULL', + }, + }, + value => { + TYPE => 'varchar(64)', + NOTNULL => 1, + }, + sortkey => { + TYPE => 'INT2', + NOTNULL => 1, + DEFAULT => '0', + }, + enter_bug => { + TYPE => 'BOOLEAN', + NOTNULL => 1, + DEFAULT => 'TRUE', + }, + is_active => { + TYPE => 'BOOLEAN', + NOTNULL => 1, + DEFAULT => 'TRUE', + }, + comment => { + TYPE => 'TEXT', + NOTNULL => 0, + }, + ], + INDEXES => [ + tracking_flags_values_idx => { + FIELDS => ['tracking_flag_id', 'value'], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'tracking_flags_bugs'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + tracking_flag_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'tracking_flags', + COLUMN => 'id', + DELETE => 'CASCADE', + }, + }, + bug_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'bugs', + COLUMN => 'bug_id', + DELETE => 'CASCADE', + }, + }, + value => { + TYPE => 'varchar(64)', + NOTNULL => 1, + }, + ], + INDEXES => [ + tracking_flags_bugs_idx => { + FIELDS => ['tracking_flag_id', 'bug_id'], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'tracking_flags_visibility'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + tracking_flag_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'tracking_flags', + COLUMN => 'id', + DELETE => 'CASCADE', + }, + }, + product_id => { + TYPE => 'INT2', + NOTNULL => 1, + REFERENCES => { + TABLE => 'products', + COLUMN => 'id', + DELETE => 'CASCADE', + }, + }, + component_id => { + TYPE => 'INT2', + NOTNULL => 0, + REFERENCES => { + TABLE => 'components', + COLUMN => 'id', + DELETE => 'CASCADE', + }, + }, + ], + INDEXES => [ + tracking_flags_visibility_idx => { + FIELDS => ['tracking_flag_id', 'product_id', 'component_id'], + TYPE => 'UNIQUE', + }, + ], + }; +} + +sub install_update_db { + my $dbh = Bugzilla->dbh; + + my $fk = $dbh->bz_fk_info('tracking_flags', 'field_id'); + if ($fk and !defined $fk->{DELETE}) { + $fk->{DELETE} = 'CASCADE'; + $dbh->bz_alter_fk('tracking_flags', 'field_id', $fk); + } + + $dbh->bz_add_column( + 'tracking_flags', + 'enter_bug', + { + TYPE => 'BOOLEAN', + NOTNULL => 1, + DEFAULT => 'TRUE', + } + ); + $dbh->bz_add_column( + 'tracking_flags_values', + 'comment', + { + TYPE => 'TEXT', + NOTNULL => 0, + }, + ); +} + +sub install_filesystem { + my ($self, $args) = @_; + my $files = $args->{files}; + my $extensions_dir = bz_locations()->{extensionsdir}; + $files->{"$extensions_dir/TrackingFlags/bin/bulk_flag_clear.pl"} = { + perms => Bugzilla::Install::Filesystem::OWNER_EXECUTE + }; +} + +sub active_custom_fields { + my ($self, $args) = @_; + my $fields = $args->{'fields'}; + my $params = $args->{'params'}; + my $product = $params->{'product'}; + my $component = $params->{'component'}; + + # Create a hash of current fields based on field names + my %field_hash = map { $_->name => $_ } @$$fields; + + my @tracking_flags; + if ($product) { + $params->{'product_id'} = $product->id; + $params->{'component_id'} = $component->id if $component; + $params->{'is_active'} = 1; + @tracking_flags = @{ Bugzilla::Extension::TrackingFlags::Flag->match($params) }; + } + else { + @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; + } + + # Add tracking flags to fields hash replacing if already exists for our + # flag object instead of the usual Field.pm object + foreach my $flag (@tracking_flags) { + $field_hash{$flag->name} = $flag; + } + + @$$fields = sort { $a->sortkey <=> $b->sortkey } values %field_hash; +} + +sub buglist_columns { + my ($self, $args) = @_; + my $columns = $args->{columns}; + my $dbh = Bugzilla->dbh; + my @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; + foreach my $flag (@tracking_flags) { + $columns->{$flag->name} = { + name => "COALESCE(map_" . $flag->name . ".value, '---')", + title => $flag->description + }; + } +} + +sub buglist_column_joins { + my ($self, $args) = @_; + # if there are elements in the tracking_flags array, then they have been + # removed from the query, so we mustn't generate joins + return if scalar @{ $args->{search}->{tracking_flags} }; + + my $column_joins = $args->{'column_joins'}; + my @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; + foreach my $flag (@tracking_flags) { + $column_joins->{$flag->name} = { + as => 'map_' . $flag->name, + table => 'tracking_flags_bugs', + extra => [ 'map_' . $flag->name . '.tracking_flag_id = ' . $flag->flag_id ] + }; + } +} + +sub bug_create_cf_accessors { + my ($self, $args) = @_; + # Create the custom accessors for the flag values + my @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; + foreach my $flag (@tracking_flags) { + my $flag_name = $flag->name; + if (!Bugzilla::Bug->can($flag_name)) { + my $accessor = sub { + my $self = shift; + return $self->{$flag_name} if defined $self->{$flag_name}; + if (!exists $self->{'_tf_bug_values_preloaded'}) { + # preload all values currently set for this bug + my $bug_values + = Bugzilla::Extension::TrackingFlags::Flag::Bug->match({ bug_id => $self->id }); + foreach my $value (@$bug_values) { + $self->{$value->tracking_flag->name} = $value->value; + } + $self->{'_tf_bug_values_preloaded'} = 1; + } + return $self->{$flag_name} ||= '---'; + }; + no strict 'refs'; + *{"Bugzilla::Bug::$flag_name"} = $accessor; + } + if (!Bugzilla::Bug->can("set_$flag_name")) { + my $setter = sub { + my ($self, $value) = @_; + $value = ref($value) eq 'ARRAY' + ? $value->[0] + : $value; + $self->set($flag_name, $value); + }; + no strict 'refs'; + *{"Bugzilla::Bug::set_$flag_name"} = $setter; + } + } +} + +sub bug_editable_bug_fields { + my ($self, $args) = @_; + my $fields = $args->{'fields'}; + my @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; + foreach my $flag (@tracking_flags) { + push(@$fields, $flag->name); + } +} + +sub search_operator_field_override { + my ($self, $args) = @_; + my $operators = $args->{'operators'}; + my @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; + foreach my $flag (@tracking_flags) { + $operators->{$flag->name} = { + _non_changed => sub { + _tracking_flags_search_nonchanged($flag->flag_id, @_) + } + }; + } +} + +sub _tracking_flags_search_nonchanged { + my ($flag_id, $search, $args) = @_; + my ($bugs_table, $chart_id, $joins, $value, $operator) = + @$args{qw(bugs_table chart_id joins value operator)}; + my $dbh = Bugzilla->dbh; + + return if ($operator =~ m/^changed/); + + my $bugs_alias = "tracking_flags_bugs_$chart_id"; + my $flags_alias = "tracking_flags_$chart_id"; + + my $bugs_join = { + table => 'tracking_flags_bugs', + as => $bugs_alias, + from => $bugs_table . ".bug_id", + to => "bug_id", + extra => [$bugs_alias . ".tracking_flag_id = $flag_id"] + }; + + push(@$joins, $bugs_join); + + if ($operator eq 'isempty' or $operator eq 'isnotempty') { + $args->{'full_field'} = "$bugs_alias.value"; + } + else { + $args->{'full_field'} = "COALESCE($bugs_alias.value, '---')"; + } +} + +sub bug_end_of_create { + my ($self, $args) = @_; + my $bug = $args->{'bug'}; + my $timestamp = $args->{'timestamp'}; + my $user = Bugzilla->user; + + my $params = Bugzilla->request_cache->{tracking_flags_create_params}; + return if !$params; + + my $tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->match({ + product => $bug->product, + component => $bug->component, + is_active => 1, + }); + + foreach my $flag (@$tracking_flags) { + next if !$params->{$flag->name}; + foreach my $value (@{$flag->values}) { + next if $value->value ne $params->{$flag->name}; + next if $value->value eq '---'; # do not insert if value is '---', same as empty + if (!$flag->can_set_value($value->value)) { + ThrowUserError('tracking_flags_change_denied', + { flag => $flag, value => $value }); + } + Bugzilla::Extension::TrackingFlags::Flag::Bug->create({ + tracking_flag_id => $flag->flag_id, + bug_id => $bug->id, + value => $value->value, + }); + # Add the name/value pair to the bug object + $bug->{$flag->name} = $value->value; + } + } +} + +sub object_end_of_set_all { + my ($self, $args) = @_; + my $object = $args->{object}; + my $params = $args->{params}; + + return unless $object->isa('Bugzilla::Bug'); + + # Do not filter by product/component as we may be changing those + my $tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->match({ + bug_id => $object->id, + is_active => 1, + }); + + foreach my $flag (@$tracking_flags) { + my $flag_name = $flag->name; + if (exists $params->{$flag_name}) { + my $value = ref($params->{$flag_name}) eq 'ARRAY' + ? $params->{$flag_name}->[0] + : $params->{$flag_name}; + $object->set($flag_name, $value); + } + } +} + +sub bug_check_can_change_field { + my ($self, $args) = @_; + my ($bug, $field, $old_value, $new_value, $priv_results) + = @$args{qw(bug field old_value new_value priv_results)}; + + return if $field !~ /^cf_/ or $old_value eq $new_value; + return unless my $flag = Bugzilla::Extension::TrackingFlags::Flag->new({ name => $field }); + + if ($flag->can_set_value($new_value)) { + push @$priv_results, PRIVILEGES_REQUIRED_NONE; + } + else { + push @$priv_results, PRIVILEGES_REQUIRED_EMPOWERED; + } +} + +sub bug_end_of_update { + my ($self, $args) = @_; + my ($bug, $old_bug, $timestamp, $changes) + = @$args{qw(bug old_bug timestamp changes)}; + my $user = Bugzilla->user; + + # Do not filter by product/component as we may be changing those + my $tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->match({ + bug_id => $bug->id, + is_active => 1, + }); + + my (@flag_changes); + foreach my $flag (@$tracking_flags) { + my $flag_name = $flag->name; + my $new_value = $bug->$flag_name; + my $old_value = $old_bug->$flag_name; + + if ($new_value ne $old_value) { + # Do not allow if the user cannot set the old value or the new value + if (!$flag->can_set_value($new_value)) { + ThrowUserError('tracking_flags_change_denied', + { flag => $flag, value => $new_value }); + } + push(@flag_changes, { flag => $flag, + added => $new_value, + removed => $old_value }); + } + } + + foreach my $change (@flag_changes) { + my $flag = $change->{'flag'}; + my $added = $change->{'added'}; + my $removed = $change->{'removed'}; + + if ($added eq '---') { + $flag->bug_flag->remove_from_db(); + } + elsif ($removed eq '---') { + Bugzilla::Extension::TrackingFlags::Flag::Bug->create({ + tracking_flag_id => $flag->flag_id, + bug_id => $bug->id, + value => $added, + }); + } + else { + $flag->bug_flag->set_value($added); + $flag->bug_flag->update($timestamp); + } + + $changes->{$flag->name} = [ $removed, $added ]; + LogActivityEntry($bug->id, $flag->name, $removed, $added, $user->id, $timestamp); + + # Update the name/value pair in the bug object + $bug->{$flag->name} = $added; + } +} + +sub bug_end_of_create_validators { + my ($self, $args) = @_; + my $params = $args->{params}; + + # We need to stash away any params that are setting/updating tracking + # flags early on. Otherwise set_all or insert_create_data will complain. + my @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; + my $cache = Bugzilla->request_cache->{tracking_flags_create_params} ||= {}; + foreach my $flag (@tracking_flags) { + my $flag_name = $flag->name; + if (defined $params->{$flag_name}) { + $cache->{$flag_name} = delete $params->{$flag_name}; + } + } +} + +sub mailer_before_send { + my ($self, $args) = @_; + my $email = $args->{email}; + + # Add X-Bugzilla-Tracking header or add to it + # if already exists + if ($email->header('X-Bugzilla-ID')) { + my $bug_id = $email->header('X-Bugzilla-ID'); + + my $tracking_flags + = Bugzilla::Extension::TrackingFlags::Flag->match({ bug_id => $bug_id }); + + my @set_values = (); + foreach my $flag (@$tracking_flags) { + next if $flag->bug_flag->value eq '---'; + push(@set_values, $flag->description . ":" . $flag->bug_flag->value); + } + + if (@set_values) { + my $set_values_string = join(' ', @set_values); + if ($email->header('X-Bugzilla-Tracking')) { + $set_values_string = $email->header('X-Bugzilla-Tracking') . + " " . $set_values_string; + } + $email->header_set('X-Bugzilla-Tracking' => $set_values_string); + } + } +} + +# Purpose: generically handle generating pretty blocking/status "flags" from +# custom field names. +sub quicksearch_map { + my ($self, $args) = @_; + my $map = $args->{'map'}; + + foreach my $name (keys %$map) { + if ($name =~ /^cf_(blocking|tracking|status)_([a-z]+)?(\d+)?$/) { + my $type = $1; + my $product = $2; + my $version = $3; + + if ($version) { + $version = join('.', split(//, $version)); + } + + my $pretty_name = $type; + if ($product) { + $pretty_name .= "-" . $product; + } + if ($version) { + $pretty_name .= $version; + } + + $map->{$pretty_name} = $name; + } + } +} + +sub reorg_move_component { + my ($self, $args) = @_; + my $new_product = $args->{new_product}; + my $component = $args->{component}; + + Bugzilla->dbh->do( + "UPDATE tracking_flags_visibility SET product_id=? WHERE component_id=?", + undef, + $new_product->id, $component->id, + ); +} + +sub sanitycheck_check { + my ($self, $args) = @_; + my $status = $args->{status}; + + $status->('tracking_flags_check'); + + my ($count) = Bugzilla->dbh->selectrow_array(" + SELECT COUNT(*) + FROM tracking_flags_visibility + INNER JOIN components ON components.id = tracking_flags_visibility.component_id + WHERE tracking_flags_visibility.product_id <> components.product_id + "); + if ($count) { + $status->('tracking_flags_alert', undef, 'alert'); + $status->('tracking_flags_repair'); + } +} + +sub sanitycheck_repair { + my ($self, $args) = @_; + return unless Bugzilla->cgi->param('tracking_flags_repair'); + + my $status = $args->{'status'}; + my $dbh = Bugzilla->dbh; + $status->('tracking_flags_repairing'); + + my $rows = $dbh->selectall_arrayref(" + SELECT DISTINCT tracking_flags_visibility.product_id AS bad_product_id, + components.product_id AS good_product_id, + tracking_flags_visibility.component_id + FROM tracking_flags_visibility + INNER JOIN components ON components.id = tracking_flags_visibility.component_id + WHERE tracking_flags_visibility.product_id <> components.product_id + ", + { Slice => {} } + ); + foreach my $row (@$rows) { + $dbh->do(" + UPDATE tracking_flags_visibility + SET product_id=? + WHERE product_id=? AND component_id=? + ", undef, + $row->{good_product_id}, + $row->{bad_product_id}, + $row->{component_id}, + ); + } +} + +__PACKAGE__->NAME; diff --git a/extensions/TrackingFlags/bin/bulk_flag_clear.pl b/extensions/TrackingFlags/bin/bulk_flag_clear.pl new file mode 100755 index 000000000..1eff355fe --- /dev/null +++ b/extensions/TrackingFlags/bin/bulk_flag_clear.pl @@ -0,0 +1,137 @@ +#!/usr/bin/perl -w +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +use strict; +use warnings; + +use FindBin '$RealBin'; +use lib "$RealBin/../../.."; +use lib "$RealBin/../../../lib"; +use lib "$RealBin/../lib"; + +BEGIN { + use Bugzilla; + Bugzilla->extensions; +} + +use Bugzilla::Constants; +use Bugzilla::Extension::TrackingFlags::Flag; +use Bugzilla::Extension::TrackingFlags::Flag::Bug; +use Bugzilla::User; + +use Getopt::Long; + +Bugzilla->usage_mode(USAGE_MODE_CMDLINE); + +my $config = {}; +GetOptions( + $config, + "trace=i", + "update_db", + "flag=s", + "modified_before=s", + "modified_after=s", + "value=s" +) or exit; +unless ($config->{flag} + && ($config->{modified_before} + || $config->{modified_after} + || $config->{value})) +{ + die <<EOF; +$0 + clears tracking flags matching the specified criteria. + the last-modified will be updated, however bugmail will not be generated. + +SYNTAX + $0 --flag <flag> (conditions) [--update_db] + +CONDITIONS + --modified_before <datetime> bug last-modified before <datetime> + --modified_after <datetime> bug last-modified after <datetime> + --value <flag value> flag = <flag value> + +OPTIONS + --update_db : by default only the impacted bugs will be listed. pass this + switch to update the database. +EOF +} + +# build sql + +my (@where, @values); + +my $flag = Bugzilla::Extension::TrackingFlags::Flag->check({ name => $config->{flag} }); +push @where, 'tracking_flags_bugs.tracking_flag_id = ?'; +push @values, $flag->flag_id; + +if ($config->{modified_before}) { + push @where, 'bugs.delta_ts < ?'; + push @values, $config->{modified_before}; +} + +if ($config->{modified_after}) { + push @where, 'bugs.delta_ts > ?'; + push @values, $config->{modified_after}; +} + +if ($config->{value}) { + push @where, 'tracking_flags_bugs.value = ?'; + push @values, $config->{value}; +} + +my $sql = " + SELECT tracking_flags_bugs.bug_id + FROM tracking_flags_bugs + INNER JOIN bugs ON bugs.bug_id = tracking_flags_bugs.bug_id + WHERE (" . join(") AND (", @where) . ") + ORDER BY tracking_flags_bugs.bug_id +"; + +# execute query + +my $dbh = Bugzilla->dbh; +$dbh->{TraceLevel} = $config->{trace} if $config->{trace}; + +my $bug_ids = $dbh->selectcol_arrayref($sql, undef, @values); + +if (!@$bug_ids) { + die "no matching bugs found\n"; +} + +if (!$config->{update_db}) { + print "bugs found: ", scalar(@$bug_ids), "\n\n", join(',', @$bug_ids), "\n\n"; + print "--update_db not provided, no changes made to the database\n"; + exit; +} + +# update bugs + +my $nobody = Bugzilla::User->check({ name => 'nobody@mozilla.org' }); +# put our nobody user into all groups to avoid permissions issues +$nobody->{groups} = [Bugzilla::Group->get_all]; +Bugzilla->set_user($nobody); + +foreach my $bug_id (@$bug_ids) { + print "updating bug $bug_id\n"; + $dbh->bz_start_transaction; + + # update the bug + # this will deal with history for us but not send bugmail + my $bug = Bugzilla::Bug->check({ id => $bug_id }); + $bug->set_all({ $flag->name => '---' }); + $bug->update; + + # update lastdiffed to skip bugmail for this change + $dbh->do( + "UPDATE bugs SET lastdiffed = delta_ts WHERE bug_id = ?", + undef, + $bug->id + ); + $dbh->bz_commit_transaction; +} diff --git a/extensions/TrackingFlags/bin/migrate_tracking_flags.pl b/extensions/TrackingFlags/bin/migrate_tracking_flags.pl new file mode 100755 index 000000000..06b3596c4 --- /dev/null +++ b/extensions/TrackingFlags/bin/migrate_tracking_flags.pl @@ -0,0 +1,316 @@ +#!/usr/bin/perl -w +# 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. + +# Migrate old custom field based tracking flags to the new +# table based tracking flags + +use strict; +use warnings; + +use FindBin '$RealBin'; +use lib "$RealBin/../../.."; +use lib "$RealBin/../../../lib"; +use lib "$RealBin/../lib"; + +BEGIN { + use Bugzilla; + Bugzilla->extensions; +} + +use Bugzilla::Constants; +use Bugzilla::Field; +use Bugzilla::Product; +use Bugzilla::Component; +use Bugzilla::Extension::BMO::Data; +use Bugzilla::Install::Util qw(indicate_progress); + +use Bugzilla::Extension::TrackingFlags::Constants; +use Bugzilla::Extension::TrackingFlags::Flag; +use Bugzilla::Extension::TrackingFlags::Flag::Bug; +use Bugzilla::Extension::TrackingFlags::Flag::Value; +use Bugzilla::Extension::TrackingFlags::Flag::Visibility; + +use Getopt::Long; +use Data::Dumper; + +Bugzilla->usage_mode(USAGE_MODE_CMDLINE); + +my ($dry_run, $trace) = (0, 0); +GetOptions( + "dry-run" => \$dry_run, + "trace" => \$trace, +) or exit; + +my $dbh = Bugzilla->dbh; + +$dbh->{TraceLevel} = 1 if $trace; + +my %product_cache; +my %component_cache; + +sub migrate_flag_visibility { + my ($new_flag, $products) = @_; + + # Create product/component visibility + foreach my $prod_name (keys %$products) { + $product_cache{$prod_name} ||= Bugzilla::Product->new({ name => $prod_name }); + if (!$product_cache{$prod_name}) { + warn "No such product $prod_name\n"; + next; + } + + # If no components specified then we do Product/__any__ + # otherwise, we enter an entry for each Product/Component + my $components = $products->{$prod_name}; + if (!@$components) { + Bugzilla::Extension::TrackingFlags::Flag::Visibility->create({ + tracking_flag_id => $new_flag->flag_id, + product_id => $product_cache{$prod_name}->id, + component_id => undef + }); + } + else { + foreach my $comp_name (@$components) { + my $comp_matches = []; + # If the component is a regexp, we need to find all components + # matching the regex and insert each individually + if (ref $comp_name eq 'Regexp') { + my $comp_re = $comp_name; + $comp_re =~ s/\?\-xism://; + $comp_re =~ s/\(//; + $comp_re =~ s/\)//; + $comp_matches = $dbh->selectcol_arrayref( + 'SELECT components.name FROM components + WHERE components.product_id = ? + AND ' . $dbh->sql_regexp('components.name', $dbh->quote($comp_re)) . ' + ORDER BY components.name', + undef, + $product_cache{$prod_name}->id); + } + else { + $comp_matches = [ $comp_name ]; + } + + foreach my $comp_match (@$comp_matches) { + $component_cache{"${prod_name}:${comp_match}"} + ||= Bugzilla::Component->new({ name => $comp_match, + product => $product_cache{$prod_name} }); + if (!$component_cache{"${prod_name}:${comp_match}"}) { + warn "No such product $prod_name and component $comp_match\n"; + next; + } + + Bugzilla::Extension::TrackingFlags::Flag::Visibility->create({ + tracking_flag_id => $new_flag->flag_id, + product_id => $product_cache{$prod_name}->id, + component_id => $component_cache{"${prod_name}:${comp_match}"}->id, + }); + } + } + } + } +} + +sub migrate_flag_values { + my ($new_flag, $field) = @_; + + print "Migrating flag values..."; + + my %blocking_trusted_requesters + = %{$Bugzilla::Extension::BMO::Data::blocking_trusted_requesters}; + my %blocking_trusted_setters + = %{$Bugzilla::Extension::BMO::Data::blocking_trusted_setters}; + my %status_trusted_wanters + = %{$Bugzilla::Extension::BMO::Data::status_trusted_wanters}; + my %status_trusted_setters + = %{$Bugzilla::Extension::BMO::Data::status_trusted_setters}; + + my %group_cache; + foreach my $value (@{ $field->legal_values }) { + my $group_name = 'everyone'; + + if ($field->name =~ /^cf_(blocking|tracking)_/) { + if ($value->name ne '---' && $value->name !~ '\?$') { + $group_name = get_setter_group($field->name, \%blocking_trusted_setters); + } + if ($value->name eq '?') { + $group_name = get_setter_group($field->name, \%blocking_trusted_requesters); + } + } elsif ($field->name =~ /^cf_status_/) { + if ($value->name eq 'wanted') { + $group_name = get_setter_group($field->name, \%status_trusted_wanters); + } elsif ($value->name ne '---' && $value->name ne '?') { + $group_name = get_setter_group($field->name, \%status_trusted_setters); + } + } + + $group_cache{$group_name} ||= Bugzilla::Group->new({ name => $group_name }); + $group_cache{$group_name} || die "Setter group '$group_name' does not exist"; + + Bugzilla::Extension::TrackingFlags::Flag::Value->create({ + tracking_flag_id => $new_flag->flag_id, + value => $value->name, + setter_group_id => $group_cache{$group_name}->id, + sortkey => $value->sortkey, + is_active => $value->is_active + }); + } + + print "done.\n"; +} + +sub get_setter_group { + my ($field, $trusted) = @_; + my $setter_group = $trusted->{'_default'} || ""; + foreach my $dfield (keys %$trusted) { + if ($field =~ $dfield) { + $setter_group = $trusted->{$dfield}; + } + } + return $setter_group; +} + +sub migrate_flag_bugs { + my ($new_flag, $field) = @_; + + print "Migrating bug values..."; + + my $bugs = $dbh->selectall_arrayref("SELECT bug_id, " . $field->name . " + FROM bugs + WHERE " . $field->name . " != '---' + ORDER BY bug_id"); + local $| = 1; + my $count = 1; + my $total = scalar @$bugs; + foreach my $row (@$bugs) { + my ($id, $value) = @$row; + indicate_progress({ current => $count++, total => $total, every => 25 }); + Bugzilla::Extension::TrackingFlags::Flag::Bug->create({ + tracking_flag_id => $new_flag->flag_id, + bug_id => $id, + value => $value, + + }); + } + + print "done.\n"; +} + +sub migrate_flag_activity { + my ($new_flag, $field) = @_; + + print "Migating flag activity..."; + + my $new_field = Bugzilla::Field->new({ name => $new_flag->name }); + $dbh->do("UPDATE bugs_activity SET fieldid = ? WHERE fieldid = ?", + undef, $new_field->id, $field->id); + + print "done.\n"; +} + +sub do_migration { + my $bmo_tracking_flags = $Bugzilla::Extension::BMO::Data::cf_visible_in_products; + my $bmo_project_flags = $Bugzilla::Extension::BMO::Data::cf_project_flags; + my $bmo_disabled_flags = $Bugzilla::Extension::BMO::Data::cf_disabled_flags; + + my $fields = Bugzilla::Field->match({ custom => 1, + type => FIELD_TYPE_SINGLE_SELECT }); + + my @drop_columns; + foreach my $field (@$fields) { + next if $field->name !~ /^cf_(blocking|tracking|status)_/; + + foreach my $field_re (keys %$bmo_tracking_flags) { + next if $field->name !~ $field_re; + + # Create the new tracking flag if not exists + my $new_flag + = Bugzilla::Extension::TrackingFlags::Flag->new({ name => $field->name }); + + next if $new_flag; + + print "----------------------------------\n" . + "Migrating custom tracking field " . $field->name . "...\n"; + + my $new_flag_name = $field->name . "_new"; # Temporary name til we delete the old + + my $type = grep($field->name =~ $_, @$bmo_project_flags) + ? 'project' + : 'tracking'; + + my $is_active = grep($_ eq $field->name, @$bmo_disabled_flags) ? 0 : 1; + + $new_flag = Bugzilla::Extension::TrackingFlags::Flag->create({ + name => $new_flag_name, + description => $field->description, + type => $type, + sortkey => $field->sortkey, + is_active => $is_active, + enter_bug => $field->enter_bug, + }); + + migrate_flag_visibility($new_flag, $bmo_tracking_flags->{$field_re}); + + migrate_flag_values($new_flag, $field); + + migrate_flag_bugs($new_flag, $field); + + migrate_flag_activity($new_flag, $field); + + push(@drop_columns, $field->name); + + # Remove the old flag entry from fielddefs + $dbh->do("DELETE FROM fielddefs WHERE name = ?", + undef, $field->name); + + # Rename the new flag + $dbh->do("UPDATE fielddefs SET name = ? WHERE name = ?", + undef, $field->name, $new_flag_name); + + $new_flag->set_name($field->name); + $new_flag->update; + + # more than one regex could possibly match but we only want the first one + last; + } + } + + # Drop each custom flag's value table and the column from the bz schema object + if (!$dry_run && @drop_columns) { + print "Dropping value tables and updating bz schema object...\n"; + + foreach my $column (@drop_columns) { + # Drop the values table + $dbh->bz_drop_table($column); + + # Drop the bugs table column from the bz schema object + $dbh->_bz_real_schema->delete_column('bugs', $column); + $dbh->_bz_store_real_schema; + } + + # Do the one alter table to drop all columns at once + $dbh->do("ALTER TABLE bugs DROP COLUMN " . join(", DROP COLUMN ", @drop_columns)); + } +} + +# Start Main + +eval { + if ($dry_run) { + print "** dry run : no changes to the database will be made **\n"; + $dbh->bz_start_transaction(); + } + print "Starting migration...\n"; + do_migration(); + $dbh->bz_rollback_transaction() if $dry_run; + print "All done!\n"; +}; +if ($@) { + $dbh->bz_rollback_transaction() if $dry_run; + die "$@" if $@; +} diff --git a/extensions/TrackingFlags/lib/Admin.pm b/extensions/TrackingFlags/lib/Admin.pm new file mode 100644 index 000000000..1bae18ef8 --- /dev/null +++ b/extensions/TrackingFlags/lib/Admin.pm @@ -0,0 +1,446 @@ +# 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::TrackingFlags::Admin; + +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Component; +use Bugzilla::Error; +use Bugzilla::Group; +use Bugzilla::Product; +use Bugzilla::Util qw(trim detaint_natural); + +use Bugzilla::Extension::TrackingFlags::Constants; +use Bugzilla::Extension::TrackingFlags::Flag; +use Bugzilla::Extension::TrackingFlags::Flag::Bug; +use Bugzilla::Extension::TrackingFlags::Flag::Value; +use Bugzilla::Extension::TrackingFlags::Flag::Visibility; + +use JSON; +use Scalar::Util qw(blessed); + +use base qw(Exporter); +our @EXPORT = qw( + admin_list + admin_edit +); + +# +# page loading +# + +sub admin_list { + my ($vars) = @_; + $vars->{show_bug_counts} = Bugzilla->input_params->{show_bug_counts}; + $vars->{flags} = [ Bugzilla::Extension::TrackingFlags::Flag->get_all() ]; +} + +sub admin_edit { + my ($vars, $page) = @_; + my $input = Bugzilla->input_params; + + $vars->{groups} = _groups_to_json(); + $vars->{mode} = $input->{mode} || 'new'; + $vars->{flag_id} = $input->{flag_id} || 0; + $vars->{tracking_flag_types} = FLAG_TYPES; + + if ($input->{delete}) { + my $flag = Bugzilla::Extension::TrackingFlags::Flag->new($vars->{flag_id}) + || ThrowCodeError('tracking_flags_invalid_item_id', { item => 'flag', id => $vars->{flag_id} }); + $flag->remove_from_db(); + + $vars->{message} = 'tracking_flag_deleted'; + $vars->{flag} = $flag; + $vars->{flags} = [ Bugzilla::Extension::TrackingFlags::Flag->get_all() ]; + + print Bugzilla->cgi->header; + my $template = Bugzilla->template; + $template->process('pages/tracking_flags_admin_list.html.tmpl', $vars) + || ThrowTemplateError($template->error()); + exit; + + } elsif ($input->{save}) { + # save + + my ($flag, $values, $visibilities) = _load_from_input($input, $vars); + _validate($flag, $values, $visibilities); + my $flag_obj = _update_db($flag, $values, $visibilities); + + $vars->{flag} = $flag_obj; + $vars->{values} = _flag_values_to_json($values); + $vars->{visibility} = _flag_visibility_to_json($visibilities); + + if ($vars->{mode} eq 'new') { + $vars->{message} = 'tracking_flag_created'; + } else { + $vars->{message} = 'tracking_flag_updated'; + } + $vars->{mode} = 'edit'; + + } else { + # initial load + + if ($vars->{mode} eq 'edit') { + # edit - straight load + my $flag = Bugzilla::Extension::TrackingFlags::Flag->new($vars->{flag_id}) + || ThrowCodeError('tracking_flags_invalid_item_id', { item => 'flag', id => $vars->{flag_id} }); + $vars->{flag} = $flag; + $vars->{values} = _flag_values_to_json($flag->values); + $vars->{visibility} = _flag_visibility_to_json($flag->visibility); + $vars->{can_delete} = !$flag->bug_count; + + } elsif ($vars->{mode} eq 'copy') { + # copy - load the source flag + $vars->{mode} = 'new'; + my $flag = Bugzilla::Extension::TrackingFlags::Flag->new($input->{copy_from}) + || ThrowCodeError('tracking_flags_invalid_item_id', { item => 'flag', id => $vars->{copy_from} }); + + # increment the number at the end of the name and description + if ($flag->name =~ /^(\D+)(\d+)$/) { + $flag->set_name("$1" . ($2 + 1)); + } + if ($flag->description =~ /^(\D+)(\d+)$/) { + $flag->set_description("$1" . ($2 + 1)); + } + $flag->set_sortkey(_next_unique_sortkey($flag->sortkey)); + $flag->set_type($flag->flag_type); + $flag->set_enter_bug($flag->enter_bug); + # always default new flags as active, even when copying an inactive one + $flag->set_is_active(1); + + $vars->{flag} = $flag; + $vars->{values} = _flag_values_to_json($flag->values, 1); + $vars->{visibility} = _flag_visibility_to_json($flag->visibility, 1); + $vars->{can_delete} = 0; + + } else { + $vars->{mode} = 'new'; + $vars->{flag} = { + sortkey => 0, + enter_bug => 1, + is_active => 1, + }; + $vars->{values} = _flag_values_to_json([ + { + id => 0, + value => '---', + setter_group_id => '', + is_active => 1, + comment => '', + }, + ]); + $vars->{visibility} = ''; + $vars->{can_delete} = 0; + } + } +} + +sub _load_from_input { + my ($input, $vars) = @_; + + # flag + + my $flag = { + id => ($input->{mode} eq 'edit' ? $input->{flag_id} : 0), + name => trim($input->{flag_name} || ''), + description => trim($input->{flag_desc} || ''), + sortkey => $input->{flag_sort} || 0, + type => trim($input->{flag_type} || ''), + enter_bug => $input->{flag_enter_bug} ? 1 : 0, + is_active => $input->{flag_active} ? 1 : 0, + }; + detaint_natural($flag->{id}); + detaint_natural($flag->{sortkey}); + detaint_natural($flag->{enter_bug}); + detaint_natural($flag->{is_active}); + + # values + + my $values = decode_json($input->{values} || '[]'); + foreach my $value (@$values) { + $value->{value} = '' unless exists $value->{value} && defined $value->{value}; + $value->{setter_group_id} = '' unless $value->{setter_group_id}; + $value->{is_active} = $value->{is_active} ? 1 : 0; + } + + # vibility + + my $visibilities = decode_json($input->{visibility} || '[]'); + foreach my $visibility (@$visibilities) { + $visibility->{product} = '' unless exists $visibility->{product} && defined $visibility->{product}; + $visibility->{component} = '' unless exists $visibility->{component} && defined $visibility->{component}; + } + + return ($flag, $values, $visibilities); +} + +sub _next_unique_sortkey { + my ($sortkey) = @_; + + my %current; + foreach my $flag (Bugzilla::Extension::TrackingFlags::Flag->get_all()) { + $current{$flag->sortkey} = 1; + } + + $sortkey += 5; + $sortkey += 5 while exists $current{$sortkey}; + return $sortkey; +} + +# +# validation +# + +sub _validate { + my ($flag, $values, $visibilities) = @_; + + # flag + + my @missing; + push @missing, 'Field Name' if $flag->{name} eq ''; + push @missing, 'Field Description' if $flag->{description} eq ''; + push @missing, 'Field Sort Key' if $flag->{sortkey} eq ''; + scalar(@missing) + && ThrowUserError('tracking_flags_missing_mandatory', { fields => \@missing }); + + $flag->{name} =~ /^cf_/ + || ThrowUserError('tracking_flags_cf_prefix'); + + if ($flag->{id}) { + my $old_flag = Bugzilla::Extension::TrackingFlags::Flag->new($flag->{id}) + || ThrowCodeError('tracking_flags_invalid_item_id', { item => 'flag', id => $flag->{id} }); + if ($flag->{name} ne $old_flag->name) { + Bugzilla::Field->new({ name => $flag->{name} }) + && ThrowUserError('field_already_exists', { field => { name => $flag->{name} }}); + } + } else { + Bugzilla::Field->new({ name => $flag->{name} }) + && ThrowUserError('field_already_exists', { field => { name => $flag->{name} }}); + } + + # values + + scalar(@$values) + || ThrowUserError('tracking_flags_missing_values'); + + my %seen; + foreach my $value (@$values) { + my $v = $value->{value}; + + $v eq '' + && ThrowUserError('tracking_flags_missing_value'); + + exists $seen{$v} + && ThrowUserError('tracking_flags_duplicate_value', { value => $v }); + $seen{$v} = 1; + + push @missing, "Setter for $v" if !$value->{setter_group_id}; + } + scalar(@missing) + && ThrowUserError('tracking_flags_missing_mandatory', { fields => \@missing }); + + # visibility + + scalar(@$visibilities) + || ThrowUserError('tracking_flags_missing_visibility'); + + %seen = (); + foreach my $visibility (@$visibilities) { + my $name = $visibility->{product} . ':' . $visibility->{component}; + + exists $seen{$name} + && ThrowUserError('tracking_flags_duplicate_visibility', { name => $name }); + + $visibility->{product_obj} = Bugzilla::Product->new({ name => $visibility->{product} }) + || ThrowCodeError('tracking_flags_invalid_product', { product => $visibility->{product} }); + + if ($visibility->{component} ne '') { + $visibility->{component_obj} = Bugzilla::Component->new({ product => $visibility->{product_obj}, + name => $visibility->{component} }) + || ThrowCodeError('tracking_flags_invalid_component', { + product => $visibility->{product}, + component_name => $visibility->{component}, + }); + } + } + +} + +# +# database updating +# + +sub _update_db { + my ($flag, $values, $visibilities) = @_; + my $dbh = Bugzilla->dbh; + + $dbh->bz_start_transaction(); + my $flag_obj = _update_db_flag($flag); + _update_db_values($flag_obj, $flag, $values); + _update_db_visibility($flag_obj, $flag, $visibilities); + $dbh->bz_commit_transaction(); + + return $flag_obj; +} + +sub _update_db_flag { + my ($flag) = @_; + + my $object_set = { + name => $flag->{name}, + description => $flag->{description}, + sortkey => $flag->{sortkey}, + type => $flag->{type}, + enter_bug => $flag->{enter_bug}, + is_active => $flag->{is_active}, + }; + + my $flag_obj; + if ($flag->{id}) { + # update existing flag + $flag_obj = Bugzilla::Extension::TrackingFlags::Flag->new($flag->{id}) + || ThrowCodeError('tracking_flags_invalid_item_id', { item => 'flag', id => $flag->{id} }); + $flag_obj->set_all($object_set); + $flag_obj->update(); + + } else { + # create new flag + $flag_obj = Bugzilla::Extension::TrackingFlags::Flag->create($object_set); + } + + return $flag_obj; +} + +sub _update_db_values { + my ($flag_obj, $flag, $values) = @_; + + # delete + foreach my $current_value (@{ $flag_obj->values }) { + if (!grep { $_->{id} == $current_value->id } @$values) { + $current_value->remove_from_db(); + } + } + + # add/update + my $sortkey = 0; + foreach my $value (@{ $values }) { + $sortkey += 10; + + my $object_set = { + value => $value->{value}, + setter_group_id => $value->{setter_group_id}, + is_active => $value->{is_active}, + sortkey => $sortkey, + comment => $value->{comment}, + }; + + if ($value->{id}) { + my $value_obj = Bugzilla::Extension::TrackingFlags::Flag::Value->new($value->{id}) + || ThrowCodeError('tracking_flags_invalid_item_id', { item => 'flag value', id => $flag->{id} }); + my $old_value = $value_obj->value; + $value_obj->set_all($object_set); + $value_obj->update(); + Bugzilla::Extension::TrackingFlags::Flag::Bug->update_all_values({ + value_obj => $value_obj, + old_value => $old_value, + new_value => $value_obj->value, + }); + } else { + $object_set->{tracking_flag_id} = $flag_obj->flag_id; + Bugzilla::Extension::TrackingFlags::Flag::Value->create($object_set); + } + } +} + +sub _update_db_visibility { + my ($flag_obj, $flag, $visibilities) = @_; + + # delete + foreach my $current_visibility (@{ $flag_obj->visibility }) { + if (!grep { $_->{id} == $current_visibility->id } @$visibilities) { + $current_visibility->remove_from_db(); + } + } + + # add + foreach my $visibility (@{ $visibilities }) { + next if $visibility->{id}; + Bugzilla::Extension::TrackingFlags::Flag::Visibility->create({ + tracking_flag_id => $flag_obj->flag_id, + product_id => $visibility->{product_obj}->id, + component_id => $visibility->{component} ? $visibility->{component_obj}->id : undef, + }); + } +} + +# +# serialisation +# + +sub _groups_to_json { + my @data; + foreach my $group (sort { $a->name cmp $b->name } Bugzilla::Group->get_all()) { + push @data, { + id => $group->id, + name => $group->name, + }; + } + return encode_json(\@data); +} + +sub _flag_values_to_json { + my ($values, $is_copy) = @_; + # setting is_copy will set the id's to zero, to force new values rather + # than editing existing ones + my @data; + foreach my $value (@$values) { + push @data, { + id => $is_copy ? 0 : $value->{id}, + value => $value->{value}, + setter_group_id => $value->{setter_group_id}, + is_active => $value->{is_active} ? JSON::true : JSON::false, + comment => $value->{comment} // '', + }; + } + return encode_json(\@data); +} + +sub _flag_visibility_to_json { + my ($visibilities, $is_copy) = @_; + # setting is_copy will set the id's to zero, to force new visibilites + # rather than editing existing ones + my @data; + + foreach my $visibility (@$visibilities) { + my $product = exists $visibility->{product_id} + ? $visibility->product->name + : $visibility->{product}; + my $component; + if (exists $visibility->{component_id} && $visibility->{component_id}) { + $component = $visibility->component->name; + } elsif (exists $visibility->{component}) { + $component = $visibility->{component}; + } else { + $component = undef; + } + push @data, { + id => $is_copy ? 0 : $visibility->{id}, + product => $product, + component => $component, + }; + } + @data = sort { + lc($a->{product}) cmp lc($b->{product}) + || lc($a->{component}) cmp lc($b->{component}) + } @data; + return encode_json(\@data); +} + +1; diff --git a/extensions/TrackingFlags/lib/Constants.pm b/extensions/TrackingFlags/lib/Constants.pm new file mode 100644 index 000000000..0b1ae3a1a --- /dev/null +++ b/extensions/TrackingFlags/lib/Constants.pm @@ -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. + +package Bugzilla::Extension::TrackingFlags::Constants; + +use strict; +use base qw(Exporter); + +our @EXPORT = qw( + FLAG_TYPES +); + +sub FLAG_TYPES { + my @flag_types = ( + { + name => 'project', + description => 'Project Flags', + collapsed => 0, + sortkey => 0 + }, + { + name => 'tracking', + description => 'Tracking Flags', + collapsed => 1, + sortkey => 1 + }, + { + name => 'blocking', + description => 'Blocking Flags', + collapsed => 1, + sortkey => 2 + }, + ); + return [ sort { $a->{'sortkey'} <=> $b->{'sortkey'} } @flag_types ]; +} + +1; diff --git a/extensions/TrackingFlags/lib/Flag.pm b/extensions/TrackingFlags/lib/Flag.pm new file mode 100644 index 000000000..3ae7a937e --- /dev/null +++ b/extensions/TrackingFlags/lib/Flag.pm @@ -0,0 +1,467 @@ +# 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::TrackingFlags::Flag; + +use base qw(Bugzilla::Object); + +use strict; +use warnings; + +use Bugzilla::Error; +use Bugzilla::Constants; +use Bugzilla::Util qw(detaint_natural trim); +use Bugzilla::Config qw(SetParam write_params); + +use Bugzilla::Extension::TrackingFlags::Constants; +use Bugzilla::Extension::TrackingFlags::Flag::Bug; +use Bugzilla::Extension::TrackingFlags::Flag::Value; +use Bugzilla::Extension::TrackingFlags::Flag::Visibility; + +############################### +#### Initialization #### +############################### + +use constant DB_TABLE => 'tracking_flags'; + +use constant DB_COLUMNS => qw( + id + field_id + name + description + type + sortkey + enter_bug + is_active +); + +use constant LIST_ORDER => 'sortkey'; + +use constant UPDATE_COLUMNS => qw( + name + description + type + sortkey + enter_bug + is_active +); + +use constant VALIDATORS => { + name => \&_check_name, + description => \&_check_description, + type => \&_check_type, + sortkey => \&_check_sortkey, + enter_bug => \&Bugzilla::Object::check_boolean, + is_active => \&Bugzilla::Object::check_boolean, +}; + +use constant UPDATE_VALIDATORS => { + name => \&_check_name, + description => \&_check_description, + type => \&_check_type, + sortkey => \&_check_sortkey, + enter_bug => \&Bugzilla::Object::check_boolean, + is_active => \&Bugzilla::Object::check_boolean, +}; + +############################### +#### Methods #### +############################### + +sub new { + my $class = shift; + my $param = shift; + my $cache = Bugzilla->request_cache; + + if (!ref $param + && exists $cache->{'tracking_flags'} + && exists $cache->{'tracking_flags'}->{$param}) + { + return $cache->{'tracking_flags'}->{$param}; + } + + return $class->SUPER::new($param); +} + +sub create { + my $class = shift; + my $params = shift; + my $dbh = Bugzilla->dbh; + my $flag; + + # Disable bug updates temporarily to avoid conflicts. + SetParam('disable_bug_updates', 1); + write_params(); + + eval { + $dbh->bz_start_transaction(); + + $params = $class->run_create_validators($params); + + # We have to create an entry for this new flag + # in the fielddefs table for use elsewhere. We cannot + # use Bugzilla::Field->create as it will create the + # additional tables needed by custom fields which we + # do not need. Also we do this so as not to add a + # another column to the bugs table. + # We will create the entry as a custom field with a + # type of FIELD_TYPE_EXTENSION so Bugzilla will skip + # these field types in certain parts of the core code. + $dbh->do("INSERT INTO fielddefs + (name, description, sortkey, type, custom, obsolete, buglist) + VALUES + (?, ?, ?, ?, ?, ?, ?)", + undef, + $params->{'name'}, + $params->{'description'}, + $params->{'sortkey'}, + FIELD_TYPE_EXTENSION, + 1, 0, 1); + $params->{'field_id'} = $dbh->bz_last_key; + + $flag = $class->SUPER::create($params); + + $dbh->bz_commit_transaction(); + }; + my $error = "$@"; + SetParam('disable_bug_updates', 0); + write_params(); + die $error if $error; + + return $flag; +} + +sub update { + my $self = shift; + my $dbh = Bugzilla->dbh; + + my $old_self = $self->new($self->flag_id); + + # HACK! Bugzilla::Object::update uses hardcoded $self->id + # instead of $self->{ID_FIELD} so we need to reverse field_id + # and the real id temporarily + my $field_id = $self->id; + $self->{'field_id'} = $self->{'id'}; + + my $changes = $self->SUPER::update(@_); + + $self->{'field_id'} = $field_id; + + # Update the fielddefs entry + $dbh->do("UPDATE fielddefs SET name = ?, description = ? WHERE name = ?", + undef, + $self->name, $self->description, $old_self->name); + + # Update request_cache + my $cache = Bugzilla->request_cache; + if (exists $cache->{'tracking_flags'}) { + $cache->{'tracking_flags'}->{$self->flag_id} = $self; + } + + return $changes; +} + +sub match { + my $class = shift; + my ($params) = @_; + + # Use later for preload + my $bug_id = delete $params->{'bug_id'}; + + # Retrieve all flags relevant for the given product and component + if (!exists $params->{'id'} + && ($params->{'component'} || $params->{'component_id'} + || $params->{'product'} || $params->{'product_id'})) + { + my $visible_flags + = Bugzilla::Extension::TrackingFlags::Flag::Visibility->match(@_); + my @flag_ids = map { $_->tracking_flag_id } @$visible_flags; + + delete $params->{'component'} if exists $params->{'component'}; + delete $params->{'component_id'} if exists $params->{'component_id'}; + delete $params->{'product'} if exists $params->{'product'}; + delete $params->{'product_id'} if exists $params->{'product_id'}; + + $params->{'id'} = \@flag_ids; + } + + # We need to return inactive flags if a value has been set + my $is_active_filter = delete $params->{is_active}; + + my $flags = $class->SUPER::match($params); + preload_all_the_things($flags, { bug_id => $bug_id }); + + if ($is_active_filter) { + $flags = [ grep { $_->is_active || exists $_->{bug_flag} } @$flags ]; + } + return [ sort { $a->sortkey <=> $b->sortkey } @$flags ]; +} + +sub get_all { + my $self = shift; + my $cache = Bugzilla->request_cache; + if (!exists $cache->{'tracking_flags'}) { + my @tracking_flags = $self->SUPER::get_all(@_); + preload_all_the_things(\@tracking_flags); + my %tracking_flags_hash = map { $_->flag_id => $_ } @tracking_flags; + $cache->{'tracking_flags'} = \%tracking_flags_hash; + } + return sort { $a->flag_type cmp $b->flag_type || $a->sortkey <=> $b->sortkey } + values %{ $cache->{'tracking_flags'} }; +} + +# avoids the overhead of pre-loading if just the field names are required +sub get_all_names { + my $self = shift; + my $cache = Bugzilla->request_cache; + if (!exists $cache->{'tracking_flags_names'}) { + $cache->{'tracking_flags_names'} = + Bugzilla->dbh->selectcol_arrayref("SELECT name FROM tracking_flags ORDER BY name"); + } + return @{ $cache->{'tracking_flags_names'} }; +} + +sub remove_from_db { + my $self = shift; + my $dbh = Bugzilla->dbh; + + # Check to see if tracking_flags_bugs table has records + if ($self->bug_count) { + ThrowUserError('tracking_flag_has_contents', { flag => $self }); + } + + # Disable bug updates temporarily to avoid conflicts. + SetParam('disable_bug_updates', 1); + write_params(); + + eval { + $dbh->bz_start_transaction(); + + $dbh->do('DELETE FROM bugs_activity WHERE fieldid = ?', undef, $self->id); + $dbh->do('DELETE FROM fielddefs WHERE name = ?', undef, $self->name); + + $dbh->bz_commit_transaction(); + + # Remove from request cache + my $cache = Bugzilla->request_cache; + if (exists $cache->{'tracking_flags'}) { + delete $cache->{'tracking_flags'}->{$self->flag_id}; + } + }; + my $error = "$@"; + SetParam('disable_bug_updates', 0); + write_params(); + die $error if $error; +} + +sub preload_all_the_things { + my ($flags, $params) = @_; + + my %flag_hash = map { $_->flag_id => $_ } @$flags; + my @flag_ids = keys %flag_hash; + return unless @flag_ids; + + # Preload values + my $value_objects + = Bugzilla::Extension::TrackingFlags::Flag::Value->match({ tracking_flag_id => \@flag_ids }); + + # Now populate the tracking flags with this set of value objects. + foreach my $obj (@$value_objects) { + my $flag_id = $obj->tracking_flag_id; + + # Prepopulate the tracking flag object in the value object + $obj->{'tracking_flag'} = $flag_hash{$flag_id}; + + # Prepopulate the current value objects for this tracking flag + $flag_hash{$flag_id}->{'values'} ||= []; + push(@{$flag_hash{$flag_id}->{'values'}}, $obj); + } + + # Preload bug values if a bug_id is passed + if ($params && exists $params->{'bug_id'} && $params->{'bug_id'}) { + # We don't want to use @flag_ids here as we want all flags attached to this bug + # even if they are inactive. + my $bug_objects + = Bugzilla::Extension::TrackingFlags::Flag::Bug->match({ bug_id => $params->{'bug_id'} }); + # Now populate the tracking flags with this set of objects. + # Also we add them to the flag hash since we want them to be visible even if + # they are not longer applicable to this product/component. + foreach my $obj (@$bug_objects) { + my $flag_id = $obj->tracking_flag_id; + + # Load the flag object if it does not yet exist. + # This can happen if the bug value tracking flag + # is no longer visible for the product/component + $flag_hash{$flag_id} + ||= Bugzilla::Extension::TrackingFlags::Flag->new($flag_id); + + # Prepopulate the tracking flag object in the bug flag object + $obj->{'tracking_flag'} = $flag_hash{$flag_id}; + + # Prepopulate the the current bug flag object for the tracking flag + $flag_hash{$flag_id}->{'bug_flag'} = $obj; + } + } + + @$flags = values %flag_hash; +} + +############################### +#### Validators #### +############################### + +sub _check_name { + my ($invocant, $name) = @_; + $name = trim($name); + $name || ThrowCodeError('param_required', { param => 'name' }); + return $name; +} + +sub _check_description { + my ($invocant, $description) = @_; + $description = trim($description); + $description || ThrowCodeError( 'param_required', { param => 'description' } ); + return $description; +} + +sub _check_type { + my ($invocant, $type) = @_; + $type = trim($type); + $type || ThrowCodeError( 'param_required', { param => 'type' } ); + grep($_->{name} eq $type, @{FLAG_TYPES()}) + || ThrowUserError('tracking_flags_invalid_flag_type', { type => $type }); + return $type; +} + +sub _check_sortkey { + my ($invocant, $sortkey) = @_; + detaint_natural($sortkey) + || ThrowUserError('field_invalid_sortkey', { sortkey => $sortkey }); + return $sortkey; +} + +############################### +#### Setters #### +############################### + +sub set_name { $_[0]->set('name', $_[1]); } +sub set_description { $_[0]->set('description', $_[1]); } +sub set_type { $_[0]->set('type', $_[1]); } +sub set_sortkey { $_[0]->set('sortkey', $_[1]); } +sub set_enter_bug { $_[0]->set('enter_bug', $_[1]); } +sub set_is_active { $_[0]->set('is_active', $_[1]); } + +############################### +#### Accessors #### +############################### + +sub flag_id { return $_[0]->{'id'}; } +sub name { return $_[0]->{'name'}; } +sub description { return $_[0]->{'description'}; } +sub flag_type { return $_[0]->{'type'}; } +sub sortkey { return $_[0]->{'sortkey'}; } +sub enter_bug { return $_[0]->{'enter_bug'}; } +sub is_active { return $_[0]->{'is_active'}; } + +sub values { + return $_[0]->{'values'} ||= Bugzilla::Extension::TrackingFlags::Flag::Value->match({ + tracking_flag_id => $_[0]->flag_id + }); +} + +sub visibility { + return $_[0]->{'visibility'} ||= Bugzilla::Extension::TrackingFlags::Flag::Visibility->match({ + tracking_flag_id => $_[0]->flag_id + }); +} + +sub can_set_value { + my ($self, $new_value, $user) = @_; + $user ||= Bugzilla->user; + my $new_value_obj; + foreach my $value (@{$self->values}) { + if ($value->value eq $new_value) { + $new_value_obj = $value; + last; + } + } + return $new_value_obj + && $new_value_obj->setter_group + && $user->in_group($new_value_obj->setter_group->name) + ? 1 + : 0; +} + +sub bug_flag { + my ($self, $bug_id) = @_; + # Return the current bug value object if defined unless the passed bug_id does + # not equal the current bug value objects id. + if (defined $self->{'bug_flag'} + && (!$bug_id || $self->{'bug_flag'}->bug->id == $bug_id)) + { + return $self->{'bug_flag'}; + } + + # Flag::Bug->new will return a default bug value object if $params undefined + my $params = !$bug_id + ? undef + : { condition => "tracking_flag_id = ? AND bug_id = ?", + values => [ $self->flag_id, $bug_id ] }; + return $self->{'bug_flag'} = Bugzilla::Extension::TrackingFlags::Flag::Bug->new($params); +} + +sub bug_count { + my ($self) = @_; + return $self->{'bug_count'} if defined $self->{'bug_count'}; + my $dbh = Bugzilla->dbh; + return $self->{'bug_count'} = scalar $dbh->selectrow_array(" + SELECT COUNT(bug_id) + FROM tracking_flags_bugs + WHERE tracking_flag_id = ?", + undef, $self->flag_id); +} + +sub activity_count { + my ($self) = @_; + return $self->{'activity_count'} if defined $self->{'activity_count'}; + my $dbh = Bugzilla->dbh; + return $self->{'activity_count'} = scalar $dbh->selectrow_array(" + SELECT COUNT(bug_id) + FROM bugs_activity + WHERE fieldid = ?", + undef, $self->id); +} + +###################################### +# Compatibility with Bugzilla::Field # +###################################### + +# Here we return 'field_id' instead of the real +# id as we want other Bugzilla code to treat this +# as a Bugzilla::Field object in certain places. +sub id { return $_[0]->{'field_id'}; } +sub type { return FIELD_TYPE_EXTENSION; } +sub legal_values { return $_[0]->values; } +sub custom { return 1; } +sub in_new_bugmail { return 1; } +sub obsolete { return $_[0]->is_active ? 0 : 1; } +sub buglist { return 1; } +sub is_select { return 1; } +sub is_abnormal { return 1; } +sub is_timetracking { return 0; } +sub visibility_field { return undef; } +sub visibility_values { return undef; } +sub controls_visibility_of { return undef; } +sub value_field { return undef; } +sub controls_values_of { return undef; } +sub is_visible_on_bug { return 1; } +sub is_relationship { return 0; } +sub reverse_desc { return ''; } +sub is_mandatory { return 0; } +sub is_numeric { return 0; } + +1; diff --git a/extensions/TrackingFlags/lib/Flag/Bug.pm b/extensions/TrackingFlags/lib/Flag/Bug.pm new file mode 100644 index 000000000..ea382a29d --- /dev/null +++ b/extensions/TrackingFlags/lib/Flag/Bug.pm @@ -0,0 +1,187 @@ +# 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::TrackingFlags::Flag::Bug; + +use base qw(Bugzilla::Object); + +use strict; +use warnings; + +use Bugzilla::Extension::TrackingFlags::Flag; + +use Bugzilla::Bug; +use Bugzilla::Error; + +use Scalar::Util qw(blessed); + +############################### +#### Initialization #### +############################### + +use constant DEFAULT_FLAG_BUG => { + 'id' => 0, + 'tracking_flag_id' => 0, + 'bug_id' => '', + 'value' => '---', +}; + +use constant DB_TABLE => 'tracking_flags_bugs'; + +use constant DB_COLUMNS => qw( + id + tracking_flag_id + bug_id + value +); + +use constant LIST_ORDER => 'id'; + +use constant UPDATE_COLUMNS => qw( + value +); + +use constant VALIDATORS => { + tracking_flag_id => \&_check_tracking_flag, + value => \&_check_value, +}; + +use constant AUDIT_CREATES => 0; +use constant AUDIT_UPDATES => 0; +use constant AUDIT_REMOVES => 0; + +############################### +#### Object Methods #### +############################### + +sub new { + my $invocant = shift; + my $class = ref($invocant) || $invocant; + my ($param) = @_; + + my $self; + if ($param) { + $self = $class->SUPER::new(@_); + if (!$self) { + $self = DEFAULT_FLAG_BUG; + bless($self, $class); + } + } + else { + $self = DEFAULT_FLAG_BUG; + bless($self, $class); + } + + return $self +} + +sub match { + my $class = shift; + my $bug_flags = $class->SUPER::match(@_); + preload_all_the_things($bug_flags); + return $bug_flags; +} + +sub remove_from_db { + my ($self) = @_; + $self->SUPER::remove_from_db(); + $self->{'id'} = $self->{'tracking_flag_id'} = $self->{'bug_id'} = 0; + $self->{'value'} = '---'; +} + +sub preload_all_the_things { + my ($bug_flags) = @_; + my $cache = Bugzilla->request_cache; + + # Preload tracking flag objects + my @tracking_flag_ids; + foreach my $bug_flag (@$bug_flags) { + if (exists $cache->{'tracking_flags'} + && $cache->{'tracking_flags'}->{$bug_flag->tracking_flag_id}) + { + $bug_flag->{'tracking_flag'} + = $cache->{'tracking_flags'}->{$bug_flag->tracking_flag_id}; + next; + } + push(@tracking_flag_ids, $bug_flag->tracking_flag_id); + } + + return unless @tracking_flag_ids; + + my $tracking_flags + = Bugzilla::Extension::TrackingFlags::Flag->match({ id => \@tracking_flag_ids }); + my %tracking_flag_hash = map { $_->flag_id => $_ } @$tracking_flags; + + foreach my $bug_flag (@$bug_flags) { + next if exists $bug_flag->{'tracking_flag'}; + $bug_flag->{'tracking_flag'} = $tracking_flag_hash{$bug_flag->tracking_flag_id}; + } +} + +############################## +#### Class Methods #### +############################## + +sub update_all_values { + my ($invocant, $params) = @_; + my $dbh = Bugzilla->dbh; + $dbh->do( + "UPDATE tracking_flags_bugs SET value=? WHERE tracking_flag_id=? AND value=?", + undef, + $params->{new_value}, + $params->{value_obj}->tracking_flag_id, + $params->{old_value}, + ); +} + +############################### +#### Validators #### +############################### + +sub _check_value { + my ($invocant, $value) = @_; + $value || ThrowCodeError('param_required', { param => 'value' }); + return $value; +} + +sub _check_tracking_flag { + my ($invocant, $flag) = @_; + if (blessed $flag) { + return $flag->flag_id; + } + $flag = Bugzilla::Extension::TrackingFlags::Flag->new({ id => $flag, cache => 1 }) + || ThrowCodeError('tracking_flags_invalid_param', { name => 'flag_id', value => $flag }); + return $flag->flag_id; +} + +############################### +#### Setters #### +############################### + +sub set_value { $_[0]->set('value', $_[1]); } + +############################### +#### Accessors #### +############################### + +sub tracking_flag_id { return $_[0]->{'tracking_flag_id'}; } +sub bug_id { return $_[0]->{'bug_id'}; } +sub value { return $_[0]->{'value'}; } + +sub bug { + return $_[0]->{'bug'} ||= Bugzilla::Bug->new({ + id => $_[0]->bug_id, cache => 1 + }); +} + +sub tracking_flag { + return $_[0]->{'tracking_flag'} ||= Bugzilla::Extension::TrackingFlags::Flag->new({ + id => $_[0]->tracking_flag_id, cache => 1 + }); +} + +1; diff --git a/extensions/TrackingFlags/lib/Flag/Value.pm b/extensions/TrackingFlags/lib/Flag/Value.pm new file mode 100644 index 000000000..964d76810 --- /dev/null +++ b/extensions/TrackingFlags/lib/Flag/Value.pm @@ -0,0 +1,142 @@ +# 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::TrackingFlags::Flag::Value; + +use base qw(Bugzilla::Object); + +use strict; +use warnings; + +use Bugzilla::Error; +use Bugzilla::Group; +use Bugzilla::Util qw(detaint_natural trim); +use Scalar::Util qw(blessed); + +############################### +#### Initialization #### +############################### + +use constant DB_TABLE => 'tracking_flags_values'; + +use constant DB_COLUMNS => qw( + id + tracking_flag_id + setter_group_id + value + sortkey + is_active + comment +); + +use constant LIST_ORDER => 'sortkey'; + +use constant UPDATE_COLUMNS => qw( + setter_group_id + value + sortkey + is_active + comment +); + +use constant VALIDATORS => { + tracking_flag_id => \&_check_tracking_flag, + setter_group_id => \&_check_setter_group, + value => \&_check_value, + sortkey => \&_check_sortkey, + is_active => \&Bugzilla::Object::check_boolean, + comment => \&_check_comment, +}; + +############################### +#### Validators #### +############################### + +sub _check_value { + my ($invocant, $value) = @_; + defined $value || ThrowCodeError('param_required', { param => 'value' }); + return $value; +} + +sub _check_tracking_flag { + my ($invocant, $flag) = @_; + if (blessed $flag) { + return $flag->flag_id; + } + $flag = Bugzilla::Extension::TrackingFlags::Flag->new({ id => $flag, cache => 1 }) + || ThrowCodeError('tracking_flags_invalid_param', { name => 'flag_id', value => $flag }); + return $flag->flag_id; +} + +sub _check_setter_group { + my ($invocant, $group) = @_; + if (blessed $group) { + return $group->id; + } + $group = Bugzilla::Group->new({ id => $group, cache => 1 }) + || ThrowCodeError('tracking_flags_invalid_param', { name => 'setter_group_id', value => $group }); + return $group->id; +} + +sub _check_sortkey { + my ($invocant, $sortkey) = @_; + detaint_natural($sortkey) + || ThrowUserError('field_invalid_sortkey', { sortkey => $sortkey }); + return $sortkey; +} + +sub _check_comment { + my ($invocant, $value) = @_; + return undef unless defined $value; + $value = trim($value); + return $value eq '' ? undef : $value; +} + +############################### +#### Setters #### +############################### + +sub set_setter_group_id { $_[0]->set('setter_group_id', $_[1]); } +sub set_value { $_[0]->set('value', $_[1]); } +sub set_sortkey { $_[0]->set('sortkey', $_[1]); } +sub set_is_active { $_[0]->set('is_active', $_[1]); } +sub set_comment { $_[0]->set('comment', $_[1]); } + +############################### +#### Accessors #### +############################### + +sub tracking_flag_id { return $_[0]->{'tracking_flag_id'}; } +sub setter_group_id { return $_[0]->{'setter_group_id'}; } +sub value { return $_[0]->{'value'}; } +sub sortkey { return $_[0]->{'sortkey'}; } +sub is_active { return $_[0]->{'is_active'}; } +sub comment { return $_[0]->{'comment'}; } + +sub tracking_flag { + return $_[0]->{'tracking_flag'} ||= Bugzilla::Extension::TrackingFlags::Flag->new({ + id => $_[0]->tracking_flag_id, cache => 1 + }); +} + +sub setter_group { + if ($_[0]->setter_group_id) { + $_[0]->{'setter_group'} ||= Bugzilla::Group->new({ + id => $_[0]->setter_group_id, cache => 1 + }); + } + return $_[0]->{'setter_group'}; +} + +######################################## +## Compatibility with Bugzilla::Field ## +######################################## + +sub name { return $_[0]->{'value'}; } +sub is_visible_on_bug { return 1; } + +1; diff --git a/extensions/TrackingFlags/lib/Flag/Visibility.pm b/extensions/TrackingFlags/lib/Flag/Visibility.pm new file mode 100644 index 000000000..7600d71bd --- /dev/null +++ b/extensions/TrackingFlags/lib/Flag/Visibility.pm @@ -0,0 +1,172 @@ +# 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::TrackingFlags::Flag::Visibility; + +use base qw(Bugzilla::Object); + +use strict; +use warnings; + +use Bugzilla::Error; +use Bugzilla::Product; +use Bugzilla::Component; +use Scalar::Util qw(blessed); + +############################### +#### Initialization #### +############################### + +use constant DB_TABLE => 'tracking_flags_visibility'; + +use constant DB_COLUMNS => qw( + id + tracking_flag_id + product_id + component_id +); + +use constant LIST_ORDER => 'id'; + +use constant UPDATE_COLUMNS => (); # imutable + +use constant VALIDATORS => { + tracking_flag_id => \&_check_tracking_flag, + product_id => \&_check_product, + component_id => \&_check_component, +}; + +############################### +#### Methods #### +############################### + +sub match { + my $class= shift; + my ($params) = @_; + my $dbh = Bugzilla->dbh; + + # Allow matching component and product by name + # (in addition to matching by ID). + # Borrowed from Bugzilla::Bug::match + my %translate_fields = ( + product => 'Bugzilla::Product', + component => 'Bugzilla::Component', + ); + + foreach my $field (keys %translate_fields) { + my @ids; + # Convert names to ids. We use "exists" everywhere since people can + # legally specify "undef" to mean IS NULL + if (exists $params->{$field}) { + my $names = $params->{$field}; + my $type = $translate_fields{$field}; + my $objects = Bugzilla::Object::match($type, { name => $names }); + push(@ids, map { $_->id } @$objects); + } + # You can also specify ids directly as arguments to this function, + # so include them in the list if they have been specified. + if (exists $params->{"${field}_id"}) { + my $current_ids = $params->{"${field}_id"}; + my @id_array = ref $current_ids ? @$current_ids : ($current_ids); + push(@ids, @id_array); + } + # We do this "or" instead of a "scalar(@ids)" to handle the case + # when people passed only invalid object names. Otherwise we'd + # end up with a SUPER::match call with zero criteria (which dies). + if (exists $params->{$field} or exists $params->{"${field}_id"}) { + delete $params->{$field}; + $params->{"${field}_id"} = scalar(@ids) == 1 ? [ $ids[0] ] : \@ids; + } + } + + # If we aren't matching on the product, use the default matching code + if (!exists $params->{product_id}) { + return $class->SUPER::match(@_); + } + + my @criteria = ("1=1"); + + if ($params->{product_id}) { + push(@criteria, $dbh->sql_in('product_id', $params->{'product_id'})); + if ($params->{component_id}) { + my $component_id = $params->{component_id}; + push(@criteria, "(" . $dbh->sql_in('component_id', $params->{'component_id'}) . + " OR component_id IS NULL)"); + } + } + + my $where = join(' AND ', @criteria); + my $flag_ids = $dbh->selectcol_arrayref("SELECT id + FROM tracking_flags_visibility + WHERE $where"); + + return Bugzilla::Extension::TrackingFlags::Flag::Visibility->new_from_list($flag_ids); +} + +############################### +#### Validators #### +############################### + +sub _check_tracking_flag { + my ($invocant, $flag) = @_; + if (blessed $flag) { + return $flag->flag_id; + } + $flag = Bugzilla::Extension::TrackingFlags::Flag->new($flag) + || ThrowCodeError('tracking_flags_invalid_param', { name => 'flag_id', value => $flag }); + return $flag->flag_id; +} + +sub _check_product { + my ($invocant, $product) = @_; + if (blessed $product) { + return $product->id; + } + $product = Bugzilla::Product->new($product) + || ThrowCodeError('tracking_flags_invalid_param', { name => 'product_id', value => $product }); + return $product->id; +} + +sub _check_component { + my ($invocant, $component) = @_; + return undef unless defined $component; + if (blessed $component) { + return $component->id; + } + $component = Bugzilla::Component->new($component) + || ThrowCodeError('tracking_flags_invalid_param', { name => 'component_id', value => $component }); + return $component->id; +} + +############################### +#### Accessors #### +############################### + +sub tracking_flag_id { return $_[0]->{'tracking_flag_id'}; } +sub product_id { return $_[0]->{'product_id'}; } +sub component_id { return $_[0]->{'component_id'}; } + +sub tracking_flag { + my ($self) = @_; + $self->{'tracking_flag'} ||= Bugzilla::Extension::TrackingFlags::Flag->new($self->tracking_flag_id); + return $self->{'tracking_flag'}; +} + +sub product { + my ($self) = @_; + $self->{'product'} ||= Bugzilla::Product->new($self->product_id); + return $self->{'product'}; +} + +sub component { + my ($self) = @_; + return undef unless $self->component_id; + $self->{'component'} ||= Bugzilla::Component->new($self->component_id); + return $self->{'component'}; +} + +1; diff --git a/extensions/TrackingFlags/template/en/default/bug/tracking_flags.html.tmpl b/extensions/TrackingFlags/template/en/default/bug/tracking_flags.html.tmpl new file mode 100644 index 000000000..4e2c97dfa --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/bug/tracking_flags.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. + #%] + +[% FOREACH flag = flag_list %] + [% SET bug_id = bug.defined ? bug.id : 0 %] + [% SET flag_bug_value = flag.bug_flag(bug_id).value %] + [% NEXT IF !new_bug && (!user.id && flag_bug_value == '---') %] + <tr id="row_[% flag.name FILTER html %]"> + <td [% IF new_bug %]class="field_label"[% END %]> + <label for="[% flag.name FILTER html %]"> + [% IF new_bug %] + <a + [% IF help_html.${flag.name}.defined %] + title="[% help_html.${flag.name} FILTER txt FILTER collapse FILTER html %]" + class="field_help_link" + [% END %] + href="page.cgi?id=fields.html#[% flag.name FILTER uri %]"> + [% END %] + [% flag.description FILTER html %] + [% IF new_bug %] + </a> + [% END %]:</label> + </td> + <td> + [% IF user.id %] + <input type="hidden" id="[% flag.name FILTER html %]_dirty"> + <select id="[% flag.name FILTER html %]" + name="[% flag.name FILTER html %]" + onchange="tracking_flag_change(this)"> + [% FOREACH value = flag.values %] + [% IF new_bug || value.name != flag_bug_value %] + [% NEXT IF !value.is_active || !flag.can_set_value(value.name) %] + [% END %] + <option value="[% value.name FILTER html %]" + id="v[% value.id FILTER html %]_[% flag.name FILTER html %]" + [% " selected" IF !new_bug && flag_bug_value == value.name %]> + [% value.name FILTER html %]</option> + [% END %] + </select> + <script type="text/javascript"> + initHidingOptionsForIE('[% flag.name FILTER js %]'); + </script> + [% IF !new_bug && user.id %] + <span id="ro_[% flag.name FILTER html %]" class="bz_default_hidden"> + [% flag_bug_value FILTER html %] + </span> + [% END %] + [% ELSE %] + [% flag_bug_value FILTER html %] + [% END %] + </td> + </tr> +[% END %] + +<script type="text/javascript"> + TrackingFlags = [% tracking_flags_json FILTER none %]; +</script> diff --git a/extensions/TrackingFlags/template/en/default/hook/admin/admin-end_links_right.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/admin/admin-end_links_right.html.tmpl new file mode 100644 index 000000000..4808da069 --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/admin/admin-end_links_right.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 user.in_group('admin') %] + <dt id="push"> + <a href="page.cgi?id=tracking_flags_admin_list.html">Release Tracking Flags</a> + </dt> + <dd> + Tracking flags are special multi-value fields used to aid tracking releases + of Firefox, Firefox OS, Thunderbird, and other projects. + </dd> +[% END %] + diff --git a/extensions/TrackingFlags/template/en/default/hook/admin/sanitycheck/messages-statuses.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/admin/sanitycheck/messages-statuses.html.tmpl new file mode 100644 index 000000000..71ef63c11 --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/admin/sanitycheck/messages-statuses.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. + #%] + +[% IF san_tag == "tracking_flags_repair" %] + <a href="sanitycheck.cgi?tracking_flags_repair=1&token= + [%- issue_hash_token(['sanitycheck']) FILTER uri %]" + >Repair invalid product_id values in the tracking_flags_visibility table</a> + +[% ELSIF san_tag == "tracking_flags_check" %] + Checking tracking_flags_visibility table for bad values of product_id. + +[% ELSIF san_tag == "tracking_flags_alert" %] + Bad values for product_id found in the tracking_flags_visibility table. + +[% ELSIF san_tag == "tracking_flags_repairing" %] + OK, now fixing bad product_id values in the tracking_flags_visibility table. + +[% END %] diff --git a/extensions/TrackingFlags/template/en/default/hook/bug/create/create-bug_flags.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/bug/create/create-bug_flags.html.tmpl new file mode 100644 index 000000000..b41e1619f --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/bug/create/create-bug_flags.html.tmpl @@ -0,0 +1,29 @@ +[%# 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 IF NOT tracking_flags.size %] +<td> + <table class="tracking_flags"> + [% FOREACH type = tracking_flag_types %] + [% flag_list = [] %] + [% FOREACH flag = tracking_flags %] + [% flag_list.push(flag) IF flag.flag_type == type.name %] + [% END %] + [% IF flag_list.size %] + <tr> + <th style="text-align:right"> + [% type.description FILTER html %]: + </th> + </tr> + [% INCLUDE bug/tracking_flags.html.tmpl + flag_list = flag_list + new_bug = 1 %] + [% END %] + [% END %] + </table> +</td> diff --git a/extensions/TrackingFlags/template/en/default/hook/bug/create/create-form.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/bug/create/create-form.html.tmpl new file mode 100644 index 000000000..59fe1d0ec --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/bug/create/create-form.html.tmpl @@ -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. + #%] + +[% IF tracking_flags.size %] + [% tracking_flag_names = [] %] + [% FOREACH flag = tracking_flags %] + [% tracking_flag_names.push(flag.name) %] + [% END %] + + <script type="text/javascript"> + [% js_filtered_names = [] %] + [% FOREACH flag = tracking_flag_names %] + [% js_filtered = flag FILTER js %] + [% js_filtered_names.push(js_filtered) %] + [% END %] + var tracking_flag_names = ['[% js_filtered_names.join("','") FILTER none %]']; + var tracking_flags = new Array([% product.components.size %]); + + [% count = 0 %] + [% FOREACH c = product.components %] + [% NEXT IF NOT c.is_active %] + [% tracking_flag_list = [] %] + [% FOREACH flag = tracking_flags %] + [% FOREACH v = flag.visibility %] + [% IF v.product_id == product.id + && (!v.component_id.defined || v.component_id == c.id) %] + [% tracking_flag_list.push(flag.name) %] + [% END %] + [% END %] + [% END %] + [% js_filtered_flags = [] %] + [% FOREACH flag = tracking_flag_list %] + [% js_filtered = flag FILTER js %] + [% js_filtered_flags.push(js_filtered) %] + [% END %] + tracking_flags[[% count %]] = ['[% js_filtered_flags.join("','") FILTER none %]']; + [% count = count + 1 %] + [% END %] + + function update_tracking_flags () { + var component = document.getElementById('component'); + // First, we disable all flags. + for (var i = 0; i < tracking_flag_names.length; i++) { + var flagField = document.getElementById(tracking_flag_names[i]); + flagField.disabled = true; + } + // Now enable flags available for the selected component. + var index = component.selectedIndex; + for (var i = 0; i < tracking_flags[index].length; i++) { + var flagField = document.getElementById(tracking_flags[index][i]); + flagField.disabled = false; + } + } + + YAHOO.util.Event.onDOMReady(update_tracking_flags); + YAHOO.util.Event.addListener("component", "change", update_tracking_flags); + </script> +[% END %] diff --git a/extensions/TrackingFlags/template/en/default/hook/bug/create/create-winqual-bug_flags_end.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/bug/create/create-winqual-bug_flags_end.html.tmpl new file mode 100644 index 000000000..2a90cbfe3 --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/bug/create/create-winqual-bug_flags_end.html.tmpl @@ -0,0 +1,33 @@ +[%# 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 IF NOT tracking_flags.size %] + +[% FOREACH type = tracking_flag_types %] + [% NEXT IF type.name == 'tracking' || type.name == 'project' %] + [% flag_list = [] %] + [% FOREACH flag = tracking_flags %] + [% flag_list.push(flag) IF flag.flag_type == type.name %] + [% END %] + [% IF flag_list.size %] + <tr> + <td> + <table class="tracking_flags"> + <tr> + <th> + [% type.description FILTER html %]: + </th> + </tr> + [% INCLUDE bug/tracking_flags.html.tmpl + flag_list = flag_list + new_bug = 1 %] + </table> + </td> + </tr> + [% END %] +[% END %] diff --git a/extensions/TrackingFlags/template/en/default/hook/bug/create/create-winqual-project_flags_end.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/bug/create/create-winqual-project_flags_end.html.tmpl new file mode 100644 index 000000000..662bc26ee --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/bug/create/create-winqual-project_flags_end.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. + #%] + +[% RETURN IF NOT tracking_flags.size %] + +[% flag_list = [] %] +[% FOREACH flag = tracking_flags %] + [% NEXT IF flag.flag_type != 'project' %] + [% flag_list.push(flag) %] +[% END %] +[% INCLUDE bug/tracking_flags.html.tmpl + flag_list = flag_list + new_bug = 1 %] diff --git a/extensions/TrackingFlags/template/en/default/hook/bug/create/create-winqual-tracking_flags_end.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/bug/create/create-winqual-tracking_flags_end.html.tmpl new file mode 100644 index 000000000..69827a87a --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/bug/create/create-winqual-tracking_flags_end.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. + #%] + +[% RETURN IF NOT tracking_flags.size %] + +[% flag_list = [] %] +[% FOREACH flag = tracking_flags %] + [% NEXT IF flag.flag_type != 'tracking' %] + [% flag_list.push(flag) %] +[% END %] +[% INCLUDE bug/tracking_flags.html.tmpl + flag_list = flag_list + new_bug = 1 %] diff --git a/extensions/TrackingFlags/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl new file mode 100644 index 000000000..b66bd3df4 --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl @@ -0,0 +1,46 @@ +[%# 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 tracking_flags.size %] + +[% FOREACH type = tracking_flag_types %] + [% flag_list = [] %] + [% FOREACH flag = tracking_flags %] + [% flag_list.push(flag) IF flag.flag_type == type.name %] + [% END %] + [% IF flag_list.size %] + <tr> + <td class="field_label"> + <label>[% type.description FILTER html %]:</label> + </td> + <td> + [% IF bug.check_can_change_field('flagtypes.name', 0, 1) %] + [% IF user.id && type.collapsed %] + <span id="edit_[% type.name FILTER html %]_flags_action"> + (<a href="#" name="[% type.name FILTER html %]" class="edit_tracking_flags_link">edit</a>) + </span> + [% END %] + <table class="tracking_flags"> + [% INCLUDE bug/tracking_flags.html.tmpl + flag_list = flag_list %] + </table> + [% ELSE %] + [% FOREACH flag = flag_list %] + [% NEXT IF flag.status == '---' %] + [% flag.description FILTER html %]: [% flag.bug_flag.value FILTER html %]<br> + [% END %] + [% END %] + </td> + </tr> + [% END %] +[% END %] + +<script type="text/javascript"> + TrackingFlags = [% tracking_flags_json FILTER none %]; + hide_tracking_flags(); +</script> diff --git a/extensions/TrackingFlags/template/en/default/hook/bug/field-editable.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/bug/field-editable.html.tmpl new file mode 100644 index 000000000..f598609e8 --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/bug/field-editable.html.tmpl @@ -0,0 +1,38 @@ +[%# 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. + #%] + +<input type="hidden" id="[% field.name FILTER html %]_dirty"> +<select id="[% field.name FILTER html %]" + name="[% field.name FILTER html %]"> + [% IF allow_dont_change %] + <option value="[% dontchange FILTER html %]" + [% ' selected="selected"' IF value == dontchange %]> + [% dontchange FILTER html %] + </option> + [% END %] + [% FOREACH legal_value = field.values %] + [% IF legal_value.name != value %] + [% NEXT IF !field.can_set_value(legal_value.name) %] + [% NEXT IF !legal_value.is_active %] + [% END %] + <option value="[% legal_value.name FILTER html %]" + id="v[% legal_value.id FILTER html %] [%- field.name FILTER html %]" + [% IF legal_value.name == value %] + selected="selected" + [% END %]> + [%- display_value(field.name, legal_value.name) FILTER html ~%] + </option> + [% END %] +</select> +<script type="text/javascript"> +<!-- + initHidingOptionsForIE('[% field.name FILTER js %]'); + [%+ INCLUDE "bug/field-events.js.tmpl" + field = field, product = bug.product_obj %] +//--> +</script> diff --git a/extensions/TrackingFlags/template/en/default/hook/bug/field-non_editable.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/bug/field-non_editable.html.tmpl new file mode 100644 index 000000000..8fa1f1623 --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/bug/field-non_editable.html.tmpl @@ -0,0 +1,9 @@ +[%# 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. + #%] + +[% display_value(field.name, value) FILTER html %] diff --git a/extensions/TrackingFlags/template/en/default/hook/bug/show-header-end.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/bug/show-header-end.html.tmpl new file mode 100644 index 000000000..5e4ef2fcb --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/bug/show-header-end.html.tmpl @@ -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. + #%] + +[% javascript_urls.push('extensions/TrackingFlags/web/js/tracking_flags.js') %] +[% style_urls.push('extensions/TrackingFlags/web/styles/edit_bug.css') %] diff --git a/extensions/TrackingFlags/template/en/default/hook/global/code-error-errors.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/global/code-error-errors.html.tmpl new file mode 100644 index 000000000..d656aac92 --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/global/code-error-errors.html.tmpl @@ -0,0 +1,27 @@ +[%# 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 == "tracking_flags_invalid_product" %] + [% title = "Invalid Product" %] + The product named '[% product FILTER html %]' does not exist. + +[% ELSIF error == "tracking_flags_invalid_component" %] + [% title = "Invalid Component" %] + The component named '[% component_name FILTER html %]' does not exist in the + product '[% product FILTER html %]'. + +[% ELSIF error == "tracking_flags_invalid_item_id" %] + [% title = "Invalid " _ item _ " ID" %] + Invalid [% item FILTER html %] ID ([% id FILTER html %]). + +[% ELSIF error == "tracking_flags_invalid_param" %] + [% title = "Invalid Parameter Provided" %] + An invalid parameter '[% value FILTER html %]' + for '[% name FILTER html %]' was provided. + +[% END %] diff --git a/extensions/TrackingFlags/template/en/default/hook/global/header-start.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/global/header-start.html.tmpl new file mode 100644 index 000000000..2bf1c75c3 --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/global/header-start.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 template.name == "bug/create/create.html.tmpl" && tracking_flags.size %] + [% javascript_urls.push('extensions/TrackingFlags/web/js/tracking_flags.js') %] +[% END %] diff --git a/extensions/TrackingFlags/template/en/default/hook/global/messages-messages.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/global/messages-messages.html.tmpl new file mode 100644 index 000000000..ce254b8cc --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/global/messages-messages.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 message_tag == 'tracking_flag_created' %] + The tracking flag '[% flag.name FILTER html %]' has been created. + +[% ELSIF message_tag == 'tracking_flag_updated' %] + The tracking flag '[% flag.name FILTER html %]' has been updated. + +[% ELSIF message_tag == "tracking_flag_deleted" %] + The tracking flag '[% flag.name FILTER html %]' has been deleted. + +[% END %] diff --git a/extensions/TrackingFlags/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/TrackingFlags/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..8c067a5d1 --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,58 @@ +[%# 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 == "tracking_flags_change_denied" %] + [% title = "Tracking Flag Modification Denied" %] + You tried to update the status of the tracking flag '[% flag.name FILTER html %]' + [% IF value %] to '[% value FILTER html %]'[% END %]. + Only a user with the required permissions may make this change. + +[% ELSIF error == "tracking_flags_missing_mandatory" %] + [% IF fields.size == 1 %] + [% title = "Missing mandatory field" %] + The field "[% fields.first FILTER html %]" is mandatory, and must be provided. + [% ELSE %] + [% title = "Missing mandatory fields" %] + The following fields are mandatory, and must be provided: + [%+ fields.join(', ') FILTER html %] + [% END %] + +[% ELSIF error == "tracking_flags_cf_prefix" %] + [% title = "Invalid flag name" %] + The flag name must start with 'cf_'. + +[% ELSIF error == "tracking_flags_missing_values" %] + [% title = "Missing values" %] + You must provide at least one value. + +[% ELSIF error == "tracking_flags_missing_value" %] + [% title = "Missing value" %] + You must provied the value for all values. + +[% ELSIF error == "tracking_flags_duplicate_value" %] + [% title = "Duplicate value" %] + The value "[% value FILTER html %]" has been provided more than once. + +[% ELSIF error == "tracking_flags_missing_visibility" %] + [% title = "Missing visibility" %] + You must provide at least one product for visibility. + +[% ELSIF error == "tracking_flags_duplicate_visibility" %] + [% title = "Duplicate visibility" %] + The visibility '[% name FILTER html %]' has been provided more than once. + +[% ELSIF error == "tracking_flags_invalid_flag_type" %] + [% title = "Invalid flag type" %] + The flag type '[% type FILTER html %]' is invalid. + +[% ELSIF error == "tracking_flag_has_contents" %] + [% title = "Tracking Flag Has Contents" %] + The tracking flag '[% flag.name FILTER html %]' cannot be deleted because + at least one [% terms.bug %] has a non empty value for this field. + +[% END %] diff --git a/extensions/TrackingFlags/template/en/default/pages/tracking_flags_admin_edit.html.tmpl b/extensions/TrackingFlags/template/en/default/pages/tracking_flags_admin_edit.html.tmpl new file mode 100644 index 000000000..60406490f --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/pages/tracking_flags_admin_edit.html.tmpl @@ -0,0 +1,197 @@ +[%# 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. + #%] + +[% js_data = BLOCK %] +var useclassification = false; +var first_load = true; +var last_sel = []; +var cpts = new Array(); +[% n = 1 %] +[% FOREACH p = user.get_selectable_products %] + cpts['[% n FILTER js %]'] = [ + [%- FOREACH c = p.components %]'[% c.name FILTER js %]'[% ", " UNLESS loop.last %] [%- END -%] ]; + [% n = n+1 %] +[% END %] +var selected_components = [ + [%- FOREACH c = input.component %]'[% c FILTER js %]' + [%- ',' UNLESS loop.last %] [%- END ~%] ]; +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Release Tracking Flags" + javascript = js_data + javascript_urls = [ 'extensions/TrackingFlags/web/js/admin.js', 'js/productform.js' ] + style_urls = [ 'extensions/TrackingFlags/web/styles/admin.css' ] +%] + +<script> + var groups = [% groups || '[]' FILTER none %]; + var flag_values = [% values || '[]' FILTER none %]; + var flag_visibility = [% visibility || '[]' FILTER none %]; +</script> + +<div id="edit_mode"> + [% IF mode == 'edit' %] + Editing <b>[% flag.name FILTER html %]</b>. + [% ELSE %] + New flag + [% END %] +</div> + +<form method="POST" action="page.cgi" onsubmit="return on_submit()"> +<input type="hidden" name="id" value="tracking_flags_admin_edit.html"> +<input type="hidden" name="mode" value="[% mode FILTER html %]"> +<input type="hidden" name="flag_id" value="[% flag ? flag.flag_id : 0 FILTER html %]"> +<input type="hidden" name="values" id="values" value=""> +<input type="hidden" name="visibility" id="visibility" value=""> +<input type="hidden" name="save" value="1"> + +[%# name/desc/etc %] + +<table class="edit" cellspacing="0"> + +<tr class="header"> + <th colspan="3">Flag</th> +</tr> + +<tr> + <th>Name</th> + <td><input name="flag_name" id="flag_name" value="[% flag.name FILTER html %]"></td> + <td class="help">database field name</td> +</tr> + +<tr> + <th>Description</th> + <td><input name="flag_desc" id="flag_desc" value="[% flag.description FILTER html %]"></td> + <td class="help">visible name</td> +</tr> + +<tr> + <th>Type</th> + <td> + <select name="flag_type" id="flag_type"> + <option value=""></option> + [% FOREACH type = tracking_flag_types %] + <option value="[% type.name FILTER html %]" + [% " selected" IF flag.flag_type == type.name %]> + [% type.name FILTER html %]</option> + [% END %] + </select> + </td> + <td class="help">flag type used for grouping</td> +</tr> + +<tr> + <th>Sort Key</th> + <td> + <input name="flag_sort" id="flag_sort" value="[% flag.sortkey FILTER html %]"> + [ + <a class="txt_icon" href="#" onclick="inc_field('flag_sort', 5);return false">+5</a> + | <a class="txt_icon" href="#" onclick="inc_field('flag_sort', -5);return false">-5</a> + ] + </td> +</tr> + +<tr> + <th>Enter [% terms.Bug %]</th> + <td><input type="checkbox" name="flag_enter_bug" id="flag_enter_bug" value="1" [% "checked" IF flag.enter_bug %]></td> + <td class="help">can be set on [% terms.bug %] creation</td> +</tr> + +<tr> + <th>Active</th> + <td><input type="checkbox" name="flag_active" id="flag_active" value="1" [% "checked" IF flag.is_active %]></td> +</tr> + +[% IF mode == 'edit' %] + <tr> + <th>[% terms.Bug %] Count</th> + <td>[% flag.bug_count FILTER html %]</td> + </tr> +[% END %] + +</table> + +[%# values %] + +<table id="flag_values" class="edit" cellspacing="0"> + +<tr class="header"> + <th colspan="4">Values</th> +</tr> + +<tr> + <th>Value</th> + <th>Setter</th> + <th>Active</th> +</tr> + +<tr> + <td colspan="4"> + [ <a href="#" onclick="add_value();return false">New Value</a> ] + </td> +</tr> + +</table> + +[%# visibility %] + +<table id="flag_visibility" class="edit" cellspacing="0"> + +<tr class="header"> + <th colspan="3">Visibility</th> +</tr> + +<tr> + <th>Product</th> + <th>Component</th> +</tr> + +<tr id="flag_visibility_add"> + <td> + <select id="product" onChange="selectProduct(Dom.get('product'), Dom.get('component'), null, null, '-- Any --')"> + <option value=""></option> + [% FOREACH p = user.get_selectable_products %] + <option value="[% p.name FILTER html %]" + [% " selected" IF input.product == p.name %]> + [% p.name FILTER html %] + </option> + [% END %] + </select> + </td> + <td> + <select id="component"> + </select> + </td> + <td> + [ <a href="#" onclick="add_visibility();return false">Add</a> ] + <td> +</tr> + +</table> + + +[%# submit %] + +<div> + <input type="submit" name="submit" id="submit" value="[% mode == 'edit' ? 'Save Changes' : 'Add' %]"> + [% IF mode == "edit" && !flag.bug_count %] + <input type="hidden" name="delete" id="delete" value=""> + <input type="submit" value="Delete Flag [% IF flag.activity_count %] and Activity[% END %]" + onclick="return delete_confirm('[% flag.name FILTER js FILTER html %]')"> + [% END %] +</div> + +</form> + +<hr> +<p> +Return to the <a href="page.cgi?id=tracking_flags_admin_list.html">list of Tracking Flags</a>. +</p> + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/TrackingFlags/template/en/default/pages/tracking_flags_admin_list.html.tmpl b/extensions/TrackingFlags/template/en/default/pages/tracking_flags_admin_list.html.tmpl new file mode 100644 index 000000000..5ea68dd98 --- /dev/null +++ b/extensions/TrackingFlags/template/en/default/pages/tracking_flags_admin_list.html.tmpl @@ -0,0 +1,73 @@ +[%# 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/header.html.tmpl + title = "Release Tracking Flags" + style_urls = [ 'extensions/TrackingFlags/web/styles/admin.css' ] + javascript_urls = [ 'extensions/TrackingFlags/web/js/admin.js' ] +%] + +<table id="flag_list" class="list" cellspacing="0"> + +<tr> + <th>Name</th> + <th>Description</th> + <th>Type</th> + <th>Sort Key</th> + <th>Active</th> + [% IF show_bug_counts %] + <th>[% terms.Bugs %]</th> + [% END %] + <th> </th> +</tr> + +[% FOREACH flag = flags %] + <tr class="flag_row + [% loop.count % 2 == 1 ? " odd_row" : " even_row" %] + [% " is_disabled" UNLESS flag.is_active %]"> + <td [% 'class="disabled"' UNLESS flag.is_active %]> + <a href="page.cgi?id=tracking_flags_admin_edit.html&mode=edit&flag_id=[% flag.flag_id FILTER uri %]"> + [% flag.name FILTER html %] + </a> + </td> + <td [% 'class="disabled"' UNLESS flag.is_active %]> + [% flag.description FILTER html %] + </td> + <td [% 'class="disabled"' UNLESS flag.is_active %]> + [% flag.flag_type FILTER html %] + </td> + <td [% 'class="disabled"' UNLESS flag.is_active %]> + [% flag.sortkey FILTER html %] + </td> + <td> + [% flag.is_active ? "Yes" : "No" %] + </td> + [% IF show_bug_counts %] + <td> + [% flag.bug_count FILTER html %] + </td> + [% END %] + <td> + <a href="page.cgi?id=tracking_flags_admin_edit.html&mode=copy&copy_from=[% flag.flag_id FILTER uri %]">Copy</a> + </td> + </tr> +[% END %] + +</table> + +<div id="new_flag"> + <a href="page.cgi?id=tracking_flags_admin_edit.html">Add Flag</a> | + [% IF !show_bug_counts %] + <a href="page.cgi?id=tracking_flags_admin_list.html&show_bug_counts=1"> + Show [% terms.bug %] counts (slower)</a> | + [% END %] + <input type="checkbox" onclick="filter_flag_list(this.checked)" id="filter"> + <label for="filter">Show disabled flags</label> +</div> + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/TrackingFlags/web/js/admin.js b/extensions/TrackingFlags/web/js/admin.js new file mode 100644 index 000000000..58bdd294f --- /dev/null +++ b/extensions/TrackingFlags/web/js/admin.js @@ -0,0 +1,440 @@ +/* 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. */ + +// init + +var Dom = YAHOO.util.Dom; +var Event = YAHOO.util.Event; + +Event.onDOMReady(function() { + try { + if (Dom.get('flag_list')) { + filter_flag_list(Dom.get('filter').checked); + } + else { + if (!JSON) + JSON = YAHOO.lang.JSON; + Event.addListener('flag_name', 'change', change_flag_name, Dom.get('flag_name')); + Event.addListener('flag_desc', 'change', change_string_value, Dom.get('flag_desc')); + Event.addListener('flag_type', 'change', change_select_value, Dom.get('flag_type')); + Event.addListener('flag_sort', 'change', change_int_value, Dom.get('flag_sort')); + + Event.addListener('product', 'change', function() { + if (Dom.get('product').value == '') + Dom.get('component').options.length = 0; + }); + + update_flag_values(); + update_flag_visibility(); + tag_missing_values(); + } + } catch(e) { + console.error(e); + } +}); + +// field + +function change_flag_name(e, o) { + change_string_value(e, o); + if (o.value == '') + return; + o.value = o.value.replace(/[^a-z0-9_]/g, '_'); + if (!o.value.match(/^cf_/)) + o.value = 'cf_' + o.value; + if (Dom.get('flag_desc').value == '') { + var desc = o.value; + desc = desc.replace(/^cf_/, ''); + desc = desc.replace(/_/g, '-'); + Dom.get('flag_desc').value = desc; + tag_missing_value(Dom.get('flag_desc')); + } +} + +function inc_field(id, amount) { + var el = Dom.get(id); + el.value = el.value.match(/-?\d+/) * 1 + amount; + change_int_value(null, el); +} + +// values + +function update_flag_values() { + // update the values table from the flag_values global + + var tbl = Dom.get('flag_values'); + if (!tbl) + return; + + // remove current entries + while (tbl.rows.length > 3) { + tbl.deleteRow(2); + } + + // add all entries + + for (var i = 0, l = flag_values.length; i < l; i++) { + var value = flag_values[i]; + + var row = tbl.insertRow(2 + (i * 2)); + var cell; + + // value + cell = row.insertCell(0); + if (value.value == '---') { + cell.innerHTML = '---'; + } + else { + var inputEl = document.createElement('input'); + inputEl.id = 'value_' + i; + inputEl.type = 'text'; + inputEl.className = 'option_value'; + inputEl.value = value.value; + Event.addListener(inputEl, 'change', change_string_value, inputEl); + Event.addListener(inputEl, 'change', function(e, o) { + flag_values[o.id.match(/\d+$/)].value = o.value; + tag_invalid_values(); + }, inputEl); + Event.addListener(inputEl, 'keyup', function(e, o) { + if ((e.key || e.keyCode) == 27 && o.value == '') + remove_value(o.id.match(/\d+$/)); + }, inputEl); + cell.appendChild(inputEl); + } + + // setter + cell = row.insertCell(1); + var selectEl = document.createElement('select'); + selectEl.id = 'setter_' + i; + Event.addListener(selectEl, 'change', change_select_value, selectEl); + var optionEl = document.createElement('option'); + optionEl.value = ''; + selectEl.appendChild(optionEl); + for (var j = 0, m = groups.length; j < m; j++) { + var group = groups[j]; + optionEl = document.createElement('option'); + optionEl.value = group.id; + optionEl.innerHTML = YAHOO.lang.escapeHTML(group.name); + optionEl.selected = group.id == value.setter_group_id; + selectEl.appendChild(optionEl); + } + Event.addListener(selectEl, 'change', function(e, o) { + flag_values[o.id.match(/\d+$/)].setter_group_id = o.value; + tag_invalid_values(); + }, selectEl); + cell.appendChild(selectEl); + + // active + cell = row.insertCell(2); + if (value.value == '---') { + cell.innerHTML = 'Yes'; + } + else { + var inputEl = document.createElement('input'); + inputEl.type = 'checkbox'; + inputEl.id = 'is_active_' + i; + inputEl.checked = value.is_active; + Event.addListener(inputEl, 'change', function(e, o) { + flag_values[o.id.match(/\d+$/)].is_active = o.checked; + }, inputEl); + cell.appendChild(inputEl); + } + + // actions + cell = row.insertCell(3); + var html = + '[' + + (i == 0 + ? '<span class="txt_icon"> - </span>' + : '<a class="txt_icon" href="#" onclick="value_move_up(' + i + ');return false"> Δ </a>' + ) + + '|' + + (i == l - 1 + ? '<span class="txt_icon"> - </span>' + : '<a class="txt_icon" href="#" onclick="value_move_down(' + i + ');return false"> ∇ </a>' + ); + if (value.value != '---') { + var lbl = value.comment == '' ? 'Set Comment' : 'Edit Comment'; + html += + '|<a href="#" onclick="remove_value(' + i + ');return false">Remove</a>' + + '|<a href="#" onclick="toggle_value_comment(this, ' + i + ');return false">' + lbl + '</a>' + + } + html += ' ]'; + cell.innerHTML = html; + + row = tbl.insertRow(3 + (i * 2)); + row.className = 'bz_default_hidden'; + row.id = 'comment_row_' + i; + cell = row.insertCell(0); + cell = row.insertCell(1); + cell.colSpan = 3; + var ta = document.createElement('textarea'); + ta.className = 'value_comment'; + ta.id = 'value_comment_' + i; + ta.rows = 5; + ta.value = value.comment; + cell.appendChild(ta); + Event.addListener(ta, 'blur', function(e, idx) { + flag_values[idx].comment = e.target.value; + }, i); + } + + tag_invalid_values(); +} + +function tag_invalid_values() { + // reset + for (var i = 0, l = flag_values.length; i < l; i++) { + Dom.removeClass('value_' + i, 'admin_error'); + } + + for (var i = 0, l = flag_values.length; i < l; i++) { + // missing + if (flag_values[i].value == '') + Dom.addClass('value_' + i, 'admin_error'); + if (!flag_values[i].setter_group_id) + Dom.addClass('setter_' + i, 'admin_error'); + + // duplicate values + for (var j = i; j < l; j++) { + if (i != j && flag_values[i].value == flag_values[j].value) { + Dom.addClass('value_' + i, 'admin_error'); + Dom.addClass('value_' + j, 'admin_error'); + } + } + } +} + +function value_move_up(idx) { + if (idx == 0) + return; + var tmp = flag_values[idx]; + flag_values[idx] = flag_values[idx - 1]; + flag_values[idx - 1] = tmp; + update_flag_values(); +} + +function value_move_down(idx) { + if (idx == flag_values.length - 1) + return; + var tmp = flag_values[idx]; + flag_values[idx] = flag_values[idx + 1]; + flag_values[idx + 1] = tmp; + update_flag_values(); +} + +function add_value() { + var value = new Object(); + value.id = 0; + value.value = ''; + value.setter_group_id = ''; + value.is_active = true; + var idx = flag_values.length; + flag_values[idx] = value; + update_flag_values(); + Dom.get('value_' + idx).focus(); +} + +function remove_value(idx) { + flag_values.splice(idx, 1); + update_flag_values(); +} + +function update_value(e, o) { + var i = o.value.match(/\d+/); + flag_values[i].value = o.value; +} + +function toggle_value_comment(btn, idx) { + var row = Dom.get('comment_row_' + idx); + if (Dom.hasClass(row, 'bz_default_hidden')) { + Dom.removeClass(row, 'bz_default_hidden'); + btn.innerHTML = 'Hide Comment'; + Dom.get('value_comment_' + idx).select(); + Dom.get('value_comment_' + idx).focus(); + } else { + Dom.addClass(row, 'bz_default_hidden'); + btn.innerHTML = flag_values[idx].comment == '' ? 'Set Comment' : 'Edit Comment'; + } +} + +// visibility + +function update_flag_visibility() { + // update the visibility table from the flag_visibility global + + var tbl = Dom.get('flag_visibility'); + if (!tbl) + return; + + // remove current entries + while (tbl.rows.length > 3) { + tbl.deleteRow(2); + } + + // show something if there aren't any components + + if (!flag_visibility.length) { + var row = tbl.insertRow(2); + var cell = row.insertCell(0); + cell.innerHTML = '<i class="admin_error_text">missing</i>'; + } + + // add all entries + + for (var i = 0, l = flag_visibility.length; i < l; i++) { + var visibility = flag_visibility[i]; + + var row = tbl.insertRow(2 + i); + var cell; + + // product + cell = row.insertCell(0); + cell.innerHTML = visibility.product; + + // component + cell = row.insertCell(1); + cell.innerHTML = visibility.component + ? visibility.component + : '<i>-- Any --</i>'; + + // actions + cell = row.insertCell(2); + cell.innerHTML = '[ <a href="#" onclick="remove_visibility(' + i + ');return false">Remove</a> ]'; + } +} + +function add_visibility() { + // validation + var product = Dom.get('product').value; + var component = Dom.get('component').value; + if (!product) { + alert('Please select a product.'); + return; + } + + // don't allow duplicates + for (var i = 0, l = flag_visibility.length; i < l; i++) { + if (flag_visibility[i].product == product && flag_visibility[i].component == component) { + Dom.get('product').value = ''; + Dom.get('component').options.length = 0; + return; + } + } + + if (component == '') { + // if we're adding an "any" component, remove non-any components + for (var i = 0; i < flag_visibility.length; i++) { + var visibility = flag_visibility[i]; + if (visibility.product == product) { + flag_visibility.splice(i, 1); + i--; + } + } + } + else { + // don't add non-any components if an "any" component exists + for (var i = 0, l = flag_visibility.length; i < l; i++) { + var visibility = flag_visibility[i]; + if (visibility.product == product && !visibility.component) + return; + } + } + + // add to model + var visibility = new Object(); + visibility.id = 0; + visibility.product = product; + visibility.component = component; + flag_visibility[flag_visibility.length] = visibility; + + // update ui + update_flag_visibility(); + Dom.get('product').value = ''; + Dom.get('component').options.length = 0; +} + +function remove_visibility(idx) { + flag_visibility.splice(idx, 1); + update_flag_visibility(); +} + +// validation and submission + +function tag_missing_values() { + var els = document.getElementsByTagName('input'); + for (var i = 0, l = els.length; i < l; i++) { + var el = els[i]; + if (el.id.match(/^(flag|value)_/)) + tag_missing_value(el); + } + tag_missing_value(Dom.get('flag_type')); +} + +function tag_missing_value(el) { + el.value == '' + ? Dom.addClass(el, 'admin_error') + : Dom.removeClass(el, 'admin_error'); +} + +function delete_confirm(flag) { + if (confirm('Are you sure you want to delete the flag ' + flag + ' ?')) { + Dom.get('delete').value = 1; + return true; + } + else { + return false; + } +} + +function on_submit() { + if (Dom.get('delete') && Dom.get('delete').value) + return; + // let perl manage most validation errors, because they are clearly marked + // the exception is an empty visibility list, so catch that here as well + if (!flag_visibility.length) { + alert('You must provide at least one product for visibility.'); + return false; + } + + Dom.get('values').value = JSON.stringify(flag_values); + Dom.get('visibility').value = JSON.stringify(flag_visibility); + return true; +} + +// flag list + +function filter_flag_list(show_disabled) { + var rows = Dom.getElementsByClassName('flag_row', 'tr', 'flag_list'); + for (var i = 0, l = rows.length; i < l; i++) { + if (Dom.hasClass(rows[i], 'is_disabled')) { + if (show_disabled) { + Dom.removeClass(rows[i], 'bz_default_hidden'); + } + else { + Dom.addClass(rows[i], 'bz_default_hidden'); + } + } + } +} + +// utils + +function change_string_value(e, o) { + o.value = YAHOO.lang.trim(o.value); + tag_missing_value(o); +} + +function change_int_value(e, o) { + o.value = o.value.match(/-?\d+/); + tag_missing_value(o); +} + +function change_select_value(e, o) { + tag_missing_value(o); +} diff --git a/extensions/TrackingFlags/web/js/tracking_flags.js b/extensions/TrackingFlags/web/js/tracking_flags.js new file mode 100644 index 000000000..041ae43f5 --- /dev/null +++ b/extensions/TrackingFlags/web/js/tracking_flags.js @@ -0,0 +1,95 @@ +/* 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; + +function hide_tracking_flags() { + for (var i = 0, l = TrackingFlags.types.length; i < l; i++) { + var flag_type = TrackingFlags.types[i]; + for (var field in TrackingFlags.flags[flag_type]) { + var el = Dom.get(field); + var value = el ? el.value : TrackingFlags.flags[flag_type][field]; + if (el && (value != TrackingFlags.flags[flag_type][field])) { + show_tracking_flags(flag_type); + return; + } + if (value == '---') { + Dom.addClass('row_' + field, 'bz_default_hidden'); + } else { + Dom.addClass(field, 'bz_default_hidden'); + Dom.removeClass('ro_' + field, 'bz_default_hidden'); + } + } + } +} + +function show_tracking_flags(flag_type) { + Dom.addClass('edit_' + flag_type + '_flags_action', 'bz_default_hidden'); + for (var field in TrackingFlags.flags[flag_type]) { + if (Dom.get(field).value == '---') { + Dom.removeClass('row_' + field, 'bz_default_hidden'); + } else { + Dom.removeClass(field, 'bz_default_hidden'); + Dom.addClass('ro_' + field, 'bz_default_hidden'); + } + } +} + +function tracking_flag_change(e) { + var value = e.value; + var prefill; + if (TrackingFlags.comments[e.name]) + prefill = TrackingFlags.comments[e.name][e.value]; + if (!prefill) { + var cr = document.getElementById('cr_' + e.id); + if (cr) + cr.parentElement.removeChild(cr); + return; + } + if (!document.getElementById('cr_' + e.id)) { + // create "comment required" + var span = document.createElement('span'); + span.id = 'cr_' + e.id; + span.appendChild(document.createTextNode('(')); + var a = document.createElement('a'); + a.appendChild(document.createTextNode('comment required')); + a.href = '#'; + a.onclick = function() { + var c = document.getElementById('comment'); + c.focus(); + c.select(); + document.getElementById('add_comment').scrollIntoView(); + return false; + }; + span.appendChild(a); + span.appendChild(document.createTextNode(')')); + e.parentNode.appendChild(span); + } + // prefill comment + var commentEl = document.getElementById('comment'); + if (!commentEl) + return; + var value = commentEl.value; + if (value == prefill) + return; + if (value == '') { + commentEl.value = prefill; + } else { + commentEl.value = prefill + "\n\n" + value; + } +} + +YAHOO.util.Event.onDOMReady(function() { + var edit_tracking_links = Dom.getElementsByClassName('edit_tracking_flags_link'); + for (var i = 0, l = edit_tracking_links.length; i < l; i++) { + YAHOO.util.Event.addListener(edit_tracking_links[i], 'click', function(e) { + e.preventDefault(); + show_tracking_flags(this.name); + }); + } +}); diff --git a/extensions/TrackingFlags/web/styles/admin.css b/extensions/TrackingFlags/web/styles/admin.css new file mode 100644 index 000000000..51c6ab966 --- /dev/null +++ b/extensions/TrackingFlags/web/styles/admin.css @@ -0,0 +1,111 @@ +/* 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. */ + +/* list */ + +.list { + border: 1px solid #888888; +} + +.list td, .list th { + padding: 3px 10px 3px 3px; + border: 1px solid #888888; +} + +.list .odd_row { + background-color: #ffffff; + color: #000000; +} + +.list .even_row { + background-color: #eeeeee; + color: #000000; +} + +.list tr:hover { + background-color: #ccddee; +} + + +.list th { + text-align: left; + background: #dddddd; +} + +.list .disabled { + color: #888888; + text-decoration: line-through; +} + +#new_flag { + margin: 1em 0em; +} + +/* edit */ + +.edit { + margin-bottom: 2em; +} + +.edit .header { + background: #dddddd; +} + +.edit .help { + font-style: italic; +} + +.edit td, .edit th { + padding: 1px 5px; +} + +.edit th { + text-align: left; +} + +#edit_mode { + margin: 1em 0em; +} + +#flag_name { + width: 20em; +} + +#flag_desc { + width: 20em; +} + +#flag_sort { + width: 10em; +} + +.option_value { + width: 10em; +} + +.value_comment { + width: 100%; +} + +.hidden { + display: none; +} + +.txt_icon { + font-family: monospace; +} + +.admin_error { + border: 1px solid red; + box-shadow: 0px 0px 4px #ff0000; + -webkit-box-shadow: 0px 0px 4px #ff0000; + -moz-box-shadow: 0px 0px 4px #ff0000; +} + +.admin_error_text { + color: #cc0000; +} diff --git a/extensions/TrackingFlags/web/styles/edit_bug.css b/extensions/TrackingFlags/web/styles/edit_bug.css new file mode 100644 index 000000000..132a6a1ca --- /dev/null +++ b/extensions/TrackingFlags/web/styles/edit_bug.css @@ -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. */ + +.tracking_flags { + width: auto !important; +} + +.tracking_flags .field_label { + font-weight: normal !important; +} + +#Create .tracking_flags th { + text-align: left; +} diff --git a/extensions/TryAutoLand/Config.pm b/extensions/TryAutoLand/Config.pm new file mode 100644 index 000000000..8b299183b --- /dev/null +++ b/extensions/TryAutoLand/Config.pm @@ -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. + +package Bugzilla::Extension::TryAutoLand; +use strict; + +use constant NAME => 'TryAutoLand'; + +use constant REQUIRED_MODULES => [ +]; + +use constant OPTIONAL_MODULES => [ +]; + +__PACKAGE__->NAME; diff --git a/extensions/TryAutoLand/Extension.pm b/extensions/TryAutoLand/Extension.pm new file mode 100644 index 000000000..40dbb70d9 --- /dev/null +++ b/extensions/TryAutoLand/Extension.pm @@ -0,0 +1,323 @@ +# 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::TryAutoLand; + +use strict; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Bug; +use Bugzilla::Attachment; +use Bugzilla::User; +use Bugzilla::Util qw(trick_taint diff_arrays); +use Bugzilla::Error; + +use Bugzilla::Extension::TryAutoLand::Constants; + +our $VERSION = '0.01'; + +BEGIN { + *Bugzilla::Bug::autoland_branches = \&_autoland_branches; + *Bugzilla::Bug::autoland_try_syntax = \&_autoland_try_syntax; + *Bugzilla::Attachment::autoland_checked = \&_autoland_attachment_checked; + *Bugzilla::Attachment::autoland_who = \&_autoland_attachment_who; + *Bugzilla::Attachment::autoland_status = \&_autoland_attachment_status; + *Bugzilla::Attachment::autoland_status_when = \&_autoland_attachment_status_when; + *Bugzilla::Attachment::autoland_update_status = \&_autoland_attachment_update_status; + *Bugzilla::Attachment::autoland_remove = \&_autoland_attachment_remove; +} + +sub db_schema_abstract_schema { + my ($self, $args) = @_; + $args->{'schema'}->{'autoland_branches'} = { + FIELDS => [ + bug_id => { + TYPE => 'INT3', + NOTNULL => 1, + PRIMARYKEY => 1, + REFERENCES => { + TABLE => 'bugs', + COLUMN => 'bug_id', + DELETE => 'CASCADE' + } + }, + branches => { + TYPE => 'VARCHAR(255)', + NOTNULL => 1 + }, + try_syntax => { + TYPE => 'VARCHAR(255)', + NOTNULL => 1, + DEFAULT => "''", + } + ], + }; + + $args->{'schema'}->{'autoland_attachments'} = { + FIELDS => [ + attach_id => { + TYPE => 'INT3', + NOTNULL => 1, + PRIMARYKEY => 1, + REFERENCES => { + TABLE => 'attachments', + COLUMN => 'attach_id', + DELETE => 'CASCADE' + }, + }, + who => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'profiles', + COLUMN => 'userid', + }, + }, + status => { + TYPE => 'varchar(64)', + NOTNULL => 1 + }, + status_when => { + TYPE => 'DATETIME', + NOTNULL => 1, + }, + ], + }; +} + +sub install_update_db { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + + if (!$dbh->bz_column_info('autoland_branches', 'try_syntax')) { + $dbh->bz_add_column('autoland_branches', 'try_syntax', { + TYPE => 'VARCHAR(255)', + NOTNULL => 1, + DEFAULT => "''", + }); + } +} + +sub _autoland_branches { + my $self = shift; + return $self->{'autoland_branches'} if exists $self->{'autoland_branches'}; + _preload_bug_data($self); + return $self->{'autoland_branches'}; +} + +sub _autoland_try_syntax { + my $self = shift; + return $self->{'autoland_try_syntax'} if exists $self->{'autoland_try_syntax'}; + _preload_bug_data($self); + return $self->{'autoland_try_syntax'}; +} + +sub _preload_bug_data { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + my $result = $dbh->selectrow_hashref("SELECT branches, try_syntax FROM autoland_branches + WHERE bug_id = ?", { Slice => {} }, $self->id); + if ($result) { + $self->{'autoland_branches'} = $result->{'branches'}; + $self->{'autoland_try_syntax'} = $result->{'try_syntax'}; + } + else { + $self->{'autoland_branches'} = undef; + $self->{'autoland_try_syntax'} = undef; + } +} + +sub _autoland_attachment_checked { + my $self = shift; + my $dbh = Bugzilla->dbh; + return $self->{'autoland_checked'} if exists $self->{'autoland_checked'}; + my $result = $dbh->selectrow_hashref("SELECT who, status, status_when + FROM autoland_attachments + WHERE attach_id = ?", { Slice => {} }, $self->id); + if ($result) { + $self->{'autoland_checked'} = 1; + $self->{'autoland_who'} = Bugzilla::User->new($result->{'who'}); + $self->{'autoland_status'} = $result->{'status'}; + $self->{'autoland_status_when'} = $result->{'status_when'}; + } + else { + $self->{'autoland_checked'} = 0; + $self->{'autoland_who'} = undef; + $self->{'autoland_status'} = undef; + $self->{'autoland_status_when'} = undef; + } + return $self->{'autoland_checked'}; +} + +sub _autoland_attachment_who { + my $self = shift; + return undef if !$self->autoland_checked; + return $self->{'autoland_who'}; +} + +sub _autoland_attachment_status { + my $self = shift; + return undef if !$self->autoland_checked; + return $self->{'autoland_status'}; +} + +sub _autoland_attachment_status_when { + my $self = shift; + return undef if !$self->autoland_checked; + return $self->{'autoland_status_when'}; +} + +sub _autoland_attachment_update_status { + my ($self, $status) = @_; + my $dbh = Bugzilla->dbh; + + return undef if !$self->autoland_checked; + + grep($_ eq $status, VALID_STATUSES) + || ThrowUserError('autoland_invalid_status', + { status => $status, + valid => [ VALID_STATUSES ] }); + + if ($self->autoland_status ne $status) { + my $timestamp = $dbh->selectrow_array("SELECT LOCALTIMESTAMP(0)"); + trick_taint($status); + $dbh->do("UPDATE autoland_attachments SET status = ?, status_when = ? + WHERE attach_id = ?", undef, $status, $timestamp, $self->id); + $self->{'autoland_status'} = $status; + $self->{'autoland_status_when'} = $timestamp; + } + + return 1; +} + +sub _autoland_attachment_remove { + my ($self) = @_; + my $dbh = Bugzilla->dbh; + return undef if !$self->autoland_checked; + $dbh->do("DELETE FROM autoland_attachments WHERE attach_id = ?", undef, $self->id); + delete $self->{'autoland_checked'}; + delete $self->{'autoland_who'}; + delete $self->{'autoland_status'}; + delete $self->{'autoland_status_when'}; +} + +sub object_end_of_update { + my ($self, $args) = @_; + my $object = $args->{'object'}; + my $user = Bugzilla->user; + my $dbh = Bugzilla->dbh; + my $cgi = Bugzilla->cgi; + my $params = Bugzilla->input_params; + + return if !$user->in_group('autoland'); + + if ($object->isa('Bugzilla::Bug')) { + # First make any needed changes to the branches and try_syntax fields + my $bug_id = $object->bug_id; + my $bug_result = $dbh->selectrow_hashref("SELECT branches, try_syntax + FROM autoland_branches + WHERE bug_id = ?", + { Slice => {} }, $bug_id); + + my $old_branches = ''; + my $old_try_syntax = ''; + if ($bug_result) { + $old_branches = $bug_result->{'branches'}; + $old_try_syntax = $bug_result->{'try_syntax'}; + } + + my $new_branches = $params->{'autoland_branches'} || ''; + my $new_try_syntax = $params->{'autoland_try_syntax'} || ''; + + my $set_attachments = []; + if (ref $params->{'autoland_attachments'}) { + $set_attachments = $params->{'autoland_attachments'}; + } elsif ($params->{'autoland_attachments'}) { + $set_attachments = [ $params->{'autoland_attachments'} ]; + } + + # Check for required values + (!$new_branches && @{$set_attachments}) + && ThrowUserError('autoland_empty_branches'); + ($new_branches && !$new_try_syntax) + && ThrowUserError('autoland_empty_try_syntax'); + + trick_taint($new_branches); + if (!$new_branches && $old_branches) { + $dbh->do("DELETE FROM autoland_branches WHERE bug_id = ?", + undef, $bug_id); + } + elsif ($new_branches && !$old_branches) { + $dbh->do("INSERT INTO autoland_branches (bug_id, branches) + VALUES (?, ?)", undef, $bug_id, $new_branches); + } + elsif ($old_branches ne $new_branches) { + $dbh->do("UPDATE autoland_branches SET branches = ? WHERE bug_id = ?", + undef, $new_branches, $bug_id); + } + + trick_taint($new_try_syntax); + if (($old_try_syntax ne $new_try_syntax) && $new_branches) { + $dbh->do("UPDATE autoland_branches SET try_syntax = ? WHERE bug_id = ?", + undef, $new_try_syntax, $bug_id); + } + + # Next make any changes needed to each of the attachments. + # 1. If an attachment is checked it has a row in the table, if + # there is no row in the table it is not checked. + # 2. Do not allow changes to checked state if status == 'running' or status == 'waiting' + my $check_attachments = ref $params->{'defined_autoland_attachments'} + ? $params->{'defined_autoland_attachments'} + : [ $params->{'defined_autoland_attachments'} ]; + my ($removed_attachments) = diff_arrays($check_attachments, $set_attachments); + foreach my $attachment (@{$object->attachments}) { + next if !$attachment->ispatch; + my $attach_id = $attachment->id; + + my $checked = (grep $_ == $attach_id, @$set_attachments) ? 1 : 0; + my $unchecked = (grep $_ == $attach_id, @$removed_attachments) ? 1 : 0; + my $old_checked = $dbh->selectrow_array("SELECT 1 FROM autoland_attachments + WHERE attach_id = ?", undef, $attach_id) || 0; + + next if $checked && $old_checked; + + if ($unchecked && $old_checked && $attachment->autoland_status =~ /^(failed|success)$/) { + $dbh->do("DELETE FROM autoland_attachments WHERE attach_id = ?", undef, $attach_id); + } + elsif ($checked && !$old_checked) { + $dbh->do("INSERT INTO autoland_attachments (attach_id, who, status, status_when) + VALUES (?, ?, 'waiting', now())", undef, $attach_id, $user->id); + } + } + + } +} + +sub template_before_process { + my ($self, $args) = @_; + my $file = $args->{'file'}; + my $vars = $args->{'vars'}; + + # in the header we just need to set the var to ensure the css gets included + if ($file eq 'bug/show-header.html.tmpl' && Bugzilla->user->in_group('autoland') ) { + $vars->{'autoland'} = 1; + } + + if ($file eq 'bug/edit.html.tmpl') { + $vars->{'autoland_default_try_syntax'} = DEFAULT_TRY_SYNTAX; + } +} + +sub webservice { + my ($self, $args) = @_; + + my $dispatch = $args->{dispatch}; + $dispatch->{TryAutoLand} = "Bugzilla::Extension::TryAutoLand::WebService"; +} + +__PACKAGE__->NAME; diff --git a/extensions/TryAutoLand/bin/TryAutoLand.getBugs.pl b/extensions/TryAutoLand/bin/TryAutoLand.getBugs.pl new file mode 100755 index 000000000..5d05831a8 --- /dev/null +++ b/extensions/TryAutoLand/bin/TryAutoLand.getBugs.pl @@ -0,0 +1,60 @@ +#!/usr/bin/perl -w +# 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 XMLRPC::Lite; +use Data::Dumper; +use HTTP::Cookies; + +################################### +# Need to login first # +################################### + +my $username = shift; +my $password = shift; + +my $cookie_jar = new HTTP::Cookies( file => "/tmp/lwp_cookies.dat" ); + +my $rpc = new XMLRPC::Lite; + +$rpc->proxy('http://fedora/726193/xmlrpc.cgi'); + +$rpc->encoding('UTF-8'); + +$rpc->transport->cookie_jar($cookie_jar); + +my $call = $rpc->call( 'User.login', + { login => $username, password => $password } ); + +if ( $call->faultstring ) { + print $call->faultstring . "\n"; + exit; +} + +# Save the cookies in the cookie file +$rpc->transport->cookie_jar->extract_cookies( + $rpc->transport->http_response ); +$rpc->transport->cookie_jar->save; + +print "Successfully logged in.\n"; + +################################### +# Main call here # +################################### + +$call = $rpc->call('TryAutoLand.getBugs', { status => [] }); + +my $result = ""; +if ( $call->faultstring ) { + print $call->faultstring . "\n"; + exit; +} +else { + $result = $call->result; +} + +print Dumper($result); diff --git a/extensions/TryAutoLand/bin/TryAutoLand.updateStatus.pl b/extensions/TryAutoLand/bin/TryAutoLand.updateStatus.pl new file mode 100755 index 000000000..4a8f92089 --- /dev/null +++ b/extensions/TryAutoLand/bin/TryAutoLand.updateStatus.pl @@ -0,0 +1,65 @@ +#!/usr/bin/perl -w +# 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 XMLRPC::Lite; +use Data::Dumper; +use HTTP::Cookies; + +################################### +# Need to login first # +################################### + +my $username = shift; +my $password = shift; + +my $cookie_jar = new HTTP::Cookies( file => "/tmp/lwp_cookies.dat" ); + +my $rpc = new XMLRPC::Lite; + +$rpc->proxy('http://fedora/726193/xmlrpc.cgi'); + +$rpc->encoding('UTF-8'); + +$rpc->transport->cookie_jar($cookie_jar); + +my $call = $rpc->call( 'User.login', + { login => $username, password => $password } ); + +if ( $call->faultstring ) { + print $call->faultstring . "\n"; + exit; +} + +# Save the cookies in the cookie file +$rpc->transport->cookie_jar->extract_cookies( + $rpc->transport->http_response ); +$rpc->transport->cookie_jar->save; + +print "Successfully logged in.\n"; + +################################### +# Main call here # +################################### + +my $attach_id = shift; +my $action = shift; +my $status = shift; + +$call = $rpc->call('TryAutoLand.update', + { attach_id => $attach_id, action => $action, status => $status }); + +my $result = ""; +if ( $call->faultstring ) { + print $call->faultstring . "\n"; + exit; +} +else { + $result = $call->result; +} + +print Dumper($result); diff --git a/extensions/TryAutoLand/bin/TryAutoLand.updateStatus_json.pl b/extensions/TryAutoLand/bin/TryAutoLand.updateStatus_json.pl new file mode 100755 index 000000000..f39b55229 --- /dev/null +++ b/extensions/TryAutoLand/bin/TryAutoLand.updateStatus_json.pl @@ -0,0 +1,65 @@ +#!/usr/bin/perl -w + +use JSON::RPC::Client; +use Data::Dumper; +use HTTP::Cookies; + +################################### +# Need to login first # +################################### + +my $username = shift; +my $password = shift; + +my $cookie_jar = HTTP::Cookies->new( file => "/tmp/lwp_cookies.dat" ); + +my $rpc = new JSON::RPC::Client; + +$rpc->ua->ssl_opts(verify_hostname => 0); + +my $uri = "http://fedora/726193/jsonrpc.cgi"; + +#$rpc->ua->cookie_jar($cookie_jar); + +#my $result = $rpc->call($uri, { method => 'User.login', params => +# { login => $username, password => $password } }); + +#if ($result) { +# if ($result->is_error) { +# print "Error : ", $result->error_message; +# exit; +# } +# else { +# print "Successfully logged in.\n"; +# } +#} +#else { +# print $rpc->status_line; +#} + +################################### +# Main call here # +################################### + +my $attach_id = shift; +my $action = shift; +my $status = shift; + +$result = $rpc->call($uri, { method => 'TryAutoLand.update', + params => { attach_id => $attach_id, + action => $action, + status => $status, + Bugzilla_login => $username, + Bugzilla_password => $password } }); + +if ($result) { + if ($result->is_error) { + print "Error : ", $result->error_message; + exit; + } +} +else { + print $rpc->status_line; +} + +print Dumper($result->result); diff --git a/extensions/TryAutoLand/lib/Constants.pm b/extensions/TryAutoLand/lib/Constants.pm new file mode 100644 index 000000000..53bad630a --- /dev/null +++ b/extensions/TryAutoLand/lib/Constants.pm @@ -0,0 +1,31 @@ +# 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::TryAutoLand::Constants; + +use strict; + +use base qw(Exporter); + +our @EXPORT = qw( + VALID_STATUSES + WEBSERVICE_USER + DEFAULT_TRY_SYNTAX +); + +use constant VALID_STATUSES => qw( + waiting + running + failed + success +); + +use constant WEBSERVICE_USER => 'autoland-try@mozilla.bugs'; + +use constant DEFAULT_TRY_SYNTAX => '-b do -p all -u none -t none'; + +1; diff --git a/extensions/TryAutoLand/lib/WebService.pm b/extensions/TryAutoLand/lib/WebService.pm new file mode 100644 index 000000000..1088386dd --- /dev/null +++ b/extensions/TryAutoLand/lib/WebService.pm @@ -0,0 +1,189 @@ +# 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::TryAutoLand::WebService; + +use strict; +use warnings; + +use base qw(Bugzilla::WebService); + +use Bugzilla::Error; +use Bugzilla::Util qw(trick_taint); + +use Bugzilla::Extension::TryAutoLand::Constants; + +use constant READ_ONLY => qw( + getBugs +); + +# TryAutoLand.getBugs +# Params: status - List of statuses to filter attachments (only 'waiting' is default) +# Returns: List of bugs, each being a hash of data needed by the AutoLand polling server +# Params +# [ { bug_id => $bug_id1, attachments => [ $attach_id1, $attach_id2 ] }, branches => $branchListFromTextField ... ] + +sub getBugs { + my ($self, $params) = @_; + my $user = Bugzilla->user; + my $dbh = Bugzilla->dbh; + my %bugs; + + if ($user->login ne WEBSERVICE_USER) { + ThrowUserError("auth_failure", { action => "access", + object => "autoland_attachments" }); + } + + my $status_where = "AND status = 'waiting'"; + my $status_values = []; + if (exists $params->{'status'}) { + my $statuses = ref $params->{'status'} + ? $params->{'status'} + : [ $params->{'status'} ]; + foreach my $status (@$statuses) { + if (grep($_ eq $status, VALID_STATUSES)) { + trick_taint($status); + push(@$status_values, $status); + } + } + if (@$status_values) { + my @qmarks = ("?") x @$status_values; + $status_where = "AND " . $dbh->sql_in('status', \@qmarks); + } + + } + + my $attachments = $dbh->selectall_arrayref(" + SELECT attachments.bug_id, + attachments.attach_id, + autoland_attachments.who, + autoland_attachments.status, + autoland_attachments.status_when + FROM attachments, autoland_attachments + WHERE attachments.attach_id = autoland_attachments.attach_id + $status_where + ORDER BY attachments.bug_id", + undef, @$status_values); + + foreach my $row (@$attachments) { + my ($bug_id, $attach_id, $al_who, $al_status, $al_status_when) = @$row; + + my $al_user = Bugzilla::User->new($al_who); + + # Silent Permission checks + next if !$user->can_see_bug($bug_id); + my $attachment = Bugzilla::Attachment->new($attach_id); + next if !$attachment + || $attachment->isobsolete + || ($attachment->isprivate && !$user->is_insider); + + $bugs{$bug_id} = {} if !exists $bugs{$bug_id}; + + if (!$bugs{$bug_id}{'branches'}) { + my $bug_result = $dbh->selectrow_hashref("SELECT branches, try_syntax + FROM autoland_branches + WHERE bug_id = ?", + undef, $bug_id); + $bugs{$bug_id}{'branches'} = $bug_result->{'branches'}; + $bugs{$bug_id}{'try_syntax'} = $bug_result->{'try_syntax'}; + } + + $bugs{$bug_id}{'attachments'} = [] if !exists $bugs{$bug_id}{'attachments'}; + + push(@{$bugs{$bug_id}{'attachments'}}, { + id => $self->type('int', $attach_id), + who => $self->type('string', $al_user->login), + status => $self->type('string', $al_status), + status_when => $self->type('dateTime', $al_status_when), + }); + } + + return [ + map + { { bug_id => $_, attachments => $bugs{$_}{'attachments'}, + branches => $bugs{$_}{'branches'}, try_syntax => $bugs{$_}{'try_syntax'} } } + keys %bugs + ]; +} + +# TryAutoLand.update({ attach_id => $attach_id, action => $action, status => $status }) +# Let's BMO know if a patch has landed or not and BMO will update the auto_land table accordingly +# If $action eq 'status', $status will be a predetermined set of status values -- when waiting, +# the UI for submitting autoland will be locked and once complete status update occurs or the +# mapping is removed, the UI can be unlocked for the $attach_id +# Allowed statuses: waiting, running, failed, or success +# +# If $action eq 'remove', the attach_id will be removed from the mapping table and the UI +# will be unlocked for the $attach_id. + +sub update { + my ($self, $params) = @_; + my $user = Bugzilla->user; + my $dbh = Bugzilla->dbh; + + if ($user->login ne WEBSERVICE_USER) { + ThrowUserError("auth_failure", { action => "modify", + object => "autoland_attachments" }); + } + + foreach my $param ('attach_id', 'action') { + defined $params->{$param} + || ThrowCodeError('param_required', + { param => $param }); + } + + my $action = delete $params->{'action'}; + my $attach_id = delete $params->{'attach_id'}; + my $status = delete $params->{'status'}; + + if ($action eq 'status' && !$status) { + ThrowCodeError('param_required', { param => 'status' }); + } + + grep($_ eq $action, ('remove', 'status')) + || ThrowUserError('autoland_update_invalid_action', + { action => $action, + valid => ["remove", "status"] }); + + my $attachment = Bugzilla::Attachment->new($attach_id); + $attachment + || ThrowUserError('autoland_invalid_attach_id', + { attach_id => $attach_id }); + + # Loud Permission checks + if (!$user->can_see_bug($attachment->bug_id)) { + ThrowUserError("bug_access_denied", { bug_id => $attachment->bug_id }); + } + if ($attachment->isprivate && !$user->is_insider) { + ThrowUserError('auth_failure', { action => 'access', + object => 'attachment', + attach_id => $attachment->id }); + } + + $attachment->autoland_checked + || ThrowUserError('autoland_invalid_attach_id', + { attach_id => $attach_id }); + + if ($action eq 'status') { + # Update the status + $attachment->autoland_update_status($status); + + return { + id => $self->type('int', $attachment->id), + who => $self->type('string', $attachment->autoland_who->login), + status => $self->type('string', $attachment->autoland_status), + status_when => $self->type('dateTime', $attachment->autoland_status_when), + }; + } + elsif ($action eq 'remove') { + $attachment->autoland_remove(); + } + + return {}; +} + +1; diff --git a/extensions/TryAutoLand/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl b/extensions/TryAutoLand/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl new file mode 100644 index 000000000..ed6224afe --- /dev/null +++ b/extensions/TryAutoLand/template/en/default/hook/bug/edit-after_custom_fields.html.tmpl @@ -0,0 +1,101 @@ +[%# 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.in_group('autoland') %] + [% autoland_attachments = [] %] + [% autoland_waiting = 0 %] + [% autoland_running = 0 %] + [% autoland_finished = 0 %] + [% FOREACH attachment = bug.attachments %] + [% NEXT IF attachment.isprivate && !user.is_insider && attachment.attacher.id != user.id %] + [% NEXT IF attachment.isobsolete %] + [% NEXT IF !attachment.ispatch %] + [% autoland_attachments.push(attachment) %] + [% IF attachment.autoland_checked %] + [% IF attachment.autoland_status == 'waiting' %] + [% autoland_waiting = autoland_waiting + 1 %] + [% END %] + [% IF attachment.autoland_status == 'running' %] + [% autoland_running = autoland_running + 1 %] + [% END %] + [% IF attachment.autoland_status == 'success' || attachment.autoland_status == 'failed' %] + [% autoland_finished = autoland_finished + 1 %] + [% END %] + [% END %] + [% END %] + [% IF autoland_attachments.size %] + <tr> + <th class="field_label field_land_autoland"> + <a title="[% help_html.autoland FILTER txt FILTER collapse FILTER html %]" + class="field_help_link" href="https://wiki.mozilla.org/Build:Autoland"> + AutoLand:</a> + </th> + <td> + <span id="autoland_edit_container"> + (<a href="#" id="autoland_edit_action">edit</a>) + Total: [% autoland_attachments.size FILTER html %] - + <span class="autoland_waiting">Waiting:</span> [% autoland_waiting FILTER html %] - + <span class="autoland_running">Running:</span> [% autoland_running FILTER html %] - + <span class="autoland_success">Finished:</span> [% autoland_finished FILTER html %] + </span> + <div id="autoland_edit_input"> + Branches (required):<br> + <input type="text" id="autoland_branches" name="autoland_branches" + value="[% bug.autoland_branches FILTER html %]" size="40" + class="text_input"><br> + Try Syntax (required): (Default: [% autoland_default_try_syntax FILTER html %])<br> + <input type="text" id="autoland_try_syntax" name="autoland_try_syntax" + value="[% bug.autoland_try_syntax || autoland_default_try_syntax FILTER html %]" size="40" + class="text_input"><br> + Patches: + <br> + <table id="autoland_edit_table"> + [% FOREACH attachment = autoland_attachments %] + <tr> + <td> + [% IF attachment.autoland_checked %] + <input type="hidden" name="defined_autoland_attachments" + value="[% attachment.id FILTER html %]"> + [% END %] + <input type="checkbox" name="autoland_attachments" value="[% attachment.id FILTER html %]" + [% ' checked="checked"' IF attachment.autoland_checked %] + [% IF attachment.autoland_status == 'running' || attachment.autoland_status == 'waiting' %] + disabled="disabled" + [% END %]> + </td> + <td> + <span title="[% attachment.description FILTER html %]"> + [% attachment.filename FILTER html %] + </span> + <td> + [% IF attachment.autoland_checked %] + <span class="autoland_[% attachment.autoland_status FILTER html %]"> + [% attachment.autoland_status FILTER html %] + </span> + [% END %] + </td> + <td> + [% IF attachment.autoland_checked %] + [% attachment.autoland_status_when FILTER time('%Y-%m-%d %H:%M') %] + [% END %] + </td> + </tr> + [% END %] + </table> + </div> + <script type="text/javascript"> + hideEditableField('autoland_edit_container', + 'autoland_edit_input', + 'autoland_edit_action', + '', + ''); + </script> + </td> + </tr> + [% END %] +[% END %] diff --git a/extensions/TryAutoLand/template/en/default/hook/bug/field-help-end.none.tmpl b/extensions/TryAutoLand/template/en/default/hook/bug/field-help-end.none.tmpl new file mode 100644 index 000000000..899db60c4 --- /dev/null +++ b/extensions/TryAutoLand/template/en/default/hook/bug/field-help-end.none.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. + #%] + +[% + vars.help_html.autoland = + "TryAutoLand is a BMO extension that allows integration with the $terms.Bugzilla + AutoLanding system. Select patches on a $terms.bug will be picked up + automatically and landed on the try build server for specified branches. + Results of the try build will be sent back to the bug report as comments." +%] diff --git a/extensions/TryAutoLand/template/en/default/hook/bug/show-header-end.html.tmpl b/extensions/TryAutoLand/template/en/default/hook/bug/show-header-end.html.tmpl new file mode 100644 index 000000000..c61f478ea --- /dev/null +++ b/extensions/TryAutoLand/template/en/default/hook/bug/show-header-end.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 autoland %] + [% style_urls.push('extensions/TryAutoLand/web/style.css') %] +[% END %] diff --git a/extensions/TryAutoLand/template/en/default/hook/global/user-error-auth_failure_object.html.tmpl b/extensions/TryAutoLand/template/en/default/hook/global/user-error-auth_failure_object.html.tmpl new file mode 100644 index 000000000..50a1e48d5 --- /dev/null +++ b/extensions/TryAutoLand/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 == 'autoland_attachments' %] + AutoLand attachments +[% END %] diff --git a/extensions/TryAutoLand/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/TryAutoLand/template/en/default/hook/global/user-error-errors.html.tmpl new file mode 100644 index 000000000..c12950dcf --- /dev/null +++ b/extensions/TryAutoLand/template/en/default/hook/global/user-error-errors.html.tmpl @@ -0,0 +1,33 @@ +[%# 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 == "autoland_invalid_status" %] + [% title = "AutoLand Invalid Status" %] + The status '[% status FILTER html %]' is not a valid + status for the AutoLand extension. Valid statuses + are [% valid.join(', ') FILTER html %]. + +[% ELSIF error == "autoland_invalid_attach_id" %] + [% title = "AutoLand Invalid Attachment ID" %] + The attachment id '[% attach_id FILTER html %]' is not + a valid id for the AutoLand extension. + +[% ELSIF error == "autoland_empty_try_syntax" %] + [% title = "AutoLand Empty Try Syntax" %] + You cannot have a value for Branches and have an empty Try Syntax value. + +[% ELSIF error == "autoland_empty_branches" %] + [% title = "AutoLand Empty Branches" %] + You cannot check one or more patches for AutoLanding and have an empty + Branches value. + +[% ELSIF error == "autoland_update_invalid_action" %] + [% title = "AutoLand Update Invalid Action" %] + The action '[% action FILTER html %]' is not a valid action. + Valid actions are [% valid.join(', ') FILTER html %]. +[% END %] diff --git a/extensions/TryAutoLand/web/style.css b/extensions/TryAutoLand/web/style.css new file mode 100644 index 000000000..99409c0c0 --- /dev/null +++ b/extensions/TryAutoLand/web/style.css @@ -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. + */ + +.autoland_waiting { + color: blue; +} + +.autoland_running { + color: orange; +} + +.autoland_failed { + color: red; +} + +.autoland_success { + color: green; +} diff --git a/extensions/TypeSniffer/Config.pm b/extensions/TypeSniffer/Config.pm new file mode 100644 index 000000000..6ad03b362 --- /dev/null +++ b/extensions/TypeSniffer/Config.pm @@ -0,0 +1,40 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the TypeSniffer Bugzilla Extension. +# +# The Initial Developer of the Original Code is The Mozilla Foundation. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Gervase Markham <gerv@mozilla.org> + +package Bugzilla::Extension::TypeSniffer; +use strict; + +use constant NAME => 'TypeSniffer'; + +use constant REQUIRED_MODULES => [ + { + package => 'File-MimeInfo', + module => 'File::MimeInfo::Magic', + version => '0' + }, + { + package => 'IO-stringy', + module => 'IO::Scalar', + version => '0' + }, +]; + +__PACKAGE__->NAME;
\ No newline at end of file diff --git a/extensions/TypeSniffer/Extension.pm b/extensions/TypeSniffer/Extension.pm new file mode 100644 index 000000000..c593b76e8 --- /dev/null +++ b/extensions/TypeSniffer/Extension.pm @@ -0,0 +1,100 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the TypeSniffer Bugzilla Extension. +# +# The Initial Developer of the Original Code is The Mozilla Foundation. +# Portions created by the Initial Developer are Copyright (C) 2010 the +# Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Gervase Markham <gerv@mozilla.org> + +package Bugzilla::Extension::TypeSniffer; +use strict; +use base qw(Bugzilla::Extension); + +use File::MimeInfo::Magic; +use IO::Scalar; + +our $VERSION = '1'; + +# These extensions override/supplement File::MimeInfo::Magic's detection. +our %EXTENSION_OVERRIDES = ( + '.lang' => 'text/plain', +); + +################################################################################ +# This extension uses magic to guess MIME types for data where the browser has +# told us it's application/octet-stream (probably because there's no file +# extension, or it's a text type with a non-.txt file extension). +################################################################################ +sub attachment_process_data { + my ($self, $args) = @_; + my $attributes = $args->{'attributes'}; + my $params = Bugzilla->input_params; + + # If we have autodetected application/octet-stream from the Content-Type + # header, let's have a better go using a sniffer. + if ($params->{'contenttypemethod'} && + $params->{'contenttypemethod'} eq 'autodetect' && + $attributes->{'mimetype'} eq 'application/octet-stream') + { + my $filename = $attributes->{'filename'} . ''; + + # Check for an override first + if ($filename =~ /^.+(\..+$)/) { + my $ext = lc($1); + if (exists $EXTENSION_OVERRIDES{$ext}) { + $attributes->{'mimetype'} = $EXTENSION_OVERRIDES{$ext}; + return; + } + } + + # Then try file extension detection + my $mimetype = mimetype($filename); + if ($mimetype) { + $attributes->{'mimetype'} = $mimetype; + return; + } + + # data attribute can be either scalar data or filehandle + # bugzilla.org/docs/3.6/en/html/api/Bugzilla/Attachment.html#create + my $fh = $attributes->{'data'}; + if (!ref($fh)) { + my $data = $attributes->{'data'}; + $fh = new IO::Scalar \$data; + } + else { + # CGI.pm sends us an Fh that isn't actually an IO::Handle, but + # has a method for getting an actual handle out of it. + if (!$fh->isa('IO::Handle')) { + $fh = $fh->handle; + # ->handle returns an literal IO::Handle, even though the + # underlying object is a file. So we rebless it to be a proper + # IO::File object so that we can call ->seek on it and so on. + # Just in case CGI.pm fixes this some day, we check ->isa first. + if (!$fh->isa('IO::File')) { + bless $fh, 'IO::File'; + } + } + } + + $mimetype = mimetype($fh); + $fh->seek(0, 0); + if ($mimetype) { + $attributes->{'mimetype'} = $mimetype; + } + } +} + +__PACKAGE__->NAME; diff --git a/extensions/UserProfile/Config.pm b/extensions/UserProfile/Config.pm new file mode 100644 index 000000000..99dca9e02 --- /dev/null +++ b/extensions/UserProfile/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::UserProfile; +use strict; + +use constant NAME => 'UserProfile'; +use constant REQUIRED_MODULES => [ ]; +use constant OPTIONAL_MODULES => [ ]; + +__PACKAGE__->NAME; diff --git a/extensions/UserProfile/Extension.pm b/extensions/UserProfile/Extension.pm new file mode 100644 index 000000000..8671ba755 --- /dev/null +++ b/extensions/UserProfile/Extension.pm @@ -0,0 +1,554 @@ +# 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::UserProfile; + +use strict; +use warnings; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Constants; +use Bugzilla::Extension::UserProfile::TimeAgo qw(time_ago); +use Bugzilla::Extension::UserProfile::Util; +use Bugzilla::Install::Filesystem; +use Bugzilla::User; +use Bugzilla::Util qw(datetime_from); +use Email::Address; +use Scalar::Util qw(blessed); + +our $VERSION = '1'; + +# +# user methods +# + +BEGIN { + *Bugzilla::User::last_activity_ts = \&_user_last_activity_ts; + *Bugzilla::User::set_last_activity_ts = \&_user_set_last_activity_ts; + *Bugzilla::User::last_statistics_ts = \&_user_last_statistics_ts; + *Bugzilla::User::clear_last_statistics_ts = \&_user_clear_last_statistics_ts; + *Bugzilla::User::address = \&_user_address; +} + +sub _user_last_activity_ts { $_[0]->{last_activity_ts} } +sub _user_last_statistics_ts { $_[0]->{last_statistics_ts} } +sub _user_address { Email::Address->new(undef, $_[0]->email) } + +sub _user_set_last_activity_ts { + my ($self, $value) = @_; + $self->set('last_activity_ts', $_[1]); + + # we update the database directly to avoid audit_log entries + Bugzilla->dbh->do( + "UPDATE profiles SET last_activity_ts = ? WHERE userid = ?", + undef, + $value, $self->id); + Bugzilla->memcached->clear({ table => 'profiles', id => $self->id }); +} + +sub _user_clear_last_statistics_ts { + my ($self) = @_; + $self->set('last_statistics_ts', undef); + + # we update the database directly to avoid audit_log entries + Bugzilla->dbh->do( + "UPDATE profiles SET last_statistics_ts = NULL WHERE userid = ?", + undef, + $self->id); + Bugzilla->memcached->clear({ table => 'profiles', id => $self->id }); +} + +# +# hooks +# + +sub bug_after_create { + my ($self, $args) = @_; + $self->_bug_touched($args); +} + +sub bug_after_update { + my ($self, $args) = @_; + $self->_bug_touched($args); +} + +sub _bug_touched { + my ($self, $args) = @_; + my $bug = $args->{bug}; + + my $user = Bugzilla->user; + my ($assigned_to, $qa_contact); + + # bug update + if (exists $args->{changes}) { + return unless + scalar(keys %{ $args->{changes} }) + || exists $args->{bug}->{added_comments}; + + # if the assignee or qa-contact is changed to someone other than the + # current user, update them + if (exists $args->{changes}->{assigned_to} + && $args->{changes}->{assigned_to}->[1] ne $user->login) + { + $assigned_to = $bug->assigned_to; + } + if (exists $args->{changes}->{qa_contact} + && ($args->{changes}->{qa_contact}->[1] || '') ne $user->login) + { + $qa_contact = $bug->qa_contact; + } + + # if the product is changed, we need to recount everyone involved with + # this bug + if (exists $args->{changes}->{product}) { + tag_for_recount_from_bug($bug->id); + } + + } + # new bug + else { + # if the assignee or qa-contact is created set to someone other than + # the current user, update them + if ($bug->assigned_to->id != $user->id) { + $assigned_to = $bug->assigned_to; + } + if ($bug->qa_contact && $bug->qa_contact->id != $user->id) { + $qa_contact = $bug->qa_contact; + } + } + + my $dbh = Bugzilla->dbh; + $dbh->bz_start_transaction(); + + # update user's last_activity_ts + eval { + $user->set_last_activity_ts($args->{timestamp}); + $self->_recalc_remove($user); + }; + if ($@) { + warn $@; + $self->_recalc_insert($user); + } + + # clear the last_statistics_ts for assignee/qa-contact to force a recount + # at the next poll + if ($assigned_to) { + eval { + $assigned_to->clear_last_statistics_ts(); + $self->_recalc_remove($assigned_to); + }; + if ($@) { + warn $@; + $self->_recalc_insert($assigned_to); + } + } + if ($qa_contact) { + eval { + $qa_contact->clear_last_statistics_ts(); + $self->_recalc_remove($qa_contact); + }; + if ($@) { + warn $@; + $self->_recalc_insert($qa_contact); + } + } + + $dbh->bz_commit_transaction(); +} + +sub _recalc_insert { + my ($self, $user) = @_; + Bugzilla->dbh->do( + "INSERT IGNORE INTO profiles_statistics_recalc SET user_id=?", + undef, $user->id + ); +} + +sub _recalc_remove { + my ($self, $user) = @_; + Bugzilla->dbh->do( + "DELETE FROM profiles_statistics_recalc WHERE user_id=?", + undef, $user->id + ); +} + +sub object_end_of_create { + my ($self, $args) = @_; + $self->_object_touched($args); +} + +sub object_end_of_update { + my ($self, $args) = @_; + $self->_object_touched($args); +} + +sub _object_touched { + my ($self, $args) = @_; + my $object = $args->{object} + or return; + return if exists $args->{changes} && !scalar(keys %{ $args->{changes} }); + + if ($object->isa('Bugzilla::Attachment')) { + # if an attachment is created or updated, that counts as user activity + my $user = Bugzilla->user; + my $timestamp = Bugzilla->dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); + eval { + $user->set_last_activity_ts($timestamp); + $self->_recalc_remove($user); + }; + if ($@) { + warn $@; + $self->_recalc_insert($user); + } + } + elsif ($object->isa('Bugzilla::Product') && exists $args->{changes}->{name}) { + # if a product is renamed by an admin, rename in the + # profiles_statistics_products table + Bugzilla->dbh->do( + "UPDATE profiles_statistics_products SET product=? where product=?", + undef, + $args->{changes}->{name}->[1], $args->{changes}->{name}->[0], + ); + } +} + +sub reorg_move_bugs { + my ($self, $args) = @_; + my $bug_ids = $args->{bug_ids}; + printf "Touching user profile data for %s bugs.\n", scalar(@$bug_ids); + my $count = 0; + foreach my $bug_id (@$bug_ids) { + $count += tag_for_recount_from_bug($bug_id); + } + print "Updated $count users.\n"; +} + +sub merge_users_before { + my ($self, $args) = @_; + my ($old_id, $new_id) = @$args{qw(old_id new_id)}; + # when users are merged, we have to delete all the statistics for both users + # we'll recalcuate the stats after the merge + print "deleting user profile statistics for $old_id and $new_id\n"; + my $dbh = Bugzilla->dbh; + foreach my $table (qw( profiles_statistics profiles_statistics_status profiles_statistics_products )) { + $dbh->do("DELETE FROM $table WHERE " . $dbh->sql_in('user_id', [ $old_id, $new_id ])); + } +} + +sub merge_users_after { + my ($self, $args) = @_; + my $new_id = $args->{new_id}; + print "generating user profile statistics $new_id\n"; + update_statistics_by_user($new_id); +} + +sub webservice_user_get { + my ($self, $args) = @_; + my ($service, $users) = @$args{qw(webservice users)}; + + my $dbh = Bugzilla->dbh; + my $ids = [ + map { blessed($_->{id}) ? $_->{id}->value : $_->{id} } + grep { exists $_->{id} } + @$users + ]; + return unless @$ids; + my $timestamps = $dbh->selectall_hashref( + "SELECT userid,last_activity_ts FROM profiles WHERE " . $dbh->sql_in('userid', $ids), + 'userid', + ); + foreach my $user (@$users) { + my $id = blessed($user->{id}) ? $user->{id}->value : $user->{id}; + $user->{last_activity} = $service->type('dateTime', $timestamps->{$id}->{last_activity_ts}); + } +} + +sub template_before_create { + my ($self, $args) = @_; + $args->{config}->{FILTERS}->{timeago} = sub { + my ($time_str) = @_; + return time_ago(datetime_from($time_str, 'UTC')); + }; +} + +sub page_before_template { + my ($self, $args) = @_; + my ($vars, $page) = @$args{qw(vars page_id)}; + return unless $page eq 'user_profile.html'; + my $user = Bugzilla->user; + + # determine user to display + my ($target, $login); + my $input = Bugzilla->input_params; + if (my $user_id = $input->{user_id}) { + # load from user_id + $user_id = 0 if $user_id =~ /\D/; + $target = Bugzilla::User->check({ id => $user_id }); + } else { + # loading from login name requires authentication + Bugzilla->login(LOGIN_REQUIRED); + $login = $input->{login}; + if (!$login) { + # show current user's profile by default + $target = $user; + } else { + my $limit = Bugzilla->params->{'maxusermatches'} + 1; + my $users = Bugzilla::User::match($login, $limit, 1); + if (scalar(@$users) == 1) { + # always allow singular matches without confirmation + $target = $users->[0]; + } else { + Bugzilla::User::match_field({ 'login' => {'type' => 'single'} }); + $target = Bugzilla::User->check($login); + } + } + } + $login ||= $target->login; + + # load statistics into $vars + my $dbh = Bugzilla->switch_to_shadow_db; + + my $stats = $dbh->selectall_hashref( + "SELECT name, count + FROM profiles_statistics + WHERE user_id = ?", + "name", + undef, + $target->id, + ); + map { $stats->{$_} = $stats->{$_}->{count} } keys %$stats; + + my $statuses = $dbh->selectall_hashref( + "SELECT status, count + FROM profiles_statistics_status + WHERE user_id = ?", + "status", + undef, + $target->id, + ); + map { $statuses->{$_} = $statuses->{$_}->{count} } keys %$statuses; + + my $products = $dbh->selectall_arrayref( + "SELECT product, count + FROM profiles_statistics_products + WHERE user_id = ? + ORDER BY product = '', count DESC", + { Slice => {} }, + $target->id, + ); + + # ensure there's always an "other" product entry + my ($other_product) = grep { $_->{product} eq '' } @$products; + if (!$other_product) { + $other_product = { product => '', count => 0 }; + push @$products, $other_product; + } + + # load product objects and validate product visibility + foreach my $product (@$products) { + next if $product->{product} eq ''; + my $product_obj = Bugzilla::Product->new({ name => $product->{product} }); + if (!$product_obj || !$user->can_see_product($product_obj->name)) { + # products not accessible to current user are moved into "other" + $other_product->{count} += $product->{count}; + $product->{count} = 0; + } else { + $product->{product} = $product_obj; + } + } + + # set other's name, and remove empty products + $other_product->{product} = { name => 'Other' }; + $products = [ grep { $_->{count} } @$products ]; + + $vars->{stats} = $stats; + $vars->{statuses} = $statuses; + $vars->{products} = $products; + $vars->{login} = $login; + $vars->{target} = $target; +} + +sub object_columns { + my ($self, $args) = @_; + my ($class, $columns) = @$args{qw(class columns)}; + if ($class->isa('Bugzilla::User')) { + push(@$columns, qw(last_activity_ts last_statistics_ts)); + } +} + +sub object_update_columns { + my ($self, $args) = @_; + my ($object, $columns) = @$args{qw(object columns)}; + if ($object->isa('Bugzilla::User')) { + push(@$columns, qw(last_activity_ts last_statistics_ts)); + } +} + +# +# installation +# + +sub db_schema_abstract_schema { + my ($self, $args) = @_; + $args->{'schema'}->{'profiles_statistics'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + user_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE', + } + }, + name => { + TYPE => 'VARCHAR(30)', + NOTNULL => 1, + }, + count => { + TYPE => 'INT', + NOTNULL => 1, + }, + ], + INDEXES => [ + profiles_statistics_name_idx => { + FIELDS => [ 'user_id', 'name' ], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'profiles_statistics_status'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + user_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE', + } + }, + status => { + TYPE => 'VARCHAR(64)', + NOTNULL => 1, + }, + count => { + TYPE => 'INT', + NOTNULL => 1, + }, + ], + INDEXES => [ + profiles_statistics_status_idx => { + FIELDS => [ 'user_id', 'status' ], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'profiles_statistics_products'} = { + FIELDS => [ + id => { + TYPE => 'MEDIUMSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + user_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE', + } + }, + product => { + TYPE => 'VARCHAR(64)', + NOTNULL => 1, + }, + count => { + TYPE => 'INT', + NOTNULL => 1, + }, + ], + INDEXES => [ + profiles_statistics_products_idx => { + FIELDS => [ 'user_id', 'product' ], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'profiles_statistics_recalc'} = { + FIELDS => [ + user_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE', + } + }, + ], + INDEXES => [ + profiles_statistics_recalc_idx => { + FIELDS => [ 'user_id' ], + TYPE => 'UNIQUE', + }, + ], + }; + $args->{'schema'}->{'profiles_statistics_recalc'} = { + FIELDS => [ + user_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'profiles', + COLUMN => 'userid', + DELETE => 'CASCADE', + } + }, + ], + INDEXES => [ + profiles_statistics_recalc_idx => { + FIELDS => [ 'user_id' ], + TYPE => 'UNIQUE', + }, + ], + }; +} + +sub install_update_db { + my $dbh = Bugzilla->dbh; + $dbh->bz_add_column('profiles', 'last_activity_ts', { TYPE => 'DATETIME' }); + $dbh->bz_add_column('profiles', 'last_statistics_ts', { TYPE => 'DATETIME' }); +} + +sub install_filesystem { + my ($self, $args) = @_; + my $files = $args->{'files'}; + my $extensions_dir = bz_locations()->{'extensionsdir'}; + my $script_name = $extensions_dir . "/" . __PACKAGE__->NAME . "/bin/update.pl"; + $files->{$script_name} = { + perms => Bugzilla::Install::Filesystem::WS_EXECUTE + }; + $script_name = $extensions_dir . "/" . __PACKAGE__->NAME . "/bin/migrate.pl"; + $files->{$script_name} = { + perms => Bugzilla::Install::Filesystem::OWNER_EXECUTE + }; +} + +__PACKAGE__->NAME; diff --git a/extensions/UserProfile/bin/migrate.pl b/extensions/UserProfile/bin/migrate.pl new file mode 100755 index 000000000..147edef9c --- /dev/null +++ b/extensions/UserProfile/bin/migrate.pl @@ -0,0 +1,43 @@ +#!/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::Extension::UserProfile::Util; +use Bugzilla::Install::Util qw(indicate_progress); + +Bugzilla->usage_mode(USAGE_MODE_CMDLINE); +my $dbh = Bugzilla->dbh; + +my $user_ids = $dbh->selectcol_arrayref( + "SELECT userid + FROM profiles + WHERE last_activity_ts IS NULL + ORDER BY userid" +); + +my ($current, $total) = (1, scalar(@$user_ids)); +foreach my $user_id (@$user_ids) { + indicate_progress({ current => $current++, total => $total, every => 25 }); + my $ts = last_user_activity($user_id); + next unless $ts; + $dbh->do( + "UPDATE profiles SET last_activity_ts = ? WHERE userid = ?", + undef, + $ts, $user_id); +} diff --git a/extensions/UserProfile/bin/update.pl b/extensions/UserProfile/bin/update.pl new file mode 100755 index 000000000..2a4997aee --- /dev/null +++ b/extensions/UserProfile/bin/update.pl @@ -0,0 +1,81 @@ +#!/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; + +use FindBin qw($Bin); +use lib "$Bin/../../.."; + +use Bugzilla; +BEGIN { Bugzilla->extensions() } + +use Bugzilla::Constants; +use Bugzilla::Extension::UserProfile::Util; +use Bugzilla::User; + +Bugzilla->usage_mode(USAGE_MODE_CMDLINE); +my $dbh = Bugzilla->dbh; +my $user_ids; +my $verbose = grep { $_ eq '-v' } @ARGV; + +$user_ids = $dbh->selectcol_arrayref( + "SELECT user_id + FROM profiles_statistics_recalc + ORDER BY user_id", + { Slice => {} } +); + +if (@$user_ids) { + print "recalculating last_user_activity\n"; + my ($count, $total) = (0, scalar(@$user_ids)); + foreach my $user_id (@$user_ids) { + if ($verbose) { + $count++; + my $login = user_id_to_login($user_id); + print "$count/$total $login ($user_id)\n"; + } + $dbh->do( + "UPDATE profiles + SET last_activity_ts = ?, + last_statistics_ts = NULL + WHERE userid = ?", + undef, + last_user_activity($user_id), + $user_id + ); + Bugzilla->memcached->clear({ table => 'profiles', id => $user_id }); + } + $dbh->do( + "DELETE FROM profiles_statistics_recalc WHERE " . $dbh->sql_in('user_id', $user_ids) + ); +} + +$user_ids = $dbh->selectcol_arrayref( + "SELECT userid + FROM profiles + WHERE last_activity_ts IS NOT NULL + AND (last_statistics_ts IS NULL + OR last_activity_ts > last_statistics_ts) + ORDER BY userid", + { Slice => {} } +); + +if (@$user_ids) { + $verbose && print "updating statistics\n"; + my ($count, $total) = (0, scalar(@$user_ids)); + foreach my $user_id (@$user_ids) { + if ($verbose) { + $count++; + my $login = user_id_to_login($user_id); + print "$count/$total $login ($user_id)\n"; + } + update_statistics_by_user($user_id); + } +} diff --git a/extensions/UserProfile/lib/TimeAgo.pm b/extensions/UserProfile/lib/TimeAgo.pm new file mode 100644 index 000000000..d20f0edf5 --- /dev/null +++ b/extensions/UserProfile/lib/TimeAgo.pm @@ -0,0 +1,179 @@ +package Bugzilla::Extension::UserProfile::TimeAgo; + +use strict; +use utf8; +use DateTime; +use Carp; +use Exporter qw(import); + +use if $ENV{ARCH_64BIT}, 'integer'; + +our @EXPORT_OK = qw(time_ago); + +our $VERSION = '0.06'; + +my @ranges = ( + [ -1, 'in the future' ], + [ 60, 'just now' ], + [ 900, 'a few minutes ago'], # 15*60 + [ 3000, 'less than an hour ago'], # 50*60 + [ 4500, 'about an hour ago'], # 75*60 + [ 7200, 'more than an hour ago'], # 2*60*60 + [ 21600, 'several hours ago'], # 6*60*60 + [ 86400, 'today', sub { # 24*60*60 + my $time = shift; + my $now = shift; + if ( $time->day < $now->day + or $time->month < $now->month + or $time->year < $now->year + ) { + return 'yesterday' + } + if ($time->hour < 5) { + return 'tonight' + } + if ($time->hour < 10) { + return 'this morning' + } + if ($time->hour < 15) { + return 'today' + } + if ($time->hour < 19) { + return 'this afternoon' + } + return 'this evening' + }], + [ 172800, 'yesterday'], # 2*24*60*60 + [ 604800, 'this week'], # 7*24*60*60 + [ 1209600, 'last week'], # 2*7*24*60*60 + [ 2678400, 'this month', sub { # 31*24*60*60 + my $time = shift; + my $now = shift; + if ($time->year == $now->year and $time->month == $now->month) { + return 'this month' + } + return 'last month' + }], + [ 5356800, 'last month'], # 2*31*24*60*60 + [ 24105600, 'several months ago'], # 9*31*24*60*60 + [ 31536000, 'about a year ago'], # 365*24*60*60 + [ 34214400, 'last year'], # (365+31)*24*60*60 + [ 63072000, 'more than a year ago'], # 2*365*24*60*60 + [ 283824000, 'several years ago'], # 9*365*24*60*60 + [ 315360000, 'about a decade ago'], # 10*365*24*60*60 + [ 630720000, 'last decade'], # 20*365*24*60*60 + [ 2838240000, 'several decades ago'], # 90*365*24*60*60 + [ 3153600000, 'about a century ago'], # 100*365*24*60*60 + [ 6307200000, 'last century'], # 200*365*24*60*60 + [ 6622560000, 'more than a century ago'], # 210*365*24*60*60 + [ 28382400000, 'several centuries ago'], # 900*365*24*60*60 + [ 31536000000, 'about a millenium ago'], # 1000*365*24*60*60 + [ 63072000000, 'more than a millenium ago'], # 2000*365*24*60*60 +); + +sub time_ago { + my ($time, $now) = @_; + + if (not defined $time or not $time->isa('DateTime')) { + croak('DateTime::Duration::Fuzzy::time_ago needs a DateTime object as first parameter') + } + if (not defined $now) { + $now = DateTime->now(); + } + if (not $now->isa('DateTime')) { + croak('Invalid second parameter provided to DateTime::Duration::Fuzzy::time_ago; it must be a DateTime object if provided') + } + + my $dur = $now->subtract_datetime_absolute($time)->in_units('seconds'); + + foreach my $range ( @ranges ) { + if ( $dur <= $range->[0] ) { + if ( $range->[2] ) { + return $range->[2]->($time, $now) + } + return $range->[1] + } + } + + return 'millenia ago' +} + +1 + +__END__ + +=head1 NAME + +DateTime::Duration::Fuzzy -- express dates as fuzzy human-friendly strings + +=head1 SYNOPSIS + + use DateTime::Duration::Fuzzy qw(time_ago); + use DateTime; + + my $now = DateTime->new( + year => 2010, month => 12, day => 12, + hour => 19, minute => 59, + ); + my $then = DateTime->new( + year => 2010, month => 12, day => 12, + hour => 15, + ); + print time_ago($then, $now); + # outputs 'several hours ago' + + print time_ago($then); + # $now taken from C<time> function + +=head1 DESCRIPTION + +DateTime::Duration::Fuzzy is inspired from the timeAgo jQuery module +L<http://timeago.yarp.com/>. + +It takes two DateTime objects -- first one representing a moment in the past +and second optional one representine the present, and returns a human-friendly +fuzzy expression of the time gone. + +=head2 functions + +=over 4 + +=item time_ago($then, $now) + +The only exportable function. + +First obligatory parameter is a DateTime object. + +Second optional parameter is also a DateTime object. +If it's not provided, then I<now> as the C<time> function returns is +substituted. + +Returns a string expression of the interval between the two DateTime +objects, like C<several hours ago>, C<yesterday> or <last century>. + +=back + +=head2 performance + +On 64bit machines, it is asvisable to 'use integer', which makes +the calculations faster. You can turn this on by setting the +C<ARCH_64BIT> environmental variable to a true value. + +If you do this on a 32bit machine, you will get wrong results for +intervals starting with "several decades ago". + +=head1 AUTHOR + +Jan Oldrich Kruza, C<< <sixtease at cpan.org> >> + +=head1 LICENSE AND COPYRIGHT + +Copyright 2010 Jan Oldrich Kruza. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See http://dev.perl.org/licenses/ for more information. + +=cut diff --git a/extensions/UserProfile/lib/Util.pm b/extensions/UserProfile/lib/Util.pm new file mode 100644 index 000000000..71d0e6501 --- /dev/null +++ b/extensions/UserProfile/lib/Util.pm @@ -0,0 +1,387 @@ +# 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::UserProfile::Util; + +use strict; +use warnings; + +use base qw(Exporter); +our @EXPORT = qw( update_statistics_by_user + tag_for_recount_from_bug + last_user_activity ); + +use Bugzilla; + +sub update_statistics_by_user { + my ($user_id) = @_; + + # run all our queries on the slaves + + my $dbh = Bugzilla->switch_to_shadow_db(); + + my $now = $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); + + # grab the current values + + my $last_statistics_ts = _get_last_statistics_ts($user_id); + + my $statistics = _get_stats($user_id, 'profiles_statistics', 'name'); + my $by_status = _get_stats($user_id, 'profiles_statistics_status', 'status'); + my $by_product = _get_stats($user_id, 'profiles_statistics_products', 'product'); + + # bugs filed + _update_statistics($statistics, 'bugs_filed', [ $user_id ], <<EOF); + SELECT COUNT(*) + FROM bugs + WHERE bugs.reporter = ? +EOF + + # comments made + _update_statistics($statistics, 'comments', [ $user_id ], <<EOF); + SELECT COUNT(*) + FROM longdescs + WHERE who = ? +EOF + + # commented on + _update_statistics($statistics, 'commented_on', [ $user_id ], <<EOF); + SELECT COUNT(*) FROM ( + SELECT longdescs.bug_id + FROM longdescs + WHERE who = ? + GROUP BY longdescs.bug_id + ) AS temp +EOF + + # confirmed + _update_statistics($statistics, 'confirmed', [ $user_id, _field_id('bug_status') ], <<EOF); + SELECT COUNT(*) + FROM bugs_activity + WHERE who = ? + AND fieldid = ? + AND removed = 'UNCONFIRMED' + AND added = 'NEW' +EOF + + # patches submitted + _update_statistics($statistics, 'patches', [ $user_id ], <<EOF); + SELECT COUNT(*) + FROM attachments + WHERE submitter_id = ? + AND (ispatch = 1 + OR mimetype = 'text/x-github-pull-request' + OR mimetype = 'text/x-review-board-request') +EOF + + # patches reviewed + _update_statistics($statistics, 'reviews', [ $user_id ], <<EOF); + SELECT COUNT(*) + FROM flags + INNER JOIN attachments ON attachments.attach_id = flags.attach_id + WHERE setter_id = ? + AND (attachments.ispatch = 1 + OR attachments.mimetype = 'text/x-github-pull-request' + OR attachments.mimetype = 'text/x-review-board-request') + AND status IN ('+', '-') +EOF + + # assigned to + _update_statistics($statistics, 'assigned', [ $user_id ], <<EOF); + SELECT COUNT(*) + FROM bugs + WHERE assigned_to = ? +EOF + + # qa contact + _update_statistics($statistics, 'qa_contact', [ $user_id ], <<EOF); + SELECT COUNT(*) + FROM bugs + WHERE qa_contact = ? +EOF + + # bugs touched + _update_statistics($statistics, 'touched', [ $user_id, $user_id], <<EOF); + SELECT COUNT(*) FROM ( + SELECT bugs_activity.bug_id + FROM bugs_activity + WHERE who = ? + GROUP BY bugs_activity.bug_id + UNION + SELECT longdescs.bug_id + FROM longdescs + WHERE who = ? + GROUP BY longdescs.bug_id + ) temp +EOF + + # activity by status/resolution, and product + _activity_by_status($by_status, $user_id); + _activity_by_product($by_product, $user_id); + + # if nothing is dirty, no need to do anything else + if ($last_statistics_ts) { + return unless _has_dirty($statistics) + || _has_dirty($by_status) + || _has_dirty($by_product); + } + + # switch back to the main db for updating + + $dbh = Bugzilla->switch_to_main_db(); + $dbh->bz_start_transaction(); + + # commit updated statistics + + _set_stats($statistics, $user_id, 'profiles_statistics', 'name') + if _has_dirty($statistics); + _set_stats($by_status, $user_id, 'profiles_statistics_status', 'status') + if _has_dirty($by_status); + _set_stats($by_product, $user_id, 'profiles_statistics_products', 'product') + if _has_dirty($by_product); + + # update the user's last_statistics_ts + _set_last_statistics_ts($user_id, $now); + + $dbh->bz_commit_transaction(); +} + +sub tag_for_recount_from_bug { + my ($bug_id) = @_; + my $dbh = Bugzilla->dbh; + # get a list of all users associated with this bug + my $user_ids = $dbh->selectcol_arrayref(<<EOF, undef, $bug_id, _field_id('cc'), $bug_id); + SELECT DISTINCT user_id + FROM ( + SELECT DISTINCT who AS user_id + FROM bugs_activity + WHERE bug_id = ? + AND fieldid <> ? + UNION ALL + SELECT DISTINCT who AS user_id + FROM longdescs + WHERE bug_id = ? + ) tmp +EOF + # clear last_statistics_ts + $dbh->do( + "UPDATE profiles SET last_statistics_ts=NULL WHERE " . $dbh->sql_in('userid', $user_ids) + ); + foreach my $id (@$user_ids) { + Bugzilla->memcached->clear({ table => 'profiles', id => $id }); + } + return scalar(@$user_ids); +} + +sub last_user_activity { + # last comment, or change to a bug (excluding CC changes) + my ($user_id) = @_; + return Bugzilla->dbh->selectrow_array(<<EOF, undef, $user_id, $user_id, _field_id('cc')); + SELECT MAX(bug_when) + FROM ( + SELECT MAX(bug_when) AS bug_when + FROM longdescs + WHERE who = ? + UNION ALL + SELECT MAX(bug_when) AS bug_when + FROM bugs_activity + WHERE who = ? + AND fieldid <> ? + ) tmp +EOF +} + +# for performance reasons hit the db directly rather than using the user object + +sub _get_last_statistics_ts { + my ($user_id) = @_; + return Bugzilla->dbh->selectrow_array( + "SELECT last_statistics_ts FROM profiles WHERE userid = ?", + undef, $user_id + ); +} + +sub _set_last_statistics_ts { + my ($user_id, $timestamp) = @_; + Bugzilla->dbh->do( + "UPDATE profiles SET last_statistics_ts = ? WHERE userid = ?", + undef, + $timestamp, $user_id, + ); + Bugzilla->memcached->clear({ table => 'profiles', id => $user_id }); +} + +sub _update_statistics { + my ($statistics, $name, $values, $sql) = @_; + my ($count) = Bugzilla->dbh->selectrow_array($sql, undef, @$values); + if (!exists $statistics->{$name}) { + $statistics->{$name} = { + id => 0, + count => $count, + dirty => 1, + }; + } elsif ($statistics->{$name}->{count} != $count) { + $statistics->{$name}->{count} = $count; + $statistics->{$name}->{dirty} = 1; + }; +} + +sub _activity_by_status { + my ($by_status, $user_id) = @_; + my $dbh = Bugzilla->dbh; + + # we actually track both status and resolution changes as statuses + my @values = ($user_id, _field_id('bug_status'), $user_id, _field_id('resolution')); + my $rows = $dbh->selectall_arrayref(<<EOF, { Slice => {} }, @values); + SELECT added AS status, COUNT(*) AS count + FROM bugs_activity + WHERE who = ? + AND fieldid = ? + GROUP BY added + UNION ALL + SELECT CONCAT('RESOLVED/', added) AS status, COUNT(*) AS count + FROM bugs_activity + WHERE who = ? + AND fieldid = ? + AND added != '' + GROUP BY added +EOF + + foreach my $row (@$rows) { + my $status = $row->{status}; + if (!exists $by_status->{$status}) { + $by_status->{$status} = { + id => 0, + count => $row->{count}, + dirty => 1, + }; + } elsif ($by_status->{$status}->{count} != $row->{count}) { + $by_status->{$status}->{count} = $row->{count}; + $by_status->{$status}->{dirty} = 1; + } + } +} + +sub _activity_by_product { + my ($by_product, $user_id) = @_; + my $dbh = Bugzilla->dbh; + + my %products; + + # changes + my $rows = $dbh->selectall_arrayref(<<EOF, { Slice => {} }, $user_id); + SELECT products.name AS product, count(*) AS count + FROM bugs_activity + INNER JOIN bugs ON bugs.bug_id = bugs_activity.bug_id + INNER JOIN products ON products.id = bugs.product_id + WHERE who = ? + GROUP BY bugs.product_id +EOF + map { $products{$_->{product}} += $_->{count} } @$rows; + + # comments + $rows = $dbh->selectall_arrayref(<<EOF, { Slice => {} }, $user_id); + SELECT products.name AS product, count(*) AS count + FROM longdescs + INNER JOIN bugs ON bugs.bug_id = longdescs.bug_id + INNER JOIN products ON products.id = bugs.product_id + WHERE who = ? + GROUP BY bugs.product_id +EOF + map { $products{$_->{product}} += $_->{count} } @$rows; + + # store only the top 10 and 'other' (which is an empty string) + my @sorted = sort { $products{$b} <=> $products{$a} } keys %products; + my @other; + @other = splice(@sorted, 10) if scalar(@sorted) > 10; + map { $products{''} += $products{$_} } @other; + push @sorted, '' if $products{''}; + + # update by_product + foreach my $product (@sorted) { + if (!exists $by_product->{$product}) { + $by_product->{$product} = { + id => 0, + count => $products{$product}, + dirty => 1, + }; + } elsif ($by_product->{$product}->{count} != $products{$product}) { + $by_product->{$product}->{count} = $products{$product}; + $by_product->{$product}->{dirty} = 1; + } + } + foreach my $product (keys %$by_product) { + if (!grep { $_ eq $product } @sorted) { + delete $by_product->{$product}; + } + } +} + +our $_field_id_cache; +sub _field_id { + my ($name) = @_; + if (!$_field_id_cache) { + my $rows = Bugzilla->dbh->selectall_arrayref("SELECT id, name FROM fielddefs"); + foreach my $row (@$rows) { + $_field_id_cache->{$row->[1]} = $row->[0]; + } + } + return $_field_id_cache->{$name}; +} + +sub _get_stats { + my ($user_id, $table, $name_field) = @_; + my $result = {}; + my $rows = Bugzilla->dbh->selectall_arrayref( + "SELECT * FROM $table WHERE user_id = ?", + { Slice => {} }, + $user_id, + ); + foreach my $row (@$rows) { + unless (defined $row->{$name_field}) { + print "$user_id $table $name_field\n"; + die; + } + $result->{$row->{$name_field}} = { + id => $row->{id}, + count => $row->{count}, + dirty => 0, + } + } + return $result; +} + +sub _set_stats { + my ($statistics, $user_id, $table, $name_field) = @_; + my $dbh = Bugzilla->dbh; + foreach my $name (keys %$statistics) { + next unless $statistics->{$name}->{dirty}; + if ($statistics->{$name}->{id}) { + $dbh->do( + "UPDATE $table SET count = ? WHERE user_id = ? AND $name_field = ?", + undef, + $statistics->{$name}->{count}, $user_id, $name, + ); + } else { + $dbh->do( + "INSERT INTO $table(user_id, $name_field, count) VALUES (?, ?, ?)", + undef, + $user_id, $name, $statistics->{$name}->{count}, + ); + } + } +} + +sub _has_dirty { + my ($statistics) = @_; + foreach my $name (keys %$statistics) { + return 1 if $statistics->{$name}->{dirty}; + } + return 0; +} + +1; diff --git a/extensions/UserProfile/template/en/default/hook/account/prefs/account-field.html.tmpl b/extensions/UserProfile/template/en/default/hook/account/prefs/account-field.html.tmpl new file mode 100644 index 000000000..f2e3aad01 --- /dev/null +++ b/extensions/UserProfile/template/en/default/hook/account/prefs/account-field.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. + #%] + +<a href="user_profile?login=[% user.login FILTER uri %]"> + [% terms.Bugzilla %] User Profile +</a><br><hr> diff --git a/extensions/UserProfile/template/en/default/pages/user_profile.html.tmpl b/extensions/UserProfile/template/en/default/pages/user_profile.html.tmpl new file mode 100644 index 000000000..810a974ec --- /dev/null +++ b/extensions/UserProfile/template/en/default/pages/user_profile.html.tmpl @@ -0,0 +1,300 @@ +[%# 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 %] + +[% IF user.id %] + [% filtered_identity = target.identity FILTER html %] +[% ELSE %] + [% filtered_identity = target.name || target.address.user FILTER html %] +[% END %] +[% PROCESS global/header.html.tmpl + title = "User Profile: $filtered_identity" + style_urls = [ "extensions/UserProfile/web/styles/user_profile.css" ] + yui = [ 'autocomplete' ] + javascript_urls = [ "js/field.js" ] +%] + +<table id="user_profile_table"> + +[% IF user.id %] + <tr> + <td> </td> + <th>Search</th> + <td colspan="2"> + <form action="user_profile"> + [% INCLUDE global/userselect.html.tmpl + id => "login" + name => "login" + value => login + size => 40 + emptyok => 0 + %] + <input type="submit" value="Show"> + </form> + </td> + </tr> + + <tr> + <td colspan="4" class="separator"><hr></td> + </tr> +[% END %] + +<tr> + <td rowspan="[% user.id ? 6 : 5 %]" id="gravatar-container"> + [% IF user.gravatar %] + <img id="gravatar" src="[% target.gravatar(256) FILTER none %]" width="128" height="128"><br> + [% IF target.id == user.id %] + <a href="http://gravatar.com/">Change my image</a> + [% END %] + [% ELSE %] + + [% END %] + </td> + <th>Name</th> + <td colspan="2"> + [% target.name || target.address.user FILTER html %] + [% IF target.id == user.id %] + <span style="font-size: x-small;">(<a href="userprefs.cgi?tab=account">change</a>)</span> + [% END %] + </td> +</tr> + +[% IF user.id %] + <tr> + <th>Email</th> + <td colspan="2"> + <a href="mailto:[% target.login FILTER uri %]">[% target.login FILTER html %]</a> + [% " (disabled)" UNLESS target.is_enabled %] + </td> + </tr> +[% END %] + +<tr> + <td> </td> +</tr> + +[%# user.creation_ts is added by the TagNewUsers extension %] +[% IF target.can('creation_ts') %] + <tr> + <th>Created</th> + <td colspan="2"> + [% target.creation_ts FILTER time %] ([% target.creation_ts FILTER timeago FILTER html %]) + </td> + </tr> +[% END %] + +<tr> + <th>Last activity</th> + <td colspan="2"> + [% IF user.id %] + <a href="page.cgi?id=user_activity.html&action=run&who=[% target.login FILTER uri %]"> + [% END %] + [% target.last_activity_ts FILTER time %] + [% "</a>" IF user.id %] + </td> +</tr> + +<tr> + <td> </td> +</tr> + +[%# request counters provided by the Review extension %] +[% IF target.can("review_count") + && ( + stats.reviews + || ( + target.review_request_count + || target.feedback_request_count + || target.needinfo_request_count + ) + ) +%] + <tr> + <td colspan="4" class="separator"><hr></td> + </tr> + <tr> + <td>Review Queue</td> + </tr> + <tr> + <td> </td> + <th>Review requests</th> + <td class="numeric"> + [% IF user.id %] + <a href="request.cgi?action=queue&type=review&requestee=[% target.login FILTER uri %]&group=type" + target="_blank"> + [% END %] + [% target.review_request_count FILTER html %] + [% "</a>" IF user.id %] + </td> + [% IF user.id %] + <td> + (<a href="page.cgi?id=review_history.html&requestee=[% target.login FILTER uri %]">Review History</a>) + </td> + [% END %] + </tr> + <tr> + <td> </td> + <th>Feedback requests</th> + <td class="numeric"> + [% IF user.id %] + <a href="request.cgi?action=queue&type=feedback&requestee=[% target.login FILTER uri %]&group=type" + target="_blank"> + [% END %] + [% target.feedback_request_count FILTER html %] + [% "</a>" IF user.id %] + </td> + </tr> + <tr> + <td> </td> + <th>Needinfo requests</th> + <td class="numeric"> + [% IF user.id %] + <a href="request.cgi?action=queue&type=needinfo&requestee=[% target.login FILTER uri %]&group=type" + target="_blank"> + [% END %] + [% target.needinfo_request_count FILTER html %] + [% "</a>" IF user.id %] + </td> + </tr> +[% END %] + +<tr> + <td colspan="4" class="separator"><hr></td> +</tr> +<tr> + <td>User Statistics</td> +</tr> + +<tr> + <td> </td> + <th>[% terms.Bugs %] filed</th> + <td class="numeric"> + [% IF user.id %] + <a href="buglist.cgi?query_format=advanced&emailtype1=exact&emailreporter1=1&email1=[% target.login FILTER uri %]" + target="_blank"> + [% END %] + [% stats.bugs_filed || 0 FILTER html %] + [% "</a>" IF user.id %] + </td> +</tr> +<tr> + <td> </td> + <th>Comments made</th> + <td class="numeric">[% stats.comments || 0 FILTER html %]</td> +</tr> +<tr> + <td> </td> + <th>Assigned to</th> + <td class="numeric"> + [% IF user.id %] + <a href="buglist.cgi?query_format=advanced&emailtype1=exact&emailassigned_to1=1&email1=[% target.login FILTER uri %]" + target="_blank"> + [% END %] + [% stats.assigned || 0 FILTER html %] + [% "</a>" IF user.id %] + </td> +</tr> +<tr> + <td> </td> + <th>Commented on</th> + <td class="numeric"> + [% IF user.id %] + <a href="buglist.cgi?query_format=advanced&emailtype1=exact&emaillongdesc1=1&email1=[% target.login FILTER uri %]" + target="_blank"> + [% END %] + [% stats.commented_on || 0 FILTER html %] + [% "</a>" IF user.id %] + </td> +</tr> +<tr> + <td> </td> + <th>QA-Contact</th> + <td class="numeric"> + [% IF user.id %] + <a href="buglist.cgi?query_format=advanced&emailtype1=exact&emailqa_contact1=1&email1=[% target.login FILTER uri %]" + target="_blank"> + [% END %] + [% stats.qa_contact || 0 FILTER html %] + [% "</a>" IF user.id %] + </td> +</tr> +<tr> + <td> </td> + <th>Patches submitted</th> + <td class="numeric">[% stats.patches || 0 FILTER html %]</td> +</tr> +<tr> + <td> </td> + <th>Patches reviewed</th> + <td class="numeric">[% stats.reviews || 0 FILTER html %]</td> +</tr> +<tr> + <td> </td> + <th>[% terms.Bugs %] poked</th> + <td class="numeric">[% stats.touched || 0 FILTER html %]</td> +</tr> + +<tr> + <td> </td> +</tr> + +<tr> + <td> </td> + <th>Statuses changed</th> + <td colspan="2"> + RESOLVED ([% statuses.item('RESOLVED') || 0 FILTER html %]), + FIXED ([% statuses.item('RESOLVED/FIXED') || 0 FILTER html %]), + VERIFIED ([% statuses.item('VERIFIED') || 0 FILTER html %]), + INVALID ([% statuses.item('RESOLVED/INVALID') || 0 FILTER html %]) + </td> +</tr> + +<tr> + <td> </td> + <th>Activity by product</th> + <td colspan="2"> + [% FOREACH p = products %] + <span class="product_span"> + [% IF p.product.id %] + <a href="describecomponents.cgi?product=[% p.product.name FILTER uri %]" + target="_blank"> + [% END %] + [% p.product.name FILTER html %] ([% p.count || 0 FILTER html %]) + [% "</a>" IF p.product.id %] + [% "," UNLESS loop.last ~%] + </span> + [%+ END %] + </td> +</tr> + +<tr> + <td colspan="3"> + <div id="what"> + <a href="https://wiki.mozilla.org/BMO/User_profile_fields" target="_blank"> + What do these fields mean? + </a> + </div> + + <div id="updated"> + This information is updated daily + </div> + </td> +</tr> + +<tr> + <td> </td> + <td> </td> + <td> </td> + <td width="100%"> </td> +</tr> + +</table> + +[% PROCESS global/footer.html.tmpl %] + diff --git a/extensions/UserProfile/web/styles/user_profile.css b/extensions/UserProfile/web/styles/user_profile.css new file mode 100644 index 000000000..ef1f71dd9 --- /dev/null +++ b/extensions/UserProfile/web/styles/user_profile.css @@ -0,0 +1,48 @@ +/* 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. */ + +#login_autocomplete { + float: left; +} + +#user_profile_table th { + text-align: right; + padding-right: 1em; + vertical-align: middle; + white-space: nowrap; +} + +#user_profile_table .numeric { + text-align: right; +} + +#user_profile_table .product_span { + white-space: nowrap; +} + +#updated { + font-style: italic; + font-size: x-small; +} + +#gravatar-container { + text-align: center; + font-size: x-small; + vertical-align: top; + padding-right: 15px; +} + +#gravatar { + -moz-box-shadow: 2px 2px 5px #888; + -webkit-box-shadow: 2px 2px 5px #888; + box-shadow: 2px 2px 5px #888; + margin-bottom: 5px; +} + +#what { + margin-top: 1em; +} diff --git a/extensions/UserStory/Config.pm b/extensions/UserStory/Config.pm new file mode 100644 index 000000000..8649c71cf --- /dev/null +++ b/extensions/UserStory/Config.pm @@ -0,0 +1,21 @@ +# 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::UserStory; +use strict; + +use constant NAME => 'UserStory'; +use constant REQUIRED_MODULES => [ + { + package => 'Text-Diff', + module => 'Text::Diff', + version => 0, + }, +]; +use constant OPTIONAL_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/UserStory/Extension.pm b/extensions/UserStory/Extension.pm new file mode 100644 index 000000000..2053a0097 --- /dev/null +++ b/extensions/UserStory/Extension.pm @@ -0,0 +1,102 @@ +# 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::UserStory; +use strict; +use warnings; + +use base qw(Bugzilla::Extension); +our $VERSION = '1'; + +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Extension::UserStory::Constants; +use Bugzilla::Extension::BMO::FakeBug; + +use Text::Diff; + +BEGIN { + *Bugzilla::Bug::user_story_visible = \&_bug_user_story_visible; + *Bugzilla::Extension::BMO::FakeBug::user_story_visible = \&_bug_user_story_visible; +} + +sub _bug_user_story_visible { + my ($self) = @_; + if (!exists $self->{user_story_visible}) { + # Visible by default + $self->{user_story_visible} = 1; + my ($product, $component) = ($self->product, $self->component); + my $exclude_components = []; + if (exists USER_STORY_EXCLUDE->{$product}) { + $exclude_components = USER_STORY_EXCLUDE->{$product}; + if (scalar(@$exclude_components) == 0 + || ($component && grep { $_ eq $component } @$exclude_components)) + { + $self->{user_story_visible} = 0; + } + } + $self->{user_story_exclude_components} = $exclude_components; + } + return ($self->{user_story_visible}, $self->{user_story_exclude_components}); +} + +# ensure user is allowed to edit the story +sub bug_check_can_change_field { + my ($self, $args) = @_; + my ($bug, $field, $priv_results) = @$args{qw(bug field priv_results)}; + return unless $field eq 'cf_user_story'; + if (!Bugzilla->user->in_group(USER_STORY_GROUP)) { + push (@$priv_results, PRIVILEGES_REQUIRED_EMPOWERED); + } +} + +# store just a diff of the changes in the bugs_activity table +sub bug_update_before_logging { + my ($self, $args) = @_; + my $changes = $args->{changes}; + return unless exists $changes->{cf_user_story}; + my $diff = diff( + \$changes->{cf_user_story}->[0], + \$changes->{cf_user_story}->[1], + { + CONTEXT => 0, + }, + ); + $changes->{cf_user_story} = [ '', $diff ]; +} + +# stop inline-history from displaying changes to the user story +sub inline_history_activtiy { + my ($self, $args) = @_; + foreach my $activity (@{ $args->{activity} }) { + foreach my $change (@{ $activity->{changes} }) { + if ($change->{fieldname} eq 'cf_user_story') { + $change->{removed} = ''; + $change->{added} = '(updated)'; + } + } + } +} + +# create cf_user_story field +sub install_update_db { + my ($self, $args) = @_; + return if Bugzilla::Field->new({ name => 'cf_user_story'}); + Bugzilla::Field->create({ + name => 'cf_user_story', + description => 'User Story', + type => FIELD_TYPE_TEXTAREA, + mailhead => 0, + enter_bug => 0, + obsolete => 0, + custom => 1, + buglist => 0, + }); +} + +__PACKAGE__->NAME; diff --git a/extensions/UserStory/lib/Constants.pm b/extensions/UserStory/lib/Constants.pm new file mode 100644 index 000000000..d09b28fef --- /dev/null +++ b/extensions/UserStory/lib/Constants.pm @@ -0,0 +1,29 @@ +# 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::UserStory::Constants; + +use strict; +use warnings; + +use base qw(Exporter); + +our @EXPORT = qw( USER_STORY_EXCLUDE USER_STORY_GROUP ); + +# Group allowed to set/edit the user story field +use constant USER_STORY_GROUP => 'editbugs'; + +# Exclude showing the user story field for these products/components. +# Examples: +# Don't show User Story on any Firefox OS component: +# 'Firefox OS' => [], +# Don't show User Story on Developer Tools component, visible on all other +# Firefox components +# 'Firefox' => ['Developer Tools'], +use constant USER_STORY_EXCLUDE => { }; + +1; diff --git a/extensions/UserStory/template/en/default/hook/bug/comments-comment_banner.html.tmpl b/extensions/UserStory/template/en/default/hook/bug/comments-comment_banner.html.tmpl new file mode 100644 index 000000000..6a7770066 --- /dev/null +++ b/extensions/UserStory/template/en/default/hook/bug/comments-comment_banner.html.tmpl @@ -0,0 +1,73 @@ +[%# 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 bug.user_story_visible.0 %] +[% RETURN IF user.id == 0 && bug.cf_user_story == "" %] +[% can_edit_story = bug.check_can_change_field('cf_user_story', 0, 1) %] + +<div class="user_story"> + <script type="text/javascript"> + function userStoryComment() { + var commenttext = "(Commenting on User Story)\n"; + var text_elem = document.getElementById('user_story'); + var commenttext = commenttext + wrapReplyText(text_elem.value); + var textarea = document.getElementById('comment'); + if (textarea.value != commenttext) { + textarea.value += commenttext; + } + textarea.focus(); + } + </script> + <div id="user_story_header"> + <b>User Story</b> + [% IF can_edit_story %] + <span id="user_story_edit"> + (<a href="javascript:void(0)" id="user_story_edit_action" >edit</a>) + </span> + [% END %] + [% IF user.id + && bug.cf_user_story != "" + && bug.check_can_change_field('longdesc', 0, 1) %] + <span id="user_story_comment"> + [<a class="bz_reply_link" href="#user_story_comment" + onclick="userStoryComment(); return false;" + >comment</a>] + </span> + [% END %] + </div> + + [% IF bug.cf_user_story != "" %] + <div id="user_story_readonly" class="bz_comment"> + <pre class="bz_comment_text"> + [%- bug.cf_user_story FILTER quoteUrls(bug) -%] + </pre> + </div> + [% ELSE %] + <br id="user_story_readonly"> + [% END %] + + [% IF can_edit_story %] + <div id="user_story_edit_container" class="bz_default_hidden"> + [% INCLUDE global/textarea.html.tmpl + name = 'cf_user_story' + id = 'user_story' + minrows = 10 + maxrows = 10 + cols = constants.COMMENT_COLS + defaultcontent = bug.cf_user_story %] + </div> + <script type="text/javascript"> + YAHOO.util.Event.addListener('user_story_edit_action', 'click', function() { + YAHOO.util.Dom.addClass('user_story_edit', 'bz_default_hidden'); + YAHOO.util.Dom.addClass('user_story_readonly', 'bz_default_hidden'); + YAHOO.util.Dom.removeClass('user_story_edit_container', 'bz_default_hidden'); + YAHOO.util.Dom.get('user_story').focus(); + }); + </script> + [% END %] +</div> diff --git a/extensions/UserStory/template/en/default/hook/bug/create/create-after_custom_fields.html.tmpl b/extensions/UserStory/template/en/default/hook/bug/create/create-after_custom_fields.html.tmpl new file mode 100644 index 000000000..04c7f3c04 --- /dev/null +++ b/extensions/UserStory/template/en/default/hook/bug/create/create-after_custom_fields.html.tmpl @@ -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. + #%] + +[% RETURN UNLESS default.user_story_visible.0 && default.check_can_change_field('cf_user_story', 0, 1) %] + +</tbody> +<tbody id="cf_user_story_container" class="expert_fields bz_default_hidden"> + <tr> + <th class="field_label"> + <label for="cf_user_story">User Story:</label> + </th> + <td colspan="3"> + <div id="user_story_header"> + <span id="user_story_edit"> + (<a href="javascript:void(0)" id="user_story_edit_action" >edit</a>) + </span> + </div> + <div id="user_story_edit_container" class="bz_default_hidden"> + [% user_story_default = cloned_bug ? cloned_bug.cf_user_story : "" %] + [% INCLUDE global/textarea.html.tmpl + name = 'cf_user_story' + id = 'user_story' + minrows = 10 + maxrows = 10 + cols = constants.COMMENT_COLS + disabled = 1 + defaultcontent = user_story_default + %] + </div> + <script type="text/javascript"> + var user_story_exclude_components = []; + [% FOREACH c = default.user_story_visible.1 %] + user_story_exclude_components.push('[% c FILTER js %]'); + [% END %] + function toggleUserStory() { + if (YAHOO.util.Dom.get('user_story').value != '') { + hideUserStoryEdit(); + } + if (user_story_exclude_components.length == 0) { + YAHOO.util.Dom.removeClass('cf_user_story_container', 'bz_default_hidden'); + YAHOO.util.Dom.get('user_story').disabled = false; + return; + } + var index = -1; + var form = document.Create; + if (form.component.type == 'select-one') { + index = form.component.selectedIndex; + } else if (form.component.type == 'hidden') { + // Assume there is only one component in the list + index = 0; + } + if (index != -1) { + for (var i = 0, l = user_story_exclude_components.length; i < l; i++) { + if (user_story_exclude_components[i] == components[index]) { + YAHOO.util.Dom.addClass('cf_user_story_container', 'bz_default_hidden'); + YAHOO.util.Dom.get('user_story').disabled = true; + return; + } + else { + YAHOO.util.Dom.removeClass('cf_user_story_container', 'bz_default_hidden'); + YAHOO.util.Dom.get('user_story').disabled = false; + } + } + } + } + function hideUserStoryEdit() { + YAHOO.util.Dom.addClass('user_story_edit', 'bz_default_hidden'); + YAHOO.util.Dom.addClass('user_story_readonly', 'bz_default_hidden'); + YAHOO.util.Dom.removeClass('user_story_edit_container', 'bz_default_hidden'); + } + YAHOO.util.Event.addListener('component', 'change', toggleUserStory); + YAHOO.util.Event.addListener('user_story_edit_action', 'click', function() { + hideUserStoryEdit(); + YAHOO.util.Dom.get('user_story').focus(); + }); + toggleUserStory(); + </script> + </td> + </tr> diff --git a/extensions/UserStory/template/en/default/hook/bug/create/create-custom_field.html.tmpl b/extensions/UserStory/template/en/default/hook/bug/create/create-custom_field.html.tmpl new file mode 100644 index 000000000..4d809e4a2 --- /dev/null +++ b/extensions/UserStory/template/en/default/hook/bug/create/create-custom_field.html.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. + #%] + +[%# user story gets custom handling %] +[% IF field.name == 'cf_user_story' %] + [% field.hidden = 1 %] +[% END %] diff --git a/extensions/UserStory/template/en/default/hook/bug/edit-custom_field.html.tmpl b/extensions/UserStory/template/en/default/hook/bug/edit-custom_field.html.tmpl new file mode 100644 index 000000000..2e8762dbe --- /dev/null +++ b/extensions/UserStory/template/en/default/hook/bug/edit-custom_field.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 field.name == 'cf_user_story' %] + [% field.hidden = 1 %] +[% END %] diff --git a/extensions/UserStory/template/en/default/hook/bug/show-header-end.html.tmpl b/extensions/UserStory/template/en/default/hook/bug/show-header-end.html.tmpl new file mode 100644 index 000000000..abdbe865e --- /dev/null +++ b/extensions/UserStory/template/en/default/hook/bug/show-header-end.html.tmpl @@ -0,0 +1,9 @@ +[%# 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. + #%] + +[% style_urls.push("extensions/UserStory/web/style/user_story.css") %] diff --git a/extensions/UserStory/web/style/user_story.css b/extensions/UserStory/web/style/user_story.css new file mode 100644 index 000000000..f1a457f75 --- /dev/null +++ b/extensions/UserStory/web/style/user_story.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. */ + +.user_story { + width: 50em; +} + +.skin-Mozilla .user_story { + width: 65em; +} + +textarea#user_story { + width: 100%; +} + +#user_story_comment { + float: right; +} + +#user_story_readonly { + border: 1px solid black; + border-radius: 4px; +} + +.skin-standard #user_story_readonly { + padding: 2px; +} + +.skin-Mozilla #user_story_readonly { + border: none; + border-radius: 0px; +} + +.skin-Mozilla #user_story_readonly .bz_comment_text { + border: 1px solid darkgrey; + border-radius: 4px; +} diff --git a/extensions/Voting/Extension.pm b/extensions/Voting/Extension.pm index a5a3bc11b..0b79d3d21 100644 --- a/extensions/Voting/Extension.pm +++ b/extensions/Voting/Extension.pm @@ -38,7 +38,7 @@ use Bugzilla::User; use Bugzilla::Util qw(detaint_natural); use Bugzilla::Token; -use List::Util qw(min); +use List::Util qw(min sum); use constant VERSION => BUGZILLA_VERSION; use constant DEFAULT_VOTES_PER_BUG => 1; @@ -47,6 +47,10 @@ use constant DEFAULT_VOTES_PER_BUG => 1; use constant CMT_POPULAR_VOTES => 3; use constant REL_VOTER => 4; +BEGIN { + *Bugzilla::Bug::user_votes = \&_bug_user_votes; +} + ################ # Installation # ################ @@ -122,6 +126,15 @@ sub install_update_db { # Objects # ########### +sub _bug_user_votes { + my ($self) = @_; + return $self->{'user_votes'} if exists $self->{'user_votes'}; + $self->{'user_votes'} = Bugzilla->dbh->selectrow_array( + "SELECT vote_count FROM votes WHERE bug_id = ? AND who = ?", + undef, $self->id, Bugzilla->user->id); + return $self->{'user_votes'}; +} + sub object_columns { my ($self, $args) = @_; my ($class, $columns) = @$args{qw(class columns)}; @@ -204,7 +217,7 @@ sub bug_end_of_update { # If some votes have been removed, RemoveVotes() returns # a list of messages to send to voters. @msgs = _remove_votes($bug->id, 0, 'votes_bug_moved'); - _confirm_if_vote_confirmed($bug->id); + _confirm_if_vote_confirmed($bug); foreach my $msg (@msgs) { MessageToMTA($msg); @@ -406,9 +419,10 @@ sub _page_user { } # If a bug_id is given, and we're editing, we'll add it to the votes list. - + my $bug_id = $input->{bug_id}; - my $bug = Bugzilla::Bug->check($bug_id) if $bug_id; + $bug_id = $bug_id->[0] if ref($bug_id) eq 'ARRAY'; + my $bug = Bugzilla::Bug->check({ id => $bug_id, cache => 1 }) if $bug_id; my $who_id = $input->{user_id} || $user->id; # Logged-out users must specify a user_id. @@ -437,52 +451,38 @@ sub _page_user { foreach my $product (@{ $user->get_selectable_products }) { next unless ($product->{votesperuser} > 0); - my @bugs; - my @bug_ids; - my $total = 0; - my $onevoteonly = 0; - my $vote_list = - $dbh->selectall_arrayref('SELECT votes.bug_id, votes.vote_count, - bugs.short_desc - FROM votes - INNER JOIN bugs - ON votes.bug_id = bugs.bug_id - WHERE votes.who = ? - AND bugs.product_id = ? - ORDER BY votes.bug_id', - undef, ($who->id, $product->id)); - - foreach (@$vote_list) { - my ($id, $count, $summary) = @$_; - $total += $count; - - # Next if user can't see this bug. So, the totals will be correct - # and they can see there are votes 'missing', but not on what bug - # they are. This seems a reasonable compromise; the alternative is - # to lie in the totals. - next if !$user->can_see_bug($id); - - push (@bugs, { id => $id, - summary => $summary, - count => $count }); - push (@bug_ids, $id); - push (@all_bug_ids, $id); - } + $dbh->selectall_arrayref('SELECT votes.bug_id, votes.vote_count + FROM votes + INNER JOIN bugs + ON votes.bug_id = bugs.bug_id + WHERE votes.who = ? + AND bugs.product_id = ?', + undef, ($who->id, $product->id)); + + my %votes = map { $_->[0] => $_->[1] } @$vote_list; + my @bug_ids = sort keys %votes; + # Exclude bugs that the user can no longer see. + @bug_ids = @{ $user->visible_bugs(\@bug_ids) }; + next unless scalar @bug_ids; + + push(@all_bug_ids, @bug_ids); + my @bugs = @{ Bugzilla::Bug->new_from_list(\@bug_ids) }; + $_->{count} = $votes{$_->id} foreach @bugs; + # We include votes from bugs that the user can no longer see. + my $total = sum(values %votes) || 0; + my $onevoteonly = 0; $onevoteonly = 1 if (min($product->{votesperuser}, $product->{maxvotesperbug}) == 1); - # Only add the product for display if there are any bugs in it. - if ($#bugs > -1) { - push (@products, { name => $product->name, - bugs => \@bugs, - bug_ids => \@bug_ids, - onevoteonly => $onevoteonly, - total => $total, - maxvotes => $product->{votesperuser}, - maxperbug => $product->{maxvotesperbug} }); - } + push(@products, { name => $product->name, + bugs => \@bugs, + bug_ids => \@bug_ids, + onevoteonly => $onevoteonly, + total => $total, + maxvotes => $product->{votesperuser}, + maxperbug => $product->{maxvotesperbug} }); } if ($canedit && $bug) { @@ -516,6 +516,7 @@ sub _update_votes { # IDs and the field values are the number of votes. my @buglist = grep {/^\d+$/} keys %$input; + my (%bugs, %votes); # If no bugs are in the buglist, let's make sure the user gets notified # that their votes will get nuked if they continue. @@ -531,20 +532,23 @@ sub _update_votes { exit; } } + else { + $user->visible_bugs(\@buglist); + my $bugs_obj = Bugzilla::Bug->new_from_list(\@buglist); + $bugs{$_->id} = $_ foreach @$bugs_obj; + } - # Call check() on each bug ID to make sure it is a positive - # integer representing an existing bug that the user is authorized - # to access, and make sure the number of votes submitted is also - # a non-negative integer (a series of digits not preceded by a - # minus sign). - my (%votes, @bugs); + # Call check_is_visible() on each bug to make sure it is an existing bug + # that the user is authorized to access, and make sure the number of votes + # submitted is also an integer. foreach my $id (@buglist) { - my $bug = Bugzilla::Bug->check($id); - push(@bugs, $bug); - $id = $bug->id; - $votes{$id} = $input->{$id}; - detaint_natural($votes{$id}) - || ThrowUserError("voting_must_be_nonnegative"); + my $bug = $bugs{$id} + or ThrowUserError('bug_id_does_not_exist', { bug_id => $id }); + $bug->check_is_visible; + $id = $bug->id; + $votes{$id} = $input->{$id}; + detaint_natural($votes{$id}) + || ThrowUserError("voting_must_be_nonnegative"); } my $token = $cgi->param('token'); @@ -557,10 +561,10 @@ sub _update_votes { # If the user is voting for bugs, make sure they aren't overstuffing # the ballot box. - if (scalar @bugs) { + if (scalar @buglist) { my (%prodcount, %products); - foreach my $bug (@bugs) { - my $bug_id = $bug->id; + foreach my $bug_id (keys %bugs) { + my $bug = $bugs{$bug_id}; my $prod = $bug->product; $products{$prod} ||= $bug->product_obj; $prodcount{$prod} ||= 0; @@ -584,56 +588,65 @@ sub _update_votes { } } - # Update the user's votes in the database. If the user did not submit - # any votes, they may be using a form with checkboxes to remove all their - # votes (checkboxes are not submitted along with other form data when - # they are not checked, and Bugzilla uses them to represent single votes - # for products that only allow one vote per bug). In that case, we still - # need to clear the user's votes from the database. - my %affected; + # Update the user's votes in the database. $dbh->bz_start_transaction(); - # Take note of, and delete the user's old votes from the database. - my $bug_list = $dbh->selectcol_arrayref('SELECT bug_id FROM votes + my $old_list = $dbh->selectall_arrayref('SELECT bug_id, vote_count FROM votes WHERE who = ?', undef, $who); - foreach my $id (@$bug_list) { - $affected{$id} = 1; - } - $dbh->do('DELETE FROM votes WHERE who = ?', undef, $who); + my %old_votes = map { $_->[0] => $_->[1] } @$old_list; my $sth_insertVotes = $dbh->prepare('INSERT INTO votes (who, bug_id, vote_count) VALUES (?, ?, ?)'); + my $sth_updateVotes = $dbh->prepare('UPDATE votes SET vote_count = ? + WHERE bug_id = ? AND who = ?'); - # Insert the new values in their place - foreach my $id (@buglist) { - if ($votes{$id} > 0) { + my %affected = map { $_ => 1 } (@buglist, keys %old_votes); + my @deleted_votes; + + foreach my $id (keys %affected) { + if (!$votes{$id}) { + push(@deleted_votes, $id); + next; + } + if ($votes{$id} == ($old_votes{$id} || 0)) { + delete $affected{$id}; + next; + } + # We use 'defined' in case 0 was accidentally stored in the DB. + if (defined $old_votes{$id}) { + $sth_updateVotes->execute($votes{$id}, $id, $who); + } + else { $sth_insertVotes->execute($who, $id, $votes{$id}); } - $affected{$id} = 1; + } + + if (@deleted_votes) { + $dbh->do('DELETE FROM votes WHERE who = ? AND ' . + $dbh->sql_in('bug_id', \@deleted_votes), undef, $who); } # Update the cached values in the bugs table - print $cgi->header(); my @updated_bugs = (); my $sth_getVotes = $dbh->prepare("SELECT SUM(vote_count) FROM votes WHERE bug_id = ?"); - my $sth_updateVotes = $dbh->prepare("UPDATE bugs SET votes = ? - WHERE bug_id = ?"); + $sth_updateVotes = $dbh->prepare('UPDATE bugs SET votes = ? WHERE bug_id = ?'); foreach my $id (keys %affected) { $sth_getVotes->execute($id); my $v = $sth_getVotes->fetchrow_array || 0; $sth_updateVotes->execute($v, $id); - my $confirmed = _confirm_if_vote_confirmed($id); + my $confirmed = _confirm_if_vote_confirmed($bugs{$id} || $id); push (@updated_bugs, $id) if $confirmed; } $dbh->bz_commit_transaction(); + print $cgi->header() if scalar @updated_bugs; $vars->{'type'} = "votes"; $vars->{'title_tag'} = 'change_votes'; foreach my $bug_id (@updated_bugs) { @@ -844,7 +857,7 @@ sub _remove_votes { # confirm a bug has been reduced, check if the bug is now confirmed. sub _confirm_if_vote_confirmed { my $id = shift; - my $bug = new Bugzilla::Bug($id); + my $bug = ref $id ? $id : new Bugzilla::Bug({ id => $id, cache => 1 }); my $ret = 0; if (!$bug->everconfirmed diff --git a/extensions/Voting/template/en/default/hook/bug/edit-after_importance.html.tmpl b/extensions/Voting/template/en/default/hook/bug/edit-after_importance.html.tmpl index f73ffaebd..b57a5cb27 100644 --- a/extensions/Voting/template/en/default/hook/bug/edit-after_importance.html.tmpl +++ b/extensions/Voting/template/en/default/hook/bug/edit-after_importance.html.tmpl @@ -29,6 +29,9 @@ [% ELSE %] votes [% END %]</a> + [% IF bug.user_votes %] + including you + [% END %] [% END %] (<a href="page.cgi?id=voting/user.html&bug_id= [%- bug.id FILTER uri %]#vote_ diff --git a/extensions/Voting/template/en/default/pages/voting/user.html.tmpl b/extensions/Voting/template/en/default/pages/voting/user.html.tmpl index 61eaf8491..627011fd4 100644 --- a/extensions/Voting/template/en/default/pages/voting/user.html.tmpl +++ b/extensions/Voting/template/en/default/pages/voting/user.html.tmpl @@ -109,8 +109,7 @@ </tr> [% FOREACH bug = product.bugs %] - <tr [% IF bug.id == this_bug.id && canedit %] - class="bz_bug_being_voted_on" [% END %]> + <tr [% IF bug.id == this_bug.id && canedit %] class="bz_bug_being_voted_on"[% END %]> <td> [% IF bug.id == this_bug.id && canedit %] [% IF product.onevoteonly %] @@ -120,25 +119,25 @@ [% END %] [%- END %] </td> - <td align="right"><a name="vote_[% bug.id FILTER html %]"> + <td align="right"><a name="vote_[% bug.id FILTER none %]"> [% IF canedit %] [% IF product.onevoteonly %] - <input type="checkbox" name="[% bug.id FILTER html %]" value="1" - [% " checked" IF bug.count %] id="bug_[% bug.id FILTER html %]"> + <input type="checkbox" name="[% bug.id FILTER none %]" value="1" + [% " checked" IF bug.count %] id="bug_[% bug.id FILTER none %]"> [% ELSE %] - <input name="[% bug.id FILTER html %]" value="[% bug.count FILTER html %]" - size="2" id="bug_[% bug.id FILTER html %]"> + <input name="[% bug.id FILTER none %]" value="[% bug.count FILTER html %]" + size="2" id="bug_[% bug.id FILTER none %]"> [% END %] [% ELSE %] [% bug.count FILTER html %] [% END %] </a></td> <td align="center"> - [% bug.id FILTER bug_link(bug) FILTER none %] + [% PROCESS bug/link.html.tmpl bug = bug, link_text = bug.id %] </td> <td> - [% bug.summary FILTER html %] - (<a href="page.cgi?id=voting/bug.html&bug_id=[% bug.id FILTER uri %]">Show Votes</a>) + [% bug.short_desc FILTER html %] + (<a href="page.cgi?id=voting/bug.html&bug_id=[% bug.id FILTER none %]">Show Votes</a>) </td> </tr> [% END %] diff --git a/extensions/ZPushNotify/Config.pm b/extensions/ZPushNotify/Config.pm new file mode 100644 index 000000000..e65169d41 --- /dev/null +++ b/extensions/ZPushNotify/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::ZPushNotify; +use strict; + +use constant NAME => 'ZPushNotify'; +use constant REQUIRED_MODULES => []; +use constant OPTIONAL_MODULES => []; + +__PACKAGE__->NAME; diff --git a/extensions/ZPushNotify/Extension.pm b/extensions/ZPushNotify/Extension.pm new file mode 100644 index 000000000..6e8ab4d27 --- /dev/null +++ b/extensions/ZPushNotify/Extension.pm @@ -0,0 +1,110 @@ +# 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::ZPushNotify; +use strict; +use warnings; + +use base qw(Bugzilla::Extension); +our $VERSION = '1'; + +use Bugzilla; + +# +# insert into the notifications table +# + +sub _notify { + my ($bug_id, $delta_ts) = @_; + Bugzilla->dbh->do( + "REPLACE INTO push_notify(bug_id, delta_ts) VALUES(?, ?)", + undef, + $bug_id, $delta_ts + ); +} + +# +# object hooks +# + +sub object_end_of_update { + my ($self, $args) = @_; + return unless Bugzilla->params->{enable_simple_push}; + return unless scalar keys %{ $args->{changes} }; + return unless my $object = $args->{object}; + if ($object->isa('Bugzilla::Attachment')) { + _notify($object->bug->id, $object->bug->delta_ts); + } +} + +sub object_before_delete { + my ($self, $args) = @_; + return unless Bugzilla->params->{enable_simple_push}; + return unless my $object = $args->{object}; + if ($object->isa('Bugzilla::Attachment')) { + _notify($object->bug->id, $object->bug->delta_ts); + } +} + +sub bug_end_of_update_delta_ts { + my ($self, $args) = @_; + return unless Bugzilla->params->{enable_simple_push}; + _notify($args->{bug_id}, $args->{timestamp}); +} + +sub bug_end_of_create { + my ($self, $args) = @_; + return unless Bugzilla->params->{enable_simple_push}; + _notify($args->{bug}->id, $args->{timestamp}); +} + +# +# schema / param +# + +sub db_schema_abstract_schema { + my ($self, $args) = @_; + $args->{'schema'}->{'push_notify'} = { + FIELDS => [ + id => { + TYPE => 'INTSERIAL', + NOTNULL => 1, + PRIMARYKEY => 1, + }, + bug_id => { + TYPE => 'INT3', + NOTNULL => 1, + REFERENCES => { + TABLE => 'bugs', + COLUMN => 'bug_id', + DELETE => 'CASCADE' + }, + }, + delta_ts => { + TYPE => 'DATETIME', + NOTNULL => 1, + }, + ], + INDEXES => [ + push_notify_idx => { + FIELDS => [ 'bug_id' ], + TYPE => 'UNIQUE', + }, + ], + }; +} + +sub config_modify_panels { + my ($self, $args) = @_; + push @{ $args->{panels}->{advanced}->{params} }, { + name => 'enable_simple_push', + type => 'b', + default => 0, + }; +} + +__PACKAGE__->NAME; diff --git a/extensions/ZPushNotify/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl b/extensions/ZPushNotify/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl new file mode 100644 index 000000000..e6c171916 --- /dev/null +++ b/extensions/ZPushNotify/template/en/default/hook/admin/params/editparams-current_panel.html.tmpl @@ -0,0 +1,13 @@ +[%# 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 panel.name == "advanced" %] + [% panel.param_descs.enable_simple_push = + 'When enabled bug changes will result in an entry in the <code>push_notify</code> table' + %] +[% END -%] |