diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e36d263 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# 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 + +# 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 + +# Optional: Override cache directory for cloned repositories +# Defaults to ~/.cache/patchbot or /tmp/patchbot +# PATCHBOT_CACHE_DIR=/path/to/cache diff --git a/.gitignore b/.gitignore index d7ebe7c..c765624 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .env .idea +/build/ /composer.phar /composer.lock /vendor/ +/repositories.json* +/*.phar 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/.gitlab-ci.yml b/.gitlab-ci.yml index f8b9461..ae4c9b7 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 bash python3 + - 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-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/.php-version b/.php-version index 5904f7a..c9dc049 100644 --- a/.php-version +++ b/.php-version @@ -1 +1 @@ -7.2 +8.4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b80c84..5dd0f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # 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 + * 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 + * 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 diff --git a/README.md b/README.md index bc24d3e..b0ae533 100644 --- a/README.md +++ b/README.md @@ -8,318 +8,363 @@ [![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 +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. -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. +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. -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. +Saving time, preventing careless mistakes and avoiding 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. +to see how Patchbot helps reduce technical debt across Git repositories. -See [»Usage«](#usage) for example commands. +## Key Features -The package follows the KISS principle. +- 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 +- 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 -## Requirements +## Quick Start -- PHP -- Git +```bash +# Create a new patch project using the skeleton +composer create-project pixelbrackets/patchbot-skeleton my-patches +cd my-patches -## Installation +# Create a new patch +./vendor/bin/patchbot create "My first patch" -💡 Use the -[skeleton package](https://packagist.org/packages/pixelbrackets/patchbot-skeleton/) -to create an example project right away. +# Edit the patch script and commit message +# patches/my-first-patch/patch.php +# patches/my-first-patch/commit-message.txt -- `composer create-project pixelbrackets/patchbot-skeleton` +# 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 +``` -Packagist Entry to install Patchbot only -https://packagist.org/packages/pixelbrackets/patchbot/ +## Requirements -- `composer require pixelbrackets/patchbot` +- Git +- PHP, when installed via Composer -### Access rights +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. -🔑 *The user running Patchbot needs to have access to the target repository.* +## Installation -Make sure that the user running Patchbot is allowed to clone and push to -all target repositories. +### Standalone binary -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) +Download the latest binary from the +[GitHub Releases](https://github.com/pixelbrackets/patchbot-dist/releases): -The recommended protocol is SSH. +```bash +# Download and make executable +curl -L -o patchbot https://github.com/pixelbrackets/patchbot-dist/releases/latest/download/patchbot-linux-x64 +chmod +x patchbot -#### HTTPS Credentials +# Run +./patchbot list +``` -Git by default does not store any credentials. So *every connection* to a -repository by HTTPS will prompt for a username and password. +A PHAR archive is also available for systems with PHP installed: -To avoid these password prompts when using HTTPS URIs with Patchbot -you have two options: +```bash +curl -L -o patchbot.phar https://github.com/pixelbrackets/patchbot-dist/releases/latest/download/patchbot.phar +php patchbot.phar list +``` -- 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/" - ``` +### Composer (Recommended) -## Source +Use the +[skeleton package](https://packagist.org/packages/pixelbrackets/patchbot-skeleton/) +to create a patch project right away: -https://gitlab.com/pixelbrackets/patchbot/ +```bash +composer create-project pixelbrackets/patchbot-skeleton my-patches +``` -Mirror https://github.com/pixelbrackets/patchbot/ +Or install Patchbot as a dependency in an existing project: + +```bash +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#1-set-up-access) +for details on configuring access. ## Usage -Patchbot patches a given Git repository. +Real world use cases for Patchbot include: -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. +- [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 +- Run database migration scripts across all projects +- Run follow-up tasks after package updates (config changes, renamed classes, updated imports) -Patchbot uses a lean file structure to organize patches (see -[skeleton package](https://packagist.org/packages/pixelbrackets/patchbot-skeleton/)). +(See the [patchbot-examples](https://github.com/pixelbrackets/patchbot-examples) repository for some ready-to-use patches) -The directory `patches` contains a collection of all your “patch directories“. +### Patch structure -Each patch directory always contains at least a PHP script named `patch.php` -and a commit message named `commit-message.txt`. +Patchbot organizes patches in the `patches/` directory. Each patch directory +contains a patch file and a commit message (`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.sh ``` -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. +The patch file type is detected automatically by filename. Each patch directory +must contain exactly one of the following: -### Apply patch +| 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` | -Pass the name of the patch directory as `patch-name` and the Git repository as -`repository-url` to the `patchbot` script. +The patch script runs in the root directory of the cloned target repository. +You can develop it incrementally by running it directly in any project directory, +for example `php /patch.php` or `bash /patch.sh`. -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 -``` +### Apply a patch -Example command applying the patch script in directory `template` to -the repository `ssh://git@git.example.com/repository.git`: ```bash -./vendor/bin/patchbot patch --patch-name=template --repository-url=ssh://git@git.example.com/repository.git +./vendor/bin/patchbot patch ``` -**Custom options** +Patchbot clones the repository, creates a feature branch, runs the patch script, +commits the changes, and pushes the branch to the remote. -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 -``` +# Apply patch "template" to a repository +./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git -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 -``` +# Preview without making changes +./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git --dry-run -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: +# Create a GitLab merge request after pushing +./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git --create-mr -```bash -./vendor/bin/patchbot patch --halt-before-commit --patch-name=template --repository-url=https://git.example.com/repository -``` +# Use a custom source branch (default: main) +./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git --source-branch=development -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 a custom feature branch name +./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git --branch-name=feature-1337 -### Merge feature branch +# Pause before committing (for manual review) +./vendor/bin/patchbot patch template git@gitlab.com:user/repo.git --halt-before-commit +``` -✨️Patchbot intentionally creates a feature branch to apply patches. +### Batch apply patches -When you reviewed the feature branch and all CI tests are successful then -you can use Patchbot again to merge the feature branch. +Apply a patch to all repositories listed in `repositories.json`: -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 patch:many ``` -### Add a new patch - -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" -``` -Or copy the example folder `template` manually instead and rename it as desired. +# Apply to all repositories +./vendor/bin/patchbot patch:many update-changelog -Now replace the patch code in `patch.php` and the commit message -in `commit-message.txt`. +# Filter by path pattern +./vendor/bin/patchbot patch:many update-changelog --filter="path:my-org/*" -🛡 ️Patchbot runs the patch script isolated, as a consequence it is possible -to run the script without Patchbot. +# Filter by GitLab topic +./vendor/bin/patchbot patch:many update-changelog --filter="topic:php" -💡 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. +# Combine filters, create MRs, and preview first +./vendor/bin/patchbot patch:many update-changelog --filter="topic:php" --create-mr --dry-run +``` -The patch code will be executed in the root directory scope of the target -repository, keep this in mind for file searches. +After batch processing completes, a summary shows how many repositories were +patched, skipped, or failed. -### Share a patch +### Discover repositories -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. +Instead of adding repository URLs manually to the `repositories.json` file, +let Patchbot discover them from a GitLab namespace (group or user): -However, since a motivation for this tool was to reuse migration scripts, -you could share general-purpose scripts with others though. +```bash +# Set up your GitLab token in .env +cp .env.example .env +# Edit .env with your GITLAB_TOKEN and GITLAB_NAMESPACE -One possible way is to create a GitHub Gist for a single patch. +# Discover repositories +./vendor/bin/patchbot discover -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 -``` +# Or specify the namespace directly +./vendor/bin/patchbot discover --gitlab-namespace=mygroup -🔎 Search for [Gists with Patchbot tags](https://gist.github.com/search?l=PHP&q=%23patchbot). +# Overwrite existing repositories.json +./vendor/bin/patchbot discover --force +``` -### Import a shared patch +This creates a `repositories.json` file with all discovered repositories, +including their clone URLs and default branches. When you don't have GitLab +it will create a template file for you instead. -Copy & paste all files manually to import an existing patch from another source. +### Merge feature branches -If the source is a Git repository then a Git clone command is sufficient. +Patchbot creates feature branches by design, so changes can be reviewed and +tested by CI before merging. Use the merge commands when ready: -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 +# Merge a branch into a target branch for one repository +./vendor/bin/patchbot merge + +# Merge a branch into the default branch for all repositories +./vendor/bin/patchbot merge:many ``` -### Batch processing +```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 +``` -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. +### Create a new patch -This mode will trigger the `patch` or `merge` command for a list of -repositories. The list is expected as CSV file named `repositories.csv`. +```bash +# Interactive wizard +./vendor/bin/patchbot create -*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 +# Set options directly +./vendor/bin/patchbot create "Add CHANGELOG file" --type=php # type may be php, sh, diff, py ``` -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 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. -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. +### Import and export patches -```bash -./vendor/bin/patchbot batch patch --patch-name=update-changelog -``` +Use the `import` command to import a patch from a Gist, a Git repository, +or a subdirectory within a repository: -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. +For a quick start you could import all patches from the [patchbot examples](https://github.com/pixelbrackets/patchbot-examples) +repository. -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 -``` +# Import from a Gist +./vendor/bin/patchbot import https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745 -**Different branch names** +# Import with a custom name +./vendor/bin/patchbot import https://gist.github.com/pixelbrackets/98664b79c788766e4248f16e268c5745 --patch-name=my-find-replace -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`. +# Import from a Git repository subdirectory +./vendor/bin/patchbot import https://github.com/pixelbrackets/patchbot-examples --path=patches/add-editorconfig -*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 +# Import all patches from a Git repository +./vendor/bin/patchbot import https://github.com/pixelbrackets/patchbot-examples ``` -Apply the patch `rename-changelog` to the feature branch -`feature-rename-changelog`, which is based on branch name given in column -`development`: +Export a patch to share it as a GitHub Gist: + ```bash -./vendor/bin/patchbot batch patch --branch-column=development patch-name=rename-changelog branch-name=feature-rename-changelog +./vendor/bin/patchbot export add-editorconfig ``` -Now merge the feature branch into the branch name given in column `test-stage` -and then into the of given in column `integration-stage`: + +### Command and options reference + +**Commands** + +| 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 | +| `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** + +| 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 | +| `--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 | + +### Custom git user + +By default, commits use your system Git configuration. To push as a bot user, +set these environment variables: + ```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 +BOT_GIT_NAME="Patchbot" +BOT_GIT_EMAIL="patchbot@example.com" ``` +### 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`. + +## Source + +https://gitlab.com/pixelbrackets/patchbot/ + +Mirror https://github.com/pixelbrackets/patchbot/ + ## License GNU General Public License version 2 or later @@ -328,8 +373,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/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 c81a9e2..ac5d9b7 100644 --- a/composer.json +++ b/composer.json @@ -1,27 +1,50 @@ { "name": "pixelbrackets/patchbot", "description": "Automate the distribution of patches to various Git repositories", - "type": "library", "license": "GPL-2.0-or-later", + "type": "library", "require": { - "consolidation/robo": "^2.1", - "jean85/pretty-package-versions": "^1.5", - "cocur/slugify": "^4.0" + "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" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" }, "autoload": { "psr-4": { "Pixelbrackets\\Patchbot\\": "src/" } }, - "require-dev": { - "phpunit/phpunit": "^8.0" + "bin": [ + "bin/patchbot" + ], + "config": { + "allow-plugins": { + "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": { + "build:binary": "./build-binary.sh", + "build:executables": "./build-executables.sh", + "build:phar": "php --define phar.readonly=0 build-phar.php", "test": [ "phpunit tests/unit/" ] - }, - "bin": [ - "bin/patchbot" - ] + } } 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 3218560..d62c844 100644 Binary files a/docs/screenshot.png and b/docs/screenshot.png differ diff --git a/docs/walkthrough.md b/docs/walkthrough.md new file mode 100644 index 0000000..406137b --- /dev/null +++ b/docs/walkthrough.md @@ -0,0 +1,343 @@ +# 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 + +### 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/): + +```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 + +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 +``` + +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: + +- 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. The `create` command generates +the correct file based on the selected type: + +| 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: + +```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 +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 + +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 +``` + +### 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 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 [patchbot-examples](https://github.com/pixelbrackets/patchbot-examples) +repository contains ready-to-use patches you can import and customize. + +### Importing a Patch + +Use the `import` command to import a patch from a Gist, a Git repository, +or a subdirectory within a 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 +``` + +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. + +### Exporting a Patch + +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, 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 +./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 +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. 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 @@ +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', + 'topics' => $project['topics'] ?? [], + ]; + } + + $page++; + } while (count($projects) === $perPage); + + return $repositories; + } +} 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 @@ + null, - 'working-directory|d' => null, - 'patch-source-directory|s' => null, - 'patch-name|p' => 'template', - 'source-branch' => 'main', - 'branch-name' => null, - 'halt-before-commit' => 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; } $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(); - $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'] . ')' + 'Repository: ' . pathinfo($options['repository-url'], PATHINFO_FILENAME) . ' (' . $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 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'); + } + return 0; + } + try { $patchApplied = $this->runPatch($options); } catch (Exception | TaskException $e) { @@ -70,11 +94,25 @@ 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'); + + // 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 ' + . $options['branch-name'] . ' ' + . $options['repository-url'] . '` to merge the feature branch'); + } return 0; } @@ -82,21 +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 $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 $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 Deprecated: Working directory (uses workspace cache now) + * @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 - ]): 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']) || @@ -106,17 +158,21 @@ public function merge(array $options = [ return 1; } - $options['working-directory'] = $options['working-directory'] ?? $this->getTemporaryDirectory(); - $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'] . ')' + 'Repository: ' . pathinfo($options['repository-url'], PATHINFO_FILENAME) . ' (' . $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) { @@ -137,119 +193,636 @@ public function merge(array $options = [ /** * Create a new patch * + * When called without arguments, an interactive wizard guides through + * patch name and type selection. When arguments are provided, runs + * non-interactively (suitable for CI and scripted usage). + * + * @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) + * @option $type Patch type: php, sh, diff, py (default: php) * @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' => '', + '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()->input()->isInteractive()) { $this->io()->error('Missing arguments'); return 1; } + if (empty($patchName)) { + $patchName = $this->ask('Patch name (e.g. "Add CHANGELOG file")'); + if (empty($patchName)) { + $this->io()->error('Patch name is required'); + return 1; + } + } - $patchName = (new Slugify())->slugify($options['patch-name']); - $patchDirectory = getcwd() . '/patches/' . $patchName; + // Resolve patch type + $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)) { + $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: ' . $options['patch-name'] + 'Patch: ' . $patchName, + 'Type: ' . $patchFile, ]); - $this->say('Create patch ' . $patchName); + $this->say('Create patch ' . $slugifiedName); if (is_dir($patchDirectory)) { $this->io()->error('Patch directory »' . $patchDirectory . '« already exists'); 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'); - $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->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; + } + + /** + * 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; } /** - * Run batch-mode commands + * Import a patch from a URL (Gist, Git repository, or subdirectory) * - * @param string $batchCommand Name of command to run in batch mode (patch or merge, default: patch) + * @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 + * + * @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'); + $token = getenv('GITLAB_TOKEN'); + + 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 + $gitlabUrl = !empty($options['gitlab-url']) ? $options['gitlab-url'] : (getenv('GITLAB_URL') ?: 'https://gitlab.com'); + + $outputFile = 'repositories.json'; + + // 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 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; + } + + /** + * 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) { + $this->io()->text('[DRY-RUN] Would patch: ' . $repository['path_with_namespace'] . ' (' . $repository['default_branch'] . ')'); + if ($options['create-mr']) { + $this->io()->text('[DRY-RUN] Would create MR'); + } + continue; + } + chdir($workingDirectory); + try { + $result = $this->runPatch([ + 'repository-url' => $repository['clone_url_ssh'], + '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'], + '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 + * + * @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 $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 + * @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 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, + 'filter' => [], + 'create-mr' => false, ]): int { - $workingDirectory = getcwd(); - - if (false === is_file('repositories.csv')) { - $this->io()->error('Can not find file »repositories.csv«'); - 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)); - }); + $this->io()->warning('The "batch" command is deprecated. Use "patch:many" or "merge:many" instead.'); if ($batchCommand === 'patch') { - foreach ($repositories as $repository) { - /** @noinspection DisconnectedForeachInstructionInspection */ - chdir($workingDirectory); // reset working directory - $this->patch([ - 'repository-url' => $repository[$options['repository-column']] ?? array_values($repository)[0], - '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], - 'branch-name' => $options['branch-name'], - 'halt-before-commit' => $options['halt-before-commit'], - ]); - } + 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) { - /** @noinspection DisconnectedForeachInstructionInspection */ - chdir($workingDirectory); // reset working directory - $this->merge([ - 'repository-url' => $repository[$options['repository-column']] ?? array_values($repository)[0], - 'working-directory' => $options['working-directory'], - 'source' => $options['source'], - 'target' => $repository[$options['branch-column']] ?? array_values($repository)[1], - ]); - } + return $this->mergeMany($options['source'] ?? '', [ + 'dry-run' => $options['dry-run'], + 'filter' => $options['filter'], + ]); } - return 0; + $this->io()->error('Unknown batch command: ' . $batchCommand . '. Use "patch" or "merge".'); + return 1; } /** @@ -261,26 +834,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 @@ -300,9 +857,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'); } @@ -327,6 +888,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'); @@ -361,26 +931,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 - & @@ -445,6 +999,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 * @@ -457,4 +1136,211 @@ 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); + } + + /** + * 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'; + $repositoryUrlParts = $this->parseRepositoryUrl($repositoryUrl); + $projectPath = $repositoryUrlParts['path']; + + 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; + } + } + + /** + * 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; + } + + /** + * Offer to create a template repositories.json when discovery is not available + * + * @return int exit code + */ + protected function offerRepositoriesTemplate(): int + { + $outputFile = getcwd() . '/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, $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 + * + * @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/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 60d455d..5343a43 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,11 @@ class PatchbotCommandsTest extends TestCase use CommandTesterTrait; /** @var string[] */ - protected $commandClass; + protected array $commandClass; - protected static $bareRepository = ''; + protected static string $bareRepository = ''; + protected static string $projectRoot = ''; + protected static string $starterTemplatePatchDirectory = ''; /** * Set up a bare local Git repository @@ -23,19 +26,29 @@ 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) . '/'; 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'); 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 @@ -45,7 +58,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); + } } /** @@ -58,9 +75,9 @@ protected function setUp(): void } /** - * Data provider for testExampleCommands. + * Data provider for testGeneralCommands. */ - public function generalCommandsProvider(): array + public static function generalCommandsProvider(): array { return [ [ @@ -69,12 +86,12 @@ public function generalCommandsProvider(): array 'list', ], [ - '--repository-url', + '', 0, 'patch', '--help' ], [ - 'Missing arguments', + 'Missing repository URL', 1, 'patch', ], @@ -86,23 +103,36 @@ public function generalCommandsProvider(): array [ 'Missing arguments', 1, - 'create', + 'create', '--no-interaction', ], [ - 'Not enough arguments ', + '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', ] ]; } - /** - * @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 +143,7 @@ public function testGeneralCommands($expectedOutput, $expectedStatus, $CliArgume /** * Data provider for testPatchCommandWarning. */ - public function patchCommandWarningProvider(): array + public static function patchCommandWarningProvider(): array { self::loadFixtures(); @@ -121,28 +151,27 @@ public 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', 'starter-template-patch', 'file://' . self::$bareRepository, + '--patch-source-directory=' . self::$starterTemplatePatchDirectory ] ]; } - /** - * @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..2bf0471 100644 --- a/tests/unit/PatchbotTest.php +++ b/tests/unit/PatchbotTest.php @@ -2,24 +2,86 @@ use Pixelbrackets\Patchbot\RoboFile; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class PatchbotTest extends TestCase { - protected function setUp(): void + public function testRoboFileClassExists(): void { - \Robo\Robo::unsetContainer(); - $container = \Robo\Robo::createDefaultContainer(); - \Robo\Robo::setContainer($container); + $this->assertTrue(class_exists(RoboFile::class)); } - public function testPatchRequiresRepositoryUrl() + public function testRoboFileHasPatchMethod(): void { - $patchbot = new RoboFile(); - $expectedOutput = 1; // exit code 1 = error + $this->assertTrue(method_exists(RoboFile::class, 'patch')); + } + + 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')); + } + + 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); - $parameters = []; - $this->assertSame($expectedOutput, $patchbot->patch($parameters)); - $parameters = ['repository-url' => '']; - $this->assertSame($expectedOutput, $patchbot->patch($parameters)); + $this->assertEquals($expectedHostname, $result['hostname']); + $this->assertEquals($expectedPath, $result['path']); } } 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 diff --git a/tests/unit/src/CommandTesterTrait.php b/tests/unit/src/CommandTesterTrait.php index a6f9f7c..52474da 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(); @@ -54,6 +35,9 @@ protected function execute($argv, $commandClass) $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]; }