From 179b28016a9ccde8122885041f20393d0b6be97f Mon Sep 17 00:00:00 2001 From: Dan Untenzu Date: Tue, 5 Apr 2022 09:27:00 +0200 Subject: [PATCH 01/24] =?UTF-8?q?[FEATURE]=20=F0=9F=92=84=20Dont=20show=20?= =?UTF-8?q?next=20steps=20hint=20by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show hints/tops only in verbose mode, to reduce visual clutter in batch mode. --- src/RoboFile.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/RoboFile.php b/src/RoboFile.php index 963b35e..fed5ab7 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -71,10 +71,10 @@ public function patch(array $options = [ $this->io()->success('Patch applied'); // Suggest next steps - $this->io()->block('Hint: Run `./vendor/bin/patchbot merge' - . ' --source=' . $options['branch-name'] - . ' --target=' - . ' --repository-url=' . $options['repository-url'] . '` to merge the feature branch'); + $this->say('Hint: Run `./vendor/bin/patchbot merge' + . ' --source=' . $options['branch-name'] + . ' --target=' + . ' --repository-url=' . $options['repository-url'] . '` to merge the feature branch'); return 0; } @@ -171,6 +171,7 @@ public function create(array $options = [ ->run(); $this->io()->success('Patch directory created'); + // Suggest next steps $this->say('- Edit patch.php & commit-message.txt in ' . $patchDirectory); $this->say('- Run `./vendor/bin/patchbot patch --patch-name=' . $patchName From cc3259a8ad20e01700ae3315e76615b85d016866 Mon Sep 17 00:00:00 2001 From: Dan Untenzu Date: Thu, 5 Feb 2026 08:38:40 +0100 Subject: [PATCH 02/24] =?UTF-8?q?[FEATURE]=20=E2=AC=86=EF=B8=8F=20=20Upgra?= =?UTF-8?q?de=20PHP=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade to PHP 8.4 --- .gitlab-ci.yml | 36 +++++++++++++++++++-------- .php-version | 2 +- composer.json | 24 ++++++++++++++---- tests/unit/PatchbotCommandsTest.php | 29 ++++++++++----------- tests/unit/PatchbotTest.php | 4 +-- tests/unit/src/CommandTesterTrait.php | 35 ++++++-------------------- 6 files changed, 68 insertions(+), 62 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f8b9461..db9c476 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,9 +1,12 @@ ### GENERAL ### -image: chialab/php:7.2-apache +variables: + PHP_VERSION: '8.4' + +image: php:${PHP_VERSION}-cli-alpine cache: - key: ${CI_COMMIT_REF_SLUG} + key: ${CI_COMMIT_REF_SLUG}-${PHP_VERSION} paths: - vendor/ @@ -12,25 +15,36 @@ stages: - test before_script: - - apt-get update -yqq - - apt-get install curl ssh rsync git -yqq + - apk add --no-cache curl git unzip + - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - composer install ### JOBS ### -job_lint_app_phpdefaultversion: &definition_lint_app_phpdefaultversion +job_lint_app: stage: lint + image: php:${PHP_VERSION}-cli-alpine + parallel: + matrix: + - PHP_VERSION: ['8.3', '8.4', '8.5'] script: - - composer require --dev friendsofphp/php-cs-fixer:^2.0 jakub-onderka/php-parallel-lint:^1.0 + - composer require --dev friendsofphp/php-cs-fixer:^3.0 php-parallel-lint/php-parallel-lint:^1.0 - vendor/bin/php-cs-fixer -vvv fix . --dry-run --diff --using-cache=no --rules=@PSR2 - vendor/bin/parallel-lint --exclude vendor . - -job_lint_app_phpnextversion: - <<: *definition_lint_app_phpdefaultversion - image: chialab/php:7.3-apache - allow_failure: true + rules: + - if: $PHP_VERSION == "8.5" + allow_failure: true + - when: always job_test_app: stage: test + image: php:${PHP_VERSION}-cli-alpine + parallel: + matrix: + - PHP_VERSION: ['8.3', '8.4', '8.5'] script: - composer test + rules: + - if: $PHP_VERSION == "8.5" + allow_failure: true + - when: always diff --git a/.php-version b/.php-version index 5904f7a..c9dc049 100644 --- a/.php-version +++ b/.php-version @@ -1 +1 @@ -7.2 +8.4 diff --git a/composer.json b/composer.json index c81a9e2..8e6c8d8 100644 --- a/composer.json +++ b/composer.json @@ -4,9 +4,11 @@ "type": "library", "license": "GPL-2.0-or-later", "require": { - "consolidation/robo": "^2.1", - "jean85/pretty-package-versions": "^1.5", - "cocur/slugify": "^4.0" + "php": "^8.3", + "consolidation/robo": "^4.0 || ^5.0", + "jean85/pretty-package-versions": "^2.0", + "cocur/slugify": "^4.0", + "cweagans/composer-patches": "~1.0" }, "autoload": { "psr-4": { @@ -14,7 +16,7 @@ } }, "require-dev": { - "phpunit/phpunit": "^8.0" + "phpunit/phpunit": "^11.0" }, "scripts": { "test": [ @@ -23,5 +25,17 @@ }, "bin": [ "bin/patchbot" - ] + ], + "config": { + "allow-plugins": { + "cweagans/composer-patches": true + } + }, + "extra": { + "patches": { + "consolidation/robo": { + "Symfony Console 7.4 compatibility": "https://patch-diff.githubusercontent.com/raw/consolidation/robo/pull/1185.diff" + } + } + } } diff --git a/tests/unit/PatchbotCommandsTest.php b/tests/unit/PatchbotCommandsTest.php index 60d455d..5cdf37e 100644 --- a/tests/unit/PatchbotCommandsTest.php +++ b/tests/unit/PatchbotCommandsTest.php @@ -2,6 +2,7 @@ use Pixelbrackets\Patchbot\RoboFile; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; require __DIR__ . '/src/CommandTesterTrait.php'; @@ -10,9 +11,9 @@ class PatchbotCommandsTest extends TestCase use CommandTesterTrait; /** @var string[] */ - protected $commandClass; + protected array $commandClass; - protected static $bareRepository = ''; + protected static string $bareRepository = ''; /** * Set up a bare local Git repository @@ -29,7 +30,7 @@ public static function loadFixtures(): void exec('git init --bare ' . self::$bareRepository); exec('git clone ' . self::$bareRepository . ' ' . $tmpDirectory . ' 2> /dev/null'); chdir($tmpDirectory); - exec('git config --global user.email "patchbot@example.com" && git config --global user.name "Patchbot"'); + exec('git config user.email "patchbot@example.com" && git config user.name "Patchbot"'); exec('git checkout --orphan main 2> /dev/null'); file_put_contents($tmpDirectory . 'README.md', '# ACME Project' . PHP_EOL . 'Hello World' . PHP_EOL . PHP_EOL); exec('git add -A'); @@ -58,9 +59,9 @@ protected function setUp(): void } /** - * Data provider for testExampleCommands. + * Data provider for testGeneralCommands. */ - public function generalCommandsProvider(): array + public static function generalCommandsProvider(): array { return [ [ @@ -96,13 +97,11 @@ public function generalCommandsProvider(): array ]; } - /** - * @dataProvider generalCommandsProvider - */ - public function testGeneralCommands($expectedOutput, $expectedStatus, $CliArguments): void + #[DataProvider('generalCommandsProvider')] + public function testGeneralCommands(string $expectedOutput, int $expectedStatus, string ...$cliArguments): void { // Create Robo arguments and execute a runner instance - $argv = $this->argv(func_get_args()); + $argv = array_merge([$this->appName], $cliArguments); list($actualOutput, $statusCode) = $this->execute($argv, $this->commandClass); // Confirm that our output and status code match expectations @@ -113,7 +112,7 @@ public function testGeneralCommands($expectedOutput, $expectedStatus, $CliArgume /** * Data provider for testPatchCommandWarning. */ - public function patchCommandWarningProvider(): array + public static function patchCommandWarningProvider(): array { self::loadFixtures(); @@ -136,13 +135,11 @@ public function patchCommandWarningProvider(): array ]; } - /** - * @dataProvider patchCommandWarningProvider - */ - public function testPatchCommandWarning($expectedOutput, $expectedStatus, $CliArguments): void + #[DataProvider('patchCommandWarningProvider')] + public function testPatchCommandWarning(string $expectedOutput, int $expectedStatus, string ...$cliArguments): void { // Create Robo arguments and execute a runner instance - $argv = $this->argv(func_get_args()); + $argv = array_merge([$this->appName], $cliArguments); list($actualOutput, $statusCode) = $this->execute($argv, $this->commandClass); $this->assertStringContainsString($expectedOutput, $actualOutput); diff --git a/tests/unit/PatchbotTest.php b/tests/unit/PatchbotTest.php index b673368..3cd5a81 100644 --- a/tests/unit/PatchbotTest.php +++ b/tests/unit/PatchbotTest.php @@ -8,11 +8,11 @@ class PatchbotTest extends TestCase protected function setUp(): void { \Robo\Robo::unsetContainer(); - $container = \Robo\Robo::createDefaultContainer(); + $container = \Robo\Robo::createContainer(); \Robo\Robo::setContainer($container); } - public function testPatchRequiresRepositoryUrl() + public function testPatchRequiresRepositoryUrl(): void { $patchbot = new RoboFile(); $expectedOutput = 1; // exit code 1 = error diff --git a/tests/unit/src/CommandTesterTrait.php b/tests/unit/src/CommandTesterTrait.php index a6f9f7c..aea497b 100644 --- a/tests/unit/src/CommandTesterTrait.php +++ b/tests/unit/src/CommandTesterTrait.php @@ -1,49 +1,30 @@ appName = $appName; $this->appVersion = $appVersion; } - /** - * Helper method to set up the $argv array for Robo: - * - * - * @param array $functionParameters All test method arguments - * @param int $leadingParameterCount The number of method argumnents - * to ignore in this helper - 2 by default - * (first argument = expected content, second argument = expected - * status code, all following arguments = argv). - */ - protected function argv($functionParameters, $leadingParameterCount = 2) - { - $argv = $functionParameters; - $argv = array_slice($argv, $leadingParameterCount); - array_unshift($argv, $this->appName); - - return $argv; - } - /** * Simulated Robo task runner execution + * + * @param string[] $argv + * @param string[] $commandClass + * @return array{0: string, 1: int} */ - protected function execute($argv, $commandClass) + protected function execute(array $argv, array $commandClass): array { // Buffer CLI output for tests $output = new BufferedOutput(); From 8d6225e2523407af1fde26c5f97ec880e085b33a Mon Sep 17 00:00:00 2001 From: Dan Untenzu Date: Thu, 5 Feb 2026 09:39:27 +0100 Subject: [PATCH 03/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Add=20autodiscove?= =?UTF-8?q?ry=20for=20gitlab=20repos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow users to automatically discover a list of possible target repos by reading a given gitlab namespace. --- .env.example | 14 +++ README.md | 13 +++ composer.json | 31 ++++--- src/Discovery/GitLabDiscovery.php | 143 ++++++++++++++++++++++++++++++ src/RoboFile.php | 87 ++++++++++++++++++ 5 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 .env.example create mode 100644 src/Discovery/GitLabDiscovery.php diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1321dac --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Patchbot environment configuration +# Copy this file to .env and fill in your values + +# GitLab API token (required for discover command) +# Create at: https://gitlab.com/-/user_settings/personal_access_tokens +# Required scopes: read_api +GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx + +# GitLab instance URL (optional, defaults to https://gitlab.com) +GITLAB_URL=https://gitlab.com + +# Default GitLab namespace to discover (group path or username) +# Auto-detects whether it's a group or user +GITLAB_NAMESPACE=acmeusernameongitlabcom diff --git a/README.md b/README.md index bc24d3e..fdae5e2 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,19 @@ you have two options: git config --global url."ssh://git@bitbucket.org/".insteadOf "https://bitbucket.org/" ``` +### Discover repositories automatically + +Instead of adding all repository URLs manually to a storage file you may +let Patchbot discover them automatically from a GitLab namespace. + +Usage: + +```bash +./vendor/bin/patchbot discover # uses GITLAB_NAMESPACE and GITLAB_URL from .env +./vendor/bin/patchbot discover -g myusername # explicit namespace +./vendor/bin/patchbot discover --force # overwrite existing storage file +``` + ## Source https://gitlab.com/pixelbrackets/patchbot/ diff --git a/composer.json b/composer.json index 8e6c8d8..41d42c3 100644 --- a/composer.json +++ b/composer.json @@ -1,41 +1,48 @@ { "name": "pixelbrackets/patchbot", "description": "Automate the distribution of patches to various Git repositories", - "type": "library", "license": "GPL-2.0-or-later", + "type": "library", "require": { "php": "^8.3", + "cocur/slugify": "^4.0", "consolidation/robo": "^4.0 || ^5.0", + "cweagans/composer-patches": "^1.0", + "guzzlehttp/guzzle": "^7.0", + "helhum/dotenv-connector": "^3.0", "jean85/pretty-package-versions": "^2.0", - "cocur/slugify": "^4.0", - "cweagans/composer-patches": "~1.0" + "symfony/yaml": "^5.0 || ^6.0 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" }, "autoload": { "psr-4": { "Pixelbrackets\\Patchbot\\": "src/" } }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "scripts": { - "test": [ - "phpunit tests/unit/" - ] - }, "bin": [ "bin/patchbot" ], "config": { "allow-plugins": { - "cweagans/composer-patches": true + "cweagans/composer-patches": true, + "helhum/dotenv-connector": true } }, "extra": { + "helhum/dotenv-connector": { + "env-file": ".env" + }, "patches": { "consolidation/robo": { "Symfony Console 7.4 compatibility": "https://patch-diff.githubusercontent.com/raw/consolidation/robo/pull/1185.diff" } } + }, + "scripts": { + "test": [ + "phpunit tests/unit/" + ] } } diff --git a/src/Discovery/GitLabDiscovery.php b/src/Discovery/GitLabDiscovery.php new file mode 100644 index 0000000..26f865e --- /dev/null +++ b/src/Discovery/GitLabDiscovery.php @@ -0,0 +1,143 @@ +token = $token; + $this->baseUrl = rtrim($baseUrl, '/'); + $this->client = new Client([ + 'base_uri' => $this->baseUrl, + 'headers' => [ + 'PRIVATE-TOKEN' => $this->token, + 'Accept' => 'application/json', + ], + ]); + } + + /** + * Discover all repositories for a GitLab namespace (auto-detects group vs user) + * + * @param string $namespace Namespace path (group path or username) + * @return array{type: string, repositories: array} Type ('group' or 'user') and repository data + * @throws GuzzleException + * @throws \RuntimeException If namespace not found + */ + public function discover(string $namespace): array + { + // Try as group first + try { + $repositories = $this->discoverGroup($namespace); + return ['type' => 'group', 'repositories' => $repositories]; + } catch (\GuzzleHttp\Exception\ClientException $e) { + if ($e->getResponse()->getStatusCode() !== 404) { + throw $e; + } + } + + // Fall back to user + $repositories = $this->discoverUser($namespace); + return ['type' => 'user', 'repositories' => $repositories]; + } + + /** + * Discover all repositories in a GitLab group + * + * @param string $groupPath Group path (e.g., "my-org" or "my-org/subgroup") + * @param bool $includeSubgroups Include projects from subgroups + * @return array Array of repository data with keys: url, default_branch, name, path_with_namespace + * @throws GuzzleException + */ + public function discoverGroup(string $groupPath, bool $includeSubgroups = true): array + { + $encodedGroup = urlencode($groupPath); + + return $this->fetchProjects("/api/v4/groups/{$encodedGroup}/projects", [ + 'include_subgroups' => $includeSubgroups ? 'true' : 'false', + ]); + } + + /** + * Discover all repositories for a GitLab user + * + * @param string $username GitLab username + * @return array Array of repository data with keys: url, default_branch, name, path_with_namespace + * @throws GuzzleException + * @throws \RuntimeException If user not found + */ + public function discoverUser(string $username): array + { + // Look up user ID by username + $response = $this->client->get('/api/v4/users', [ + 'query' => ['username' => $username], + ]); + + $users = json_decode($response->getBody()->getContents(), true); + + if (empty($users)) { + throw new \RuntimeException("User '{$username}' not found"); + } + + $userId = $users[0]['id']; + + return $this->fetchProjects("/api/v4/users/{$userId}/projects"); + } + + /** + * Fetch projects from a GitLab API endpoint with pagination + * + * @param string $endpoint API endpoint + * @param array $extraParams Additional query parameters + * @return array Array of repository data + * @throws GuzzleException + */ + private function fetchProjects(string $endpoint, array $extraParams = []): array + { + $repositories = []; + $page = 1; + $perPage = 100; + + do { + $response = $this->client->get($endpoint, [ + 'query' => array_merge([ + 'per_page' => $perPage, + 'page' => $page, + 'archived' => 'false', + ], $extraParams), + ]); + + $projects = json_decode($response->getBody()->getContents(), true); + + if (empty($projects)) { + break; + } + + foreach ($projects as $project) { + $repositories[] = [ + 'name' => $project['name'], + 'path_with_namespace' => $project['path_with_namespace'], + 'url' => $project['web_url'], + 'clone_url_ssh' => $project['ssh_url_to_repo'], + 'clone_url_http' => $project['http_url_to_repo'], + 'default_branch' => $project['default_branch'] ?? 'main', + ]; + } + + $page++; + } while (count($projects) === $perPage); + + return $repositories; + } +} diff --git a/src/RoboFile.php b/src/RoboFile.php index fed5ab7..bea3930 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -3,8 +3,10 @@ namespace Pixelbrackets\Patchbot; use Cocur\Slugify\Slugify; +use Pixelbrackets\Patchbot\Discovery\GitLabDiscovery; use Robo\Contract\VerbosityThresholdInterface; use Robo\Exception\TaskException; +use Symfony\Component\Yaml\Yaml; /** * Patchbot tasks (based on Robo) @@ -180,6 +182,91 @@ public function create(array $options = [ return 0; } + /** + * Discover repositories from a GitLab namespace + * + * @param array $options + * @option $gitlab-namespace GitLab namespace (group path or username) + * @option $gitlab-url GitLab instance URL + * @option $force Overwrite repositories.yaml if it exists + * @return int exit code + */ + public function discover(array $options = [ + 'gitlab-namespace|g' => '', + 'gitlab-url' => '', + 'force|f' => false, + ]): int + { + // Get GitLab namespace: CLI option > env + $namespace = !empty($options['gitlab-namespace']) ? $options['gitlab-namespace'] : getenv('GITLAB_NAMESPACE'); + if (empty($namespace)) { + $this->io()->error('Missing GitLab namespace. Use --gitlab-namespace or set GITLAB_NAMESPACE in .env'); + return 1; + } + + // Get GitLab token from environment + $token = getenv('GITLAB_TOKEN'); + if (empty($token)) { + $this->io()->error('Missing GITLAB_TOKEN environment variable. Create a .env file with GITLAB_TOKEN=your-token'); + return 1; + } + + // Get GitLab URL: CLI option > env > default + $gitlabUrl = !empty($options['gitlab-url']) ? $options['gitlab-url'] : (getenv('GITLAB_URL') ?: 'https://gitlab.com'); + + $outputFile = 'repositories.yaml'; + + // Print summary + $this->io()->section('Discover'); + $this->io()->listing([ + 'GitLab Namespace: ' . $namespace, + 'GitLab URL: ' . $gitlabUrl, + ]); + + // Check if output file already exists + if (file_exists($outputFile) && !$options['force']) { + $this->io()->warning('File "' . $outputFile . '" already exists.'); + $this->io()->text([ + '', + 'Options:', + ' - Use --force to overwrite', + ' - Delete the file manually and run again', + ' - Compare changes with: git diff ' . $outputFile, + '', + ]); + $this->io()->error('Aborting.'); + return 1; + } + + // Discover repositories + try { + $discovery = new GitLabDiscovery($token, $gitlabUrl); + $result = $discovery->discover($namespace); + $namespaceType = $result['type']; + $repositories = $result['repositories']; + } catch (\Exception $e) { + $this->io()->error('Discovery failed: ' . $e->getMessage()); + return 1; + } + + if (empty($repositories)) { + $this->io()->warning('No repositories found for namespace "' . $namespace . '"'); + return 0; + } + + $this->io()->success('Found ' . count($repositories) . ' repositories'); + + // Store discovered repos + $yamlContent = '# Generated by patchbot discover on ' . date('Y-m-d H:i:s') . PHP_EOL; + $yamlContent .= '# Source: GitLab ' . $namespaceType . ' "' . $namespace . '" at ' . $gitlabUrl . PHP_EOL; + $yamlContent .= Yaml::dump(['repositories' => $repositories], 4, 2); + + file_put_contents($outputFile, $yamlContent); + $this->io()->success('Written to ' . $outputFile); + + return 0; + } + /** * Run batch-mode commands * From 3f488f0623a8d05a8682170a03216ed56015142c Mon Sep 17 00:00:00 2001 From: Dan Untenzu Date: Thu, 5 Feb 2026 10:35:47 +0100 Subject: [PATCH 04/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Use=20JSON=20as?= =?UTF-8?q?=20storage=20file=20and=20add=20dry-run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use JSON as storage file for discovered repos and batch mode now, since this is the best solution to still have a human readable format which may scale with upcoming features. As a sideeffect during developmemt a dry-run option was added. --- .gitignore | 1 + README.md | 81 ++++++++++++++++----------------- composer.json | 3 +- src/RoboFile.php | 114 +++++++++++++++++++++++++++++++++-------------- 4 files changed, 121 insertions(+), 78 deletions(-) diff --git a/.gitignore b/.gitignore index d7ebe7c..513c595 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /composer.phar /composer.lock /vendor/ +/repositories.json* diff --git a/README.md b/README.md index fdae5e2..aa7391b 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,12 @@ To be more verbose add `-v` to each command. Add `-vvv` for debugging. This will show all steps and commands applied by Patchbot. The flag `--no-ansi` will remove output formation. +Use `--dry-run` to preview what would happen without making any changes: + +```bash +./vendor/bin/patchbot patch --dry-run --patch-name=template --repository-url=https://git.example.com/repository +``` + ### Merge feature branch ✨️Patchbot intentionally creates a feature branch to apply patches. @@ -208,6 +214,11 @@ branch `main` in repository `https://git.example.com/repository`: ./vendor/bin/patchbot merge --source=bugfix-add-missing-lock-file --target=main --repository-url=https://git.example.com/repository ``` +Use `--dry-run` to preview without executing: +```bash +./vendor/bin/patchbot merge --dry-run --source=bugfix-add-missing-lock-file --target=main --repository-url=https://git.example.com/repository +``` + ### Add a new patch Example command to create a directory named `add-changelog-file` and @@ -272,65 +283,49 @@ repeatedly with different URLs. To do this with 300 repos you may want to use the batch processing mode instead. This mode will trigger the `patch` or `merge` command for a list of -repositories. The list is expected as CSV file named `repositories.csv`. - -*repositories.csv - Example file content, with repository & branch to use* -```csv -repository-url,main-branch -https://git.example.com/projecta,main -https://git.example.com/projectb,main -https://git.example.com/projectc,development +repositories. The list is stored in a JSON file named `repositories.json`. + +#### Discover repositories + +Use the `discover` command to automatically fetch all repositories from a +GitLab namespace (group or user): + +```bash +# Set up your GitLab token in .env +cp .env.example .env +# Edit .env with your GITLAB_TOKEN and GITLAB_NAMESPACE + +# Discover repositories +./vendor/bin/patchbot discover ``` -The `patch` subcommand allows all options of the `patch` command, except for -`repository-url` and `source-branch`. Both are provided by the -`repositories.csv` file instead. +This creates a `repositories.json` file with all discovered repositories, +including their clone URLs and default branches. -The following command will apply the patch script `update-changelog` to all -repository URLs in the first column of the `repositories.csv` file and create -the feature branch based on the name in the second column. +#### Apply patches + +The `patch` subcommand applies a patch to all repositories in `repositories.json`: ```bash ./vendor/bin/patchbot batch patch --patch-name=update-changelog ``` -The `merge` subcommand also allows all options of the `merge` command, -except for `repository-url` and `target`. Both are provided by the -`repositories.csv` file instead. +#### Merge feature branches + +The `merge` subcommand merges a feature branch into the default branch +for all repositories: -The next command will merge the feature branch `feature-add-phpcs-rules` -into the branch name in the second column of the `repositories.csv` file and -in all repositories of the first column: ```bash ./vendor/bin/patchbot batch merge --source=feature-add-phpcs-rules ``` -**Different branch names** +#### Dry run -When the branch names used in the `patch` and `merge` subcommand differ, -or when you need to merge the feature branch into several stage branches -you may provide a file with all branches and pass the name of the designated -branch column as option `branch-column`. +Use `--dry-run` to preview what would happen without making any changes: -*repositories.csv - Example file content with many branch columns* -```csv -repository-url,main,development,integration-stage,test-stage -https://git.example.com/projecta,main,development,integration,testing -https://git.example.com/projectb,main,dev,stage/integration,stage/test -https://git.example.com/projectc,live,development,stage/integration,stage/test -``` - -Apply the patch `rename-changelog` to the feature branch -`feature-rename-changelog`, which is based on branch name given in column -`development`: -```bash -./vendor/bin/patchbot batch patch --branch-column=development patch-name=rename-changelog branch-name=feature-rename-changelog -``` -Now merge the feature branch into the branch name given in column `test-stage` -and then into the of given in column `integration-stage`: ```bash -./vendor/bin/patchbot batch merge --branch-column=test-stage source=feature-rename-changelog -./vendor/bin/patchbot batch merge --branch-column=integration-stage source=feature-rename-changelog +./vendor/bin/patchbot batch patch --patch-name=update-changelog --dry-run +./vendor/bin/patchbot batch merge --source=feature-add-phpcs-rules --dry-run ``` ## License diff --git a/composer.json b/composer.json index 41d42c3..15e8959 100644 --- a/composer.json +++ b/composer.json @@ -10,8 +10,7 @@ "cweagans/composer-patches": "^1.0", "guzzlehttp/guzzle": "^7.0", "helhum/dotenv-connector": "^3.0", - "jean85/pretty-package-versions": "^2.0", - "symfony/yaml": "^5.0 || ^6.0 || ^7.0" + "jean85/pretty-package-versions": "^2.0" }, "require-dev": { "phpunit/phpunit": "^11.0" diff --git a/src/RoboFile.php b/src/RoboFile.php index bea3930..fe3643e 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -6,7 +6,6 @@ use Pixelbrackets\Patchbot\Discovery\GitLabDiscovery; use Robo\Contract\VerbosityThresholdInterface; use Robo\Exception\TaskException; -use Symfony\Component\Yaml\Yaml; /** * Patchbot tasks (based on Robo) @@ -27,6 +26,7 @@ class RoboFile extends \Robo\Tasks * @option $source-branch Name of the branch to create a new branch upon * @option $branch-name Name of the feature branch to be created * @option $halt-before-commit Pause before changes are committed, asks to continue + * @option $dry-run Show what would be done without executing * @return int exit code * @throws TaskException */ @@ -38,6 +38,7 @@ public function patch(array $options = [ 'source-branch' => 'main', 'branch-name' => null, 'halt-before-commit' => false, + 'dry-run' => false, ]): int { if (empty($options['repository-url'])) { @@ -52,13 +53,20 @@ public function patch(array $options = [ $repositoryName = pathinfo($options['repository-url'], PATHINFO_FILENAME); // Print summary - $this->io()->section('Patch'); + $this->io()->section($options['dry-run'] ? 'Patch (Dry Run)' : 'Patch'); $this->io()->listing([ 'Patch: ' . $options['patch-name'], 'Branch: ' . $options['branch-name'], 'Repository: ' . $repositoryName . ' (' . $options['repository-url'] . ')' ]); + if ($options['dry-run']) { + $this->io()->text('[DRY-RUN] Would clone repository and create branch'); + $this->io()->text('[DRY-RUN] Would run patch script: ' . $options['patch-source-directory'] . $options['patch-name'] . '/patch.php'); + $this->io()->text('[DRY-RUN] Would commit and push changes'); + return 0; + } + try { $patchApplied = $this->runPatch($options); } catch (Exception | TaskException $e) { @@ -89,6 +97,7 @@ public function patch(array $options = [ * @option $working-directory Working directory to check out repositories * @option $source Source branch name (e.g. feature branch) * @option $target Target branch name (e.g. main branch) + * @option $dry-run Show what would be done without executing * @return int exit code * @throws TaskException */ @@ -96,7 +105,8 @@ public function merge(array $options = [ 'repository-url|g' => null, 'working-directory|d' => null, 'source|s' => null, - 'target|t' => null + 'target|t' => null, + 'dry-run' => false, ]): int { if ( @@ -112,13 +122,20 @@ public function merge(array $options = [ $repositoryName = pathinfo($options['repository-url'], PATHINFO_FILENAME); // Print summary - $this->io()->section('Merge'); + $this->io()->section($options['dry-run'] ? 'Merge (Dry Run)' : 'Merge'); $this->io()->listing([ 'Source Branch: ' . $options['source'], 'Target Branch: ' . $options['target'], 'Repository: ' . $repositoryName . ' (' . $options['repository-url'] . ')' ]); + if ($options['dry-run']) { + $this->io()->text('[DRY-RUN] Would clone repository'); + $this->io()->text('[DRY-RUN] Would merge ' . $options['source'] . ' into ' . $options['target']); + $this->io()->text('[DRY-RUN] Would push changes'); + return 0; + } + try { $branchMerged = $this->runMerge($options); } catch (Exception | TaskException $e) { @@ -214,7 +231,7 @@ public function discover(array $options = [ // Get GitLab URL: CLI option > env > default $gitlabUrl = !empty($options['gitlab-url']) ? $options['gitlab-url'] : (getenv('GITLAB_URL') ?: 'https://gitlab.com'); - $outputFile = 'repositories.yaml'; + $outputFile = 'repositories.json'; // Print summary $this->io()->section('Discover'); @@ -256,12 +273,18 @@ public function discover(array $options = [ $this->io()->success('Found ' . count($repositories) . ' repositories'); - // Store discovered repos - $yamlContent = '# Generated by patchbot discover on ' . date('Y-m-d H:i:s') . PHP_EOL; - $yamlContent .= '# Source: GitLab ' . $namespaceType . ' "' . $namespace . '" at ' . $gitlabUrl . PHP_EOL; - $yamlContent .= Yaml::dump(['repositories' => $repositories], 4, 2); - - file_put_contents($outputFile, $yamlContent); + // Store discovered repos as JSON + $jsonData = [ + 'generated' => date('Y-m-d H:i:s'), + 'source' => [ + 'type' => $namespaceType, + 'namespace' => $namespace, + 'url' => $gitlabUrl, + ], + 'repositories' => $repositories, + ]; + + file_put_contents($outputFile, json_encode($jsonData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); $this->io()->success('Written to ' . $outputFile); return 0; @@ -270,73 +293,98 @@ public function discover(array $options = [ /** * Run batch-mode commands * - * @param string $batchCommand Name of command to run in batch mode (patch or merge, default: patch) + * @param string $batchCommand Name of command to run in batch mode (patch or merge) * @param array $options - * @option $repository-column Name of the repository column (fallback: first column in CSV) - * @option $branch-column Name of the branch column (fallback: second column in CSV) * @option $working-directory Working directory to check out repositories * @option $patch-source-directory Source directory for all collected patches * @option $patch-name Name of the directory where the patch code resides * @option $branch-name Name of the feature branch to be created * @option $halt-before-commit Pause before changes are committed, asks to continue - * @option $source Source branch name (e.g. feature branch) + * @option $source Source branch name for merge (e.g. feature branch) + * @option $dry-run Show what would be done without executing * @return int exit code * @throws TaskException */ public function batch(string $batchCommand, array $options = [ - 'repository-column' => null, - 'branch-column' => null, 'working-directory|d' => null, 'patch-source-directory|s' => null, 'patch-name|p' => 'template', 'branch-name' => null, 'halt-before-commit' => false, - 'source' => null + 'source' => null, + 'dry-run' => false, ]): int { $workingDirectory = getcwd(); + $configFile = 'repositories.json'; + + if (false === is_file($configFile)) { + $this->io()->error('Can not find file "' . $configFile . '". Run "patchbot discover" first.'); + return 1; + } - if (false === is_file('repositories.csv')) { - $this->io()->error('Can not find file »repositories.csv«'); + // Parse JSON config + $jsonContent = file_get_contents($configFile); + $config = json_decode($jsonContent, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + $this->io()->error('Invalid JSON in ' . $configFile . ': ' . json_last_error_msg()); return 1; } - // Parse CSV to array, with keys from header row - $csvFileContent = file_get_contents('repositories.csv'); - $repositories = str_getcsv($csvFileContent, PHP_EOL); - $header = str_getcsv(array_shift($repositories)); // get & remove header - array_walk($repositories, static function (&$k) use ($repositories, $header) { - $k = array_combine($header, str_getcsv($k)); - }); + $repositories = $config['repositories'] ?? []; + + if (empty($repositories)) { + $this->io()->warning('No repositories found in ' . $configFile); + return 0; + } + + $isDryRun = $options['dry-run']; + + if ($isDryRun) { + $this->io()->section('Dry Run'); + } if ($batchCommand === 'patch') { foreach ($repositories as $repository) { - /** @noinspection DisconnectedForeachInstructionInspection */ + if ($isDryRun) { + $this->io()->text('[DRY-RUN] Would patch: ' . $repository['path_with_namespace'] . ' (' . $repository['default_branch'] . ')'); + continue; + } chdir($workingDirectory); // reset working directory $this->patch([ - 'repository-url' => $repository[$options['repository-column']] ?? array_values($repository)[0], + 'repository-url' => $repository['clone_url_ssh'], 'working-directory' => $options['working-directory'], 'patch-source-directory' => $options['patch-source-directory'], 'patch-name' => $options['patch-name'], - 'source-branch' => $repository[$options['branch-column']] ?? array_values($repository)[1], + 'source-branch' => $repository['default_branch'], 'branch-name' => $options['branch-name'], 'halt-before-commit' => $options['halt-before-commit'], ]); } } + if ($batchCommand === 'merge') { foreach ($repositories as $repository) { - /** @noinspection DisconnectedForeachInstructionInspection */ + if ($isDryRun) { + $this->io()->text('[DRY-RUN] Would merge: ' . $options['source'] . ' -> ' . $repository['default_branch'] . ' in ' . $repository['path_with_namespace']); + continue; + } chdir($workingDirectory); // reset working directory $this->merge([ - 'repository-url' => $repository[$options['repository-column']] ?? array_values($repository)[0], + 'repository-url' => $repository['clone_url_ssh'], 'working-directory' => $options['working-directory'], 'source' => $options['source'], - 'target' => $repository[$options['branch-column']] ?? array_values($repository)[1], + 'target' => $repository['default_branch'], ]); } } + if ($isDryRun) { + $this->io()->newLine(); + $this->io()->text(count($repositories) . ' repositories would be processed'); + } + return 0; } From c9caf090c982cd3cfe56764a92e6507b03efd1d7 Mon Sep 17 00:00:00 2001 From: Dan Untenzu Date: Thu, 5 Feb 2026 10:52:43 +0100 Subject: [PATCH 05/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Add=20batch=20sum?= =?UTF-8?q?mary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report a short result list how many repos had been processed sucessfully and how many were skipped in batch mode. --- README.md | 10 +++++++ src/RoboFile.php | 74 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index aa7391b..60820d9 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,16 @@ Use `--dry-run` to preview what would happen without making any changes: ./vendor/bin/patchbot batch merge --source=feature-add-phpcs-rules --dry-run ``` +After batch processing completes, a summary shows the results: + +``` +Summary +10 repositories processed + ✓ 7 patched + - 2 skipped (no changes) + ✗ 1 failed +``` + ## License GNU General Public License version 2 or later diff --git a/src/RoboFile.php b/src/RoboFile.php index fe3643e..df567b9 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -345,6 +345,8 @@ public function batch(string $batchCommand, array $options = [ $this->io()->section('Dry Run'); } + $results = ['success' => 0, 'skipped' => 0, 'failed' => 0]; + if ($batchCommand === 'patch') { foreach ($repositories as $repository) { if ($isDryRun) { @@ -352,15 +354,27 @@ public function batch(string $batchCommand, array $options = [ continue; } chdir($workingDirectory); // reset working directory - $this->patch([ - 'repository-url' => $repository['clone_url_ssh'], - 'working-directory' => $options['working-directory'], - 'patch-source-directory' => $options['patch-source-directory'], - 'patch-name' => $options['patch-name'], - 'source-branch' => $repository['default_branch'], - 'branch-name' => $options['branch-name'], - 'halt-before-commit' => $options['halt-before-commit'], - ]); + try { + $result = $this->runPatch([ + 'repository-url' => $repository['clone_url_ssh'], + 'working-directory' => $options['working-directory'] ?? $this->getTemporaryDirectory(), + 'patch-source-directory' => ($options['patch-source-directory'] ?? getcwd() . '/patches') . '/', + 'patch-name' => $options['patch-name'], + 'source-branch' => $repository['default_branch'], + 'branch-name' => $options['branch-name'] ?? date('Ymd') . '_patchbot_' . uniqid(), + 'halt-before-commit' => $options['halt-before-commit'], + ]); + if ($result) { + $results['success']++; + $this->io()->success('Patched: ' . $repository['path_with_namespace']); + } else { + $results['skipped']++; + $this->io()->text('Skipped: ' . $repository['path_with_namespace'] . ' (no changes)'); + } + } catch (\Exception $e) { + $results['failed']++; + $this->io()->error('Failed: ' . $repository['path_with_namespace'] . ' - ' . $e->getMessage()); + } } } @@ -371,21 +385,47 @@ public function batch(string $batchCommand, array $options = [ continue; } chdir($workingDirectory); // reset working directory - $this->merge([ - 'repository-url' => $repository['clone_url_ssh'], - 'working-directory' => $options['working-directory'], - 'source' => $options['source'], - 'target' => $repository['default_branch'], - ]); + try { + $result = $this->runMerge([ + 'repository-url' => $repository['clone_url_ssh'], + 'working-directory' => $options['working-directory'] ?? $this->getTemporaryDirectory(), + 'source' => $options['source'], + 'target' => $repository['default_branch'], + ]); + if ($result) { + $results['success']++; + $this->io()->success('Merged: ' . $repository['path_with_namespace']); + } else { + $results['skipped']++; + $this->io()->text('Skipped: ' . $repository['path_with_namespace'] . ' (already up-to-date)'); + } + } catch (\Exception $e) { + $results['failed']++; + $this->io()->error('Failed: ' . $repository['path_with_namespace'] . ' - ' . $e->getMessage()); + } } } + // Print summary + $this->io()->newLine(); if ($isDryRun) { - $this->io()->newLine(); $this->io()->text(count($repositories) . ' repositories would be processed'); + } else { + $total = $results['success'] + $results['skipped'] + $results['failed']; + $this->io()->section('Summary'); + $this->io()->text($total . ' repositories processed'); + if ($results['success'] > 0) { + $this->io()->text(' ✓ ' . $results['success'] . ' ' . ($batchCommand === 'patch' ? 'patched' : 'merged')); + } + if ($results['skipped'] > 0) { + $this->io()->text(' - ' . $results['skipped'] . ' skipped (no changes)'); + } + if ($results['failed'] > 0) { + $this->io()->text(' ✗ ' . $results['failed'] . ' failed'); + } } - return 0; + return $results['failed'] > 0 ? 1 : 0; } /** From de6859b32258dca0de9c0e4a26c31f2f5c8ee743 Mon Sep 17 00:00:00 2001 From: Dan Untenzu Date: Thu, 5 Feb 2026 11:27:21 +0100 Subject: [PATCH 06/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Add=20filter=20to?= =?UTF-8?q?=20batch=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow to filter the list of repos by path or gitlab topic (eg. only repos with a certain product). ./vendor/bin/patchbot batch patch --filter="path:*/typo3-*" --filter="topic:php" --patch-name=X --- README.md | 28 +++++++++++++------- src/Discovery/GitLabDiscovery.php | 1 + src/RoboFile.php | 44 +++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 60820d9..b51ab26 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,8 @@ to use the batch processing mode instead. This mode will trigger the `patch` or `merge` command for a list of repositories. The list is stored in a JSON file named `repositories.json`. +After batch processing completes, a summary shows the results. + #### Discover repositories Use the `discover` command to automatically fetch all repositories from a @@ -319,6 +321,22 @@ for all repositories: ./vendor/bin/patchbot batch merge --source=feature-add-phpcs-rules ``` +#### Filter repositories + +Use `--filter` to process only matching repositories: + +```bash +# Filter by path pattern (glob syntax) +./vendor/bin/patchbot batch patch --patch-name=X --filter="path:my-org/*" +./vendor/bin/patchbot batch patch --patch-name=X --filter="path:*/typo3-*" + +# Filter by GitLab topic +./vendor/bin/patchbot batch patch --patch-name=X --filter="topic:php" + +# Combine multiple filters (AND logic) +./vendor/bin/patchbot batch patch --patch-name=X --filter="path:my-org/*" --filter="topic:php" +``` + #### Dry run Use `--dry-run` to preview what would happen without making any changes: @@ -328,16 +346,6 @@ Use `--dry-run` to preview what would happen without making any changes: ./vendor/bin/patchbot batch merge --source=feature-add-phpcs-rules --dry-run ``` -After batch processing completes, a summary shows the results: - -``` -Summary -10 repositories processed - ✓ 7 patched - - 2 skipped (no changes) - ✗ 1 failed -``` - ## License GNU General Public License version 2 or later diff --git a/src/Discovery/GitLabDiscovery.php b/src/Discovery/GitLabDiscovery.php index 26f865e..d7cfc71 100644 --- a/src/Discovery/GitLabDiscovery.php +++ b/src/Discovery/GitLabDiscovery.php @@ -132,6 +132,7 @@ private function fetchProjects(string $endpoint, array $extraParams = []): array 'clone_url_ssh' => $project['ssh_url_to_repo'], 'clone_url_http' => $project['http_url_to_repo'], 'default_branch' => $project['default_branch'] ?? 'main', + 'topics' => $project['topics'] ?? [], ]; } diff --git a/src/RoboFile.php b/src/RoboFile.php index df567b9..a92fd36 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -302,6 +302,7 @@ public function discover(array $options = [ * @option $halt-before-commit Pause before changes are committed, asks to continue * @option $source Source branch name for merge (e.g. feature branch) * @option $dry-run Show what would be done without executing + * @option $filter Filter repositories (path:pattern or topic:tag, can be used multiple times) * @return int exit code * @throws TaskException */ @@ -313,6 +314,7 @@ public function batch(string $batchCommand, array $options = [ 'halt-before-commit' => false, 'source' => null, 'dry-run' => false, + 'filter' => [], ]): int { $workingDirectory = getcwd(); @@ -339,6 +341,17 @@ public function batch(string $batchCommand, array $options = [ return 0; } + // Apply filters + $filters = is_array($options['filter']) ? $options['filter'] : [$options['filter']]; + $filters = array_filter($filters); // remove empty values + if (!empty($filters)) { + $repositories = $this->filterRepositories($repositories, $filters); + if (empty($repositories)) { + $this->io()->warning('No repositories match the filter criteria'); + return 0; + } + } + $isDryRun = $options['dry-run']; if ($isDryRun) { @@ -633,4 +646,35 @@ protected function say($text): void parent::say($text); } } + + /** + * Filter repositories based on filter expressions + * + * @param array $repositories List of repositories + * @param array $filters Filter expressions (path:pattern or topic:tag) + * @return array Filtered repositories + */ + protected function filterRepositories(array $repositories, array $filters): array + { + foreach ($filters as $filter) { + if (!str_contains($filter, ':')) { + $this->io()->warning('Invalid filter format: ' . $filter . ' (expected path:pattern or topic:tag)'); + continue; + } + + [$type, $value] = explode(':', $filter, 2); + + $repositories = match ($type) { + 'path' => array_filter($repositories, fn($repo) => fnmatch($value, $repo['path_with_namespace'])), + 'topic' => array_filter($repositories, fn($repo) => in_array($value, $repo['topics'] ?? [])), + default => $repositories, + }; + + if ($type !== 'path' && $type !== 'topic') { + $this->io()->warning('Unknown filter type: ' . $type . ' (supported: path, topic)'); + } + } + + return array_values($repositories); + } } From 0c60526dedd6d8597251377be71a5820eb26aa94 Mon Sep 17 00:00:00 2001 From: Dan Untenzu Date: Thu, 5 Feb 2026 11:40:12 +0100 Subject: [PATCH 07/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Docs:=20Add=20CI?= =?UTF-8?q?=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add example to run the script in CI context --- .gitlab-ci.example.yml | 34 ++++++++++++++++++++++++++++++++++ README.md | 8 ++++++++ 2 files changed, 42 insertions(+) create mode 100644 .gitlab-ci.example.yml diff --git a/.gitlab-ci.example.yml b/.gitlab-ci.example.yml new file mode 100644 index 0000000..9284aab --- /dev/null +++ b/.gitlab-ci.example.yml @@ -0,0 +1,34 @@ +# Example GitLab CI configuration for Patchbot +# +# Setup: +# 1. Copy this file to .gitlab-ci.yml in your patchbot config repository +# 2. Set CI/CD variables: GITLAB_TOKEN, GITLAB_NAMESPACE +# 3. Trigger manually via GitLab UI (CI/CD > Pipelines > Run pipeline) +# +# Note: Scheduled runs require idempotency tracking (not yet implemented). +# For now, trigger manually when you want to apply a patch. + +stages: + - patch + +variables: + PATCH_NAME: "template" + # Optional: filter repositories + # FILTER: "--filter=path:my-org/* --filter=topic:php" + +patchbot: + stage: patch + image: php:8.3-cli + before_script: + - apt-get update && apt-get install -y git unzip + - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + - composer install --no-interaction --prefer-dist + # Configure Git for commits + - git config --global user.email "patchbot@example.com" + - git config --global user.name "Patchbot" + script: + - test -f repositories.json || ./vendor/bin/patchbot discover + - ./vendor/bin/patchbot batch patch --patch-name=$PATCH_NAME $FILTER + rules: + - if: $CI_PIPELINE_SOURCE == "web" + when: manual diff --git a/README.md b/README.md index b51ab26..b6aced9 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,14 @@ Use `--dry-run` to preview what would happen without making any changes: ./vendor/bin/patchbot batch merge --source=feature-add-phpcs-rules --dry-run ``` +#### GitLab CI + +Run Patchbot via GitLab CI instead of locally: + +1. Copy `.gitlab-ci.example.yml` to `.gitlab-ci.yml` in your config repository +2. Set CI/CD variables: `GITLAB_TOKEN`, `GITLAB_NAMESPACE` +3. Trigger manually via GitLab UI (CI/CD > Pipelines > Run pipeline) + ## License GNU General Public License version 2 or later From 5b2d8a17aa79e53095182e068de61e39482e98ea Mon Sep 17 00:00:00 2001 From: Dan Untenzu Date: Thu, 5 Feb 2026 14:05:02 +0100 Subject: [PATCH 08/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Add=20MR=20creati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an option to create a GitLab MR right away when pushing a patched feature branch. /patchbot patch --create-mr --patch-name=template --repository-url=https:://git.example.com/repo --- README.md | 11 ++++ src/RoboFile.php | 142 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 145 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b6aced9..3fbcdfe 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,17 @@ Use `--filter` to process only matching repositories: ./vendor/bin/patchbot batch patch --patch-name=X --filter="path:my-org/*" --filter="topic:php" ``` +#### Create merge requests + +Use `--create-mr` to automatically create GitLab merge requests after pushing: + +```bash +./vendor/bin/patchbot patch --create-mr --patch-name=template --repository-url=git@gitlab.com:user/repo.git +./vendor/bin/patchbot batch patch --create-mr --patch-name=template +``` + +Requires `GITLAB_TOKEN` environment variable. + #### Dry run Use `--dry-run` to preview what would happen without making any changes: diff --git a/src/RoboFile.php b/src/RoboFile.php index a92fd36..65b1225 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -27,6 +27,7 @@ class RoboFile extends \Robo\Tasks * @option $branch-name Name of the feature branch to be created * @option $halt-before-commit Pause before changes are committed, asks to continue * @option $dry-run Show what would be done without executing + * @option $create-mr Create GitLab merge request after pushing * @return int exit code * @throws TaskException */ @@ -39,6 +40,7 @@ public function patch(array $options = [ 'branch-name' => null, 'halt-before-commit' => false, 'dry-run' => false, + 'create-mr' => false, ]): int { if (empty($options['repository-url'])) { @@ -64,6 +66,9 @@ public function patch(array $options = [ $this->io()->text('[DRY-RUN] Would clone repository and create branch'); $this->io()->text('[DRY-RUN] Would run patch script: ' . $options['patch-source-directory'] . $options['patch-name'] . '/patch.php'); $this->io()->text('[DRY-RUN] Would commit and push changes'); + if ($options['create-mr']) { + $this->io()->text('[DRY-RUN] Would create merge request'); + } return 0; } @@ -80,11 +85,26 @@ public function patch(array $options = [ } $this->io()->success('Patch applied'); - // Suggest next steps - $this->say('Hint: Run `./vendor/bin/patchbot merge' - . ' --source=' . $options['branch-name'] - . ' --target=' - . ' --repository-url=' . $options['repository-url'] . '` to merge the feature branch'); + + // Create merge request if requested + if ($options['create-mr']) { + $commitMessage = file_get_contents($options['patch-source-directory'] . $options['patch-name'] . '/commit-message.txt'); + $mrUrl = $this->createMergeRequest( + $options['repository-url'], + $options['branch-name'], + $options['source-branch'], + $commitMessage + ); + if ($mrUrl) { + $this->io()->success('Merge request created: ' . $mrUrl); + } + } else { + // Suggest next steps + $this->say('Hint: Run `./vendor/bin/patchbot merge' + . ' --source=' . $options['branch-name'] + . ' --target=' + . ' --repository-url=' . $options['repository-url'] . '` to merge the feature branch'); + } return 0; } @@ -303,6 +323,7 @@ public function discover(array $options = [ * @option $source Source branch name for merge (e.g. feature branch) * @option $dry-run Show what would be done without executing * @option $filter Filter repositories (path:pattern or topic:tag, can be used multiple times) + * @option $create-mr Create GitLab merge request after pushing * @return int exit code * @throws TaskException */ @@ -315,6 +336,7 @@ public function batch(string $batchCommand, array $options = [ 'source' => null, 'dry-run' => false, 'filter' => [], + 'create-mr' => false, ]): int { $workingDirectory = getcwd(); @@ -361,9 +383,16 @@ public function batch(string $batchCommand, array $options = [ $results = ['success' => 0, 'skipped' => 0, 'failed' => 0]; if ($batchCommand === 'patch') { + $patchSourceDirectory = ($options['patch-source-directory'] ?? getcwd() . '/patches') . '/'; + $branchName = $options['branch-name'] ?? date('Ymd') . '_patchbot_' . uniqid(); + foreach ($repositories as $repository) { if ($isDryRun) { - $this->io()->text('[DRY-RUN] Would patch: ' . $repository['path_with_namespace'] . ' (' . $repository['default_branch'] . ')'); + $dryRunMsg = '[DRY-RUN] Would patch: ' . $repository['path_with_namespace'] . ' (' . $repository['default_branch'] . ')'; + if ($options['create-mr']) { + $dryRunMsg .= ' and create MR'; + } + $this->io()->text($dryRunMsg); continue; } chdir($workingDirectory); // reset working directory @@ -371,15 +400,29 @@ public function batch(string $batchCommand, array $options = [ $result = $this->runPatch([ 'repository-url' => $repository['clone_url_ssh'], 'working-directory' => $options['working-directory'] ?? $this->getTemporaryDirectory(), - 'patch-source-directory' => ($options['patch-source-directory'] ?? getcwd() . '/patches') . '/', + 'patch-source-directory' => $patchSourceDirectory, 'patch-name' => $options['patch-name'], 'source-branch' => $repository['default_branch'], - 'branch-name' => $options['branch-name'] ?? date('Ymd') . '_patchbot_' . uniqid(), + 'branch-name' => $branchName, 'halt-before-commit' => $options['halt-before-commit'], ]); if ($result) { $results['success']++; $this->io()->success('Patched: ' . $repository['path_with_namespace']); + + // Create merge request if requested + if ($options['create-mr']) { + $commitMessage = file_get_contents($patchSourceDirectory . $options['patch-name'] . '/commit-message.txt'); + $mrUrl = $this->createMergeRequest( + $repository['clone_url_ssh'], + $branchName, + $repository['default_branch'], + $commitMessage + ); + if ($mrUrl) { + $this->io()->text(' MR: ' . $mrUrl); + } + } } else { $results['skipped']++; $this->io()->text('Skipped: ' . $repository['path_with_namespace'] . ' (no changes)'); @@ -677,4 +720,87 @@ protected function filterRepositories(array $repositories, array $filters): arra return array_values($repositories); } + + /** + * Create a GitLab merge request + * + * @param string $repositoryUrl Repository URL (SSH or HTTPS) + * @param string $sourceBranch Feature branch name + * @param string $targetBranch Target branch name + * @param string $commitMessage Commit message (first line used as title) + * @return string|null MR URL on success, null on failure + */ + protected function createMergeRequest( + string $repositoryUrl, + string $sourceBranch, + string $targetBranch, + string $commitMessage + ): ?string { + $token = getenv('GITLAB_TOKEN'); + if (empty($token)) { + $this->io()->warning('Cannot create MR: GITLAB_TOKEN not set'); + return null; + } + + $gitlabUrl = getenv('GITLAB_URL') ?: 'https://gitlab.com'; + $projectPath = $this->extractProjectPath($repositoryUrl); + + if (empty($projectPath)) { + $this->io()->warning('Cannot create MR: unable to extract project path from URL'); + return null; + } + + // First line of commit message is title, rest is description + $lines = explode("\n", trim($commitMessage)); + $title = $lines[0]; + $description = count($lines) > 1 ? implode("\n", array_slice($lines, 1)) : ''; + + $this->say('Creating merge request for ' . $projectPath); + + try { + $client = new \GuzzleHttp\Client([ + 'base_uri' => rtrim($gitlabUrl, '/'), + 'headers' => [ + 'PRIVATE-TOKEN' => $token, + 'Content-Type' => 'application/json', + ], + ]); + + $response = $client->post('/api/v4/projects/' . urlencode($projectPath) . '/merge_requests', [ + 'json' => [ + 'source_branch' => $sourceBranch, + 'target_branch' => $targetBranch, + 'title' => $title, + 'description' => $description, + ], + ]); + + $data = json_decode($response->getBody()->getContents(), true); + return $data['web_url'] ?? null; + } catch (\GuzzleHttp\Exception\ClientException $e) { + $this->io()->error('Failed to create MR: ' . $e->getMessage()); + return null; + } + } + + /** + * Extract project path from repository URL + * + * @param string $url Repository URL (SSH or HTTPS) + * @return string|null Project path (e.g., "user/repo") + */ + protected function extractProjectPath(string $url): ?string + { + // SSH format: git@gitlab.com:user/repo.git + if (preg_match('/^git@[^:]+:(.+?)(?:\.git)?$/', $url, $matches)) { + return $matches[1]; + } + + // HTTPS format: https://gitlab.com/user/repo.git + if (preg_match('/^https?:\/\/[^\/]+\/(.+?)(?:\.git)?$/', $url, $matches)) { + return $matches[1]; + } + + return null; + } } From 2f116177393d5e53ccc8f313e8869644e392420a Mon Sep 17 00:00:00 2001 From: Dan Untenzu Date: Thu, 5 Feb 2026 14:40:37 +0100 Subject: [PATCH 09/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Enable=20setting?= =?UTF-8?q?=20git=20user=20&=20mail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow to set username and mail for the bot during patch creation. If not given, the system default (current user) is used. --- .env.example | 5 +++++ README.md | 11 +++++++++++ src/RoboFile.php | 9 +++++++++ 3 files changed, 25 insertions(+) diff --git a/.env.example b/.env.example index 1321dac..1683c6d 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,8 @@ GITLAB_URL=https://gitlab.com # Default GitLab namespace to discover (group path or username) # Auto-detects whether it's a group or user GITLAB_NAMESPACE=acmeusernameongitlabcom + +# Optional: Override Git user for commits +# If not set, uses system default (git config user.name/email) +# BOT_GIT_NAME=Patchbot +# BOT_GIT_EMAIL=patchbot@example.com diff --git a/README.md b/README.md index 3fbcdfe..ee68ad2 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,17 @@ Use `--dry-run` to preview what would happen without making any changes: ./vendor/bin/patchbot batch merge --source=feature-add-phpcs-rules --dry-run ``` +#### Custom Git user + +By default, commits use your system Git config. To use a different identity +(e.g., a bot user), set these environment variables: + +```bash +# In .env or shell +BOT_GIT_NAME="Patchbot" +BOT_GIT_EMAIL="patchbot@example.com" +``` + #### GitLab CI Run Patchbot via GitLab CI instead of locally: diff --git a/src/RoboFile.php b/src/RoboFile.php index 65b1225..7783070 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -559,6 +559,15 @@ protected function runPatch(array $options): bool } } + // Configure Git user if set in environment + $botGitName = getenv('BOT_GIT_NAME'); + $botGitEmail = getenv('BOT_GIT_EMAIL'); + if (!empty($botGitName) && !empty($botGitEmail)) { + $this->say('Configure Git user: ' . $botGitName . ' <' . $botGitEmail . '>'); + shell_exec('git config user.name ' . escapeshellarg($botGitName)); + shell_exec('git config user.email ' . escapeshellarg($botGitEmail)); + } + // Commit changes $this->say('Commit changes'); $commitMessage = file_get_contents($options['patch-source-directory'] . $options['patch-name'] . '/commit-message.txt'); From f4f25a5d3261c56bdc81a7ce72f432514674786c Mon Sep 17 00:00:00 2001 From: Dan Untenzu Date: Fri, 13 Feb 2026 22:26:17 +0100 Subject: [PATCH 10/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Easen=20command?= =?UTF-8?q?=20arguments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add positional arguments to easen the usage of all comands. Example before: ./vendor/bin/patchbot patch --patch-name=template --repository-url=https://git.example.com/repository After: ./vendor/bin/patchbot patch template https://git.example.com/repository Also replaces the batch command with specific patch-many/merge-many commands. All commands stay backwards compatible though. --- README.md | 78 +++--- docs/carbon.txt | 2 +- docs/screenshot.png | Bin 51163 -> 20168 bytes src/RoboFile.php | 349 ++++++++++++++++++++++---- tests/unit/PatchbotCommandsTest.php | 18 +- tests/unit/PatchbotTest.php | 44 +++- tests/unit/src/CommandTesterTrait.php | 3 + 7 files changed, 401 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index ee68ad2..77c3ae8 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,9 @@ let Patchbot discover them automatically from a GitLab namespace. Usage: ```bash -./vendor/bin/patchbot discover # uses GITLAB_NAMESPACE and GITLAB_URL from .env -./vendor/bin/patchbot discover -g myusername # explicit namespace -./vendor/bin/patchbot discover --force # overwrite existing storage file +./vendor/bin/patchbot discover # uses GITLAB_NAMESPACE and GITLAB_URL from .env +./vendor/bin/patchbot discover --gitlab-namespace=myusername # set namespace to crawl +./vendor/bin/patchbot discover --force # overwrite existing storage file ``` ## Source @@ -154,19 +154,22 @@ many repositories or ad hoc every time the need arises. ### Apply patch -Pass the name of the patch directory as `patch-name` and the Git repository as -`repository-url` to the `patchbot` script. +Pass the name of the patch directory and the Git repository URL to apply a patch: + +```bash +./vendor/bin/patchbot patch +``` Example command applying the patch script in directory `template` to the repository `https://git.example.com/repository`: ```bash -./vendor/bin/patchbot patch --patch-name=template --repository-url=https://git.example.com/repository +./vendor/bin/patchbot patch template https://git.example.com/repository ``` Example command applying the patch script in directory `template` to -the repository `ssh://git@git.example.com/repository.git`: +the repository `git@git.example.com:user/repository.git`: ```bash -./vendor/bin/patchbot patch --patch-name=template --repository-url=ssh://git@git.example.com/repository.git +./vendor/bin/patchbot patch template git@git.example.com:user/repository.git ``` **Custom options** @@ -174,13 +177,13 @@ the repository `ssh://git@git.example.com/repository.git`: To create the feature branch based on the branch `development` instead of the default main branch use this command: ```bash -./vendor/bin/patchbot patch --source-branch=development --patch-name=template --repository-url=https://git.example.com/repository +./vendor/bin/patchbot patch template https://git.example.com/repository --source-branch=development ``` Patchbot will use a random name for the feature branch. To use a custom name like `feature-1337-add-license-file` for the feature branch instead run: ```bash -./vendor/bin/patchbot patch --branch-name=feature-1337-add-license-file --patch-name=template --repository-url=https://git.example.com/repository +./vendor/bin/patchbot patch template https://git.example.com/repository --branch-name=feature-1337-add-license-file ``` It is recommended to let a CI run all tests. That's why Patchbot creates a @@ -188,7 +191,7 @@ feature branch by default. If you want to review complex changes manually before the commit is created, then use the `halt-before-commit` option: ```bash -./vendor/bin/patchbot patch --halt-before-commit --patch-name=template --repository-url=https://git.example.com/repository +./vendor/bin/patchbot patch template https://git.example.com/repository --halt-before-commit ``` To be more verbose add `-v` to each command. Add `-vvv` for debugging. @@ -198,7 +201,7 @@ The flag `--no-ansi` will remove output formation. Use `--dry-run` to preview what would happen without making any changes: ```bash -./vendor/bin/patchbot patch --dry-run --patch-name=template --repository-url=https://git.example.com/repository +./vendor/bin/patchbot patch template https://git.example.com/repository --dry-run ``` ### Merge feature branch @@ -208,15 +211,19 @@ Use `--dry-run` to preview what would happen without making any changes: When you reviewed the feature branch and all CI tests are successful then you can use Patchbot again to merge the feature branch. +```bash +./vendor/bin/patchbot merge +``` + Example command to merge branch `bugfix-add-missing-lock-file` into branch `main` in repository `https://git.example.com/repository`: ```bash -./vendor/bin/patchbot merge --source=bugfix-add-missing-lock-file --target=main --repository-url=https://git.example.com/repository +./vendor/bin/patchbot merge bugfix-add-missing-lock-file main https://git.example.com/repository ``` Use `--dry-run` to preview without executing: ```bash -./vendor/bin/patchbot merge --dry-run --source=bugfix-add-missing-lock-file --target=main --repository-url=https://git.example.com/repository +./vendor/bin/patchbot merge bugfix-add-missing-lock-file main https://git.example.com/repository --dry-run ``` ### Add a new patch @@ -224,8 +231,9 @@ Use `--dry-run` to preview without executing: Example command to create a directory named `add-changelog-file` and all files needed for the patch (the name is slugified automatically): ```bash -./vendor/bin/patchbot create --patch-name="Add CHANGELOG file" +./vendor/bin/patchbot create "Add CHANGELOG file" ``` + Or copy the example folder `template` manually instead and rename it as desired. Now replace the patch code in `patch.php` and the commit message @@ -297,8 +305,11 @@ GitLab namespace (group or user): cp .env.example .env # Edit .env with your GITLAB_TOKEN and GITLAB_NAMESPACE -# Discover repositories +# Discover repositories (uses GITLAB_NAMESPACE from .env) ./vendor/bin/patchbot discover + +# Or specify namespace directly +./vendor/bin/patchbot discover --gitlab-namespace=mygroup ``` This creates a `repositories.json` file with all discovered repositories, @@ -306,19 +317,28 @@ including their clone URLs and default branches. #### Apply patches -The `patch` subcommand applies a patch to all repositories in `repositories.json`: +Apply a patch to all repositories in `repositories.json`: + +```bash +./vendor/bin/patchbot patch-many +``` +Example: ```bash -./vendor/bin/patchbot batch patch --patch-name=update-changelog +./vendor/bin/patchbot patch-many update-changelog ``` #### Merge feature branches -The `merge` subcommand merges a feature branch into the default branch -for all repositories: +Merge a feature branch into the default branch for all repositories: + +```bash +./vendor/bin/patchbot merge-many +``` +Example: ```bash -./vendor/bin/patchbot batch merge --source=feature-add-phpcs-rules +./vendor/bin/patchbot merge-many feature-add-phpcs-rules ``` #### Filter repositories @@ -327,14 +347,14 @@ Use `--filter` to process only matching repositories: ```bash # Filter by path pattern (glob syntax) -./vendor/bin/patchbot batch patch --patch-name=X --filter="path:my-org/*" -./vendor/bin/patchbot batch patch --patch-name=X --filter="path:*/typo3-*" +./vendor/bin/patchbot patch-many update-changelog --filter="path:my-org/*" +./vendor/bin/patchbot patch-many update-changelog --filter="path:*/typo3-*" # Filter by GitLab topic -./vendor/bin/patchbot batch patch --patch-name=X --filter="topic:php" +./vendor/bin/patchbot patch-many update-changelog --filter="topic:php" # Combine multiple filters (AND logic) -./vendor/bin/patchbot batch patch --patch-name=X --filter="path:my-org/*" --filter="topic:php" +./vendor/bin/patchbot patch-many update-changelog --filter="path:my-org/*" --filter="topic:php" ``` #### Create merge requests @@ -342,8 +362,8 @@ Use `--filter` to process only matching repositories: Use `--create-mr` to automatically create GitLab merge requests after pushing: ```bash -./vendor/bin/patchbot patch --create-mr --patch-name=template --repository-url=git@gitlab.com:user/repo.git -./vendor/bin/patchbot batch patch --create-mr --patch-name=template +./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git --create-mr +./vendor/bin/patchbot patch-many update-changelog --create-mr ``` Requires `GITLAB_TOKEN` environment variable. @@ -353,8 +373,8 @@ Requires `GITLAB_TOKEN` environment variable. Use `--dry-run` to preview what would happen without making any changes: ```bash -./vendor/bin/patchbot batch patch --patch-name=update-changelog --dry-run -./vendor/bin/patchbot batch merge --source=feature-add-phpcs-rules --dry-run +./vendor/bin/patchbot patch-many update-changelog --dry-run +./vendor/bin/patchbot merge-many feature-add-phpcs-rules --dry-run ``` #### Custom Git user diff --git a/docs/carbon.txt b/docs/carbon.txt index 3d95805..7a086de 100644 --- a/docs/carbon.txt +++ b/docs/carbon.txt @@ -1,4 +1,4 @@ -~/acme-migrations $ ./vendor/bin/patchbot patch --patch-name=my-patch --repository-url=https://git.example.com/example-repository -v +~/acme-migrations $ ./vendor/bin/patchbot patch my-patch https://git.example.com/example-repository -v Patch ----- diff --git a/docs/screenshot.png b/docs/screenshot.png index 3218560e741bb4089c26ad85f8e1cfb789fa0430..d62c8444250f52d3450d09a73fa7c3b549644a46 100644 GIT binary patch literal 20168 zcmb@tbx>SQ@HVZd;-F64V~#4ZN=nO6P*HVscNKgs6c!cb;Np&mj?T@`?}ZFR$Hk6} zkGXq#es64aa&^_yH%QOQN=Z*A{_vr^s;aTMnTL-*C^W>v%DSkuBq%aID>uj3%uGXD z``18!K~a<(}^N2A`l%*RP)D))rDSa^C>|(9fUaz~I{NbzdSPJiUF)EG$~ve}WSd zRMpk1YHREq99)4wIe7(dKVNwzrG(@p89BKZQ(gLD@t00E9lcMi92|)$DQz7ckulNs zPEKy_?hQ>pv~_j8)1(dD_!gCA$;&wx3g~61DBcT_Nb?iZk`aseB{3@KsF`zWH!ky7 z`6#3{Uh1hgiwjYA30^ibi>WY?#xwfJ(*B5`j;>bDAJ#GR6CyOF%IMZ~iV{~ZAC3E9 zH3sM%)-~MJN2kZG?mT(}wni?wnmo=OjfR2>e4`cRP5x;4bc; zfo`XF(NCoUe<+7$0Xtz$@7GOOL_48Un5G|k&Gy*AnE16dUUq}}51piX-Eew>y~4FG zk4YokP|G0Z&2~b2D#yX5tGjE+;!^tl>Zfyoj+jernw#qfs$F|H;3|K9jIPn->I4CW z`g*_DCV<{xbyx6QF5f=_^Y(9;-DDel;K50194vUd#t^}ts$F)%c(3bqv|JO)JDn_Y za5-qPXJ7Aje>v(YU0^ri)g|Y5jKJ2n6Svbwb(g@mb7&TR`z3oz_0I@zOW?6izEJe2p8m-r^E9^MNnkSOi~)2WbJTq+ zl+Hk>%)>9?=Z^_rx(*?EdH(6dosogUEmF%4V0u^YZOmbGjoXHA!3`EP!flua!@lU# zhbhC!#;C-yrRdQYA!L^#a0Nkcu~t4+e*uPV-?l0b-p?|TSP{RTfo94M@>_b0_`VP8 zbM2|>K@j?&e)KKM85v}DvkkKiu*iVi8iC8Q144b|N* zdU?zvvaO%jxJ^0*MA?iyxmRSqatZFfhNp8Cb!?H)ZfCCT3=*xzCMQNy+#&)%T2KMJ8{F`=H3ES;Vu;grvoG zjR=;45`x_t^SNR|woQC*BykZ!LI6FBM!UGT#Jc4#g5sA1GZ)IV3GQ7dsz(By2j*x0 z+=LDqh0q8|ah~H}-M{^$QOnZiU=&Tlys>ei-v%L>m63gOlj~Q%C**E9r1jUb@!-$Z zA4nvBm~_riHEO3On_+E1-3fby;(+5PTCaZVKGv;6y5nRQ?z&Xwp_9cQ-;T#^v{HR3 zI|gqti>Mc%9sWh@vTW8vT53iO(WB9E7b`925A@TVmz{AQvTg0VT)Uq;_?|r(`oY)) z+pJr}$LBJGiqxi@b!mDBC&ccI{A~T#_$~5A$ZS81=enbB(v(fUpNx&-dK37G?pAaV zc3a_gd*0q9Mju`W_;#c)E+(~vn1>T~`)TaT{1wBV)?ME;aZemgjsw0}bP&SY+GS)` zy?j=4-CvSL+PF7JVORH@*wfyk`IF;}K9NSC$%32cQ4eRMI9%uDFCB&hGHjwGdNyk& zux{FizOGIv0|t@?A=}raYF%MMP$Gx01$PhpQ;(I14DO%L2k|;Z?~vPDhrtsBDqByB zjO|24sZw6g$uGYO+7-t@A^JO6SQ<)MvShrwI9FV?5y(U&^h(Sp)Qcn3Cc{mzN!>LZ zTc}KoHL+iRa$meOw3vAU5~DfV0HgIGSu~F#A~yu`sihjd-%4(mt_3sSYPR$rNXamc zbANI_Fld3~iRUcG8g6^M&RGa_NlOUv>7yjDg_sZU!v5K96mu6@?4%roZ~`6wGh{!` z;(m}uN_@-irSjwq9c&iuZLIxbo6~1bErVwrnG|xr_#gvS+gb7rs8uUFUK^3{ajjk4 z-x%)M)-`3j{!Jl+H-ER3+zx)s5Tc6*zi6+TTyy&_oCb_flWYmwZXSY%XrI1-p{38+caG45 z-+lUXK`(9uUaO*O;n8BvzOkgWUQR|a+)%d)#~o!q_v&icarq>%W5={@&I)ne{Wkc} zLpe^wVTjfN!V1fPtfue=JCrUO*T5Xh${q%YyDvV`$J&unMY*21Kn0+gzU(w6-+@IX zu~_$~s=FM!O9B(EAr%Bju3oP)i(P|PKA1reTIV=n_}y*Tv`wFfV2!Ij0I)E(ShBCi zrK_!sVj7an*}HoM=myAfhc*O3I#s(ODL^_N40T=mW*gY{RNycfUx@~0FN=C2 zq0Xq+`XJVdZVDOYI(Si}RAOQu{lzR8=sjz>?i%R->b6ldL+Kh-0kOLisk&D#uzl{@ zTI3%oB98y0t<`#wx{EB(21BHGoN^hOjHrDg*PPc1U^<(32CsTvn|C85?oMlh^RO?5 z3b|lh+Li^%s7aTJdNwU?vYuBTU$ttQ1cCu+pSzM?iwDvPmr6i)3gVmR9NVZ_02v(!G>^ zfydgQuNEU9F~-hAvg`ED+bm(NR_E_epkwUolp*ZhX0OJEvpUl)%9oI5sA zvkOrFHhwZ5nnCy)tdkYWHOu26?1;OKo>%Wyw$Lb`k(* z2NX@^0s77_#_7E4v+L)LHh^@~>&m(c{#y`xx0qpY!2Z}B|2MJ_?GWoHow|ny|EnK; zKap;rL@!~42hrVJ&y{*X#+}=VxKMGCEZF!WP^i+?Mg*t7v1DW$y!knTZP2$QI0RW< zk!j(?*=BsE=rp2YHAo~?Z|1vH8&+59$$KL_Zux(^do2;g;mnA zkZX4QW~}rv%sNfZ##&i2V44~=?6$cWCpql93D!m(@h?w=kc0={bSkG5WoAVd&%Ivi z>uq&`iu?V_QfJl~I>=i^pQLI^RO>BxMGTWk94#ZVUd$I} zSrZq$Gg&0Re5;;Jrr|_AxpudGQ<91{nepjll;}mjHBmeVcyJ;hABX_e>`i!4tf0xK zJ8!=|-wf$-7+&=ig8oSLILck>Y`h#DOM=laD%G1z+{K?KfwUHWql2`fhH)$wY)`O3 z-D_EwqeNu}rXvDTYxh!TLwEe~3G)LdcM1J>>YvK?LvwaC(;qzm3@j!*vl75%u&|#(hqY~vjC&f{^M)UL2GWfbqe81s+mrW*zEh8~x;3%4 z7%^cbQ3LM*fNu>QdvHgSKI9vF!GlV948c8VJZd9MQ}O(d*dJM%=GM-So?Pp4R?VLS zXx)v6%Wq}?DHBCn7qsrAg{4Nt7@;e{%>Lde?eIDijj1eA)wOOA*P;f**a&;xl!bC# z!@u@*ZN`X+)kCV7y8W?f%4u|2OD=5%`~-uK6ZI?Zl1bpasAQu19`*WY7_JGiwdzO! zr@k7(c-(Ir_I@~rk;|jr34%HE2E_4sLLO^c`O^KhV_ zb3zpunc8_Mb{+DKr!+lCQV~nVLVpbr$7hBU-sa0ST8C;Aw{Tr%T4bT~HYQ_77$EU4 z_hJ88bOk&WbbLCbPXYP|G-m?w^k1X*Hy|}M%nLZ6p{8dE3GmW zkNnOq*TVYJnd=ccuG302FiS#0e7X|k*#%M10iAdsudaQ#er8zgp*00dAyajos?F87 zV?_OpyNl`M>G`uz!;TtWGAaTvcp$oAzvEJ@!QIPo5`$lV5yHNPMx+E;0w5(EMT~DSF{vYHN zvxdVmD>g=MxUu!IWoej_?ZTITmMK|zTO<|t&xmKx=ZWJJ!wJv7UGD9g5SQ3F2)GO| zrZ0bFW+u`o*KteXC6x?$8kc-Qv9G4PgAd&dy0 zc6yGmM*VLOgy|tZ;FzdXX|rU+KiHU=&6Y@hm*p6zK7iA_%L!;>-VS19k_2@xxTua*#Hykcha!q*iZEWZd#$V4u1X# zn;_}<`a>E)U8*Vz4f`Gab$-C{SOPAZ(?#_h-7F4|p5s(%T5T=8NHF0N8>z>`*xw}i z&m9ZT{r_;o5aRIs^(4_OrCj6uG5Mz}J!j)@S{h2o=o!{`=o`l4GkCmFhw`*E5W^f( z>o9SA;n_h#PuZpfsN3)2W^DWGDmNiP9h3@catjU&H9!hAMI{a_o3zSOT;h~JAED|3 zSEGGc-gD+#+aF_KuTg=L z_yhB$>&4?*KM1SIrQe({s=#>Tyb8C)5{wU^{!so-B>?eLW;!v5psiE-J@f~9p~t(4 zR*Wqnb^OfmC1c6!cOUh8;=OvGL?+JOLM8!gX`y{H>D8mm76T)pQMn4Nw@l7fY;dlT zRdoGS$R?QeT<_E9xJ>r(e_;R2?zd$Y9%GgCFz)jOx1FV30NUV%5v%_p#?p&6p$|&Q zVOjs(H)S7nHCtG^z{h~PQ!R6Yld=TD{PT#9@Xa6{sc60w-4MO0;eTg#Rv+bG7&Aer z>C3ODMkP=j!DSi8^Y2;0mm>m^sfc$k8(x(V`AtMU6PFLo`Xtc)oI!n6*( zaR+Cz{yV(TUw0PXm5*jDv{`f-Z>9 zhv(~mSvdh}!6VQMEr|D%`7a^Qy>q-+A;YTabku|^^(lx=SH92xPZ|o6&J1q0g8F{S$$LFr&T0|T57pny6B!R4{cc5oN@8w_*La}O zfd1~MdYH(ONb;HT`K(;7qN23 z-*!uDn?`=ktrN?Fr{pv7_Q>%I>%wrd1`K5^XxrI`7JuSHAwkcO6fgksAI*k3;cH7q z=OJ*kOMzDFKeLSKCoDDLKuIg)h->L?Kap9m*g{TXlDfne46W<8hy5Xp%-=VSIP!OA z--bFuYyfVZ>dsWkQ%2*GIr@sukK2DKyhwBU);4jVl4i$zQf{7z{N|%iQe2Rl!dXp6 zlGJ|a6@yp9VkONu&P&tWuLc1qUL3K5H7)hqrnfE)kGEBU zXuT&XeM zU!4dIQHgM?9vcn|L-{dHra&J1bZ|#n@=@MojqP~hH?W-WD_XFbzs2ClfZRu~4+`|& z*xDlh;bWaNY6}u%9AKAB99!&FT#)Kte`tWeWd&3*_qXCJJCaINc}q0Scr+LreW!9E zF&PU)imuNkimGj07~By?ETn4)E;qM*#c3wMXz-8V^a)^qz5g} zcy*8JDZE5&(lF&5<}WBSRrccPU+myhob}>rYzsh-JMMT>ZLT+PLpBrqRqf4>xwSX6 zj1MBWeg`*hy0k}S?hw9&Fa>q9+}HE;|IWH;H^jeJM;_6H1Y|!^U)1}rh+4$qq!(f5 zTP3yE2Ce(~t;Y9cSPS}5rIL|x%<|F;2E)Dr5S&n52Zq73pW^~IX~LWFzoDIh5my#Tgqa7*naMFy9$k4^BH#kU+rOgZH51LT@O?=+$AXQ!TW6iM} zFYK$a(u>)V%7~=)M_3N1tV1O7W7raY?0lL{m4$_)FxOS~qxRw;&Ypu9pRNnv>;>Mv zH~c}HY|dxFuc}t4*(Xfnzm=XSP#@8_>hPuz`KOJ*?1*D9BqZCnigp5yDZ4hB1fQ!v z&3znElA}q=3Ey^F@QEk5tQ+?&j3yWjAk4p|CpMu;0>Wl@bFMo|lPFjqFbnJq-&**W zdLMpq4Fq!lWYbOrtHuoUOF`stqT&RnU&lv}`m>#k6%38{CpIrrk=EV*rbuF6xu zZytzwo>aTHkmt!&JI^u#O)CAljZOB3gUA@HafVp^p4+(VWUDLtiAFa5MdeCaWRr9f zoUjBw(Q^5#>=K4&?_g3P?^+74G;`PB&)7w*-d&eZ!auUy+9jxuXfr*Y>p8k{x4moW za>K|!D*z~f511a`5K;C;Yb$Y1SRFlvC6j;=f{|cFIi25KToY{_0~c!=HHeWrxWNf$ z4SwlWuy9&_Vq_7t_qPCx$^|T2&MIU z&_NvHgm)`_{Gwfe8VAO=6l~@UzUk6?MN>mJY-gOMvcVkAwN;He)lzuxNaR6Ahup*?P~N2R?IUbVMbsp&;H3S1WLcgTiY`D z8UaNouv7A^6?l?_*ACMqY|d9Mh-g0Lxsn6y+O3JCYC1s)a7awGm3VE+2JF2ya zQ7Po11V%h!4YttY0G?n~wrg!Y(6=IBj~}$y@4z=6A}iK~)mCq0D#*^I(_OrOa@`#5 zxw&X-paESk-Ahx3FKmU#zs-YQK3S-L(=!!4o$#`ze5zdNbu-wVwQ>zQGMe)(YguTc z1B`GDV!oTZ`^(W$ldt4-({*3l^_5KF^r3p_Ko)MR0XAgj@l-&fr!;<&y~$!+;dA^A zW?1>R9p>`1;17r=nG_#PuzISk6}A7f(6{QN0K*Fcs*(lTHn=-DAx5JUT&2WdH9frm&Tu!nlqcgyO($w25Bu&^N$D$&F_80KIZ zk1*f-;t_a~LpKv&XNXGvG`C0&Z^m$NcjKdKMcL&-ANakr&hVc^AT+O%e-Q4Ec|3yI zf6oijzQ9N4*VR`3kJY-D+8e*kC2_p#Ip2Fq>^e@=R!EY7htuz)W4tkbKY(-%PeA(e zw}e|Ms+hY^fCTrkP2jzA_LBhA0eA2t?{}AhfUEBH{_`EjC`?mCmh6?XVDXN_O9?7P1_vCwro8 z-7I?b z`)Y$$@_6PN@|%80e64!AF778I7W-SpzED1P#u|icT>h1+Y=tB#_GD&BEU6veV}Yqz zTa@TzJz|{^S4=hd+v5Y<3tmT-Urd1FcG*zv@cGZl5{>Uvs95^m~LlKWzLgqmGPS5T@rDhi3 zLQ02ph|X7v+2l*F7=8(Q5eb=WMYqbq3QrU$63XA`CDge&pC{63`!pjm?fqIKMMK7a z-*Rn4EwnkDSehl;I!PqJ*u0k4wBp5q;eHmLf8N$n*$M`xi9% zM4_9ymq5gy3n?QS{4>)g9h#5)hQ=i6Jgl_<=0~7%DP?7ZQc7J3T?>XlX~Lq2HT285 ziA84B@Ctu{A7S>O}JrB_SwmU0x5Cu3>bf{S1$cQkM%>XSbXx@gCWe& z%u(?ZTJg#l3`*J_&*79)L~a+h!vil;9W=>@V)xpQg?mBK+VoDxphTWf+qPm%W-6%E z5-IS*fwsY5mrxTh^B7*zpE1}p%r-FP?zCju8R(lc{s@N$iIKJ7V zRhBO#T-51X)5Fc+!er`*n^salMx=doYZn2&_2+E7{lh~00O9`Ih5<1@I)YsbvrwjRmhG72 z>@z!wbl-;4wrY5D`8lsDZmzC5I;EH$WbI%1VWrx8d>m|t)PLVcx|hG4=y~(pLy~Ft=Q_;TBQI zD#v8&c_2K`A4DuD(XDaomV+!xFZMie<0QtG)C3Uv(^0%V{;e0A;+{b0*x!m(Ua{Yk zsY|*SUuM5<(pw;%q+=%``1WiZm$pb2Vzb)!3?Yo1p~s|S8`@f?4Ix+Y zp61aYv_aiLzq&FB2zccK7JcIpUBDL|_jaK)vxk%p`|OQE5_6J4o8m9hJyhZ@A+>vAyjd(h?FP0pDv+vY?qUu3iWxMN9Vw_;hzd zE1=~5qO8LX^Cx^^0*YbN2KIWIbUlg)(M(a-wn}+_0Au-`mZY~qp|*j~jkiuLP?f$( ztg)hwerAmvp8z894%O5d1c(*F?YmHD;Mg5^1%fSgW{Gy7lr30*#14114w{FyWsgp= zxa7QKBiwd-8|7Hq%VqNBT^oLGHWlj4F1K64`*7E@=wpHN>;CikB&sj#RZ3+GUIhzk zVJYuN?nH^m4X(*wE8udE8 z-J1BD35$j=q(|_tTc6)$?qAk^@Xlwo=<$b}vOncBq6apI{;tb-Q#gEz;)s)*&D!HJ zjnqP8iL$x+AD0;uVFAfY)*U|qKXCJ5lMme2r1)_4qk@@DNQPPq=LjND{{C9(quvY( z{tH$$4qajgmO4jMd)!hMK+YQV5g?Hau44oxb_9P*7H&;`$!T7|ur2A!d45Sxvwbqe zrSDsZ`TWXFi%4kMd$>Y81TE$bYV~JQBQfOd9N{9NuD6Ty}IL@H3PvdY(ZN8=VjgbP3#hP9m49grPYDnzqu}Jn8=(fq(>3_I` z`4PDNxTdHp*0l>MA6GRztPv`2V;u3nm=pE#-y##e9Ek@OtR`b!dg=|bL17*P$!fIu zF!cxSn)jA&sJ%l@9n#e@W@ZwZs23k89`FkpH~Oyd+*Czo5I!>E-m5%n<}g^-<{lJ< zE~KLEVvHeCBDsQ+g-dA-O zN4!&g*vAUrybe0K3+a8H-%J9|-o%K5n4ZL58!1Vw*A@!#^<`W5-&^nR=#y5}BXCS# zx#ruh*qD%LIz>&=koi&4iwXhlN;vi1hyoT2@kPSR<2RRN>kcH%qu%3$-Pkdx%@ zS}eKO^FZA)AR)TUeg}(BKa4XC@tH9dC${^zcEoUq)b4%I3rS7j@vj4+&hWatxDVgK zeFKNQzX~8Nz&om!9a?pC>HGMv#g)+7&J37G=>E4mZVQ^Q5R9*P?IoBPi;x*#$o*X3 z1n;-ht(2K4XQ8IVz_T6<}sl(US zygng06$Brxpa~z!q@yUAtkMJWJm=eDwIcP#``sW|ivHQ+M5PT5#LY^v@EgHT?^rGj zvU|$1ZN=2C;|FRSg%z)%ZI`b3v(}SI=>ZuX1S6aMqWMA-k3EQ7wfI~;S!t&4vn5QrSgiLD zFV>Fm(@-ud`427NDP$GNB_g_#nU#3$#7*VOtOg8+#;O3h4W@siQC*D6`7ZmYrWQ^P ze}?iI{vc_4Lq-3?Tdn5#u~G!e8f4&JO(;;hmAbIH?$P-l92|9>wGAXaB?(T{9T6&u z`7$9k(u?W`>>3N^m6-3M!5>sNL+vp}Yesurn8X(m>;3PIlGx!(hVi;!-{DO6vGYfz zJZtg9Tr6!1Gm7nLf8{$m88~iS$^F;7s?zMa4>zfhb+@o%5YXD@WbB{K-D!uAJSAVr zZah3*D0g)a4JuD`*SeBy7`q2c+GfpHS%wJcJ&%7;F>gM#zGd^v3x|z&cYpVBQME@f zCk%d(JHLIq^sWRo%1#OOWBiT`H45C1BGOnByPBq#uJw00uJ)P%+4ijC9?%@^_cwg}hPC#<7rQ8W#S*tT{T z%z)N>sjp>eq5Roca<)g-VWF{cAm)5e@saWppt6&G)t4!?xlXA;!pks0k-Ccgm& zQFZOZ`#T>uhfTebD9Q`5Cc2Zz>;jEZTqrkj8PCCfTQloTlOpT+8ezmFJ zodkJPLt>o;jmc7ZUry3b0tuWw@+V0X?s`ZCj+zNAd-ZQ2*J9kv=n`kqG1X0t4V8a|T%G>Spe=q2 zirhj8)!kfC7J?*Vjv;JT*+O|H2HaiYSWHJvmC`)xkc)=SDBS7>@*@Pmt34zOMHKyZ zZ50iq3>&zpBBd~m2W2Gs4}j*1y??>2%O9Xe>Mgaux$vdflaHbvZczWITwKb9?$CxJ z^(;1b`I}-oebl2g+8Sj~Ne8uQ^Z zyJ%FtU6^sqx_SG-atL|}P;vmqI|c;c1Fm9H$v?!?Ou7`N%PCtE&$(lVKLwTV ztx6WLW*jJ?j;3o#U(04;{}^czXD8%+^>p7nSdsJrh;giO1bQ8 zbCu@U7XkU;j@0SX;pM-$;Z+eC+Vw(w&9h-Ns`54gZgLNpG5BJ|}i#+8d$c zthxhhQ#qPMKukl1(ElBb!swL~dHY~NE|gsnx*5h3Jf>!7QXDZ;63%qex9~94seE{b z!mu>8iP3^#_!!;ebp*e>tUWD|Ez&6{XX9j?PHUDBmb)C$3M3araBP$&P?N)#{1&z% ztJ+7`)`Zrtb5#Yu&yuIB;Iqm)P&P^Yw>JYiy)=le41}HQfhj?9B_B_jo4~r z0zp;y4PvA>DD=_hS+{DSBy5J^L*VfmKu>h5VLzi9RcexCMnHUk6*mt307b%Apr`L- zRf030AyX;kb>A9PxlryO6Cv}j98?Bt_0G8yHStcFZ{AWj&FoQvwMIAXguD}M{{)NZ zLd$tSevhj#(`x`pj$O!{A1JVPHduLg(pRfvcDsZp2$gCP&UFh%h0 zfQNq4#OwjRL2`+fPMF)U9s=hTK|=u)hDylVd#s%>id zA^5kvL+PkAaalS)(~@NV^dpR(;Y~dKe0EX;e?G&>d&H`s{uFim`hhe(DV7?qktikp zG$culd3v>ir8v^pje-y$^}83#rA(wU(YjuHx@IOZ9db&>ieQF`i@&s;`#U~ zit_mb>Q0-mMRP;jzl(a8@F;y*!8} zeFh6YuM(3tHovIbIV=g_(~J4OeH86?^R~JnRW>R_0w$knrcbPx8z(KU zoV_ea2CF}?fx+skLhX4ODUJ3!!1g1u#UH<$r$t>vewueoIKH2R_Z8&onS_7J>f3oz z%-Z#FvAXl3N3dHVf8-DiPEP_}VI@?4&*XaYrIpPrFrvHTUdCW21x}o6hhJ5uG_hpg z42mfww0q+i;OZ_lhGf9x3$6xAugo;UzyQ8O^i(JRledRqtP_2z~ zOSw+R$y226w6M7L$%J2X9uVqh&P1Mm?4C1YSJKU}P-9HK-yildo-m=2ZgYxHZM_?N z2bUgx~~^feH|_#nF#rPX<;4vb~+?X-hb?v!%9xYT|I z_p`IWXe+|egFB=<`r?nPVaLaFF3d3d88&af^!4k_wD(*l5ryP$kt8ZX!aHy2;)o`> zd7~c#yTVWq`%^|*Z;7u`{>cC6*L`kqbcS~LC8(!4`0!URxO!~3S+OO?u-HCb#T>k> z%~b4x#A65rcF?#ReFnnO){J`x#3mc|?GJ(}p;7suMQsQ(75wl32sw+%J5cNE_snuo zKpm&JAiA$7nBhR%=vflbA( zQqj=&Yc2IoTBl-xF< zsv#8N`p$+SvzPQr-8jY@P1R8Ow)LC+&p}?*SCK1z!nIEjp!=hrz#w-XDgirj!LeLf6)o3!) z&TBl4;oy6%y{@*DKNB1uN^^pct?j)k{5oe(2SgGek(%#|w-mBWK1DqEJq@b@*7A$& z&sXLC+#T1Hb$ox%eYP~vnJQjrlZo;B*@TkJ!p6^QxH+H<_VZwR`+3&yf!G4#yxjh_ ziRT%QD`83)W97Z?6`5)GwdeOy3rN(5!k`M9%pH0Ow#l)}<782QUif@*6D50~^?A#~ z<(*b1%3rSRn~6@pgt|?Cp^KMP*b0CFZ=YMm9C&hdFJy0%uAA|_3JXjAcp8~I3A1|W z?(!}rCr00YP%_|UqhoNcoJF7m9Le>As zN7UJ^1him_cFA>ar!(n?P6H?k?6G;jT!FMUG5hY&xJRp$Z-POOt)*nVQsKw4fW_)^ zopOArx^}pIq5b*#E0DzLlk74?6!&?Z!j4KtF~xvjF%@yI6ugq2#P4+tS`0Z<@bG71 ztz)3zN5^vE^)VW#TiO15#itjOY6s^{wLH6DUng-wr)Nh_$(W!(4ZP8C)ZwO|slE#t zLYqH@%-Cg9YCqiTFy!`5jJp=!l7i+=Y6BV`=udeLloAs?21@mU{c{uD zipl6{)}Gd(IW=VobF5EUH@x23rI>WoVfzPUFJ0Gv9Mkjr3x@H(7mqNapmK=cwyyX; zqzM#*aB)J(Ud9)+s&ejUL_zS-BeT6+(Qh&>7L-iH)hyw(WqBo_A-w*#p`6vF@83@_ zxviPFT0a>wt!mDyDTt$S2Ce*zZD(IUOG<%xscK6oNZc0r+GuvB}C;=`C$!ZCFC4H1r zgbh|<;nO7wfvev6s46U2#KKx<;TI2%O*t3qtW86l-v|hds-aIQ-4?)4Qm~prgtSBz zjafnFf254~zG3*|)=zaFTmRFB%$|B9DzyqObA0}X)(>}fMCheMYNvUz1%lm7pAc$g z=j|Paq56LJQB!-855COxJQ6#nw2hIPa0Q4Q5J4Jg&~IJ@^b#*%cTaRb0Q3`Fd&43L zYvrA2>d3xF)x5Ov)=4UG{O%kKX}>Pv3U2F#2*GWw47U!DHJ9ZX5s?d;ewa;j?!0-Q z=#Bumk+SKi6$R|;LQWo#taHP|*!`dSYUtBoN-(%jK4uLx`Pn(re^+mQq|kg>5wGsc zHt?MdI6UT0bT=R#WMSoYIiyu_AIR*8wHJTmHX?v3WcueW%21sN%3znl&4;+Z;#gD( z1W1GHTXVhLtx3NHQ9X>Jvhc|$a#QR%(APO(WC2N!ltpI*wDfA&0;Vw-p)`k2YtXZr zGKP@5TgOq$m0_)9@}AjQ^=?b|l>P*RTrt`R#Dao=Tr8X$GWzh33Aj?_gQ)i%uRo~# z|JXV{D4M_h1o@n?9%b(CczU`@Djxeeaq9}0(6Zc~kJR!izLZ~D4nh#$5<)>0d%d(3 zfO@fm_ad9y#`Qc{iHe02*mVdyJk%qSuMHbV;8%y6KUI%onXPL4WiwVfhyR7JInsQp zji8Mt@%%^o7X*d{qi6#hiEnwh*wVjytil<#)>}sl0`z(mcz7vwDQLC=r=jl1kThU% zt`@nh(b3C~q2w8d`C8{+nXK``1M$R>zX<-G)_;Jzyjf1mi{NtYszFPTVBQ3ylccYN z)!%s_oF|WwrN`ovUNll2C}QEb1)Re$z0Lj%1y*cUfZE^dYbsOJ8k-eQX-yuhpQ4c5 z!(TL^mI@C-j`8Auf5;r_CK8ALrtGgvZ(e^(_F{(oje$5uzWhJMTY$-G&(A!Z=8r^m zTxcZs6Z@5yI?6gl6E%XQ4#;xWBG9!uEr_DRY3JZP257O-U&Zm&;>KPU6QDKMq9jlB z(m>k!sR$+NXC-MJ&I;#uqrgeus{@dH74x)-h%)FZ*b)uexVkdUJ$O-#1obv}{=*+QkOs5llXH!@8{>wq5GGVdxc(J5!%-bF zCmge(dv}%uB$MyZLx^u$MJ1zSf=*~Sroe1f*c<^WaxXw?%B?uE7YZl?`Dgki`$2AK zTd;l=09JeB;1lb``!1z$sJ9*8()tx; zu`}R+QlA!6PFq~x^p3{jvofg(-KLLu(OP&EO`OLpfYN*$O6KEXOB#|eB7lOLB=JYU zIyZ5085*AGb}K*`{#rHF94`3aIZpGq!%$@9NQnv0;pw6dRWK9VVMlRYR`_adqBWV>3<- z=s_^e_aI9{cK>ca7LbwKLebWozfe(k(#-efl063jwDhOET8ER7U#K+Y?M3?G#Q+fs zYEI|BdVV=}rx*FI9d3t(wP1el*YO-w|L>xq`gp9+AsM_+b!@x~oX&^Vys$)$m4H{9 zWu{hZ0v1U=1DRv3BEES6wric&R;1SLQsP?3f9pcO>HuB3Un)J`%QpBfyM!f9E&o%Z zp9HlyziW!_OC`!c9*yvUtQ)GoHgo+s&R$d9@P#@W!eD_N34fPHl>f&oU+y^Ul+8R= zzz_oOQusX}c5p$<5{~e`1{BA_VWaay{cEoM9|#afsOu2us-JU}9~|oc@*Ts`0q$_` zodj9=FFI|tosVmV>TPgthAib@;)4UHW-B?Cc@qT@#@>MjP*G4xO#igDu*{wsy$Q81 zh<uN=)K{pjq4%g*En=Mun?7?~eBhi?~Wtloyd_ExnPf5{)GVDr{)@ z!J}Fq_l@AP8*@U1)NOL+Cu;TOqtScs+q|foQ@;}ZUxI3$&A@ovJm=|r=uxsKr;=?- zfLXNKZ&G#rh&&_wVp8ok zam(vM?h+Z8J-@0(CFck98q zVZPG{KWKd+%S;>mnClC_lvgLgvMb9Zn(zq$nSAOH5UulSn_WRqyY+)}pe*l%D2L2zM9%?cGdoEB zTo!PNRIUzB&^Na|Sh6AUk9I$@(+sd(97$ZDtSVrBC~xR%&_$est zU1m!sQe~;Y-B%OSVDZc$(nT=xLGXC-NuHD5R@AG)^RlnZ!JWoGOcwUHUn-@pJ&VXMqm-cC6Cy?;4`TVkK&}d(|;2GiC5IW$@<0%YmNjKqO0*Tqhhj! zP!ZpfcHf79>17sIy+ePq*>CrJi8GIWBJL(~h)?(mD|x*4i3j6?+hG?TPu|}ld5dCT z1g|8`&AL5lz-6iRZ)sPz8Svze!!q7{mVVMaf7CDiq-%BFWs-(tFR-aP=ZZ&tC3 z-<$^{uV0stZ2BXmIx#p;^YL~e&!HXilbz6Z3QYL|u*0&|Bz?%O+6RsMV<@XN$J8vfs50&ful^+sT}XeF$w64nOfsZE#3B*A2B2QWBh zy&YR=_$eJ2!V z1~#>V%9Kqh@@_z9?>@XrMK?~Mk&ww8V!d}B9dTe>Sx7mAorpxdjD)<6=JV@VvenCF zU-0#+XA!+Gbo-#SE43FAL#Jx0NF)vAk0Q}S-lZr~)EtENfj33MAm|y&^BG>R>ze(gyus6Vvy}b zhDNI&LjO%l7bD~E+TU+8s3gJs!{zw$pD^mcS7fNFdLP1`AR_Y5oBtL031^Vt!!n3YInyJzf4>5A!Kd-Rk>Bt6mO(F;bF#ec$@#Mja ziSc6Qw969Mu%)m91xn@HltQ7f*wQa;D~DP&{`u|_?YLQ%j*Y27=eZHW@t2)<_6`4F z3gdX>^|&u)jZfT%^FbXl+1+f8^0)< zr^r%ArdiJdgX`kJ%fa-$Hz>A|#tWudCMa`Rs&|1@XK>_cHTKTH@>}}sO)gtnfvvuV~xOZ zyyN^XxK@4VZ9XTB=Zu>sz+}7Gtsc~%+3HOz+PH4*lUr_~ZFP0yF^g_7n{EomM`8$! z%M>f3GFoojGltur8$Uk0mS^?T@z1k*8s3x%*Q$Z@4)(+b?g?b9V`&@`O@MAP2RlhN z#U_-raoyS%yX6*Hb5k%Lx>yfyy7|bJjqm+1ZgtNO?WM+rum+ous(my~VMycOj(!?1 zKzWzj>|XJ$ne@QvLP`vAKLX!6QRDCOiA3U^NF7_iOdHp&eR3QRZMm(!R`=ua>P5HM zf&5P6DS1|>ZFzBL^`NG?e>N0DY5Zh{ymKp;xUG}_Ff>{naMI8ad$?ubd>S=gA18t` z`$^!dHm+OyBICH4@y8M4t1jK-VO_yE`6+COVPKq@m5n#vu;!3m7(W^ioX-jLU&Md{|f6ff3?%2c~UJV~x{Go+J}Z56B$~Q3SqNednMN zHJ&|5=FN@?&W-;Y1Kd)7+o}q3 z_<`IX)z`{5>-G7lhsz=80Rab7yjX#MD zxK4Mko`&9>MaFS8UYVLoU?2a5BqX*U~g@xh$C!qN>vug}W zO<8rZs()ft4;XisZk98Sn|1&AY`0KKP1nvnIK!#ZU+?;Vfkn^&RL4H^$rKanE zG0v+xAIHgfY+1*x+jrG3lv2~xSKqjC3#{rv<5<>tkRKOHsi`{B7^hV|W}Kg%@vWN; z@{GJc=uEEE6zvXuCT?S#Zrtd}85<8S>paM-SNlPj@@M2Po6|?`Ew|lno93(ZQ%Wh- zLtWRkrFX7Z>JfYA%s{qK4`m@H#mqEK_EQ)YPNz+lwO`xMzr0p7#$&6x8ILXN_^unC zkyqLHE#y$n%niCy&sJ*@a1%a4ycnaxdUOSM^cjd?M#TZuf3yHR&Y@kYUv}*%rL8E6!f+p|;HA}~fn1}42pNGBasH#J zEHuA6JNt0*(#kpOFZ5Z#&$V-}bM%;c1~|gG(@WwVGoFl}Kjdphe)9!6%3&_&0lLMz zp?jpvTkQdk@UO&gGV%4i8Ly4!A?LAM8~NvyqdYjLN9ShiStZ{H{~YlaaUQs(8Sgb- z4LR4kK9VQo-0LXM%#m*IZib!--3d6txzjUoeHiCbFJ`yN| zsyNmQA6{Oh*(4!|Y+EaL4M$2iC_u1*a(&)rz^ose^{qg>3Rb8~l7wk3Q6 z9O2yQ`s+4`>v>x>;};l*cwrpmt!q6ZXUb6yb2%6EEZy+kcx{W&E#C4CaD+4Bk$C%3 zhj_p7_O%Xj?sb$$=0KNpwRLW;-jsC18{k1WcRIwImwMB9|B%1_e7#~D-4Qs#kBQ%%CVo{C?=@Z{S3=Ict|>>km;*ggM|^6qUjpNNC$3F6 zhiY(V-U5zrjW~BY@5O84ZR0&dUL)6(qdYLjd4`U7=f>`uB5!;HoDFBhYvMgKo{hJ! zb&$t)GY18H3wRRFot}u-mwIiy|5{JTnR1lFT+V}Zi+6K%qX^yd4RC}r z;)!_orH*kSUohT89$%qxucI91at?I5acy#skx9+@K@>!}y#M(inuZFDE#L3ls$wsCg6A9XUMyerzuA{YpywOO5eTzb8hIWA98RUdo#@X?H zOW+7UllY?l7L0>j{4ri4ua(0b=aP=~s@45%=Z{4ql-qL5K^N>b-yYYu z&=%f+6Yfxahxixbqj3e~l%KcUM{|K5ytnqe0Jz}|_#2i^!ZYzT(6e#K<3B%{a(Q^n zIVZifCefV+{0-cO#}P+dh%+uj4*Bhr+j7b|ul3d>wrAhoqzB;x@iotgGd>MDy3oYWKaZAqmrgF~1qL+Jncn8?$>>)mYZwQwa-!RVigq(8B rgLJP@t+k77;|`KDLkp79IfQgK0s?bs5TsK;LQ+7adjLTiMFmtsIz&WD zLf`T8{r&x}HFws!PwcbL-se6ylMM7U$w`<F z2=~H3_klVsJwtVCUtx7qWlL{e2j+QGS8e-H*0Y-Y%9qdT+j7e5X5Y=%v<*HfsHkZh zC~Ii1Z0^Z@-te@zI{s;4R6=@rV`oWiYjF+s_bMs7Bsw_@NY6{oc@~?RUDnups0B7;7E^v@X`rWzwj9EkSNc}Kt(Z(xS*47a!e`h8lo^d4iXqiCe0$gxP)ur?8j>p92@N_9&sjyZ8N&+H z>_TN#3VW2|ynm(>&( zOE}a`?;r+c*gM2iM1{^@^B*+uIP-Edcp;Kp*ys$|Miph5@_2@yz$>UIlv3oyYvD9Fum10(wU{s9C*P|?Y3IdIkYOAR{ z2wgt>&}E1(3IQpn*0wy@WEY~KTNrXPd$+%*_go1VM8S;&J^BA_?I8fMGG0ro0zwz` zhi7~>H%$a5Ur*4j984YUJR-K8E`nojKIOfF-Ax;5;7%36Sv1vksmAWAj>vB(ir~l? zOs)OI?pBOeJWHL60qHict3tP71ks=UGeQSBpbsf$@Gjr;-+g00%rOT$qGJto3a7~;2iYB6l=gC4-3`>= zgOWt*vp*dYHRt=Qm?piEQ_H8Fpv1*rRISaXeO`VJ-M*fX#=$*2NV%08_hTk}4s(wo zGm9y^fBy4UxWFcZfx~d%+PGTsOvtdNG&b1w>^Q+!8Q-jj2edZlE)9#9oMlG_5PTvF zCj!kbk+^xIrhg_hmpHh=*72hG8gOP9KFT}>d(m2R>CfYWWSl*zz3w!K{(-bp7egyU zLC0)w5QUcJLD&XGV?1qk&=e=TnkH%e8+OdcymZ(ZUUU~%_~kjxHd3ZP_LLH&m}H>p zNLoM0jQN@R#h=q$sV-7*33QSffB0@Bl$FP-Yo3GF*aNEO=a7CReehlx^cMI#UNmub zbc*pV8R~PDGuD)jvQ<#@Ur!bF*5b0N=IP8Wdb?sk4TM;cZt_&8K<~2c5w#H9jGT6%_O%A>B z{v@}?mx;l1IYZ`nZ`nb*<*UQ*HtwN5XOXV2kr5)aKsffuF z15F+ACTwM7AOncmb17a5zheAi+ev*%3OXhTq3EDrqIoGS$Q2L$`7!Xvvm2z7#)X%D6znL>S8k zHGF&k^G07lh(_RU5^ir2P@iASA^w3C7hG=vc!Iay37_GM+7)+lgso+R$N ziiG&e_>{B7^#h7PKUxv(K*En3lb*PnFAqHvv92=E*;U=WyDL^hD?x^@gm~^D`LLMz^E65Vp3Za`-QB{dqlZZCZR14(H%9FKz z)f`Gg0ET`3FRu({kd8Ej#9udd zcm3vE3^;1YN8}LCU*&2Q56!)3)i-bEaXJi}_l@|8k^@Qh&!Iz@aK^FtUqUvk>N zn4M_MP-nrwcbMlZC}Tci|8fn8sgCORT1`ys4;^(Up(KbO!XQZKIUhj&$Tu#k*bOO{ zol|~FOg!Xv?j0=mLA3xe!Z@f#tXY;ru4NF&MPj0RPol#_dGlOZvl#|XQGpq9Cd`S2 z4p}Roav=7FX+?(&%*|EHbA2aea8OC;SgaJ{1XVGYnrfUL)P}CSf@G~C<;~_8Fscjf zMf?p8>(ozfjJ|kN^I9Dc;iXkM{e2NaoX48g+4aO}^N@S}Hrof9|#wwvi5=o2) zC{PrVZIck@eEP44- zE9UYH^f88eWzW!sLp5t}Apo|>4%!`=G)N`m{AkX#^Obk+^6r#9G!R%^Ulqpsz1liO zZ)je_yMUc45;GSHDWq^G++av0(ZCPDfnzI|U*4Sz9rsr70ZllSr4I&0j$tAiWssm5 zQA8kY29o3B9Oz!^xawo(zm?6}KQBX-t*^2p5X)1L`8kmmtn2ESmVGSW!XX)KvV_sk z9w{Xz{%P?U>oim~WQZ#jgcCAxR`MF_QOB^T6iErfyz1uOy_g{3<$dKMh)SAre3|~E z$fdA&Lp|cE>ARUxMJgG>%6uAG2GKwf5#t~)4XUSy3Wri->j|>u#9LQ%U!G?OranYf z0H;G_Dqyt8k2Ms@j{TH7XU>YTH@fwR! zX9{XMLK+OOt`>OAFh0W$@(L|M~s^NrVucwG{%y!ci{ zE=5Wl43;9koS&L{;(iPf^?aT&TVcZ`m^92Q19maA&!FustS8S_z)lxu!clF8@RH3` z@WRhUvQuvqMe#O8xZW;;mh! ztIxb+4f8Wa9<$uJj}|$wA6+|h6crb*55KWC%+F|!q{_X+dgHr1l;H62Ves<=OH3Cz zmlOCGHpYUAxOmD{5OGDb3Nk)}2!5C0nfmJO##$bBBwgTrD%)rvNt;1=JYgO{8=s9m zH=7-uniAX%lQx{^{*UPP@GdG=B)Iuc$Z>UkK|x&P)wMqSo(xXlAHUbfF!|vj>c12# zv{;tSJ=A|`p26Yz7+iF76Z-o0Py4^|U-cdYKE=992LF4{?ygJ%h2GbHIW=G3k`S-r zQ+@L}69s;vXcV(?h&%`{{$*w5`o)YU6L>5?!LA8KDyD9;vW%5uq3%eeI+`LCpWp%_ z;B}G000Z#!lxGVZ4rkFsQQ-ogWq5&y4+$aO2)HuXZek6U&05SzgA^Bc1iO$}VCS7& zh;fO6hlWz-hQcUF$_ok_(Y|bPs#?u2q1|bI4pw{7Az$b z=8F6(%zz(?f|rI+5G*V)!dZ#Yqq2x|gQ5Q6D|>+n-#g|E+tE%}b0$9Hq=9Fn4IZo1+?5dl zftbkDh^hOzBR&N%Qlkx6G_vk$fpI`-gv4A6V}=UEjbC94c95t5q5ql@Knzt>z{n>c zDa!ybAVdBTDV$S5f zCG*I=T_sj8CF0wEw!lZoJbwuTV2~fRgIbbrvA8xlC*Ij5^{9&66$5W%HQV0DGi?j|_{(CjSQ`u~`Jm5>I_^s?1fsxM zgs3Ylr2Q-tZB`jZeoLZ8LjPQzG<1dxG-s&L1Q3tSk7(yJAj25aLgvB_>;u3d`dMW! zN=jt=bD>h}_lpp~?!tdx@jzR~x$Px^!HK>W!zc=o# zWsHe|qPbNKF`unOiK%@ma!(#;P+~6Wfj(pK@89?!<@h<`sQWcMF(qCw( zDAV3r2KxdYsE`bgE47zJL0wB1NsIJjyw41ri0~sX=apL8BX>&8uf0LhPnal1{UqF| z_CMFOu>oFkyT_UxI3NVD$E8~e?7kB9L=f1jdifIof@bTO5X z@Vt2sO5^~V<2~0V`!C$V$DW&c#}ip|18K`9gs2;l{SaTLB(6Z#)WrubA@gbKH`c{p z)%Reaz~ylP)TzvJ6xACf@3Vx5TfLq>trOIx|oap=Z$#1pe0T(S_EYa895q$L%kqcHHz8y2K zJX?I(Jyue1mB-R>xpeb^qz*%R%z2*S-zwbzB7Jyo%f*s{%-yUf3AcZICHBtd*MGbP zDPMdoMl$fBHv4q1Ki5!{vjOwNu4QOxLXa}LNSWBiXc>D*V1=aBcQztiPlVutR;ZCY zsDD=OOS#9e>+il0bBAbP7mygc!>=mto5~Qi`R-^bI$JRQn~IW<$iI_m47%ic0tW*1 zkAAOm#U|bh(qXd75-k;zw^+9<($NK6o}Vt;|10my5 z0LrNz+8I3PuT6jf&Vl`qqx+eaF_GO=CsGqCG6Sh^vgQSeuxh@Wd&Y^9VTtp4O#|Gh zE25icX&vzEvulyL#&9heJ?7be${s2Zw@|$a zU|hixJNTzLHhSQyZ5_~?<`3D{2Y!ZVps;+8#fUn%wciZ}KgH6*NHIP33B1u-+@Itv zp5Tl-zWYBS=OzC-yQXfWmTlWp-`Fvd|7YvbV&*-DC_kF^d3qwaaD@T*5d{;FS=rLa z1Al-O#08y^Ybc-dmUR6gR<6O|d;IX?$w8??qV=7|1MpjFA{}4>a-2^Owrh2T|KTPzDDkiN1r#)4q^_(XCt@UV zbJ)U4YkYt$>qesW1!}AWEf6N)YI274vnd&#DCmqrgFol|kN7sdN;C_;Ax~w54>3l5 z0yJ-670m+DvPTVn`6{%jI;pAlc9|{jgoifqnIps z*tGi7f7DW=qzgZZ-NL4<3T(x%ve2=RX!{#()UHO6M*##tNm#avrBI{XHCC~lSKg&b zJpr8WP(Q4IgRQIzmQz1|I!(t?UVw^YB%5>B4m2 z>yj%m=8q}2bVY22Pw-47&l8U1ji8wxt`4=$?Af^&M~wNX7EK4IR*N@+6gndIspJeI z_@Dh2bz@aUAnC1o%^EGfv4mtX1-=L)Wu}Z~C-@RkzjF4-(}+p<2a^vzZ>ro2-Ex(u z{zgv=+L$!etu`I>nXw$0kIChdLgYp?zM4&r!vgu4g6sr1G&NRb!(k0)L!X>F`17Kq z?V}I7o=q$A{7u`(NxW-=Gw$c*e-QvXkRNo*tP=-Wj97H{58PgXZ#JW85a*V2n9aYO zV^zLdZRKtPCk?mh>MB}XV;WygCuQ=|sOZ%zX84fu@^DR!5F!jT8YZhnfHRZ%mG9Tl zowtv1v#E>|Z~#Z&--9ZNjR>%F){irz%ae^>1tOHT@g(s#%f+T)5TuR5GEq}2_6x=JsFmLzyFiY5Z>q`3XCU68kT(>1Xh-*b!(yo*S<{`0&j4t|FQ&b z(sSTr8YP*)`3JF=%DdJQ?p6XEFTh*lyDwEPA!}r^23vpR^oP4SVE?t7p zXLp2cGbKJy(o;%FBxBQgFC{A!=Llg#A4}q{TSsu6F2_I|D<(M0t?z7fdoC~nM;^}E zPv^hJG?U89$+{yUN2RukMYLBbl~qj9Lui73Yy$^qo~VS9VLpL@YFlAQokPXTdgowD z@eddC;gmy}1R^!WV3&+6bc)IsEbly$5Uw1%Ba+?)DeT-?}) z3fkSZUY+v}l~ZaC*a;gFb*#yaI9a^>!+h{E+Kk#X@{FF`$BNP|!P6G(xFW4E`t0CN z>CXqBeS)e!l$uhOY|+EgVYS9158xoJQL&-+SmiS_=dO>93^1(oxzo-3) z*HL-?@z;`z@LoC+Y5S+dmQ|nQ3%uH!vm_k)kX*mCPK=*jK|ATB>$4gW@{r3UmRE4W~#+CaV@iWKoOA z_fPnS`(jRcv<&I-!o>{@{T`ML45?I@AX29G1I2ckbX-?^~|tjJbavTDX4xCnCctqGkqneL9*ULOd37 zYHIJEX+;a`H72OWWetX9Tjk`%(p&aK?`a|{Wt)FRMcTl!g_+2oza;>-{{BRwmzaPn zO4QG-30XcnV2WA?YzB5m0&Ik#u#ur2qIFX>aauZ|pnKHGx{EQs4kkBjB~X|(aqFs? zYB0>&JZhBB#~rzDOMy(Y@kjTK_;u6LdFg(R&RF`k+@X9%EX+Ogra3g|C@SCDmgGpK zX3vA^eY}Kek#<4L!!1f1-Jwiwnq@xlSVdtccdRde_FO*Q(nu>gXX!6z|J|IJ7Q>x? z2YePG*l18sbC3Dzk=UcYIbqkKrfJ`6Eg&S;l< z2JithkvG1_iO*~0H9U3K2iC1hw7`9um?c_uFA#ZmCvC#=W8!CfM3>J*B$XX)nqNp= zZ|Hj|Urup>g&B@pt}G&;iK) zx@39&Bj~|H1ejgB1rlvngqZKtfs#yYHN%c!ynhvqoL`-ONx z^13g6EoS^`{^nlvqgUiNL!ota^Se%C=0p6>QSPcrP()sAo3m{+^u=6_&47^E3Oh^V zuB+r=R{hZ@T4Ci+ACiCg-z(3cTp7fgiZZuKZ1U7-J7yt5UG4=&_(k4nP)F|1?Xm=6 z^xNsos$aa8#ldJf$tkiLUh02AsZm??F{AeVWx*9mTbB+${MAzQd?9a4bu~*w9(zOU zg6@6W3XPDBU9lmAf2oPYhcGmB5t@84y>-ALSbO*9f7Z)1-npZzFD&Ra?r5TaFShVu zq@O^w8)>e3g~!j)oS#b1;it?3uKXsh+5OBb=1EQS9(tBPnuJ{Ii{4KTXwru@jXaFF zpEC1QRXCZ1UK@GgTrmTS_(T8}RqqUe9sU~TBSU{oCK3b%425Nv&-wF8mZgaIL0q_U+$a_g>_ml3t5}w@m z007URFgdP{6jy#{q>({{BbX*H?J!L++S||MtGDK*;3snMK>QnBjLE~Wvxs%IeriW9 zo@Ln%MckEnj5`BWRVfbL4|xI*ZgmV3s@lOR?yn^=hj!YRIuQH@F+;HpUE1`o->+Ju zE&8delnM=_SXgOF3eG4xSD*}SJ@BZ4fSdsM0&7U6i;U7smb z>jvE09O@w~U(7kRR1CK96za%`5(C$o(e=D`YvEWxh_hsf-i5e7PApkvx|qm%{+su6 z(?|j7<9i&jG$V%`;-{8VqX`azy7cdj&KB_3={@&KE6cq>?^(XCO>Vxysaa&tn$#Im z01q2a1&XJfnCE1@6F8(#7NxFwBgzxx;Pwde=JoFLvLoYzTi?Jm{o%^6pUrI{sjkHS zmpP~+&Okg&h)v9#*ZgkL1B$2ftTD}R9Z!FGPT_^B0g6O+bWcqP2alde9aqJR1-73Q z;jk5k#C*Du>TaZn77TLDCNREQn$D~^nXoY}e13-jnA4@XVvxU!hk6o}p3C5C@g+Iz zWBcY^I>KQ2{R?^pxu0T@knQl=BIwl{qWh*N_gx%E6Luf1(b~!u`5qGlo|6w1lqwQH z29I7Nj5JC|llPI%^2-oDZ!0)1H(ZEpI%!9O{T zxyT@OW2T_j%&=V$axCh%7w8tJ%$WuqLXP3G0o3xw1&{-Yu#u)4A==5rj-9p|Zz7C# zTgok3_CV&*W8;)UdX$;nLpD2^BVsOa?bE<>&3Lij|sh`d~sDPjV&cOS3Y51sK<%|^h!4qzR78Yul)?A_Q{YJFL+iK z`M}~e!BluvVvB6wG&_B|n&`HRD|sEEkL4CVm)ps;+Nx<$f~p*$_`3Qj*Mq2N@p}*o z(VZoY#}q@XIcDd>9lLl?;O3S8!2aMOBRFNq%mF1;_RH#LUC{ViuPaBor@-y3h4JiP zZZ|%jHslf0YT&LzQ;x@ApOYJg_9!Qz@x~nfV+X9!0{$TTFkpu7a^}^0g`j#c=K3;+ zB<^jMsfo^z$NEdUwV1CgWf#=(w}oX7xRub*3!xf`ND<|daa!D)Z~tT^A99(9`f5Mx z#T$0)cJDl$8N=!*?O=(-g-7|S6RsC;&F>r{D-c0kf6$E$qL-rX-m2UL)t5ofnatM&Bp-Y2JkQr~eZCiyi#!*M$h8mM zm|UQ}ZW#kruXeuJ&sEN!J?G^yZ%+Se#CKIpfRkdNQ~+}dlzS`xi~cwXBS2YU)uaxY zc-G(KIe5({f<>>Ii@kJtawe~ddvJSSKwS28BP8jBt;<6KZB=?ybQ#-mV^II% z&r{_(J@KXpn~c=QClt|(=+bYFURzDSQphYDb{AiM5J=34kTU2ez#<= z1|XOMBdI9;pzketuav4mYKOjg;sg8cUTHr6%hPtp%sKU?8V^XfFjEjEh9!%-tIE^l z(Q#KpOn-9sF&^0Og(=RlJu_tgVFs7_{Y?`Ix~k~r{P|kj^_*5;o}W0e8LG5F-5`T* zYA^vkQ)WSZ;X;8KM_IP7A&r3OfE_Y!E3uYH%%ZyC(pGQ0VsZXZXG!ncvg(#S%rs{eDT>&-qAZAAs6tn{qu2b)&5V&Sl_2* zl`Z_-&O%*iU9gqD-cPZwH9R9to!9O*Nph%5?+$-TNuUF6#;24 zW+sIM*lRBninM2bgm58(BFN@!Z~!bp1N{W18?Q0&6j?Efb$-uG^mPjLpN_Rxj>R*K zz>HD6MyVz3wbKWpUTae=oSfj#=P{v2*F^T@`<_~KdJ zXVWP|2vMF#`XK#)_UXOdRW{($V0{lxIZn{|sF)a7IHuG$GA9sI0z)5*GZ@iL-b}7^ z-HsT>;oNX4GW;4P5;lCFf2v}4NfcVRWD|^vxgR_%6iD0=hY=DCsfvdAL-8k_tw>0` zs^;q4BwjN$n;V9?Q+qt;mUPoS9eOyF*IR?uw(Sf_CIwDCW!3h+X)FJ_|!FgwBXYLkF8R$us7}WHY zeSyrUEz7Et4qSHlkPV&}^i6XVxw?5Zt046(%Ux$7^M{t-=c>6DmF2`g9JHroq)%Sb z5$#jIJlsvpAh0s0$6mRBS3c6fiSs0ck(;sgytpRk!M|HlGJNX&n#ml0zz%jI-w%lq zG0}3+i?WZ?!QWUf@|G&0w1Ei4zN)hM_ZkR^J_ToLhlRTI*SA7)S2M?Z)V<(GIPQ&! zBDgtAd(_&6isg4O{25E8`682_MRlT%4q>#piYNor2hDP8L>7Rc$Lz0CjJydr6@iqI()p@Qp_ zE+XPwEA`2q#>$=^d?5MJT6=-}uI`hJa@BXlCdZjY>~PQM(opJ!!FlNQ=K&h{Be^yW z)32dWe!@%Iy$TMDdPW`&6*g(Vl#Di$zt3-SOB9D@*I!XyXZHvZ{!7NMcuN0s*i*I8 zY_NV2CG(c}{+YU~LfV{+X?)mRH(23o!XF+*m6I*ekeF~z9Cu`-zlAZN&Hj4cufA^> z0NW91oYXbB&xkXaZYRpvO+IJYNUn;kez2vkR7kGce-<|-4a}Ar6$YZ$M zCF7s~{@lxUXrZ%28p$1NQ z?az_;!++Xnsd3Q7a9|(Z+Wlz_K~_fAVS&62)zt@9e0CG%)XlV*_l|I(eBv#h%JNrd z%ebrDvg*t=rdtPXqes={HLQrufQ~)Y8igRrrlNw;9?e4HGo?dI>UC8)g|1gNsg(0n z>qlwd(22Cf|yBoh&}yycp1`UYvRBTX9!aRb>CxSjl(9k}7TJz3&;yxw6T!J1Rt75%`7 zx{j97&^nA^eBVFuG>V1d$(eH3b`8rB;YGqvgNG??>3yANEf@Q^utzdb_ne7#^B(qt zoF%w*-mj`xRx#^@jP35$6H3rv!S;&Du>#KD%E&W{UPeTgZRwn)Ut z{0Zeb`$`$^4ej-hueU^(JLqpEx>M6L!);%`+H@Ngt~h)r>K&k8ahNUyD=ljKT(tc? z>e%zUh{*;2481P}z20Au`qk0`o@XKdt=RrQmwu;U+RYycc%-!1`JLdepzieXeKuP% zR*qO$UM4E2z0R}y{{6@ndroZs8|~HdCdJ^k;zkQGQY;p#<(jR8vIOW}@zj8W9Gnm7 zQAY75+4mQT0Oh|R>ng;C)YonG#Q4IPcg{$_l~5#5?Oq$QaGfri&lUg(g&~Ag?) zJmn;!Ofit^R?av!%6Cw>bFJaU)+xWz!OlV{QS^l19YQJS*a7Fw6TcbK?4IQc|JrvO z2YsN0M>@)`6mVN0qE;6mzn3?mGGcEtlUEle&{=+ifOsF+ptcU-NU#b2~r>141 z-1$3NK5A24OZs_rL{Sg;Mf6c?u&#~w0Qh&iyIr=;8RK5MSq+%)?u9|T%@&L<{8!O@ z^=G2QorhPmztF~X&zMk`0k3(!<;9^D;|8P_Gn zW?g6d#r44ZsIKnmRN~y@M(hW#j!mdSU$fjb>JDE-CFjF0wi!f9Dvbp}x^7X!J6s4S zvJaEG0Iv)+@ud`ZQARuXc4+eKh%2ny=O*#u{a+P{OC8B~>ij-YqgibhJK%4%CSrt) z-|JlxdOl0-JA_jtWVTDOvv^U4K%)Y`Y8k%2FK34+$J^)V{wK?toCK! zL88MyJfXnrJh)*KbS(+jChtJRY`>JFe5DWkIAV?3m~HwTYOZ*xJwfsm?qeZ$!&Hqv z^~hILrc+O3yNQZU*jzn79>3PI0$hK*@UkB7uED_Wt9p)K}bBwX%RxU zD^z7^fvh!WkXMW@dYe;RbXHDE=Xn#W*h~|O6UFz{jXshj1dcSn@A)!MzcqFfR`7$5 zdR~m@4rWI%DI*-ayibC@jcZT62w-E0XN2Cv^Cc}2`KYty6L-UKTWr{bOO0aJRwFis|xg*pUcG|s4r114)U=}z_UnmL;Gq0&e6fZl@K>`~?n<`8mVgs8oxFw{I>w3x>Eku~c+P&bMl&C=o*mb0 zc$*sDqkh@z230iHLg2?$8Er~b4*a=czGe5@-`z_(B!GOG$xmYB1m~xM7Z%1wJ?1wm;g6M*%wy?+V6>YjET_b3xd9i2oF%H7(pWe zY!^&~4lY?<+iaL?_?QY&$wq^q0psK1AoZZ-?#Kf~#8e$v@)b`$QQUt($4%HzBgS|1 z&FSHzLeN4oraM&sjUReSLt8+~+@=q_VV(p-N$_!FqQ?pl5{s5^6rryqkQ-%&-C|dQ zR9NVkHSn`4kBWFyh2Ya;PPI$G8TyL298#_fgMf7tGxUfXQy>8jh|Tw4ebWDc)@0N| z3-vi)s{NXI=#i|$Wt}|$y+^eDpvX_u{{7?Ck+$CTs)j0S8SdT6V5Sl z)kCmyMu(Gr7%I` zQJN)v`CghcmcHjBja?Wtip(J8RTrTSTcwU?)kvV^q03gK+W1BMEp|GWfGDRLFJuvc zthgcGXmoB!nfsIGya+UBs9(x;a%RV^lAFyxy(v|zSz?akMW$l)w66d!WzVfNIs@SJ zVP!5`SGA>V93T#yen<{S#wZ(W!$@DwPXFpfWs~e%xBTkS&C8>__mmJ3$G`N}N-~LGw;avz~eBc##Gb?Te3R)~&ZszPr?X zRiB#ARMvO((!;^N5@`pl*O_*8)weS3W%k}aeN^jpq}nF^+|g$El?P{oVV$i{rQBQi z_X+rcNQN3v4*_Sb=1q4xElGAZ#Fu7EX>3~6Iynb?=68$Y^7fzJ^$oZUi-cb7?0kv2 z>$AXus*%q4I~<2vy_=U6vGLm~#;5+?*&@cbqp4na#Jf`>O0A24u`UDqYTxcnn#Mq| zB$L=Djt}JZ;|zNmKPz-E7xT1WyewquiEpi-bQbjMW=e-NFMbQTKxZDor?qAfK-mfE zfSdJZKPHpWm>zox#EPJemix-^?)o&l?W1~rFd^IU(s$#x*gM-p0;Co0p1}s*_{+#ea(tgAjoRftL!F|}jrp&T@%OSHCQjI# z;BGlkL(BbV=I;y&@*^5b@i7OPPrzd~Wd=ff$;TBI%eA8YKW`cbJ5273kY_Jht1l9f zC^Wg7I+{KdHXp!;Xv!Xk{Y(?kx{Z}r!&O<2RyWU9LM(hieLwX}JYJcZsd!ZSCs5o4 zdXB#xA4~5PZSJj8;S3s{?n^R|W#0RN+xyc}MrvVaGEw9XnlLa?E?;YJG{f{7W)O1w zXUkhUS~JbR7#&WnQxSEzGEuIk(?zdnl=;4T-7|bpfoSEL_rA-;=dAKjUw;tp_vFlo zrZ`I+#8A#%gQ`T|#m}Eg&9jH{-qEe|4~7qqNtTFpN;*rD`;aTz${9+Y&HuEcw5KlDiO+W|kswt8_Fa&r* zT_vv5Dj0rtz)_*`s0CNYQ4$TZ$$Tg|6Ro?mOe&J*zh3{%p=xK=Y?D7;Wdl0XKXEGOpu((lL`_oSawq{eoxsYeLA2zWw9JtLO#dkBQGK11e>m z!D&OW2xRAd?jTToA)=RJ?nSX9L#b5?w=~&U_G6ppe-gs{0ZMD8D)&>31)_udn`ESQ z*cT{^Sw|UC%foG%-OGn(5a`RSWi!MnxpHnUf!Ej741JF@CjJwLl#QrjnqaN7M8Z=sIohk6%`l}3q5pdvO_%@`0a zpj{8(;lqr*-^|tj+u#hP6UW=;MK(Q8FwLK|0K_Lk@4SB;D*0~pd63K1JX|QI14x0h zW@N-JXq51LZQ(}_6y3IylE#P-uNW*49NfHHO|7fs6KjY{oulc@d3yFljlH{z%NCG0 z`kzsBs zvNkIwDtBp(;)TB}-e|`r8rV4Gq6~BZJ9I~(`rd=RAG-f z*v}7Tx!?8pE{4?se_Li+taC^p5i;z&@Cz}rD1aH8N;$5zR=<2jj<`wa6My7|NZh#m zaew_9mKe*M3{$+M+;o$!Wlm#C%pXpiuU@VcZFg&{)l@J#Fq~zx^ts;$9VH#T4mC1R zAt=4!85vNyTg3XwkLU2s-I<+HnM|jL`Ik$-zxza8&mH_sf;$LCueO2T%ReiSXqqxS zyhmnW5+3vZ9g7j$bmfqxdMAsNlsSSJw9A#!zx4?B);=GRD)M`pCmFOlvP^-?%&KM{ zTf!@AFhcu^di@m`=f#Lm+=H;2-9lt1l@cafpEzslOeh8OXQQRY{wlv2=B& z{7Pn+jkuT|-<34t-A^LUq(1xL(GW%lmxhN$kz`o|Cm1o~$L7GSLMg|o5azf1pV3O% z{WrXSo)mGupydmr*t1^a7Zv)zkGa!4uS&ZO>UoP`!qcSA!B6X3(MgpYWHAI9mh=i) z)|f?xejb*-yc=0B!(52N{N1#kUB+d03FQkFazKFahX+PA5-U+ z7be0Pdz)!STEyHM2|yd;MJy84osa1Z*UE^S6m-rxj~ z)qBvQVm!6f7+~>E#~;+j@zzNxJD5O5<(6u^!DzdcO|40LXcz5zy>N@+$Eb9g#-4e>?Tp~ZjwvJQ2^tlgub4ER z64B)S6yD27X%2Ulm{83zaxfR5OcM&GDVJeZ@#KCQ1G-uB7eYAq|E#V6e+4u;9Pr*k z)p*HlUtwkQKM5#|IbT+-cn-b}3^c@bg@3YUOG~pgerl-R_cKdf=E#R?T~Hd~Wc$dO z(NG8@-9P57zN=uNkx?R4FGx+Woh(}^`+WbcnCjvWTVQxlkt!5r@7B)Fn)@f#$q37V z(_&i!vfln z6~>OJMRiWIKk*|Z&Ag~beJ(7X`-&6M%Us7&I&RLM+iFJ0fXolk-;F=KR8y8wT!$f1 zQp=+ZoF0}cKb5yQwRl$TN^nizs*R`XvwX8@vC*!KQGcd3P9$tY^3D9a^^o5yb3${` zz^FJ(m(yKEL+&K79f&!IjB0G!$N5vhsuwMz$pWL2(0>UyJ}w9AuSlsXq#8Ik3+_i%5=i*;Dxe)t4@$@A6Sj5zlbPUPJ4MY>oLR#EH z4b^o6rr(sz1*lmv7R*ctrJucSAuBURAjy)6_9ao2A9yiV=o6zlr|8KJ z7H$cS(39(V&ViO~sL1J?yWu{3C`I9@`ba0x2S=q$9fAD1E0f38l4oAwy{0CYchHcE z-*R3*Kc0)bEgOucA`EZWd_MerTHt;ofqfvE%LPIH0joOi%o`HCtGc-Pb*~(?ynfsp zZDWczOaTEY3Q6xU>RuWU7v~Fb8Ya`#A*(OiQrI{6zL0bg{|CF~Bl~verEU=>>qG;F z-cA~z<%C~pOp`8GkDVWP_+0&kaQe2t-#5R74G+T1w;BTcHoMarA8|A#YQd$i9+(OD zo|UlQ=J3*-jpEOaYS<)Y7}kTgBs&yxnM&65fU%itjj%{VNO>oh6HCAyj1x{O*Aw!9 z80XaomTb|ZxYJ#)bZy7h_hl<3Jfk+Bs4AmFr9}7-KJhJXWc+Teu}_V<^knS`E>9|9pb2@u@fLU7NW ze1H4y**)jp_wL!VXaA^%?ylHJ@;z;me@_mkh~7 z;Epa9CeSj=x8Q!41ri(UJ0Fo7ydS1CFLomEBU1=6iV+J?M%3X{cw710;Aho_5u-CH`~@!f-Ffxti=S)BiyzDH-iV1b)%FBz$ufuemQnV6I^j-9{Aa;z z)5o!!+r8n?fUBiAQC zo6Y{FOZXvTj-|lYtLqAu)i zc&THa4}_^sW`$iE4b#Tr>J~YWes0tnvk}cM@{rs3^>7KZ&=C~A4-MEPUWEc^8wCH3 zXEk$R9yqAG+yL>JjCl14fR={8`X(4;1I03}Vg@`&p#dN$+A@O5lMd;D5dvs}(5bS( z7<^5th$}KU+6RCS3`fI31(p%i=76115BdxLyhw!<6V4_ulPEylYlz@mQe#F?eN%`4 zhfJSQB42~huY>{rA!tCO?zbn8#@sx18P!ZsY|Vkc40y7l8cnA-uAOxds z9)!OPp^yW~+@Q*|J=o0055PoScLu^YA>OFEBic`q=_tUqRv*Z4KK|IW5%dg(_3xM? zrw4|jaa8&P_h@KcJ8|HU+!%03&HpNgV#qSo`^TNZ98vFqM+!yLul5BH5DdPLZy-dd z2P6M=_^(twHbVet!<9w=Ap|2?09AzlpXGyt5MK3S-!C+oSUIW>=DVvw?j9sMUxEUp zD4{ZhF?I2-TRYq}?ldJJ^gSiO3TXMcJGAW|zm5;Vuv9>us}=FxM7zP@A;ZzfY)Dsc zx^EdUtbrlQXFiQ{a%BBxcZ+{r>`qp(9O!K8KQ-wo(XzJ73>?Rkq5Hctr3(5=VOU0pR;n=i^Jk~nO@9Ri;Cf-_`kB+7R zfD;i^U%b{hr*gF`g*bsxeJ-EXsfz2M3XA{Rz5=y~a;qO%3!W^F;hDX%zo$j+yDqUh z{C9I5d@;1SbzhH7)M-~G!U?eKI;=53Q9){2FkrP#Wh3Ei<2GAbs)_{_qjs7AW`*nJ zss@RqKnc{eP}(t!3(o|Z>3`ufVZk$d_84xX{4bX6G!#|u^W)K29?SRIIks0`1YVm* z5I9;UKEMY-F1otR<&}D$aa{=nG2f{NyCOj7j8h;p7@(?s^y+YJD-Q36S`I34Kuf-f zp}#}tmo(ZtW8)gi52*f1X`m{=csl-x-zU<*hVJ#Q3}@Xk<(5bPb0>2AL{VRDsXsB# zp${I{(t7y{bU3kiJZ!j|?#F}`;>iS{l17zJmYD_#35l+y_)WKS_gvP& zhtQX#aI_x)I7A4F#rP70uJnI)>bk)2bEaeaA&+55BL5p^2tEW^zfkroC}8^`{-;IW zx4*t!d;{@=EvRm)(ZvT{>1< zAuZyzH1k?p*R?`?y>91fksih|MhQ@67);j!6car#MFoe!+=U~PqoYxm@8Z^qGqC11 zgCUe1epoB4`z-4$dV0zHv;2I#4DP)%%-aDtLn3*WW}% z66oHjS1A4#4{hI+_pnj7B;1A_N`9Y%2H?{v&wlJ#I#YO4yRFWJ!{ZevFr6z9zwMM| z53=YTOE_B#(IRxZ>@pxpa1)Y>p()Qn)ZXvr6-IB=^S&Tr+IG)jpxU`Q%KJ`f_?1G9 zLi!@Ac&J*IV`wFo@||{W-j&FYA(OQ!a5LFy(cNe=7%~NoJWd}4V~nhlEuEK!T+czz zFge}L=wAev^8m^p3ju8q6aEED1`8cl-3Q{L^M_rg3LiquDwljA)jn~+!#IuF!aOT7 z#eO29P7uw|g$0z)#J;MoBzX-8J z4s*_ar%N%Hu@d-(k0vSfvcg+ORV+FGYDJ76mu16V8k=fGNAD3Mc9@qou&r5*7tZO; z>L_hf@TF-dIdNh~p>)_^Bs4C()FSxOIB?ymnGnS_7`Bs9$Eoj{b>5oDsK}`%lPGgy z$i4)*mAz=S$auz@E^a!?54bVlJ-RKwd;%!Fqnvr$pN%3NL*)2`#$&$AQg2WOaxBDC z<%0q6iywH*(Ef)u$13}M4eb>dd3Ei}Vq&vbUoF-&dCzL%kIm|z(akH;7ZzJaYnFk2zWjCZ;z z?hNZxO&HBjNH808F=jx_o=``MAqqZMry_?LZ-Q0+_^T1H^NU%oaj2Y}R}|DUML000 zA>o%#1fS3@<@}FS@?{*ZMCbu#zjGwttHp8mF|cZMG%?Ftv13^tA*MsfTXL?71#g|} zsSd?z6KmDk+5KI@O!gda0y8o;cP2p4+zx{Vr6xyw08tpewOlC^4b8Y(`g7Lh2tnCb zb0rlgViB%nRIXBrJGeBzsgaL4fk2onBaAXg-)~-sv(-PmO4jRlieCR@`7m{bm>WS+ z=y$_y5H%cDWkXOD?!_s7;!%3}0rPxNgRFnQ#SKFJP!1(?5jXs8*7?~6B*=R5KGMDG zl7AFT0^tWn`za1Ho$?k1fk2sZJ@;(z1^U}AFw$%It0kf0TKDdREhS!*x&Ao77wd*n zc%ukt`}`tw7|Pfj_!30gO&kakPPIBBG7FDMVc4N)UAE4`=89$M3=KTArbg9Y0H6WU z;N~9nO@8!hOK(r1Z)`%)N!9d;DD`xH$#}RC1JXtI*4*FlCEho44s-|eXSn%B(o#KO zlD9d005Jr~eQqn%8Ej>_Epp<%Tl1#y!3jb=fo?jaaa(CX``u?gmTAfoS3E@z(RoV-U0k^BipN`Df<*NqO%ASM)VusRwgP z_V9`Vt`cIib_SVZ1rVX&rMR0qb^SD9>DF8$E;^QJw0rzp?!W3>tm&$xf~TthyG z1W-sTI^qfpO_L%`51?R=;l7|tcRt_eNULK7ZoUiLfL}nAo+p44w>}as@*usnFbuw? z^M4>*%zx57J%kz@EoyKi@b5e~DpaZ4Daa4(2vRjdPT)o9ki&5490_rg!z2ZIq(ekq zJyQm;d>nVNry3=iiCW!i!;HZAL0;>POD569PuktxDVBd!4uJ-2EMY!mL&;z;bI|?_ z#+wL~!88Xg(jYkslm6#lY9z(qT;d5v6kWc8zX$?MXd+1De=^`qX!dt7pAThp35t|a zx8(?+2&J=}4*%QdOoZ!V4-0=E-c!pcs6Q71k*%TE7w!!9R8@o+O2}7FBAi}2I!b=^ zonW0e&N?g0czNS;C~!K;H?HXEv;QS0P)hWZj$h#N^DFN}JSa0j0*3-%5;$t#77lt; z1gL|c0HBVVg~4z*JCqC#fO@7c#@oe*=nuHPjUJiKqSbAVF zzX(om*RA{|18ZC!9xW{oznXyt;HKtF9R^Q=aZJuP&eb=5$tO7edAFX%FG0hsETo?I zargR!dG^bfUfQ6^3cHnGN=}{M9{W=^6J(9H_4b|?P5#N*9%c;AOBbWQ-i4lZ%odK` zs7Zdw=MA=D9V1xFmLP>&)AB)wdn34d^5T>RDtONtphf^b!%q@N781A;h=Coja@hty zdB3tA!M7iAa?qG3PONgyE?l##9!rz;#mqgz6;!6ak`AUQx(YVKxgHAW9wRm*hE0Cg zAy0oQ=w}VKKKTH4pD*+PN!s^1bqjd;fZA99I3F|cR|#}`mI%G`^79j~UqkPdFb0`m z=C3)g)S?rla%ABS-q$aOP);6`RPbUY=5K^x3nZXbQi?tSs^atWek)RM7|%o7RWtjw z@h_yf4!kK}h?}+qCjZp~eZm80;l^33VnkHD8e&Qs) zIopUG<(a+l7UxH1%x2e0D9){7MjQ_=^8=cb;B#2hxi6INs62Aak5QJeq&s=ejPLL6 zzFj&^KYZr&Rqw`8p~6@;lwgU`Lsc#unmYqsU~4fJ?&+a{NfVqbFSSc|h zNwa9+!Z}&xm1&B%sz#*^e}kRFloGqPNtOqk>MzSj8ma^g~j({&*Xe(1+k5HFqPHU=BPA1*bW+T5+*NIX{`vrk7H(svKM+W_C7EWcUB=7pK!7j40&n%tk6)o*MO@`YFuOsEnkHYL??0co=298fFh$@s64o&4T3EC#X*qz(9oT2*J#DKiFh7|zs)96cdNyRJXT~e+ z&<9f9FDI$~!ZbZ6s16|@y=Tv{hM04?Gk^L|AoBXXy*48V&uyp&WJM#2uo5ZUK?R2df*mprxv>K zsl}g6tg5$^cm{eFteNrCGZoP$o5%H>H@&|mDNzgbu2<|4YNjBDz~{1Y?|vV(C%N+G zvakVJ?ED02Q}YE;G+QAb_fldyFXF7;99;G(YpfyEtao{q@AsZ2Xm%@>$Vd}5b>eqn zdB;2|db(<3`uWfEuItDSttavE>6$W@T`a)T1h+F-3J}ev8K~y3o}hty3r`=i0+1gy z(%N3q2Gsju_Zs9ohL?CnxOTobseb^<2Yu@kr*PL)wHcORY^@n5aOAQgp^JfTxifDB zv698D-|-z|l!{h|?NpT!9`67IQz3_(Fxk*fO5&+T#a0OZe;i{5_I>|6qU~veM;nM8 z0mt0;GC!N15^00tEPvDz>x3p9LXDbuS*XjU&7{MwD?0vuRtr@q;R(fdAKr46cLWt+ zn!#L3u;*n`q641WM}J8oO62bP^5MoUV3Yv4(~zo)E0eCDFb!^~t|vN}|4X8su&2#0 z%qF;IbnT91w8RNeci-yMYj>EkE{lLfuf5KHs=E`Dd(%qv@n;$ReOcFkvnIsAJIqQE zg1KE&T^UOzE`rM4GHWuOx-l(Z)&r{hAJY%G{`kFEj;QhM41`?=umh`w^hhiBQ$j-8 zAR#R+5WeL}u_OJ9A?$gC*p`t4{4zr#R7!Rv?~`t~-hP{_F3z>xUY(+EJmQn)B$@Es zhe!0|{3ZP1bx7DwDb8qf9(~>FW&5GX27w|3&l?Nr1Y+h|%Jn>dBEs_g)7) z6Q@6S{s|jO8;DTaK;_qW_CIoPsr{2E$HV0{4ge)SVzIDOVx=vRN&h_d2I`_;N3`mkQa5&qNl zZ+h-DBPPOc0B5h&(xE5Eu?r2|WY3hg!2;EPd=NYL4p2Irt=v{ZzEl@_APY6me=QT;5!>QP7av7J}X*HGEy4nY;u>80V%(ddh3>x=!?UVus{*=+8X?D5mQLR zUV<_;$FuriYvC*KQy&pJy>Cy3yptRZRV4|&yv}Hx6-oZ_{}7akz+0>~!q|mF(t-ut zx0v3@%lpsvZGp&Lt8tlZGOt^#ZF<-LbkQR6m~;GF#4$#HYim+E`VU9YE(WNAs&5v& zbZ?mjFyL>9;HiANGmnPB`S-|>rdW|l(X4PPk;?!Q+3Il0y`bbC^N)DvkL^8%8690+ zO_$ZjY9i&ok!oa4t-yUtj=aXQ3dCNCPA0X{7@)n`^`76G8x<}bR_SyCJD)tq^gE5b zDVK>@#7SKsJohV2v5cdgiLNUDR9gwY>8hsP`!$eaJ1e_9XlfTm)8u^m?RxrdBzfqc zd`ZDUwabd1^(xRlaZw_%l8tF|B?L`nP@d)x+Eic6_qR@Rt^bW7MslO=ov>Fc=QydM z6c*0`pHAC5^nFemhOPQvS4>sKd<<0VR0FRK4h3G|K5OeESc$3f(Ed&NL2*2pj8n<~63)Ki+aSREj)cTjEtUD6s zhd*bms4^!8PT+@nPxz(n_wU0$;_sGE>pxe!wzd33hJo7uK#ltah<+((4X$LR$KxGh zECNivi(>hD1o_7<2cgY@$^Fn>%WRq#wr|Wj7_(Uof}Y=k^r1~7S4y8`{(Jj5XdvFY8cG_rBViy+NxQ)|IIC`Lh$9|NUN=ThxTzm!ysdqdYxgV!xvoZK>i(A4 zYSRZ7l|0SV=G&#?YOs65h>O|32}=69FZS9YbE(jMNCXu0bUL9o$Ym5qE|153oOv{Lh8Mjq#n||L?20 zA-oV6{C~6PTJIAqX@_v5`c`ne&HahYa$2?+-HeJV8X@oYgyT{tUY0V7&RO&u1 zeec!2t#{v-LU%Ou*ME(8);u4lun1D5;`zI7xlz^XD64W{qv6;3ivLX(lWX9TA*B2D&SA!ew z-@(2vq zu{u1Rp_}Fla{izQU9Z$>GF$!4cc!wgG?I?JRkJgJ^;p$UqL`hn+mxcft-$rJNse=y{~td!hqIP&M*O)wZ1oxkzC&vF!5Ho#}>k{d+Z1p zfig8-3Z#JUgs#Y=qH+O;`!q!@w#8>Ak#%tl7D(_C)#6j;UuOK&@VIkHz>k7I6TC1@5y>+Eeq_I1n@PZy%X~MqE;Go8Ce_^+1TAL}GSe-LlR?HgbHU zq^u;BV6}@Q8Q&F;8cD>!ihSxi=+8)%M;Zw>&hD608_fQ@YC(lVoJDl+cDU`G4b2%; zyWOsKqbD5n)qkd`8z}gQYU_$QHj*SqqayC$gJboFJX66mW5$5e%F2C7G;y7Yy3xza zinf#dGANj!baLYbt0htJqsTL<({oD7O}k)s;X2cYv5iNLViH&t>^rnPpkf_+R=f)RoZ&jLEgL?jXvGC;NQ!^c9BDc70h7SuS(clqOZVQz6eS;;fnJQ zPHWb>4nMEdQ|HQAZyS>St?;;S4fBmJ4;LQ^-}ueUOjwN$U*|8|@YqCpf2_?jm;{&g zUop`8G{EK7)h$XJv2sa<$c|eT0e{NMNqCxx0|kZg?!}o!`2RCXf8p=d>K!vC>@XQ-0QsG+(AU_YWD-b2L~!-mqMCC_B+y zMzog;xs%A*TnlF2{aAzC*Bo#S2lEVpOmYc!6r>myNt;4JeK54>v`Pk=<+0tn|Dxw#2dWKZWO_?+ zNUdY_g^2OFycfhCMypGMW)jbUs{sU{`4vm;e9g+YA>r`D{A5bn&0?yaIM~aa>TWPD z-55lOhU+-^9>kFCZoQtBX4o%L;BJG8Zbgx-Jk7*BE+20Uc9-vyN(~}XOxa+EcSTyQ zgEebwH$*5)zqfZc=zQ29(z;5l7?Dt?QBt4`6_fYPB5DCWDS!c3<|4jVoGgr0ZK>-26@rUP}cshP(ZY^cCTpu3x5f zOJ_>WL0_n3$HA2j+>Vh%;&0fi4mmChAC^{K?67uFxv*Szk;})|<6+G##S&3Bi%WkL zwGUmI19*fi zb#BDTY~TqC-4|wxfK-4FR4`F|9vj*nm>U0~hmf*M)KbuQMrxp1j|H|#V$xqR;^@{w zJ&2Q|jdgPKl(&W;cQE-ELTP=c|B)3gs^Pw=pFkw$NW|Aq%(} z*ZwAwtWwPL55E;lpWFA^#K+g+5WJ=T4K0yw=NeKF8-G}>wkG(qn#&buXkHsjRUEz+ z{f7SbCVfTjF=4xvJk{wc40^FIwdJS`@Ya?7Vh+4uIt%7T=e(hS3QQXZ8!h*eDKc`7 zh4m8_Xeq6*q`qc@;DkEW+fj~35(;0z{Op;e9O$#ZT^%#mZ-ibuL(E_NHGv!&0tTvE zds>ebYKQWc`TIq{$0_d&O_AO`bQr8uxe}t@+1ib=%>DB)klFgYF31d(ea^WB>dA zA+1O!+vY;%wq}qO8@%j{Z=_E7dMo%$p=(1Q2yO#aYGXH<47>tjw|x;(QVa2Rx7jJa4l-t~Ws zZsISv8_7Rm9NbD0!{YOc)}Mn+QrnA=wuGK%Tg*wjE-+K{x<|7B_O zJjTG;jXCCmr!&<1M{##`fLJp8>`!ThdKFpX_rkrRvA)R|FCGyVR&x8S(MsJ52v0`7;lQp?=X!DcU5OC2edyj5H zHYEKi=o*w+#|ep|c;EHA!j#NTA12ZNen1}f23>9hE<}!)O ztz@eRjZqhg)RQCrv9bkyKxOys$PUxyY%_1cKNTIYJ7Rj;b5juGs@3m8OEE2n&Ij|V z%19NfDi!3|6v5YWm?(LWrxJ<^VYr&Ty2sO}hATj@UE*$v(K*e;6Bl=$4rMKQSAqjb zt=QO`UG$oiC|7Yw$U+LlAUET(lY4qi;ePW-`$7&qy>uTnrS00Qq3l-z?CdW40T;4{ zv;>@iaeNbhUeZAjgwn6nhyi)(UrUfNYF7vHqS;gDnhS1Zsb3dYnaNuJ!{RN4cmtlv zkarI|IMuxa6^bhb283kJQ)C_Ne+7a1Qx@0;2&wWuneR^ zo}$7~90wyn4#oJFaw?;(Y08kNHOy4P*Tbc`vsWL|1TFHPw>E-We=ucFs0~W`KK-ds zAV09O`~!YivMjT-J0jp8m)*`G!(g^5$BX9AT(R?tCu<@Hx51pH+IIa(@R25vC9&P3H6agvZL+6{T1EJn*GOcsQ}Fg(deC@h1Zl&nFc@|u4i}9? zK?LhsXCME$#YP)nTH>%AdWDvHfv-^bc66;ne&90H5_FY7fP`eS!+{_Kkg8|eABu{e zMWfc@N_q6p`gr}pImBe=1@W}Zq~SwUV|fCgWMt+jmu28%sbaz_*3IF5pZEXyEqU?- z?;cOY2WjNvvi_${k(eESyC~L2Q>}19sX34&UONaNtY7vu_Ho}W8sr0Y*QEv}4;M+E ztp8LCh^>N>g~(AzsR4&zvHxuSe!N!>6rVzF3@|OqU(sisalq}l6WY*L%5kIltSZcs zzfgr47V*`|iKw6{2Umiyi$pzWLOvH9WXi1!1oePo`_+=btIzf}-x0G@-`L6*eh?** z6Y_U~oS@(B%TawZY`{sT@1wHR3^IY7aIVUWtqI-a%75-Jy(@yz?XqpQI2B1l^5qBA zXQvf^{>f_u?b1ZK+b;=t?QH+3_{va&$?(|K&T~Nlz@3Snkor(5jqIjA7r?KLt(E(o z_nk!tkfclq+T>06@K(|&CUZ}G%Bxezo_jmL+KTZyS-GSgG#1J@Vk#~xvVajzOacwI zYm&i<2yH?@?XT+wxvm^|6Cx`!o-RCz>})gC-ICk!v`ZsoRKvozM4m}-A`MMb=`g8& ztc2}ZX^ciE|5M&xl=8NpzMpVo-6ZZ1diq0cQ1{rgGh|I47!`}--O1 zn{?DRgILz+d|$Gn*qPn!-=JQRTVTMc%7G;#26cO}C0*8*D?F@m9w@ zM)=(|< z!dsc4RFEIS zvAs9K*%QW)poyrNFsZ=Jeu1d#hD$uHFap@(qt|;cigWw}kjje(g;JbJ2RD9=!;diX z^O43*x>20#XhBG^le4xEc3$|<*Av(63nc!&Sl(xwm}4_#X!ughrp*nW&hujkEq6nq zZH0bSNKTETzVev)#vyOu}i6N6lk|F_T;9I7o$TPK{qS6Dw!b3q+6 z*>bArcR*UylEc-z=i*Foo=x-BD<%zRXOy4ojPjv^(S5Je1m8!ILJLmhOY?^*`6c6y zU%~EJlYIBbMXaDT9Y68W5^IS`k}6%vzrJdso35?!L-4>^9gbNl5I?F&tI#!1cITtb z*^n=7o&7|9jpbN9>6Q=0>fsQ_)ES9uiLx~QzP_COhn3E8F6LNnN&1bQ-|}F2qG8~G zQqi`}4JTxOt0*FvZ$QdE0LoCEA9*LusaSS>S!1K{{pg1#OKOEsaxg>oFIC_%p>x_m zzJOMr0F$U@G%A8KX+z?|Y7sUNsTTm*uiqp@w;_E$9gq6V z(H?i8RTx|co(u+jn&elX8^)mwZHZ-fcO9t#8cE$&LR88^^VFq&AJV0@u}ReHDKpqP z+isp7IEdECdBapn8LR0}Q7{ux8OsL|IwM{)C7n${{u7NENMAWtt&v@>aWC$|Y_xix zm(a5nlCG2*3~83rYu)~#^2sdqt9JdU>H-qz0uUE!>HQ_QC#2oK_#G>9;)`3AN41%s z)b)@hT9qQD#!B!0yu$l3m?mSmF8b#(+Uxd$fdEQ%UoHwwOZr|fq4p-?wC}1w6Xv!! zrb$GYFGRqBJV55pjA3*i0i>$Z`XqondI%q#Qn#p|*G79~VQWOd<3!Dg5(pV66BP{u z8Qm?_i6BzYLMogX?&5oEmZWOpI>@_E0(jbW!-xm|z?u>g)5+h2Axc1l`)3SuUQL9@r~o-ZE%khkS4R*!OO z`t#N@Ie7Pie>OH*XR|=iCEcvjF8OZG`ExA&se7CrDyN8Sp$1B#V02HfkG#Grnx7`1 zx1@zxei$Qp^*K`R*}GJRq&5>p30IKp6S*(}=B$sCW>vS3KIv8Nzn`ZZ*FT+)b0r;@ z*pPW-_3R(vET@h#oV#}3>|T&CM`@x2_0gb*;w`oianF^Q(eToaQzSEnt#O;wj8M{S z|EqR#>bJ9%TOpDbKgR(XG?gTE4bEh3pfrPpV7nK`#G5n0oomDHk{xdQ?ZE8FrQD#LBh|S=WAtO->KN$ z4M!(a$EVM;Iqo_@z~KG<;h()A znZc}tZ4Ku{zwNXr>1zPg+ODrmyi6dqX_vP9{g?~cMTY>+e!H@6TqQv{AC}{|Ab*L> z;^_4IHN@UW!Q`(pS;bra&^+^$ez;uk&Xu{%Jy9&ZHr|Y~Ae9txOSijFxI?){l76Fp3y~j|pEAg2c$?5uOq$8L0UE#0P-|RLWlj@)35p@emaeq5%m)5%?K6baP z&;{^q4yjxh(v->ou4RPyrfD8QJLT6k(E=^vluMHKMxbxky%#zLD)^&4X&f%&X`;E{ z(IzwW_>FY3*E0*br2UPMfNM`u|H^3@{4t{Qu{2(r-);`iF`J zowPjONCEwCmeBui5aOtoUpw>8g&+}TbiqdjA18~{3nQM}zA${j?)g?@It-5to51#w zcCviB923u1*U%Vu`L%=$<{<9F+$;|6sHa^0hxf0+@SWXVXjbs3nF0v<;6Q>j`L?1t z`C`>wzkybI`*bzK9*V2(l5!VqxWZJ>n@)Vp?feA$A2979A50{V7wPv@I^EfKN^Pw@ z{N4CVL}q60QAj^8(JIxf*F2OUHSlxRRtL#HSlg8$Tnw(*JfZ!UaMZzk>?LAG;UN*s zld?Kbw7QrR#rO3;@Y~fh_>7{0&D`1;Oq{~4^2~Wingp4;*f{AF#pm1H#(~1d-JvrB zzs4QiB|~FZ45$+Bn4ntDfOCy!qko+NjJH36ku#P~&p3b>a+)lU_C!B~YJb+5 ztbL}bg?G7#!QHGqr3Z))mjsjVX{5ZaOMw|B#(H3PW1cYK317 zE189%lOGhKzg1uG*`Zy|V7<>Viu||j<1q453=nnN@W#`w4It7b{xx+P)G3fkn5gjk z_Irf+O`YWT%*Dw2){m=80@pq36Sh*_t+jVY@!#WlWEMB5jb0^Iuwmdk?Ot(-A9+{{ z3y)rpjWs6{iULzaRY~qTw8+3lqk}mCE#nY9&XLaEMqSb%Cy@3hinU$gV1)>E?lFKK zekc^SZ)w9{K#1*2psX9Rr4_*5E*z-nAyoA`0j#X1N9Erv=un1!*0#@&jCuJFHCflm zpRfFRk1dC_EwIk%l++0ONY-zT4`3MZ)|<naRjR)r^q&w<_hf*#DUF7)I-QFbx|HkReW%|-AEF%(#L zy;hPnb^{Yl=~}$}OU~Q`px#CUrJ-e#+RyuqsVAIEOumWx-zzP>y(D?ulv4WVfju;K z`wUab(nPBhNAHXTX+|DZ#YGL5=k?p+0Jt|0KB8^0af8!_o|awc(WyBy&#tM+ABA7$ z>buoHSW~xz-+*znj&Ssw-{e?mL|mSwfE99e4|d@dISS*yKTLf#C>L&c?bE1IG6i~j z`l)NV>fz-JivN(MhGJ{cDgBDRC(c#x$XktXk#mmn_5*Wl!v2p|1+MF*0C_EfxB6<( z1Ge?LgPnQtepavROZ2$uOGyG|Q~ko-Mw?HNYW&sgdD%b^{%EMpcd5|(13-ZR`D`JI zrG~J`nkH%kv!xjIT~nu06mGnU1}Yr8&y(d9j#6V1xMAXU2cXz_w@r-mUE~vf0D60K z7~gkI-$!P|Y+>h6m1i7p4mbcoXP++vHlNv5`$O2XaGPfzF@CpW4;)o`NvG%p`t`h7 z6mM08E6>rLn3vly0~p`n;`-eLom8<1*&eR^Ic%{5pRpB&TNWSj)}b-JTP6EvWp-wU zs6!`>#fF?gPnhV^iNcOUooNyV%lKmvb%qs%-?{|&Gm;f zd7UOCEGOr=`#=ohHRwA*LLv?{K#LUV?c?HKN^CZ`Xj=AP$8Wc}&_jCkoj|m;3enV) zdZpBjgL(hy4zKN>)Q_&hOnY_QNP)=f&5kUjP{!X(aB+8wo&~>0y!Di%6X@AKLhPrL zz-}8HL~Lr;^$PUN0dYn2VLxg-iDm@-13EsJ%s(4I*_3PC5k8Qbs#CDV*ZqOsJ1_a# zfj(gmg6VPe@Y8SSWe*5lZJu(M!JB z!AX~Jv5$!MV}%H|{%5m;Y*2A9+5amJ#%t%D0V{`{a%GWBT+^mOmC+n*Kpw-LmY`b;_@PICcpBQ#IfmOL|Z|rNsA17gxIe#b>}(=4)!dssfFG%nyE` zmOEYC)rhOaY*HAw?QRzO%$=U42sio=sSHBMl#+(pK~dQ4;8RIHPHsHWC_C-i&yTEH z-2YLB<#`_n8tP7&QJ|s4kuvu~&~c$7X2=;jB_pnX<%v3>R`@Q# zu_g#Nbw^0yPhlXxup$6PRJNX*HJ{HMeqt7ECEBeg+(E=34V>U8mcbGn4<)s^-W;T>0DBe*Y)D=8EeMIlNtWtMX} zWB7>_mNJK47CcW#Ny(z!elvauBkMS)F7hj9G6^?x8si2kX$Vp`>5N6TC%~R`k?|c-EXM{aQ+R1jGha+&#f=Cv zz=M2zyeOFdD&hH=PpuUjCvJThJ8kOT=J_L%AEmj|C=_M=tfIl`Jl}b+>dtJ8R6)~Y zcudgo(cN&iv`CGeL&FGAi`H%D^JZNMTugvvo3wCD38yqp-24h|0>RVU9jfm)J#%*+fen# z_w~Q_6h`Sv%)c&S{~AQ2p2iF-zWo=jR_y=}uV zA3Fb-%W)l|!Z{9ZQBgRs$YWNdJl4&WBJaHuB~lp`D}s_}QvdFXnSViR-l%qQ!aI_| zcxIPI@Uc7spl=eBY@^Ri*kpCF)-PVQo3n+dm6Y@O z9hf7x*Yz`Z?}t33vIAL~cW&Z4B~HzLzmp_F7;yOsWl?C_yA>CE4S#zb$sbNypT_5h z3Sx3fgf?|Z@+if@y31jfJF8!N>zV2+jETVK^RPK6q(; z>Nr(T0Q;&P{52T@yB)vvE1Dg01Z4xqR9Cc+<7-N!l<7R34w!zl_bS?~UiC2nKIB!K z1&DUkkY|A~pvE6|p3mpHAjdn=xE@VD~Z&^)*wDH<(dFez22RagB+%=1%`&MU|Zo#JI?t9|X$utb*93)HDg3`IcXINl@739^FNXct6YGFM4 zN{d(O`c0GrO7aAH?;27fJs!?2EudN^(obllj29x;4L4o#voc9w#na!wI@bg%N;Leq z1owU*T-r=eVq=0;+Ddyq;H2zq0?jl*1cJoPXfE3M%-ewLp%Wc#z<}5}4n$AP&SY|2 zv7KZAL_<3`BV9LsK_XrDCA?D7lXixMY_RHMM6uY z-!u#}pL`G z?0;H!X8YMqRt@&a+kcT2uca>VW#NIQz1CZ*j^Nw)_J5T2-tknvfBd-Z*vH{ z%s9siWn^U(iL8(jA?CWh9kjlbx0MyH20)?P{t*FChuH?O&4Ji@H(-gA1-F>5!vks2{) zF8vORE0sb)mVE9u^2Bmmf4+-eb%eUcOSFe3la*I>>aL;eDvBGS%^rd_Q-Z4foyk`3cm-fD<8-H+`18k2=z;A!HbadP8UrtL~C{NfBN zeJ!88D@xiVH*wg}bz-r$kV5|Xf{V%{Yni&yN2HS}<=1JBmJLjHzqPodGke7>_2q9&eUN>9z9b11P+J;Wr?i5SEJmY&q>|SXwb}29 zFweF^x^_O=CK|TF4MWZcPv$2p@-yY(XyLF*PpjKOW4|RAUf6!=@wnOfHEX!`!#&Z^ zpQ9n@P!BH(jJfAVT`{_}sCgHy0Hwa@>!As;9(~AdXj0J@_7wOTFVXU{2`7dvDn>Iq zN0{B@UX(9gJ@|C@5(<)9n?g4b5l|%b-yNv>yEUlYlm&|`bs*dgK`T?uxZ40EBg0JM zsxCm=pECe9TqJn0RX2jm2$Ux{GgU6a_jtVj{8&;C_*&0QpYREGX`u}GTJP(~{~3<| z5a0h%frf0srx_aqrg_JK6N#$3FKDG6S|@Pj4=o@4z$W4Iz6=y}t_^cm5i2%%xj;_v zMj|L^f*&`pUVX30bXqCJmGBvvy`?B<^yIXT+TPd2<{}T8huS358Magdj&3n6r`?|Ekv(bG|Jul5Bm? zSTo!B^Y&?l;X&}Y&yVJhTY}J~=IFY*RbZCsop99~d?iKNKiZ4!S%}mhSd`|_dX#3; z8g?^z#chNj)n(0O(#zR#*stpS8#=FV>BfvMFkU8gD&y+-QtAl{ViJ4LR*tma|AD!# zJ=hxY*}f{s@_G)G8icknx4Q{Um#{*5UhDIo1QJXJCOE!Ezog{LnITO7ijGxi+w*|T z@~c5}y>`;KXdcjo^qsl|@GJA24Qu9i1YZ+{!Y?tdQLSF}Y7KY$8)dOwHI>Y7Qnboe z*R25EdmkjSXNz47L@U4Q9xZwE>fmMeZ|(aJkc#gExhMTTmeUgVOuV>#{&c^O-f3_u zlV;OR%NPI{9cCGphiBR#iX1p!zsZ&2Px{{ugC>2U-^HfH%4e7OQ{n|o1L*K}j<)?x zOZTiqA%ojGMLwUBrYTM|#IvIJjBm5^zm|x3cmSWRSmkZXh9A9A5&vCge@U1%UtxK} zI`M__^0f-qb@(lZ!OuixGN`SBM)8 z1O=3|uL#Ab!r4+`7nl?;UOBNHbuXz6j<<2HeYI5Y_ENcgU{0(z2&ZscK}Ql?VM=7va$osk<*S*4SEI zlzbOISVi*qTcQt{>Jt784yZiil(Y++mj95Uezc;3jxyC*%|P2QZ9NTbh;yHNt4Auf zonmIO%;0zH^Di>TT!sp$n#Oeuqa06dRwXd-2kT41Y=7heOO8i+>Ap~T_g9%dFVX87 zOt*`i1+71mFnyf11S4%=ndOm~#(xr>wE6_VF6faQWTN7~C*(TZ*>_Q}|@_5Bv+2DR8`Mz)3C)yWlU+Sg8 zdH61~&o3_xFX9h-wYHvm8V%onKF(9lgf>a7pw5q#her)>D=5ch*$Plr(cve@xL=9$ z;p+6N3TK|JY5#X2`m}T~EKN3^+Wn_t8<9x#(gSu&bY@#v7!HoR_^uxAD*es>Z-PD*EGR_{%k|G-x1(N>M)xt#Ss16bt^2G$ z{cl+A|E!vokfBTL43l|vdjz$UQ$6FD=%uaw(XwV`|8|P*k`fJ)&0O-G2J2f1XQJ7x z{g;dIpBH$XbsedZQ<&PQUeUv!>a3X8SL#{OjAbAhNo3NOzUAZ@jG*y{jnvfS@?ECa zSG!al_<1m;Z9_z}F}lF(ebCd4k^Z(uO}W&cn;YG9nAqYEolo8yeE1nJxcS!rAH9U> zF_nT&w_D?UD^-a6VfaTgbaG+!{3aX~vaFGR>gDh@RVKd};|)knVY)h9V=QP!#(N5Y z93A^BNESv$YoLMu|1O_=e4vi%k*5cuIS>{->Z(0TP{A0*{Zi6N4VX(TD%ZS(pPFR-d2`MUgtO#uh?l z&u`c-BjjvCGmJIXpl^4JYfuaCt)g=Z9e>{+^t}GFjCV3USUjqrBWKL)CHNcm%LeN}y=HXFolj5NNBYGB1_y;Ut2*|h zG78{N`PY6pTw^lh>D@j*-IQ7BYY_9nYUO(EDfvP&O?&nBaVry1pw!O|ku>=(R z`~Jd>cwM=f;M8kTDBh^MpXi)^XjDj0yC_%1-VJ3w ziwt<9^=Lr}>ZC~5VZ_Dg$+l*b6-Qel?O$eQUG17KcQ?57mglN9eFSND;YdT|Q@^In z$?JV;)~|RQt>ZSABg8LBk-mPv7!@Ej51EG2Jd3{islPyGCA!)k*MCf@<`WpSGP|?^ zU!9=2^ZAE}?~`P`II##0T+;IL+puU~Qf1R9_}ri&R4W1X)XGg(*}QvMYvH655{+W- zNp*h|vTDz2H-3+=LEzG>UY(w*EV)4*l*i4buI94DB#W+8c&}3s>}iu*A}ac6F9^-Z zL3xx1g<|;H@^KTc!PFtn-W>;S+QaR0y42+4xD(v)B!7QzPc2> z+#{I)Qr+TObxPS=GHDNZr9R{!jqz^+{+qjeTz*M7BF>}kQjd${Su;F+eiy!a>pLdy zf1AlQSx#akNn=s_Fx3WSYn55>59`+ZZZP`+|?h0$z89*PhxLwp7*O8x@lBUUTsr*Po%JIS? zW=kD}wAch>z^lKd%XzAhUqUo*`6Z>x75(sX{=_LyMs`$j+yYht5})2;M_bFdBeMPZ z5=Gh=ko<{EBM4`h!@+Z23-A$rLc9xq@2I(X%+~IIvg>k8>u_%gkpVGusg?gbPmqM$019;tJ+0iL;#3X2LG_`nqmFM4k$&hodhPsnHOP)kR0n`@G} zTkN<8h#$^>ApvM9B0pz9G$;N|LMfb)oD8!(()9@Tv3;`QStH8u?yXWhxV}gZ5d;9an+<$tIbs)|4B>7r}|`wIR*H z+kPOD`{fLKbP*cR8oL_PUHWT>5jWtF<$cGnnzuW}< zUz}LpIb6Fv`?fQ2a011vP{MWX!rrGA?=wu{@^gt%WYcfV4 zg^v1v{@9bJ>%PZx{_FgwdGkAZ)AZ`K{}}m1pAmbLlJ~%UX_3ZSWM<5<0!*{yBCF5i zly1B{tP5DG{Vt@->rt?>>HmdT&9tiRo4X0ah=~kzj)vXT=tA&_#e5WN+Ic2lmVHEXK(wA$@#trZh_d888>aN(vRCP=i) zxl&5B@Y}2B-;UVl++;3fWQGKJud9BT=uCw7>`VE#dc^L#__covynke(bD3D>o9ZXm z&7yAq<4-bwEa!-jBTU;ea#VIiZ z5k{`b<+C*D(7eUieLnGkePMJXy#cGg`}GIw0@D6lEjuntSj@A)axY@Eg&ilz9iPVh z`c}sWaUC7vc6dm-V@@3}E@tDlt3KJ{$t~}7`r%DYg^(|L@3@ZN@jm`>A@Dt0bqmUG zN+IH_^wq@uso24yUguE%LT}iUkty_M4poA|k2g51hgVCzYRobOT4}1~+(;n3TK~11 z==YvM2xmGsuB075leH_wJRbLT!7MYu((e^Fh*=rQNifSRkc1OA@+;PAwUk!geThq;ve*8+ZDjEc!bF&A8Qq3eWcS6$u@Qa2d*pG-|+izGF(pYH3aT!kk0NGOf%k7DkImWwiELJs6 zjmS|mwATpIVWFjU!c3AR6}7^vmIGcDid_julFg3pW}q8hK#k^{Y)|bnqN@?M$>3FO zqu9Q@4=Kkon?M1PBkU5Mp&Yv&RZW&q5sDfZo!3-olDuO5imUa{9GsrS-OM!%y{x40 zl8C;m*J6nSCG|hu<0I0)t9f07q<46L>VhUco=Fm8dxf;b`s4K;O-L>__I}u97(+z1 z7*e4ql$Cq-!omzl!bu{KyJ7E^UjKm8H*WI~&-#socsdtL4{BS5rJJ-+Mo(SWxCcs| zWfnkp9luCJbL<%=Yl2hAw!wR1|5FBzQE%9B!Osn5qv{Er%b#;&>ylO6GvnLKVr6B- z1K)C8x9f`?=%Qb1AR@m7~-LdNVwnF`cp zx9$J!s%14CdiCa*@kTPy>)yG>4s#aX0?WvyI13Ty;A9&)QRg_(F<}S9if_b1ln=(k zAnrG9RO^<7h~U|%qSfYsNRDxG;!$+9VEj(N#TrW;vcA93C<>toTqB=QRcKq}r8;;816Y6liGJ_7Ov5M8hDlfKVS(greE$=si|8kcI3@41!~E}0 zT--?8kf0Atzdilr4qd)6rtw}UufQKgd;UO}Lo?`N8q*mPgNtm&ow!N z3BkpS`d3)dQhQsI;eT{yF6xMctFCJ?gxhd4o@_d2n>U_z_IF-sv04 zXw!vu=pm-G-YG;;=n}`?+2WTGzYdiR{*iP2&5?)?o>fB@P^=i70~Zkz1**?6VmK_K zqb?)C&owAtSKSpdJ@E4q2d-Uab1&)|zEvzeitz8u^(e&@CW33g-dUe|@6}Ou_nr^c z|MfeQBf-6h{c|kT|2z=MKi~7u>-=Ar+uj99U%H~>#L@ff^v`qER@x7SX^OQLRKql; z#k&2v7um=fVd4m@6eIJ3Jg7QC$KgfwJ@?s(ciD2?Xo|a@v@aioXI;Ijw!U`u;zf>% zPE`;5Cvu?cseMLj^vWLCK`gQ*4IZsnCv82}YLhaq8|hs(w^F_<3tYv=Zul7~(l}X5 zj&$_hHaDnGNN_Jcz~N}YD4(=Xt$frV@2!1Wsr&ey z!0a!(;Xfb3f+AYJ5?&&n%SBhSUVsJl2S(Nq3x3gjEgh(b-+Rmb^TrDI^=b7Wj?&f6 z*WNXB1%uG^t>rH--exBnN0DMhnoRTCXr!ZWR0yY{24%VG-zp~Ui`Eh4YWos!i(W$w3e|djpuRmB zVUnvyUzn17(WYZe7C7*#cKA5i2~haws1YOq;Oc5Z{nePK1&U=@Qh@ zx!4ZBp>nqY96{hNV<&QbJ9xpUJoIef=$YjHQCOZ)fKZ|U>H zpHt0LSads{-x>_RL;o{HjB@Mp*hLg!G8|k_&0{v=-bK1#Vx6KJ(kXBkN@c4b7vhAT zvOZPUP^PVA!ZCA{aU*#c7X+8CQuviZME30;Prh81C);l#7Y9LUZ)Kn1O--LCEb2lh%jasTWM zyk~oUAAaMbrOF~4U1C%y zNiN09>j?6PsX4|np(0bS!6<#M=1%HON+%7PG71Q=JHp-u7de4y3LQsdq7wVikw%^a z7XzMW%G|uS9nPA9I+6=}M-wg(%!!lM6Wv#Aqxas{epuz9lRM_ltNubP!3WY-{D*jTbHw(Gt= zm zo|4jDO}9E^pBY=cgymPxnrBT?B;M2v2rY)a>6L*-J<%^}Bj|eJ{bJ8z81Sqv{m?o( zX4vW~H6x*5qLksQs@~2XUky>)Jm+?l)z0SYGI(^79!?dq-DnHi&KN-P^12RuyCZEh zh-C2`yMxO-lOzrP&4F&E{St0IH6m^6{r00xAK(3wJw{#nkT4f#7cXrfgh*sS8G{Wt zZvYA=_RnE%!~#4GctvIQSj+a`pS*Jqtn=imS$nbxeq&kV$#94&!9Ac4uD5;66~8^9 z*ic6X?touvxXS+gp&{@DS*<@%HyDl^NfukbRP3i&QYJeXkIh3|92p91pF+}{rZitVir)O*|(Mv zmxFPm3kPM6eTx`zKhX`g6=OdaNLEBZVQRrd_&ysh+d+ugJQ_~YZl(tdrOQ_kx`fGMn%>IWMNz%XvGR%eteuPUIj%X#SAr-4e%}{e2RTGlKQdKap~bti z4t>{^&-81 zr!=4Q>jN3w-yck`eggkF^z3!T(ko<3;ZECkD;)E+(fa$y?DtQMbl>P9ExMIyFF$-! z)^_RT1>2fl&cB80C4f8m7)5r@lokdD4bx4RL^-IWHf1`a=(6*J`*>RX%bG=t1hkDJ zW!J5m77Mp4pw*?m)c0%kortD?&vsg_@3mCGDdaBC(K}g54Bjx34z&2A26Q!*fmo-zNw{uYVZzY%3xYSU;c% z6N4Fj!pC-ba4J{6&prDn4Y7)E8k#Tlir%+Jw!js~c6(p9T!4y^kDdYZL4w1WBCa6} zP7eLG@hl?lzf)^^igxoc%jdAS2kG`mo~O2DXAt!YRP=i<(CGpY$ZM|nHXJI97alpPJ9(kcZo7ng>Wiu!isIV z|5;o+0JaLpowMQGlJ7oh<%%-RU!RC!aQb=eTFX!a+HJ7tHu-b+3Hp-p!;Rwa5S$3S zLZ{8=Y-&jfwv&0{lzKGIZQY_DHef~;T0wpy?%;!o99%OCm z&3Ye7(nO|nMnI!i6dZ^J9KA=90nH`>6c7WJ5)pxJ3hgrb;GbSDWS7bQY9<95L7g`v zoT)_`zx8(BR)R_5Y{%7@RtpqvHvom(-336gbxgo;~clQxbOPSGyJ+Y2ZwXQ>Ch`~$mfgkv67Dyj)Dhz=_z_{}OYrDJD~a@y?KfH|aJ{kz-gZumv(BKvH%n ziWFLRyb9xVJ~z@tAEv_jpsgp`8S#%EqllPlXwlXJNqgjnPvA}aV5j?B?> zF~f$3D_?F#Z28mOzQ-jdwvJfqcIz^SVdbxM(zc&XBNKL}1)+qR^Vc`E4p-6wefe-~ zQom<3p{>6PXq$chIlnv(3za5By&>;kz(z#=^S3845aBT4Hp8=eGJP&RXEJ&Hk5 zP@7af9v*zk1o)_y1;g_|gC)iR-CD~CW7buJ%INA^|5%4_9IuVPRFukihb*cP>GxyN<>fyQm=7cP$@N}z<=_7hx>y)lHONC-_0Bo}%_jap!hV5B+>v|7PyNzSbmp8W5&O zL%#HAee2cIG&WrAJDbXAU(w^|ia#>$zmuj!?n)TkJ-4Rsbt>4GNtNoQ90+~+FsiF* zBI^Z7$L$$0DCFcP4&Uc|?71bBaIjuO0o3en{u|8UJQ-Z%mqTgZS_Ii}WelpRJbbaU z{?`k%4asW`cG@B1ysj`hW6dB1Nv|>y(kF-$`wzuas~fz%-lrD`Qxu4DM{muoAim;U ziPVTJNw>LR4z8j-x>~_-9PQ{aCuk}W@HA5@V)cj7H201usS)C<)~blYkB_HMLgRt3 z>r9tsz~EzkwDPG=>{sj3f<3rl{rol24>}WP2#BIvN9o}D_7mkyYuD#!YX+xfwv(c? zNMU$7hp<}i1t&S6k#$kw47l&-d^r6HI{e8m*uaKLqMP0O_r@E^u6MJrrui>!=qoei zD=z!PZ#3>nZ{K$0LmynRro2bo`5WlGod*(1K9hy+E|KCpEDL}9`jk?2Q$d2qRkw-x zZV7g{B5%L#&RAF*BGw* zS|MTF3jF6W4g82QD7|J{r~(;q@_eK4Texe@Cob3zW*uV58D(t5UFpls=Ia)AM@UOA%Pu2Smpu#YIshc?-ii2G{g?z~gHW5uM3q?R7P zl6y)tvG*O7N^R}kUr{jKJ6{bfzvf*70M9@Za~RBqYn~v>U-v??oYkm(-4seWXM@b> z>@xG19@>8PZn_lhyYHb}7EiDXR~WLqmHvsoiX4|U zL5A;)g12cZKsXwjGUJ&ij1cy6j~!)x);p_+INg(nbsHCrG=D{3c70tBN9Sizr(6-D zW%_+Xz3umCKzp&=H|^#4)N<@_x~N{61WinC-1^yTlhxp!+m|&%^Rf9 zpG2g3MO&j1uVaGDeOg=wfXjV9MA#l3E(mF_c;$Fc?ww_R78nP5Yvtp7ID!l47*iF#$Wau*=F5@XOYy%y&?hgC-(Gv3)sM^Fwi@lGF$@$e}dzB``L@Pk!$~+d}DaLYYp1{&^dk71TCm; zL*JPfvcAt@CVjrLABeIM!C2}=r!-Fc;5{WSfPcR}bK+*IjisQ4FS6TbI$%EN=Y(5m z!LJg27Tn?ve4jw1mYIj!?hahT9*#?9qDB%8$Qr>kRfD78(cF96lgnl>;y&Ol=o5R6 zclY4wRo;}`drmM0@SLIi1yCl9!N+?7bNCGA6z)}fo#=4zC+~C}osKq<~hd}3V0Rv|t~M=0aqC5PKNt?t%)vBWAeeDXEy)*E_U1%fP3<|-5jZ+pcI{o(d} zV5WJ%eDksxjCfgnxj$jAE1Pjqx^e-rrYZGhQ?xDB6i(vnIMh)fZ~Mrmm;o1Vpqvan zhDWf@=>(4_fa=7?$5h&|cK`U%2WPN%9^l>DK`x!?~6q6Fo@adI@dbZcY*@{ zb_~%%g(jk(&V?P1y5%sYYb&1@ws{Rqe@q0c(4Q{2)c)>II4^m05q6Bk|6AI_NEGL% zmx)Dfh#pP3g>wTI+}?JP&(J&G>ss9TMuD$PEUx?X!vqZ&^>iDH3UJYLuVuguF+xAW z1t#>A0F8#KjEkdgH%zkGA!%MJA8=)PZhtfm@HgSZ$f`jDBSgeQi4P4D`1E>21Ll8M zzRqRmDM2)qs*HmK_Ka(1dKNZH{h7JDOG4Cv%@`SePW5_35q3R&~@g=TW zGI0^{={T-UO70gyG%>gVx3TB{{f^^-9^RFw69deRx}`qyOb-K3|1k1e5FgBadKGM3 zt9EH7nA1U(asTxQ9D1*)Iwd>cQwa8{yD+f4)}Juy#t79YPSv|CeqUY$I|vQtlxmi3 zlz)k>_8FKb?#>}74e6YVe1d3Gl`%gwR`0U&y%a&__<41}?Tm2cp}fTOEm8-UBc8);dT(sPVVKchqb_2x zwEXvHqCc46>yruTJ}#@sXPz%U{zzm(6Gd2mPgoVj>0-o$v$@g#F11U#%BjjhQ`-Ev zsc)q>2FQ+|qLLwy7CA0N4nGoT5w4dVD>S3azcKJtlW8^Uh>+X(_i2f}U0GmuTDR(n zsrHp}zF+7qv_?dP`lPk2Sa@dD6TNlv!8jJ)lni+*9T74jT5cSrtDJ`hWAt)f<8yIn zI83N=T@Unva*SSe{rX6tGRpL=%ZWa2$D5Ee4wm`o?J?(nd_Dpnf9tZ^JVID>=|3%- zfApY(nti3__)HU;%`}++QSF?r2;(fXKfwil7yQ+tst=zIZ|VgKffjcW?*kCKStCcH zICGdMgV?UUo<5F&mxw4$0}xOmd$z4}3Hsp4`2hSL2c~KR6lCt2Q=s2-KJR7Tv*V<5 zIu*><%!p%WaTf}kASObsIB>z{)*;R3Wsd~D!e4s6*ddMQ6vP*k5;>02D8a4xO+JC% z*Kjrsrd4l?A(sH5mV-Co9&X?QCv49`PpNRs1meOS>Ek#Bogh+wwBGsH86{g0>zjL_{19L-zJ{=aCH1 z;yUy#A>G?W2wD`*|CY$6kK6Vci>aunIL2=vdFgo(H2yn=O&6U z$di7vVRSwGOB0p>R1{hWtmDZGI)L8raw-8B*P){t#7&zb&o8 za=iaBLXo6_as1;s=kZ92fEa5n=U4i4wnA0-xKiO6E1m;K9#rTmBtKwgjpn6x01uv~ zcyNpp5qaivafFoRea=h57GqsyJScDriiJS1-T_DA`DZ<54oOGrrTo7%Cr?fod_r20Kzb`AGHJb}%FRw2fND1y6`Jyk2wQ8{}kZzb<$LOKQR$hDRa z2S@HUn?2CZCv5sFOEk6z#UmV(Pf6rFjkLa4Kybk43RCP7C zgNIP|HFO$Io>h;wLCj0Cu0pk7 zJAn09s9#4p(~ik}BPKZdIb}v&Z5F!Au^b@>)FqUX_A*7$ zWHs`2<3Hcz!iYxOSI&$(oKpN#?u=cwXg>l(MEAtH;! z=3RhM+`NzCfOafI9{Z9&e%uTGoUfqY_gGByb#W268s8>uOo$DWo#*Gntc_&~GX0JS zZDlbAy^PLM2DJL~ngKCQMkd^v;%eG(a$zHcMoJb%OWeBW^`_NY6Mj+{Y2lmOF|DnV z(2t}f}W-DQ>#!bQOnRcAr)>PBz2EZWd+&I+U7-Cq&RO$W3#a*>IRhCk#K33S?%SF z5jxQL%+#mwo6slWE-?#gcVuWK-@D`7Tp+i%C%@V#VJzfP+Mhb6JG|gKva9fLKG@BV ziAO@ab8eDIaVV-Qh39HLpkkIEM)uegPP<5d@e|*+HZ$e;#QTW?KaU+o(s~zm2-VJ&8ughOP%n{-OY+d>=O4Je`5rIPVqP8sqJ(_!QKxnYxP~u)XaD~w~VAJd1UCu!dIQbW7Ruudn_UzS(PVaK&eEL ztn3yuGG9hZi9~+#Db%3aelaXG|KO{5QB(omH~19nRccL@EI!h7n6vA;1@Am1njabg zEOf0m%Z0*u0;y!E55#$Ba-D4$fiy`^{DHDKP^eJ;(B{+oL*N)IZbB6KYhX+HrO|Ss zuUHY_0oQ0(IS|gHCbXuEFI=7={Ej}LF0-#ct!QVWGfmJBR3(`FSW?lp2DB^r-y6=p z_K#0J8e!Em9U>=tY>`6iM_-QJaoojH=3`2ST$HIaZ=jRV<_0uzc(>T9Hy_4Rs0~Qt z5UfH#eoMT&+RG*`MEGx)l~1EgHJH)3h4W0jm{3$UVJqptD4zT+jMOphMF1tqR-{;& z8KlH5Fnr17hKQkUA$1AlU?ERbQ3@LqunC~DpXdFF|IM9=Rer+fkK%`<(HzF;2hxST zX3yb;!(!PNFGN=W1o$w{B}?RvXp*FU475AYl)iAsT!aUTKs6*%r1_fQsF-d65^GYp z&hXVDb>p_h{8Uh60bv(K*qcGT#uQGzoUE|9%b7-fqH;V@SMlR69dMp1zVV)MTWpBL zj2@y{lA0Qj7%al9n#q8ULA53NG3_Y#1GOF=Dr5%w_86(n1LX2X=7^GrTwClA0!G%Y z9gdiHV!@&s5=e0KjR__=eL6}On|;dNS2zN`ZLQwDqY$GLJCtB<&^1qKDyP zKnVUPZV0$NOBV+jHWOQhRZhs^f=HY+s2=dp33Z9dQ@i6G8{C%p*!uRfUa9FlC4m(p zc*=r_Nio}JcWOsU#l;>dW*Ll4J8AyZykuwjOPD!@f{!de4;8DTtQ_09EDz&FHv>Qj zOHC;p7H^Rfip~I(iuupR^7Tes1PTtj zMN3e^9Nm?fQ&Vux;cx|_MQa_RsPw44xPj1=Y_uiHhoCNof zpiw1YBSofCMbWz`mWpz=6CEv%QtH%>AKDrm-A)1g5ItUl>X$H&xH`afUpIjsXUj2@ zqO+%30>Lai9AWo;`5_V{0bLf9<0<6?@Wx0$_OF+# z%*;e?{pl16OEJ~MUqMP)(EjKQs4QptW1*#Q%F@F5OyzK9rW0`zhR7Dl=ht^Yt0fbV z0+oZ`ccv(Ldd$0W!)iO2&|?cz;OxVEx9$NhSW|tZXId3gXL$+igoh7a zQEtcuT&Y$H!L~cTb$qG7LZ&$beW9ExN#x8A{dNV?0* zxwc3nf$Y)RPQ!~cvojy*(UHE{(76b5qY(J)7F3sFQOt^s<-+1!5tf{h$ z^^MrV2&>0I4atuQ7+i+DTv;yh-{Ccqf56ugACLW|F|Hl6MFuJ==KrW1HN-EBN9Wra(D^X9-?Fr%J9Hq}xfnG)ZFmuuKHmhbnbc+!Y$o=8%LYdK zAM#RBJWzA;LoI$XAdoF{xHFu#O>H^Gj30{d&4rpF4QlOtFcP2tRLpRHe%|Ksn2pSf_`|_|--DQy=*n&Ccad4rHr6o-`SqcH&#AuIrSSVvUOM%TO z^XmUqz#{-^L_P!BJ~C|lskdArR`jSWQYVFaR*ag0ozi3$2$~#ss@!CBXpEEzpmXRc zFZVl`*3#a3)$ya_6lDl|$h5)q^CruU!mUk~rl|Vp`WQ>gy*u9HxrEGpJpH!=?wdw;UxrbHedfCz}!=1Rl`2BWtLH#bVd|= z^gN2GqFf_dlgWV0LHkaMzi-emLm>&?4|r@738a4hwZrdddT_Fk;aW}k>JJTqznpoU zC)=m8Odrlfc>sjfVAOZszFwHZQXa(5 zrlfYlN9Fq(0C1qbatQ3+fSi&d=KXRZRuI}zz>6U_|EF;3Tmm7PiI$cOt8o#_N6b>d zdhB(TC>LmO#5gCwk;gbAf*(+gW~mAP;MrRmsH4rI!;FEg_^wq}AVlnr@OsZb~4BBS&%e!(|J9$e11vHZ}>K=ioXc}7PYCFbw@8$pRg zv zOUpU_eo_RCh3ty96Wrf-11KRct`*ue!wCa8;u^)I_Jtqp?(Uu(QbuaRV5IpoNgd~} zbPwo3Ikk)U+^0a05BP-%20$GHe|pi%l9lQ2GC!5j<6;n$SXwO3^mU}E9U38?eWW^I zN=4VFa5cpw-7>ZsW7xE0Izi-dEd!tM9D4?$vWE2GGqC__6&m+^_hm$Uf~DochDwBY z-i1DyW!j@(*igz}$QA_tG1}&^eOSII*3$Ar;#niXf-Z8j%;aJE7+lA}5`*ZPVPyiK zeBW1ztU`bg&0e{bzh4>r?4bO7SbX<-uq44h1Xq^ZK2>5*0Z7M*?U`xs_=X^Z>;St( zfo(C+IsnHowREQPxOfHvXx)m1wu*sYy-EO7KTf?{md^unHE%v<000OKAoxoMX+E$1 z5(q#gqP&qY$Py=%m8CT1R25dEcE{G%2yu;pl4(N*mlTZ z*?GvI3sk4-{UA*Lfp$DRh~lxb96Qa{;yR2ZBwsR=3zAKNTUx=Ff!hJHX2N&`gU|EJ z%a0k`F$g-~dWJz1*fN7av>@}Z_0g08FwEr>xn;@PG{X?#!`B2U z6+p|+hwd`2EI;x%UFIhjdR#S=qa8F%P$vXF1wc4m2lA2-0VfsiGaQ;}!C|Dj9mLoJ z9wCSp?k$`R=I_XlHQ;QWuPpbO_+HDda}EGJ`$7W&HR$p7IO)QFyEI^MpM6(fUhXqG zAU2UqGDnR)`!r6^zt@Q{SSMz)k>$U;sl_S^6cR zsObFAWszbR%hQ=N+p1ic7!Vsjb`U{|6$BI+2HP4CxYj$YUGxN~MT!Yg0Dv&r2LIuY zY4>0UOoIRjlkz>6g^F(j!%$rqq6+|6m`xBX$VNqg^_T~f5Hengm_gt^@-ICUN`bw6 zeBzk`P@Isv8~&T*WEy>InP(9g*QM=jEdgaS-@%!m9bYsJ4t&xxPQFzVY9C(Ky{c1% Ic6|K*0Le#zhyVZp diff --git a/src/RoboFile.php b/src/RoboFile.php index 7783070..1d9e7cb 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -18,11 +18,13 @@ class RoboFile extends \Robo\Tasks /** * Apply changes, commit, push * + * @param string $patchName Name of the patch directory (positional arg) + * @param string $repositoryUrl URI of Git repository (positional arg) * @param array $options - * @option $repository-url URI of Git repository (HTTPS/SSH/FILE) + * @option $patch-name Name of the patch directory (alternative to positional arg) + * @option $repository-url URI of Git repository (alternative to positional arg) * @option $working-directory Working directory to check out repositories * @option $patch-source-directory Source directory for all collected patches - * @option $patch-name Name of the directory where the patch code resides * @option $source-branch Name of the branch to create a new branch upon * @option $branch-name Name of the feature branch to be created * @option $halt-before-commit Pause before changes are committed, asks to continue @@ -31,20 +33,27 @@ class RoboFile extends \Robo\Tasks * @return int exit code * @throws TaskException */ - public function patch(array $options = [ - 'repository-url|g' => null, - 'working-directory|d' => null, - 'patch-source-directory|s' => null, - 'patch-name|p' => 'template', - 'source-branch' => 'main', - 'branch-name' => null, - 'halt-before-commit' => false, - 'dry-run' => false, - 'create-mr' => false, - ]): int - { + public function patch( + string $patchName = '', + string $repositoryUrl = '', + array $options = [ + 'patch-name|p' => '', + 'repository-url|g' => '', + 'working-directory|d' => null, + 'patch-source-directory|s' => null, + 'source-branch' => 'main', + 'branch-name' => null, + 'halt-before-commit' => false, + 'dry-run' => false, + 'create-mr' => false, + ] + ): int { + // Support both positional args and options (backwards compatibility) + $options['patch-name'] = $patchName ?: ($options['patch-name'] ?: 'template'); + $options['repository-url'] = $repositoryUrl ?: $options['repository-url']; + if (empty($options['repository-url'])) { - $this->io()->error('Missing arguments'); + $this->io()->error('Missing repository URL'); return 1; } @@ -100,10 +109,9 @@ public function patch(array $options = [ } } else { // Suggest next steps - $this->say('Hint: Run `./vendor/bin/patchbot merge' - . ' --source=' . $options['branch-name'] - . ' --target=' - . ' --repository-url=' . $options['repository-url'] . '` to merge the feature branch'); + $this->say('Hint: Run `./vendor/bin/patchbot merge ' + . $options['branch-name'] . ' ' + . $options['repository-url'] . '` to merge the feature branch'); } return 0; @@ -112,23 +120,35 @@ public function patch(array $options = [ /** * Merge one branch into another, push * + * @param string $sourceBranch Source branch name (positional arg) + * @param string $targetBranch Target branch name (positional arg) + * @param string $repositoryUrl URI of Git repository (positional arg) * @param array $options - * @option $repository-url URI of Git repository (HTTPS/SSH/FILE) + * @option $source Source branch name (alternative to positional arg) + * @option $target Target branch name (alternative to positional arg) + * @option $repository-url URI of Git repository (alternative to positional arg) * @option $working-directory Working directory to check out repositories - * @option $source Source branch name (e.g. feature branch) - * @option $target Target branch name (e.g. main branch) * @option $dry-run Show what would be done without executing * @return int exit code * @throws TaskException */ - public function merge(array $options = [ - 'repository-url|g' => null, - 'working-directory|d' => null, - 'source|s' => null, - 'target|t' => null, - 'dry-run' => false, - ]): int - { + public function merge( + string $sourceBranch = '', + string $targetBranch = '', + string $repositoryUrl = '', + array $options = [ + 'source|s' => '', + 'target|t' => '', + 'repository-url|g' => '', + 'working-directory|d' => null, + 'dry-run' => false, + ] + ): int { + // Support both positional args and options (backwards compatibility) + $options['source'] = $sourceBranch ?: $options['source']; + $options['target'] = $targetBranch ?: $options['target']; + $options['repository-url'] = $repositoryUrl ?: $options['repository-url']; + if ( empty($options['repository-url']) || empty($options['source']) || @@ -176,30 +196,36 @@ public function merge(array $options = [ /** * Create a new patch * + * @param string $patchName Name of the patch (positional arg) * @param array $options - * @option $patch-name Name of the patch, used as directory name + * @option $patch-name Name of the patch (alternative to positional arg) * @return int exit code * @throws TaskException */ - public function create(array $options = [ - 'patch-name|p' => null, - ]): int - { - if (empty($options['patch-name'])) { + public function create( + string $patchName = '', + array $options = [ + 'patch-name|p' => '', + ] + ): int { + // Support both positional arg and option (backwards compatibility) + $patchName = $patchName ?: $options['patch-name']; + + if (empty($patchName)) { $this->io()->error('Missing arguments'); return 1; } - $patchName = (new Slugify())->slugify($options['patch-name']); - $patchDirectory = getcwd() . '/patches/' . $patchName; + $slugifiedName = (new Slugify())->slugify($patchName); + $patchDirectory = getcwd() . '/patches/' . $slugifiedName; // Print summary $this->io()->section('Create'); $this->io()->listing([ - 'Patch: ' . $options['patch-name'] + 'Patch: ' . $patchName ]); - $this->say('Create patch ' . $patchName); + $this->say('Create patch ' . $slugifiedName); if (is_dir($patchDirectory)) { $this->io()->error('Patch directory »' . $patchDirectory . '« already exists'); return 1; @@ -212,9 +238,9 @@ public function create(array $options = [ $this->io()->success('Patch directory created'); // Suggest next steps $this->say('- Edit patch.php & commit-message.txt in ' . $patchDirectory); - $this->say('- Run `./vendor/bin/patchbot patch --patch-name=' - . $patchName - . ' --repository-url=` to apply the patch to a repository'); + $this->say('- Run `./vendor/bin/patchbot patch ' + . $slugifiedName + . ' ` to apply the patch to a repository'); return 0; } @@ -310,9 +336,166 @@ public function discover(array $options = [ return 0; } + /** + * Apply a patch to all repositories + * + * @param string $patchName Name of the patch directory + * @param array $options + * @option $branch-name Name of the feature branch to be created + * @option $halt-before-commit Pause before changes are committed, asks to continue + * @option $dry-run Show what would be done without executing + * @option $filter Filter repositories (path:pattern or topic:tag, can be used multiple times) + * @option $create-mr Create GitLab merge request after pushing + * @return int exit code + * @throws TaskException + */ + public function patchMany( + string $patchName = 'template', + array $options = [ + 'branch-name' => null, + 'halt-before-commit' => false, + 'dry-run' => false, + 'filter' => [], + 'create-mr' => false, + ] + ): int { + $repositories = $this->loadRepositories($options['filter']); + if ($repositories === null) { + return 1; + } + if (empty($repositories)) { + return 0; + } + + $workingDirectory = getcwd(); + $patchSourceDirectory = getcwd() . '/patches/'; + $branchName = $options['branch-name'] ?? date('Ymd') . '_patchbot_' . uniqid(); + $isDryRun = $options['dry-run']; + + if ($isDryRun) { + $this->io()->section('Dry Run'); + } + + $results = ['success' => 0, 'skipped' => 0, 'failed' => 0]; + + foreach ($repositories as $repository) { + if ($isDryRun) { + $dryRunMsg = '[DRY-RUN] Would patch: ' . $repository['path_with_namespace'] . ' (' . $repository['default_branch'] . ')'; + if ($options['create-mr']) { + $dryRunMsg .= ' and create MR'; + } + $this->io()->text($dryRunMsg); + continue; + } + chdir($workingDirectory); + try { + $result = $this->runPatch([ + 'repository-url' => $repository['clone_url_ssh'], + 'working-directory' => $this->getTemporaryDirectory(), + 'patch-source-directory' => $patchSourceDirectory, + 'patch-name' => $patchName, + 'source-branch' => $repository['default_branch'], + 'branch-name' => $branchName, + 'halt-before-commit' => $options['halt-before-commit'], + ]); + if ($result) { + $results['success']++; + $this->io()->success('Patched: ' . $repository['path_with_namespace']); + + if ($options['create-mr']) { + $commitMessage = file_get_contents($patchSourceDirectory . $patchName . '/commit-message.txt'); + $mrUrl = $this->createMergeRequest( + $repository['clone_url_ssh'], + $branchName, + $repository['default_branch'], + $commitMessage + ); + if ($mrUrl) { + $this->io()->text(' MR: ' . $mrUrl); + } + } + } else { + $results['skipped']++; + $this->io()->text('Skipped: ' . $repository['path_with_namespace'] . ' (no changes)'); + } + } catch (\Exception $e) { + $results['failed']++; + $this->io()->error('Failed: ' . $repository['path_with_namespace'] . ' - ' . $e->getMessage()); + } + } + + $this->printBatchSummary($results, $isDryRun, count($repositories), 'patched'); + return $results['failed'] > 0 ? 1 : 0; + } + + /** + * Merge a branch into all repositories + * + * @param string $sourceBranch Source branch name to merge + * @param array $options + * @option $dry-run Show what would be done without executing + * @option $filter Filter repositories (path:pattern or topic:tag, can be used multiple times) + * @return int exit code + * @throws TaskException + */ + public function mergeMany( + string $sourceBranch, + array $options = [ + 'dry-run' => false, + 'filter' => [], + ] + ): int { + $repositories = $this->loadRepositories($options['filter']); + if ($repositories === null) { + return 1; + } + if (empty($repositories)) { + return 0; + } + + $workingDirectory = getcwd(); + $isDryRun = $options['dry-run']; + + if ($isDryRun) { + $this->io()->section('Dry Run'); + } + + $results = ['success' => 0, 'skipped' => 0, 'failed' => 0]; + + foreach ($repositories as $repository) { + if ($isDryRun) { + $this->io()->text('[DRY-RUN] Would merge: ' . $sourceBranch . ' -> ' . $repository['default_branch'] . ' in ' . $repository['path_with_namespace']); + continue; + } + chdir($workingDirectory); + try { + $result = $this->runMerge([ + 'repository-url' => $repository['clone_url_ssh'], + 'working-directory' => $this->getTemporaryDirectory(), + 'source' => $sourceBranch, + 'target' => $repository['default_branch'], + ]); + if ($result) { + $results['success']++; + $this->io()->success('Merged: ' . $repository['path_with_namespace']); + } else { + $results['skipped']++; + $this->io()->text('Skipped: ' . $repository['path_with_namespace'] . ' (already up-to-date)'); + } + } catch (\Exception $e) { + $results['failed']++; + $this->io()->error('Failed: ' . $repository['path_with_namespace'] . ' - ' . $e->getMessage()); + } + } + + $this->printBatchSummary($results, $isDryRun, count($repositories), 'merged'); + return $results['failed'] > 0 ? 1 : 0; + } + /** * Run batch-mode commands * + * @deprecated Use patch-many or merge-many instead * @param string $batchCommand Name of command to run in batch mode (patch or merge) * @param array $options * @option $working-directory Working directory to check out repositories @@ -339,6 +522,9 @@ public function batch(string $batchCommand, array $options = [ 'create-mr' => false, ]): int { + // Deprecation warning + $this->io()->warning('The "batch" command is deprecated. Use "patch-many" or "merge-many" instead.'); + $workingDirectory = getcwd(); $configFile = 'repositories.json'; @@ -717,8 +903,8 @@ protected function filterRepositories(array $repositories, array $filters): arra [$type, $value] = explode(':', $filter, 2); $repositories = match ($type) { - 'path' => array_filter($repositories, fn($repo) => fnmatch($value, $repo['path_with_namespace'])), - 'topic' => array_filter($repositories, fn($repo) => in_array($value, $repo['topics'] ?? [])), + 'path' => array_filter($repositories, fn ($repo) => fnmatch($value, $repo['path_with_namespace'])), + 'topic' => array_filter($repositories, fn ($repo) => in_array($value, $repo['topics'] ?? [])), default => $repositories, }; @@ -812,4 +998,77 @@ protected function extractProjectPath(string $url): ?string return null; } + + /** + * Load repositories from config file and apply filters + * + * @param array|string $filters Filter expressions + * @return array|null Repositories array, or null on error + */ + protected function loadRepositories(array|string $filters = []): ?array + { + $configFile = 'repositories.json'; + + if (false === is_file($configFile)) { + $this->io()->error('Can not find file "' . $configFile . '". Run "patchbot discover" first.'); + return null; + } + + $jsonContent = file_get_contents($configFile); + $config = json_decode($jsonContent, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + $this->io()->error('Invalid JSON in ' . $configFile . ': ' . json_last_error_msg()); + return null; + } + + $repositories = $config['repositories'] ?? []; + + if (empty($repositories)) { + $this->io()->warning('No repositories found in ' . $configFile); + return []; + } + + // Apply filters + $filters = is_array($filters) ? $filters : [$filters]; + $filters = array_filter($filters); + if (!empty($filters)) { + $repositories = $this->filterRepositories($repositories, $filters); + if (empty($repositories)) { + $this->io()->warning('No repositories match the filter criteria'); + return []; + } + } + + return $repositories; + } + + /** + * Print batch processing summary + * + * @param array $results Results array with success/skipped/failed counts + * @param bool $isDryRun Whether this was a dry run + * @param int $totalCount Total number of repositories + * @param string $action Action verb (patched/merged) + */ + protected function printBatchSummary(array $results, bool $isDryRun, int $totalCount, string $action): void + { + $this->io()->newLine(); + if ($isDryRun) { + $this->io()->text($totalCount . ' repositories would be processed'); + } else { + $total = $results['success'] + $results['skipped'] + $results['failed']; + $this->io()->section('Summary'); + $this->io()->text($total . ' repositories processed'); + if ($results['success'] > 0) { + $this->io()->text(' ✓ ' . $results['success'] . ' ' . $action); + } + if ($results['skipped'] > 0) { + $this->io()->text(' - ' . $results['skipped'] . ' skipped (no changes)'); + } + if ($results['failed'] > 0) { + $this->io()->text(' ✗ ' . $results['failed'] . ' failed'); + } + } + } } diff --git a/tests/unit/PatchbotCommandsTest.php b/tests/unit/PatchbotCommandsTest.php index 5cdf37e..5301f67 100644 --- a/tests/unit/PatchbotCommandsTest.php +++ b/tests/unit/PatchbotCommandsTest.php @@ -46,7 +46,11 @@ public static function setUpBeforeClass(): void public static function tearDownAfterClass(): void { - //rmdir(self::$bareRepository); + // Ensure Robo container is available for temp directory cleanup + if (!\Robo\Robo::hasContainer()) { + $container = \Robo\Robo::createContainer(); + \Robo\Robo::setContainer($container); + } } /** @@ -70,12 +74,12 @@ public static function generalCommandsProvider(): array 'list', ], [ - '--repository-url', + '', 0, 'patch', '--help' ], [ - 'Missing arguments', + 'Missing repository URL', 1, 'patch', ], @@ -90,7 +94,7 @@ public static function generalCommandsProvider(): array 'create', ], [ - 'Not enough arguments ', + 'Not enough arguments', 1, 'batch', ] @@ -120,17 +124,17 @@ public static function patchCommandWarningProvider(): array [ 'Cloning failed', 1, - 'patch', '--repository-url=file:///not-existing-repository-' . microtime() + 'patch', 'template', 'file:///not-existing-repository-' . microtime() ], [ 'Branch creation failed', 1, - 'patch', '--source-branch=branch-does-not-exist', '--repository-url=file://' . self::$bareRepository + 'patch', 'template', 'file://' . self::$bareRepository, '--source-branch=branch-does-not-exist' ], [ 'nothing to change', 0, - 'patch', '--repository-url=file://' . self::$bareRepository + 'patch', 'template', 'file://' . self::$bareRepository ] ]; } diff --git a/tests/unit/PatchbotTest.php b/tests/unit/PatchbotTest.php index 3cd5a81..5fd3fc9 100644 --- a/tests/unit/PatchbotTest.php +++ b/tests/unit/PatchbotTest.php @@ -5,21 +5,43 @@ class PatchbotTest extends TestCase { - protected function setUp(): void + public function testRoboFileClassExists(): void { - \Robo\Robo::unsetContainer(); - $container = \Robo\Robo::createContainer(); - \Robo\Robo::setContainer($container); + $this->assertTrue(class_exists(RoboFile::class)); } - public function testPatchRequiresRepositoryUrl(): void + public function testRoboFileHasPatchMethod(): void { - $patchbot = new RoboFile(); - $expectedOutput = 1; // exit code 1 = error + $this->assertTrue(method_exists(RoboFile::class, 'patch')); + } - $parameters = []; - $this->assertSame($expectedOutput, $patchbot->patch($parameters)); - $parameters = ['repository-url' => '']; - $this->assertSame($expectedOutput, $patchbot->patch($parameters)); + public function testRoboFileHasMergeMethod(): void + { + $this->assertTrue(method_exists(RoboFile::class, 'merge')); + } + + public function testRoboFileHasCreateMethod(): void + { + $this->assertTrue(method_exists(RoboFile::class, 'create')); + } + + public function testRoboFileHasBatchMethod(): void + { + $this->assertTrue(method_exists(RoboFile::class, 'batch')); + } + + public function testRoboFileHasDiscoverMethod(): void + { + $this->assertTrue(method_exists(RoboFile::class, 'discover')); + } + + public function testRoboFileHasPatchManyMethod(): void + { + $this->assertTrue(method_exists(RoboFile::class, 'patchMany')); + } + + public function testRoboFileHasMergeManyMethod(): void + { + $this->assertTrue(method_exists(RoboFile::class, 'mergeMany')); } } diff --git a/tests/unit/src/CommandTesterTrait.php b/tests/unit/src/CommandTesterTrait.php index aea497b..52474da 100644 --- a/tests/unit/src/CommandTesterTrait.php +++ b/tests/unit/src/CommandTesterTrait.php @@ -35,6 +35,9 @@ protected function execute(array $argv, array $commandClass): array $statusCode = $runner->execute($argv, $this->appName, $this->appVersion, $output); \Robo\Robo::unsetContainer(); + // Restore default error handler to avoid PHPUnit "risky" warnings + restore_error_handler(); + // Return output and status code return [trim($output->fetch()), $statusCode]; } From e6adebddb7e43dbea68618c875571a77a9d50d17 Mon Sep 17 00:00:00 2001 From: Dan Untenzu Date: Sun, 15 Feb 2026 21:38:49 +0100 Subject: [PATCH 11/24] =?UTF-8?q?[FEATURE]=20=F0=9F=93=9D=20Rewrite=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce clutter in README and move the details to a dedicated walktrough guide. Make the reason why more clear and provide better examples. --- README.md | 433 +++++++++++++++----------------------------- docs/walkthrough.md | 213 ++++++++++++++++++++++ 2 files changed, 363 insertions(+), 283 deletions(-) create mode 100644 docs/walkthrough.md diff --git a/README.md b/README.md index 77c3ae8..582df10 100644 --- a/README.md +++ b/README.md @@ -8,393 +8,261 @@ [![License](https://img.shields.io/badge/license-gpl--2.0--or--later-blue.svg?style=flat-square)](https://spdx.org/licenses/GPL-2.0-or-later.html) [![Contribution](https://img.shields.io/badge/contributions_welcome-%F0%9F%94%B0-brightgreen.svg?labelColor=brightgreen&style=flat-square)](https://gitlab.com/pixelbrackets/patchbot/-/blob/master/CONTRIBUTING.md) -A tool to automate the distribution of patches to various Git repositories. +Automate changes across multiple Git repositories — create branches, apply +patches, push, and open merge requests in batch. ![Screenshot](docs/screenshot.png) -_⭐ You like this package? Please star it or send a tweet. ⭐_ +## Why Patchbot? -## Vision +You need to apply the same change to 20 repositories. Manually that means: +clone, branch, edit, commit, push, create merge request — times 20. -This project provides a tool to distribute changes to a several Git repositories -with as little manual work as possible. +Maybe you need to rename a file in every repo, replace a deprecated URL in +all docs, add a package to all projects or run a migration script. Nothing a plain +[Git patch file](https://git-scm.com/docs/git-format-patch/2.7.6) could solve, +but something that can be automated with a script. -The need for this came up when I had to apply the same manual changes to many -of my repositories: +Patchbot does the repetitive parts for you. Write the change once as a patch +script, point Patchbot at your repositories, and let it create feature branches, +commit, push, and optionally open merge requests — across all of them. -- Rename files having a certain name pattern, remove a line of code - only if a condition matches, replace a link in all documents, execute another - tool which then changes files and so on. Nothing a plain - [Git patch file](https://git-scm.com/docs/git-format-patch/2.7.6) could solve, - but something that could be automated nevertheless with a migration script. -- Create a feature branch, commit all changes with a good commit message, - push the branch, wait for tests to turn green, open a pull request. -- Repeat the same steps in many other repositories. +Saving time, preventing careless mistakes and avoiding monotonous work. -The idea is to do the changes only once and move the repetitions to a tool. -Saving time, preventing careless mistakes and shun monotonous work. - -📝 Take a look at this +Take a look at this [blog post with real world examples](https://pixelbrackets.de/notes/distribute-patches-to-many-git-repositories-with-patchbot) -and how Patchbot helps to reduce technical debt across your own Git -repositories. - -See [»Usage«](#usage) for example commands. - -The package follows the KISS principle. - -## Requirements - -- PHP -- Git - -## Installation - -💡 Use the -[skeleton package](https://packagist.org/packages/pixelbrackets/patchbot-skeleton/) -to create an example project right away. - -- `composer create-project pixelbrackets/patchbot-skeleton` - -Packagist Entry to install Patchbot only -https://packagist.org/packages/pixelbrackets/patchbot/ +to see how Patchbot helps reduce technical debt across Git repositories. -- `composer require pixelbrackets/patchbot` +## Key Features -### Access rights +- Batch git operations - Apply the same patch to 1, 20, or 300 repositories +- Auto-discovery - Scan a GitLab namespace to find all repositories automatically +- Merge request creation - Optionally create GitLab MRs after pushing (`--create-mr`) +- Repository filtering - Target specific repos by path pattern or GitLab topic (`--filter`) +- Dry-run mode - Preview what would happen without making changes (`--dry-run`) +- Custom git user - Push as a bot user instead of your personal account +- CI-ready - Run Patchbot as a scheduled GitLab CI pipeline -🔑 *The user running Patchbot needs to have access to the target repository.* +## Quick Start -Make sure that the user running Patchbot is allowed to clone and push to -all target repositories. - -Patchbot allows all protocols for connections to remotes which are supported -by Git natively: -[FILE, HTTP/HTTPS, SSH](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols) - -The recommended protocol is SSH. +```bash +# Create a new patch project using the skeleton +composer create-project pixelbrackets/patchbot-skeleton my-patches +cd my-patches -#### HTTPS Credentials +# Create a new patch +./vendor/bin/patchbot create "My first patch" -Git by default does not store any credentials. So *every connection* to a -repository by HTTPS will prompt for a username and password. +# Edit the patch script and commit message +# patches/my-first-patch/patch.php +# patches/my-first-patch/commit-message.txt -To avoid these password prompts when using HTTPS URIs with Patchbot -you have two options: +# Apply the patch to a repository +./vendor/bin/patchbot patch my-first-patch git@gitlab.com:user/repo.git +``` -- Allow Git to store credentials in memory for some time - - The password prompt then pops up once only for each host - - Example command to keep the credentials in memory for 15 minutes: - ```bash - git config --global credential.helper 'cache --timeout=900' - ``` -- Force Git to use SSH protocol checkouts instead of HTTP/HTTPS - - Has to be configured for each host - - Example commands to set up the replacements for GitHub, GitLab & BitBucket - ```bash - git config --global url."ssh://git@github.com/".insteadOf "https://github.com/" - git config --global url."ssh://git@gitlab.com/".insteadOf "https://gitlab.com/" - git config --global url."ssh://git@bitbucket.org/".insteadOf "https://bitbucket.org/" - ``` +## Requirements -### Discover repositories automatically +- PHP +- Git -Instead of adding all repository URLs manually to a storage file you may -let Patchbot discover them automatically from a GitLab namespace. +## Installation -Usage: +Use the +[skeleton package](https://packagist.org/packages/pixelbrackets/patchbot-skeleton/) +to create a patch project right away: ```bash -./vendor/bin/patchbot discover # uses GITLAB_NAMESPACE and GITLAB_URL from .env -./vendor/bin/patchbot discover --gitlab-namespace=myusername # set namespace to crawl -./vendor/bin/patchbot discover --force # overwrite existing storage file +composer create-project pixelbrackets/patchbot-skeleton my-patches ``` -## Source +Or install Patchbot as a dependency in an existing project: -https://gitlab.com/pixelbrackets/patchbot/ +```bash +composer require pixelbrackets/patchbot +``` -Mirror https://github.com/pixelbrackets/patchbot/ +The user running Patchbot needs clone and push access to the target repositories. +SSH is the recommended protocol. See the +[walkthrough guide](docs/walkthrough.md#access-rights) +for details on configuring access. ## Usage -Patchbot patches a given Git repository. +### Patch structure -This means it will clone the repository, create a feature branch, -run a given PHP patch script, commit the changes with a given commit message -and push the branch to the remote. +Patchbot organizes patches in the `patches/` directory. Each patch directory +contains a PHP script (`patch.php`) and a commit message (`commit-message.txt`): -Patchbot uses a lean file structure to organize patches (see -[skeleton package](https://packagist.org/packages/pixelbrackets/patchbot-skeleton/)). - -The directory `patches` contains a collection of all your “patch directories“. - -Each patch directory always contains at least a PHP script named `patch.php` -and a commit message named `commit-message.txt`. - -Example file structure: ``` -. -|-- patches -| |-- template -| | |-- commit-message.txt -| | `-- patch.php -| `-- yet-another-patch -| |-- commit-message.txt -| `-- patch.php -|-- vendor -| `-- bin -| `-- patchbot -|-- composer.json -`-- README.md +patches/ +|-- template/ +| |-- commit-message.txt +| `-- patch.php +`-- update-changelog/ + |-- commit-message.txt + `-- patch.php ``` -This way a migration script may be created once and applied in a row to -many repositories or ad hoc every time the need arises. - -### Apply patch +The patch script runs in the root directory of the cloned target repository. +You can develop it incrementally by running `php /patch.php` +directly in any project directory. -Pass the name of the patch directory and the Git repository URL to apply a patch: +### Apply a patch ```bash ./vendor/bin/patchbot patch ``` -Example command applying the patch script in directory `template` to -the repository `https://git.example.com/repository`: -```bash -./vendor/bin/patchbot patch template https://git.example.com/repository -``` - -Example command applying the patch script in directory `template` to -the repository `git@git.example.com:user/repository.git`: -```bash -./vendor/bin/patchbot patch template git@git.example.com:user/repository.git -``` - -**Custom options** - -To create the feature branch based on the branch `development` -instead of the default main branch use this command: -```bash -./vendor/bin/patchbot patch template https://git.example.com/repository --source-branch=development -``` - -Patchbot will use a random name for the feature branch. To use a custom name -like `feature-1337-add-license-file` for the feature branch instead run: -```bash -./vendor/bin/patchbot patch template https://git.example.com/repository --branch-name=feature-1337-add-license-file -``` - -It is recommended to let a CI run all tests. That's why Patchbot creates a -feature branch by default. If you want to review complex changes manually before -the commit is created, then use the `halt-before-commit` option: - -```bash -./vendor/bin/patchbot patch template https://git.example.com/repository --halt-before-commit -``` - -To be more verbose add `-v` to each command. Add `-vvv` for debugging. -This will show all steps and commands applied by Patchbot. -The flag `--no-ansi` will remove output formation. - -Use `--dry-run` to preview what would happen without making any changes: +Patchbot clones the repository, creates a feature branch, runs the patch script, +commits the changes, and pushes the branch to the remote. ```bash -./vendor/bin/patchbot patch template https://git.example.com/repository --dry-run -``` +# Apply patch "template" to a repository +./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git -### Merge feature branch +# Preview without making changes +./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git --dry-run -✨️Patchbot intentionally creates a feature branch to apply patches. +# Create a GitLab merge request after pushing +./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git --create-mr -When you reviewed the feature branch and all CI tests are successful then -you can use Patchbot again to merge the feature branch. +# Use a custom source branch (default: main) +./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git --source-branch=development -```bash -./vendor/bin/patchbot merge -``` +# Use a custom feature branch name +./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git --branch-name=feature-1337 -Example command to merge branch `bugfix-add-missing-lock-file` into -branch `main` in repository `https://git.example.com/repository`: -```bash -./vendor/bin/patchbot merge bugfix-add-missing-lock-file main https://git.example.com/repository +# Pause before committing (for manual review) +./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git --halt-before-commit ``` -Use `--dry-run` to preview without executing: -```bash -./vendor/bin/patchbot merge bugfix-add-missing-lock-file main https://git.example.com/repository --dry-run -``` +### Batch apply patches -### Add a new patch +Apply a patch to all repositories listed in `repositories.json`: -Example command to create a directory named `add-changelog-file` and -all files needed for the patch (the name is slugified automatically): ```bash -./vendor/bin/patchbot create "Add CHANGELOG file" +./vendor/bin/patchbot patch-many ``` -Or copy the example folder `template` manually instead and rename it as desired. - -Now replace the patch code in `patch.php` and the commit message -in `commit-message.txt`. - -🛡 ️Patchbot runs the patch script isolated, as a consequence it is possible -to run the script without Patchbot. - -💡 Tip: Switch to an existing projekt repository, run -`php /patch.php` and develop the patch incrementally. -When development is finished, then commit it and use Patchbot to distribute -the patch to all other repositories. - -The patch code will be executed in the root directory scope of the target -repository, keep this in mind for file searches. - -### Share a patch - -The patches created in the patch directory are probably very specific to your -organisation or domain. So the best way to share the patches in your -organisation is to share the patch project as Git repository. - -However, since a motivation for this tool was to reuse migration scripts, -you could share general-purpose scripts with others though. - -One possible way is to create a GitHub Gist for a single patch. - -Example command using the CLI gem [gist](https://github.com/defunkt/gist) -to upload the `template` patch: ```bash -cd patches/template/ -gist -d "Patchbot Patch »template« - Just a template without changes" patch.php commit-message.txt -``` - -🔎 Search for [Gists with Patchbot tags](https://gist.github.com/search?l=PHP&q=%23patchbot). - -### Import a shared patch +# Apply to all repositories +./vendor/bin/patchbot patch-many update-changelog -Copy & paste all files manually to import an existing patch from another source. +# Filter by path pattern +./vendor/bin/patchbot patch-many update-changelog --filter="path:my-org/*" -If the source is a Git repository then a Git clone command is sufficient. +# Filter by GitLab topic +./vendor/bin/patchbot patch-many update-changelog --filter="topic:php" -Example command importing the -[Gist `https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745`](https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745) -as patch `add-editorconfig`: -```bash -git clone --depth=1 https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745 patches/add-editorconfig/ -rm -r patches/add-editorconfig/.git +# Combine filters, create MRs, and preview first +./vendor/bin/patchbot patch-many update-changelog --filter="topic:php" --create-mr --dry-run ``` -### Batch processing +After batch processing completes, a summary shows how many repositories were +patched, skipped, or failed. -To apply a patch to 1 or 20 repositories you may run the Patchbot script -repeatedly with different URLs. To do this with 300 repos you may want -to use the batch processing mode instead. +### Discover repositories -This mode will trigger the `patch` or `merge` command for a list of -repositories. The list is stored in a JSON file named `repositories.json`. - -After batch processing completes, a summary shows the results. - -#### Discover repositories - -Use the `discover` command to automatically fetch all repositories from a -GitLab namespace (group or user): +Instead of adding repository URLs manually, let Patchbot discover them from +a GitLab namespace (group or user): ```bash # Set up your GitLab token in .env cp .env.example .env # Edit .env with your GITLAB_TOKEN and GITLAB_NAMESPACE -# Discover repositories (uses GITLAB_NAMESPACE from .env) +# Discover repositories ./vendor/bin/patchbot discover -# Or specify namespace directly +# Or specify the namespace directly ./vendor/bin/patchbot discover --gitlab-namespace=mygroup + +# Overwrite existing repositories.json +./vendor/bin/patchbot discover --force ``` This creates a `repositories.json` file with all discovered repositories, including their clone URLs and default branches. -#### Apply patches +### Merge feature branches -Apply a patch to all repositories in `repositories.json`: - -```bash -./vendor/bin/patchbot patch-many -``` +Patchbot creates feature branches by design, so changes can be reviewed and +tested by CI before merging. Use the merge commands when ready: -Example: ```bash -./vendor/bin/patchbot patch-many update-changelog -``` - -#### Merge feature branches - -Merge a feature branch into the default branch for all repositories: +# Merge a branch into a target branch for one repository +./vendor/bin/patchbot merge -```bash +# Merge a branch into the default branch for all repositories ./vendor/bin/patchbot merge-many ``` -Example: ```bash +# Examples +./vendor/bin/patchbot merge feature-add-license main git@gitlab.com:user/repo.git ./vendor/bin/patchbot merge-many feature-add-phpcs-rules +./vendor/bin/patchbot merge-many feature-add-phpcs-rules --dry-run ``` -#### Filter repositories - -Use `--filter` to process only matching repositories: +### Create a new patch ```bash -# Filter by path pattern (glob syntax) -./vendor/bin/patchbot patch-many update-changelog --filter="path:my-org/*" -./vendor/bin/patchbot patch-many update-changelog --filter="path:*/typo3-*" - -# Filter by GitLab topic -./vendor/bin/patchbot patch-many update-changelog --filter="topic:php" - -# Combine multiple filters (AND logic) -./vendor/bin/patchbot patch-many update-changelog --filter="path:my-org/*" --filter="topic:php" +./vendor/bin/patchbot create "Add CHANGELOG file" ``` -#### Create merge requests +This generates a patch directory with the required files. Edit `patch.php` +with your change logic and `commit-message.txt` with the commit message. +See the [walkthrough guide](docs/walkthrough.md#writing-patches) for tips +on developing and testing patches. -Use `--create-mr` to automatically create GitLab merge requests after pushing: +### Command and options reference -```bash -./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git --create-mr -./vendor/bin/patchbot patch-many update-changelog --create-mr -``` +**Commands** -Requires `GITLAB_TOKEN` environment variable. +| Command | Description | +|---------|-------------| +| `patch` | Apply changes, commit, push | +| `patch-many` | Apply a patch to all repositories | +| `merge` | Merge one branch into another, push | +| `merge-many` | Merge a branch into all repositories | +| `create` | Create a new patch | +| `discover` | Discover repositories from a GitLab namespace | -#### Dry run +**Options** -Use `--dry-run` to preview what would happen without making any changes: - -```bash -./vendor/bin/patchbot patch-many update-changelog --dry-run -./vendor/bin/patchbot merge-many feature-add-phpcs-rules --dry-run -``` +| Option | Available in | Description | +|--------|--------------|-------------| +| `--dry-run` | patch, merge, patch-many, merge-many | Preview without making changes | +| `--create-mr` | patch, patch-many | Create GitLab merge request after pushing | +| `--filter` | patch-many, merge-many | Filter repositories by `path:glob` or `topic:name` | +| `--source-branch` | patch | Base branch for the feature branch (default: `main`) | +| `--branch-name` | patch, patch-many | Custom name for the feature branch | +| `--halt-before-commit` | patch, patch-many | Pause before committing for manual review | +| `--force` | discover | Overwrite existing `repositories.json` | +| `-v` / `-vvv` | all | Increase output verbosity | -#### Custom Git user +### Custom git user -By default, commits use your system Git config. To use a different identity -(e.g., a bot user), set these environment variables: +By default, commits use your system Git configuration. To push as a bot user, +set these environment variables: ```bash -# In .env or shell BOT_GIT_NAME="Patchbot" BOT_GIT_EMAIL="patchbot@example.com" ``` -#### GitLab CI +### GitLab CI + +Run Patchbot as a scheduled GitLab CI pipeline instead of locally. +Copy `.gitlab-ci.example.yml` to your config repository and set the CI/CD +variables `GITLAB_TOKEN` and `GITLAB_NAMESPACE`. -Run Patchbot via GitLab CI instead of locally: +## Source -1. Copy `.gitlab-ci.example.yml` to `.gitlab-ci.yml` in your config repository -2. Set CI/CD variables: `GITLAB_TOKEN`, `GITLAB_NAMESPACE` -3. Trigger manually via GitLab UI (CI/CD > Pipelines > Run pipeline) +https://gitlab.com/pixelbrackets/patchbot/ + +Mirror https://github.com/pixelbrackets/patchbot/ ## License @@ -404,8 +272,7 @@ The GNU General Public License can be found at http://www.gnu.org/copyleft/gpl.h ## Author -Dan Untenzu ( / [@pixelbrackets](https://pixelbrackets.de)) - +Dan Kleine ( / [@pixelbrackets](https://pixelbrackets.de)) See [CHANGELOG.md](./CHANGELOG.md) diff --git a/docs/walkthrough.md b/docs/walkthrough.md new file mode 100644 index 0000000..53cad89 --- /dev/null +++ b/docs/walkthrough.md @@ -0,0 +1,213 @@ +# Patchbot Walkthrough + +This guide walks you through a typical Patchbot workflow — from setting up +access, writing your first patch, applying it to repositories, and running +Patchbot in CI. For a quick overview see the [README](../README.md). + +## 1. Set Up Access + +The user running Patchbot needs to be allowed to clone and push to all +target repositories. + +Patchbot supports all protocols that Git supports natively: +[FILE, HTTP/HTTPS, SSH](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols). +The recommended protocol is SSH. + +### HTTPS Credentials + +Git by default does not store any credentials. So *every connection* to a +repository by HTTPS will prompt for a username and password. + +To avoid these password prompts when using HTTPS URIs with Patchbot +you have two options: + +- Allow Git to store credentials in memory for some time + - The password prompt then pops up once only for each host + - Example command to keep the credentials in memory for 15 minutes: + ```bash + git config --global credential.helper 'cache --timeout=900' + ``` +- Force Git to use SSH protocol checkouts instead of HTTP/HTTPS + - Has to be configured for each host + - Example commands to set up the replacements for GitHub, GitLab & BitBucket: + ```bash + git config --global url."ssh://git@github.com/".insteadOf "https://github.com/" + git config --global url."ssh://git@gitlab.com/".insteadOf "https://gitlab.com/" + git config --global url."ssh://git@bitbucket.org/".insteadOf "https://bitbucket.org/" + ``` + +## 2. Create a Patch Project + +The easiest way to get started is the +[skeleton package](https://packagist.org/packages/pixelbrackets/patchbot-skeleton/): + +```bash +composer create-project pixelbrackets/patchbot-skeleton my-patches +cd my-patches +``` + +This gives you a ready-made project structure: + +``` +my-patches/ +|-- composer.json +|-- patches/ +| `-- template/ +| |-- commit-message.txt +| `-- patch.php +`-- .editorconfig +``` + +## 3. Write a Patch + +Create a new patch directory: + +```bash +./vendor/bin/patchbot create "Add CHANGELOG file" +``` + +This generates `patches/add-changelog-file/` with two files to edit: + +- `patch.php` — The PHP script that makes the actual changes +- `commit-message.txt` — The commit message used when applying the patch + +Patchbot runs the patch script isolated in the root directory of the cloned +target repository. This means you can develop the script incrementally by +running it directly in any project directory: + +```bash +cd /path/to/some-project +php /path/to/my-patches/patches/add-changelog-file/patch.php +``` + +Check the result, adjust the script, repeat. When the patch works as +expected, use Patchbot to distribute it. + +## 4. Apply a Patch + +Apply the patch to a single repository: + +```bash +./vendor/bin/patchbot patch add-changelog-file git@gitlab.com:user/repo.git +``` + +Patchbot will clone the repository, create a feature branch, run the patch +script, commit the changes, and push the branch to the remote. + +Use `--dry-run` to preview what would happen without making any changes: + +```bash +./vendor/bin/patchbot patch add-changelog-file git@gitlab.com:user/repo.git --dry-run +``` + +## 5. Apply to Many Repositories + +For batch operations, first create a `repositories.json` file. You can either +write it manually or let Patchbot discover repositories from GitLab: + +```bash +# Set up your GitLab token in .env +cp .env.example .env +# Edit .env with your GITLAB_TOKEN and GITLAB_NAMESPACE + +# Discover all repositories in a namespace +./vendor/bin/patchbot discover --gitlab-namespace=mygroup +``` + +Then apply the patch to all discovered repositories: + +```bash +./vendor/bin/patchbot patch-many add-changelog-file +``` + +Use filters to target specific repositories: + +```bash +# Only repositories matching a path pattern +./vendor/bin/patchbot patch-many add-changelog-file --filter="path:mygroup/typo3-*" + +# Only repositories with a specific GitLab topic +./vendor/bin/patchbot patch-many add-changelog-file --filter="topic:php" +``` + +After batch processing completes, a summary shows how many repositories were +patched, skipped, or failed. + +## 6. Review and Merge + +Patchbot always creates a feature branch rather than committing directly to +existing branches. This way you can review the changes and let CI run tests +before merging. + +To create a GitLab merge request automatically, add `--create-mr`: + +```bash +./vendor/bin/patchbot patch-many add-changelog-file --create-mr +``` + +When ready to merge, use the merge commands: + +```bash +# Merge a single repository +./vendor/bin/patchbot merge feature-add-changelog-file main git@gitlab.com:user/repo.git + +# Merge across all repositories +./vendor/bin/patchbot merge-many feature-add-changelog-file +``` + +## 7. Share Patches + +The patches in your project are probably specific to your organisation or +domain. The best way to share them is to share the entire patch project as +a Git repository. + +To share a single general-purpose patch, you can create a GitHub Gist. + +Example command using the CLI tool [gist](https://github.com/defunkt/gist) +to upload the `template` patch: + +```bash +cd patches/template/ +gist -d "Patchbot Patch template - Just a template without changes" patch.php commit-message.txt +``` + +### Importing a Shared Patch + +Copy & paste all files manually to import an existing patch from another source. + +If the source is a Git repository then a Git clone command is sufficient. + +Example command importing a Gist as patch `add-editorconfig`: + +```bash +git clone --depth=1 https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745 patches/add-editorconfig/ +rm -r patches/add-editorconfig/.git +``` + +## Running in GitLab CI + +Instead of running Patchbot locally, you can set it up as a scheduled +GitLab CI pipeline: + +1. Copy `.gitlab-ci.example.yml` to `.gitlab-ci.yml` in your config repository +2. Set CI/CD variables: `GITLAB_TOKEN`, `GITLAB_NAMESPACE` +3. Trigger manually via GitLab UI (CI/CD > Pipelines > Run pipeline) + +See the [example configuration](../.gitlab-ci.example.yml) for details. + +## Tips + +### Custom Git User + +By default, commits use your system Git configuration. To push as a bot +user, set these environment variables (in `.env` or your shell): + +```bash +BOT_GIT_NAME="Patchbot" +BOT_GIT_EMAIL="patchbot@example.com" +``` + +### Verbosity and Debugging + +Add `-v` to any command for more output, or `-vvv` for full debugging. +This will show all steps and Git commands applied by Patchbot. From ee8e54c571e05d134f4314dc9f805c831ef3835b Mon Sep 17 00:00:00 2001 From: Dan Untenzu Date: Sun, 15 Feb 2026 21:49:20 +0100 Subject: [PATCH 12/24] =?UTF-8?q?[TASK]=20=F0=9F=94=96=20Set=20version=203?= =?UTF-8?q?.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FEATURE Rewrite docs * FEATURE Easen command arguments * FEATURE Enable setting git user & mail * FEATURE Add MR creation * FEATURE Add filter to batch mode * FEATURE Use JSON as storage file now * FEATURE Add a `dry-run` option to preview changes without making them * FEATURE Add autodiscovery for GitLab repos * FEATURE Upgrade PHP version --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b80c84..c60eb36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +2026-02-15 Dan Untenzu + + * 3.0.0 + * FEATURE Rewrite docs → Remove clutter and be more concise in reason why + * FEATURE Easen command arguments + * Before: `patchbot patch --patch-name=template --repository-url=https://git.example.com/repository` + After: `patchbot patch template https://git.example.com/repository` + * FEATURE Enable setting git user & mail → Don't use global config only + * FEATURE Add MR creation → Option to create a GitLab merge request after pushing the branch + * FEATURE Add filter to batch mode → Filter repositories by path or topic in batch mode + * FEATURE Use JSON as storage file now + * FEATURE Add a `dry-run` option to preview changes without making them + * FEATURE Add autodiscovery for GitLab repos + * FEATURE Upgrade PHP version + 2022-03-14 Dan Untenzu * 2.0.0 From 5b63fa27204b122f5ebf59415a7183c689fa7ba2 Mon Sep 17 00:00:00 2001 From: Dan Kleine Date: Mon, 16 Feb 2026 11:07:18 +0100 Subject: [PATCH 13/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Support=20multi-l?= =?UTF-8?q?anguage=20patches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce patch providers that detect and execute patches by file extension: patch.php (PHP), patch.sh (Shell), patch.diff (Git diff), and patch.py (Python). This allows users to build much more flexible patch scripts now. --- .gitlab-ci.yml | 2 +- README.md | 24 +- docs/walkthrough.md | 20 +- src/PatchProvider/GitPatchProvider.php | 16 ++ src/PatchProvider/PatchProviderInterface.php | 23 ++ src/PatchProvider/PatchProviderResolver.php | 73 ++++++ src/PatchProvider/PhpProvider.php | 16 ++ src/PatchProvider/PythonProvider.php | 16 ++ src/PatchProvider/ShellProvider.php | 16 ++ src/RoboFile.php | 11 +- tests/unit/PatchProviderTest.php | 259 +++++++++++++++++++ tests/unit/PatchbotCommandsTest.php | 7 +- tests/unit/fixtures/commit-message.txt | 4 + tests/unit/fixtures/diff-patch/patch.diff | 7 + tests/unit/fixtures/php-patch/patch.php | 4 + tests/unit/fixtures/python-patch/patch.py | 5 + tests/unit/fixtures/shell-patch/patch.sh | 4 + 17 files changed, 497 insertions(+), 10 deletions(-) create mode 100644 src/PatchProvider/GitPatchProvider.php create mode 100644 src/PatchProvider/PatchProviderInterface.php create mode 100644 src/PatchProvider/PatchProviderResolver.php create mode 100644 src/PatchProvider/PhpProvider.php create mode 100644 src/PatchProvider/PythonProvider.php create mode 100644 src/PatchProvider/ShellProvider.php create mode 100644 tests/unit/PatchProviderTest.php create mode 100644 tests/unit/fixtures/commit-message.txt create mode 100644 tests/unit/fixtures/diff-patch/patch.diff create mode 100644 tests/unit/fixtures/php-patch/patch.php create mode 100644 tests/unit/fixtures/python-patch/patch.py create mode 100644 tests/unit/fixtures/shell-patch/patch.sh diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index db9c476..ae4c9b7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,7 +15,7 @@ stages: - test before_script: - - apk add --no-cache curl git unzip + - apk add --no-cache curl git unzip bash python3 - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - composer install diff --git a/README.md b/README.md index 582df10..b620fec 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ Patchbot does the repetitive parts for you. Write the change once as a patch script, point Patchbot at your repositories, and let it create feature branches, commit, push, and optionally open merge requests — across all of them. +Patchbot runs centrally on your machine or CI server and pushes changes out to +repositories. It is not a service that repositories pull from or run on their own. + Saving time, preventing careless mistakes and avoiding monotonous work. Take a look at this @@ -41,6 +44,7 @@ to see how Patchbot helps reduce technical debt across Git repositories. - Repository filtering - Target specific repos by path pattern or GitLab topic (`--filter`) - Dry-run mode - Preview what would happen without making changes (`--dry-run`) - Custom git user - Push as a bot user instead of your personal account +- Multi-language patches - Write patch scripts in PHP, Shell, Python, or as Git diffs - CI-ready - Run Patchbot as a scheduled GitLab CI pipeline ## Quick Start @@ -59,6 +63,8 @@ cd my-patches # Apply the patch to a repository ./vendor/bin/patchbot patch my-first-patch git@gitlab.com:user/repo.git +# Or apply to all repositories in repositories.json +./vendor/bin/patchbot patch-many my-first-patch --dry-run ``` ## Requirements @@ -92,7 +98,7 @@ for details on configuring access. ### Patch structure Patchbot organizes patches in the `patches/` directory. Each patch directory -contains a PHP script (`patch.php`) and a commit message (`commit-message.txt`): +contains a patch file and a commit message (`commit-message.txt`): ``` patches/ @@ -101,12 +107,22 @@ patches/ | `-- patch.php `-- update-changelog/ |-- commit-message.txt - `-- patch.php + `-- patch.sh ``` +The patch file type is detected automatically by filename. Each patch directory +must contain exactly one of the following: + +| File | Language | Execution | +|------|----------|-----------| +| `patch.php` | PHP | `php patch.php` | +| `patch.sh` | Shell | `bash patch.sh` | +| `patch.diff` | Git diff | `git apply patch.diff` | +| `patch.py` | Python | `python3 patch.py` | + The patch script runs in the root directory of the cloned target repository. -You can develop it incrementally by running `php /patch.php` -directly in any project directory. +You can develop it incrementally by running it directly in any project directory, +for example `php /patch.php` or `bash /patch.sh`. ### Apply a patch diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 53cad89..0160aee 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -68,9 +68,25 @@ Create a new patch directory: This generates `patches/add-changelog-file/` with two files to edit: -- `patch.php` — The PHP script that makes the actual changes +- `patch.php` — The script that makes the actual changes - `commit-message.txt` — The commit message used when applying the patch +#### Supported patch file types + +Patchbot detects the patch type by filename. Replace the generated `patch.php` +with any of the supported types: + +| File | Language | Execution | +|------|----------|-----------| +| `patch.php` | PHP | `php patch.php` | +| `patch.sh` | Shell | `bash patch.sh` | +| `patch.diff` | Git diff | `git apply patch.diff` | +| `patch.py` | Python | `python3 patch.py` | + +Each patch directory must contain exactly one patch file. Using multiple patch +files in the same directory (e.g. both `patch.php` and `patch.sh`) is not +allowed and will result in an error. + Patchbot runs the patch script isolated in the root directory of the cloned target repository. This means you can develop the script incrementally by running it directly in any project directory: @@ -78,6 +94,8 @@ running it directly in any project directory: ```bash cd /path/to/some-project php /path/to/my-patches/patches/add-changelog-file/patch.php +# or +bash /path/to/my-patches/patches/add-changelog-file/patch.sh ``` Check the result, adjust the script, repeat. When the patch works as diff --git a/src/PatchProvider/GitPatchProvider.php b/src/PatchProvider/GitPatchProvider.php new file mode 100644 index 0000000..83b1e7e --- /dev/null +++ b/src/PatchProvider/GitPatchProvider.php @@ -0,0 +1,16 @@ +&1'); + } +} diff --git a/src/PatchProvider/PatchProviderInterface.php b/src/PatchProvider/PatchProviderInterface.php new file mode 100644 index 0000000..69157a3 --- /dev/null +++ b/src/PatchProvider/PatchProviderInterface.php @@ -0,0 +1,23 @@ +providers = [ + new PhpProvider(), + new ShellProvider(), + new GitPatchProvider(), + new PythonProvider(), + ]; + } + + /** + * Resolve the patch provider for the given patch directory + * + * @param string $patchDir Path to the patch directory + * @return PatchProviderInterface + * @throws \RuntimeException If no provider matches or multiple providers match + */ + public function resolve(string $patchDir): PatchProviderInterface + { + $matches = []; + $matchedFiles = []; + + foreach ($this->providers as $provider) { + if ($provider->supports($patchDir)) { + $matches[] = $provider; + $matchedFiles[] = $this->getPatchFileName($provider); + } + } + + if (count($matches) === 0) { + throw new \RuntimeException( + 'No patch file found in ' . $patchDir + . ' (supported: patch.php, patch.sh, patch.diff, patch.py)' + ); + } + + if (count($matches) > 1) { + throw new \RuntimeException( + 'Multiple patch files found in ' . $patchDir + . ': ' . implode(', ', $matchedFiles) + . ' - only one patch file per directory is allowed' + ); + } + + return $matches[0]; + } + + /** + * Get the patch file name for a provider + * + * @param PatchProviderInterface $provider + * @return string + */ + protected function getPatchFileName(PatchProviderInterface $provider): string + { + return match (true) { + $provider instanceof PhpProvider => 'patch.php', + $provider instanceof ShellProvider => 'patch.sh', + $provider instanceof GitPatchProvider => 'patch.diff', + $provider instanceof PythonProvider => 'patch.py', + default => '(unknown)', + }; + } +} diff --git a/src/PatchProvider/PhpProvider.php b/src/PatchProvider/PhpProvider.php new file mode 100644 index 0000000..b262800 --- /dev/null +++ b/src/PatchProvider/PhpProvider.php @@ -0,0 +1,16 @@ +io()->text('[DRY-RUN] Would clone repository and create branch'); - $this->io()->text('[DRY-RUN] Would run patch script: ' . $options['patch-source-directory'] . $options['patch-name'] . '/patch.php'); + $this->io()->text('[DRY-RUN] Would run patch script in: ' . $options['patch-source-directory'] . $options['patch-name'] . '/'); $this->io()->text('[DRY-RUN] Would commit and push changes'); if ($options['create-mr']) { $this->io()->text('[DRY-RUN] Would create merge request'); @@ -718,9 +719,13 @@ protected function runPatch(array $options): bool // Patch! $this->say('Run patch script'); try { - $patchFile = $options['patch-source-directory'] . $options['patch-name'] . '/patch.php'; - $output = shell_exec('php ' . escapeshellcmd($patchFile)); + $patchDir = $options['patch-source-directory'] . $options['patch-name']; + $resolver = new PatchProviderResolver(); + $provider = $resolver->resolve($patchDir); + $output = $provider->execute($patchDir, $currentDirectory); $this->say($output); + } catch (\RuntimeException $e) { + throw new TaskException($this, $e->getMessage()); } catch (Exception $e) { throw new TaskException($this, 'Patch script execution failed'); } diff --git a/tests/unit/PatchProviderTest.php b/tests/unit/PatchProviderTest.php new file mode 100644 index 0000000..1ca70f4 --- /dev/null +++ b/tests/unit/PatchProviderTest.php @@ -0,0 +1,259 @@ + $content) { + file_put_contents($dir . '/' . $file, $content); + } + return $dir; + } + + // --- PhpProvider --- + + public function testPhpProviderSupportsMatchingDirectory(): void + { + $dir = $this->createPatchDir('php-patch', ['patch.php' => 'assertTrue($provider->supports($dir)); + } + + public function testPhpProviderRejectsNonMatchingDirectory(): void + { + $dir = $this->createPatchDir('shell-only', ['patch.sh' => 'echo ok']); + $provider = new PhpProvider(); + $this->assertFalse($provider->supports($dir)); + } + + public function testPhpProviderExecute(): void + { + $dir = $this->createPatchDir('php-exec', ['patch.php' => 'execute($dir, sys_get_temp_dir()); + $this->assertStringContainsString('hello from php', $output); + } + + // --- ShellProvider --- + + public function testShellProviderSupportsMatchingDirectory(): void + { + $dir = $this->createPatchDir('shell-patch', ['patch.sh' => 'echo ok']); + $provider = new ShellProvider(); + $this->assertTrue($provider->supports($dir)); + } + + public function testShellProviderRejectsNonMatchingDirectory(): void + { + $dir = $this->createPatchDir('php-only', ['patch.php' => 'assertFalse($provider->supports($dir)); + } + + public function testShellProviderExecute(): void + { + $dir = $this->createPatchDir('shell-exec', ['patch.sh' => 'echo "hello from shell"']); + $provider = new ShellProvider(); + $output = $provider->execute($dir, sys_get_temp_dir()); + $this->assertStringContainsString('hello from shell', $output); + } + + // --- GitPatchProvider --- + + public function testGitPatchProviderSupportsMatchingDirectory(): void + { + $dir = $this->createPatchDir('diff-patch', ['patch.diff' => '']); + $provider = new GitPatchProvider(); + $this->assertTrue($provider->supports($dir)); + } + + public function testGitPatchProviderRejectsNonMatchingDirectory(): void + { + $dir = $this->createPatchDir('no-diff', ['patch.php' => 'assertFalse($provider->supports($dir)); + } + + // --- PythonProvider --- + + public function testPythonProviderSupportsMatchingDirectory(): void + { + $dir = $this->createPatchDir('python-patch', ['patch.py' => 'print("ok")']); + $provider = new PythonProvider(); + $this->assertTrue($provider->supports($dir)); + } + + public function testPythonProviderRejectsNonMatchingDirectory(): void + { + $dir = $this->createPatchDir('no-python', ['patch.sh' => 'echo ok']); + $provider = new PythonProvider(); + $this->assertFalse($provider->supports($dir)); + } + + // --- Fixture-based execute tests --- + + public function testPhpProviderExecuteFixture(): void + { + $patchDir = self::FIXTURES_DIR . '/php-patch'; + $workDir = sys_get_temp_dir() . '/patchbot-php-exec-' . uniqid('', true); + mkdir($workDir); + $originalDir = getcwd(); + chdir($workDir); + + $provider = new PhpProvider(); + $provider->execute($patchDir, $workDir); + + $this->assertFileExists($workDir . '/example.txt'); + $this->assertSame('example content', file_get_contents($workDir . '/example.txt')); + + chdir($originalDir); + unlink($workDir . '/example.txt'); + rmdir($workDir); + } + + public function testShellProviderExecuteFixture(): void + { + $patchDir = self::FIXTURES_DIR . '/shell-patch'; + $workDir = sys_get_temp_dir() . '/patchbot-shell-exec-' . uniqid('', true); + mkdir($workDir); + $originalDir = getcwd(); + chdir($workDir); + + $provider = new ShellProvider(); + $provider->execute($patchDir, $workDir); + + $this->assertFileExists($workDir . '/example.txt'); + $this->assertStringContainsString('example content', file_get_contents($workDir . '/example.txt')); + + chdir($originalDir); + unlink($workDir . '/example.txt'); + rmdir($workDir); + } + + public function testPythonProviderExecuteFixture(): void + { + $patchDir = self::FIXTURES_DIR . '/python-patch'; + $workDir = sys_get_temp_dir() . '/patchbot-python-exec-' . uniqid('', true); + mkdir($workDir); + $originalDir = getcwd(); + chdir($workDir); + + $provider = new PythonProvider(); + $provider->execute($patchDir, $workDir); + + $this->assertFileExists($workDir . '/example.txt'); + $this->assertSame('example content', file_get_contents($workDir . '/example.txt')); + + chdir($originalDir); + unlink($workDir . '/example.txt'); + rmdir($workDir); + } + + public function testGitPatchProviderExecuteFixture(): void + { + $patchDir = self::FIXTURES_DIR . '/diff-patch'; + $workDir = sys_get_temp_dir() . '/patchbot-diff-exec-' . uniqid('', true); + mkdir($workDir); + $originalDir = getcwd(); + chdir($workDir); + + // Initialize a git repo (required for git apply) + shell_exec('git init 2>&1'); + + $provider = new GitPatchProvider(); + $provider->execute($patchDir, $workDir); + + $this->assertFileExists($workDir . '/example.txt'); + $this->assertStringContainsString('example content', file_get_contents($workDir . '/example.txt')); + + chdir($originalDir); + shell_exec('rm -rf ' . escapeshellarg($workDir)); + } + + // --- PatchProviderResolver --- + + public function testResolverReturnPhpProvider(): void + { + $dir = $this->createPatchDir('resolve-php', ['patch.php' => 'resolve($dir); + $this->assertInstanceOf(PhpProvider::class, $provider); + } + + public function testResolverReturnShellProvider(): void + { + $dir = $this->createPatchDir('resolve-shell', ['patch.sh' => 'echo ok']); + $resolver = new PatchProviderResolver(); + $provider = $resolver->resolve($dir); + $this->assertInstanceOf(ShellProvider::class, $provider); + } + + public function testResolverReturnGitPatchProvider(): void + { + $dir = $this->createPatchDir('resolve-diff', ['patch.diff' => '']); + $resolver = new PatchProviderResolver(); + $provider = $resolver->resolve($dir); + $this->assertInstanceOf(GitPatchProvider::class, $provider); + } + + public function testResolverReturnPythonProvider(): void + { + $dir = $this->createPatchDir('resolve-python', ['patch.py' => 'print("ok")']); + $resolver = new PatchProviderResolver(); + $provider = $resolver->resolve($dir); + $this->assertInstanceOf(PythonProvider::class, $provider); + } + + public function testResolverThrowsOnNoMatch(): void + { + $dir = $this->createPatchDir('resolve-empty', ['readme.txt' => 'nothing here']); + $resolver = new PatchProviderResolver(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No patch file found'); + $resolver->resolve($dir); + } + + public function testResolverThrowsOnMultipleMatches(): void + { + $dir = $this->createPatchDir('resolve-multi', [ + 'patch.php' => ' 'echo ok', + ]); + $resolver = new PatchProviderResolver(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Multiple patch files found'); + $resolver->resolve($dir); + } +} diff --git a/tests/unit/PatchbotCommandsTest.php b/tests/unit/PatchbotCommandsTest.php index 5301f67..2cdac41 100644 --- a/tests/unit/PatchbotCommandsTest.php +++ b/tests/unit/PatchbotCommandsTest.php @@ -14,6 +14,7 @@ class PatchbotCommandsTest extends TestCase protected array $commandClass; protected static string $bareRepository = ''; + protected static string $projectRoot = ''; /** * Set up a bare local Git repository @@ -24,6 +25,9 @@ class PatchbotCommandsTest extends TestCase */ public static function loadFixtures(): void { + if (empty(self::$projectRoot)) { + self::$projectRoot = dirname(__DIR__, 2); + } if (empty(self::$bareRepository)) { self::$bareRepository = sys_get_temp_dir() . '/' . 'patchbot-source-repository-' . uniqid('', true) . '.git'; $tmpDirectory = sys_get_temp_dir() . '/' . 'patchbot-source-repository-clone-' . uniqid('', true) . '/'; @@ -134,7 +138,8 @@ public static function patchCommandWarningProvider(): array [ 'nothing to change', 0, - 'patch', 'template', 'file://' . self::$bareRepository + 'patch', 'template', 'file://' . self::$bareRepository, + '--patch-source-directory=' . self::$projectRoot . '/patches' ] ]; } diff --git a/tests/unit/fixtures/commit-message.txt b/tests/unit/fixtures/commit-message.txt new file mode 100644 index 0000000..64a8ffa --- /dev/null +++ b/tests/unit/fixtures/commit-message.txt @@ -0,0 +1,4 @@ +Add example file + +This is an example commit message for a patch +that adds a new feature to the project. diff --git a/tests/unit/fixtures/diff-patch/patch.diff b/tests/unit/fixtures/diff-patch/patch.diff new file mode 100644 index 0000000..1ec2b15 --- /dev/null +++ b/tests/unit/fixtures/diff-patch/patch.diff @@ -0,0 +1,7 @@ +diff --git a/example.txt b/example.txt +new file mode 100644 +index 0000000..ddd2787 +--- /dev/null ++++ b/example.txt +@@ -0,0 +1 @@ ++example content diff --git a/tests/unit/fixtures/php-patch/patch.php b/tests/unit/fixtures/php-patch/patch.php new file mode 100644 index 0000000..05d6c9a --- /dev/null +++ b/tests/unit/fixtures/php-patch/patch.php @@ -0,0 +1,4 @@ + example.txt From 9f8fe0dd51320c7d06b02607b38ba744dd2b5f1b Mon Sep 17 00:00:00 2001 From: Dan Kleine Date: Mon, 16 Feb 2026 11:53:32 +0100 Subject: [PATCH 14/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Add=20interactive?= =?UTF-8?q?=20wizard=20to=20create=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an interactive wizard to create new patches. Keeps the CLI options available if wanted. --- .php-cs-fixer.dist.php | 14 +++++ README.md | 29 ++++++++-- docs/walkthrough.md | 27 ++++++--- patches/template/patch.php | 4 -- .../templates}/commit-message.txt | 0 resources/templates/patch.diff | 3 + resources/templates/patch.php | 4 ++ resources/templates/patch.py | 4 ++ resources/templates/patch.sh | 4 ++ src/RoboFile.php | 57 ++++++++++++++++--- tests/unit/PatchbotCommandsTest.php | 14 ++++- 11 files changed, 131 insertions(+), 29 deletions(-) create mode 100644 .php-cs-fixer.dist.php delete mode 100644 patches/template/patch.php rename {patches/template => resources/templates}/commit-message.txt (100%) create mode 100644 resources/templates/patch.diff create mode 100644 resources/templates/patch.php create mode 100644 resources/templates/patch.py create mode 100644 resources/templates/patch.sh diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..57a9029 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,14 @@ +in(__DIR__) + ->exclude(['build', 'var', 'vendor']) +; + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'] + ]) + ->setFinder($finder) +; diff --git a/README.md b/README.md index b620fec..7c0a05a 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,20 @@ for details on configuring access. ## Usage +Real world use cases for Patchbot include: + +- Add a PHP package to all projects (e.g. `composer require vendor/package`) +- Add or update PHP-CS-Fixer rules across all projects +- Introduce an `.editorconfig` file to all repositories +- Replace copyright year strings in files across many repos, even when the location varies +- Rename a vendor or company name after rebranding +- Rename a file due to a new naming convention (e.g. `LICENSE` → `LICENSE.txt`) +- Run database migration scripts across all projects +- Update a subset of packages on a CI schedule +- Run follow-up tasks after package updates (config changes, renamed classes, updated imports) +- Add or update CI configuration files (e.g. `.gitlab-ci.yml`) across all projects +- Remove deprecated files or config keys that are no longer needed + ### Patch structure Patchbot organizes patches in the `patches/` directory. Each patch directory @@ -224,13 +238,17 @@ tested by CI before merging. Use the merge commands when ready: ### Create a new patch ```bash -./vendor/bin/patchbot create "Add CHANGELOG file" +# Interactive wizard +./vendor/bin/patchbot create + +# Set options directly +./vendor/bin/patchbot create "Add CHANGELOG file" --type=php # type may be php, sh, diff, py ``` -This generates a patch directory with the required files. Edit `patch.php` -with your change logic and `commit-message.txt` with the commit message. -See the [walkthrough guide](docs/walkthrough.md#writing-patches) for tips -on developing and testing patches. +This generates a patch directory with the required files. Edit the patch file +and `commit-message.txt` with the commit message and that's it. +See the [walkthrough guide](docs/walkthrough.md#writing-patches) +for tips on developing and testing patches. ### Command and options reference @@ -255,6 +273,7 @@ on developing and testing patches. | `--source-branch` | patch | Base branch for the feature branch (default: `main`) | | `--branch-name` | patch, patch-many | Custom name for the feature branch | | `--halt-before-commit` | patch, patch-many | Pause before committing for manual review | +| `--type` | create | Patch type: `php`, `sh`, `diff`, `py` (default: `php`) | | `--force` | discover | Overwrite existing `repositories.json` | | `-v` / `-vvv` | all | Increase output verbosity | diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 0160aee..2eb1096 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -60,21 +60,32 @@ my-patches/ ## 3. Write a Patch -Create a new patch directory: +Create a new patch directory. The interactive wizard guides you +through the required options. ```bash +./vendor/bin/patchbot create +``` + +Or provide the name and optionally the type directly: + +```bash +# Creates a PHP patch (default) ./vendor/bin/patchbot create "Add CHANGELOG file" + +# Creates a Shell patch +./vendor/bin/patchbot create "Add CHANGELOG file" --type=sh ``` This generates `patches/add-changelog-file/` with two files to edit: -- `patch.php` — The script that makes the actual changes -- `commit-message.txt` — The commit message used when applying the patch +- The patch file (e.g. `patch.php`, `patch.sh`) - The script that makes the actual changes +- `commit-message.txt` - The commit message used when applying the patch #### Supported patch file types -Patchbot detects the patch type by filename. Replace the generated `patch.php` -with any of the supported types: +Patchbot detects the patch type by filename. The `create` command generates +the correct file based on the selected type: | File | Language | Execution | |------|----------|-----------| @@ -182,11 +193,11 @@ a Git repository. To share a single general-purpose patch, you can create a GitHub Gist. Example command using the CLI tool [gist](https://github.com/defunkt/gist) -to upload the `template` patch: +to upload the `add-editorconfig` patch: ```bash -cd patches/template/ -gist -d "Patchbot Patch template - Just a template without changes" patch.php commit-message.txt +cd patches/add-editorconfig/ +gist -d "Patchbot Patch - Add .editorconfig to a repository" patch.php commit-message.txt ``` ### Importing a Shared Patch diff --git a/patches/template/patch.php b/patches/template/patch.php deleted file mode 100644 index 4371db8..0000000 --- a/patches/template/patch.php +++ /dev/null @@ -1,4 +0,0 @@ - patch.diff +# Or use git format-patch diff --git a/resources/templates/patch.php b/resources/templates/patch.php new file mode 100644 index 0000000..8a4b5ca --- /dev/null +++ b/resources/templates/patch.php @@ -0,0 +1,4 @@ + '', + 'type|t' => '', ] ): int { // Support both positional arg and option (backwards compatibility) $patchName = $patchName ?: $options['patch-name']; + // Interactive wizard: prompt for patch name when missing if (empty($patchName)) { - $this->io()->error('Missing arguments'); + if (!$this->io()->input()->isInteractive()) { + $this->io()->error('Missing arguments'); + return 1; + } + $patchName = $this->ask('Patch name (e.g. "Add CHANGELOG file")'); + if (empty($patchName)) { + $this->io()->error('Patch name is required'); + return 1; + } + } + + // Resolve patch type + $patchFiles = ['php' => 'patch.php', 'sh' => 'patch.sh', 'diff' => 'patch.diff', 'py' => 'patch.py']; + $type = $options['type']; + + if (empty($type)) { + if ($this->io()->input()->isInteractive()) { + $question = new ChoiceQuestion('Patch type', array_keys($patchFiles), 0); + $type = $this->io()->askQuestion($question); + } else { + $type = 'php'; + } + } + + if (!isset($patchFiles[$type])) { + $this->io()->error('Invalid patch type "' . $type . '". Supported: php, sh, diff, py'); return 1; } + $patchFile = $patchFiles[$type]; $slugifiedName = (new Slugify())->slugify($patchName); $patchDirectory = getcwd() . '/patches/' . $slugifiedName; // Print summary $this->io()->section('Create'); $this->io()->listing([ - 'Patch: ' . $patchName + 'Patch: ' . $patchName, + 'Type: ' . $patchFile, ]); $this->say('Create patch ' . $slugifiedName); @@ -232,16 +267,20 @@ public function create( return 1; } - $this->taskCopyDir([__DIR__ . '/../patches/template/' => $patchDirectory]) + $templateDirectory = __DIR__ . '/../resources/templates/'; + $this->taskFilesystemStack() + ->mkdir($patchDirectory) + ->copy($templateDirectory . 'commit-message.txt', $patchDirectory . '/commit-message.txt') + ->copy($templateDirectory . $patchFile, $patchDirectory . '/' . $patchFile) ->setVerbosityThreshold(VerbosityThresholdInterface::VERBOSITY_DEBUG) ->run(); - $this->io()->success('Patch directory created'); - // Suggest next steps - $this->say('- Edit patch.php & commit-message.txt in ' . $patchDirectory); - $this->say('- Run `./vendor/bin/patchbot patch ' - . $slugifiedName - . ' ` to apply the patch to a repository'); + $this->io()->success('Patch directory created: ' . $patchDirectory); + $this->io()->text('Next steps:'); + $this->io()->listing([ + 'Edit ' . $patchFile . ' & commit-message.txt in ' . $patchDirectory, + 'Run ./vendor/bin/patchbot patch ' . $slugifiedName . ' ', + ]); return 0; } diff --git a/tests/unit/PatchbotCommandsTest.php b/tests/unit/PatchbotCommandsTest.php index 2cdac41..4acf1be 100644 --- a/tests/unit/PatchbotCommandsTest.php +++ b/tests/unit/PatchbotCommandsTest.php @@ -15,6 +15,7 @@ class PatchbotCommandsTest extends TestCase protected static string $bareRepository = ''; protected static string $projectRoot = ''; + protected static string $starterTemplatePatchDirectory = ''; /** * Set up a bare local Git repository @@ -41,6 +42,13 @@ public static function loadFixtures(): void exec('git commit -a -m "Add README"'); exec('git push origin main 2> /dev/null'); } + if (empty(self::$starterTemplatePatchDirectory)) { + self::$starterTemplatePatchDirectory = sys_get_temp_dir() . '/patchbot-starter-template-patch-' . uniqid('', true); + $templateDir = self::$projectRoot . '/resources/templates/'; + mkdir(self::$starterTemplatePatchDirectory . '/starter-template-patch', 0777, true); + copy($templateDir . 'patch.php', self::$starterTemplatePatchDirectory . '/starter-template-patch/patch.php'); + copy($templateDir . 'commit-message.txt', self::$starterTemplatePatchDirectory . '/starter-template-patch/commit-message.txt'); + } } public static function setUpBeforeClass(): void @@ -95,7 +103,7 @@ public static function generalCommandsProvider(): array [ 'Missing arguments', 1, - 'create', + 'create', '--no-interaction', ], [ 'Not enough arguments', @@ -138,8 +146,8 @@ public static function patchCommandWarningProvider(): array [ 'nothing to change', 0, - 'patch', 'template', 'file://' . self::$bareRepository, - '--patch-source-directory=' . self::$projectRoot . '/patches' + 'patch', 'starter-template-patch', 'file://' . self::$bareRepository, + '--patch-source-directory=' . self::$starterTemplatePatchDirectory ] ]; } From 94dd01325b6d1cc3dfd546d401e40d2bba172b9f Mon Sep 17 00:00:00 2001 From: Dan Kleine Date: Mon, 16 Feb 2026 14:57:39 +0100 Subject: [PATCH 15/24] =?UTF-8?q?[FEATURE]=20=F0=9F=93=9D=20Docs:=20Add=20?= =?UTF-8?q?links=20to=20example=20patch=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bunch of real world use cases was collected in a patchbot examples repo. Add links to these patches in the docs. --- README.md | 32 ++++++++++++++++-------------- docs/walkthrough.md | 47 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7c0a05a..181103d 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,15 @@ [![License](https://img.shields.io/badge/license-gpl--2.0--or--later-blue.svg?style=flat-square)](https://spdx.org/licenses/GPL-2.0-or-later.html) [![Contribution](https://img.shields.io/badge/contributions_welcome-%F0%9F%94%B0-brightgreen.svg?labelColor=brightgreen&style=flat-square)](https://gitlab.com/pixelbrackets/patchbot/-/blob/master/CONTRIBUTING.md) -Automate changes across multiple Git repositories — create branches, apply +Automate changes across multiple Git repositories - create branches, apply patches, push, and open merge requests in batch. ![Screenshot](docs/screenshot.png) ## Why Patchbot? -You need to apply the same change to 20 repositories. Manually that means: -clone, branch, edit, commit, push, create merge request — times 20. +Let's say you want to apply the same change to 20 repositories. Manually that means: +clone, branch, edit, commit, push, create merge request - times 20. Maybe you need to rename a file in every repo, replace a deprecated URL in all docs, add a package to all projects or run a migration script. Nothing a plain @@ -25,7 +25,7 @@ but something that can be automated with a script. Patchbot does the repetitive parts for you. Write the change once as a patch script, point Patchbot at your repositories, and let it create feature branches, -commit, push, and optionally open merge requests — across all of them. +commit, push, and optionally open merge requests across all of them. Patchbot runs centrally on your machine or CI server and pushes changes out to repositories. It is not a service that repositories pull from or run on their own. @@ -97,17 +97,21 @@ for details on configuring access. Real world use cases for Patchbot include: -- Add a PHP package to all projects (e.g. `composer require vendor/package`) -- Add or update PHP-CS-Fixer rules across all projects -- Introduce an `.editorconfig` file to all repositories -- Replace copyright year strings in files across many repos, even when the location varies -- Rename a vendor or company name after rebranding -- Rename a file due to a new naming convention (e.g. `LICENSE` → `LICENSE.txt`) -- Run database migration scripts across all projects -- Update a subset of packages on a CI schedule -- Run follow-up tasks after package updates (config changes, renamed classes, updated imports) +- [Find and replace](https://github.com/pixelbrackets/patchbot-examples/tree/main/patches/find-and-replace) strings across all files (URLs, class names, config values) +- [Add a PHP package](https://github.com/pixelbrackets/patchbot-examples/tree/main/patches/add-php-package) to all projects (e.g. `composer require vendor/package`) +- [Add or update PHP-CS-Fixer rules](https://github.com/pixelbrackets/patchbot-examples/tree/main/patches/update-php-cs-fixer-rules) across all projects +- [Introduce an `.editorconfig`](https://github.com/pixelbrackets/patchbot-examples/tree/main/patches/add-editorconfig) file to all repositories +- [Add `.gitattributes`](https://github.com/pixelbrackets/patchbot-examples/tree/main/patches/add-gitattributes) with export-ignore rules for smaller Composer packages +- [Replace copyright year](https://github.com/pixelbrackets/patchbot-examples/tree/main/patches/replace-copyright-year) strings in files across many repos +- [Rename a vendor or company name](https://github.com/pixelbrackets/patchbot-examples/tree/main/patches/rename-vendor-name) after rebranding +- [Rename a file](https://github.com/pixelbrackets/patchbot-examples/tree/main/patches/rename-file) due to a new naming convention (e.g. `LICENSE` → `LICENSE.txt`) +- [Remove deprecated files](https://github.com/pixelbrackets/patchbot-examples/tree/main/patches/remove-deprecated-files) or config keys that are no longer needed +- [Update a subset of packages](https://github.com/pixelbrackets/patchbot-examples/tree/main/patches/typo3-minor-update) on a CI schedule - Add or update CI configuration files (e.g. `.gitlab-ci.yml`) across all projects -- Remove deprecated files or config keys that are no longer needed +- Run database migration scripts across all projects +- Run follow-up tasks after package updates (config changes, renamed classes, updated imports) + +(See the [patchbot-examples](https://github.com/pixelbrackets/patchbot-examples) repository for some ready-to-use patches) ### Patch structure diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 2eb1096..2d3ecce 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -60,8 +60,10 @@ my-patches/ ## 3. Write a Patch -Create a new patch directory. The interactive wizard guides you -through the required options. +Browse the [patchbot-examples](https://github.com/pixelbrackets/patchbot-examples) +repository for ready-to-use patches you can import and customize. + +To create a new patch from scratch, use the interactive wizard: ```bash ./vendor/bin/patchbot create @@ -112,6 +114,36 @@ bash /path/to/my-patches/patches/add-changelog-file/patch.sh Check the result, adjust the script, repeat. When the patch works as expected, use Patchbot to distribute it. +#### Writing idempotent patches + +A patch script should be safe to run multiple times on the same repository. +If the change was already applied, the script should skip gracefully and +exit with code `0` (success). Patchbot detects "nothing to change" by +checking `git status` after the script runs — if no files changed, the +repository is skipped automatically. + +Use a guard clause at the top of your script to exit early when the +patch does not apply. Do not exit with an error code, since the patch +was not needed rather than broken. + +```php + Date: Mon, 16 Feb 2026 16:19:14 +0100 Subject: [PATCH 16/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Add=20import=20an?= =?UTF-8?q?d=20export=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Easen the import of existing patches from Gists, Git repositories, or subdirectories within a repository. Export patches git GitHub gists. --- README.md | 31 ++++ docs/walkthrough.md | 47 +++--- src/RoboFile.php | 223 ++++++++++++++++++++++++++++ tests/unit/PatchbotCommandsTest.php | 15 ++ 4 files changed, 297 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 181103d..ec412dd 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,34 @@ and `commit-message.txt` with the commit message and that's it. See the [walkthrough guide](docs/walkthrough.md#writing-patches) for tips on developing and testing patches. +### Import and export patches + +Use the `import` command to import a patch from a Gist, a Git repository, +or a subdirectory within a repository: + +For a quick start you could import all patches from the [patchbot examples](https://github.com/pixelbrackets/patchbot-examples) +repository. + +```bash +# Import from a Gist +./vendor/bin/patchbot import https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745 + +# Import with a custom name +./vendor/bin/patchbot import https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745 --patch-name=my-find-replace + +# Import from a Git repository subdirectory +./vendor/bin/patchbot import https://github.com/pixelbrackets/patchbot-examples --path=patches/add-editorconfig + +# Import all patches from a Git repository +./vendor/bin/patchbot import https://github.com/pixelbrackets/patchbot-examples +``` + +Export a patch to share it as a GitHub Gist: + +```bash +./vendor/bin/patchbot export add-editorconfig +``` + ### Command and options reference **Commands** @@ -265,6 +293,8 @@ for tips on developing and testing patches. | `merge` | Merge one branch into another, push | | `merge-many` | Merge a branch into all repositories | | `create` | Create a new patch | +| `import` | Import a patch from a URL (Gist, Git repo, or subdirectory) | +| `export` | Print sharing commands for a patch | | `discover` | Discover repositories from a GitLab namespace | **Options** @@ -277,6 +307,7 @@ for tips on developing and testing patches. | `--source-branch` | patch | Base branch for the feature branch (default: `main`) | | `--branch-name` | patch, patch-many | Custom name for the feature branch | | `--halt-before-commit` | patch, patch-many | Pause before committing for manual review | +| `--path` | import | Subdirectory within the repository to import | | `--type` | create | Patch type: `php`, `sh`, `diff`, `py` (default: `php`) | | `--force` | discover | Overwrite existing `repositories.json` | | `-v` / `-vvv` | all | Increase output verbosity | diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 2d3ecce..73097f2 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -221,37 +221,46 @@ When ready to merge, use the merge commands: The [patchbot-examples](https://github.com/pixelbrackets/patchbot-examples) repository contains ready-to-use patches you can import and customize. -The patches in your project are probably specific to your organisation or -domain. The best way to share them is to share the entire patch project as -a Git repository. - -To share a single general-purpose patch, you can create a GitHub Gist. -See [this example patch](https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745) -that adds an `.editorconfig` file to a repository. +### Importing a Patch -Example command using the CLI tool [gist](https://github.com/defunkt/gist) -to upload a patch: +Use the `import` command to import a patch from a Gist, a Git repository, +or a subdirectory within a repository: ```bash -cd patches/add-editorconfig/ -gist -d "Patchbot Patch - Add .editorconfig to a repository" patch.php commit-message.txt +# Import from a Gist +./vendor/bin/patchbot import https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745 + +# Import with a custom name +./vendor/bin/patchbot import https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745 --patch-name=my-find-replace + +# Import from a Git repository subdirectory +./vendor/bin/patchbot import https://github.com/pixelbrackets/patchbot-examples --path=patches/add-editorconfig + +# Import all patches from a Git repository +./vendor/bin/patchbot import https://github.com/pixelbrackets/patchbot-examples ``` -### Importing a Shared Patch +The command clones the source, copies the patch files into your `patches/` +directory, and removes the `.git` directory. Review and customize the +imported patch before applying it. -Copy & paste all files manually to import an existing patch from another source. +### Exporting a Patch -If the source is a Git repository then a Git clone command is sufficient. +The patches in your project are probably specific to your organisation or +domain. The best way to share them is to share the entire patch project +as a Git repository. -Example command importing the -[add-editorconfig](https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745) -patch from a Gist: +To share a single general-purpose patch, use the `export` command. It +creates a GitHub Gist using the [GitHub CLI](https://cli.github.com/) +(or prints the command if `gh` is not installed): ```bash -git clone --depth=1 https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745 patches/add-editorconfig/ -rm -r patches/add-editorconfig/.git +./vendor/bin/patchbot export add-editorconfig ``` +See [this example patch](https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745) +that adds an `.editorconfig` file to a repository. + ## Running in GitLab CI Instead of running Patchbot locally, you can set it up as a scheduled diff --git a/src/RoboFile.php b/src/RoboFile.php index bad6699..df78d0c 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -285,6 +285,229 @@ public function create( return 0; } + /** + * Import a patch from a URL (Gist, Git repository, or subdirectory) + * + * @param string $url URL of the Git repository or Gist (positional arg) + * @param string $patchName Name for the imported patch (positional arg, optional) + * @param array $options + * @option $url URL of the Git repository or Gist (alternative to positional arg) + * @option $patch-name Name for the imported patch (alternative to positional arg) + * @option $path Subdirectory within the repository to import + * @return int exit code + */ + public function import( + string $url = '', + string $patchName = '', + array $options = [ + 'url|u' => '', + 'patch-name|p' => '', + 'path' => '', + ] + ): int { + $url = $url ?: $options['url']; + $patchName = $patchName ?: $options['patch-name']; + + if (empty($url)) { + $this->io()->error('Missing URL'); + return 1; + } + + // Clone to temp directory + $tempDirectory = $this->getTemporaryDirectory(); + $temporaryImportName = 'import-source'; + + $this->say('Clone ' . $url); + $result = $this->taskGitStack() + ->cloneShallow($url, $temporaryImportName) + ->dir($tempDirectory) + ->setVerbosityThreshold(VerbosityThresholdInterface::VERBOSITY_DEBUG) + ->run(); + if ($result->wasSuccessful() !== true) { + $this->io()->error('Cloning failed - check the URL and your access rights'); + return 1; + } + + // Determine source directory + $sourceDirectory = $tempDirectory . '/' . $temporaryImportName; + $hasPath = !empty($options['path']); + if ($hasPath) { + $sourceDirectory .= '/' . trim($options['path'], '/'); + } + + if (!is_dir($sourceDirectory)) { + $this->io()->error('Path not found in repository: ' . $options['path']); + return 1; + } + + // Multi-patch import: repo contains a patches/ subdirectory + if (!$hasPath && is_dir($sourceDirectory . '/patches')) { + $entries = array_diff(scandir($sourceDirectory . '/patches'), ['.', '..']); + $patches = array_filter($entries, fn ($entry) => is_dir($sourceDirectory . '/patches/' . $entry)); + + if (empty($patches)) { + $this->io()->warning('No patch directories found in patches/'); + return 0; + } + + // Print summary + $this->io()->section('Import'); + $this->io()->listing([ + 'URL: ' . $url, + 'Patches: ' . count($patches) . ' found', + ]); + + $imported = 0; + $skipped = 0; + + foreach ($patches as $patch) { + $targetDirectory = getcwd() . '/patches/' . $patch; + if (is_dir($targetDirectory)) { + $this->io()->text('Skipped: ' . $patch . ' (already exists)'); + $skipped++; + continue; + } + + $this->taskFilesystemStack() + ->mirror($sourceDirectory . '/patches/' . $patch, $targetDirectory) + ->setVerbosityThreshold(VerbosityThresholdInterface::VERBOSITY_DEBUG) + ->run(); + $this->io()->text('Imported: ' . $patch); + $imported++; + } + + $this->io()->newLine(); + if ($imported > 0) { + $this->io()->success($imported . ' patch(es) imported'); + } + if ($skipped > 0) { + $this->io()->text($skipped . ' patch(es) skipped (already exist)'); + } + } else { + // Single patch import + if (empty($patchName)) { + $nameSource = $hasPath ? $options['path'] : $url; + $patchName = (new Slugify())->slugify(basename($nameSource)); + } + + $patchDirectory = getcwd() . '/patches/' . $patchName; + + // Print summary + $this->io()->section('Import'); + $this->io()->listing([ + 'URL: ' . $url, + 'Patch: ' . $patchName, + $hasPath ? 'Path: ' . $options['path'] : 'Path: (root)', + ]); + + if (is_dir($patchDirectory)) { + $this->io()->error('Patch directory already exists: ' . $patchDirectory); + return 1; + } + + $this->taskFilesystemStack() + ->mirror($sourceDirectory, $patchDirectory) + ->setVerbosityThreshold(VerbosityThresholdInterface::VERBOSITY_DEBUG) + ->run(); + + // Remove .git directory if present (gist clones include it) + $gitDir = $patchDirectory . '/.git'; + if (is_dir($gitDir)) { + $this->taskDeleteDir($gitDir) + ->setVerbosityThreshold(VerbosityThresholdInterface::VERBOSITY_DEBUG) + ->run(); + } + + $this->io()->success('Patch imported: ' . $patchName); + } + + $this->io()->text('Next steps:'); + $this->io()->listing([ + 'Review and customize the imported patches in patches/', + 'Run ./vendor/bin/patchbot patch ', + ]); + + return 0; + } + + + /** + * Export a patch for sharing + * + * Prints ready-to-use commands to share a patch as a GitHub Gist. + * + * @param string $patchName Name of the patch to export (positional arg) + * @param array $options + * @option $patch-name Name of the patch to export (alternative to positional arg) + * @return int exit code + */ + public function export( + string $patchName = '', + array $options = [ + 'patch-name|p' => '', + ] + ): int { + $patchName = $patchName ?: $options['patch-name']; + + if (empty($patchName)) { + $this->io()->error('Missing patch name'); + return 1; + } + + $patchDirectory = getcwd() . '/patches/' . $patchName; + + if (!is_dir($patchDirectory)) { + $this->io()->error('Patch directory not found: ' . $patchDirectory); + return 1; + } + + // Warn about missing files + $commitMessageFile = $patchDirectory . '/commit-message.txt'; + if (!is_file($commitMessageFile) || empty(trim(file_get_contents($commitMessageFile)))) { + $this->io()->warning('Missing or empty commit-message.txt'); + } + $resolver = new PatchProviderResolver(); + try { + $resolver->resolve($patchDirectory); + } catch (\RuntimeException $e) { + $this->io()->warning($e->getMessage()); + } + + // Collect files + $files = array_diff(scandir($patchDirectory), ['.', '..']); + + // Print summary + $this->io()->section('Export'); + $this->io()->listing([ + 'Patch: ' . $patchName, + 'Files: ' . implode(', ', $files), + ]); + + // Create gist via GitHub CLI + $ghAvailable = shell_exec('which gh 2>/dev/null'); + if (!$ghAvailable) { + $this->io()->error('GitHub CLI (gh) not found. Install it from https://cli.github.com/'); + return 1; + } + + $filesArgument = implode(' ', $files); + $ghCommand = 'gh gist create --desc ' + . escapeshellarg('Patchbot Patch - ' . $patchName) . ' ' + . $filesArgument; + + $result = $this->taskExec($ghCommand) + ->dir($patchDirectory) + ->run(); + if ($result->wasSuccessful()) { + $this->io()->success('Gist created'); + } else { + $this->io()->error('Gist creation failed'); + return 1; + } + + return 0; + } + /** * Discover repositories from a GitLab namespace * diff --git a/tests/unit/PatchbotCommandsTest.php b/tests/unit/PatchbotCommandsTest.php index 4acf1be..5343a43 100644 --- a/tests/unit/PatchbotCommandsTest.php +++ b/tests/unit/PatchbotCommandsTest.php @@ -109,6 +109,21 @@ public static function generalCommandsProvider(): array 'Not enough arguments', 1, 'batch', + ], + [ + 'Missing URL', + 1, + 'import', '--no-interaction', + ], + [ + 'Missing patch name', + 1, + 'export', '--no-interaction', + ], + [ + 'Patch directory not found', + 1, + 'export', 'nonexistent-patch-' . PHP_INT_MAX, '--no-interaction', ] ]; } From 1ae306dae003de7fa4eaf9c9bfbcc12f01590f60 Mon Sep 17 00:00:00 2001 From: Dan Kleine Date: Mon, 16 Feb 2026 21:08:05 +0100 Subject: [PATCH 17/24] =?UTF-8?q?[FEATURE]=20=E2=99=BB=EF=B8=8F=20=20Reduc?= =?UTF-8?q?e=20cognitive=20complexity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce nested if statements, double variables. --- README.md | 32 ++++---- docs/walkthrough.md | 10 +-- src/RoboFile.php | 187 +++++++------------------------------------- 3 files changed, 50 insertions(+), 179 deletions(-) diff --git a/README.md b/README.md index ec412dd..3abd34c 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ cd my-patches # Apply the patch to a repository ./vendor/bin/patchbot patch my-first-patch git@gitlab.com:user/repo.git # Or apply to all repositories in repositories.json -./vendor/bin/patchbot patch-many my-first-patch --dry-run +./vendor/bin/patchbot patch:many my-first-patch --dry-run ``` ## Requirements @@ -176,21 +176,21 @@ commits the changes, and pushes the branch to the remote. Apply a patch to all repositories listed in `repositories.json`: ```bash -./vendor/bin/patchbot patch-many +./vendor/bin/patchbot patch:many ``` ```bash # Apply to all repositories -./vendor/bin/patchbot patch-many update-changelog +./vendor/bin/patchbot patch:many update-changelog # Filter by path pattern -./vendor/bin/patchbot patch-many update-changelog --filter="path:my-org/*" +./vendor/bin/patchbot patch:many update-changelog --filter="path:my-org/*" # Filter by GitLab topic -./vendor/bin/patchbot patch-many update-changelog --filter="topic:php" +./vendor/bin/patchbot patch:many update-changelog --filter="topic:php" # Combine filters, create MRs, and preview first -./vendor/bin/patchbot patch-many update-changelog --filter="topic:php" --create-mr --dry-run +./vendor/bin/patchbot patch:many update-changelog --filter="topic:php" --create-mr --dry-run ``` After batch processing completes, a summary shows how many repositories were @@ -229,14 +229,14 @@ tested by CI before merging. Use the merge commands when ready: ./vendor/bin/patchbot merge # Merge a branch into the default branch for all repositories -./vendor/bin/patchbot merge-many +./vendor/bin/patchbot merge:many ``` ```bash # Examples ./vendor/bin/patchbot merge feature-add-license main git@gitlab.com:user/repo.git -./vendor/bin/patchbot merge-many feature-add-phpcs-rules -./vendor/bin/patchbot merge-many feature-add-phpcs-rules --dry-run +./vendor/bin/patchbot merge:many feature-add-phpcs-rules +./vendor/bin/patchbot merge:many feature-add-phpcs-rules --dry-run ``` ### Create a new patch @@ -289,9 +289,9 @@ Export a patch to share it as a GitHub Gist: | Command | Description | |---------|-------------| | `patch` | Apply changes, commit, push | -| `patch-many` | Apply a patch to all repositories | +| `patch:many` | Apply a patch to all repositories | | `merge` | Merge one branch into another, push | -| `merge-many` | Merge a branch into all repositories | +| `merge:many` | Merge a branch into all repositories | | `create` | Create a new patch | | `import` | Import a patch from a URL (Gist, Git repo, or subdirectory) | | `export` | Print sharing commands for a patch | @@ -301,12 +301,12 @@ Export a patch to share it as a GitHub Gist: | Option | Available in | Description | |--------|--------------|-------------| -| `--dry-run` | patch, merge, patch-many, merge-many | Preview without making changes | -| `--create-mr` | patch, patch-many | Create GitLab merge request after pushing | -| `--filter` | patch-many, merge-many | Filter repositories by `path:glob` or `topic:name` | +| `--dry-run` | patch, merge, patch:many, merge:many | Preview without making changes | +| `--create-mr` | patch, patch:many | Create GitLab merge request after pushing | +| `--filter` | patch:many, merge:many | Filter repositories by `path:glob` or `topic:name` | | `--source-branch` | patch | Base branch for the feature branch (default: `main`) | -| `--branch-name` | patch, patch-many | Custom name for the feature branch | -| `--halt-before-commit` | patch, patch-many | Pause before committing for manual review | +| `--branch-name` | patch, patch:many | Custom name for the feature branch | +| `--halt-before-commit` | patch, patch:many | Pause before committing for manual review | | `--path` | import | Subdirectory within the repository to import | | `--type` | create | Patch type: `php`, `sh`, `diff`, `py` (default: `php`) | | `--force` | discover | Overwrite existing `repositories.json` | diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 73097f2..2a21362 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -178,17 +178,17 @@ cp .env.example .env Then apply the patch to all discovered repositories: ```bash -./vendor/bin/patchbot patch-many add-changelog-file +./vendor/bin/patchbot patch:many add-changelog-file ``` Use filters to target specific repositories: ```bash # Only repositories matching a path pattern -./vendor/bin/patchbot patch-many add-changelog-file --filter="path:mygroup/typo3-*" +./vendor/bin/patchbot patch:many add-changelog-file --filter="path:mygroup/typo3-*" # Only repositories with a specific GitLab topic -./vendor/bin/patchbot patch-many add-changelog-file --filter="topic:php" +./vendor/bin/patchbot patch:many add-changelog-file --filter="topic:php" ``` After batch processing completes, a summary shows how many repositories were @@ -203,7 +203,7 @@ before merging. To create a GitLab merge request automatically, add `--create-mr`: ```bash -./vendor/bin/patchbot patch-many add-changelog-file --create-mr +./vendor/bin/patchbot patch:many add-changelog-file --create-mr ``` When ready to merge, use the merge commands: @@ -213,7 +213,7 @@ When ready to merge, use the merge commands: ./vendor/bin/patchbot merge feature-add-changelog-file main git@gitlab.com:user/repo.git # Merge across all repositories -./vendor/bin/patchbot merge-many feature-add-changelog-file +./vendor/bin/patchbot merge:many feature-add-changelog-file ``` ## 7. Share Patches diff --git a/src/RoboFile.php b/src/RoboFile.php index df78d0c..d8d199b 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -63,14 +63,13 @@ public function patch( $options['working-directory'] = $options['working-directory'] ?? $this->getTemporaryDirectory(); /** @noinspection NonSecureUniqidUsageInspection */ $options['branch-name'] = $options['branch-name'] ?? date('Ymd') . '_' . 'patchbot_' . uniqid(); - $repositoryName = pathinfo($options['repository-url'], PATHINFO_FILENAME); // Print summary $this->io()->section($options['dry-run'] ? 'Patch (Dry Run)' : 'Patch'); $this->io()->listing([ 'Patch: ' . $options['patch-name'], 'Branch: ' . $options['branch-name'], - 'Repository: ' . $repositoryName . ' (' . $options['repository-url'] . ')' + 'Repository: ' . pathinfo($options['repository-url'], PATHINFO_FILENAME) . ' (' . $options['repository-url'] . ')' ]); if ($options['dry-run']) { @@ -161,14 +160,13 @@ public function merge( } $options['working-directory'] = $options['working-directory'] ?? $this->getTemporaryDirectory(); - $repositoryName = pathinfo($options['repository-url'], PATHINFO_FILENAME); // Print summary $this->io()->section($options['dry-run'] ? 'Merge (Dry Run)' : 'Merge'); $this->io()->listing([ 'Source Branch: ' . $options['source'], 'Target Branch: ' . $options['target'], - 'Repository: ' . $repositoryName . ' (' . $options['repository-url'] . ')' + 'Repository: ' . pathinfo($options['repository-url'], PATHINFO_FILENAME) . ' (' . $options['repository-url'] . ')' ]); if ($options['dry-run']) { @@ -220,11 +218,11 @@ public function create( $patchName = $patchName ?: $options['patch-name']; // Interactive wizard: prompt for patch name when missing + if (empty($patchName) && !$this->io()->input()->isInteractive()) { + $this->io()->error('Missing arguments'); + return 1; + } if (empty($patchName)) { - if (!$this->io()->input()->isInteractive()) { - $this->io()->error('Missing arguments'); - return 1; - } $patchName = $this->ask('Patch name (e.g. "Add CHANGELOG file")'); if (empty($patchName)) { $this->io()->error('Patch name is required'); @@ -236,13 +234,12 @@ public function create( $patchFiles = ['php' => 'patch.php', 'sh' => 'patch.sh', 'diff' => 'patch.diff', 'py' => 'patch.py']; $type = $options['type']; + if (empty($type) && $this->io()->input()->isInteractive()) { + $question = new ChoiceQuestion('Patch type', array_keys($patchFiles), 0); + $type = $this->io()->askQuestion($question); + } if (empty($type)) { - if ($this->io()->input()->isInteractive()) { - $question = new ChoiceQuestion('Patch type', array_keys($patchFiles), 0); - $type = $this->io()->askQuestion($question); - } else { - $type = 'php'; - } + $type = 'php'; } if (!isset($patchFiles[$type])) { @@ -643,11 +640,10 @@ public function patchMany( foreach ($repositories as $repository) { if ($isDryRun) { - $dryRunMsg = '[DRY-RUN] Would patch: ' . $repository['path_with_namespace'] . ' (' . $repository['default_branch'] . ')'; + $this->io()->text('[DRY-RUN] Would patch: ' . $repository['path_with_namespace'] . ' (' . $repository['default_branch'] . ')'); if ($options['create-mr']) { - $dryRunMsg .= ' and create MR'; + $this->io()->text('[DRY-RUN] Would create MR'); } - $this->io()->text($dryRunMsg); continue; } chdir($workingDirectory); @@ -756,9 +752,9 @@ public function mergeMany( } /** - * Run batch-mode commands + * Run batch-mode commands - Deprecated, Use patch:many or merge:many instead * - * @deprecated Use patch-many or merge-many instead + * @deprecated Use patch:many or merge:many instead * @param string $batchCommand Name of command to run in batch mode (patch or merge) * @param array $options * @option $working-directory Working directory to check out repositories @@ -785,152 +781,27 @@ public function batch(string $batchCommand, array $options = [ 'create-mr' => false, ]): int { - // Deprecation warning - $this->io()->warning('The "batch" command is deprecated. Use "patch-many" or "merge-many" instead.'); - - $workingDirectory = getcwd(); - $configFile = 'repositories.json'; - - if (false === is_file($configFile)) { - $this->io()->error('Can not find file "' . $configFile . '". Run "patchbot discover" first.'); - return 1; - } - - // Parse JSON config - $jsonContent = file_get_contents($configFile); - $config = json_decode($jsonContent, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - $this->io()->error('Invalid JSON in ' . $configFile . ': ' . json_last_error_msg()); - return 1; - } - - $repositories = $config['repositories'] ?? []; - - if (empty($repositories)) { - $this->io()->warning('No repositories found in ' . $configFile); - return 0; - } - - // Apply filters - $filters = is_array($options['filter']) ? $options['filter'] : [$options['filter']]; - $filters = array_filter($filters); // remove empty values - if (!empty($filters)) { - $repositories = $this->filterRepositories($repositories, $filters); - if (empty($repositories)) { - $this->io()->warning('No repositories match the filter criteria'); - return 0; - } - } - - $isDryRun = $options['dry-run']; - - if ($isDryRun) { - $this->io()->section('Dry Run'); - } - - $results = ['success' => 0, 'skipped' => 0, 'failed' => 0]; + $this->io()->warning('The "batch" command is deprecated. Use "patch:many" or "merge:many" instead.'); if ($batchCommand === 'patch') { - $patchSourceDirectory = ($options['patch-source-directory'] ?? getcwd() . '/patches') . '/'; - $branchName = $options['branch-name'] ?? date('Ymd') . '_patchbot_' . uniqid(); - - foreach ($repositories as $repository) { - if ($isDryRun) { - $dryRunMsg = '[DRY-RUN] Would patch: ' . $repository['path_with_namespace'] . ' (' . $repository['default_branch'] . ')'; - if ($options['create-mr']) { - $dryRunMsg .= ' and create MR'; - } - $this->io()->text($dryRunMsg); - continue; - } - chdir($workingDirectory); // reset working directory - try { - $result = $this->runPatch([ - 'repository-url' => $repository['clone_url_ssh'], - 'working-directory' => $options['working-directory'] ?? $this->getTemporaryDirectory(), - 'patch-source-directory' => $patchSourceDirectory, - 'patch-name' => $options['patch-name'], - 'source-branch' => $repository['default_branch'], - 'branch-name' => $branchName, - 'halt-before-commit' => $options['halt-before-commit'], - ]); - if ($result) { - $results['success']++; - $this->io()->success('Patched: ' . $repository['path_with_namespace']); - - // Create merge request if requested - if ($options['create-mr']) { - $commitMessage = file_get_contents($patchSourceDirectory . $options['patch-name'] . '/commit-message.txt'); - $mrUrl = $this->createMergeRequest( - $repository['clone_url_ssh'], - $branchName, - $repository['default_branch'], - $commitMessage - ); - if ($mrUrl) { - $this->io()->text(' MR: ' . $mrUrl); - } - } - } else { - $results['skipped']++; - $this->io()->text('Skipped: ' . $repository['path_with_namespace'] . ' (no changes)'); - } - } catch (\Exception $e) { - $results['failed']++; - $this->io()->error('Failed: ' . $repository['path_with_namespace'] . ' - ' . $e->getMessage()); - } - } + return $this->patchMany($options['patch-name'], [ + 'branch-name' => $options['branch-name'], + 'halt-before-commit' => $options['halt-before-commit'], + 'dry-run' => $options['dry-run'], + 'filter' => $options['filter'], + 'create-mr' => $options['create-mr'], + ]); } if ($batchCommand === 'merge') { - foreach ($repositories as $repository) { - if ($isDryRun) { - $this->io()->text('[DRY-RUN] Would merge: ' . $options['source'] . ' -> ' . $repository['default_branch'] . ' in ' . $repository['path_with_namespace']); - continue; - } - chdir($workingDirectory); // reset working directory - try { - $result = $this->runMerge([ - 'repository-url' => $repository['clone_url_ssh'], - 'working-directory' => $options['working-directory'] ?? $this->getTemporaryDirectory(), - 'source' => $options['source'], - 'target' => $repository['default_branch'], - ]); - if ($result) { - $results['success']++; - $this->io()->success('Merged: ' . $repository['path_with_namespace']); - } else { - $results['skipped']++; - $this->io()->text('Skipped: ' . $repository['path_with_namespace'] . ' (already up-to-date)'); - } - } catch (\Exception $e) { - $results['failed']++; - $this->io()->error('Failed: ' . $repository['path_with_namespace'] . ' - ' . $e->getMessage()); - } - } - } - - // Print summary - $this->io()->newLine(); - if ($isDryRun) { - $this->io()->text(count($repositories) . ' repositories would be processed'); - } else { - $total = $results['success'] + $results['skipped'] + $results['failed']; - $this->io()->section('Summary'); - $this->io()->text($total . ' repositories processed'); - if ($results['success'] > 0) { - $this->io()->text(' ✓ ' . $results['success'] . ' ' . ($batchCommand === 'patch' ? 'patched' : 'merged')); - } - if ($results['skipped'] > 0) { - $this->io()->text(' - ' . $results['skipped'] . ' skipped (no changes)'); - } - if ($results['failed'] > 0) { - $this->io()->text(' ✗ ' . $results['failed'] . ' failed'); - } + return $this->mergeMany($options['source'] ?? '', [ + 'dry-run' => $options['dry-run'], + 'filter' => $options['filter'], + ]); } - return $results['failed'] > 0 ? 1 : 0; + $this->io()->error('Unknown batch command: ' . $batchCommand . '. Use "patch" or "merge".'); + return 1; } /** From 19a49f21dfabe766cd1243727e14574ce92725de Mon Sep 17 00:00:00 2001 From: Dan Kleine Date: Tue, 17 Feb 2026 10:27:56 +0100 Subject: [PATCH 18/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Add=20cache=20for?= =?UTF-8?q?=20cloned=20repositories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, every patch or merge run cloned repositories into a fresh temp directory in order to avoid issues with broken git states due to conflicting branch names or stale changes. This caused high network traffic and slow execution time in batch mode. Patchbot now resolves those issues and may use a cache directory. Either set in environment, the local users cache dir or in /tmp. --- .env.example | 4 + src/RoboFile.php | 206 ++++++++++++++++++++++++------------ tests/unit/PatchbotTest.php | 40 +++++++ 3 files changed, 181 insertions(+), 69 deletions(-) diff --git a/.env.example b/.env.example index 1683c6d..e36d263 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,7 @@ GITLAB_NAMESPACE=acmeusernameongitlabcom # If not set, uses system default (git config user.name/email) # BOT_GIT_NAME=Patchbot # BOT_GIT_EMAIL=patchbot@example.com + +# Optional: Override cache directory for cloned repositories +# Defaults to ~/.cache/patchbot or /tmp/patchbot +# PATCHBOT_CACHE_DIR=/path/to/cache diff --git a/src/RoboFile.php b/src/RoboFile.php index d8d199b..3535bf8 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -25,7 +25,7 @@ class RoboFile extends \Robo\Tasks * @param array $options * @option $patch-name Name of the patch directory (alternative to positional arg) * @option $repository-url URI of Git repository (alternative to positional arg) - * @option $working-directory Working directory to check out repositories + * @option $working-directory Deprecated: Working directory (uses workspace cache now) * @option $patch-source-directory Source directory for all collected patches * @option $source-branch Name of the branch to create a new branch upon * @option $branch-name Name of the feature branch to be created @@ -60,7 +60,6 @@ public function patch( } $options['patch-source-directory'] = ($options['patch-source-directory'] ?? getcwd() . '/patches') . '/'; - $options['working-directory'] = $options['working-directory'] ?? $this->getTemporaryDirectory(); /** @noinspection NonSecureUniqidUsageInspection */ $options['branch-name'] = $options['branch-name'] ?? date('Ymd') . '_' . 'patchbot_' . uniqid(); @@ -128,7 +127,7 @@ public function patch( * @option $source Source branch name (alternative to positional arg) * @option $target Target branch name (alternative to positional arg) * @option $repository-url URI of Git repository (alternative to positional arg) - * @option $working-directory Working directory to check out repositories + * @option $working-directory Deprecated: Working directory (uses workspace cache now) * @option $dry-run Show what would be done without executing * @return int exit code * @throws TaskException @@ -159,8 +158,6 @@ public function merge( return 1; } - $options['working-directory'] = $options['working-directory'] ?? $this->getTemporaryDirectory(); - // Print summary $this->io()->section($options['dry-run'] ? 'Merge (Dry Run)' : 'Merge'); $this->io()->listing([ @@ -650,7 +647,6 @@ public function patchMany( try { $result = $this->runPatch([ 'repository-url' => $repository['clone_url_ssh'], - 'working-directory' => $this->getTemporaryDirectory(), 'patch-source-directory' => $patchSourceDirectory, 'patch-name' => $patchName, 'source-branch' => $repository['default_branch'], @@ -730,7 +726,6 @@ public function mergeMany( try { $result = $this->runMerge([ 'repository-url' => $repository['clone_url_ssh'], - 'working-directory' => $this->getTemporaryDirectory(), 'source' => $sourceBranch, 'target' => $repository['default_branch'], ]); @@ -813,26 +808,10 @@ public function batch(string $batchCommand, array $options = [ */ protected function runPatch(array $options): bool { - $repositoryName = pathinfo($options['repository-url'], PATHINFO_FILENAME); - - // Set working directory - $this->say('Switch to working directory ' . $options['working-directory']); - chdir($options['working-directory']); - - // Clone repo or use existing repository in workspace - if (false === is_dir($repositoryName)) { - $this->say('Clone repository'); - $result = $this->taskGitStack() - ->cloneRepo($options['repository-url'], $repositoryName) - ->setVerbosityThreshold(VerbosityThresholdInterface::VERBOSITY_DEBUG) - ->run(); - if ($result->wasSuccessful() !== true) { - throw new TaskException($this, 'Cloning failed - ' - . 'Maybe wrong URI or missing access rights'); - } - } - chdir($repositoryName); - $currentDirectory = getcwd(); + $currentDirectory = $this->resolveWorkingDirectory( + $options['repository-url'], + $options['working-directory'] ?? null + ); $this->say('Use repository in ' . $currentDirectory); // Checkout main branch, update, create new feature branch @@ -926,26 +905,10 @@ protected function runPatch(array $options): bool */ protected function runMerge(array $options): bool { - $repositoryName = pathinfo($options['repository-url'], PATHINFO_FILENAME); - - // Set working directory - $this->say('Switch to working directory ' . $options['working-directory']); - chdir($options['working-directory']); - - // Clone repo or use existing repository - if (false === is_dir($repositoryName)) { - $this->say('Clone repository'); - $result = $this->taskGitStack() - ->cloneRepo($options['repository-url'], $repositoryName) - ->setVerbosityThreshold(VerbosityThresholdInterface::VERBOSITY_DEBUG) - ->run(); - if ($result->wasSuccessful() !== true) { - throw new TaskException($this, 'Cloning failed - ' - . 'Maybe wrong URI or missing access rights'); - } - } - chdir($repositoryName); - $currentDirectory = getcwd(); + $currentDirectory = $this->resolveWorkingDirectory( + $options['repository-url'], + $options['working-directory'] ?? null + ); $this->say('Use repository in ' . $currentDirectory); // Fetch branches - & @@ -1010,6 +973,131 @@ protected function getTemporaryDirectory(): string return $result['path'] ?? ''; } + /** + * Resolve general Patchbot cache directory + * + * Within the directory repositories are cloned and cached for patching. + * The working directory is resolved from the cache directory later. + * + * Resolution order: + * - Environment variable (PATCHBOT_CACHE_DIR) + * - Local user cache directory ($XDG_CACHE_HOME/patchbot or ~/.cache/patchbot) + * - Temporary folder (/tmp/patchbot) + * + * @return string Cache directory path + */ + protected function getCacheDirectory(): string + { + $envDir = getenv('PATCHBOT_CACHE_DIR'); + if (!empty($envDir)) { + return $envDir; + } + + $xdgCache = getenv('XDG_CACHE_HOME') ?: (getenv('HOME') . '/.cache'); + $xdgDir = $xdgCache . '/patchbot'; + if (is_writable($xdgCache) || is_writable($xdgDir)) { + return $xdgDir; + } + + return sys_get_temp_dir() . '/patchbot'; + } + + /** + * Parse repository URL into hostname and project path + * + * Supports SSH, HTTPS, and file:// URL formats: + * + * @param string $url Repository URL + * @return array{hostname: string, path: string} Parsed URL parts + */ + protected function parseRepositoryUrl(string $url): array + { + // SSH format: git@example.com:user/repo.git + if (preg_match('/^git@([^:]+):(.+?)(?:\.git)?$/', $url, $matches)) { + return ['hostname' => $matches[1], 'path' => $matches[2]]; + } + + // HTTPS format: https://example.com/user/repo.git + if (preg_match('/^https?:\/\/([^\/]+)\/(.+?)(?:\.git)?$/', $url, $matches)) { + return ['hostname' => $matches[1], 'path' => $matches[2]]; + } + + // Local file:// format: file:///path/to/repo.git + if (preg_match('/^file:\/\/(.+?)(?:\.git)?$/', $url, $matches)) { + return ['hostname' => 'local', 'path' => $matches[1]]; + } + + // Fallback: use filename + return ['hostname' => 'local', 'path' => pathinfo($url, PATHINFO_FILENAME)]; + } + + /** + * Resolve working directory for a repository (clone or reuse) + * + * Uses workspace cache by default. When --working-directory is set + * (deprecated), falls back to the old behavior. + * + * @param string $repositoryUrl Repository clone URL + * @param string|null $workingDirectory Deprecated --working-directory option + * @return string The resolved repository directory (cwd is changed to it) + * @throws TaskException + */ + protected function resolveWorkingDirectory(string $repositoryUrl, ?string $workingDirectory = null): string + { + // Deprecated: --working-directory option (use workspace cache instead) + if (!empty($workingDirectory)) { + $this->io()->warning('The --working-directory option is deprecated and will be removed in a future version. ' + . 'Repositories are now cached automatically.'); + $repositoryName = pathinfo($repositoryUrl, PATHINFO_FILENAME); + chdir($workingDirectory); + if (false === is_dir($repositoryName)) { + $this->cloneRepository($repositoryUrl, $repositoryName); + } + chdir($repositoryName); + return getcwd(); + } + + $cacheDir = $this->getCacheDirectory() . '/repositories'; + $urlParts = $this->parseRepositoryUrl($repositoryUrl); + $repoWorkspace = $cacheDir . '/' . $urlParts['hostname'] . '/' . $urlParts['path']; + $this->say('Working directory: ' . $repoWorkspace); + + // Clone repo or use existing repository in workspace cache + if (false === is_dir($repoWorkspace)) { + $parentDir = dirname($repoWorkspace); + if (!is_dir($parentDir)) { + mkdir($parentDir, 0777, true); + } + $this->cloneRepository($repositoryUrl, $repoWorkspace); + } else { + // Clean up stale state from previous runs + chdir($repoWorkspace); + shell_exec('git checkout -- .'); + shell_exec('git clean -fd'); + } + chdir($repoWorkspace); + return getcwd(); + } + + /** + * Clone a repository into a target directory + * + * @param string $repositoryUrl Repository clone URL + * @param string $targetDirectory Directory to clone into + * @throws TaskException + */ + protected function cloneRepository(string $repositoryUrl, string $targetDirectory): void + { + $this->say('Clone repository'); + $result = $this->taskGitStack() + ->cloneRepo($repositoryUrl, $targetDirectory) + ->setVerbosityThreshold(VerbosityThresholdInterface::VERBOSITY_DEBUG) + ->run(); + if ($result->wasSuccessful() !== true) { + throw new TaskException($this, 'Cloning failed - Check URI or access rights'); + } + } + /** * Overwrite the say method to be less verbose * @@ -1076,7 +1164,8 @@ protected function createMergeRequest( } $gitlabUrl = getenv('GITLAB_URL') ?: 'https://gitlab.com'; - $projectPath = $this->extractProjectPath($repositoryUrl); + $repositoryUrlParts = $this->parseRepositoryUrl($repositoryUrl); + $projectPath = $repositoryUrlParts['path']; if (empty($projectPath)) { $this->io()->warning('Cannot create MR: unable to extract project path from URL'); @@ -1116,27 +1205,6 @@ protected function createMergeRequest( } } - /** - * Extract project path from repository URL - * - * @param string $url Repository URL (SSH or HTTPS) - * @return string|null Project path (e.g., "user/repo") - */ - protected function extractProjectPath(string $url): ?string - { - // SSH format: git@gitlab.com:user/repo.git - if (preg_match('/^git@[^:]+:(.+?)(?:\.git)?$/', $url, $matches)) { - return $matches[1]; - } - - // HTTPS format: https://gitlab.com/user/repo.git - if (preg_match('/^https?:\/\/[^\/]+\/(.+?)(?:\.git)?$/', $url, $matches)) { - return $matches[1]; - } - - return null; - } - /** * Load repositories from config file and apply filters * diff --git a/tests/unit/PatchbotTest.php b/tests/unit/PatchbotTest.php index 5fd3fc9..2bf0471 100644 --- a/tests/unit/PatchbotTest.php +++ b/tests/unit/PatchbotTest.php @@ -2,6 +2,7 @@ use Pixelbrackets\Patchbot\RoboFile; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class PatchbotTest extends TestCase { @@ -44,4 +45,43 @@ public function testRoboFileHasMergeManyMethod(): void { $this->assertTrue(method_exists(RoboFile::class, 'mergeMany')); } + + public function testGetCacheDirectoryFromEnv(): void + { + putenv('PATCHBOT_CACHE_DIR=/custom/cache/dir'); + $roboFile = new RoboFile(); + $method = new \ReflectionMethod($roboFile, 'getCacheDirectory'); + + $result = $method->invoke($roboFile); + + $this->assertEquals('/custom/cache/dir', $result); + putenv('PATCHBOT_CACHE_DIR'); + } + + public static function parseRepositoryUrlProvider(): array + { + return [ + 'SSH' => ['git@gitlab.com:user/repo.git', 'gitlab.com', 'user/repo'], + 'SSH without .git' => ['git@gitlab.com:user/repo', 'gitlab.com', 'user/repo'], + 'HTTPS' => ['https://gitlab.com/user/repo.git', 'gitlab.com', 'user/repo'], + 'HTTPS without .git' => ['https://gitlab.com/user/repo', 'gitlab.com', 'user/repo'], + 'GitHub SSH' => ['git@github.com:user/repo.git', 'github.com', 'user/repo'], + 'Custom host' => ['git@git.example.com:team/project.git', 'git.example.com', 'team/project'], + 'file URL' => ['file:///tmp/repo.git', 'local', '/tmp/repo'], + 'Nested namespace' => ['git@gitlab.com:org/group/subgroup/repo.git', 'gitlab.com', 'org/group/subgroup/repo'], + 'HTTPS nested namespace' => ['https://gitlab.com/org/group/subgroup/repo.git', 'gitlab.com', 'org/group/subgroup/repo'], + ]; + } + + #[DataProvider('parseRepositoryUrlProvider')] + public function testParseRepositoryUrl(string $url, string $expectedHostname, string $expectedPath): void + { + $roboFile = new RoboFile(); + $method = new \ReflectionMethod($roboFile, 'parseRepositoryUrl'); + + $result = $method->invoke($roboFile, $url); + + $this->assertEquals($expectedHostname, $result['hostname']); + $this->assertEquals($expectedPath, $result['path']); + } } From ed99270ba722cdf2ef1c7022ff8af5a7829dcc05 Mon Sep 17 00:00:00 2001 From: Dan Kleine Date: Tue, 17 Feb 2026 10:40:53 +0100 Subject: [PATCH 19/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Add=20command=20t?= =?UTF-8?q?o=20clear=20cache=20dir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a simple clear cache command --- src/RoboFile.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/RoboFile.php b/src/RoboFile.php index 3535bf8..e93db55 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -279,6 +279,31 @@ public function create( return 0; } + /** + * Clear the Patchbot cache directory + * + * @return int exit code + */ + public function clearcache(): int + { + $cacheDir = $this->getCacheDirectory() . '/repositories'; + + if (!is_dir($cacheDir)) { + $this->io()->text('Cache directory does not exist: ' . $cacheDir); + return 0; + } + + $this->io()->section('Clear Cache'); + $this->io()->text('Cache directory: ' . $cacheDir); + + $this->taskDeleteDir($cacheDir) + ->setVerbosityThreshold(VerbosityThresholdInterface::VERBOSITY_DEBUG) + ->run(); + + $this->io()->success('Cache cleared'); + return 0; + } + /** * Import a patch from a URL (Gist, Git repository, or subdirectory) * From 27baaae49ef49b1803eca81f73147ae7b6a60074 Mon Sep 17 00:00:00 2001 From: Dan Kleine Date: Tue, 17 Feb 2026 11:18:10 +0100 Subject: [PATCH 20/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Offer=20repositor?= =?UTF-8?q?y=20file=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offer to copy a repositories.json template when no GitLab connection is available. --- README.md | 4 +-- docs/walkthrough.md | 37 +++++++++++++++++++++++++--- src/RoboFile.php | 59 ++++++++++++++++++++++++++++++++++++++------- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3abd34c..fee33a9 100644 --- a/README.md +++ b/README.md @@ -198,8 +198,8 @@ patched, skipped, or failed. ### Discover repositories -Instead of adding repository URLs manually, let Patchbot discover them from -a GitLab namespace (group or user): +Instead of adding repository URLs manually to the `repositories.json` file, +let Patchbot discover them from a GitLab namespace (group or user): ```bash # Set up your GitLab token in .env diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 2a21362..4cb93a5 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -163,8 +163,10 @@ Use `--dry-run` to preview what would happen without making any changes: ## 5. Apply to Many Repositories -For batch operations, first create a `repositories.json` file. You can either -write it manually or let Patchbot discover repositories from GitLab: +For batch operations, Patchbot reads a `repositories.json` file that lists +all target repositories. You can generate it automatically or write it by hand. + +### Discover repositories from GitLab ```bash # Set up your GitLab token in .env @@ -175,19 +177,46 @@ cp .env.example .env ./vendor/bin/patchbot discover --gitlab-namespace=mygroup ``` +This generates a `repositories.json` file with the following structure: + +```json +{ + "generated": "manual", + "repositories": [ + { + "path_with_namespace": "user/example-project", + "clone_url_ssh": "git@gitlab.com:user/example-project.git", + "clone_url_http": "https://gitlab.com/user/example-project.git", + "default_branch": "main", + "topics": [] + } + ] +} +``` + +The `topics` field is populated from the GitLab project topics (Settings > +General > Topics). Repositories without topics get an empty array. You can +add custom topics manually to categorize repositories for filtering — for +example, add `"topics": ["php", "internal"]` to group repositories by +language or team. + +### Apply patches + Then apply the patch to all discovered repositories: ```bash ./vendor/bin/patchbot patch:many add-changelog-file ``` -Use filters to target specific repositories: +### Filter repositories + +Use `--filter` to target specific repositories by path or topic: ```bash # Only repositories matching a path pattern ./vendor/bin/patchbot patch:many add-changelog-file --filter="path:mygroup/typo3-*" -# Only repositories with a specific GitLab topic +# Only repositories with a specific topic ./vendor/bin/patchbot patch:many add-changelog-file --filter="topic:php" ``` diff --git a/src/RoboFile.php b/src/RoboFile.php index e93db55..de9fcfd 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -544,16 +544,17 @@ public function discover(array $options = [ { // Get GitLab namespace: CLI option > env $namespace = !empty($options['gitlab-namespace']) ? $options['gitlab-namespace'] : getenv('GITLAB_NAMESPACE'); - if (empty($namespace)) { - $this->io()->error('Missing GitLab namespace. Use --gitlab-namespace or set GITLAB_NAMESPACE in .env'); - return 1; - } - - // Get GitLab token from environment $token = getenv('GITLAB_TOKEN'); - if (empty($token)) { - $this->io()->error('Missing GITLAB_TOKEN environment variable. Create a .env file with GITLAB_TOKEN=your-token'); - return 1; + + if (empty($namespace) || empty($token)) { + if (empty($namespace)) { + $this->io()->error('Missing GitLab namespace. Use --gitlab-namespace or set GITLAB_NAMESPACE in .env'); + } + if (empty($token)) { + $this->io()->error('Missing GITLAB_TOKEN environment variable. Create a .env file with GITLAB_TOKEN=your-token'); + } + + return $this->offerRepositoriesTemplate(); } // Get GitLab URL: CLI option > env > default @@ -1274,6 +1275,46 @@ protected function loadRepositories(array|string $filters = []): ?array return $repositories; } + /** + * Offer to create a template repositories.json when discovery is not available + * + * @return int exit code + */ + protected function offerRepositoriesTemplate(): int + { + $outputFile = 'repositories.json'; + + if (is_file($outputFile)) { + return 1; + } + + if (!$this->io()->input()->isInteractive()) { + return 1; + } + + $question = $this->io()->confirm( + 'Create a template ' . $outputFile . ' to fill in manually?', + true + ); + if ($question === false) { + return 1; + } + + $templateFile = __DIR__ . '/../resources/templates/repositories.json'; + $this->taskFilesystemStack() + ->copy($templateFile, getcwd() . '/' . $outputFile) + ->setVerbosityThreshold(VerbosityThresholdInterface::VERBOSITY_DEBUG) + ->run(); + + $this->io()->success('Template created: ' . $outputFile); + $this->io()->text('Edit the file and add your repositories, then run:'); + $this->io()->listing([ + './vendor/bin/patchbot patch:many ', + ]); + + return 0; + } + /** * Print batch processing summary * From 294ce835c4cae808c3dbdbca3fa70f3ee578547e Mon Sep 17 00:00:00 2001 From: Dan Kleine Date: Tue, 17 Feb 2026 11:26:56 +0100 Subject: [PATCH 21/24] =?UTF-8?q?[TASK]=20=F0=9F=94=96=20Set=20version=203?= =?UTF-8?q?.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FEATURE Add cache for cloned repositories * FEATURE Add import and export commands * FEATURE Add interactive wizard to create command * FEATURE Support multi-language patches --- CHANGELOG.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c60eb36..69d7047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ # Changelog -2026-02-15 Dan Untenzu +2026-02-17 Dan Kleine + + * 3.1.0 + * FEATURE Add cache for cloned repositories → saves traffic and time + * FEATURE Add import and export commands → Eg. import all patches from our examples repository + which contains common use cases like file renaming or adding configurations + `patchbot import https://github.com/pixelbrackets/patchbot-examples` + * FEATURE Add interactive wizard to create command + * FEATURE Support multi-language patches → Now supports patches written in PHP, Shell, Python and as Git Diff + +2026-02-15 Dan Kleine * 3.0.0 * FEATURE Rewrite docs → Remove clutter and be more concise in reason why From f102e4a001c8658189b7fcf10a6c0bbe535bc5d0 Mon Sep 17 00:00:00 2001 From: Dan Kleine Date: Wed, 18 Feb 2026 10:20:59 +0100 Subject: [PATCH 22/24] =?UTF-8?q?[BUGFIX]=20=F0=9F=90=9B=20=20Add=20missin?= =?UTF-8?q?g=20repo=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recently added discover task is missing the template file in the commit. --- README.md | 8 ++++++-- resources/templates/repositories.json | 12 ++++++++++++ src/RoboFile.php | 4 ++-- 3 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 resources/templates/repositories.json diff --git a/README.md b/README.md index fee33a9..a464337 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ to see how Patchbot helps reduce technical debt across Git repositories. - Repository filtering - Target specific repos by path pattern or GitLab topic (`--filter`) - Dry-run mode - Preview what would happen without making changes (`--dry-run`) - Custom git user - Push as a bot user instead of your personal account +- Import and export - Quickly import patches from GitHub Gists or Git repositories - Multi-language patches - Write patch scripts in PHP, Shell, Python, or as Git diffs - CI-ready - Run Patchbot as a scheduled GitLab CI pipeline @@ -88,9 +89,11 @@ Or install Patchbot as a dependency in an existing project: composer require pixelbrackets/patchbot ``` +### Access rights + The user running Patchbot needs clone and push access to the target repositories. SSH is the recommended protocol. See the -[walkthrough guide](docs/walkthrough.md#access-rights) +[walkthrough guide](docs/walkthrough.md#1-set-up-access) for details on configuring access. ## Usage @@ -217,7 +220,8 @@ cp .env.example .env ``` This creates a `repositories.json` file with all discovered repositories, -including their clone URLs and default branches. +including their clone URLs and default branches. When you don't have GitLab +it will create a template file for you instead. ### Merge feature branches diff --git a/resources/templates/repositories.json b/resources/templates/repositories.json new file mode 100644 index 0000000..98204a8 --- /dev/null +++ b/resources/templates/repositories.json @@ -0,0 +1,12 @@ +{ + "generated": "manual", + "repositories": [ + { + "path_with_namespace": "user/example-project", + "clone_url_ssh": "git@gitlab.com:user/example-project.git", + "clone_url_http": "https://gitlab.com/user/example-project.git", + "default_branch": "main", + "topics": [] + } + ] +} diff --git a/src/RoboFile.php b/src/RoboFile.php index de9fcfd..b818ffd 100644 --- a/src/RoboFile.php +++ b/src/RoboFile.php @@ -1282,7 +1282,7 @@ protected function loadRepositories(array|string $filters = []): ?array */ protected function offerRepositoriesTemplate(): int { - $outputFile = 'repositories.json'; + $outputFile = getcwd() . '/repositories.json'; if (is_file($outputFile)) { return 1; @@ -1302,7 +1302,7 @@ protected function offerRepositoriesTemplate(): int $templateFile = __DIR__ . '/../resources/templates/repositories.json'; $this->taskFilesystemStack() - ->copy($templateFile, getcwd() . '/' . $outputFile) + ->copy($templateFile, $outputFile) ->setVerbosityThreshold(VerbosityThresholdInterface::VERBOSITY_DEBUG) ->run(); From 1261686e54fd6aec53bc6bc6f90ffb64b8e4ef52 Mon Sep 17 00:00:00 2001 From: Dan Kleine Date: Wed, 18 Feb 2026 11:45:11 +0100 Subject: [PATCH 23/24] =?UTF-8?q?[FEATURE]=20=E2=9C=A8=20Build=20binary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add build scripts to generate a self-contained binary with Phpacker. --- .gitignore | 2 ++ README.md | 29 +++++++++++++++- bin/patchbot | 16 +++++++-- build-binary.sh | 28 ++++++++++++++++ build-executables.sh | 33 ++++++++++++++++++ build-phar.php | 80 ++++++++++++++++++++++++++++++++++++++++++++ composer.json | 3 ++ docs/walkthrough.md | 24 +++++++++++++ phpacker.json | 6 ++++ 9 files changed, 217 insertions(+), 4 deletions(-) create mode 100755 build-binary.sh create mode 100755 build-executables.sh create mode 100644 build-phar.php create mode 100644 phpacker.json diff --git a/.gitignore b/.gitignore index 513c595..c765624 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .env .idea +/build/ /composer.phar /composer.lock /vendor/ /repositories.json* +/*.phar diff --git a/README.md b/README.md index a464337..b0ae533 100644 --- a/README.md +++ b/README.md @@ -70,11 +70,38 @@ cd my-patches ## Requirements -- PHP - Git +- PHP, when installed via Composer + +Patchbot is written in PHP, but standalone Linux binaries are available. +Combined with multi-language patch support (Shell, Python, Git diff), +_you can use Patchbot without writing or running PHP_ yourself. ## Installation +### Standalone binary + +Download the latest binary from the +[GitHub Releases](https://github.com/pixelbrackets/patchbot-dist/releases): + +```bash +# Download and make executable +curl -L -o patchbot https://github.com/pixelbrackets/patchbot-dist/releases/latest/download/patchbot-linux-x64 +chmod +x patchbot + +# Run +./patchbot list +``` + +A PHAR archive is also available for systems with PHP installed: + +```bash +curl -L -o patchbot.phar https://github.com/pixelbrackets/patchbot-dist/releases/latest/download/patchbot.phar +php patchbot.phar list +``` + +### Composer (Recommended) + Use the [skeleton package](https://packagist.org/packages/pixelbrackets/patchbot-skeleton/) to create a patch project right away: diff --git a/bin/patchbot b/bin/patchbot index 2c6c9d1..29c23b8 100755 --- a/bin/patchbot +++ b/bin/patchbot @@ -18,8 +18,18 @@ if (!$loaded) { ); } -$version = \Jean85\PrettyVersions::getVersion('pixelbrackets/patchbot')->getPrettyVersion(); +// Determine version: prefer .version file (PHAR/binary builds), fallback to PrettyVersions (Composer installs) +if (file_exists(__DIR__ . '/../.version')) { + $version = trim(file_get_contents(__DIR__ . '/../.version')); +} else { + $version = \Jean85\PrettyVersions::getVersion('pixelbrackets/patchbot')->getPrettyVersion(); +} -$runner = new \Robo\Runner(\Pixelbrackets\Patchbot\RoboFile::class); -$statusCode = $runner->execute($argv, 'Patchbot', $version); +try { + $runner = new \Robo\Runner(\Pixelbrackets\Patchbot\RoboFile::class); + $statusCode = $runner->execute($argv, 'Patchbot', $version); +} catch (\Exception $e) { + echo 'Fatal Error: ' . $e->getMessage() . PHP_EOL; + $statusCode = 1; +} exit($statusCode); diff --git a/build-binary.sh b/build-binary.sh new file mode 100755 index 0000000..095e06c --- /dev/null +++ b/build-binary.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -e + +# Check requirements +if [ ! -f "patchbot.phar" ]; then + echo "Error: patchbot.phar not found. Run build-phar.php first" + exit 1 +fi + +if [ ! -f "$HOME/.config/composer/vendor/bin/phpacker" ]; then + echo "Error: PHPacker not found. Install with: composer global require phpacker/phpacker" + exit 1 +fi + +# Read PHP version from .php-version file +PHP_VERSION=$(tr -d '[:space:]' < .php-version) + +# Build Linux binary with PHPacker +~/.config/composer/vendor/bin/phpacker build linux x64 --src=./patchbot.phar --dest=./build --php="$PHP_VERSION" --no-interaction + +# Clean up +if [ -d "build/linux" ]; then + [ -f "build/linux/linux-x64" ] && mv build/linux/linux-x64 build/patchbot-linux-x64 && chmod +x build/patchbot-linux-x64 + [ -f "build/linux/linux-arm64" ] && mv build/linux/linux-arm64 build/patchbot-linux-arm64 && chmod +x build/patchbot-linux-arm64 + rmdir build/linux 2>/dev/null || true +fi + +echo "Done" diff --git a/build-executables.sh b/build-executables.sh new file mode 100755 index 0000000..af7abe5 --- /dev/null +++ b/build-executables.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -e + +# Build all executables (PHAR and binary) + +# Install dependencies without dev dependencies +composer install --no-dev --optimize-autoloader + +# Build PHAR +php --define phar.readonly=0 build-phar.php +if [ ! -f "patchbot.phar" ]; then + echo "Error: patchbot.phar was not created" + exit 1 +fi + +# Test PHAR +php patchbot.phar list > /dev/null + +# Build binary +./build-binary.sh + +# Move PHAR to build directory +mv patchbot.phar build/patchbot.phar + +# Generate checksums +cd build +sha256sum patchbot-linux-* patchbot.phar > checksums.txt 2>/dev/null || sha256sum patchbot.phar > checksums.txt +cd .. + +# Re-Install with dev dependencies for further development +composer install + +echo "Done" diff --git a/build-phar.php b/build-phar.php new file mode 100644 index 0000000..0b5b0f6 --- /dev/null +++ b/build-phar.php @@ -0,0 +1,80 @@ +getPath()) === $baseDir && in_array($file->getFilename(), $exclude)) { + return false; + } + return $iterator->hasChildren() || $file->isFile() || $file->isLink(); +}; + +$iterator = new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator(__DIR__, RecursiveDirectoryIterator::SKIP_DOTS | RecursiveDirectoryIterator::FOLLOW_SYMLINKS), + $filter + ) +); + +// Inject version from Git tag +exec('git describe --tags --dirty --always', $gitVersion); +$version = trim($gitVersion[0] ?? 'dev'); +file_put_contents(__DIR__ . '/.version', $version); +echo 'Building version: ' . $version . PHP_EOL; + +// Create entry script to avoid shebang duplicates +$file = file(__DIR__ . '/bin/patchbot'); +unset($file[0]); +file_put_contents(__DIR__ . '/bin/patchbot.php', $file); + +// Create phar +$phar = new \Phar('patchbot.phar'); +$phar->setSignatureAlgorithm(\Phar::SHA1); +$phar->startBuffering(); +$phar->buildFromIterator($iterator, __DIR__); +//default executable +$phar->setStub( + '#!/usr/bin/env php ' . PHP_EOL . $phar->createDefaultStub('bin/patchbot.php') +); +$phar->stopBuffering(); + +// Make phar executable +chmod(__DIR__ . '/patchbot.phar', 0770); + +// Remove generated entry script and version file +unlink(__DIR__ . '/bin/patchbot.php'); +unlink(__DIR__ . '/.version'); + +echo 'Done'; diff --git a/composer.json b/composer.json index 15e8959..ac5d9b7 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,9 @@ } }, "scripts": { + "build:binary": "./build-binary.sh", + "build:executables": "./build-executables.sh", + "build:phar": "php --define phar.readonly=0 build-phar.php", "test": [ "phpunit tests/unit/" ] diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 4cb93a5..406137b 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -38,6 +38,30 @@ you have two options: ## 2. Create a Patch Project +### Using the standalone binary + +If you don't use PHP, download the standalone binary from +[GitHub Releases](https://github.com/pixelbrackets/patchbot-dist/releases) +and create a project directory manually: + +```bash +# Download binary +curl -L -o patchbot https://github.com/pixelbrackets/patchbot-dist/releases/latest/download/patchbot-linux-x64 +chmod +x patchbot + +# Create project structure +mkdir -p my-patches/patches +cd my-patches + +# Create your first patch +../patchbot create "My first patch" --type=sh +``` + +The binary bundles the PHP runtime, so no PHP installation is needed on +your system. Write patches in Shell, Python, or as Git diffs. + +### Using Composer + The easiest way to get started is the [skeleton package](https://packagist.org/packages/pixelbrackets/patchbot-skeleton/): diff --git a/phpacker.json b/phpacker.json new file mode 100644 index 0000000..528c123 --- /dev/null +++ b/phpacker.json @@ -0,0 +1,6 @@ +{ + "src": "./patchbot.phar", + "dest": "./build", + "platform": "linux", + "php": "8.4" +} From 73cffab472edc390fde0677960b8ea5892480c68 Mon Sep 17 00:00:00 2001 From: Dan Kleine Date: Wed, 18 Feb 2026 11:49:56 +0100 Subject: [PATCH 24/24] =?UTF-8?q?[TASK]=20=F0=9F=94=96=20Set=20version=203?= =?UTF-8?q?.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FEATURE Offer standalone binary * BUGFIX Add missing repo file --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d7047..5dd0f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +2026-02-19 Dan Kleine + + * 3.2.0 + * FEATURE Offer standalone binary + * BUGFIX Add missing repo file + 2026-02-17 Dan Kleine * 3.1.0