+=head2 set_merged
+
+ eval {
+ set_merged(bug => $ref,
+ transcript => $transcript,
+ ($dl > 0 ? (debug => $transcript):()),
+ requester => $header{from},
+ request_addr => $controlrequestaddr,
+ message => \@log,
+ affected_packages => \%affected_packages,
+ recipients => \%recipients,
+ merge_with => 12345,
+ add => 1,
+ force => 1,
+ allow_reassign => 1,
+ reassign_same_source_only => 1,
+ );
+ };
+ if ($@) {
+ $errors++;
+ print {$transcript} "Failed to set merged on $ref: $@";
+ }
+
+
+Sets, adds, or removes the specified merged bugs of a bug
+
+By default, requires
+
+=cut
+
+sub set_merged {
+ my %param = validate_with(params => \@_,
+ spec => {bug => {type => SCALAR,
+ regex => qr/^\d+$/,
+ },
+ # specific options here
+ merge_with => {type => ARRAYREF|SCALAR,
+ optional => 1,
+ },
+ remove => {type => BOOLEAN,
+ default => 0,
+ },
+ force => {type => BOOLEAN,
+ default => 0,
+ },
+ masterbug => {type => BOOLEAN,
+ default => 0,
+ },
+ allow_reassign => {type => BOOLEAN,
+ default => 0,
+ },
+ reassign_different_sources => {type => BOOLEAN,
+ default => 1,
+ },
+ %common_options,
+ %append_action_options,
+ },
+ );
+ my @merging = exists $param{merge_with} ? make_list($param{merge_with}):();
+ my %merging;
+ @merging{@merging} = (1) x @merging;
+ if (grep {$_ !~ /^\d+$/} @merging) {
+ croak "Requested to merge with a non-numeric bug: ".join(',',map {qq('$_');} @merging);
+ }
+ $param{locks} = {} if not exists $param{locks};
+ my %info =
+ __begin_control(%param,
+ command => 'merge'
+ );
+ my ($debug,$transcript) =
+ @info{qw(debug transcript)};
+ if (not @merging and exists $param{merge_with}) {
+ print {$transcript} "Requested to merge with no additional bugs; not doing anything\n";
+ __end_control(%info);
+ return;
+ }
+ my @data = @{$info{data}};
+ my %data;
+ my %merged_bugs;
+ for my $data (@data) {
+ $data{$data->{bug_num}} = $data;
+ my @merged_bugs = split / /, $data->{mergedwith};
+ @merged_bugs{@merged_bugs} = (1) x @merged_bugs;
+ }
+ # handle unmerging
+ my $new_locks = 0;
+ if (not exists $param{merge_with}) {
+ delete $merged_bugs{$param{bug}};
+ if (not keys %merged_bugs) {
+ print {$transcript} "Ignoring request to unmerge a bug which is not merged with any others.\n";
+ __end_control(%info);
+ return;
+ }
+ my $action = "Disconnected #$param{bug} from all other report(s).";
+ for my $data (@data) {
+ my $old_data = dclone($data);
+ if ($data->{bug_num} == $param{bug}) {
+ $data->{mergedwith} = '';
+ }
+ else {
+ $data->{mergedwith} = join(' ',sort grep {$_ != $data->{bug_num}}
+ keys %merged_bugs);
+ }
+ append_action_to_log(bug => $data->{bug_num},
+ command => 'merge',
+ new_data => $data,
+ old_data => $old_data,
+ get_lock => 0,
+ __return_append_to_log_options(%param,
+ action => $action,
+ ),
+ )
+ if not exists $param{append_log} or $param{append_log};
+ writebug($data->{bug_num},$data);
+ }
+ print {$transcript} "$action\n";
+ __end_control(%info);
+ return;
+ }
+ # lock and load all of the bugs we need
+ my ($data,$n_locks) =
+ __lock_and_load_merged_bugs(bugs_to_load => [keys %merging],
+ data => \@data,
+ locks => $param{locks},
+ debug => $debug,
+ );
+ $new_locks += $n_locks;
+ %data = %{$data};
+ @data = values %data;
+ if (not check_limit(data => [@data],
+ exists $param{limit}?(limit => $param{limit}):(),
+ transcript => $transcript,
+ )) {
+ die "limit failed for bugs: ".join(', ',map {$_->{bug_num}} @data);
+ }
+ for my $data (@data) {
+ $data{$data->{bug_num}} = $data;
+ $merged_bugs{$data->{bug_num}} = 1;
+ my @merged_bugs = split / /, $data->{mergedwith};
+ @merged_bugs{@merged_bugs} = (1) x @merged_bugs;
+ if (exists $param{affected_bugs}) {
+ $param{affected_bugs}{$data->{bug_num}} = 1;
+ }
+ }
+ __handle_affected_packages(%param,data => [@data]);
+ my %bug_info_shown; # which bugs have had information shown
+ $bug_info_shown{$param{bug}} = 1;
+ add_recipients(data => [@data],
+ recipients => $param{recipients},
+ (exists $param{command}?(actions_taken => {$param{command} => 1}):()),
+ debug => $debug,
+ (__internal_request()?(transcript => $transcript):()),
+ );
+
+ # Figure out what the ideal state is for the bug,
+ my ($merge_status,$bugs_to_merge) =
+ __calculate_merge_status(\@data,\%data,$param{bug});
+ # find out if we actually have any bugs to merge
+ if (not $bugs_to_merge) {
+ print {$transcript} "Requested to merge with no additional bugs; not doing anything\n";
+ for (1..$new_locks) {
+ unfilelock($param{locks});
+ $locks--;
+ }
+ __end_control(%info);
+ return;
+ }
+ # see what changes need to be made to merge the bugs
+ # check to make sure that the set of changes we need to make is allowed
+ my ($disallowed_changes,$changes) =
+ __calculate_merge_changes(\@data,$merge_status,\%param);
+ # at this point, stop if there are disallowed changes, otherwise
+ # make the allowed changes, and then reread the bugs in question
+ # to get the new data, then recaculate the merges; repeat
+ # reloading and recalculating until we try too many times or there
+ # are no changes to make.
+
+ my $attempts = 0;
+ # we will allow at most 4 times through this; more than 1
+ # shouldn't really happen.
+ my %bug_changed;
+ while ($attempts < 4 and (@{$disallowed_changes} or keys %{$changes})) {
+ if ($attempts > 1) {
+ print {$transcript} "Unable to complete merge on previous attempt; trying again (retry: $attempts)\n";
+ }
+ if (@{$disallowed_changes}) {
+ # figure out the problems
+ print {$transcript} "Unable to merge bugs because:\n";
+ for my $change (@{$disallowed_changes}) {
+ print {$transcript} "$change->{field} of #$change->{bug} is '$change->{text_orig_value}' not '$change->{text_value}'\n";
+ }
+ if ($attempts > 0) {
+ croak "Some bugs were altered while attempting to merge";
+ }
+ else {
+ croak "Did not alter merged bugs";
+ }
+ }
+ my @bugs_to_change = keys %{$changes};
+ for my $change_bug (@bugs_to_change) {
+ next unless exists $changes->{$change_bug};
+ $bug_changed{$change_bug}++;
+ print {$transcript} __bug_info($data{$change_bug}) if
+ $param{show_bug_info} and not __internal_request(1);
+ $bug_info_shown{$change_bug} = 1;
+ __allow_relocking($param{locks},[keys %data]);
+ for my $change (@{$changes->{$change_bug}}) {
+ if ($change->{field} eq 'blockedby' or $change->{field} eq 'blocks') {
+ my %target_blockedby;
+ @target_blockedby{@{$change->{func_value}}} = (1) x @{$change->{func_value}};
+ my %unhandled_targets = %target_blockedby;
+ for my $key (split / /,$change->{orig_value}) {
+ delete $unhandled_targets{$key};
+ next if exists $target_blockedby{$key};
+ set_blocks(bug => $change->{field} eq 'blocks' ? $key : $change->{bug},
+ block => $change->{field} eq 'blocks' ? $change->{bug} : $key,
+ remove => 1,
+ hash_slice(%param,
+ keys %common_options,
+ keys %append_action_options),
+ );
+ }
+ for my $key (keys %unhandled_targets) {
+ set_blocks(bug => $change->{field} eq 'blocks' ? $key : $change->{bug},
+ block => $change->{field} eq 'blocks' ? $change->{bug} : $key,
+ add => 1,
+ hash_slice(%param,
+ keys %common_options,
+ keys %append_action_options),
+ );
+ }
+ }
+ else {
+ $change->{function}->(bug => $change->{bug},
+ $change->{key}, $change->{func_value},
+ exists $change->{options}?@{$change->{options}}:(),
+ hash_slice(%param,
+ keys %common_options,
+ keys %append_action_options),
+ );
+ }
+ }
+ __disallow_relocking($param{locks});
+ my ($data,$n_locks) =
+ __lock_and_load_merged_bugs(bugs_to_load => [keys %merging],
+ data => \@data,
+ locks => $param{locks},
+ debug => $debug,
+ reload_all => 1,
+ );
+ $new_locks += $n_locks;
+ $locks += $n_locks;
+ %data = %{$data};
+ @data = values %data;
+ ($merge_status,$bugs_to_merge) =
+ __calculate_merge_status(\@data,\%data,$param{bug},$merge_status);
+ ($disallowed_changes,$changes) =
+ __calculate_merge_changes(\@data,$merge_status,\%param);
+ $attempts = max(values %bug_changed);
+ }
+ }
+ if ($param{show_bug_info} and not __internal_request(1)) {
+ for my $data (sort {$a->{bug_num} <=> $b->{bug_num}} @data) {
+ next if $bug_info_shown{$data->{bug_num}};
+ print {$transcript} __bug_info($data);
+ }
+ }
+ if (keys %{$changes} or @{$disallowed_changes}) {
+ print {$transcript} "After four attempts, the following changes were unable to be made:\n";
+ for (1..$new_locks) {
+ unfilelock($param{locks});
+ $locks--;
+ }
+ __end_control(%info);
+ for my $change ((map {@{$_}} values %{$changes}), @{$disallowed_changes}) {
+ print {$transcript} "$change->{field} of #$change->{bug} is '$change->{text_orig_value}' not '$change->{text_value}'\n";
+ }
+ die "Unable to modify bugs so they could be merged";
+ return;
+ }
+
+ # finally, we can merge the bugs
+ my $action = "Merged ".join(' ',sort keys %merged_bugs);
+ for my $data (@data) {
+ my $old_data = dclone($data);
+ $data->{mergedwith} = join(' ',sort grep {$_ != $data->{bug_num}}
+ keys %merged_bugs);
+ append_action_to_log(bug => $data->{bug_num},
+ command => 'merge',
+ new_data => $data,
+ old_data => $old_data,
+ get_lock => 0,
+ __return_append_to_log_options(%param,
+ action => $action,
+ ),
+ )
+ if not exists $param{append_log} or $param{append_log};
+ writebug($data->{bug_num},$data);
+ }
+ print {$transcript} "$action\n";
+ # unlock the extra locks that we got earlier
+ for (1..$new_locks) {
+ unfilelock($param{locks});
+ $locks--;
+ }
+ __end_control(%info);
+}
+
+sub __allow_relocking{
+ my ($locks,$bugs) = @_;
+
+ my @locks = (@{$bugs},'merge');
+ for my $lock (@locks) {
+ my @lockfiles = grep {m{/\Q$lock\E$}} keys %{$locks->{locks}};
+ next unless @lockfiles;
+ $locks->{relockable}{$lockfiles[0]} = 0;
+ }
+}
+
+sub __disallow_relocking{
+ my ($locks) = @_;
+ delete $locks->{relockable};
+}
+
+sub __lock_and_load_merged_bugs{
+ my %param =
+ validate_with(params => \@_,
+ spec =>
+ {bugs_to_load => {type => ARRAYREF,
+ default => sub {[]},
+ },
+ data => {type => HASHREF|ARRAYREF,
+ },
+ locks => {type => HASHREF,
+ default => sub {{};},
+ },
+ reload_all => {type => BOOLEAN,
+ default => 0,
+ },
+ debug => {type => HANDLE,
+ },
+ },
+ );
+ my %data;
+ my $new_locks = 0;
+ if (ref($param{data}) eq 'ARRAY') {
+ for my $data (@{$param{data}}) {
+ $data{$data->{bug_num}} = dclone($data);
+ }
+ }
+ else {
+ %data = %{dclone($param{data})};
+ }
+ my @bugs_to_load = @{$param{bugs_to_load}};
+ if ($param{reload_all}) {
+ push @bugs_to_load, keys %data;
+ }
+ my %temp;
+ @temp{@bugs_to_load} = (1) x @bugs_to_load;
+ @bugs_to_load = keys %temp;
+ my %loaded_this_time;
+ my $bug_to_load;
+ while ($bug_to_load = shift @bugs_to_load) {
+ if (not $param{reload_all}) {
+ next if exists $data{$bug_to_load};
+ }
+ else {
+ next if $loaded_this_time{$bug_to_load};
+ }
+ my $lock_bug = 1;
+ if ($param{reload_all}) {
+ if (exists $data{$bug_to_load}) {
+ $lock_bug = 0;
+ }
+ }
+ my $data =
+ read_bug(bug => $bug_to_load,
+ lock => $lock_bug,
+ locks => $param{locks},
+ ) or
+ die "Unable to load bug $bug_to_load";
+ print {$param{debug}} "read bug $bug_to_load\n";
+ $data{$data->{bug_num}} = $data;
+ $new_locks += $lock_bug;
+ $loaded_this_time{$data->{bug_num}} = 1;
+ push @bugs_to_load,
+ grep {not exists $data{$_}}
+ split / /,$data->{mergedwith};
+ }
+ return (\%data,$new_locks);
+}
+
+
+sub __calculate_merge_status{
+ my ($data_a,$data_h,$master_bug,$merge_status) = @_;
+ my %merge_status = %{$merge_status // {}};
+ my %merged_bugs;
+ my $bugs_to_merge = 0;
+ for my $data (@{$data_a}) {
+ # check to see if this bug is unmerged in the set
+ if (not length $data->{mergedwith} or
+ grep {not exists $merged_bugs{$_}} split / /,$data->{mergedwith}) {
+ $merged_bugs{$data->{bug_num}} = 1;
+ $bugs_to_merge = 1;
+ }
+ # the master_bug is the bug that every other bug is made to
+ # look like. However, if merge is set, tags, fixed and found
+ # are merged.
+ if ($data->{bug_num} == $master_bug) {
+ for (qw(package forwarded severity blocks blockedby done owner summary outlook affects)) {
+ $merge_status{$_} = $data->{$_}
+ }
+ }
+ if (defined $merge_status) {
+ next unless $data->{bug_num} == $master_bug;
+ }
+ $merge_status{tag} = {} if not exists $merge_status{tag};
+ for my $tag (split /\s+/, $data->{keywords}) {
+ $merge_status{tag}{$tag} = 1;
+ }
+ $merge_status{keywords} = join(' ',sort keys %{$merge_status{tag}});
+ for (qw(fixed found)) {
+ @{$merge_status{"${_}_versions"}}{@{$data->{"${_}_versions"}}} = (1) x @{$data->{"${_}_versions"}};
+ }
+ }
+ # if there is a non-source qualified version with a corresponding
+ # source qualified version, we only want to merge the source
+ # qualified version(s)
+ for (qw(fixed found)) {
+ my @unqualified_versions = grep {m{/}?0:1} keys %{$merge_status{"${_}_versions"}};
+ for my $unqualified_version (@unqualified_versions) {
+ if (grep {m{/\Q$unqualified_version\E}} keys %{$merge_status{"${_}_versions"}}) {
+ delete $merge_status{"${_}_versions"}{$unqualified_version};
+ }
+ }
+ }
+ return (\%merge_status,$bugs_to_merge);
+}
+
+
+
+sub __calculate_merge_changes{
+ my ($datas,$merge_status,$param) = @_;
+ my %changes;
+ my @disallowed_changes;
+ for my $data (@{$datas}) {
+ # things that can be forced
+ #
+ # * func is the function to set the new value
+ #
+ # * key is the key of the function to set the value,
+
+ # * modify_value is a function which is called to modify the new
+ # value so that the function will accept it
+
+ # * options is an ARRAYREF of options to pass to the function
+
+ # * allowed is a BOOLEAN which controls whether this setting
+ # is allowed to be different by default.
+ my %force_functions =
+ (forwarded => {func => \&set_forwarded,
+ key => 'forwarded',
+ options => [],
+ },
+ severity => {func => \&set_severity,
+ key => 'severity',
+ options => [],
+ },
+ blocks => {func => \&set_blocks,
+ modify_value => sub {defined $_[0]?[split ' ',$_[0]]:[]},
+ key => 'block',
+ options => [],
+ },
+ blockedby => {func => \&set_blocks,
+ modify_value => sub {defined $_[0]?[split ' ',$_[0]]:[]},
+ key => 'block',
+ options => [],
+ },
+ done => {func => \&set_done,
+ key => 'done',
+ options => [],
+ },
+ owner => {func => \&owner,
+ key => 'owner',
+ options => [],
+ },
+ summary => {func => \&summary,
+ key => 'summary',
+ options => [],
+ },
+ outlook => {func => \&outlook,
+ key => 'outlook',
+ options => [],
+ },
+ affects => {func => \&affects,
+ key => 'package',
+ options => [],
+ },
+ package => {func => \&set_package,
+ key => 'package',
+ options => [],
+ },
+ keywords => {func => \&set_tag,
+ key => 'tag',
+ modify_value => sub {defined $_[0]?[sort split /\s+/,$_[0]]:[]},
+ allowed => 1,
+ },
+ fixed_versions => {func => \&set_fixed,
+ key => 'fixed',
+ modify_value => sub {(defined $_[0] and ref($_[0]) eq 'HASH')?[sort keys %{$_[0]}]:$_[0]},
+ allowed => 1,
+ },
+ found_versions => {func => \&set_found,
+ key => 'found',
+ modify_value => sub {(defined $_[0] and ref($_[0]) eq 'HASH')?[sort keys %{$_[0]}]:$_[0]},
+ allowed => 1,
+ },
+ );
+ for my $field (qw(forwarded severity blocks blockedby done owner summary outlook affects package fixed_versions found_versions keywords)) {
+ # if the ideal bug already has the field set properly, we
+ # continue on.
+ if ($field eq 'keywords'){
+ next if join(' ',sort split /\s+/,$data->{keywords}) eq
+ join(' ',sort keys %{$merge_status->{tag}});
+ }
+ elsif ($field =~ /^(?:fixed|found)_versions$/) {
+ next if join(' ', sort @{$data->{$field}}) eq
+ join(' ',sort keys %{$merge_status->{$field}});
+ }
+ elsif ($field eq 'done') {
+ # for done, we only care if the bug is done or not
+ # done, not the value it's set to.
+ if (defined $merge_status->{$field} and length $merge_status->{$field} and
+ defined $data->{$field} and length $data->{$field}) {
+ next;
+ }
+ elsif ((not defined $merge_status->{$field} or not length $merge_status->{$field}) and
+ (not defined $data->{$field} or not length $data->{$field})
+ ) {
+ next;
+ }
+ }
+ elsif ($merge_status->{$field} eq $data->{$field}) {
+ next;
+ }
+ my $change =
+ {field => $field,
+ bug => $data->{bug_num},
+ orig_value => $data->{$field},
+ func_value =>
+ (exists $force_functions{$field}{modify_value} ?
+ $force_functions{$field}{modify_value}->($merge_status->{$field}):
+ $merge_status->{$field}),
+ value => $merge_status->{$field},
+ function => $force_functions{$field}{func},
+ key => $force_functions{$field}{key},
+ options => $force_functions{$field}{options},
+ allowed => exists $force_functions{$field}{allowed} ? $force_functions{$field}{allowed} : 0,
+ };
+ $change->{text_value} = ref($change->{func_value}) eq 'ARRAY'?join(' ',@{$change->{func_value}}):$change->{func_value};
+ $change->{text_orig_value} = ref($change->{orig_value}) eq 'ARRAY'?join(' ',@{$change->{orig_value}}):$change->{orig_value};
+ if ($param->{force} or $change->{allowed}) {
+ if ($field ne 'package' or $change->{allowed}) {
+ push @{$changes{$data->{bug_num}}},$change;
+ next;
+ }
+ if ($param->{allow_reassign}) {
+ if ($param->{reassign_different_sources}) {
+ push @{$changes{$data->{bug_num}}},$change;
+ next;
+ }
+ # allow reassigning if binary_to_source returns at
+ # least one of the same source packages
+ my @merge_status_source =
+ binary_to_source(package => $merge_status->{package},
+ source_only => 1,
+ );
+ my @other_bug_source =
+ binary_to_source(package => $data->{package},
+ source_only => 1,
+ );
+ my %merge_status_sources;
+ @merge_status_sources{@merge_status_source} =
+ (1) x @merge_status_source;
+ if (grep {$merge_status_sources{$_}} @other_bug_source) {
+ push @{$changes{$data->{bug_num}}},$change;
+ next;
+ }
+ }
+ }
+ push @disallowed_changes,$change;
+ }
+ # blocks and blocked by are weird; we have to go through and
+ # set blocks to the other half of the merged bugs
+ }
+ return (\@disallowed_changes,\%changes);
+}