Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.77% covered (success)
96.77%
60 / 62
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
CodeOwnersGenerator
96.77% covered (success)
96.77%
60 / 62
75.00% covered (warning)
75.00%
6 / 8
27
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
 inferOwners
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
 normalizeOwners
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 generate
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 inferGroupOwner
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 extractGitHubHandleFromUrl
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 extractGitHubRepositoryOwner
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 githubPath
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
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\CodeOwners;
21
22use FastForward\DevTools\Composer\Json\ComposerJsonInterface;
23use FastForward\DevTools\Composer\Json\Schema\AuthorInterface;
24use FastForward\DevTools\Filesystem\FilesystemInterface;
25use Symfony\Component\Config\FileLocatorInterface;
26
27use function Safe\preg_match;
28use function Safe\parse_url;
29use function Safe\preg_replace;
30use function Safe\preg_split;
31use function array_filter;
32use function array_map;
33use function array_unique;
34use function implode;
35use function is_iterable;
36use function str_contains;
37use function str_starts_with;
38use function trim;
39
40/**
41 * Generates CODEOWNERS content from repository metadata.
42 */
43  class CodeOwnersGenerator
44{
45    /**
46     * Creates a new generator instance.
47     *
48     * @param ComposerJsonInterface $composer the composer metadata accessor
49     * @param FilesystemInterface $filesystem the filesystem used to read the packaged template
50     * @param FileLocatorInterface $fileLocator the locator used to find the packaged template
51     */
52    public function __construct(
53        private ComposerJsonInterface $composer,
54        private FilesystemInterface $filesystem,
55        private FileLocatorInterface $fileLocator,
56    ) {}
57
58    /**
59     * Returns the automatically inferred CODEOWNERS handles.
60     *
61     * @return list<string>
62     */
63    public function inferOwners(): array
64    {
65        $owners = [];
66        $groupOwner = $this->inferGroupOwner();
67
68        if (null !== $groupOwner) {
69            $owners[] = $groupOwner;
70        }
71
72        $authors = $this->composer->getAuthors();
73
74        if (! is_iterable($authors)) {
75            return $owners;
76        }
77
78        foreach ($authors as $author) {
79            if (! $author instanceof AuthorInterface) {
80                continue;
81            }
82
83            $handle = $this->extractGitHubHandleFromUrl($author->getHomepage());
84
85            if (null === $handle) {
86                continue;
87            }
88
89            $owners[] = '@' . $handle;
90        }
91
92        return array_values(array_unique($owners));
93    }
94
95    /**
96     * Normalizes user-provided owner tokens.
97     *
98     * @param string $owners the raw owner input
99     *
100     * @return list<string>
101     */
102    public function normalizeOwners(string $owners): array
103    {
104        $tokens = preg_split('/[\s,]+/', trim($owners));
105        $normalized = array_map(
106            static function (string $owner): string {
107                if ('' === $owner) {
108                    return '';
109                }
110
111                if (str_contains($owner, '@') && ! str_starts_with($owner, '@')) {
112                    return $owner;
113                }
114
115                return str_starts_with($owner, '@') ? $owner : '@' . $owner;
116            },
117            $tokens,
118        );
119
120        return array_values(array_unique(array_filter($normalized, static fn(string $owner): bool => '' !== $owner)));
121    }
122
123    /**
124     * Generates CODEOWNERS contents.
125     *
126     * @param list<string>|null $owners explicit owners to render; inferred owners are used when null
127     *
128     * @return string the rendered CODEOWNERS file contents
129     */
130    public function generate(?array $owners = null): string
131    {
132        $owners ??= $this->inferOwners();
133        $template = $this->filesystem->readFile($this->fileLocator->locate('resources/CODEOWNERS.dist'));
134        $suggestionBlock = [] === $owners
135            ? '# No GitHub owners could be inferred from composer.json metadata.'
136            : '';
137        $rule = [] === $owners
138            ? '# * @your-github-user'
139            : \sprintf('* %s', implode(' ', $owners));
140
141        return str_replace(['{{ suggestions }}', '{{ rule }}'], [$suggestionBlock, $rule], $template);
142    }
143
144    /**
145     * Returns the repository or organization owner inferred from support metadata.
146     *
147     * @return string|null the inferred group owner with `@`, or null when unavailable
148     */
149    public function inferGroupOwner(): ?string
150    {
151        $source = $this->composer->getSupport()
152            ->getSource();
153
154        if ('' === $source) {
155            return null;
156        }
157
158        $owner = $this->extractGitHubRepositoryOwner($source);
159
160        if (null === $owner) {
161            return null;
162        }
163
164        return '@' . $owner;
165    }
166
167    /**
168     * Extracts a GitHub user handle from a homepage URL.
169     *
170     * @param string $url the homepage URL
171     *
172     * @return string|null the GitHub handle without `@`, or null when unavailable
173     */
174    private function extractGitHubHandleFromUrl(string $url): ?string
175    {
176        $path = $this->githubPath($url);
177
178        if (null === $path) {
179            return null;
180        }
181
182        if (0 === preg_match('#^/([^/]+)/?$#', $path, $matches)) {
183            return null;
184        }
185
186        return $matches[1];
187    }
188
189    /**
190     * Extracts the repository owner from a GitHub repository URL.
191     *
192     * @param string $url the repository URL
193     *
194     * @return string|null the owner without `@`, or null when unavailable
195     */
196    private function extractGitHubRepositoryOwner(string $url): ?string
197    {
198        $path = $this->githubPath($url);
199
200        if (null === $path) {
201            return null;
202        }
203
204        if (0 === preg_match('#^/([^/]+)/([^/]+)/?$#', $path, $matches)) {
205            return null;
206        }
207
208        return $matches[1];
209    }
210
211    /**
212     * Returns the path portion of a GitHub URL when the host matches github.com.
213     *
214     * @param string $url the URL to inspect
215     *
216     * @return string|null the URL path, or null when the URL is not a GitHub URL
217     */
218    private function githubPath(string $url): ?string
219    {
220        $host = parse_url($url, \PHP_URL_HOST);
221        $path = parse_url($url, \PHP_URL_PATH);
222
223        if ('github.com' !== $host || ! \is_string($path)) {
224            return null;
225        }
226
227        return preg_replace('#/+?#', '/', $path);
228    }
229}