]> Dogcows Code - chaz/git-codeowners/blob - lib/App/Codeowners.pm
626fc82b20a75520522c527db01ba601d86d6359
[chaz/git-codeowners] / lib / App / Codeowners.pm
1 package App::Codeowners;
2 # ABSTRACT: A tool for managing CODEOWNERS files
3
4 use v5.10.1; # defined-or
5 use utf8;
6 use warnings;
7 use strict;
8
9 use App::Codeowners::Options;
10 use App::Codeowners::Util qw(find_codeowners_in_directory run_git git_ls_files git_toplevel stringf);
11 use Color::ANSI::Util qw(ansifg ansi_reset);
12 use Encode qw(encode);
13 use File::Codeowners;
14 use Path::Tiny;
15
16 our $VERSION = '0.41'; # VERSION
17
18
19 sub main {
20 my $class = shift;
21 my $self = bless {}, $class;
22
23 my $opts = App::Codeowners::Options->new(@_);
24
25 my $color = $opts->{color};
26 local $ENV{NO_COLOR} = 1 if defined $color && !$color;
27
28 my $command = $opts->command;
29 my $handler = $self->can("_command_$command")
30 or die "Unknown command: $command\n";
31 $self->$handler($opts);
32
33 exit 0;
34 }
35
36 sub _command_show {
37 my $self = shift;
38 my $opts = shift;
39
40 my $toplevel = git_toplevel('.') or die "Not a git repo\n";
41
42 my $codeowners_path = find_codeowners_in_directory($toplevel)
43 or die "No CODEOWNERS file in $toplevel\n";
44 my $codeowners = File::Codeowners->parse_from_filepath($codeowners_path);
45
46 my ($cdup) = run_git(qw{rev-parse --show-cdup});
47
48 my @results;
49
50 my $filepaths = git_ls_files('.', $opts->args) or die "Cannot list files\n";
51 for my $filepath (@$filepaths) {
52 my $match = $codeowners->match(path($filepath)->relative($cdup));
53 push @results, [
54 $filepath,
55 $match->{owners},
56 $opts->{project} ? $match->{project} : (),
57 ];
58 }
59
60 _format(
61 format => $opts->{format} || ' * %-50F %O',
62 out => *STDOUT,
63 headers => [qw(File Owner), $opts->{project} ? 'Project' : ()],
64 rows => \@results,
65 );
66 }
67
68 sub _command_owners {
69 my $self = shift;
70 my $opts = shift;
71
72 my $toplevel = git_toplevel('.') or die "Not a git repo\n";
73
74 my $codeowners_path = find_codeowners_in_directory($toplevel)
75 or die "No CODEOWNERS file in $toplevel\n";
76 my $codeowners = File::Codeowners->parse_from_filepath($codeowners_path);
77
78 my $results = $codeowners->owners($opts->{pattern});
79
80 _format(
81 format => $opts->{format} || '%O',
82 out => *STDOUT,
83 headers => [qw(Owner)],
84 rows => [map { [$_] } @$results],
85 );
86 }
87
88 sub _command_patterns {
89 my $self = shift;
90 my $opts = shift;
91
92 my $toplevel = git_toplevel('.') or die "Not a git repo\n";
93
94 my $codeowners_path = find_codeowners_in_directory($toplevel)
95 or die "No CODEOWNERS file in $toplevel\n";
96 my $codeowners = File::Codeowners->parse_from_filepath($codeowners_path);
97
98 my $results = $codeowners->patterns($opts->{owner});
99
100 _format(
101 format => $opts->{format} || '%T',
102 out => *STDOUT,
103 headers => [qw(Pattern)],
104 rows => [map { [$_] } @$results],
105 );
106 }
107
108 sub _command_create { goto &_command_update }
109 sub _command_update {
110 my $self = shift;
111 my $opts = shift;
112
113 my ($filepath) = $opts->args;
114
115 my $path = path($filepath || '.');
116 my $repopath;
117
118 die "Does not exist: $path\n" if !$path->parent->exists;
119
120 if ($path->is_dir) {
121 $repopath = $path;
122 $path = find_codeowners_in_directory($path) || $repopath->child('CODEOWNERS');
123 }
124
125 my $is_new = !$path->is_file;
126
127 my $codeowners;
128 if ($is_new) {
129 $codeowners = File::Codeowners->new;
130 my $template = <<'END';
131 This file shows mappings between subdirs/files and the individuals and
132 teams who own them. You can read this file yourself or use tools to query it,
133 so you can quickly determine who to speak with or send pull requests to. ❤️
134
135 Simply write a gitignore pattern followed by one or more names/emails/groups.
136 Examples:
137 /project_a/** @team1
138 *.js @harry @javascript-cabal
139 END
140 for my $line (split(/\n/, $template)) {
141 $codeowners->append(comment => $line);
142 }
143 }
144 else {
145 $codeowners = File::Codeowners->parse_from_filepath($path);
146 }
147
148 if ($repopath) {
149 # if there is a repo we can try to update the list of unowned files
150 my $git_files = git_ls_files($repopath);
151 if (@$git_files) {
152 $codeowners->clear_unowned;
153 $codeowners->add_unowned(grep { !$codeowners->match($_) } @$git_files);
154 }
155 }
156
157 $codeowners->write_to_filepath($path);
158 print STDERR "Wrote $path\n";
159 }
160
161 sub _format {
162 my %args = @_;
163
164 my $format = $args{format} || 'table';
165 my $fh = $args{out} || *STDOUT;
166 my $headers = $args{headers} || [];
167 my $rows = $args{rows} || [];
168
169 if ($format eq 'table') {
170 eval { require Text::Table::Any } or die "Missing dependency: Text::Table::Any\n";
171
172 my $table = Text::Table::Any::table(
173 header_row => 1,
174 rows => [$headers, map { [map { _stringify($_) } @$_] } @$rows],
175 backend => $ENV{PERL_TEXT_TABLE},
176 );
177 print { $fh } encode('UTF-8', $table);
178 }
179 elsif ($format =~ /^json(:pretty)?$/) {
180 my $pretty = !!$1;
181 eval { require JSON::MaybeXS } or die "Missing dependency: JSON::MaybeXS\n";
182
183 my $json = JSON::MaybeXS->new(canonical => 1, utf8 => 1, pretty => $pretty);
184 my $data = _combine_headers_rows($headers, $rows);
185 print { $fh } $json->encode($data);
186 }
187 elsif ($format =~ /^([ct])sv$/) {
188 my $sep = $1 eq 'c' ? ',' : "\t";
189 eval { require Text::CSV } or die "Missing dependency: Text::CSV\n";
190
191 my $csv = Text::CSV->new({binary => 1, eol => $/, sep => $sep});
192 $csv->print($fh, $headers);
193 $csv->print($fh, [map { encode('UTF-8', _stringify($_)) } @$_]) for @$rows;
194 }
195 elsif ($format =~ /^ya?ml$/) {
196 eval { require YAML } or die "Missing dependency: YAML\n";
197
198 my $data = _combine_headers_rows($headers, $rows);
199 print { $fh } encode('UTF-8', YAML::Dump($data));
200 }
201 else {
202 my $data = _combine_headers_rows($headers, $rows);
203
204 # https://sashat.me/2017/01/11/list-of-20-simple-distinct-colors/
205 my @contrasting_colors = qw(
206 e6194b 3cb44b ffe119 4363d8 f58231
207 911eb4 42d4f4 f032e6 bfef45 fabebe
208 469990 e6beff 9a6324 fffac8 800000
209 aaffc3 808000 ffd8b1 000075 a9a9a9
210 );
211
212 # assign a color to each owner, on demand
213 my %owner_colors;
214 my $num = -1;
215 my $owner_color = sub {
216 my $owner = shift or return;
217 $owner_colors{$owner} ||= do {
218 $num = ($num + 1) % scalar @contrasting_colors;
219 $contrasting_colors[$num];
220 };
221 };
222
223 my %filter = (
224 quote => sub { local $_ = $_[0]; s/"/\"/s; "\"$_\"" },
225 );
226
227 my $create_filterer = sub {
228 my $value = shift || '';
229 my $color = shift || '';
230 my $gencolor = ref($color) eq 'CODE' ? $color : sub { $color };
231 return sub {
232 my $arg = shift;
233 my ($filters, $color) = _expand_filter_args($arg);
234 if (ref($value) eq 'ARRAY') {
235 $value = join(',', map { _colored($_, $color // $gencolor->($_)) } @$value);
236 }
237 else {
238 $value = _colored($value, $color // $gencolor->($value));
239 }
240 for my $key (@$filters) {
241 if (my $filter = $filter{$key}) {
242 $value = $filter->($value);
243 }
244 else {
245 warn "Unknown filter: $key\n"
246 }
247 }
248 $value || '';
249 };
250 };
251
252 for my $row (@$data) {
253 my %info = (
254 F => $create_filterer->($row->{File}, undef),
255 O => $create_filterer->($row->{Owner}, $owner_color),
256 P => $create_filterer->($row->{Project}, undef),
257 T => $create_filterer->($row->{Pattern}, undef),
258 );
259
260 my $text = stringf($format, %info);
261 print { $fh } encode('UTF-8', $text), "\n";
262 }
263 }
264 }
265
266 sub _expand_filter_args {
267 my $arg = shift || '';
268
269 my @filters = split(/,/, $arg);
270 my $color_override;
271
272 for (my $i = 0; $i < @filters; ++$i) {
273 my $filter = $filters[$i] or next;
274 if ($filter =~ /^(?:nocolor|color:([0-9a-fA-F]{3,6}))$/) {
275 $color_override = $1 || '';
276 splice(@filters, $i, 1);
277 redo;
278 }
279 }
280
281 return (\@filters, $color_override);
282 }
283
284 sub _colored {
285 my $text = shift;
286 my $rgb = shift or return $text;
287
288 # ansifg honors NO_COLOR already, but ansi_reset does not.
289 return $text if $ENV{NO_COLOR};
290
291 $rgb =~ s/^(.)(.)(.)$/$1$1$2$2$3$3/;
292 if ($rgb !~ m/^[0-9a-fA-F]{6}$/) {
293 warn "Color value must be in 'ffffff' or 'fff' form.\n";
294 return $text;
295 }
296
297 my ($begin, $end) = (ansifg($rgb), ansi_reset);
298 return "${begin}${text}${end}";
299 }
300
301 sub _combine_headers_rows {
302 my $headers = shift;
303 my $rows = shift;
304
305 my @new_rows;
306
307 for my $row (@$rows) {
308 push @new_rows, (my $new_row = {});
309 for (my $i = 0; $i < @$headers; ++$i) {
310 $new_row->{$headers->[$i]} = $row->[$i];
311 }
312 }
313
314 return \@new_rows;
315 }
316
317 sub _stringify {
318 my $item = shift;
319 return ref($item) eq 'ARRAY' ? join(',', @$item) : $item;
320 }
321
322 1;
323
324 __END__
325
326 =pod
327
328 =encoding UTF-8
329
330 =head1 NAME
331
332 App::Codeowners - A tool for managing CODEOWNERS files
333
334 =head1 VERSION
335
336 version 0.41
337
338 =head1 METHODS
339
340 =head2 main
341
342 App::Codeowners->main(@ARGV);
343
344 Run the script and exit; does not return.
345
346 =head1 BUGS
347
348 Please report any bugs or feature requests on the bugtracker website
349 L<https://github.com/chazmcgarvey/git-codeowners/issues>
350
351 When submitting a bug or request, please include a test-file or a
352 patch to an existing test-file that illustrates the bug or desired
353 feature.
354
355 =head1 AUTHOR
356
357 Charles McGarvey <chazmcgarvey@brokenzipper.com>
358
359 =head1 COPYRIGHT AND LICENSE
360
361 This software is copyright (c) 2019 by Charles McGarvey.
362
363 This is free software; you can redistribute it and/or modify it under
364 the same terms as the Perl 5 programming language system itself.
365
366 =cut
This page took 0.053467 seconds and 3 git commands to generate.