Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.26% covered (success)
91.26%
94 / 103
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
CodeOwnersCommand
91.26% covered (success)
91.26%
94 / 103
80.00% covered (warning)
80.00%
4 / 5
25.42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
1
 execute
86.36% covered (warning)
86.36%
57 / 66
0.00% covered (danger)
0.00%
0 / 1
22.12
 promptForOwners
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 shouldWriteCodeOwners
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Console\Command;
21
22use FastForward\DevTools\Console\Command\Traits\LogsCommandResults;
23use Composer\Command\BaseCommand;
24use FastForward\DevTools\Console\Input\HasJsonOption;
25use FastForward\DevTools\CodeOwners\CodeOwnersGenerator;
26use FastForward\DevTools\Filesystem\FilesystemInterface;
27use FastForward\DevTools\Resource\FileDiffer;
28use Psr\Log\LoggerInterface;
29use Psr\Log\LogLevel;
30use Symfony\Component\Console\Attribute\AsCommand;
31use Symfony\Component\Console\Input\InputInterface;
32use Symfony\Component\Console\Input\InputOption;
33use Symfony\Component\Console\Output\OutputInterface;
34
35/**
36 * Generates and synchronizes CODEOWNERS files from local project metadata.
37 */
38#[AsCommand(
39    name: 'codeowners',
40    description: 'Generates .github/CODEOWNERS from local project metadata.',
41    help: 'This command infers CODEOWNERS entries from composer.json metadata, falls back to a commented template, and supports drift-aware preview and overwrite flows.'
42)]
43 class CodeOwnersCommand extends BaseCommand implements LoggerAwareCommandInterface
44{
45    use HasJsonOption;
46    use LogsCommandResults;
47
48    /**
49     * Creates a new command instance.
50     *
51     * @param CodeOwnersGenerator $generator the generator used to infer and render CODEOWNERS contents
52     * @param FilesystemInterface $filesystem the filesystem used to read and write the target file
53     * @param FileDiffer $fileDiffer the differ used to report managed-file drift
54     * @param LoggerInterface $logger the output-aware logger
55     */
56    public function __construct(
57        private  CodeOwnersGenerator $generator,
58        private  FilesystemInterface $filesystem,
59        private  FileDiffer $fileDiffer,
60        private  LoggerInterface $logger,
61    ) {
62        parent::__construct();
63    }
64
65    /**
66     * {@inheritDoc}
67     */
68    protected function configure(): void
69    {
70        $this->addJsonOption()
71            ->addOption(
72                name: 'file',
73                mode: InputOption::VALUE_OPTIONAL,
74                description: 'Path to the CODEOWNERS file to manage.',
75                default: '.github/CODEOWNERS',
76            )
77            ->addOption(
78                name: 'overwrite',
79                shortcut: 'o',
80                mode: InputOption::VALUE_NONE,
81                description: 'Replace an existing CODEOWNERS file.',
82            )
83            ->addOption(
84                name: 'dry-run',
85                mode: InputOption::VALUE_NONE,
86                description: 'Preview CODEOWNERS drift without writing the file.',
87            )
88            ->addOption(
89                name: 'check',
90                mode: InputOption::VALUE_NONE,
91                description: 'Report CODEOWNERS drift and exit non-zero when updates are required.',
92            )
93            ->addOption(
94                name: 'interactive',
95                mode: InputOption::VALUE_NONE,
96                description: 'Prompt for owners and confirmation before replacing CODEOWNERS.',
97            );
98    }
99
100    /**
101     * Generates or updates the CODEOWNERS file.
102     *
103     * @param InputInterface $input the command input
104     * @param OutputInterface $output the command output
105     *
106     * @return int the command status code
107     */
108    protected function execute(InputInterface $input, OutputInterface $output): int
109    {
110        $targetPath = $this->filesystem->getAbsolutePath((string) $input->getOption('file'));
111        $targetDirectory = $this->filesystem->dirname($targetPath);
112        $overwrite = (bool) $input->getOption('overwrite');
113        $dryRun = (bool) $input->getOption('dry-run');
114        $check = (bool) $input->getOption('check');
115        $interactive = (bool) $input->getOption('interactive');
116
117        if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($targetPath)) {
118            return $this->success(
119                'Managed file {target_path} already exists. Skipping CODEOWNERS generation.',
120                $input,
121                [
122                    'target_path' => $targetPath,
123                ],
124                LogLevel::NOTICE,
125            );
126        }
127
128        $owners = $this->generator->inferOwners();
129
130        if ([] === $owners && $interactive && $input->isInteractive()) {
131            $owners = $this->promptForOwners();
132        }
133
134        $generatedContent = $this->generator->generate($owners);
135        $existingContent = $this->filesystem->exists($targetPath) ? $this->filesystem->readFile($targetPath) : null;
136
137        $comparison = $this->fileDiffer->diffContents(
138            'generated CODEOWNERS content',
139            $targetPath,
140            $generatedContent,
141            $existingContent,
142            null === $existingContent
143                ? \sprintf('Managed file %s will be created from generated CODEOWNERS content.', $targetPath)
144                : \sprintf('Updating managed file %s from generated CODEOWNERS content.', $targetPath),
145        );
146
147        $this->notice($comparison->getSummary(), $input, [
148            'target_path' => $targetPath,
149        ]);
150
151        if ($comparison->isChanged()) {
152            $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated());
153
154            if (null !== $consoleDiff) {
155                $this->notice(
156                    $consoleDiff,
157                    $input,
158                    [
159                        'target_path' => $targetPath,
160                        'diff' => $comparison->getDiff(),
161                    ],
162                );
163            }
164        }
165
166        if ($comparison->isUnchanged()) {
167            return self::SUCCESS;
168        }
169
170        if ($check) {
171            return self::FAILURE;
172        }
173
174        if ($dryRun) {
175            return self::SUCCESS;
176        }
177
178        if (null !== $existingContent && $interactive && $input->isInteractive() && ! $this->shouldWriteCodeOwners(
179            $targetPath
180        )) {
181            return $this->success(
182                'Skipped updating {target_path}.',
183                $input,
184                [
185                    'target_path' => $targetPath,
186                ],
187                LogLevel::NOTICE,
188            );
189        }
190
191        if (! $this->filesystem->exists($targetDirectory)) {
192            $this->filesystem->mkdir($targetDirectory);
193        }
194
195        $this->filesystem->dumpFile($targetPath, $generatedContent);
196
197        return $this->success('Updated CODEOWNERS in {target_path}.', $input, [
198            'target_path' => $targetPath,
199        ]);
200    }
201
202    /**
203     * Prompts for CODEOWNERS entries when metadata inference is insufficient.
204     *
205     * @return list<string>
206     */
207    private function promptForOwners(): array
208    {
209        $answer = (string) $this->getIO()
210            ->ask(
211                'No CODEOWNERS entries could be inferred from composer.json. Enter space-separated owners for "*" or leave blank to use a commented placeholder: ',
212                '',
213            );
214
215        return $this->generator->normalizeOwners($answer);
216    }
217
218    /**
219     * Prompts whether the generated CODEOWNERS file should be written.
220     *
221     * @param string $targetPath the target file path
222     *
223     * @return bool true when the write SHOULD proceed
224     */
225    private function shouldWriteCodeOwners(string $targetPath): bool
226    {
227        return $this->getIO()
228            ->askConfirmation(\sprintf('Write managed file %s? [y/N] ', $targetPath), false);
229    }
230}