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