File Coverage

blib/lib/Net/Async/Github.pm
Criterion Covered Total %
statement 108 494 21.8
branch 0 148 0.0
condition 0 112 0.0
subroutine 36 119 30.2
pod 34 59 57.6
total 178 932 19.1


line stmt bran cond sub pod time code
1             package Net::Async::Github;
2              
3 1     1   199444 use strict;
  1         2  
  1         48  
4 1     1   7 use warnings;
  1         3  
  1         232  
5              
6             our $VERSION = '0.013';
7             our $AUTHORITY = 'cpan:TEAM'; # AUTHORITY
8              
9 1     1   14 use parent qw(IO::Async::Notifier);
  1         2  
  1         9  
10              
11 1     1   29699 no indirect;
  1         2135  
  1         6  
12 1     1   850 use utf8;
  1         441  
  1         7  
13              
14             =encoding utf8
15              
16             =head1 NAME
17              
18             Net::Async::Github - support for the L REST API with L
19              
20             =head1 SYNOPSIS
21              
22             use IO::Async::Loop;
23             use Net::Async::Github;
24             my $loop = IO::Async::Loop->new;
25             $loop->add(
26             my $gh = Net::Async::Github->new(
27             token => '...',
28             )
29             );
30             # Give 'secret_team' pull access to all private repos
31             $gh->repos(visibility => 'private')
32             ->grant_team(secret_team => 'pull')
33             ->await;
34              
35             =head1 DESCRIPTION
36              
37             This is a basic wrapper for Github's API.
38              
39             =cut
40              
41 1     1   61 no indirect;
  1         2  
  1         7  
42              
43 1     1   56 use Future;
  1         3  
  1         45  
44 1     1   633 use Dir::Self;
  1         782  
  1         7  
45 1     1   1158 use Path::Tiny;
  1         22376  
  1         95  
46 1     1   843 use File::ShareDir;
  1         45368  
  1         79  
47 1     1   1093 use URI;
  1         9568  
  1         62  
48 1     1   826 use URI::QueryParam;
  1         161  
  1         52  
49 1     1   806 use URI::Template;
  1         12105  
  1         77  
50 1     1   649 use JSON::MaybeXS;
  1         11781  
  1         117  
51 1     1   675 use Time::Moment;
  1         2338  
  1         50  
52 1     1   672 use Syntax::Keyword::Try;
  1         3881  
  1         6  
53              
54 1     1   928 use Cache::LRU;
  1         1699  
  1         71  
55              
56 1     1   756 use Ryu::Async;
  1         322081  
  1         232  
57 1     1   1012 use Ryu::Observable;
  1         5167  
  1         108  
58 1     1   1477 use Net::Async::WebSocket::Client;
  1         65806  
  1         117  
59              
60 1     1   12 use Log::Any qw($log);
  1         2  
  1         15  
61              
62 1     1   1079 use Net::Async::Github::Branch;
  1         5  
  1         63  
63 1     1   735 use Net::Async::Github::User;
  1         4  
  1         97  
64 1     1   651 use Net::Async::Github::Team;
  1         4  
  1         65  
65 1     1   8 use Net::Async::Github::Plan;
  1         2  
  1         63  
66 1     1   623 use Net::Async::Github::PullRequest;
  1         4  
  1         75  
67 1     1   802 use Net::Async::Github::Repository;
  1         8  
  1         69  
68 1     1   678 use Net::Async::Github::RateLimit;
  1         3  
  1         61  
69 1     1   750 use Net::Async::Github::Comment;
  1         4  
  1         75  
70              
71 1     1   876 use JSON::MaybeUTF8 qw(:v2);
  1         3259  
  1         11191  
72              
73             =head1 METHODS
74              
75             =head2 current_user
76              
77             Returns information about the current user.
78              
79             my $user = $gh->current_user->get;
80             printf "User [%s] has %d public repos and was last updated on %s%s\n",
81             $user->login, $user->public_repos, $user->updated_at->to_string
82              
83             Resolves to a L instance.
84              
85             =cut
86              
87             sub current_user {
88 0     0 1   my ($self, %args) = @_;
89 0           $self->validate_args(%args);
90             $self->http_get(
91             uri => $self->endpoint('current_user')
92             )->transform(
93             done => sub {
94             Net::Async::Github::User->new(
95 0     0     %{$_[0]},
  0            
96             github => $self,
97             )
98             }
99             )
100 0           }
101              
102             =head2 configure
103              
104             Accepts the following optional named parameters:
105              
106             =over 4
107              
108             =item * C - the Github API token
109              
110             =item * C - hashref of L-compliant URL mappings
111              
112             =item * C - an HTTP client compatible with the L API
113              
114             =item * C - the MIME type to use as the C header for requests
115              
116             =item * C - number of GET responses to cache. Defaults to 1000, set to 0 to disable.
117              
118             =item * C - How long in seconds to wait before giving up on a request. Defaults to 60. If set to 0, then no timeout will take place.
119              
120             =back
121              
122             B< You probably just want C >, defaults should be fine for the
123             other settings.
124              
125             If you're creating a large number of instances, you can avoid
126             some disk access overhead by passing C from an existing
127             instance to the constructor for a new instance.
128              
129             =cut
130              
131             sub configure {
132 0     0 1   my ($self, %args) = @_;
133 0           for my $k (grep exists $args{$_}, qw(token endpoints api_key http base_uri mime_type page_cache_size timeout)) {
134 0           $self->{$k} = delete $args{$k};
135             }
136 0           $self->SUPER::configure(%args);
137             }
138              
139             =head2 reopen
140              
141             Reopens the given PR.
142              
143             Expects the following named parameters:
144              
145             =over 4
146              
147             =item * owner - which user or organisation owns this PR
148              
149             =item * repo - which repo it's for
150              
151             =item * id - the pull request ID
152              
153             =back
154              
155             Resolves to the current status.
156              
157             =cut
158              
159             sub reopen {
160 0     0 1   my ($self, %args) = @_;
161 0           die "needs $_" for grep !$args{$_}, qw(owner repo id);
162 0           $self->validate_args(%args);
163 0           my $uri = URI->new($self->base_uri);
164             $uri->path(
165             join '/', 'repos', $args{owner}, $args{repo}, 'pulls', $args{id}
166 0           );
167 0           $self->http_patch(
168             uri => $uri,
169             data => {
170             state => 'open',
171             },
172             )
173             }
174              
175             =head2 pull_request
176              
177             Returns information about the given PR.
178              
179             Expects the following named parameters:
180              
181             =over 4
182              
183             =item * owner - which user or organisation owns this PR
184              
185             =item * repo - which repo it's for
186              
187             =item * id - the pull request ID
188              
189             =back
190              
191             Resolves to the current status.
192              
193             =cut
194              
195             sub pull_request {
196 0     0 1   my ($self, %args) = @_;
197 0           die "needs $_" for grep !$args{$_}, qw(owner repo id);
198 0           $self->validate_args(%args);
199 0           my $uri = $self->base_uri;
200             $uri->path(
201             join '/', 'repos', $args{owner}, $args{repo}, 'pulls', $args{id}
202 0           );
203 0           $log->tracef('Check Github pull request via URI %s', "$uri");
204             $self->http_get(
205             uri => $uri,
206             )->transform(
207             done => sub {
208 0     0     $log->tracef('Github PR data was ', $_[0]);
209             Net::Async::Github::PullRequest->new(
210 0           %{$_[0]},
  0            
211             github => $self,
212             )
213             }
214             )
215 0           }
216              
217             # Provide an alias for anyone relying on previous name
218             *pr = *pull_request;
219              
220             =head2 pull_requests
221              
222             Returns information of all PRs of given repository.
223              
224             Expects the following named parameters:
225              
226             =over 4
227              
228             =item * C - which user or organisation owns this PR
229              
230             =item * C - the repository this pull request is for
231              
232             =back
233              
234             Returns a L instance, this will emit a L
235             instance for each found repository.
236              
237             =cut
238              
239             sub pull_requests {
240 0     0 1   my ($self, %args) = @_;
241 0           $self->validate_args(%args);
242             $self->api_get_list(
243             endpoint => 'pull_request',
244             endpoint_args => {
245             owner => $args{owner},
246             repo => $args{repo},
247             },
248 0           class => 'Net::Async::Github::PullRequest'
249             );
250             }
251              
252             sub issue_comments {
253 0     0 0   my ($self, %args) = @_;
254 0           $self->validate_args(%args);
255             $self->api_get_list(
256             endpoint => 'issue_comments',
257             endpoint_args => {
258             owner => $args{owner},
259             repo => $args{repo},
260             },
261 0           class => 'Net::Async::Github::Comment'
262             );
263             }
264              
265             # Provide an alias for anyone relying on previous name
266             *prs = *pull_requests;
267              
268             sub teams {
269 0     0 0   my ($self, %args) = @_;
270 0           $self->validate_args(%args);
271             $self->api_get_list(
272 0           uri => $self->endpoint('team', org => $args{organisation}),
273             class => 'Net::Async::Github::Team',
274             )
275             }
276              
277             sub Net::Async::Github::Repository::forks {
278 0     0 0   my ($self, %args) = @_;
279 0           my $gh = $self->github;
280 0           $gh->validate_args(%args);
281 0           $gh->api_get_list(
282             uri => $self->forks_url->process,
283             class => 'Net::Async::Github::Repository',
284             )
285             }
286              
287             sub Net::Async::Github::Repository::branches {
288 0     0 0   my ($self, %args) = @_;
289 0           my $gh = $self->github;
290 0           $gh->validate_args(%args);
291 0           $gh->api_get_list(
292             uri => $self->branches_url->process,
293             class => 'Net::Async::Github::Branch',
294             )
295             }
296              
297             sub Net::Async::Github::Repository::grant_team {
298 0     0 0   my ($self, %args) = @_;
299 0           my $gh = $self->github;
300 0           $gh->validate_args(%args);
301             $self->github->http_put(
302             uri => $self->github->endpoint(
303             'team_repo',
304             team => $args{team},
305             owner => $self->owner->{login},
306             repo => $self->name,
307             ),
308             data => {
309             permission => $args{permission},
310             },
311             )
312 0           }
313              
314             =head2 create_branch
315              
316             Creates a new branch.
317              
318             Takes the following named parameters:
319              
320             =over 4
321              
322             =item * C - which organisation owns the target repository
323              
324             =item * C - the repository to raise the PR against
325              
326             =item * C - new branch name that will be created
327              
328             =item * C - the SHA1 value for this branch
329              
330             =back
331              
332             =cut
333              
334             sub create_branch {
335 0     0 1   my ($self, %args) = @_;
336 0           $self->validate_args(%args);
337             $self->http_post(
338             uri => $self->endpoint(
339             'git_refs_create',
340             owner => $args{owner},
341             repo => $args{repo},
342             ),
343             data => {
344             ref => "refs/heads/$args{branch}",
345             sha => $args{sha}
346             },
347             )
348 0           }
349              
350              
351             =head2 update_ref
352              
353             Update a reference to a new commit
354              
355             Takes the following named parameters:
356              
357             =over 4
358              
359             =item * C - which organisation owns the target repository
360              
361             =item * C - the repository to raise the PR against
362              
363             =item * C - ref name that we are updating.
364              
365             =item * C - the SHA1 value of comment that the ref will point to
366              
367             =item * C - force update ref even if it is not fast-forward if it is true.
368              
369             =back
370              
371             =cut
372              
373             sub update_ref {
374 0     0 1   my ($self, %args) = @_;
375 0           $self->validate_args(%args);
376             $self->http_patch(
377             uri => $self->endpoint(
378             'git_refs',
379             owner => $args{owner},
380             repo => $args{repo},
381             category => 'heads',
382             ref => $args{ref},
383             ),
384             data => {
385             sha => $args{sha},
386 0 0         force => ($args{force} ? JSON->true : JSON->false)
387             },
388             )
389             }
390              
391             =head2 create_pr
392              
393             Creates a new pull request.
394              
395             Takes the following named parameters:
396              
397             =over 4
398              
399             =item * C - which organisation owns the target repository
400              
401             =item * C - the repository to raise the PR against
402              
403             =item * C - head commit starting point, typically the latest commit on your fork's branch
404              
405             =item * C - base commit this PR applies changes to typically you'd want the target repo C
406              
407             =back
408              
409             =cut
410              
411             sub create_pr {
412 0     0 1   my ($self, %args) = @_;
413 0           $self->validate_args(%args);
414             $self->http_post(
415             uri => $self->endpoint(
416             'pull_request',
417             owner => $args{owner},
418             repo => $args{repo},
419             ),
420             data => {
421             head => $args{head},
422             base => $args{base},
423             title => $args{title},
424 0 0         $args{body} ? (body => $args{body}) : (),
425             },
426             )
427             }
428              
429             =head2 create_commit
430              
431             Creates an empty commit. Can be used to simulate C
432             or to create a merge commit from multiple heads.
433              
434             Takes the following named parameters:
435              
436             =over 4
437              
438             =item * C - which organisation owns the target repository
439              
440             =item * C - the repository to raise the PR against
441              
442             =item * C - The commit message
443              
444             =item * C - The SHA of tree object that commit will point to
445              
446             =item * C - Arrayref that include the parents of the commit
447              
448             =back
449              
450             =cut
451              
452             sub create_commit {
453 0     0 1   my ($self, %args) = @_;
454 0           $self->validate_args(%args);
455             $self->http_post(
456             uri => $self->endpoint(
457             'commits',
458             owner => $args{owner},
459             repo => $args{repo},
460             ),
461             data => {
462             message => $args{message},
463             tree => $args{tree},
464             parents => $args{parents},
465             },
466             )
467 0           }
468              
469             # Example:
470             #
471             # $repo->protect_branch(
472             # branch => 'master',
473             # required_status_checks => {
474             # strict => 1,
475             # contexts => [
476             # '',
477             # ]
478             # },
479             # enforce_admins => 0,
480             # restrictions => {
481             # teams => [
482             # 'WRITE-Admin',
483             # ]
484             # }
485             sub Net::Async::Github::Repository::protect_branch {
486 0     0 0   my ($self, %args) = @_;
487 0           my $gh = $self->github;
488 0           $gh->validate_args(%args);
489              
490             # Coërce the true/false values into something appropriate for JSON
491 0           $args{required_status_checks} = { %{$args{required_status_checks}} };
  0            
492 0 0         $_->{strict} = $_->{strict} ? JSON->true : JSON->false for $args{required_status_checks};
493 0 0         $args{enforce_admins} = $args{enforce_admins} ? JSON->true : JSON->false;
494 0   0       $args{required_pull_request_reviews} //= undef;
495 0 0 0       if($args{restrictions} //= undef) {
496 0   0       $args{restrictions}{users} ||= [];
497 0   0       $args{restrictions}{teams} ||= [];
498             }
499              
500             $self->github->http_put(
501             uri => $self->github->endpoint(
502             'branch_protection',
503             owner => $self->owner->{login},
504             repo => $self->name,
505             branch => ($args{branch} // die 'need a branch'),
506             ),
507             data => {
508             map {;
509 0           $_ => $args{$_}
510             } grep {
511 0   0       exists $args{$_}
  0            
512             } qw(
513             required_status_checks
514             enforce_admins
515             required_pull_request_reviews
516             restrictions
517             )
518             },
519             )
520             }
521              
522             sub Net::Async::Github::Repository::branch_protection {
523 0     0 0   my ($self, %args) = @_;
524 0           my $gh = $self->github;
525 0           $gh->validate_args(%args);
526             $self->github->http_get(
527             uri => $self->github->endpoint(
528             'branch_protection',
529             owner => $self->owner->{login},
530             repo => $self->name,
531 0   0       branch => ($args{branch} // die 'need a branch'),
532             ),
533             )
534             }
535              
536             sub Net::Async::Github::Repository::get_file {
537 0     0 0   my ($self, %args) = @_;
538 0           my $gh = $self->github;
539 0           $gh->validate_args(%args);
540             $self->github->http_get(
541             uri => $self->github->endpoint(
542             'contents',
543             owner => $self->owner->{login},
544             repo => $self->name,
545             path => ($args{path} // die 'need a path'),
546             (exists $args{branch} ? (branch => $args{branch}) : ()),
547             ),
548             )->transform(
549             done => sub {
550 0     0     my ($result) = @_;
551 0 0         if($result->{encoding} eq 'base64') {
552             return MIME::Base64::decode_base64($result->{content})
553 0           } else {
554             return $result->{content}
555 0           }
556             }
557             )
558 0 0 0       }
559              
560 0     0 0   sub Net::Async::Github::PullRequest::owner { shift->{base}{repo}{owner}{login} }
561 0     0 0   sub Net::Async::Github::PullRequest::repo { shift->{base}{repo}{name} }
562 0     0 0   sub Net::Async::Github::PullRequest::branch_name { shift->{head}{ref} }
563              
564             sub Net::Async::Github::PullRequest::merge {
565 0     0 0   my ($self, %args) = @_;
566 0           my $gh = $self->github;
567 0           $gh->validate_args(%args);
568 0 0         die 'invalid owner' if ref $self->owner;
569 0 0         die 'invalid repo' if ref $self->repo;
570 0 0         die 'invalid id' if ref $self->id;
571             my $uri = $gh->endpoint(
572             'pull_request_merge',
573             owner => $args{owner} // $self->owner,
574             repo => $args{repo} // $self->repo,
575 0   0       id => $args{id} // $self->number,
      0        
      0        
576             );
577 0           $log->infof('URI for PR merge is %s', "$uri");
578             $gh->http_put(
579             uri => $uri,
580             data => {
581             sha => $self->{head}{sha},
582 0           map { $_ => $args{$_} } grep { exists $args{$_} } qw(
  0            
  0            
583             commit_title
584             commit_message
585             sha
586             merge_method
587             admin_override
588             )
589             }
590             )
591             }
592              
593             sub Net::Async::Github::PullRequest::cleanup {
594 0     0 0   my ($self, %args) = @_;
595 0           my $gh = $self->github;
596 0           $gh->validate_args(%args);
597 0 0         die 'invalid owner' if ref $self->owner;
598 0 0         die 'invalid repo' if ref $self->repo;
599 0 0         die 'invalid id' if ref $self->id;
600             my $uri = $gh->endpoint(
601             'git_refs',
602             category => 'heads',
603             owner => $args{owner} // $self->{head}{repo}{owner}{login},
604             repo => $args{repo} // $self->{head}{repo}{name},
605 0   0       ref => $args{ref} // $self->branch_name,
      0        
      0        
606             );
607 0           $log->infof('URI for PR delete is %s', "$uri");
608 0           $gh->http_delete(
609             uri => $uri,
610             )
611             }
612              
613             sub repos {
614 0     0 0   my ($self, %args) = @_;
615 0 0         if(my $user = delete $args{owner}) {
616 0           $self->validate_owner_name($user);
617             $self->api_get_list(
618             endpoint => 'user_repositories',
619             endpoint_args => {
620             user => $user,
621 0   0       visibility => $args{visibility} // 'all',
622             },
623             class => 'Net::Async::Github::Repository',
624             )
625             } else {
626             $self->api_get_list(
627             endpoint => 'current_user_repositories',
628             endpoint_args => {
629 0   0       visibility => $args{visibility} // 'all',
630             },
631             class => 'Net::Async::Github::Repository',
632             )
633             }
634             }
635              
636             sub org_repos {
637 0     0 0   my ($self, %args) = @_;
638 0 0         if(my $user = delete $args{owner}) {
639 0           $self->validate_owner_name($user);
640 0           $self->api_get_list(
641             endpoint => 'organization_repositories',
642             endpoint_args => {
643             org => $user,
644             },
645             class => 'Net::Async::Github::Repository',
646             )
647             } else {
648             $self->api_get_list(
649             endpoint => 'current_user_repositories',
650             endpoint_args => {
651 0   0       visibility => $args{visibility} // 'all',
652             },
653             class => 'Net::Async::Github::Repository',
654             )
655             }
656             }
657              
658             sub repo {
659 0     0 1   my ($self, %args) = @_;
660 0 0         die 'need an owner name' unless my $owner = delete $args{owner};
661 0 0         die 'need a repo name' unless my $repo_name = delete $args{name};
662 0           $self->validate_owner_name($owner);
663 0           $self->validate_repo_name($repo_name);
664             $self->http_get(
665             uri => $self->endpoint(
666             repository => (
667             owner => $owner,
668             repo => $repo_name,
669             )
670             ),
671             )->transform(
672             done => sub {
673 0     0     $log->tracef('Github repo data was %s', $_[0]);
674             Net::Async::Github::Repository->new(
675 0           %{$_[0]},
  0            
676             github => $self,
677             )
678             }
679             )
680 0           }
681              
682             =head2 user
683              
684             Returns information about the given user.
685              
686             =cut
687              
688             sub user {
689 0     0 1   my ($self, $user, %args) = @_;
690 0           $self->validate_owner_name($user);
691             $self->http_get(
692             uri => $self->endpoint(
693             'user',
694             user => $user
695             ),
696             )->transform(
697             done => sub {
698             Net::Async::Github::User->new(
699 0     0     %{$_[0]},
  0            
700             github => $self,
701             )
702             }
703             )
704 0           }
705              
706             =head2 users
707              
708             Iterates through all users. This is a good way to exhaust your 5000-query
709             ratelimiting quota.
710              
711             =cut
712              
713             sub users {
714 0     0 1   my ($self, %args) = @_;
715 0           $self->validate_args(%args);
716 0           $self->api_get_list(
717             uri => '/users',
718             class => 'Net::Async::Github::User',
719             )
720             }
721              
722             sub users_for_org {
723 0     0 0   my ($self, %args) = @_;
724 0           $self->validate_args(%args);
725             $self->api_get_list(
726 0           uri => '/orgs/' . $args{owner} . '/members',
727             class => 'Net::Async::Github::User',
728             )
729             }
730              
731             =head2 head
732              
733             Identifies the head version for this branch.
734              
735             Requires the following named parameters:
736              
737             =over 4
738              
739             =item * owner - which organisation or person owns the repo
740              
741             =item * repo - the repository name
742              
743             =item * branch - which branch to check
744              
745             =back
746              
747             =cut
748              
749             sub head {
750 0     0 1   my ($self, %args) = @_;
751 0           die "needs $_" for grep !$args{$_}, qw(owner repo branch);
752 0           $self->validate_args(%args);
753 0           my $uri = $self->base_uri;
754             $uri->path(
755             join '/', 'repos', $args{owner}, $args{repo}, qw(git refs heads), $args{branch}
756 0           );
757 0           $self->http_get(
758             uri => $uri
759             )
760             }
761              
762             =head2 update
763              
764             =cut
765              
766             sub update {
767 0     0 1   my ($self, %args) = @_;
768 0           die "needs $_" for grep !$args{$_}, qw(owner repo branch head);
769 0           $self->validate_branch_name($args{branch});
770 0           my $uri = $self->base_uri;
771             $uri->path(
772 0           join '/', 'repos', $args{owner}, $args{repo}, qw(merges)
773             );
774             $self->http_post(
775             uri => $uri,
776             data => {
777             head => $args{head},
778             base => $args{branch},
779             commit_message => "Merge branch 'master' into " . $args{branch},
780             },
781             )
782 0           }
783              
784             =head2 core_rate_limit
785              
786             Returns a L instance which can track rate limits.
787              
788             =cut
789              
790             sub core_rate_limit {
791 0     0 1   my ($self) = @_;
792 0   0       $self->{core_rate_limit} //= do {
793 1     1   734 use Variable::Disposition qw(retain_future);
  1         1073  
  1         151  
794 1     1   830 use namespace::clean qw(retain_future);
  1         33505  
  1         9  
795 0           my $rl = Net::Async::Github::RateLimit::Core->new(
796             limit => Ryu::Observable->new(undef),
797             remaining => Ryu::Observable->new(undef),
798             reset => Ryu::Observable->new(undef),
799             );
800             retain_future(
801             $self->http_get(
802             uri => $self->endpoint('rate_limit')
803             )->on_done(sub {
804 0     0     my $data = shift;
805 0           $log->tracef("Github rate limit response was %s", $data);
806 0           $rl->reset->set_numeric($data->{resources}{core}{reset});
807 0           $rl->limit->set_numeric($data->{resources}{core}{limit});
808 0           $rl->remaining->set_numeric($data->{resources}{core}{remaining});
809             })
810 0           );
811 0           $rl;
812             }
813             }
814              
815             =head2 rate_limit
816              
817             =cut
818              
819             sub rate_limit {
820 0     0 1   my ($self) = @_;
821             $self->http_get(
822             uri => $self->endpoint('rate_limit')
823             )->transform(
824             done => sub {
825             Net::Async::Github::RateLimit->new(
826 0     0     %{$_[0]}
  0            
827             )
828             }
829             )
830 0           }
831              
832             =head1 METHODS - Internal
833              
834             The following methods are used internally. They're not expected to be
835             useful for external callers.
836              
837             =head2 api_key
838              
839             =cut
840              
841 0     0 1   sub api_key { shift->{api_key} }
842              
843             =head2 token
844              
845             =cut
846              
847 0     0 1   sub token { shift->{token} }
848              
849             =head2 endpoints
850              
851             Returns an accessor for the endpoints data. This is a hashref containing URI
852             templates, used by L.
853              
854             =cut
855              
856             sub endpoints {
857 0     0 1   my ($self) = @_;
858 0   0       $self->{endpoints} ||= do {
859 0           my $path = Path::Tiny::path(__DIR__)->parent(3)->child('share/endpoints.json');
860 0 0         $path = Path::Tiny::path(
861             File::ShareDir::dist_file(
862             'Net-Async-Github',
863             'endpoints.json'
864             )
865             ) unless $path->exists;
866 0           decode_json_text($path->slurp_utf8)
867             };
868             }
869              
870             =head2 endpoint
871              
872             Expands the selected URI via L. Each item is defined in our C< endpoints.json >
873             file.
874              
875             Returns a L instance.
876              
877             =cut
878              
879             sub endpoint {
880 0     0 1   my ($self, $endpoint, %args) = @_;
881             URI::Template->new(
882 0           $self->endpoints->{$endpoint . '_url'}
883             )->process(%args);
884             }
885              
886             =head2 http
887              
888             Accessor for the HTTP client object. Will load and instantiate a L instance
889             if necessary.
890              
891             Actual HTTP implementation is not guaranteed, and the default is likely to change in future.
892              
893             =cut
894              
895             sub http {
896 0     0 1   my ($self) = @_;
897 0   0       $self->{http} ||= do {
898 0           require Net::Async::HTTP;
899 0 0         $self->add_child(
900             my $ua = Net::Async::HTTP->new(
901             fail_on_error => 1,
902             max_connections_per_host => $self->connections_per_host,
903             pipeline => 1,
904             max_in_flight => 4,
905             decode_content => 1,
906             user_agent => 'Mozilla/4.0 (perl; Net::Async::Github; TEAM@cpan.org)',
907             (
908             $self->timeout
909             ? (timeout => $self->timeout)
910             : ()
911             ),
912             )
913             );
914 0           $ua
915             }
916             }
917              
918             sub update_limiter {
919 0     0 0   my ($self) = @_;
920 0 0         unless($self->{update_limiter}) {
921             # Any modification should wait for this to resolve first
922             $self->{update_limiter} = $self->loop->delay_future(
923             after => 1
924             )->on_ready(sub {
925             delete $self->{update_limiter}
926 0     0     });
  0            
927             # Limit the next request - not this one
928 0           return Future->done;
929             }
930 0           return $self->{update_limiter};
931             }
932              
933             # Github ratelimit guidelines suggest max 1 request in parallel
934             # per user, with 1s between any state-modifying calls. Since this
935             # is part of their defined API, we don't expose this in L.
936 0     0 0   sub connections_per_host { 4 }
937              
938             # Like connections, but for data modification - POST, PUT, PATCH etc.
939 0     0 0   sub updates_per_host { 1 }
940              
941             =head2 timeout
942              
943             The parameter that will be used when create Net::Async::HTTP object. If it is undef, then a default value
944             60 seconds will be used. If it is 0, then Net::Async::HTTP will never timeout.
945              
946             =cut
947              
948 0   0 0 1   sub timeout { shift->{timeout} //= 60 }
949              
950             =head2 auth_info
951              
952             Returns authentication information used in the HTTP request.
953              
954             =cut
955              
956             sub auth_info {
957 0     0 1   my ($self) = @_;
958 0 0         if(my $key = $self->api_key) {
959             return (
960 0           user => $key,
961             pass => '',
962             );
963             }
964 0 0         if(my $token = $self->token) {
965             return (
966 0           headers => {
967             Authorization => 'token ' . $token
968             }
969             )
970             }
971              
972 0           die "need some form of auth, try passing a token or api_key"
973             }
974              
975             =head2 mime_type
976              
977             Returns the MIME type used for requests. Currently defined by github in
978             L as C.
979              
980             =cut
981              
982 0   0 0 1   sub mime_type { shift->{mime_type} //= 'application/vnd.github.v3+json' }
983              
984             =head2 base_uri
985              
986             The L for requests. Defaults to L.
987              
988             =cut
989              
990             sub base_uri {
991             (
992 0   0 0 1   shift->{base_uri} //= URI->new('https://api.github.com')
993             )->clone
994             }
995              
996             =head2 http_get
997              
998             Performs an HTTP GET request.
999              
1000             =cut
1001              
1002             sub http_get {
1003 0     0 1   my ($self, %args) = @_;
1004 0           my %auth = $self->auth_info;
1005              
1006 0 0         if(my $hdr = delete $auth{headers}) {
1007 0   0       $args{headers}{$_} //= $hdr->{$_} for keys %$hdr
1008             }
1009 0   0       $args{$_} //= $auth{$_} for keys %auth;
1010              
1011 0           my $uri = delete $args{uri};
1012 0           $log->tracef("GET %s { %s }", $uri->as_string, \%args);
1013 0           my $cached = $self->page_cache->get($uri->as_string);
1014 0 0         if($cached) {
1015 0           $log->tracef("Had cached page data, etag %s and last modified %s", $cached->header('ETag'), $cached->header('Last-Modified'));
1016 0 0         $args{headers}{'If-None-Match'} = $cached->header('ETag') if $cached->header('ETag');
1017 0 0         $args{headers}{'If-Modified-Since'} = $cached->header('Last-Modified') if $cached->header('Last-Modified');
1018             }
1019             $self->http->GET(
1020             $uri,
1021             %args,
1022             )->on_fail(sub {
1023 0     0     $log->tracef('Response failed for %s', "$uri");
1024             })->on_cancel(sub {
1025 0     0     $log->tracef('Request cancelled for %s', "$uri");
1026             })->on_done(sub {
1027 0     0     $log->tracef('Response received for %s', "$uri");
1028             })->then(sub {
1029 0     0     my ($resp) = @_;
1030 0           $log->tracef("Github response: %s", $resp->as_string("\n"));
1031             # If we had ratelimiting headers, apply them
1032 0           for my $k (qw(Reset Limit Remaining)) {
1033 0 0         if(defined(my $v = $resp->header('X-RateLimit-' . $k))) {
1034 0           my $method = lc $k;
1035 0           $self->core_rate_limit->$method->set_numeric($v);
1036             }
1037             }
1038              
1039 0 0 0       if($cached && $resp->code == 304) {
    0          
1040 0           $resp = $cached;
1041 0           $log->tracef("Using cached version of [%s] for %d byte response", $uri->as_string, $resp->content_length);
1042             } elsif($resp->is_success) {
1043 0           $log->tracef("Caching [%s] with %d byte response", $uri->as_string, $resp->content_length);
1044 0           $self->page_cache->set($uri->as_string => $resp);
1045             } else {
1046 0           $log->tracef("Not caching [%s] due to status %d", $resp->code);
1047             }
1048              
1049 0 0         return Future->done(
1050             { },
1051             $resp
1052             ) if $resp->code == 204;
1053 0 0         return Future->done(
1054             { },
1055             $resp
1056             ) if 3 == ($resp->code / 100);
1057             try {
1058             return Future->done(
1059             decode_json_utf8(
1060             $resp->decoded_content
1061             ),
1062             $resp
1063             );
1064 0           } catch {
1065             $log->errorf("JSON decoding error %s from HTTP response %s", $@, $resp->as_string("\n"));
1066             return Future->fail($@ => json => $resp);
1067             }
1068             })->else(sub {
1069 0     0     my ($err, $src, $resp, $req) = @_;
1070 0           $log->warnf("Github failed with error %s on source %s", $err, $src);
1071 0   0       $src //= '';
1072 0 0         if($src eq 'http') {
1073 0           $log->errorf("HTTP error %s, request was %s with response %s", $err, $req->as_string("\n"), $resp->as_string("\n"));
1074             } else {
1075 0   0       $log->errorf("Other failure (%s): %s", $src // 'unknown', $err);
1076             }
1077 0           Future->fail(@_);
1078             })
1079 0           }
1080              
1081             sub http_delete {
1082 0     0 0   my ($self, %args) = @_;
1083 0           my %auth = $self->auth_info;
1084              
1085 0 0         if(my $hdr = delete $auth{headers}) {
1086 0   0       $args{headers}{$_} //= $hdr->{$_} for keys %$hdr
1087             }
1088 0   0       $args{$_} //= $auth{$_} for keys %auth;
1089              
1090 0           my $uri = delete $args{uri};
1091 0           $log->tracef("DELETE %s { %s }", $uri->as_string, \%args);
1092              
1093             # we never cache deletes
1094             $self->http->do_request(
1095             method => 'DELETE',
1096             uri => $uri,
1097             %args,
1098             )->then(sub {
1099 0     0     my ($resp) = @_;
1100 0           $log->tracef("Github response: %s", $resp->as_string("\n"));
1101             # If we had ratelimiting headers, apply them
1102 0           for my $k (qw(Reset Limit Remaining)) {
1103 0 0         if(defined(my $v = $resp->header('X-RateLimit-' . $k))) {
1104 0           my $method = lc $k;
1105 0           $self->core_rate_limit->$method->set_numeric($v);
1106             }
1107             }
1108              
1109 0 0         return Future->done(
1110             { },
1111             $resp
1112             ) if $resp->code == 204;
1113 0 0         return Future->done(
1114             { },
1115             $resp
1116             ) if 3 == ($resp->code / 100);
1117             try {
1118             return Future->done(
1119             decode_json_utf8(
1120             $resp->decoded_content
1121             ),
1122             $resp
1123             );
1124 0           } catch {
1125             $log->errorf("JSON decoding error %s from HTTP response %s", $@, $resp->as_string("\n"));
1126             return Future->fail($@ => json => $resp);
1127             }
1128             })->else(sub {
1129 0     0     my ($err, $src, $resp, $req) = @_;
1130 0           $log->warnf("Github failed with error %s on source %s", $err, $src);
1131 0   0       $src //= '';
1132 0 0         if($src eq 'http') {
1133 0           $log->errorf("HTTP error %s, request was %s with response %s", $err, $req->as_string("\n"), $resp->as_string("\n"));
1134             } else {
1135 0   0       $log->errorf("Other failure (%s): %s", $src // 'unknown', $err);
1136             }
1137 0           Future->fail(@_);
1138             })
1139 0           }
1140              
1141             sub http_put {
1142 0     0 0   my ($self, %args) = @_;
1143 0           my %auth = $self->auth_info;
1144 0   0       my $method = delete $args{method} || 'PUT';
1145              
1146 0 0         if(my $hdr = delete $auth{headers}) {
1147 0   0       $args{headers}{$_} //= $hdr->{$_} for keys %$hdr
1148             }
1149 0   0       $args{$_} //= $auth{$_} for keys %auth;
1150              
1151 0           my $uri = delete $args{uri};
1152 0           my $data = delete $args{data};
1153 0           $log->tracef("%s %s { %s } <= %s", $method, $uri->as_string, \%args, $data);
1154 0 0         $data = encode_json_utf8($data) if ref $data;
1155             $self->http->do_request(
1156             method => $method,
1157             uri => $uri,
1158             content => $data,
1159             content_type => 'application/json',
1160             %args,
1161             )->then(sub {
1162 0     0     my ($resp) = @_;
1163 0           $log->tracef("Github response: %s", $resp->as_string("\n"));
1164             # If we had ratelimiting headers, apply them
1165 0           for my $k (qw(Limit Remaining Reset)) {
1166 0 0         if(defined(my $v = $resp->header('X-RateLimit-' . $k))) {
1167 0           my $method = lc $k;
1168 0           $self->core_rate_limit->$method->set_numeric($v);
1169             }
1170             }
1171              
1172 0 0         return Future->done(
1173             { },
1174             $resp
1175             ) if $resp->code == 204;
1176 0 0         return Future->done(
1177             { },
1178             $resp
1179             ) if 3 == ($resp->code / 100);
1180             try {
1181             return Future->done(
1182             decode_json_utf8(
1183             $resp->decoded_content
1184             ),
1185             $resp
1186             );
1187 0           } catch {
1188             $log->errorf("JSON decoding error %s from HTTP response %s", $@, $resp->as_string("\n"));
1189             return Future->fail($@ => json => $resp);
1190             }
1191             })->else(sub {
1192 0     0     my ($err, $src, $resp, $req) = @_;
1193 0           $log->warnf("Github failed with error %s on source %s", $err, $src);
1194 0   0       $src //= '';
1195 0 0         if($src eq 'http') {
1196 0           $log->errorf("HTTP error %s, request was %s with response %s", $err, $req->as_string("\n"), $resp->as_string("\n"));
1197             } else {
1198 0   0       $log->errorf("Other failure (%s): %s", $src // 'unknown', $err);
1199             }
1200 0           Future->fail(@_);
1201             })
1202 0           }
1203              
1204             sub http_patch {
1205 0     0 0   my ($self, %args) = @_;
1206 0           return $self->http_put(%args, method => 'PATCH');
1207             }
1208              
1209             sub http_post {
1210 0     0 0   my ($self, %args) = @_;
1211 0           my %auth = $self->auth_info;
1212              
1213 0 0         if(my $hdr = delete $auth{headers}) {
1214 0   0       $args{headers}{$_} //= $hdr->{$_} for keys %$hdr
1215             }
1216 0   0       $args{$_} //= $auth{$_} for keys %auth;
1217              
1218 0           my $uri = delete $args{uri};
1219 0           my $data = delete $args{data};
1220 0           $log->tracef("POST %s { %s } <= %s", $uri->as_string, \%args, $data);
1221 0 0         $data = encode_json_utf8($data) if ref $data;
1222             $self->http->POST(
1223             $uri,
1224             $data,
1225             content_type => 'application/json',
1226             %args,
1227             )->then(sub {
1228 0     0     my ($resp) = @_;
1229 0           $log->tracef("Github response: %s", $resp->as_string("\n"));
1230             # If we had ratelimiting headers, apply them
1231 0           for my $k (qw(Limit Remaining Reset)) {
1232 0 0         if(defined(my $v = $resp->header('X-RateLimit-' . $k))) {
1233 0           my $method = lc $k;
1234 0           $self->core_rate_limit->$method->set_numeric($v);
1235             }
1236             }
1237              
1238 0 0         return Future->done(
1239             { },
1240             $resp
1241             ) if $resp->code == 204;
1242 0 0         return Future->done(
1243             { },
1244             $resp
1245             ) if 3 == ($resp->code / 100);
1246             try {
1247             return Future->done(
1248             decode_json_utf8(
1249             $resp->decoded_content
1250             ),
1251             $resp
1252             );
1253 0           } catch {
1254             $log->errorf("JSON decoding error %s from HTTP response %s", $@, $resp->as_string("\n"));
1255             return Future->fail($@ => json => $resp);
1256             }
1257             })->else(sub {
1258 0     0     my ($err, $src, $resp, $req) = @_;
1259 0           $log->warnf("Github failed with error %s on source %s", $err, $src);
1260 0   0       $src //= '';
1261 0 0         if($src eq 'http') {
1262 0           $log->errorf("HTTP error %s, request was %s with response %s", $err, $req->as_string("\n"), $resp->as_string("\n"));
1263             } else {
1264 0   0       $log->errorf("Other failure (%s): %s", $src // 'unknown', $err);
1265             }
1266 0           Future->fail(@_);
1267             })
1268 0           }
1269              
1270             sub api_get_list {
1271 1     1   11694 use Variable::Disposition qw(retain_future);
  1         3  
  1         97  
1272 1     1   9 use Scalar::Util qw(refaddr);
  1         2  
  1         94  
1273 1     1   11 use Future::Utils qw(fmap0);
  1         2  
  1         111  
1274 1     1   8 use namespace::clean qw(retain_future fmap0 refaddr);
  1         2  
  1         6  
1275              
1276 0     0 0   my ($self, %args) = @_;
1277             my $label = $args{endpoint}
1278 0 0         ? ('Github[' . $args{endpoint} . ']')
1279             : (caller 1)[3];
1280              
1281 0 0         die "Must be a member of a ::Loop" unless $self->loop;
1282              
1283             # Hoist our HTTP API call into a source of items
1284 0           my $src = $self->ryu->source(
1285             label => $label
1286             );
1287             my $uri = $args{endpoint}
1288             ? $self->endpoint(
1289             $args{endpoint},
1290 0           %{$args{endpoint_args}}
1291             ) : ref $args{uri}
1292             ? $args{uri}
1293             : URI->new(
1294             $self->base_uri . delete($args{uri})
1295 0 0         );
    0          
1296              
1297 0   0       my $per_page = (delete $args{per_page}) || 100;
1298 0           $uri->query_param(
1299             limit => $per_page
1300             );
1301 0           my @pending = $uri;
1302             my $f = (fmap0 {
1303 0     0     my $uri = shift;
1304             $self->http_get(
1305             uri => $uri,
1306             )->on_done(sub {
1307 0           my ($data, $resp) = @_;
1308             # Handle paging - this takes the form of zero or more Link headers like this:
1309             # Link: ; rel="next"
1310 0           for my $link (map { split /\s*,\s*/, $_ } $resp->header('Link')) {
  0            
1311 0 0         if($link =~ m{<([^>]+)>; rel="next"}) {
1312 0           push @pending, URI->new($1);
1313             }
1314             }
1315              
1316             $src->emit(
1317             $args{class}->new(
1318             %$_,
1319 0           ($args{extra} ? %{$args{extra}} : ()),
1320             github => $self
1321             )
1322 0 0         ) for @{ $_[0] };
  0            
1323             })->on_fail(sub {
1324 0           warn "fail - @_";
1325 0           $src->fail(@_)
1326             })->on_cancel(sub {
1327 0           warn "cancel - @_";
1328 0           $src->cancel
1329 0           });
1330             } foreach => \@pending)->on_done(sub {
1331 0     0     $src->finish;
1332 0           });
1333              
1334             # If our source finishes earlier than our HTTP request, then cancel the request
1335             $src->completed->on_ready(sub {
1336 0 0   0     return if $f->is_ready;
1337 0           $log->tracef("Finishing HTTP request early for %s since our source is no longer active", $label);
1338 0           $f->cancel
1339 0           });
1340              
1341             # Track active requests
1342 0           my $refaddr = Scalar::Util::refaddr($f);
1343             $self->pending_requests->push([ {
1344             id => $refaddr,
1345             src => $src,
1346             uri => $uri,
1347             future => $f,
1348             } ])->then(sub {
1349             $f->on_ready(sub {
1350             retain_future(
1351 0           $self->pending_requests->extract_first_by(sub { $_->{id} == $refaddr })
1352 0           )
1353 0     0     });
1354 0           })->retain;
1355 0           $src
1356             }
1357              
1358             =head2 pending_requests
1359              
1360             A list of all pending requests.
1361              
1362             =cut
1363              
1364             sub pending_requests {
1365 0   0 0 1   shift->{pending_requests} //= do {
1366 0           require Adapter::Async::OrderedList::Array;
1367 0           Adapter::Async::OrderedList::Array->new
1368             }
1369             }
1370              
1371             =head2 validate_branch_name
1372              
1373             Applies validation rules from L for a branch name.
1374              
1375             Will raise an exception on invalid input.
1376              
1377             =cut
1378              
1379             sub validate_branch_name {
1380 0     0 1   my ($self, $branch) = @_;
1381 0 0         die "branch not defined" unless defined $branch;
1382 0 0         die "branch contains path component with leading ." if $branch =~ m{/\.};
1383 0 0         die "branch contains double ." if $branch =~ m{\.\.};
1384 0 0         die "branch contains invalid character(s)" if $branch =~ m{[[:cntrl:][:space:]~^:\\]};
1385 0 0         die "branch ends with /" if substr($branch, -1) eq '/';
1386 0 0         die "branch ends with .lock" if substr($branch, -5) eq '.lock';
1387 0           return 1;
1388             }
1389              
1390             =head2 validate_owner_name
1391              
1392             Applies github rules for user/organisation name.
1393              
1394             Will raise an exception on invalid input.
1395              
1396             =cut
1397              
1398             sub validate_owner_name {
1399 0     0 1   my ($self, $owner) = @_;
1400 0 0         die "owner name not defined" unless defined $owner;
1401 0 0         die "owner name too long" if length($owner) > 39;
1402 0 0         die "owner name contains invalid characters" if $owner =~ /[^a-z0-9-]/i;
1403 0 0         die "owner name contains double hyphens" if $owner =~ /--/;
1404 0 0         die "owner name contains leading hyphen" if $owner =~ /^-/;
1405 0 0         die "owner name contains trailing hyphen" if $owner =~ /-$/;
1406 0           return 1;
1407             }
1408              
1409             =head2 validate_repo_name
1410              
1411             Applies github rules for repository name.
1412              
1413             Will raise an exception on invalid input.
1414              
1415             =cut
1416              
1417             sub validate_repo_name {
1418 0     0 1   my ($self, $repo) = @_;
1419 0 0         die "repo name not defined" unless defined $repo;
1420             # Not really as well-defined as I'd like, closest to an official answer seems to be here:
1421             # https://github.community/t/github-repository-name-vs-description-vs-readme-heading-h1/3284
1422             # There are repositories with underscores, but that seems to be strongly discouraged:
1423             # https://github.com/Automattic/_s
1424             # Canonical repositories with '. character would include the `.wiki` "magic" repo for each
1425             # Github repo
1426 0 0         die "repo name contains invalid characters" if $repo =~ /[^a-z0-9._-]/i;
1427 0 0         die "repo name too long" if length($repo) > 100;
1428 0           return 1;
1429             }
1430              
1431             =head2 validate_args
1432              
1433             Convenience method to apply validation on common parameters.
1434              
1435             =cut
1436              
1437             sub validate_args {
1438 0     0 1   my ($self, %args) = @_;
1439 0 0         $self->validate_branch_name($args{branch}) if exists $args{branch};
1440 0 0         $self->validate_owner_name($args{owner}) if exists $args{owner};
1441 0 0         $self->validate_repo_name($args{repo}) if exists $args{repo};
1442             }
1443              
1444             =head2 page_cache_size
1445              
1446             Returns the total number of GET responses we'll cache. Default is probably 1000.
1447              
1448             =cut
1449              
1450 0   0 0 1   sub page_cache_size { shift->{page_cache_size} //= 1000 }
1451              
1452             =head2 page_cache
1453              
1454             The page cache instance, likely to be provided by L.
1455             =cut
1456              
1457             sub page_cache {
1458 0   0 0 1   $_[0]->{page_cache} //= do {
1459 0           Cache::LRU->new(
1460             size => $_[0]->page_cache_size
1461             )
1462             }
1463             }
1464              
1465             =head2 ryu
1466              
1467             Our L instance, used for instantiating L instances.
1468              
1469             =cut
1470              
1471 0     0 1   sub ryu { shift->{ryu} }
1472              
1473             sub _add_to_loop {
1474 0     0     my ($self, $loop) = @_;
1475              
1476             # Hand out sources and sinks
1477             $self->add_child(
1478 0           $self->{ryu} = Ryu::Async->new
1479             );
1480              
1481             }
1482              
1483             sub ws {
1484 0     0 0   my ($self) = @_;
1485 0   0       $self->{ws} // do {
1486 0           require Net::Async::WebSocket::Client;
1487 0           $self->add_child(
1488             my $ws = Net::Async::WebSocket::Client->new(
1489             on_frame => $self->curry::weak::on_frame,
1490             )
1491             );
1492 0           Scalar::Util::weaken($self->{ws} = $ws);
1493 0           $ws
1494             };
1495             }
1496              
1497             1;
1498              
1499             =head1 AUTHOR
1500              
1501             Tom Molesworth , with contributions from C<< @chylli-binary >>.
1502              
1503             =head1 LICENSE
1504              
1505             Copyright Tom Molesworth 2014-2021. Licensed under the same terms as Perl itself.
1506