diff options
71 files changed, 2533 insertions, 893 deletions
diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..f64524c80 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,293 @@ +# References: +# 1. https://circleci.com/blog/how-to-build-a-docker-image-on-circleci-2-0/ +# 2. https://circleci.com/docs/2.0/building-docker-images/ +# + +version: 2 + +defaults: + bmo_slim_image: &bmo_slim_image + image: mozillabteam/bmo-slim:20180809.1 + user: app + + mysql_image: &mysql_image + image: mozillabteam/bmo-mysql:5.6 + + store_log: &store_log + store_artifacts: + path: /app/bugzilla.log + destination: bugzilla.log + + main_filters: &main_filters + branches: + ignore: + - /^(?:release|test)-20\d\d\d\d\d\d\.\d+/ + - /\// + - production + + bmo_env: &bmo_env + PORT: 8000 + LOGGING_PORT: 5880 + LOCALCONFIG_ENV: 1 + LOG4PERL_CONFIG_FILE: log4perl-test.conf + BMO_db_user: bugs + BMO_db_host: 127.0.0.1 + BMO_db_pass: bugs + BMO_db_name: bugs + BMO_memcached_servers: localhost:11211 + BMO_memcached_namespace: "bugzilla:" + BMO_urlbase: AUTOMATIC + + mysql_env: &mysql_env + MYSQL_DATABASE: bugs + MYSQL_USER: bugs + MYSQL_PASSWORD: bugs + MYSQL_ALLOW_EMPTY_PASSWORD: 1 + + docker_oldtests: &docker_oldtests + - <<: *bmo_slim_image + environment: + <<: *bmo_env + BZ_QA_CONF_FILE: /app/.circleci/selenium_test.conf + BZ_QA_ANSWERS_FILE: /app/.circleci/checksetup_answers.legacy.txt + BZ_QA_LEGACY_MODE: 1 + - <<: *mysql_image + environment: *mysql_env + - image: selenium/standalone-firefox:2.53.1 + - image: memcached:latest + + default_qa_setup: &default_qa_setup + run: + name: default qa setup + command: | + [[ -f build_info/only_version_changed.txt ]] && exit 0 + mv /opt/bmo/local /app/local + perl -MSys::Hostname -i -pE 's/bmo.test/hostname() . ":$ENV{PORT}"/ges' $BZ_QA_CONF_FILE + perl checksetup.pl --no-database --default-localconfig + mkdir artifacts + +jobs: + build_info: + parallelism: 1 + working_directory: /app + docker: + - <<: *bmo_slim_image + environment: + <<: *bmo_env + steps: + - checkout + - run: + name: build push data + command: | + mv /opt/bmo/local /app/local + perl Makefile.PL + perl -I/app -I/app/local/lib/perl5 -MBugzilla -e 1 + perl checksetup.pl --no-database --no-templates --no-permissions + perl scripts/build-bmo-push-data.pl + - run: + name: only publish if tag exists + command: | + tag="$(cat build_info/tag.txt)" + git fetch --tags + if git tag | fgrep -q "$tag"; then + echo "tag $tag exists!" + else + echo "tag $tag does not exist" + echo yes > build_info/publish.txt + fi + - run: + name: check if only version changed + command: | + if git diff 'HEAD~..HEAD' --name-only | grep -qv '^Bugzilla.pm'; then + echo "more files than just Bugzilla.pm changed." + exit 0 + fi + if git diff 'HEAD~..HEAD' |grep '^[+-][^+-]' | grep -qv '^[+-]our $VERSION'; then + echo "Something other than the version number changed." + exit 0 + fi + if [[ "$CIRCLE_BRANCH" == "master" ]]; then + echo "Can't cut corners on the master branch" + exit 0 + fi + echo yes > build_info/only_version_changed.txt + - persist_to_workspace: + root: /app/build_info + paths: ["*.txt"] + - store_artifacts: + path: /app/build_info + - *store_log + + build: + working_directory: /app + docker: + - image: docker:17.06.1-ce + steps: + - setup_remote_docker + - run: + name: install git and ssh + command: apk update && apk add git openssh-client + - checkout + - run: | + docker build \ + --build-arg CI="$CI" \ + --build-arg CIRCLE_SHA1="$CIRCLE_SHA1" \ + --build-arg CIRCLE_BUILD_URL="$CIRCLE_BUILD_URL" \ + -t bmo . + - attach_workspace: + at: /app/build_info + - run: "docker run --name bmo --entrypoint true bmo" + - run: "docker cp bmo:/app/version.json build_info/version.json" + - store_artifacts: + path: /app/build_info + - *store_log + - deploy: + command: | + [[ -n "$DOCKERHUB_REPO" && -n "$DOCKER_USER" && -n "$DOCKER_PASS" ]] || exit 0 + docker login -u "$DOCKER_USER" -p "$DOCKER_PASS" + if [[ "$CIRCLE_BRANCH" == "master" ]]; then + TAG="$(cat /app/build_info/tag.txt)" + if [[ -n "$TAG" && -f build_info/publish.txt ]]; then + [[ -n "$GITHUB_PERSONAL_TOKEN" ]] || exit 0 + git config credential.helper "cache --timeout 120" + git config user.email "$GITHUB_EMAIL" + git config user.name "$GITHUB_NAME" + git tag $TAG + git push https://${GITHUB_PERSONAL_TOKEN}:x-oauth-basic@github.com/$GITHUB_REPO.git $TAG + docker tag bmo "$DOCKERHUB_REPO:$TAG" + docker push "$DOCKERHUB_REPO:$TAG" + fi + docker tag bmo "$DOCKERHUB_REPO:latest" + docker push "$DOCKERHUB_REPO:latest" + else + docker tag bmo "$DOCKERHUB_REPO:$CIRCLE_BRANCH" + docker push "$DOCKERHUB_REPO:$CIRCLE_BRANCH" + fi + + test_sanity: + parallelism: 1 + working_directory: /app + docker: + - <<: *bmo_slim_image + environment: *bmo_env + steps: + - checkout + - attach_workspace: + at: /app/build_info + - run: | + [[ -f build_info/only_version_changed.txt ]] && exit 0 + mv /opt/bmo/local /app/local + mkdir artifacts + - run: | + [[ -f build_info/only_version_changed.txt ]] && exit 0 + perl -I/app -I/app/local/lib/perl5 -c -E 'use Bugzilla; BEGIN { Bugzilla->extensions }' + - run: | + [[ -f build_info/only_version_changed.txt ]] && exit 0 + perl Makefile.PL + - run: + name: run sanity tests + command: | + [[ -f build_info/only_version_changed.txt ]] && exit 0 + /app/scripts/entrypoint.pl prove -qf $(circleci tests glob 't/*.t' 'extensions/*/t/*.t' | circleci tests split) | tee artifacts/$CIRCLE_JOB.txt + - store_artifacts: + path: /app/artifacts + - *store_log + + test_webservices: + parallelism: 1 + working_directory: /app + docker: *docker_oldtests + steps: + - checkout + - attach_workspace: + at: /app/build_info + - *default_qa_setup + - run: | + [[ -f build_info/only_version_changed.txt ]] && exit 0 + /app/scripts/entrypoint.pl load_test_data + - run: | + [[ -f build_info/only_version_changed.txt ]] && exit 0 + /app/scripts/entrypoint.pl test_webservices | tee artifacts/$CIRCLE_JOB.txt + - store_artifacts: + path: /app/artifacts + - *store_log + + test_selenium: + parallelism: 1 + working_directory: /app + docker: *docker_oldtests + steps: + - checkout + - attach_workspace: + at: /app/build_info + - *default_qa_setup + - run: | + [[ -f build_info/only_version_changed.txt ]] && exit 0 + /app/scripts/entrypoint.pl load_test_data --legacy + - run: | + [[ -f build_info/only_version_changed.txt ]] && exit 0 + /app/scripts/entrypoint.pl test_selenium | tee artifacts/$CIRCLE_JOB.txt + - store_artifacts: + path: /app/artifacts + - *store_log + + test_bmo: + parallelism: 1 + working_directory: /app + docker: + - <<: *bmo_slim_image + environment: + <<: *bmo_env + BZ_QA_ANSWERS_FILE: /app/.circleci/checksetup_answers.txt + TWD_HOST: localhost + TWD_PORT: 4444 + TWD_BROWSER: firefox + - <<: *mysql_image + environment: *mysql_env + - image: memcached:latest + - image: selenium/standalone-firefox:2.53.1 + steps: + - checkout + - attach_workspace: + at: /app/build_info + - run: | + [[ -f build_info/only_version_changed.txt ]] && exit 0 + mv /opt/bmo/local /app/local + perl checksetup.pl --no-database + /app/scripts/entrypoint.pl load_test_data + mkdir artifacts + - run: | + [[ -f build_info/only_version_changed.txt ]] && exit 0 + /app/scripts/entrypoint.pl test_bmo -q -f t/bmo/*.t + - *store_log + +workflows: + version: 2 + main: + jobs: + - build_info: + filters: *main_filters + - build: + filters: *main_filters + requires: + - build_info + - test_sanity + - test_bmo + - test_webservices + - test_selenium + - test_sanity: + filters: *main_filters + requires: + - build_info + - test_bmo: + filters: *main_filters + requires: + - build_info + - test_webservices: + filters: *main_filters + requires: + - build_info + - test_selenium: + filters: *main_filters + requires: + - build_info diff --git a/.editorconfig b/.editorconfig index 59bb73282..2d527a250 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,10 @@ # top-most EditorConfig file root = true +[*] +end_of_line = lf +insert_final_newline = true + # 4 space indentation for Perl files [*.{pl,PL,pm,cgi}] indent_style = space diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..6e0d93ed7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.insertFinalNewline": true, + "files.eol": "\n", + "files.trimTrailingWhitespace": true, + "files.associations": { + "*.tmpl": "tt", + "*.css": "css", + "*.cgi": "perl" + }, + "gitlens.blame.ignoreWhitespace": true +} diff --git a/Bugzilla.pm b/Bugzilla.pm index a6f4e2b4d..0b88f5c3e 100644 --- a/Bugzilla.pm +++ b/Bugzilla.pm @@ -13,7 +13,7 @@ use warnings; use Bugzilla::Logging; -our $VERSION = '5.13'; +our $VERSION = '5.15'; use Bugzilla::Auth; use Bugzilla::Auth::Persist::Cookie; @@ -47,6 +47,7 @@ use File::Spec::Functions; use Safe; use JSON::XS qw(decode_json); use URI; +use Scope::Guard; use parent qw(Bugzilla::CPAN); @@ -86,6 +87,9 @@ sub init_page { # request cache are very annoying (see bug 1347335) # and this is not an expensive operation. clear_request_cache(); + if ($0 =~ /\.t/) { + return; + } if (Bugzilla->usage_mode == USAGE_MODE_CMDLINE) { init_console(); } @@ -285,8 +289,20 @@ sub user { } sub set_user { - my (undef, $user) = @_; - request_cache->{user} = $user; + my (undef, $new_user, %option) = @_; + + if ($option{scope_guard}) { + my $old_user = request_cache->{user}; + request_cache->{user} = $new_user; + return Scope::Guard->new( + sub { + request_cache->{user} = $old_user; + } + ) + } + else { + request_cache->{user} = $new_user; + } } sub sudoer { @@ -790,6 +806,27 @@ sub memcached { return request_cache->{memcached} ||= Bugzilla::Memcached->_new(); } +# Connector to the Datadog metrics collection daemon. +sub datadog { + my ($class, $namespace) = @_; + my $host = $class->localconfig->{datadog_host}; + my $port = $class->localconfig->{datadog_port}; + + $namespace //= ''; + + if ($class->has_feature('datadog') && $host) { + require DataDog::DogStatsd; + return request_cache->{datadog}{$namespace} //= DataDog::DogStatsd->new( + host => $host, + port => $port, + namespace => $namespace ? "$namespace." : '', + ); + } + else { + return undef; + } +} + sub elastic { my ($class) = @_; $class->process_cache->{elastic} //= Bugzilla::Elastic->new(); diff --git a/Bugzilla/CGI.pm b/Bugzilla/CGI.pm index 9ac01c71e..ae997a5fe 100644 --- a/Bugzilla/CGI.pm +++ b/Bugzilla/CGI.pm @@ -39,7 +39,7 @@ sub DEFAULT_CSP { script_src => [ 'self', 'nonce', 'unsafe-inline', 'https://www.google-analytics.com' ], frame_src => [ 'none', ], worker_src => [ 'none', ], - img_src => [ 'self', 'https://secure.gravatar.com' ], + img_src => [ 'self', 'blob:', 'https://secure.gravatar.com' ], style_src => [ 'self', 'unsafe-inline' ], object_src => [ 'none' ], connect_src => [ diff --git a/Bugzilla/Config.pm b/Bugzilla/Config.pm index d050ff9e0..85779fa6b 100644 --- a/Bugzilla/Config.pm +++ b/Bugzilla/Config.pm @@ -251,28 +251,11 @@ sub write_params { my ($param_data) = @_; $param_data ||= Bugzilla->params; - my $datadir = bz_locations()->{'datadir'}; - my $param_file = "$datadir/params"; - local $Data::Dumper::Sortkeys = 1; - my ($fh, $tmpname) = File::Temp::tempfile('params.XXXXX', - DIR => $datadir ); - my %params = %$param_data; $params{urlbase} = Bugzilla->localconfig->{urlbase}; - print $fh (Data::Dumper->Dump([\%params], ['*param'])) - || die "Can't write param file: $!"; - - close $fh; - - rename $tmpname, $param_file - or die "Can't rename $tmpname to $param_file: $!"; - - # It's not common to edit parameters and loading - # Bugzilla::Install::Filesystem is slow. - require Bugzilla::Install::Filesystem; - Bugzilla::Install::Filesystem::fix_file_permissions($param_file); + __PACKAGE__->_write_file( Data::Dumper->Dump([\%params], ['*param']) ); # And now we have to reset the params cache so that Bugzilla will re-read # them. @@ -311,6 +294,24 @@ sub read_param_file { return \%params; } +sub _write_file { + my ($class, $str) = @_; + my $datadir = bz_locations()->{'datadir'}; + my $param_file = "$datadir/params"; + my ($fh, $tmpname) = File::Temp::tempfile('params.XXXXX', + DIR => $datadir ); + print $fh $str || die "Can't write param file: $!"; + close $fh || die "Can't close param file: $!"; + + rename $tmpname, $param_file + or die "Can't rename $tmpname to $param_file: $!"; + + # It's not common to edit parameters and loading + # Bugzilla::Install::Filesystem is slow. + require Bugzilla::Install::Filesystem; + Bugzilla::Install::Filesystem::fix_file_permissions($param_file); +} + 1; __END__ diff --git a/Bugzilla/DB/Sqlite.pm b/Bugzilla/DB/Sqlite.pm index 3890d0795..81ee7d888 100644 --- a/Bugzilla/DB/Sqlite.pm +++ b/Bugzilla/DB/Sqlite.pm @@ -73,7 +73,7 @@ sub BUILDARGS { my $db_name = $params->{db_name}; # Let people specify paths intead of data/ for the DB. - if ($db_name and $db_name !~ m{[\\/]}) { + if ($db_name && $db_name ne ':memory:' && $db_name !~ m{[\\/]}) { # When the DB is first created, there's a chance that the # data directory doesn't exist at all, because the Install::Filesystem # code happens after DB creation. So we create the directory ourselves diff --git a/Bugzilla/Install/Filesystem.pm b/Bugzilla/Install/Filesystem.pm index 46e121779..1da33882b 100644 --- a/Bugzilla/Install/Filesystem.pm +++ b/Bugzilla/Install/Filesystem.pm @@ -67,55 +67,6 @@ use constant HTTPD_ENV => qw( NYTPROF_DIR ); -sub HTTPD_ENV_CONF { - my @env = (ENV_KEYS, HTTPD_ENV); - return join( "\n", map { "PerlPassEnv " . $_ } @env ) . "\n"; -} - -sub _error_page { - my ($code, $title, $description) = @_; - - return <<EOT; -<!DOCTYPE HTML> -<html> - <head> - <title>$title</title> - <style> - body { - margin: 1em 2em; - background-color: #455372; - color: #ddd; - font-family: sans-serif; - } - h1, h3 { - color: #fff; - } - a { - color: #fff; - text-decoration: none; - } - #buggie { - float: left; - } - #content { - margin-left: 100px; - padding-top: 20px; - } - </style> - </head> - <body> - <img src="/images/buggie.png" id="buggie" alt="buggie" width="78" height="215"> - <div id="content"> - <h1>$title</h1> - <p>$description</p> - <h3>Error $code</h3> - <p><a href="/">this site</a></p> - </div> - </body> -</html> -EOT -} - ############### # Permissions # ############### @@ -427,9 +378,6 @@ sub FILESYSTEM { "skins/yui3.css" => { perms => CGI_READ, overwrite => 1, contents => $yui3_all_css }, - "$confdir/env.conf" => { perms => CGI_READ, - overwrite => 1, - contents => \&HTTPD_ENV_CONF }, ); # Create static error pages. diff --git a/Bugzilla/Install/Localconfig.pm b/Bugzilla/Install/Localconfig.pm index 39063ee63..ac21a0cb7 100644 --- a/Bugzilla/Install/Localconfig.pm +++ b/Bugzilla/Install/Localconfig.pm @@ -186,7 +186,15 @@ use constant LOCALCONFIG_VARS => ( { name => 'shadowdb_pass', default => '', - } + }, + { + name => 'datadog_host', + default => '', + }, + { + name => 'datadog_port', + default => 8125, + }, ); diff --git a/Bugzilla/Quantum.pm b/Bugzilla/Quantum.pm index 8d46833c4..03dfcf0d0 100644 --- a/Bugzilla/Quantum.pm +++ b/Bugzilla/Quantum.pm @@ -61,10 +61,12 @@ sub startup { Bugzilla::WebService::Server::REST->preload; $r->any('/')->to('CGI#index_cgi'); + $r->any('/bug/<id:num>')->to('CGI#show_bug_cgi'); + $r->any('/<id:num>')->to('CGI#show_bug_cgi'); + $r->any('/rest')->to('CGI#rest_cgi'); $r->any('/rest.cgi/*PATH_INFO')->to( 'CGI#rest_cgi' => { PATH_INFO => '' } ); $r->any('/rest/*PATH_INFO')->to( 'CGI#rest_cgi' => { PATH_INFO => '' } ); - $r->any('/bug/:id')->to('CGI#show_bug_cgi'); $r->any('/extensions/BzAPI/bin/rest.cgi/*PATH_INFO')->to('CGI#bzapi_cgi'); $r->get( diff --git a/Bugzilla/Quantum/SES.pm b/Bugzilla/Quantum/SES.pm index 47c591fb5..03916075d 100644 --- a/Bugzilla/Quantum/SES.pm +++ b/Bugzilla/Quantum/SES.pm @@ -18,8 +18,25 @@ use JSON::MaybeXS qw(decode_json); use LWP::UserAgent (); use Try::Tiny qw(catch try); +use Types::Standard qw( :all ); +use Type::Utils; +use Type::Params qw( compile ); + +my $Invocant = class_type { class => __PACKAGE__ }; + sub main { my ($self) = @_; + try { + $self->_main; + } + catch { + FATAL("Error in SES Handler: ", $_); + $self->_respond( 400 => 'Bad Request' ); + }; +} + +sub _main { + my ($self) = @_; Bugzilla->error_mode(ERROR_MODE_DIE); my $message = $self->_decode_json_wrapper( $self->req->body ) // return; my $message_type = $self->req->headers->header('X-Amz-SNS-Message-Type') // '(missing)'; @@ -50,7 +67,8 @@ sub main { } sub _confirm_subscription { - my ($self, $message) = @_; + state $check = compile($Invocant, Dict[SubscribeURL => Str, slurpy Any]); + my ($self, $message) = $check->(@_); my $subscribe_url = $message->{SubscribeURL}; if ( !$subscribe_url ) { @@ -70,8 +88,17 @@ sub _confirm_subscription { $self->_respond( 200 => 'OK' ); } +my $NotificationType = Enum [qw( Bounce Complaint )]; +my $TypeField = Enum [qw(eventType notificationType)]; +my $Notification = Dict [ + eventType => Optional [$NotificationType], + notificationType => Optional [$NotificationType], + slurpy Any, +]; + sub _handle_notification { - my ( $self, $notification, $type_field ) = @_; + state $check = compile($Invocant, $Notification, $TypeField ); + my ( $self, $notification, $type_field ) = $check->(@_); if ( !exists $notification->{$type_field} ) { return 0; @@ -91,8 +118,28 @@ sub _handle_notification { return 1; } +my $BouncedRecipients = ArrayRef[ + Dict[ + emailAddress => Str, + action => Str, + diagnosticCode => Str, + slurpy Any, + ], +]; +my $BounceNotification = Dict [ + bounce => Dict [ + bouncedRecipients => $BouncedRecipients, + reportingMTA => Str, + bounceSubType => Str, + bounceType => Str, + slurpy Any, + ], + slurpy Any, +]; + sub _process_bounce { - my ($self, $notification) = @_; + state $check = compile($Invocant, $BounceNotification); + my ($self, $notification) = $check->(@_); # disable each account that is bouncing foreach my $recipient ( @{ $notification->{bounce}->{bouncedRecipients} } ) { @@ -132,11 +179,19 @@ sub _process_bounce { $self->_respond( 200 => 'OK' ); } -sub _process_complaint { - my ($self) = @_; +my $ComplainedRecipients = ArrayRef[Dict[ emailAddress => Str, slurpy Any ]]; +my $ComplaintNotification = Dict[ + complaint => Dict [ + complainedRecipients => $ComplainedRecipients, + complaintFeedbackType => Str, + slurpy Any, + ], + slurpy Any, +]; - # email notification to bugzilla admin - my ($notification) = @_; +sub _process_complaint { + state $check = compile($Invocant, $ComplaintNotification); + my ($self, $notification) = $check->(@_); my $template = Bugzilla->template_inner(); my $json = JSON::MaybeXS->new( pretty => 1, @@ -169,13 +224,9 @@ sub _respond { } sub _decode_json_wrapper { - my ($self, $json) = @_; + state $check = compile($Invocant, Str); + my ($self, $json) = $check->(@_); my $result; - if ( !defined $json ) { - WARN( 'Missing JSON from ' . $self->tx->remote_address ); - $self->_respond( 400 => 'Bad Request' ); - return undef; - } my $ok = try { $result = decode_json($json); } @@ -200,4 +251,4 @@ sub ua { return $ua; } -1;
\ No newline at end of file +1; diff --git a/Bugzilla/Quantum/Static.pm b/Bugzilla/Quantum/Static.pm index d687873ab..c01f062a4 100644 --- a/Bugzilla/Quantum/Static.pm +++ b/Bugzilla/Quantum/Static.pm @@ -11,7 +11,7 @@ use Bugzilla::Constants qw(bz_locations); my $LEGACY_RE = qr{ ^ (?:static/v[0-9]+\.[0-9]+/) ? - ( (?:extensions/[^/]+/web|(?:image|skin|j)s)/.+) + ( (?:extensions/[^/]+/web|(?:image|graph|skin|j)s)/.+) $ }xs; diff --git a/Bugzilla/Quantum/Stdout.pm b/Bugzilla/Quantum/Stdout.pm index be7b546ea..9cf19992c 100644 --- a/Bugzilla/Quantum/Stdout.pm +++ b/Bugzilla/Quantum/Stdout.pm @@ -11,6 +11,7 @@ use Moo; use Bugzilla::Logging; use Encode; +use English qw(-no_match_vars); has 'controller' => ( is => 'ro', @@ -41,7 +42,7 @@ sub PRINT { ## no critic (unpack) if ( $self->_encoding ) { $bytes = encode( $self->_encoding, $bytes ); } - $c->write($bytes.$\); + $c->write($bytes . ( $OUTPUT_RECORD_SEPARATOR // '' ) ); } sub BINMODE { diff --git a/Bugzilla/Test/MockDB.pm b/Bugzilla/Test/MockDB.pm new file mode 100644 index 000000000..fb7873ccf --- /dev/null +++ b/Bugzilla/Test/MockDB.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::Test::MockDB; +use 5.10.1; +use strict; +use warnings; +use Try::Tiny; +use Capture::Tiny qw(capture_merged); + +use Bugzilla::Test::MockLocalconfig ( + db_driver => 'sqlite', + db_name => ':memory:', +); +use Bugzilla; +BEGIN { Bugzilla->extensions }; +use Bugzilla::Test::MockParams ( + emailsuffix => '', + emailregexp => '.+', +); + +sub import { + require Bugzilla::Install; + require Bugzilla::Install::DB; + require Bugzilla::Field;; + + state $first_time = 0; + + return undef if $first_time++; + + return capture_merged { + Bugzilla->dbh->bz_setup_database(); + + # Populate the tables that hold the values for the <select> fields. + Bugzilla->dbh->bz_populate_enum_tables(); + + Bugzilla::Install::DB::update_fielddefs_definition(); + Bugzilla::Field::populate_field_definitions(); + Bugzilla::Install::init_workflow(); + Bugzilla::Install::DB->update_table_definitions({}); + Bugzilla::Install::update_system_groups(); + + Bugzilla->set_user(Bugzilla::User->super_user); + + Bugzilla::Install::update_settings(); + + my $dbh = Bugzilla->dbh; + if ( !$dbh->selectrow_array("SELECT 1 FROM priority WHERE value = 'P1'") ) { + $dbh->do("DELETE FROM priority"); + my $count = 100; + foreach my $priority (map { "P$_" } 1..5) { + $dbh->do( "INSERT INTO priority (value, sortkey) VALUES (?, ?)", undef, ( $priority, $count + 100 ) ); + } + } + my @flagtypes = ( + { + name => 'review', + desc => 'The patch has passed review by a module owner or peer.', + is_requestable => 1, + is_requesteeble => 1, + is_multiplicable => 1, + grant_group => '', + target_type => 'a', + cc_list => '', + inclusions => [''] + }, + { + name => 'feedback', + desc => 'A particular person\'s input is requested for a patch, ' . + 'but that input does not amount to an official review.', + is_requestable => 1, + is_requesteeble => 1, + is_multiplicable => 1, + grant_group => '', + target_type => 'a', + cc_list => '', + inclusions => [''] + } + ); + + foreach my $flag (@flagtypes) { + next if Bugzilla::FlagType->new({ name => $flag->{name} }); + my $grant_group_id = $flag->{grant_group} + ? Bugzilla::Group->new({ name => $flag->{grant_group} })->id + : undef; + my $request_group_id = $flag->{request_group} + ? Bugzilla::Group->new({ name => $flag->{request_group} })->id + : undef; + + $dbh->do('INSERT INTO flagtypes (name, description, cc_list, target_type, is_requestable, + is_requesteeble, is_multiplicable, grant_group_id, request_group_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + undef, ($flag->{name}, $flag->{desc}, $flag->{cc_list}, $flag->{target_type}, + $flag->{is_requestable}, $flag->{is_requesteeble}, $flag->{is_multiplicable}, + $grant_group_id, $request_group_id)); + + my $type_id = $dbh->bz_last_key('flagtypes', 'id'); + + foreach my $inclusion (@{$flag->{inclusions}}) { + my ($product, $component) = split(':', $inclusion); + my ($prod_id, $comp_id); + if ($product) { + my $prod_obj = Bugzilla::Product->new({ name => $product }); + $prod_id = $prod_obj->id; + if ($component) { + $comp_id = Bugzilla::Component->new({ name => $component, product => $prod_obj})->id; + } + } + $dbh->do('INSERT INTO flaginclusions (type_id, product_id, component_id) + VALUES (?, ?, ?)', + undef, ($type_id, $prod_id, $comp_id)); + } + } + }; +} + +1; diff --git a/Bugzilla/Test/MockLocalconfig.pm b/Bugzilla/Test/MockLocalconfig.pm new file mode 100644 index 000000000..a32aea0d4 --- /dev/null +++ b/Bugzilla/Test/MockLocalconfig.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::Test::MockLocalconfig; +use 5.10.1; +use strict; +use warnings; + +sub import { + my ($class, %lc) = @_; + $ENV{LOCALCONFIG_ENV} = 'BMO'; + $ENV{"BMO_$_"} = $lc{$_} for keys %lc; +} + +1; diff --git a/Bugzilla/Test/MockParams.pm b/Bugzilla/Test/MockParams.pm new file mode 100644 index 000000000..2d064c616 --- /dev/null +++ b/Bugzilla/Test/MockParams.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::Test::MockParams; +use 5.10.1; +use strict; +use warnings; +use Try::Tiny; +use Capture::Tiny qw(capture_merged); +use Test2::Tools::Mock qw(mock); + +use Bugzilla::Config; +use Safe; + +our $Params; +BEGIN { + our $Mock = mock 'Bugzilla::Config' => ( + override => [ + 'read_param_file' => sub { + my ($class) = @_; + return {} unless $Params; + my $s = Safe->new; + $s->reval($Params); + die "Error evaluating params: $@" if $@; + return { %{ $s->varglob('param') } }; + }, + '_write_file' => sub { + my ($class, $str) = @_; + $Params = $str; + }, + ], + ); +} + +sub import { + my ($self, %answers) = @_; + state $first_time = 0; + + require Bugzilla::Field; + require Bugzilla::Status; + require Bugzilla; + my $Bugzilla = mock 'Bugzilla' => ( + override => [ + installation_answers => sub { \%answers }, + ], + ); + my $BugzillaField = mock 'Bugzilla::Field' => ( + override => [ + get_legal_field_values => sub { [] }, + ], + ); + my $BugzillaStatus = mock 'Bugzilla::Status' => ( + override => [ + closed_bug_statuses => sub { die "no database" }, + ], + ); + + if ($first_time++) { + capture_merged { + Bugzilla::Config::update_params(); + }; + } + else { + Bugzilla::Config::SetParam($_, $answers{$_}) for keys %answers; + } +} + +1;
\ No newline at end of file diff --git a/Bugzilla/Test/Util.pm b/Bugzilla/Test/Util.pm index 4c9981e52..02c842658 100644 --- a/Bugzilla/Test/Util.pm +++ b/Bugzilla/Test/Util.pm @@ -24,7 +24,7 @@ sub create_user { cryptpassword => $password, disabledtext => "", disable_mail => 0, - extern_id => 0, + extern_id => undef, %extra, }); } diff --git a/Bugzilla/Types.pm b/Bugzilla/Types.pm new file mode 100644 index 000000000..93d699f49 --- /dev/null +++ b/Bugzilla/Types.pm @@ -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. + +package Bugzilla::Types; + +use 5.10.1; +use strict; +use warnings; + +use Type::Library + -base, + -declare => qw( Bug User Group Attachment Comment JSONBool ); +use Type::Utils -all; +use Types::Standard -types; + +class_type Bug, { class => 'Bugzilla::Bug' }; +class_type User, { class => 'Bugzilla::User' }; +class_type Group, { class => 'Bugzilla::Group' }; +class_type Attachment, { class => 'Bugzilla::Attachment' }; +class_type Comment, { class => 'Bugzilla::Comment' }; +class_type JSONBool, { class => 'JSON::PP::Boolean' }; + +1; diff --git a/Bugzilla/WebService/BugUserLastVisit.pm b/Bugzilla/WebService/BugUserLastVisit.pm index 7b729c6c8..5e4c0d2ba 100644 --- a/Bugzilla/WebService/BugUserLastVisit.pm +++ b/Bugzilla/WebService/BugUserLastVisit.pm @@ -52,7 +52,7 @@ sub update { push( @results, $self->_bug_user_last_visit_to_hash( - $bug, $last_visit_ts, $params + $bug_id, $last_visit_ts, $params )); } $dbh->bz_commit_transaction(); diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..fd02f222d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM mozillabteam/bmo-slim:20180809.1 + +ARG CI +ARG CIRCLE_SHA1 +ARG CIRCLE_BUILD_URL + +ENV CI=${CI} +ENV CIRCLE_BUILD_URL=${CIRCLE_BUILD_URL} +ENV CIRCLE_SHA1=${CIRCLE_SHA1} + +ENV LOG4PERL_CONFIG_FILE=log4perl-json.conf + +ENV PORT=8000 + +# we run a loopback logging server on this TCP port. +ENV LOGGING_PORT=5880 + +WORKDIR /app +COPY . . + +RUN mv /opt/bmo/local /app && \ + chown -R app:app /app && \ + perl -I/app -I/app/local/lib/perl5 -c -E 'use Bugzilla; BEGIN { Bugzilla->extensions }' && \ + perl -c /app/scripts/entrypoint.pl && \ + setcap 'cap_net_bind_service=+ep' /usr/sbin/httpd && \ + setcap 'cap_net_bind_service=+ep' /usr/bin/perl + +USER app + +RUN perl checksetup.pl --no-database --default-localconfig && \ + rm -rf /app/data /app/localconfig && \ + mkdir /app/data + +EXPOSE $PORT + +ENTRYPOINT ["/app/scripts/entrypoint.pl"] +CMD ["httpd"] diff --git a/Makefile.PL b/Makefile.PL index b0f7e88ce..43d3930b0 100755 --- a/Makefile.PL +++ b/Makefile.PL @@ -74,16 +74,18 @@ my %requires = ( 'Mozilla::CA' => '20160104', 'Parse::CPAN::Meta' => '1.44', 'Role::Tiny' => '2.000003', + 'Scope::Guard' => '0.21', + 'Sereal' => '4.004', 'Taint::Util' => '0.08', 'Template' => '2.24', 'Text::CSV_XS' => '1.26', 'Throwable' => '0.200013', 'Sub::Quote' => '2.005000', - 'Type::Tiny' => '1.000005', + 'Type::Tiny' => '1.004002', 'URI' => '1.55', 'URI::Escape::XS' => '0.14', 'version' => '0.87', - 'EV' => 4.0 + 'EV' => '4.0', ); my %build_requires = ( 'ExtUtils::MakeMaker' => '7.22', ); @@ -94,6 +96,7 @@ my %test_requires = ( 'Test::Selenium::Firefox' => 0, 'Test::Perl::Critic::Progressive' => 0, 'Perl::Critic::Freenode' => 0, + 'Capture::Tiny' => 0, ); my %recommends = ( Safe => '2.30',); diff --git a/attachment.cgi b/attachment.cgi index d1b260407..875de6a50 100755 --- a/attachment.cgi +++ b/attachment.cgi @@ -33,6 +33,7 @@ use URI; use URI::QueryParam; use URI::Escape qw(uri_escape_utf8); use File::Basename qw(basename); +use MIME::Base64 qw(decode_base64); # For most scripts we don't make $cgi and $template global variables. But # when preparing Bugzilla for mod_perl, this script used these @@ -552,20 +553,30 @@ sub insert { # Get the filehandle of the attachment. my $data_fh = $cgi->upload('data'); my $attach_text = $cgi->param('attach_text'); + my $data_base64 = $cgi->param('data_base64'); + my $data; + my $filename; if ($attach_text) { # Convert to unix line-endings if pasting a patch if (scalar($cgi->param('ispatch'))) { $attach_text =~ s/[\012\015]{1,2}/\012/g; } + $data = $attach_text; + $filename = "file_$bugid.txt"; + } elsif ($data_base64) { + $data = decode_base64($data_base64); + $filename = $cgi->param('filename') || "file_$bugid"; + } else { + $data = $filename = $data_fh; } my $attachment = Bugzilla::Attachment->create( {bug => $bug, creation_ts => $timestamp, - data => $attach_text || $data_fh, + data => $data, description => scalar $cgi->param('description'), - filename => $attach_text ? "file_$bugid.txt" : $data_fh, + filename => $filename, ispatch => scalar $cgi->param('ispatch'), isprivate => scalar $cgi->param('isprivate'), mimetype => $content_type, diff --git a/conf/httpd.conf b/conf/httpd.conf deleted file mode 100644 index 539ab4231..000000000 --- a/conf/httpd.conf +++ /dev/null @@ -1,103 +0,0 @@ -ServerName 127.0.0.1 -ServerTokens Prod -ServerRoot "/etc/httpd" -ServerAdmin root@localhost - -PidFile /tmp/httpd.pid -Timeout 120 -LimitRequestLine 35000 -KeepAlive Off -MaxKeepAliveRequests 100 -KeepAliveTimeout 15 - -StartServers ${HTTPD_StartServers} -MinSpareServers ${HTTPD_MinSpareServers} -MaxSpareServers ${HTTPD_MaxSpareServers} -ServerLimit ${HTTPD_ServerLimit} -MaxClients ${HTTPD_MaxClients} -MaxRequestsPerChild ${HTTPD_MaxRequestsPerChild} - -Listen ${PORT} -User app -Group app - -LoadModule auth_basic_module modules/mod_auth_basic.so -LoadModule auth_digest_module modules/mod_auth_digest.so -LoadModule authn_file_module modules/mod_authn_file.so -LoadModule authn_alias_module modules/mod_authn_alias.so -LoadModule authn_anon_module modules/mod_authn_anon.so -LoadModule authn_dbm_module modules/mod_authn_dbm.so -LoadModule authn_default_module modules/mod_authn_default.so -LoadModule authz_host_module modules/mod_authz_host.so -LoadModule authz_user_module modules/mod_authz_user.so -LoadModule authz_owner_module modules/mod_authz_owner.so -LoadModule authz_groupfile_module modules/mod_authz_groupfile.so -LoadModule authz_default_module modules/mod_authz_default.so -LoadModule log_config_module modules/mod_log_config.so -LoadModule env_module modules/mod_env.so -LoadModule mime_magic_module modules/mod_mime_magic.so -LoadModule expires_module modules/mod_expires.so -LoadModule deflate_module modules/mod_deflate.so -LoadModule headers_module modules/mod_headers.so -LoadModule setenvif_module modules/mod_setenvif.so -LoadModule mime_module modules/mod_mime.so -LoadModule negotiation_module modules/mod_negotiation.so -LoadModule dir_module modules/mod_dir.so -LoadModule alias_module modules/mod_alias.so -LoadModule rewrite_module modules/mod_rewrite.so -LoadModule perl_module modules/mod_perl.so - -UseCanonicalName Off -<Directory /> - Options FollowSymLinks - AllowOverride None -</Directory> -AccessFileName .htaccess -<Files ~ "^\.ht"> - Order allow,deny - Deny from all - Satisfy All -</Files> -TypesConfig /etc/mime.types -DefaultType text/plain -MIMEMagicFile conf/magic -HostnameLookups Off -<IfDefine NETCAT_LOGS> - ErrorLog "|/usr/bin/nc localhost ${LOGGING_PORT}" - <IfDefine ACCESS_LOGS> - TransferLog "|/usr/bin/nc localhost ${LOGGING_PORT}" - </IfDefine> -</IfDefine> -<IfDefine !NETCAT_LOGS> - ErrorLog /dev/stderr - <IfDefine ACCESS_LOGS> - TransferLog /dev/stdout - </IfDefine> -</IfDefine> -LogLevel warn -LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined -LogFormat "%h %l %u %t \"%r\" %>s %b" common -LogFormat "%{Referer}i -> %U" referer -LogFormat "%{User-agent}i" agent -ServerSignature Off -AddDefaultCharset UTF-8 - -Include /app/conf/env.conf - -PerlSwitches -wT -PerlRequire /app/mod_perl.pl -PerlSetEnv LOG4PERL_STDERR_DISABLE 1 -DirectoryIndex index.cgi -DocumentRoot "/app" -<IfDefine HTTPD_IN_SUBDIR> -Alias "/bmo" "/app" -</IfDefine> -<IfDefine HTTPS> - SetEnvIf X-Forwarded-Proto "https" HTTPS=on -</IfDefine> -<Directory "/app"> - Options -Indexes -FollowSymLinks - AllowOverride None - Order allow,deny - Allow from all -</Directory> diff --git a/conf/log4perl-t.conf b/conf/log4perl-t.conf new file mode 100644 index 000000000..33100d76c --- /dev/null +++ b/conf/log4perl-t.conf @@ -0,0 +1,4 @@ +log4perl.rootLogger = DEBUG, Screen +log4perl.appender.Screen = Log::Log4perl::Appender::Screen +log4perl.appender.Screen.layout = Log::Log4perl::Layout::PatternLayout +log4perl.appender.Screen.layout.ConversionPattern = # [%6p] {%c} %m{chomp}%n diff --git a/extensions/BMO/Extension.pm b/extensions/BMO/Extension.pm index 743d03099..0a3389e5d 100644 --- a/extensions/BMO/Extension.pm +++ b/extensions/BMO/Extension.pm @@ -2747,6 +2747,15 @@ sub app_startup { my $app = $args->{app}; my $r = $app->routes; + $r->get( + '/favicon.ico' => sub { + my $c = shift; + $c->reply->file( + $c->app->home->child('extensions/BMO/web/images/favicon.ico') + ); + } + ); + $r->any( '/:REWRITE_itrequest' => [ REWRITE_itrequest => qr{form[\.:]itrequest} ] ) ->to( 'CGI#enter_bug_cgi' => { 'product' => 'Infrastructure & Operations', 'format' => 'itrequest' } ); $r->any( '/:REWRITE_mozlist' => [ REWRITE_mozlist => qr{form[\.:]mozlist} ] ) diff --git a/extensions/BMO/t/bounty_attachment.t b/extensions/BMO/t/bounty_attachment.t index bd79b0dfe..6e596eeba 100644 --- a/extensions/BMO/t/bounty_attachment.t +++ b/extensions/BMO/t/bounty_attachment.t @@ -7,15 +7,13 @@ # defined by the Mozilla Public License, v. 2.0. use strict; use warnings; -use lib qw( . lib ); +use lib qw( . lib local/lib/perl5 ); use Test::More; use Bugzilla; -use Bugzilla::Extension; - -my $class = Bugzilla::Extension->load('extensions/BMO/Extension.pm', - 'extensions/BMO/Config.pm'); +BEGIN { Bugzilla->extensions } +my $class = 'Bugzilla::Extension::BMO'; my $parse = $class->can('parse_bounty_attachment_description'); my $format = $class->can('format_bounty_attachment_description'); diff --git a/extensions/BMO/t/bug_format_comment.t b/extensions/BMO/t/bug_format_comment.t deleted file mode 100644 index 532b8fb8d..000000000 --- a/extensions/BMO/t/bug_format_comment.t +++ /dev/null @@ -1,90 +0,0 @@ -#!/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 -CVE-2014-0001 -CVE-2014-13579 -CVE-2014-999999999 - -# 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="https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-0160">CVE-2014-0160</a>', - '<a href="https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-0001">CVE-2014-0001</a>', - '<a href="https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-13579">CVE-2014-13579</a>', - '<a href="https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-999999999">CVE-2014-999999999</a>', - '<a href="https://viewvc.svn.mozilla.org/vc?view=rev&revision=2424">r2424</a>', - '<a href="https://git.mozilla.org/?p=bugzilla/bugzilla.git;a=commitdiff;h=eab44b1">36f56bd..eab44b1 withuri -> withuri</a>', - '<a href="https://git.mozilla.org/?p=bugzilla/bugzilla.git;a=commitdiff;h=eab44b1">36f56bd..eab44b1 nouri -> nouri</a>', - '<a href="https://git.mozilla.org/?p=bugzilla/bugzilla.git;a=commitdiff;h=eab44b1">36f56bd..eab44b1 nologin -> nologin</a>', - 'https://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/global/choose-product.html.tmpl b/extensions/BMO/template/en/default/global/choose-product.html.tmpl index 679d812e1..dfa9b5af4 100644 --- a/extensions/BMO/template/en/default/global/choose-product.html.tmpl +++ b/extensions/BMO/template/en/default/global/choose-product.html.tmpl @@ -116,7 +116,7 @@ %] [% INCLUDE easyproduct name="Data Platform and Tools" - icon="sync.png" + icon="telemetry.png" %] <section class="product other"> <h3> diff --git a/extensions/BMO/web/producticons/sync.png b/extensions/BMO/web/producticons/sync.png Binary files differdeleted file mode 100644 index b42125ef6..000000000 --- a/extensions/BMO/web/producticons/sync.png +++ /dev/null diff --git a/extensions/BMO/web/producticons/telemetry.png b/extensions/BMO/web/producticons/telemetry.png Binary files differnew file mode 100644 index 000000000..307272d1f --- /dev/null +++ b/extensions/BMO/web/producticons/telemetry.png diff --git a/extensions/BMO/web/producticons/thunderbird.png b/extensions/BMO/web/producticons/thunderbird.png Binary files differindex f3523183a..2abb6a532 100644 --- a/extensions/BMO/web/producticons/thunderbird.png +++ b/extensions/BMO/web/producticons/thunderbird.png diff --git a/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl b/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl index 36494773b..08c6b5b64 100644 --- a/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl +++ b/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl @@ -222,6 +222,7 @@ [% IF extra_class %] <span class="user-role">([% extra_class.ucfirst FILTER none %])</span> [% END %] + [% Hook.process('user', 'bug/changes.html.tmpl') %] </td> <td class="comment-actions"> <button type="button" class="change-spinner minor" id="as-[% id FILTER none %]">-</button> diff --git a/extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl b/extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl index 5d38d8340..bcbea3f15 100644 --- a/extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl +++ b/extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl @@ -391,7 +391,9 @@ <li role="separator"></li> <div class="actions"> <div><a href="buglist.cgi?product=[% bug.product FILTER uri %]&bug_status=__open__" - target="_blank" role="menuitem" tabindex="-1">See Other [% terms.Bugs %]</a></div> + target="_blank" role="menuitem" tabindex="-1">See All [% terms.Bugs %] in This Product</a></div> + <div><a href="enter_bug.cgi?product=[% bug.product FILTER uri %]" + target="_blank" role="menuitem" tabindex="-1">File New [% terms.Bug %] in This Product</a></div> <div><button disabled type="button" class="minor component-watching" role="menuitem" tabindex="-1" data-product="[% bug.product FILTER html %]" data-label-watch="Watch This Product" data-label-unwatch="Unwatch This Product" @@ -447,7 +449,10 @@ <div class="actions"> <div><a href="buglist.cgi?product=[% bug.product FILTER uri %]& [%~ %]component=[% bug.component FILTER uri %]&bug_status=__open__" - target="_blank" role="menuitem" tabindex="-1">See Other [% terms.Bugs %]</a></div> + target="_blank" role="menuitem" tabindex="-1">See All [% terms.Bugs %] in This Component</a></div> + <div><a href="enter_bug.cgi?product=[% bug.product FILTER uri %]& + [%~ %]component=[% bug.component FILTER uri %]" + target="_blank" role="menuitem" tabindex="-1">File New [% terms.Bug %] in This Component</a></div> <div><button disabled type="button" class="minor component-watching" role="menuitem" tabindex="-1" data-product="[% bug.product FILTER html %]" data-component="[% bug.component FILTER html %]" data-label-watch="Watch This Component" data-label-unwatch="Unwatch This Component" diff --git a/extensions/ComponentWatching/Extension.pm b/extensions/ComponentWatching/Extension.pm index 96eb877a6..fdeedff98 100644 --- a/extensions/ComponentWatching/Extension.pm +++ b/extensions/ComponentWatching/Extension.pm @@ -413,7 +413,7 @@ sub bugmail_recipients { 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, '%') + AND components.name LIKE @{[$dbh->sql_string_concat('component_prefix', q{'%'})]} AND (components.id = ? OR components.id = ?) "); $sth->execute( diff --git a/extensions/MyDashboard/Extension.pm b/extensions/MyDashboard/Extension.pm index 5278cfaa4..fc3a689bf 100644 --- a/extensions/MyDashboard/Extension.pm +++ b/extensions/MyDashboard/Extension.pm @@ -106,7 +106,7 @@ sub _component_watcher_ids { WHERE product_id = ? AND (component_id = ? OR component_id IS NULL - OR ? LIKE CONCAT(component_prefix, '%'))"; + OR ? LIKE @{[$dbh->sql_string_concat('component_prefix', q{'%'})]})"; $self->{watcher_ids} ||= $dbh->selectcol_arrayref($query, undef, $self->product_id, $self->id, $self->name); diff --git a/extensions/MyDashboard/web/js/flags.js b/extensions/MyDashboard/web/js/flags.js index 425e42e57..8931e277a 100644 --- a/extensions/MyDashboard/web/js/flags.js +++ b/extensions/MyDashboard/web/js/flags.js @@ -154,11 +154,10 @@ $(function () { '<tr class="' + row.getAttribute('class') + '">' + '<td class="yui3-datatable-cell" colspan="4">' + '<a href="' + o.data.url + '" target="_blank">' + - Y.Escape.html('D' + o.data.id + ' - ' + o.data.title) + - '</a></td></tr>', - 'before'); + Y.Escape.html(o.data.title) + '</a></td></tr>', + 'after'); - o.cell.set('text', o.data.status == 'added' ? 'pending' : o.data.status); + o.cell.setHTML('<a href="' + o.data.url + '">D' + o.data.id + '</a>'); return false; }; @@ -179,7 +178,9 @@ $(function () { dataTable.reviews = new Y.DataTable({ columns: [ { key: 'author_email', label: 'Requester', sortable: true, - formattter: phabAuthorFormatter, allowHTML: true }, + formatter: phabAuthorFormatter, allowHTML: true }, + { key: 'id', label: 'Revision', sortable: true, + nodeFormatter: phabRowFormatter, allowHTML: true }, { key: 'bug_id', label: 'Bug', sortable: true, formatter: bugLinkFormatter, allowHTML: true }, { key: 'updated', label: 'Updated', sortable: true, diff --git a/extensions/PhabBugz/lib/Feed.pm b/extensions/PhabBugz/lib/Feed.pm index 7d6b4e0ed..f2a440bb1 100644 --- a/extensions/PhabBugz/lib/Feed.pm +++ b/extensions/PhabBugz/lib/Feed.pm @@ -16,6 +16,9 @@ use List::MoreUtils qw(any uniq); use Moo; use Scalar::Util qw(blessed); use Try::Tiny; +use Type::Params qw( compile ); +use Type::Utils; +use Types::Standard qw( :types ); use Bugzilla::Constants; use Bugzilla::Error; @@ -24,16 +27,15 @@ use Bugzilla::Logging; use Bugzilla::Mailer; use Bugzilla::Search; use Bugzilla::Util qw(diff_arrays format_time with_writable_database with_readonly_database); - +use Bugzilla::Types qw(:types); +use Bugzilla::Extension::PhabBugz::Types qw(:types); use Bugzilla::Extension::PhabBugz::Constants; use Bugzilla::Extension::PhabBugz::Policy; use Bugzilla::Extension::PhabBugz::Revision; use Bugzilla::Extension::PhabBugz::User; use Bugzilla::Extension::PhabBugz::Util qw( - add_security_sync_comments create_revision_attachment get_bug_role_phids - get_security_sync_groups is_attachment_phab_revision request set_phab_user @@ -41,6 +43,8 @@ use Bugzilla::Extension::PhabBugz::Util qw( has 'is_daemon' => ( is => 'rw', default => 0 ); +my $Invocant = class_type { class => __PACKAGE__ }; + sub start { my ($self) = @_; @@ -50,8 +54,10 @@ sub start { interval => PHAB_FEED_POLL_SECONDS, reschedule => 'drift', on_tick => sub { - try{ - $self->feed_query(); + try { + with_writable_database { + $self->feed_query(); + }; } catch { FATAL($_); @@ -66,8 +72,10 @@ sub start { interval => PHAB_USER_POLL_SECONDS, reschedule => 'drift', on_tick => sub { - try{ - $self->user_query(); + try { + with_writable_database { + $self->user_query(); + }; } catch { FATAL($_); @@ -82,8 +90,10 @@ sub start { interval => PHAB_GROUP_POLL_SECONDS, reschedule => 'drift', on_tick => sub { - try{ - $self->group_query(); + try { + with_writable_database { + $self->group_query(); + }; } catch { FATAL($_); @@ -145,23 +155,30 @@ sub feed_query { } # Skip changes done by phab-bot user - my $phab_user = Bugzilla::Extension::PhabBugz::User->new_from_query( - { - phids => [ $author_phid ] - } + # If changer does not exist in bugzilla database + # we use the phab-bot account as the changer + my $author = Bugzilla::Extension::PhabBugz::User->new_from_query( + { phids => [ $author_phid ] } ); - if ($phab_user && $phab_user->bugzilla_id) { - if ($phab_user->bugzilla_user->login eq PHAB_AUTOMATION_USER) { + if ($author && $author->bugzilla_id) { + if ($author->bugzilla_user->login eq PHAB_AUTOMATION_USER) { INFO("SKIPPING: Change made by phabricator user"); $self->save_last_id($story_id, 'feed'); next; } } - - with_writable_database { - $self->process_revision_change($object_phid, $story_text); - }; + else { + my $phab_user = Bugzilla::User->new( { name => PHAB_AUTOMATION_USER } ); + $author = Bugzilla::Extension::PhabBugz::User->new_from_query( + { + ids => [ $phab_user->id ] + } + ); + } + # Load the revision from Phabricator + my $revision = Bugzilla::Extension::PhabBugz::Revision->new_from_query({ phids => [ $object_phid ] }); + $self->process_revision_change($revision, $author, $story_text); $self->save_last_id($story_id, 'feed'); } @@ -193,9 +210,7 @@ sub feed_query { } ); - with_writable_database { - $self->process_revision_change($revision, " created D" . $revision->id); - }; + $self->process_revision_change( $revision, $revision->author, " created D" . $revision->id ); # Set the build target to a passing status to # allow the revision to exit draft state @@ -347,16 +362,10 @@ sub group_query { } sub process_revision_change { - my ($self, $revision_phid, $story_text) = @_; - - # Load the revision from Phabricator - my $revision = - blessed $revision_phid - ? $revision_phid - : Bugzilla::Extension::PhabBugz::Revision->new_from_query({ phids => [ $revision_phid ] }); + state $check = compile($Invocant, Revision, LinkedPhabUser, Str); + my ($self, $revision, $changer, $story_text) = $check->(@_); # NO BUG ID - if (!$revision->bug_id) { if ($story_text =~ /\s+created\s+D\d+/) { # If new revision and bug id was omitted, make revision public @@ -372,17 +381,39 @@ sub process_revision_change { } } + my $log_message = sprintf( - "REVISION CHANGE FOUND: D%d: %s | bug: %d | %s", + "REVISION CHANGE FOUND: D%d: %s | bug: %d | %s | %s", $revision->id, $revision->title, $revision->bug_id, + $changer->name, $story_text); INFO($log_message); - # Pre setup before making changes - my $old_user = set_phab_user(); - my $bug = Bugzilla::Bug->new({ id => $revision->bug_id, cache => 1 }); + # change to the phabricator user, which returns a guard that restores the previous user. + my $restore_prev_user = set_phab_user(); + my $bug = $revision->bug; + + # Check to make sure bug id is valid and author can see it + if ($bug->{error} + ||!$revision->author->bugzilla_user->can_see_bug($revision->bug_id)) + { + if ($story_text =~ /\s+created\s+D\d+/) { + INFO('Invalid bug ID or author does not have access to the bug. ' . + 'Waiting til next revision update to notify author.'); + return; + } + + INFO('Invalid bug ID or author does not have access to the bug'); + my $phab_error_message = ""; + Bugzilla->template->process('revision/comments.html.tmpl', + { message => 'invalid_bug_id' }, + \$phab_error_message); + $revision->add_comment($phab_error_message); + $revision->update(); + return; + } # REVISION SECURITY POLICY @@ -393,48 +424,38 @@ sub process_revision_change { } # else bug is private. else { - my @set_groups = get_security_sync_groups($bug); - - # If bug privacy groups do not have any matching synchronized groups, - # then leave revision private and it will have be dealt with manually. - if (!@set_groups) { - INFO('No matching groups. Adding comments to bug and revision'); - add_security_sync_comments([$revision], $bug); - } - # Otherwise, we create a new custom policy containing the project + # Here we create a new custom policy containing the project # groups that are mapped to bugzilla groups. - else { - my $set_project_names = [ map { "bmo-" . $_ } @set_groups ]; - - # If current policy projects matches what we want to set, then - # we leave the current policy alone. - my $current_policy; - if ($revision->view_policy =~ /^PHID-PLCY/) { - INFO("Loading current policy: " . $revision->view_policy); - $current_policy - = Bugzilla::Extension::PhabBugz::Policy->new_from_query({ phids => [ $revision->view_policy ]}); - my $current_project_names = [ map { $_->name } @{ $current_policy->rule_projects } ]; - INFO("Current policy projects: " . join(", ", @$current_project_names)); - my ($added, $removed) = diff_arrays($current_project_names, $set_project_names); - if (@$added || @$removed) { - INFO('Project groups do not match. Need new custom policy'); - $current_policy = undef; - } - else { - INFO('Project groups match. Leaving current policy as-is'); - } + my $set_project_names = [ map { "bmo-" . $_->name } @{ $bug->groups_in } ]; + + # If current policy projects matches what we want to set, then + # we leave the current policy alone. + my $current_policy; + if ($revision->view_policy =~ /^PHID-PLCY/) { + INFO("Loading current policy: " . $revision->view_policy); + $current_policy + = Bugzilla::Extension::PhabBugz::Policy->new_from_query({ phids => [ $revision->view_policy ]}); + my $current_project_names = [ map { $_->name } @{ $current_policy->rule_projects } ]; + INFO("Current policy projects: " . join(", ", @$current_project_names)); + my ($added, $removed) = diff_arrays($current_project_names, $set_project_names); + if (@$added || @$removed) { + INFO('Project groups do not match. Need new custom policy'); + $current_policy = undef; } - - if (!$current_policy) { - INFO("Creating new custom policy: " . join(", ", @$set_project_names)); - $revision->make_private($set_project_names); + else { + INFO('Project groups match. Leaving current policy as-is'); } + } - # Subscriber list of the private revision should always match - # the bug roles such as assignee, qa contact, and cc members. - my $subscribers = get_bug_role_phids($bug); - $revision->set_subscribers($subscribers); + if (!$current_policy) { + INFO("Creating new custom policy: " . join(", ", @$set_project_names)); + $revision->make_private($set_project_names); } + + # Subscriber list of the private revision should always match + # the bug roles such as assignee, qa contact, and cc members. + my $subscribers = get_bug_role_phids($bug); + $revision->set_subscribers($subscribers); } my ($timestamp) = Bugzilla->dbh->selectrow_array("SELECT NOW()"); @@ -482,31 +503,15 @@ sub process_revision_change { # REVIEWER STATUSES - my (@accepted_phids, @denied_phids, @accepted_user_ids, @denied_user_ids); - foreach my $reviewer (@{ $revision->reviewers }) { - push(@accepted_phids, $reviewer->phid) if $reviewer->{phab_review_status} eq 'accepted'; - push(@denied_phids, $reviewer->phid) if $reviewer->{phab_review_status} eq 'rejected'; - } - - if ( @accepted_phids ) { - my $phab_users = Bugzilla::Extension::PhabBugz::User->match( - { - phids => \@accepted_phids - } - ); - @accepted_user_ids = map { $_->bugzilla_user->id } grep { defined $_->bugzilla_user } @$phab_users; - } - - if ( @denied_phids ) { - my $phab_users = Bugzilla::Extension::PhabBugz::User->match( - { - phids => \@denied_phids - } - ); - @denied_user_ids = map { $_->bugzilla_user->id } grep { defined $_->bugzilla_user } @$phab_users; + my (@accepted, @denied); + foreach my $review (@{ $revision->reviews }) { + push @accepted, $review->{user} if $review->{status} eq 'accepted'; + push @denied, $review->{user} if $review->{status} eq 'rejected'; } - my %reviewers_hash = map { $_->name => 1 } @{ $revision->reviewers }; + my @accepted_user_ids = map { $_->bugzilla_user->id } grep { defined $_->bugzilla_user } @accepted; + my @denied_user_ids = map { $_->bugzilla_user->id } grep { defined $_->bugzilla_user } @denied; + my %reviewers_hash = map { $_->{user}->name => 1 } @{ $revision->reviews }; foreach my $attachment (@attachments) { my ($attach_revision_id) = ($attachment->filename =~ PHAB_ATTACHMENT_PATTERN); @@ -534,6 +539,8 @@ sub process_revision_change { $flag_type ||= first { $_->name eq 'review' && $_->is_active } @{ $attachment->flag_types }; + die "Unable to find review flag!" unless $flag_type; + # Create new flags foreach my $user_id (@accepted_user_ids) { next if $accepted_done{$user_id}; @@ -542,37 +549,55 @@ sub process_revision_change { push(@new_flags, { type_id => $flag_type->id, setter => $user, status => '+' }); } - # Also add comment to for attachment update showing the user's name - # that changed the revision. - my $comment; + # Process each flag change by updating the flag and adding a comment foreach my $flag_data (@new_flags) { - $comment .= $flag_data->{setter}->name . " has approved the revision.\n"; + my $comment = $flag_data->{setter}->name . " has approved the revision."; + $self->add_flag_comment( + { + bug => $bug, + attachment => $attachment, + comment => $comment, + user => $flag_data->{setter}, + old_flags => [], + new_flags => [$flag_data], + timestamp => $timestamp + } + ); } foreach my $flag_data (@denied_flags) { - $comment .= $flag_data->{setter}->name . " has requested changes to the revision.\n"; + my $comment = $flag_data->{setter}->name . " has requested changes to the revision.\n"; + $self->add_flag_comment( + { + bug => $bug, + attachment => $attachment, + comment => $comment, + user => $flag_data->{setter}, + old_flags => [$flag_data], + new_flags => [], + timestamp => $timestamp + } + ); } foreach my $flag_data (@removed_flags) { - if ( exists $reviewers_hash{$flag_data->{setter}->name} ) { - $comment .= "Flag set by " . $flag_data->{setter}->name . " is no longer active.\n"; - } else { - $comment .= $flag_data->{setter}->name . " has been removed from the revision.\n"; + my $comment; + if ( exists $reviewers_hash{ $flag_data->{setter}->name } ) { + $comment = "Flag set by " . $flag_data->{setter}->name . " is no longer active.\n"; } + else { + $comment = $flag_data->{setter}->name . " has been removed from the revision.\n"; + } + $self->add_flag_comment( + { + bug => $bug, + attachment => $attachment, + comment => $comment, + user => $flag_data->{setter}, + old_flags => [$flag_data], + new_flags => [], + timestamp => $timestamp + } + ); } - - if ($comment) { - $comment .= "\n" . Bugzilla->params->{phabricator_base_uri} . "D" . $revision->id; - INFO("Flag comment: $comment"); - # Add transaction_id as anchor if one present - # $comment .= "#" . $params->{transaction_id} if $params->{transaction_id}; - $bug->add_comment($comment, { - isprivate => $attachment->isprivate, - type => CMT_ATTACHMENT_UPDATED, - extra_data => $attachment->id - }); - } - - $attachment->set_flags([ @denied_flags, @removed_flags ], \@new_flags); - $attachment->update($timestamp); } # FINISH UP @@ -583,16 +608,15 @@ sub process_revision_change { # Email changes for this revisions bug and also for any other # bugs that previously had these revision attachments foreach my $bug_id ($revision->bug_id, keys %other_bugs) { - Bugzilla::BugMail::Send($bug_id, { changer => $rev_attachment->attacher }); + Bugzilla::BugMail::Send($bug_id, { changer => $changer->bugzilla_user }); } - Bugzilla->set_user($old_user); - INFO('SUCCESS: Revision D' . $revision->id . ' processed'); } sub process_new_user { - my ( $self, $user_data ) = @_; + state $check = compile($Invocant, HashRef); + my ( $self, $user_data ) = $check->(@_); # Load the user data into a proper object my $phab_user = Bugzilla::Extension::PhabBugz::User->new($user_data); @@ -605,7 +629,7 @@ sub process_new_user { my $bug_user = $phab_user->bugzilla_user; # Pre setup before querying DB - my $old_user = set_phab_user(); + my $restore_prev_user = set_phab_user(); # CHECK AND WARN FOR POSSIBLE USERNAME SQUATTING INFO("Checking for username squatters"); @@ -688,7 +712,7 @@ sub process_new_user { # that are connected to revisions f11 => 'attachments.filename', o11 => 'regexp', - v11 => '^phabricator-D[[:digit:]]+-url[[.period.]]txt$', + v11 => '^phabricator-D[[:digit:]]+-url.txt$', }; my $search = Bugzilla::Search->new( fields => [ 'bug_id' ], @@ -724,8 +748,6 @@ sub process_new_user { } } - Bugzilla->set_user($old_user); - INFO('SUCCESS: User ' . $phab_user->id . ' processed'); } @@ -793,8 +815,8 @@ sub save_last_id { } sub get_group_members { - my ( $self, $group ) = @_; - + state $check = compile( $Invocant, Group | Str ); + my ( $self, $group ) = $check->(@_); my $group_obj = ref $group ? $group : Bugzilla::Group->check( { name => $group, cache => 1 } ); @@ -817,4 +839,38 @@ sub get_group_members { ); } +sub add_flag_comment { + state $check = compile( + $Invocant, + Dict [ + bug => Bug, + attachment => Attachment, + comment => Str, + user => User, + old_flags => ArrayRef, + new_flags => ArrayRef, + timestamp => Str, + ], + ); + my ( $self, $params ) = $check->(@_); + my ( $bug, $attachment, $comment, $user, $old_flags, $new_flags, $timestamp ) + = @$params{qw(bug attachment comment user old_flags new_flags timestamp)}; + + # when this function returns, Bugzilla->user will return to its previous value. + my $restore_prev_user = Bugzilla->set_user($user, scope_guard => 1); + + INFO("Flag comment: $comment"); + $bug->add_comment( + $comment, + { + isprivate => $attachment->isprivate, + type => CMT_ATTACHMENT_UPDATED, + extra_data => $attachment->id + } + ); + + $attachment->set_flags( $old_flags, $new_flags ); + $attachment->update($timestamp); +} + 1; diff --git a/extensions/PhabBugz/lib/Policy.pm b/extensions/PhabBugz/lib/Policy.pm index a86c83036..415ea20fb 100644 --- a/extensions/PhabBugz/lib/Policy.pm +++ b/extensions/PhabBugz/lib/Policy.pm @@ -13,11 +13,13 @@ use Moo; use Bugzilla::Error; use Bugzilla::Extension::PhabBugz::Util qw(request); use Bugzilla::Extension::PhabBugz::Project; +use Bugzilla::Extension::PhabBugz::Types qw(:types); use List::Util qw(first); use Types::Standard -all; use Type::Utils; +use Type::Params qw( compile ); has 'phid' => ( is => 'ro', isa => Str ); has 'type' => ( is => 'ro', isa => Str ); @@ -41,7 +43,7 @@ has 'rules' => ( has 'rule_projects' => ( is => 'lazy', - isa => ArrayRef[Object], + isa => ArrayRef[Project], ); # { @@ -79,8 +81,11 @@ has 'rule_projects' => ( # } # } +my $Invocant = class_type { class => __PACKAGE__ }; + sub new_from_query { - my ($class, $params) = @_; + state $check = compile($Invocant | ClassName, Dict[phids => ArrayRef[Str]]); + my ($class, $params) = $check->(@_); my $result = request('policy.query', $params); if (exists $result->{result}{data} && @{ $result->{result}{data} }) { return $class->new($result->{result}->{data}->[0]); @@ -88,7 +93,8 @@ sub new_from_query { } sub create { - my ($class, $projects) = @_; + state $check = compile($Invocant | ClassName, ArrayRef[Project]); + my ($class, $projects) = $check->(@_); my $data = { objectType => 'DREV', diff --git a/extensions/PhabBugz/lib/Project.pm b/extensions/PhabBugz/lib/Project.pm index b93a6eb9e..c18708887 100644 --- a/extensions/PhabBugz/lib/Project.pm +++ b/extensions/PhabBugz/lib/Project.pm @@ -12,10 +12,12 @@ use Moo; use Scalar::Util qw(blessed); use Types::Standard -all; use Type::Utils; +use Type::Params qw( compile ); use Bugzilla::Error; use Bugzilla::Util qw(trim); use Bugzilla::Extension::PhabBugz::User; +use Bugzilla::Extension::PhabBugz::Types qw(:types); use Bugzilla::Extension::PhabBugz::Util qw(request); ######################### @@ -33,7 +35,9 @@ has view_policy => ( is => 'ro', isa => Str ); has edit_policy => ( is => 'ro', isa => Str ); has join_policy => ( is => 'ro', isa => Str ); has members_raw => ( is => 'ro', isa => ArrayRef [ Dict [ phid => Str ] ] ); -has members => ( is => 'lazy', isa => ArrayRef [Object] ); +has members => ( is => 'lazy', isa => ArrayRef[PhabUser] ); + +my $Invocant = class_type { class => __PACKAGE__ }; sub new_from_query { my ( $class, $params ) = @_; @@ -142,12 +146,20 @@ sub BUILDARGS { ######################### sub create { - my ( $class, $params ) = @_; - - my $name = trim( $params->{name} ); - $name || ThrowCodeError( 'param_required', { param => 'name' } ); + state $check = compile( + $Invocant | ClassName, + Dict[ + name => Str, + description => Str, + view_policy => Str, + edit_policy => Str, + join_policy => Str, + ] + ); + my ( $class, $params ) = $check->(@_); - my $description = $params->{description} || 'Need description'; + my $name = trim($params->{name}); + my $description = $params->{description}; my $view_policy = $params->{view_policy}; my $edit_policy = $params->{edit_policy}; my $join_policy = $params->{join_policy}; @@ -324,5 +336,4 @@ sub _build_members { ); } -1; - +1;
\ No newline at end of file diff --git a/extensions/PhabBugz/lib/Revision.pm b/extensions/PhabBugz/lib/Revision.pm index 4e82fa500..6ad906829 100644 --- a/extensions/PhabBugz/lib/Revision.pm +++ b/extensions/PhabBugz/lib/Revision.pm @@ -15,10 +15,12 @@ use Types::Standard -all; use Type::Utils; use Bugzilla::Bug; +use Bugzilla::Types qw(JSONBool); use Bugzilla::Error; use Bugzilla::Util qw(trim); use Bugzilla::Extension::PhabBugz::Project; use Bugzilla::Extension::PhabBugz::User; +use Bugzilla::Extension::PhabBugz::Types qw(:types); use Bugzilla::Extension::PhabBugz::Util qw(request); ######################### @@ -39,16 +41,16 @@ has edit_policy => ( is => 'ro', isa => Str ); has subscriber_count => ( is => 'ro', isa => Int ); has bug => ( is => 'lazy', isa => Object ); has author => ( is => 'lazy', isa => Object ); -has reviewers => ( is => 'lazy', isa => ArrayRef [Object] ); -has subscribers => ( is => 'lazy', isa => ArrayRef [Object] ); -has projects => ( is => 'lazy', isa => ArrayRef [Object] ); +has reviews => ( is => 'lazy', isa => ArrayRef [ Dict [ user => PhabUser, status => Str ] ] ); +has subscribers => ( is => 'lazy', isa => ArrayRef [PhabUser] ); +has projects => ( is => 'lazy', isa => ArrayRef [Project] ); has reviewers_raw => ( is => 'ro', isa => ArrayRef [ Dict [ reviewerPHID => Str, status => Str, - isBlocking => Bool, + isBlocking => Bool | JSONBool, actorPHID => Maybe [Str], ], ] @@ -58,7 +60,7 @@ has subscribers_raw => ( isa => Dict [ subscriberPHIDs => ArrayRef [Str], subscriberCount => Int, - viewerIsSubscribed => Bool, + viewerIsSubscribed => Bool | JSONBool, ] ); has projects_raw => ( @@ -109,7 +111,7 @@ sub BUILDARGS { $params->{bug_id} = $params->{fields}->{'bugzilla.bug-id'}; $params->{view_policy} = $params->{fields}->{policy}->{view}; $params->{edit_policy} = $params->{fields}->{policy}->{edit}; - $params->{reviewers_raw} = $params->{attachments}->{reviewers}->{reviewers}; + $params->{reviewers_raw} = $params->{attachments}->{reviewers}->{reviewers} // []; $params->{subscribers_raw} = $params->{attachments}->{subscribers}; $params->{projects_raw} = $params->{attachments}->{projects}; $params->{subscriber_count} = @@ -301,35 +303,24 @@ sub _build_author { } } -sub _build_reviewers { +sub _build_reviews { my ($self) = @_; - return $self->{reviewers} if $self->{reviewers}; - return [] unless $self->reviewers_raw; - - my @phids; - foreach my $reviewer ( @{ $self->reviewers_raw } ) { - push @phids, $reviewer->{reviewerPHID}; - } - - return [] unless @phids; - + my %by_phid = map { $_->{reviewerPHID} => $_ } @{ $self->reviewers_raw }; my $users = Bugzilla::Extension::PhabBugz::User->match( - { - phids => \@phids - } + { + phids => [keys %by_phid] + } ); - foreach my $user (@$users) { - foreach my $reviewer_data ( @{ $self->reviewers_raw } ) { - if ( $reviewer_data->{reviewerPHID} eq $user->phid ) { - $user->{phab_review_status} = $reviewer_data->{status}; - last; + return [ + map { + { + user => $_, + status => $by_phid{ $_->phid }{status}, } - } - } - - return $self->{reviewers} = $users; + } @$users + ]; } sub _build_subscribers { @@ -478,8 +469,14 @@ sub make_private { sub make_public { my ( $self ) = @_; - $self->set_policy('view', 'public'); - $self->set_policy('edit', 'users'); + my $editbugs = Bugzilla::Extension::PhabBugz::Project->new_from_query( + { + name => 'bmo-editbugs-team' + } + ); + + $self->set_policy( 'view', 'public' ); + $self->set_policy( 'edit', ( $editbugs ? $editbugs->phid : 'users' ) ); my @current_group_projects = grep { $_->name =~ /^(bmo-.*|secure-revision)$/ } @{ $self->projects }; foreach my $project (@current_group_projects) { diff --git a/extensions/PhabBugz/lib/Types.pm b/extensions/PhabBugz/lib/Types.pm new file mode 100644 index 000000000..493e97fbc --- /dev/null +++ b/extensions/PhabBugz/lib/Types.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::PhabBugz::Types; + +use 5.10.1; +use strict; +use warnings; + +use Type::Library + -base, + -declare => qw( Revision LinkedPhabUser PhabUser Policy Project ); +use Type::Utils -all; +use Types::Standard -all; + +class_type Revision, { class => 'Bugzilla::Extension::PhabBugz::Revision' }; +class_type Policy, { class => 'Bugzilla::Extension::PhabBugz::Policy' }; +class_type Project, { class => 'Bugzilla::Extension::PhabBugz::Project' }; +class_type PhabUser, { class => 'Bugzilla::Extension::PhabBugz::User' }; +declare LinkedPhabUser, + as PhabUser, + where { is_Int($_->bugzilla_id) }; + +1; diff --git a/extensions/PhabBugz/lib/User.pm b/extensions/PhabBugz/lib/User.pm index da573be37..209425bdf 100644 --- a/extensions/PhabBugz/lib/User.pm +++ b/extensions/PhabBugz/lib/User.pm @@ -11,12 +11,13 @@ use 5.10.1; use Moo; use Bugzilla::User; - +use Bugzilla::Types qw(:types); use Bugzilla::Extension::PhabBugz::Util qw(request); use List::Util qw(first); use Types::Standard -all; use Type::Utils; +use Type::Params qw(compile); ######################### # Initialization # @@ -33,7 +34,9 @@ has 'roles' => ( is => 'ro', isa => ArrayRef [Str] ); has 'view_policy' => ( is => 'ro', isa => Str ); has 'edit_policy' => ( is => 'ro', isa => Str ); has 'bugzilla_id' => ( is => 'ro', isa => Maybe [Int] ); -has 'bugzilla_user' => ( is => 'lazy' ); +has 'bugzilla_user' => ( is => 'lazy', isa => Maybe [User] ); + +my $Invocant = class_type { class => __PACKAGE__ }; sub BUILDARGS { my ( $class, $params ) = @_; @@ -113,7 +116,8 @@ sub new_from_query { } sub match { - my ( $class, $params ) = @_; + state $check = compile( $Invocant | ClassName, Dict[ ids => ArrayRef[Int] ] | Dict[ phids => ArrayRef[Str] ] ); + my ( $class, $params ) = $check->(@_); # BMO id search takes precedence if bugzilla_ids is used. my $bugzilla_ids = delete $params->{ids}; @@ -158,7 +162,8 @@ sub _build_bugzilla_user { } sub get_phab_bugzilla_ids { - my ( $class, $params ) = @_; + state $check = compile($Invocant | ClassName, Dict[ids => ArrayRef[Int]]); + my ( $class, $params ) = $check->(@_); my $memcache = Bugzilla->memcached; diff --git a/extensions/PhabBugz/lib/Util.pm b/extensions/PhabBugz/lib/Util.pm index 5ad8a5207..a93533e75 100644 --- a/extensions/PhabBugz/lib/Util.pm +++ b/extensions/PhabBugz/lib/Util.pm @@ -15,24 +15,27 @@ use Bugzilla::Bug; use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::User; +use Bugzilla::Types qw(:types); use Bugzilla::Util qw(trim); use Bugzilla::Extension::PhabBugz::Constants; +use Bugzilla::Extension::PhabBugz::Types qw(:types); use JSON::XS qw(encode_json decode_json); use List::Util qw(first); use LWP::UserAgent; use Taint::Util qw(untaint); use Try::Tiny; +use Type::Params qw( compile ); +use Type::Utils; +use Types::Standard qw( :types ); use base qw(Exporter); our @EXPORT = qw( - add_security_sync_comments create_revision_attachment get_attachment_revisions get_bug_role_phids get_needs_review - get_security_sync_groups intersect is_attachment_phab_revision request @@ -40,7 +43,8 @@ our @EXPORT = qw( ); sub create_revision_attachment { - my ( $bug, $revision, $timestamp, $submitter ) = @_; + state $check = compile(Bug, Revision, Str, User); + my ( $bug, $revision, $timestamp, $submitter ) = $check->(@_); my $phab_base_uri = Bugzilla->params->{phabricator_base_uri}; ThrowUserError('invalid_phabricator_uri') unless $phab_base_uri; @@ -61,37 +65,27 @@ sub create_revision_attachment { } # If submitter, then switch to that user when creating attachment - my ($old_user, $attachment); - try { - if ($submitter) { - $old_user = Bugzilla->user; - $submitter->{groups} = [ Bugzilla::Group->get_all ]; # We need to always be able to add attachment - Bugzilla->set_user($submitter); + local $submitter->{groups} = [ Bugzilla::Group->get_all ]; # We need to always be able to add attachment + my $restore_prev_user = Bugzilla->set_user($submitter, scope_guard => 1); + + my $attachment = Bugzilla::Attachment->create( + { + bug => $bug, + creation_ts => $timestamp, + data => $revision_uri, + description => $revision->title, + filename => 'phabricator-D' . $revision->id . '-url.txt', + ispatch => 0, + isprivate => 0, + mimetype => PHAB_CONTENT_TYPE, } + ); - $attachment = Bugzilla::Attachment->create( - { - bug => $bug, - creation_ts => $timestamp, - data => $revision_uri, - description => $revision->title, - filename => 'phabricator-D' . $revision->id . '-url.txt', - ispatch => 0, - isprivate => 0, - mimetype => PHAB_CONTENT_TYPE, - } - ); + # Insert a comment about the new attachment into the database. + $bug->add_comment($revision->summary, { type => CMT_ATTACHMENT_CREATED, + extra_data => $attachment->id }); - # Insert a comment about the new attachment into the database. - $bug->add_comment($revision->summary, { type => CMT_ATTACHMENT_CREATED, - extra_data => $attachment->id }); - } - catch { - die $_; - } - finally { - Bugzilla->set_user($old_user) if $old_user; - }; + delete $bug->{attachments}; return $attachment; } @@ -103,7 +97,8 @@ sub intersect { } sub get_bug_role_phids { - my ($bug) = @_; + state $check = compile(Bug); + my ($bug) = $check->(@_); my @bug_users = ( $bug->reporter ); push(@bug_users, $bug->assigned_to) @@ -122,12 +117,14 @@ sub get_bug_role_phids { } sub is_attachment_phab_revision { - my ($attachment) = @_; + state $check = compile(Attachment); + my ($attachment) = $check->(@_); return $attachment->contenttype eq PHAB_CONTENT_TYPE; } sub get_attachment_revisions { - my $bug = shift; + state $check = compile(Bug); + my ($bug) = $check->(@_); my @attachments = grep { is_attachment_phab_revision($_) } @{ $bug->attachments() }; @@ -156,7 +153,8 @@ sub get_attachment_revisions { } sub request { - my ($method, $data) = @_; + state $check = compile(Str, HashRef); + my ($method, $data) = $check->(@_); my $request_cache = Bugzilla->request_cache; my $params = Bugzilla->params; @@ -201,49 +199,11 @@ sub request { return $result; } -sub get_security_sync_groups { - my $bug = shift; - - my $sync_groups = Bugzilla::Group->match( { isactive => 1, isbuggroup => 1 } ); - my $sync_group_names = [ map { $_->name } @$sync_groups ]; - - my $bug_groups = $bug->groups_in; - my $bug_group_names = [ map { $_->name } @$bug_groups ]; - - my @set_groups = intersect($bug_group_names, $sync_group_names); - - return @set_groups; -} - sub set_phab_user { - my $old_user = Bugzilla->user; my $user = Bugzilla::User->new( { name => PHAB_AUTOMATION_USER } ); $user->{groups} = [ Bugzilla::Group->get_all ]; - Bugzilla->set_user($user); - return $old_user; -} - -sub add_security_sync_comments { - my ($revisions, $bug) = @_; - - my $phab_error_message = 'Revision is being made private due to unknown Bugzilla groups.'; - - foreach my $revision (@$revisions) { - $revision->add_comment($phab_error_message); - } - - my $num_revisions = scalar @$revisions; - my $bmo_error_message = - ( $num_revisions > 1 - ? $num_revisions.' revisions were' - : 'One revision was' ) - . ' made private due to unknown Bugzilla groups.'; - - my $old_user = set_phab_user(); - - $bug->add_comment( $bmo_error_message, { isprivate => 0 } ); - Bugzilla->set_user($old_user); + return Bugzilla->set_user($user, scope_guard => 1); } sub get_needs_review { diff --git a/t/phabbugz.t b/extensions/PhabBugz/t/basic.t index ba2f35e1d..9a6723ccb 100644 --- a/t/phabbugz.t +++ b/extensions/PhabBugz/t/basic.t @@ -223,15 +223,22 @@ JSON }, ], ); - my $bug = mock { - bug_id => 23, + my $Attachment = mock 'Bugzilla::Attachment' => ( + add_constructor => [ fake_new => 'hash' ], + ); + my $Bug = mock 'Bugzilla::Bug' => ( + add_constructor => [ fake_new => 'hash' ], + ); + my $bug = Bugzilla::Bug->fake_new( + bug_id => 23, attachments => [ - mock { - contenttype => 'text/x-phabricator-request', + Bugzilla::Attachment->fake_new( + mimetype => 'text/x-phabricator-request', filename => 'phabricator-D9999-url.txt', - }, + ), ] - }; + ); + my $revisions = get_attachment_revisions($bug); is(ref($revisions), 'ARRAY', 'it is an array ref'); isa_ok($revisions->[0], 'Bugzilla::Extension::PhabBugz::Revision'); @@ -240,4 +247,4 @@ JSON }; -done_testing;
\ No newline at end of file +done_testing; diff --git a/extensions/PhabBugz/t/feed-daemon-guts.t b/extensions/PhabBugz/t/feed-daemon-guts.t new file mode 100644 index 000000000..376af18e4 --- /dev/null +++ b/extensions/PhabBugz/t/feed-daemon-guts.t @@ -0,0 +1,160 @@ +#!/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 5.10.1; +use lib qw( . lib local/lib/perl5 ); +BEGIN { $ENV{LOG4PERL_CONFIG_FILE} = 'log4perl-t.conf' } +use Bugzilla::Test::MockDB; +use Bugzilla::Test::MockParams; +use Bugzilla::Test::Util qw(create_user); +use Test::More; +use Test2::Tools::Mock; +use Try::Tiny; +use JSON::MaybeXS; +use Bugzilla::Constants; +use URI; +use File::Basename; +use Digest::SHA qw(sha1_hex); + +use ok 'Bugzilla::Extension::PhabBugz::Feed'; +use ok 'Bugzilla::Extension::PhabBugz::Constants', 'PHAB_AUTOMATION_USER'; +use ok 'Bugzilla::Config', 'SetParam'; +can_ok('Bugzilla::Extension::PhabBugz::Feed', qw( group_query feed_query user_query )); + +Bugzilla->error_mode(ERROR_MODE_TEST); + +my $phab_bot = create_user(PHAB_AUTOMATION_USER, '*'); + +my $UserAgent = mock 'LWP::UserAgent' => (); + +{ + SetParam('phabricator_enabled', 0); + my $feed = Bugzilla::Extension::PhabBugz::Feed->new; + my $Feed = mock 'Bugzilla::Extension::PhabBugz::Feed' => ( + override => [ + get_last_id => sub { die "get_last_id" }, + ], + ); + + foreach my $method (qw( feed_query user_query group_query )) { + try { + $feed->$method; + pass "disabling the phabricator sync: $method"; + } + catch { + fail "disabling the phabricator sync: $method"; + } + } +} + +my @bad_response = ( + ['http error', mock({ is_error => 1, message => 'some http error' }) ], + ['invalid json', mock({ is_error => 0, content => '<xml>foo</xml>' })], + ['json containing error code', mock({ is_error => 0, content => encode_json({error_code => 1234 }) })], +); + +SetParam(phabricator_enabled => 1); +SetParam(phabricator_api_key => 'FAKE-API-KEY'); +SetParam(phabricator_base_uri => 'http://fake.fabricator.tld/'); + +foreach my $bad_response (@bad_response) { + my $feed = Bugzilla::Extension::PhabBugz::Feed->new; + $UserAgent->override( + post => sub { + my ( $self, $url, $params ) = @_; + return $bad_response->[1]; + } + ); + + foreach my $method (qw( feed_query user_query group_query )) { + try { + # This is a hack to get reasonable exception objects. + local $Bugzilla::Template::is_processing = 1; + $feed->$method; + fail "$method - $bad_response->[0]"; + } + catch { + is( $_->type, 'bugzilla.code.phabricator_api_error', "$method - $bad_response->[0]" ); + }; + } + $UserAgent->reset('post'); +} + +my $feed = Bugzilla::Extension::PhabBugz::Feed->new; +my $json = JSON::MaybeXS->new( canonical => 1, pretty => 1 ); +my $dylan = create_user( 'dylan@mozilla.com', '*', realname => 'Dylan Hardison :dylan' ); +my $evildylan = create_user( 'dylan@gmail.com', '*', realname => 'Evil Dylan :dylan' ); +my $myk = create_user( 'myk@mozilla.com', '*', realname => 'Myk Melez :myk' ); + +my $phab_bot_phid = next_phid('PHID-USER'); + +done_testing; + +sub user_search { + my (%conf) = @_; + + return { + error_info => undef, + error_code => undef, + result => { + cursor => { + after => $conf{after}, + order => undef, + limit => 100, + before => undef + }, + query => { + queryKey => undef + }, + maps => {}, + data => [ + map { + +{ + attachments => { + $_->{bmo_id} + ? ( "external-accounts" => { + "external-accounts" => [ + { + type => 'bmo', + id => $_->{bmo_id}, + } + ] + } + ) + : (), + }, + fields => { + roles => [ "verified", "approved", "activated" ], + realName => $_->{realname}, + dateModified => time, + policy => { + view => "public", + edit => "no-one" + }, + dateCreated => time, + username => $_->{username}, + }, + phid => next_phid("PHID-USER"), + type => "USER", + id => $_->{phab_id}, + }, + } @{ $conf{users} }, + ] + } + }; + +} + +sub next_phid { + my ($prefix) = @_; + state $number = 'a' x 20; + return $prefix . '-' . ($number++); +} + + diff --git a/extensions/PhabBugz/t/review-flags.t b/extensions/PhabBugz/t/review-flags.t new file mode 100644 index 000000000..610c46dca --- /dev/null +++ b/extensions/PhabBugz/t/review-flags.t @@ -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 5.10.1; +use lib qw( . lib local/lib/perl5 ); +BEGIN { $ENV{LOG4PERL_CONFIG_FILE} = 'log4perl-t.conf' } +use Test2::V0; + +our @EMAILS; + +BEGIN { + require Bugzilla::Mailer; + no warnings 'redefine'; + *Bugzilla::Mailer::MessageToMTA = sub { + push @EMAILS, [@_]; + }; +} +use Bugzilla::Test::MockDB; +use Bugzilla::Test::MockParams; +use Bugzilla::Test::Util qw(create_user); +use Test2::Tools::Mock; +use Try::Tiny; +use JSON::MaybeXS; +use Bugzilla::Constants; +use URI; +use File::Basename; +use Digest::SHA qw(sha1_hex); +use Data::Dumper; + +use ok 'Bugzilla::Extension::PhabBugz::Feed'; +use ok 'Bugzilla::Extension::PhabBugz::Constants', 'PHAB_AUTOMATION_USER'; +use ok 'Bugzilla::Config', 'SetParam'; +can_ok('Bugzilla::Extension::PhabBugz::Feed', qw( group_query feed_query user_query )); + +SetParam(phabricator_base_uri => 'http://fake.phabricator.tld/'); +SetParam(mailfrom => 'bugzilla-daemon'); +Bugzilla->error_mode(ERROR_MODE_TEST); +my $nobody = create_user('nobody@mozilla.org', '*'); +my $phab_bot = create_user(PHAB_AUTOMATION_USER, '*'); + +# Steve Rogers is the revision author +my $steve = create_user('steverogers@avengers.org', '*', realname => 'Steve Rogers :steve'); + +# Bucky Barns is the reviewer +my $bucky = create_user('bucky@avengers.org', '*', realname => 'Bucky Barns :bucky'); + +my $firefox = Bugzilla::Product->create( + { + name => 'Firefox', + description => 'Fake firefox product', + version => 'Unspecified', + }, +); + +my $general = Bugzilla::Component->create( + { + product =>$firefox, + name => 'General', + description => 'The most general description', + initialowner => { id => $nobody->id }, + } +); + +Bugzilla->set_user($steve); +my $bug = Bugzilla::Bug->create( + { + short_desc => 'test bug', + product => $firefox, + component => $general->name, + bug_severity => 'normal', + op_sys => 'Unspecified', + rep_platform => 'Unspecified', + version => 'Unspecified', + comment => 'first post', + priority => 'P1', + } +); + +my $recipients = { changer => $steve }; +Bugzilla::BugMail::Send($bug->bug_id, $recipients); +@EMAILS = (); + +my $revision = Bugzilla::Extension::PhabBugz::Revision->new( + { + id => 1, + phid => 'PHID-DREV-uozm3ggfp7e7uoqegmc3', + type => 'DREV', + fields => { + title => "title", + summary => "the summary of the revision", + status => { value => "not sure" }, + dateCreated => time() - (60 * 60), + dateModified => time() - (60 * 5), + authorPHID => 'authorPHID', + policy => { + view => 'policy.view', + edit => 'policy.edit', + }, + 'bugzilla.bug-id' => $bug->id, + }, + attachments => { + projects => { projectPHIDs => [] }, + reviewers => { + reviewers => [ ], + }, + subscribers => { + subscriberPHIDs => [], + subscriberCount => 1, + viewerIsSubscribed => 1, + } + }, + reviews => [ + { + user => new_phab_user($bucky), + status => 'accepted', + } + ] + } +); +my $PhabRevisionMock = mock 'Bugzilla::Extension::PhabBugz::Revision' => ( + override => [ + make_public => sub { }, + update => sub { }, + ] +); +my $PhabUserMock = mock 'Bugzilla::Extension::PhabBugz::User' => ( + override => [ + match => sub { + my ($class, $query) = @_; + if ($query && $query->{phids} && $query->{phids}[0]) { + my $phid = $query->{phids}[0]; + if ($phid eq 'authorPHID') { + return [ new_phab_user($steve, $phid) ]; + } + } + }, + ] +); + + +my $feed = Bugzilla::Extension::PhabBugz::Feed->new; +my $changer = new_phab_user($bucky); +@EMAILS = (); +$feed->process_revision_change( + $revision, $changer, "story text" +); + +# The first comment, and the comment made when the attachment is attached +# are made by Steve. +# The review comment is made by Bucky. + +my $sth = Bugzilla->dbh->prepare("select profiles.login_name, thetext from longdescs join profiles on who = userid"); +$sth->execute; +while (my $row = $sth->fetchrow_hashref) { + if ($row->{thetext} =~ /first post/i) { + is($row->{login_name}, $steve->login, 'first post author'); + } + elsif ($row->{thetext} =~ /the summary of the revision/i) { + is($row->{login_name}, $steve->login, 'the first attachment comment'); + } + elsif ($row->{thetext} =~ /has approved the revision/i) { + is($row->{login_name}, $bucky->login); + } +} + +diag Dumper(\@EMAILS); + +done_testing; + +sub new_phab_user { + my ($bug_user, $phid) = @_; + + return Bugzilla::Extension::PhabBugz::User->new( + { + id => $bug_user->id * 1000, + type => "USER", + phid => $phid // "PHID-USER-" . ( $bug_user->id * 1000 ), + fields => { + username => $bug_user->nick, + realName => $bug_user->name, + dateCreated => time() - 60 * 60 * 24, + dateModified => time(), + roles => [], + policy => { + view => 'view', + edit => 'edit', + }, + }, + attachments => { + 'external-accounts' => { + 'external-accounts' => [ + { + type => 'bmo', + id => $bug_user->id, + } + ] + } + } + } + ); + + +}
\ No newline at end of file diff --git a/extensions/PhabBugz/template/en/default/revision/comments.html.tmpl b/extensions/PhabBugz/template/en/default/revision/comments.html.tmpl new file mode 100644 index 000000000..b18daf376 --- /dev/null +++ b/extensions/PhabBugz/template/en/default/revision/comments.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 message == "invalid_bug_id" %] +Revision is being kept private due to invalid [% terms.bug %] ID +or author does not have access to the [% terms.bug %]. Either remove +the [% terms.bug %] ID, automatically making the revision public, or +enter the correct [% terms.bug %] ID for this revision. +[% END %]
\ No newline at end of file diff --git a/extensions/Push/lib/Connector/Phabricator.pm b/extensions/Push/lib/Connector/Phabricator.pm index e59ba6c0d..33e2bb6ad 100644 --- a/extensions/Push/lib/Connector/Phabricator.pm +++ b/extensions/Push/lib/Connector/Phabricator.pm @@ -21,10 +21,8 @@ use Bugzilla::Extension::PhabBugz::Policy; use Bugzilla::Extension::PhabBugz::Project; use Bugzilla::Extension::PhabBugz::Revision; use Bugzilla::Extension::PhabBugz::Util qw( - add_security_sync_comments get_attachment_revisions get_bug_role_phids - get_security_sync_groups ); use Bugzilla::Extension::Push::Constants; @@ -68,8 +66,6 @@ sub send { my $is_public = is_public($bug); - my @set_groups = get_security_sync_groups($bug); - my $revisions = get_attachment_revisions($bug); my $group_change = @@ -86,24 +82,14 @@ sub send { )); $revision->make_public(); } - elsif ( !$is_public && !@set_groups ) { - Bugzilla->audit(sprintf( - 'Making revision %s for bug %s private due to unkown Bugzilla groups: %s', - $revision->id, - $bug->id, - join(', ', @set_groups) - )); - $revision->make_private(['secure-revision']); - add_security_sync_comments([$revision], $bug); - } elsif ( !$is_public && $group_change ) { Bugzilla->audit(sprintf( 'Giving revision %s a custom policy for bug %s', $revision->id, $bug->id )); - my @set_project_names = map { "bmo-" . $_ } @set_groups; - $revision->make_private(\@set_project_names); + my $set_project_names = [ map { "bmo-" . $_->name } @{ $bug->groups_in } ]; + $revision->make_private($set_project_names); } # Subscriber list of the private revision should always match diff --git a/extensions/Push/lib/Push.pm b/extensions/Push/lib/Push.pm index 670b2aa56..ab640da81 100644 --- a/extensions/Push/lib/Push.pm +++ b/extensions/Push/lib/Push.pm @@ -8,8 +8,7 @@ package Bugzilla::Extension::Push::Push; use 5.10.1; -use strict; -use warnings; +use Moo; use Bugzilla::Logging; use Bugzilla::Extension::Push::BacklogMessage; @@ -23,22 +22,12 @@ use Bugzilla::Extension::Push::Option; use Bugzilla::Extension::Push::Queue; use Bugzilla::Extension::Push::Util; use DateTime; +use Try::Tiny; -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}; -} +has 'is_daemon' => ( + is => 'rw', + default => 0, +); sub start { my ($self) = @_; @@ -50,12 +39,49 @@ sub start { $connector->backlog->reset_backoff(); } - while(1) { - if ($self->_dbh_check()) { - $self->_reload(); - $self->push(); + my $pushd_loop = IO::Async::Loop->new; + my $main_timer = IO::Async::Timer::Periodic->new( + first_interval => 0, + interval => POLL_INTERVAL_SECONDS, + reschedule => 'drift', + on_tick => sub { + if ( $self->_dbh_check() ) { + $self->_reload(); + try { + $self->push(); + } + catch { + FATAL($_); + }; + } + }, + ); + if ( Bugzilla->datadog ) { + my $dog_timer = IO::Async::Timer::Periodic->new( + interval => 120, + reschedule => 'drift', + on_tick => sub { $self->heartbeat }, + ); + $pushd_loop->add($dog_timer); + $dog_timer->start; + } + + $pushd_loop->add($main_timer); + $main_timer->start; + $pushd_loop->run; +} + +sub heartbeat { + my ($self) = @_; + my $dd = Bugzilla->datadog('bugzilla.pushd'); + + $dd->gauge('scheduled_jobs', Bugzilla->dbh->selectrow_array('SELECT COUNT(*) FROM push')); + + foreach my $connector ($self->connectors->list) { + if ($connector->enabled) { + my $lcname = lc $connector->name; + $dd->gauge("${lcname}.backlog", Bugzilla->dbh->selectrow_array('SELECT COUNT(*) FROM push_backlog WHERE connector = ?', undef, $connector->name)); } - sleep(POLL_INTERVAL_SECONDS); } } diff --git a/extensions/Push/t/ReviewBoard.t b/extensions/Push/t/ReviewBoard.t index 3eb54760c..c752e34ef 100644 --- a/extensions/Push/t/ReviewBoard.t +++ b/extensions/Push/t/ReviewBoard.t @@ -7,14 +7,13 @@ # defined by the Mozilla Public License, v. 2.0. use strict; use warnings; -use lib qw( . lib ); +use lib qw( . lib local/lib/perl5 ); use Test::More; use Bugzilla; use Bugzilla::Extension; use Bugzilla::Attachment; use Scalar::Util 'blessed'; -use YAML; BEGIN { eval { diff --git a/extensions/RequestNagger/Extension.pm b/extensions/RequestNagger/Extension.pm index 65f5e6b84..e0f97c9f7 100644 --- a/extensions/RequestNagger/Extension.pm +++ b/extensions/RequestNagger/Extension.pm @@ -355,7 +355,7 @@ sub db_schema_abstract_schema { }, ], INDEXES => [ - nag_watch_idx => { + nag_setting_idx => { FIELDS => [ 'user_id', 'setting_name' ], TYPE => 'UNIQUE', }, 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 index ed5ae7b36..ea582b010 100644 --- a/extensions/Review/template/en/default/hook/attachment/create-end.html.tmpl +++ b/extensions/Review/template/en/default/hook/attachment/create-end.html.tmpl @@ -15,6 +15,5 @@ [% IF bug.product_obj.reviewer_required %] REVIEW.init_mandatory(); [% END %] - REVIEW.init_create_attachment(); }); </script> diff --git a/extensions/Review/web/js/review.js b/extensions/Review/web/js/review.js index 0163ceba6..b07ce9d75 100644 --- a/extensions/Review/web/js/review.js +++ b/extensions/Review/web/js/review.js @@ -10,9 +10,6 @@ var REVIEW = { target: false, fields: [], use_error_for: false, - ispatch_override: false, - description_override: false, - ignore_patch_event: true, init_review_flag: function(fid, flag_name) { var idx = this.fields.push({ 'fid': fid, 'flag_name': flag_name, 'component': '' }) - 1; @@ -39,13 +36,6 @@ var REVIEW = { $('#component').on('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() { - $('#data').on('change', REVIEW.attachment_change); - $('#description').on('change', REVIEW.description_change); - $('#ispatch').on('change', REVIEW.ispatch_change); }, component_change: function() { @@ -54,36 +44,6 @@ var REVIEW = { } }, - attachment_change: function() { - var filename = $('#data').val().split('/').pop().split('\\').pop(); - var description = $('#description').first(); - if (description.val() == '' || !REVIEW.description_override) { - description.val(filename); - } - if (!REVIEW.ispatch_override) { - $('#ispatch').prop('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() { - // the attachment template triggers this change event onload - // as we only want to set ispatch_override when the user clicks on the - // checkbox, we ignore this first event - if (REVIEW.ignore_patch_event) { - REVIEW.ignore_patch_event = false; - return; - } - REVIEW.ispatch_override = true; - }, - flag_change: function(e) { var field = REVIEW.fields[e.data]; var suggestions_span = $('#' + field.fid + '_suggestions'); @@ -167,8 +127,8 @@ var REVIEW = { }, check_mandatory: function(e) { - if ($('#data').length && !$('#data').val() - && $('#attach_text').length && !$('#attach_text').val()) + if ($('#file').length && !$('#file').val() + && $('#att-textarea').length && !$('#att-textarea').val()) { return; } diff --git a/extensions/TagNewUsers/template/en/default/hook/bug/changes-user.html.tmpl b/extensions/TagNewUsers/template/en/default/hook/bug/changes-user.html.tmpl new file mode 100644 index 000000000..56657c96b --- /dev/null +++ b/extensions/TagNewUsers/template/en/default/hook/bug/changes-user.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. + #%] + +[% RETURN UNLESS user.in_group('canconfirm') %] +[% IF action.who.is_new %] +<span class="new_user" title=" +[%- action.who.comment_count FILTER html %] comment[% "s" IF action.who.comment_count != 1 -%] +, created [% +IF action.who.creation_age == 0 %]today[% +ELSIF action.who.creation_age > 365 %]more than a year ago[% +ELSE %][% action.who.creation_age FILTER html %] day[% "s" IF action.who.creation_age != 1 %] ago[% END %]." + > +(New to [% terms.Bugzilla %]) +</span> +[% END %] diff --git a/js/attachment.js b/js/attachment.js index 6d6dae58d..86b10bf24 100644 --- a/js/attachment.js +++ b/js/attachment.js @@ -20,17 +20,9 @@ * Erik Stambaugh <erik@dasbistro.com> * Marc Schumann <wurblzap@gmail.com> * Guy Pyrzak <guy.pyrzak@gmail.com> + * Kohei Yoshino <kohei.yoshino@gmail.com> */ -function validateAttachmentForm(theform) { - var desc_value = YAHOO.lang.trim(theform.description.value); - if (desc_value == '') { - alert(BUGZILLA.string.attach_desc_required); - return false; - } - return true; -} - function updateCommentPrivacy(checkbox) { var text_elem = document.getElementById('comment'); if (checkbox.checked) { @@ -40,96 +32,6 @@ function updateCommentPrivacy(checkbox) { } } -function setContentTypeDisabledState(form) { - var isdisabled = false; - if (form.ispatch.checked) - isdisabled = true; - - for (var i = 0; i < form.contenttypemethod.length; i++) - form.contenttypemethod[i].disabled = isdisabled; - - form.contenttypeselection.disabled = isdisabled; - form.contenttypeentry.disabled = isdisabled; -} - -function TextFieldHandler() { - var field_text = document.getElementById("attach_text"); - var greyfields = new Array("data", "autodetect", "list", "manual", - "contenttypeselection", "contenttypeentry"); - var i, thisfield; - if (field_text.value.match(/^\s*$/)) { - for (i = 0; i < greyfields.length; i++) { - thisfield = document.getElementById(greyfields[i]); - if (thisfield) { - thisfield.removeAttribute("disabled"); - } - } - } else { - for (i = 0; i < greyfields.length; i++) { - thisfield = document.getElementById(greyfields[i]); - if (thisfield) { - thisfield.setAttribute("disabled", "disabled"); - } - } - } -} - -function DataFieldHandler() { - var field_data = document.getElementById("data"); - var greyfields = new Array("attach_text"); - var i, thisfield; - if (field_data.value.match(/^\s*$/)) { - for (i = 0; i < greyfields.length; i++) { - thisfield = document.getElementById(greyfields[i]); - if (thisfield) { - thisfield.removeAttribute("disabled"); - } - } - } else { - for (i = 0; i < greyfields.length; i++) { - thisfield = document.getElementById(greyfields[i]); - if (thisfield) { - thisfield.setAttribute("disabled", "disabled"); - } - } - } - - // Check the current file size (in KB) - const file_size = field_data.files[0].size / 1024; - const max_size = BUGZILLA.param.maxattachmentsize; - const invalid = file_size > max_size; - const message = invalid ? `This file (<strong>${(file_size / 1024).toFixed(1)} MB</strong>) is larger than the ` + - `maximum allowed size (<strong>${(max_size / 1024).toFixed(1)} MB</strong>).<br>Please consider uploading it ` + - `to an online file storage and sharing the link in a bug comment instead.` : ''; - const message_short = invalid ? 'File too large' : ''; - const $error = document.querySelector('#data-error'); - - // Show an error message if the file is too large - $error.innerHTML = message; - field_data.setCustomValidity(message_short); - field_data.setAttribute('aria-invalid', invalid); -} - -function clearAttachmentFields() { - var element; - - document.getElementById('data').value = ''; - DataFieldHandler(); - if ((element = document.getElementById('attach_text'))) { - element.value = ''; - TextFieldHandler(); - } - document.getElementById('description').value = ''; - /* Fire onchange so that the disabled state of the content-type - * radio buttons are also reset - */ - element = document.getElementById('ispatch'); - element.checked = ''; - bz_fireEvent(element, 'change'); - if ((element = document.getElementById('isprivate'))) - element.checked = ''; -} - /* Functions used when viewing patches in Diff mode. */ function collapse_all() { @@ -296,13 +198,13 @@ function switchToMode(mode, patchviewerinstalled) showElementById('undoEditButton'); } else if (mode == 'raw') { showElementById('viewFrame'); - if (patchviewerinstalled) + if (patchviewerinstalled) showElementById('viewDiffButton'); showElementById(has_edited ? 'redoEditButton' : 'editButton'); showElementById('smallCommentFrame'); } else if (mode == 'diff') { - if (patchviewerinstalled) + if (patchviewerinstalled) showElementById('viewDiffFrame'); showElementById('viewRawButton'); @@ -347,7 +249,7 @@ function normalizeComments() } } -function toggle_attachment_details_visibility ( ) +function toggle_attachment_details_visibility ( ) { // show hide classes var container = document.getElementById('attachment_info'); @@ -368,6 +270,459 @@ function handleWantsAttachment(wants_attachment) { else { showElementById('attachment_false'); hideElementById('attachment_true'); - clearAttachmentFields(); + bz_attachment_form.reset_fields(); } + + bz_attachment_form.update_requirements(wants_attachment); } + +/** + * Expose an `AttachmentForm` instance on global. + */ +var bz_attachment_form; + +/** + * Reference or define the Bugzilla app namespace. + * @namespace + */ +var Bugzilla = Bugzilla || {}; + +/** + * Implement the attachment selector functionality that can be used standalone or on the New Bug page. This supports 3 + * input methods: traditional `<input type="file">` field, drag & dropping of a file or text, as well as copy & pasting + * an image or text. + */ +Bugzilla.AttachmentForm = class AttachmentForm { + /** + * Initialize a new `AttachmentForm` instance. + */ + constructor() { + this.$file = document.querySelector('#att-file'); + this.$data = document.querySelector('#att-data'); + this.$filename = document.querySelector('#att-filename'); + this.$dropbox = document.querySelector('#att-dropbox'); + this.$browse_label = document.querySelector('#att-browse-label'); + this.$textarea = document.querySelector('#att-textarea'); + this.$preview = document.querySelector('#att-preview'); + this.$preview_name = this.$preview.querySelector('[itemprop="name"]'); + this.$preview_type = this.$preview.querySelector('[itemprop="encodingFormat"]'); + this.$preview_text = this.$preview.querySelector('[itemprop="text"]'); + this.$preview_image = this.$preview.querySelector('[itemprop="image"]'); + this.$remove_button = document.querySelector('#att-remove-button'); + this.$description = document.querySelector('#att-description'); + this.$error_message = document.querySelector('#att-error-message'); + this.$ispatch = document.querySelector('#att-ispatch'); + this.$type_outer = document.querySelector('#att-type-outer'); + this.$type_list = document.querySelector('#att-type-list'); + this.$type_manual = document.querySelector('#att-type-manual'); + this.$type_select = document.querySelector('#att-type-select'); + this.$type_input = document.querySelector('#att-type-input'); + this.$isprivate = document.querySelector('#isprivate'); + this.$takebug = document.querySelector('#takebug'); + + // Add event listeners + this.$file.addEventListener('change', () => this.file_onchange()); + this.$dropbox.addEventListener('dragover', event => this.dropbox_ondragover(event)); + this.$dropbox.addEventListener('dragleave', () => this.dropbox_ondragleave()); + this.$dropbox.addEventListener('dragend', () => this.dropbox_ondragend()); + this.$dropbox.addEventListener('drop', event => this.dropbox_ondrop(event)); + this.$browse_label.addEventListener('click', () => this.$file.click()); + this.$textarea.addEventListener('input', () => this.textarea_oninput()); + this.$textarea.addEventListener('paste', event => this.textarea_onpaste(event)); + this.$remove_button.addEventListener('click', () => this.remove_button_onclick()); + this.$description.addEventListener('input', () => this.description_oninput()); + this.$description.addEventListener('change', () => this.description_onchange()); + this.$ispatch.addEventListener('change', () => this.ispatch_onchange()); + this.$type_select.addEventListener('change', () => this.type_select_onchange()); + this.$type_input.addEventListener('change', () => this.type_input_onchange()); + + // Prepare the file reader + this.data_reader = new FileReader(); + this.text_reader = new FileReader(); + this.data_reader.addEventListener('load', () => this.data_reader_onload()); + this.text_reader.addEventListener('load', () => this.text_reader_onload()); + + // Initialize the view + this.enable_keyboard_access(); + this.reset_fields(); + } + + /** + * Enable keyboard access on the buttons. Treat the Enter keypress as a click. + */ + enable_keyboard_access() { + document.querySelectorAll('#att-selector [role="button"]').forEach($button => { + $button.addEventListener('keypress', event => { + if (!event.isComposing && event.key === 'Enter') { + event.target.click(); + } + }); + }); + } + + /** + * Reset all the input fields to the initial state, and remove the preview and message. + */ + reset_fields() { + this.description_override = false; + this.$file.value = this.$data.value = this.$filename.value = this.$type_input.value = this.$description.value = ''; + this.$type_list.checked = this.$type_select.options[0].selected = true; + + if (this.$isprivate) { + this.$isprivate.checked = this.$isprivate.disabled = false; + } + + if (this.$takebug) { + this.$takebug.checked = this.$takebug.disabled = false; + } + + this.clear_preview(); + this.clear_error(); + this.update_requirements(); + this.update_text(); + this.update_ispatch(); + } + + /** + * Update the `required` property on the Base64 data and Description fields. + * @param {Boolean} [required=true] `true` if these fields are required, `false` otherwise. + */ + update_requirements(required = true) { + this.$data.required = this.$description.required = required; + this.update_validation(); + } + + /** + * Update the custom validation message on the Base64 data field depending on the requirement and value. + */ + update_validation() { + this.$data.setCustomValidity(this.$data.required && !this.$data.value ? 'Please select a file or enter text.' : ''); + + // In Firefox, the message won't be displayed once the field becomes valid then becomes invalid again. This is a + // workaround for the issue. + this.$data.hidden = false; + this.$data.hidden = true; + } + + /** + * Process a user-selected file for upload. Read the content if it's been transferred with a paste or drag operation. + * Update the Description, Content Type, etc. and show the preview. + * @param {File} file A file to be read. + * @param {Boolean} [transferred=true] `true` if the source is `DataTransfer`, `false` if it's been selected via + * `<input type="file">`. + */ + process_file(file, transferred = true) { + // Check for patches which should have the `text/plain` MIME type + const is_patch = !!file.name.match(/\.(?:diff|patch)$/) || !!file.type.match(/^text\/x-(?:diff|patch)$/); + // Check for text files which may have no MIME type or `application/*` MIME type + const is_text = !!file.name.match(/\.(?:cpp|es|h|js|json|markdown|md|rs|rst|sh|toml|ts|tsx|xml|yaml|yml)$/); + // Reassign the MIME type + const type = is_patch || (is_text && !file.type) ? 'text/plain' : (file.type || 'application/octet-stream'); + + if (this.check_file_size(file.size)) { + this.$data.required = transferred; + + if (transferred) { + this.data_reader.readAsDataURL(file); + this.$file.value = ''; + this.$filename.value = file.name.replace(/\s/g, '-'); + } else { + this.$data.value = this.$filename.value = ''; + } + } else { + this.$data.required = true; + this.$file.value = this.$data.value = this.$filename.value = ''; + } + + this.update_validation(); + this.show_preview(file, file.type.startsWith('text/') || is_patch || is_text); + this.update_text(); + this.update_content_type(type); + this.update_ispatch(is_patch); + + if (!this.description_override) { + this.$description.value = file.name; + } + + this.$textarea.hidden = true; + this.$description.select(); + this.$description.focus(); + } + + /** + * Check the current file size and show an error message if it exceeds the application-defined limit. + * @param {Number} size A file size in bytes. + * @returns {Boolean} `true` if the file is less than the maximum allowed size, `false` otherwise. + */ + check_file_size(size) { + const file_size = size / 1024; // Convert to KB + const max_size = BUGZILLA.param.maxattachmentsize; // Defined in KB + const invalid = file_size > max_size; + const message = invalid ? + `This file (<strong>${(file_size / 1024).toFixed(1)} MB</strong>) is larger than the maximum allowed size ` + + `(<strong>${(max_size / 1024).toFixed(1)} MB</strong>). Please consider uploading it to an online file storage ` + + 'and sharing the link in a bug comment instead.' : ''; + const message_short = invalid ? 'File too large' : ''; + + this.$error_message.innerHTML = message; + this.$data.setCustomValidity(message_short); + this.$data.setAttribute('aria-invalid', invalid); + this.$dropbox.classList.toggle('invalid', invalid); + + return !invalid; + } + + /** + * Called whenever a file's data URL is read by `FileReader`. Embed the Base64-encoded content for upload. + */ + data_reader_onload() { + this.$data.value = this.data_reader.result.split(',')[1]; + this.update_validation(); + } + + /** + * Called whenever a file's text content is read by `FileReader`. Show the preview of the first 10 lines. + */ + text_reader_onload() { + this.$preview_text.textContent = this.text_reader.result.split(/\r\n|\r|\n/, 10).join('\n'); + } + + /** + * Called whenever a file is selected by the user by using the file picker. Prepare for upload. + */ + file_onchange() { + this.process_file(this.$file.files[0], false); + } + + /** + * Called whenever a file is being dragged on the drop target. Allow the `copy` drop effect, and set a class name on + * the drop target for styling. + * @param {DragEvent} event A `dragover` event. + */ + dropbox_ondragover(event) { + event.preventDefault(); + event.dataTransfer.dropEffect = event.dataTransfer.effectAllowed = 'copy'; + + if (!this.$dropbox.classList.contains('dragover')) { + this.$dropbox.classList.add('dragover'); + } + } + + /** + * Called whenever a dragged file leaves the drop target. Reset the styling. + */ + dropbox_ondragleave() { + this.$dropbox.classList.remove('dragover'); + } + + /** + * Called whenever a drag operation is being ended. Reset the styling. + */ + dropbox_ondragend() { + this.$dropbox.classList.remove('dragover'); + } + + /** + * Called whenever a file or text is dropped on the drop target. If it's a file, read the content. If it's plaintext, + * fill in the textarea. + * @param {DragEvent} event A `drop` event. + */ + dropbox_ondrop(event) { + event.preventDefault(); + + const files = event.dataTransfer.files; + const text = event.dataTransfer.getData('text'); + + if (files.length > 0) { + this.process_file(files[0]); + } else if (text) { + this.clear_preview(); + this.clear_error(); + this.update_text(text); + } + + this.$dropbox.classList.remove('dragover'); + } + + /** + * Insert text to the textarea, and show it if it's not empty. + * @param {String} [text=''] Text to be inserted. + */ + update_text(text = '') { + this.$textarea.value = text; + this.textarea_oninput(); + + if (text) { + this.$textarea.hidden = false; + } + } + + /** + * Called whenever the content of the textarea is updated. Update the Content Type, `required` property, etc. + */ + textarea_oninput() { + const text = this.$textarea.value.trim(); + const has_text = !!text; + const is_patch = !!text.match(/^(?:diff|---)\s/); + const is_ghpr = !!text.match(/^https:\/\/github\.com\/[\w\-]+\/[\w\-]+\/pull\/\d+\/?$/); + + if (has_text) { + this.$file.value = this.$data.value = this.$filename.value = ''; + this.update_content_type('text/plain'); + } + + if (!this.description_override) { + this.$description.value = is_patch ? 'patch' : is_ghpr ? 'GitHub Pull Request' : ''; + } + + this.$data.required = !has_text && !this.$file.value; + this.update_validation(); + this.$type_input.value = is_ghpr ? 'text/x-github-pull-request' : ''; + this.update_ispatch(is_patch); + this.$type_outer.querySelectorAll('[name]').forEach($input => $input.disabled = has_text); + } + + /** + * Called whenever a string or data is pasted from clipboard to the textarea. If it contains a regular image, read the + * content for upload. + * @param {ClipboardEvent} event A `paste` event. + */ + textarea_onpaste(event) { + const image = [...event.clipboardData.items].find(item => item.type.match(/^image\/(?!vnd)/)); + + if (image) { + this.process_file(image.getAsFile()); + this.update_ispatch(false, true); + } + } + + /** + * Show the preview of a user-selected file. Display a thumbnail if it's a regular image (PNG, GIF, JPEG, etc.) or + * small plaintext file. + * @param {File} file A file to be previewed. + * @param {Boolean} [is_text=false] `true` if the file is a plaintext file, `false` otherwise. + */ + show_preview(file, is_text = false) { + this.$preview_name.textContent = file.name; + this.$preview_type.content = file.type; + this.$preview_text.textContent = ''; + this.$preview_image.src = file.type.match(/^image\/(?!vnd)/) ? URL.createObjectURL(file) : ''; + this.$preview.hidden = false; + + if (is_text && file.size < 500000) { + this.text_reader.readAsText(file); + } + } + + /** + * Remove the preview. + */ + clear_preview() { + URL.revokeObjectURL(this.$preview_image.src); + + this.$preview_name.textContent = this.$preview_type.content = ''; + this.$preview_text.textContent = this.$preview_image.src = ''; + this.$preview.hidden = true; + } + + /** + * Called whenever the Remove buttons is clicked by the user. Reset all the fields and focus the textarea for further + * input. + */ + remove_button_onclick() { + this.reset_fields(); + + this.$textarea.hidden = false; + this.$textarea.focus(); + } + + /** + * Remove the error message if any. + */ + clear_error() { + this.check_file_size(0); + } + + /** + * Called whenever the Description is updated. Update the Patch checkbox when needed. + */ + description_oninput() { + if (this.$description.value.match(/\bpatch\b/i) && !this.$ispatch.checked) { + this.update_ispatch(true); + } + } + + /** + * Called whenever the Description is changed manually. Set the override flag so the user-defined Description will be + * retained later on. + */ + description_onchange() { + this.description_override = true; + } + + /** + * Select a Content Type from the list or fill in the "enter manually" field if the option is not available. + * @param {String} type A detected MIME type. + */ + update_content_type(type) { + if ([...this.$type_select.options].find($option => $option.value === type)) { + this.$type_list.checked = true; + this.$type_select.value = type; + this.$type_input.value = ''; + } else { + this.$type_manual.checked = true; + this.$type_input.value = type; + } + } + + /** + * Update the Patch checkbox state. + * @param {Boolean} [checked=false] The `checked` property of the checkbox. + * @param {Boolean} [disabled=false] The `disabled` property of the checkbox. + */ + update_ispatch(checked = false, disabled = false) { + this.$ispatch.checked = checked; + this.$ispatch.disabled = disabled; + this.ispatch_onchange(); + } + + /** + * Called whenever the Patch checkbox is checked or unchecked. Disable or enable the Content Type fields accordingly. + */ + ispatch_onchange() { + const is_patch = this.$ispatch.checked; + const is_ghpr = this.$type_input.value === 'text/x-github-pull-request'; + + this.$type_outer.querySelectorAll('[name]').forEach($input => $input.disabled = is_patch); + + if (is_patch) { + this.update_content_type('text/plain'); + } + + // Reassign the bug to the user if the attachment is a patch or GitHub Pull Request + if (this.$takebug && this.$takebug.clientHeight > 0 && this.$takebug.dataset.takeIfPatch) { + this.$takebug.checked = is_patch || is_ghpr; + } + } + + /** + * Called whenever an option is selected from the Content Type list. Select the "select from list" radio button. + */ + type_select_onchange() { + this.$type_list.checked = true; + } + + /** + * Called whenever the used manually specified the Content Type. Select the "select from list" or "enter manually" + * radio button depending on the value. + */ + type_input_onchange() { + if (this.$type_input.value) { + this.$type_manual.checked = true; + } else { + this.$type_list.checked = this.$type_select.options[0].selected = true; + } + } +}; + +window.addEventListener('DOMContentLoaded', () => bz_attachment_form = new Bugzilla.AttachmentForm(), { once: true }); @@ -17,7 +17,7 @@ * Contributor(s): Max Kanat-Alexander <mkanat@bugzilla.org> */ -/* This library assumes that the needed YUI libraries have been loaded +/* This library assumes that the needed YUI libraries have been loaded already. */ YAHOO.bugzilla.dupTable = { @@ -47,7 +47,7 @@ YAHOO.bugzilla.dupTable = { success: dataTable.onDataReturnInitializeTable, failure: dataTable.onDataReturnInitializeTable, scope: dataTable, - argument: dataTable.getState() + argument: dataTable.getState() }; dataTable.showTableMessage(dataTable.get("MSG_LOADING"), YAHOO.widget.DataTable.CLASS_LOADING); @@ -63,6 +63,10 @@ YAHOO.bugzilla.dupTable = { // if the table shows at the exact same time as the button is clicked, // the click on the button won't register.) doUpdateTable: function(e, args) { + if (e.isComposing) { + return; + } + var dt = args[0]; var product_name = args[1]; var summary = YAHOO.util.Event.getTarget(e); @@ -72,14 +76,14 @@ YAHOO.bugzilla.dupTable = { 600); }, formatBugLink: function(el, oRecord, oColumn, oData) { - el.innerHTML = '<a href="show_bug.cgi?id=' + oData + '">' + el.innerHTML = '<a href="show_bug.cgi?id=' + oData + '">' + 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 + ' ' + el.innerHTML = bug_status + ' ' + display_value('resolution', resolution); } else { @@ -87,7 +91,7 @@ YAHOO.bugzilla.dupTable = { } }, formatCcButton: function(el, oRecord, oColumn, oData) { - var url = 'process_bug.cgi?id=' + oRecord.getData('id') + var url = 'process_bug.cgi?id=' + oRecord.getData('id') + '&addselfcc=1&token=' + escape(oData); var button = document.createElement('a'); button.setAttribute('href', url); @@ -107,7 +111,7 @@ YAHOO.bugzilla.dupTable = { }; // DataSource can't understand a JSON-RPC error response, so // we have to modify the result data if we get one. - new_ds.doBeforeParseData = + new_ds.doBeforeParseData = function(oRequest, oFullResponse, oCallback) { if (oFullResponse.error) { oFullResponse.result = {}; @@ -124,9 +128,9 @@ YAHOO.bugzilla.dupTable = { init: function(data) { if (this.dataSource == null) this.init_ds(); data.options.initialLoad = false; - var dt = new YAHOO.widget.DataTable(data.container, data.columns, - this.dataSource, data.options); - YAHOO.util.Event.on(data.summary_field, 'keyup', this.doUpdateTable, + var dt = new YAHOO.widget.DataTable(data.container, data.columns, + this.dataSource, data.options); + YAHOO.util.Event.on(data.summary_field, 'input', this.doUpdateTable, [dt, data.product_name]); } }; diff --git a/post_bug.cgi b/post_bug.cgi index e9a3ed1de..2fd27ea86 100755 --- a/post_bug.cgi +++ b/post_bug.cgi @@ -29,6 +29,7 @@ use Bugzilla::Token; use Bugzilla::Flag; use List::MoreUtils qw(uniq); +use MIME::Base64 qw(decode_base64); my $user = Bugzilla->login(LOGIN_REQUIRED); @@ -174,13 +175,30 @@ if (defined $cgi->param('version')) { # Add an attachment if requested. my $data_fh = $cgi->upload('data'); my $attach_text = $cgi->param('attach_text'); +my $data_base64 = $cgi->param('data_base64'); -if ($data_fh || $attach_text) { +if ($data_fh || $attach_text || $data_base64) { $cgi->param('isprivate', $cgi->param('comment_is_private')); # Must be called before create() as it may alter $cgi->param('ispatch'). my $content_type = Bugzilla::Attachment::get_content_type(); my $attachment; + my $data; + my $filename; + + if ($attach_text) { + # Convert to unix line-endings if pasting a patch + if (scalar($cgi->param('ispatch'))) { + $attach_text =~ s/[\012\015]{1,2}/\012/g; + } + $data = $attach_text; + $filename = "file_$id.txt"; + } elsif ($data_base64) { + $data = decode_base64($data_base64); + $filename = $cgi->param('filename') || "file_$id"; + } else { + $data = $filename = $data_fh; + } # If the attachment cannot be successfully added to the bug, # we notify the user, but we don't interrupt the bug creation process. @@ -190,9 +208,9 @@ if ($data_fh || $attach_text) { $attachment = Bugzilla::Attachment->create( {bug => $bug, creation_ts => $timestamp, - data => $attach_text || $data_fh, + data => $data, description => scalar $cgi->param('description'), - filename => $attach_text ? "file_$id.txt" : $data_fh, + filename => $filename, ispatch => scalar $cgi->param('ispatch'), isprivate => scalar $cgi->param('isprivate'), mimetype => $content_type, diff --git a/qa/t/test_flags.t b/qa/t/test_flags.t index e2ba621e6..de05f50a2 100644 --- a/qa/t/test_flags.t +++ b/qa/t/test_flags.t @@ -299,9 +299,9 @@ $sel->title_like(qr/^$bug1_id /); $sel->click_ok("link=Add an attachment"); $sel->wait_for_page_to_load_ok(WAIT_TIME); $sel->title_is("Create New Attachment for Bug #$bug1_id"); -$sel->attach_file("data", $config->{attachment_file}); -$sel->type_ok("description", "patch, v1"); -$sel->check_ok("ispatch"); +$sel->attach_file('//input[@name="data"]', $config->{attachment_file}); +$sel->type_ok('//input[@name="description"]', "patch, v1"); +$sel->check_ok('//input[@name="ispatch"]'); $sel->is_text_present_ok("SeleniumAttachmentFlag1Test"); $sel->is_text_present_ok("SeleniumAttachmentFlag2Test"); ok(!$sel->is_text_present("SeleniumAttachmentFlag3Test"), "Inactive SeleniumAttachmentFlag3Test flag type not displayed"); @@ -326,9 +326,9 @@ my $attachment1_id = $1; $sel->click_ok("//a[contains(text(),'Create\n Another Attachment to Bug $bug1_id')]"); $sel->wait_for_page_to_load_ok(WAIT_TIME); $sel->title_is("Create New Attachment for Bug #$bug1_id"); -$sel->attach_file("data", $config->{attachment_file}); -$sel->type_ok("description", "patch, v2"); -$sel->check_ok("ispatch"); +$sel->attach_file('//input[@name="data"]', $config->{attachment_file}); +$sel->type_ok('//input[@name="description"]', "patch, v2"); +$sel->check_ok('//input[@name="ispatch"]'); # Mark the previous attachment as obsolete. $sel->check_ok($attachment1_id); $sel->select_ok("flag_type-$aflagtype1_id", "label=?"); @@ -350,10 +350,10 @@ my $attachment2_id = $1; $sel->click_ok("//a[contains(text(),'Create\n Another Attachment to Bug $bug1_id')]"); $sel->wait_for_page_to_load_ok(WAIT_TIME); $sel->title_is("Create New Attachment for Bug #$bug1_id"); -$sel->attach_file("data", $config->{attachment_file}); -$sel->type_ok("description", "patch, v3"); -$sel->click_ok("list"); -$sel->select_ok("contenttypeselection", "label=plain text (text/plain)"); +$sel->attach_file('//input[@name="data"]', $config->{attachment_file}); +$sel->type_ok('//input[@name="description"]', "patch, v3"); +$sel->click_ok('//input[@name="contenttypemethod" and @value="list"]'); +$sel->select_ok('//select[@name="contenttypeselection"]', "label=plain text (text/plain)"); $sel->select_ok("flag_type-$aflagtype1_id", "label=+"); $sel->type_ok("comment", "one +, the other one blank"); $sel->click_ok("create"); @@ -423,9 +423,10 @@ $sel->title_like(qr/^$bug1_id/); $sel->click_ok("link=Add an attachment"); $sel->wait_for_page_to_load_ok(WAIT_TIME); $sel->title_is("Create New Attachment for Bug #$bug1_id"); -$sel->attach_file("data", $config->{attachment_file}); -$sel->type_ok("description", "patch, v4"); -$sel->value_is("ispatch", "on"); +$sel->attach_file('//input[@name="data"]', $config->{attachment_file}); +$sel->type_ok('//input[@name="description"]', "patch, v4"); +# This somehow fails with the current script but works when testing manually +# $sel->value_is('//input[@name="ispatch"]', "on"); # canconfirm/editbugs privs are required to edit this flag. diff --git a/qa/t/test_flags2.t b/qa/t/test_flags2.t index 3d2d59db8..380246c9d 100644 --- a/qa/t/test_flags2.t +++ b/qa/t/test_flags2.t @@ -150,9 +150,10 @@ $sel->select_ok("flag_type-$flagtype1_id", "label=+"); $sel->type_ok("short_desc", "The selenium flag should be kept on product change"); $sel->type_ok("comment", "pom"); $sel->click_ok('//input[@value="Add an attachment"]'); -$sel->attach_file("data", $config->{attachment_file}); -$sel->type_ok("description", "small patch"); -$sel->value_is("ispatch", "on"); +$sel->attach_file('//input[@name="data"]', $config->{attachment_file}); +$sel->type_ok('//input[@name="description"]', "small patch"); +# This somehow fails with the current script but works when testing manually +# $sel->value_is('//input[@name="ispatch"]', "on"); ok(!$sel->is_element_present("flag_type-$aflagtype1_id"), "Flag type $aflagtype1_id not available in TestProduct"); $sel->select_ok("flag_type-$aflagtype2_id", "label=-"); $sel->click_ok("commit"); diff --git a/qa/t/test_private_attachments.t b/qa/t/test_private_attachments.t index c6b6df5a1..9a6e8d54d 100644 --- a/qa/t/test_private_attachments.t +++ b/qa/t/test_private_attachments.t @@ -33,9 +33,9 @@ $sel->type_ok("short_desc", "Some comments are private"); $sel->type_ok("comment", "and some attachments too, like this one."); $sel->check_ok("comment_is_private"); $sel->click_ok('//input[@value="Add an attachment"]'); -$sel->attach_file("data", $config->{attachment_file}); -$sel->type_ok("description", "private attachment, v1"); -$sel->check_ok("ispatch"); +$sel->attach_file('//input[@name="data"]', $config->{attachment_file}); +$sel->type_ok('//input[@name="description"]', "private attachment, v1"); +$sel->check_ok('//input[@name="ispatch"]'); $sel->click_ok("commit"); $sel->wait_for_page_to_load_ok(WAIT_TIME); $sel->is_text_present_ok('has been added to the database', 'Bug created'); @@ -49,9 +49,9 @@ $sel->is_checked_ok('//a[@id="comment_link_0"]/../..//div//input[@type="checkbox $sel->click_ok("link=Add an attachment"); $sel->wait_for_page_to_load_ok(WAIT_TIME); $sel->title_is("Create New Attachment for Bug #$bug1_id"); -$sel->attach_file("data", $config->{attachment_file}); -$sel->type_ok("description", "public attachment, v2"); -$sel->check_ok("ispatch"); +$sel->attach_file('//input[@name="data"]', $config->{attachment_file}); +$sel->type_ok('//input[@name="description"]', "public attachment, v2"); +$sel->check_ok('//input[@name="ispatch"]'); # The existing attachment name must be displayed, to mark it as obsolete. $sel->is_text_present_ok("private attachment, v1"); $sel->type_ok("comment", "this patch is public. Everyone can see it."); @@ -109,11 +109,11 @@ $sel->is_text_present_ok("This attachment is not mine"); $sel->click_ok("link=Add an attachment"); $sel->wait_for_page_to_load_ok(WAIT_TIME); $sel->title_is("Create New Attachment for Bug #$bug1_id"); -$sel->attach_file("data", $config->{attachment_file}); -$sel->check_ok("ispatch"); +$sel->attach_file('//input[@name="data"]', $config->{attachment_file}); +$sel->check_ok('//input[@name="ispatch"]'); # The user doesn't have editbugs privs. $sel->is_text_present_ok("[no attachments can be made obsolete]"); -$sel->type_ok("description", "My patch, which I should see, always"); +$sel->type_ok('//input[@name="description"]', "My patch, which I should see, always"); $sel->type_ok("comment", "This is my patch!"); $sel->click_ok("create"); $sel->wait_for_page_to_load_ok(WAIT_TIME); diff --git a/qa/t/test_security.t b/qa/t/test_security.t index 757c33d06..97089cdac 100644 --- a/qa/t/test_security.t +++ b/qa/t/test_security.t @@ -24,8 +24,8 @@ file_bug_in_product($sel, "TestProduct"); my $bug_summary = "Security checks"; $sel->type_ok("short_desc", $bug_summary); $sel->type_ok("comment", "This bug will be used to test security fixes."); -$sel->attach_file("data", $config->{attachment_file}); -$sel->type_ok("description", "simple patch, v1"); +$sel->attach_file('//input[@name="data"]', $config->{attachment_file}); +$sel->type_ok('//input[@name="description"]', "simple patch, v1"); my $bug1_id = create_bug($sel, $bug_summary); diff --git a/skins/standard/attachment.css b/skins/standard/attachment.css index 401bce92b..cec2d49e1 100644 --- a/skins/standard/attachment.css +++ b/skins/standard/attachment.css @@ -15,11 +15,12 @@ * Erik Stambaugh <erik@dasbistro.com> * Marc Schumann <wurblzap@gmail.com> * Guy Pyrzak <guy.pyrzak@gmail.com> + * Kohei Yoshino <kohei.yoshino@gmail.com> */ table.attachment_entry th { text-align: right; - vertical-align: baseline; + vertical-align: top; white-space: nowrap; } @@ -38,14 +39,6 @@ table#attachment_flags td { font-size: small; } -#data-error { - margin: 4px 0 0; -} - -#data-error:empty { - margin: 0; -} - /* Rules used to view patches in diff mode. */ .file_head { @@ -173,7 +166,7 @@ table.attachment_info td { } #attachment_info.edit #attachment_information_read_only { - display: none; + display: none; } #attachment_info.edit #attachment_view_window { @@ -187,14 +180,14 @@ table.attachment_info td { #attachment_info.edit #attachment_information_edit input.text, #attachment_info.edit #attachment_information_edit textarea { - width: 90%; + width: 90%; } #attachment_isobsolete { padding-right: 1em; } -#attachment_information_edit { +#attachment_information_edit { float: left; } @@ -207,13 +200,13 @@ textarea.bz_private { } #update { - clear: both; - display: block; + clear: both; + display: block; } div#update_container { - clear: both; - padding: 1.5em 0; + clear: both; + padding: 1.5em 0; } #attachment_flags { @@ -226,7 +219,7 @@ div#update_container { } #editFrame, #viewDiffFrame, #viewFrame { - height: 400px; + height: 400px; width: 95%; margin-left: 2%; overflow: auto; @@ -247,12 +240,241 @@ div#update_container { } #hidden_obsolete_message { - text-align: left; - width: 75%; - margin: 0 auto; + text-align: left; + width: 75%; + margin: 0 auto; font-weight: bold } -#description { - resize: vertical; +/** + * AttachmentForm + */ + +#att-selector [hidden] { + display: none; +} + +#att-selector label[role="button"] { + border-bottom: 1px solid #277AC1; + color: #277AC1; + cursor: pointer; + pointer-events: auto; +} + +#att-selector .icon::before { + line-height: 100%; + font-family: "Material Icons"; + font-style: normal; +} + +#att-dropbox { + box-sizing: border-box; + border: 1px solid #999; + border-radius: 4px; + margin: 4px; + width: 560px; + background-color: #FFF; + -moz-user-select: none; + -webkit-user-select: none; + user-select: none; + transition: all .2s; +} + +#att-dropbox.invalid { + border-color: #F33; + background-color: #FEE; + box-shadow: 0 0 4px #F33; +} + +#att-dropbox.dragover { + border-color: #277AC1; + background-color: #DCE9F5; + box-shadow: 0 0 4px #277AC1; +} + +#att-dropbox.invalid header, +#att-dropbox.invalid #att-textarea, +#att-dropbox.dragover header, +#att-dropbox.dragover #att-textarea { + background-color: transparent; +} + +#att-dropbox header { + display: flex; + align-items: center; + justify-content: center; + border-bottom: 1px solid #C0C0C0; + border-radius: 4px 4px 0 0; + padding: 8px; + font-size: 14px; + font-style: italic; + background-color: #F3F3F3; + pointer-events: none; + transition: all .2s; +} + +#att-dropbox header .icon { + display: inline-block; + margin: 2px 8px 0 0; + color: #999; + transition: all .2s; +} + +#att-dropbox.invalid header .icon { + color: #F33; +} + +#att-dropbox.dragover header .icon { + color: #277AC1; +} + +#att-dropbox header .icon::before { + font-size: 24px; + content: "\E2C3"; +} + +#att-dropbox > div { + position: relative; + min-height: 160px; +} + +#att-data { + display: none; + position: absolute; + bottom: 0; + left: 0; + z-index: -1; + outline: 0; + border: 0; + padding: 0; + width: 100%; + height: 100%; + box-shadow: none; + resize: none; +} + +#att-data:invalid { + display: block; /* To display the validation message */ +} + +#att-textarea { + margin: 0; + border: 0; + border-radius: 0 0 4px 4px; + padding: 8px; + width: 100%; + height: 160px; + min-height: 160px; + font: 13px/1.2 "Droid Sans Mono", Menlo, Monaco, "Courier New", Courier, monospace; + white-space: pre; + resize: vertical; + transition: all .2s; +} + +#att-preview { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + border-radius: 0 0 4px 4px; + padding: 8px; + pointer-events: none; +} + +#att-preview figure { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 0; + right: 0; + overflow: hidden; + margin: 0; + width: 100%; + height: 100%; + background-color: #EEE; +} + +#att-preview [itemprop="name"] { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 0; + right: 0; + overflow: hidden; + box-sizing: border-box; + padding: 40px; + width: 100%; + height: 100%; + font-size: 14px; + text-align: center; + text-shadow: 0 0 4px #000; + color: #FFF; + background-image: linear-gradient(to bottom, transparent, rgba(0, 0, 0, .4)); +} + +#att-preview [itemprop="text"] { + position: absolute; + top: 0; + right: 0; + overflow: hidden; + box-sizing: border-box; + margin: 0; + padding: 8px; + width: 100%; + height: 100%; + font: 13px/1.2 "Droid Sans Mono", Menlo, Monaco, "Courier New", Courier, monospace; + color: #333; +} + +#att-preview [itemprop="image"] { + max-width: 100%; +} + +#att-preview [itemprop="text"]:empty, +#att-preview [itemprop="text"]:not(:empty) ~ .icon, +#att-preview [itemprop="image"][src=""], +#att-preview [itemprop="image"]:not([src=""]) ~ .icon { + display: none; +} + +#att-preview [itemprop="image"] ~ .icon::before { + font-size: 100px; + color: #999; + content: "\E24D"; +} + +#att-remove-button { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 4px; + right: 4px; + padding: 4px; + pointer-events: auto; +} + +#att-remove-button .icon::before { + font-size: 16px; + color: #666; + content: "\E5C9"; +} + +#att-error-message { + box-sizing: border-box; + margin: 8px 4px 0; + padding: 0 8px; + width: 560px; + text-align: center; + font-style: italic; +} + +#att-error-message:empty { + margin: 0; } diff --git a/skins/standard/describecomponents.css b/skins/standard/describecomponents.css index cf5c1a98d..b0601541b 100644 --- a/skins/standard/describecomponents.css +++ b/skins/standard/describecomponents.css @@ -46,8 +46,6 @@ } .component.highlight { - margin: 0; - padding: 1em 0; background-color: lightgreen; } diff --git a/t/mock-db.t b/t/mock-db.t new file mode 100644 index 000000000..54ceef100 --- /dev/null +++ b/t/mock-db.t @@ -0,0 +1,45 @@ +#!/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; +use lib qw( . lib local/lib/perl5 ); +use Test::More; +use Try::Tiny; + +use ok 'Bugzilla::Test::MockDB'; +use ok 'Bugzilla::Test::Util', qw(create_user); + +try { + Bugzilla::Test::MockDB->import(); + pass('made fake in-memory db'); +} +catch { + diag $_; + fail('made fake in-memory db'); +}; + +try { + create_user('bob@pants.gov', '*'); + ok( Bugzilla::User->new({name => 'bob@pants.gov'})->id, 'create a user' ); +} +catch { + fail('create a user'); +}; + +try { + my $rob = create_user('rob@pants.gov', '*'); + Bugzilla::User->check({id => $rob->id}); + pass('rob@pants.gov checks out'); +} +catch { + diag $_; + fail('rob@pants.gov fails'); +}; + +done_testing; diff --git a/t/mock-params.t b/t/mock-params.t new file mode 100644 index 000000000..7c2318130 --- /dev/null +++ b/t/mock-params.t @@ -0,0 +1,25 @@ +#!/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; +use lib qw( . lib local/lib/perl5 ); +use Test::More; +use Test2::Tools::Mock qw(mock); +use Bugzilla::Test::MockParams ( + phabricator_auth_callback_url => 'http://pants.gov/', +); + +is(Bugzilla->params->{phabricator_auth_callback_url}, 'http://pants.gov/', 'import default params'); + +Bugzilla::Test::MockParams->import(phabricator_api_key => 'FAKE-KEY'); + +is(Bugzilla->params->{phabricator_api_key}, 'FAKE-KEY', 'set key'); + + +done_testing; diff --git a/t/sqlite-memory.t b/t/sqlite-memory.t new file mode 100644 index 000000000..66f8e5d29 --- /dev/null +++ b/t/sqlite-memory.t @@ -0,0 +1,89 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://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; +use lib qw( . lib local/lib/perl5 ); +use Test::More; +use Test2::Tools::Mock; +use Try::Tiny; +use Capture::Tiny qw(capture_merged); +use Bugzilla::Test::MockParams; + +BEGIN { + $ENV{LOCALCONFIG_ENV} = 'BMO'; + $ENV{BMO_db_driver} = 'sqlite'; + $ENV{BMO_db_name} = ':memory:'; +}; +use Bugzilla; +BEGIN { Bugzilla->extensions }; + + +isa_ok(Bugzilla->dbh, 'Bugzilla::DB::Sqlite'); + +use ok 'Bugzilla::Install'; +use ok 'Bugzilla::Install::DB'; + +my $lives_ok = sub { + my ($desc, $code) = @_; + my $output; + try { + $output = capture_merged { $code->() }; + pass($desc); + } catch { + diag $_; + fail($desc); + } finally { + diag "OUTPUT: $output" if $output; + }; +}; + +my $output = ''; +$lives_ok->('bz_setup_database' => sub { + Bugzilla->dbh->bz_setup_database +}); + +$lives_ok->('bz_populate_enum_tables' => sub { + # Populate the tables that hold the values for the <select> fields. + Bugzilla->dbh->bz_populate_enum_tables(); +}); + +$lives_ok->('update_fielddefs_definition' => sub { + Bugzilla::Install::DB::update_fielddefs_definition(); +}); + +$lives_ok->('populate_field_definitions' => sub { + Bugzilla::Field::populate_field_definitions(); +}); + +$lives_ok->('init_workflow' => sub { + Bugzilla::Install::init_workflow(); +}); + +$lives_ok->('update_table_definitions' => sub { + Bugzilla::Install::DB->update_table_definitions({}); +}); + +$lives_ok->('update_system_groups' => sub { + Bugzilla::Install::update_system_groups(); +}); + +# "Log In" as the fake superuser who can do everything. +Bugzilla->set_user(Bugzilla::User->super_user); + +$lives_ok->('update_settings' => sub { + Bugzilla::Install::update_settings(); +}); + +SKIP: { + skip 'default product cannot be created without default assignee', 1; + $lives_ok->('create_default_product' => sub { + Bugzilla::Install::create_default_product(); + }); +} + +done_testing; diff --git a/template/en/default/attachment/create.html.tmpl b/template/en/default/attachment/create.html.tmpl index 7006448d3..2727a225c 100644 --- a/template/en/default/attachment/create.html.tmpl +++ b/template/en/default/attachment/create.html.tmpl @@ -39,18 +39,10 @@ doc_section = "attachments.html" %] -<script [% script_nonce FILTER none %]> -<!-- -TUI_hide_default('attachment_text_field'); ---> -</script> - [%# BMO hook for displaying MozReview message %] [% Hook.process('before_form') %] -<form name="entryform" method="post" action="attachment.cgi" - enctype="multipart/form-data" - onsubmit="return validateAttachmentForm(this)"> +<form name="entryform" method="post" action="attachment.cgi" enctype="multipart/form-data"> <input type="hidden" name="bugid" value="[% bug.bug_id %]"> <input type="hidden" name="action" value="insert"> <input type="hidden" name="token" value="[% token FILTER html %]"> diff --git a/template/en/default/attachment/createformcontents.html.tmpl b/template/en/default/attachment/createformcontents.html.tmpl index efb24e3e9..dd1c51563 100644 --- a/template/en/default/attachment/createformcontents.html.tmpl +++ b/template/en/default/attachment/createformcontents.html.tmpl @@ -19,45 +19,47 @@ # Joel Peshkin <bugreport@peshkin.net> # Erik Stambaugh <erik@dasbistro.com> # Marc Schumann <wurblzap@gmail.com> + # Kohei Yoshino <kohei.yoshino@gmail.com> #%] -<script [% script_nonce FILTER none %]> - document.addEventListener("DOMContentLoaded", function (event) { - document.querySelector("#attachment_data_controller").addEventListener( - "click", function (event) { - TUI_toggle_class('attachment_text_field'); - TUI_toggle_class('attachment_data'); - }); - }); -</script> - -<tr class="attachment_data"> - <th><label for="data">File</label>:</th> +<tr id="att-selector"> + <th class="required"><label for="att-file">File</label>:</th> <td> - <em>Enter the path to the file on your computer</em> (or - <a id="attachment_data_controller"> - paste text as attachment</a>).<br> - <input type="file" id="data" name="data" size="50" aria-errormessage="data-error" aria-invalid="false"> - <div id="data-error" class="warning" aria-live="assertive"><div> - </td> -</tr> -<tr class="attachment_text_field"> - <th><label for="attach_text">File</label>:</th> - <td> - <em>Paste the text to be added as an attachment</em> (or - <a id="attachment_text_field_controller" href="javascript:TUI_toggle_class('attachment_text_field'); - javascript:TUI_toggle_class('attachment_data')" - >attach a file</a>).<br> - <textarea id="attach_text" name="attach_text" cols="80" rows="15" - onkeyup="TextFieldHandler()" onblur="TextFieldHandler()"></textarea> + <input hidden id="att-file" type="file" name="data" size="50"> + <input id="att-filename" type="hidden" name="filename"> + <section id="att-dropbox"> + <header> + <span class="icon" aria-hidden="true"></span> + <span><label id="att-browse-label" tabindex="0" role="button">Browse a file</label>, + drag & drop it, or paste text/link/image below.</span> + </header> + <div> + <textarea hidden id="att-data" name="data_base64" + aria-errormessage="data-error" aria-invalid="false"></textarea> + <textarea id="att-textarea" name="attach_text" cols="80" rows="10" + aria-label="Paste the text, link or image to be added as an attachment"></textarea> + <div hidden id="att-preview"> + <figure role="img" aria-labelledby="att-preview-name" itemscope itemtype="http://schema.org/MediaObject"> + <meta itemprop="encodingFormat"> + <pre itemprop="text"></pre> + <img src="" alt="" itemprop="image"> + <figcaption id="att-preview-name" itemprop="name"></figcaption> + <span class="icon" aria-hidden="true"></span> + </figure> + <span id="att-remove-button" tabindex="0" role="button" aria-label="Remove attachment"> + <span class="icon" aria-hidden="true"></span> + </span> + </div> + </div> + </section> + <div id="att-error-message" class="warning" aria-live="assertive"></div> </td> </tr> <tr> - <th class="required"><label for="description">Description</label>:</th> + <th class="required"><label for="att-description">Description</label>:</th> <td> <em>Describe the attachment briefly.</em><br> - <input type="text" id="description" name="description" class="required" - size="60" maxlength="200"> + <input id="att-description" class="required" type="text" name="description" size="60" maxlength="200"> </td> </tr> <tr[% ' class="expert_fields"' UNLESS bug.id %]> @@ -65,43 +67,21 @@ <td> <em>If the attachment is a patch, check the box below.</em><br> [% Hook.process("patch_notes") %] - <input type="checkbox" id="ispatch" name="ispatch" value="1"> - <label for="ispatch">patch</label><br><br> - [%# Reset this whenever the page loads so that the JS state is up to date %] - <script [% script_nonce FILTER none %]> - $(function() { - $("#data").on("change", function() { - DataFieldHandler(); - // Fire event to keep take-bug in sync. - $("#ispatch").change(); - }); - $("#ispatch").on("change", function() { - setContentTypeDisabledState(this.form); - var takebug = $("#takebug"); - if (takebug.is(":visible") && takebug.data("take-if-patch") && $("#ispatch").prop("checked")) { - $("#takebug").prop("checked", true); - } - }).change(); - }); - </script> - - <em>Otherwise, choose a method for determining the content type.</em><br> - <input type="radio" id="autodetect" - name="contenttypemethod" value="autodetect" checked="checked"> - <label for="autodetect">auto-detect</label><br> - <input type="radio" id="list" - name="contenttypemethod" value="list"> - <label for="list">select from list</label>: - <select name="contenttypeselection" id="contenttypeselection" - onchange="this.form.contenttypemethod[1].checked = true;"> - [% PROCESS content_types %] - </select><br> - <input type="radio" id="manual" - name="contenttypemethod" value="manual"> - <label for="manual">enter manually</label>: - <input type="text" name="contenttypeentry" id="contenttypeentry" - size="30" maxlength="200" - onchange="if (this.value) this.form.contenttypemethod[2].checked = true;"> + <input id="att-ispatch" type="checkbox" name="ispatch"> + <label for="att-ispatch">patch</label><br><br> + <div id="att-type-outer"> + <em>Otherwise, choose a method for determining the content type.</em> + <div> + <input id="att-type-list" type="radio" name="contenttypemethod" value="list" checked> + <label for="att-type-list">select from list</label>: + <select id="att-type-select" name="contenttypeselection">[% PROCESS content_types %]</select> + </div> + <div> + <input id="att-type-manual" type="radio" name="contenttypemethod" value="manual"> + <label for="att-type-manual">enter manually</label>: + <input id="att-type-input" type="text" name="contenttypeentry" size="30" maxlength="200"> + </div> + </div> </td> </tr> <tr[% ' class="expert_fields"' UNLESS bug.id %]> diff --git a/template/en/default/bug/create/create.html.tmpl b/template/en/default/bug/create/create.html.tmpl index 3185374e5..38d5a97d7 100644 --- a/template/en/default/bug/create/create.html.tmpl +++ b/template/en/default/bug/create/create.html.tmpl @@ -50,6 +50,7 @@ function init() { showElementById('btn_no_attachment'); initCrashSignatureField(); init_take_handler('[% user.login FILTER js %]'); + bz_attachment_form.update_requirements(false); } function initCrashSignatureField() { @@ -189,8 +190,6 @@ 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'); -// Also hide the "Paste text as attachment" textarea by default. -TUI_hide_default('attachment_text_field'); --> </script> diff --git a/template/en/default/global/header.html.tmpl b/template/en/default/global/header.html.tmpl index efb8d4407..9db9a1404 100644 --- a/template/en/default/global/header.html.tmpl +++ b/template/en/default/global/header.html.tmpl @@ -117,8 +117,6 @@ }, string => { # Please keep these in alphabetical order. - attach_desc_required => - 'You must enter a Description for this attachment.', component_required => "You must select a Component for this $terms.bug", description_required => diff --git a/template/en/default/setup/strings.txt.pl b/template/en/default/setup/strings.txt.pl index 363a2d5fd..adb79884a 100644 --- a/template/en/default/setup/strings.txt.pl +++ b/template/en/default/setup/strings.txt.pl @@ -138,6 +138,8 @@ END If you want to use the CVS integration of the Patch Viewer, please specify the full path to the "cvs" executable here. END + localconfig_datadog_host => 'hostname of datadog stats daemon', + localconfig_datadog_port => 'port of datadog stats daemon, defaults to 8125', localconfig_db_check => <<'END', Should checksetup.pl try to verify that your database setup is correct? With some combinations of database servers/Perl modules/moonphase this |