File Coverage

blib/lib/GitHub/RSS.pm
Criterion Covered Total %
statement 35 196 17.8
branch 0 28 0.0
condition 0 11 0.0
subroutine 12 29 41.3
pod 3 13 23.0
total 50 277 18.0


line stmt bran cond sub pod time code
1             use strict;
2 1     1   751 use 5.010;
  1         1  
  1         32  
3 1     1   20 use Moo 2;
  1         4  
4 1     1   506 use Filter::signatures;
  1         10726  
  1         6  
5 1     1   1821 use feature 'signatures';
  1         22660  
  1         6  
6 1     1   38 no warnings 'experimental::signatures';
  1         2  
  1         26  
7 1     1   6 use PerlX::Maybe;
  1         2  
  1         31  
8 1     1   605  
  1         2474  
  1         5  
9             use IO::Socket::SSL;
10 1     1   727 use Net::GitHub;
  1         65377  
  1         13  
11 1     1   858 use DBI;
  1         298594  
  1         39  
12 1     1   3145 use JSON;
  1         17481  
  1         94  
13 1     1   847  
  1         7982  
  1         8  
14             use Data::Dumper;
15 1     1   845  
  1         5337  
  1         1951  
16             our $VERSION = '0.03';
17              
18             =head1 NAME
19              
20             GitHub::RSS - collect data from Github.com for feeding into RSS
21              
22             =head1 SYNOPSIS
23              
24             my $gh = GitHub::RSS->new(
25             dbh => {
26             dsn => "dbi:SQLite:dbname=$store",
27             },
28             );
29              
30             my $last_updated = $gh->last_check;
31             $gh->fetch_and_store( $github_user => $github_repo, $last_updated );
32             if( $verbose ) {
33             print "Updated from $last_updated to " . $gh->last_check, "\n";
34             };
35              
36             =head1 DESCRIPTION
37              
38             This module provides a cache database for GitHub issues and scripts to
39             periodically update the database from GitHub.
40              
41             This is mainly used for creating an RSS feed from the database, hence the
42             name.
43              
44             =head1 METHODS
45              
46             =head2 C<< ->new >>
47              
48             my $gh = GitHub::RSS->new(
49             dbh => {
50             dsn => 'dbi:SQLite:dbname=db/issues.sqlite',
51             },
52             );
53              
54             Constructs a new GitHub::RSS instance
55              
56             =over 4
57              
58             =item *
59              
60             B<gh> - instance of L<Net::GitHub>
61              
62             =cut
63              
64             has 'gh' => (
65             is => 'ro',
66             default => sub( $self ) {
67             Net::GitHub->new(
68             maybe access_token => $self->token
69             ),
70             },
71             );
72              
73             =item *
74              
75             B<token_file> - name and path of the JSON-format token file containing the
76             GitHub API token By default, that file is searched for under the name
77             C<github.credentials> in C<.>, C<$ENV{XDG_DATA_HOME}>, C<$ENV{USERPROFILE}>
78             and C<$ENV{HOME}>.
79              
80             =cut
81              
82             has 'token_file' => (
83             is => 'lazy',
84             default => \&_find_gh_token_file,
85             );
86              
87             =item *
88              
89             B<token> - GitHub API token. If this is missing, it will be attempted to read
90             it from the C<token_file>.
91              
92             =cut
93              
94             has 'token' => (
95             is => 'lazy',
96             default => \&_read_gh_token,
97             );
98              
99             =item *
100              
101             B<default_user> - name of the GitHub user whose repos will be read
102              
103             =cut
104              
105             has default_user => (
106             is => 'ro',
107             );
108              
109             =item *
110              
111             B<default_repo> - name of the GitHub repo whose issues will be read
112              
113             =cut
114              
115             has default_repo => (
116             is => 'ro',
117             );
118              
119             =item *
120              
121             B<dbh> - premade database handle or alternatively a hashref containing
122             the L<DBI> arguments
123              
124             dbh => $dbh,
125              
126             or alternatively
127              
128             dbh => {
129             user => 'scott',
130             password => 'tiger',
131             dsn => 'dbi:SQLite:dbname=db/issues.sqlite',
132             }
133              
134             =cut
135              
136             has dbh => (
137             is => 'ro',
138             required => 1,
139             coerce => \&_build_dbh,
140             );
141              
142             return $args if ref($args) eq 'DBI::db';
143 0     0     ref($args) eq 'HASH' or die 'Not a DB handle nor a hashref';
  0            
  0            
144 0 0         return DBI->connect( @{$args}{qw/dsn user password options/} );
145 0 0         }
146 0            
  0            
147             =item *
148              
149             B<fetch_additional_pages> - number of additional pages to fetch from GitHub.
150             This is relevant when catching up a database for a repository with many issues.
151              
152             =back
153              
154             =cut
155              
156             has fetch_additional_pages => (
157             is => 'ro',
158             default => '1',
159             );
160              
161             $env //= \%ENV;
162              
163 0     0     my $token_file;
  0            
  0            
  0            
164 0   0        
165             # This should use File::User
166 0           for my $candidate_dir ('.',
167             $ENV{XDG_DATA_HOME},
168             $ENV{USERPROFILE},
169 0           $ENV{HOME}
170             ) {
171             next unless defined $candidate_dir;
172             if( -f "$candidate_dir/github.credentials" ) {
173             $token_file = "$candidate_dir/github.credentials";
174 0 0         last
175 0 0         };
176 0           };
177              
178 0           return $token_file
179             }
180              
181 0           my $file = $token_file // $self->token_file;
182              
183             if( $file ) {
184 0     0     open my $fh, '<', $file
  0            
  0            
  0            
185 0   0       or die "Couldn't open file '$file': $!";
186             binmode $fh;
187 0 0         local $/;
188 0 0         my $json = <$fh>;
189             my $token_json = decode_json( $json );
190 0           return $token_json->{token};
191 0           } else {
192 0           # We'll run without a known account
193 0           return
194 0           }
195             }
196              
197             $user = $self->default_user,
198 0           $repo = $self->default_repo,
199             $since=undef ) {
200             my @issues = $self->fetch_issues( $user, $repo, $since );
201 0           my $gh = $self->gh;
202 0           while ($gh->issue->has_next_page) {
203 0           push @issues, $gh->issue->next_page;
204 0     0 0   }
  0            
  0            
205 0           @issues
206 0           }
207 0            
208 0           $user = $self->default_user,
209             $repo = $self->default_repo,
210             $since=undef ) {
211 0           my $gh = $self->gh;
212             my @issues = $gh->issue->repos_issues($user => $repo,
213 0           { sort => 'updated',
214 0           direction => 'asc', # so we can interrupt any time
215 0           state => 'all', # so we find issues that got closed
216 0     0 0   maybe since => $since,
  0            
  0            
217 0           }
218 0           );
219             };
220              
221             =head2 C<< ->fetch_issue_comments >>
222              
223             =cut
224              
225             $user=$self->default_user,
226             $repo=$self->default_repo
227             ) {
228             # Shouldn't this loop as well, just like with the issues?!
229             return $self->gh->issue->comments($user, $repo, $issue_number );
230             }
231 0            
  0            
232 0           my @columns = sort keys %{ $rows[0] };
233 0           my $statement = sprintf q{replace into "%s" (%s) values (%s)},
234 0     0 1   $table,
  0            
235             join( ",", map qq{"$_"}, @columns ),
236 0           join( ",", ('?') x (0+@columns))
237             ;
238             my $sth = $self->dbh->prepare( $statement );
239 0     0 0   eval {
  0            
  0            
  0            
  0            
240 0           $sth->execute_for_fetch(sub { @rows ? [ @{ shift @rows }{@columns} ] : () }, \my @errors);
  0            
241 0           } or die Dumper \@rows;
242             #if( @errors ) {
243             # warn Dumper \@errors if (0+@errors);
244             #};
245             }
246 0            
247 0 0         # Munge some columns:
248 0 0   0     for (@$issues) {
  0            
  0            
249             my $u = $_->{user};
250             @{ $_ }{qw( user_id user_login user_gravatar_id )}
251             = @{ $u }{qw( id login gravatar_id )};
252              
253             # Squish all structure into JSON, for later
254             for (values %$_) {
255 0     0 0   if( ref($_) ) { $_ = encode_json($_) };
  0            
  0            
  0            
  0            
  0            
256             };
257 0           };
258 0            
259 0           for my $issue (@$issues) {
260 0           #$|=1;
  0            
261             #print sprintf "% 6d %s\r", $issue->{number}, $issue->{updated_at};
262             my @comments = $self->fetch_issue_comments( $issue->{number}, $user => $repo );
263 0            
264 0 0         # Squish all structure into JSON, for later
  0            
265             for (@comments) {
266             for (values %$_) {
267             if( ref($_) ) { $_ = encode_json($_) };
268 0           };
269             };
270             $self->write_data( 'comment' => @comments )
271 0           if @comments;
272             };
273              
274 0           # We wrote the comments first so we will refetch them if there is a problem
275 0           # when writing the issue
276 0 0         $self->write_data( 'issue' => @$issues );
  0            
277             };
278              
279 0 0         =head2 C<< ->fetch_and_store($user, $repo, $since) >>
280              
281             my $since = $gh->last_check;
282             $gh->fetch_and_store($user, $repo, $since)
283              
284             Fetches all issues and comments modified after the C<$since> timestamp.
285 0           If C<$since> is missing or C<undef>, all issues will be retrieved.
286              
287             =cut
288              
289             $user = $self->default_user,
290             $repo = $self->default_repo,
291             $since = undef) {
292             my $dbh = $self->dbh;
293             my $gh = $self->gh;
294              
295             my $can_fetch_more = $self->fetch_additional_pages;
296              
297             FETCH:
298 0           my @issues = $self->fetch_issues( $user => $repo, $since );
299 0           my $has_more = $gh->issue->has_next_page;
300 0           $self->store_issues_comments( $user => $repo, \@issues );
301 0     0 1    
  0            
  0            
302 0           if( $has_more and (!defined($can_fetch_more) or $can_fetch_more-- > 0)) {
303 0           $since = $issues[-1]->{updated_at};
304             goto FETCH;
305 0           }
306             }
307 0            
308             $user = $self->default_user,
309 0           $repo = $self->default_repo,
310 0           @issue_numbers) {
311             my $dbh = $self->dbh;
312 0 0 0       my $gh = $self->gh;
      0        
313 0            
314 0           my @issues = map { scalar $gh->issue->issue($user => $repo, $_) } @issue_numbers;
315             $self->store_issues_comments( $user => $repo, \@issues );
316             }
317              
318 0           for (@fields) {
319 0           $item->{$_} = $item->{$_} ? decode_json( $item->{$_} ) : $item->{$_};
320 0           }
321 0     0 0   }
  0            
  0            
322 0            
323 0           map {
324             $self->inflate_fields( $_, qw(user closed_by));
325 0           $_
  0            
326 0           }
327             @{ $self->dbh->selectall_arrayref(<<'SQL', { Slice => {}}, $since, $since) }
328             select
329 0     0 0   i.id
  0            
  0            
  0            
  0            
330 0           , i.user
331 0 0         , i.html_url
332             , i.body
333             , i.created_at
334             , i.updated_at
335 0     0 0   , i.title as issue_title
  0            
  0            
  0            
336             , i.number as issue_number
337 0           from issue i
338 0           where i.updated_at >= ?
339             union all
340 0           select
  0            
341             c.id
342             , c.user
343             , c.html_url
344             , c.body
345             , c.created_at
346             , c.updated_at
347             , i.title as issue_title
348             , i.number as issue_number
349             from comment c
350             join issue i on c.issue_url=i.url
351             where i.updated_at >= ?
352             order by i.updated_at, html_url
353             SQL
354             }
355              
356             map {
357             $self->inflate_fields( $_, qw(user closed_by));
358             $_
359             }
360             @{ $self->dbh->selectall_arrayref(<<'SQL', { Slice => {}}) }
361             select distinct
362             i.* -- later, expand to explicit list
363             from issue i
364             join comment c on c.issue_url=i.url
365             where c.body like '%```diff%'
366             and i.state = 'open'
367             order by i.url
368             SQL
369 0     0 0   }
  0            
  0            
370              
371 0           $self->dbh->selectall_arrayref(<<'SQL', { Slice => {}}, $issue)->[0]
372 0           select
373             * -- later, expand to explicit list
374 0           from issue i
  0            
375             where i.number = ?
376             order by i.url
377             SQL
378             }
379              
380             @{ $self->dbh->selectall_arrayref(<<'SQL', { Slice => {}}, $issue) }
381             select
382             c.* -- later, expand to explicit list
383             from comment c
384             join issue i on c.issue_url=i.url
385 0     0 0   where i.number = ?
  0            
  0            
  0            
386 0           order by c.url
387             SQL
388             }
389              
390             =head2 C<< ->last_check >>
391              
392             my $since = $gh->last_check;
393              
394             Returns the timestamp of the last stored modification or C<undef>
395 0     0 0   if no issue or comment is stored.
  0            
  0            
  0            
396 0            
  0            
397             =cut
398              
399              
400             $user = $self->default_user,
401             $repo = $self->default_repo ) {
402             my $last = $self->dbh->selectall_arrayref(<<'SQL', { Slice => {} });
403             select
404             max(updated_at) as updated_at
405             from issue
406             SQL
407             if( @$last ) {
408             return $last->[0]->{updated_at}
409             } else {
410             return undef # empty DB
411             }
412             }
413              
414             1;