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