Skip to content

Commit

Permalink
Bug 913911 - add a webservice method to expose a bug graphs (includin…
Browse files Browse the repository at this point in the history
…g dependency and duplicates)

* Bug 913911 - add a webservice method to expose a bug graphs (including dependency and duplicates)

* - No longer require Graph::D3
- Updated query to use 'WITH RECURSIVE' for better DB performance
- Added simple rest API test that needs to expand coverage

* Removed unneeded comment from test case

* - Return both sides of the relationship such as blocked and dependson in the result
- Improved test script

* Extended test case to cover regressions and duplicates. Also check for private bug handling

* Updated docs and also using to_hash provided by Bug.pm instead of rolling our own

* Add stricter value checking for SQL values
  • Loading branch information
dklawren authored Nov 14, 2023
1 parent b796adc commit 4f01a41
Show file tree
Hide file tree
Showing 11 changed files with 949 additions and 254 deletions.
121 changes: 121 additions & 0 deletions Bugzilla/API/V1/BugGraph.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# This Source Code Form is "Incompatible With Secondary Licenses", as
# defined by the Mozilla Public License, v. 2.0.

package Bugzilla::API::V1::BugGraph;

use 5.10.1;
use Mojo::Base qw( Mojolicious::Controller );

use List::Util qw(any none);
use PerlX::Maybe;
use Try::Tiny;

use Bugzilla;
use Bugzilla::Constants;
use Bugzilla::Error;
use Bugzilla::Logging;
use Bugzilla::Report::Graph;

sub setup_routes {
my ($class, $r) = @_;
$r->get('/bug/:id/graph')->to('V1::BugGraph#graph');
}

sub graph {
my ($self, $params) = @_;
my $user = $self->bugzilla->login;

Bugzilla->usage_mode(USAGE_MODE_MOJO_REST);

my $bug_id = $self->param('id');
my $relationship = $self->param('relationship') || 'dependencies';
my $depth = $self->param('depth') || 3;
my $ids_only = $self->param('ids_only') ? 1 : 0;
my $show_resolved = $self->param('show_resolved') ? 1 : 0;

if ($bug_id !~ /^\d+$/) {
ThrowCodeError('param_invalid',
{function => 'bug/<id>/graph', param => 'bug_id'});
}

if ($depth !~ /^\d+$/ || ($depth > 9 || $depth < 1)) {
ThrowCodeError('param_invalid',
{function => 'bug/<id>/graph', param => 'depth'});
}

my %relationships = (
dependencies => ['dependson,blocked', 'blocked,dependson'],
duplicates => ['dupe,dupe_of', 'dupe_of,dupe'],
regressions => ['regresses,regressed_by', 'regressed_by,regresses'],
);

if (none { $relationship eq $_ } keys %relationships) {
ThrowCodeError('param_invalid',
{function => 'bug/<id>/graph ', param => 'relationship'});
}

my $result = {};
try {
foreach my $fields (@{$relationships{$relationship}}) {
Bugzilla->switch_to_shadow_db();

my ($source, $sink) = split /,/, $fields;

my $report = Bugzilla::Report::Graph->new(
bug_id => $bug_id,
table => $relationship,
source => $source,
sink => $sink,
depth => $depth,
);

# Remove any secure bugs that user cannot see
$report->prune_graph(sub { $user->visible_bugs($_[0]) });

# If we do not want resolved bugs (default) then filter those
# by passing in reference to the subroutine for filtering out
# resolved bugs
if (!$show_resolved) {
$report->prune_graph(sub { $self->_prune_resolved($_[0]) });
}

if (!$ids_only) {
my $bugs = Bugzilla::Bug->new_from_list([$report->graph->vertices]);
foreach my $bug (@$bugs) {
$report->graph->set_vertex_attributes($bug->id, $bug->to_hash);
}
}

$result->{$source} = $report->tree;
}
}
catch {
FATAL($_);
$result = {exception => 'Internal Error', request_id => $self->req->request_id};
};

return $self->render(json => $result);
}

# This method takes a set of bugs and using a single SQL statement,
# removes any bugs from the list which have a non-empty resolution (unresolved)
sub _prune_resolved {
my ($self, $bugs) = @_;
my $dbh = Bugzilla->dbh;

return $bugs if !$bugs->size;

my $placeholders = join ',', split //, '?' x $bugs->size;
my $query
= "SELECT bug_id FROM bugs WHERE (resolution IS NULL OR resolution = '') AND bug_id IN ($placeholders)";
my $filtered_bugs
= Bugzilla->dbh->selectcol_arrayref($query, undef, $bugs->elements);

return $filtered_bugs;
}

1;
121 changes: 121 additions & 0 deletions Bugzilla/Report/Graph.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# This Source Code Form is "Incompatible With Secondary Licenses", as
# defined by the Mozilla Public License, v. 2.0.

package Bugzilla::Report::Graph;
use 5.10.1;
use Moo;

use Graph::Directed;
use Graph::Traversal::DFS;
use PerlX::Maybe 'maybe';
use Type::Utils qw(class_type);
use Types::Standard qw(Bool Enum Int Str ArrayRef);
use Set::Object qw(set);

use Bugzilla;
use Bugzilla::Logging;
use Bugzilla::Types qw(DB);

our $valid_tables = [qw(dependencies duplicates regressions)];
our $valid_fields = [qw(blocked dependson dupe dupe_of regresses regressed_by)];

has bug_id => (is => 'ro', isa => Int, required => 1);
has table =>
(is => 'ro', isa => Enum $valid_tables, default => 'dependencies',);
has depth => (is => 'ro', isa => Int, default => 3);
has source => (is => 'ro', isa => Enum $valid_fields, default => 'dependson',);
has sink => (is => 'ro', isa => Enum $valid_fields, default => 'blocked',);
has limit => (is => 'ro', isa => Int, default => 10_000);
has paths => (is => 'lazy', isa => ArrayRef [ArrayRef]);
has graph => (is => 'lazy', isa => class_type({class => 'Graph'}));
has query => (is => 'lazy', isa => Str);

# Run the query that will list of the paths from the parent bug
# down to the last child in the tree
sub _build_paths {
my ($self) = @_;
return Bugzilla->dbh->selectall_arrayref($self->query, undef, $self->bug_id);
}

# Builds a new directed graph
sub _build_graph {
my ($self) = @_;
my $paths = $self->paths;
my $graph = Graph::Directed->new;

foreach my $path (@$paths) {
pop @$path until defined $path->[-2];
$graph->add_path(@$path);
}

return $graph;
}

sub _build_query {
my ($self) = @_;
my $table = $self->table;
my $alias = substr $table, 0, 1;
my $depth = $self->depth;
my $source = $self->source;
my $sink = $self->sink;
my $limit = $self->limit;

# WITH RECURSIVE is available in MySQL 8.x and newer as
# well as recent versions of PostgreSQL and SQLite.
my $query = "WITH RECURSIVE RelationshipTree AS (
SELECT t.$source, t.$sink, 1 AS depth FROM $table t WHERE t.$source = ?
UNION ALL
SELECT t.$source, t.$sink, rt.depth + 1 AS depth FROM $table t
JOIN RelationshipTree rt ON t.$source = rt.$sink WHERE rt.depth <= $depth LIMIT $limit)
SELECT rt.$source, rt.$sink FROM RelationshipTree rt";

return $query;
}

# Using a callback filter being passed in, remove any unwanted vertices
# in the graph such as secure bugs if the user cannot see them. Then
# remove any unreachable vertices as well.
sub prune_graph {
my ($self, $filter) = @_;

my $all_vertices = set($self->graph->vertices);
my $filtered_vertices = set(@{$filter->($all_vertices)});
my $pruned_vertices = $all_vertices - $filtered_vertices;
$self->graph->delete_vertices($pruned_vertices->members);

# Finally remove any vertices that are now unreachable
my $reachable_vertices
= set($self->bug_id, $self->graph->all_reachable($self->bug_id));
my $unreachable_vertices = $filtered_vertices - $reachable_vertices;
$self->graph->delete_vertices($unreachable_vertices->members);

return $pruned_vertices + $unreachable_vertices;
}

# Generates the final tree stucture based on the directed graph
sub tree {
my ($self) = @_;
my $graph = $self->graph;

my %nodes = map { $_ => {maybe bug => $graph->get_vertex_attributes($_)} }
$graph->vertices;

my $search = Graph::Traversal::DFS->new(
$graph,
start => $self->bug_id,
tree_edge => sub {
my ($u, $v) = @_;
$nodes{$u}{$v} = $nodes{$v};
}
);
$search->dfs;

return $nodes{$self->bug_id} || {};
}


1;
3 changes: 2 additions & 1 deletion Bugzilla/Types.pm
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ use strict;
use warnings;

use Type::Library -base,
-declare => qw( Bug User Group Attachment Comment JSONBool URI URL Task );
-declare => qw( DB Bug User Group Attachment Comment JSONBool URI URL Task );
use Type::Utils -all;
use Types::Standard -types;

class_type DB, {class => 'Bugzilla::DB'};
class_type Bug, {class => 'Bugzilla::Bug'};
class_type User, {class => 'Bugzilla::User'};
class_type Group, {class => 'Bugzilla::Group'};
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM mozillabteam/bmo-perl-slim:20231023.1
FROM mozillabteam/bmo-perl-slim:20231024.1

ENV DEBIAN_FRONTEND noninteractive

Expand Down
2 changes: 2 additions & 0 deletions Makefile.PL
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ my %requires = (
'Email::Sender' => 0,
'FFI::Platypus' => 0,
'Future' => '0.34',
'Graph' => 0,
'HTML::Escape' => '1.10',
'IO::Async' => '0.71',
'IPC::System::Simple' => 0,
Expand Down Expand Up @@ -94,6 +95,7 @@ my %requires = (
'Role::Tiny' => '2.000003',
'Scope::Guard' => '0.21',
'Sereal' => '4.004',
'Set::Object' => 0,
'Sub::Quote' => '2.005000',
'Template' => '2.24',
'Text::CSV_XS' => '1.26',
Expand Down
3 changes: 3 additions & 0 deletions cpanfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ requires 'GD', '1.20';
requires 'GD::Barcode', '== 1.15';
requires 'GD::Graph';
requires 'GD::Text';
requires 'Graph';
requires 'Graph::D3';
requires 'HTML::Escape', '1.10';
requires 'HTML::Parser', '3.67';
requires 'HTML::Scrubber';
Expand Down Expand Up @@ -102,6 +104,7 @@ requires 'SOAP::Lite', '0.712';
requires 'SQL::Tokenizer';
requires 'Scope::Guard', '0.21';
requires 'Sereal', '4.004';
requires 'Set::Object';
requires 'Sub::Quote', '2.005000';
requires 'Sys::Syslog';
requires 'Template', '2.24';
Expand Down
Loading

0 comments on commit 4f01a41

Please sign in to comment.