1 package App
::Codeowners
;
2 # ABSTRACT: A tool for managing CODEOWNERS files
4 use v5
.10
.1; # defined-or
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);
16 our $VERSION = '9999.999'; # VERSION
20 App
::Codeowners-
>main(@ARGV);
22 Run the script
and exit; does not return.
28 my $self = bless {}, $class;
30 my $opts = App
::Codeowners
::Options-
>new(@_);
32 my $color = $opts->{color
};
33 local $ENV{NO_COLOR
} = 1 if defined $color && !$color;
35 my $command = $opts->command;
36 my $handler = $self->can("_command_$command")
37 or die "Unknown command: $command\n";
38 $self->$handler($opts);
47 my $toplevel = git_toplevel
('.') or die "Not a git repo\n";
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);
53 my ($cdup) = run_git
(qw{rev-parse --show-cdup});
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));
63 $opts->{project
} ? $match->{project
} : (),
68 format
=> $opts->{format
} || ' * %-50F %O',
70 headers
=> [qw(File Owner), $opts->{project
} ? 'Project' : ()],
79 my $toplevel = git_toplevel
('.') or die "Not a git repo\n";
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);
85 my $results = $codeowners->owners($opts->{pattern
});
88 format
=> $opts->{format
} || '%O',
90 headers
=> [qw(Owner)],
91 rows
=> [map { [$_] } @$results],
95 sub _command_patterns
{
99 my $toplevel = git_toplevel
('.') or die "Not a git repo\n";
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);
105 my $results = $codeowners->patterns($opts->{owner
});
108 format
=> $opts->{format
} || '%T',
110 headers
=> [qw(Pattern)],
111 rows
=> [map { [$_] } @$results],
115 sub _command_create
{ goto &_command_update
}
116 sub _command_update
{
120 my ($filepath) = $opts->args;
122 my $path = path
($filepath || '.');
125 die "Does not exist: $path\n" if !$path->parent->exists;
129 $path = find_codeowners_in_directory
($path) || $repopath->child('CODEOWNERS');
132 my $is_new = !$path->is_file;
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. ❤️
142 Simply write a gitignore pattern followed by one or more names/emails/groups.
145 *.js @harry @javascript-cabal
147 for my $line (split(/\n/, $template)) {
148 $codeowners->append(comment
=> $line);
152 $codeowners = File
::Codeowners-
>parse_from_filepath($path);
156 # if there is a repo we can try to update the list of unowned files
157 my $git_files = git_ls_files
($repopath);
159 $codeowners->clear_unowned;
160 $codeowners->add_unowned(grep { !$codeowners->match($_) } @$git_files);
164 $codeowners->write_to_filepath($path);
165 print STDERR
"Wrote $path\n";
171 my $format = $args{format
} || 'table';
172 my $fh = $args{out
} || *STDOUT
;
173 my $headers = $args{headers
} || [];
174 my $rows = $args{rows
} || [];
176 if ($format eq 'table') {
177 eval { require Text
::Table
} or die "Missing dependency: Text::Table\n";
179 my $table = Text
::Table-
>new(@$headers);
180 $table->load(map { [map { _stringify
($_) } @$_] } @$rows);
181 print { $fh } encode
('UTF-8', "$table");
183 elsif ($format =~ /^json(:pretty)?$/) {
185 eval { require JSON
::MaybeXS
} or die "Missing dependency: JSON::MaybeXS\n";
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);
191 elsif ($format =~ /^([ct])sv$/) {
192 my $sep = $1 eq 'c' ? ',' : "\t";
193 eval { require Text
::CSV
} or die "Missing dependency: Text::CSV\n";
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;
199 elsif ($format =~ /^ya?ml$/) {
200 eval { require YAML
} or die "Missing dependency: YAML\n";
202 my $data = _combine_headers_rows
($headers, $rows);
203 print { $fh } encode
('UTF-8', YAML
::Dump
($data));
206 my $data = _combine_headers_rows
($headers, $rows);
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
216 # assign a color to each owner, on demand
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];
228 quote
=> sub { local $_ = $_[0]; s/"/\"/s; "\"$_\"" },
231 my $create_filterer = sub {
232 my $value = shift || '';
233 my $color = shift || '';
234 my $gencolor = ref($color) eq 'CODE' ? $color : sub { $color };
237 my ($filters, $color) = _expand_filter_args
($arg);
238 if (ref($value) eq 'ARRAY') {
239 $value = join(',', map { _colored
($_, $color // $gencolor->($_)) } @$value);
242 $value = _colored
($value, $color // $gencolor->($value));
244 for my $key (@$filters) {
245 if (my $filter = $filter{$key}) {
246 $value = $filter->($value);
249 warn "Unknown filter: $key\n"
256 for my $row (@$data) {
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),
264 my $text = stringf
($format, %info);
265 print { $fh } encode
('UTF-8', $text), "\n";
270 sub _expand_filter_args
{
271 my $arg = shift || '';
273 my @filters = split(/,/, $arg);
276 for (my $i = 0; $i < @filters; ++$i) {
277 my $filter = $filters[$i] or next;
278 if ($filter =~ /^(?:nocolor|color:([0-9a-fA-F]{3,6}))$/) {
279 $color_override = $1 || '';
280 splice(@filters, $i, 1);
285 return (\
@filters, $color_override);
290 my $rgb = shift or return $text;
292 # ansifg honors NO_COLOR already, but ansi_reset does not.
293 return $text if $ENV{NO_COLOR
};
295 $rgb =~ s/^(.)(.)(.)$/$1$1$2$2$3$3/;
296 if ($rgb !~ m/^[0-9a-fA-F]{6}$/) {
297 warn "Color value must be in 'ffffff' or 'fff' form.\n";
301 my ($begin, $end) = (ansifg
($rgb), ansi_reset
);
302 return "${begin}${text}${end}";
305 sub _combine_headers_rows
{
311 for my $row (@$rows) {
312 push @new_rows, (my $new_row = {});
313 for (my $i = 0; $i < @$headers; ++$i) {
314 $new_row->{$headers->[$i]} = $row->[$i];
323 return ref($item) eq 'ARRAY' ? join(',', @$item) : $item;