File Coverage

blib/lib/Bot/BasicBot/Pluggable/Module/JIRA.pm
Criterion Covered Total %
statement 8 10 80.0
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 12 14 85.7


line stmt bran cond sub pod time code
1             package Bot::BasicBot::Pluggable::Module::JIRA;
2             BEGIN {
3 1     1   24203 $Bot::BasicBot::Pluggable::Module::JIRA::VERSION = '0.03';
4             }
5              
6 1     1   10 use warnings;
  1         2  
  1         31  
7 1     1   6 use strict;
  1         2  
  1         69  
8              
9             =head1 NAME
10              
11             Bot::BasicBot::Pluggable::Module::JIRA - Access JIRA via IRC!
12              
13             =head1 VERSION
14              
15             This POD describes version 0.02
16              
17             =cut
18              
19             =head1 SYNOPSIS
20              
21             You will need to load the module into your instance:
22              
23             $bot->load('JIRA');
24              
25             Then feel free to interrogate the shit out of her:
26              
27             <diz> purl: what's going on with PRJ-284?
28             <purl> diz: PRJ-284 (add jira support to bot) is unresolved - http://jira.domain.com/issue/284
29             <diz> purl: who fixed PRJ-284?
30             <purl> diz: gphat marked PRJ-284 as fixed in changeset bbb6a0f9
31              
32             =head1 CONFIGURATION
33              
34             =head2 uri
35              
36             The location of the JIRA instance. (Ex, http://jira.domain.com)
37              
38             =head2 username
39              
40             User to authenticate against JIRA with. This is configured in JIRA.
41             Note that the user will need permissions to view issues in order to
42             send getIssue requests over the SOAP interface.
43              
44             =head2 password
45              
46             The password to use for the user during authentication. Also setup in
47             JIRA (or LDAP if you're using the LDAP linkage).
48              
49             =head2 db_dsn (optional)
50              
51             =head2 db_user (optional)
52              
53             =head2 db_pass (optional)
54              
55             The SOAP API in JIRA is hit or miss. It exposes functionality that
56             I'll never use while omitting other functionality that makes the API
57             seem downright useless.
58              
59             One of the particular egregious omissions happens to be the lack of a
60             remote interface for retrieving the ChangeHistory for an issue. If,
61             however, you configure db_dsn, db_user, and db_pass, this module will
62             attempt to extract that information directly from the database. This
63             isn't recommended and has only been tested with 4.1.2.
64              
65             =head2 status_verbs
66              
67             The default inquiry format reports issue status by referring to the
68             last status change in the past tense. By default, the reply uses
69             "changed to" prepended to the status name. Instead of this, you may
70             want the bot to respond using more colloquial language. You may do
71             this by setting status_verbs to a hashref mapping status names to
72             status verb phrases. (ie, { Open => 'opened' }) Any missing pairs
73             default to "changed to".
74              
75             =head2 status_colors
76              
77             This one is fun. You may map issue status names to individual IRC
78             colors by setting status_colors to a hashref containing the map. The
79             following colors are understood:
80              
81             =over 2
82              
83             =item * bold
84              
85             =item * white
86              
87             =item * black
88              
89             =item * blue
90              
91             =item * green
92              
93             =item * red
94              
95             =item * brown
96              
97             =item * purple
98              
99             =item * orange
100              
101             =item * yellow
102              
103             =item * light_green
104              
105             =item * teal
106              
107             =item * cyan
108              
109             =item * light_blue
110              
111             =item * pink
112              
113             =item * gray
114              
115             =back
116              
117             =head2 formats
118              
119             Inquiry responses may be custom formatted. The formats configuration
120             item is expected to be a hashref containing key/value pairs for each
121             class of inquiry. The format is rendered using Text::Xslate. Several
122             convienient xslate functions have also been provided. In addition to
123             a function for each of the individual color names above, the following
124             functions are available:
125              
126             =over 2
127              
128             =item * colorize
129              
130             colors the given text according to the configured status_colors
131              
132             =back
133              
134             The RemoteIssue object returned by JIRA::CLient is the root context of
135             the template rendering, with the following keys added to the hashref:
136              
137             =over 2
138              
139             =item * issue (shortcut for key)
140              
141             =item * version (shortcut for the first fixVersion listed)
142              
143             =item * status_name (the issue's current status)
144              
145             =item * status_verb (the issue's current status in the past tense)
146              
147             =back
148              
149             If you've enabled the DBI options above, these are also available to
150             you:
151              
152             =over 2
153              
154             =item * status_last_changed_user (user that most recently changed the issue status)
155              
156             =item * status_last_changed_datetime (DateTime object for the most recent status change)
157              
158             =back
159              
160             More details on the RemoteIssue object may be found in the JIRA docs:
161              
162             http://docs.atlassian.com/rpc-jira-plugin/4.1-1/com/atlassian/jira/rpc/soap/beans/RemoteIssue.html
163              
164             Most all accessors methods on the RemoteIssue object are available in
165             the stash as keys of the hashref. For example, getAssignee may be
166             accessed in xslate like:
167              
168             <: $assignee :>
169              
170             The getComponents method, which returns a list of components, may be
171             accessed using similar perl/xslate idioms:
172              
173             <: $components.0 :>
174              
175             The default inquiry format is:
176              
177             <: colorize($issue) :> [<: $version :>] <: bold($summary) :> for <: $assignee :>
178              
179             which produces replies such as:
180              
181             <purl> PRJ-284 [Unscheduled] add jira to bot for diz
182              
183             The default status format is:
184              
185             <: $issue :> [<: $version :>] was <: $status_verb :> by <: $status_last_changed_user :> on <: $status_last_changed_datetime.strftime("%Y %b %d (%a) at %l:%M %P") :>
186              
187             which produces replies such as:
188              
189             <purl> PRJ-284 [Unscheduled] was closed by diz on 2010 Sep 13 (Mon) at 1:26 pm
190              
191             =cut
192              
193 1     1   472 use Moose;
  0            
  0            
194             use MooseX::Traits;
195              
196             use POE;
197             use Try::Tiny;
198              
199             use DateTime;
200             use DateTime::Format::MySQL;
201             use Lingua::StopWords::EN;
202             use JIRA::Client;
203             use Text::Xslate;
204              
205             #use Data::Dump qw(dd pp);
206              
207             #$Data::Dump::INDENT = '';
208              
209             extends 'Bot::BasicBot::Pluggable::Module';
210              
211             has log =>
212             is => 'ro',
213             isa => 'Log::Log4perl::Logger',
214             lazy => 1,
215             default => sub { Log::Log4perl->get_logger(__PACKAGE__) };
216              
217             has xslate =>
218             is => 'ro',
219             isa => 'Text::Xslate',
220             lazy_build => 1;
221              
222             has client =>
223             is => 'ro',
224             lazy_build => 1;
225              
226             has dbh =>
227             is => 'rw',
228             isa => 'Maybe[DBI::db]';
229              
230             has sths =>
231             is => 'rw',
232             isa => 'HashRef[DBI::st]';
233              
234             has projects =>
235             is => 'ro',
236             isa => 'ArrayRef[RemoteProject]',
237             lazy_build => 1;
238              
239             has project_keys =>
240             is => 'ro',
241             isa => 'ArrayRef[Str]',
242             lazy_build => 1;
243              
244             has context =>
245             is => 'rw',
246             isa => 'RemoteIssue';
247              
248             has statuses =>
249             is => 'ro',
250             isa => 'HashRef',
251             lazy_build => 1,
252             traits => [ 'Hash' ],
253             handles => { get_status_name => 'get', get_statuses => 'values' };
254              
255             has status_verbs =>
256             is => 'ro',
257             isa => 'HashRef',
258             lazy_build => 1,
259             traits => [ 'Hash' ],
260             handles => { get_verb_for_status => 'get' };
261              
262             has status_colors =>
263             is => 'ro',
264             isa => 'HashRef',
265             lazy_build => 1,
266             traits => [ 'Hash' ],
267             handles => { get_color_for_status => 'get' };
268              
269             has formats =>
270             is => 'ro',
271             isa => 'HashRef',
272             lazy_build => 1,
273             traits => [ 'Hash' ],
274             handles => { get_format_for_inquiry => 'get' };
275              
276             has stopwords =>
277             is => 'ro',
278             isa => 'HashRef',
279             lazy => 1,
280             default => sub { Lingua::StopWords::EN->getStopWords };
281              
282             has regex =>
283             is => 'ro',
284             lazy_build => 1;
285              
286             has handlers =>
287             is => 'ro',
288             isa => 'ArrayRef',
289             traits => [ 'Array' ],
290             lazy => 1,
291             default => sub { [] },
292             handles => { add_handler => 'push' };
293              
294             sub _build_xslate
295             {
296             my $self = shift;
297              
298             my $colorizers =
299             {
300             bold => sub { "$_[0]" },
301             white => sub { "0$_[0]" },
302             black => sub { "1$_[0]" },
303             blue => sub { "2$_[0]" },
304             green => sub { "3$_[0]" },
305             red => sub { "4$_[0]" },
306             brown => sub { "5$_[0]" },
307             purple => sub { "6$_[0]" },
308             orange => sub { "7$_[0]" },
309             yellow => sub { "8$_[0]" },
310             light_green => sub { "9$_[0]" },
311             teal => sub { "10$_[0]" },
312             cyan => sub { "11$_[0]" },
313             light_blue => sub { "12$_[0]" },
314             pink => sub { "13$_[0]" },
315             gray => sub { "14$_[0]" },
316             };
317              
318             new Text::Xslate
319             function =>
320             {
321             %$colorizers,
322              
323             colorize => sub {
324             my $status = $self->get_status_name($self->context->{status});
325             my $color = $self->get_color_for_status($status) || '';
326             my $colorizer = $colorizers->{$color};
327              
328             return $colorizer ? $colorizer->($_[0]) : $_[0];
329             }
330             };
331             }
332              
333             sub _build_client
334             {
335             my $self = shift;
336              
337             # we have to use the Store directly since Moose already
338             # defines a get method for us.
339              
340             my $uri = $self->store->get(JIRA => 'uri');
341             my $user = $self->store->get(JIRA => 'username');
342             my $pass = $self->store->get(JIRA => 'password');
343             my $client = undef;
344              
345             $self->log->warn('missing configuration item "uri"') if not $uri;
346             $self->log->warn('missing configuration item "username"') if not $user;
347             $self->log->warn('missing configuration item "password"') if not $pass;
348              
349             return undef unless $uri and $user and $pass;
350              
351             my $meta = Class::MOP::Class->initialize('JIRA::Client');
352              
353             $meta->add_around_method_modifier(AUTOLOAD => sub {
354             my $next = shift;
355             my $self = shift;
356             my @args = @_;
357             my $res = undef;
358              
359             try {
360             $res = $self->$next(@args);
361             } catch {
362             if (/RemoteAuthenticationException/) {
363             my $auth = $self->{soap}->login($user, $pass);
364              
365             die $auth->faultcode . ': ' . $auth->faultstring
366             if defined $auth->fault;
367              
368             $self->{auth} = $auth->result;
369             $res = $self->$next(@args);
370             } else {
371             die $_;
372             }
373             };
374              
375             return $res;
376             });
377              
378             return new JIRA::Client $uri, $user, $pass;
379             }
380              
381             sub _build_projects
382             {
383             my $self = shift;
384              
385             return $self->client
386             ? $self->client->getProjectsNoSchemes
387             : [];
388             }
389              
390             sub _build_project_keys
391             {
392             my $self = shift;
393              
394             my @keys = map { $_->{key} } @{ $self->projects };
395              
396             return \@keys;
397             }
398              
399             sub _build_statuses
400             {
401             my $self = shift;
402              
403             my $statuses = { map { $_->{id} => $_->{name} } @{ $self->client->getStatuses } };
404              
405             #foreach my $status (values %$statuses) {
406             # $status =~ s/under quality review/submitted for QA/;
407             # $status =~ s/under technical review/submitted for review/;
408             #}
409              
410             return $statuses;
411             }
412              
413             sub _build_status_verbs
414             {
415             my $self = shift;
416              
417             my $verbs = $self->store->get(JIRA => 'status_verbs');
418              
419             $verbs = {} unless ref($verbs) eq 'HASH';
420              
421             foreach my $status ($self->get_statuses) {
422             $verbs->{$status} ||= "changed to $status";
423             }
424              
425             return $verbs;
426             }
427              
428             sub _build_status_colors
429             {
430             my $self = shift;
431              
432             my $colors = $self->store->get(JIRA => 'status_colors');
433              
434             $colors = {} unless ref($colors) eq 'HASH';
435              
436             return $colors;
437             }
438              
439             sub _build_formats
440             {
441             my $self = shift;
442              
443             my $formats = $self->store->get(JIRA => 'formats');
444              
445             $formats = {} unless ref($formats) eq 'HASH';
446              
447             return
448             {
449             default => '<: colorize($issue) :> [<: $version :>] <: bold($summary) :> for <: $assignee :>',
450             status => '<: $issue :> [<: $version :>] was <: $status_verb :> by <: $status_last_changed_user :> on <: $status_last_changed_datetime.strftime("%Y %b %d (%a) at %l:%M %P") :>',
451             %$formats
452             #my $date = $dt->strftime('%Y %b %d (%a)');
453             #my $time = $dt->strftime('%l:%M %P');
454             }
455             }
456              
457             sub _build_regex
458             {
459             my $self = shift;
460              
461             my $keys = join '|', @{ $self->project_keys };
462             my $re = qr/([a-zA-Z]*)\s*((?:$keys)-[0-9]+)/;
463              
464             return $re;
465             }
466              
467             sub init
468             {
469             my $self = shift;
470              
471             $self->init_dbh;
472              
473             $self->add_handler([ qr/fix(ed|es)/ => \&inquiry_fixed ]);
474             $self->add_handler([ qr/closed/ => \&inquiry_closed ]);
475             $self->add_handler([ qr/opened|reported/ => \&inquiry_reporter ]);
476             $self->add_handler([ qr/status/ => \&inquiry_status ]);
477             #$self->add_handler([ qr/details/ => \&inquiry_details ]);
478             $self->add_handler([ qr/.?/ => \&inquiry_default ]);
479             }
480              
481             sub init_dbh
482             {
483             my $self = shift;
484              
485             my $dsn = $self->store->get(JIRA => 'db_dsn');
486             my $user = $self->store->get(JIRA => 'db_user');
487             my $pass = $self->store->get(JIRA => 'db_pass');
488              
489             return unless $dsn and $user and $pass;
490              
491             $self->dbh(DBI->connect($dsn, $user, $pass));
492              
493             $self->sths({
494             last_status_modification_info => $self->dbh->prepare('SELECT cg.created, cg.author FROM changegroup cg JOIN changeitem ci ON ci.groupid=cg.ID JOIN jiraissue ji ON ji.id=cg.issueid WHERE ji.pkey=? AND ci.field="status" ORDER BY cg.created DESC LIMIT 1')
495             });
496              
497             foreach my $name (keys %{ $self->sths }) {
498             my $coderef = sub {
499             my $self = shift;
500             my $issue = shift;
501              
502             $self->sths->{$name}->execute($issue);
503             $self->sths->{$name}->fetch;
504             };
505              
506             $self->meta->add_method("get_$name" => $coderef);
507             }
508             }
509              
510             sub inquiry_fixed
511             {
512             }
513              
514             sub inquiry_closed
515             {
516             }
517              
518             sub inquiry_reporter
519             {
520             }
521              
522             sub inquiry_details
523             {
524             my $self = shift;
525             my $issue = shift;
526             my $callback = shift;
527              
528             # IDEA: using the stopword filter, pull out text at
529             # random out of the description and display it. kinda
530             # neat. multiple calls will return different things,
531             # exposing information about the ticket without flooding
532             # the channel
533              
534             my $href = $self->client->getIssue($issue);
535              
536             return unless defined $href;
537              
538             my $ver = $href->{fixVersions}->[0] ? $href->{fixVersions}->[0]->{name} : '';
539             my $details = '';
540              
541             if ($href->{description} < 80) {
542             $details = $href->{description};
543             } else {
544             my @tokens = grep { not exists $self->stopwords->{$_} }
545             split /\s+/, $href->{description};
546             my %keywords = map { $tokens[int rand $#tokens] => 1 } (1 .. 10);
547              
548             $details = join ', ', keys %keywords;
549             }
550              
551             $callback->("$issue [$ver] details: $details");
552             }
553              
554             sub inquiry_status
555             {
556             my $self = shift;
557             my $key = shift;
558             my $callback = shift;
559              
560             my $issue = $self->get_issue($key);
561              
562             return unless defined $issue;
563              
564             #my $status = $self->get_status_name($issue->{status});
565             #my $color = $self->get_color_for_status($status);
566             #my $verb = $self->get_verb_for_status($status);
567             #my $aref = $self->get_last_status_modification_info($issue);
568             #my $dt = DateTime::Format::MySQL->parse_datetime($aref->[0]);
569             #my $user = $aref->[1];
570             #my $date = $dt->strftime('%Y %b %d (%a)');
571             #my $time = $dt->strftime('%l:%M %P');
572              
573             #$time =~ s/^ //;
574              
575             $callback->($self->render(status => $issue));
576              
577             #$callback->("$issue [$ver] was $sverb by $user on $date at $time");
578             }
579              
580             sub get_issue
581             {
582             my $self = shift;
583             my $key = shift;
584              
585             my $issue = $self->client->getIssue($key);
586              
587             if ($issue) {
588             my $status = $self->get_status_name($issue->{status});
589             my $color = $self->get_color_for_status($status);
590              
591             $issue->{issue} = $issue->{key};
592             $issue->{status_name} = $status;
593             $issue->{status_verb} = $self->get_verb_for_status($status);
594              
595             if ($self->meta->has_method('get_last_status_modification_info')) {
596             if (my $aref = $self->get_last_status_modification_info($key)) {
597             my $dt = DateTime::Format::MySQL->parse_datetime($aref->[0]);
598             my $user = $aref->[1];
599              
600             $issue->{status_last_changed_user} = $aref->[1];
601             $issue->{status_last_changed_datetime} = $dt;
602             }
603             }
604              
605             $issue->{version} = $issue->{fixVersions}->[0]
606             ? $issue->{fixVersions}->[0]->{name}
607             : 'Unscheduled';
608              
609             $self->context($issue);
610             }
611              
612             return $issue;
613             }
614              
615             sub inquiry_default
616             {
617             my $self = shift;
618             my $key = shift;
619             my $callback = shift;
620              
621             my $issue = $self->get_issue($key);
622              
623             return unless defined $issue;
624             #dd $issue;
625              
626             #my $msg = $self->xslate->render_string($self->get_format_for_inquiry('default'), $issue);
627              
628             $callback->($self->render(default => $issue));
629             }
630              
631             sub render
632             {
633             my $self = shift;
634             my $format = shift;
635             my $issue = shift;
636              
637             return $self->xslate->render_string($self->get_format_for_inquiry($format), $issue);
638             }
639              
640             sub said
641             {
642             my $self = shift;
643             my $msg = shift;
644             my $pri = shift;
645              
646             #dd { priority => $pri, %$msg};
647              
648             return if $pri == 0;
649              
650             #$self->log->info("said(pri => $pri)");
651              
652             my $re = $self->regex;
653             my @tokens = grep { not exists $self->stopwords->{$_} } split /\s+/, $msg->{body};
654             my $body = join ' ', @tokens;
655              
656             my @response = ();
657             my $callback = sub { push @response, $_[0] ? $_[0] : () };
658              
659             # strip shit out of the message
660             #$body =~ s/\?//g;
661              
662             if (my @pairs = ($body =~ /$re/g)) {
663             #$callback->('match');
664              
665             while (my ($inquiry, $issue) = splice @pairs, 0, 2) {
666             my $handler = [ grep { $inquiry =~ $_->[0] } @{ $self->handlers } ]->[0]->[1];
667              
668             $handler->($self, $issue, $callback) if $handler and $self->client;
669             }
670             }
671              
672             $self->say(%$msg, body => $_) foreach @response[0..$#response-1];
673              
674             return $response[$#response];
675             }
676              
677             =head1 BUGS
678              
679             Probably a lot of them. The test suite does nothing beyond what
680             Module::Starter already provides.
681              
682             =head1 AUTHOR
683              
684             Mike Eldridge, C<< <diz at cpan.org> >>
685              
686             =head1 LICENSE AND COPYRIGHT
687              
688             Copyright 2010 Mike Eldridge
689              
690             This program is free software; you can redistribute it and/or modify it
691             under the terms of either: the GNU General Public License as published
692             by the Free Software Foundation; or the Artistic License.
693              
694             See http://dev.perl.org/licenses/ for more information.
695              
696             =head1 SEE ALSO
697              
698             =over 2
699              
700             =item * L<Bot::BasicBot::Pluggable>
701              
702             =item * L<JIRA::Client>
703              
704             =back
705              
706             =cut
707              
708             1;
709