Skip to content

Commit 94a1617

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

File tree

5 files changed

+262
-98
lines changed

5 files changed

+262
-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: 9 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,13 @@ sub about : Path : Args(0) {
1921

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

2433
sub contact : Local : Args(0) {

lib/MetaCPAN/Web/Model/GitHub.pm

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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($_) ),
82+
$header, $payload;
83+
84+
my $sign = $self->rsa->sign($jwt_content);
85+
86+
$jwt_content .= '.' . encode_base64url($sign);
87+
return $jwt_content;
88+
}
89+
90+
sub req {
91+
my ( $self, $method, $url, $headers, $body ) = @_;
92+
93+
$url =~ s{^(?:https://api\.github\.com/|/|)}{https://api.github.com/};
94+
my @headers
95+
= is_arrayref($headers) ? @$headers
96+
: is_hashref($headers) ? %$headers
97+
: die "invalid headers";
98+
99+
$self->client->do_request(
100+
method => $method,
101+
uri => $url,
102+
headers => [
103+
'Accept' => 'application/vnd.github+json',
104+
'X-GitHub-Api-Version' => '2022-11-28',
105+
@headers,
106+
],
107+
(
108+
$method eq 'POST'
109+
? (
110+
content => encode_json( $body || {} ),
111+
content_type => 'application/json',
112+
)
113+
: ()
114+
),
115+
)->then( sub {
116+
my $response = shift;
117+
my $data = $response->decoded_content( charset => 'none' );
118+
my $content_type = $response->header('content-type') || q{};
119+
120+
if ( $content_type =~ /^application\/json/ ) {
121+
my $out = decode_json($data);
122+
if ( $response->is_success ) {
123+
return Future->done($out);
124+
}
125+
else {
126+
return Future->fail($out);
127+
}
128+
}
129+
else {
130+
return Future->fail($data);
131+
}
132+
} );
133+
}
134+
135+
sub access_token {
136+
my $self = shift;
137+
my $current_token = $self->_access_token;
138+
if ( $current_token && $current_token->{expires_at} > time ) {
139+
return Future->done( $current_token->{token} );
140+
}
141+
my $jwt = $self->jwt;
142+
$self->req( 'GET', 'integration/installations',
143+
[ 'Authorization' => 'Bearer ' . $jwt ],
144+
)->then( sub {
145+
my $res = shift;
146+
my ($installation) = @$res;
147+
$self->req(
148+
'POST',
149+
$installation->{access_tokens_url},
150+
[ 'Authorization' => 'Bearer ' . $jwt ],
151+
);
152+
} )->then( sub {
153+
my $token = shift;
154+
my $expires_at = DateTime::Format::ISO8601->parse_datetime(
155+
$token->{expires_at} );
156+
$token->{expires_at} = $expires_at->epoch;
157+
$self->_access_token($token);
158+
return Future->done( $token->{token} );
159+
} );
160+
}
161+
162+
sub maybe_auth {
163+
my $self = shift;
164+
return Future->done( [] )
165+
if !$self->app_id;
166+
$self->access_token->then( sub {
167+
Future->done( [ 'Authorization' => "token " . shift ] );
168+
} );
169+
}
170+
171+
sub contributors {
172+
my $self = shift;
173+
$self->maybe_auth->then( sub {
174+
my $auth = shift;
175+
$self->req( 'GET', 'orgs/metacpan/repos?type=public&per_page=100',
176+
[@$auth], )->then( sub {
177+
my $repos = shift;
178+
warn "found " . scalar(@$repos) . ' repositories';
179+
Future->wait_all(
180+
map $self->req(
181+
'GET', "repos/$_->{full_name}/contributors", [@$auth],
182+
),
183+
@$repos
184+
);
185+
} );
186+
} )->then( sub {
187+
my (@results) = @_;
188+
my %users;
189+
for my $result ( map @{ $_->get }, @results ) {
190+
my $user = $users{ $result->{login} }
191+
||= { %$result, contributions => 0 };
192+
$user->{contributions} += $result->{contributions};
193+
}
194+
return [
195+
sort {
196+
$b->{contributions} <=> $a->{contributions}
197+
|| lc $a->{login} cmp lc $b->{login}
198+
}
199+
grep {
200+
$_->{type} ne 'Bot'
201+
&& $_->{id} != 87378114 # metacpan-bot
202+
} values %users
203+
];
204+
} );
205+
}
206+
207+
__PACKAGE__->meta->make_immutable;
208+
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)