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