# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://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::WebService::Bug; use 5.10.1; use strict; use warnings; use parent qw(Bugzilla::WebService); use Bugzilla::Comment; use Bugzilla::Comment::TagWeights; use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Field; use Bugzilla::WebService::Constants; use Bugzilla::WebService::Util qw(extract_flags filter filter_wants validate translate); use Bugzilla::Bug; use Bugzilla::BugMail; use Bugzilla::Util qw(trick_taint trim diff_arrays detaint_natural); use Bugzilla::Version; use Bugzilla::Milestone; use Bugzilla::Status; use Bugzilla::Token qw(issue_hash_token); use Bugzilla::Search; use Bugzilla::Product; use Bugzilla::FlagType; use Bugzilla::Search::Quicksearch; use List::Util qw(max); use List::MoreUtils qw(uniq); use Storable qw(dclone); ############# # Constants # ############# use constant PRODUCT_SPECIFIC_FIELDS => qw(version target_milestone component); use constant DATE_FIELDS => { comments => ['new_since'], history => ['new_since'], search => ['last_change_time', 'creation_time'], }; use constant BASE64_FIELDS => { add_attachment => ['data'], }; use constant READ_ONLY => qw( attachments comments fields get history legal_values search ); use constant ATTACHMENT_MAPPED_SETTERS => { file_name => 'filename', summary => 'description', }; use constant ATTACHMENT_MAPPED_RETURNS => { description => 'summary', ispatch => 'is_patch', isprivate => 'is_private', isobsolete => 'is_obsolete', filename => 'file_name', mimetype => 'content_type', }; ###################################################### # Add aliases here for old method name compatibility # ###################################################### BEGIN { # In 3.0, get was called get_bugs *get_bugs = \&get; # Before 3.4rc1, "history" was get_history. *get_history = \&history; } ########### # Methods # ########### sub fields { my ($self, $params) = validate(@_, 'ids', 'names'); Bugzilla->switch_to_shadow_db(); my @fields; if (defined $params->{ids}) { my $ids = $params->{ids}; foreach my $id (@$ids) { my $loop_field = Bugzilla::Field->check({ id => $id }); push(@fields, $loop_field); } } if (defined $params->{names}) { my $names = $params->{names}; foreach my $field_name (@$names) { my $loop_field = Bugzilla::Field->check($field_name); # Don't push in duplicate fields if we also asked for this field # in "ids". if (!grep($_->id == $loop_field->id, @fields)) { push(@fields, $loop_field); } } } if (!defined $params->{ids} and !defined $params->{names}) { @fields = @{ Bugzilla->fields({ obsolete => 0 }) }; } my @fields_out; foreach my $field (@fields) { my $visibility_field = $field->visibility_field ? $field->visibility_field->name : undef; my $vis_values = $field->visibility_values; my $value_field = $field->value_field ? $field->value_field->name : undef; my (@values, $has_values); if ( ($field->is_select and $field->name ne 'product') or grep($_ eq $field->name, PRODUCT_SPECIFIC_FIELDS) or $field->name eq 'keywords') { $has_values = 1; @values = @{ $self->_legal_field_values({ field => $field }) }; } if (grep($_ eq $field->name, PRODUCT_SPECIFIC_FIELDS)) { $value_field = 'product'; } my %field_data = ( id => $self->type('int', $field->id), type => $self->type('int', $field->type), is_custom => $self->type('boolean', $field->custom), name => $self->type('string', $field->name), display_name => $self->type('string', $field->description), is_mandatory => $self->type('boolean', $field->is_mandatory), is_on_bug_entry => $self->type('boolean', $field->enter_bug), visibility_field => $self->type('string', $visibility_field), visibility_values => [ map { $self->type('string', $_->name) } @$vis_values ], ); if ($has_values) { $field_data{value_field} = $self->type('string', $value_field); $field_data{values} = \@values; }; push(@fields_out, filter $params, \%field_data); } return { fields => \@fields_out }; } sub _legal_field_values { my ($self, $params) = @_; my $field = $params->{field}; my $field_name = $field->name; my $user = Bugzilla->user; my @result; if (grep($_ eq $field_name, PRODUCT_SPECIFIC_FIELDS)) { my @list; if ($field_name eq 'version') { @list = Bugzilla::Version->get_all; } elsif ($field_name eq 'component') { @list = Bugzilla::Component->get_all; } else { @list = Bugzilla::Milestone->get_all; } foreach my $value (@list) { my $sortkey = $field_name eq 'target_milestone' ? $value->sortkey : 0; # XXX This is very slow for large numbers of values. my $product_name = $value->product->name; if ($user->can_see_product($product_name)) { push(@result, { name => $self->type('string', $value->name), sort_key => $self->type('int', $sortkey), sortkey => $self->type('int', $sortkey), # deprecated visibility_values => [$self->type('string', $product_name)], is_active => $self->type('boolean', $value->is_active), }); } } } elsif ($field_name eq 'bug_status') { my @status_all = Bugzilla::Status->get_all; my $initial_status = bless({ id => 0, name => '', is_open => 1, sortkey => 0, can_change_to => Bugzilla::Status->can_change_to }, 'Bugzilla::Status'); unshift(@status_all, $initial_status); foreach my $status (@status_all) { my @can_change_to; foreach my $change_to (@{ $status->can_change_to }) { # There's no need to note that a status can transition # to itself. next if $change_to->id == $status->id; my %change_to_hash = ( name => $self->type('string', $change_to->name), comment_required => $self->type('boolean', $change_to->comment_required_on_change_from($status)), ); push(@can_change_to, \%change_to_hash); } push (@result, { name => $self->type('string', $status->name), is_open => $self->type('boolean', $status->is_open), sort_key => $self->type('int', $status->sortkey), sortkey => $self->type('int', $status->sortkey), # deprecated can_change_to => \@can_change_to, visibility_values => [], }); } } elsif ($field_name eq 'keywords') { my @legal_keywords = Bugzilla::Keyword->get_all; foreach my $value (@legal_keywords) { push (@result, { name => $self->type('string', $value->name), description => $self->type('string', $value->description), }); } } else { my @values = Bugzilla::Field::Choice->type($field)->get_all(); foreach my $value (@values) { my $vis_val = $value->visibility_value; push(@result, { name => $self->type('string', $value->name), sort_key => $self->type('int' , $value->sortkey), sortkey => $self->type('int' , $value->sortkey), # deprecated visibility_values => [ defined $vis_val ? $self->type('string', $vis_val->name) : () ], }); } } 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 $dbh = Bugzilla->switch_to_shadow_db(); my $user = Bugzilla->user; my %bugs; foreach my $bug_id (@$bug_ids) { my $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} }); my @result; foreach my $comment (@$comments) { next if $comment->is_private && !$user->is_insider; 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); 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 $tmpl = '[% text FILTER quoteUrls(bug) %]'; my $html; my $template = Bugzilla->template; $template->process( \$tmpl, { bug => $bug, text => $params->{text}}, \$html ); return { html => $html }; } # Helper for Bug.comments 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), 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), attachment_id => $self->type('int', $attach_id), count => $self->type('int', $comment->count), }; # Don't load comment tags unless enabled if (Bugzilla->params->{'comment_taggers_group'}) { $comment_hash->{tags} = [ map { $self->type('string', $_) } @{ $comment->tags } ]; } return filter($filters, $comment_hash, $types, $prefix); } sub get { my ($self, $params) = validate(@_, 'ids'); Bugzilla->switch_to_shadow_db() unless Bugzilla->user->id; my $ids = $params->{ids}; defined $ids || ThrowCodeError('param_required', { param => 'ids' }); my (@bugs, @faults, @hashes); # 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+$/ } @$ids; Bugzilla->user->visible_bugs(\@int); foreach my $bug_id (@$ids) { my $bug; if ($params->{permissive}) { eval { $bug = Bugzilla::Bug->check($bug_id); }; if ($@) { push(@faults, {id => $bug_id, faultString => $@->faultstring, faultCode => $@->faultcode, } ); undef $@; next; } } else { $bug = Bugzilla::Bug->check($bug_id); } push(@bugs, $bug); push(@hashes, $self->_bug_to_hash($bug, $params)); } # Set the ETag before inserting the update tokens # since the tokens will always be unique even if # the data has not changed. $self->bz_etag(\@hashes); $self->_add_update_tokens($params, \@bugs, \@hashes); return { bugs => \@hashes, faults => \@faults }; } # this is a function that gets bug activity for list of bug ids # it can be called as the following: # $call = $rpc->call( 'Bug.history', { ids => [1,2] }); sub history { my ($self, $params) = validate(@_, 'ids'); Bugzilla->switch_to_shadow_db(); my $ids = $params->{ids}; defined $ids || ThrowCodeError('param_required', { param => 'ids' }); my %api_name = reverse %{ Bugzilla::Bug::FIELD_MAP() }; $api_name{'bug_group'} = 'groups'; my @return; foreach my $bug_id (@$ids) { my %item; my $bug = Bugzilla::Bug->check($bug_id); $bug_id = $bug->id; $item{id} = $self->type('int', $bug_id); my ($activity) = $bug->get_activity(undef, $params->{new_since}); my @history; foreach my $changeset (@$activity) { my %bug_history; $bug_history{when} = $self->type('dateTime', $changeset->{when}); $bug_history{who} = $self->type('string', $changeset->{who}); $bug_history{changes} = []; foreach my $change (@{ $changeset->{changes} }) { my $api_field = $api_name{$change->{fieldname}} || $change->{fieldname}; my $attach_id = delete $change->{attachid}; if ($attach_id) { $change->{attachment_id} = $self->type('int', $attach_id); } $change->{removed} = $self->type('string', $change->{removed}); $change->{added} = $self->type('string', $change->{added}); $change->{field_name} = $self->type('string', $api_field); delete $change->{fieldname}; push (@{$bug_history{changes}}, $change); } push (@history, \%bug_history); } $item{history} = \@history; # alias is returned in case users passes a mixture of ids and aliases # then they get to know which bug activity relates to which value # they passed $item{alias} = [ map { $self->type('string', $_) } @{ $bug->alias } ]; push(@return, \%item); } return { bugs => \@return }; } sub search { my ($self, $params) = @_; my $user = Bugzilla->user; my $dbh = Bugzilla->dbh; Bugzilla->switch_to_shadow_db(); my $match_params = dclone($params); delete $match_params->{include_fields}; delete $match_params->{exclude_fields}; # Determine whether this is a quicksearch query if (exists $match_params->{quicksearch}) { my $quicksearch = quicksearch($match_params->{'quicksearch'}); my $cgi = Bugzilla::CGI->new($quicksearch); $match_params = $cgi->Vars; } if ( defined($match_params->{offset}) and !defined($match_params->{limit}) ) { ThrowCodeError('param_required', { param => 'limit', function => 'Bug.search()' }); } my $max_results = Bugzilla->params->{max_search_results}; unless (defined $match_params->{limit} && $match_params->{limit} == 0) { if (!defined $match_params->{limit} || $match_params->{limit} > $max_results) { $match_params->{limit} = $max_results; } } else { delete $match_params->{limit}; delete $match_params->{offset}; } $match_params = Bugzilla::Bug::map_fields($match_params); my %options = ( fields => ['bug_id'] ); # Find the highest custom field id my @field_ids = grep(/^f(\d+)$/, keys %$match_params); my $last_field_id = @field_ids ? max @field_ids + 1 : 1; # Do special search types for certain fields. if (my $change_when = delete $match_params->{'delta_ts'}) { $match_params->{"f${last_field_id}"} = 'delta_ts'; $match_params->{"o${last_field_id}"} = 'greaterthaneq'; $match_params->{"v${last_field_id}"} = $change_when; $last_field_id++; } if (my $creation_when = delete $match_params->{'creation_ts'}) { $match_params->{"f${last_field_id}"} = 'creation_ts'; $match_params->{"o${last_field_id}"} = 'greaterthaneq'; $match_params->{"v${last_field_id}"} = $creation_when; $last_field_id++; } # Some fields require a search type such as short desc, keywords, etc. foreach my $param (qw(short_desc longdesc status_whiteboard bug_file_loc)) { if (defined $match_params->{$param} && !defined $match_params->{$param . '_type'}) { $match_params->{$param . '_type'} = 'allwordssubstr'; } } if (defined $match_params->{'keywords'} && !defined $match_params->{'keywords_type'}) { $match_params->{'keywords_type'} = 'allwords'; } # Backwards compatibility with old method regarding role search $match_params->{'reporter'} = delete $match_params->{'creator'} if $match_params->{'creator'}; foreach my $role (qw(assigned_to reporter qa_contact longdesc cc)) { next if !exists $match_params->{$role}; my $value = delete $match_params->{$role}; $match_params->{"f${last_field_id}"} = $role; $match_params->{"o${last_field_id}"} = "anywordssubstr"; $match_params->{"v${last_field_id}"} = ref $value ? join(" ", @{$value}) : $value; $last_field_id++; } # If no other parameters have been passed other than limit and offset # then we throw error if system is configured to do so. if (!grep(!/^(limit|offset)$/, keys %$match_params) && !Bugzilla->params->{search_allow_no_criteria}) { ThrowUserError('buglist_parameters_required'); } $options{order} = [ split(/\s*,\s*/, delete $match_params->{order}) ] if $match_params->{order}; $options{params} = $match_params; my $search = new Bugzilla::Search(%options); my ($data) = $search->data; if (!scalar @$data) { return { bugs => [] }; } # Search.pm won't return bugs that the user shouldn't see so no filtering is needed. my @bug_ids = map { $_->[0] } @$data; my %bug_objects = map { $_->id => $_ } @{ Bugzilla::Bug->new_from_list(\@bug_ids) }; my @bugs = map { $bug_objects{$_} } @bug_ids; @bugs = map { $self->_bug_to_hash($_, $params) } @bugs; return { bugs => \@bugs }; } sub possible_duplicates { my ($self, $params) = validate(@_, 'products'); my $user = Bugzilla->user; Bugzilla->switch_to_shadow_db(); # Undo the array-ification that validate() does, for "summary". $params->{summary} || ThrowCodeError('param_required', { function => 'Bug.possible_duplicates', param => 'summary' }); my @products; foreach my $name (@{ $params->{'products'} || [] }) { my $object = $user->can_enter_product($name, THROW_ERROR); push(@products, $object); } my $possible_dupes = Bugzilla::Bug->possible_duplicates( { summary => $params->{summary}, products => \@products, limit => $params->{limit} }); my @hashes = map { $self->_bug_to_hash($_, $params) } @$possible_dupes; $self->_add_update_tokens($params, $possible_dupes, \@hashes); return { bugs => \@hashes }; } sub update { my ($self, $params) = validate(@_, 'ids'); my $user = Bugzilla->login(LOGIN_REQUIRED); my $dbh = Bugzilla->dbh; # We skip certain fields because their set_ methods actually use # the external names instead of the internal names. $params = Bugzilla::Bug::map_fields($params, { summary => 1, platform => 1, severity => 1, url => 1 }); my $ids = delete $params->{ids}; defined $ids || ThrowCodeError('param_required', { param => 'ids' }); my @bugs = map { Bugzilla::Bug->check_for_edit($_) } @$ids; my %values = %$params; $values{other_bugs} = \@bugs; if (exists $values{comment} and exists $values{comment}{comment}) { $values{comment}{body} = delete $values{comment}{comment}; } # Prevent bugs that could be triggered by specifying fields that # have valid "set_" functions in Bugzilla::Bug, but shouldn't be # called using those field names. delete $values{dependencies}; # For backwards compatibility, treat alias string or array as a set action if (exists $values{alias}) { if (not ref $values{alias}) { $values{alias} = { set => [ $values{alias} ] }; } elsif (ref $values{alias} eq 'ARRAY') { $values{alias} = { set => $values{alias} }; } } my $flags = delete $values{flags}; foreach my $bug (@bugs) { $bug->set_all(\%values); if ($flags) { my ($old_flags, $new_flags) = extract_flags($flags, $bug); $bug->set_flags($old_flags, $new_flags); } } my %all_changes; $dbh->bz_start_transaction(); foreach my $bug (@bugs) { $all_changes{$bug->id} = $bug->update(); } $dbh->bz_commit_transaction(); foreach my $bug (@bugs) { $bug->send_changes($all_changes{$bug->id}); } my %api_name = reverse %{ Bugzilla::Bug::FIELD_MAP() }; # This doesn't normally belong in FIELD_MAP, but we do want to translate # "bug_group" back into "groups". $api_name{'bug_group'} = 'groups'; my @result; foreach my $bug (@bugs) { my %hash = ( id => $self->type('int', $bug->id), last_change_time => $self->type('dateTime', $bug->delta_ts), changes => {}, ); # alias is returned in case users pass a mixture of ids and aliases, # so that they can know which set of changes relates to which value # they passed. $hash{alias} = [ map { $self->type('string', $_) } @{ $bug->alias } ]; my %changes = %{ $all_changes{$bug->id} }; foreach my $field (keys %changes) { my $change = $changes{$field}; my $api_field = $api_name{$field} || $field; # We normalize undef to an empty string, so that the API # stays consistent for things like Deadline that can become # empty. $change->[0] = '' if !defined $change->[0]; $change->[1] = '' if !defined $change->[1]; $hash{changes}->{$api_field} = { removed => $self->type('string', $change->[0]), added => $self->type('string', $change->[1]) }; } push(@result, \%hash); } return { bugs => \@result }; } sub create { my ($self, $params) = @_; my $dbh = Bugzilla->dbh; Bugzilla->login(LOGIN_REQUIRED); $params = Bugzilla::Bug::map_fields($params); my $flags = delete $params->{flags}; # We start a nested transaction in case flag setting fails # we want the bug creation to roll back as well. $dbh->bz_start_transaction(); my $bug = Bugzilla::Bug->create($params); # Set bug flags if ($flags) { my ($flags, $new_flags) = extract_flags($flags, $bug); $bug->set_flags($flags, $new_flags); $bug->update($bug->creation_ts); } $dbh->bz_commit_transaction(); $bug->send_changes(); return { id => $self->type('int', $bug->bug_id) }; } sub legal_values { my ($self, $params) = @_; Bugzilla->switch_to_shadow_db(); defined $params->{field} or ThrowCodeError('param_required', { param => 'field' }); my $field = Bugzilla::Bug::FIELD_MAP->{$params->{field}} || $params->{field}; my @global_selects = @{ Bugzilla->fields({ is_select => 1, is_abnormal => 0 }) }; my $values; if (grep($_->name eq $field, @global_selects)) { # The field is a valid one. trick_taint($field); $values = get_legal_field_values($field); } elsif (grep($_ eq $field, PRODUCT_SPECIFIC_FIELDS)) { my $id = $params->{product_id}; defined $id || ThrowCodeError('param_required', { function => 'Bug.legal_values', param => 'product_id' }); grep($_->id eq $id, @{Bugzilla->user->get_accessible_products}) || ThrowUserError('product_access_denied', { id => $id }); my $product = new Bugzilla::Product($id); my @objects; if ($field eq 'version') { @objects = @{$product->versions}; } elsif ($field eq 'target_milestone') { @objects = @{$product->milestones}; } elsif ($field eq 'component') { @objects = @{$product->components}; } $values = [map { $_->name } @objects]; } else { ThrowCodeError('invalid_field_name', { field => $params->{field} }); } my @result; foreach my $val (@$values) { push(@result, $self->type('string', $val)); } return { values => \@result }; } sub add_attachment { my ($self, $params) = validate(@_, 'ids'); my $dbh = Bugzilla->dbh; Bugzilla->login(LOGIN_REQUIRED); defined $params->{ids} || ThrowCodeError('param_required', { param => 'ids' }); defined $params->{data} || ThrowCodeError('param_required', { param => 'data' }); my @bugs = map { Bugzilla::Bug->check_for_edit($_) } @{ $params->{ids} }; my @created; $dbh->bz_start_transaction(); my $timestamp = $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); my $flags = delete $params->{flags}; foreach my $bug (@bugs) { my $attachment = Bugzilla::Attachment->create({ bug => $bug, creation_ts => $timestamp, data => $params->{data}, description => $params->{summary}, filename => $params->{file_name}, mimetype => $params->{content_type}, ispatch => $params->{is_patch}, isprivate => $params->{is_private}, }); if ($flags) { my ($old_flags, $new_flags) = extract_flags($flags, $bug, $attachment); $attachment->set_flags($old_flags, $new_flags); } $attachment->update($timestamp); my $comment = $params->{comment} || ''; $attachment->bug->add_comment($comment, { isprivate => $attachment->isprivate, type => CMT_ATTACHMENT_CREATED, extra_data => $attachment->id }); push(@created, $attachment); } $_->bug->update($timestamp) foreach @created; $dbh->bz_commit_transaction(); $_->send_changes() foreach @bugs; my @created_ids = map { $_->id } @created; return { ids => \@created_ids }; } sub update_attachment { my ($self, $params) = validate(@_, 'ids'); my $user = Bugzilla->login(LOGIN_REQUIRED); my $dbh = Bugzilla->dbh; my $ids = delete $params->{ids}; defined $ids || ThrowCodeError('param_required', { param => 'ids' }); # Some fields cannot be sent to set_all foreach my $key (qw(login password token)) { delete $params->{$key}; } $params = translate($params, ATTACHMENT_MAPPED_SETTERS); # Get all the attachments, after verifying that they exist and are editable my @attachments = (); my %bugs = (); foreach my $id (@$ids) { my $attachment = Bugzilla::Attachment->new($id) || ThrowUserError("invalid_attach_id", { attach_id => $id }); my $bug = $attachment->bug; $attachment->_check_bug; $attachment->validate_can_edit || ThrowUserError("illegal_attachment_edit", { attach_id => $id }); push @attachments, $attachment; $bugs{$bug->id} = $bug; } my $flags = delete $params->{flags}; my $comment = delete $params->{comment}; # Update the values foreach my $attachment (@attachments) { $attachment->set_all($params); if ($flags) { my ($old_flags, $new_flags) = extract_flags($flags, $attachment->bug, $attachment); $attachment->set_flags($old_flags, $new_flags); } } $dbh->bz_start_transaction(); # Do the actual update and get information to return to user my @result; foreach my $attachment (@attachments) { my $changes = $attachment->update(); if ($comment = trim($comment)) { $attachment->bug->add_comment($comment, { isprivate => $attachment->isprivate, type => CMT_ATTACHMENT_UPDATED, extra_data => $attachment->id }); } $changes = translate($changes, ATTACHMENT_MAPPED_RETURNS); my %hash = ( id => $self->type('int', $attachment->id), last_change_time => $self->type('dateTime', $attachment->modification_time), changes => {}, ); foreach my $field (keys %$changes) { my $change = $changes->{$field}; # We normalize undef to an empty string, so that the API # stays consistent for things like Deadline that can become # empty. $hash{changes}->{$field} = { removed => $self->type('string', $change->[0] // ''), added => $self->type('string', $change->[1] // '') }; } push(@result, \%hash); } $dbh->bz_commit_transaction(); # Email users about the change foreach my $bug (values %bugs) { $bug->update(); $bug->send_changes(); } # Return the information to the user return { attachments => \@result }; } sub add_comment { my ($self, $params) = @_; # The user must login in order add a comment my $user = 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_for_edit($params->{id}); # 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} }); $bug->update(); my $new_comment_id = $bug->{added_comments}[0]->id; # Send mail. Bugzilla::BugMail::Send($bug->bug_id, { changer => $user }); return { id => $self->type('int', $new_comment_id) }; } sub update_see_also { my ($self, $params) = @_; my $user = Bugzilla->login(LOGIN_REQUIRED); # Check parameters $params->{ids} || ThrowCodeError('param_required', { param => 'id' }); my ($add, $remove) = @$params{qw(add remove)}; ($add || $remove) or ThrowCodeError('params_required', { params => ['add', 'remove'] }); my @bugs; foreach my $id (@{ $params->{ids} }) { my $bug = Bugzilla::Bug->check_for_edit($id); push(@bugs, $bug); if ($remove) { $bug->remove_see_also($_) foreach @$remove; } if ($add) { $bug->add_see_also($_) foreach @$add; } } my %changes; foreach my $bug (@bugs) { my $change = $bug->update(); if (my $see_also = $change->{see_also}) { $changes{$bug->id}->{see_also} = { removed => [split(', ', $see_also->[0])], added => [split(', ', $see_also->[1])], }; } else { # We still want a changes entry, for API consistency. $changes{$bug->id}->{see_also} = { added => [], removed => [] }; } Bugzilla::BugMail::Send($bug->id, { changer => $user }); } return { changes => \%changes }; } sub attachments { my ($self, $params) = validate(@_, 'ids', 'attachment_ids'); Bugzilla->switch_to_shadow_db() unless Bugzilla->user->id; if (!(defined $params->{ids} or defined $params->{attachment_ids})) { ThrowCodeError('param_required', { function => 'Bug.attachments', params => ['ids', 'attachment_ids'] }); } my $ids = $params->{ids} || []; my $attach_ids = $params->{attachment_ids} || []; my %bugs; foreach my $bug_id (@$ids) { my $bug = Bugzilla::Bug->check($bug_id); $bugs{$bug->id} = []; foreach my $attach (@{$bug->attachments}) { push @{$bugs{$bug->id}}, $self->_attachment_to_hash($attach, $params); } } my %attachments; foreach my $attach (@{Bugzilla::Attachment->new_from_list($attach_ids)}) { Bugzilla::Bug->check($attach->bug_id); if ($attach->isprivate && !Bugzilla->user->is_insider) { ThrowUserError('auth_failure', {action => 'access', object => 'attachment', attach_id => $attach->id}); } $attachments{$attach->id} = $self->_attachment_to_hash($attach, $params); } return { bugs => \%bugs, attachments => \%attachments }; } sub update_tags { my ($self, $params) = @_; Bugzilla->login(LOGIN_REQUIRED); my $ids = $params->{ids}; my $tags = $params->{tags}; ThrowCodeError('param_required', { function => 'Bug.update_tags', param => 'ids' }) if !defined $ids; ThrowCodeError('param_required', { function => 'Bug.update_tags', param => 'tags' }) if !defined $tags; my %changes; foreach my $bug_id (@$ids) { my $bug = Bugzilla::Bug->check($bug_id); my @old_tags = @{ $bug->tags }; $bug->remove_tag($_) foreach @{ $tags->{remove} || [] }; $bug->add_tag($_) foreach @{ $tags->{add} || [] }; my ($removed, $added) = diff_arrays(\@old_tags, $bug->tags); my @removed = map { $self->type('string', $_) } @$removed; my @added = map { $self->type('string', $_) } @$added; $changes{$bug->id}->{tags} = { removed => \@removed, added => \@added }; } return { changes => \%changes }; } 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' }); 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 # ############################## # A helper for get() and search(). This is done in this fashion in order # to produce a stable API and to explicitly type return values. # The internals of Bugzilla::Bug are not stable enough to just # return them directly. sub _bug_to_hash { my ($self, $bug, $params) = @_; # All the basic bug attributes are here, in alphabetical order. # A bug attribute is "basic" if it doesn't require an additional # database call to get the info. my %item = %{ filter $params, { # No need to format $bug->deadline specially, because Bugzilla::Bug # already does it for us. deadline => $self->type('string', $bug->deadline), id => $self->type('int', $bug->bug_id), is_confirmed => $self->type('boolean', $bug->everconfirmed), op_sys => $self->type('string', $bug->op_sys), platform => $self->type('string', $bug->rep_platform), priority => $self->type('string', $bug->priority), resolution => $self->type('string', $bug->resolution), severity => $self->type('string', $bug->bug_severity), status => $self->type('string', $bug->bug_status), summary => $self->type('string', $bug->short_desc), target_milestone => $self->type('string', $bug->target_milestone), url => $self->type('string', $bug->bug_file_loc), version => $self->type('string', $bug->version), whiteboard => $self->type('string', $bug->status_whiteboard), } }; # First we handle any fields that require extra work (such as date parsing # or SQL calls). if (filter_wants $params, 'alias') { $item{alias} = [ map { $self->type('string', $_) } @{ $bug->alias } ]; } if (filter_wants $params, 'assigned_to') { $item{'assigned_to'} = $self->type('email', $bug->assigned_to->login); $item{'assigned_to_detail'} = $self->_user_to_hash($bug->assigned_to, $params, undef, 'assigned_to'); } if (filter_wants $params, 'blocks') { my @blocks = map { $self->type('int', $_) } @{ $bug->blocked }; $item{'blocks'} = \@blocks; } if (filter_wants $params, 'classification') { $item{classification} = $self->type('string', $bug->classification); } if (filter_wants $params, 'component') { $item{component} = $self->type('string', $bug->component); } if (filter_wants $params, 'cc') { my @cc = map { $self->type('email', $_) } @{ $bug->cc }; $item{'cc'} = \@cc; $item{'cc_detail'} = [ map { $self->_user_to_hash($_, $params, undef, 'cc') } @{ $bug->cc_users } ]; } if (filter_wants $params, 'creation_time') { $item{'creation_time'} = $self->type('dateTime', $bug->creation_ts); } if (filter_wants $params, 'creator') { $item{'creator'} = $self->type('email', $bug->reporter->login); $item{'creator_detail'} = $self->_user_to_hash($bug->reporter, $params, undef, 'creator'); } if (filter_wants $params, 'depends_on') { my @depends_on = map { $self->type('int', $_) } @{ $bug->dependson }; $item{'depends_on'} = \@depends_on; } if (filter_wants $params, 'dupe_of') { $item{'dupe_of'} = $self->type('int', $bug->dup_id); } if (filter_wants $params, 'groups') { my @groups = map { $self->type('string', $_->name) } @{ $bug->groups_in }; $item{'groups'} = \@groups; } if (filter_wants $params, 'is_open') { $item{'is_open'} = $self->type('boolean', $bug->status->is_open); } if (filter_wants $params, 'keywords') { my @keywords = map { $self->type('string', $_->name) } @{ $bug->keyword_objects }; $item{'keywords'} = \@keywords; } if (filter_wants $params, 'last_change_time') { $item{'last_change_time'} = $self->type('dateTime', $bug->delta_ts); } if (filter_wants $params, 'product') { $item{product} = $self->type('string', $bug->product); } if (filter_wants $params, 'qa_contact') { my $qa_login = $bug->qa_contact ? $bug->qa_contact->login : ''; $item{'qa_contact'} = $self->type('email', $qa_login); if ($bug->qa_contact) { $item{'qa_contact_detail'} = $self->_user_to_hash($bug->qa_contact, $params, undef, 'qa_contact'); } } if (filter_wants $params, 'see_also') { my @see_also = map { $self->type('string', $_->name) } @{ $bug->see_also }; $item{'see_also'} = \@see_also; } if (filter_wants $params, 'flags') { $item{'flags'} = [ map { $self->_flag_to_hash($_) } @{$bug->flags} ]; } if (filter_wants $params, 'tags', 'extra') { $item{'tags'} = $bug->tags; } # And now custom fields my @custom_fields = Bugzilla->active_custom_fields; foreach my $field (@custom_fields) { my $name = $field->name; next if !filter_wants($params, $name, ['default', 'custom']); if ($field->type == FIELD_TYPE_BUG_ID) { $item{$name} = $self->type('int', $bug->$name); } elsif ($field->type == FIELD_TYPE_DATETIME || $field->type == FIELD_TYPE_DATE) { $item{$name} = $self->type('dateTime', $bug->$name); } elsif ($field->type == FIELD_TYPE_MULTI_SELECT) { my @values = map { $self->type('string', $_) } @{ $bug->$name }; $item{$name} = \@values; } else { $item{$name} = $self->type('string', $bug->$name); } } # Timetracking fields are only sent if the user can see them. if (Bugzilla->user->is_timetracker) { if (filter_wants $params, 'estimated_time') { $item{'estimated_time'} = $self->type('double', $bug->estimated_time); } if (filter_wants $params, 'remaining_time') { $item{'remaining_time'} = $self->type('double', $bug->remaining_time); } if (filter_wants $params, 'actual_time') { $item{'actual_time'} = $self->type('double', $bug->actual_time); } } # The "accessible" bits go here because they have long names and it # makes the code look nicer to separate them out. if (filter_wants $params, 'is_cc_accessible') { $item{'is_cc_accessible'} = $self->type('boolean', $bug->cclist_accessible); } if (filter_wants $params, 'is_creator_accessible') { $item{'is_creator_accessible'} = $self->type('boolean', $bug->reporter_accessible); } return \%item; } 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), name => $self->type('email', $user->login), email => $self->type('email', $user->email), }, $types, $prefix; return $item; } sub _attachment_to_hash { my ($self, $attach, $filters, $types, $prefix) = @_; my $item = filter $filters, { creation_time => $self->type('dateTime', $attach->attached), last_change_time => $self->type('dateTime', $attach->modification_time), id => $self->type('int', $attach->id), bug_id => $self->type('int', $attach->bug_id), file_name => $self->type('string', $attach->filename), summary => $self->type('string', $attach->description), content_type => $self->type('string', $attach->contenttype), is_private => $self->type('int', $attach->isprivate), is_obsolete => $self->type('int', $attach->isobsolete), is_patch => $self->type('int', $attach->ispatch), }, $types, $prefix; # creator requires an extra lookup, so we only send them if # the filter wants them. if (filter_wants $filters, 'creator', $types, $prefix) { $item->{'creator'} = $self->type('email', $attach->attacher->login); } if (filter_wants $filters, 'data', $types, $prefix) { $item->{'data'} = $self->type('base64', $attach->data); } if (filter_wants $filters, 'size', $types, $prefix) { $item->{'size'} = $self->type('int', $attach->datasize); } if (filter_wants $filters, 'flags', $types, $prefix) { $item->{'flags'} = [ map { $self->_flag_to_hash($_) } @{$attach->flags} ]; } return $item; } sub _flag_to_hash { my ($self, $flag) = @_; my $item = { id => $self->type('int', $flag->id), name => $self->type('string', $flag->name), type_id => $self->type('int', $flag->type_id), creation_date => $self->type('dateTime', $flag->creation_date), modification_date => $self->type('dateTime', $flag->modification_date), status => $self->type('string', $flag->status) }; foreach my $field (qw(setter requestee)) { my $field_id = $field . "_id"; $item->{$field} = $self->type('email', $flag->$field->login) if $flag->$field_id; } return $item; } sub _add_update_tokens { my ($self, $params, $bugs, $hashes) = @_; return if !Bugzilla->user->id; return if !filter_wants($params, 'update_token'); for(my $i = 0; $i < @$bugs; $i++) { my $token = issue_hash_token([$bugs->[$i]->id, $bugs->[$i]->delta_ts]); $hashes->[$i]->{'update_token'} = $self->type('string', $token); } } 1; __END__ =head1 NAME Bugzilla::Webservice::Bug - The API for creating, changing, and getting the details of bugs. =head1 DESCRIPTION This part of the Bugzilla API allows you to file a new bug in Bugzilla, or get information about bugs that have already been filed. =head1 METHODS See L for a description of how parameters are passed, and what B, B, and B mean. Although the data input and output is the same for JSONRPC, XMLRPC and REST, the directions for how to access the data via REST is noted in each method where applicable. =head1 Utility Functions =head2 fields B =over =item B Get information about valid bug fields, including the lists of legal values for each field. =item B You have several options for retreiving information about fields. The first part is the request method and the rest is the related path needed. To get information about all fields: GET /rest/field/bug To get information related to a single field: GET /rest/field/bug/ The returned data format is the same as below. =item B You can pass either field ids or field names. B: If neither C nor C is specified, then all non-obsolete fields will be returned. In addition to the parameters below, this method also accepts the standard L and L arguments. =over =item C (array) - An array of integer field ids. =item C (array) - An array of strings representing field names. =back =item B A hash containing a single element, C. This is an array of hashes, containing the following keys: =over =item C C An integer id uniquely identifying this field in this installation only. =item C C The number of the fieldtype. The following values are defined: =over =item C<0> Unknown =item C<1> Free Text =item C<2> Drop Down =item C<3> Multiple-Selection Box =item C<4> Large Text Box =item C<5> Date/Time =item C<6> Bug Id =item C<7> Bug URLs ("See Also") =item C<8> Keywords =item C<9> Date =item C<10> Integer value =back =item C C True when this is a custom field, false otherwise. =item C C The internal name of this field. This is a unique identifier for this field. If this is not a custom field, then this name will be the same across all Bugzilla installations. =item C C The name of the field, as it is shown in the user interface. =item C C True if the field must have a value when filing new bugs. Also, mandatory fields cannot have their value cleared when updating bugs. =item C C For custom fields, this is true if the field is shown when you enter a new bug. For standard fields, this is currently always false, even if the field shows up when entering a bug. (To know whether or not a standard field is valid on bug entry, see L.) =item C C The name of a field that controls the visibility of this field in the user interface. This field only appears in the user interface when the named field is equal to one of the values in C. Can be null. =item C C of Cs This field is only shown when C matches one of these values. When C is null, then this is an empty array. =item C C The name of the field that controls whether or not particular values of the field are shown in the user interface. Can be null. =item C This is an array of hashes, representing the legal values for select-type (drop-down and multiple-selection) fields. This is also populated for the C, C, C, and C fields, but not for the C field (you must use L for that. For fields that aren't select-type fields, this will simply be an empty array. Each hash has the following keys: =over =item C C The actual value--this is what you would specify for this field in L, etc. =item C C Values, when displayed in a list, are sorted first by this integer and then secondly by their name. =item C B - Use C instead. =item C If C is defined for this field, then this value is only shown if the C is set to one of the values listed in this array. Note that for per-product fields, C is set to C<'product'> and C will reflect which product(s) this value appears in. =item C C This value is defined only for certain product specific fields such as version, target_milestone or component. When true, the value is active, otherwise the value is not active. =item C C The description of the value. This item is only included for the C field. =item C C For C values, determines whether this status specifies that the bug is "open" (true) or "closed" (false). This item is only included for the C field. =item C For C values, this is an array of hashes that determines which statuses you can transition to from this status. (This item is only included for the C field.) Each hash contains the following items: =over =item C the name of the new status =item C this C True if a comment is required when you change a bug into this status using this transition. =back =back =back =item B =over =item 51 (Invalid Field Name or Id) You specified an invalid field name or id. =back =item B =over =item Added in Bugzilla B<3.6>. =item The C return value was added in Bugzilla B<4.0>. =item C was renamed to C in Bugzilla B<4.2>. =item C return key for C was added in Bugzilla B<4.4>. =item REST API call added in Bugzilla B<5.0> =back =back =head2 legal_values B - Use L instead. =over =item B Tells you what values are allowed for a particular field. =item B To get information on the values for a field based on field name: GET /rest/field/bug//values To get information based on field name and a specific product: GET /rest/field/bug///values The returned data format is the same as below. =item B =over =item C - The name of the field you want information about. This should be the same as the name you would use in L, below. =item C - If you're picking a product-specific field, you have to specify the id of the product you want the values for. =back =item B C - An array of strings: the legal values for this field. The values will be sorted as they normally would be in Bugzilla. =item B =over =item 106 (Invalid Product) You were required to specify a product, and either you didn't, or you specified an invalid product (or a product that you can't access). =item 108 (Invalid Field Name) You specified a field that doesn't exist or isn't a drop-down field. =back =item B =over =item REST API call added in Bugzilla B<5.0>. =back =back =head1 Bug Information =head2 attachments B =over =item B It allows you to get data about attachments, given a list of bugs and/or attachment ids. B: Private attachments will only be returned if you are in the insidergroup or if you are the submitter of the attachment. =item B To get all current attachments for a bug: GET /rest/bug//attachment To get a specific attachment based on attachment ID: GET /rest/bug/attachment/ The returned data format is the same as below. =item B B: At least one of C or C is required. =over =item C See the description of the C parameter in the L method. =item C C An array of integer attachment ids. =back Also accepts the L, and L arguments. =item B A hash containing two elements: C and C. The return value looks like this: { bugs => { 1345 => [ { (attachment) }, { (attachment) } ], 9874 => [ { (attachment) }, { (attachment) } ], }, attachments => { 234 => { (attachment) }, 123 => { (attachment) }, } } The attachments of any bugs that you specified in the C argument in input are returned in C on output. C is a hash that has integer bug IDs for keys and the values are arrayrefs that contain hashes as attachments. (Fields for attachments are described below.) For any attachments that you specified directly in C, they are returned in C on output. This is a hash where the attachment ids point directly to hashes describing the individual attachment. The fields for each attachment (where it says C<(attachment)> in the diagram above) are: =over =item C C The raw data of the attachment, encoded as Base64. =item C C The length (in bytes) of the attachment. =item C C The time the attachment was created. =item C C The last time the attachment was modified. =item C C The numeric id of the attachment. =item C C The numeric id of the bug that the attachment is attached to. =item C C The file name of the attachment. =item C C A short string describing the attachment. =item C C The MIME type of the attachment. =item C C True if the attachment is private (only visible to a certain group called the "insidergroup"), False otherwise. =item C C True if the attachment is obsolete, False otherwise. =item C C True if the attachment is a patch, False otherwise. =item C C The login name of the user that created the attachment. =item C An array of hashes containing the information about flags currently set for each attachment. Each flag hash contains the following items: =over =item C C The id of the flag. =item C C The name of the flag. =item C C The type id of the flag. =item C C The timestamp when this flag was originally created. =item C C The timestamp when the flag was last modified. =item C C The current status of the flag. =item C C The login name of the user who created or last modified the flag. =item C C The login name of the user this flag has been requested to be granted or denied. Note, this field is only returned if a requestee is set. =back =back =item B This method can throw all the same errors as L. In addition, it can also throw the following error: =over =item 304 (Auth Failure, Attachment is Private) You specified the id of a private attachment in the C argument, and you are not in the "insider group" that can see private attachments. =back =item B =over =item Added in Bugzilla B<3.6>. =item In Bugzilla B<4.0>, the C return value was renamed to C. =item In Bugzilla B<4.0>, the C return value was renamed to C. =item The C return value was added in Bugzilla B<4.0>. =item In Bugzilla B<4.2>, the C return value was removed (this attribute no longer exists for attachments). =item The C return value was added in Bugzilla B<4.4>. =item The C array was added in Bugzilla B<4.4>. =item REST API call added in Bugzilla B<5.0>. =back =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 /rest/bug//comment To get a specific comment based on the comment ID: GET /rest/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. =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 count C The number of the comment local to the bug. The Description is 0, comments start with 1. =item text C The actual text of the comment. =item creator C The login name of the comment's author. =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