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
0.03 qw(ansifg);
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
::Any
} or die "Missing dependency: Text::Table::Any\n";
179 my $table = Text
::Table
::Any
::table
(
181 rows
=> [$headers, map { [map { _stringify
($_) } @$_] } @$rows],
182 backend
=> $ENV{PERL_TEXT_TABLE
},
184 print { $fh } encode
('UTF-8', $table);
186 elsif ($format =~ /^json(:pretty)?$/) {
188 eval { require JSON
::MaybeXS
} or die "Missing dependency: JSON::MaybeXS\n";
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);
194 elsif ($format =~ /^([ct])sv$/) {
195 my $sep = $1 eq 'c' ? ',' : "\t";
196 eval { require Text
::CSV
} or die "Missing dependency: Text::CSV\n";
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;
202 elsif ($format =~ /^ya?ml$/) {
203 eval { require YAML
} or die "Missing dependency: YAML\n";
205 my $data = _combine_headers_rows
($headers, $rows);
206 print { $fh } encode
('UTF-8', YAML
::Dump
($data));
209 my $data = _combine_headers_rows
($headers, $rows);
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
219 # assign a color to each owner, on demand
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];
231 quote
=> sub { local $_ = $_[0]; s/"/\"/s; "\"$_\"" },
234 my $create_filterer = sub {
235 my $value = shift || '';
236 my $color = shift || '';
237 my $gencolor = ref($color) eq 'CODE' ? $color : sub { $color };
240 my ($filters, $color) = _expand_filter_args
($arg);
241 if (ref($value) eq 'ARRAY') {
242 $value = join(',', map { _colored
($_, $color // $gencolor->($_)) } @$value);
245 $value = _colored
($value, $color // $gencolor->($value));
247 for my $key (@$filters) {
248 if (my $filter = $filter{$key}) {
249 $value = $filter->($value);
252 warn "Unknown filter: $key\n"
259 for my $row (@$data) {
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),
267 my $text = stringf
($format, %info);
268 print { $fh } encode
('UTF-8', $text), "\n";
273 sub _expand_filter_args
{
274 my $arg = shift || '';
276 my @filters = split(/,/, $arg);
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);
288 return (\
@filters, $color_override);
291 sub _ansi_reset
{ "\033[0m" }
295 my $rgb = shift or return $text;
297 return $text if $ENV{NO_COLOR
};
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";
305 my ($begin, $end) = (ansifg
($rgb), _ansi_reset
);
306 return "${begin}${text}${end}";
309 sub _combine_headers_rows
{
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];
327 return ref($item) eq 'ARRAY' ? join(',', @$item) : $item;