From dd3ec886496c54fea03a26f2c2737b1bc3236758 Mon Sep 17 00:00:00 2001 From: Xavier L'Hour Date: Thu, 15 Oct 2026 21:29:46 +0200 Subject: [PATCH] Bug 2081859 - Migrate Bug REST resource to native Mojo API: comments (comments, add_comment, render_comment, reactions, tags) --- Bugzilla/API/V1/BugComment.pm | 587 ++++++++++++++ Bugzilla/WebService/Bug.pm | 716 +----------------- .../WebService/Server/REST/Resources/Bug.pm | 63 -- extensions/MyDashboard/lib/WebService.pm | 13 +- qa/t/rest_bug_comment_misc.t | 111 +++ 5 files changed, 715 insertions(+), 775 deletions(-) create mode 100644 Bugzilla/API/V1/BugComment.pm create mode 100644 qa/t/rest_bug_comment_misc.t diff --git a/Bugzilla/API/V1/BugComment.pm b/Bugzilla/API/V1/BugComment.pm new file mode 100644 index 0000000000..863c9d03ac --- /dev/null +++ b/Bugzilla/API/V1/BugComment.pm @@ -0,0 +1,587 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://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::API::V1::BugComment; + +use 5.10.1; +use Mojo::Base qw( Mojolicious::Controller ); + +use List::MoreUtils qw(uniq); + +use Bugzilla::API::V1::Util; +use Bugzilla::Bug; +use Bugzilla::BugMail; +use Bugzilla::Comment; +use Bugzilla::Comment::TagWeights; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Template; +use Bugzilla::Util qw(datetime_from detaint_natural remote_ip trim); +use Bugzilla::WebService::Constants; +use Bugzilla::WebService::Util qw(filter merge_request_params); + +sub setup_routes { + my ($class, $r) = @_; + my $routes + = $r->under('/' => sub { Bugzilla->usage_mode(USAGE_MODE_MOJO_REST); }); + + my @routes = ( + ['/bug/#id/comment' => {get => 'comments', post => 'add_comment'}], + ['/bug/comment/' => {get => 'comments'}], + [ + '/bug/comment/#comment_id/reactions' => + {get => 'get_comment_reactions', put => 'update_comment_reactions'} + ], + ['/bug/comment/#comment_id/tags' => {put => 'update_comment_tags'}], + ['/bug/comment/tags/#query' => {get => 'search_comment_tags'}], + ['/bug/comment/render' => {post => 'render_comment'}], + ); + foreach my $route (@routes) { + my ($path, $actions) = @$route; + foreach my $method (sort keys %$actions) { + $routes->$method($path)->to("V1::BugComment#$actions->{$method}"); + } + $routes->options($path) + ->to('V1::BugComment#options', + allow => join(', ', map {uc} sort keys %$actions)); + } +} + +sub options { + my ($self) = @_; + + my $allow = $self->stash('allow'); + $self->res->headers->header('Allow' => $allow); + $self->res->headers->header('Access-Control-Allow-Methods' => $allow); + + return $self->rendered(200); +} + +# Same output as type() on the legacy JSON-RPC/REST server. +sub type { + my ($self, $type, $value) = @_; + return Bugzilla::API::V1::Util->type($type, $value); +} + +# GET /bug//comment and /bug/comment/ +sub comments { + my ($self) = @_; + + my $user = $self->bugzilla->login; + + my $params = {}; + for my $name (qw(ids comment_ids include_fields exclude_fields)) { + my $values = $self->every_param($name); + $params->{$name} = $values if @$values; + } + for my $name (qw(include_fields exclude_fields)) { + $params->{$name} = [map { split(/[\s,]+/) } @{$params->{$name}}] + if exists $params->{$name}; + } + for my $name (qw(new_since skip_private)) { + $params->{$name} = $self->param($name) if defined $self->param($name); + } + + # As on the legacy REST server, an id in the path is added to the ones from + # the query string. + for my $pair (['id', 'ids'], ['comment_id', 'comment_ids']) { + my ($stash, $key) = @$pair; + next if !defined(my $id = $self->stash($stash)); + $params->{$key} = [uniq($id, @{$params->{$key} || []})]; + } + + # The legacy server converted new_since to the database timezone. + if (defined $params->{new_since}) { + my $converted = datetime_from($params->{new_since}, Bugzilla->local_timezone) + // return $self->user_error('illegal_date', {date => $params->{new_since}}); + $params->{new_since} = $converted->ymd() . ' ' . $converted->hms(); + } + + my $bug_ids = $params->{ids} || []; + my $comment_ids = $params->{comment_ids} || []; + my $skip_private = $params->{skip_private} ? 1 : 0; + + Bugzilla->switch_to_shadow_db(); + + unless ($user->id) { + Bugzilla->check_rate_limit("get_comments", remote_ip()); + } + + if ($skip_private) { + + # Cache permissions for bugs. This highly reduces the number of calls to the DB. + # visible_bugs() is only able to handle bug IDs, so we have to skip aliases. + my @int = grep { $_ =~ /^\d+$/ } @$bug_ids; + $user->visible_bugs(\@int); + } + + my %bugs; + my @bug_comments; + foreach my $bug_id (@$bug_ids) { + my $bug; + + if ($skip_private) { + $bug = Bugzilla::Bug->new({id => $bug_id, cache => 1}); + next if $bug->error || !$user->can_see_bug($bug->id); + } + else { + $bug = Bugzilla::Bug->check($bug_id); + } + + # We want the API to always return comments in the same order. + my $comments = $bug->comments( + {order => 'oldest_to_newest', after => $params->{new_since}}); + push(@bug_comments, [$bug, $comments]); + } + + # Preload every requested bug's comments in one query, otherwise a bulk + # request would run one aggregation query per bug. + _preload_comment_edit_info([map { @{$_->[1]} } @bug_comments]); + + my $want_collapsed = _wants_collapsed_comments($params); + + foreach my $bug_comment (@bug_comments) { + my ($bug, $comments) = @$bug_comment; + my @result; + foreach my $comment (@$comments) { + next if $comment->is_private && !$user->is_insider; + next if $comment->collapsed && !$want_collapsed; + push(@result, $self->_translate_comment($comment, $params)); + } + $bugs{$bug->id}{'comments'} = \@result; + } + + my %comments; + if (scalar @$comment_ids) { + my @ids = map { trim($_) } @$comment_ids; + my $comment_data = Bugzilla::Comment->new_from_list(\@ids); + + # See if we were passed any invalid comment ids. + my %got_ids = map { $_->id => 1 } @$comment_data; + foreach my $comment_id (@ids) { + if (!$got_ids{$comment_id}) { + return $self->user_error('comment_id_invalid', {id => $comment_id}); + } + } + + # Now make sure that we can see all the associated bugs. + my %got_bug_ids = map { $_->bug_id => 1 } @$comment_data; + Bugzilla::Bug->check($_) foreach (keys %got_bug_ids); + + _preload_comment_edit_info($comment_data); + + foreach my $comment (@$comment_data) { + if ($comment->is_private && !$user->is_insider) { + return $self->user_error('comment_is_private', {id => $comment->id}); + } + $comments{$comment->id} = $self->_translate_comment($comment, $params); + } + } + + return $self->render(json => {bugs => \%bugs, comments => \%comments}); +} + +# POST /bug//comment +sub add_comment { + my ($self) = @_; + + # BMO: Don't allow updating of bugs if disabled + return $self->_updates_disabled if Bugzilla->params->{disable_bug_updates}; + + # The user must login in order add a comment + my $user = $self->bugzilla->login; + $user->id || return $self->user_error('login_required'); + + my ($params, $error) = $self->_request_params; + return $self->user_error($error) if $error; + + # Check parameters + my $comment = $params->{comment}; + (defined $comment && trim($comment) ne '') + || return $self->code_error('param_required', {param => 'comment'}); + + my $bug = Bugzilla::Bug->check($params->{id}); + + $user->can_edit_product($bug->product_id) + || return $self->user_error("product_edit_denied", + {product => $bug->product}); + + # Backwards-compatibility for versions before 3.6 + if (defined $params->{private}) { + $params->{is_private} = delete $params->{private}; + } + + # Append comment + $bug->add_comment( + $comment, + { + isprivate => $params->{is_private}, + work_time => $params->{work_time}, + is_markdown => ( + defined $params->{is_markdown} + ? ($params->{is_markdown} ? 1 : 0) + : (Bugzilla->params->{use_markdown} ? 1 : 0) + ) + } + ); + + # Add comment tags + $bug->set_all({comment_tags => $params->{comment_tags}}) + if defined $params->{comment_tags}; + + $bug->update(); + + my $new_comment_id = $bug->{added_comments}[0]->id; + + # Send mail. + Bugzilla::BugMail::Send($bug->bug_id, {changer => $user}); + + return $self->render( + json => {id => $self->type('int', $new_comment_id)}, + status => 201 + ); +} + +# POST /bug/comment/render +sub render_comment { + my ($self) = @_; + + $self->bugzilla->login; + + my ($params, $error) = $self->_request_params; + return $self->user_error($error) if $error; + + unless (defined $params->{text}) { + return $self->code_error('params_required', + {function => 'Bug.render_comment', params => ['text']}); + } + + Bugzilla->switch_to_shadow_db(); + my $bug = $params->{id} ? Bugzilla::Bug->check($params->{id}) : undef; + + my $html + = Bugzilla->params->{use_markdown} + ? Bugzilla->markdown->render_html($params->{text}, $bug) + : Bugzilla::Template::quoteUrls($params->{text}, $bug); + + return $self->render(json => {html => $html}); +} + +# GET /bug/comment//reactions +sub get_comment_reactions { + my ($self) = @_; + + Bugzilla->params->{use_comment_reactions} + || return $self->user_error('comment_reaction_disabled'); + + $self->bugzilla->login; + + my ($comment, undef, $responded) + = $self->_visible_comment('Bug.get_comment_reactions'); + return if $responded; + return $self->render(json => []) if !$comment; + + return $self->render( + json => $self->_get_comment_reactions_with_users($comment)); +} + +# PUT /bug/comment//reactions +sub update_comment_reactions { + my ($self) = @_; + + Bugzilla->params->{use_comment_reactions} + || return $self->user_error('comment_reaction_disabled'); + + my $user = $self->bugzilla->login; + $user->id || return $self->user_error('login_required'); + + my ($comment, $params, $responded) + = $self->_visible_comment('Bug.update_comment_reactions'); + return if $responded; + return $self->render(json => []) if !$comment; + + if ($comment->bug->is_closed_for({months => 3})) { + return $self->user_error('comment_reaction_closed'); + } + + if ($comment->bug->restrict_comments + && !$user->in_group(Bugzilla->params->{'restrict_comments_group'})) + { + return $self->user_error('comment_reaction_restricted'); + } + + my $dbh = Bugzilla->dbh; + $dbh->bz_start_transaction(); + foreach my $reaction (@{$params->{add} || []}) { + $comment->add_reaction($reaction); + } + foreach my $reaction (@{$params->{remove} || []}) { + $comment->remove_reaction($reaction); + } + $comment->update(); + $dbh->bz_commit_transaction(); + + return $self->render( + json => $self->_get_comment_reactions_with_users($comment)); +} + +# PUT /bug/comment//tags +sub update_comment_tags { + my ($self) = @_; + + my $user = $self->bugzilla->login; + $user->id || return $self->user_error('login_required'); + Bugzilla->params->{'comment_taggers_group'} + || return $self->user_error("comment_tag_disabled"); + $user->can_tag_comments || return $self->user_error( + "auth_failure", + { + group => Bugzilla->params->{'comment_taggers_group'}, + action => "update", + object => "comment_tags" + } + ); + + my ($comment, $params, $responded) + = $self->_visible_comment('Bug.update_comment_tags'); + return if $responded; + return $self->render(json => []) if !$comment; + + my $dbh = Bugzilla->dbh; + $dbh->bz_start_transaction(); + foreach my $tag (@{$params->{add} || []}) { + $comment->add_tag($tag) if defined $tag; + } + foreach my $tag (@{$params->{remove} || []}) { + $comment->remove_tag($tag) if defined $tag; + } + $comment->update(); + $dbh->bz_commit_transaction(); + + return $self->render(json => $comment->tags); +} + +# GET /bug/comment/tags/ +sub search_comment_tags { + my ($self) = @_; + + my $user = $self->bugzilla->login; + $user->id || return $self->user_error('login_required'); + Bugzilla->params->{'comment_taggers_group'} + || return $self->user_error("comment_tag_disabled"); + $user->can_tag_comments || return $self->user_error( + "auth_failure", + { + group => Bugzilla->params->{'comment_taggers_group'}, + action => "search", + object => "comment_tags" + } + ); + + my $query = $self->param('query'); + my $limit = $self->param('limit') || 7; + detaint_natural($limit) + || return $self->code_error('param_must_be_numeric', + {param => 'limit', function => 'Bug.search_comment_tags'}); + + my $tags + = Bugzilla::Comment::TagWeights->match({ + WHERE => {'tag LIKE ?' => "\%$query\%",}, LIMIT => $limit, + }); + return $self->render(json => [map { $_->tag } @$tags]); +} + +# Parameters of a POST or PUT: the query string and the request body, as on +# the legacy REST server. The id in the path is only used when neither of +# them has one. +sub _request_params { + my ($self) = @_; + + my ($params, $error) + = merge_request_params($self, [qw(add remove comment_tags)]); + return (undef, $error) if $error; + + $params->{$_} //= $self->stash($_) for qw(id comment_id); + + # A bug id is a single value. A hash here would reach Bugzilla::Bug->check() + # as a set of object conditions. (A comment_id hash fails detaint_natural.) + return (undef, 'invalid_params') if ref $params->{id}; + + return ($params, undef); +} + +# Loads the comment named by comment_id and checks that the user can see it. +# Returns ($comment, $params, $responded). $responded is true when an error +# response has already been rendered (the error helpers return 0, so their +# result can't be used for this). An unknown comment gives an undef $comment: +# the legacy methods returned an empty list for it. +sub _visible_comment { + my ($self, $function) = @_; + my $user = Bugzilla->user; + + my ($params, $error) = $self->_request_params; + if ($error) { + $self->user_error($error); + return (undef, undef, 1); + } + + my $comment_id = $params->{comment_id}; + if (!detaint_natural($comment_id)) { + $self->code_error('param_must_be_numeric', + {function => $function, param => 'comment_id'}); + return (undef, undef, 1); + } + my $comment = Bugzilla::Comment->new($comment_id) || return (undef, $params, 0); + + $comment->bug->check_is_visible(); + if ($comment->is_private && !$user->is_insider) { + $self->user_error('comment_is_private', {id => $comment_id}); + return (undef, undef, 1); + } + + return ($comment, $params, 0); +} + +# The legacy REST server answered this with the HTML "updates disabled" page, +# as ThrowErrorPage() only returns an error object in JSON-RPC mode. Return the +# JSON error an API client can handle, with the generic code user_error() uses +# for an error that has no code of its own. +sub _updates_disabled { + my ($self) = @_; + return $self->render( + json => { + error => 1, + code => ERROR_UNKNOWN_TRANSIENT, + message => 'Bug updates are currently disabled.', + documentation => 'https://bmo.readthedocs.io/en/latest/api/', + }, + status => REST_STATUS_CODE_MAP()->{_default} + ); +} + +sub _get_comment_reactions_with_users { + my ($self, $comment) = @_; + my $reactions_with_users = $comment->reactions_with_users(); + + foreach my $reaction (keys %$reactions_with_users) { + $reactions_with_users->{$reaction} + = [map { $self->_user_to_hash($_) } @{$reactions_with_users->{$reaction}}]; + } + + return $reactions_with_users; +} + +sub _user_to_hash { + my ($self, $user, $filters, $types, $prefix) = @_; + my $item = filter $filters, + { + id => $self->type('int', $user->id), + real_name => $self->type('string', $user->name), + nick => $self->type('string', $user->nick), + name => $self->type('email', $user->login), + email => $self->type('email', $user->email), + }, + $types, $prefix; + return $item; +} + +# Fetches the revision metadata (number of edits and the timestamp of the most +# recent edit) for a set of comments using a single query, and stashes it on +# each comment object for _translate_comment. +# +# The data is only exposed to users who are allowed to edit other people's +# comments; everyone else gets no edit_count/last_change_time keys at all. +# Revisions hidden by an edit-comments admin are only counted for admins. +# +# Comments that were already preloaded are skipped. +sub _preload_comment_edit_info { + my ($comments) = @_; + my $user = Bugzilla->user; + + # can_edit_comments is injected by the EditComments extension; without it + # there is no longdescs_activity table to query. + return unless $user->can('can_edit_comments') && $user->can_edit_comments; + + my @todo = grep { !exists $_->{edit_info} } @$comments; + return unless @todo; + + my $dbh = Bugzilla->dbh; + my @ids = map { $_->id } @todo; + + # Admins can see hidden revisions, so they are counted for them only. + my $hidden_clause = $user->is_edit_comments_admin ? '' : 'AND is_hidden = 0'; + + my $rows = $dbh->selectall_hashref( + 'SELECT comment_id, COUNT(*) AS edit_count, + MAX(change_when) AS last_change_time + FROM longdescs_activity + WHERE ' . $dbh->sql_in('comment_id', \@ids) . " $hidden_clause + GROUP BY comment_id", 'comment_id' + ); + + foreach my $comment (@todo) { + $comment->{edit_info} + = $rows->{$comment->id} || {edit_count => 0, last_change_time => undef}; + } +} + +# Comments that Bugzilla::Comment::collapsed flags (tagged with one of the +# 'collapsed_comment_tags' tags, or authored by treeherder) are hidden behind a +# click in the web UI, so they are left out of the API response too unless the +# caller asks for them with include_fields=_collapsed_comments. Like the other +# underscore-prefixed include_fields values, it does not imply _default, so +# callers that want the usual fields as well need +# include_fields=_default,_collapsed_comments. +sub _wants_collapsed_comments { + my ($params) = @_; + return grep { $_ eq '_collapsed_comments' } @{$params->{include_fields} || []}; +} + +sub _translate_comment { + my ($self, $comment, $filters, $types, $prefix) = @_; + my $attach_id = $comment->is_about_attachment ? $comment->extra_data : undef; + + my $comment_hash = { + id => $self->type('int', $comment->id), + bug_id => $self->type('int', $comment->bug_id), + creator => $self->type('email', $comment->author->login), + author => $self->type('email', $comment->author->login), + time => $self->type('dateTime', $comment->creation_ts), + creation_time => $self->type('dateTime', $comment->creation_ts), + is_private => $self->type('boolean', $comment->is_private), + text => $self->type('string', $comment->body_full), + raw_text => $self->type('string', $comment->body), + attachment_id => $self->type('int', $attach_id), + count => $self->type('int', $comment->count), + }; + + # Only set by _preload_comment_edit_info when the user may edit others' comments + if (my $edit_info = $comment->{edit_info}) { + $comment_hash->{edit_count} = $self->type('int', $edit_info->{edit_count}); + $comment_hash->{last_change_time} + = $self->type('dateTime', $edit_info->{last_change_time}); + } + + if (Bugzilla->params->{use_comment_reactions}) { + $comment_hash->{reactions} = {}; + + foreach my $key (keys %{$comment->reactions}) { + $comment_hash->{reactions}->{$key} + = $self->type('int', $comment->reactions->{$key}); + } + } + + # Don't load comment tags unless enabled + if (Bugzilla->params->{'comment_taggers_group'}) { + $comment_hash->{tags} = [map { $self->type('string', $_) } @{$comment->tags}]; + $comment_hash->{collapsed} = $self->type('boolean', $comment->collapsed); + } + + return filter($filters, $comment_hash, $types, $prefix); +} + +1; diff --git a/Bugzilla/WebService/Bug.pm b/Bugzilla/WebService/Bug.pm index 264d2bfed3..bcf3722652 100644 --- a/Bugzilla/WebService/Bug.pm +++ b/Bugzilla/WebService/Bug.pm @@ -14,7 +14,6 @@ use warnings; use base qw(Bugzilla::WebService); use Bugzilla::Comment; -use Bugzilla::Comment::TagWeights; use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Field; @@ -49,7 +48,6 @@ use constant PRODUCT_SPECIFIC_FIELDS => qw(version target_milestone component); sub DATE_FIELDS { my $fields = { - comments => ['new_since'], create => [], history => ['new_since'], search => ['last_change_time', 'creation_time'], @@ -72,7 +70,6 @@ use constant BASE64_FIELDS => {add_attachment => ['data'],}; use constant READ_ONLY => qw( attachments - comments fields get history @@ -82,23 +79,16 @@ use constant READ_ONLY => qw( use constant PUBLIC_METHODS => qw( add_attachment - add_comment attachments - comments create fields get history legal_values possible_duplicates - render_comment search - search_comment_tags update update_attachment - get_comment_reactions - update_comment_reactions - update_comment_tags update_see_also ); @@ -314,119 +304,8 @@ sub _legal_field_values { return \@result; } -sub comments { - my ($self, $params) = validate(@_, 'ids', 'comment_ids'); - - if (!(defined $params->{ids} || defined $params->{comment_ids})) { - ThrowCodeError('params_required', - {function => 'Bug.comments', params => ['ids', 'comment_ids']}); - } - - my $bug_ids = $params->{ids} || []; - my $comment_ids = $params->{comment_ids} || []; - my $skip_private = $params->{skip_private} ? 1 : 0; - - my $dbh = Bugzilla->switch_to_shadow_db(); - my $user = Bugzilla->user; - - unless (Bugzilla->user->id) { - Bugzilla->check_rate_limit("get_comments", remote_ip()); - } - - if ($skip_private) { - # Cache permissions for bugs. This highly reduces the number of calls to the DB. - # visible_bugs() is only able to handle bug IDs, so we have to skip aliases. - my @int = grep { $_ =~ /^\d+$/ } @$bug_ids; - $user->visible_bugs(\@int); - } - - my %bugs; - my @bug_comments; - foreach my $bug_id (@$bug_ids) { - my $bug; - - if ($skip_private) { - $bug = Bugzilla::Bug->new({id => $bug_id, cache => 1}); - next if $bug->error || !$user->can_see_bug($bug->id); - } else { - $bug = Bugzilla::Bug->check($bug_id); - } - - # We want the API to always return comments in the same order. - - my $comments - = $bug->comments({order => 'oldest_to_newest', after => $params->{new_since} - }); - push(@bug_comments, [$bug, $comments]); - } - - # Preload every requested bug's comments in one query, otherwise a bulk - # request would run one aggregation query per bug. - $self->_preload_comment_edit_info([map { @{$_->[1]} } @bug_comments]); - - my $want_collapsed = _wants_collapsed_comments($params); - - foreach my $bug_comment (@bug_comments) { - my ($bug, $comments) = @$bug_comment; - my @result; - foreach my $comment (@$comments) { - next if $comment->is_private && !$user->is_insider; - next if $comment->collapsed && !$want_collapsed; - push(@result, $self->_translate_comment($comment, $params)); - } - $bugs{$bug->id}{'comments'} = \@result; - } - - my %comments; - if (scalar @$comment_ids) { - my @ids = map { trim($_) } @$comment_ids; - my $comment_data = Bugzilla::Comment->new_from_list(\@ids); - - # See if we were passed any invalid comment ids. - my %got_ids = map { $_->id => 1 } @$comment_data; - foreach my $comment_id (@ids) { - if (!$got_ids{$comment_id}) { - ThrowUserError('comment_id_invalid', {id => $comment_id}); - } - } - - # Now make sure that we can see all the associated bugs. - my %got_bug_ids = map { $_->bug_id => 1 } @$comment_data; - Bugzilla::Bug->check($_) foreach (keys %got_bug_ids); - - $self->_preload_comment_edit_info($comment_data); - - foreach my $comment (@$comment_data) { - if ($comment->is_private && !$user->is_insider) { - ThrowUserError('comment_is_private', {id => $comment->id}); - } - $comments{$comment->id} = $self->_translate_comment($comment, $params); - } - } - - return {bugs => \%bugs, comments => \%comments}; -} - -sub render_comment { - my ($self, $params) = @_; - - unless (defined $params->{text}) { - ThrowCodeError('params_required', - {function => 'Bug.render_comment', params => ['text']}); - } - - Bugzilla->switch_to_shadow_db(); - my $bug = $params->{id} ? Bugzilla::Bug->check($params->{id}) : undef; - - my $html - = Bugzilla->params->{use_markdown} - ? Bugzilla->markdown->render_html($params->{text}, $bug) - : Bugzilla::Template::quoteUrls($params->{text}, $bug); - - return {html => $html}; -} - -# Helper for every method that returns comments. Fetches the revision metadata +# Helper for the comments field of get, search and possible_duplicates +# (Bugzilla::API::V1::BugComment has its own copy). Fetches the revision metadata # (number of edits and the timestamp of the most recent edit) for a set of # comments using a single query, and stashes it on each comment object for # _translate_comment. @@ -490,7 +369,8 @@ sub _wants_collapsed_comments { return grep { $_ eq '_collapsed_comments' } @{$params->{include_fields} || []}; } -# Helper for Bug.comments +# Helper for Bug.get (comments field). Bugzilla::API::V1::BugComment has its +# own copy until the rest of this module is migrated. sub _translate_comment { my ($self, $comment, $filters, $types, $prefix) = @_; my $attach_id = $comment->is_about_attachment ? $comment->extra_data : undef; @@ -1284,64 +1164,6 @@ sub update_attachment { return {attachments => \@result}; } -sub add_comment { - my ($self, $params) = @_; - - # BMO: Don't allow updating of bugs if disabled - if (Bugzilla->params->{disable_bug_updates}) { - ThrowErrorPage( - 'bug/process/updates-disabled.html.tmpl', - 'Bug updates are currently disabled.' - ); - } - - #The user must login in order add a comment - Bugzilla->login(LOGIN_REQUIRED); - - # Check parameters - defined $params->{id} || ThrowCodeError('param_required', {param => 'id'}); - my $comment = $params->{comment}; - (defined $comment && trim($comment) ne '') - || ThrowCodeError('param_required', {param => 'comment'}); - - my $bug = Bugzilla::Bug->check($params->{id}); - - Bugzilla->user->can_edit_product($bug->product_id) - || ThrowUserError("product_edit_denied", {product => $bug->product}); - - # Backwards-compatibility for versions before 3.6 - if (defined $params->{private}) { - $params->{is_private} = delete $params->{private}; - } - - # Append comment - $bug->add_comment( - $comment, - { - isprivate => $params->{is_private}, - work_time => $params->{work_time}, - is_markdown => ( - defined $params->{is_markdown} - ? ($params->{is_markdown} ? 1 : 0) - : (Bugzilla->params->{use_markdown} ? 1 : 0) - ) - } - ); - - # Add comment tags - $bug->set_all({comment_tags => $params->{comment_tags}}) - if defined $params->{comment_tags}; - - $bug->update(); - - my $new_comment_id = $bug->{added_comments}[0]->id; - - # Send mail. - Bugzilla::BugMail::Send($bug->bug_id, {changer => Bugzilla->user}); - - return {id => $self->type('int', $new_comment_id)}; -} - sub update_see_also { my ($self, $params) = @_; @@ -1479,154 +1301,6 @@ sub flag_types { return $flag_types; } -sub _get_comment_reactions_with_users { - my ($self, $comment) = @_; - my $reactions_with_users = $comment->reactions_with_users(); - - foreach my $reaction (keys %$reactions_with_users) { - $reactions_with_users->{$reaction} - = [map { $self->_user_to_hash($_) } @{$reactions_with_users->{$reaction}}]; - } - - return $reactions_with_users; -} - -sub get_comment_reactions { - if (!Bugzilla->params->{use_comment_reactions}) { - ThrowUserError('comment_reaction_disabled'); - } - - my ($self, $params) = @_; - my $user = Bugzilla->user; - my $comment_id = $params->{comment_id} // ThrowCodeError('param_required', - {function => 'Bug.get_comment_reactions', param => 'comment_id'}); - detaint_natural($comment_id) - || ThrowCodeError('param_must_be_numeric', - {function => 'Bug.get_comment_reactions', param => 'comment_id'}); - my $comment = Bugzilla::Comment->new($comment_id) || return []; - - $comment->bug->check_is_visible(); - if ($comment->is_private && !$user->is_insider) { - ThrowUserError('comment_is_private', {id => $comment_id}); - } - - return $self->_get_comment_reactions_with_users($comment); -} - -sub update_comment_reactions { - if (!Bugzilla->params->{use_comment_reactions}) { - ThrowUserError('comment_reaction_disabled'); - } - - my ($self, $params) = @_; - my $user = Bugzilla->login(LOGIN_REQUIRED); - my $comment_id = $params->{comment_id} // ThrowCodeError('param_required', - {function => 'Bug.update_comment_reactions', param => 'comment_id'}); - detaint_natural($comment_id) - || ThrowCodeError('param_must_be_numeric', - {function => 'Bug.update_comment_reactions', param => 'comment_id'}); - my $comment = Bugzilla::Comment->new($comment_id) || return []; - - $comment->bug->check_is_visible(); - if ($comment->is_private && !$user->is_insider) { - ThrowUserError('comment_is_private', {id => $comment_id}); - } - - if ($comment->bug->is_closed_for({months => 3})) { - ThrowUserError('comment_reaction_closed'); - } - - if ($comment->bug->restrict_comments - && !$user->in_group(Bugzilla->params->{'restrict_comments_group'})) - { - ThrowUserError('comment_reaction_restricted'); - } - - my $dbh = Bugzilla->dbh; - $dbh->bz_start_transaction(); - foreach my $reaction (@{$params->{add} || []}) { - $comment->add_reaction($reaction); - } - foreach my $reaction (@{$params->{remove} || []}) { - $comment->remove_reaction($reaction); - } - $comment->update(); - $dbh->bz_commit_transaction(); - - return $self->_get_comment_reactions_with_users($comment); -} - -sub update_comment_tags { - my ($self, $params) = @_; - - my $user = Bugzilla->login(LOGIN_REQUIRED); - Bugzilla->params->{'comment_taggers_group'} - || ThrowUserError("comment_tag_disabled"); - $user->can_tag_comments || ThrowUserError( - "auth_failure", - { - group => Bugzilla->params->{'comment_taggers_group'}, - action => "update", - object => "comment_tags" - } - ); - - my $comment_id = $params->{comment_id} // ThrowCodeError('param_required', - {function => 'Bug.update_comment_tags', param => 'comment_id'}); - detaint_natural($comment_id) - || ThrowCodeError('param_must_be_numeric', - {function => 'Bug.update_comment_tags', param => 'comment_id'}); - - my $comment = Bugzilla::Comment->new($comment_id) || return []; - $comment->bug->check_is_visible(); - if ($comment->is_private && !$user->is_insider) { - ThrowUserError('comment_is_private', {id => $comment_id}); - } - - my $dbh = Bugzilla->dbh; - $dbh->bz_start_transaction(); - foreach my $tag (@{$params->{add} || []}) { - $comment->add_tag($tag) if defined $tag; - } - foreach my $tag (@{$params->{remove} || []}) { - $comment->remove_tag($tag) if defined $tag; - } - $comment->update(); - $dbh->bz_commit_transaction(); - - return $comment->tags; -} - -sub search_comment_tags { - my ($self, $params) = @_; - - Bugzilla->login(LOGIN_REQUIRED); - Bugzilla->params->{'comment_taggers_group'} - || ThrowUserError("comment_tag_disabled"); - Bugzilla->user->can_tag_comments || ThrowUserError( - "auth_failure", - { - group => Bugzilla->params->{'comment_taggers_group'}, - action => "search", - object => "comment_tags" - } - ); - - my $query = $params->{query}; - $query // ThrowCodeError('param_required', {param => 'query'}); - my $limit = $params->{limit} || 7; - detaint_natural($limit) - || ThrowCodeError('param_must_be_numeric', - {param => 'limit', function => 'Bug.search_comment_tags'}); - - - my $tags - = Bugzilla::Comment::TagWeights->match({ - WHERE => {'tag LIKE ?' => "\%$query\%",}, LIMIT => $limit, - }); - return [map { $_->tag } @$tags]; -} - ############################## # Private Helper Subroutines # ############################## @@ -2749,229 +2423,6 @@ C. =back -=head2 comments - -B - -=over - -=item B - -This allows you to get data about comments, given a list of bugs -and/or comment ids. - -=item B - -To get all comments for a particular bug using the bug ID or alias: - -GET /bug//comment - -To get a specific comment based on the comment ID: - -GET /bug/comment/ - -The returned data format is the same as below. - -=item B - -B: At least one of C or C is required. - -In addition to the parameters below, this method also accepts the -standard L and -L arguments. - -=over - -=item C - -C An array that can contain both bug IDs and bug aliases. -All of the comments (that are visible to you) will be returned for the -specified bugs. - -=item C - -C An array of integer comment_ids. These comments will be -returned individually, separate from any other comments in their -respective bugs. - -=item C - -C If specified, the method will only return comments I -than this time. This only affects comments returned from the C -argument. You will always be returned all comments you request in the -C argument, even if they are older than this date. - -=item C<_collapsed_comments> - -Comments that are collapsed in the web UI -- those tagged with one of the tags -listed in the C parameter (C, C, etc), and -those authored by treeherder -- are left out of the comments returned for C -entirely. Passing -C<_collapsed_comments> in C includes them in the response. - -As with the other underscore-prefixed C values, it does not -imply C<_default>, so use C to get -the usual comment fields as well. - -Comments requested by C are always returned, collapsed or not. - -=item C B - -C Normally, if you request any inaccessible or invalid bug ids, this -method will throw an error. If this parameter is C, these bugs will just -be skipped. In the future, error objects will be returned instead, just like -C with the C parameter provided. - -=back - -=item B - -Two items are returned: - -=over - -=item C - -This is used for bugs specified in C. This is a hash, -where the keys are the numeric ids of the bugs, and the value is -a hash with a single key, C, which is an array of comments. -(The format of comments is described below.) - -Note that any individual bug will only be returned once, so if you -specify an id multiple times in C, it will still only be -returned once. - -=item C - -Each individual comment requested in C is returned here, -in a hash where the numeric comment id is the key, and the value -is the comment. (The format of comments is described below.) - -=back - -A "comment" as described above is a hash that contains the following -keys: - -=over - -=item id - -C The globally unique ID for the comment. - -=item bug_id - -C The ID of the bug that this comment is on. - -=item attachment_id - -C If the comment was made on an attachment, this will be the -ID of that attachment. Otherwise it will be null. - -=item text - -C The body of the comment, including any special text (such as -"this bug was marked as a duplicate of..."). - -=item raw_text - -C The body of the comment without any special additional text. - -=item creator - -C The login name of the comment's author. - -Also returned as C, for backwards-compatibility with older -Bugzillas. (However, this backwards-compatibility will go away in Bugzilla -5.0.) - -=item time - -C The time (in Bugzilla's timezone) that the comment was added. - -=item creation_time - -C This is exactly same as the C