blob: 3a9a64ddc021b0ed0dfc0295920da33d94c33966 [file] [log] [blame]
# -*- Mode: perl; indent-tabs-mode: nil -*-
#
# The contents of this file are subject to the Mozilla Public
# License Version 1.1 (the "License"); you may not use this file
# except in compliance with the License. You may obtain a copy of
# the License at http://www.mozilla.org/MPL/
#
# Software distributed under the License is distributed on an "AS
# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
# implied. See the License for the specific language governing
# rights and limitations under the License.
#
# The Original Code is the Bugzilla Bug Tracking System.
#
# The Initial Developer of the Original Code is Netscape Communications
# Corporation. Portions created by Netscape are
# Copyright (C) 1998 Netscape Communications Corporation. All
# Rights Reserved.
#
# Contributor(s): Dawn Endico <endico@mozilla.org>
# Terry Weissman <terry@mozilla.org>
# Chris Yeh <cyeh@bluemartini.com>
# Bradley Baetz <bbaetz@acm.org>
# Dave Miller <justdave@bugzilla.org>
# Max Kanat-Alexander <mkanat@bugzilla.org>
# Frédéric Buclin <LpSolit@gmail.com>
package Bugzilla::Bug;
use strict;
use vars qw($legal_keywords @legal_platform
@legal_priority @legal_severity @legal_opsys @legal_bug_status
@settable_resolution %components %versions %target_milestone
@enterable_products %milestoneurl %prodmaxvotes);
use CGI::Carp qw(fatalsToBrowser);
use Bugzilla;
use Bugzilla::Attachment;
use Bugzilla::BugMail;
use Bugzilla::Config;
use Bugzilla::Constants;
use Bugzilla::Flag;
use Bugzilla::FlagType;
use Bugzilla::User;
use Bugzilla::Util;
use Bugzilla::Error;
use base qw(Exporter);
@Bugzilla::Bug::EXPORT = qw(
AppendComment ValidateComment
bug_alias_to_id ValidateBugAlias
RemoveVotes CheckIfVotedConfirmed
);
use constant MAX_COMMENT_LENGTH => 65535;
sub fields {
# Keep this ordering in sync with bugzilla.dtd
my @fields = qw(bug_id alias creation_ts short_desc delta_ts
reporter_accessible cclist_accessible
classification_id classification
product component version rep_platform op_sys
bug_status resolution
bug_file_loc status_whiteboard keywords
priority bug_severity target_milestone
dependson blocked votes
reporter assigned_to cc
);
if (Param('useqacontact')) {
push @fields, "qa_contact";
}
if (Param('timetrackinggroup')) {
push @fields, qw(estimated_time remaining_time actual_time deadline);
}
return @fields;
}
my %ok_field;
foreach my $key (qw(error groups
longdescs milestoneurl attachments
isopened isunconfirmed
flag_types num_attachment_flag_types
show_attachment_flags use_keywords any_flags_requesteeble
),
fields()) {
$ok_field{$key}++;
}
# create a new empty bug
#
sub new {
my $type = shift();
my %bug;
# create a ref to an empty hash and bless it
#
my $self = {%bug};
bless $self, $type;
# construct from a hash containing a bug's info
#
if ($#_ == 1) {
$self->initBug(@_);
} else {
confess("invalid number of arguments \($#_\)($_)");
}
# bless as a Bug
#
return $self;
}
# dump info about bug into hash unless user doesn't have permission
# user_id 0 is used when person is not logged in.
#
sub initBug {
my $self = shift();
my ($bug_id, $user_id) = (@_);
my $dbh = Bugzilla->dbh;
$bug_id = trim($bug_id);
my $old_bug_id = $bug_id;
# If the bug ID isn't numeric, it might be an alias, so try to convert it.
$bug_id = bug_alias_to_id($bug_id) if $bug_id !~ /^0*[1-9][0-9]*$/;
if ((! defined $bug_id) || (!$bug_id) || (!detaint_natural($bug_id))) {
# no bug number given or the alias didn't match a bug
$self->{'bug_id'} = $old_bug_id;
$self->{'error'} = "InvalidBugId";
return $self;
}
# If the user is not logged in, sets $user_id to 0.
# Else gets $user_id from the user login name if this
# argument is not numeric.
my $stored_user_id = $user_id;
if (!defined $user_id) {
$user_id = 0;
} elsif (!detaint_natural($user_id)) {
$user_id = login_to_id($stored_user_id);
}
$self->{'who'} = new Bugzilla::User($user_id);
my $query = "
SELECT
bugs.bug_id, alias, products.classification_id, classifications.name,
bugs.product_id, products.name, version,
rep_platform, op_sys, bug_status, resolution, priority,
bug_severity, bugs.component_id, components.name,
assigned_to AS assigned_to_id, reporter AS reporter_id,
bug_file_loc, short_desc, target_milestone,
qa_contact AS qa_contact_id, status_whiteboard, " .
$dbh->sql_date_format('creation_ts', '%Y.%m.%d %H:%i') . ",
delta_ts, COALESCE(SUM(votes.vote_count), 0),
reporter_accessible, cclist_accessible,
estimated_time, remaining_time, " .
$dbh->sql_date_format('deadline', '%Y-%m-%d') . "
FROM bugs
LEFT JOIN votes
USING (bug_id)
INNER JOIN components
ON components.id = bugs.component_id
INNER JOIN products
ON products.id = bugs.product_id
INNER JOIN classifications
ON classifications.id = products.classification_id
WHERE bugs.bug_id = ? " .
$dbh->sql_group_by('bugs.bug_id', 'alias, products.classification_id,
classifications.name, bugs.product_id, products.name, version,
rep_platform, op_sys, bug_status, resolution, priority,
bug_severity, bugs.component_id, components.name, assigned_to,
reporter, bug_file_loc, short_desc, target_milestone,
qa_contact, status_whiteboard, creation_ts,
delta_ts, reporter_accessible, cclist_accessible,
estimated_time, remaining_time, deadline');
my $bug_sth = $dbh->prepare($query);
$bug_sth->execute($bug_id);
my @row;
if ((@row = $bug_sth->fetchrow_array())
&& $self->{'who'}->can_see_bug($bug_id)) {
my $count = 0;
my %fields;
foreach my $field ("bug_id", "alias", "classification_id", "classification",
"product_id", "product", "version",
"rep_platform", "op_sys", "bug_status", "resolution",
"priority", "bug_severity", "component_id", "component",
"assigned_to_id", "reporter_id",
"bug_file_loc", "short_desc",
"target_milestone", "qa_contact_id", "status_whiteboard",
"creation_ts", "delta_ts", "votes",
"reporter_accessible", "cclist_accessible",
"estimated_time", "remaining_time", "deadline")
{
$fields{$field} = shift @row;
if (defined $fields{$field}) {
$self->{$field} = $fields{$field};
}
$count++;
}
} elsif (@row) {
$self->{'bug_id'} = $bug_id;
$self->{'error'} = "NotPermitted";
return $self;
} else {
$self->{'bug_id'} = $bug_id;
$self->{'error'} = "NotFound";
return $self;
}
$self->{'isunconfirmed'} = ($self->{bug_status} eq 'UNCONFIRMED');
$self->{'isopened'} = &::IsOpenedState($self->{bug_status});
return $self;
}
# This is the correct way to delete bugs from the DB.
# No bug should be deleted from anywhere else except from here.
#
sub remove_from_db {
my ($self) = @_;
my $dbh = Bugzilla->dbh;
if ($self->{'error'}) {
ThrowCodeError("bug_error", { bug => $self });
}
my $bug_id = $self->{'bug_id'};
# tables having 'bugs.bug_id' as a foreign key:
# - attachments
# - bug_group_map
# - bugs
# - bugs_activity
# - cc
# - dependencies
# - duplicates
# - flags
# - keywords
# - longdescs
# - votes
$dbh->bz_lock_tables('attachments WRITE', 'bug_group_map WRITE',
'bugs WRITE', 'bugs_activity WRITE', 'cc WRITE',
'dependencies WRITE', 'duplicates WRITE',
'flags WRITE', 'keywords WRITE',
'longdescs WRITE', 'votes WRITE');
$dbh->do("DELETE FROM bug_group_map WHERE bug_id = ?", undef, $bug_id);
$dbh->do("DELETE FROM bugs_activity WHERE bug_id = ?", undef, $bug_id);
$dbh->do("DELETE FROM cc WHERE bug_id = ?", undef, $bug_id);
$dbh->do("DELETE FROM dependencies WHERE blocked = ? OR dependson = ?",
undef, ($bug_id, $bug_id));
$dbh->do("DELETE FROM duplicates WHERE dupe = ? OR dupe_of = ?",
undef, ($bug_id, $bug_id));
$dbh->do("DELETE FROM flags WHERE bug_id = ?", undef, $bug_id);
$dbh->do("DELETE FROM keywords WHERE bug_id = ?", undef, $bug_id);
$dbh->do("DELETE FROM longdescs WHERE bug_id = ?", undef, $bug_id);
$dbh->do("DELETE FROM votes WHERE bug_id = ?", undef, $bug_id);
# Several of the previous tables also depend on attach_id.
$dbh->do("DELETE FROM attachments WHERE bug_id = ?", undef, $bug_id);
$dbh->do("DELETE FROM bugs WHERE bug_id = ?", undef, $bug_id);
$dbh->bz_unlock_tables();
# Now this bug no longer exists
$self->DESTROY;
return $self;
}
#####################################################################
# Accessors
#####################################################################
# These subs are in alphabetical order, as much as possible.
# If you add a new sub, please try to keep it in alphabetical order
# with the other ones.
# Note: If you add a new method, remember that you must check the error
# state of the bug before returning any data. If $self->{error} is
# defined, then return something empty. Otherwise you risk potential
# security holes.
sub dup_id {
my ($self) = @_;
return $self->{'dup_id'} if exists $self->{'dup_id'};
$self->{'dup_id'} = undef;
return if $self->{'error'};
if ($self->{'resolution'} eq 'DUPLICATE') {
my $dbh = Bugzilla->dbh;
$self->{'dup_id'} =
$dbh->selectrow_array(q{SELECT dupe_of
FROM duplicates
WHERE dupe = ?},
undef,
$self->{'bug_id'});
}
return $self->{'dup_id'};
}
sub actual_time {
my ($self) = @_;
return $self->{'actual_time'} if exists $self->{'actual_time'};
if ( $self->{'error'} ||
!Bugzilla->user->in_group(Param("timetrackinggroup")) ) {
$self->{'actual_time'} = undef;
return $self->{'actual_time'};
}
my $sth = Bugzilla->dbh->prepare("SELECT SUM(work_time)
FROM longdescs
WHERE longdescs.bug_id=?");
$sth->execute($self->{bug_id});
$self->{'actual_time'} = $sth->fetchrow_array();
return $self->{'actual_time'};
}
sub any_flags_requesteeble () {
my ($self) = @_;
return $self->{'any_flags_requesteeble'}
if exists $self->{'any_flags_requesteeble'};
return 0 if $self->{'error'};
$self->{'any_flags_requesteeble'} =
grep($_->{'is_requesteeble'}, @{$self->flag_types});
return $self->{'any_flags_requesteeble'};
}
sub attachments () {
my ($self) = @_;
return $self->{'attachments'} if exists $self->{'attachments'};
return [] if $self->{'error'};
$self->{'attachments'} = Bugzilla::Attachment::query($self->{bug_id});
return $self->{'attachments'};
}
sub assigned_to () {
my ($self) = @_;
return $self->{'assigned_to'} if exists $self->{'assigned_to'};
$self->{'assigned_to_id'} = 0 if $self->{'error'};
$self->{'assigned_to'} = new Bugzilla::User($self->{'assigned_to_id'});
return $self->{'assigned_to'};
}
sub blocked () {
my ($self) = @_;
return $self->{'blocked'} if exists $self->{'blocked'};
return [] if $self->{'error'};
$self->{'blocked'} = EmitDependList("dependson", "blocked", $self->bug_id);
return $self->{'blocked'};
}
# Even bugs in an error state always have a bug_id.
sub bug_id { $_[0]->{'bug_id'}; }
sub cc () {
my ($self) = @_;
return $self->{'cc'} if exists $self->{'cc'};
return [] if $self->{'error'};
my $dbh = Bugzilla->dbh;
$self->{'cc'} = $dbh->selectcol_arrayref(
q{SELECT profiles.login_name FROM cc, profiles
WHERE bug_id = ?
AND cc.who = profiles.userid
ORDER BY profiles.login_name},
undef, $self->bug_id);
$self->{'cc'} = undef if !scalar(@{$self->{'cc'}});
return $self->{'cc'};
}
sub dependson () {
my ($self) = @_;
return $self->{'dependson'} if exists $self->{'dependson'};
return [] if $self->{'error'};
$self->{'dependson'} =
EmitDependList("blocked", "dependson", $self->bug_id);
return $self->{'dependson'};
}
sub flag_types () {
my ($self) = @_;
return $self->{'flag_types'} if exists $self->{'flag_types'};
return [] if $self->{'error'};
# The types of flags that can be set on this bug.
# If none, no UI for setting flags will be displayed.
my $flag_types = Bugzilla::FlagType::match(
{'target_type' => 'bug',
'product_id' => $self->{'product_id'},
'component_id' => $self->{'component_id'} });
foreach my $flag_type (@$flag_types) {
$flag_type->{'flags'} = Bugzilla::Flag::match(
{ 'bug_id' => $self->bug_id,
'type_id' => $flag_type->{'id'},
'target_type' => 'bug',
'is_active' => 1 });
}
$self->{'flag_types'} = $flag_types;
return $self->{'flag_types'};
}
sub keywords () {
my ($self) = @_;
return $self->{'keywords'} if exists $self->{'keywords'};
return () if $self->{'error'};
my $dbh = Bugzilla->dbh;
my $list_ref = $dbh->selectcol_arrayref(
"SELECT keyworddefs.name
FROM keyworddefs, keywords
WHERE keywords.bug_id = ?
AND keyworddefs.id = keywords.keywordid
ORDER BY keyworddefs.name",
undef, ($self->bug_id));
$self->{'keywords'} = join(', ', @$list_ref);
return $self->{'keywords'};
}
sub longdescs {
my ($self) = @_;
return $self->{'longdescs'} if exists $self->{'longdescs'};
return [] if $self->{'error'};
$self->{'longdescs'} = GetComments($self->{bug_id});
return $self->{'longdescs'};
}
sub milestoneurl () {
my ($self) = @_;
return $self->{'milestoneurl'} if exists $self->{'milestoneurl'};
return '' if $self->{'error'};
$self->{'milestoneurl'} = $::milestoneurl{$self->{product}};
return $self->{'milestoneurl'};
}
sub qa_contact () {
my ($self) = @_;
return $self->{'qa_contact'} if exists $self->{'qa_contact'};
return undef if $self->{'error'};
if (Param('useqacontact') && $self->{'qa_contact_id'}) {
$self->{'qa_contact'} = new Bugzilla::User($self->{'qa_contact_id'});
} else {
# XXX - This is somewhat inconsistent with the assignee/reporter
# methods, which will return an empty User if they get a 0.
# However, we're keeping it this way now, for backwards-compatibility.
$self->{'qa_contact'} = undef;
}
return $self->{'qa_contact'};
}
sub reporter () {
my ($self) = @_;
return $self->{'reporter'} if exists $self->{'reporter'};
$self->{'reporter_id'} = 0 if $self->{'error'};
$self->{'reporter'} = new Bugzilla::User($self->{'reporter_id'});
return $self->{'reporter'};
}
sub show_attachment_flags () {
my ($self) = @_;
return $self->{'show_attachment_flags'}
if exists $self->{'show_attachment_flags'};
return 0 if $self->{'error'};
# The number of types of flags that can be set on attachments to this bug
# and the number of flags on those attachments. One of these counts must be
# greater than zero in order for the "flags" column to appear in the table
# of attachments.
my $num_attachment_flag_types = Bugzilla::FlagType::count(
{ 'target_type' => 'attachment',
'product_id' => $self->{'product_id'},
'component_id' => $self->{'component_id'} });
my $num_attachment_flags = Bugzilla::Flag::count(
{ 'target_type' => 'attachment',
'bug_id' => $self->bug_id,
'is_active' => 1 });
$self->{'show_attachment_flags'} =
($num_attachment_flag_types || $num_attachment_flags);
return $self->{'show_attachment_flags'};
}
sub use_keywords {
return @::legal_keywords;
}
sub use_votes {
my ($self) = @_;
return 0 if $self->{'error'};
return Param('usevotes')
&& $::prodmaxvotes{$self->{product}} > 0;
}
sub groups {
my $self = shift;
return $self->{'groups'} if exists $self->{'groups'};
return [] if $self->{'error'};
my $dbh = Bugzilla->dbh;
my @groups;
# Some of this stuff needs to go into Bugzilla::User
# For every group, we need to know if there is ANY bug_group_map
# record putting the current bug in that group and if there is ANY
# user_group_map record putting the user in that group.
# The LEFT JOINs are checking for record existence.
#
my $sth = $dbh->prepare(
"SELECT DISTINCT groups.id, name, description," .
" bug_group_map.group_id IS NOT NULL," .
" user_group_map.group_id IS NOT NULL," .
" isactive, membercontrol, othercontrol" .
" FROM groups" .
" LEFT JOIN bug_group_map" .
" ON bug_group_map.group_id = groups.id" .
" AND bug_id = ?" .
" LEFT JOIN user_group_map" .
" ON user_group_map.group_id = groups.id" .
" AND user_id = ?" .
" AND isbless = 0" .
" LEFT JOIN group_control_map" .
" ON group_control_map.group_id = groups.id" .
" AND group_control_map.product_id = ? " .
" WHERE isbuggroup = 1" .
" ORDER BY description");
$sth->execute($self->{'bug_id'}, Bugzilla->user->id,
$self->{'product_id'});
while (my ($groupid, $name, $description, $ison, $ingroup, $isactive,
$membercontrol, $othercontrol) = $sth->fetchrow_array()) {
$membercontrol ||= 0;
# For product groups, we only want to use the group if either
# (1) The bit is set and not required, or
# (2) The group is Shown or Default for members and
# the user is a member of the group.
if ($ison ||
($isactive && $ingroup
&& (($membercontrol == CONTROLMAPDEFAULT)
|| ($membercontrol == CONTROLMAPSHOWN))
))
{
my $ismandatory = $isactive
&& ($membercontrol == CONTROLMAPMANDATORY);
push (@groups, { "bit" => $groupid,
"name" => $name,
"ison" => $ison,
"ingroup" => $ingroup,
"mandatory" => $ismandatory,
"description" => $description });
}
}
$self->{'groups'} = \@groups;
return $self->{'groups'};
}
sub user {
my $self = shift;
return $self->{'user'} if exists $self->{'user'};
return {} if $self->{'error'};
my $user = Bugzilla->user;
my $canmove = Param('move-enabled') && $user->is_mover;
# In the below, if the person hasn't logged in, then we treat them
# as if they can do anything. That's because we don't know why they
# haven't logged in; it may just be because they don't use cookies.
# Display everything as if they have all the permissions in the
# world; their permissions will get checked when they log in and
# actually try to make the change.
my $unknown_privileges = !$user->id
|| $user->in_group("editbugs");
my $canedit = $unknown_privileges
|| $user->id == $self->{assigned_to_id}
|| (Param('useqacontact')
&& $self->{'qa_contact_id'}
&& $user->id == $self->{qa_contact_id});
my $canconfirm = $unknown_privileges
|| $user->in_group("canconfirm");
my $isreporter = $user->id
&& $user->id == $self->{reporter_id};
$self->{'user'} = {canmove => $canmove,
canconfirm => $canconfirm,
canedit => $canedit,
isreporter => $isreporter};
return $self->{'user'};
}
sub choices {
my $self = shift;
return $self->{'choices'} if exists $self->{'choices'};
return {} if $self->{'error'};
&::GetVersionTable();
$self->{'choices'} = {};
# Fiddle the product list.
my $seen_curr_prod;
my @prodlist;
foreach my $product (@::enterable_products) {
if ($product eq $self->{'product'}) {
# if it's the product the bug is already in, it's ALWAYS in
# the popup, period, whether the user can see it or not, and
# regardless of the disallownew setting.
$seen_curr_prod = 1;
push(@prodlist, $product);
next;
}
if (!&::CanEnterProduct($product)) {
# If we're using bug groups to restrict entry on products, and
# this product has an entry group, and the user is not in that
# group, we don't want to include that product in this list.
next;
}
push(@prodlist, $product);
}
# The current product is part of the popup, even if new bugs are no longer
# allowed for that product
if (!$seen_curr_prod) {
push (@prodlist, $self->{'product'});
@prodlist = sort @prodlist;
}
# Hack - this array contains "". See bug 106589.
my @res = grep ($_, @::settable_resolution);
$self->{'choices'} =
{
'product' => \@prodlist,
'rep_platform' => \@::legal_platform,
'priority' => \@::legal_priority,
'bug_severity' => \@::legal_severity,
'op_sys' => \@::legal_opsys,
'bug_status' => \@::legal_bug_status,
'resolution' => \@res,
'component' => $::components{$self->{product}},
'version' => $::versions{$self->{product}},
'target_milestone' => $::target_milestone{$self->{product}},
};
return $self->{'choices'};
}
# Convenience Function. If you need speed, use this. If you need
# other Bug fields in addition to this, just create a new Bug with
# the alias.
# Queries the database for the bug with a given alias, and returns
# the ID of the bug if it exists or the undefined value if it doesn't.
sub bug_alias_to_id ($) {
my ($alias) = @_;
return undef unless Param("usebugaliases");
my $dbh = Bugzilla->dbh;
trick_taint($alias);
return $dbh->selectrow_array(
"SELECT bug_id FROM bugs WHERE alias = ?", undef, $alias);
}
#####################################################################
# Subroutines
#####################################################################
sub AppendComment ($$$;$$$) {
my ($bugid, $whoid, $comment, $isprivate, $timestamp, $work_time) = @_;
$work_time ||= 0;
my $dbh = Bugzilla->dbh;
ValidateTime($work_time, "work_time") if $work_time;
trick_taint($work_time);
# Use the date/time we were given if possible (allowing calling code
# to synchronize the comment's timestamp with those of other records).
$timestamp = "NOW()" unless $timestamp;
$comment =~ s/\r\n/\n/g; # Handle Windows-style line endings.
$comment =~ s/\r/\n/g; # Handle Mac-style line endings.
if ($comment =~ /^\s*$/) { # Nothin' but whitespace
return;
}
# Comments are always safe, because we always display their raw contents,
# and we use them in a placeholder below.
trick_taint($comment);
my $privacyval = $isprivate ? 1 : 0 ;
$dbh->do(q{INSERT INTO longdescs
(bug_id, who, bug_when, thetext, isprivate, work_time)
VALUES (?,?,?,?,?,?)}, undef,
($bugid, $whoid, $timestamp, $comment, $privacyval, $work_time));
$dbh->do("UPDATE bugs SET delta_ts = ? WHERE bug_id = ?",
undef, $timestamp, $bugid);
}
# This method is private and is not to be used outside of the Bug class.
sub EmitDependList {
my ($myfield, $targetfield, $bug_id) = (@_);
my $dbh = Bugzilla->dbh;
my $list_ref =
$dbh->selectcol_arrayref(
"SELECT dependencies.$targetfield
FROM dependencies, bugs
WHERE dependencies.$myfield = ?
AND bugs.bug_id = dependencies.$targetfield
ORDER BY dependencies.$targetfield",
undef, ($bug_id));
return $list_ref;
}
sub ValidateTime {
my ($time, $field) = @_;
# regexp verifies one or more digits, optionally followed by a period and
# zero or more digits, OR we have a period followed by one or more digits
# (allow negatives, though, so people can back out errors in time reporting)
if ($time !~ /^-?(?:\d+(?:\.\d*)?|\.\d+)$/) {
ThrowUserError("number_not_numeric",
{field => "$field", num => "$time"});
}
# Only the "work_time" field is allowed to contain a negative value.
if ( ($time < 0) && ($field ne "work_time") ) {
ThrowUserError("number_too_small",
{field => "$field", num => "$time", min_num => "0"});
}
if ($time > 99999.99) {
ThrowUserError("number_too_large",
{field => "$field", num => "$time", max_num => "99999.99"});
}
}
sub GetComments {
my ($id, $comment_sort_order) = (@_);
$comment_sort_order = $comment_sort_order ||
Bugzilla->user->settings->{'comment_sort_order'}->{'value'};
my $sort_order = ($comment_sort_order eq "oldest_to_newest") ? 'asc' : 'desc';
my $dbh = Bugzilla->dbh;
my @comments;
my $sth = $dbh->prepare(
"SELECT profiles.realname AS name, profiles.login_name AS email,
" . $dbh->sql_date_format('longdescs.bug_when', '%Y.%m.%d %H:%i') . "
AS time, longdescs.thetext AS body, longdescs.work_time,
isprivate, already_wrapped,
" . $dbh->sql_date_format('longdescs.bug_when', '%Y%m%d%H%i%s') . "
AS bug_when
FROM longdescs, profiles
WHERE profiles.userid = longdescs.who
AND longdescs.bug_id = ?
ORDER BY longdescs.bug_when $sort_order");
$sth->execute($id);
while (my $comment_ref = $sth->fetchrow_hashref()) {
my %comment = %$comment_ref;
# Can't use "when" as a field name in MySQL
$comment{'when'} = $comment{'bug_when'};
delete($comment{'bug_when'});
$comment{'email'} .= Param('emailsuffix');
$comment{'name'} = $comment{'name'} || $comment{'email'};
push (@comments, \%comment);
}
if ($comment_sort_order eq "newest_to_oldest_desc_first") {
unshift(@comments, pop @comments);
}
return \@comments;
}
# CountOpenDependencies counts the number of open dependent bugs for a
# list of bugs and returns a list of bug_id's and their dependency count
# It takes one parameter:
# - A list of bug numbers whose dependencies are to be checked
sub CountOpenDependencies {
my (@bug_list) = @_;
my @dependencies;
my $dbh = Bugzilla->dbh;
my $sth = $dbh->prepare(
"SELECT blocked, COUNT(bug_status) " .
"FROM bugs, dependencies " .
"WHERE blocked IN (" . (join "," , @bug_list) . ") " .
"AND bug_id = dependson " .
"AND bug_status IN ('" . (join "','", &::OpenStates()) . "') " .
$dbh->sql_group_by('blocked'));
$sth->execute();
while (my ($bug_id, $dependencies) = $sth->fetchrow_array()) {
push(@dependencies, { bug_id => $bug_id,
dependencies => $dependencies });
}
return @dependencies;
}
sub ValidateComment ($) {
my ($comment) = @_;
if (defined($comment) && length($comment) > MAX_COMMENT_LENGTH) {
ThrowUserError("comment_too_long");
}
}
# If a bug is moved to a product which allows less votes per bug
# compared to the previous product, extra votes need to be removed.
sub RemoveVotes {
my ($id, $who, $reason) = (@_);
my $dbh = Bugzilla->dbh;
my $whopart = ($who) ? " AND votes.who = $who" : "";
my $sth = $dbh->prepare("SELECT profiles.login_name, " .
"profiles.userid, votes.vote_count, " .
"products.votesperuser, products.maxvotesperbug " .
"FROM profiles " .
"LEFT JOIN votes ON profiles.userid = votes.who " .
"LEFT JOIN bugs USING(bug_id) " .
"LEFT JOIN products ON products.id = bugs.product_id " .
"WHERE votes.bug_id = ? " . $whopart);
$sth->execute($id);
my @list;
while (my ($name, $userid, $oldvotes, $votesperuser, $maxvotesperbug) = $sth->fetchrow_array()) {
push(@list, [$name, $userid, $oldvotes, $votesperuser, $maxvotesperbug]);
}
if (scalar(@list)) {
foreach my $ref (@list) {
my ($name, $userid, $oldvotes, $votesperuser, $maxvotesperbug) = (@$ref);
my $s;
$maxvotesperbug = min($votesperuser, $maxvotesperbug);
# If this product allows voting and the user's votes are in
# the acceptable range, then don't do anything.
next if $votesperuser && $oldvotes <= $maxvotesperbug;
# If the user has more votes on this bug than this product
# allows, then reduce the number of votes so it fits
my $newvotes = $maxvotesperbug;
my $removedvotes = $oldvotes - $newvotes;
$s = ($oldvotes == 1) ? "" : "s";
my $oldvotestext = "You had $oldvotes vote$s on this bug.";
$s = ($removedvotes == 1) ? "" : "s";
my $removedvotestext = "You had $removedvotes vote$s removed from this bug.";
my $newvotestext;
if ($newvotes) {
$dbh->do("UPDATE votes SET vote_count = ? " .
"WHERE bug_id = ? AND who = ?",
undef, ($newvotes, $id, $userid));
$s = $newvotes == 1 ? "" : "s";
$newvotestext = "You still have $newvotes vote$s on this bug."
} else {
$dbh->do("DELETE FROM votes WHERE bug_id = ? AND who = ?",
undef, ($id, $userid));
$newvotestext = "You have no more votes remaining on this bug.";
}
# Notice that we did not make sure that the user fit within the $votesperuser
# range. This is considered to be an acceptable alternative to losing votes
# during product moves. Then next time the user attempts to change their votes,
# they will be forced to fit within the $votesperuser limit.
# Now lets send the e-mail to alert the user to the fact that their votes have
# been reduced or removed.
my %substs;
$substs{"to"} = $name . Param('emailsuffix');
$substs{"bugid"} = $id;
$substs{"reason"} = $reason;
$substs{"votesremoved"} = $removedvotes;
$substs{"votesold"} = $oldvotes;
$substs{"votesnew"} = $newvotes;
$substs{"votesremovedtext"} = $removedvotestext;
$substs{"votesoldtext"} = $oldvotestext;
$substs{"votesnewtext"} = $newvotestext;
$substs{"count"} = $removedvotes . "\n " . $newvotestext;
my $msg = PerformSubsts(Param("voteremovedmail"), \%substs);
Bugzilla::BugMail::MessageToMTA($msg);
}
my $votes = $dbh->selectrow_array("SELECT SUM(vote_count) " .
"FROM votes WHERE bug_id = ?",
undef, $id) || 0;
$dbh->do("UPDATE bugs SET votes = ? WHERE bug_id = ?",
undef, ($votes, $id));
}
}
# If a user votes for a bug, or the number of votes required to
# confirm a bug has been reduced, check if the bug is now confirmed.
sub CheckIfVotedConfirmed {
my ($id, $who) = (@_);
my $dbh = Bugzilla->dbh;
my ($votes, $status, $everconfirmed, $votestoconfirm, $timestamp) =
$dbh->selectrow_array("SELECT votes, bug_status, everconfirmed, " .
" votestoconfirm, NOW() " .
"FROM bugs INNER JOIN products " .
" ON products.id = bugs.product_id " .
"WHERE bugs.bug_id = ?",
undef, $id);
my $ret = 0;
if ($votes >= $votestoconfirm && !$everconfirmed) {
if ($status eq 'UNCONFIRMED') {
my $fieldid = &::GetFieldID("bug_status");
$dbh->do("UPDATE bugs SET bug_status = 'NEW', everconfirmed = 1, " .
"delta_ts = ? WHERE bug_id = ?",
undef, ($timestamp, $id));
$dbh->do("INSERT INTO bugs_activity " .
"(bug_id, who, bug_when, fieldid, removed, added) " .
"VALUES (?, ?, ?, ?, ?, ?)",
undef, ($id, $who, $timestamp, $fieldid, 'UNCONFIRMED', 'NEW'));
}
else {
$dbh->do("UPDATE bugs SET everconfirmed = 1, delta_ts = ? " .
"WHERE bug_id = ?", undef, ($timestamp, $id));
}
my $fieldid = &::GetFieldID("everconfirmed");
$dbh->do("INSERT INTO bugs_activity " .
"(bug_id, who, bug_when, fieldid, removed, added) " .
"VALUES (?, ?, ?, ?, ?, ?)",
undef, ($id, $who, $timestamp, $fieldid, '0', '1'));
AppendComment($id, $who,
"*** This bug has been confirmed by popular vote. ***",
0, $timestamp);
$ret = 1;
}
return $ret;
}
#
# Field Validation
#
# ValidateBugAlias:
# Check that the bug alias is valid and not used by another bug. If
# curr_id is specified, verify the alias is not used for any other
# bug id.
sub ValidateBugAlias {
my ($alias, $curr_id) = @_;
my $dbh = Bugzilla->dbh;
$alias = trim($alias || "");
trick_taint($alias);
if ($alias eq "") {
ThrowUserError("alias_not_defined");
}
# Make sure the alias isn't too long.
if (length($alias) > 20) {
ThrowUserError("alias_too_long");
}
# Make sure the alias is unique.
my $query = "SELECT bug_id FROM bugs WHERE alias = ?";
if (detaint_natural($curr_id)) {
$query .= " AND bug_id != $curr_id";
}
my $id = $dbh->selectrow_array($query, undef, $alias);
my $vars = {};
$vars->{'alias'} = $alias;
if ($id) {
$vars->{'bug_link'} = &::GetBugLink($id, $id);
ThrowUserError("alias_in_use", $vars);
}
# Make sure the alias isn't just a number.
if ($alias =~ /^\d+$/) {
ThrowUserError("alias_is_numeric", $vars);
}
# Make sure the alias has no commas or spaces.
if ($alias =~ /[, ]/) {
ThrowUserError("alias_has_comma_or_space", $vars);
}
$_[0] = $alias;
}
# Validate and return a hash of dependencies
sub ValidateDependencies($$$) {
my $fields = {};
$fields->{'dependson'} = shift;
$fields->{'blocked'} = shift;
my $id = shift || 0;
unless (defined($fields->{'dependson'})
|| defined($fields->{'blocked'}))
{
return;
}
my $dbh = Bugzilla->dbh;
my %deps;
my %deptree;
foreach my $pair (["blocked", "dependson"], ["dependson", "blocked"]) {
my ($me, $target) = @{$pair};
$deptree{$target} = [];
$deps{$target} = [];
next unless $fields->{$target};
my %seen;
foreach my $i (split('[\s,]+', $fields->{$target})) {
if ($id == $i) {
ThrowUserError("dependency_loop_single");
}
if (!exists $seen{$i}) {
push(@{$deptree{$target}}, $i);
$seen{$i} = 1;
}
}
# populate $deps{$target} as first-level deps only.
# and find remainder of dependency tree in $deptree{$target}
@{$deps{$target}} = @{$deptree{$target}};
my @stack = @{$deps{$target}};
while (@stack) {
my $i = shift @stack;
my $dep_list =
$dbh->selectcol_arrayref("SELECT $target
FROM dependencies
WHERE $me = ?", undef, $i);
foreach my $t (@$dep_list) {
# ignore any _current_ dependencies involving this bug,
# as they will be overwritten with data from the form.
if ($t != $id && !exists $seen{$t}) {
push(@{$deptree{$target}}, $t);
push @stack, $t;
$seen{$t} = 1;
}
}
}
}
my @deps = @{$deptree{'dependson'}};
my @blocks = @{$deptree{'blocked'}};
my @union = ();
my @isect = ();
my %union = ();
my %isect = ();
foreach my $b (@deps, @blocks) { $union{$b}++ && $isect{$b}++ }
@union = keys %union;
@isect = keys %isect;
if (scalar(@isect) > 0) {
my $both = "";
foreach my $i (@isect) {
$both .= &::GetBugLink($i, "#" . $i) . " ";
}
ThrowUserError("dependency_loop_multi", { both => $both });
}
return %deps;
}
sub AUTOLOAD {
use vars qw($AUTOLOAD);
my $attr = $AUTOLOAD;
$attr =~ s/.*:://;
return unless $attr=~ /[^A-Z]/;
confess ("invalid bug attribute $attr") unless $ok_field{$attr};
no strict 'refs';
*$AUTOLOAD = sub {
my $self = shift;
if (defined $self->{$attr}) {
return $self->{$attr};
} else {
return '';
}
};
goto &$AUTOLOAD;
}
1;