]> Dogcows Code - chaz/git-codeowners/blob - lib/App/Codeowners.pm
initial commit
[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 = '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 } or die "Missing dependency: Text::Table\n";
178
179 my $table = Text::Table->new(@$headers);
180 $table->load(map { [map { _stringify($_) } @$_] } @$rows);
181 print { $fh } encode('UTF-8', "$table");
182 }
183 elsif ($format =~ /^json(:pretty)?$/) {
184 my $pretty = !!$1;
185 eval { require JSON::MaybeXS } or die "Missing dependency: JSON::MaybeXS\n";
186
187 my $json = JSON::MaybeXS->new(canonical => 1, utf8 => 1, pretty => $pretty);
188 my $data = _combine_headers_rows($headers, $rows);
189 print { $fh } $json->encode($data);
190 }
191 elsif ($format =~ /^([ct])sv$/) {
192 my $sep = $1 eq 'c' ? ',' : "\t";
193 eval { require Text::CSV } or die "Missing dependency: Text::CSV\n";
194
195 my $csv = Text::CSV->new({binary => 1, eol => $/, sep => $sep});
196 $csv->print($fh, $headers);
197 $csv->print($fh, [map { encode('UTF-8', _stringify($_)) } @$_]) for @$rows;
198 }
199 elsif ($format =~ /^ya?ml$/) {
200 eval { require YAML } or die "Missing dependency: YAML\n";
201
202 my $data = _combine_headers_rows($headers, $rows);
203 print { $fh } encode('UTF-8', YAML::Dump($data));
204 }
205 else {
206 my $data = _combine_headers_rows($headers, $rows);
207
208 # https://sashat.me/2017/01/11/list-of-20-simple-distinct-colors/
209 my @contrasting_colors = qw(
210 e6194b 3cb44b ffe119 4363d8 f58231
211 911eb4 42d4f4 f032e6 bfef45 fabebe
212 469990 e6beff 9a6324 fffac8 800000
213 aaffc3 808000 ffd8b1 000075 a9a9a9
214 );
215
216 # assign a color to each owner, on demand
217 my %owner_colors;
218 my $num = -1;
219 my $owner_color = sub {
220 my $owner = shift or return;
221 $owner_colors{$owner} ||= do {
222 $num = ($num + 1) % scalar @contrasting_colors;
223 $contrasting_colors[$num];
224 };
225 };
226
227 my %filter = (
228 quote => sub { local $_ = $_[0]; s/"/\"/s; "\"$_\"" },
229 );
230
231 my $create_filterer = sub {
232 my $value = shift || '';
233 my $color = shift || '';
234 my $gencolor = ref($color) eq 'CODE' ? $color : sub { $color };
235 return sub {
236 my $arg = shift;
237 my ($filters, $color) = _expand_filter_args($arg);
238 if (ref($value) eq 'ARRAY') {
239 $value = join(',', map { _colored($_, $color // $gencolor->($_)) } @$value);
240 }
241 else {
242 $value = _colored($value, $color // $gencolor->($value));
243 }
244 for my $key (@$filters) {
245 if (my $filter = $filter{$key}) {
246 $value = $filter->($value);
247 }
248 else {
249 warn "Unknown filter: $key\n"
250 }
251 }
252 $value || '';
253 };
254 };
255
256 for my $row (@$data) {
257 my %info = (
258 F => $create_filterer->($row->{File}, undef),
259 O => $create_filterer->($row->{Owner}, $owner_color),
260 P => $create_filterer->($row->{Project}, undef),
261 T => $create_filterer->($row->{Pattern}, undef),
262 );
263
264 my $text = stringf($format, %info);
265 print { $fh } encode('UTF-8', $text), "\n";
266 }
267 }
268 }
269
270 sub _expand_filter_args {
271 my $arg = shift || '';
272
273 my @filters = split(/,/, $arg);
274 my $color_override;
275
276 for (my $i = 0; $i < @filters; ++$i) {
277 my $filter = $filters[$i] or next;
278 if ($filter =~ /^(?:nocolor|color:([0-9a-fA-F]{6}))$/) {
279 $color_override = $1 || '';
280 splice(@filters, $i, 1);
281 redo;
282 }
283 }
284
285 return (\@filters, $color_override);
286 }
287
288 sub _colored {
289 my $text = shift;
290 my $rgb = shift or return $text;
291
292 # ansifg honors NO_COLOR already, but ansi_reset does not.
293 return $text if $ENV{NO_COLOR};
294
295 my ($begin, $end) = (ansifg($rgb), ansi_reset);
296 return "${begin}${text}${end}";
297 }
298
299 sub _combine_headers_rows {
300 my $headers = shift;
301 my $rows = shift;
302
303 my @new_rows;
304
305 for my $row (@$rows) {
306 push @new_rows, (my $new_row = {});
307 for (my $i = 0; $i < @$headers; ++$i) {
308 $new_row->{$headers->[$i]} = $row->[$i];
309 }
310 }
311
312 return \@new_rows;
313 }
314
315 sub _stringify {
316 my $item = shift;
317 return ref($item) eq 'ARRAY' ? join(',', @$item) : $item;
318 }
319
320 1;
This page took 0.056911 seconds and 4 git commands to generate.