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