File Coverage

blib/lib/Net/Gnats/PR.pm
Criterion Covered Total %
statement 205 245 83.6
branch 76 92 82.6
condition 16 19 84.2
subroutine 29 34 85.2
pod 16 18 88.8
total 342 408 83.8


line stmt bran cond sub pod time code
1             package Net::Gnats::PR;
2 40     40   918 use 5.010_000;
  40         116  
  40         1490  
3 40     40   182 use utf8;
  40         59  
  40         288  
4 40     40   779 use strictures;
  40         70  
  40         177  
5              
6             BEGIN {
7 40     40   3238 $Net::Gnats::PR::VERSION = '0.20';
8             }
9 40     40   392 use vars qw($VERSION);
  40         58  
  40         1527  
10              
11 40     40   179 use Carp;
  40         67  
  40         2472  
12 40     40   21457 use MIME::Base64;
  40         25164  
  40         2542  
13 40     40   813 use Net::Gnats::Constants qw(FROM_FIELD REPLYTO_FIELD TO_FIELD CC_FIELD SUBJECT_FIELD SENDPR_VER_FIELD NOTIFY_FIELD);
  40         65  
  40         2941  
14              
15 40     40   784 use Net::Gnats::FieldInstance;
  40         64  
  40         770  
16 40     40   15457 use Net::Gnats::Attachment;
  40         82  
  40         142991  
17              
18             $| = 1;
19             require Exporter;
20             our @ISA = qw(Exporter);
21             our @EXPORT_OK = qw( serialize deserialize parse_line);
22              
23             # TODO: These came from gnatsweb.pl for the parsepr and unparsepr routines.
24             # should be done a better way?
25             my $UNFORMATTED_FIELD = 'Unformatted';
26             my $SYNOPSIS_FIELD = 'Synopsis';
27             my $ORIGINATOR_FIELD = 'Originator';
28             my $attachment_delimiter = "----gnatsweb-attachment----\n";
29             my $SENDINCLUDE = 1; # whether the send command should include the field
30             our $REVISION = '$Id: PR.pm,v 1.8 2014/08/16 23:40:56 thacker Exp $'; #'
31              
32             #******************************************************************************
33             # Sub: new
34             # Description: Constructor
35             # Args: hash (parameter list)
36             # Returns: self
37             #******************************************************************************
38              
39             =head1 CONSTRUCTOR
40              
41             =head2 new
42              
43             The new() constructor does not expect any options. However, this may
44             change in the future when PR initialization is moved from Net::Gnats
45             to this class.
46              
47             my $pr = Net::Gnats::PR->new
48              
49             =cut
50              
51             sub new {
52 18     18 1 56 my ( $class, %options ) = @_;
53 18         62 my $self = bless {}, $class;
54 18         88 $self->{number} = undef;
55 18         38 $self->{fieldlist} = [];
56 18 50       92 return $self if not %options;
57 0         0 return $self;
58             }
59              
60              
61             =head1 ACCESSORS
62              
63             =head2 asHash
64              
65             Returns the PR formatted as a hash. The returned hash contains field
66             names as keys, and the corresponding field values as hash values.
67              
68             CHANGE ALERT: This method now returns all FieldInstance objects.
69              
70             DEPRECATION NOTICE: This accessor will be removed in the near future.
71              
72             =cut
73              
74             sub asHash {
75 0     0 1 0 my ( $self ) = shift;
76 0 0       0 return %{$self->{fields}} if defined($self->{fields}); #XXX Deep copy?
  0         0  
77 0         0 return undef;
78             }
79              
80             =head2 asString
81              
82             Returns the PR object formatted as a Gnats recongizable string. The
83             result is suitable for submitting to Gnats.
84              
85             my $serialized_pr = $pr->asString;
86              
87             DEPRECATION NOTICE: This accessor will be removed in the near future.
88             Instead, use:
89              
90             my $serialized_pr = $pr->serialize;
91              
92             =cut
93              
94             sub asString {
95 7     7 1 448 my $self = shift;
96 7         29 return Net::Gnats::PR->serialize($self,
97             Net::Gnats->current_session->username);
98             }
99              
100             =head2 getField
101              
102             Returns the string value of a PR field.
103              
104             $pr->getField('field');
105              
106             DEPRECATION NOTICE: this will be deprecated in the near future. Use instead:
107              
108             $r->get_field('field')->value
109              
110             =cut
111              
112             sub getField {
113 2     2 1 5 my ( $self, $field ) = @_;
114 2         8 return $self->{fields}->{$field}->value;
115             }
116              
117             =head2 getKeys
118              
119             Returns the list of PR fields contained in the object.
120              
121             =cut
122              
123             sub getKeys {
124 0     0 1 0 return keys %{shift->{fields}};
  0         0  
125             }
126              
127             =head2 getNumber
128              
129             Returns the gnats PR number. In previous versions of gnatsperl the
130             Number field was explicitly known to Net::Gnats::PR. This method
131             remains for backwards compatibility.
132              
133             DEPRECATION NOTICE: this will be deprecated in the near future. Use
134             instead:
135              
136             $r->get_field('Number')->value
137              
138             =cut
139              
140             sub getNumber {
141 1     1 1 7 return shift->{fields}->{'Number'}->value;
142             }
143              
144             =head2 add_field
145              
146             Adds a field instance to the fieldlist of a Nets::Gnats::PR object for fields that are not header fields.
147              
148             =cut
149              
150              
151             sub add_field {
152 222     222 1 212 my ($self, $field) = @_;
153 222         493 $self->{fields}->{$field->name} = $field;
154             # manage the list of fields in order only if it's not a header field.
155 222 100       382 unless (_is_header_field($field->name)) {
156 124         93 push @{ $self->{fieldlist} }, $field->name;
  124         266  
157             }
158 222         329 return;
159             }
160              
161             =head2 get_field
162              
163             Returns a field instance of an Nets::Gnats::PR object if field instance is defined.
164              
165             =cut
166              
167             sub get_field {
168 295     295 1 268 my ($self, $fieldname) = @_;
169 295 100       759 return $self->{fields}->{$fieldname} if defined $self->{fields}->{$fieldname};
170 117         254 return undef;
171             }
172              
173             =head2 get_field_from
174              
175             Return an anonymous array of Nets::Gnats::PR object fields instances from a word part match.
176              
177             =cut
178              
179              
180             sub get_field_from {
181 0     0 1 0 my ( $self, $fieldname) = @_;
182 0         0 my $result = [];
183              
184 0         0 foreach my $field ( sort keys %{ $self->{fields} } ) {
  0         0  
185 0 0       0 push @$result, $field if $field =~ qr/^$fieldname/;
186             }
187              
188 0         0 return $result;
189             }
190              
191             =head2 replaceField
192              
193             Sets a new value for an existing field.
194              
195             If the field requires a Change Reason, and the field does not exist in
196             the PR, then the FieldInstance for the Change Reason is created.
197              
198             Returns 0 if the field does not exist.
199              
200             Returns 0 if the field requires a changeReason, but one was not provided.
201              
202             Returns 0 if the change did not occur successfully.
203              
204             Returns 1 if the field is set and flushed to Gnats.
205              
206             =cut
207              
208             sub replaceField {
209 4     4 1 10 my ($self, $name, $value, $reason_value) = @_;
210 4 50       9 return 0 if not defined $self->get_field($name);
211 4 50       9 return 0 if not $self->setField($name, $value, $reason_value);
212              
213 4         7 my $f = $self->get_field($name);
214              
215 4 100       8 if ($f->schema->requires_change_reason) {
216 2         7 return Net::Gnats
217             ->current_session
218             ->issue(Net::Gnats::Command->repl(pr_number => $self->get_field('Number')->value,
219             field => $f,
220             field_change_reason => $self->get_field($name . '-Changed-Why')))->is_ok;
221             }
222 2         8 return Net::Gnats
223             ->current_session
224             ->issue(Net::Gnats::Command->repl(pr_number => $self->get_field('Number')->value,
225             field => $f))->is_ok;
226             }
227              
228              
229             =head2 setField
230              
231             Sets a Gnats field value. Expects two arguments: the field name
232             followed by the field value. If the field requires a change reason,
233             provide it as a third argument.
234              
235             =cut
236              
237             sub setField {
238 51     51 1 93 my ($self, $name, $value, $reason_value) = @_;
239 51 100       87 return 0 if not defined $self->get_field($name);
240 22         35 my $f = $self->get_field($name);
241              
242 22 100       59 if ($f->schema->requires_change_reason) {
243 5 100       18 return 0 if (not defined $reason_value);
244 4         9 my $cr_instance =
245             Net::Gnats::FieldInstance->new(schema => $f->schema->change_reason_field);
246 4         9 $cr_instance->value($reason_value);
247 4         8 $self->add_field($cr_instance);
248             }
249              
250 21         43 $f->value($value);
251 21         70 return 1;
252             }
253              
254             =head2 submit
255              
256             Submit this PR to Gnats. It uses the currently active session to
257             perform the submit.
258              
259             $pr = $pr->submit;
260              
261             After a successful submit, the PR with the PR Number is returned.
262              
263             say 'My new number is: ' . $pr->get_field('Number')->value;
264              
265             By default, submit will not send a PR which already has a PR Number.
266             If your intent is to create a new PR based on this one, use the force
267             option (may change in the future). This is useful when a series of
268             similar PRs need to be submitted.
269              
270             $pr = $pr->submit(1);
271              
272             If the PR submission based on force was not successful, the PR will
273             return with the same PR Number.
274              
275             =cut
276              
277             sub submit {
278 3     3 1 7 my ($self, $force) = @_;
279              
280 3 100 100     7 return $self if defined $self->get_field('Number') and not defined $force;
281              
282 2         5 my $command = Net::Gnats
283             ->current_session
284             ->issue(Net::Gnats::Command->subm(pr => $self));
285 2 50       7 return $self if $command->is_ok == 0;
286              
287             # the number is in the second response item. This should probably be in SUBM.pm.
288 2         2 my $number = @{ $command->response->as_list }[1];
  2         7  
289 2 100       4 if ( $self->get_field('Number') ) {
290 1         3 $self->get_field('Number')->value($number);
291             }
292             else {
293 1         3 my $field_schema = Net::Gnats->current_session->schema->field('Number');
294 1         3 $self->add_field(Net::Gnats::FieldInstance->new( name => 'Number',
295             value => $number,
296             schema => $field_schema ));
297             }
298 2         18 return $self;
299             }
300              
301             =head2 split_csl
302              
303             Split comma-separated list.
304             Commas in quotes are not separators!
305              
306             =cut
307              
308             sub split_csl {
309 0     0 1 0 my ($list) = @_;
310              
311             # Substitute commas in quotes with \002.
312 0         0 while ($list =~ m~"([^"]*)"~g)
313             {
314 0         0 my $pos = pos($list);
315 0         0 my $str = $1;
316 0         0 $str =~ s~,~\002~g;
317 0         0 $list =~ s~"[^"]*"~"$str"~;
318 0         0 pos($list) = $pos;
319             }
320              
321 0         0 my @res;
322 0         0 foreach my $person (split(/\s*,\s*/, $list))
323             {
324 0         0 $person =~ s/\002/,/g;
325 0 0       0 push(@res, $person) if $person;
326             }
327 0         0 return @res;
328             }
329              
330             =head2 fix_email_addrs
331              
332             Trim email addresses as they appear in an email From or Reply-To
333             header into a comma separated list of just the addresses.
334              
335             Delete everything inside ()'s and outside <>'s, inclusive.
336              
337             =cut
338              
339             sub fix_email_addrs
340             {
341 0     0 1 0 my $addrs = shift;
342 0         0 my @addrs = split_csl ($addrs);
343 0         0 my @trimmed_addrs;
344             my $addr;
345 0         0 foreach $addr (@addrs)
346             {
347 0         0 $addr =~ s/\(.*\)//;
348 0         0 $addr =~ s/.*<(.*)>.*/$1/;
349 0         0 $addr =~ s/^\s+//;
350 0         0 $addr =~ s/\s+$//;
351 0         0 push(@trimmed_addrs, $addr);
352             }
353 0         0 $addrs = join(', ', @trimmed_addrs);
354 0         0 $addrs;
355             }
356              
357             =head2 parse_line
358              
359             Breaks down a Gnats query result.
360              
361             =cut
362              
363             sub parse_line {
364 187     187 1 1081 my ( $line, $known ) = @_;
365 187         199 my $result = [];
366              
367 187 100       261 if (_is_header_line($line)) {
368 90         406 my @found = $line =~ /^([\w\-\{\}]+):\s*(.*)$/;
369 90         233 return \@found;
370             }
371              
372 97         376 my @found = $line =~ /^>([\w\-\{\d\}]+):\s*(.*)$/;
373              
374 97 100       189 if ( not defined $found[0] ) {
375 32         27 @{ $result }[1] = $line;
  32         44  
376 32         64 return $result;
377             }
378              
379 65         122 my $schemaname = _schema_fieldname($found[0]);
380              
381 65         62 my $schema_found = grep { $_ eq $schemaname } @{ $known };
  1450         1352  
  65         103  
382              
383 65 100       137 if ( $schema_found == 0 ) {
384 1         2 @{ $result }[1] = $line;
  1         3  
385 1         6 return $result;
386             }
387              
388 64         63 @{ $result }[0] = $found[0];
  64         118  
389 64         128 $found[1] =~ s/\s+$//;
390 64         64 @{ $result }[1] = $found[1];
  64         83  
391 64         157 return $result;
392             }
393              
394             sub _schema_fieldname {
395 125     125   128 my ( $fieldname ) = @_;
396 125         113 my $schemaname = $fieldname;
397 125         141 $schemaname =~ s/{\d+}$//;
398 125         231 return $schemaname;
399             }
400              
401             sub _schema_fieldinstance {
402 60     60   70 my ( $self, $fieldname ) = @_;
403 60         177 return Net::Gnats->current_session
404             ->schema
405             ->field(_schema_fieldname($fieldname))
406             ->instance( for_name => $fieldname );
407             }
408              
409             sub _clean {
410 195     195   206 my ( $self, $line ) = @_;
411 195 50       287 if ( not defined $line ) { return; }
  0         0  
412              
413 195         658 $line =~ s/\r|\n//gsm;
414             # $line =~ s/^[.][.]/./gsm;
415 195         255 return $line;
416             }
417              
418             sub _is_header_line {
419 331     331   301 my ( $line ) = @_;
420 331 100       280 return 1 if $line =~ /^${\(FROM_FIELD)}:/;
  331         1114  
421 302 100       284 return 1 if $line =~ /^${\(REPLYTO_FIELD)}:/;
  302         836  
422 273 100       245 return 1 if $line =~ /^${\(TO_FIELD)}:/;
  273         740  
423 244 100       211 return 1 if $line =~ /^${\(CC_FIELD)}:/;
  244         688  
424 215 100       194 return 1 if $line =~ /^${\(SUBJECT_FIELD)}:/;
  215         565  
425 186 100       168 return 1 if $line =~ /^${\(SENDPR_VER_FIELD)}:/;
  186         576  
426 157 50       138 return 1 if $line =~ /^${\(NOTIFY_FIELD)}:/;
  157         409  
427 157         399 return 0;
428             }
429              
430             sub _is_header_field {
431 222     222   230 my ( $name ) = @_;
432 222 100       379 return 1 if $name eq FROM_FIELD;
433 208 100       329 return 1 if $name eq REPLYTO_FIELD;
434 194 100       324 return 1 if $name eq TO_FIELD;
435 180 100       301 return 1 if $name eq CC_FIELD;
436 166 100       259 return 1 if $name eq SUBJECT_FIELD;
437 152 100       227 return 1 if $name eq SENDPR_VER_FIELD;
438 138 100       218 return 1 if $name eq NOTIFY_FIELD;
439 124         240 return 0;
440             }
441              
442             sub _is_first_line {
443 144     144   130 my ( $line ) = @_;
444 144 100       132 return 1 if $line =~ /^${\(FROM_FIELD)}:/;
  144         487  
445 130         382 return 0;
446             }
447              
448             =head2 deserialize
449              
450             Deserializes a PR from Gnats and returns a hydrated PR.
451              
452             my $pr = Net::Gnats::PR->deserialize(raw => $c->response->raw,
453             schema => $s->schema);
454              
455             =cut
456              
457             sub setFromString {
458 1     1 0 2 my ($self, $data) = @_;
459             # expects just a block of text, so we need to break it out
460 1         3 $data =~ s/\r//g;
461 1         6 my @lines = split /\n/, $data;
462 1         4 return Net::Gnats::PR->deserialize(data => \@lines,
463             schema => Net::Gnats->current_session->schema);
464             }
465              
466             sub deserialize {
467 14     14 1 63 my ($self, %options) = @_;
468              
469 14         28 my $data = $options{data};
470 14         20 my $schema = $options{schema};
471              
472 14         52 my $pr = Net::Gnats::PR->new();
473 14         18 my $field;
474              
475 14         16 foreach my $line (@{$options{data}}) {
  14         33  
476 195         330 $line = $self->_clean($line);
477 195 100 100     658 next if $line eq '' or $line eq '.';
478              
479 173         162 my ( $name, $content ) = @{ parse_line( $line, $schema->fields ) };
  173         358  
480 173 50 66     738 next if not defined $name and $content eq '';
481              
482 173 100 100     503 if ( defined $name and _is_first_line( $name . ':') ) {
483             # put last PR in array, start new PR
484             }
485              
486 173 100 100     423 if ( defined $name and _is_header_line( $name . ':' ) ) {
487 84         229 $pr->add_field(
488             Net::Gnats::FieldInstance->new( name => $name,
489             value => $content,
490             schema => $schema->field($name)));
491 84         145 next;
492             }
493              
494             # known header field found, save.
495 89 100       141 if ( defined $name ) {
496 60         122 $field = $self->_schema_fieldinstance($name);
497 60         163 $field->value($content);
498 60         93 $pr->add_field($field);
499             }
500             # known header field not found, append to last.
501             else {
502 29         52 $field->value( $field->value . "\n" . $content );
503 29         71 $pr->setField($field);
504             }
505             }
506              
507              
508 14 50       57 $pr->get_field('Reply-To')->value($pr->get_field('From'))
509             if not defined $pr->get_field('Reply-To')->value;
510              
511             # create X-GNATS-Notify if we did not receive it.
512 14 50       35 if (not defined $pr->get_field('X-GNATS-Notify')) {
513 14         53 $pr->add_field(Net::Gnats::FieldInstance->new( name => 'X-GNATS-Notify',
514             value => '' ));
515             }
516              
517             # Create an Unformatted field if it doesn't come in from Gnats.
518 14 100       34 if (not $pr->get_field($UNFORMATTED_FIELD)) {
519 11         32 $pr->add_field(Net::Gnats::FieldInstance->new( name => $UNFORMATTED_FIELD,
520             value => '' ));
521             }
522              
523 14         110 my @attachments = split /$attachment_delimiter/,
524             $pr->get_field($UNFORMATTED_FIELD)->value;
525              
526 14 100       100 return $pr if scalar ( @attachments ) == 0;
527              
528             # First element is any random text which precedes delimited attachments.
529 2         4 $pr->get_field($UNFORMATTED_FIELD)->value( shift @attachments );
530              
531 2         3 foreach my $attachment (@attachments) {
532             # encoded PR always has a space in front of it
533 3         3 push @{$pr->{attachments}},
  3         16  
534             Net::Gnats::Attachment->new( payload => $attachment );
535             }
536              
537 2         7 return $pr;
538             }
539              
540             # unparse -
541             # Turn PR fields hash into a multi-line string.
542             #
543             # The $purpose arg controls how things are done. The possible values
544             # are:
545             # 'gnatsd' - PR will be filed using gnatsd; proper '.' escaping done
546             # 'send' - PR will be field using gnatsd, and is an initial PR.
547             # 'test' - we're being called from the regression tests
548              
549             # What is the user from the session? Need to have user passed for originator.
550             sub serialize {
551 7     7 0 16 my ( $self, $pr, $user ) = @_;
552 7   50     30 my $purpose ||= 'gnatsd';
553 7   50     15 $user ||= 'bugs';
554 7         8 my ( $tmp, $text );
555 7         10 my $debug = 0;
556              
557             # First create or reconstruct the Unformatted field containing the
558             # attachments, if any.
559 7         7 my %fields = %{$pr->{fields}};
  7         73  
560              
561             #if (not defined $pr->get_field('Unformatted')) {
562             # $pr->add_field(Net::Gnats::FieldInstance->new( name => $UNFORMATTED_FIELD,
563             # value => '',
564             # schema => ));
565             #}
566              
567             # deal with attachment later
568             # my $array_ref = $fields{'attachments'};
569             # foreach my $hash_ref (@$array_ref) {
570             # my $attachment_data = $$hash_ref{'original_attachment'};
571             # # Deleted attachments leave empty hashes behind.
572             # next unless defined($attachment_data);
573             # $fields{$UNFORMATTED_FIELD} .= $attachment_delimiter . $attachment_data . "\n";
574             # }
575             # warn "unparsepr 2 =>$fields{$UNFORMATTED_FIELD}<=\n" if $debug;
576              
577             # Headers are necessary because Gnats expects it.
578 7         32 $text .= FROM_FIELD . ': ' . $user . "\n";
579 7         14 $text .= REPLYTO_FIELD . ': ' . $user . "\n";
580 7         12 $text .= TO_FIELD . ': bugs' . "\n";
581 7         8 $text .= CC_FIELD . ': ' . "\n";
582 7         19 $text .= SUBJECT_FIELD . ': ' . $pr->get_field($SYNOPSIS_FIELD)->value . "\n";
583 7         19 $text .= SENDPR_VER_FIELD . ': Net::Gnats ' . $Net::Gnats::VERSION . "\n";
584 7         12 $text .= "\n";
585              
586 7         8 foreach my $fn (@{ $pr->{fieldlist} } ) {
  7         18  
587 68         83 my $field = $pr->get_field($fn);
588             #next if /^.$/;
589             #next if (not defined($fields{$_})); # Don't send fields that aren't defined.
590             # Do include Unformatted field in 'send' operation, even though
591             # it's excluded. We need it to hold the file attachment.
592             # XXX ??? !!! FIXME
593              
594             # if(($purpose eq 'send')
595             # && (! ($self->{__gnatsObj}->getFieldTypeInfo ($_, 'flags') & $SENDINCLUDE))
596             # && ($_ ne $UNFORMATTED_FIELD))
597             # {
598             # next;
599             # }
600              
601 68 100       107 if ($fn eq 'Unformatted') {
602 7         14 next;
603             }
604             # $fields{$_} ||= ''; # Default to empty
605 61 100       94 if ( $field->schema->type eq 'MultiText' ) {
606 14         25 $tmp = $field->value;
607 14         12 $tmp =~ s/\r//;
608 14         14 $tmp =~ s/^[.]/../gm;
609 14         14 chomp($tmp);
610 14         22 $text .= sprintf(">%s:\n%s\n", $field->name, $tmp);
611             }
612             else {
613             # Format string derived from gnats/pr.c.
614 47         75 $text .= sprintf("%-16s %s\n", '>' . $field->name . ':', $field->value);
615             }
616              
617 61 50       107 if ($pr->get_field($field->name . '-Changed-Why')) {
618             # Lines which begin with a '.' need to be escaped by another '.'
619             # if we're feeding it to gnatsd.
620 0         0 $tmp = $pr->get_field($_."-Changed-Why")->value;
621 0         0 $tmp =~ s/^[.]/../gm;
622 0         0 $text .= sprintf(">%s-Changed-Why:\n%s\n", $field->name, $tmp);
623             }
624             }
625 7         20 $text =~ s/\r//;
626 7         52 return $text;
627             }
628              
629              
630             # preloaded methods go here.
631              
632             # Autoload methods go after =cut, and are processed by the autosplit program.
633              
634             1;
635             __END__