Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Bugzilla/Config/Github.pm
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ sub get_param_list {
{name => 'github_pr_linking_enabled', type => 'b', default => 0},
{name => 'github_pr_signature_secret', type => 't', default => ''},
{name => 'github_push_comment_enabled', type => 'b', default => 0},
{name => 'github_api_token', type => 'p', default => ''},
);
return @param_list;
}
Expand Down
16 changes: 16 additions & 0 deletions extensions/GitHubPullRequests/Config.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# 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::Extension::GitHubPullRequests;

use 5.10.1;
use strict;
use warnings;

use constant NAME => 'GitHubPullRequests';

__PACKAGE__->NAME;
59 changes: 59 additions & 0 deletions extensions/GitHubPullRequests/Extension.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# 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::Extension::GitHubPullRequests;

use 5.10.1;
use strict;
use warnings;

use parent qw(Bugzilla::Extension);

use Bugzilla;

use constant GITHUB_CONTENT_TYPE => 'text/x-github-pull-request';

our $VERSION = '0.01';

sub template_before_process {
my ($self, $args) = @_;
my $file = $args->{'file'};
my $vars = $args->{'vars'};

return unless Bugzilla->user->id;
return unless $file =~ /bug_modal\/(header|edit)\.html\.tmpl$/;

my $bug = exists $vars->{'bugs'} ? $vars->{'bugs'}[0] : $vars->{'bug'};
return unless $bug;

# Note: this only counts linked (non-obsolete) PR attachments. The actual
# open/closed/merged state isn't known until the WebService queries GitHub,
# so we can't report an "open" count at template-processing time.
my $has_prs = 0;
my $linked_pr_count = 0;
foreach my $attachment (@{$bug->attachments}) {
next if $attachment->contenttype ne GITHUB_CONTENT_TYPE;
next if $attachment->isobsolete;

# Don't reveal that a private PR attachment exists to users who aren't
# permitted to see it; the WebService applies the same check.
next if $attachment->isprivate && !Bugzilla->user->is_insider;
$has_prs = 1;
$linked_pr_count++;
}

$vars->{github_pull_requests} = $has_prs;
$vars->{github_linked_pr_count} = $linked_pr_count;
}

sub webservice {
my ($self, $args) = @_;
$args->{dispatch}->{GitHubPullRequests}
= 'Bugzilla::Extension::GitHubPullRequests::WebService';
}

__PACKAGE__->NAME;
241 changes: 241 additions & 0 deletions extensions/GitHubPullRequests/lib/WebService.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
# 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::Extension::GitHubPullRequests::WebService;

use 5.10.1;
use strict;
use warnings;

use base qw(Bugzilla::WebService);

use Bugzilla::Bug;
use Bugzilla::Constants;
use Bugzilla::Error;
use Bugzilla::Logging;
use Bugzilla::WebService::Constants;
use Types::Standard qw(-types);
use Type::Params qw(compile);

use JSON qw(decode_json);
use LWP::UserAgent;

use constant GITHUB_CONTENT_TYPE => 'text/x-github-pull-request';
use constant GITHUB_PR_REGEX => qr{^https://github\.com/([^/]+)/([^/]+)/pull/(\d+)/?$};
use constant GITHUB_API_BASE => 'https://api.github.com';
use constant GITHUB_API_TIMEOUT => 10;

# How long (in seconds) to cache a PR's summary in memcached. GitHub's
# unauthenticated rate limit is low (60 req/hr per IP) and authenticated is
# 5000/hr, so caching avoids re-fetching the same PR on every bug view.
use constant GITHUB_CACHE_SECONDS => 300;

# Cache inaccessible/failed lookups for a shorter period so that persistent
# failures (rate limiting, outages, private repos) don't re-hit GitHub on every
# bug view, while still recovering quickly once the PR becomes reachable again.
use constant GITHUB_ERROR_CACHE_SECONDS => 60;

use constant READ_ONLY => qw(
bug_pull_requests
);

use constant PUBLIC_METHODS => qw(
bug_pull_requests
);

sub bug_pull_requests {
state $check = compile(Object, Dict [bug_id => Int]);
my ($self, $params) = $check->(@_);

my $user = Bugzilla->login(LOGIN_REQUIRED);

ThrowUserError('invalid_parameter', {name => 'bug_id', err => 'required'})
unless $params->{bug_id};

my $bug = Bugzilla::Bug->check({id => $params->{bug_id}, cache => 1});

my $ua = LWP::UserAgent->new(timeout => GITHUB_API_TIMEOUT);
$ua->agent('BMO-Bugzilla/1.0');
if (Bugzilla->params->{proxy_url}) {
$ua->proxy('https', Bugzilla->params->{proxy_url});
}

# Authenticate when a token is configured. This raises GitHub's API rate
# limit from 60 to 5000 requests/hour, avoiding HTTP 403 failures under load.
my $token = Bugzilla->params->{github_api_token};
if ($token) {
$ua->default_header('Authorization' => "Bearer $token");
}

my @pull_requests;
foreach my $attachment (@{$bug->attachments}) {
next if $attachment->contenttype ne GITHUB_CONTENT_TYPE;
next if $attachment->isobsolete;

# Don't expose private attachments (and their PR details) to users who
# aren't permitted to see them.
next if $attachment->isprivate && !$user->is_insider;

my $url = $attachment->data;
$url =~ s/\s+$//;

my ($owner, $repo, $pr_number) = ($url =~ GITHUB_PR_REGEX);
unless ($owner && $repo && $pr_number) {
WARN("GitHub: could not parse PR URL: $url");
next;
}

my $pr_data = _fetch_pull_request($ua, $owner, $repo, $pr_number);
push @pull_requests, $pr_data;
}
Comment thread
dklawren marked this conversation as resolved.
Comment thread
dklawren marked this conversation as resolved.

return {pull_requests => \@pull_requests};
}

sub _fetch_pull_request {
my ($ua, $owner, $repo, $pr_number) = @_;

my $url = "https://github.com/$owner/$repo/pull/$pr_number";
my $api_url = GITHUB_API_BASE . "/repos/$owner/$repo/pulls/$pr_number";

my $base = {
url => $url,
number => int($pr_number),
repo => "$owner/$repo",
sortkey => int($pr_number),
};

# Return a cached summary if we have a fresh one.
my $cache_key = "github_pr." . $url;
my $cached = Bugzilla->memcached->get_data({key => $cache_key});
return $cached if defined $cached;

my $pr_response = _github_get($ua, $api_url);
unless ($pr_response->{ok}) {
WARN("GitHub: failed to fetch PR $url: " . $pr_response->{errmsg});
return _cache_inaccessible($cache_key, $base);
}

my $pr = $pr_response->{data};

# GitHub should return a JSON object; anything else (an error object, a
# list, etc.) means we can't trust the structure, so fall back gracefully.
unless (ref($pr) eq 'HASH') {
WARN("GitHub: unexpected response shape for PR $url");
return _cache_inaccessible($cache_key, $base);
}

my $state;
if ($pr->{draft}) {
$state = 'draft';
}
elsif ($pr->{merged_at}) {
$state = 'merged';
}
elsif ($pr->{state} eq 'closed') {
$state = 'closed';
}
else {
$state = 'open';
}
Comment thread
dklawren marked this conversation as resolved.

my @labels = map { $_->{name} } @{$pr->{labels} // []};

my $reviews_response = _github_get($ua, $api_url . '/reviews');
my @reviews;
if ($reviews_response->{ok}) {
@reviews = _summarize_reviews($reviews_response->{data});
}

my $pr_data = {
%$base,
title => $pr->{title},
state => $state,
author => ref($pr->{user}) eq 'HASH' ? $pr->{user}{login} : undef,
reviews => \@reviews,
labels => \@labels,
inaccessible => 0,
};

Bugzilla->memcached->set_data(
{key => $cache_key, value => $pr_data, expires_in => GITHUB_CACHE_SECONDS});

return $pr_data;
}

sub _cache_inaccessible {
my ($cache_key, $base) = @_;

my $error_data = {%$base, inaccessible => 1};
Bugzilla->memcached->set_data({
key => $cache_key,
value => $error_data,
expires_in => GITHUB_ERROR_CACHE_SECONDS,
});

return $error_data;
}

sub _github_get {
my ($ua, $url) = @_;

my $response = $ua->get(
$url,
'Accept' => 'application/vnd.github+json',
'X-GitHub-Api-Version' => '2022-11-28',
);

unless ($response->is_success) {
return {ok => 0, errmsg => $response->status_line};
}

my $data = eval { decode_json($response->decoded_content) };
if ($@) {
return {ok => 0, errmsg => "JSON parse error: $@"};
}

return {ok => 1, data => $data};
}

sub _summarize_reviews {
my ($reviews) = @_;

return () unless ref($reviews) eq 'ARRAY';

# Keep only the latest review state per reviewer.
# Reviews are returned in chronological order so we can just overwrite.
my %latest;
my @order;
for my $review (@{$reviews}) {
my $login = $review->{user}{login};
my $state = $review->{state};

# COMMENTED is not a conclusive review state; skip it
next if $state eq 'COMMENTED';

if (!exists $latest{$login}) {
push @order, $login;
}
$latest{$login} = $state;
}

return map { {user => $_, state => $latest{$_}} } @order;
}

sub rest_resources {
return [
qr{^/githubpr/bug_pull_requests/(\d+)$},
{
GET => {
method => 'bug_pull_requests',
params => sub { return {bug_id => $_[0]} },
},
},
];
}

1;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[%# 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.
#%]

[% style_urls.push('extensions/GitHubPullRequests/web/style/github_pull_requests.css') %]
[% javascript_urls.push('extensions/GitHubPullRequests/web/js/github_pull_requests.js') %]
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[%# 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.
#%]

<table class="github-pull-requests">
<thead>
<tr>
<th>PR</th>
<th>Status</th>
<th>Author</th>
<th>Reviewers</th>
<th>Repository</th>
<th>Labels</th>
<th>Title</th>
</tr>
</thead>
<tbody class="github-prs-body">
<tr class="github-loading-row">
<td colspan="7">Loading...</td>
</tr>
<tr class="github-loading-error-row bz_default_hidden">
<td colspan="7">Error loading GitHub pull requests:
<span class="github-load-error-string"></span></td>
</tr>
</tbody>
<tbody class="github-show-closed bz_default_hidden">
<tr>
<td colspan="7">
<input id="github-show-closed" type="checkbox">
<label for="github-show-closed">Show Closed/Merged Pull Requests</label>
</td>
</tr>
</tbody>
</table>
Loading
Loading