diff --git a/MANIFEST b/MANIFEST index 0440eb8..3146d64 100644 --- a/MANIFEST +++ b/MANIFEST @@ -6,6 +6,7 @@ lib/Bot/BasicBot/Pluggable/Module/GitHub.pm lib/Bot/BasicBot/Pluggable/Module/GitHub/Announce.pm lib/Bot/BasicBot/Pluggable/Module/GitHub/EasyLinks.pm lib/Bot/BasicBot/Pluggable/Module/GitHub/PullRequests.pm +lib/Bot/BasicBot/Pluggable/Module/GitHub/IssueSearch.pm t/00-load.t t/01-parse-config.t t/manifest.t diff --git a/Makefile.PL b/Makefile.PL index 1df03a3..21dff26 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -14,11 +14,12 @@ WriteMakefile( PREREQ_PM => { 'Test::More' => 0, 'Bot::BasicBot::Pluggable::Module' => 0, - 'Net::GitHub::V2' => 0, + 'Net::GitHub::V3' => 0, 'YAML' => 0, 'LWP::Simple' => 0, 'JSON' => 0, 'URI::Title' => 0, + 'Mojo::UserAgent' => 0, }, dist => { COMPRESS => 'gzip -9f', SUFFIX => 'gz', }, clean => { FILES => 'Bot-BasicBot-Pluggable-Module-GitHub-*' }, diff --git a/README b/README index 1e50601..2acdf39 100644 --- a/README +++ b/README @@ -10,7 +10,7 @@ DESCRIPTION interest to you. They're already in use on the Dancer project's IRC channel, and internally at my workplace, UK2. - Most communication with GitHub uses Net::GitHub::V2, and can use + Most communication with GitHub uses Net::GitHub::V3, and can use authentication with an API key for private repositories. MODULES diff --git a/lib/Bot/BasicBot/Pluggable/Module/GitHub.pm b/lib/Bot/BasicBot/Pluggable/Module/GitHub.pm index 22e2452..42df2e9 100644 --- a/lib/Bot/BasicBot/Pluggable/Module/GitHub.pm +++ b/lib/Bot/BasicBot/Pluggable/Module/GitHub.pm @@ -9,7 +9,8 @@ use base 'Bot::BasicBot::Pluggable::Module'; # want. use strict; -use Net::GitHub::V2; +use Net::GitHub::V3; +use Mojo::UserAgent; our $VERSION = '0.04'; @@ -37,26 +38,91 @@ sub ng { } return unless $user && $project; + my $id = "$user/$project"; # If we've already got a suitable Net::GitHub::V2 object, use it: - if (my $ng = $net_github{"$user/$project"}) { + if (my $ng = $net_github{$id}) { return $ng; } # Right - assemble the params we need to give to Net::GitHub::V2 my %ngparams = ( - owner => $user, - repo => $project, +# owner => $user, +# repo => $project, ); # If authentication is needed, add that in too: if (my $auth = $self->auth_for_project("$user/$project")) { - my ($user, $token) = split /:/, $auth, 2; - $ngparams{login} = $user; - $ngparams{token} = $token; - $ngparams{always_Authorization} = 1; +# my ($user, $token) = split /:/, $auth, 2; +# $ngparams{login} = $user; +# $ngparams{token} = $token; +# $ngparams{always_Authorization} = 1; + $ngparams{access_token} = $auth; } - return $net_github{"$user/$project"} = Net::GitHub::V2->new(%ngparams); + my $endpoint; + if ($user =~ /:/) { + ($endpoint, $user) = split /:/, $user, 2; + } + if ($endpoint) { + $ngparams{api_url} = "https://$endpoint/api/v1"; + } + my $api = Net::GitHub::V3->new(%ngparams); + $api->set_default_user_repo($user, $project); + return $net_github{$id} = $api; +} + +my $ua; + +sub _make_ua { + return $ua if $ua; + $ua = Mojo::UserAgent->new; + $ua->proxy->detect; + return $ua; +} + +my %_commit_branch_cache; +my $_commit_branch_cache_timeout = 60 * 60; + +sub _commit_branch { + my ($self, $pr, $commit_id) = @_; + _make_ua(); + my $base_url = $pr->{html_url}; + $base_url =~ s{/(pull|commit)/.*}{}; + my $url = "$base_url/branch_commits/$commit_id"; + my $tag; + my $time = time; + if ($_commit_branch_cache{$url} && ($_commit_branch_cache{$url}{_time} + $_commit_branch_cache_timeout) > $time) { + $tag = $_commit_branch_cache{$url}{tag}; + } else { + warn "getting: $url"; + my $res = $ua->get("$url")->res; + return "" unless $res; + my @tags = ((grep !m{[-/]}, $res->dom("ul.branches-list li.branch a")->map("text")->each), + (map s/~/-/r, grep !/-/, map s/-(pre\d*|an)/~$1/gr, $res->dom("ul.branches-tag-list li a")->map("text")->each)); + #my @tags = grep !/-/, $res->dom("ul li a")->map("text")->each; + if (@tags) { + $tag = $tags[-1]; + $_commit_branch_cache{$url} = { _time => $time, tag => $tag }; + } + } + return "T:\cB$tag\cB " if length $tag; + return ""; +} + +sub _pr_branch { + my ($self, $ng, $pr) = @_; + my $issue_number = $pr->{number}; + my $ie = $ng->issue; + my $commit_id; + # while (my $event = $ie->next_event($issue_number)) + for my $event ($ie->events($issue_number)) { + if ($event->{event} eq 'merged') { + $commit_id = $event->{commit_id}; + last; + } + } + return '' unless length $commit_id; + return $self->_commit_branch($pr, $commit_id); } @@ -98,6 +164,10 @@ sub channels_and_projects { return \%project_for_channel; } +sub help { + "github module; configuration: !listgithubproject, !setgithubproject [authtok], !delgithubproject , !delgithubauth " +} + # Support configuring project details for a channel (potentially with auth # details) via a msg. This is a bit too tricky to just leave the Vars module to # handle, I think. (Note that each of the modules which inherit from us will @@ -137,7 +207,55 @@ sub said { } return $message; - } elsif ($mess->{body} =~ /^!setgithubproject/i) { + } elsif ($mess->{body} =~ m{ + ^!delgithubproject \s+ + (? \#\S+ ) \s+ + (? \S+ ) + }xi) { + my $project_for_channel = + $self->store->get('GitHub','project_for_channel') || {}; + my $message = 'project not found'; + if ($project_for_channel->{$+{channel}} eq $+{project}) { + delete $project_for_channel->{$+{channel}}; + $message = "OK, project for $+{channel} deleted"; + } + $self->store->set( + 'GitHub', 'project_for_channel', $project_for_channel + ); + return $message; + } elsif ($mess->{body} =~ m{ + ^!delgithubauth \s+ + (? \S+ ) + }xi) { + my $auth_for_project = + $self->store->get('GitHub', 'auth_for_project') || {}; + my $message = 'project not found'; + if ($auth_for_project->{$+{project}}) { + delete $auth_for_project->{$+{project}}; + $message = "OK, auth for $+{project} deleted"; + } + $self->store->set( + 'GitHub', 'auth_for_project', $auth_for_project + ); + + # Invalidate any cached Net::GitHub object we might have, so the new + # settings are used + delete $net_github{$+{project}}; + return $message; + } elsif ($mess->{body} =~ m{^!listgithubproject(?:\s+|$)}xi) { + my $project_for_channel = + $self->store->get('GitHub','project_for_channel') || {}; + my $auth_for_project = + $self->store->get('GitHub', 'auth_for_project') || {}; + my $message; + for my $c (sort keys %$project_for_channel) { + $message .= "$c: $project_for_channel->{$c}\n"; + } + if (%$auth_for_project) { + $message .= "*: ".(join " ", sort keys %$auth_for_project)."\n"; + } + return $message || "no projects"; + } elsif ($mess->{body} =~ /^!(set|del|list)github(project|auth)/i) { return "Invalid usage. Try '!help github'"; } return; diff --git a/lib/Bot/BasicBot/Pluggable/Module/GitHub/Announce.pm b/lib/Bot/BasicBot/Pluggable/Module/GitHub/Announce.pm index 9e4efc8..4bd3cd6 100644 --- a/lib/Bot/BasicBot/Pluggable/Module/GitHub/Announce.pm +++ b/lib/Bot/BasicBot/Pluggable/Module/GitHub/Announce.pm @@ -8,118 +8,369 @@ use strict; use Bot::BasicBot::Pluggable::Module::GitHub; use base 'Bot::BasicBot::Pluggable::Module::GitHub'; use JSON; +use YAML qw(LoadFile DumpFile); +use Try::Tiny; +use Bot::BasicBot::Pluggable::MiscUtils qw(util_dehi util_strip_codes); our $VERSION = 0.02; - + sub help { return < [flags], !delgithubannounce HELPMSG } +my %issues_cache; +my %issues_lu; +my $old_issues_cache; + +sub init { + my $self = shift; + $self->config({'user_poll_issues_interval' => 0}); +} + +sub _map_issue { + map { +{ + state => $_->{state}, + id => $_->{id}, + title => $_->{title}, + url => $_->{html_url}, + type => ($_->{pull_request} ? 'pull' : 'issue'), + by => $_->{user}{login}, + number => $_->{number}, + updated_at => $_->{updated_at}, + } } @_ +} + +sub _all_issues { + my ($ng, $args) = @_; + my @issues = $ng->issue->repos_issues($args); + my $page = 1; + while ($ng->issue->has_next_page) { + push @issues, $ng->issue->next_page; + $page++; + } + warn ".. @{[scalar @issues]} issues, $page pages" + if $page > 1; + @issues +} + +sub _merge_issues { + my ($new, $old) = @_; + my %idmap = (map { ($_->{id} => 1) } @$new); + ( @$new, + (grep { !$idmap{ $_->{id} } } @{ $old || [] }) + ) +} + +sub _update_issues { + my ($prjcache, $prjlu, $new) = @_; + $prjcache->{_issues} = [_merge_issues($new, $prjcache->{_issues})]; + %{ $prjlu } = ( map { ($_->{number} => $_) } @{ $prjcache->{_issues} } ); +} sub tick { my $self = shift; - my $issue_state_file = 'last-issues-state.json'; - - my $seconds_between_checks = $self->get('poll_issues_interval') || 60 * 5; + my $seconds_between_checks = 60 * ( $self->get('user_poll_issues_interval') || 5 ); return if time - $self->get('last_issues_poll') < $seconds_between_checks; $self->set('last_issues_poll', time); - # Grab details of the issues we know about already: - # Have to handle storing & loading old issue state myself - I don't know - # why, but the bot storage doesn't want to work for this. - open my $fh, '<', $issue_state_file - or die "Failed to open $issue_state_file - $!"; - my $json; - { local $/; $json = <$fh> } - close $fh; - my $seen_issues = $json ? JSON::from_json($json) : {}; + my $announce_for_channel = + $self->store->get('GitHub','announce_for_channel') || {}; + my $conf = $self->store->get('GitHub', 'announce_config_flags') || +{}; + my %projects; + for my $channel (keys %$announce_for_channel) { + for my $project (@{ $announce_for_channel->{$channel} || [] }) { + $projects{$project} = 1; + } + } + unless ($old_issues_cache) { + if (-f "gh_announce_issues_cache.yml") { + ($old_issues_cache) = LoadFile("gh_announce_issues_cache.yml"); + } + else { + $old_issues_cache = {}; + } + } + for my $project (sort keys %projects) { + unless ($issues_cache{$project}) { + warn "Loading issues of $project initially.."; + my $ng = $self->ng($project) or next; + $issues_cache{$project} = delete $old_issues_cache->{$project}; + $issues_cache{$project}{__time__} = time; + delete $issues_cache{$project}{_lu}; + my $prjcache = $issues_cache{$project}; + my $prjlu = $issues_lu{$project} ||= +{}; + my $since = @{$prjcache->{_issues} || []} ? + $prjcache->{_issues}[0]{updated_at} : ''; + warn ".. " . (scalar @{$prjcache->{_issues} || []}) . " cached.." . ($since ? " since $since .." : ''); + my @issues = _map_issue(_all_issues($ng, +{ state => 'all', sort => 'updated', ($since ? (since => "$since") : ()) })); + _update_issues($prjcache, $prjlu, \@issues); + my $heads = $ng->git_data->ref('heads'); + for my $head (@{$heads||[]}) { + my $ref = $head->{ref}; + $ref =~ s{^refs/heads/}{}; + $prjcache->{'__heads__'}{$ref} = $head->{object}{sha}; + } + delete $projects{$project}; + } + } + DumpFile("gh_announce_issues_cache.yml", \%issues_cache); + my %messages; + for my $project (sort keys %projects) { + next unless $issues_cache{$project}; + my $ng = $self->ng($project) or next; + my $prjcache = $issues_cache{$project}; + my $prjlu = $issues_lu{$project} ||= +{}; + my $since = @{$prjcache->{_issues}} ? + $prjcache->{_issues}[0]{updated_at} : ''; + my @issues = _map_issue(_all_issues($ng, +{ state => 'all', sort => 'updated', ($since ? (since => "$since") : ()) })); + my %notifications; + for my $issue (@issues) { + my $type; + my $details = +{ state => $issue->{state}, by => $issue->{by} }; + if (my $existing = $prjlu->{ $issue->{number} }) { + if ($existing->{state} eq $issue->{state}) { + # no change + } + elsif ($issue->{state} eq 'closed') { + # It was open before, but isn't in the list now - it must have + # been closed. + my $by; + my $state = $issue->{state}; + if ($issue->{type} eq 'pull' ) { + my $pr = $ng->pull_request->pull($issue->{number}); + if ($pr->{merged}) { + $state = 'merged'; + $by = $pr->{merged_by}{login}; + } + } + unless (defined $by) { + my $issue = $ng->issue->issue($issue->{number}); + $by = $issue->{closed_by}{login}; + } + $details = +{ state => $state, by => $by }; + $type = $state; + } + elsif ($issue->{state} eq 'open') { + # It was closed before, but is now in the open feed, so it's + # been re-opened + $type = 'reopened'; + } + } + elsif ($issue->{state} eq 'open') { + # A new issue we haven't seen before + $type = 'opened'; + } + + if ($type) { + $prjcache->{_details}{ $issue->{number} } = $details; + push @{ $notifications{$type} }, + [ $issue->{number}, $issue, $details ]; + } + } + _update_issues($prjcache, $prjlu, \@issues); + my @push_not; + my $heads = $ng->git_data->ref('heads'); + for my $head (@{$heads||[]}) { + my $ref = $head->{ref}; + $ref =~ s{^refs/heads/}{}; + my $sha = $head->{object}{sha}; + my $ex = $prjcache->{'__heads__'}{$ref}; + if ($ex ne $sha) { + my $commit = $ng->git_data->commit($sha); + my $ignore; + my $re = $conf->{$project}{ignore_branches_re}; + if ($re) { + $ignore ||= $ref =~ /$re/; + } + unless ($ignore) { + if ($commit && !exists $commit->{error}) { + my $title = ( split /\n+/, $commit->{message} )[0]; + my $url = $commit->{html_url}; + push @push_not, [ + $ref, + ($commit->{author}{login}||$commit->{committer}{login}||$commit->{author}{name}||$commit->{committer}{name}), + $title, + $project, + $url + ]; + } + else { + push @push_not, [$ref,$sha]; + } + } + $prjcache->{'__heads__'}{$ref} = $sha; + } + } + $messages{$project} = +{ notifications => \%notifications, + push_not => \@push_not }; + if (%notifications || @push_not) { + warn "Loading issues of $project " . ($since ? "since $since" : ''); + } + } + DumpFile("gh_announce_issues_cache.yml", \%issues_cache); + # try { # OK, for each channel, pull details of all issues from the API, and look # for changes - my $channels_and_projects = $self->channels_and_projects; - channel: - for my $channel (keys %$channels_and_projects) { - my $project = $channels_and_projects->{$channel}; - my %notifications; - warn "Looking for issues for $project for $channel"; - - my $ng = $self->ng($project) or next channel; - - my $issues = $ng->issue->list('open'); - - # Go through all currently-open issues and look for new/reopened ones - for my $issue (@$issues) { - my $issuenum = $issue->{number}; - my $details = { - title => $issue->{title}, - url => $issue->{html_url}, - created_by => $issue->{user}, - }; - - if (my $existing = $seen_issues->{$project}{$issuenum}) { - if ($existing->{state} eq 'closed') { - # It was closed before, but is now in the open feed, so it's - # been re-opened - push @{ $notifications{reopened} }, - [ $issuenum, $details ]; - $existing->{state} = 'open'; - } - } else { - # A new issue we haven't seen before - push @{ $notifications{opened} }, - [ $issuenum, $details ]; - $seen_issues->{$project}{$issuenum} = { - state => 'open', - details => $details, - }; - } - } - - # Now, go through ones we already know about - if we knew about them, - # and they were open, but weren't in the list of open issues we fetched - # above, they must now be closed - for my $issuenum (keys %{ $seen_issues->{$project} }) { - my $existing = $seen_issues->{$project}{$issuenum}; - my $current = grep { - $_->{number} == $issuenum - } @$issues; - - if ($existing->{state} eq 'open' && !$current) { - # It was open before, but isn't in the list now - it must have - # been closed. - push @{ $notifications{closed} }, - [ $issuenum, $existing->{details} ]; - $existing->{state} = 'closed'; - } - } - - # Announce any changes - for my $type (keys %notifications) { - my $s = scalar $notifications{$type} > 1 ? 's':''; - - $self->say( - channel => $channel, - body => "Issue$s $type : " - . join ', ', map { - sprintf "%d (%s) by %s : %s", - $_->[0], # issue number - @{$_->[1]}{qw(title created_by url)} - } @{ $notifications{$type} } - ); - } + channel: + for my $netchannel (keys %$announce_for_channel) { + my $channel = $netchannel; + my $network; + if ($channel =~ s{^(\w+)/}{}) { + $network = $1; + } + next if !$network && $self->bot->can('MULTINET') && $self->bot->MULTINET; + local $self->bot->{conn_tag} = $network + if $network; + + my $dfltproject = $self->github_project($channel) || ''; + my $dfltuser = $dfltproject && $dfltproject =~ m{^([^/]+)} ? $1 : ''; + project: + for my $project (@{ $announce_for_channel->{$netchannel} || [] }) { + my @bots = grep /^Not-/, $self->bot->pocoirc->channel_list($channel); + @bots = grep { $_ ne $self->bot->nick } @bots; + my %notifications = %{$messages{$project}{notifications} || +{}}; + my @push_not = @{$messages{$project}{push_not} || []}; + if (%notifications || @push_not) { + warn "Looking for issues for $project for $netchannel"; warn "`bots: @bots" if @bots; + } + + my $in = $project eq $dfltproject ? '' : $project =~ m{^\Q$dfltuser\E/(.*)$} ? " in $1" : " in $project"; + # Announce any changes + for my $type (qw(opened reopened merged closed)) { + next unless $notifications{$type}; + my $s = scalar @{$notifications{$type}} > 1 ? 's':''; + my %nt = (issue => "\cC52Issue", pull => "\cC29Pull request"); + my %tc = ('closed' => "\cC55", 'opened' => "\cC52", reopened => "\cC52", merged => "\cC46"); + for my $t (qw(issue pull)) { + my @not = grep { $_->[1]{type} eq $t } @{ $notifications{$type} }; + my @message = ( + channel => $channel, + body => "$nt{$t}$s\cC $tc{$type}$type\cC$in: " + . join ', ', map { + sprintf "\cC43%d\cC (\cC59%s\cC) by \cB%s\cB: \cC73%s\cC", + $_->[0], # issue number + $_->[1]{title}, + util_dehi($_->[2]{by}), + $self->linkshortener->shorten($_->[1]{url}) + } @not + ); + warn "msg: $message[1]: ".util_strip_codes($message[3]) if @not; + $self->say(@message) if @not && !@bots; + } + } + + if (@push_not) { + my $in = $project eq $dfltproject ? '' : $project =~ m{^\Q$dfltuser\E/(.*)$} ? " in $1" : " in $project"; + my @message = ( + channel => $channel, + body => "New commits$in " . join ', ', map { + @$_>2 ? ( + sprintf "on branch %s by \cB%s\cB - \cC59%s\cC: \cC73%s\cC", + $_->[0], util_dehi($_->[1]), $_->[2], $self->linkshortener->shorten('https://github.com/'.$_->[3].'/tree/'.$_->[0])) + : sprintf 'branch %s now at %s', @{$_}[0,1] + } @push_not + ); + warn "msg: $message[1]: ".util_strip_codes($message[3]); + $self->say(@message) unless @bots || $notifications{merged} || $conf->{$project}{no_heads}; + } + } } + #} + # catch { + # warn "Exception: " . s/(\sat\s\/.*\sline\s\d+).*/$1/grs; + # } - my $store_json = JSON::to_json($seen_issues); - # Store the updated issue details: - open my $storefh, '>', $issue_state_file - or die "Failed to write to $issue_state_file - $!"; - print {$storefh} $store_json; - close $storefh; return; } + +# Support configuring project details for a channel (potentially with auth +# details) via a msg. This is a bit too tricky to just leave the Vars module to +# handle, I think. (Note that each of the modules which inherit from us will +# get this method; one of them will catch it and handle it.) +sub said { + my ($self, $mess, $pri) = @_; + return unless $pri == 2; + return unless $mess->{address} eq 'msg'; + + if ($mess->{body} =~ m{ + ^!(? add | del )githubannounce \s+ + (? (?:\w+/)?\#\S+ ) \s+ + (? \S+ ) (?:\s+ + (? .* ))? + }xi) { + my $announce_for_channel = + $self->store->get('GitHub','announce_for_channel') || {}; + my $conf = $self->store->get('GitHub', 'announce_config_flags') || +{}; + my @projects = @{ $announce_for_channel->{$+{channel}} || [] }; + if (lc $+{action} eq 'del') { + @projects = grep { lc $_ ne lc $+{project} } @projects; + } + else { + unless (grep { lc $_ eq lc $+{project} } @projects) { + push @projects, $+{project}; + } + } + $announce_for_channel->{$+{channel}} = \@projects; + $self->store->set( + 'GitHub', 'announce_for_channel', $announce_for_channel + ); + if ($+{action} eq 'add' && $+{flags}) { + my $flags = " $+{flags}"; + my $project = "\L$+{project}"; + while ($flags =~ /\s-(\w+)=(?:(["'])(.*?)\2|(\S+))(?=\s|$)/g) { + my $key = lc $1; + my $val = $3 || $4; + if ($val) { + $conf->{$project}{$key} = $val; + } + else { + delete $conf->{$project}{$key}; + } + } + delete $conf->{$project} + unless %{ $conf->{$project} }; + } + delete $conf->{''}; + $self->store->set( + 'GitHub', 'announce_config_flags', $conf + ); + + return "OK, $+{action}ed $+{project} for $+{channel}."; + + } + elsif ($mess->{body} =~ /^!listgithubannounce\s*$/) { + my $var = $self->store->get('GitHub', 'announce_for_channel') || +{}; + my $conf = $self->store->get('GitHub', 'announce_config_flags') || +{}; + my $ret = ''; + for my $ch (sort keys %$var) { + if (@{$var->{$ch}}) { + $ret .= $ch . ': ' . join ", ", map { + my $ret = $_; + my $proj = $_; + if ($conf->{$proj}) { + $ret .= ' ' . join ' ', + map { "-$_=".$conf->{$proj}{$_} } + grep { $conf->{$proj}{$_} } + sort keys %{$conf->{$proj}}; + } + $ret + } sort @{$var->{$ch}}; + $ret .= "\n"; + } + } + return $ret; + } + elsif ($mess->{body} =~ /^!(?:add|del|list)githubannounce/i) { + return "Invalid usage. Try 'help github::announce'"; + } + return; +} + +1; diff --git a/lib/Bot/BasicBot/Pluggable/Module/GitHub/EasyLinks.pm b/lib/Bot/BasicBot/Pluggable/Module/GitHub/EasyLinks.pm index a90b62b..ffd91fe 100644 --- a/lib/Bot/BasicBot/Pluggable/Module/GitHub/EasyLinks.pm +++ b/lib/Bot/BasicBot/Pluggable/Module/GitHub/EasyLinks.pm @@ -8,57 +8,241 @@ use strict; use Bot::BasicBot::Pluggable::Module::GitHub; use base 'Bot::BasicBot::Pluggable::Module::GitHub'; use URI::Title; +use List::Util qw(min max); +use Mojo::DOM; +use Mojo::JSON qw(from_json); sub help { return <{ $msg } ||= +{}; + + my $now = time; + + my $user_time = $mr->{$who} || 0; + my $other_time = $mr->{'@'} || 0; + + $mr->{'@'} = $mr->{$who} = $now; + + my $block = $user_time > $now - $mass_stop_user || $other_time > $now - $mass_stop_other; + warn "blocking " . substr(_strip_codes($msg),0,14) . ".." . max($now-$user_time,$now-$other_time) if $block; + !$block -HELPMSG } +sub _expire_mass_blocks { + my $now = time; + for my $ch (keys %mass_blocker) { + for my $msg (keys %{ $mass_blocker{$ch} }) { + if ($mass_blocker{ $ch }{ $msg }{'@'} < $now - $mass_stop_user) { + delete $mass_blocker{$ch}{$msg}; + } + } + } +} sub said { my ($self, $mess, $pri) = @_; return unless $pri == 2; + # return if $mess->{body} =~ m{://git}; + + # do not react to other bots, identified by /^Not-/ + return if $mess->{who} =~ /^Not-/; # Loop through matching things in the message body, assembling quick links # ready to return. my @return; + unless ($mess->{body} =~ m{://git}) { match: while ($mess->{body} =~ m{ (?: \b # "Issue 42", "PR 42" or "Pull Request 42" - (? (?:issue|gh|pr|pull request) ) + (? (?:issue|pr|pull request) ) (?:\s+|-)? + \#? (? \d+) + | + (?:^|\s+) + \# + (? (?!999)\d{3,}) | # Or a commit SHA - (? [0-9a-f]{6,}) + (? [0-9a-f]{6,40}) ) # Possibly with a specific project repo ("user/repo") appeneded - (?: \s* \@ \s* (? \S+/\S+) )? + (?: (?: \s* \@ \s* | \s+ in \s+ ) (? \S+/?\S+) )? + \b }gxi ) { + # First, extract what kind of thing we're looking at, and normalise it a + # little, then go on to handle it. + my $thing = $+{thing}; + my $thingnum = $+{num}; + + if ($+{sha}) { + $thing = 'commit'; + $thingnum = $+{sha}; + } elsif ($+{hnum}) { + $thing = 'issue'; + $thingnum = $+{hnum}; + } my $project = $+{project} || $self->github_project($mess->{channel}); + if ($project !~ m{/}) { + if ($self->github_project($mess->{channel}) =~ m{^(.*?)/} ) { + $project = "$1/$project"; + } else { + return; + } + } return unless $project; # Get the Net::GitHub::V2 object we'll be using. (If we don't get one, # for some reason, we can't do anything useful.) my $ng = $self->ng($project) or return; - # First, extract what kind of thing we're looking at, and normalise it a - # little, then go on to handle it. + + warn "OK, about to try to handle $thing $thingnum for $project"; + + # Right, handle it in the approriate way + if ($thing =~ /Issue|GH|pr|pull request/i) { + warn "Handling issue $thingnum"; + my $issue = $ng->issue->issue($thingnum); + my $pr; + if (!exists $issue->{error} && $issue->{pull_request}) { + $pr = $ng->pull_request->pull($thingnum); + if (exists $pr->{error}) { + $pr = undef; + } + } + if (exists $issue->{error}) { + push @return, $issue->{error}; + next match; + } + push @return, sprintf "%s \cC43%d\cC (\cC59%s\cC) by \cB%s\cB - \cC73%s\cC%s %s\{%s\cC\}", + (exists $issue->{pull_request} ? "\cC29Pull request" : "\cC52Issue"), + $thingnum, + $issue->{title}, + _dehih($issue->{user}{login}), + $self->linkshortener->shorten($issue->{html_url}), + ($issue->{labels}&&@{$issue->{labels}}?" [".(join",",map{$_->{name}}@{$issue->{labels}})."]":""), + ($issue->{milestone} ? "MS:\cB$issue->{milestone}{title}\cB ": ($pr?$self->_pr_branch($ng, $pr):"")), +$pr&&$pr->{merged_at}?"\cC46merged on ".($pr->{merged_at}=~s/T.*//r): +$issue->{closed_at}?"\cC55closed on ".($issue->{closed_at}=~s/T.*//r):"\cC52".$issue->{state}." since ".($issue->{created_at}=~s/T.*//r); + } + + # Similarly, pull requests: +# if ($thing =~ /(?:pr|pull request)/i) { +# warn "Handling pull request $thingnum"; +# # TODO: send a pull request to add support for fetching details of +# # pull requests to Net::GitHub::V2, so we can handle PRs on private +# # repos appropriately. +# my $pull_url = "https://github.com/$project/pull/$thingnum"; +# my $title = URI::Title::title($pull_url); +# push @return, "Pull request $thingnum ($title) - $pull_url"; +# } + + # If it was a commit: + if ($thing eq 'commit') { + warn "Handling commit $thingnum"; +# my $commit = $ng->git_data->commit($thingnum); + local $@; + my $commit = eval { $ng->repos->commit($thingnum) }; + if (!$commit && $@) { + $commit = +{ error => $@ }; + } + if ($commit->{commit}) { + if ($commit->{html_url}) { + $commit->{commit}{html_url} = $commit->{html_url}; + } + $commit->{commit}{sha} //= $commit->{sha}; + $commit = $commit->{commit}; + } + if ($commit && !exists $commit->{error}) { + my $title = ( split /\n+/, $commit->{message} )[0]; + my $url = $commit->{html_url}; + + # Currently, the URL given doesn't include the host, but that + # might perhaps change in future, so play it safe: +# $url = "https://github.com$url" unless $url =~ /^http/; +# $url =~ s{https://api.github.com/repos/(.*?)/commits/}{https://github.com/$1/commit/}; + push @return, sprintf "Commit \cC43$thingnum\cC (\cC59%s\cC) by \cB%s\cB on %s - \cC73%s\cC %s", + $title, + _dehih($commit->{author}{login}||$commit->{committer}{login}||$commit->{author}{name}||$commit->{committer}{name}), + ($commit->{author}{date}=~s/T.*//r), + $self->linkshortener->shorten($url), + $self->_commit_branch($commit, $commit->{sha}), + ; + } else { + # We purposefully don't show a message on IRC here, as we guess + # what might be a SHA, so we could be annoying saying that we + # didn't match a commit when someone said a word that just + # happened to look like it could be the start of a SHA. + warn "No commit details for $thingnum \@ $project/$thingnum" . ($commit && ref $commit ? ": $commit->{error}" : ""); + } + } + } + } + + unless (@return) { + match: + while ($mess->{body} =~ m{ + \b + https?://github.com/(? \S+/\S+)/ + (?: + (? (?: issues|pull ))/ + (? \d+) + (?: + (?: + (?: + /commits/ (? [0-9a-f]{6,40}) + | + /files + ) + (?: [?] [^#\s] +)? (? [#] diff\S* )? + ) + | + (?
/? [#] \S* ) + )? + | + commit/ (? [0-9a-f]{6,40}) + (?: [?] [^#\s]+ )? (? [#] diff\S* )? + | + (? blob)/ + (? [^?#\s] +) + (?: [?] [^#\s]+ )? (? [#] L\S* ) + ) + \b + }gxi + ) { my $thing = $+{thing}; my $thingnum = $+{num}; @@ -67,47 +251,176 @@ sub said { $thingnum = $+{sha}; } + my $project = $+{project}; + return unless $project; + + # Get the Net::GitHub::V2 object we'll be using. (If we don't get one, + # for some reason, we can't do anything useful.) + my $ng = $self->ng($project) or return; + my $stop = $+{stop}; + my $details = $+{details}; + $details =~ s/.*[#]// if $details; + + warn "OK, about to try to handle $thing $thingnum for $project"; + # link to lines inside a blob/diff + if ($stop) { + return unless $stop =~ s/([LR])(\d+)(?:-\1(\d+))?$//; + my ($lr, $line, $line2) = ($1, $2, $3); + next unless $line; + $line2 = $line unless defined $line2; + my $req = ($thing eq 'commit' || $thing eq 'blob') + ? HTTP::Request->new( GET => "https://github.com/$project/$thing/$thingnum") + : HTTP::Request->new( GET => "https://github.com/$project/$thing/$thingnum/files") ; + $req->accept_decodable; + my $res = $ng->_make_request($req); + my $dom = Mojo::DOM->new($res->decoded_content); + my @lines = map { + map { + my $x = $_; + $x->descendant_nodes->grep(sub{ $_->type ne "text" })->map('strip'); + $x->all_text(0) + } $dom->find("$stop$lr$_")->map('parent')->map('find', '.blob-code-inner')->flatten->each + } $line..$line2; + unless (@lines) { + # see if it has lines present as embedded json + eval { + my $es = $dom->at('script[data-target="react-app.embeddedData"]')->text; + my $js = from_json($es); + if ($js->{payload}{blob}{rawLines}) { + @lines = $js->{payload}{blob}{rawLines}->@[ ($line-1)..($line2-1) ]; + } + 1; } || warn "no embedded json\n"; + } + unless (@lines) { + # see if it has github fragment + eval { + my $frag = $dom->at('include-fragment[src*="/diffs?"]')->{src}; + die "no absolute path" unless $frag =~ /^\//; + my $res = $ng->_make_request(HTTP::Request->new( GET => "https://github.com$frag" )); + my $dom = Mojo::DOM->new($res->decoded_content); + @lines = map { + map { + my $x = $_; + $x->descendant_nodes->grep(sub{ $_->type ne "text" })->map('strip'); + $x->all_text(0) + } $dom->find("$stop$lr$_")->map('parent')->map('find', '.blob-code-inner')->flatten->each + } $line..$line2; + 1; } || warn "no github fragment\n"; + } + if (@lines) { + my $tag = "$lr$line"; + my $ret = $lines[0]; + if ($line2 > $line) { + $tag .= "-$line2"; + $ret = join ' ', map { /^\s*(.*?)\s*$/ ? $1 : $_ } @lines; + if (length $ret > 400) { + (substr $ret, 290 - 3 - 3 - length $tag) = '...'; + } + } + push @return, "$tag: $ret"; + } + next; + } + + # link to a issue comment + elsif ($details) { + next unless $details =~ /issue(comment)?-(\d+)$/; + my ($comment, $id) = ($1, $2); + warn "Handling issue $thingnum $details"; + my $issue = $ng->issue->issue($thingnum); + if (exists $issue->{error}) { + push @return, $issue->{error}; + next match; + } + + my $stitle = $issue->{title}; + if (length $stitle > 25) { + (substr $stitle, 23) = '...'; + } + my ($pre_ret, $suff_ret); + my @lines; + # issue text + unless ($comment) { + next unless $id eq $issue->{id}; + @lines = split /\R/, $issue->{body}; + $pre_ret = sprintf "%s \cC43%d\cC (%s) by \cB%s\cB -\cC ", + (exists $issue->{pull_request} ? "Pull request" : "Issue"), + $thingnum, + $stitle, + _dehih($issue->{user}{login}); + $suff_ret = ""; + } + else { + my $comment = $ng->issue->comment($id); + if (exists $comment->{error}) { + push @return, $comment->{error}; + next match; + } + @lines = split /\R/, $comment->{body}; + $pre_ret = sprintf "%s \cC43%d\cC (%s) comment by \cB%s\cB -\cC ", + (exists $issue->{pull_request} ? "Pull request" : "Issue"), + $thingnum, + $stitle, + _dehih($comment->{user}{login}); + $suff_ret = " \cBon\cB ".($comment->{created_at}=~s/T.*//r); + } + while (@lines && $lines[0] =~ /^>/) { + shift @lines; + } + my $text = join ' ', map { /^\s*(.*?)\s*$/ ? $1 : $_ } @lines; + my $maxlen = 290 - (length $pre_ret) - (length $suff_ret); + $text =~ s{\b(https?://github\.com/\S+)}{$self->linkshortener->shorten($1)}ge; + if (length $text > $maxlen) { + (substr $text, $maxlen - 3) = '...'; + } + $text =~ s{\w+://\S+\.\.\.$}{...}; + push @return, "$pre_ret$text$suff_ret"; + } + # Right, handle it in the approriate way - if ($thing =~ /Issue|GH/i) { + elsif ($thing ne 'commit') { warn "Handling issue $thingnum"; - my $issue = $ng->issue->view($thingnum); + my $issue = $ng->issue->issue($thingnum); + my $pr; + if (!exists $issue->{error} && $issue->{pull_request}) { + $pr = $ng->pull_request->pull($thingnum); + if (exists $pr->{error}) { + $pr = undef; + } + } if (exists $issue->{error}) { push @return, $issue->{error}; next match; } - push @return, sprintf "Issue %d (%s) - %s", + push @return, sprintf "%s \cC43%d\cC (\cC59%s\cC) by \cB%s\cB - \cC73%s\cC%s %s\{%s\cC\}", + (exists $issue->{pull_request} ? "\cC29Pull request" : "\cC52Issue"), $thingnum, $issue->{title}, - $issue->{html_url}; - } - - # Similarly, pull requests: - if ($thing =~ /(?:pr|pull request)/i) { - warn "Handling pull request $thingnum"; - # TODO: send a pull request to add support for fetching details of - # pull requests to Net::GitHub::V2, so we can handle PRs on private - # repos appropriately. - my $pull_url = "https://github.com/$project/pull/$thingnum"; - my $title = URI::Title::title($pull_url); - push @return, "Pull request $thingnum ($title) - $pull_url"; + _dehih($issue->{user}{login}), + $project, + ($issue->{labels}&&@{$issue->{labels}}?" [".(join",",map{$_->{name}}@{$issue->{labels}})."]":""), + ($issue->{milestone} ? "MS:\cB$issue->{milestone}{title}\cB ": ($pr?$self->_pr_branch($ng, $pr):"")), +$pr&&$pr->{merged_at}?"\cC46merged on ".($pr->{merged_at}=~s/T.*//r): +$issue->{closed_at}?"\cC55closed on ".($issue->{closed_at}=~s/T.*//r):"\cC52".$issue->{state}." since ".($issue->{created_at}=~s/T.*//r); } # If it was a commit: - if ($thing eq 'commit') { + elsif ($thing eq 'commit') { warn "Handling commit $thingnum"; - my $commit = $ng->commit->show($thingnum); + my $commit = $ng->git_data->commit($thingnum); if ($commit && !exists $commit->{error}) { my $title = ( split /\n+/, $commit->{message} )[0]; - my $url = $commit->{url}; + my $url = $commit->{html_url}; - # Currently, the URL given doesn't include the host, but that - # might perhaps change in future, so play it safe: - $url = "https://github.com$url" unless $url =~ /^http/; - push @return, sprintf "Commit $thingnum (%s) - %s", + push @return, sprintf "Commit \cC43$thingnum\cC (\cC59%s\cC) by \cB%s\cB on %s - \cC73%s\cC %s", $title, - $url; + _dehih($commit->{author}{login}||$commit->{committer}{login}||$commit->{author}{name}||$commit->{committer}{name}), + ($commit->{author}{date}=~s/T.*//r), + $project, + $self->_commit_branch($commit, $commit->{sha}), + ; } else { # We purposefully don't show a message on IRC here, as we guess # what might be a SHA, so we could be annoying saying that we @@ -117,7 +430,10 @@ sub said { } } } + } + @return = grep { _check_mass($mess->{who}, $mess->{channel}, $_) } @return; + _expire_mass_blocks(); return join "\n", @return; } diff --git a/lib/Bot/BasicBot/Pluggable/Module/GitHub/IssueSearch.pm b/lib/Bot/BasicBot/Pluggable/Module/GitHub/IssueSearch.pm new file mode 100644 index 0000000..64fe84c --- /dev/null +++ b/lib/Bot/BasicBot/Pluggable/Module/GitHub/IssueSearch.pm @@ -0,0 +1,128 @@ +package Bot::BasicBot::Pluggable::Module::GitHub::IssueSearch; +use strict; +use Bot::BasicBot::Pluggable::Module::GitHub; +use base 'Bot::BasicBot::Pluggable::Module::GitHub'; +use LWP::Simple (); +use List::Util 'min'; +use JSON; + +sub help { + return < query [in ] +HELPMSG +} + +sub _dehih { + my $r = shift; + $r =~ s/^(.)(.*)$/$1\cB\cB$2/g; + $r +} + +sub said { + my ($self, $mess, $pri) = @_; + + return unless $pri == 2; + + return if $mess->{who} =~ /^Not-/; + return if $mess->{body} =~ m{://git}; + my $body = $mess->{body}; + + my $readdress = $mess->{channel} ne 'msg' && $body =~ s/\s+@\s+(\S+)[.]?\s*$// ? $1 : ''; + + if ($body =~ /^find \s+ + (?: (? \d+) \s+ )? + (?:(? open | closed | merged ) \s+)? + (? issue | pr | pull \s+ request )s? \s+ + (?: (?: with | matching ) \s+ )? + (? \S+ (?:\s+\S+)* ) /xi) { + my $realcount = $+{count} || 1; + my $count = min 3, $realcount; + my $expr = $+{expr}; + my $type = $+{type}; + my $status = $+{status}; + $type = 'pr' if $type =~ /^p/i; + my $project; + if ($expr =~ s/(?: ^ | \s+ ) in \s+ (? \S+) \s* $//xi) { + $project = $+{project}; + } + $project ||= $self->github_project($mess->{channel}); + $expr = "is:\L$status\E $expr" if $status; + $expr =~ s{ (?:^|\s) \K ( -? ) \[ ( .+? ) \] (?=\s|$) }{ + join ' ', map { + $1 . 'label:' . ($_ =~ /\s/ ? qq{"$_"} : $_) + } split ',', $2 + }gex; + my $orig_expr = $expr; + my $search_type = 'issues'; + if ('pr' eq lc $type && $expr !~ /\b is:pr \b/xi) { + $expr = "is:pr $expr"; + $search_type = 'pulls'; + } + if ($project !~ m{/}) { + if ($self->github_project($mess->{channel}) =~ m{^(.*?)/} ) { + $project = "$1/$project"; + } else { + return; + } + } + return unless $project; + my $ng = $self->ng($project) or return; + warn "sending search repo:$project $expr"; + my $res = $ng->search->issues({q => "repo:$project $expr"}); + unless ($res && $res->{items}) { + warn "no result for query $expr."; + return; + } + my @ret; + while ($count-- && @{$res->{items}}) { + my $issue = shift @{$res->{items}}; + my $pr; + if (!exists $issue->{error} && $issue->{pull_request}) { + $pr = $ng->pull_request->pull($issue->{number}); + if (exists $pr->{error}) { + $pr = undef; + } + } + if (exists $issue->{error}) { + push @ret, $issue->{error}; + next; + } + push @ret, sprintf "%s \cC43%d\cC (\cC59%s\cC) by \cB%s\cB - \cC73%s\cC%s %s\{%s\cC\}", + (exists $issue->{pull_request} ? "\cC29Pull request" : "\cC52Issue"), + $issue->{number}, + $issue->{title}, + _dehih($issue->{user}{login}), + $self->linkshortener->shorten($issue->{html_url}), + ($issue->{labels}&&@{$issue->{labels}}?" [".(join",",map{$_->{name}}@{$issue->{labels}})."]":""), + ($issue->{milestone} ? "MS:\cB$issue->{milestone}{title}\cB ": ($pr?$self->_pr_branch($ng, $pr):"")), +$pr&&$pr->{merged_at}?"\cC46merged on ".($pr->{merged_at}=~s/T.*//r): +$issue->{closed_at}?"\cC55closed on ".($issue->{closed_at}=~s/T.*//r):"\cC52".$issue->{state}." since ".($issue->{created_at}=~s/T.*//r); + } + if (@ret) { + my $info; + my $sen; + if (@{$res->{items}}) { + $sen = "and \cB" . ($res->{total_count}-@ret) . "\cB more: " . $self->linkshortener->shorten("https://github.com/$project/$search_type?q=".($orig_expr=~y/ /+/r)); + } + if (@ret > 1) { + $info = join "\n", "\c_Issues matching\c_" . ($sen ? " ( $sen )" : ""), @ret; + } else { + $info = join ' ', "\c_Matching issue:\c_", @ret, $sen; + } + if ($readdress) { + my %hash = %$mess; + $hash{who} = $readdress; + $hash{address} = 1; + $self->reply(\%hash, $info); + return 1; + } + return $info; + } else { + return "Nothing found..."; + } + } + return; +} + +1; +