├── .github ├── FUNDING.yml └── workflows │ └── repo-lockdown.yaml ├── LICENSE ├── bin ├── changelog-linker └── changelog-linker.php ├── composer.json ├── config └── config.php └── src ├── Analyzer ├── IdsAnalyzer.php ├── LinksAnalyzer.php └── VersionsAnalyzer.php ├── Application └── ChangelogLinkerApplication.php ├── ChangeTree ├── ChangeFactory.php ├── ChangeResolver.php ├── ChangeSorter.php └── Resolver │ ├── CategoryResolver.php │ └── PackageResolver.php ├── ChangelogCleaner.php ├── ChangelogDumper.php ├── ChangelogFormatter.php ├── ChangelogLinker.php ├── Configuration └── HighestMergedIdResolver.php ├── Console └── Command │ ├── CleanupCommand.php │ ├── DumpMergesCommand.php │ └── LinkCommand.php ├── Contract └── Worker │ └── WorkerInterface.php ├── DependencyInjection └── CompilerPass │ └── AddRepositoryUrlAndRepositoryNameParametersCompilerPass.php ├── Exception ├── Git │ └── InvalidGitRemoteException.php ├── Github │ └── GithubApiException.php └── MissingPlaceholderInChangelogException.php ├── FileSystem ├── ChangelogFileSystem.php └── ChangelogPlaceholderGuard.php ├── Git └── GitCommitDateTagResolver.php ├── Github ├── GithubApi.php └── GithubRepositoryFromRemoteResolver.php ├── Guzzle └── ResponseFormatter.php ├── HttpKernel └── ChangelogLinkerKernel.php ├── LinkAppender.php ├── ValueObject ├── Category.php ├── ChangeTree │ └── Change.php ├── ChangelogFormat.php ├── Option.php ├── PackageName.php └── RegexPattern.php └── Worker ├── BracketsAroundReferencesWorker.php ├── DiffLinksToVersionsWorker.php ├── LinkifyWorker.php ├── LinksToReferencesWorker.php └── UserReferencesWorker.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: tomasvotruba 3 | custom: https://www.paypal.me/rectorphp 4 | -------------------------------------------------------------------------------- /.github/workflows/repo-lockdown.yaml: -------------------------------------------------------------------------------- 1 | # see https://github.com/dear-github/dear-github/issues/84#issuecomment-696475017 2 | name: 'Repo Lockdown' 3 | 4 | on: 5 | pull_request: null 6 | 7 | jobs: 8 | lockdown: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - 12 | # see https://github.com/marketplace/actions/repo-lockdown 13 | uses: dessant/repo-lockdown@v2 14 | with: 15 | github-token: ${{ secrets.ACCESS_TOKEN }} 16 | pr-comment: | 17 | Hi, thank you for your contribution. 18 | 19 | Unfortunately, this repository is read-only. It's a split from our main monorepo repository. 20 | 21 | We'd like to kindly ask you to move the contribution there - https://github.com/symplify/symplify. 22 | 23 | We'll check it, review it and give you feed back right way. 24 | 25 | Thank you 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | --------------- 3 | 4 | Copyright (c) 2017 Tomas Votruba (https://tomasvotruba.com) 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the "Software"), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /bin/changelog-linker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | resolveFromInputWithFallback(new ArgvInput(), ['changelog-linker.php']); 33 | 34 | if ($inputConfigFileInfo !== null) { 35 | $configFileInfos[] = $inputConfigFileInfo; 36 | } 37 | 38 | $kernelBootAndApplicationRun = new KernelBootAndApplicationRun(ChangelogLinkerKernel::class, $configFileInfos); 39 | $kernelBootAndApplicationRun->run(); 40 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symplify/changelog-linker", 3 | "description": "Generates beautiful CHANGELOG.md with links to PRs, versions and users grouped in Added/Changed/Fixed/Removed categories.", 4 | "license": "MIT", 5 | "bin": [ 6 | "bin/changelog-linker" 7 | ], 8 | "require": { 9 | "php": ">=7.3", 10 | "guzzlehttp/guzzle": "^7.2", 11 | "nette/utils": "^3.2", 12 | "symfony/console": "^4.4|^5.2", 13 | "symfony/http-kernel": "^4.4|^5.2", 14 | "symfony/process": "^4.4|^5.2", 15 | "symplify/package-builder": "^9.3", 16 | "symplify/smart-file-system": "^9.3", 17 | "symplify/set-config-resolver": "^9.3", 18 | "symplify/symplify-kernel": "^9.3" 19 | }, 20 | "require-dev": { 21 | "phpunit/phpunit": "^9.5" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Symplify\\ChangelogLinker\\": "src" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "Symplify\\ChangelogLinker\\Tests\\": "tests" 31 | } 32 | }, 33 | "extra": { 34 | "branch-alias": { 35 | "dev-master": "9.3-dev" 36 | } 37 | }, 38 | "minimum-stability": "dev", 39 | "prefer-stable": true 40 | } 41 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | parameters(); 14 | 15 | $parameters->set(Option::AUTHORS_TO_IGNORE, []); 16 | $parameters->set(Option::NAMES_TO_URLS, []); 17 | $parameters->set(Option::PACKAGE_ALIASES, []); 18 | $parameters->set('env(GITHUB_TOKEN)', null); 19 | $parameters->set(Option::GITHUB_TOKEN, '%env(GITHUB_TOKEN)%'); 20 | $parameters->set(Option::CHANGELOG_FORMAT, ChangelogFormat::BARE); 21 | $parameters->set(Option::FILE, getcwd() . '/CHANGELOG.md'); 22 | 23 | $services = $containerConfigurator->services(); 24 | 25 | $services->defaults() 26 | ->autowire() 27 | ->autoconfigure() 28 | ->public(); 29 | 30 | $services->set(FileSystemGuard::class); 31 | 32 | $services->load('Symplify\\ChangelogLinker\\', __DIR__ . '/../src') 33 | ->exclude([ 34 | __DIR__ . '/../src/HttpKernel', 35 | __DIR__ . '/../src/DependencyInjection/CompilerPass', 36 | __DIR__ . '/../src/Exception', 37 | __DIR__ . '/../src/ValueObject', 38 | ]) 39 | ; 40 | 41 | $services->set(Client::class); 42 | $services->alias(ClientInterface::class, Client::class); 43 | }; 44 | -------------------------------------------------------------------------------- /src/Analyzer/IdsAnalyzer.php: -------------------------------------------------------------------------------- 1 | 5 20 | * - [#10] Change that => 10 21 | */ 22 | private const PR_REFERENCE_IN_LIST_REGEX = '#- \[?(\#(?\d+))\]?#'; 23 | 24 | public function getHighestIdInChangelog(string $content): int 25 | { 26 | $ids = $this->getAllIdsInChangelog($content); 27 | if ($ids === null) { 28 | return 0; 29 | } 30 | if ($ids === []) { 31 | return 0; 32 | } 33 | 34 | return (int) max($ids); 35 | } 36 | 37 | public function getAllIdsInChangelog(string $content): ?array 38 | { 39 | $matches = Strings::matchAll($content, self::PR_REFERENCE_IN_LIST_REGEX); 40 | if ($matches === []) { 41 | return null; 42 | } 43 | return array_column($matches, 'id'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Analyzer/LinksAnalyzer.php: -------------------------------------------------------------------------------- 1 | [(-\/@\w\d\.]+)\](?!:)(?!\()#'; 20 | 21 | /** 22 | * @var string[] 23 | */ 24 | private $linkedIds = []; 25 | 26 | /** 27 | * @var string[] 28 | */ 29 | private $references = []; 30 | 31 | public function analyzeContent(string $content): void 32 | { 33 | // [content]: url 34 | $this->linkedIds = []; 35 | $matches = Strings::matchAll($content, RegexPattern::LINK_REFERENCE_REGEX); 36 | foreach ($matches as $match) { 37 | $this->linkedIds[] = $match['reference']; 38 | } 39 | $this->linkedIds = array_unique($this->linkedIds); 40 | 41 | // [content] 42 | $this->references = []; 43 | $matches = Strings::matchAll($content, self::REFERENCE_REGEX); 44 | foreach ($matches as $match) { 45 | $this->references[] = $match['reference']; 46 | } 47 | $this->references = array_unique($this->references); 48 | } 49 | 50 | public function hasLinkedId(string $id): bool 51 | { 52 | return in_array($id, $this->linkedIds, true); 53 | } 54 | 55 | /** 56 | * @return string[] 57 | */ 58 | public function getDeadLinks(): array 59 | { 60 | $deadLinks = array_diff($this->linkedIds, $this->references); 61 | 62 | // special link, that needs to be kept 63 | $commentPosition = array_search('comment', $deadLinks, true); 64 | if ($commentPosition !== false) { 65 | unset($deadLinks[$commentPosition]); 66 | } 67 | 68 | return array_values($deadLinks); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Analyzer/VersionsAnalyzer.php: -------------------------------------------------------------------------------- 1 | versions = []; 23 | 24 | $matches = Strings::matchAll($content, '#\#\# (\[)?' . RegexPattern::VERSION_REGEX . '(\])?#'); 25 | foreach ($matches as $match) { 26 | $this->versions[] = $match['version']; 27 | } 28 | } 29 | 30 | /** 31 | * @return string[] 32 | */ 33 | public function getVersions(): array 34 | { 35 | return $this->versions; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Application/ChangelogLinkerApplication.php: -------------------------------------------------------------------------------- 1 | changeResolver = $changeResolver; 34 | $this->changelogDumper = $changelogDumper; 35 | $this->changelogLinker = $changelogLinker; 36 | } 37 | 38 | public function createContentFromPullRequestsBySortPriority(array $pullRequests, string $changelogFormat): string 39 | { 40 | $changes = $this->changeResolver->resolveSortedChangesFromPullRequestsWithSortPriority( 41 | $pullRequests, 42 | $changelogFormat 43 | ); 44 | 45 | $content = $this->changelogDumper->reportChangesWithHeadlines($changes, $changelogFormat); 46 | 47 | return $this->changelogLinker->processContent($content); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/ChangeTree/ChangeFactory.php: -------------------------------------------------------------------------------- 1 | gitCommitDateTagResolver = $gitCommitDateTagResolver; 59 | $this->categoryResolver = $categoryResolver; 60 | $this->authorsToIgnore = $parameterProvider->provideArrayParameter(Option::AUTHORS_TO_IGNORE); 61 | $this->packageResolver = $packageResolver; 62 | } 63 | 64 | /** 65 | * @param mixed[] $pullRequest 66 | */ 67 | public function createFromPullRequest(array $pullRequest): Change 68 | { 69 | $message = sprintf('- [#%s] %s', $pullRequest['number'], $this->escapeMarkdown($pullRequest[self::TITLE])); 70 | 71 | $author = $pullRequest['user']['login'] ?? ''; 72 | 73 | // skip the main maintainer to prevent self-thanking floods 74 | if ($author && ! in_array($author, $this->authorsToIgnore, true)) { 75 | $message .= ', Thanks to @' . $author; 76 | } 77 | 78 | $category = $this->categoryResolver->resolveCategory($pullRequest[self::TITLE]); 79 | $package = $this->packageResolver->resolvePackage($pullRequest[self::TITLE]); 80 | $messageWithoutPackage = $this->resolveMessageWithoutPackage($message, $package); 81 | 82 | // @todo 'merge_commit_sha' || 'head' 83 | $pullRequestTag = $this->gitCommitDateTagResolver->resolveCommitToTag($pullRequest['merge_commit_sha']); 84 | 85 | return new Change($message, $category, $package, $messageWithoutPackage, $pullRequestTag); 86 | } 87 | 88 | private function escapeMarkdown(string $content): string 89 | { 90 | $content = trim($content); 91 | 92 | return Strings::replace($content, self::ASTERISK_REGEX, '\\\$1'); 93 | } 94 | 95 | private function resolveMessageWithoutPackage(string $message, string $package): string 96 | { 97 | if ($package === PackageName::UNKNOWN) { 98 | return $message; 99 | } 100 | 101 | // can be aliased (not the $package variable), so we need to check any naming 102 | return Strings::replace($message, PackageResolver::PACKAGE_NAME_REGEX, ''); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/ChangeTree/ChangeResolver.php: -------------------------------------------------------------------------------- 1 | changeFactory = $changeFactory; 26 | $this->changeSorter = $changeSorter; 27 | } 28 | 29 | /** 30 | * @param mixed[] $pullRequests 31 | * @return Change[] 32 | */ 33 | public function resolveSortedChangesFromPullRequestsWithSortPriority( 34 | array $pullRequests, 35 | string $changelogFormat 36 | ): array { 37 | $changes = []; 38 | 39 | foreach ($pullRequests as $pullRequest) { 40 | $changes[] = $this->changeFactory->createFromPullRequest($pullRequest); 41 | } 42 | 43 | $changes = $this->filterOutUselessChanges($changes); 44 | 45 | return $this->changeSorter->sort($changes, $changelogFormat); 46 | } 47 | 48 | /** 49 | * @param Change[] $changes 50 | * @return Change[] 51 | */ 52 | private function filterOutUselessChanges(array $changes): array 53 | { 54 | return array_filter($changes, function (Change $change): bool { 55 | // skip new/fixed tests 56 | return ! Strings::match($change->getMessage(), RegexPattern::TEST_TITLE_REGEX); 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/ChangeTree/ChangeSorter.php: -------------------------------------------------------------------------------- 1 | compareTags($firstChange, $secondChange); 28 | if ($comparisonStatus !== 0) { 29 | return $comparisonStatus; 30 | } 31 | 32 | if ($changelogFormat === ChangelogFormat::PACKAGES_THEN_CATEGORIES) { 33 | return $this->comparePackagesOverCategories($firstChange, $secondChange); 34 | } 35 | 36 | return $this->compareCategoriesOverPackages($firstChange, $secondChange); 37 | }); 38 | 39 | return $changes; 40 | } 41 | 42 | private function compareTags(Change $firstChange, Change $secondChange): int 43 | { 44 | // v9999 => put "Unreleased" first 45 | $firstTag = $firstChange->getTag() === 'Unreleased' ? 'v9999' : $firstChange->getTag(); 46 | $secondTag = $secondChange->getTag() === 'Unreleased' ? 'v9999' : $secondChange->getTag(); 47 | 48 | // -1 => put higher first 49 | return -1 * version_compare($firstTag, $secondTag); 50 | } 51 | 52 | private function comparePackagesOverCategories(Change $firstChange, Change $secondChange): int 53 | { 54 | $compareStatus = $firstChange->getPackage() <=> $secondChange->getPackage(); 55 | if ($compareStatus !== 0) { 56 | return $compareStatus; 57 | } 58 | 59 | return $firstChange->getCategory() <=> $secondChange->getCategory(); 60 | } 61 | 62 | private function compareCategoriesOverPackages(Change $firstChange, Change $secondChange): int 63 | { 64 | $compareStatus = $firstChange->getCategory() <=> $secondChange->getCategory(); 65 | if ($compareStatus !== 0) { 66 | return $compareStatus; 67 | } 68 | 69 | return $firstChange->getPackage() <=> $secondChange->getPackage(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/ChangeTree/Resolver/CategoryResolver.php: -------------------------------------------------------------------------------- 1 | package-name 25 | * - "[aliased-package-name] "Message => aliased-package-name 26 | * - "[Aliased\PackageName] "Message => Aliased\PackageName 27 | */ 28 | public const PACKAGE_NAME_REGEX = '#\[(?[-\w\\\\]+)\]( ){1,}#'; 29 | 30 | /** 31 | * @var string 32 | */ 33 | private const PACKAGE = 'package'; 34 | 35 | /** 36 | * @var string[] 37 | */ 38 | private $packageAliases = []; 39 | 40 | public function __construct(ParameterProvider $parameterProvider) 41 | { 42 | $this->packageAliases = $parameterProvider->provideArrayParameter(Option::PACKAGE_ALIASES); 43 | } 44 | 45 | /** 46 | * E.g. "[ChangelogLinker] Add feature XY" => "ChangelogLinker" 47 | */ 48 | public function resolvePackage(string $message): string 49 | { 50 | $match = Strings::match($message, self::PACKAGE_NAME_REGEX); 51 | if (! isset($match[self::PACKAGE])) { 52 | return PackageName::UNKNOWN; 53 | } 54 | 55 | return $this->packageAliases[$match[self::PACKAGE]] ?? $match[self::PACKAGE]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ChangelogCleaner.php: -------------------------------------------------------------------------------- 1 | linksAnalyzer = $linksAnalyzer; 23 | } 24 | 25 | public function processContent(string $changelogContent): string 26 | { 27 | $this->linksAnalyzer->analyzeContent($changelogContent); 28 | 29 | $deadLinks = $this->linksAnalyzer->getDeadLinks(); 30 | 31 | foreach ($deadLinks as $deadLink) { 32 | $deadLinkPattern = '#\[\#?(' . preg_quote($deadLink, '#') . ')\]:(.*?)\n#'; 33 | $changelogContent = Strings::replace($changelogContent, $deadLinkPattern, ''); 34 | } 35 | 36 | return rtrim($changelogContent) . PHP_EOL; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/ChangelogDumper.php: -------------------------------------------------------------------------------- 1 | gitCommitDateTagResolver = $gitCommitDateTagResolver; 51 | $this->changelogFormatter = $changelogFormatter; 52 | } 53 | 54 | /** 55 | * @param Change[] $changes 56 | */ 57 | public function reportChangesWithHeadlines(array $changes, string $changelogFormat): string 58 | { 59 | $this->content .= PHP_EOL; 60 | 61 | foreach ($changes as $change) { 62 | $this->displayHeadlines($changelogFormat, $change); 63 | 64 | if (in_array( 65 | $changelogFormat, 66 | [ 67 | ChangelogFormat::PACKAGES_ONLY, 68 | ChangelogFormat::PACKAGES_THEN_CATEGORIES, 69 | ChangelogFormat::CATEGORIES_THEN_PACKAGES, 70 | ], 71 | true 72 | )) { 73 | $message = $change->getMessageWithoutPackage(); 74 | } else { 75 | $message = $change->getMessage(); 76 | } 77 | 78 | $this->content .= $message . PHP_EOL; 79 | } 80 | 81 | $this->content .= PHP_EOL; 82 | 83 | return $this->changelogFormatter->format($this->content); 84 | } 85 | 86 | private function displayHeadlines(string $changelogFormat, Change $change): void 87 | { 88 | $this->displayTag($change); 89 | 90 | if ($changelogFormat === ChangelogFormat::PACKAGES_THEN_CATEGORIES) { 91 | $this->displayPackageIfDesired($change, $changelogFormat); 92 | $this->displayCategoryIfDesired($change, $changelogFormat); 93 | } else { 94 | $this->displayCategoryIfDesired($change, $changelogFormat); 95 | $this->displayPackageIfDesired($change, $changelogFormat); 96 | } 97 | } 98 | 99 | private function displayTag(Change $change): void 100 | { 101 | if ($this->previousTag === $change->getTag()) { 102 | return; 103 | } 104 | 105 | $this->content .= '## ' . $this->createTagLine($change) . PHP_EOL; 106 | $this->previousTag = $change->getTag(); 107 | } 108 | 109 | private function displayPackageIfDesired(Change $change, string $changelogFormat): void 110 | { 111 | if ($changelogFormat === ChangelogFormat::BARE) { 112 | return; 113 | } 114 | 115 | if ($changelogFormat === ChangelogFormat::CATEGORIES_ONLY) { 116 | return; 117 | } 118 | 119 | if ($this->previousPackage === $change->getPackage()) { 120 | return; 121 | } 122 | 123 | $headlineLevel = $changelogFormat === ChangelogFormat::CATEGORIES_THEN_PACKAGES ? 4 : 3; 124 | $this->content .= str_repeat('#', $headlineLevel) . ' ' . $change->getPackage() . PHP_EOL; 125 | $this->previousPackage = $change->getPackage(); 126 | } 127 | 128 | private function displayCategoryIfDesired(Change $change, string $changelogFormat): void 129 | { 130 | if ($changelogFormat === ChangelogFormat::BARE) { 131 | return; 132 | } 133 | 134 | if ($changelogFormat === ChangelogFormat::PACKAGES_ONLY) { 135 | return; 136 | } 137 | 138 | if ($this->previousCategory === $change->getCategory()) { 139 | return; 140 | } 141 | 142 | $headlineLevel = $changelogFormat === ChangelogFormat::PACKAGES_THEN_CATEGORIES ? 4 : 3; 143 | $this->content .= str_repeat('#', $headlineLevel) . ' ' . $change->getCategory() . PHP_EOL; 144 | $this->previousCategory = $change->getCategory(); 145 | } 146 | 147 | private function createTagLine(Change $change): string 148 | { 149 | $tagLine = $change->getTag(); 150 | 151 | $tagDate = $this->gitCommitDateTagResolver->resolveDateForTag($change->getTag()); 152 | if ($tagDate) { 153 | $tagLine .= ' - ' . $tagDate; 154 | } 155 | 156 | return $tagLine; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/ChangelogFormatter.php: -------------------------------------------------------------------------------- 1 | [\#]{2,} [\w\d.\-/ ]+)$#m'; 19 | 20 | /** 21 | * @var string 22 | * @see https://regex101.com/r/GSqRiD/1 23 | */ 24 | private const TWO_LINES_START_REGEX = '#^(\n){2,}#'; 25 | 26 | /** 27 | * @var string 28 | * @see https://regex101.com/r/SEAAh7/1 29 | */ 30 | private const THREE_LINES_REGEX = '#(\n){3,}#'; 31 | 32 | /** 33 | * @var string 34 | */ 35 | private const HEADLINE_PART = 'headline'; 36 | 37 | public function format(string $content): string 38 | { 39 | $content = $this->wrapHeadlinesWithEmptyLines($content); 40 | $content = $this->removeSuperfluousSpaces($content); 41 | 42 | return ltrim($content); 43 | } 44 | 45 | private function wrapHeadlinesWithEmptyLines(string $content): string 46 | { 47 | return Strings::replace($content, self::HEADLINE_REGEX, function (array $match): string { 48 | return PHP_EOL . $match[self::HEADLINE_PART] . PHP_EOL; 49 | }); 50 | } 51 | 52 | private function removeSuperfluousSpaces(string $content): string 53 | { 54 | // 2 lines from the start 55 | $content = Strings::replace($content, self::TWO_LINES_START_REGEX, PHP_EOL); 56 | 57 | // 3 lines to 2 58 | return Strings::replace($content, self::THREE_LINES_REGEX, PHP_EOL . PHP_EOL); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/ChangelogLinker.php: -------------------------------------------------------------------------------- 1 | linksAnalyzer = $linksAnalyzer; 46 | $this->linkAppender = $linkAppender; 47 | $this->versionsAnalyzer = $versionsAnalyzer; 48 | $this->workers = $this->sortWorkers($workers); 49 | } 50 | 51 | public function processContent(string $content): string 52 | { 53 | $this->versionsAnalyzer->analyzeContent($content); 54 | $this->linksAnalyzer->analyzeContent($content); 55 | 56 | foreach ($this->workers as $worker) { 57 | $content = $worker->processContent($content); 58 | } 59 | 60 | return $content; 61 | } 62 | 63 | public function processContentWithLinkAppends(string $content): string 64 | { 65 | $content = $this->processContent($content); 66 | 67 | return $this->appendLinksToContentIfAny($content); 68 | } 69 | 70 | /** 71 | * @param WorkerInterface[] $workers 72 | * @return WorkerInterface[] 73 | */ 74 | private function sortWorkers(array $workers): array 75 | { 76 | usort($workers, function (WorkerInterface $firstWorker, WorkerInterface $secondWorker): int { 77 | return $secondWorker->getPriority() <=> $firstWorker->getPriority(); 78 | }); 79 | 80 | return $workers; 81 | } 82 | 83 | private function appendLinksToContentIfAny(string $content): string 84 | { 85 | $linksToAppend = $this->linkAppender->getLinksToAppend(); 86 | if ($linksToAppend !== []) { 87 | $content = rtrim($content) . PHP_EOL; 88 | $content .= $this->linkAppender->isExistingLinks() ? '' : PHP_EOL; 89 | $content .= implode(PHP_EOL, $linksToAppend) . PHP_EOL; 90 | } 91 | 92 | return $content; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Configuration/HighestMergedIdResolver.php: -------------------------------------------------------------------------------- 1 | idsAnalyzer = $idsAnalyzer; 32 | $this->githubApi = $githubApi; 33 | } 34 | 35 | public function resolveFromInputAndChangelogContent(InputInterface $input, string $content): int 36 | { 37 | /** @var string|int|null $sinceId */ 38 | $sinceId = $input->getOption(Option::SINCE_ID); 39 | if ($sinceId !== null) { 40 | return (int) $sinceId; 41 | } 42 | 43 | /** @var string $baseBranch */ 44 | $baseBranch = $input->getOption(Option::BASE_BRANCH); 45 | if ($baseBranch !== null) { 46 | $resolvedId = $this->resolveFromChangelogContentAndBranch($content, $baseBranch); 47 | if ($resolvedId !== null) { 48 | return $resolvedId; 49 | } 50 | 51 | return self::FALLBACK_ID; 52 | } 53 | 54 | return $this->idsAnalyzer->getHighestIdInChangelog($content); 55 | } 56 | 57 | private function resolveFromChangelogContentAndBranch(string $content, string $branch): ?int 58 | { 59 | $allIdsInChangelog = $this->idsAnalyzer->getAllIdsInChangelog($content); 60 | if ($allIdsInChangelog === null) { 61 | return null; 62 | } 63 | 64 | rsort($allIdsInChangelog); 65 | foreach ($allIdsInChangelog as $id) { 66 | $idInt = (int) $id; 67 | if ($this->githubApi->isPullRequestMergedToBaseBranch($idInt, $branch)) { 68 | return $idInt; 69 | } 70 | } 71 | return null; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Console/Command/CleanupCommand.php: -------------------------------------------------------------------------------- 1 | changelogFileSystem = $changelogFileSystem; 33 | $this->changelogCleaner = $changelogCleaner; 34 | } 35 | 36 | protected function configure(): void 37 | { 38 | $this->setDescription('Removes dead links from CHANGELOG.md'); 39 | $this->addOption(Option::CONFIG, 'c', InputOption::VALUE_REQUIRED, 'Config file'); 40 | } 41 | 42 | protected function execute(InputInterface $input, OutputInterface $output): int 43 | { 44 | $changelogContent = $this->changelogFileSystem->readChangelog(); 45 | 46 | $processedChangelogContent = $this->changelogCleaner->processContent($changelogContent); 47 | $this->changelogFileSystem->storeChangelog($processedChangelogContent); 48 | 49 | $this->symfonyStyle->success('Changelog is now clean from duplicates!'); 50 | 51 | return ShellCode::SUCCESS; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Console/Command/DumpMergesCommand.php: -------------------------------------------------------------------------------- 1 | '; 31 | 32 | /** 33 | * @var GithubApi 34 | */ 35 | private $githubApi; 36 | 37 | /** 38 | * @var ChangelogFileSystem 39 | */ 40 | private $changelogFileSystem; 41 | 42 | /** 43 | * @var ChangelogPlaceholderGuard 44 | */ 45 | private $changelogPlaceholderGuard; 46 | 47 | /** 48 | * @var ChangelogLinkerApplication 49 | */ 50 | private $changelogLinkerApplication; 51 | 52 | /** 53 | * @var HighestMergedIdResolver 54 | */ 55 | private $highestMergedIdResolver; 56 | 57 | /** 58 | * @var ParameterProvider 59 | */ 60 | private $parameterProvider; 61 | 62 | public function __construct( 63 | GithubApi $githubApi, 64 | ChangelogFileSystem $changelogFileSystem, 65 | ParameterProvider $parameterProvider, 66 | ChangelogPlaceholderGuard $changelogPlaceholderGuard, 67 | ChangelogLinkerApplication $changelogLinkerApplication, 68 | HighestMergedIdResolver $highestMergedIdResolver 69 | ) { 70 | parent::__construct(); 71 | 72 | $this->githubApi = $githubApi; 73 | $this->changelogFileSystem = $changelogFileSystem; 74 | $this->changelogPlaceholderGuard = $changelogPlaceholderGuard; 75 | $this->changelogLinkerApplication = $changelogLinkerApplication; 76 | $this->highestMergedIdResolver = $highestMergedIdResolver; 77 | $this->parameterProvider = $parameterProvider; 78 | } 79 | 80 | protected function configure(): void 81 | { 82 | $this->setDescription( 83 | 'Scans repository merged PRs, that are not in the CHANGELOG.md yet, and dumps them in changelog format.' 84 | ); 85 | $this->addOption( 86 | Option::IN_CATEGORIES, 87 | null, 88 | InputOption::VALUE_NONE, 89 | 'Print in Added/Changed/Fixed/Removed - detected from "Add", "Fix", "Removed" etc. keywords in merge title.' 90 | ); 91 | 92 | $this->addOption( 93 | Option::IN_PACKAGES, 94 | null, 95 | InputOption::VALUE_NONE, 96 | 'Print in groups in package names - detected from "[PackageName]" in merge title.' 97 | ); 98 | 99 | $this->addOption( 100 | Option::DRY_RUN, 101 | null, 102 | InputOption::VALUE_NONE, 103 | 'Print out to the output instead of writing directly into CHANGELOG.md.' 104 | ); 105 | 106 | $this->addOption( 107 | Option::SINCE_ID, 108 | null, 109 | InputOption::VALUE_REQUIRED, 110 | 'Include pull-request with provided ID and higher. The ID is detected in CHANGELOG.md otherwise.' 111 | ); 112 | 113 | $this->addOption( 114 | Option::BASE_BRANCH, 115 | null, 116 | InputOption::VALUE_OPTIONAL, 117 | 'Base branch towards which the pull requests are targeted' 118 | ); 119 | 120 | $this->addOption(Option::CONFIG, 'c', InputOption::VALUE_REQUIRED, 'Config file'); 121 | } 122 | 123 | protected function execute(InputInterface $input, OutputInterface $output): int 124 | { 125 | $content = $this->changelogFileSystem->readChangelog(); 126 | $inCategories = (bool) $input->getOption(Option::IN_CATEGORIES); 127 | $inPackages = (bool) $input->getOption(Option::IN_PACKAGES); 128 | 129 | $this->reportDeprecatedOptions($inCategories, $inPackages); 130 | 131 | $this->changelogPlaceholderGuard->ensurePlaceholderIsPresent($content, self::CHANGELOG_PLACEHOLDER_TO_WRITE); 132 | 133 | $sinceId = $this->highestMergedIdResolver->resolveFromInputAndChangelogContent($input, $content); 134 | 135 | /** @var string $baseBranch */ 136 | $baseBranch = (string) $input->getOption(Option::BASE_BRANCH); 137 | 138 | $pullRequests = $this->githubApi->getMergedPullRequestsSinceId($sinceId, $baseBranch); 139 | if ($pullRequests === []) { 140 | $message = 'No pull requests have been merged.'; 141 | if ($sinceId > 0) { 142 | $message = sprintf('No new pull requests have been merged since ID "%d".', $sinceId); 143 | } 144 | $this->symfonyStyle->success($message); 145 | 146 | return ShellCode::SUCCESS; 147 | } 148 | 149 | $changelogFormat = $this->parameterProvider->provideStringParameter(Option::CHANGELOG_FORMAT); 150 | 151 | $content = $this->changelogLinkerApplication->createContentFromPullRequestsBySortPriority( 152 | $pullRequests, 153 | $changelogFormat 154 | ); 155 | 156 | $dryRun = $input->getOption(Option::DRY_RUN); 157 | if ((bool) $dryRun) { 158 | $this->symfonyStyle->writeln($content); 159 | 160 | return ShellCode::SUCCESS; 161 | } 162 | 163 | $this->changelogFileSystem->addToChangelogOnPlaceholder($content, self::CHANGELOG_PLACEHOLDER_TO_WRITE); 164 | $this->symfonyStyle->success('The CHANGELOG.md was updated'); 165 | 166 | return ShellCode::SUCCESS; 167 | } 168 | 169 | private function reportDeprecatedOptions(bool $inCategories, bool $inPackages): void 170 | { 171 | if ($inCategories) { 172 | $message = sprintf( 173 | 'Command option "--%s" is deprecated, use config and "%s" parameter instead. Use constans from "%s" class to configure it', 174 | Option::IN_CATEGORIES, 175 | 'Option::CHANGELOG_FORMAT', 176 | ChangelogFormat::class 177 | ); 178 | $this->symfonyStyle->error($message); 179 | sleep(3); 180 | } 181 | 182 | if ($inPackages) { 183 | $message = sprintf( 184 | 'Command option "--%s" is deprecated, use config and "%s" parameter instead. Use constans from "%s" class to configure it', 185 | Option::IN_PACKAGES, 186 | 'Option::CHANGELOG_FORMAT', 187 | ChangelogFormat::class 188 | ); 189 | $this->symfonyStyle->error($message); 190 | sleep(3); 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/Console/Command/LinkCommand.php: -------------------------------------------------------------------------------- 1 | changelogLinker = $changelogLinker; 33 | $this->changelogFileSystem = $changelogFileSystem; 34 | } 35 | 36 | protected function configure(): void 37 | { 38 | $this->setDescription('Adds links to CHANGELOG.md'); 39 | $this->addOption(Option::CONFIG, 'c', InputOption::VALUE_REQUIRED, 'Config file'); 40 | } 41 | 42 | protected function execute(InputInterface $input, OutputInterface $output): int 43 | { 44 | $changelogContent = $this->changelogFileSystem->readChangelog(); 45 | 46 | $processedChangelogContent = $this->changelogLinker->processContentWithLinkAppends($changelogContent); 47 | 48 | $this->changelogFileSystem->storeChangelog($processedChangelogContent); 49 | 50 | $this->symfonyStyle->success('Changelog PRs, links, users and versions are now linked!'); 51 | 52 | return ShellCode::SUCCESS; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Contract/Worker/WorkerInterface.php: -------------------------------------------------------------------------------- 1 | githubRepositoryFromRemoteResolver = new GithubRepositoryFromRemoteResolver(); 24 | } 25 | 26 | public function process(ContainerBuilder $containerBuilder): void 27 | { 28 | if (! $containerBuilder->hasParameter(Option::REPOSITORY_URL)) { 29 | $containerBuilder->setParameter(Option::REPOSITORY_URL, $this->detectRepositoryUrlFromGit()); 30 | } 31 | 32 | if (! $containerBuilder->hasParameter(Option::REPOSITORY_NAME)) { 33 | $containerBuilder->setParameter( 34 | Option::REPOSITORY_NAME, 35 | $this->detectRepositoryName($containerBuilder) 36 | ); 37 | } 38 | } 39 | 40 | private function detectRepositoryUrlFromGit(): string 41 | { 42 | $process = new Process(['git', 'config', '--get', 'remote.origin.url']); 43 | $process->run(); 44 | 45 | $trimmedOutput = trim($process->getOutput()); 46 | return $this->githubRepositoryFromRemoteResolver->resolveFromUrl($trimmedOutput); 47 | } 48 | 49 | private function detectRepositoryName(ContainerBuilder $containerBuilder): string 50 | { 51 | $repositoryUrl = (string) $containerBuilder->getParameter(Option::REPOSITORY_URL); 52 | 53 | return Strings::substring($repositoryUrl, Strings::length('https://github.com/')); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Exception/Git/InvalidGitRemoteException.php: -------------------------------------------------------------------------------- 1 | [\[?\w+\]?])(?- \[\#\d+])#'; 31 | 32 | /** 33 | * @var ChangelogLinker 34 | */ 35 | private $changelogLinker; 36 | 37 | /** 38 | * @var ChangelogPlaceholderGuard 39 | */ 40 | private $changelogPlaceholderGuard; 41 | 42 | /** 43 | * @var ParameterProvider 44 | */ 45 | private $parameterProvider; 46 | 47 | /** 48 | * @var FileSystemGuard 49 | */ 50 | private $fileSystemGuard; 51 | 52 | /** 53 | * @var SmartFileSystem 54 | */ 55 | private $smartFileSystem; 56 | 57 | public function __construct( 58 | ChangelogLinker $changelogLinker, 59 | ChangelogPlaceholderGuard $changelogPlaceholderGuard, 60 | FileSystemGuard $fileSystemGuard, 61 | ParameterProvider $parameterProvider, 62 | SmartFileSystem $smartFileSystem 63 | ) { 64 | $this->changelogLinker = $changelogLinker; 65 | $this->changelogPlaceholderGuard = $changelogPlaceholderGuard; 66 | $this->parameterProvider = $parameterProvider; 67 | $this->fileSystemGuard = $fileSystemGuard; 68 | $this->smartFileSystem = $smartFileSystem; 69 | } 70 | 71 | public function readChangelog(): string 72 | { 73 | $changelogFilePath = $this->getChangelogFilePath(); 74 | try { 75 | $this->fileSystemGuard->ensureFileExists($changelogFilePath, __METHOD__); 76 | } catch (FileNotFoundException $fileNotFoundException) { 77 | $this->smartFileSystem->dumpFile($changelogFilePath, DumpMergesCommand::CHANGELOG_PLACEHOLDER_TO_WRITE); 78 | } 79 | 80 | return $this->smartFileSystem->readFile($changelogFilePath); 81 | } 82 | 83 | public function storeChangelog(string $content): void 84 | { 85 | $this->smartFileSystem->dumpFile($this->getChangelogFilePath(), $content); 86 | } 87 | 88 | public function addToChangelogOnPlaceholder(string $newContent, string $placeholder): void 89 | { 90 | $changelogContent = $this->readChangelog(); 91 | 92 | $this->changelogPlaceholderGuard->ensurePlaceholderIsPresent($changelogContent, $placeholder); 93 | 94 | if (Strings::contains($changelogContent, $placeholder)) { 95 | $newContent = str_replace($placeholder, '', $newContent); 96 | } 97 | 98 | $contentToWrite = sprintf('%s%s%s%s', $placeholder, PHP_EOL, PHP_EOL, $newContent); 99 | 100 | $updatedChangelogContent = str_replace($placeholder, $contentToWrite, $changelogContent); 101 | $updatedChangelogContent = $this->changelogLinker->processContentWithLinkAppends($updatedChangelogContent); 102 | $updatedChangelogContent = str_replace(PHP_EOL . PHP_EOL . ' -', PHP_EOL . ' -', $updatedChangelogContent); 103 | $updatedChangelogContent = str_replace( 104 | $placeholder . PHP_EOL . ' -', 105 | $placeholder . PHP_EOL . PHP_EOL . ' -', 106 | $updatedChangelogContent 107 | ); 108 | 109 | // clean up ## Unreleased 110 | $updatedChangelogContent = $this->cleanUpUnreleased($updatedChangelogContent, $placeholder); 111 | $this->storeChangelog($updatedChangelogContent); 112 | } 113 | 114 | public function getChangelogFilePath(): string 115 | { 116 | return $this->parameterProvider->provideStringParameter(Option::FILE); 117 | } 118 | 119 | private function cleanUpUnreleased(string $updatedChangelogContent, string $placeholder): string 120 | { 121 | $updatedChangelogContent = Strings::replace( 122 | $updatedChangelogContent, 123 | self::TRIMMED_NEW_ENTRY_DASH_REGEX, 124 | function (array $match): string { 125 | return $match['prevlist'] . PHP_EOL . $match['newlist']; 126 | } 127 | ); 128 | 129 | $multiUnreleased = explode(self::UNRELEASED_HEADLINE, $updatedChangelogContent); 130 | if (count($multiUnreleased) > 2) { 131 | $updatedChangelogContent = str_replace(self::UNRELEASED_HEADLINE, '', $updatedChangelogContent); 132 | $updatedChangelogContent = str_replace( 133 | $placeholder, 134 | $placeholder . PHP_EOL . PHP_EOL . self::UNRELEASED_HEADLINE, 135 | $updatedChangelogContent 136 | ); 137 | } 138 | 139 | return str_replace(PHP_EOL . PHP_EOL . PHP_EOL, PHP_EOL, $updatedChangelogContent); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/FileSystem/ChangelogPlaceholderGuard.php: -------------------------------------------------------------------------------- 1 | \d{4}-\d{2}-\d{2})#'; 20 | 21 | /** 22 | * @var string 23 | * @see https://regex101.com/r/50201m/2 24 | */ 25 | private const TAG_WITH_DATE_REGEX = '#\(?tag: (?[v.\d]+)\)#'; 26 | 27 | /** 28 | * @var string[] 29 | */ 30 | private $tagsToDates = []; 31 | 32 | /** 33 | * @var string[] 34 | */ 35 | private $commitHashToTag = []; 36 | 37 | /** 38 | * @inspiration https://stackoverflow.com/a/6900369/1348344 39 | */ 40 | public function __construct() 41 | { 42 | $datesWithTags = explode(PHP_EOL, $this->getDatesWithTagsInString()); 43 | 44 | foreach ($datesWithTags as $datesWithTag) { 45 | $dateMatch = Strings::match($datesWithTag, self::DATE_REGEX); 46 | $date = $dateMatch['date']; 47 | 48 | $tagMatch = Strings::match($datesWithTag, self::TAG_WITH_DATE_REGEX); 49 | if (! isset($tagMatch['tag'])) { 50 | continue; 51 | } 52 | 53 | $tag = $tagMatch['tag']; 54 | 55 | $this->tagsToDates[$tag] = $date; 56 | } 57 | } 58 | 59 | public function resolveDateForTag(string $tag): ?string 60 | { 61 | if ($tag === 'Unreleased') { 62 | return null; 63 | } 64 | 65 | if (isset($this->tagsToDates[$tag])) { 66 | return $this->tagsToDates[$tag]; 67 | } 68 | 69 | return null; 70 | } 71 | 72 | /** 73 | * @inspiration https://stackoverflow.com/a/7561599/1348344 74 | */ 75 | public function resolveCommitToTag(string $commitHash): string 76 | { 77 | if (isset($this->commitHashToTag[$commitHash])) { 78 | $tag = $this->commitHashToTag[$commitHash]; 79 | } else { 80 | $process = new Process(['git', 'describe', '--contains', $commitHash]); 81 | $process->run(); 82 | $tag = trim($process->getOutput()); 83 | $this->commitHashToTag[$commitHash] = $tag; 84 | } 85 | 86 | if ($tag === '') { 87 | return 'Unreleased'; 88 | } 89 | 90 | // resolves formats like "v4.2.0~5^2" 91 | if (Strings::contains($tag, '~')) { 92 | return explode('~', $tag)[0]; 93 | } 94 | 95 | return $tag; 96 | } 97 | 98 | private function getDatesWithTagsInString(): string 99 | { 100 | $process = new Process(['git', 'log', '--tags', '--simplify-by-decoration', '--pretty="format:%ai %d"']); 101 | $process->run(); 102 | 103 | return trim($process->getOutput()); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Github/GithubApi.php: -------------------------------------------------------------------------------- 1 | client = $client; 74 | $this->repositoryName = $parameterProvider->provideStringParameter(Option::REPOSITORY_NAME); 75 | $this->responseFormatter = $responseFormatter; 76 | 77 | $githubToken = $parameterProvider->provideStringParameter(Option::GITHUB_TOKEN); 78 | 79 | // Inspired by https://github.com/weierophinney/changelog_generator/blob/master/changelog_generator.php 80 | if ($githubToken !== '') { 81 | $this->options['headers']['Authorization'] = 'token ' . $githubToken; 82 | } 83 | } 84 | 85 | /** 86 | * @return mixed[] 87 | */ 88 | public function getMergedPullRequestsSinceId(int $id, string $baseBranch): array 89 | { 90 | $pullRequests = $this->getPullRequestsSinceId($id, $baseBranch); 91 | 92 | $mergedPullRequests = $this->filterMergedPullRequests($pullRequests); 93 | 94 | // include all 95 | if ($id === 0) { 96 | return $mergedPullRequests; 97 | } 98 | 99 | // include none 100 | $sinceMergedAt = $this->getMergedAtByPullRequest($id); 101 | if ($sinceMergedAt === null) { 102 | return []; 103 | } 104 | 105 | return $this->filterPullRequestsNewerThanMergedAt($mergedPullRequests, $sinceMergedAt); 106 | } 107 | 108 | public function isPullRequestMergedToBaseBranch(int $pullRequestId, string $baseBranch): bool 109 | { 110 | $json = $this->getSinglePullRequestJson($pullRequestId); 111 | return $json['base']['ref'] === $baseBranch; 112 | } 113 | 114 | /** 115 | * @return mixed[] 116 | */ 117 | private function getPullRequestsSinceId(int $id, string $baseBranch): array 118 | { 119 | $pullRequests = []; 120 | 121 | for ($i = 1; $i <= self::MAX_PAGE; ++$i) { 122 | $url = sprintf(self::URL_CLOSED_PULL_REQUESTS, $this->repositoryName) . '&page=' . $i; 123 | if ($baseBranch !== '') { 124 | $url .= '&base=' . $baseBranch; 125 | } 126 | $response = $this->getResponseToUrl($url); 127 | 128 | // already no more pages → stop 129 | $newPullRequests = $this->responseFormatter->formatToJson($response); 130 | if ($newPullRequests === []) { 131 | break; 132 | } 133 | 134 | $pullRequests = array_merge($pullRequests, $newPullRequests); 135 | 136 | // our id was found → stop after this one 137 | $pullRequestIds = array_column($newPullRequests, 'number'); 138 | if (in_array($id, $pullRequestIds, true)) { 139 | break; 140 | } 141 | } 142 | 143 | return $pullRequests; 144 | } 145 | 146 | /** 147 | * @param mixed[] $pullRequests 148 | * @return mixed[] 149 | */ 150 | private function filterMergedPullRequests(array $pullRequests): array 151 | { 152 | return array_filter($pullRequests, function (array $pullRequest): bool { 153 | return $this->isKeySetWithValue($pullRequest, self::MERGED_AT); 154 | }); 155 | } 156 | 157 | private function getMergedAtByPullRequest(int $id): ?string 158 | { 159 | $json = $this->getSinglePullRequestJson($id); 160 | 161 | return $json[self::MERGED_AT] ?? null; 162 | } 163 | 164 | /** 165 | * @param mixed[] $pullRequests 166 | * @return mixed[] 167 | */ 168 | private function filterPullRequestsNewerThanMergedAt(array $pullRequests, string $mergedAt): array 169 | { 170 | return array_filter($pullRequests, function (array $pullRequest) use ($mergedAt): bool { 171 | return $pullRequest[self::MERGED_AT] > $mergedAt; 172 | }); 173 | } 174 | 175 | /** 176 | * @return mixed[] 177 | */ 178 | private function getSinglePullRequestJson(int $pullRequestId): array 179 | { 180 | $url = sprintf(self::URL_PULL_REQUEST_BY_ID, $this->repositoryName, $pullRequestId); 181 | $response = $this->getResponseToUrl($url); 182 | return $this->responseFormatter->formatToJson($response); 183 | } 184 | 185 | private function getResponseToUrl(string $url): ResponseInterface 186 | { 187 | try { 188 | $request = new Request('GET', $url); 189 | $response = $this->client->send($request, $this->options); 190 | } catch (RequestException $requestException) { 191 | if (Strings::contains($requestException->getMessage(), 'API rate limit exceeded')) { 192 | throw $this->createGithubApiTokenException('Github API rate limit exceeded.', $requestException); 193 | } 194 | 195 | // un-authorized access → provide token 196 | if ($requestException->getCode() === 401) { 197 | throw $this->createGithubApiTokenException('Github API un-authorized access.', $requestException); 198 | } 199 | 200 | throw $requestException; 201 | } 202 | 203 | if ($response->getStatusCode() !== 200) { 204 | throw BadResponseException::create($request, $response); 205 | } 206 | 207 | return $response; 208 | } 209 | 210 | private function createGithubApiTokenException(string $reason, Throwable $throwable): GithubApiException 211 | { 212 | $message = $reason . PHP_EOL . 'Create a token at https://github.com/settings/tokens/new with only repository scope and use it as ENV variable: "GITHUB_TOKEN=... vendor/bin/changelog-linker ..." option.'; 213 | 214 | return new GithubApiException($message, $throwable->getCode(), $throwable); 215 | } 216 | 217 | private function isKeySetWithValue(array $data, string $keyName): bool 218 | { 219 | if (! isset($data[$keyName])) { 220 | return false; 221 | } 222 | 223 | return $data[$keyName] !== null; 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/Github/GithubRepositoryFromRemoteResolver.php: -------------------------------------------------------------------------------- 1 | throwException($url); 39 | } 40 | 41 | $pathDirname = pathinfo($urlPath, PATHINFO_DIRNAME); 42 | $pathFilename = pathinfo($urlPath, PATHINFO_FILENAME); 43 | 44 | return sprintf('%s://%s%s/%s', $urlScheme, $urlHost, $pathDirname, $pathFilename); 45 | } 46 | 47 | // turn SSH format to "https" 48 | if (Strings::startsWith($url, 'git@')) { 49 | $url = Strings::substring($url, 0, -4); 50 | $url = str_replace(':', '/', $url); 51 | $url = Strings::substring($url, Strings::length('git@')); 52 | 53 | return sprintf('%s://%s', self::HTTPS_SCHEME, $url); 54 | } 55 | 56 | $this->throwException($url); 57 | } 58 | 59 | private function throwException(string $url): void 60 | { 61 | throw new InvalidGitRemoteException(sprintf( 62 | 'Remote url "%s" could not be resolved to https form. Have you added it via "git remote add origin"?', 63 | $url 64 | )); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Guzzle/ResponseFormatter.php: -------------------------------------------------------------------------------- 1 | getBody(); 18 | return Json::decode($stream->getContents(), Json::FORCE_ARRAY); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/HttpKernel/ChangelogLinkerKernel.php: -------------------------------------------------------------------------------- 1 | load(__DIR__ . '/../../config/config.php'); 17 | 18 | parent::registerContainerConfiguration($loader); 19 | } 20 | 21 | protected function build(ContainerBuilder $containerBuilder): void 22 | { 23 | $containerBuilder->addCompilerPass(new AddRepositoryUrlAndRepositoryNameParametersCompilerPass()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/LinkAppender.php: -------------------------------------------------------------------------------- 1 | linksAnalyzer = $linksAnalyzer; 29 | } 30 | 31 | public function hasId(string $id): bool 32 | { 33 | return array_key_exists($id, $this->linksToAppend); 34 | } 35 | 36 | public function add(string $id, string $link): void 37 | { 38 | $this->linksToAppend[$id] = $link; 39 | } 40 | 41 | /** 42 | * @return string[] 43 | */ 44 | public function getLinksToAppend(): array 45 | { 46 | krsort($this->linksToAppend); 47 | 48 | // filter out already existing links 49 | $this->removeAlreadyExistingLinks(); 50 | 51 | return $this->linksToAppend; 52 | } 53 | 54 | /** 55 | * Tells you if links have been removed from LinkAppender::$linksToAppend after calling 56 | * LinkAppender::removeAlreadyExistingLinks 57 | * 58 | * Implicitly this method is telling you that changelog file already contains links at the end. 59 | */ 60 | public function isExistingLinks(): bool 61 | { 62 | return $this->isExistingLinks; 63 | } 64 | 65 | private function removeAlreadyExistingLinks(): void 66 | { 67 | $this->isExistingLinks = false; 68 | 69 | $ids = array_keys($this->linksToAppend); 70 | foreach ($ids as $id) { 71 | if ($this->linksAnalyzer->hasLinkedId((string) $id)) { 72 | unset($this->linksToAppend[$id]); 73 | $this->isExistingLinks = true; 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/ValueObject/Category.php: -------------------------------------------------------------------------------- 1 | message = $message; 42 | $this->category = $category; 43 | $this->package = $package; 44 | $this->messageWithoutPackage = $messageWithoutPackage; 45 | $this->tag = $tag; 46 | } 47 | 48 | public function getMessage(): string 49 | { 50 | return $this->message; 51 | } 52 | 53 | public function getCategory(): string 54 | { 55 | return $this->category; 56 | } 57 | 58 | public function getPackage(): string 59 | { 60 | return $this->package; 61 | } 62 | 63 | public function getMessageWithoutPackage(): string 64 | { 65 | return $this->messageWithoutPackage; 66 | } 67 | 68 | public function getTag(): string 69 | { 70 | return $this->tag; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/ValueObject/ChangelogFormat.php: -------------------------------------------------------------------------------- 1 | @(?!(var))[\w\d-]+)'; 25 | 26 | /** 27 | * @var string 28 | * @see https://regex101.com/r/c9P7PS/1 29 | */ 30 | public const VERSION_REGEX = '(?(v|[\d])[\w\d\.-]+)'; 31 | 32 | /** 33 | * @var string 34 | * @see https://regex101.com/r/0I2XoB/1 35 | */ 36 | public const PR_OR_ISSUE = '(?\#(?\d+))'; 37 | 38 | /** 39 | * @var string 40 | * @see https://regex101.com/r/yNOAul/1 41 | * @see http://www.rexegg.com/regex-best-trick.html 42 | */ 43 | public const PR_OR_ISSUE_NOT_IN_BRACKETS = '\[(.*)\#\d+(.*)\]|(?\#(?\d+))'; 44 | 45 | /** 46 | * links: "[<...>]: http://" 47 | * 48 | * @var string 49 | * @see https://regex101.com/r/t8GV67/1 50 | */ 51 | public const LINK_REFERENCE_REGEX = '#\[\#?(?.*)\]:\s+#'; 52 | } 53 | -------------------------------------------------------------------------------- /src/Worker/BracketsAroundReferencesWorker.php: -------------------------------------------------------------------------------- 1 | wrapClosesKeywordIds($content); 52 | 53 | // user references 54 | return Strings::replace($content, '# ' . RegexPattern::USER_REGEX . '#', ' [$1]'); 55 | } 56 | 57 | public function getPriority(): int 58 | { 59 | return 1000; 60 | } 61 | 62 | private function wrapClosesKeywordIds(string $content): string 63 | { 64 | return Strings::replace( 65 | $content, 66 | sprintf('#(%s) \#%s#', implode('|', self::CLOSES_KEYWORDS), RegexPattern::VERSION_REGEX), 67 | '$1 [#$2]' 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Worker/DiffLinksToVersionsWorker.php: -------------------------------------------------------------------------------- 1 | linkAppender = $linkAppender; 43 | $this->versionsAnalyzer = $versionsAnalyzer; 44 | $this->linksAnalyzer = $linksAnalyzer; 45 | $this->repositoryUrl = $parameterProvider->provideStringParameter(Option::REPOSITORY_URL); 46 | } 47 | 48 | public function processContent(string $content): string 49 | { 50 | // we need more than 1 version to make A...B 51 | if (count($this->versionsAnalyzer->getVersions()) <= 1) { 52 | return $content; 53 | } 54 | 55 | $versions = $this->versionsAnalyzer->getVersions(); 56 | foreach ($versions as $index => $version) { 57 | if ($this->shouldSkip($versions, $index)) { 58 | continue; 59 | } 60 | 61 | $link = sprintf( 62 | '[%s]: %s/compare/%s...%s', 63 | $version, 64 | $this->repositoryUrl, 65 | $this->versionsAnalyzer->getVersions()[$index + 1], 66 | $version 67 | ); 68 | 69 | $this->linkAppender->add($version, $link); 70 | } 71 | 72 | // append new links to the file 73 | return $content; 74 | } 75 | 76 | public function getPriority(): int 77 | { 78 | return 800; 79 | } 80 | 81 | /** 82 | * @param string[] $versions 83 | */ 84 | private function shouldSkip(array $versions, int $index): bool 85 | { 86 | // there is no next version to compare this with 87 | if (! isset($versions[$index + 1])) { 88 | return true; 89 | } 90 | 91 | $version = $versions[$index]; 92 | return $this->linksAnalyzer->hasLinkedId($version); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Worker/LinkifyWorker.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | private $namesToUrls = []; 31 | 32 | /** 33 | * @var LinkAppender 34 | */ 35 | private $linkAppender; 36 | 37 | public function __construct(LinkAppender $linkAppender, ParameterProvider $parameterProvider) 38 | { 39 | $this->linkAppender = $linkAppender; 40 | $this->namesToUrls = $parameterProvider->provideArrayParameter(Option::NAMES_TO_URLS); 41 | } 42 | 43 | public function processContent(string $content): string 44 | { 45 | $contentLines = explode(PHP_EOL, $content); 46 | 47 | foreach ($contentLines as $key => $contentLine) { 48 | if ($this->shouldSkipContentLine($contentLine)) { 49 | continue; 50 | } 51 | 52 | $contentLines[$key] = $this->linkifyContentLine($contentLine); 53 | } 54 | 55 | return implode(PHP_EOL, $contentLines); 56 | } 57 | 58 | public function getPriority(): int 59 | { 60 | return 900; 61 | } 62 | 63 | private function shouldSkipContentLine(string $contentLine): bool 64 | { 65 | // skip spaces only 66 | if (Strings::match($contentLine, self::SPACE_START_REGEX)) { 67 | return true; 68 | } 69 | 70 | // skip links 71 | return (bool) Strings::match($contentLine, self::LINKS_REGEX); 72 | } 73 | 74 | private function linkifyContentLine(string $contentLine): string 75 | { 76 | foreach ($this->namesToUrls as $name => $url) { 77 | $quotedName = preg_quote($name, '#'); 78 | 79 | if ($this->shouldSkipContentLineAndName($quotedName, $contentLine)) { 80 | continue; 81 | } 82 | 83 | $unlinkedPattern = '#\b(' . $quotedName . ')\b#'; 84 | if (! Strings::match($contentLine, $unlinkedPattern)) { 85 | continue; 86 | } 87 | 88 | $contentLine = Strings::replace($contentLine, $unlinkedPattern, '[$1]'); 89 | $link = sprintf('[%s]: %s', $name, $url); 90 | 91 | $this->linkAppender->add($name, $link); 92 | } 93 | 94 | return $contentLine; 95 | } 96 | 97 | private function shouldSkipContentLineAndName(string $quotedName, string $contentLine): bool 98 | { 99 | // is already linked 100 | $linkedPattern = '#\[' . $quotedName . '\]#'; 101 | if (Strings::match($contentLine, $linkedPattern)) { 102 | return true; 103 | } 104 | 105 | // part of another string, e.g. "linked-", "to-be-linked" 106 | $partOfAnotherStringPattern = '#\-' . $quotedName . '|' . $quotedName . '\-#'; 107 | 108 | return (bool) Strings::match($contentLine, $partOfAnotherStringPattern); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Worker/LinksToReferencesWorker.php: -------------------------------------------------------------------------------- 1 | linkAppender = $linkAppender; 34 | $this->repositoryUrl = $parameterProvider->provideStringParameter(Option::REPOSITORY_URL); 35 | } 36 | 37 | /** 38 | * Github can redirects PRs to issues, so no need to trouble with their separatoin 39 | * 40 | * @inspiration for Regex: https://stackoverflow.com/a/406408/1348344 41 | */ 42 | public function processContent(string $content): string 43 | { 44 | $matches = Strings::matchAll($content, '#\[' . RegexPattern::PR_OR_ISSUE . '\]#m'); 45 | foreach ($matches as $match) { 46 | $link = sprintf('[#%d]: %s/pull/%d', $match[self::ID], $this->repositoryUrl, $match[self::ID]); 47 | $this->linkAppender->add($match[self::ID], $link); 48 | } 49 | 50 | return $content; 51 | } 52 | 53 | public function getPriority(): int 54 | { 55 | return 700; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Worker/UserReferencesWorker.php: -------------------------------------------------------------------------------- 1 | linkAppender = $linkAppender; 30 | } 31 | 32 | public function processContent(string $content): string 33 | { 34 | $matches = Strings::matchAll($content, '#\[' . RegexPattern::USER_REGEX . '\]#'); 35 | foreach ($matches as $match) { 36 | if ($this->shouldSkip($match)) { 37 | continue; 38 | } 39 | 40 | $markdownUserLink = sprintf( 41 | '[%s]: https://github.com/%s', 42 | $match[self::REFERENCE], 43 | ltrim($match[self::REFERENCE], '@') 44 | ); 45 | 46 | $this->linkAppender->add($match[self::REFERENCE], $markdownUserLink); 47 | } 48 | 49 | return $content; 50 | } 51 | 52 | public function getPriority(): int 53 | { 54 | return 500; 55 | } 56 | 57 | /** 58 | * @param mixed[] $match 59 | */ 60 | private function shouldSkip(array $match): bool 61 | { 62 | return $this->linkAppender->hasId($match[self::REFERENCE]); 63 | } 64 | } 65 | --------------------------------------------------------------------------------