diff --git a/.github/actions/github/resolve-predictable-conflicts/action.yml b/.github/actions/github/resolve-predictable-conflicts/action.yml new file mode 100644 index 000000000..915ad946d --- /dev/null +++ b/.github/actions/github/resolve-predictable-conflicts/action.yml @@ -0,0 +1,23 @@ +name: Resolve Predictable PR Conflicts +description: Resolve open pull requests conflicted only by CHANGELOG.md drift or workflow-managed .github/wiki pointers. + +inputs: + base-ref: + description: Base branch inspected for open pull requests. + required: false + default: main + pull-request-number: + description: Optional pull request number to inspect. When omitted, all open pull requests targeting the base branch are scanned. + required: false + default: '' + +runs: + using: composite + steps: + - name: Resolve predictable conflicts + shell: bash + env: + INPUT_BASE_REF: ${{ inputs.base-ref }} + INPUT_PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }} + DEV_TOOLS_CONFLICT_RESOLVER: ${{ github.action_path }}/resolve-changelog.php + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/github/resolve-predictable-conflicts/resolve-changelog.php b/.github/actions/github/resolve-predictable-conflicts/resolve-changelog.php new file mode 100644 index 000000000..7ae7af94b --- /dev/null +++ b/.github/actions/github/resolve-predictable-conflicts/resolve-changelog.php @@ -0,0 +1,49 @@ + --source= --output= [--repository-url=]\n"); + + exit(2); +} + +$targetContents = file_get_contents($target); +$sourceContents = file_get_contents($source); + +if (! is_string($targetContents) || ! is_string($sourceContents)) { + fwrite(STDERR, "Unable to read changelog conflict stages.\n"); + + exit(2); +} + +$resolver = new UnreleasedChangelogConflictResolver(new ChangelogParser(), new MarkdownRenderer()); +$resolved = $resolver->resolve($targetContents, [$sourceContents], $repositoryUrl); + +file_put_contents($output, $resolved); diff --git a/.github/actions/github/resolve-predictable-conflicts/run.sh b/.github/actions/github/resolve-predictable-conflicts/run.sh new file mode 100755 index 000000000..6cf361be1 --- /dev/null +++ b/.github/actions/github/resolve-predictable-conflicts/run.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_ref="${INPUT_BASE_REF:-main}" +pull_request_number="${INPUT_PULL_REQUEST_NUMBER:-}" +allowed_conflicts=$'CHANGELOG.md\n.github/wiki' +resolved_count=0 +skipped_count=0 +failed_count=0 +summary_file="${GITHUB_STEP_SUMMARY:-}" + +append_summary() { + local message="$1" + + if [ -n "${summary_file}" ]; then + printf '%s\n' "${message}" >> "${summary_file}" + else + printf '%s\n' "${message}" + fi +} + +collect_pull_requests() { + if [ -n "${pull_request_number}" ]; then + gh pr view "${pull_request_number}" \ + --json number,title,url,baseRefName,headRefName,headRepositoryOwner,isCrossRepository,mergeable + + return + fi + + gh pr list \ + --state open \ + --base "${base_ref}" \ + --json number,title,url,baseRefName,headRefName,headRepositoryOwner,isCrossRepository,mergeable +} + +repository_url() { + php -r ' + $composer = json_decode((string) file_get_contents("composer.json"), true); + $support = is_array($composer) ? ($composer["support"] ?? []) : []; + $source = is_array($support) ? ($support["source"] ?? null) : null; + echo is_string($source) && "" !== $source ? $source : "https://github.com/" . getenv("GITHUB_REPOSITORY"); + ' +} + +is_allowed_conflict_scope() { + local conflicts="$1" + + while IFS= read -r file; do + if [ -z "${file}" ]; then + continue + fi + + if ! grep -Fx --quiet -- "${file}" <<< "${allowed_conflicts}"; then + return 1 + fi + done <<< "${conflicts}" + + return 0 +} + +dispatch_required_tests() { + local head_ref="$1" + + if ! gh workflow view tests.yml >/dev/null 2>&1; then + append_summary " - tests dispatch skipped: tests.yml workflow was not found" + + return 0 + fi + + if gh workflow run tests.yml --ref "${head_ref}" -f max-outdated=-1 -f publish-required-statuses=true >/dev/null 2>&1; then + append_summary " - tests dispatch requested with required status mirroring" + + return 0 + fi + + if gh workflow run tests.yml --ref "${head_ref}" >/dev/null 2>&1; then + append_summary " - tests dispatch requested without required status mirroring" + + return 0 + fi + + append_summary " - failed: resolved branch was pushed, but tests.yml could not be dispatched" + + return 1 +} + +resolve_pull_request() { + local number="$1" + local title="$2" + local url="$3" + local head_ref="$4" + local head_owner="$5" + local cross_repository="$6" + local pr_base_ref="$7" + local mergeable="$8" + + append_summary "- PR #${number}: inspecting ${url}" + + if [ "${pr_base_ref}" != "${base_ref}" ]; then + append_summary " - skipped: base branch is \`${pr_base_ref}\`, expected \`${base_ref}\`" + skipped_count=$((skipped_count + 1)) + + return + fi + + if [ "${cross_repository}" = "true" ] || [ "${head_owner}" != "${GITHUB_REPOSITORY_OWNER}" ]; then + append_summary " - skipped: pull request branch is outside this repository" + skipped_count=$((skipped_count + 1)) + + return + fi + + if [ "${mergeable}" = "MERGEABLE" ]; then + append_summary " - skipped: GitHub currently reports the pull request as mergeable" + skipped_count=$((skipped_count + 1)) + + return + fi + + local workdir + workdir="$(mktemp -d)" + trap 'rm -rf "${workdir}"' RETURN + + git clone --no-tags "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${workdir}/repo" >/dev/null 2>&1 + git -C "${workdir}/repo" config user.name "github-actions[bot]" + git -C "${workdir}/repo" config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -C "${workdir}/repo" fetch --no-tags origin \ + "+refs/heads/${base_ref}:refs/remotes/origin/${base_ref}" \ + "+refs/heads/${head_ref}:refs/remotes/origin/${head_ref}" >/dev/null 2>&1 + git -C "${workdir}/repo" switch -C "${head_ref}" "refs/remotes/origin/${head_ref}" >/dev/null 2>&1 + + if git -C "${workdir}/repo" merge --no-commit --no-ff "refs/remotes/origin/${base_ref}" >/dev/null 2>&1; then + git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true + append_summary " - skipped: merge succeeds cleanly when checked locally" + skipped_count=$((skipped_count + 1)) + + return + fi + + local conflicts + conflicts="$(git -C "${workdir}/repo" diff --name-only --diff-filter=U)" + + if [ -z "${conflicts}" ]; then + git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true + append_summary " - skipped: merge failed but no unmerged files were reported" + skipped_count=$((skipped_count + 1)) + + return + fi + + if ! is_allowed_conflict_scope "${conflicts}"; then + git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true + append_summary " - skipped: conflict scope requires manual review" + append_summary "$(printf '%s\n' "${conflicts}" | sed 's/^/ - `/; s/$/`/')" + skipped_count=$((skipped_count + 1)) + + return + fi + + if grep -Fx --quiet -- ".github/wiki" <<< "${conflicts}"; then + git -C "${workdir}/repo" checkout --ours -- .github/wiki + git -C "${workdir}/repo" add .github/wiki + fi + + if grep -Fx --quiet -- "CHANGELOG.md" <<< "${conflicts}"; then + # During `git merge base into PR`, stage 2 is the PR side and stage 3 is the base branch side. + git -C "${workdir}/repo" show :2:CHANGELOG.md > "${workdir}/CHANGELOG.ours.md" + git -C "${workdir}/repo" show :3:CHANGELOG.md > "${workdir}/CHANGELOG.theirs.md" + ( + cd "${workdir}/repo" + php "${DEV_TOOLS_CONFLICT_RESOLVER}" \ + --target="${workdir}/CHANGELOG.theirs.md" \ + --source="${workdir}/CHANGELOG.ours.md" \ + --output="CHANGELOG.md" \ + --repository-url="$(repository_url)" + ) + git -C "${workdir}/repo" add CHANGELOG.md + fi + + if [ -n "$(git -C "${workdir}/repo" diff --name-only --diff-filter=U)" ]; then + git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true + append_summary " - failed: predictable files were handled, but unmerged paths remain" + failed_count=$((failed_count + 1)) + + return + fi + + git -C "${workdir}/repo" commit -m "Resolve predictable conflicts with ${base_ref}" >/dev/null 2>&1 + git -C "${workdir}/repo" push origin "HEAD:${head_ref}" >/dev/null 2>&1 + append_summary " - resolved: pushed an automatic conflict-resolution commit for \`${title}\`" + + if ! dispatch_required_tests "${head_ref}"; then + failed_count=$((failed_count + 1)) + + return + fi + + resolved_count=$((resolved_count + 1)) +} + +if [ -z "${GH_TOKEN:-}" ]; then + echo "GH_TOKEN is required." >&2 + + exit 1 +fi + +append_summary "## Predictable Conflict Resolution Summary" +append_summary "" +append_summary "- Base branch: \`${base_ref}\`" + +pull_requests="$(collect_pull_requests)" + +if [ "${pull_requests:0:1}" = "{" ]; then + pull_requests="[${pull_requests}]" +fi + +while IFS= read -r pull_request; do + [ -n "${pull_request}" ] || continue + + resolve_pull_request \ + "$(jq -r '.number' <<< "${pull_request}")" \ + "$(jq -r '.title' <<< "${pull_request}")" \ + "$(jq -r '.url' <<< "${pull_request}")" \ + "$(jq -r '.headRefName' <<< "${pull_request}")" \ + "$(jq -r '.headRepositoryOwner.login' <<< "${pull_request}")" \ + "$(jq -r '.isCrossRepository' <<< "${pull_request}")" \ + "$(jq -r '.baseRefName' <<< "${pull_request}")" \ + "$(jq -r '.mergeable // "UNKNOWN"' <<< "${pull_request}")" +done < <(jq -c '.[]' <<< "${pull_requests}") + +append_summary "" +append_summary "- Resolved: ${resolved_count}" +append_summary "- Skipped: ${skipped_count}" +append_summary "- Failed: ${failed_count}" + +if [ "${failed_count}" -gt 0 ]; then + exit 1 +fi diff --git a/.github/wiki b/.github/wiki index d8aa7baf4..6523430c4 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit d8aa7baf4723953a1ec41d261a08c237a757053f +Subproject commit 6523430c4fcc16b4b940efb31fd136ff239ace30 diff --git a/.github/workflows/auto-resolve-conflicts.yml b/.github/workflows/auto-resolve-conflicts.yml new file mode 100644 index 000000000..2a4148037 --- /dev/null +++ b/.github/workflows/auto-resolve-conflicts.yml @@ -0,0 +1,80 @@ +name: Auto-resolve Predictable Conflicts + +on: + workflow_call: + inputs: + base-ref: + description: Base branch inspected for open pull requests. + required: false + type: string + default: main + pull-request-number: + description: Optional pull request number to inspect. + required: false + type: string + default: '' + workflow_dispatch: + inputs: + base-ref: + description: Base branch inspected for open pull requests. + required: false + type: string + default: main + pull-request-number: + description: Optional pull request number to inspect. Leave empty to scan open pull requests targeting the base branch. + required: false + type: string + default: '' + push: + branches: [ "main" ] + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: + actions: write + contents: write + pull-requests: write + +concurrency: + group: ${{ github.event_name == 'pull_request' && format('auto-resolve-conflicts-pr-{0}', github.event.pull_request.number) || format('auto-resolve-conflicts-{0}', github.ref) }} + cancel-in-progress: true + +env: + FORCE_COLOR: '1' + +jobs: + resolve_predictable_conflicts: + name: Resolve Predictable Conflicts + runs-on: ubuntu-latest + env: + BASE_REF: ${{ inputs.base-ref || github.event.pull_request.base.ref || github.event.repository.default_branch || 'main' }} + PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number || github.event.pull_request.number || '' }} + AUTO_RESOLVE_ROOT_VERSION: ${{ github.event_name == 'pull_request' && format('dev-{0}', github.event.pull_request.head.ref) || 'dev-main' }} + GH_TOKEN: ${{ github.token }} + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Checkout dev-tools workflow action source + uses: actions/checkout@v6 + with: + repository: php-fast-forward/dev-tools + ref: ${{ github.repository == 'php-fast-forward/dev-tools' && github.sha || 'main' }} + path: .dev-tools-actions + sparse-checkout: | + .github/actions + + - name: Setup PHP and install dependencies + uses: ./.dev-tools-actions/.github/actions/php/setup-composer + with: + php-version: '8.3' + root-version: ${{ env.AUTO_RESOLVE_ROOT_VERSION }} + install-options: --prefer-dist --no-progress --no-interaction --no-plugins --no-scripts + + - name: Resolve predictable pull request conflicts + uses: ./.dev-tools-actions/.github/actions/github/resolve-predictable-conflicts + with: + base-ref: ${{ env.BASE_REF }} + pull-request-number: ${{ env.PULL_REQUEST_NUMBER }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ddc0714f2..492fbfac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Auto-resolve pull-request conflicts limited to workflow-managed `.github/wiki` pointers and `CHANGELOG.md` `Unreleased` drift (#192) + ### Fixed - Keep required PHPUnit matrix checks reporting after workflow-managed `.github/wiki` pointer commits by running the pull-request test workflow without top-level path filters and aligning the packaged consumer test wrapper (#230) diff --git a/docs/advanced/branch-protection-and-bot-commits.rst b/docs/advanced/branch-protection-and-bot-commits.rst index 6cf434ff0..970739dc2 100644 --- a/docs/advanced/branch-protection-and-bot-commits.rst +++ b/docs/advanced/branch-protection-and-bot-commits.rst @@ -113,6 +113,16 @@ test run also mirrors the matrix result into commit statuses named concurrency cancels older in-progress runs for the same pull request so the newest commit owns the required check contexts. +The predictable-conflict workflow MAY also refresh pull request branches when +the only conflicts are ``.github/wiki`` pointer drift and/or ``CHANGELOG.md`` +``Unreleased`` drift. It keeps pull request wiki preview pointers on the branch +side and replays branch-only changelog entries into the current base +``Unreleased`` section, which avoids placing new entries under a freshly +published release after ``main`` moved. Because those automatic resolutions are +also pushed with the built-in workflow token, the workflow dispatches +``tests.yml`` for the refreshed branch after a successful push so required test +statuses can be reported for the new commit. + At a high level, the workflows need permission to read repository contents, write generated preview commits, update pull request comments, and publish Pages content. Keep those permissions scoped to the workflow jobs that actually need @@ -143,6 +153,11 @@ pointer updates, ``actions: write`` to dispatch ``tests.yml`` after bot-authored pointer commits, and ``pull-requests: read`` to inspect pull request metadata safely. +``auto-resolve-conflicts.yml`` keeps ``contents: write`` so it can push +predictable conflict-resolution commits, ``pull-requests: write`` so it can +inspect pull request state through GitHub tooling, and ``actions: write`` so it +can dispatch ``tests.yml`` after it pushes a bot-authored resolution commit. + ``wiki-maintenance-entry.yml`` and ``wiki-maintenance.yml`` keep ``contents: write`` for wiki publication and cleanup tasks, and ``pull-requests: read`` on jobs that need to distinguish merged, closed, and @@ -172,8 +187,10 @@ Resolving ``.github/wiki`` Pointer Conflicts -------------------------------------------- Submodule pointer conflicts happen when ``main`` and the pull request point to -different generated wiki commits. Resolve them by rebasing the pull request and -choosing the preview wiki commit that belongs to the pull request. +different generated wiki commits. The predictable-conflict workflow can resolve +this automatically when the conflict scope is limited to ``.github/wiki`` and +``CHANGELOG.md``. When resolving it manually, rebase the pull request and choose +the preview wiki commit that belongs to the pull request. For pull request ``123``: diff --git a/docs/usage/github-actions.rst b/docs/usage/github-actions.rst index ad971ca71..51fbdbeb1 100644 --- a/docs/usage/github-actions.rst +++ b/docs/usage/github-actions.rst @@ -50,6 +50,7 @@ The packaged wrappers currently include: * ``reports.yml`` * ``review.yml`` * ``changelog.yml`` +* ``auto-resolve-conflicts.yml`` * ``wiki.yml`` for pull-request wiki previews * ``wiki-maintenance.yml`` for merged-publication and cleanup work * ``auto-assign.yml`` @@ -198,6 +199,29 @@ wrapper in ``resources/github-actions/changelog.yml``. .. note:: Branch protection is not what blocks the release-preparation workflow from opening a pull request. Branch protection affects the merge of the ``release/v...`` pull request later in the flow. The gray or disabled workflow-permission controls come from repository permissions or organization policy. +Fast Forward Predictable Conflict Resolution +-------------------------------------------- + +The ``auto-resolve-conflicts.yml`` workflow scans pull requests after ``main`` +moves and after pull request updates. It only attempts an automatic merge when +the conflicted file list is limited to: + +* ``.github/wiki``; +* ``CHANGELOG.md``. + +For ``.github/wiki`` conflicts, the workflow keeps the pull request side of the +submodule pointer because that pointer references the preview wiki branch that +belongs to the pull request. For ``CHANGELOG.md`` conflicts, it treats the base +branch changelog as authoritative and merges branch-only ``Unreleased`` entries +back into the current top-level ``Unreleased`` section. That keeps entries in +the right place even when a release was promoted while the pull request was +waiting. + +If any other file is conflicted, or if GitHub reports a forked pull request +branch, the workflow stops and leaves the branch for manual resolution. Each +run writes a summary explaining which pull requests were resolved, skipped, or +left untouched. + Fast Forward Rigorous Review ---------------------------- diff --git a/resources/github-actions/auto-resolve-conflicts.yml b/resources/github-actions/auto-resolve-conflicts.yml new file mode 100644 index 000000000..0c248f6a7 --- /dev/null +++ b/resources/github-actions/auto-resolve-conflicts.yml @@ -0,0 +1,36 @@ +name: "Fast Forward Predictable Conflict Resolution" + +on: + push: + branches: [ "main" ] + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + workflow_dispatch: + inputs: + base-ref: + description: Base branch inspected for open pull requests. + required: false + type: string + default: main + pull-request-number: + description: Optional pull request number to inspect. Leave empty to scan open pull requests targeting the base branch. + required: false + type: string + default: '' + +permissions: + actions: write + contents: write + pull-requests: write + +jobs: + auto-resolve-conflicts: + uses: php-fast-forward/dev-tools/.github/workflows/auto-resolve-conflicts.yml@main + with: + base-ref: ${{ inputs.base-ref || github.event.pull_request.base.ref || github.event.repository.default_branch || 'main' }} + pull-request-number: ${{ inputs.pull-request-number || github.event.pull_request.number || '' }} + secrets: inherit diff --git a/src/Changelog/Conflict/UnreleasedChangelogConflictResolver.php b/src/Changelog/Conflict/UnreleasedChangelogConflictResolver.php new file mode 100644 index 000000000..fd1bf8adb --- /dev/null +++ b/src/Changelog/Conflict/UnreleasedChangelogConflictResolver.php @@ -0,0 +1,107 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Changelog\Conflict; + +use FastForward\DevTools\Changelog\Document\ChangelogDocument; +use FastForward\DevTools\Changelog\Document\ChangelogRelease; +use FastForward\DevTools\Changelog\Entry\ChangelogEntryType; +use FastForward\DevTools\Changelog\Parser\ChangelogParserInterface; +use FastForward\DevTools\Changelog\Renderer\MarkdownRendererInterface; + +/** + * Rebuilds the active Unreleased section from predictable changelog conflicts. + */ +final readonly class UnreleasedChangelogConflictResolver +{ + /** + * @param ChangelogParserInterface $parser + * @param MarkdownRendererInterface $renderer + */ + public function __construct( + private ChangelogParserInterface $parser, + private MarkdownRendererInterface $renderer, + ) {} + + /** + * Merges source Unreleased entries into the target changelog document. + * + * The target SHOULD be the current base branch changelog. This preserves + * newly published release sections when a release happened while the pull + * request branch was waiting, then re-adds branch-only Unreleased entries to + * the current top-level Unreleased section. + * + * @param string $targetContents + * @param list $sourceContents + * @param ?string $repositoryUrl + */ + public function resolve(string $targetContents, array $sourceContents, ?string $repositoryUrl = null): string + { + $targetDocument = $this->parser->parse($targetContents); + $targetEntries = $targetDocument->getUnreleased() + ->getEntries(); + $knownTargetEntries = $this->flattenEntries($targetDocument); + + foreach ($sourceContents as $sourceContent) { + $sourceUnreleased = $this->parser->parse($sourceContent) + ->getUnreleased(); + + foreach (ChangelogEntryType::ordered() as $type) { + foreach ($sourceUnreleased->getEntriesFor($type) as $entry) { + if (\in_array($entry, $targetEntries[$type->value], true)) { + continue; + } + + if (\in_array($entry, $knownTargetEntries, true)) { + continue; + } + + $targetEntries[$type->value][] = $entry; + $knownTargetEntries[] = $entry; + } + } + } + + $document = $targetDocument->withRelease(new ChangelogRelease( + ChangelogDocument::UNRELEASED_VERSION, + null, + $targetEntries, + )); + + return $this->renderer->render($document, $repositoryUrl); + } + + /** + * @param ChangelogDocument $document + * + * @return list + */ + private function flattenEntries(ChangelogDocument $document): array + { + $entries = []; + + foreach ($document->getReleases() as $release) { + foreach (ChangelogEntryType::ordered() as $type) { + $entries = [...$entries, ...$release->getEntriesFor($type)]; + } + } + + return array_values(array_unique($entries)); + } +} diff --git a/tests/Changelog/Conflict/UnreleasedChangelogConflictResolverTest.php b/tests/Changelog/Conflict/UnreleasedChangelogConflictResolverTest.php new file mode 100644 index 000000000..4d7d56c0b --- /dev/null +++ b/tests/Changelog/Conflict/UnreleasedChangelogConflictResolverTest.php @@ -0,0 +1,195 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Changelog\Conflict; + +use FastForward\DevTools\Changelog\Conflict\UnreleasedChangelogConflictResolver; +use FastForward\DevTools\Changelog\Document\ChangelogDocument; +use FastForward\DevTools\Changelog\Document\ChangelogRelease; +use FastForward\DevTools\Changelog\Entry\ChangelogEntryType; +use FastForward\DevTools\Changelog\Parser\ChangelogParserInterface; +use FastForward\DevTools\Changelog\Renderer\MarkdownRendererInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +#[CoversClass(UnreleasedChangelogConflictResolver::class)] +#[UsesClass(ChangelogDocument::class)] +#[UsesClass(ChangelogEntryType::class)] +#[UsesClass(ChangelogRelease::class)] +final class UnreleasedChangelogConflictResolverTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $parser; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $renderer; + + private UnreleasedChangelogConflictResolver $resolver; + + /** + * @return void + */ + protected function setUp(): void + { + $this->parser = $this->prophesize(ChangelogParserInterface::class); + $this->renderer = $this->prophesize(MarkdownRendererInterface::class); + $this->resolver = new UnreleasedChangelogConflictResolver( + $this->parser->reveal(), + $this->renderer->reveal(), + ); + } + + /** + * @return void + */ + #[Test] + public function resolveWillPreserveBothSidesOfUnreleasedDrift(): void + { + $target = new ChangelogDocument([ + (new ChangelogRelease(ChangelogDocument::UNRELEASED_VERSION)) + ->withEntry(ChangelogEntryType::Fixed, 'Keep main fix'), + ]); + $source = new ChangelogDocument([ + (new ChangelogRelease(ChangelogDocument::UNRELEASED_VERSION)) + ->withEntry(ChangelogEntryType::Fixed, 'Keep branch fix'), + ]); + + $this->parser->parse('main changelog') + ->willReturn($target) + ->shouldBeCalledOnce(); + $this->parser->parse('branch changelog') + ->willReturn($source) + ->shouldBeCalledOnce(); + $this->renderer->render( + Argument::that(static fn(ChangelogDocument $document): bool => [ + 'Keep main fix', + 'Keep branch fix', + ] === $document->getUnreleased() + ->getEntriesFor(ChangelogEntryType::Fixed)), + 'https://github.com/php-fast-forward/dev-tools', + )->willReturn('resolved changelog') + ->shouldBeCalledOnce(); + + self::assertSame( + 'resolved changelog', + $this->resolver->resolve( + 'main changelog', + ['branch changelog'], + 'https://github.com/php-fast-forward/dev-tools', + ), + ); + } + + /** + * @return void + */ + #[Test] + public function resolveWillKeepBranchEntriesInCurrentUnreleasedAfterAReleaseMovedMain(): void + { + $target = new ChangelogDocument([ + new ChangelogRelease(ChangelogDocument::UNRELEASED_VERSION), + (new ChangelogRelease('1.2.0', '2026-04-24')) + ->withEntry(ChangelogEntryType::Changed, 'Already released main entry'), + ]); + $source = new ChangelogDocument([ + (new ChangelogRelease(ChangelogDocument::UNRELEASED_VERSION)) + ->withEntry(ChangelogEntryType::Changed, 'Already released main entry') + ->withEntry(ChangelogEntryType::Changed, 'Keep branch-only entry'), + ]); + + $this->parser->parse('released main changelog') + ->willReturn($target) + ->shouldBeCalledOnce(); + $this->parser->parse('branch changelog') + ->willReturn($source) + ->shouldBeCalledOnce(); + $this->renderer->render( + Argument::that(static function (ChangelogDocument $document): bool { + $unreleased = $document->getUnreleased(); + $release = $document->getRelease('1.2.0'); + + return ['Keep branch-only entry'] === $unreleased->getEntriesFor(ChangelogEntryType::Changed) + && $release instanceof ChangelogRelease + && ['Already released main entry'] === $release->getEntriesFor(ChangelogEntryType::Changed); + }), + null, + )->willReturn('resolved changelog') + ->shouldBeCalledOnce(); + + self::assertSame( + 'resolved changelog', + $this->resolver->resolve('released main changelog', ['branch changelog']), + ); + } + + /** + * @return void + */ + #[Test] + public function resolveWillDeduplicateEntriesFromMultipleSources(): void + { + $target = new ChangelogDocument([ + (new ChangelogRelease(ChangelogDocument::UNRELEASED_VERSION)) + ->withEntry(ChangelogEntryType::Added, 'Keep main addition'), + ]); + $firstSource = new ChangelogDocument([ + (new ChangelogRelease(ChangelogDocument::UNRELEASED_VERSION)) + ->withEntry(ChangelogEntryType::Added, 'Keep branch addition'), + ]); + $secondSource = new ChangelogDocument([ + (new ChangelogRelease(ChangelogDocument::UNRELEASED_VERSION)) + ->withEntry(ChangelogEntryType::Added, 'Keep branch addition'), + ]); + + $this->parser->parse('main changelog') + ->willReturn($target) + ->shouldBeCalledOnce(); + $this->parser->parse('first branch changelog') + ->willReturn($firstSource) + ->shouldBeCalledOnce(); + $this->parser->parse('second branch changelog') + ->willReturn($secondSource) + ->shouldBeCalledOnce(); + $this->renderer->render( + Argument::that(static fn(ChangelogDocument $document): bool => [ + 'Keep main addition', + 'Keep branch addition', + ] === $document->getUnreleased() + ->getEntriesFor(ChangelogEntryType::Added)), + null, + )->willReturn('resolved changelog') + ->shouldBeCalledOnce(); + + self::assertSame( + 'resolved changelog', + $this->resolver->resolve('main changelog', ['first branch changelog', 'second branch changelog']), + ); + } +} diff --git a/tests/Config/ComposerDependencyAnalyserConfigTest.php b/tests/Config/ComposerDependencyAnalyserConfigTest.php index 83b8e3c7b..902f086e9 100644 --- a/tests/Config/ComposerDependencyAnalyserConfigTest.php +++ b/tests/Config/ComposerDependencyAnalyserConfigTest.php @@ -62,7 +62,7 @@ public function configureWillIgnoreShadowDependenciesByDefault(): void ->shouldIgnoreError(ErrorType::SHADOW_DEPENDENCY, null, 'vendor/shadow-package') ); } finally { - self::restoreShadowDependenciesEnvironment($originalValue); + $this->restoreShadowDependenciesEnvironment($originalValue); } } @@ -83,7 +83,7 @@ public function configureWillKeepShadowDependenciesVisibleWhenRequested(): void ->shouldIgnoreError(ErrorType::SHADOW_DEPENDENCY, null, 'vendor/shadow-package') ); } finally { - self::restoreShadowDependenciesEnvironment($originalValue); + $this->restoreShadowDependenciesEnvironment($originalValue); } } @@ -166,7 +166,7 @@ public function applyIgnoresShadowDependenciesWillReturnTheSameConfigurationInst * * @return void */ - private static function restoreShadowDependenciesEnvironment(false|string $value): void + private function restoreShadowDependenciesEnvironment(false|string $value): void { if (false === $value) { putenv(ComposerDependencyAnalyserConfig::ENV_SHOW_SHADOW_DEPENDENCIES); diff --git a/tests/Config/ECSConfigTest.php b/tests/Config/ECSConfigTest.php index d2e6849c0..6e6153a53 100644 --- a/tests/Config/ECSConfigTest.php +++ b/tests/Config/ECSConfigTest.php @@ -20,6 +20,8 @@ namespace FastForward\DevTools\Tests\Config; use FastForward\DevTools\Config\ECSConfig; +use FastForward\DevTools\Path\ManagedWorkspace; +use FastForward\DevTools\Path\WorkingProjectPathResolver; use PhpCsFixer\Fixer\Import\GlobalNamespaceImportFixer; use PhpCsFixer\Fixer\Phpdoc\GeneralPhpdocAnnotationRemoveFixer; use PhpCsFixer\Fixer\Phpdoc\NoEmptyPhpdocFixer; @@ -31,8 +33,8 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Symplify\EasyCodingStandard\Configuration\ECSConfigBuilder; -use FastForward\DevTools\Path\ManagedWorkspace; -use FastForward\DevTools\Path\WorkingProjectPathResolver; + +use function Safe\getcwd; #[CoversClass(ECSConfig::class)] #[UsesClass(ManagedWorkspace::class)]