diff --git a/extensions/Push/Extension.pm b/extensions/Push/Extension.pm index 265330a971..846c4f1f26 100644 --- a/extensions/Push/Extension.pm +++ b/extensions/Push/Extension.pm @@ -165,6 +165,105 @@ sub _object_modified { } $self->_push_object('modify', $object, $change_set, $changes_data); + + # Dependency/regression relationship edits on one bug also affect the + # corresponding field on related bugs; emit synthetic modify events so + # webhook consumers receive both sides of the relationship change. + $self->_emit_counterpart_bug_modify_events( + $object, + $changes, + $args->{'timestamp'}, + $change_set, + ); +} + +sub _emit_counterpart_bug_modify_events { + my ($self, $source_bug, $changes, $timestamp, $change_set) = @_; + + return unless $source_bug && $source_bug->isa('Bugzilla::Bug'); + return unless $changes && scalar keys %$changes; + + my $counterpart_changes = _counterpart_bug_changes($source_bug, $changes); + my @related_bug_ids = sort { $a <=> $b } keys %$counterpart_changes; + return unless @related_bug_ids; + + my $related_bugs = Bugzilla::Bug->new_from_list(\@related_bug_ids); + my %related_bug_map = map { $_->id => $_ } grep { !$_->{error} } @$related_bugs; + + foreach my $related_bug_id (@related_bug_ids) { + my $related_bug = $related_bug_map{$related_bug_id}; + next if !$related_bug; + + # Do not emit counterpart payloads for private target bugs because + # connectors may not uniformly apply per-consumer visibility checks. + next unless is_public($related_bug); + + my $related_changes_data = { + timestamp => $timestamp, + changes => $counterpart_changes->{$related_bug_id}, + indirect_change => { + type => 'related', + source_bug_id => $source_bug->id, + }, + }; + $self->_push_object('modify', $related_bug, $change_set, $related_changes_data); + } +} + +sub _counterpart_bug_changes { + my ($source_bug, $changes) = @_; + + my %field_map = ( + blocked => 'dependson', + dependson => 'blocked', + regresses => 'regressed_by', + regressed_by => 'regresses', + ); + + my %result; + foreach my $source_field (sort keys %field_map) { + next unless exists $changes->{$source_field}; + + my $counterpart_field = $field_map{$source_field}; + my ($old_value, $new_value) = @{$changes->{$source_field}}; + my $old_ids = _parse_related_bug_ids($old_value); + my $new_ids = _parse_related_bug_ids($new_value); + + my %all_ids = map { $_ => 1 } (keys %$old_ids, keys %$new_ids); + foreach my $related_bug_id (sort { $a <=> $b } keys %all_ids) { + my $in_old = exists $old_ids->{$related_bug_id}; + my $in_new = exists $new_ids->{$related_bug_id}; + next if $in_old == $in_new; + + push @{$result{$related_bug_id}}, { + field => $counterpart_field, + removed => $in_old ? $source_bug->id : '', + added => $in_new ? $source_bug->id : '', + }; + } + } + + return \%result; +} + +sub _parse_related_bug_ids { + my ($raw_value) = @_; + + my $value = ''; + if (ref $raw_value eq 'ARRAY') { + $value = join(',', @$raw_value); + } + elsif (defined $raw_value) { + $value = $raw_value; + } + + my %ids; + foreach my $id (split(/[\s,]+/, $value)) { + next unless $id =~ /^\d+$/; + $ids{$id} = 1; + } + + return \%ids; } sub _get_object_from_args { @@ -290,6 +389,9 @@ sub _push_object { $rh_event->{'action'} = $message_type; $rh_event->{'target'} = $name; $rh_event->{'change_set'} = $change_set; + if (exists $changes->{'indirect_change'}) { + $rh_event->{'indirect_change'} = $changes->{'indirect_change'}; + } $rh_event->{'routing_key'} = "$name.$message_type"; if (exists $rh_event->{'changes'}) { $rh_event->{'routing_key'} @@ -322,6 +424,24 @@ sub _push_object { # update/create hooks # +sub bug_before_create { + my ($self, $args) = @_; + return unless $self->_enabled; + + my $params = $args->{params} || return; + my $stash = $args->{stash} || return; + + my %relation_values; + foreach my $field (qw(blocked dependson regresses regressed_by)) { + my $value = $params->{$field}; + next unless ref $value eq 'ARRAY' && @$value; + $relation_values{$field} = [@$value]; + } + + return unless keys %relation_values; + $stash->{push_creation_relation_values} = \%relation_values; +} + sub object_end_of_create { my ($self, $args) = @_; return unless $self->_enabled; @@ -364,6 +484,29 @@ sub bug_end_of_create { my ($self, $args) = @_; return unless $self->_enabled; $self->_object_created($args); + + my $bug = $args->{bug}; + return unless $bug && $bug->isa('Bugzilla::Bug'); + + my $stash = $args->{stash}; + return unless ref $stash eq 'HASH'; + return unless exists $stash->{push_creation_relation_values}; + + my $creation_relation_values = $stash->{push_creation_relation_values}; + + my %creation_relation_changes; + foreach my $field (qw(blocked dependson regresses regressed_by)) { + my $related_ids = $creation_relation_values->{$field} || []; + next unless @$related_ids; + $creation_relation_changes{$field} = ['', join(', ', @$related_ids)]; + } + + $self->_emit_counterpart_bug_modify_events( + $bug, + \%creation_relation_changes, + $args->{timestamp}, + change_set_id(), + ); } sub bug_end_of_update { diff --git a/extensions/Push/lib/Connector/Webhook.pm b/extensions/Push/lib/Connector/Webhook.pm index 0accb7af20..b76a419044 100644 --- a/extensions/Push/lib/Connector/Webhook.pm +++ b/extensions/Push/lib/Connector/Webhook.pm @@ -167,6 +167,22 @@ sub send { delete $payload->{event}->{changes}; } + my $indirect_change = $payload->{event}->{indirect_change}; + if (ref $indirect_change eq 'HASH' && exists $indirect_change->{source_bug_id}) { + my $source_bug_id = $indirect_change->{source_bug_id}; + if (!$webhook->user->can_see_bug($source_bug_id)) { + delete $indirect_change->{source_bug_id}; + } + } + + _redact_indirect_relationship_change_bug_ids($webhook->user, $payload->{event}); + if (ref $indirect_change eq 'HASH' + && ($indirect_change->{type} || '') eq 'related' + && !exists $payload->{event}->{changes}) + { + return PUSH_RESULT_BLOCKED; + } + delete $payload->{$target}; $payload->{bug} = $bug_data; @@ -209,6 +225,44 @@ sub _integer { return defined($value) ? $value + 0 : undef; } +sub _redact_indirect_relationship_change_bug_ids { + my ($owner, $event) = @_; + return unless $owner; + return unless $event; + return unless ref $event->{indirect_change} eq 'HASH'; + return unless $event->{indirect_change}->{type} eq 'related'; + return unless ref $event->{changes} eq 'ARRAY'; + + my @filtered_changes; + foreach my $change (@{$event->{changes}}) { + if (ref $change ne 'HASH') { + push @filtered_changes, $change; + next; + } + + my %filtered = %$change; + foreach my $key (qw(removed added)) { + next unless defined $filtered{$key}; + if ($filtered{$key} =~ /^\d+$/ && !$owner->can_see_bug($filtered{$key})) { + $filtered{$key} = ''; + } + } + + my $removed = defined $filtered{removed} ? $filtered{removed} : ''; + my $added = defined $filtered{added} ? $filtered{added} : ''; + next if $removed eq '' && $added eq ''; + + push @filtered_changes, \%filtered; + } + + if (@filtered_changes) { + $event->{changes} = \@filtered_changes; + } + else { + delete $event->{changes}; + } +} + sub _owner_can_see { my ($webhook, $message, $payload) = @_; my $target = $payload->{event}->{target};