Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion Bugzilla/WebService/Bug.pm
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,11 @@ sub render_comment {
? Bugzilla->markdown->render_html($params->{text}, $bug)
: Bugzilla::Template::quoteUrls($params->{text}, $bug);

return {html => $html};
my $result = {html => $html};
Bugzilla::Hook::process('webservice_bug_render_comment',
{webservice => $self, bug => $bug, params => $params, result => $result});

return $result;
}

# Helper for every method that returns comments. Fetches the revision metadata
Expand Down Expand Up @@ -5424,6 +5428,12 @@ C<int> The ID of the bug to render the comment against.

C<html> containing the HTML rendering.

Extensions can add params and return values through the
C<webservice_bug_render_comment> hook. When C<id> is given, the Needinfo
extension accepts C<include_mentions> and C<mention_is_private> and returns
C<mentions>, an array of C<nick>/C<name> hashes for the users that saving the
comment on that bug would CC and needinfo.

=item B<Errors>

This method can throw all of the errors that L</get> throws.
Expand Down
9 changes: 9 additions & 0 deletions extensions/BugModal/web/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,15 @@ Bugzilla.BugModal.InstantUpdater = class InstantUpdater {
});
}

// `@nickname` mentions are only processed for comments submitted through the form, not the
// API, so let the form handle them. This loosely mirrors `_extract_mentions` in the Needinfo
// extension. A false positive only costs a page reload.
if (/(?<![\w@./-])@[\p{L}\p{N}|._-]/u.test(changedFields.comment)) {
throw new Error('BEFORE_SUBMIT_HALT', {
cause: 'Comment mentions users.',
});
}

// Show a placeholder comment immediately. Don't `await` this because the HTML rendering doesn’t
// block the following operations.
try {
Expand Down
196 changes: 193 additions & 3 deletions extensions/Needinfo/Extension.pm
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,21 @@ use warnings;

use base qw(Bugzilla::Extension);

use Bugzilla::Bug;
use Bugzilla::Constants;
use Bugzilla::Error;
use Bugzilla::Flag;
use Bugzilla::FlagType;
use Bugzilla::Logging;
use Bugzilla::User;
use Bugzilla::User::Setting;

use List::Util qw(all);

our $VERSION = '0.01';

use constant MAX_MENTIONS => 10;

BEGIN {
*Bugzilla::User::needinfo_blocked = \&_user_needinfo_blocked;
}
Expand Down Expand Up @@ -60,7 +67,7 @@ sub install_update_db {
is_active => 1,
is_requestable => 1,
is_requesteeble => 1,
is_multiplicable => 0,
is_multiplicable => 1,
request_group => '',
grant_group => '',
inclusions => ['0:0'],
Expand Down Expand Up @@ -97,10 +104,19 @@ sub bug_end_of_create {
$self->bug_start_of_update($args);
}

# Clear the needinfo? flag if comment is being given by
# requestee or someone used the override flag.
sub bug_start_of_update {
my ($self, $args) = @_;
_process_needinfo_params($args);

# Runs after the explicit needinfo handling so that the duplicate check
# sees any flags that were just requested through the form.
_process_mentions($args->{bug}, $args->{old_bug}) if $args->{old_bug};
}

# Clear the needinfo? flag if comment is being given by
# requestee or someone used the override flag.
sub _process_needinfo_params {
my ($args) = @_;
my $bug = $args->{bug};
my $old_bug = $args->{old_bug};

Expand Down Expand Up @@ -248,6 +264,174 @@ sub bug_start_of_update {
}
}

# Returns the unique nicknames @mentioned in a comment, in order of first
# appearance. Mentions inside quoted lines (> ...) and code are ignored, as
# are email addresses (the @ must not follow a word character).
sub _extract_mentions {
Comment thread
dklawren marked this conversation as resolved.
my ($text) = @_;
return () unless defined $text;

$text =~ s/^[ \t]*(`{3,}|~{3,}).*?(?:^[ \t]*\1[^\n]*$|\z)//msg;
# A code span closes on a backtick run of the same length, within a paragraph.
$text = join "\n\n",
map { s/(?<!`)(`+)(?!`).+?(?<!`)\1(?!`)//sgr } split /\n[ \t]*\n/, $text;
$text =~ s/^[ \t]*>.*$//mg;

my (@nicks, %seen);
# Same character set as extract_nicks in Bugzilla/Util.pm.
while ($text =~ /(?<![\w@.\/-])@([\p{IsAlnum}|._-]+)/g) {
(my $nick = $1) =~ s/[.|]+$//;
next if $nick eq '' || $seen{lc $nick}++;
push @nicks, $nick;
}
return @nicks;
}

# Maps nicknames to enabled user accounts. A nickname shared by more than one
# account is ambiguous and dropped rather than guessed at.
sub _users_for_mentions {
my (@nicks) = @_;
return () unless @nicks;

my $dbh = Bugzilla->dbh;
my $rows = $dbh->selectall_arrayref(
'SELECT userid, nickname FROM profiles WHERE is_enabled = 1 AND '
. $dbh->sql_in('nickname', [map { $dbh->quote($_) } @nicks]));

my %ids_by_nick;
push @{$ids_by_nick{lc $_->[1]}}, $_->[0] foreach @$rows;
my @ids = map { $_->[0] } grep { @$_ == 1 } values %ids_by_nick;
return @{Bugzilla::User->new_from_list(\@ids)};
}

# Returns the users a set of new comments ({thetext, isprivate} hashes) on
# $bug would CC and needinfo, applying the safeguards described above
# _process_mentions. Shared by saving and the comment preview so the preview
# can't disagree with what actually happens. $old_bug is only passed when
# saving, to account for groups added in the same update.
sub _mentioned_users {
my ($bug, $comments, $old_bug) = @_;
return () unless Bugzilla->params->{needinfo_mentions_enabled};

# Lowercased nick => true if mentioned in at least one public comment.
my %public;

# The first MAX_MENTIONS distinct lowercased nicks, in order of appearance.
my @nicks;

foreach my $comment (@$comments) {
foreach my $nick (map {lc} _extract_mentions($comment->{thetext})) {
push @nicks, $nick if !exists $public{$nick} && @nicks < MAX_MENTIONS;
$public{$nick} ||= !$comment->{isprivate};
}
}
return () unless @nicks;

# Checked after parsing so updates without mentions skip the group lookup.
my $user = Bugzilla->user;
return () unless $user->in_group('editbugs', $bug->product_id);

# can_see_bug reads the groups stored in the database, which don't yet
# include groups added by this update, so check those separately.
my @added_groups;
if ($old_bug) {
my %old_groups = map { $_->id => 1 } @{$old_bug->groups_in};
@added_groups
= map { $_->name } grep { !$old_groups{$_->id} } @{$bug->groups_in};
}

return grep {
my $mentioned = $_;
$mentioned->id != $user->id
&& ($public{lc $mentioned->nick} || $mentioned->is_insider)
&& $mentioned->can_see_bug($bug->id)
&& all { $mentioned->in_group($_) } @added_groups
} _users_for_mentions(@nicks);
Comment thread
dklawren marked this conversation as resolved.
}

# GitHub-style mentions: each @nickname in a new comment is CC'd and gets a
# needinfo request. Mentions never block the comment from being saved; anyone
# who can't be needinfo'd (blocked, already asked, no permission, or the
# needinfo type isn't multiplicable and a flag exists) is only CC'd.
#
# Safeguards against misuse:
# - only comments made in the web UI count, so bots and API clients posting
# commit messages, code or logs can't CC people by accident
# - only editbugs users can trigger mentions; for everyone else they are text
# - only the first MAX_MENTIONS distinct nicknames per update are processed
# - mass edits are skipped, since the same comment lands on every bug and
# would CC and needinfo each mentioned user once per bug
# - users who can't already see the bug are skipped, so a mention (or a typo,
# or a squatted nickname) can never grant access to a restricted bug; that
# includes groups being added to the bug in this same update
# - admins can switch the whole feature off with the needinfo_mentions_enabled
# param
sub _process_mentions {
Comment thread
dklawren marked this conversation as resolved.
my ($bug, $old_bug) = @_;
return unless Bugzilla->usage_mode == USAGE_MODE_BROWSER;
return if _is_mass_edit();

my @mentioned
= _mentioned_users($bug, $bug->{added_comments} || [], $old_bug);
return unless @mentioned;

my ($type) = grep { $_->name eq 'needinfo' } @{$bug->flag_types};
undef $type
unless $type && $bug->check_can_change_field('flagtypes.name', 0, 1)->{allowed};

# A failure for one user (e.g. add_cc's strict_isolation check) skips that
# user rather than rolling back the whole update. Throw*Error only dies
# (instead of printing an error page and exiting) in ERROR_MODE_DIE, so the
# eval can catch it.
local Bugzilla->request_cache->{error_mode} = ERROR_MODE_DIE;
local $@;
foreach my $mentioned (@mentioned) {
eval { _cc_and_needinfo($bug, $mentioned, $type); 1 }
or WARN('Skipped mention of ' . $mentioned->login . " on bug "
. $bug->id . ": $@");
}
}

# process_bug.cgi takes a single bug as `id` and a mass edit as `id_<bug id>`
# params, one per bug.
sub _is_mass_edit {
return grep {/^id_[1-9]/} Bugzilla->cgi->param;
}

sub _cc_and_needinfo {
my ($bug, $mentioned, $type) = @_;
$bug->add_cc($mentioned);

return if !$type || $mentioned->needinfo_blocked;

# A non-multiplicable type allows only one needinfo flag per bug.
return if !$type->is_multiplicable && @{$type->{flags}};
return if grep {
$_->status eq '?' && ($_->requestee_id // 0) == $mentioned->id
} @{$type->{flags}};

# One call per user so $type->{flags} reflects each flag as it is added.
$bug->set_flags([],
[{type_id => $type->id, status => '?', requestee => $mentioned->login}]);
}

# Lets the comment preview list who saving the comment would CC and needinfo,
# so a stray @word is caught before the comment is submitted. The comment
# editor sends include_mentions (and mention_is_private) along with the bug id
# only for new comments on an existing bug, the only case _process_mentions
# handles.
sub webservice_bug_render_comment {
my ($self, $args) = @_;
my ($bug, $params) = @$args{qw(bug params)};
return unless $bug && $params->{include_mentions};

my @mentioned = _mentioned_users($bug,
[{thetext => $params->{text}, isprivate => $params->{mention_is_private}}]);

$args->{result}->{mentions}
= [map { +{nick => $_->nick, name => $_->name} } @mentioned];
}

sub _check_requestee {
my ($requestee) = @_;
my $user
Expand All @@ -263,6 +447,12 @@ sub _check_requestee {
{requestee => $user});
}

sub config_add_panels {
my ($self, $args) = @_;
my $modules = $args->{panel_modules};
$modules->{Needinfo} = "Bugzilla::Extension::Needinfo::Config";
}

sub object_end_of_create {
my ($self, $args) = @_;
my $object = $args->{object};
Expand Down
23 changes: 23 additions & 0 deletions extensions/Needinfo/lib/Config.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# This Source Code Form is "Incompatible With Secondary Licenses", as
# defined by the Mozilla Public License, v. 2.0.

package Bugzilla::Extension::Needinfo::Config;

use 5.10.1;
use strict;
use warnings;

use Bugzilla::Config::Common;

sub get_param_list {
my ($class) = @_;

my @param_list = ({name => 'needinfo_mentions_enabled', type => 'b', default => '1',},);
return @param_list;
}

1;
Loading
Loading