# -*- 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>
#                 Lance Larsh <lance.larsh@oracle.com>

package Bugzilla::Bug;

use strict;

use Bugzilla::Attachment;
use Bugzilla::Constants;
use Bugzilla::Field;
use Bugzilla::Flag;
use Bugzilla::FlagType;
use Bugzilla::Keyword;
use Bugzilla::User;
use Bugzilla::Util;
use Bugzilla::Error;
use Bugzilla::Product;
use Bugzilla::Component;
use Bugzilla::Group;
use Bugzilla::Status;

use List::Util qw(min);
use Storable qw(dclone);

use base qw(Bugzilla::Object Exporter);
@Bugzilla::Bug::EXPORT = qw(
    AppendComment ValidateComment
    bug_alias_to_id ValidateBugID
    RemoveVotes CheckIfVotedConfirmed
    LogActivityEntry
    BUG_STATE_OPEN is_open_state
    editable_bug_fields
    SPECIAL_STATUS_WORKFLOW_ACTIONS
);

#####################################################################
# Constants
#####################################################################

use constant DB_TABLE   => 'bugs';
use constant ID_FIELD   => 'bug_id';
use constant NAME_FIELD => 'alias';
use constant LIST_ORDER => ID_FIELD;

# This is a sub because it needs to call other subroutines.
sub DB_COLUMNS {
    my $dbh = Bugzilla->dbh;
    return qw(
        alias
        bug_file_loc
        bug_id
        bug_severity
        bug_status
        cclist_accessible
        component_id
        delta_ts
        estimated_time
        everconfirmed
        op_sys
        priority
        product_id
        remaining_time
        rep_platform
        reporter_accessible
        resolution
        short_desc
        status_whiteboard
        target_milestone
        version
    ),
    'assigned_to AS assigned_to_id',
    'reporter    AS reporter_id',
    'qa_contact  AS qa_contact_id',
    $dbh->sql_date_format('creation_ts', '%Y.%m.%d %H:%i') . ' AS creation_ts',
    $dbh->sql_date_format('deadline', '%Y-%m-%d') . ' AS deadline',
    Bugzilla->custom_field_names;
}

use constant REQUIRED_CREATE_FIELDS => qw(
    bug_severity
    comment
    component
    op_sys
    priority
    product
    rep_platform
    short_desc
    version
);

# There are also other, more complex validators that are called
# from run_create_validators.
sub VALIDATORS {
    my $validators = {
        alias          => \&_check_alias,
        bug_file_loc   => \&_check_bug_file_loc,
        bug_severity   => \&_check_bug_severity,
        comment        => \&_check_comment,
        commentprivacy => \&_check_commentprivacy,
        deadline       => \&_check_deadline,
        estimated_time => \&_check_estimated_time,
        op_sys         => \&_check_op_sys,
        priority       => \&_check_priority,
        product        => \&_check_product,
        remaining_time => \&_check_remaining_time,
        rep_platform   => \&_check_rep_platform,
        short_desc     => \&_check_short_desc,
        status_whiteboard => \&_check_status_whiteboard,
    };

    # Set up validators for custom fields.    
    my @custom_fields = Bugzilla->get_fields({custom => 1, obsolete => 0});
    foreach my $field (@custom_fields) {
        my $validator;
        if ($field->type == FIELD_TYPE_SINGLE_SELECT) {
            $validator = \&_check_select_field;
        }
        else {
            $validator = \&_check_freetext_field;
        }
        $validators->{$field->name} = $validator;
    }

    return $validators;
};

use constant UPDATE_VALIDATORS => {
    bug_status => \&_check_bug_status,
    cclist_accessible   => \&Bugzilla::Object::check_boolean,
    reporter_accessible => \&Bugzilla::Object::check_boolean,
    resolution => \&_check_resolution,
};

sub UPDATE_COLUMNS {
    my @columns = qw(
        alias
        cclist_accessible
        deadline
        estimated_time
        everconfirmed
        bug_file_loc
        bug_severity
        bug_status
        op_sys
        priority
        remaining_time
        rep_platform
        reporter_accessible
        resolution
        short_desc
        status_whiteboard
    );
    push(@columns, Bugzilla->custom_field_names);
    return @columns;
};

use constant NUMERIC_COLUMNS => qw(
    estimated_time
    remaining_time
);

# This is used by add_comment to know what we validate before putting in
# the DB.
use constant UPDATE_COMMENT_COLUMNS => qw(
    thetext
    work_time
    type
    extra_data
    isprivate
);

# Used in LogActivityEntry(). Gives the max length of lines in the
# activity table.
use constant MAX_LINE_LENGTH => 254;

# Used in ValidateComment(). Gives the max length allowed for a comment.
use constant MAX_COMMENT_LENGTH => 65535;

use constant SPECIAL_STATUS_WORKFLOW_ACTIONS => qw(
    none
    duplicate
    change_resolution
    clearresolution
);

sub BUG_STATE_OPEN {
    # XXX - We should cache this list.
    my $dbh = Bugzilla->dbh;
    return @{$dbh->selectcol_arrayref('SELECT value FROM bug_status WHERE is_open = 1')};
}

#####################################################################

sub new {
    my $invocant = shift;
    my $class = ref($invocant) || $invocant;
    my $param = shift;

    # If we get something that looks like a word (not a number),
    # make it the "name" param.
    if (!defined $param || (!ref($param) && $param !~ /^\d+$/)) {
        # But only if aliases are enabled.
        if (Bugzilla->params->{'usebugaliases'} && $param) {
            $param = { name => $param };
        }
        else {
            # Aliases are off, and we got something that's not a number.
            my $error_self = {};
            bless $error_self, $class;
            $error_self->{'bug_id'} = $param;
            $error_self->{'error'}  = 'InvalidBugId';
            return $error_self;
        }
    }

    unshift @_, $param;
    my $self = $class->SUPER::new(@_);

    # Bugzilla::Bug->new always returns something, but sets $self->{error}
    # if the bug wasn't found in the database.
    if (!$self) {
        my $error_self = {};
        bless $error_self, $class;
        $error_self->{'bug_id'} = ref($param) ? $param->{name} : $param;
        $error_self->{'error'}  = 'NotFound';
        return $error_self;
    }

    return $self;
}

# Docs for create() (there's no POD in this file yet, but we very
# much need this documented right now):
#
# The same as Bugzilla::Object->create. Parameters are only required
# if they say so below.
#
# Params:
#
# C<product>     - B<Required> The name of the product this bug is being
#                  filed against.
# C<component>   - B<Required> The name of the component this bug is being
#                  filed against.
#
# C<bug_severity> - B<Required> The severity for the bug, a string.
# C<creation_ts>  - B<Required> A SQL timestamp for when the bug was created.
# C<short_desc>   - B<Required> A summary for the bug.
# C<op_sys>       - B<Required> The OS the bug was found against.
# C<priority>     - B<Required> The initial priority for the bug.
# C<rep_platform> - B<Required> The platform the bug was found against.
# C<version>      - B<Required> The version of the product the bug was found in.
#
# C<alias>        - An alias for this bug. Will be ignored if C<usebugaliases>
#                   is off.
# C<target_milestone> - When this bug is expected to be fixed.
# C<status_whiteboard> - A string.
# C<bug_status>   - The initial status of the bug, a string.
# C<bug_file_loc> - The URL field.
#
# C<assigned_to> - The full login name of the user who the bug is
#                  initially assigned to.
# C<qa_contact>  - The full login name of the QA Contact for this bug. 
#                  Will be ignored if C<useqacontact> is off.
#
# C<estimated_time> - For time-tracking. Will be ignored if 
#                     C<timetrackinggroup> is not set, or if the current
#                     user is not a member of the timetrackinggroup.
# C<deadline>       - For time-tracking. Will be ignored for the same
#                     reasons as C<estimated_time>.
sub create {
    my $class  = shift;
    my $dbh = Bugzilla->dbh;

    $dbh->bz_start_transaction();

    $class->check_required_create_fields(@_);
    my $params = $class->run_create_validators(@_);

    # These are not a fields in the bugs table, so we don't pass them to
    # insert_create_data.
    my $cc_ids = $params->{cc};
    delete $params->{cc};
    my $groups = $params->{groups};
    delete $params->{groups};
    my $depends_on = $params->{dependson};
    delete $params->{dependson};
    my $blocked = $params->{blocked};
    delete $params->{blocked};
    my ($comment, $privacy) = ($params->{comment}, $params->{commentprivacy});
    delete $params->{comment};
    delete $params->{commentprivacy};

    # Set up the keyword cache for bug creation.
    my $keywords = $params->{keywords};
    $params->{keywords} = join(', ', sort {lc($a) cmp lc($b)} 
                                          map($_->name, @$keywords));

    # We don't want the bug to appear in the system until it's correctly
    # protected by groups.
    my $timestamp = $params->{creation_ts}; 
    delete $params->{creation_ts};

    my $bug = $class->insert_create_data($params);

    # Add the group restrictions
    my $sth_group = $dbh->prepare(
        'INSERT INTO bug_group_map (bug_id, group_id) VALUES (?, ?)');
    foreach my $group_id (@$groups) {
        $sth_group->execute($bug->bug_id, $group_id);
    }

    $dbh->do('UPDATE bugs SET creation_ts = ? WHERE bug_id = ?', undef,
             $timestamp, $bug->bug_id);
    # Update the bug instance as well
    $bug->{creation_ts} = $timestamp;

    # Add the CCs
    my $sth_cc = $dbh->prepare('INSERT INTO cc (bug_id, who) VALUES (?,?)');
    foreach my $user_id (@$cc_ids) {
        $sth_cc->execute($bug->bug_id, $user_id);
    }

    # Add in keywords
    my $sth_keyword = $dbh->prepare(
        'INSERT INTO keywords (bug_id, keywordid) VALUES (?, ?)');
    foreach my $keyword_id (map($_->id, @$keywords)) {
        $sth_keyword->execute($bug->bug_id, $keyword_id);
    }

    # Set up dependencies (blocked/dependson)
    my $sth_deps = $dbh->prepare(
        'INSERT INTO dependencies (blocked, dependson) VALUES (?, ?)');
    my $sth_bug_time = $dbh->prepare('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?');

    foreach my $depends_on_id (@$depends_on) {
        $sth_deps->execute($bug->bug_id, $depends_on_id);
        # Log the reverse action on the other bug.
        LogActivityEntry($depends_on_id, 'blocked', '', $bug->bug_id,
                         $bug->{reporter_id}, $timestamp);
        $sth_bug_time->execute($timestamp, $depends_on_id);
    }
    foreach my $blocked_id (@$blocked) {
        $sth_deps->execute($blocked_id, $bug->bug_id);
        # Log the reverse action on the other bug.
        LogActivityEntry($blocked_id, 'dependson', '', $bug->bug_id,
                         $bug->{reporter_id}, $timestamp);
        $sth_bug_time->execute($timestamp, $blocked_id);
    }

    $dbh->bz_commit_transaction();

    # Because MySQL doesn't support transactions on the longdescs table,
    # we do this after we've committed the transaction. That way we're
    # fairly sure we're inserting a good Bug ID.

    # And insert the comment. We always insert a comment on bug creation,
    # but sometimes it's blank.
    my @columns = qw(bug_id who bug_when thetext);
    my @values  = ($bug->bug_id, $bug->{reporter_id}, $timestamp, $comment);
    # We don't include the "isprivate" column unless it was specified. 
    # This allows it to fall back to its database default.
    if (defined $privacy) {
        push(@columns, 'isprivate');
        push(@values, $privacy);
    }
    my $qmarks = "?," x @columns;
    chop($qmarks);
    $dbh->do('INSERT INTO longdescs (' . join(',', @columns)  . ")
                   VALUES ($qmarks)", undef, @values);


    return $bug;
}


sub run_create_validators {
    my $class  = shift;
    my $params = $class->SUPER::run_create_validators(@_);

    my $product = $params->{product};
    $params->{product_id} = $product->id;
    delete $params->{product};

    ($params->{bug_status}, $params->{everconfirmed})
        = $class->_check_bug_status($params->{bug_status}, $product);

    # Check whether a comment is required on bug creation.
    my $vars = {};
    $vars->{comment_exists} = ($params->{comment} =~ /\S+/) ? 1 : 0;
    Bugzilla::Bug->check_status_change_triggers($params->{bug_status}, [], $vars);

    $params->{target_milestone} = $class->_check_target_milestone($product,
        $params->{target_milestone});

    $params->{version} = $class->_check_version($product, $params->{version});

    $params->{keywords} = $class->_check_keywords($params->{keywords}, $product);

    $params->{groups} = $class->_check_groups($product,
        $params->{groups});

    my $component = $class->_check_component($product, $params->{component});
    $params->{component_id} = $component->id;
    delete $params->{component};

    $params->{assigned_to} = 
        $class->_check_assigned_to($component, $params->{assigned_to});
    $params->{qa_contact} =
        $class->_check_qa_contact($component, $params->{qa_contact});
    $params->{cc} = $class->_check_cc($component, $params->{cc});

    # Callers cannot set Reporter, currently.
    $params->{reporter} = Bugzilla->user->id;

    $params->{creation_ts} ||= Bugzilla->dbh->selectrow_array('SELECT NOW()');
    $params->{delta_ts} = $params->{creation_ts};

    if ($params->{estimated_time}) {
        $params->{remaining_time} = $params->{estimated_time};
    }

    $class->_check_strict_isolation($product, $params->{cc},
                                    $params->{assigned_to}, $params->{qa_contact});

    ($params->{dependson}, $params->{blocked}) = 
        $class->_check_dependencies($params->{dependson}, $params->{blocked},
                                    $product);

    # You can't set these fields on bug creation (or sometimes ever).
    delete $params->{resolution};
    delete $params->{votes};
    delete $params->{lastdiffed};
    delete $params->{bug_id};

    return $params;
}

sub update {
    my $self = shift;

    my $dbh = Bugzilla->dbh;
    # XXX This is just a temporary hack until all updating happens
    # inside this function.
    my $delta_ts = shift || $dbh->selectrow_array("SELECT NOW()");
    $self->{delta_ts} = $delta_ts;

    my $changes = $self->SUPER::update(@_);

    foreach my $comment (@{$self->{added_comments} || []}) {
        my $columns = join(',', keys %$comment);
        my @values  = values %$comment;
        my $qmarks  = join(',', ('?') x @values);
        $dbh->do("INSERT INTO longdescs (bug_id, who, bug_when, $columns)
                       VALUES (?,?,?,$qmarks)", undef,
                 $self->bug_id, Bugzilla->user->id, $delta_ts, @values);
    }

    # Log bugs_activity items
    # XXX Eventually, when bugs_activity is able to track the dupe_id,
    # this code should go below the duplicates-table-updating code below.
    foreach my $field (keys %$changes) {
        my $change = $changes->{$field};
        my $from = defined $change->[0] ? $change->[0] : '';
        my $to   = defined $change->[1] ? $change->[1] : '';
        LogActivityEntry($self->id, $field, $from, $to, Bugzilla->user->id,
                         $delta_ts);
    }

    # If this bug is no longer a duplicate, it no longer belongs in the
    # dup table.
    if (exists $changes->{'resolution'} 
        && $changes->{'resolution'}->[0] eq 'DUPLICATE') 
    {
        my $dup_id = $self->dup_id;
        $dbh->do("DELETE FROM duplicates WHERE dupe = ?", undef, $self->id);
        $changes->{'dupe_of'} = [$dup_id, undef];
    }

    return $changes;
}

# XXX Temporary hack until all of process_bug uses update().
sub update_cc {
    my $self = shift;
    
    my $dbh = Bugzilla->dbh;
    my $delta_ts = shift || $dbh->selectrow_array("SELECT NOW()");
    
    my $old_bug = $self->new($self->id);
    my @old_cc = map {$_->id} @{$old_bug->cc_users};
    my @new_cc = map {$_->id} @{$self->cc_users};
    my ($removed, $added) = diff_arrays(\@old_cc, \@new_cc);
    
    if (scalar @$removed) {
        $dbh->do('DELETE FROM cc WHERE bug_id = ? AND who IN (' .
                  join(',', @$removed) . ')', undef, $self->id);
    }
    foreach my $user_id (@$added) {
        $dbh->do('INSERT INTO cc (bug_id, who) VALUES (?,?)',
                 undef, $self->id, $user_id);
    }
    my $removed_users = Bugzilla::User->new_from_list($removed);
    my $added_users   = Bugzilla::User->new_from_list($added);
   
    # If any changes were found, record it in the activity log
    if (scalar @$removed || scalar @$added) {
        my $removed_names = join(', ', (map {$_->login} @$removed_users));
        my $added_names   = join(', ', (map {$_->login} @$added_users));
        LogActivityEntry($self->id, "cc", $removed_names, $added_names,
                         Bugzilla->user->id, $delta_ts);
    }

    return ($removed_users, $added_users);
}

# XXX Temporary hack until all of process_bug uses update()
sub update_dependencies {
    # We need to send mail for dependson/blocked bugs if the dependencies
    # change or the status or resolution change. This var keeps track of that.
    my $check_dep_bugs = 0;

    my $self = shift;
    
    my $dbh = Bugzilla->dbh;
    my $delta_ts = shift || $dbh->selectrow_array("SELECT NOW()");
    
    my $old_bug = $self->new($self->id);
    
    my %changes;
    foreach my $pair ([qw(dependson blocked)], [qw(blocked dependson)]) {
        my ($type, $other) = @$pair;
        my $old = $old_bug->$type;
        my $new = $self->$type;
        
        my ($removed, $added) = diff_arrays($old, $new);
        foreach my $removed_id (@$removed) {
            $dbh->do("DELETE FROM dependencies WHERE $type = ? AND $other = ?",
                     undef, $removed_id, $self->id);
            
            # Add an activity entry for the other bug.
            LogActivityEntry($removed_id, $other, $self->id, '',
                             Bugzilla->user->id, $delta_ts);
            # Update delta_ts on the other bug so that we trigger mid-airs.
            $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?',
                     undef, $delta_ts, $removed_id);
        }
        foreach my $added_id (@$added) {
            $dbh->do("INSERT INTO dependencies ($type, $other) VALUES (?,?)",
                     undef, $added_id, $self->id);
            
            # Add an activity entry for the other bug.
            LogActivityEntry($added_id, $other, '', $self->id,
                             Bugzilla->user->id, $delta_ts);
            # Update delta_ts on the other bug so that we trigger mid-airs.
            $dbh->do('UPDATE bugs SET delta_ts = ? WHERE bug_id = ?',
                     undef, $delta_ts, $added_id);
        }
        
        LogActivityEntry($self->id, $type, join(', ', @$removed),
                         join(', ', @$added), Bugzilla->user->id, $delta_ts);
        
        $changes{$type} = [$removed, $added];
    }

    return \%changes;
}

# XXX Temporary hack until all of process_bug uses update()
sub update_keywords {
    my $self = shift;
    
    my $dbh = Bugzilla->dbh;
    my $delta_ts = shift || $dbh->selectrow_array("SELECT NOW()");
    
    my $old_bug = $self->new($self->id);
    my @old_ids = map { $_->id } @{$old_bug->keyword_objects};
    my @new_ids = map { $_->id } @{$self->keyword_objects};

    my ($removed, $added) = diff_arrays(\@old_ids, \@new_ids);

    if (scalar @$removed) {
        $dbh->do('DELETE FROM keywords WHERE bug_id = ? AND keywordid IN ('
                 . join(',', @$removed) . ')', undef, $self->id);
    }
    foreach my $keyword_id (@$added) {
        $dbh->do('INSERT INTO keywords (bug_id, keywordid) VALUES (?,?)',
                 undef, $self->id, $keyword_id);
    }
    
    $dbh->do('UPDATE bugs SET keywords = ? WHERE bug_id = ?', undef,
             $self->keywords, $self->id);

    my $removed_keywords = Bugzilla::Keyword->new_from_list($removed);
    my $added_keywords   = Bugzilla::Keyword->new_from_list($added);

    # If any changes were found, record it in the activity log
    if (scalar @$removed || scalar @$added) {
        my $removed_names = join(', ', (map {$_->name} @$removed_keywords));
        my $added_names   = join(', ', (map {$_->name} @$added_keywords));
        LogActivityEntry($self->id, "keywords", $removed_names,
                         $added_names, Bugzilla->user->id, $delta_ts);
    }

    return [$removed_keywords, $added_keywords];
}

# 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

    # Also, the attach_data table uses attachments.attach_id as a foreign
    # key, and so indirectly depends on a bug deletion too.

    $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',
                         'attach_data 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);

    # The attach_data table doesn't depend on bugs.bug_id directly.
    my $attach_ids =
        $dbh->selectcol_arrayref("SELECT attach_id FROM attachments
                                  WHERE bug_id = ?", undef, $bug_id);

    if (scalar(@$attach_ids)) {
        $dbh->do("DELETE FROM attach_data WHERE id IN (" .
                 join(",", @$attach_ids) . ")");
    }

    # 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;
}

#####################################################################
# Validators
#####################################################################

sub _check_alias {
   my ($invocant, $alias) = @_;
   $alias = trim($alias);
   return undef if (!Bugzilla->params->{'usebugaliases'} || !$alias);

    # Make sure the alias isn't too long.
    if (length($alias) > 20) {
        ThrowUserError("alias_too_long");
    }
    # Make sure the alias isn't just a number.
    if ($alias =~ /^\d+$/) {
        ThrowUserError("alias_is_numeric", { alias => $alias });
    }
    # Make sure the alias has no commas or spaces.
    if ($alias =~ /[, ]/) {
        ThrowUserError("alias_has_comma_or_space", { alias => $alias });
    }
    # Make sure the alias is unique, or that it's already our alias.
    my $other_bug = new Bugzilla::Bug($alias);
    if (!$other_bug->{error}
        && (!ref $invocant || $other_bug->id != $invocant->id))
    {
        ThrowUserError("alias_in_use", { alias => $alias,
                                         bug_id => $other_bug->id });
    }

   return $alias;
}

sub _check_assigned_to {
    my ($invocant, $component, $name) = @_;
    my $user = Bugzilla->user;

    $name = trim($name);
    # Default assignee is the component owner.
    my $id;
    if (!$user->in_group('editbugs', $component->product_id) || !$name) {
        $id = $component->default_assignee->id;
    } else {
        $id = login_to_id($name, THROW_ERROR);
    }
    return $id;
}

sub _check_bug_file_loc {
    my ($invocant, $url) = @_;
    $url = '' if !defined($url);
    # On bug entry, if bug_file_loc is "http://", the default, use an 
    # empty value instead. However, on bug editing people can set that
    # back if they *really* want to.
    if (!ref $invocant && $url eq 'http://') {
        $url = '';
    }
    return $url;
}

sub _check_bug_severity {
    my ($invocant, $severity) = @_;
    $severity = trim($severity);
    check_field('bug_severity', $severity);
    return $severity;
}

sub _check_bug_status {
    my ($invocant, $status, $product) = @_;
    my $user = Bugzilla->user;

    my @valid_statuses;
    if (ref $invocant) {
        $product = $invocant->product_obj;
        @valid_statuses = map { $_->name } @{$invocant->status->can_change_to};
    }
    else {
        @valid_statuses = map { $_->name } @{Bugzilla::Status->can_change_to()};
    }

    if (!$product->votes_to_confirm) {
        # UNCONFIRMED becomes an invalid status if votes_to_confirm is 0,
        # even if you are in editbugs.
        @valid_statuses = grep {$_ ne 'UNCONFIRMED'} @valid_statuses;
    }

    if (!ref($invocant)) {
        if ($user->in_group('editbugs', $product->id)
            || $user->in_group('canconfirm', $product->id)) {
           # If the user with privs hasn't selected another status,
           # select the first one of the list.
           $status ||= $valid_statuses[0];
        }
        else {
            # A user with no privs cannot choose the initial status.
            $status = $valid_statuses[0];
        }
    }

    # This check already takes the workflow into account.
    check_field('bug_status', $status, \@valid_statuses);

    return $status if ref $invocant;
    return ($status, $status eq 'UNCONFIRMED' ? 0 : 1);
}

sub _check_cc {
    my ($invocant, $component, $ccs) = @_;
    return [map {$_->id} @{$component->initial_cc}] unless $ccs;

    my %cc_ids;
    foreach my $person (@$ccs) {
        next unless $person;
        my $id = login_to_id($person, THROW_ERROR);
        $cc_ids{$id} = 1;
    }

    # Enforce Default CC
    $cc_ids{$_->id} = 1 foreach (@{$component->initial_cc});

    return [keys %cc_ids];
}

sub _check_comment {
    my ($invocant, $comment) = @_;

    $comment = '' unless defined $comment;

    # Remove any trailing whitespace. Leading whitespace could be
    # a valid part of the comment.
    $comment =~ s/\s*$//s;
    $comment =~ s/\r\n?/\n/g; # Get rid of \r.

    ValidateComment($comment);

    # Creation-only checks
    if (!ref $invocant) {
        # On creation only, there must be a single-space comment, or
        # email will be supressed.
        $comment = ' ' if $comment eq '';
    }

    return $comment;
}

sub _check_commentprivacy {
    my ($invocant, $comment_privacy) = @_;
    my $insider_group = Bugzilla->params->{"insidergroup"};
    return ($insider_group && Bugzilla->user->in_group($insider_group) 
            && $comment_privacy) ? 1 : 0;
}

sub _check_comment_type {
    my ($invocant, $type) = @_;
    detaint_natural($type)
      || ThrowCodeError('bad_arg', { argument => 'type', 
                                     function => caller });
    return $type;
}

sub _check_component {
    my ($invocant, $product, $name) = @_;
    $name = trim($name);
    $name || ThrowUserError("require_component");
    my $obj = Bugzilla::Component::check_component($product, $name);
    return $obj;
}

sub _check_deadline {
    my ($invocant, $date) = @_;
    
    # Check time-tracking permissions.
    my $tt_group = Bugzilla->params->{"timetrackinggroup"};
    my $current = ref $invocant ? $invocant->deadline : undef;
    return $current unless $tt_group && Bugzilla->user->in_group($tt_group);
    
    # Validate entered deadline
    $date = trim($date);
    return undef if !$date;
    validate_date($date)
        || ThrowUserError('illegal_date', { date   => $date,
                                            format => 'YYYY-MM-DD' });
    return $date;
}

# Takes two comma/space-separated strings and returns arrayrefs
# of valid bug IDs.
sub _check_dependencies {
    my ($invocant, $depends_on, $blocks, $product) = @_;

    if (!ref $invocant) {
        # Only editbugs users can set dependencies on bug entry.
        return ([], []) unless Bugzilla->user->in_group('editbugs',
                                                        $product->id);
    }

    my %deps_in = (dependson => $depends_on || '', blocked => $blocks || '');

    foreach my $type qw(dependson blocked) {
        my @bug_ids = split(/[\s,]+/, $deps_in{$type});
        # Eliminate nulls.
        @bug_ids = grep {$_} @bug_ids;
        # We do Validate up here to make sure all aliases are converted to IDs.
        ValidateBugID($_, $type) foreach @bug_ids;
       
        my @check_access = @bug_ids;
        # When we're updating a bug, only added or removed bug_ids are 
        # checked for whether or not we can see/edit those bugs.
        if (ref $invocant) {
            my $old = $invocant->$type;
            my ($removed, $added) = diff_arrays($old, \@bug_ids);
            @check_access = (@$added, @$removed);
            
            # Check field permissions if we've changed anything.
            if (@check_access) {
                my $privs;
                if (!$invocant->check_can_change_field($type, 0, 1, \$privs)) {
                    ThrowUserError('illegal_change', { field => $type,
                                                       privs => $privs });
                }
            }
        }

        my $user = Bugzilla->user;
        foreach my $modified_id (@check_access) {
            ValidateBugID($modified_id);
            # Under strict isolation, you can't modify a bug if you can't
            # edit it, even if you can see it.
            if (Bugzilla->params->{"strict_isolation"}) {
                my $delta_bug = new Bugzilla::Bug($modified_id);
                if (!$user->can_edit_product($delta_bug->{'product_id'})) {
                    ThrowUserError("illegal_change_deps", {field => $type});
                }
            }
        }
        
        $deps_in{$type} = \@bug_ids;
    }

    # And finally, check for dependency loops.
    my $bug_id = ref($invocant) ? $invocant->id : 0;
    my %deps = ValidateDependencies($deps_in{dependson}, $deps_in{blocked}, $bug_id);

    return ($deps{'dependson'}, $deps{'blocked'});
}

sub _check_estimated_time {
    return $_[0]->_check_time($_[1], 'estimated_time');
}

sub _check_freetext_field { return defined $_[1] ? trim($_[1]) : ''; }

sub _check_groups {
    my ($invocant, $product, $group_ids) = @_;

    my $user = Bugzilla->user;

    my %add_groups;
    my $controls = $product->group_controls;

    foreach my $id (@$group_ids) {
        my $group = new Bugzilla::Group($id)
            || ThrowUserError("invalid_group_ID");

        # This can only happen if somebody hacked the enter_bug form.
        ThrowCodeError("inactive_group", { name => $group->name })
            unless $group->is_active;

        my $membercontrol = $controls->{$id}
                            && $controls->{$id}->{membercontrol};
        my $othercontrol  = $controls->{$id} 
                            && $controls->{$id}->{othercontrol};
        
        my $permit = ($membercontrol && $user->in_group($group->name))
                     || $othercontrol;

        $add_groups{$id} = 1 if $permit;
    }

    foreach my $id (keys %$controls) {
        next unless $controls->{$id}->{'group'}->is_active;
        my $membercontrol = $controls->{$id}->{membercontrol} || 0;
        my $othercontrol  = $controls->{$id}->{othercontrol}  || 0;

        # Add groups required
        if ($membercontrol == CONTROLMAPMANDATORY
            || ($othercontrol == CONTROLMAPMANDATORY
                && !$user->in_group_id($id))) 
        {
            # User had no option, bug needs to be in this group.
            $add_groups{$id} = 1;
        }
    }

    my @add_groups = keys %add_groups;
    return \@add_groups;
}

sub _check_keywords {
    my ($invocant, $keyword_string, $product) = @_;
    $keyword_string = trim($keyword_string);
    return [] if !$keyword_string;
    
    # On creation, only editbugs users can set keywords.
    if (!ref $invocant) {
        return [] if !Bugzilla->user->in_group('editbugs', $product->id);
    }
    
    my %keywords;
    foreach my $keyword (split(/[\s,]+/, $keyword_string)) {
        next unless $keyword;
        my $obj = new Bugzilla::Keyword({ name => $keyword });
        ThrowUserError("unknown_keyword", { keyword => $keyword }) if !$obj;
        $keywords{$obj->id} = $obj;
    }
    return [values %keywords];
}

sub _check_product {
    my ($invocant, $name) = @_;
    my $obj;
    if (ref $invocant) {
         $obj = Bugzilla::Product::check_product($name);
    }
    else {
        # Check that the product exists and that the user
        # is allowed to enter bugs into this product.
        Bugzilla->user->can_enter_product($name, THROW_ERROR);
        # can_enter_product already does everything that check_product
        # would do for us, so we don't need to use it.
        $obj = new Bugzilla::Product({ name => $name });
    }

    return $obj;
}

sub _check_op_sys {
    my ($invocant, $op_sys) = @_;
    $op_sys = trim($op_sys);
    check_field('op_sys', $op_sys);
    return $op_sys;
}

sub _check_priority {
    my ($invocant, $priority) = @_;
    if (!ref $invocant && !Bugzilla->params->{'letsubmitterchoosepriority'}) {
        $priority = Bugzilla->params->{'defaultpriority'};
    }
    $priority = trim($priority);
    check_field('priority', $priority);

    return $priority;
}

sub _check_remaining_time {
    return $_[0]->_check_time($_[1], 'remaining_time');
}

sub _check_rep_platform {
    my ($invocant, $platform) = @_;
    $platform = trim($platform);
    check_field('rep_platform', $platform);
    return $platform;
}

sub _check_resolution {
    my ($invocant, $resolution) = @_;
    $resolution = trim($resolution);
    check_field('resolution', $resolution);
    return $resolution;
}

sub _check_short_desc {
    my ($invocant, $short_desc) = @_;
    # Set the parameter to itself, but cleaned up
    $short_desc = clean_text($short_desc) if $short_desc;

    if (!defined $short_desc || $short_desc eq '') {
        ThrowUserError("require_summary");
    }
    return $short_desc;
}

sub _check_status_whiteboard { return defined $_[1] ? $_[1] : ''; }

# Unlike other checkers, this one doesn't return anything.
sub _check_strict_isolation {
    my ($invocant, $product, $cc_ids, $assignee_id, $qa_contact_id) = @_;

    return unless Bugzilla->params->{'strict_isolation'};

    my @related_users = @$cc_ids;
    push(@related_users, $assignee_id);

    if (Bugzilla->params->{'useqacontact'} && $qa_contact_id) {
        push(@related_users, $qa_contact_id);
    }

    # For each unique user in @related_users...(assignee and qa_contact
    # could be duplicates of users in the CC list)
    my %unique_users = map {$_ => 1} @related_users;
    my @blocked_users;
    foreach my $pid (keys %unique_users) {
        my $related_user = Bugzilla::User->new($pid);
        if (!$related_user->can_edit_product($product->id)) {
            push (@blocked_users, $related_user->login);
        }
    }
    if (scalar(@blocked_users)) {
        ThrowUserError("invalid_user_group",
            {'users' => \@blocked_users,
             'new' => 1,
             'product' => $product->name});
    }
}

sub _check_target_milestone {
    my ($invocant, $product, $target) = @_;
    $target = trim($target);
    if (ref $invocant) {
        return undef if !Bugzilla->params->{'usetargetmilestone'};
    }
    $target = $product->default_milestone if !defined $target;
    check_field('target_milestone', $target,
            [map($_->name, @{$product->milestones})]);
    return $target;
}

sub _check_time {
    my ($invocant, $time, $field) = @_;

    my $current = 0;
    if (ref $invocant && $field ne 'work_time') {
        $current = $invocant->$field;
    }
    my $tt_group = Bugzilla->params->{"timetrackinggroup"};
    return $current unless $tt_group && Bugzilla->user->in_group($tt_group);
    
    $time = trim($time) || 0;
    ValidateTime($time, $field);
    return $time;
}

sub _check_qa_contact {
    my ($invocant, $component, $name) = @_;
    my $user = Bugzilla->user;

    return undef unless Bugzilla->params->{'useqacontact'};

    $name = trim($name);

    my $id;
    if (!$user->in_group('editbugs', $component->product_id) || !$name) {
        # We want to insert NULL into the database if we get a 0.
        $id = $component->default_qa_contact->id || undef;
    } else {
        $id = login_to_id($name, THROW_ERROR);
    }

    return $id;
}

sub _check_version {
    my ($invocant, $product, $version) = @_;
    $version = trim($version);
    check_field('version', $version, [map($_->name, @{$product->versions})]);
    return $version;
}

sub _check_work_time {
    return $_[0]->_check_time($_[1], 'work_time');
}

sub _check_select_field {
    my ($invocant, $value, $field) = @_;
    $value = trim($value);
    check_field($field, $value);
    return $value;
}

#####################################################################
# Class Accessors
#####################################################################

sub fields {
    my $class = shift;

    return (
        # Standard Fields
        # Keep this ordering in sync with bugzilla.dtd.
        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 dup_id
           bug_file_loc status_whiteboard keywords
           priority bug_severity target_milestone
           dependson blocked votes everconfirmed
           reporter assigned_to cc),
    
        # Conditional Fields
        Bugzilla->params->{'useqacontact'} ? "qa_contact" : (),
        Bugzilla->params->{'timetrackinggroup'} ? 
            qw(estimated_time remaining_time actual_time deadline) : (),
    
        # Custom Fields
        Bugzilla->custom_field_names
    );
}

#####################################################################
# Mutators 
#####################################################################

# To run check_can_change_field.
sub _set_global_validator {
    my ($self, $value, $field) = @_;
    my $current_value = $self->$field;
    my $privs;
    $self->check_can_change_field($field, $current_value, $value, \$privs)
        || ThrowUserError('illegal_change', { field    => $field,
                                              oldvalue => $current_value,
                                              newvalue => $value,
                                              privs    => $privs });
}


#################
# "Set" Methods #
#################

sub set_alias { $_[0]->set('alias', $_[1]); }
sub set_cclist_accessible { $_[0]->set('cclist_accessible', $_[1]); }
sub set_custom_field {
    my ($self, $field, $value) = @_;
    ThrowCodeError('field_not_custom', { field => $field }) if !$field->custom;
    $self->set($field->name, $value);
}
sub set_deadline { $_[0]->set('deadline', $_[1]); }
sub set_dependencies {
    my ($self, $dependson, $blocked) = @_;
    ($dependson, $blocked) = $self->_check_dependencies($dependson, $blocked);
    # These may already be detainted, but all setters are supposed to
    # detaint their input if they've run a validator (just as though
    # we had used Bugzilla::Object::set), so we do that here.
    detaint_natural($_) foreach (@$dependson, @$blocked);
    $self->{'dependson'} = $dependson;
    $self->{'blocked'}   = $blocked;
}
sub set_estimated_time { $_[0]->set('estimated_time', $_[1]); }
sub _set_everconfirmed { $_[0]->set('everconfirmed', $_[1]); }
sub set_op_sys         { $_[0]->set('op_sys',        $_[1]); }
sub set_platform       { $_[0]->set('rep_platform',  $_[1]); }
sub set_priority       { $_[0]->set('priority',      $_[1]); }
sub set_remaining_time { $_[0]->set('remaining_time', $_[1]); }
# Used only when closing a bug or moving between closed states.
sub _zero_remaining_time { $_[0]->{'remaining_time'} = 0; }
sub set_reporter_accessible { $_[0]->set('reporter_accessible', $_[1]); }
sub set_resolution     { $_[0]->set('resolution',    $_[1]); }
sub set_severity       { $_[0]->set('bug_severity',  $_[1]); }
sub set_status { 
    my ($self, $status) = @_;
    $self->set('bug_status', $status); 
    # Check for the everconfirmed transition
    $self->_set_everconfirmed(1) if (is_open_state($status) && $status ne 'UNCONFIRMED');
}
sub set_status_whiteboard { $_[0]->set('status_whiteboard', $_[1]); }
sub set_summary        { $_[0]->set('short_desc',    $_[1]); }
sub set_url            { $_[0]->set('bug_file_loc',  $_[1]); }

########################
# "Add/Remove" Methods #
########################

# These are in alphabetical order by field name.

# Accepts a User object or a username. Adds the user only if they
# don't already exist as a CC on the bug.
sub add_cc {
    # XXX $product is a temporary hack until all of process_bug uses Bug
    # objects for updating.
    my ($self, $user_or_name, $product) = @_;
    return if !$user_or_name;
    my $user = ref $user_or_name ? $user_or_name
                                 : Bugzilla::User::check($user_or_name);

    my $product_id = $product ? $product->id : $self->{product_id};
    if (Bugzilla->params->{strict_isolation}
        && !$user->can_edit_product($product_id))
    {
        ThrowUserError('invalid_user_group', { users  => $user->login,
                                               bug_id => $self->id });
    }

    my $cc_users = $self->cc_users;
    push(@$cc_users, $user) if !grep($_->id == $user->id, @$cc_users);
}

# Accepts a User object or a username. Removes the User if they exist
# in the list, but doesn't throw an error if they don't exist.
sub remove_cc {
    my ($self, $user_or_name) = @_;
    my $user = ref $user_or_name ? $user_or_name
                                 : Bugzilla::User::check($user_or_name);
    my $cc_users = $self->cc_users;
    @$cc_users = grep { $_->id != $user->id } @$cc_users;
}

# $bug->add_comment("comment", {isprivate => 1, work_time => 10.5,
#                               type => CMT_NORMAL, extra_data => $data});
sub add_comment {
    my ($self, $comment, $params) = @_;

    $comment = $self->_check_comment($comment);
    # XXX At some point we need to refactor check_can_change_field
    # so that custom installs can use PrivilegesRequired here.
    $self->check_can_change_field('longdesc')
        || ThrowUserError('illegal_change', { field => 'longdesc' });

    $params ||= {};
    if (exists $params->{work_time}) {
        $params->{work_time} = $self->_check_work_time($params->{work_time});
    }
    if (exists $params->{type}) {
        $params->{type} = $self->_check_comment_type($params->{type});
    }
    if (exists $params->{isprivate}) {
        $params->{isprivate} = 
            $self->_check_commentprivacy($params->{isprivate});
    }
    # XXX We really should check extra_data, too.

    if ($comment eq '' && !($params->{type} || $params->{work_time})) {
        return;
    }

    $self->{added_comments} ||= [];
    my $add_comment = dclone($params);
    $add_comment->{thetext} = $comment;

    # We only want to trick_taint fields that we know about--we don't
    # want to accidentally let somebody set some field that's not OK
    # to set!
    foreach my $field (UPDATE_COMMENT_COLUMNS) {
        trick_taint($add_comment->{$field}) if defined $add_comment->{$field};
    }

    push(@{$self->{added_comments}}, $add_comment);
}

# There was a lot of duplicate code when I wrote this as three separate
# functions, so I just combined them all into one. This is also easier for
# process_bug to use.
sub modify_keywords {
    my ($self, $keywords, $action) = @_;
    
    $action ||= "makeexact";
    if (!grep($action eq $_, qw(add delete makeexact))) {
        $action = "makeexact";
    }
    
    $keywords = $self->_check_keywords($keywords);

    my (@result, $any_changes);
    if ($action eq 'makeexact') {
        @result = @$keywords;
        # Check if anything was added or removed.
        my @old_ids = map { $_->id } @{$self->keyword_objects};
        my @new_ids = map { $_->id } @result;
        my ($removed, $added) = diff_arrays(\@old_ids, \@new_ids);
        $any_changes = scalar @$removed || scalar @$added;
    }
    else {
        # We're adding or deleting specific keywords.
        my %keys = map {$_->id => $_} @{$self->keyword_objects};
        if ($action eq 'add') {
            $keys{$_->id} = $_ foreach @$keywords;
        }
        else {
            delete $keys{$_->id} foreach @$keywords;
        }
        @result = values %keys;
        $any_changes = scalar @$keywords;
    }
    # Make sure we retain the sort order.
    @result = sort {lc($a->name) cmp lc($b->name)} @result;
    
    if ($any_changes) {
        my $privs;
        my $new = join(', ', (map {$_->name} @result));
        my $check = $self->check_can_change_field('keywords', 0, 1, \$privs)
            || ThrowUserError('illegal_change', { field    => 'keywords',
                                                  oldvalue => $self->keywords,
                                                  newvalue => $new,
                                                  privs    => $privs });
    }

    $self->{'keyword_objects'} = \@result;
    return $any_changes;
}

#####################################################################
# Instance 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(Bugzilla->params->{"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->get_attachments_by_bug($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'};
}

# XXX Eventually this will become the standard "cc" method used everywhere.
sub cc_users {
    my $self = shift;
    return $self->{'cc_users'} if exists $self->{'cc_users'};
    return [] if $self->{'error'};
    
    my $dbh = Bugzilla->dbh;
    my $cc_ids = $dbh->selectcol_arrayref(
        'SELECT who FROM cc WHERE bug_id = ?', undef, $self->id);
    $self->{'cc_users'} = Bugzilla::User->new_from_list($cc_ids);
    return $self->{'cc_users'};
}

sub component {
    my ($self) = @_;
    return $self->{component} if exists $self->{component};
    return '' if $self->{error};
    ($self->{component}) = Bugzilla->dbh->selectrow_array(
        'SELECT name FROM components WHERE id = ?',
        undef, $self->{component_id});
    return $self->{component};
}

sub classification_id {
    my ($self) = @_;
    return $self->{classification_id} if exists $self->{classification_id};
    return 0 if $self->{error};
    ($self->{classification_id}) = Bugzilla->dbh->selectrow_array(
        'SELECT classification_id FROM products WHERE id = ?',
        undef, $self->{product_id});
    return $self->{classification_id};
}

sub classification {
    my ($self) = @_;
    return $self->{classification} if exists $self->{classification};
    return '' if $self->{error};
    ($self->{classification}) = Bugzilla->dbh->selectrow_array(
        'SELECT name FROM classifications WHERE id = ?',
        undef, $self->classification_id);
    return $self->{classification};
}

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' });
    }

    $self->{'flag_types'} = $flag_types;

    return $self->{'flag_types'};
}

sub isopened {
    my $self = shift;
    return is_open_state($self->{bug_status}) ? 1 : 0;
}

sub isunconfirmed {
    my $self = shift;
    return ($self->bug_status eq 'UNCONFIRMED') ? 1 : 0;
}

sub keywords {
    my ($self) = @_;
    return join(', ', (map { $_->name } @{$self->keyword_objects}));
}

# XXX At some point, this should probably replace the normal "keywords" sub.
sub keyword_objects {
    my $self = shift;
    return $self->{'keyword_objects'} if defined $self->{'keyword_objects'};
    return [] if $self->{'error'};

    my $dbh = Bugzilla->dbh;
    my $ids = $dbh->selectcol_arrayref(
         "SELECT keywordid FROM keywords WHERE bug_id = ?", undef, $self->id);
    $self->{'keyword_objects'} = Bugzilla::Keyword->new_from_list($ids);
    return $self->{'keyword_objects'};
}

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'} = $self->product_obj->milestone_url;
    return $self->{'milestoneurl'};
}

sub product {
    my ($self) = @_;
    return $self->{product} if exists $self->{product};
    return '' if $self->{error};
    ($self->{product}) = Bugzilla->dbh->selectrow_array(
        'SELECT name FROM products WHERE id = ?',
        undef, $self->{product_id});
    return $self->{product};
}

# XXX This should eventually replace the "product" subroutine.
sub product_obj {
    my $self = shift;
    return {} if $self->{error};
    $self->{prod_obj} ||= new Bugzilla::Product($self->{product_id});
    return $self->{prod_obj};
}

sub qa_contact {
    my ($self) = @_;
    return $self->{'qa_contact'} if exists $self->{'qa_contact'};
    return undef if $self->{'error'};

    if (Bugzilla->params->{'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 status {
    my $self = shift;
    return undef if $self->{'error'};

    $self->{'status'} ||= new Bugzilla::Status({name => $self->{'bug_status'}});
    return $self->{'status'};
}

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 });

    $self->{'show_attachment_flags'} =
        ($num_attachment_flag_types || $num_attachment_flags);

    return $self->{'show_attachment_flags'};
}

sub use_votes {
    my ($self) = @_;
    return 0 if $self->{'error'};

    return Bugzilla->params->{'usevotes'} 
           && $self->product_obj->votes_per_user > 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 $grouplist = Bugzilla->user->groups_as_string;
    my $sth = $dbh->prepare(
             "SELECT DISTINCT groups.id, name, description," .
             " CASE WHEN bug_group_map.group_id IS NOT NULL" .
             " THEN 1 ELSE 0 END," .
             " CASE WHEN groups.id IN($grouplist) THEN 1 ELSE 0 END," .
             " isactive, membercontrol, othercontrol" .
             " FROM groups" . 
             " LEFT JOIN bug_group_map" .
             " ON bug_group_map.group_id = groups.id" .
             " AND bug_id = ?" .
             " 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'},
                  $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 groups_in {
    my $self = shift;
    return $self->{'groups_in'} if exists $self->{'groups_in'};
    return [] if $self->{'error'};
    my $group_ids = Bugzilla->dbh->selectcol_arrayref(
        'SELECT group_id FROM bug_group_map WHERE bug_id = ?',
        undef, $self->id);
    $self->{'groups_in'} = Bugzilla::Group->new_from_list($group_ids);
    return $self->{'groups_in'};
}

sub user {
    my $self = shift;
    return $self->{'user'} if exists $self->{'user'};
    return {} if $self->{'error'};

    my $user = Bugzilla->user;
    my $canmove = Bugzilla->params->{'move-enabled'} && $user->is_mover;

    my $prod_id = $self->{'product_id'};

    my $unknown_privileges = $user->in_group('editbugs', $prod_id);
    my $canedit = $unknown_privileges
                  || $user->id == $self->{assigned_to_id}
                  || (Bugzilla->params->{'useqacontact'}
                      && $self->{'qa_contact_id'}
                      && $user->id == $self->{qa_contact_id});
    my $canconfirm = $unknown_privileges
                     || $user->in_group('canconfirm', $prod_id);
    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'};

    $self->{'choices'} = {};

    my @prodlist = map {$_->name} @{Bugzilla->user->get_enterable_products};
    # The current product is part of the popup, even if new bugs are no longer
    # allowed for that product
    if (lsearch(\@prodlist, $self->product) < 0) {
        push(@prodlist, $self->product);
        @prodlist = sort @prodlist;
    }

    # Hack - this array contains "". See bug 106589.
    my @res = grep ($_, @{settable_resolutions()});

    $self->{'choices'} =
      {
       'product' => \@prodlist,
       'rep_platform' => get_legal_field_values('rep_platform'),
       'priority'     => get_legal_field_values('priority'),
       'bug_severity' => get_legal_field_values('bug_severity'),
       'op_sys'       => get_legal_field_values('op_sys'),
       'bug_status'   => get_legal_field_values('bug_status'),
       'resolution'   => \@res,
       'component'    => [map($_->name, @{$self->product_obj->components})],
       'version'      => [map($_->name, @{$self->product_obj->versions})],
       'target_milestone' => [map($_->name, @{$self->product_obj->milestones})],
      };

    return $self->{'choices'};
}

# List of resolutions that may be set directly by hand in the bug form.
# 'MOVED' and 'DUPLICATE' are excluded from the list because setting
# bugs to those resolutions requires a special process.
sub settable_resolutions {
    my $resolutions = get_legal_field_values('resolution');
    my $pos = lsearch($resolutions, 'DUPLICATE');
    if ($pos >= 0) {
        splice(@$resolutions, $pos, 1);
    }
    $pos = lsearch($resolutions, 'MOVED');
    if ($pos >= 0) {
        splice(@$resolutions, $pos, 1);
    }
    return $resolutions;
}

sub votes {
    my ($self) = @_;
    return 0 if $self->{error};
    return $self->{votes} if defined $self->{votes};

    my $dbh = Bugzilla->dbh;
    $self->{votes} = $dbh->selectrow_array(
        'SELECT SUM(vote_count) FROM votes
          WHERE bug_id = ? ' . $dbh->sql_group_by('bug_id'),
        undef, $self->bug_id);
    $self->{votes} ||= 0;
    return $self->{votes};
}

# 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 Bugzilla->params->{"usebugaliases"};
    my $dbh = Bugzilla->dbh;
    trick_taint($alias);
    return $dbh->selectrow_array(
        "SELECT bug_id FROM bugs WHERE alias = ?", undef, $alias);
}

#####################################################################
# Workflow Control routines
#####################################################################

# Make sure that the new status is allowed by the status workflow.
sub check_status_transition {
    my ($self, $new_status) = @_;

    if (!grep { $_->name eq $self->bug_status } @{$new_status->can_change_from}) {
        ThrowUserError('illegal_bug_status_transition', {old => $self->bug_status,
                                                         new => $new_status->name})
    }
}

# Make sure all checks triggered by the workflow are successful.
# Some are hardcoded and come from older versions of Bugzilla.
sub check_status_change_triggers {
    my ($self, $action, $bugs, $vars) = @_;
    my $dbh = Bugzilla->dbh;
    $vars ||= {};

    my @bug_ids = map {$_->id} @$bugs;
    # First, make sure no comment is required if there is none.
    # If a comment is given, then this check is useless.
    if (!$vars->{comment_exists}) {
        if (grep { $action eq $_ } SPECIAL_STATUS_WORKFLOW_ACTIONS) {
            # 'commentonnone' doesn't exist, so this is safe.
            ThrowUserError('comment_required') if Bugzilla->params->{"commenton$action"};
        }
        elsif (!scalar @bug_ids) {
            # The bug is being created; that's why @bug_ids is undefined.
            my $comment_required =
              $dbh->selectrow_array('SELECT require_comment
                                       FROM status_workflow
                                 INNER JOIN bug_status
                                         ON id = new_status
                                      WHERE old_status IS NULL
                                        AND value = ?',
                                      undef, $action);

            ThrowUserError('description_required') if $comment_required;
        }
        else {
            my $required_for_transitions =
              $dbh->selectcol_arrayref('SELECT DISTINCT bug_status.value
                                          FROM bug_status
                                    INNER JOIN bugs
                                            ON bugs.bug_status = bug_status.value
                                    INNER JOIN status_workflow
                                            ON bug_status.id = old_status
                                    INNER JOIN bug_status b_s
                                            ON b_s.id = new_status
                                         WHERE bug_id IN (' . join (',', @bug_ids). ')
                                           AND b_s.value = ?
                                           AND require_comment = 1',
                                         undef, $action);

            if (scalar(@$required_for_transitions)) {
                ThrowUserError('comment_required', {old => $required_for_transitions,
                                                    new => $action});
            }
        }
    }

    # Now run hardcoded checks.
    # There is no checks for these actions.
    return if ($action eq 'none' || $action eq 'clearresolution');

    # Also leave now if we are creating a new bug (we only want to check
    # if a comment is required on bug creation).
    return unless scalar @bug_ids;

    if ($action eq 'duplicate') {
        # You cannot mark bugs as duplicates when changing
        # several bugs at once.
        $vars->{bug_id} || ThrowUserError('dupe_not_allowed');

        # Make sure we can change the original bug (issue A on bug 96085)
        $vars->{dup_id} || ThrowCodeError('undefined_field', { field => 'dup_id' });
        ValidateBugID($vars->{dup_id}, 'dup_id');

        # Make sure a loop isn't created when marking this bug
        # as duplicate.
        my %dupes;
        my $dupe_of = $vars->{dup_id};
        my $sth = $dbh->prepare('SELECT dupe_of FROM duplicates
                                 WHERE dupe = ?');

        while ($dupe_of) {
            if ($dupe_of == $vars->{bug_id}) {
                ThrowUserError('dupe_loop_detected', { bug_id  => $vars->{bug_id},
                                                       dupe_of => $vars->{dup_id} });
            }
            # If $dupes{$dupe_of} is already set to 1, then a loop
            # already exists which does not involve this bug.
            # As the user is not responsible for this loop, do not
            # prevent him from marking this bug as a duplicate.
            last if exists $dupes{"$dupe_of"};
            $dupes{"$dupe_of"} = 1;
            $sth->execute($dupe_of);
            $dupe_of = $sth->fetchrow_array;
        }

        # Also, let's see if the reporter has authorization to see
        # the bug to which we are duping. If not we need to prompt.
        $vars->{DuplicateUserConfirm} = 1;

        # DUPLICATE bugs should have no time remaining.
        foreach my $bug (@$bugs) {
            # Note that 0.00 is *true* for Perl!
            next unless ($bug->remaining_time > 0);
            $bug->_zero_remaining_time;
            $vars->{'message'} = "remaining_time_zeroed"
              if Bugzilla->user->in_group(Bugzilla->params->{'timetrackinggroup'});
        }
    }
    elsif ($action eq 'change_resolution' || !is_open_state($action)) {
        # don't resolve as fixed while still unresolved blocking bugs
        if (Bugzilla->params->{"noresolveonopenblockers"}
            && $vars->{resolution} eq 'FIXED')
        {
            my @dependencies = Bugzilla::Bug::CountOpenDependencies(@bug_ids);
            if (scalar @dependencies > 0) {
                ThrowUserError("still_unresolved_bugs",
                               { dependencies     => \@dependencies,
                                 dependency_count => scalar @dependencies });
            }
        }

        # You cannot use change_resolution if there is at least one open bug
        # nor can you close open bugs if no resolution is given.
        my $open_states = join(',', map {$dbh->quote($_)} BUG_STATE_OPEN);
        my $idlist = join(',', @bug_ids);
        my $is_open =
          $dbh->selectrow_array("SELECT 1 FROM bugs WHERE bug_id IN ($idlist)
                                 AND bug_status IN ($open_states)");

        if ($is_open) {
            ThrowUserError('resolution_not_allowed') if ($action eq 'change_resolution');
            ThrowUserError('missing_resolution', {status => $action}) if !$vars->{resolution};
        }
        # Now is good time to validate the resolution, if any.
        check_field('resolution', $vars->{resolution},
                    Bugzilla::Bug->settable_resolutions) if $vars->{resolution};

        if ($action ne 'change_resolution') {
            foreach my $b (@$bugs) {
                if ($b->bug_status ne $action) {
                    # Note that 0.00 is *true* for Perl!
                    next unless ($b->remaining_time > 0);
                    $b->_zero_remaining_time;
                    $vars->{'message'} = "remaining_time_zeroed"
                      if Bugzilla->user->in_group(Bugzilla->params->{'timetrackinggroup'});
                }
            }
        }
    }
    elsif ($action eq 'ASSIGNED'
           && Bugzilla->params->{"usetargetmilestone"}
           && Bugzilla->params->{"musthavemilestoneonaccept"})
    {
        $vars->{requiremilestone} = 1;
    }
}

sub get_new_status_and_resolution {
    my ($self, $action, $resolution) = @_;
    my $dbh = Bugzilla->dbh;

    my $status;
    my $everconfirmed = $self->everconfirmed;
    if ($action eq 'none') {
        # Leaving the status unchanged doesn't need more investigation.
        return ($self->bug_status, $self->resolution, $self->everconfirmed);
    }
    elsif ($action eq 'duplicate' || $action eq 'move') {
        # Always change the bug status, even if the bug was already "closed".
        $status = Bugzilla->params->{'duplicate_or_move_bug_status'};
        $resolution = ($action eq 'duplicate') ? 'DUPLICATE' : 'MOVED';
    }
    elsif ($action eq 'change_resolution') {
        $status = $self->bug_status;
        # You cannot change the resolution of an open bug.
        ThrowUserError('resolution_not_allowed') if is_open_state($status);
        $resolution || ThrowUserError('missing_resolution', {status => $status});
    }
    elsif ($action eq 'clearresolution') {
        $status = $self->bug_status;
        is_open_state($status) || ThrowUserError('missing_resolution', {status => $status});
        $resolution = '';
    }
    else {
        $status = $action;
        if (is_open_state($status)) {
            # Open bugs have no resolution.
            $resolution = '';
            $everconfirmed = ($status eq 'UNCONFIRMED') ? 0 : 1;
        }
        elsif (is_open_state($self->bug_status)) {
            # A resolution is required to close bugs.
            $resolution || ThrowUserError('missing_resolution', {status => $status});
        }
        else {
            # Already closed bugs can only change their resolution
            # using the change_resolution action.
            $resolution = $self->resolution
        }
    }
    # Now it's time to validate the bug resolution.
    # Bug resolutions have no workflow specific rules, so any valid
    # resolution will do it.
    check_field('resolution', $resolution) if ($resolution ne '');
    trick_taint($resolution);

    return ($status, $resolution, $everconfirmed);
}

#####################################################################
# Subroutines
#####################################################################

sub AppendComment {
    my ($bugid, $whoid, $comment, $isprivate, $timestamp, $work_time,
        $type, $extra_data) = @_;
    $work_time ||= 0;
    $type ||= CMT_NORMAL;
    my $dbh = Bugzilla->dbh;

    ValidateTime($work_time, "work_time") if $work_time;
    trick_taint($work_time);
    detaint_natural($type)
      || ThrowCodeError('bad_arg', {argument => 'type', function => 'AppendComment'});

    # Use the date/time we were given if possible (allowing calling code
    # to synchronize the comment's timestamp with those of other records).
    $timestamp ||= $dbh->selectrow_array('SELECT NOW()');

    $comment =~ s/\r\n/\n/g;     # Handle Windows-style line endings.
    $comment =~ s/\r/\n/g;       # Handle Mac-style line endings.

    if ($comment =~ /^\s*$/ && !$type) {  # 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,
                       type, extra_data)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?)}, undef,
             ($bugid, $whoid, $timestamp, $comment, $privacyval, $work_time,
              $type, $extra_data));
    $dbh->do("UPDATE bugs SET delta_ts = ? WHERE bug_id = ?",
             undef, $timestamp, $bugid);
}

sub update_comment {
    my ($self, $comment_id, $new_comment) = @_;

    # Some validation checks.
    if ($self->{'error'}) {
        ThrowCodeError("bug_error", { bug => $self });
    }
    detaint_natural($comment_id)
      || ThrowCodeError('bad_arg', {argument => 'comment_id', function => 'update_comment'});

    # The comment ID must belong to this bug.
    my @current_comment_obj = grep {$_->{'id'} == $comment_id} @{$self->longdescs};
    scalar(@current_comment_obj)
      || ThrowCodeError('bad_arg', {argument => 'comment_id', function => 'update_comment'});

    # If the new comment is undefined, then there is nothing to update.
    # To delete a comment, an empty string should be passed.
    return unless defined $new_comment;
    $new_comment =~ s/\s*$//s;    # Remove trailing whitespaces.
    $new_comment =~ s/\r\n?/\n/g; # Handle Windows and Mac-style line endings.
    trick_taint($new_comment);

    # We assume ValidateComment() has already been called earlier.
    Bugzilla->dbh->do('UPDATE longdescs SET thetext = ? WHERE comment_id = ?',
                       undef, ($new_comment, $comment_id));

    # Update the comment object with this new text.
    $current_comment_obj[0]->{'body'} = $new_comment;
}

# Represents which fields from the bugs table are handled by process_bug.cgi.
sub editable_bug_fields {
    my @fields = Bugzilla->dbh->bz_table_columns('bugs');
    # Obsolete custom fields are not editable.
    my @obsolete_fields = Bugzilla->get_fields({obsolete => 1, custom => 1});
    @obsolete_fields = map { $_->name } @obsolete_fields;
    foreach my $remove ("bug_id", "creation_ts", "delta_ts", "lastdiffed", @obsolete_fields) {
        my $location = lsearch(\@fields, $remove);
        splice(@fields, $location, 1);
    }
    # Sorted because the old @::log_columns variable, which this replaces,
    # was sorted.
    return sort(@fields);
}

# XXX - When Bug::update() will be implemented, we should make this routine
#       a private method.
sub EmitDependList {
    my ($myfield, $targetfield, $bug_id) = (@_);
    my $dbh = Bugzilla->dbh;
    my $list_ref = $dbh->selectcol_arrayref(
          "SELECT $targetfield FROM dependencies
            WHERE $myfield = ? ORDER BY $targetfield",
            undef, $bug_id);
    return $list_ref;
}

# Tells you whether or not the argument is a valid "open" state.
sub is_open_state {
    my ($state) = @_;
    return (grep($_ eq $state, BUG_STATE_OPEN) ? 1 : 0);
}

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, $start, $end, $raw) = @_;
    my $dbh = Bugzilla->dbh;

    $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 @comments;
    my @args = ($id);

    my $query = 'SELECT longdescs.comment_id AS id, profiles.realname AS name,
                        profiles.login_name AS email, ' .
                        $dbh->sql_date_format('longdescs.bug_when', '%Y.%m.%d %H:%i:%s') .
                      ' AS time, longdescs.thetext AS body, longdescs.work_time,
                        isprivate, already_wrapped, type, extra_data
                   FROM longdescs
             INNER JOIN profiles
                     ON profiles.userid = longdescs.who
                  WHERE longdescs.bug_id = ?';
    if ($start) {
        $query .= ' AND longdescs.bug_when > ?
                    AND longdescs.bug_when <= ?';
        push(@args, ($start, $end));
    }
    $query .= " ORDER BY longdescs.bug_when $sort_order";
    my $sth = $dbh->prepare($query);
    $sth->execute(@args);

    while (my $comment_ref = $sth->fetchrow_hashref()) {
        my %comment = %$comment_ref;

        $comment{'email'} .= Bugzilla->params->{'emailsuffix'};

        # If raw data is requested, do not format 'special' comments.
        $comment{'body'} = format_comment(\%comment) unless $raw;

        push (@comments, \%comment);
    }
   
    if ($comment_sort_order eq "newest_to_oldest_desc_first") {
        unshift(@comments, pop @comments);
    }

    return \@comments;
}

# Format language specific comments. This routine must not update
# $comment{'body'} itself, see BugMail::prepare_comments().
sub format_comment {
    my $comment = shift;
    my $body;

    if ($comment->{'type'} == CMT_DUPE_OF) {
        $body = $comment->{'body'} . "\n\n" .
                get_text('bug_duplicate_of', { dupe_of => $comment->{'extra_data'} });
    }
    elsif ($comment->{'type'} == CMT_HAS_DUPE) {
        $body = get_text('bug_has_duplicate', { dupe => $comment->{'extra_data'} });
    }
    elsif ($comment->{'type'} == CMT_POPULAR_VOTES) {
        $body = get_text('bug_confirmed_by_votes');
    }
    elsif ($comment->{'type'} == CMT_MOVED_TO) {
        $body = $comment->{'body'} . "\n\n" .
                get_text('bug_moved_to', { login => $comment->{'extra_data'} });
    }
    else {
        $body = $comment->{'body'};
    }
    return $body;
}

# Get the activity of a bug, starting from $starttime (if given).
# This routine assumes ValidateBugID has been previously called.
sub GetBugActivity {
    my ($id, $starttime) = @_;
    my $dbh = Bugzilla->dbh;

    # Arguments passed to the SQL query.
    my @args = ($id);

    # Only consider changes since $starttime, if given.
    my $datepart = "";
    if (defined $starttime) {
        trick_taint($starttime);
        push (@args, $starttime);
        $datepart = "AND bugs_activity.bug_when > ?";
    }

    # Only includes attachments the user is allowed to see.
    my $suppjoins = "";
    my $suppwhere = "";
    if (Bugzilla->params->{"insidergroup"} 
        && !Bugzilla->user->in_group(Bugzilla->params->{'insidergroup'})) 
    {
        $suppjoins = "LEFT JOIN attachments 
                   ON attachments.attach_id = bugs_activity.attach_id";
        $suppwhere = "AND COALESCE(attachments.isprivate, 0) = 0";
    }

    my $query = "
        SELECT COALESCE(fielddefs.description, " 
               # This is a hack - PostgreSQL requires both COALESCE
               # arguments to be of the same type, and this is the only
               # way supported by both MySQL 3 and PostgreSQL to convert
               # an integer to a string. MySQL 4 supports CAST.
               . $dbh->sql_string_concat('bugs_activity.fieldid', q{''}) .
               "), fielddefs.name, bugs_activity.attach_id, " .
        $dbh->sql_date_format('bugs_activity.bug_when', '%Y.%m.%d %H:%i:%s') .
            ", bugs_activity.removed, bugs_activity.added, profiles.login_name
          FROM bugs_activity
               $suppjoins
     LEFT JOIN fielddefs
            ON bugs_activity.fieldid = fielddefs.id
    INNER JOIN profiles
            ON profiles.userid = bugs_activity.who
         WHERE bugs_activity.bug_id = ?
               $datepart
               $suppwhere
      ORDER BY bugs_activity.bug_when";

    my $list = $dbh->selectall_arrayref($query, undef, @args);

    my @operations;
    my $operation = {};
    my $changes = [];
    my $incomplete_data = 0;

    foreach my $entry (@$list) {
        my ($field, $fieldname, $attachid, $when, $removed, $added, $who) = @$entry;
        my %change;
        my $activity_visible = 1;

        # check if the user should see this field's activity
        if ($fieldname eq 'remaining_time'
            || $fieldname eq 'estimated_time'
            || $fieldname eq 'work_time'
            || $fieldname eq 'deadline')
        {
            $activity_visible = 
                Bugzilla->user->in_group(Bugzilla->params->{'timetrackinggroup'}) ? 1 : 0;
        } else {
            $activity_visible = 1;
        }

        if ($activity_visible) {
            # This gets replaced with a hyperlink in the template.
            $field =~ s/^Attachment// if $attachid;

            # Check for the results of an old Bugzilla data corruption bug
            $incomplete_data = 1 if ($added =~ /^\?/ || $removed =~ /^\?/);

            # An operation, done by 'who' at time 'when', has a number of
            # 'changes' associated with it.
            # If this is the start of a new operation, store the data from the
            # previous one, and set up the new one.
            if ($operation->{'who'}
                && ($who ne $operation->{'who'}
                    || $when ne $operation->{'when'}))
            {
                $operation->{'changes'} = $changes;
                push (@operations, $operation);

                # Create new empty anonymous data structures.
                $operation = {};
                $changes = [];
            }

            $operation->{'who'} = $who;
            $operation->{'when'} = $when;

            $change{'field'} = $field;
            $change{'fieldname'} = $fieldname;
            $change{'attachid'} = $attachid;
            $change{'removed'} = $removed;
            $change{'added'} = $added;
            push (@$changes, \%change);
        }
    }

    if ($operation->{'who'}) {
        $operation->{'changes'} = $changes;
        push (@operations, $operation);
    }

    return(\@operations, $incomplete_data);
}

# Update the bugs_activity table to reflect changes made in bugs.
sub LogActivityEntry {
    my ($i, $col, $removed, $added, $whoid, $timestamp) = @_;
    my $dbh = Bugzilla->dbh;
    # in the case of CCs, deps, and keywords, there's a possibility that someone
    # might try to add or remove a lot of them at once, which might take more
    # space than the activity table allows.  We'll solve this by splitting it
    # into multiple entries if it's too long.
    while ($removed || $added) {
        my ($removestr, $addstr) = ($removed, $added);
        if (length($removestr) > MAX_LINE_LENGTH) {
            my $commaposition = find_wrap_point($removed, MAX_LINE_LENGTH);
            $removestr = substr($removed, 0, $commaposition);
            $removed = substr($removed, $commaposition);
            $removed =~ s/^[,\s]+//; # remove any comma or space
        } else {
            $removed = ""; # no more entries
        }
        if (length($addstr) > MAX_LINE_LENGTH) {
            my $commaposition = find_wrap_point($added, MAX_LINE_LENGTH);
            $addstr = substr($added, 0, $commaposition);
            $added = substr($added, $commaposition);
            $added =~ s/^[,\s]+//; # remove any comma or space
        } else {
            $added = ""; # no more entries
        }
        trick_taint($addstr);
        trick_taint($removestr);
        my $fieldid = get_field_id($col);
        $dbh->do("INSERT INTO bugs_activity
                  (bug_id, who, bug_when, fieldid, removed, added)
                  VALUES (?, ?, ?, ?, ?, ?)",
                  undef, ($i, $whoid, $timestamp, $fieldid, $removestr, $addstr));
    }
}

# 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(', ', map {$dbh->quote($_)} BUG_STATE_OPEN)  . ") " .
          $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 ON votes.bug_id = bugs.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]);
    }

    # @messages stores all emails which have to be sent, if any.
    # This array is passed to the caller which will send these emails itself.
    my @messages = ();

    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 $vars = {

                'to' => $name . Bugzilla->params->{'emailsuffix'},
                'bugid' => $id,
                'reason' => $reason,

                'votesremoved' => $removedvotes,
                'votesold' => $oldvotes,
                'votesnew' => $newvotes,

                'votesremovedtext' => $removedvotestext,
                'votesoldtext' => $oldvotestext,
                'votesnewtext' => $newvotestext,

                'count' => $removedvotes . "\n    " . $newvotestext
            };

            my $msg;
            my $template = Bugzilla->template;
            $template->process("email/votes-removed.txt.tmpl", $vars, \$msg);
            push(@messages, $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));
    }
    # Now return the array containing emails to be sent.
    return \@messages;
}

# 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 = get_field_id("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 = get_field_id("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, "", 0, $timestamp, 0, CMT_POPULAR_VOTES);

        $ret = 1;
    }
    return $ret;
}

################################################################################
# check_can_change_field() defines what users are allowed to change. You
# can add code here for site-specific policy changes, according to the
# instructions given in the Bugzilla Guide and below. Note that you may also
# have to update the Bugzilla::Bug::user() function to give people access to the
# options that they are permitted to change.
#
# check_can_change_field() returns true if the user is allowed to change this
# field, and false if they are not.
#
# The parameters to this method are as follows:
# $field    - name of the field in the bugs table the user is trying to change
# $oldvalue - what they are changing it from
# $newvalue - what they are changing it to
# $PrivilegesRequired - return the reason of the failure, if any
# $data     - hash containing relevant parameters, e.g. from the CGI object
################################################################################
sub check_can_change_field {
    my $self = shift;
    my ($field, $oldvalue, $newvalue, $PrivilegesRequired, $data) = (@_);
    my $user = Bugzilla->user;

    $oldvalue = defined($oldvalue) ? $oldvalue : '';
    $newvalue = defined($newvalue) ? $newvalue : '';

    # Return true if they haven't changed this field at all.
    if ($oldvalue eq $newvalue) {
        return 1;
    } elsif (trim($oldvalue) eq trim($newvalue)) {
        return 1;
    # numeric fields need to be compared using ==
    } elsif (($field eq 'estimated_time' || $field eq 'remaining_time')
             && (!$data || $newvalue ne $data->{'dontchange'})
             && $oldvalue == $newvalue)
    {
        return 1;
    }

    # Allow anyone to change comments.
    if ($field =~ /^longdesc/) {
        return 1;
    }

    # If the user isn't allowed to change a field, we must tell him who can.
    # We store the required permission set into the $PrivilegesRequired
    # variable which gets passed to the error template.
    #
    # $PrivilegesRequired = 0 : no privileges required;
    # $PrivilegesRequired = 1 : the reporter, assignee or an empowered user;
    # $PrivilegesRequired = 2 : the assignee or an empowered user;
    # $PrivilegesRequired = 3 : an empowered user.
    
    # Only users in the time-tracking group can change time-tracking fields.
    if ( grep($_ eq $field, qw(deadline estimated_time remaining_time)) ) {
        my $tt_group = Bugzilla->params->{timetrackinggroup};
        if (!$tt_group || !$user->in_group($tt_group)) {
            $$PrivilegesRequired = 3;
            return 0;
        }
    }

    # Allow anyone with (product-specific) "editbugs" privs to change anything.
    if ($user->in_group('editbugs', $self->{'product_id'})) {
        return 1;
    }

    # *Only* users with (product-specific) "canconfirm" privs can confirm bugs.
    if ($field eq 'canconfirm'
        || ($field eq 'bug_status'
            && $oldvalue eq 'UNCONFIRMED'
            && is_open_state($newvalue)))
    {
        $$PrivilegesRequired = 3;
        return $user->in_group('canconfirm', $self->{'product_id'});
    }

    # Make sure that a valid bug ID has been given.
    if (!$self->{'error'}) {
        # Allow the assignee to change anything else.
        if ($self->{'assigned_to_id'} == $user->id) {
            return 1;
        }

        # Allow the QA contact to change anything else.
        if (Bugzilla->params->{'useqacontact'}
            && $self->{'qa_contact_id'}
            && ($self->{'qa_contact_id'} == $user->id))
        {
            return 1;
        }
    }

    # At this point, the user is either the reporter or an
    # unprivileged user. We first check for fields the reporter
    # is not allowed to change.

    # The reporter may not:
    # - reassign bugs, unless the bugs are assigned to him;
    #   in that case we will have already returned 1 above
    #   when checking for the assignee of the bug.
    if ($field eq 'assigned_to') {
        $$PrivilegesRequired = 2;
        return 0;
    }
    # - change the QA contact
    if ($field eq 'qa_contact') {
        $$PrivilegesRequired = 2;
        return 0;
    }
    # - change the target milestone
    if ($field eq 'target_milestone') {
        $$PrivilegesRequired = 2;
        return 0;
    }
    # - change the priority (unless he could have set it originally)
    if ($field eq 'priority'
        && !Bugzilla->params->{'letsubmitterchoosepriority'})
    {
        $$PrivilegesRequired = 2;
        return 0;
    }

    # The reporter is allowed to change anything else.
    if (!$self->{'error'} && $self->{'reporter_id'} == $user->id) {
        return 1;
    }

    # If we haven't returned by this point, then the user doesn't
    # have the necessary permissions to change this field.
    $$PrivilegesRequired = 1;
    return 0;
}

#
# Field Validation
#

# Validates and verifies a bug ID, making sure the number is a 
# positive integer, that it represents an existing bug in the
# database, and that the user is authorized to access that bug.
# We detaint the number here, too.
sub ValidateBugID {
    my ($id, $field) = @_;
    my $dbh = Bugzilla->dbh;
    my $user = Bugzilla->user;

    # Get rid of leading '#' (number) mark, if present.
    $id =~ s/^\s*#//;
    # Remove whitespace
    $id = trim($id);

    # If the ID isn't a number, it might be an alias, so try to convert it.
    my $alias = $id;
    if (!detaint_natural($id)) {
        $id = bug_alias_to_id($alias);
        $id || ThrowUserError("invalid_bug_id_or_alias",
                              {'bug_id' => $alias,
                               'field'  => $field });
    }
    
    # Modify the calling code's original variable to contain the trimmed,
    # converted-from-alias ID.
    $_[0] = $id;
    
    # First check that the bug exists
    $dbh->selectrow_array("SELECT bug_id FROM bugs WHERE bug_id = ?", undef, $id)
      || ThrowUserError("invalid_bug_id_non_existent", {'bug_id' => $id});

    return if (defined $field && ($field eq "dependson" || $field eq "blocked"));
    
    return if $user->can_see_bug($id);

    # The user did not pass any of the authorization tests, which means they
    # are not authorized to see the bug.  Display an error and stop execution.
    # The error the user sees depends on whether or not they are logged in
    # (i.e. $user->id contains the user's positive integer ID).
    if ($user->id) {
        ThrowUserError("bug_access_denied", {'bug_id' => $id});
    } else {
        ThrowUserError("bug_access_query", {'bug_id' => $id});
    }
}

# Validate and return a hash of dependencies
sub ValidateDependencies {
    my $fields = {};
    # These can be arrayrefs or they can be strings.
    $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;
        my $target_array = ref($fields->{$target}) ? $fields->{$target}
                           : [split(/[\s,]+/, $fields->{$target})];
        foreach my $i (@$target_array) {
            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 = ();
    foreach my $b (@deps, @blocks) { $union{$b}++ && $isect{$b}++ }
    my @isect = keys %isect;
    if (scalar(@isect) > 0) {
        ThrowUserError("dependency_loop_multi", {'deps' => \@isect});
    }
    return %deps;
}


#####################################################################
# Autoloaded Accessors
#####################################################################

# Determines whether an attribute access trapped by the AUTOLOAD function
# is for a valid bug attribute.  Bug attributes are properties and methods
# predefined by this module as well as bug fields for which an accessor
# can be defined by AUTOLOAD at runtime when the accessor is first accessed.
#
# XXX Strangely, some predefined attributes are on the list, but others aren't,
# and the original code didn't specify why that is.  Presumably the only
# attributes that need to be on this list are those that aren't predefined;
# we should verify that and update the list accordingly.
#
sub _validate_attribute {
    my ($attribute) = @_;

    my @valid_attributes = (
        # Miscellaneous properties and methods.
        qw(error groups product_id component_id
           longdescs milestoneurl attachments
           isopened isunconfirmed
           flag_types num_attachment_flag_types
           show_attachment_flags any_flags_requesteeble),

        # Bug fields.
        Bugzilla::Bug->fields
    );

    return grep($attribute eq $_, @valid_attributes) ? 1 : 0;
}

sub AUTOLOAD {
  use vars qw($AUTOLOAD);
  my $attr = $AUTOLOAD;

  $attr =~ s/.*:://;
  return unless $attr=~ /[^A-Z]/;
  if (!_validate_attribute($attr)) {
      require Carp;
      Carp::confess("invalid bug attribute $attr");
  }

  no strict 'refs';
  *$AUTOLOAD = sub {
      my $self = shift;
      if (defined $self->{$attr}) {
          return $self->{$attr};
      } else {
          return '';
      }
  };

  goto &$AUTOLOAD;
}

1;