Passed
Push — master ( d981da...1a8193 )
by Alexander
01:54
created

SemanticVersionUpdater::updateComposerVersion()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 21
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 4

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 16
c 1
b 0
f 0
nc 4
nop 2
dl 0
loc 21
ccs 15
cts 15
cp 1
crap 4
rs 9.7333
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Vasoft\VersionIncrement;
6
7
use Vasoft\VersionIncrement\Exceptions\BranchException;
8
use Vasoft\VersionIncrement\Exceptions\ChangesNotFoundException;
9
use Vasoft\VersionIncrement\Exceptions\ComposerException;
10
use Vasoft\VersionIncrement\Exceptions\GitCommandException;
11
use Vasoft\VersionIncrement\Exceptions\IncorrectChangeTypeException;
12
use Vasoft\VersionIncrement\Exceptions\UncommittedException;
13
use Vasoft\VersionIncrement\Contract\GetExecutorInterface;
14
15
class SemanticVersionUpdater
16
{
17
    private GetExecutorInterface $gitExecutor;
18
    private array $availableTypes = [
19
        'major',
20
        'minor',
21
        'patch',
22
    ];
23
    private bool $isBreaking = false;
24
25 21
    public function __construct(
26
        private readonly string $projectPath,
27
        private readonly Config $config,
28
        private string $changeType = '',
29
        ?GetExecutorInterface $gitExecutor = null,
30
    ) {
31 21
        $this->gitExecutor = $gitExecutor ?? new GitExecutor();
32
    }
33
34
    /**
35
     * @throws IncorrectChangeTypeException
36
     */
37 21
    private function checkChangeType(): void
38
    {
39 21
        if ('' !== $this->changeType && !in_array($this->changeType, $this->availableTypes, true)) {
40 1
            throw  new IncorrectChangeTypeException($this->changeType);
41
        }
42
    }
43
44
    /**
45
     * @throws ComposerException
46
     */
47 20
    public function getComposerJson(): array
48
    {
49 20
        $composer = $this->projectPath . '/composer.json';
50 20
        if (!file_exists($composer)) {
51 1
            throw new ComposerException();
52
        }
53
54
        try {
55 19
            $result = json_decode(file_get_contents($composer), true, 512, JSON_THROW_ON_ERROR);
56 1
        } catch (\JsonException $e) {
57 1
            throw new ComposerException('JSON: ' . $e->getMessage());
58
        }
59
60 18
        return $result;
61
    }
62
63
    /**
64
     * @throws BranchException
65
     * @throws ChangesNotFoundException
66
     * @throws ComposerException
67
     * @throws GitCommandException
68
     * @throws IncorrectChangeTypeException
69
     * @throws UncommittedException
70
     */
71 21
    public function updateVersion(): void
72
    {
73 21
        $this->checkChangeType();
74 20
        $composerJson = $this->getComposerJson();
75 18
        $this->checkGitBranch();
76 17
        $this->checkUncommittedChanges();
77
78 15
        $lastTag = $this->gitExecutor->getLastTag();
79 15
        $commits = $this->gitExecutor->getCommitsSinceLastTag($lastTag);
80 15
        $sections = $this->analyzeCommits($commits);
81 14
        $this->detectionTypeChange($sections);
82
83 14
        $currentVersion = $composerJson['version'] ?? '1.0.0';
84
85 14
        $newVersion = $this->updateComposerVersion($currentVersion, $this->changeType);
86
87 14
        $composerJson['version'] = $newVersion;
88 14
        file_put_contents(
89 14
            $this->projectPath . '/composer.json',
90 14
            json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
91 14
        );
92 14
        $date = date('Y-m-d');
93 14
        $changelog = $this->generateChangelog($sections, $newVersion, $date);
94 14
        $fileChangelog = $this->projectPath . '/CHANGELOG.md';
95 14
        if (file_exists($fileChangelog)) {
96 13
            $changeLogContent = file_get_contents('CHANGELOG.md');
97 13
            $changeLogAddToGit = false;
98
        } else {
99 1
            $changeLogContent = '';
100 1
            $changeLogAddToGit = true;
101
        }
102 14
        file_put_contents($fileChangelog, $changelog . $changeLogContent);
103 14
        if ($changeLogAddToGit) {
104 1
            $this->gitExecutor->addFile('CHANGELOG.md');
105
        }
106 14
        $releaseScope = trim($this->config->getReleaseScope());
107 14
        if ('' !== $releaseScope) {
108 13
            $releaseScope = sprintf('(%s)', $releaseScope);
109
        }
110 14
        $this->gitExecutor->commit(
111 14
            sprintf(
112 14
                '%s%s: v%s',
113 14
                $this->config->getReleaseSection(),
114 14
                $releaseScope,
115 14
                $newVersion,
116 14
            ),
117 14
        );
118 14
        $this->gitExecutor->setVersionTag($newVersion);
119 14
        echo "Release {$newVersion} successfully created!\n";
120
    }
121
122 14
    private function detectionTypeChange(array $sections): void
123
    {
124 14
        if ('' === $this->changeType) {
125 14
            if ($this->isBreaking) {
126 2
                $this->changeType = 'major';
127
            } else {
128 12
                $this->changeType = 'patch';
129 12
                if ($this->hasTypedCommits($sections, $this->config->getMajorTypes())) {
130 1
                    $this->changeType = 'major';
131 11
                } elseif ($this->hasTypedCommits($sections, $this->config->getMinorTypes())) {
132 10
                    $this->changeType = 'minor';
133
                }
134
            }
135
        }
136
    }
137
138 12
    private function hasTypedCommits(array $sections, array $keys): bool
139
    {
140 12
        foreach ($keys as $key) {
141 12
            if (!empty($sections[$key])) {
142 11
                return true;
143
            }
144
        }
145
146 11
        return false;
147
    }
148
149
    /**
150
     * @throws GitCommandException
151
     * @throws UncommittedException
152
     */
153 17
    private function checkUncommittedChanges(): void
154
    {
155 17
        $out = $this->gitExecutor->status();
156 17
        if ($this->config->mastIgnoreUntrackedFiles()) {
157 2
            $out = array_filter($out, static fn(string $item): bool => !str_starts_with($item, '??'));
158
        }
159
160 17
        if (!empty($out)) {
161 2
            throw new UncommittedException();
162
        }
163
    }
164
165
    /**
166
     * @throws BranchException
167
     * @throws GitCommandException
168
     */
169 18
    private function checkGitBranch(): void
170
    {
171 18
        $currentBranch = $this->gitExecutor->getCurrentBranch();
172 18
        $targetBranch = $this->config->getMasterBranch();
173 18
        if ($currentBranch !== $targetBranch) {
174 1
            throw new BranchException($currentBranch, $targetBranch);
175
        }
176
    }
177
178
    /**
179
     * @throws ChangesNotFoundException
180
     */
181 15
    private function analyzeCommits(array $commits): array
182
    {
183 15
        if (empty($commits)) {
184 1
            throw new ChangesNotFoundException();
185
        }
186 14
        $sections = $this->config->getSectionIndex();
187 14
        $aggregateKey = $this->config->getAggregateSection();
188 14
        $shouldProcessDefaultSquashedCommit = $this->config->shouldProcessDefaultSquashedCommit();
189 14
        $squashedCommitMessage = $this->config->getSquashedCommitMessage();
190 14
        $defaultSectionNotHidden = !$this->config->isSectionHidden(Config::DEFAULT_SECTION);
191 14
        foreach ($commits as $commit) {
192
            if (
193 14
                $shouldProcessDefaultSquashedCommit
194 14
                && str_ends_with($commit, $squashedCommitMessage)
195 14
                && preg_match('/^(?<hash>[^ ]+).+/', $commit, $matches)
196
            ) {
197 2
                $this->processAggregated($matches['hash'], $sections);
198
199 2
                continue;
200
            }
201 14
            if (preg_match(
202 14
                '/^(?<hash>[^ ]+) (?<key>[a-z]+)(?:\((?<scope>[^\)]+)\))?(?<breaking>!)?:\s+(?<message>.+)/',
203 14
                $commit,
204 14
                $matches,
205 14
            )) {
206 13
                $this->analyzeFlags($matches['breaking']);
207 13
                $key = trim($matches['key']);
208 13
                if ($aggregateKey === $key) {
209 1
                    $this->processAggregated($matches['hash'], $sections);
210
                } else {
211 13
                    $rawMessage = false;
212 13
                    $key = $this->detectionSection(
213 13
                        $sections,
214 13
                        $matches['key'],
215 13
                        $matches['scope'],
216 13
                        [$matches['breaking']],
217 13
                        $matches['message'],
218 13
                        $rawMessage,
219 13
                    );
220 13
                    if (!$this->config->isSectionHidden($key)) {
221 13
                        $sections[$key][] = $rawMessage
222 11
                            ? trim(preg_replace('/^[^ ]+ /', '', $commit))
223 13
                            : $matches['message'];
224
                    }
225
                }
226 1
            } elseif ($defaultSectionNotHidden) {
227 1
                $sections[Config::DEFAULT_SECTION][] = trim(preg_replace('/^[^ ]+ /', '', $commit));
228
            }
229
        }
230
231 14
        return $sections;
232
    }
233
234 3
    private function processAggregated(string $hash, array &$sections): void
235
    {
236 3
        $description = $this->gitExecutor->getCommitDescription($hash);
237 3
        foreach ($description as $line) {
238 3
            $matches = [];
239 3
            if (preg_match(
240 3
                "/^[\t *-]*((?<key>[a-z]+)(?:\\((?<scope>[^\\)]+)\\))?(?<breaking>!)?:\\s+(?<message>.+))/",
241 3
                $line,
242 3
                $matches,
243 3
            )) {
244 3
                $this->analyzeFlags($matches['breaking']);
245 3
                $rawMessage = false;
246 3
                $key = $this->detectionSection(
247 3
                    $sections,
248 3
                    $matches['key'],
249 3
                    $matches['scope'],
250 3
                    [$matches['breaking']],
251 3
                    $matches['message'],
252 3
                    $rawMessage,
253 3
                );
254 3
                if (!$this->config->isSectionHidden($key)) {
255 3
                    $sections[$key][] = $rawMessage ? trim($line) : $matches['message'];
256
                }
257
            }
258
        }
259
    }
260
261 13
    private function detectionSection(
262
        array $sections,
263
        string $key,
264
        string $scope,
265
        array $flags,
266
        string $message,
267
        bool &$rawMessage,
268
    ): string {
269 13
        foreach ($sections as $index => $values) {
270 13
            $rules = $this->config->getSectionRules($index);
271 13
            foreach ($rules as $rule) {
272 13
                if ($rule($key, $scope, $flags, $message)) {
273 13
                    return $index;
274
                }
275
            }
276
        }
277 11
        $rawMessage = true;
278
279 11
        return Config::DEFAULT_SECTION;
280
    }
281
282 13
    private function analyzeFlags(string $flags): void
283
    {
284 13
        if ('!' === $flags) {
285 2
            $this->isBreaking = true;
286
        }
287
    }
288
289 14
    private function generateChangelog(array $sections, string $version, string $date): string
290
    {
291 14
        $changelog = "# {$version} ({$date})\n\n";
292
293 14
        foreach ($sections as $key => $messages) {
294 14
            if (!empty($messages)) {
295 14
                $changelog .= sprintf("### %s\n", $this->config->getSectionTitle($key));
296 14
                foreach ($messages as $message) {
297 14
                    $changelog .= "- {$message}\n";
298
                }
299 14
                $changelog .= "\n";
300
            }
301
        }
302
303 14
        return $changelog;
304
    }
305
306 14
    private function updateComposerVersion(string $currentVersion, string $changeType): string
307
    {
308 14
        [$major, $minor, $patch] = explode('.', $currentVersion);
309
310
        switch ($changeType) {
311 14
            case 'major':
312 3
                $major++;
313 3
                $minor = 0;
314 3
                $patch = 0;
315 3
                break;
316 11
            case 'minor':
317 10
                $minor++;
318 10
                $patch = 0;
319 10
                break;
320 1
            case 'patch':
321
            default:
322 1
                $patch++;
323 1
                break;
324
        }
325
326 14
        return "{$major}.{$minor}.{$patch}";
327
    }
328
}
329