]> Dogcows Code - chaz/git-codeowners/blob - lib/File/Codeowners.pm
add update_owner method to File::Codeowners
[chaz/git-codeowners] / lib / File / Codeowners.pm
1 package File::Codeowners;
2 # ABSTRACT: Read and write CODEOWNERS files
3
4 use v5.10.1; # defined-or
5 use warnings;
6 use strict;
7
8 use Encode qw(encode);
9 use Path::Tiny 0.089;
10 use Scalar::Util qw(openhandle);
11 use Text::Gitignore qw(build_gitignore_matcher);
12
13 our $VERSION = '9999.999'; # VERSION
14
15 sub _croak { require Carp; Carp::croak(@_); }
16 sub _usage { _croak("Usage: @_\n") }
17
18 =method new
19
20 $codeowners = File::Codeowners->new;
21
22 Construct a new L<File::Codeowners>.
23
24 =cut
25
26 sub new {
27 my $class = shift;
28 my $self = bless {}, $class;
29 }
30
31 =method parse
32
33 $codeowners = File::Codeowners->parse('path/to/CODEOWNERS');
34 $codeowners = File::Codeowners->parse($filehandle);
35 $codeowners = File::Codeowners->parse(\@lines);
36 $codeowners = File::Codeowners->parse(\$string);
37
38 Parse a F<CODEOWNERS> file.
39
40 This is a shortcut for the C<parse_from_*> methods.
41
42 =cut
43
44 sub parse {
45 my $self = shift;
46 my $input = shift or _usage(q{$codeowners->parse($input)});
47
48 return $self->parse_from_array($input, @_) if @_;
49 return $self->parse_from_array($input) if ref($input) eq 'ARRAY';
50 return $self->parse_from_string($input) if ref($input) eq 'SCALAR';
51 return $self->parse_from_fh($input) if openhandle($input);
52 return $self->parse_from_filepath($input);
53 }
54
55 =method parse_from_filepath
56
57 $codeowners = File::Codeowners->parse_from_filepath('path/to/CODEOWNERS');
58
59 Parse a F<CODEOWNERS> file from the filesystem.
60
61 =cut
62
63 sub parse_from_filepath {
64 my $self = shift;
65 my $path = shift or _usage(q{$codeowners->parse_from_filepath($filepath)});
66
67 $self = bless({}, $self) if !ref($self);
68
69 return $self->parse_from_fh(path($path)->openr_utf8);
70 }
71
72 =method parse_from_fh
73
74 $codeowners = File::Codeowners->parse_from_fh($filehandle);
75
76 Parse a F<CODEOWNERS> file from an open filehandle.
77
78 =cut
79
80 sub parse_from_fh {
81 my $self = shift;
82 my $fh = shift or _usage(q{$codeowners->parse_from_fh($fh)});
83
84 $self = bless({}, $self) if !ref($self);
85
86 my @lines;
87
88 my $parse_unowned;
89 my %unowned;
90 my $current_project;
91
92 while (my $line = <$fh>) {
93 my $lineno = $. - 1;
94 chomp $line;
95 if ($line eq '### UNOWNED (File::Codeowners)') {
96 $parse_unowned++;
97 last;
98 }
99 elsif ($line =~ /^\h*#(.*)/) {
100 my $comment = $1;
101 my $project;
102 if ($comment =~ /^\h*Project:\h*(.+?)\h*$/i) {
103 $project = $current_project = $1 || undef;
104 }
105 $lines[$lineno] = {
106 comment => $comment,
107 $project ? (project => $project) : (),
108 };
109 }
110 elsif ($line =~ /^\h*$/) {
111 # blank line
112 }
113 elsif ($line =~ /^\h*(.+?)(?<!\\)\h+(.+)/) {
114 my $pattern = $1;
115 my @owners = $2 =~ /( (?:\@+"[^"]*") | (?:\H+) )/gx;
116 $lines[$lineno] = {
117 pattern => $pattern,
118 owners => \@owners,
119 $current_project ? (project => $current_project) : (),
120 };
121 }
122 else {
123 die "Parse error on line $.: $line\n";
124 }
125 }
126
127 if ($parse_unowned) {
128 while (my $line = <$fh>) {
129 chomp $line;
130 if ($line =~ /# (.+)/) {
131 my $filepath = $1;
132 $unowned{$filepath}++;
133 }
134 }
135 }
136
137 $self->{lines} = \@lines;
138 $self->{unowned} = \%unowned;
139
140 return $self;
141 }
142
143 =method parse_from_array
144
145 $codeowners = File::Codeowners->parse_from_array(\@lines);
146
147 Parse a F<CODEOWNERS> file stored as lines in an array.
148
149 =cut
150
151 sub parse_from_array {
152 my $self = shift;
153 my $arr = shift or _usage(q{$codeowners->parse_from_array(\@lines)});
154
155 $self = bless({}, $self) if !ref($self);
156
157 $arr = [$arr, @_] if @_;
158 my $str = join("\n", @$arr);
159 return $self->parse_from_string(\$str);
160 }
161
162 =method parse_from_string
163
164 $codeowners = File::Codeowners->parse_from_string(\$string);
165 $codeowners = File::Codeowners->parse_from_string($string);
166
167 Parse a F<CODEOWNERS> file stored as a string. String should be UTF-8 encoded.
168
169 =cut
170
171 sub parse_from_string {
172 my $self = shift;
173 my $str = shift or _usage(q{$codeowners->parse_from_string(\$string)});
174
175 $self = bless({}, $self) if !ref($self);
176
177 my $ref = ref($str) eq 'SCALAR' ? $str : \$str;
178 open(my $fh, '<:encoding(UTF-8)', $ref) or die "open failed: $!";
179
180 return $self->parse_from_fh($fh);
181 }
182
183 =method write_to_filepath
184
185 $codeowners->write_to_filepath($filepath);
186
187 Write the contents of the file to the filesystem atomically.
188
189 =cut
190
191 sub write_to_filepath {
192 my $self = shift;
193 my $path = shift or _usage(q{$codeowners->write_to_filepath($filepath)});
194
195 path($path)->spew_utf8([map { "$_\n" } @{$self->write_to_array}]);
196 }
197
198 =method write_to_fh
199
200 $codeowners->write_to_fh($fh);
201
202 Format the file contents and write to a filehandle.
203
204 =cut
205
206 sub write_to_fh {
207 my $self = shift;
208 my $fh = shift or _usage(q{$codeowners->write_to_fh($fh)});
209 my $charset = shift;
210
211 for my $line (@{$self->write_to_array($charset)}) {
212 print $fh "$line\n";
213 }
214 }
215
216 =method write_to_string
217
218 $scalarref = $codeowners->write_to_string;
219
220 Format the file contents and return a reference to a formatted string.
221
222 =cut
223
224 sub write_to_string {
225 my $self = shift;
226 my $charset = shift;
227
228 my $str = join("\n", @{$self->write_to_array($charset)}) . "\n";
229 return \$str;
230 }
231
232 =method write_to_array
233
234 $lines = $codeowners->write_to_array;
235
236 Format the file contents as an arrayref of lines.
237
238 =cut
239
240 sub write_to_array {
241 my $self = shift;
242 my $charset = shift;
243
244 my @format;
245
246 for my $line (@{$self->_lines}) {
247 if (my $comment = $line->{comment}) {
248 push @format, "#$comment";
249 }
250 elsif (my $pattern = $line->{pattern}) {
251 my $owners = join(' ', @{$line->{owners}});
252 push @format, "$pattern $owners";
253 }
254 else {
255 push @format, '';
256 }
257 }
258
259 my @unowned = sort keys %{$self->_unowned};
260 if (@unowned) {
261 push @format, '' if $format[-1];
262 push @format, '### UNOWNED (File::Codeowners)';
263 for my $unowned (@unowned) {
264 push @format, "# $unowned";
265 }
266 }
267
268 if (defined $charset) {
269 $_ = encode($charset, $_) for @format;
270 }
271 return \@format;
272 }
273
274 =method match
275
276 $owners = $codeowners->match($filepath);
277
278 Match the given filepath against the available patterns and return just the
279 owners for the matching pattern. Patterns are checked in the reverse order
280 they were defined in the file.
281
282 Returns C<undef> if no patterns match.
283
284 =cut
285
286 sub match {
287 my $self = shift;
288 my $filepath = shift or _usage(q{$codeowners->match($filepath)});
289
290 my $lines = $self->{match_lines} ||= [reverse grep { ($_ || {})->{pattern} } @{$self->_lines}];
291
292 for my $line (@$lines) {
293 my $matcher = $line->{matcher} ||= build_gitignore_matcher([$line->{pattern}]);
294 return { # deep copy
295 pattern => $line->{pattern},
296 owners => [@{$line->{owners} || []}],
297 $line->{project} ? (project => $line->{project}) : (),
298 } if $matcher->($filepath);
299 }
300
301 return undef; ## no critic (Subroutines::ProhibitExplicitReturn)
302 }
303
304 =method owners
305
306 $owners = $codeowners->owners; # get all defined owners
307 $owners = $codeowners->owners($pattern);
308
309 Get an arrayref of owners defined in the file. If a pattern argument is given,
310 only owners for the given pattern are returned (or empty arrayref if the
311 pattern does not exist). If no argument is given, simply returns all owners
312 defined in the file.
313
314 =cut
315
316 sub owners {
317 my $self = shift;
318 my $pattern = shift;
319
320 return $self->{owners} if !$pattern && $self->{owners};
321
322 my %owners;
323 for my $line (@{$self->_lines}) {
324 next if $pattern && $line->{pattern} && $pattern ne $line->{pattern};
325 $owners{$_}++ for (@{$line->{owners} || []});
326 }
327
328 my $owners = [sort keys %owners];
329 $self->{owners} = $owners if !$pattern;
330
331 return $owners;
332 }
333
334 =method patterns
335
336 $patterns = $codeowners->patterns;
337 $patterns = $codeowners->patterns($owner);
338
339 Get an arrayref of all patterns defined.
340
341 =cut
342
343 sub patterns {
344 my $self = shift;
345 my $owner = shift;
346
347 return $self->{patterns} if !$owner && $self->{patterns};
348
349 my %patterns;
350 for my $line (@{$self->_lines}) {
351 next if $owner && !grep { $_ eq $owner } @{$line->{owners} || []};
352 my $pattern = $line->{pattern};
353 $patterns{$pattern}++ if $pattern;
354 }
355
356 my $patterns = [sort keys %patterns];
357 $self->{patterns} = $patterns if !$owner;
358
359 return $patterns;
360 }
361
362 =method projects
363
364 $projects = $codeowners->projects;
365
366 Get an arrayref of all projects defined.
367
368 =cut
369
370 sub projects {
371 my $self = shift;
372
373 return $self->{projects} if $self->{projects};
374
375 my %projects;
376 for my $line (@{$self->_lines}) {
377 my $project = $line->{project};
378 $projects{$project}++ if $project;
379 }
380
381 my $projects = [sort keys %projects];
382 $self->{projects} = $projects;
383
384 return $projects;
385 }
386
387 =method update_owners
388
389 $codeowners->update_owners($pattern => \@new_owners);
390
391 Set a new set of owners for a given pattern. If for some reason the file has
392 multiple such patterns, they will all be updated.
393
394 Nothing happens if the file does not already have at least one such pattern.
395
396 =cut
397
398 sub update_owners {
399 my $self = shift;
400 my $pattern = shift;
401 my $owners = shift;
402 $pattern && $owners or _usage(q{$codeowners->update_owners($pattern => \@owners)});
403
404 $owners = [$owners] if ref($owners) ne 'ARRAY';
405
406 $self->_clear;
407
408 my $count = 0;
409
410 for my $line (@{$self->_lines}) {
411 next if !$line->{pattern};
412 next if $pattern ne $line->{pattern};
413 $line->{owners} = [@$owners];
414 ++$count;
415 }
416
417 return $count;
418 }
419
420 =method update_owners_by_project
421
422 $codeowners->update_owners_by_project($project => \@new_owners);
423
424 Set a new set of owners for all patterns under the given project.
425
426 Nothing happens if the file does not have a project with the given name.
427
428 =cut
429
430 sub update_owners_by_project {
431 my $self = shift;
432 my $project = shift;
433 my $owners = shift;
434 $project && $owners or _usage(q{$codeowners->update_owners_by_project($project => \@owners)});
435
436 $owners = [$owners] if ref($owners) ne 'ARRAY';
437
438 $self->_clear;
439
440 my $count = 0;
441
442 for my $line (@{$self->_lines}) {
443 next if !$line->{project} || !$line->{owners};
444 next if $project ne $line->{project};
445 $line->{owners} = [@$owners];
446 ++$count;
447 }
448
449 return $count;
450 }
451
452 =method rename_owner
453
454 $codeowners->rename_owner($old_name => $new_name);
455
456 Rename an owner.
457
458 Nothing happens if the file does not have an owner with the old name.
459
460 =cut
461
462 sub rename_owner {
463 my $self = shift;
464 my $old_owner = shift;
465 my $new_owner = shift;
466 $old_owner && $new_owner or _usage(q{$codeowners->rename_owner($owner => $new_owner)});
467
468 $self->_clear;
469
470 my $count = 0;
471
472 for my $line (@{$self->_lines}) {
473 next if !exists $line->{owners};
474 for (my $i = 0; $i < @{$line->{owners}}; ++$i) {
475 next if $line->{owners}[$i] ne $old_owner;
476 $line->{owners}[$i] = $new_owner;
477 ++$count;
478 }
479 }
480
481 return $count;
482 }
483
484 =method rename_project
485
486 $codeowners->rename_project($old_name => $new_name);
487
488 Rename a project.
489
490 Nothing happens if the file does not have a project with the old name.
491
492 =cut
493
494 sub rename_project {
495 my $self = shift;
496 my $old_project = shift;
497 my $new_project = shift;
498 $old_project && $new_project or _usage(q{$codeowners->rename_project($project => $new_project)});
499
500 $self->_clear;
501
502 my $count = 0;
503
504 for my $line (@{$self->_lines}) {
505 next if !exists $line->{project} || $old_project ne $line->{project};
506 $line->{project} = $new_project;
507 $line->{comment} = " Project: $new_project" if exists $line->{comment};
508 ++$count;
509 }
510
511 return $count;
512 }
513
514 =method append
515
516 $codeowners->append(comment => $str);
517 $codeowners->append(pattern => $pattern, owners => \@owners);
518 $codeowners->append(); # blank line
519
520 Append a new line.
521
522 =cut
523
524 sub append {
525 my $self = shift;
526 $self->_clear;
527 push @{$self->_lines}, (@_ ? {@_} : undef);
528 }
529
530 =method prepend
531
532 $codeowners->prepend(comment => $str);
533 $codeowners->prepend(pattern => $pattern, owners => \@owners);
534 $codeowners->prepend(); # blank line
535
536 Prepend a new line.
537
538 =cut
539
540 sub prepend {
541 my $self = shift;
542 $self->_clear;
543 unshift @{$self->_lines}, (@_ ? {@_} : undef);
544 }
545
546 =method unowned
547
548 $filepaths = $codeowners->unowned;
549
550 Get the list of filepaths in the "unowned" section.
551
552 This parser supports an "extension" to the F<CODEOWNERS> file format which
553 lists unowned files at the end of the file. This list can be useful to have in
554 order to figure out what files we know are unowned versus what files we don't
555 know are unowned.
556
557 =cut
558
559 sub unowned {
560 my $self = shift;
561 [sort keys %{$self->{unowned} || {}}];
562 }
563
564 =method add_unowned
565
566 $codeowners->add_unowned($filepath, ...);
567
568 Add one or more filepaths to the "unowned" list.
569
570 This method does not check to make sure the filepath(s) actually do not match
571 any patterns in the file, so you might want to call L</match> first.
572
573 See L</unowned> for an explanation.
574
575 =cut
576
577 sub add_unowned {
578 my $self = shift;
579 $self->_unowned->{$_}++ for @_;
580 }
581
582 =method remove_unowned
583
584 $codeowners->remove_unowned($filepath, ...);
585
586 Remove one or more filepaths from the "unowned" list.
587
588 Silently ignores filepaths that are already not listed.
589
590 See L</unowned> for an explanation.
591
592 =cut
593
594 sub remove_unowned {
595 my $self = shift;
596 delete $self->_unowned->{$_} for @_;
597 }
598
599 sub is_unowned {
600 my $self = shift;
601 my $filepath = shift;
602 $self->_unowned->{$filepath};
603 }
604
605 =method clear_unowned
606
607 $codeowners->clear_unowned;
608
609 Remove all filepaths from the "unowned" list.
610
611 See L</unowned> for an explanation.
612
613 =cut
614
615 sub clear_unowned {
616 my $self = shift;
617 $self->{unowned} = {};
618 }
619
620 sub _lines { shift->{lines} ||= [] }
621 sub _unowned { shift->{unowned} ||= {} }
622
623 sub _clear {
624 my $self = shift;
625 delete $self->{match_lines};
626 delete $self->{owners};
627 delete $self->{patterns};
628 delete $self->{projects};
629 }
630
631 1;
This page took 0.062761 seconds and 4 git commands to generate.