1 # -*- Mode: perl; indent-tabs-mode: nil -*-
3 # The contents of this file are subject to the Mozilla Public
4 # License Version 1.1 (the "License"); you may not use this file
5 # except in compliance with the License. You may obtain a copy of
6 # the License at http://www.mozilla.org/MPL/
8 # Software distributed under the License is distributed on an "AS
9 # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
10 # implied. See the License for the specific language governing
11 # rights and limitations under the License.
13 # The Original Code is the Bugzilla Bug Tracking System.
15 # The Initial Developer of the Original Code is Netscape Communications
16 # Corporation. Portions created by Netscape are
17 # Copyright (C) 1998 Netscape Communications Corporation. All
20 # Contributor(s): Dawn Endico <endico@mozilla.org>
21 # Terry Weissman <terry@mozilla.org>
22 # Chris Yeh <cyeh@bluemartini.com>
23 # Bradley Baetz <bbaetz@acm.org>
24 # Dave Miller <justdave@bugzilla.org>
25 # Max Kanat-Alexander <mkanat@bugzilla.org>
26 # Frédéric Buclin <LpSolit@gmail.com>
27 # Lance Larsh <lance.larsh@oracle.com>
29 package Bugzilla
::Bug
;
33 use Bugzilla
::Attachment
;
34 use Bugzilla
::Constants
;
37 use Bugzilla
::FlagType
;
39 use Bugzilla
::Keyword
;
43 use Bugzilla
::Product
;
44 use Bugzilla
::Component
;
48 use List
::Util
qw(min);
49 use Storable
qw(dclone);
51 use base
qw(Bugzilla::Object Exporter);
52 @Bugzilla::Bug
::EXPORT
= qw(
53 bug_alias_to_id ValidateBugID
54 RemoveVotes CheckIfVotedConfirmed
57 SPECIAL_STATUS_WORKFLOW_ACTIONS
60 #####################################################################
62 #####################################################################
64 use constant DB_TABLE
=> 'bugs';
65 use constant ID_FIELD
=> 'bug_id';
66 use constant NAME_FIELD
=> 'alias';
67 use constant LIST_ORDER
=> ID_FIELD
;
69 # This is a sub because it needs to call other subroutines.
71 my $dbh = Bugzilla
->dbh;
72 my @custom = grep {$_->type != FIELD_TYPE_MULTI_SELECT
}
73 Bugzilla
->active_custom_fields;
74 my @custom_names = map {$_->name} @custom;
100 'reporter AS reporter_id',
101 $dbh->sql_date_format('creation_ts', '%Y.%m.%d %H:%i') . ' AS creation_ts',
102 $dbh->sql_date_format('deadline', '%Y-%m-%d') . ' AS deadline',
106 use constant REQUIRED_CREATE_FIELDS
=> qw(
113 # There are also other, more complex validators that are called
114 # from run_create_validators.
117 alias
=> \
&_check_alias
,
118 bug_file_loc
=> \
&_check_bug_file_loc
,
119 bug_severity
=> \
&_check_bug_severity
,
120 comment
=> \
&_check_comment
,
121 commentprivacy
=> \
&_check_commentprivacy
,
122 deadline
=> \
&_check_deadline
,
123 estimated_time
=> \
&_check_estimated_time
,
124 op_sys
=> \
&_check_op_sys
,
125 priority
=> \
&_check_priority
,
126 product
=> \
&_check_product
,
127 remaining_time
=> \
&_check_remaining_time
,
128 rep_platform
=> \
&_check_rep_platform
,
129 short_desc
=> \
&_check_short_desc
,
130 status_whiteboard
=> \
&_check_status_whiteboard
,
133 # Set up validators for custom fields.
134 foreach my $field (Bugzilla
->active_custom_fields) {
136 if ($field->type == FIELD_TYPE_SINGLE_SELECT
) {
137 $validator = \
&_check_select_field
;
139 elsif ($field->type == FIELD_TYPE_MULTI_SELECT
) {
140 $validator = \
&_check_multi_select_field
;
142 elsif ($field->type == FIELD_TYPE_DATETIME
) {
143 $validator = \
&_check_datetime_field
;
145 elsif ($field->type == FIELD_TYPE_FREETEXT
) {
146 $validator = \
&_check_freetext_field
;
149 $validator = \
&_check_default_field
;
151 $validators->{$field->name} = $validator;
157 use constant UPDATE_VALIDATORS
=> {
158 assigned_to
=> \
&_check_assigned_to
,
159 bug_status
=> \
&_check_bug_status
,
160 cclist_accessible
=> \
&Bugzilla
::Object
::check_boolean
,
161 dup_id
=> \
&_check_dup_id
,
162 qa_contact
=> \
&_check_qa_contact
,
163 reporter_accessible
=> \
&Bugzilla
::Object
::check_boolean
,
164 resolution
=> \
&_check_resolution
,
165 target_milestone
=> \
&_check_target_milestone
,
166 version
=> \
&_check_version
,
170 my @custom = grep {$_->type != FIELD_TYPE_MULTI_SELECT
}
171 Bugzilla
->active_custom_fields;
172 my @custom_names = map {$_->name} @custom;
197 push(@columns, @custom_names);
201 use constant NUMERIC_COLUMNS
=> qw(
207 my @fields = Bugzilla
->get_fields(
208 { custom
=> 1, type
=> FIELD_TYPE_DATETIME
});
209 return map { $_->name } @fields;
212 # This is used by add_comment to know what we validate before putting in
214 use constant UPDATE_COMMENT_COLUMNS
=> qw(
222 # Used in LogActivityEntry(). Gives the max length of lines in the
224 use constant MAX_LINE_LENGTH
=> 254;
226 use constant SPECIAL_STATUS_WORKFLOW_ACTIONS
=> qw(
233 #####################################################################
236 my $invocant = shift;
237 my $class = ref($invocant) || $invocant;
240 # If we get something that looks like a word (not a number),
241 # make it the "name" param.
242 if (!defined $param || (!ref($param) && $param !~ /^\d+$/)) {
243 # But only if aliases are enabled.
244 if (Bugzilla
->params->{'usebugaliases'} && $param) {
245 $param = { name
=> $param };
248 # Aliases are off, and we got something that's not a number.
250 bless $error_self, $class;
251 $error_self->{'bug_id'} = $param;
252 $error_self->{'error'} = 'InvalidBugId';
258 my $self = $class->SUPER::new
(@_);
260 # Bugzilla::Bug->new always returns something, but sets $self->{error}
261 # if the bug wasn't found in the database.
264 bless $error_self, $class;
265 $error_self->{'bug_id'} = ref($param) ?
$param->{name
} : $param;
266 $error_self->{'error'} = 'NotFound';
273 # Docs for create() (there's no POD in this file yet, but we very
274 # much need this documented right now):
276 # The same as Bugzilla::Object->create. Parameters are only required
277 # if they say so below.
281 # C<product> - B<Required> The name of the product this bug is being
283 # C<component> - B<Required> The name of the component this bug is being
286 # C<bug_severity> - B<Required> The severity for the bug, a string.
287 # C<creation_ts> - B<Required> A SQL timestamp for when the bug was created.
288 # C<short_desc> - B<Required> A summary for the bug.
289 # C<op_sys> - B<Required> The OS the bug was found against.
290 # C<priority> - B<Required> The initial priority for the bug.
291 # C<rep_platform> - B<Required> The platform the bug was found against.
292 # C<version> - B<Required> The version of the product the bug was found in.
294 # C<alias> - An alias for this bug. Will be ignored if C<usebugaliases>
296 # C<target_milestone> - When this bug is expected to be fixed.
297 # C<status_whiteboard> - A string.
298 # C<bug_status> - The initial status of the bug, a string.
299 # C<bug_file_loc> - The URL field.
301 # C<assigned_to> - The full login name of the user who the bug is
302 # initially assigned to.
303 # C<qa_contact> - The full login name of the QA Contact for this bug.
304 # Will be ignored if C<useqacontact> is off.
306 # C<estimated_time> - For time-tracking. Will be ignored if
307 # C<timetrackinggroup> is not set, or if the current
308 # user is not a member of the timetrackinggroup.
309 # C<deadline> - For time-tracking. Will be ignored for the same
310 # reasons as C<estimated_time>.
312 my ($class, $params) = @_;
313 my $dbh = Bugzilla
->dbh;
315 $dbh->bz_start_transaction();
317 # These fields have default values which we can use if they are undefined.
318 $params->{bug_severity
} = Bugzilla
->params->{defaultseverity
}
319 unless defined $params->{bug_severity
};
320 $params->{priority
} = Bugzilla
->params->{defaultpriority
}
321 unless defined $params->{priority
};
322 $params->{op_sys
} = Bugzilla
->params->{defaultopsys
}
323 unless defined $params->{op_sys
};
324 $params->{rep_platform
} = Bugzilla
->params->{defaultplatform
}
325 unless defined $params->{rep_platform
};
326 # Make sure a comment is always defined.
327 $params->{comment
} = '' unless defined $params->{comment
};
329 $class->check_required_create_fields($params);
330 $params = $class->run_create_validators($params);
332 # These are not a fields in the bugs table, so we don't pass them to
333 # insert_create_data.
334 my $cc_ids = delete $params->{cc
};
335 my $groups = delete $params->{groups
};
336 my $depends_on = delete $params->{dependson
};
337 my $blocked = delete $params->{blocked
};
338 my ($comment, $privacy) = ($params->{comment
}, $params->{commentprivacy
});
339 delete $params->{comment
};
340 delete $params->{commentprivacy
};
342 # Set up the keyword cache for bug creation.
343 my $keywords = $params->{keywords
};
344 $params->{keywords
} = join(', ', sort {lc($a) cmp lc($b)}
345 map($_->name, @
$keywords));
347 # We don't want the bug to appear in the system until it's correctly
348 # protected by groups.
349 my $timestamp = delete $params->{creation_ts
};
351 my $ms_values = $class->_extract_multi_selects($params);
352 my $bug = $class->insert_create_data($params);
354 # Add the group restrictions
355 my $sth_group = $dbh->prepare(
356 'INSERT INTO bug_group_map (bug_id, group_id) VALUES (?, ?)');
357 foreach my $group_id (@
$groups) {
358 $sth_group->execute($bug->bug_id, $group_id);
361 $dbh->do('UPDATE bugs SET creation_ts = ? WHERE bug_id = ?', undef,
362 $timestamp, $bug->bug_id);
363 # Update the bug instance as well
364 $bug->{creation_ts
} = $timestamp;
367 my $sth_cc = $dbh->prepare('INSERT INTO cc (bug_id, who) VALUES (?,?)');
368 foreach my $user_id (@
$cc_ids) {
369 $sth_cc->execute($bug->bug_id, $user_id);
373 my $sth_keyword = $dbh->prepare(
374 'INSERT INTO keywords (bug_id, keywordid) VALUES (?, ?)');
375 foreach my $keyword_id (map($_->id, @
$keywords)) {
376 $sth_keyword->execute($bug->bug_id, $keyword_id);
379 # Set up dependencies (blocked/dependson)
380 my $sth_deps = $dbh->prepare(
381 'INSERT INTO dependencies (blocked, dependson) VALUES (?, ?)');
382 my $sth_bug_time = $dbh->prepare('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?');
384 foreach my $depends_on_id (@
$depends_on) {
385 $sth_deps->execute($bug->bug_id, $depends_on_id);
386 # Log the reverse action on the other bug.
387 LogActivityEntry
($depends_on_id, 'blocked', '', $bug->bug_id,
388 $bug->{reporter_id
}, $timestamp);
389 $sth_bug_time->execute($timestamp, $depends_on_id);
391 foreach my $blocked_id (@
$blocked) {
392 $sth_deps->execute($blocked_id, $bug->bug_id);
393 # Log the reverse action on the other bug.
394 LogActivityEntry
($blocked_id, 'dependson', '', $bug->bug_id,
395 $bug->{reporter_id
}, $timestamp);
396 $sth_bug_time->execute($timestamp, $blocked_id);
399 # Insert the values into the multiselect value tables
400 foreach my $field (keys %$ms_values) {
401 $dbh->do("DELETE FROM bug_$field where bug_id = ?",
402 undef, $bug->bug_id);
403 foreach my $value ( @
{$ms_values->{$field}} ) {
404 $dbh->do("INSERT INTO bug_$field (bug_id, value) VALUES (?,?)",
405 undef, $bug->bug_id, $value);
409 # And insert the comment. We always insert a comment on bug creation,
410 # but sometimes it's blank.
411 my @columns = qw(bug_id who bug_when thetext);
412 my @values = ($bug->bug_id, $bug->{reporter_id
}, $timestamp, $comment);
413 # We don't include the "isprivate" column unless it was specified.
414 # This allows it to fall back to its database default.
415 if (defined $privacy) {
416 push(@columns, 'isprivate');
417 push(@values, $privacy);
419 my $qmarks = "?," x
@columns;
421 $dbh->do('INSERT INTO longdescs (' . join(',', @columns) . ")
422 VALUES ($qmarks)", undef, @values);
424 $dbh->bz_commit_transaction();
426 # Because MySQL doesn't support transactions on the fulltext table,
427 # we do this after we've committed the transaction. That way we're
428 # sure we're inserting a good Bug ID.
429 $bug->_sync_fulltext('new bug');
435 sub run_create_validators
{
437 my $params = $class->SUPER::run_create_validators
(@_);
439 my $product = $params->{product
};
440 $params->{product_id
} = $product->id;
441 delete $params->{product
};
443 ($params->{bug_status
}, $params->{everconfirmed
})
444 = $class->_check_bug_status($params->{bug_status
}, $product,
447 $params->{target_milestone
} = $class->_check_target_milestone(
448 $params->{target_milestone
}, $product);
450 $params->{version
} = $class->_check_version($params->{version
}, $product);
452 $params->{keywords
} = $class->_check_keywords($params->{keywords
}, $product);
454 $params->{groups
} = $class->_check_groups($product,
457 my $component = $class->_check_component($params->{component
}, $product);
458 $params->{component_id
} = $component->id;
459 delete $params->{component
};
461 $params->{assigned_to
} =
462 $class->_check_assigned_to($params->{assigned_to
}, $component);
463 $params->{qa_contact
} =
464 $class->_check_qa_contact($params->{qa_contact
}, $component);
465 $params->{cc
} = $class->_check_cc($component, $params->{cc
});
467 # Callers cannot set Reporter, currently.
468 $params->{reporter
} = $class->_check_reporter();
470 $params->{creation_ts
} ||= Bugzilla
->dbh->selectrow_array('SELECT NOW()');
471 $params->{delta_ts
} = $params->{creation_ts
};
473 if ($params->{estimated_time
}) {
474 $params->{remaining_time
} = $params->{estimated_time
};
477 $class->_check_strict_isolation($params->{cc
}, $params->{assigned_to
},
478 $params->{qa_contact
}, $product);
480 ($params->{dependson
}, $params->{blocked
}) =
481 $class->_check_dependencies($params->{dependson
}, $params->{blocked
},
484 # You can't set these fields on bug creation (or sometimes ever).
485 delete $params->{resolution
};
486 delete $params->{votes
};
487 delete $params->{lastdiffed
};
488 delete $params->{bug_id
};
496 my $dbh = Bugzilla
->dbh;
497 # XXX This is just a temporary hack until all updating happens
498 # inside this function.
499 my $delta_ts = shift || $dbh->selectrow_array("SELECT NOW()");
501 my $old_bug = $self->new($self->id);
502 my $changes = $self->SUPER::update
(@_);
504 # Certain items in $changes have to be fixed so that they hold
505 # a name instead of an ID.
506 foreach my $field (qw(product_id component_id)) {
507 my $change = delete $changes->{$field};
509 my $new_field = $field;
510 $new_field =~ s/_id$//;
511 $changes->{$new_field} =
512 [$self->{"_old_${new_field}_name"}, $self->$new_field];
515 foreach my $field (qw(qa_contact assigned_to)) {
516 if ($changes->{$field}) {
517 my ($from, $to) = @
{ $changes->{$field} };
518 $from = $old_bug->$field->login if $from;
519 $to = $self->$field->login if $to;
520 $changes->{$field} = [$from, $to];
525 my @old_cc = map {$_->id} @
{$old_bug->cc_users};
526 my @new_cc = map {$_->id} @
{$self->cc_users};
527 my ($removed_cc, $added_cc) = diff_arrays
(\
@old_cc, \
@new_cc);
529 if (scalar @
$removed_cc) {
530 $dbh->do('DELETE FROM cc WHERE bug_id = ? AND '
531 . $dbh->sql_in('who', $removed_cc), undef, $self->id);
533 foreach my $user_id (@
$added_cc) {
534 $dbh->do('INSERT INTO cc (bug_id, who) VALUES (?,?)',
535 undef, $self->id, $user_id);
537 # If any changes were found, record it in the activity log
538 if (scalar @
$removed_cc || scalar @
$added_cc) {
539 my $removed_users = Bugzilla
::User
->new_from_list($removed_cc);
540 my $added_users = Bugzilla
::User
->new_from_list($added_cc);
541 my $removed_names = join(', ', (map {$_->login} @
$removed_users));
542 my $added_names = join(', ', (map {$_->login} @
$added_users));
543 $changes->{cc
} = [$removed_names, $added_names];
547 my @old_kw_ids = map { $_->id } @
{$old_bug->keyword_objects};
548 my @new_kw_ids = map { $_->id } @
{$self->keyword_objects};
550 my ($removed_kw, $added_kw) = diff_arrays
(\
@old_kw_ids, \
@new_kw_ids);
552 if (scalar @
$removed_kw) {
553 $dbh->do('DELETE FROM keywords WHERE bug_id = ? AND '
554 . $dbh->sql_in('keywordid', $removed_kw), undef, $self->id);
556 foreach my $keyword_id (@
$added_kw) {
557 $dbh->do('INSERT INTO keywords (bug_id, keywordid) VALUES (?,?)',
558 undef, $self->id, $keyword_id);
560 $dbh->do('UPDATE bugs SET keywords = ? WHERE bug_id = ?', undef,
561 $self->keywords, $self->id);
562 # If any changes were found, record it in the activity log
563 if (scalar @
$removed_kw || scalar @
$added_kw) {
564 my $removed_keywords = Bugzilla
::Keyword
->new_from_list($removed_kw);
565 my $added_keywords = Bugzilla
::Keyword
->new_from_list($added_kw);
566 my $removed_names = join(', ', (map {$_->name} @
$removed_keywords));
567 my $added_names = join(', ', (map {$_->name} @
$added_keywords));
568 $changes->{keywords
} = [$removed_names, $added_names];
572 foreach my $pair ([qw(dependson blocked)], [qw(blocked dependson)]) {
573 my ($type, $other) = @
$pair;
574 my $old = $old_bug->$type;
575 my $new = $self->$type;
577 my ($removed, $added) = diff_arrays
($old, $new);
578 foreach my $removed_id (@
$removed) {
579 $dbh->do("DELETE FROM dependencies WHERE $type = ? AND $other = ?",
580 undef, $removed_id, $self->id);
582 # Add an activity entry for the other bug.
583 LogActivityEntry
($removed_id, $other, $self->id, '',
584 Bugzilla
->user->id, $delta_ts);
585 # Update delta_ts on the other bug so that we trigger mid-airs.
586 $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?',
587 undef, $delta_ts, $removed_id);
589 foreach my $added_id (@
$added) {
590 $dbh->do("INSERT INTO dependencies ($type, $other) VALUES (?,?)",
591 undef, $added_id, $self->id);
593 # Add an activity entry for the other bug.
594 LogActivityEntry
($added_id, $other, '', $self->id,
595 Bugzilla
->user->id, $delta_ts);
596 # Update delta_ts on the other bug so that we trigger mid-airs.
597 $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?',
598 undef, $delta_ts, $added_id);
601 if (scalar(@
$removed) || scalar(@
$added)) {
602 $changes->{$type} = [join(', ', @
$removed), join(', ', @
$added)];
607 my %old_groups = map {$_->id => $_} @
{$old_bug->groups_in};
608 my %new_groups = map {$_->id => $_} @
{$self->groups_in};
609 my ($removed_gr, $added_gr) = diff_arrays
([keys %old_groups],
611 if (scalar @
$removed_gr || scalar @
$added_gr) {
613 my $qmarks = join(',', ('?') x @
$removed_gr);
614 $dbh->do("DELETE FROM bug_group_map
615 WHERE bug_id = ? AND group_id IN ($qmarks)", undef,
616 $self->id, @
$removed_gr);
618 my $sth_insert = $dbh->prepare(
619 'INSERT INTO bug_group_map (bug_id, group_id) VALUES (?,?)');
620 foreach my $gid (@
$added_gr) {
621 $sth_insert->execute($self->id, $gid);
623 my @removed_names = map { $old_groups{$_}->name } @
$removed_gr;
624 my @added_names = map { $new_groups{$_}->name } @
$added_gr;
625 $changes->{'bug_group'} = [join(', ', @removed_names),
626 join(', ', @added_names)];
630 foreach my $comment (@
{$self->{added_comments
} || []}) {
631 my $columns = join(',', keys %$comment);
632 my @values = values %$comment;
633 my $qmarks = join(',', ('?') x
@values);
634 $dbh->do("INSERT INTO longdescs (bug_id, who, bug_when, $columns)
635 VALUES (?,?,?,$qmarks)", undef,
636 $self->bug_id, Bugzilla
->user->id, $delta_ts, @values);
637 if ($comment->{work_time
}) {
638 LogActivityEntry
($self->id, "work_time", "", $comment->{work_time
},
639 Bugzilla
->user->id, $delta_ts);
643 foreach my $comment_id (keys %{$self->{comment_isprivate
} || {}}) {
644 $dbh->do("UPDATE longdescs SET isprivate = ? WHERE comment_id = ?",
645 undef, $self->{comment_isprivate
}->{$comment_id}, $comment_id);
646 # XXX It'd be nice to track this in the bug activity.
649 # Insert the values into the multiselect value tables
650 my @multi_selects = grep {$_->type == FIELD_TYPE_MULTI_SELECT
}
651 Bugzilla
->active_custom_fields;
652 foreach my $field (@multi_selects) {
653 my $name = $field->name;
654 my ($removed, $added) = diff_arrays
($old_bug->$name, $self->$name);
655 if (scalar @
$removed || scalar @
$added) {
656 $changes->{$name} = [join(', ', @
$removed), join(', ', @
$added)];
658 $dbh->do("DELETE FROM bug_$name where bug_id = ?",
660 foreach my $value (@
{$self->$name}) {
661 $dbh->do("INSERT INTO bug_$name (bug_id, value) VALUES (?,?)",
662 undef, $self->id, $value);
667 # Log bugs_activity items
668 # XXX Eventually, when bugs_activity is able to track the dupe_id,
669 # this code should go below the duplicates-table-updating code below.
670 foreach my $field (keys %$changes) {
671 my $change = $changes->{$field};
672 my $from = defined $change->[0] ?
$change->[0] : '';
673 my $to = defined $change->[1] ?
$change->[1] : '';
674 LogActivityEntry
($self->id, $field, $from, $to, Bugzilla
->user->id,
678 # Check if we have to update the duplicates table and the other bug.
679 my ($old_dup, $cur_dup) = ($old_bug->dup_id || 0, $self->dup_id || 0);
680 if ($old_dup != $cur_dup) {
681 $dbh->do("DELETE FROM duplicates WHERE dupe = ?", undef, $self->id);
683 $dbh->do('INSERT INTO duplicates (dupe, dupe_of) VALUES (?,?)',
684 undef, $self->id, $cur_dup);
685 if (my $update_dup = delete $self->{_dup_for_update
}) {
686 $update_dup->update();
690 $changes->{'dup_id'} = [$old_dup || undef, $cur_dup || undef];
693 Bugzilla
::Hook
::process
('bug-end_of_update', { bug
=> $self,
694 timestamp
=> $delta_ts,
698 # If any change occurred, refresh the timestamp of the bug.
699 if (scalar(keys %$changes) || $self->{added_comments
}) {
700 $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?',
701 undef, ($delta_ts, $self->id));
702 $self->{delta_ts
} = $delta_ts;
705 # The only problem with this here is that update() is often called
706 # in the middle of a transaction, and if that transaction is rolled
707 # back, this change will *not* be rolled back. As we expect rollbacks
708 # to be extremely rare, that is OK for us.
709 $self->_sync_fulltext()
710 if $self->{added_comments
} || $changes->{short_desc
};
712 # Remove obsolete internal variables.
713 delete $self->{'_old_assigned_to'};
714 delete $self->{'_old_qa_contact'};
720 # We need to handle multi-select fields differently than normal fields,
721 # because they're arrays and don't go into the bugs table.
722 sub _extract_multi_selects
{
723 my ($invocant, $params) = @_;
725 my @multi_selects = grep {$_->type == FIELD_TYPE_MULTI_SELECT
}
726 Bugzilla
->active_custom_fields;
728 foreach my $field (@multi_selects) {
729 my $name = $field->name;
730 if (exists $params->{$name}) {
731 my $array = delete($params->{$name}) || [];
732 $ms_values{$name} = $array;
738 # Should be called any time you update short_desc or change a comment.
740 my ($self, $new_bug) = @_;
741 my $dbh = Bugzilla
->dbh;
743 $dbh->do('INSERT INTO bugs_fulltext (bug_id, short_desc)
744 SELECT bug_id, short_desc FROM bugs WHERE bug_id = ?',
748 $dbh->do('UPDATE bugs_fulltext SET short_desc = ? WHERE bug_id = ?',
749 undef, $self->short_desc, $self->id);
751 my $comments = $dbh->selectall_arrayref(
752 'SELECT thetext, isprivate FROM longdescs WHERE bug_id = ?',
754 my $all = join("\n", map { $_->[0] } @
$comments);
755 my @no_private = grep { !$_->[1] } @
$comments;
756 my $nopriv_string = join("\n", map { $_->[0] } @no_private);
757 $dbh->do('UPDATE bugs_fulltext SET comments = ?, comments_noprivate = ?
758 WHERE bug_id = ?', undef, $all, $nopriv_string, $self->id);
762 # This is the correct way to delete bugs from the DB.
763 # No bug should be deleted from anywhere else except from here.
767 my $dbh = Bugzilla
->dbh;
769 if ($self->{'error'}) {
770 ThrowCodeError
("bug_error", { bug
=> $self });
773 my $bug_id = $self->{'bug_id'};
775 # tables having 'bugs.bug_id' as a foreign key:
788 # Also included are custom multi-select fields.
790 # Also, the attach_data table uses attachments.attach_id as a foreign
791 # key, and so indirectly depends on a bug deletion too.
793 $dbh->bz_start_transaction();
795 $dbh->do("DELETE FROM bug_group_map WHERE bug_id = ?", undef, $bug_id);
796 $dbh->do("DELETE FROM bugs_activity WHERE bug_id = ?", undef, $bug_id);
797 $dbh->do("DELETE FROM cc WHERE bug_id = ?", undef, $bug_id);
798 $dbh->do("DELETE FROM dependencies WHERE blocked = ? OR dependson = ?",
799 undef, ($bug_id, $bug_id));
800 $dbh->do("DELETE FROM duplicates WHERE dupe = ? OR dupe_of = ?",
801 undef, ($bug_id, $bug_id));
802 $dbh->do("DELETE FROM flags WHERE bug_id = ?", undef, $bug_id);
803 $dbh->do("DELETE FROM keywords WHERE bug_id = ?", undef, $bug_id);
804 $dbh->do("DELETE FROM votes WHERE bug_id = ?", undef, $bug_id);
806 # The attach_data table doesn't depend on bugs.bug_id directly.
808 $dbh->selectcol_arrayref("SELECT attach_id FROM attachments
809 WHERE bug_id = ?", undef, $bug_id);
811 if (scalar(@
$attach_ids)) {
812 $dbh->do("DELETE FROM attach_data WHERE "
813 . $dbh->sql_in('id', $attach_ids));
816 # Several of the previous tables also depend on attach_id.
817 $dbh->do("DELETE FROM attachments WHERE bug_id = ?", undef, $bug_id);
818 $dbh->do("DELETE FROM bugs WHERE bug_id = ?", undef, $bug_id);
819 $dbh->do("DELETE FROM longdescs WHERE bug_id = ?", undef, $bug_id);
821 # Delete entries from custom multi-select fields.
822 my @multi_selects = Bugzilla
->get_fields({custom
=> 1, type
=> FIELD_TYPE_MULTI_SELECT
});
824 foreach my $field (@multi_selects) {
825 $dbh->do("DELETE FROM bug_" . $field->name . " WHERE bug_id = ?", undef, $bug_id);
828 $dbh->bz_commit_transaction();
830 # The bugs_fulltext table doesn't support transactions.
831 $dbh->do("DELETE FROM bugs_fulltext WHERE bug_id = ?", undef, $bug_id);
833 # Now this bug no longer exists
838 #####################################################################
840 #####################################################################
843 my ($invocant, $alias) = @_;
844 $alias = trim
($alias);
845 return undef if (!Bugzilla
->params->{'usebugaliases'} || !$alias);
847 # Make sure the alias isn't too long.
848 if (length($alias) > 20) {
849 ThrowUserError
("alias_too_long");
851 # Make sure the alias isn't just a number.
852 if ($alias =~ /^\d+$/) {
853 ThrowUserError
("alias_is_numeric", { alias
=> $alias });
855 # Make sure the alias has no commas or spaces.
856 if ($alias =~ /[, ]/) {
857 ThrowUserError
("alias_has_comma_or_space", { alias
=> $alias });
859 # Make sure the alias is unique, or that it's already our alias.
860 my $other_bug = new Bugzilla
::Bug
($alias);
861 if (!$other_bug->{error
}
862 && (!ref $invocant || $other_bug->id != $invocant->id))
864 ThrowUserError
("alias_in_use", { alias
=> $alias,
865 bug_id
=> $other_bug->id });
871 sub _check_assigned_to
{
872 my ($invocant, $assignee, $component) = @_;
873 my $user = Bugzilla
->user;
875 # Default assignee is the component owner.
877 # If this is a new bug, you can only set the assignee if you have editbugs.
878 # If you didn't specify the assignee, we use the default assignee.
880 && (!$user->in_group('editbugs', $component->product_id) || !$assignee))
882 $id = $component->default_assignee->id;
884 if (!ref $assignee) {
885 $assignee = trim
($assignee);
886 # When updating a bug, assigned_to can't be empty.
887 ThrowUserError
("reassign_to_empty") if ref $invocant && !$assignee;
888 $assignee = Bugzilla
::User
->check($assignee);
891 # create() checks this another way, so we don't have to run this
892 # check during create().
893 $invocant->_check_strict_isolation_for_user($assignee) if ref $invocant;
898 sub _check_bug_file_loc
{
899 my ($invocant, $url) = @_;
900 $url = '' if !defined($url);
901 # On bug entry, if bug_file_loc is "http://", the default, use an
902 # empty value instead. However, on bug editing people can set that
903 # back if they *really* want to.
904 if (!ref $invocant && $url eq 'http://') {
910 sub _check_bug_severity
{
911 my ($invocant, $severity) = @_;
912 $severity = trim
($severity);
913 check_field
('bug_severity', $severity);
917 sub _check_bug_status
{
918 my ($invocant, $new_status, $product, $comment) = @_;
919 my $user = Bugzilla
->user;
921 my $old_status; # Note that this is undef for new bugs.
924 @valid_statuses = @
{$invocant->status->can_change_to};
925 $product = $invocant->product_obj;
926 $old_status = $invocant->status;
927 my $comments = $invocant->{added_comments
} || [];
928 $comment = $comments->[-1];
931 @valid_statuses = @
{Bugzilla
::Status
->can_change_to()};
934 if (!$product->votes_to_confirm) {
935 # UNCONFIRMED becomes an invalid status if votes_to_confirm is 0,
936 # even if you are in editbugs.
937 @valid_statuses = grep {$_->name ne 'UNCONFIRMED'} @valid_statuses;
940 # Check permissions for users filing new bugs.
941 if (!ref $invocant) {
942 if ($user->in_group('editbugs', $product->id)
943 || $user->in_group('canconfirm', $product->id)) {
944 # If the user with privs hasn't selected another status,
945 # select the first one of the list.
946 unless ($new_status) {
947 if (scalar(@valid_statuses) == 1) {
948 $new_status = $valid_statuses[0];
951 $new_status = ($valid_statuses[0]->name ne 'UNCONFIRMED') ?
952 $valid_statuses[0] : $valid_statuses[1];
957 # A user with no privs cannot choose the initial status.
958 # If UNCONFIRMED is valid for this product, use it; else
959 # use the first bug status available.
960 if (grep {$_->name eq 'UNCONFIRMED'} @valid_statuses) {
961 $new_status = 'UNCONFIRMED';
964 $new_status = $valid_statuses[0];
968 # Time to validate the bug status.
969 $new_status = Bugzilla
::Status
->check($new_status) unless ref($new_status);
970 if (!grep {$_->name eq $new_status->name} @valid_statuses) {
971 ThrowUserError
('illegal_bug_status_transition',
972 { old
=> $old_status, new
=> $new_status });
975 # Check if a comment is required for this change.
976 if ($new_status->comment_required_on_change_from($old_status) && !$comment)
978 ThrowUserError
('comment_required', { old
=> $old_status,
979 new
=> $new_status });
983 if (ref $invocant && $new_status->name eq 'ASSIGNED'
984 && Bugzilla
->params->{"usetargetmilestone"}
985 && Bugzilla
->params->{"musthavemilestoneonaccept"}
986 # musthavemilestoneonaccept applies only if at least two
987 # target milestones are defined for the product.
988 && scalar(@
{ $product->milestones }) > 1
989 && $invocant->target_milestone eq $product->default_milestone)
991 ThrowUserError
("milestone_required", { bug
=> $invocant });
994 return $new_status->name if ref $invocant;
995 return ($new_status->name, $new_status->name eq 'UNCONFIRMED' ?
0 : 1);
999 my ($invocant, $component, $ccs) = @_;
1000 return [map {$_->id} @
{$component->initial_cc}] unless $ccs;
1003 foreach my $person (@
$ccs) {
1004 next unless $person;
1005 my $id = login_to_id
($person, THROW_ERROR
);
1009 # Enforce Default CC
1010 $cc_ids{$_->id} = 1 foreach (@
{$component->initial_cc});
1012 return [keys %cc_ids];
1015 sub _check_comment
{
1016 my ($invocant, $comment) = @_;
1018 $comment = '' unless defined $comment;
1020 # Remove any trailing whitespace. Leading whitespace could be
1021 # a valid part of the comment.
1022 $comment =~ s/\s*$//s;
1023 $comment =~ s/\r\n?/\n/g; # Get rid of \r.
1025 ThrowUserError
('comment_too_long') if length($comment) > MAX_COMMENT_LENGTH
;
1029 sub _check_commentprivacy
{
1030 my ($invocant, $comment_privacy) = @_;
1031 my $insider_group = Bugzilla
->params->{"insidergroup"};
1032 return ($insider_group && Bugzilla
->user->in_group($insider_group)
1033 && $comment_privacy) ?
1 : 0;
1036 sub _check_comment_type
{
1037 my ($invocant, $type) = @_;
1038 detaint_natural
($type)
1039 || ThrowCodeError
('bad_arg', { argument
=> 'type',
1040 function
=> caller });
1044 sub _check_component
{
1045 my ($invocant, $name, $product) = @_;
1046 $name = trim
($name);
1047 $name || ThrowUserError
("require_component");
1048 ($product = $invocant->product_obj) if ref $invocant;
1049 my $obj = Bugzilla
::Component
->check({ product
=> $product, name
=> $name });
1053 sub _check_deadline
{
1054 my ($invocant, $date) = @_;
1056 # Check time-tracking permissions.
1057 my $tt_group = Bugzilla
->params->{"timetrackinggroup"};
1058 # deadline() returns '' instead of undef if no deadline is set.
1059 my $current = ref $invocant ?
($invocant->deadline || undef) : undef;
1060 return $current unless $tt_group && Bugzilla
->user->in_group($tt_group);
1062 # Validate entered deadline
1063 $date = trim
($date);
1064 return undef if !$date;
1065 validate_date
($date)
1066 || ThrowUserError
('illegal_date', { date
=> $date,
1067 format
=> 'YYYY-MM-DD' });
1071 # Takes two comma/space-separated strings and returns arrayrefs
1073 sub _check_dependencies
{
1074 my ($invocant, $depends_on, $blocks, $product) = @_;
1076 if (!ref $invocant) {
1077 # Only editbugs users can set dependencies on bug entry.
1078 return ([], []) unless Bugzilla
->user->in_group('editbugs',
1082 my %deps_in = (dependson
=> $depends_on || '', blocked
=> $blocks || '');
1084 foreach my $type qw(dependson blocked) {
1085 my @bug_ids = split(/[\s,]+/, $deps_in{$type});
1087 @bug_ids = grep {$_} @bug_ids;
1088 # We do Validate up here to make sure all aliases are converted to IDs.
1089 ValidateBugID
($_, $type) foreach @bug_ids;
1091 my @check_access = @bug_ids;
1092 # When we're updating a bug, only added or removed bug_ids are
1093 # checked for whether or not we can see/edit those bugs.
1094 if (ref $invocant) {
1095 my $old = $invocant->$type;
1096 my ($removed, $added) = diff_arrays
($old, \
@bug_ids);
1097 @check_access = (@
$added, @
$removed);
1099 # Check field permissions if we've changed anything.
1100 if (@check_access) {
1102 if (!$invocant->check_can_change_field($type, 0, 1, \
$privs)) {
1103 ThrowUserError
('illegal_change', { field
=> $type,
1109 my $user = Bugzilla
->user;
1110 foreach my $modified_id (@check_access) {
1111 ValidateBugID
($modified_id);
1112 # Under strict isolation, you can't modify a bug if you can't
1113 # edit it, even if you can see it.
1114 if (Bugzilla
->params->{"strict_isolation"}) {
1115 my $delta_bug = new Bugzilla
::Bug
($modified_id);
1116 if (!$user->can_edit_product($delta_bug->{'product_id'})) {
1117 ThrowUserError
("illegal_change_deps", {field
=> $type});
1122 $deps_in{$type} = \
@bug_ids;
1125 # And finally, check for dependency loops.
1126 my $bug_id = ref($invocant) ?
$invocant->id : 0;
1127 my %deps = ValidateDependencies
($deps_in{dependson
}, $deps_in{blocked
}, $bug_id);
1129 return ($deps{'dependson'}, $deps{'blocked'});
1133 my ($self, $dupe_of) = @_;
1134 my $dbh = Bugzilla
->dbh;
1136 $dupe_of = trim
($dupe_of);
1137 $dupe_of || ThrowCodeError
('undefined_field', { field
=> 'dup_id' });
1138 # Validate the bug ID. The second argument will force ValidateBugID() to
1139 # only make sure that the bug exists, and convert the alias to the bug ID
1140 # if a string is passed. Group restrictions are checked below.
1141 ValidateBugID
($dupe_of, 'dup_id');
1143 # If the dupe is unchanged, we have nothing more to check.
1144 return $dupe_of if ($self->dup_id && $self->dup_id == $dupe_of);
1146 # If we come here, then the duplicate is new. We have to make sure
1147 # that we can view/change it (issue A on bug 96085).
1148 check_is_visible
($dupe_of);
1150 # Make sure a loop isn't created when marking this bug
1153 my $this_dup = $dupe_of;
1154 my $sth = $dbh->prepare('SELECT dupe_of FROM duplicates WHERE dupe = ?');
1157 if ($this_dup == $self->id) {
1158 ThrowUserError
('dupe_loop_detected', { bug_id
=> $self->id,
1159 dupe_of
=> $dupe_of });
1161 # If $dupes{$this_dup} is already set to 1, then a loop
1162 # already exists which does not involve this bug.
1163 # As the user is not responsible for this loop, do not
1164 # prevent him from marking this bug as a duplicate.
1165 last if exists $dupes{$this_dup};
1166 $dupes{$this_dup} = 1;
1167 $this_dup = $dbh->selectrow_array($sth, undef, $this_dup);
1170 my $cur_dup = $self->dup_id || 0;
1171 if ($cur_dup != $dupe_of && Bugzilla
->params->{'commentonduplicate'}
1172 && !$self->{added_comments
})
1174 ThrowUserError
('comment_required');
1177 # Should we add the reporter to the CC list of the new bug?
1178 # If he can see the bug...
1179 if ($self->reporter->can_see_bug($dupe_of)) {
1180 my $dupe_of_bug = new Bugzilla
::Bug
($dupe_of);
1181 # We only add him if he's not the reporter of the other bug.
1182 $self->{_add_dup_cc
} = 1
1183 if $dupe_of_bug->reporter->id != $self->reporter->id;
1185 # What if the reporter currently can't see the new bug? In the browser
1186 # interface, we prompt the user. In other interfaces, we default to
1187 # not adding the user, as the safest option.
1188 elsif (Bugzilla
->usage_mode == USAGE_MODE_BROWSER
) {
1189 # If we've already confirmed whether the user should be added...
1190 my $cgi = Bugzilla
->cgi;
1191 my $add_confirmed = $cgi->param('confirm_add_duplicate');
1192 if (defined $add_confirmed) {
1193 $self->{_add_dup_cc
} = $add_confirmed;
1196 # Note that here we don't check if he user is already the reporter
1197 # of the dupe_of bug, since we already checked if he can *see*
1198 # the bug, above. People might have reporter_accessible turned
1199 # off, but cclist_accessible turned on, so they might want to
1200 # add the reporter even though he's already the reporter of the
1203 my $template = Bugzilla
->template;
1204 # Ask the user what they want to do about the reporter.
1205 $vars->{'cclist_accessible'} = $dbh->selectrow_array(
1206 q{SELECT cclist_accessible FROM bugs WHERE bug_id = ?},
1208 $vars->{'original_bug_id'} = $dupe_of;
1209 $vars->{'duplicate_bug_id'} = $self->id;
1210 print $cgi->header();
1211 $template->process("bug/process/confirm-duplicate.html.tmpl", $vars)
1212 || ThrowTemplateError
($template->error());
1220 sub _check_estimated_time
{
1221 return $_[0]->_check_time($_[1], 'estimated_time');
1225 my ($invocant, $product, $group_ids) = @_;
1227 my $user = Bugzilla
->user;
1230 my $controls = $product->group_controls;
1232 foreach my $id (@
$group_ids) {
1233 my $group = new Bugzilla
::Group
($id)
1234 || ThrowUserError
("invalid_group_ID");
1236 # This can only happen if somebody hacked the enter_bug form.
1237 ThrowCodeError
("inactive_group", { name
=> $group->name })
1238 unless $group->is_active;
1240 my $membercontrol = $controls->{$id}
1241 && $controls->{$id}->{membercontrol
};
1242 my $othercontrol = $controls->{$id}
1243 && $controls->{$id}->{othercontrol
};
1245 my $permit = ($membercontrol && $user->in_group($group->name))
1248 $add_groups{$id} = 1 if $permit;
1251 foreach my $id (keys %$controls) {
1252 next unless $controls->{$id}->{'group'}->is_active;
1253 my $membercontrol = $controls->{$id}->{membercontrol
} || 0;
1254 my $othercontrol = $controls->{$id}->{othercontrol
} || 0;
1256 # Add groups required
1257 if ($membercontrol == CONTROLMAPMANDATORY
1258 || ($othercontrol == CONTROLMAPMANDATORY
1259 && !$user->in_group_id($id)))
1261 # User had no option, bug needs to be in this group.
1262 $add_groups{$id} = 1;
1266 my @add_groups = keys %add_groups;
1267 return \
@add_groups;
1270 sub _check_keywords
{
1271 my ($invocant, $keyword_string, $product) = @_;
1272 $keyword_string = trim
($keyword_string);
1273 return [] if !$keyword_string;
1275 # On creation, only editbugs users can set keywords.
1276 if (!ref $invocant) {
1277 return [] if !Bugzilla
->user->in_group('editbugs', $product->id);
1281 foreach my $keyword (split(/[\s,]+/, $keyword_string)) {
1282 next unless $keyword;
1283 my $obj = new Bugzilla
::Keyword
({ name
=> $keyword });
1284 ThrowUserError
("unknown_keyword", { keyword
=> $keyword }) if !$obj;
1285 $keywords{$obj->id} = $obj;
1287 return [values %keywords];
1290 sub _check_product
{
1291 my ($invocant, $name) = @_;
1292 $name = trim
($name);
1293 # If we're updating the bug and they haven't changed the product,
1295 if (ref $invocant && lc($invocant->product_obj->name) eq lc($name)) {
1296 return $invocant->product_obj;
1298 # Check that the product exists and that the user
1299 # is allowed to enter bugs into this product.
1300 Bugzilla
->user->can_enter_product($name, THROW_ERROR
);
1301 # can_enter_product already does everything that check_product
1302 # would do for us, so we don't need to use it.
1303 return new Bugzilla
::Product
({ name
=> $name });
1307 my ($invocant, $op_sys) = @_;
1308 $op_sys = trim
($op_sys);
1309 check_field
('op_sys', $op_sys);
1313 sub _check_priority
{
1314 my ($invocant, $priority) = @_;
1315 if (!ref $invocant && !Bugzilla
->params->{'letsubmitterchoosepriority'}) {
1316 $priority = Bugzilla
->params->{'defaultpriority'};
1318 $priority = trim
($priority);
1319 check_field
('priority', $priority);
1324 sub _check_qa_contact
{
1325 my ($invocant, $qa_contact, $component) = @_;
1326 $qa_contact = trim
($qa_contact) if !ref $qa_contact;
1329 if (!ref $invocant) {
1330 # Bugs get no QA Contact on creation if useqacontact is off.
1331 return undef if !Bugzilla
->params->{useqacontact
};
1332 # Set the default QA Contact if one isn't specified or if the
1333 # user doesn't have editbugs.
1334 if (!Bugzilla
->user->in_group('editbugs', $component->product_id)
1337 $id = $component->default_qa_contact->id;
1341 # If a QA Contact was specified or if we're updating, check
1342 # the QA Contact for validity.
1343 if (!defined $id && $qa_contact) {
1344 $qa_contact = Bugzilla
::User
->check($qa_contact) if !ref $qa_contact;
1345 $id = $qa_contact->id;
1346 # create() checks this another way, so we don't have to run this
1347 # check during create().
1348 # If there is no QA contact, this check is not required.
1349 $invocant->_check_strict_isolation_for_user($qa_contact)
1350 if (ref $invocant && $id);
1353 # "0" always means "undef", for QA Contact.
1354 return $id || undef;
1357 sub _check_remaining_time
{
1358 return $_[0]->_check_time($_[1], 'remaining_time');
1361 sub _check_rep_platform
{
1362 my ($invocant, $platform) = @_;
1363 $platform = trim
($platform);
1364 check_field
('rep_platform', $platform);
1368 sub _check_reporter
{
1369 my $invocant = shift;
1371 if (ref $invocant) {
1372 # You cannot change the reporter of a bug.
1373 $reporter = $invocant->reporter->id;
1376 # On bug creation, the reporter is the logged in user
1377 # (meaning that he must be logged in first!).
1378 $reporter = Bugzilla
->user->id;
1379 $reporter || ThrowCodeError
('invalid_user');
1384 sub _check_resolution
{
1385 my ($self, $resolution) = @_;
1386 $resolution = trim
($resolution);
1388 # Throw a special error for resolving bugs without a resolution
1389 # (or trying to change the resolution to '' on a closed bug without
1390 # using clear_resolution).
1391 ThrowUserError
('missing_resolution', { status
=> $self->status->name })
1392 if !$resolution && !$self->status->is_open;
1394 # Make sure this is a valid resolution.
1395 check_field
('resolution', $resolution);
1397 # Don't allow open bugs to have resolutions.
1398 ThrowUserError
('resolution_not_allowed') if $self->status->is_open;
1400 # Check noresolveonopenblockers.
1401 if (Bugzilla
->params->{"noresolveonopenblockers"} && $resolution eq 'FIXED')
1403 my @dependencies = CountOpenDependencies
($self->id);
1404 if (@dependencies) {
1405 ThrowUserError
("still_unresolved_bugs",
1406 { dependencies
=> \
@dependencies,
1407 dependency_count
=> scalar @dependencies });
1411 # Check if they're changing the resolution and need to comment.
1412 if (Bugzilla
->params->{'commentonchange_resolution'}
1413 && $self->resolution && $resolution ne $self->resolution
1414 && !$self->{added_comments
})
1416 ThrowUserError
('comment_required');
1422 sub _check_short_desc
{
1423 my ($invocant, $short_desc) = @_;
1424 # Set the parameter to itself, but cleaned up
1425 $short_desc = clean_text
($short_desc) if $short_desc;
1427 if (!defined $short_desc || $short_desc eq '') {
1428 ThrowUserError
("require_summary");
1433 sub _check_status_whiteboard
{ return defined $_[1] ?
$_[1] : ''; }
1435 # Unlike other checkers, this one doesn't return anything.
1436 sub _check_strict_isolation
{
1437 my ($invocant, $ccs, $assignee, $qa_contact, $product) = @_;
1438 return unless Bugzilla
->params->{'strict_isolation'};
1440 if (ref $invocant) {
1441 my $original = $invocant->new($invocant->id);
1443 # We only check people if they've been added. This way, if
1444 # strict_isolation is turned on when there are invalid users
1445 # on bugs, people can still add comments and so on.
1446 my @old_cc = map { $_->id } @
{$original->cc_users};
1447 my @new_cc = map { $_->id } @
{$invocant->cc_users};
1448 my ($removed, $added) = diff_arrays
(\
@old_cc, \
@new_cc);
1449 $ccs = Bugzilla
::User
->new_from_list($added);
1451 $assignee = $invocant->assigned_to
1452 if $invocant->assigned_to->id != $original->assigned_to->id;
1453 if ($invocant->qa_contact
1454 && (!$original->qa_contact
1455 || $invocant->qa_contact->id != $original->qa_contact->id))
1457 $qa_contact = $invocant->qa_contact;
1459 $product = $invocant->product_obj;
1462 my @related_users = @
$ccs;
1463 push(@related_users, $assignee) if $assignee;
1465 if (Bugzilla
->params->{'useqacontact'} && $qa_contact) {
1466 push(@related_users, $qa_contact);
1469 @related_users = @
{Bugzilla
::User
->new_from_list(\
@related_users)}
1472 # For each unique user in @related_users...(assignee and qa_contact
1473 # could be duplicates of users in the CC list)
1474 my %unique_users = map {$_->id => $_} @related_users;
1476 foreach my $id (keys %unique_users) {
1477 my $related_user = $unique_users{$id};
1478 if (!$related_user->can_edit_product($product->id) ||
1479 !$related_user->can_see_product($product->name)) {
1480 push (@blocked_users, $related_user->login);
1483 if (scalar(@blocked_users)) {
1484 my %vars = ( users
=> \
@blocked_users,
1485 product
=> $product->name );
1486 if (ref $invocant) {
1487 $vars{'bug_id'} = $invocant->id;
1492 ThrowUserError
("invalid_user_group", \
%vars);
1496 # This is used by various set_ checkers, to make their code simpler.
1497 sub _check_strict_isolation_for_user
{
1498 my ($self, $user) = @_;
1499 return unless Bugzilla
->params->{"strict_isolation"};
1500 if (!$user->can_edit_product($self->{product_id
})) {
1501 ThrowUserError
('invalid_user_group',
1502 { users
=> $user->login,
1503 product
=> $self->product,
1504 bug_id
=> $self->id });
1508 sub _check_target_milestone
{
1509 my ($invocant, $target, $product) = @_;
1510 $product = $invocant->product_obj if ref $invocant;
1512 $target = trim
($target);
1513 $target = $product->default_milestone if !defined $target;
1514 check_field
('target_milestone', $target,
1515 [map($_->name, @
{$product->milestones})]);
1520 my ($invocant, $time, $field) = @_;
1523 if (ref $invocant && $field ne 'work_time') {
1524 $current = $invocant->$field;
1526 my $tt_group = Bugzilla
->params->{"timetrackinggroup"};
1527 return $current unless $tt_group && Bugzilla
->user->in_group($tt_group);
1529 $time = trim
($time) || 0;
1530 ValidateTime
($time, $field);
1534 sub _check_version
{
1535 my ($invocant, $version, $product) = @_;
1536 $version = trim
($version);
1537 ($product = $invocant->product_obj) if ref $invocant;
1538 check_field
('version', $version, [map($_->name, @
{$product->versions})]);
1542 sub _check_work_time
{
1543 return $_[0]->_check_time($_[1], 'work_time');
1546 # Custom Field Validators
1548 sub _check_datetime_field
{
1549 my ($invocant, $date_time) = @_;
1551 # Empty datetimes are empty strings or strings only containing
1552 # 0's, whitespace, and punctuation.
1553 if ($date_time =~ /^[\s0[:punct:]]*$/) {
1557 $date_time = trim
($date_time);
1558 my ($date, $time) = split(' ', $date_time);
1559 if ($date && !validate_date
($date)) {
1560 ThrowUserError
('illegal_date', { date
=> $date,
1561 format
=> 'YYYY-MM-DD' });
1563 if ($time && !validate_time
($time)) {
1564 ThrowUserError
('illegal_time', { 'time' => $time,
1565 format
=> 'HH:MM:SS' });
1570 sub _check_default_field
{ return defined $_[1] ? trim
($_[1]) : ''; }
1572 sub _check_freetext_field
{
1573 my ($invocant, $text) = @_;
1575 $text = (defined $text) ? trim
($text) : '';
1576 if (length($text) > MAX_FREETEXT_LENGTH
) {
1577 ThrowUserError
('freetext_too_long', { text
=> $text });
1582 sub _check_multi_select_field
{
1583 my ($invocant, $values, $field) = @_;
1584 return [] if !$values;
1585 foreach my $value (@
$values) {
1586 $value = trim
($value);
1587 check_field
($field, $value);
1588 trick_taint
($value);
1593 sub _check_select_field
{
1594 my ($invocant, $value, $field) = @_;
1595 $value = trim
($value);
1596 check_field
($field, $value);
1600 #####################################################################
1602 #####################################################################
1609 # Keep this ordering in sync with bugzilla.dtd.
1610 qw(bug_id alias creation_ts short_desc delta_ts
1611 reporter_accessible cclist_accessible
1612 classification_id classification
1613 product component version rep_platform op_sys
1614 bug_status resolution dup_id
1615 bug_file_loc status_whiteboard keywords
1616 priority bug_severity target_milestone
1617 dependson blocked votes everconfirmed
1618 reporter assigned_to cc estimated_time
1619 remaining_time actual_time deadline),
1621 # Conditional Fields
1622 Bugzilla
->params->{'useqacontact'} ?
"qa_contact" : (),
1624 map { $_->name } Bugzilla
->active_custom_fields
1628 #####################################################################
1630 #####################################################################
1632 # To run check_can_change_field.
1633 sub _set_global_validator
{
1634 my ($self, $value, $field) = @_;
1635 my $current = $self->$field;
1638 if (ref $current && ref($current) ne 'ARRAY'
1639 && $current->isa('Bugzilla::Object')) {
1640 $current = $current->id ;
1642 if (ref $value && ref($value) ne 'ARRAY'
1643 && $value->isa('Bugzilla::Object')) {
1644 $value = $value->id ;
1646 my $can = $self->check_can_change_field($field, $current, $value, \
$privs);
1648 if ($field eq 'assigned_to' || $field eq 'qa_contact') {
1649 $value = user_id_to_login
($value);
1650 $current = user_id_to_login
($current);
1652 ThrowUserError
('illegal_change', { field
=> $field,
1653 oldvalue
=> $current,
1664 sub set_alias
{ $_[0]->set('alias', $_[1]); }
1665 sub set_assigned_to
{
1666 my ($self, $value) = @_;
1667 $self->set('assigned_to', $value);
1668 # Store the old assignee. check_can_change_field() needs it.
1669 $self->{'_old_assigned_to'} = $self->{'assigned_to_obj'}->id;
1670 delete $self->{'assigned_to_obj'};
1672 sub reset_assigned_to
{
1674 if (Bugzilla
->params->{'commentonreassignbycomponent'}
1675 && !$self->{added_comments
})
1677 ThrowUserError
('comment_required');
1679 my $comp = $self->component_obj;
1680 $self->set_assigned_to($comp->default_assignee);
1682 sub set_cclist_accessible
{ $_[0]->set('cclist_accessible', $_[1]); }
1683 sub set_comment_is_private
{
1684 my ($self, $comment_id, $isprivate) = @_;
1685 return unless Bugzilla
->user->is_insider;
1686 my ($comment) = grep($comment_id eq $_->{id
}, @
{$self->longdescs});
1687 ThrowUserError
('comment_invalid_isprivate', { id
=> $comment_id })
1690 $isprivate = $isprivate ?
1 : 0;
1691 if ($isprivate != $comment->{isprivate
}) {
1692 $self->{comment_isprivate
} ||= {};
1693 $self->{comment_isprivate
}->{$comment_id} = $isprivate;
1697 my ($self, $name) = @_;
1698 my $old_comp = $self->component_obj;
1699 my $component = $self->_check_component($name);
1700 if ($old_comp->id != $component->id) {
1701 $self->{component_id
} = $component->id;
1702 $self->{component
} = $component->name;
1703 $self->{component_obj
} = $component;
1705 $self->{_old_component_name
} = $old_comp->name;
1706 # Add in the Default CC of the new Component;
1707 foreach my $cc (@
{$component->initial_cc}) {
1712 sub set_custom_field
{
1713 my ($self, $field, $value) = @_;
1714 if (ref $value eq 'ARRAY' && $field->type != FIELD_TYPE_MULTI_SELECT
) {
1715 $value = $value->[0];
1717 ThrowCodeError
('field_not_custom', { field
=> $field }) if !$field->custom;
1718 $self->set($field->name, $value);
1720 sub set_deadline
{ $_[0]->set('deadline', $_[1]); }
1721 sub set_dependencies
{
1722 my ($self, $dependson, $blocked) = @_;
1723 ($dependson, $blocked) = $self->_check_dependencies($dependson, $blocked);
1724 # These may already be detainted, but all setters are supposed to
1725 # detaint their input if they've run a validator (just as though
1726 # we had used Bugzilla::Object::set), so we do that here.
1727 detaint_natural
($_) foreach (@
$dependson, @
$blocked);
1728 $self->{'dependson'} = $dependson;
1729 $self->{'blocked'} = $blocked;
1731 sub _clear_dup_id
{ $_[0]->{dup_id
} = undef; }
1733 my ($self, $dup_id) = @_;
1734 my $old = $self->dup_id || 0;
1735 $self->set('dup_id', $dup_id);
1736 my $new = $self->dup_id;
1737 return if $old == $new;
1739 # Update the other bug.
1740 my $dupe_of = new Bugzilla
::Bug
($self->dup_id);
1741 if (delete $self->{_add_dup_cc
}) {
1742 $dupe_of->add_cc($self->reporter);
1744 $dupe_of->add_comment("", { type
=> CMT_HAS_DUPE
,
1745 extra_data
=> $self->id });
1746 $self->{_dup_for_update
} = $dupe_of;
1748 # Now make sure that we add a duplicate comment on *this* bug.
1749 # (Change an existing comment into a dup comment, if there is one,
1750 # or add an empty dup comment.)
1751 if ($self->{added_comments
}) {
1752 my @normal = grep { !defined $_->{type
} || $_->{type
} == CMT_NORMAL
}
1753 @
{ $self->{added_comments
} };
1754 # Turn the last one into a dup comment.
1755 $normal[-1]->{type
} = CMT_DUPE_OF
;
1756 $normal[-1]->{extra_data
} = $self->dup_id;
1759 $self->add_comment('', { type
=> CMT_DUPE_OF
,
1760 extra_data
=> $self->dup_id });
1763 sub set_estimated_time
{ $_[0]->set('estimated_time', $_[1]); }
1764 sub _set_everconfirmed
{ $_[0]->set('everconfirmed', $_[1]); }
1765 sub set_op_sys
{ $_[0]->set('op_sys', $_[1]); }
1766 sub set_platform
{ $_[0]->set('rep_platform', $_[1]); }
1767 sub set_priority
{ $_[0]->set('priority', $_[1]); }
1769 my ($self, $name, $params) = @_;
1770 my $old_product = $self->product_obj;
1771 my $product = $self->_check_product($name);
1773 my $product_changed = 0;
1774 if ($old_product->id != $product->id) {
1775 $self->{product_id
} = $product->id;
1776 $self->{product
} = $product->name;
1777 $self->{product_obj
} = $product;
1779 $self->{_old_product_name
} = $old_product->name;
1780 # Delete fields that depend upon the old Product value.
1781 delete $self->{choices
};
1782 delete $self->{milestoneurl
};
1783 $product_changed = 1;
1787 my $comp_name = $params->{component
} || $self->component;
1788 my $vers_name = $params->{version
} || $self->version;
1789 my $tm_name = $params->{target_milestone
};
1790 # This way, if usetargetmilestone is off and we've changed products,
1791 # set_target_milestone will reset our target_milestone to
1792 # $product->default_milestone. But if we haven't changed products,
1793 # we don't reset anything.
1794 if (!defined $tm_name
1795 && (Bugzilla
->params->{'usetargetmilestone'} || !$product_changed))
1797 $tm_name = $self->target_milestone;
1800 if ($product_changed && Bugzilla
->usage_mode == USAGE_MODE_BROWSER
) {
1801 # Try to set each value with the new product.
1802 # Have to set error_mode because Throw*Error calls exit() otherwise.
1803 my $old_error_mode = Bugzilla
->error_mode;
1804 Bugzilla
->error_mode(ERROR_MODE_DIE
);
1805 my $component_ok = eval { $self->set_component($comp_name); 1; };
1806 my $version_ok = eval { $self->set_version($vers_name); 1; };
1807 my $milestone_ok = 1;
1808 # Reporters can move bugs between products but not set the TM.
1809 if ($self->check_can_change_field('target_milestone', 0, 1)) {
1810 $milestone_ok = eval { $self->set_target_milestone($tm_name); 1; };
1813 # Have to set this directly to bypass the validators.
1814 $self->{target_milestone
} = $product->default_milestone;
1816 # If there were any errors thrown, make sure we don't mess up any
1817 # other part of Bugzilla that checks $@.
1819 Bugzilla
->error_mode($old_error_mode);
1821 my $verified = $params->{change_confirmed
};
1823 if (!$verified || !$component_ok || !$version_ok || !$milestone_ok) {
1825 # Note that because of the eval { set } above, these are
1826 # already set correctly if they're valid, otherwise they're
1827 # set to some invalid value which the template will ignore.
1828 component
=> $self->component,
1829 version
=> $self->version,
1830 milestone
=> $milestone_ok ?
$self->target_milestone
1831 : $product->default_milestone
1833 $vars{components
} = [map { $_->name } @
{$product->components}];
1834 $vars{milestones
} = [map { $_->name } @
{$product->milestones}];
1835 $vars{versions
} = [map { $_->name } @
{$product->versions}];
1839 $vars{verify_bug_groups
} = 1;
1840 my $dbh = Bugzilla
->dbh;
1841 my @idlist = ($self->id);
1842 push(@idlist, map {$_->id} @
{ $params->{other_bugs
} })
1843 if $params->{other_bugs
};
1844 # Get the ID of groups which are no longer valid in the new product.
1845 my $gids = $dbh->selectcol_arrayref(
1846 'SELECT bgm.group_id
1847 FROM bug_group_map AS bgm
1848 WHERE bgm.bug_id IN (' . join(',', ('?') x
@idlist) . ')
1849 AND bgm.group_id NOT IN
1850 (SELECT gcm.group_id
1851 FROM group_control_map AS gcm
1852 WHERE gcm.product_id = ?
1853 AND ( (gcm.membercontrol != ?
1854 AND gcm.group_id IN ('
1855 . Bugzilla
->user->groups_as_string . '))
1856 OR gcm.othercontrol != ?) )',
1857 undef, (@idlist, $product->id, CONTROLMAPNA
, CONTROLMAPNA
));
1858 $vars{'old_groups'} = Bugzilla
::Group
->new_from_list($gids);
1862 $vars{product
} = $product;
1864 my $template = Bugzilla
->template;
1865 $template->process("bug/process/verify-new-product.html.tmpl",
1866 \
%vars) || ThrowTemplateError
($template->error());
1871 # When we're not in the browser (or we didn't change the product), we
1872 # just die if any of these are invalid.
1873 $self->set_component($comp_name);
1874 $self->set_version($vers_name);
1875 if ($product_changed && !$self->check_can_change_field('target_milestone', 0, 1)) {
1876 # Have to set this directly to bypass the validators.
1877 $self->{target_milestone
} = $product->default_milestone;
1880 $self->set_target_milestone($tm_name);
1884 if ($product_changed) {
1885 # Remove groups that aren't valid in the new product. This will also
1886 # have the side effect of removing the bug from groups that aren't
1889 # We copy this array because the original array is modified while we're
1890 # working, and that confuses "foreach".
1891 my @current_groups = @
{$self->groups_in};
1892 foreach my $group (@current_groups) {
1893 if (!grep($group->id == $_->id, @
{$product->groups_valid})) {
1894 $self->remove_group($group);
1898 # Make sure the bug is in all the mandatory groups for the new product.
1899 foreach my $group (@
{$product->groups_mandatory_for(Bugzilla
->user)}) {
1900 $self->add_group($group);
1904 # XXX This is temporary until all of process_bug uses update();
1905 return $product_changed;
1908 sub set_qa_contact
{
1909 my ($self, $value) = @_;
1910 $self->set('qa_contact', $value);
1911 # Store the old QA contact. check_can_change_field() needs it.
1912 if ($self->{'qa_contact_obj'}) {
1913 $self->{'_old_qa_contact'} = $self->{'qa_contact_obj'}->id;
1915 delete $self->{'qa_contact_obj'};
1917 sub reset_qa_contact
{
1919 if (Bugzilla
->params->{'commentonreassignbycomponent'}
1920 && !$self->{added_comments
})
1922 ThrowUserError
('comment_required');
1924 my $comp = $self->component_obj;
1925 $self->set_qa_contact($comp->default_qa_contact);
1927 sub set_remaining_time
{ $_[0]->set('remaining_time', $_[1]); }
1928 # Used only when closing a bug or moving between closed states.
1929 sub _zero_remaining_time
{ $_[0]->{'remaining_time'} = 0; }
1930 sub set_reporter_accessible
{ $_[0]->set('reporter_accessible', $_[1]); }
1931 sub set_resolution
{
1932 my ($self, $value, $params) = @_;
1934 my $old_res = $self->resolution;
1935 $self->set('resolution', $value);
1936 my $new_res = $self->resolution;
1938 if ($new_res ne $old_res) {
1939 # MOVED has a special meaning and can only be used when
1940 # really moving bugs to another installation.
1941 ThrowCodeError
('no_manual_moved') if ($new_res eq 'MOVED' && !$params->{moving
});
1943 # Clear the dup_id if we're leaving the dup resolution.
1944 if ($old_res eq 'DUPLICATE') {
1945 $self->_clear_dup_id();
1947 # Duplicates should have no remaining time left.
1948 elsif ($new_res eq 'DUPLICATE' && $self->remaining_time != 0) {
1949 $self->_zero_remaining_time();
1953 # We don't check if we're entering or leaving the dup resolution here,
1954 # because we could be moving from being a dup of one bug to being a dup
1955 # of another, theoretically. Note that this code block will also run
1956 # when going between different closed states.
1957 if ($self->resolution eq 'DUPLICATE') {
1958 if ($params->{dupe_of
}) {
1959 $self->set_dup_id($params->{dupe_of
});
1961 elsif (!$self->dup_id) {
1962 ThrowUserError
('dupe_id_required');
1966 sub clear_resolution
{
1968 if (!$self->status->is_open) {
1969 ThrowUserError
('resolution_cant_clear', { bug_id
=> $self->id });
1971 if (Bugzilla
->params->{'commentonclearresolution'}
1972 && $self->resolution && !$self->{added_comments
})
1974 ThrowUserError
('comment_required');
1976 $self->{'resolution'} = '';
1977 $self->_clear_dup_id;
1979 sub set_severity
{ $_[0]->set('bug_severity', $_[1]); }
1981 my ($self, $status, $params) = @_;
1982 my $old_status = $self->status;
1983 $self->set('bug_status', $status);
1984 delete $self->{'status'};
1985 my $new_status = $self->status;
1987 if ($new_status->is_open) {
1988 # Check for the everconfirmed transition
1989 $self->_set_everconfirmed(1) if $new_status->name ne 'UNCONFIRMED';
1990 $self->clear_resolution();
1993 # We do this here so that we can make sure closed statuses have
1995 my $resolution = delete $params->{resolution
} || $self->resolution;
1996 $self->set_resolution($resolution, $params);
1998 # Changing between closed statuses zeros the remaining time.
1999 if ($new_status->id != $old_status->id && $self->remaining_time != 0) {
2000 $self->_zero_remaining_time();
2004 sub set_status_whiteboard
{ $_[0]->set('status_whiteboard', $_[1]); }
2005 sub set_summary
{ $_[0]->set('short_desc', $_[1]); }
2006 sub set_target_milestone
{ $_[0]->set('target_milestone', $_[1]); }
2007 sub set_url
{ $_[0]->set('bug_file_loc', $_[1]); }
2008 sub set_version
{ $_[0]->set('version', $_[1]); }
2010 ########################
2011 # "Add/Remove" Methods #
2012 ########################
2014 # These are in alphabetical order by field name.
2016 # Accepts a User object or a username. Adds the user only if they
2017 # don't already exist as a CC on the bug.
2019 my ($self, $user_or_name) = @_;
2020 return if !$user_or_name;
2021 my $user = ref $user_or_name ?
$user_or_name
2022 : Bugzilla
::User
->check($user_or_name);
2023 $self->_check_strict_isolation_for_user($user);
2024 my $cc_users = $self->cc_users;
2025 push(@
$cc_users, $user) if !grep($_->id == $user->id, @
$cc_users);
2028 # Accepts a User object or a username. Removes the User if they exist
2029 # in the list, but doesn't throw an error if they don't exist.
2031 my ($self, $user_or_name) = @_;
2032 my $user = ref $user_or_name ?
$user_or_name
2033 : Bugzilla
::User
->check($user_or_name);
2034 my $cc_users = $self->cc_users;
2035 @
$cc_users = grep { $_->id != $user->id } @
$cc_users;
2038 # $bug->add_comment("comment", {isprivate => 1, work_time => 10.5,
2039 # type => CMT_NORMAL, extra_data => $data});
2041 my ($self, $comment, $params) = @_;
2043 $comment = $self->_check_comment($comment);
2046 if (exists $params->{work_time
}) {
2047 $params->{work_time
} = $self->_check_work_time($params->{work_time
});
2048 ThrowUserError
('comment_required')
2049 if $comment eq '' && $params->{work_time
} != 0;
2051 if (exists $params->{type
}) {
2052 $params->{type
} = $self->_check_comment_type($params->{type
});
2054 if (exists $params->{isprivate
}) {
2055 $params->{isprivate
} =
2056 $self->_check_commentprivacy($params->{isprivate
});
2058 # XXX We really should check extra_data, too.
2060 if ($comment eq '' && !($params->{type
} || $params->{work_time
})) {
2064 # So we really want to comment. Make sure we are allowed to do so.
2066 $self->check_can_change_field('longdesc', 0, 1, \
$privs)
2067 || ThrowUserError
('illegal_change', { field
=> 'longdesc', privs
=> $privs });
2069 $self->{added_comments
} ||= [];
2070 my $add_comment = dclone
($params);
2071 $add_comment->{thetext
} = $comment;
2073 # We only want to trick_taint fields that we know about--we don't
2074 # want to accidentally let somebody set some field that's not OK
2076 foreach my $field (UPDATE_COMMENT_COLUMNS
) {
2077 trick_taint
($add_comment->{$field}) if defined $add_comment->{$field};
2080 push(@
{$self->{added_comments
}}, $add_comment);
2083 # There was a lot of duplicate code when I wrote this as three separate
2084 # functions, so I just combined them all into one. This is also easier for
2085 # process_bug to use.
2086 sub modify_keywords
{
2087 my ($self, $keywords, $action) = @_;
2089 $action ||= "makeexact";
2090 if (!grep($action eq $_, qw(add delete makeexact))) {
2091 $action = "makeexact";
2094 $keywords = $self->_check_keywords($keywords);
2096 my (@result, $any_changes);
2097 if ($action eq 'makeexact') {
2098 @result = @
$keywords;
2099 # Check if anything was added or removed.
2100 my @old_ids = map { $_->id } @
{$self->keyword_objects};
2101 my @new_ids = map { $_->id } @result;
2102 my ($removed, $added) = diff_arrays
(\
@old_ids, \
@new_ids);
2103 $any_changes = scalar @
$removed || scalar @
$added;
2106 # We're adding or deleting specific keywords.
2107 my %keys = map {$_->id => $_} @
{$self->keyword_objects};
2108 if ($action eq 'add') {
2109 $keys{$_->id} = $_ foreach @
$keywords;
2112 delete $keys{$_->id} foreach @
$keywords;
2114 @result = values %keys;
2115 $any_changes = scalar @
$keywords;
2117 # Make sure we retain the sort order.
2118 @result = sort {lc($a->name) cmp lc($b->name)} @result;
2122 my $new = join(', ', (map {$_->name} @result));
2123 my $check = $self->check_can_change_field('keywords', 0, 1, \
$privs)
2124 || ThrowUserError
('illegal_change', { field
=> 'keywords',
2125 oldvalue
=> $self->keywords,
2130 $self->{'keyword_objects'} = \
@result;
2131 return $any_changes;
2135 my ($self, $group) = @_;
2136 # Invalid ids are silently ignored. (We can't tell people whether
2137 # or not a group exists.)
2138 $group = new Bugzilla
::Group
($group) unless ref $group;
2139 return unless $group;
2141 # Make sure that bugs in this product can actually be restricted
2143 grep($group->id == $_->id, @
{$self->product_obj->groups_valid})
2144 || ThrowUserError
('group_invalid_restriction',
2145 { product
=> $self->product, group_id
=> $group->id });
2147 # OtherControl people can add groups only during a product change,
2148 # and only when the group is not NA for them.
2149 if (!Bugzilla
->user->in_group($group->name)) {
2150 my $controls = $self->product_obj->group_controls->{$group->id};
2151 if (!$self->{_old_product_name
}
2152 || $controls->{othercontrol
} == CONTROLMAPNA
)
2154 ThrowUserError
('group_change_denied',
2155 { bug
=> $self, group_id
=> $group->id });
2159 my $current_groups = $self->groups_in;
2160 if (!grep($group->id == $_->id, @
$current_groups)) {
2161 push(@
$current_groups, $group);
2166 my ($self, $group) = @_;
2167 $group = new Bugzilla
::Group
($group) unless ref $group;
2168 return unless $group;
2170 # First, check if this is a valid group for this product.
2171 # You can *always* remove a group that is not valid for this product, so
2172 # we don't do any other checks if that's the case. (set_product does this.)
2174 # This particularly happens when isbuggroup is no longer 1, and we're
2175 # moving a bug to a new product.
2176 if (grep($_->id == $group->id, @
{$self->product_obj->groups_valid})) {
2177 my $controls = $self->product_obj->group_controls->{$group->id};
2179 # Nobody can ever remove a Mandatory group.
2180 if ($controls->{membercontrol
} == CONTROLMAPMANDATORY
) {
2181 ThrowUserError
('group_invalid_removal',
2182 { product
=> $self->product, group_id
=> $group->id,
2186 # OtherControl people can remove groups only during a product change,
2187 # and only when they are non-Mandatory and non-NA.
2188 if (!Bugzilla
->user->in_group($group->name)) {
2189 if (!$self->{_old_product_name
}
2190 || $controls->{othercontrol
} == CONTROLMAPMANDATORY
2191 || $controls->{othercontrol
} == CONTROLMAPNA
)
2193 ThrowUserError
('group_change_denied',
2194 { bug
=> $self, group_id
=> $group->id });
2199 my $current_groups = $self->groups_in;
2200 @
$current_groups = grep { $_->id != $group->id } @
$current_groups;
2203 #####################################################################
2204 # Instance Accessors
2205 #####################################################################
2207 # These subs are in alphabetical order, as much as possible.
2208 # If you add a new sub, please try to keep it in alphabetical order
2209 # with the other ones.
2211 # Note: If you add a new method, remember that you must check the error
2212 # state of the bug before returning any data. If $self->{error} is
2213 # defined, then return something empty. Otherwise you risk potential
2218 return $self->{'dup_id'} if exists $self->{'dup_id'};
2220 $self->{'dup_id'} = undef;
2221 return if $self->{'error'};
2223 if ($self->{'resolution'} eq 'DUPLICATE') {
2224 my $dbh = Bugzilla
->dbh;
2226 $dbh->selectrow_array(q{SELECT dupe_of
2232 return $self->{'dup_id'};
2237 return $self->{'actual_time'} if exists $self->{'actual_time'};
2239 if ( $self->{'error'} ||
2240 !Bugzilla
->user->in_group(Bugzilla
->params->{"timetrackinggroup"}) ) {
2241 $self->{'actual_time'} = undef;
2242 return $self->{'actual_time'};
2245 my $sth = Bugzilla
->dbh->prepare("SELECT SUM(work_time)
2247 WHERE longdescs.bug_id=?");
2248 $sth->execute($self->{bug_id
});
2249 $self->{'actual_time'} = $sth->fetchrow_array();
2250 return $self->{'actual_time'};
2253 sub any_flags_requesteeble
{
2255 return $self->{'any_flags_requesteeble'}
2256 if exists $self->{'any_flags_requesteeble'};
2257 return 0 if $self->{'error'};
2259 $self->{'any_flags_requesteeble'} =
2260 grep($_->{'is_requesteeble'}, @
{$self->flag_types});
2262 return $self->{'any_flags_requesteeble'};
2267 return $self->{'attachments'} if exists $self->{'attachments'};
2268 return [] if $self->{'error'};
2270 my $attachments = Bugzilla
::Attachment
->get_attachments_by_bug($self->bug_id);
2271 $_->{'flags'} = [] foreach @
$attachments;
2272 my %att = map { $_->id => $_ } @
$attachments;
2274 # Retrieve all attachment flags at once for this bug, and group them
2275 # by attachment. We populate attachment flags here to avoid querying
2276 # the DB for each attachment individually later.
2277 my $flags = Bugzilla
::Flag
->match({ 'bug_id' => $self->bug_id,
2278 'target_type' => 'attachment' });
2280 # Exclude flags for private attachments you cannot see.
2281 @
$flags = grep {exists $att{$_->attach_id}} @
$flags;
2283 push(@
{$att{$_->attach_id}->{'flags'}}, $_) foreach @
$flags;
2285 $self->{'attachments'} = [sort {$a->id <=> $b->id} values %att];
2286 return $self->{'attachments'};
2291 return $self->{'assigned_to_obj'} if exists $self->{'assigned_to_obj'};
2292 $self->{'assigned_to'} = 0 if $self->{'error'};
2293 $self->{'assigned_to_obj'} ||= new Bugzilla
::User
($self->{'assigned_to'});
2294 return $self->{'assigned_to_obj'};
2299 return $self->{'blocked'} if exists $self->{'blocked'};
2300 return [] if $self->{'error'};
2301 $self->{'blocked'} = EmitDependList
("dependson", "blocked", $self->bug_id);
2302 return $self->{'blocked'};
2305 # Even bugs in an error state always have a bug_id.
2306 sub bug_id
{ $_[0]->{'bug_id'}; }
2310 return $self->{'cc'} if exists $self->{'cc'};
2311 return [] if $self->{'error'};
2313 my $dbh = Bugzilla
->dbh;
2314 $self->{'cc'} = $dbh->selectcol_arrayref(
2315 q{SELECT profiles.login_name FROM cc, profiles
2317 AND cc.who = profiles.userid
2318 ORDER BY profiles.login_name},
2319 undef, $self->bug_id);
2321 $self->{'cc'} = undef if !scalar(@
{$self->{'cc'}});
2323 return $self->{'cc'};
2326 # XXX Eventually this will become the standard "cc" method used everywhere.
2329 return $self->{'cc_users'} if exists $self->{'cc_users'};
2330 return [] if $self->{'error'};
2332 my $dbh = Bugzilla
->dbh;
2333 my $cc_ids = $dbh->selectcol_arrayref(
2334 'SELECT who FROM cc WHERE bug_id = ?', undef, $self->id);
2335 $self->{'cc_users'} = Bugzilla
::User
->new_from_list($cc_ids);
2336 return $self->{'cc_users'};
2341 return $self->{component
} if exists $self->{component
};
2342 return '' if $self->{error
};
2343 ($self->{component
}) = Bugzilla
->dbh->selectrow_array(
2344 'SELECT name FROM components WHERE id = ?',
2345 undef, $self->{component_id
});
2346 return $self->{component
};
2349 # XXX Eventually this will replace component()
2352 return $self->{component_obj
} if defined $self->{component_obj
};
2353 return {} if $self->{error
};
2354 $self->{component_obj
} = new Bugzilla
::Component
($self->{component_id
});
2355 return $self->{component_obj
};
2358 sub classification_id
{
2360 return $self->{classification_id
} if exists $self->{classification_id
};
2361 return 0 if $self->{error
};
2362 ($self->{classification_id
}) = Bugzilla
->dbh->selectrow_array(
2363 'SELECT classification_id FROM products WHERE id = ?',
2364 undef, $self->{product_id
});
2365 return $self->{classification_id
};
2368 sub classification
{
2370 return $self->{classification
} if exists $self->{classification
};
2371 return '' if $self->{error
};
2372 ($self->{classification
}) = Bugzilla
->dbh->selectrow_array(
2373 'SELECT name FROM classifications WHERE id = ?',
2374 undef, $self->classification_id);
2375 return $self->{classification
};
2380 return $self->{'dependson'} if exists $self->{'dependson'};
2381 return [] if $self->{'error'};
2382 $self->{'dependson'} =
2383 EmitDependList
("blocked", "dependson", $self->bug_id);
2384 return $self->{'dependson'};
2389 return $self->{'flag_types'} if exists $self->{'flag_types'};
2390 return [] if $self->{'error'};
2392 # The types of flags that can be set on this bug.
2393 # If none, no UI for setting flags will be displayed.
2394 my $flag_types = Bugzilla
::FlagType
::match
(
2395 {'target_type' => 'bug',
2396 'product_id' => $self->{'product_id'},
2397 'component_id' => $self->{'component_id'} });
2399 $_->{'flags'} = [] foreach @
$flag_types;
2400 my %flagtypes = map { $_->id => $_ } @
$flag_types;
2402 # Retrieve all bug flags at once for this bug and group them
2404 my $flags = Bugzilla
::Flag
->match({ 'bug_id' => $self->bug_id,
2405 'target_type' => 'bug' });
2407 # Call the internal 'type_id' variable instead of the method
2408 # to not create a flagtype object.
2409 push(@
{$flagtypes{$_->{'type_id'}}->{'flags'}}, $_) foreach @
$flags;
2411 $self->{'flag_types'} =
2412 [sort {$a->sortkey <=> $b->sortkey || $a->name cmp $b->name} values %flagtypes];
2414 return $self->{'flag_types'};
2419 return is_open_state
($self->{bug_status
}) ?
1 : 0;
2424 return ($self->bug_status eq 'UNCONFIRMED') ?
1 : 0;
2429 return join(', ', (map { $_->name } @
{$self->keyword_objects}));
2432 # XXX At some point, this should probably replace the normal "keywords" sub.
2433 sub keyword_objects
{
2435 return $self->{'keyword_objects'} if defined $self->{'keyword_objects'};
2436 return [] if $self->{'error'};
2438 my $dbh = Bugzilla
->dbh;
2439 my $ids = $dbh->selectcol_arrayref(
2440 "SELECT keywordid FROM keywords WHERE bug_id = ?", undef, $self->id);
2441 $self->{'keyword_objects'} = Bugzilla
::Keyword
->new_from_list($ids);
2442 return $self->{'keyword_objects'};
2447 return $self->{'longdescs'} if exists $self->{'longdescs'};
2448 return [] if $self->{'error'};
2449 $self->{'longdescs'} = GetComments
($self->{bug_id
});
2450 return $self->{'longdescs'};
2455 return $self->{'milestoneurl'} if exists $self->{'milestoneurl'};
2456 return '' if $self->{'error'};
2458 $self->{'milestoneurl'} = $self->product_obj->milestone_url;
2459 return $self->{'milestoneurl'};
2464 return $self->{product
} if exists $self->{product
};
2465 return '' if $self->{error
};
2466 ($self->{product
}) = Bugzilla
->dbh->selectrow_array(
2467 'SELECT name FROM products WHERE id = ?',
2468 undef, $self->{product_id
});
2469 return $self->{product
};
2472 # XXX This should eventually replace the "product" subroutine.
2475 return {} if $self->{error
};
2476 $self->{product_obj
} ||= new Bugzilla
::Product
($self->{product_id
});
2477 return $self->{product_obj
};
2482 return $self->{'qa_contact_obj'} if exists $self->{'qa_contact_obj'};
2483 return undef if $self->{'error'};
2485 if (Bugzilla
->params->{'useqacontact'} && $self->{'qa_contact'}) {
2486 $self->{'qa_contact_obj'} = new Bugzilla
::User
($self->{'qa_contact'});
2488 # XXX - This is somewhat inconsistent with the assignee/reporter
2489 # methods, which will return an empty User if they get a 0.
2490 # However, we're keeping it this way now, for backwards-compatibility.
2491 $self->{'qa_contact_obj'} = undef;
2493 return $self->{'qa_contact_obj'};
2498 return $self->{'reporter'} if exists $self->{'reporter'};
2499 $self->{'reporter_id'} = 0 if $self->{'error'};
2500 $self->{'reporter'} = new Bugzilla
::User
($self->{'reporter_id'});
2501 return $self->{'reporter'};
2506 return undef if $self->{'error'};
2508 $self->{'status'} ||= new Bugzilla
::Status
({name
=> $self->{'bug_status'}});
2509 return $self->{'status'};
2512 sub show_attachment_flags
{
2514 return $self->{'show_attachment_flags'}
2515 if exists $self->{'show_attachment_flags'};
2516 return 0 if $self->{'error'};
2518 # The number of types of flags that can be set on attachments to this bug
2519 # and the number of flags on those attachments. One of these counts must be
2520 # greater than zero in order for the "flags" column to appear in the table
2522 my $num_attachment_flag_types = Bugzilla
::FlagType
::count
(
2523 { 'target_type' => 'attachment',
2524 'product_id' => $self->{'product_id'},
2525 'component_id' => $self->{'component_id'} });
2526 my $num_attachment_flags = Bugzilla
::Flag
->count(
2527 { 'target_type' => 'attachment',
2528 'bug_id' => $self->bug_id });
2530 $self->{'show_attachment_flags'} =
2531 ($num_attachment_flag_types || $num_attachment_flags);
2533 return $self->{'show_attachment_flags'};
2538 return 0 if $self->{'error'};
2540 return Bugzilla
->params->{'usevotes'}
2541 && $self->product_obj->votes_per_user > 0;
2546 return $self->{'groups'} if exists $self->{'groups'};
2547 return [] if $self->{'error'};
2549 my $dbh = Bugzilla
->dbh;
2552 # Some of this stuff needs to go into Bugzilla::User
2554 # For every group, we need to know if there is ANY bug_group_map
2555 # record putting the current bug in that group and if there is ANY
2556 # user_group_map record putting the user in that group.
2557 # The LEFT JOINs are checking for record existence.
2559 my $grouplist = Bugzilla
->user->groups_as_string;
2560 my $sth = $dbh->prepare(
2561 "SELECT DISTINCT groups.id, name, description," .
2562 " CASE WHEN bug_group_map.group_id IS NOT NULL" .
2563 " THEN 1 ELSE 0 END," .
2564 " CASE WHEN groups.id IN($grouplist) THEN 1 ELSE 0 END," .
2565 " isactive, membercontrol, othercontrol" .
2567 " LEFT JOIN bug_group_map" .
2568 " ON bug_group_map.group_id = groups.id" .
2570 " LEFT JOIN group_control_map" .
2571 " ON group_control_map.group_id = groups.id" .
2572 " AND group_control_map.product_id = ? " .
2573 " WHERE isbuggroup = 1" .
2574 " ORDER BY description");
2575 $sth->execute($self->{'bug_id'},
2576 $self->{'product_id'});
2578 while (my ($groupid, $name, $description, $ison, $ingroup, $isactive,
2579 $membercontrol, $othercontrol) = $sth->fetchrow_array()) {
2581 $membercontrol ||= 0;
2583 # For product groups, we only want to use the group if either
2584 # (1) The bit is set and not required, or
2585 # (2) The group is Shown or Default for members and
2586 # the user is a member of the group.
2588 ($isactive && $ingroup
2589 && (($membercontrol == CONTROLMAPDEFAULT
)
2590 || ($membercontrol == CONTROLMAPSHOWN
))
2593 my $ismandatory = $isactive
2594 && ($membercontrol == CONTROLMAPMANDATORY
);
2596 push (@groups, { "bit" => $groupid,
2599 "ingroup" => $ingroup,
2600 "mandatory" => $ismandatory,
2601 "description" => $description });
2605 $self->{'groups'} = \
@groups;
2607 return $self->{'groups'};
2612 return $self->{'groups_in'} if exists $self->{'groups_in'};
2613 return [] if $self->{'error'};
2614 my $group_ids = Bugzilla
->dbh->selectcol_arrayref(
2615 'SELECT group_id FROM bug_group_map WHERE bug_id = ?',
2617 $self->{'groups_in'} = Bugzilla
::Group
->new_from_list($group_ids);
2618 return $self->{'groups_in'};
2623 return $self->{'user'} if exists $self->{'user'};
2624 return {} if $self->{'error'};
2626 my $user = Bugzilla
->user;
2627 my $canmove = Bugzilla
->params->{'move-enabled'} && $user->is_mover;
2629 my $prod_id = $self->{'product_id'};
2631 my $unknown_privileges = $user->in_group('editbugs', $prod_id);
2632 my $canedit = $unknown_privileges
2633 || $user->id == $self->{'assigned_to'}
2634 || (Bugzilla
->params->{'useqacontact'}
2635 && $self->{'qa_contact'}
2636 && $user->id == $self->{'qa_contact'});
2637 my $canconfirm = $unknown_privileges
2638 || $user->in_group('canconfirm', $prod_id);
2639 my $isreporter = $user->id
2640 && $user->id == $self->{reporter_id
};
2642 $self->{'user'} = {canmove
=> $canmove,
2643 canconfirm
=> $canconfirm,
2644 canedit
=> $canedit,
2645 isreporter
=> $isreporter};
2646 return $self->{'user'};
2651 return $self->{'choices'} if exists $self->{'choices'};
2652 return {} if $self->{'error'};
2654 $self->{'choices'} = {};
2656 my @prodlist = map {$_->name} @
{Bugzilla
->user->get_enterable_products};
2657 # The current product is part of the popup, even if new bugs are no longer
2658 # allowed for that product
2659 if (lsearch
(\
@prodlist, $self->product) < 0) {
2660 push(@prodlist, $self->product);
2661 @prodlist = sort @prodlist;
2664 # Hack - this array contains "". See bug 106589.
2665 my @res = grep ($_, @
{get_legal_field_values
('resolution')});
2667 $self->{'choices'} =
2669 'product' => \
@prodlist,
2670 'rep_platform' => get_legal_field_values
('rep_platform'),
2671 'priority' => get_legal_field_values
('priority'),
2672 'bug_severity' => get_legal_field_values
('bug_severity'),
2673 'op_sys' => get_legal_field_values
('op_sys'),
2674 'bug_status' => get_legal_field_values
('bug_status'),
2675 'resolution' => \
@res,
2676 'component' => [map($_->name, @
{$self->product_obj->components})],
2677 'version' => [map($_->name, @
{$self->product_obj->versions})],
2678 'target_milestone' => [map($_->name, @
{$self->product_obj->milestones})],
2681 return $self->{'choices'};
2686 return 0 if $self->{error
};
2687 return $self->{votes
} if defined $self->{votes
};
2689 my $dbh = Bugzilla
->dbh;
2690 $self->{votes
} = $dbh->selectrow_array(
2691 'SELECT SUM(vote_count) FROM votes
2692 WHERE bug_id = ? ' . $dbh->sql_group_by('bug_id'),
2693 undef, $self->bug_id);
2694 $self->{votes
} ||= 0;
2695 return $self->{votes
};
2698 # Convenience Function. If you need speed, use this. If you need
2699 # other Bug fields in addition to this, just create a new Bug with
2701 # Queries the database for the bug with a given alias, and returns
2702 # the ID of the bug if it exists or the undefined value if it doesn't.
2703 sub bug_alias_to_id
{
2705 return undef unless Bugzilla
->params->{"usebugaliases"};
2706 my $dbh = Bugzilla
->dbh;
2707 trick_taint
($alias);
2708 return $dbh->selectrow_array(
2709 "SELECT bug_id FROM bugs WHERE alias = ?", undef, $alias);
2712 #####################################################################
2714 #####################################################################
2716 sub update_comment
{
2717 my ($self, $comment_id, $new_comment) = @_;
2719 # Some validation checks.
2720 if ($self->{'error'}) {
2721 ThrowCodeError
("bug_error", { bug
=> $self });
2723 detaint_natural
($comment_id)
2724 || ThrowCodeError
('bad_arg', {argument
=> 'comment_id', function
=> 'update_comment'});
2726 # The comment ID must belong to this bug.
2727 my @current_comment_obj = grep {$_->{'id'} == $comment_id} @
{$self->longdescs};
2728 scalar(@current_comment_obj)
2729 || ThrowCodeError
('bad_arg', {argument
=> 'comment_id', function
=> 'update_comment'});
2731 # If the new comment is undefined, then there is nothing to update.
2732 # To delete a comment, an empty string should be passed.
2733 return unless defined $new_comment;
2734 $new_comment =~ s/\s*$//s; # Remove trailing whitespaces.
2735 $new_comment =~ s/\r\n?/\n/g; # Handle Windows and Mac-style line endings.
2736 trick_taint
($new_comment);
2738 # We assume _check_comment() has already been called earlier.
2739 Bugzilla
->dbh->do('UPDATE longdescs SET thetext = ? WHERE comment_id = ?',
2740 undef, ($new_comment, $comment_id));
2741 $self->_sync_fulltext();
2743 # Update the comment object with this new text.
2744 $current_comment_obj[0]->{'body'} = $new_comment;
2747 # Represents which fields from the bugs table are handled by process_bug.cgi.
2748 sub editable_bug_fields
{
2749 my @fields = Bugzilla
->dbh->bz_table_columns('bugs');
2750 # Obsolete custom fields are not editable.
2751 my @obsolete_fields = Bugzilla
->get_fields({obsolete
=> 1, custom
=> 1});
2752 @obsolete_fields = map { $_->name } @obsolete_fields;
2753 foreach my $remove ("bug_id", "reporter", "creation_ts", "delta_ts", "lastdiffed", @obsolete_fields) {
2754 my $location = lsearch
(\
@fields, $remove);
2755 # Custom multi-select fields are not stored in the bugs table.
2756 splice(@fields, $location, 1) if ($location > -1);
2758 # Sorted because the old @::log_columns variable, which this replaces,
2760 return sort(@fields);
2763 # XXX - When Bug::update() will be implemented, we should make this routine
2765 sub EmitDependList
{
2766 my ($myfield, $targetfield, $bug_id) = (@_);
2767 my $dbh = Bugzilla
->dbh;
2768 my $list_ref = $dbh->selectcol_arrayref(
2769 "SELECT $targetfield FROM dependencies
2770 WHERE $myfield = ? ORDER BY $targetfield",
2776 my ($time, $field) = @_;
2778 # regexp verifies one or more digits, optionally followed by a period and
2779 # zero or more digits, OR we have a period followed by one or more digits
2780 # (allow negatives, though, so people can back out errors in time reporting)
2781 if ($time !~ /^-?(?:\d+(?:\.\d*)?|\.\d+)$/) {
2782 ThrowUserError
("number_not_numeric",
2783 {field
=> "$field", num
=> "$time"});
2786 # Only the "work_time" field is allowed to contain a negative value.
2787 if ( ($time < 0) && ($field ne "work_time") ) {
2788 ThrowUserError
("number_too_small",
2789 {field
=> "$field", num
=> "$time", min_num
=> "0"});
2792 if ($time > 99999.99) {
2793 ThrowUserError
("number_too_large",
2794 {field
=> "$field", num
=> "$time", max_num
=> "99999.99"});
2799 my ($id, $comment_sort_order, $start, $end, $raw) = @_;
2800 my $dbh = Bugzilla
->dbh;
2802 $comment_sort_order = $comment_sort_order ||
2803 Bugzilla
->user->settings->{'comment_sort_order'}->{'value'};
2805 my $sort_order = ($comment_sort_order eq "oldest_to_newest") ?
'asc' : 'desc';
2810 my $query = 'SELECT longdescs.comment_id AS id, profiles.userid, ' .
2811 $dbh->sql_date_format('longdescs.bug_when', '%Y.%m.%d %H:%i:%s') .
2812 ' AS time, longdescs.thetext AS body, longdescs.work_time,
2813 isprivate, already_wrapped, type, extra_data
2816 ON profiles.userid = longdescs.who
2817 WHERE longdescs.bug_id = ?';
2819 $query .= ' AND longdescs.bug_when > ?
2820 AND longdescs.bug_when <= ?';
2821 push(@args, ($start, $end));
2823 $query .= " ORDER BY longdescs.bug_when $sort_order";
2824 my $sth = $dbh->prepare($query);
2825 $sth->execute(@args);
2827 while (my $comment_ref = $sth->fetchrow_hashref()) {
2828 my %comment = %$comment_ref;
2829 $comment{'author'} = new Bugzilla
::User
($comment{'userid'});
2831 # If raw data is requested, do not format 'special' comments.
2832 $comment{'body'} = format_comment
(\
%comment) unless $raw;
2834 push (@comments, \
%comment);
2837 if ($comment_sort_order eq "newest_to_oldest_desc_first") {
2838 unshift(@comments, pop @comments);
2844 # Format language specific comments. This routine must not update
2845 # $comment{'body'} itself, see BugMail::prepare_comments().
2846 sub format_comment
{
2847 my $comment = shift;
2850 if ($comment->{'type'} == CMT_DUPE_OF
) {
2851 $body = $comment->{'body'} . "\n\n" .
2852 get_text
('bug_duplicate_of', { dupe_of
=> $comment->{'extra_data'} });
2854 elsif ($comment->{'type'} == CMT_HAS_DUPE
) {
2855 $body = get_text
('bug_has_duplicate', { dupe
=> $comment->{'extra_data'} });
2857 elsif ($comment->{'type'} == CMT_POPULAR_VOTES
) {
2858 $body = get_text
('bug_confirmed_by_votes');
2860 elsif ($comment->{'type'} == CMT_MOVED_TO
) {
2861 $body = $comment->{'body'} . "\n\n" .
2862 get_text
('bug_moved_to', { login
=> $comment->{'extra_data'} });
2865 $body = $comment->{'body'};
2870 # Get the activity of a bug, starting from $starttime (if given).
2871 # This routine assumes ValidateBugID has been previously called.
2872 sub GetBugActivity
{
2873 my ($bug_id, $attach_id, $starttime) = @_;
2874 my $dbh = Bugzilla
->dbh;
2876 # Arguments passed to the SQL query.
2877 my @args = ($bug_id);
2879 # Only consider changes since $starttime, if given.
2881 if (defined $starttime) {
2882 trick_taint
($starttime);
2883 push (@args, $starttime);
2884 $datepart = "AND bugs_activity.bug_when > ?";
2887 my $attachpart = "";
2889 push(@args, $attach_id);
2890 $attachpart = "AND bugs_activity.attach_id = ?";
2893 # Only includes attachments the user is allowed to see.
2896 if (Bugzilla
->params->{"insidergroup"}
2897 && !Bugzilla
->user->in_group(Bugzilla
->params->{'insidergroup'}))
2899 $suppjoins = "LEFT JOIN attachments
2900 ON attachments.attach_id = bugs_activity.attach_id";
2901 $suppwhere = "AND COALESCE(attachments.isprivate, 0) = 0";
2905 SELECT COALESCE(fielddefs.description, "
2906 # This is a hack - PostgreSQL requires both COALESCE
2907 # arguments to be of the same type, and this is the only
2908 # way supported by both MySQL 3 and PostgreSQL to convert
2909 # an integer to a string. MySQL 4 supports CAST.
2910 . $dbh->sql_string_concat('bugs_activity.fieldid', q{''}) .
2911 "), fielddefs.name, bugs_activity.attach_id, " .
2912 $dbh->sql_date_format('bugs_activity.bug_when', '%Y.%m.%d %H:%i:%s') .
2913 ", bugs_activity.removed, bugs_activity.added, profiles.login_name
2917 ON bugs_activity.fieldid = fielddefs.id
2919 ON profiles.userid = bugs_activity.who
2920 WHERE bugs_activity.bug_id = ?
2924 ORDER BY bugs_activity.bug_when";
2926 my $list = $dbh->selectall_arrayref($query, undef, @args);
2931 my $incomplete_data = 0;
2933 foreach my $entry (@
$list) {
2934 my ($field, $fieldname, $attachid, $when, $removed, $added, $who) = @
$entry;
2936 my $activity_visible = 1;
2938 # check if the user should see this field's activity
2939 if ($fieldname eq 'remaining_time'
2940 || $fieldname eq 'estimated_time'
2941 || $fieldname eq 'work_time'
2942 || $fieldname eq 'deadline')
2945 Bugzilla
->user->in_group(Bugzilla
->params->{'timetrackinggroup'}) ?
1 : 0;
2947 $activity_visible = 1;
2950 if ($activity_visible) {
2951 # This gets replaced with a hyperlink in the template.
2952 $field =~ s/^Attachment\s*// if $attachid;
2954 # Check for the results of an old Bugzilla data corruption bug
2955 $incomplete_data = 1 if ($added =~ /^\?/ || $removed =~ /^\?/);
2957 # An operation, done by 'who' at time 'when', has a number of
2958 # 'changes' associated with it.
2959 # If this is the start of a new operation, store the data from the
2960 # previous one, and set up the new one.
2961 if ($operation->{'who'}
2962 && ($who ne $operation->{'who'}
2963 || $when ne $operation->{'when'}))
2965 $operation->{'changes'} = $changes;
2966 push (@operations, $operation);
2968 # Create new empty anonymous data structures.
2973 $operation->{'who'} = $who;
2974 $operation->{'when'} = $when;
2976 $change{'field'} = $field;
2977 $change{'fieldname'} = $fieldname;
2978 $change{'attachid'} = $attachid;
2979 $change{'removed'} = $removed;
2980 $change{'added'} = $added;
2981 push (@
$changes, \
%change);
2985 if ($operation->{'who'}) {
2986 $operation->{'changes'} = $changes;
2987 push (@operations, $operation);
2990 return(\
@operations, $incomplete_data);
2993 # Update the bugs_activity table to reflect changes made in bugs.
2994 sub LogActivityEntry
{
2995 my ($i, $col, $removed, $added, $whoid, $timestamp) = @_;
2996 my $dbh = Bugzilla
->dbh;
2997 # in the case of CCs, deps, and keywords, there's a possibility that someone
2998 # might try to add or remove a lot of them at once, which might take more
2999 # space than the activity table allows. We'll solve this by splitting it
3000 # into multiple entries if it's too long.
3001 while ($removed || $added) {
3002 my ($removestr, $addstr) = ($removed, $added);
3003 if (length($removestr) > MAX_LINE_LENGTH
) {
3004 my $commaposition = find_wrap_point
($removed, MAX_LINE_LENGTH
);
3005 $removestr = substr($removed, 0, $commaposition);
3006 $removed = substr($removed, $commaposition);
3007 $removed =~ s/^[,\s]+//; # remove any comma or space
3009 $removed = ""; # no more entries
3011 if (length($addstr) > MAX_LINE_LENGTH
) {
3012 my $commaposition = find_wrap_point
($added, MAX_LINE_LENGTH
);
3013 $addstr = substr($added, 0, $commaposition);
3014 $added = substr($added, $commaposition);
3015 $added =~ s/^[,\s]+//; # remove any comma or space
3017 $added = ""; # no more entries
3019 trick_taint
($addstr);
3020 trick_taint
($removestr);
3021 my $fieldid = get_field_id
($col);
3022 $dbh->do("INSERT INTO bugs_activity
3023 (bug_id, who, bug_when, fieldid, removed, added)
3024 VALUES (?, ?, ?, ?, ?, ?)",
3025 undef, ($i, $whoid, $timestamp, $fieldid, $removestr, $addstr));
3029 # CountOpenDependencies counts the number of open dependent bugs for a
3030 # list of bugs and returns a list of bug_id's and their dependency count
3031 # It takes one parameter:
3032 # - A list of bug numbers whose dependencies are to be checked
3033 sub CountOpenDependencies
{
3034 my (@bug_list) = @_;
3036 my $dbh = Bugzilla
->dbh;
3038 my $sth = $dbh->prepare(
3039 "SELECT blocked, COUNT(bug_status) " .
3040 "FROM bugs, dependencies " .
3041 "WHERE " . $dbh->sql_in('blocked', \
@bug_list) .
3042 "AND bug_id = dependson " .
3043 "AND bug_status IN (" . join(', ', map {$dbh->quote($_)} BUG_STATE_OPEN
) . ") " .
3044 $dbh->sql_group_by('blocked'));
3047 while (my ($bug_id, $dependencies) = $sth->fetchrow_array()) {
3048 push(@dependencies, { bug_id
=> $bug_id,
3049 dependencies
=> $dependencies });
3052 return @dependencies;
3055 # If a bug is moved to a product which allows less votes per bug
3056 # compared to the previous product, extra votes need to be removed.
3058 my ($id, $who, $reason) = (@_);
3059 my $dbh = Bugzilla
->dbh;
3061 my $whopart = ($who) ?
" AND votes.who = $who" : "";
3063 my $sth = $dbh->prepare("SELECT profiles.login_name, " .
3064 "profiles.userid, votes.vote_count, " .
3065 "products.votesperuser, products.maxvotesperbug " .
3067 "LEFT JOIN votes ON profiles.userid = votes.who " .
3068 "LEFT JOIN bugs ON votes.bug_id = bugs.bug_id " .
3069 "LEFT JOIN products ON products.id = bugs.product_id " .
3070 "WHERE votes.bug_id = ? " . $whopart);
3073 while (my ($name, $userid, $oldvotes, $votesperuser, $maxvotesperbug) = $sth->fetchrow_array()) {
3074 push(@list, [$name, $userid, $oldvotes, $votesperuser, $maxvotesperbug]);
3077 # @messages stores all emails which have to be sent, if any.
3078 # This array is passed to the caller which will send these emails itself.
3081 if (scalar(@list)) {
3082 foreach my $ref (@list) {
3083 my ($name, $userid, $oldvotes, $votesperuser, $maxvotesperbug) = (@
$ref);
3085 $maxvotesperbug = min
($votesperuser, $maxvotesperbug);
3087 # If this product allows voting and the user's votes are in
3088 # the acceptable range, then don't do anything.
3089 next if $votesperuser && $oldvotes <= $maxvotesperbug;
3091 # If the user has more votes on this bug than this product
3092 # allows, then reduce the number of votes so it fits
3093 my $newvotes = $maxvotesperbug;
3095 my $removedvotes = $oldvotes - $newvotes;
3098 $dbh->do("UPDATE votes SET vote_count = ? " .
3099 "WHERE bug_id = ? AND who = ?",
3100 undef, ($newvotes, $id, $userid));
3102 $dbh->do("DELETE FROM votes WHERE bug_id = ? AND who = ?",
3103 undef, ($id, $userid));
3106 # Notice that we did not make sure that the user fit within the $votesperuser
3107 # range. This is considered to be an acceptable alternative to losing votes
3108 # during product moves. Then next time the user attempts to change their votes,
3109 # they will be forced to fit within the $votesperuser limit.
3111 # Now lets send the e-mail to alert the user to the fact that their votes have
3112 # been reduced or removed.
3114 'to' => $name . Bugzilla
->params->{'emailsuffix'},
3116 'reason' => $reason,
3118 'votesremoved' => $removedvotes,
3119 'votesold' => $oldvotes,
3120 'votesnew' => $newvotes,
3123 my $voter = new Bugzilla
::User
($userid);
3124 my $template = Bugzilla
->template_inner($voter->settings->{'lang'}->{'value'});
3127 $template->process("email/votes-removed.txt.tmpl", $vars, \
$msg);
3128 push(@messages, $msg);
3130 Bugzilla
->template_inner("");
3132 my $votes = $dbh->selectrow_array("SELECT SUM(vote_count) " .
3133 "FROM votes WHERE bug_id = ?",
3135 $dbh->do("UPDATE bugs SET votes = ? WHERE bug_id = ?",
3136 undef, ($votes, $id));
3138 # Now return the array containing emails to be sent.
3142 # If a user votes for a bug, or the number of votes required to
3143 # confirm a bug has been reduced, check if the bug is now confirmed.
3144 sub CheckIfVotedConfirmed
{
3145 my ($id, $who) = (@_);
3146 my $dbh = Bugzilla
->dbh;
3148 # XXX - Use bug methods to update the bug status and everconfirmed.
3149 my $bug = new Bugzilla
::Bug
($id);
3151 my ($votes, $status, $everconfirmed, $votestoconfirm, $timestamp) =
3152 $dbh->selectrow_array("SELECT votes, bug_status, everconfirmed, " .
3153 " votestoconfirm, NOW() " .
3154 "FROM bugs INNER JOIN products " .
3155 " ON products.id = bugs.product_id " .
3156 "WHERE bugs.bug_id = ?",
3160 if ($votes >= $votestoconfirm && !$everconfirmed) {
3161 $bug->add_comment('', { type
=> CMT_POPULAR_VOTES
});
3164 if ($status eq 'UNCONFIRMED') {
3165 my $fieldid = get_field_id
("bug_status");
3166 $dbh->do("UPDATE bugs SET bug_status = 'NEW', everconfirmed = 1, " .
3167 "delta_ts = ? WHERE bug_id = ?",
3168 undef, ($timestamp, $id));
3169 $dbh->do("INSERT INTO bugs_activity " .
3170 "(bug_id, who, bug_when, fieldid, removed, added) " .
3171 "VALUES (?, ?, ?, ?, ?, ?)",
3172 undef, ($id, $who, $timestamp, $fieldid, 'UNCONFIRMED', 'NEW'));
3175 $dbh->do("UPDATE bugs SET everconfirmed = 1, delta_ts = ? " .
3176 "WHERE bug_id = ?", undef, ($timestamp, $id));
3179 my $fieldid = get_field_id
("everconfirmed");
3180 $dbh->do("INSERT INTO bugs_activity " .
3181 "(bug_id, who, bug_when, fieldid, removed, added) " .
3182 "VALUES (?, ?, ?, ?, ?, ?)",
3183 undef, ($id, $who, $timestamp, $fieldid, '0', '1'));
3190 ################################################################################
3191 # check_can_change_field() defines what users are allowed to change. You
3192 # can add code here for site-specific policy changes, according to the
3193 # instructions given in the Bugzilla Guide and below. Note that you may also
3194 # have to update the Bugzilla::Bug::user() function to give people access to the
3195 # options that they are permitted to change.
3197 # check_can_change_field() returns true if the user is allowed to change this
3198 # field, and false if they are not.
3200 # The parameters to this method are as follows:
3201 # $field - name of the field in the bugs table the user is trying to change
3202 # $oldvalue - what they are changing it from
3203 # $newvalue - what they are changing it to
3204 # $PrivilegesRequired - return the reason of the failure, if any
3205 ################################################################################
3206 sub check_can_change_field
{
3208 my ($field, $oldvalue, $newvalue, $PrivilegesRequired) = (@_);
3209 my $user = Bugzilla
->user;
3211 $oldvalue = defined($oldvalue) ?
$oldvalue : '';
3212 $newvalue = defined($newvalue) ?
$newvalue : '';
3214 # Return true if they haven't changed this field at all.
3215 if ($oldvalue eq $newvalue) {
3217 } elsif (ref($newvalue) eq 'ARRAY' && ref($oldvalue) eq 'ARRAY') {
3218 my ($removed, $added) = diff_arrays
($oldvalue, $newvalue);
3219 return 1 if !scalar(@
$removed) && !scalar(@
$added);
3220 } elsif (trim
($oldvalue) eq trim
($newvalue)) {
3222 # numeric fields need to be compared using ==
3223 } elsif (($field eq 'estimated_time' || $field eq 'remaining_time')
3224 && $oldvalue == $newvalue)
3229 # Allow anyone to change comments.
3230 if ($field =~ /^longdesc/) {
3234 # If the user isn't allowed to change a field, we must tell him who can.
3235 # We store the required permission set into the $PrivilegesRequired
3236 # variable which gets passed to the error template.
3238 # $PrivilegesRequired = 0 : no privileges required;
3239 # $PrivilegesRequired = 1 : the reporter, assignee or an empowered user;
3240 # $PrivilegesRequired = 2 : the assignee or an empowered user;
3241 # $PrivilegesRequired = 3 : an empowered user.
3243 # Only users in the time-tracking group can change time-tracking fields.
3244 if ( grep($_ eq $field, qw(deadline estimated_time remaining_time)) ) {
3245 my $tt_group = Bugzilla
->params->{timetrackinggroup
};
3246 if (!$tt_group || !$user->in_group($tt_group)) {
3247 $$PrivilegesRequired = 3;
3252 # Allow anyone with (product-specific) "editbugs" privs to change anything.
3253 if ($user->in_group('editbugs', $self->{'product_id'})) {
3257 # *Only* users with (product-specific) "canconfirm" privs can confirm bugs.
3258 if ($field eq 'canconfirm'
3259 || ($field eq 'bug_status'
3260 && $oldvalue eq 'UNCONFIRMED'
3261 && is_open_state
($newvalue)))
3263 $$PrivilegesRequired = 3;
3264 return $user->in_group('canconfirm', $self->{'product_id'});
3267 # Make sure that a valid bug ID has been given.
3268 if (!$self->{'error'}) {
3269 # Allow the assignee to change anything else.
3270 if ($self->{'assigned_to'} == $user->id
3271 || $self->{'_old_assigned_to'} && $self->{'_old_assigned_to'} == $user->id)
3276 # Allow the QA contact to change anything else.
3277 if (Bugzilla
->params->{'useqacontact'}
3278 && (($self->{'qa_contact'} && $self->{'qa_contact'} == $user->id)
3279 || ($self->{'_old_qa_contact'} && $self->{'_old_qa_contact'} == $user->id)))
3285 # At this point, the user is either the reporter or an
3286 # unprivileged user. We first check for fields the reporter
3287 # is not allowed to change.
3289 # The reporter may not:
3290 # - reassign bugs, unless the bugs are assigned to him;
3291 # in that case we will have already returned 1 above
3292 # when checking for the assignee of the bug.
3293 if ($field eq 'assigned_to') {
3294 $$PrivilegesRequired = 2;
3297 # - change the QA contact
3298 if ($field eq 'qa_contact') {
3299 $$PrivilegesRequired = 2;
3302 # - change the target milestone
3303 if ($field eq 'target_milestone') {
3304 $$PrivilegesRequired = 2;
3307 # - change the priority (unless he could have set it originally)
3308 if ($field eq 'priority'
3309 && !Bugzilla
->params->{'letsubmitterchoosepriority'})
3311 $$PrivilegesRequired = 2;
3315 # The reporter is allowed to change anything else.
3316 if (!$self->{'error'} && $self->{'reporter_id'} == $user->id) {
3320 # If we haven't returned by this point, then the user doesn't
3321 # have the necessary permissions to change this field.
3322 $$PrivilegesRequired = 1;
3330 # Validates and verifies a bug ID, making sure the number is a
3331 # positive integer, that it represents an existing bug in the
3332 # database, and that the user is authorized to access that bug.
3333 # We detaint the number here, too.
3335 my ($id, $field) = @_;
3336 my $dbh = Bugzilla
->dbh;
3337 my $user = Bugzilla
->user;
3339 ThrowUserError
('improper_bug_id_field_value', { field
=> $field }) unless defined $id;
3341 # Get rid of leading '#' (number) mark, if present.
3346 # If the ID isn't a number, it might be an alias, so try to convert it.
3348 if (!detaint_natural
($id)) {
3349 $id = bug_alias_to_id
($alias);
3350 $id || ThrowUserError
("improper_bug_id_field_value",
3351 {'bug_id' => $alias,
3352 'field' => $field });
3355 # Modify the calling code's original variable to contain the trimmed,
3356 # converted-from-alias ID.
3359 # First check that the bug exists
3360 $dbh->selectrow_array("SELECT bug_id FROM bugs WHERE bug_id = ?", undef, $id)
3361 || ThrowUserError
("bug_id_does_not_exist", {'bug_id' => $id});
3363 unless ($field && $field =~ /^(dependson|blocked|dup_id)$/) {
3364 check_is_visible
($id);
3368 sub check_is_visible
{
3370 my $user = Bugzilla
->user;
3372 return if $user->can_see_bug($id);
3374 # The error the user sees depends on whether or not they are logged in
3375 # (i.e. $user->id contains the user's positive integer ID).
3377 ThrowUserError
("bug_access_denied", {'bug_id' => $id});
3379 ThrowUserError
("bug_access_query", {'bug_id' => $id});
3383 # Validate and return a hash of dependencies
3384 sub ValidateDependencies
{
3386 # These can be arrayrefs or they can be strings.
3387 $fields->{'dependson'} = shift;
3388 $fields->{'blocked'} = shift;
3389 my $id = shift || 0;
3391 unless (defined($fields->{'dependson'})
3392 || defined($fields->{'blocked'}))
3397 my $dbh = Bugzilla
->dbh;
3400 foreach my $pair (["blocked", "dependson"], ["dependson", "blocked"]) {
3401 my ($me, $target) = @
{$pair};
3402 $deptree{$target} = [];
3403 $deps{$target} = [];
3404 next unless $fields->{$target};
3407 my $target_array = ref($fields->{$target}) ?
$fields->{$target}
3408 : [split(/[\s,]+/, $fields->{$target})];
3409 foreach my $i (@
$target_array) {
3411 ThrowUserError
("dependency_loop_single");
3413 if (!exists $seen{$i}) {
3414 push(@
{$deptree{$target}}, $i);
3418 # populate $deps{$target} as first-level deps only.
3419 # and find remainder of dependency tree in $deptree{$target}
3420 @
{$deps{$target}} = @
{$deptree{$target}};
3421 my @stack = @
{$deps{$target}};
3423 my $i = shift @stack;
3425 $dbh->selectcol_arrayref("SELECT $target
3427 WHERE $me = ?", undef, $i);
3428 foreach my $t (@
$dep_list) {
3429 # ignore any _current_ dependencies involving this bug,
3430 # as they will be overwritten with data from the form.
3431 if ($t != $id && !exists $seen{$t}) {
3432 push(@
{$deptree{$target}}, $t);
3440 my @deps = @
{$deptree{'dependson'}};
3441 my @blocks = @
{$deptree{'blocked'}};
3444 foreach my $b (@deps, @blocks) { $union{$b}++ && $isect{$b}++ }
3445 my @isect = keys %isect;
3446 if (scalar(@isect) > 0) {
3447 ThrowUserError
("dependency_loop_multi", {'deps' => \
@isect});
3453 #####################################################################
3454 # Autoloaded Accessors
3455 #####################################################################
3457 # Determines whether an attribute access trapped by the AUTOLOAD function
3458 # is for a valid bug attribute. Bug attributes are properties and methods
3459 # predefined by this module as well as bug fields for which an accessor
3460 # can be defined by AUTOLOAD at runtime when the accessor is first accessed.
3462 # XXX Strangely, some predefined attributes are on the list, but others aren't,
3463 # and the original code didn't specify why that is. Presumably the only
3464 # attributes that need to be on this list are those that aren't predefined;
3465 # we should verify that and update the list accordingly.
3467 sub _validate_attribute
{
3468 my ($attribute) = @_;
3470 my @valid_attributes = (
3471 # Miscellaneous properties and methods.
3472 qw(error groups product_id component_id
3473 longdescs milestoneurl attachments
3474 isopened isunconfirmed
3475 flag_types num_attachment_flag_types
3476 show_attachment_flags any_flags_requesteeble),
3479 Bugzilla
::Bug
->fields
3482 return grep($attribute eq $_, @valid_attributes) ?
1 : 0;
3486 use vars
qw($AUTOLOAD);
3487 my $attr = $AUTOLOAD;
3490 return unless $attr=~ /[^A-Z]/;
3491 if (!_validate_attribute($attr)) {
3493 Carp::confess("invalid bug attribute $attr");
3500 return $self->{$attr} if defined $self->{$attr};
3502 $self->{_multi_selects} ||= [Bugzilla->get_fields(
3503 {custom => 1, type => FIELD_TYPE_MULTI_SELECT })];
3504 if ( grep($_->name eq $attr, @{$self->{_multi_selects}}) ) {
3505 $self->{$attr} ||= Bugzilla->dbh->selectcol_arrayref(
3506 "SELECT value FROM bug_$attr WHERE bug_id = ? ORDER BY value",
3508 return $self->{$attr};