From cb524aa7c0322d3565bd3ee8b5f474aecefdd9f1 Mon Sep 17 00:00:00 2001 From: Marco Cesarato Date: Sun, 17 Jan 2021 00:57:59 +0100 Subject: [PATCH] chore(release): first release --- .gitattributes | 1 + .gitignore | 3 + .php_cs | 41 ++++ README.md | 30 +++ bin/conventional-changelog | 19 ++ composer.json | 48 ++++ src/Generator.php | 475 +++++++++++++++++++++++++++++++++++++ 7 files changed, 617 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .php_cs create mode 100644 README.md create mode 100644 bin/conventional-changelog create mode 100644 composer.json create mode 100644 src/Generator.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..85564bd --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6198620 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor/** +.idea +.php_cs.cache \ No newline at end of file diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..b60591e --- /dev/null +++ b/.php_cs @@ -0,0 +1,41 @@ +setUsingCache(true) + ->setRiskyAllowed(true) + ->setCacheFile(__DIR__ . '/.php_cs.cache') + ->setRules(array( + '@PSR1' => true, + '@PSR2' => true, + '@Symfony' => true, + 'psr4' => true, + // Custom rules + 'align_multiline_comment' => array('comment_type' => 'phpdocs_only'), // PSR-5 + 'phpdoc_to_comment' => false, + 'array_indentation' => true, + 'array_syntax' => array('syntax' => 'short'), + 'cast_spaces' => array('space' => 'none'), + 'concat_space' => array('spacing' => 'one'), + 'compact_nullable_typehint' => true, + 'declare_equal_normalize' => array('space' => 'single'), + 'increment_style' => array('style' => 'post'), + 'list_syntax' => array('syntax' => 'long'), + 'no_short_echo_tag' => true, + 'phpdoc_align' => false, + 'phpdoc_no_empty_return' => false, + 'phpdoc_order' => true, // PSR-5 + 'phpdoc_no_useless_inheritdoc' => false, + 'protected_to_private' => false, + 'yoda_style' => false, + 'method_argument_space' => array('on_multiline' => 'ensure_fully_multiline'), + 'ordered_imports' => array( + 'sort_algorithm' => 'alpha', + 'imports_order' => array('class', 'const', 'function') + ), + )) + ->setFinder(PhpCsFixer\Finder::create() + ->in(__DIR__) + ->name('*.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true)); \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0afb63e --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# PHP Conventional Changelog + +## 📖 Installation + +You can install it easily with composer + +`composer require --dev marcocesarato/php-conventional-changelog` + +## 💻 Usage + +Generate a changelog without committing files: + +`php vendor/bin/conventional-changelog` + +or with auto commit and auto version tagging: + +`php vendor/bin/conventional-changelog --commit` + +### Commands List + +``` +-c --commit bool Commit the new release once changelog is generated +-f --from-date str Get commits from specified date +-h --help bool Show the helper with all commands available +-m --major bool Major release (important changes) +-n --minor bool Minor release (add functionality) +-p --patch bool Patch release (bug fixes) +-t --to-date str Get commits from today (or specified on --from-date) to specified date +-v --version str Specify next release version code (Semver) +``` \ No newline at end of file diff --git a/bin/conventional-changelog b/bin/conventional-changelog new file mode 100644 index 0000000..64b23ee --- /dev/null +++ b/bin/conventional-changelog @@ -0,0 +1,19 @@ +#!/usr/bin/env php +run(); \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..11f5d68 --- /dev/null +++ b/composer.json @@ -0,0 +1,48 @@ +{ + "name": "marcocesarato/php-conventional-changelog", + "description": "Generate changelogs and release notes from a project's commit messages and metadata using php composer.", + "type": "library", + "license": "GPL-3.0-or-later", + "minimum-stability": "stable", + "bin": [ + "conventional-changelog" + ], + "keywords": [ + "conventional-changelog", + "readme", + "generation", + "git", + "php", + "conventional-commit", + "conventional-commits", + "conventionalcommits", + "changelog", + "history", + "tag", + "commit", + "commits", + "conventional", + "convention", + "conventional-changelog-preset" + ], + "authors": [ + { + "name": "Marco Cesarato", + "email": "cesarato.developer@gmail.com" + } + ], + "autoload": { + "psr-4": { + "ConventionalChangelog\\": "src/" + } + }, + "scripts": { + "fix-cs": "vendor/bin/php-cs-fixer fix --config=.php_cs" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.17" + }, + "require": { + "php": ">=5.5" + } +} diff --git a/src/Generator.php b/src/Generator.php new file mode 100644 index 0000000..a66d42f --- /dev/null +++ b/src/Generator.php @@ -0,0 +1,475 @@ + ['code' => 'feat', 'label' => 'Features'], + 'fix' => ['code' => 'fix', 'label' => 'Fixes'], + 'perf' => ['code' => 'perf', 'label' => 'Performance Features'], + 'refactor' => ['code' => 'refactor', 'label' => 'Refactoring'], + 'docs' => ['code' => 'docs', 'label' => 'Docs'], + 'chore' => ['code' => 'chore', 'label' => 'Chores'],]; + + // Definitions + define('PHP_EOL2', PHP_EOL . PHP_EOL); + $DS = DIRECTORY_SEPARATOR; + + // Changelog header + // PS: When change it remember to also remove it from the changelog file before running it + $changelogHeader = <<arg($helper); + + // Help command + $help = $this->arg('help', false); + if ($help) { + exit("\n======= CHANGELOG HELPER =======\n\n{$helper}\n"); + } + + $autoCommit = $this->arg('commit', false); // Commit once changelog is generated + $fromDate = $this->arg('from-date', null); + $toDate = $this->arg('to-date', null); + + $patchRelease = $this->arg('patch', false); + $minorRelease = $this->arg('minor', false); + $majorRelease = $this->arg('major', false); + + // Current Dates + $today = new DateTime(); + $todayString = $this->getDateString($today); + + // Last version + $lastVersion = shell_exec('git describe --tags --abbrev=0'); + $lastVersion = $this->clean($lastVersion); + + // Last version commit + $lastVersionCommit = shell_exec("git rev-parse --verify {$lastVersion}"); + $lastVersionCommit = $this->clean($lastVersionCommit); + + // Last version date + $lastVersionDate = shell_exec("git log -1 --format=%ai {$lastVersion}"); + $lastVersionDate = $this->clean($lastVersionDate); + + // Generate new version code + $newVersion = $this->increaseSemVer($lastVersion, $majorRelease, $minorRelease, $patchRelease); + $newVersion =$this-> arg('version', $newVersion); + $newVersion = preg_replace('#^v#i', '', $newVersion); + + // Remote url + $url = shell_exec('git config --get remote.origin.url'); + $url = $this->clean($url); + $url = preg_replace("/\.git$/", '', $url); + $url = preg_replace('/^(https?:\/\/)([0-9a-z.\-_:%]+@)/i', '$1', $url); + + // Get latest commits from last version date to current date + $additionalParams = "{$lastVersion}..HEAD"; + if (!empty($fromDate) || + !empty($toDate)) { + $additionalParams = ''; + if (!empty($fromDate)) { + $additionalParams .= ' --since="' . date('Y-m-d', strtotime($fromDate)) . '"'; + } + if (!empty($toDate)) { + $time = strtotime($toDate); + $additionalParams .= ' --before="' . date('Y-m-d', $time) . '"'; + $today->setTimestamp($time); + $todayString = $this->getDateString($today); + } + } + + $gitLog = shell_exec("git log --format=%B%H----DELIMITER---- {$additionalParams}"); + $commitsRaw = explode("----DELIMITER----\n", $gitLog); + + // Get all commits information + $commits = []; + foreach ($commitsRaw as $commit) { + $rows = explode("\n", $commit); + $count = count($rows); + // Commit info + $head = $this->clean($rows[0]); + $sha = $this->clean($rows[$count - 1]); + $message = ''; + // Get message + for ($i = 0; $i < $count; $i++) { + $row = $rows[$i]; + if ($i !== 0 && $i !== $count - 1) { + $message .= $row . "\n"; + } + } + // Check ignored commit + $ignore = false; + foreach ($ignorePatterns as $pattern) { + if (preg_match($pattern, $head)) { + $ignore = true; + break; + } + } + // Add commit + if (!empty($sha) && !$ignore) { + $commits[] = [ + 'sha' => $sha, + 'head' => $head, + 'message' => $this->clean($message), + ]; + } + } + + // Changes groups + $changes = []; + foreach ($types as $key => $type) { + $changes[$key] = []; + } + + // Group all changes to lists by type + foreach ($commits as $commit) { + foreach ($types as $key => $type) { + $head = $this->clean($commit['head']); + $code = preg_quote($type['code'], '/'); + if (preg_match('/^' . $code . '(\(.*?\))?[:]?\\s/i', $head)) { + $parse = $this->parseCommitHead($head, $type['code']); + $context = $parse['context']; + $description = $parse['description']; + $sha = $commit['sha']; + $short = substr($sha, 0, 6); + // List item + $itemKey = strtolower(preg_replace('/[^a-zA-Z0-9_-]+/', '', $description)); + $changes[$key][$context][$itemKey][$sha] = [ + 'description' => $description, + 'short' => $short, + 'url' => $url, + 'sha' => $sha, + ]; + } + } + } + + // File + $file = "{$root}{$DS}{$changelogFilename}"; + + // Initialize changelogs + $changelogCurrent = ''; + $changelogNew = "## [{$newVersion}]($url/compare/{$lastVersion}...v{$newVersion}) ({$today->format('Y-m-d')})\n\n"; + + // Get changelogs content + if (file_exists($file)) { + $header = ltrim($changelogHeader); + $header = preg_quote($header, '/'); + $changelogCurrent = file_get_contents($file); + $changelogCurrent = ltrim($changelogCurrent); + $changelogCurrent = preg_replace("/^$header/i", '', $changelogCurrent); + } + + // Add all changes list to new changelog + $changelogNew .= $this->getMarkdownChanges($changes); + + // Save new changelog prepending the current one + file_put_contents($file, "{$changelogHeader}{$changelogNew}{$changelogCurrent}"); + + // Create commit and add tag + if ($autoCommit) { + system("git commit -m \"chore(release): {$newVersion}\""); + system("git tag v{$newVersion}"); + } + } + + /** + * Generate markdown from changes. + * + * @param $changes + * + * @return string + */ + function getMarkdownChanges($changes) + { + global $types; + $changelog = ''; + // Add all changes list to new changelog + foreach ($changes as $type => $list) { + if (empty($list)) { + continue; + } + ksort($list); + $changelog .= PHP_EOL . "### {$types[$type]['label']}" . PHP_EOL2; + foreach ($list as $context => $items) { + asort($items); + if (is_string($context) && !empty($context)) { + // Context section + $changelog .= PHP_EOL . "##### {$context}" . PHP_EOL2; + } + foreach ($items as $itemsList) { + $description = ''; + $sha = ''; + $shaGroup = []; + foreach ($itemsList as $item) { + $description = $item['description']; + if (!empty($item['sha'])) { + $shaGroup[] = "[{$item['short']}]({$item['url']}/commit/{$item['sha']})"; + } + } + if (!empty($shaGroup)) { + $sha = '(' . implode(', ', $shaGroup) . ')'; + } + $changelog .= "* {$description} {$sha}\n"; + } + } + } + // Add version separator + $changelog .= PHP_EOL . '---' . PHP_EOL2; + + return $changelog; + } + + /** + * Parse conventional commit head. + * + * @param string $message + * @param string $type + * + * @return array + */ + function parseCommitHead($head, $type) + { + $parse = [ + 'context' => null, + 'description' => $this->clean($head), + ]; + + $descriptionType = preg_quote(substr($parse['description'], 0, strlen($type)), '/'); + $parse['description'] = preg_replace('/^' . $descriptionType . '[:]?\s*/i', '', $parse['description']); + $parse['description'] = preg_replace('/^\((.*?)\)[!]?[:]?\s*/', '**$1**: ', $this->clean($parse['description'])); + $parse['description'] = preg_replace('/\s+/m', ' ', $parse['description']); + + // Set context + if (preg_match("/^\*\*(.*?)\*\*:(.*?)$/", $parse['description'], $match)) { + $parse['context'] = $this->clean($match[1]); + $parse['description'] = $this->clean($match[2]); + + // Unify context labels + $parse['context'] = preg_replace('/[_]+/m', ' ', $parse['context']); + $parse['context'] = ucfirst($parse['context']); + $parse['context'] = preg_replace('/((?<=\p{Ll})\p{Lu})|((?!\A)\p{Lu}(?>\p{Ll}))/u', ' $0', $parse['context']); + $parse['context'] = preg_replace('/\.(php|md|json|txt|csv)($|\s)/', '', $parse['context']); + $parse['context'] = $this->clean($parse['context']); + } + + $parse['description'] = ucfirst($parse['description']); + + return $parse; + } + + /** + * Clean string removing double spaces and trim. + * + * @param $string + * + * @return string + */ + protected function clean($string) + { + $string = trim($string); + + return preg_replace('/\s+/m', ' ', $string); + } + + /** + * Get today date string with italian format. + * + * @param DateTime $today + * + * @return string + */ + protected function getDateString($date) + { + $months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'Semptember', + 'October', + 'Novembrer', + 'December', + ]; + $day = date('j', $date->getTimestamp()); + $month = date('n', $date->getTimestamp()); + $monthName = $months[$month - 1]; + $year = date('Y', $date->getTimestamp()); + + return $day . ' ' . $monthName . ' ' . $year; + } + + /** + * Increase SemVer. + * + * @param $version + * @param false $major + * @param false $minor + * @param bool $patch + * + * @return string + */ + protected function increaseSemVer($version, $major = false, $minor = false, $patch = false) + { + $newVersion = [0, 0, 0]; + $increaseKeys = []; + + $version = preg_replace('#^v#i', '', $version); + + // Generate new version code + $parts = explode('.', $version); + + foreach ($parts as $key => $value) { + $newVersion[$key] = (int)$value; + } + + // Increase major + if ($major) { + $increaseKeys[] = 0; + } + + // Increase minor + if ($minor) { + $increaseKeys[] = 1; + } + + // Increase patch + if ($patch || (!$major && !$minor && !$patch)) { + $increaseKeys[] = 2; + } + + foreach ($increaseKeys as $key) { + $newVersion[$key]++; + } + + // Recompose semver + return implode('.', $newVersion); + } + + /** + * Argument. + * + * @param string $x + * @param null $default + * + * @return array|mixed|null + */ + protected function arg($x = '', $default = null) + { + static $arginfo = []; + + /* helper */ + $contains = function ($h, $n) { + return false !== strpos($h, $n); + }; + /* helper */ + $valuesOf = function ($s) { + return explode(',', $s); + }; + + // called with a multiline string --> parse arguments + if ($contains($x, "\n")) { + // parse multiline text input + $args = $GLOBALS['argv'] ?: []; + $rows = preg_split('/\s*\n\s*/', trim($x)); + $data = $valuesOf('char,word,type,help'); + foreach ($rows as $row) { + list($char, $word, $type, $help) = preg_split('/\s\s+/', $row); + $char = trim($char, '-'); + $word = trim($word, '-'); + $key = $word ?: $char ?: ''; + if ($key === '') { + continue; + } + $arginfo[$key] = compact($data); + $arginfo[$key]['value'] = null; + } + + $nr = 0; + while ($args) { + $x = array_shift($args); + if ($x[0] != '-') { + $arginfo[$nr++]['value'] = $x; + continue; + } + $x = ltrim($x, '-'); + $v = null; + if ($contains($x, '=')) { + list($x, $v) = explode('=', $x, 2); + } + $k = ''; + foreach ($arginfo as $k => $arg) { + if (($arg['char'] == $x) || ($arg['word'] == $x)) { + break; + } + } + $t = $arginfo[$k]['type']; + switch ($t) { + case 'bool': + $v = true; + break; + case 'str': + if (is_null($v)) { + $v = array_shift($args); + } + break; + case 'int': + if (is_null($v)) { + $v = array_shift($args); + } + $v = (int)$v; + break; + } + $arginfo[$k]['value'] = $v; + } + + return $arginfo; + } + + // called with a question --> read argument value + if ($x === '') { + return $arginfo; + } + if (isset($arginfo[$x]['value'])) { + return $arginfo[$x]['value']; + } + + return $default; + } +} \ No newline at end of file