Passed
Push — master ( 6073e7...d981da )
by Alexander
02:02
created

SemanticVersionUpdater::analyzeFlags()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

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