Skip to content

Commit ac7630b

Browse files
committed
fetch contributor information via server rather than client side
1 parent fc9e406 commit ac7630b

File tree

5 files changed

+256
-98
lines changed

5 files changed

+256
-98
lines changed

cpanfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ requires 'CatalystX::Fastly::Role::Response', '0.06';
1717
requires 'CommonMark';
1818
requires 'Config::General';
1919
requires 'Config::ZOMG', '1.000000';
20-
requires 'Cpanel::JSON::XS';
2120
requires 'CPAN::DistnameInfo', '0.12';
21+
requires 'Cpanel::JSON::XS';
22+
requires 'Crypt::OpenSSL::RSA';
2223
requires 'Data::Pageset';
2324
requires 'DateTime', '1.24';
2425
requires 'DateTime::Format::HTTP';

cpanfile.snapshot

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,33 @@ DISTRIBUTIONS
905905
overload 0
906906
strict 0
907907
warnings 0
908+
Crypt-OpenSSL-Guess-0.15
909+
pathname: A/AK/AKIYM/Crypt-OpenSSL-Guess-0.15.tar.gz
910+
provides:
911+
Crypt::OpenSSL::Guess 0.15
912+
requirements:
913+
Config 0
914+
Exporter 5.57
915+
ExtUtils::MakeMaker 6.64
916+
File::Spec 0
917+
Symbol 0
918+
perl 5.008001
919+
Crypt-OpenSSL-RSA-0.33
920+
pathname: T/TO/TODDR/Crypt-OpenSSL-RSA-0.33.tar.gz
921+
provides:
922+
Crypt::OpenSSL::RSA 0.33
923+
requirements:
924+
Crypt::OpenSSL::Random 0
925+
ExtUtils::MakeMaker 0
926+
Test::More 0
927+
perl 5.006
928+
Crypt-OpenSSL-Random-0.16
929+
pathname: R/RU/RURBAN/Crypt-OpenSSL-Random-0.16.tar.gz
930+
provides:
931+
Crypt::OpenSSL::Random 0.16
932+
requirements:
933+
Crypt::OpenSSL::Guess 0.11
934+
ExtUtils::MakeMaker 0
908935
Data-Dump-1.25
909936
pathname: G/GA/GARU/Data-Dump-1.25.tar.gz
910937
provides:

lib/MetaCPAN/Web/Controller/About.pm

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package MetaCPAN::Web::Controller::About;
22

33
use Moose;
44

5+
use Cpanel::JSON::XS qw(encode_json);
6+
57
BEGIN { extends 'MetaCPAN::Web::Controller' }
68

79
sub auto : Private {
@@ -19,6 +21,11 @@ sub about : Path : Args(0) {
1921

2022
sub contributors : Local : Args(0) {
2123
my ( $self, $c ) = @_;
24+
my $contributors = $c->model('GitHub')->contributors
25+
->else(sub { Future->fail(encode_json($_[0])) })->get;
26+
$c->stash({
27+
contributors => $contributors,
28+
});
2229
}
2330

2431
sub contact : Local : Args(0) {

lib/MetaCPAN/Web/Model/GitHub.pm

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package MetaCPAN::Web::Model::GitHub;
2+
3+
use Moose;
4+
extends 'Catalyst::Model';
5+
6+
use namespace::autoclean;
7+
8+
use Cpanel::JSON::XS qw( decode_json encode_json );
9+
10+
use IO::Async::Loop ();
11+
use IO::Async::SSL ();
12+
use IO::Socket::SSL qw( SSL_VERIFY_PEER );
13+
use Net::Async::HTTP ();
14+
use HTTP::Request::Common ();
15+
use MIME::Base64 qw(encode_base64url);
16+
use Crypt::OpenSSL::RSA ();
17+
use Ref::Util qw(is_arrayref is_hashref);
18+
19+
my $loop;
20+
21+
sub loop {
22+
$loop ||= IO::Async::Loop->new;
23+
}
24+
25+
my $client;
26+
27+
sub client {
28+
$client ||= do {
29+
my $http = Net::Async::HTTP->new(
30+
user_agent =>
31+
'MetaCPAN-Web/1.0 (https://github.com/metacpan/metacpan-web)',
32+
max_connections_per_host => $ENV{NET_ASYNC_HTTP_MAXCONNS} || 20,
33+
SSL_verify_mode => SSL_VERIFY_PEER,
34+
timeout => 10,
35+
);
36+
$_[0]->loop->add($http);
37+
$http;
38+
};
39+
}
40+
41+
has app_id => ( is => 'ro' );
42+
has app_secret_file => ( is => 'ro' );
43+
has app_secret => (
44+
is => 'ro',
45+
lazy => 1,
46+
default => sub {
47+
my $self = shift;
48+
open my $fh, '<', $self->app_secret_file
49+
or die "can't open ".$self->app_secret_file."!: $!";
50+
my $content = do { local $/; <$fh> };
51+
close $fh;
52+
return $content;
53+
},
54+
);
55+
has rsa => (
56+
is => 'ro',
57+
lazy => 1,
58+
default => sub {
59+
my $self = shift;
60+
my $rsa = Crypt::OpenSSL::RSA->new_private_key($self->app_secret);
61+
$rsa->use_sha256_hash;
62+
return $rsa;
63+
},
64+
);
65+
66+
has _access_token => ( is => 'rw' );
67+
68+
sub jwt {
69+
my $self = shift;
70+
71+
my $header = {
72+
typ => "JWT",
73+
alg => "RS256",
74+
};
75+
my $now = time;
76+
my $payload = {
77+
iat => $now - 60,
78+
exp => $now + 600,
79+
iss => 0+$self->app_id,
80+
};
81+
my $jwt_content = join '.', map encode_base64url(encode_json($_)), $header, $payload;
82+
83+
my $sign = $self->rsa->sign($jwt_content);
84+
85+
$jwt_content .= '.' . encode_base64url($sign);
86+
return $jwt_content;
87+
};
88+
89+
sub req {
90+
my ($self, $method, $url, $headers, $body) = @_;
91+
92+
$url =~ s{^(?:https://api\.github\.com/|/|)}{https://api.github.com/};
93+
my @headers
94+
= is_arrayref($headers) ? @$headers
95+
: is_hashref($headers) ? %$headers
96+
: die "invalid headers";
97+
98+
my $request = HTTP::Request::Common->can($method)->(
99+
$url,
100+
);
101+
$self->client->do_request(
102+
method => $method,
103+
uri => $url,
104+
headers => [
105+
'Accept' => 'application/vnd.github+json',
106+
'X-GitHub-Api-Version' => '2022-11-28',
107+
@headers,
108+
],
109+
( $method eq 'POST' ? (
110+
content => encode_json($body || {}),
111+
content_type => 'application/json',
112+
) : () ),
113+
)->then(sub {
114+
my $response = shift;
115+
my $data = $response->decoded_content( charset => 'none' );
116+
my $content_type = $response->header('content-type') || q{};
117+
118+
if ( $content_type =~ /^application\/json/ ) {
119+
my $out = decode_json($data);
120+
if ($response->is_success) {
121+
return Future->done($out);
122+
}
123+
else {
124+
return Future->fail($out);
125+
}
126+
}
127+
else {
128+
return Future->fail($data);
129+
}
130+
});
131+
}
132+
133+
sub access_token {
134+
my $self = shift;
135+
my $current_token = $self->_access_token;
136+
if ($current_token && $current_token->{expires_at} > time) {
137+
return Future->done($current_token->{token});
138+
}
139+
my $jwt = $self->jwt;
140+
$self->req('GET', 'integration/installations',
141+
[ 'Authorization' => 'Bearer '.$jwt ],
142+
)->then(sub {
143+
my $res = shift;
144+
my ($installation) = @$res;
145+
$self->req('POST', $installation->{access_tokens_url},
146+
[ 'Authorization' => 'Bearer '.$jwt ],
147+
);
148+
})->then(sub {
149+
my $token = shift;
150+
my $expires_at = DateTime::Format::ISO8601->parse_datetime($token->{expires_at});
151+
$token->{expires_at} = $expires_at->epoch;
152+
$self->_access_token($token);
153+
return Future->done($token->{token});
154+
});
155+
}
156+
157+
sub maybe_auth {
158+
my $self = shift;
159+
return Future->done([])
160+
if !$self->app_id;
161+
$self->access_token->then(sub {
162+
Future->done([ 'Authorization' => "token " . shift ]);
163+
});
164+
}
165+
166+
sub contributors {
167+
my $self = shift;
168+
$self->maybe_auth->then(sub {
169+
my $auth = shift;
170+
$self->req('GET', 'orgs/metacpan/repos?type=public&per_page=100',
171+
[@$auth],
172+
)->then(sub {
173+
my $repos = shift;
174+
warn "found " . scalar(@$repos) . ' repositories';
175+
Future->wait_all(
176+
map $self->req(
177+
'GET', "repos/$_->{full_name}/contributors",
178+
[ @$auth ],
179+
), @$repos
180+
);
181+
});
182+
})->then(sub {
183+
my (@results) = @_;
184+
my %users;
185+
for my $result (map @{ $_->get }, @results) {
186+
my $user = $users{$result->{login}} ||= { %$result, contributions => 0 };
187+
$user->{contributions} += $result->{contributions};
188+
}
189+
return [
190+
sort {
191+
$b->{contributions} <=> $a->{contributions}
192+
|| lc $a->{login} cmp lc $b->{login}
193+
}
194+
grep {
195+
$_->{type} ne 'Bot'
196+
&& $_->{id} != 87378114 # metacpan-bot
197+
}
198+
values %users
199+
];
200+
});
201+
}
202+
203+
__PACKAGE__->meta->make_immutable;
204+
1;

root/about/contributors.tx

Lines changed: 16 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -2,101 +2,20 @@
22
%% title => $title || 'MetaCPAN Contributors ordered by commits',
33
%% }
44
%% override content -> {
5-
<script type="text/javascript">
6-
document.addEventListener("DOMContentLoaded", function () {
7-
var repos = [
8-
'metacpan/metacpan-web',
9-
'metacpan/metacpan-api',
10-
'metacpan/p5-metacpan-websocket',
11-
'metacpan/metacpan-puppet',
12-
'metacpan/metacpan-vagrant',
13-
'metacpan/metacpan-developer',
14-
'metacpan/metacpan-explorer',
15-
'metacpan/metacpan-examples'
16-
];
17-
var baseUrl = 'https://api.github.com/repos';
18-
var path = 'contributors';
19-
20-
var cv = function() {
21-
var result = {};
22-
var requests = repos.length;
23-
var done = 0;
24-
return {
25-
render: function() {
26-
$('#metacpan_author-result-loading').hide();
27-
$('.author-results').html('<ul class="authors">');
28-
var el = $('ul.authors');
29-
var rows = [];
30-
$.each(result, function(idx, row) {
31-
rows.push(row);
32-
});
33-
rows.sort(function(a,b) {
34-
// First sort by contributions, desc
35-
var result = (a.contributions < b.contributions) ? 1 : (a.contributions > b.contributions) ? -1 : 0;
36-
// Second by login name, asc
37-
if (result === 0) {
38-
result = (a.login.toUpperCase() > b.login.toUpperCase()) ? 1 : (a.login.toUpperCase() < b.login.toUpperCase()) ? -1 : 0;
39-
}
40-
return result;
41-
});
42-
$.each(rows, function(idx, row) {
43-
el.append(
44-
'<li><a href="https://github.com/'+ row.login +'" title="GitHub profile of '+ row.login +'"><img src="'+ row.avatar_url +'" class="author-img" />'
45-
+'<strong>'+ row.login +'</strong></a>'
46-
+'('+ row.contributions +' '+ (row.contributions === 1 ? 'commit' : 'commits') +')</li>'
47-
);
48-
})
49-
$('#metacpan_author-count').html('Number of contributors: ' + rows.length);
50-
},
51-
52-
fetch: function(url) {
53-
$.getJSON(url, function(res, statusMessage, jqXHR) {
54-
cv.requestFinished(res, jqXHR);
55-
});
56-
},
57-
58-
paginate: function(jqXHR) {
59-
var link = jqXHR.getResponseHeader('Link');
60-
if (!link) {
61-
return;
62-
}
63-
var self = this;
64-
var links = link.split(/\s*,\s*/).map(
65-
l => l.match(/<(.*?)>;\s*rel="(.*?)"/)
66-
).filter(lt => lt[2] === "next").map(lt => lt[1]);
67-
$.each(links, function(idx, url) {
68-
requests++;
69-
self.fetch(url);
70-
});
71-
},
72-
73-
requestFinished: function(data, jqXHR) {
74-
this.paginate(jqXHR);
75-
$.each(data, function(idx, row) {
76-
if (typeof result[row.login] == 'undefined') {
77-
result[row.login] = row;
78-
}
79-
else {
80-
result[row.login].contributions += row.contributions;
81-
}
82-
});
83-
if (++done === requests) {
84-
this.render();
85-
}
86-
}
87-
}
88-
}();
89-
90-
$.each(repos, function(idx, repo) {
91-
var url = [baseUrl, repo, path].join('/') +'?per_page=100';
92-
cv.fetch(url);
93-
});
94-
95-
});
96-
</script>
97-
98-
<i class="fa fa-spinner fa-spin" id="metacpan_author-result-loading"></i>
99-
<div id="metacpan_author-count"></div>
100-
<div class="author-results"></div>
101-
5+
<div id="metacpan_author-count">
6+
Number of contributors: [% $contributors.size() %]
7+
</div>
8+
<div class="author-results">
9+
<ul class="authors">
10+
%% for $contributors -> $user {
11+
<li>
12+
<a href="https://github.com/[% $user.login %]" title="GitHub profile of [% $user.login %]">
13+
<img src="[% $user.avatar_url %]" class="author-img" />
14+
<strong>[% $user.login %]</strong>
15+
</a>
16+
([% pluralize("%d commit(s)", $user.contributions) %])
17+
</li>
18+
%% }
19+
</ul>
20+
</div>
10221
%% }

0 commit comments

Comments
 (0)