Skip to content

Commit f05e53a

Browse files
committed
Add predictable conflict auto-resolution
1 parent d6e865f commit f05e53a

12 files changed

Lines changed: 739 additions & 7 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Resolve Predictable PR Conflicts
2+
description: Resolve open pull requests conflicted only by CHANGELOG.md drift or workflow-managed .github/wiki pointers.
3+
4+
inputs:
5+
base-ref:
6+
description: Base branch inspected for open pull requests.
7+
required: false
8+
default: main
9+
pull-request-number:
10+
description: Optional pull request number to inspect. When omitted, all open pull requests targeting the base branch are scanned.
11+
required: false
12+
default: ''
13+
14+
runs:
15+
using: composite
16+
steps:
17+
- name: Resolve predictable conflicts
18+
shell: bash
19+
env:
20+
INPUT_BASE_REF: ${{ inputs.base-ref }}
21+
INPUT_PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }}
22+
DEV_TOOLS_CONFLICT_RESOLVER: ${{ github.action_path }}/resolve-changelog.php
23+
run: ${{ github.action_path }}/run.sh
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use FastForward\DevTools\Changelog\Conflict\UnreleasedChangelogConflictResolver;
6+
use FastForward\DevTools\Changelog\Parser\ChangelogParser;
7+
use FastForward\DevTools\Changelog\Renderer\MarkdownRenderer;
8+
9+
$autoload = getenv('DEV_TOOLS_AUTO_RESOLVE_AUTOLOAD') ?: getcwd() . '/vendor/autoload.php';
10+
11+
if (! is_file($autoload)) {
12+
fwrite(STDERR, sprintf("Composer autoload file not found: %s\n", $autoload));
13+
14+
exit(2);
15+
}
16+
17+
require $autoload;
18+
19+
$options = getopt('', [
20+
'target:',
21+
'source:',
22+
'output:',
23+
'repository-url::',
24+
]);
25+
26+
$target = is_string($options['target'] ?? null) ? $options['target'] : null;
27+
$source = is_string($options['source'] ?? null) ? $options['source'] : null;
28+
$output = is_string($options['output'] ?? null) ? $options['output'] : null;
29+
$repositoryUrl = is_string($options['repository-url'] ?? null) ? $options['repository-url'] : null;
30+
31+
if (null === $target || null === $source || null === $output) {
32+
fwrite(STDERR, "Usage: resolve-changelog.php --target=<file> --source=<file> --output=<file> [--repository-url=<url>]\n");
33+
34+
exit(2);
35+
}
36+
37+
$targetContents = file_get_contents($target);
38+
$sourceContents = file_get_contents($source);
39+
40+
if (! is_string($targetContents) || ! is_string($sourceContents)) {
41+
fwrite(STDERR, "Unable to read changelog conflict stages.\n");
42+
43+
exit(2);
44+
}
45+
46+
$resolver = new UnreleasedChangelogConflictResolver(new ChangelogParser(), new MarkdownRenderer());
47+
$resolved = $resolver->resolve($targetContents, [$sourceContents], $repositoryUrl);
48+
49+
file_put_contents($output, $resolved);
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
base_ref="${INPUT_BASE_REF:-main}"
5+
pull_request_number="${INPUT_PULL_REQUEST_NUMBER:-}"
6+
allowed_conflicts=$'CHANGELOG.md\n.github/wiki'
7+
resolved_count=0
8+
skipped_count=0
9+
failed_count=0
10+
summary_file="${GITHUB_STEP_SUMMARY:-}"
11+
12+
append_summary() {
13+
local message="$1"
14+
15+
if [ -n "${summary_file}" ]; then
16+
printf '%s\n' "${message}" >> "${summary_file}"
17+
else
18+
printf '%s\n' "${message}"
19+
fi
20+
}
21+
22+
collect_pull_requests() {
23+
if [ -n "${pull_request_number}" ]; then
24+
gh pr view "${pull_request_number}" \
25+
--json number,title,url,baseRefName,headRefName,headRepositoryOwner,isCrossRepository,mergeable
26+
27+
return
28+
fi
29+
30+
gh pr list \
31+
--state open \
32+
--base "${base_ref}" \
33+
--json number,title,url,baseRefName,headRefName,headRepositoryOwner,isCrossRepository,mergeable
34+
}
35+
36+
repository_url() {
37+
php -r '
38+
$composer = json_decode((string) file_get_contents("composer.json"), true);
39+
$support = is_array($composer) ? ($composer["support"] ?? []) : [];
40+
$source = is_array($support) ? ($support["source"] ?? null) : null;
41+
echo is_string($source) && "" !== $source ? $source : "https://github.com/" . getenv("GITHUB_REPOSITORY");
42+
'
43+
}
44+
45+
is_allowed_conflict_scope() {
46+
local conflicts="$1"
47+
48+
while IFS= read -r file; do
49+
if [ -z "${file}" ]; then
50+
continue
51+
fi
52+
53+
if ! grep -Fx --quiet -- "${file}" <<< "${allowed_conflicts}"; then
54+
return 1
55+
fi
56+
done <<< "${conflicts}"
57+
58+
return 0
59+
}
60+
61+
resolve_pull_request() {
62+
local number="$1"
63+
local title="$2"
64+
local url="$3"
65+
local head_ref="$4"
66+
local head_owner="$5"
67+
local cross_repository="$6"
68+
local pr_base_ref="$7"
69+
local mergeable="$8"
70+
71+
append_summary "- PR #${number}: inspecting ${url}"
72+
73+
if [ "${pr_base_ref}" != "${base_ref}" ]; then
74+
append_summary " - skipped: base branch is \`${pr_base_ref}\`, expected \`${base_ref}\`"
75+
skipped_count=$((skipped_count + 1))
76+
77+
return
78+
fi
79+
80+
if [ "${cross_repository}" = "true" ] || [ "${head_owner}" != "${GITHUB_REPOSITORY_OWNER}" ]; then
81+
append_summary " - skipped: pull request branch is outside this repository"
82+
skipped_count=$((skipped_count + 1))
83+
84+
return
85+
fi
86+
87+
if [ "${mergeable}" = "MERGEABLE" ]; then
88+
append_summary " - skipped: GitHub currently reports the pull request as mergeable"
89+
skipped_count=$((skipped_count + 1))
90+
91+
return
92+
fi
93+
94+
local workdir
95+
workdir="$(mktemp -d)"
96+
trap 'rm -rf "${workdir}"' RETURN
97+
98+
git clone --no-tags "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${workdir}/repo" >/dev/null 2>&1
99+
git -C "${workdir}/repo" config user.name "github-actions[bot]"
100+
git -C "${workdir}/repo" config user.email "41898282+github-actions[bot]@users.noreply.github.com"
101+
git -C "${workdir}/repo" fetch --no-tags origin \
102+
"+refs/heads/${base_ref}:refs/remotes/origin/${base_ref}" \
103+
"+refs/heads/${head_ref}:refs/remotes/origin/${head_ref}" >/dev/null 2>&1
104+
git -C "${workdir}/repo" switch -C "${head_ref}" "refs/remotes/origin/${head_ref}" >/dev/null 2>&1
105+
106+
if git -C "${workdir}/repo" merge --no-commit --no-ff "refs/remotes/origin/${base_ref}" >/dev/null 2>&1; then
107+
git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true
108+
append_summary " - skipped: merge succeeds cleanly when checked locally"
109+
skipped_count=$((skipped_count + 1))
110+
111+
return
112+
fi
113+
114+
local conflicts
115+
conflicts="$(git -C "${workdir}/repo" diff --name-only --diff-filter=U)"
116+
117+
if [ -z "${conflicts}" ]; then
118+
git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true
119+
append_summary " - skipped: merge failed but no unmerged files were reported"
120+
skipped_count=$((skipped_count + 1))
121+
122+
return
123+
fi
124+
125+
if ! is_allowed_conflict_scope "${conflicts}"; then
126+
git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true
127+
append_summary " - skipped: conflict scope requires manual review"
128+
append_summary "$(printf '%s\n' "${conflicts}" | sed 's/^/ - `/; s/$/`/')"
129+
skipped_count=$((skipped_count + 1))
130+
131+
return
132+
fi
133+
134+
if grep -Fx --quiet -- ".github/wiki" <<< "${conflicts}"; then
135+
git -C "${workdir}/repo" checkout --ours -- .github/wiki
136+
git -C "${workdir}/repo" add .github/wiki
137+
fi
138+
139+
if grep -Fx --quiet -- "CHANGELOG.md" <<< "${conflicts}"; then
140+
# During `git merge base into PR`, stage 2 is the PR side and stage 3 is the base branch side.
141+
git -C "${workdir}/repo" show :2:CHANGELOG.md > "${workdir}/CHANGELOG.ours.md"
142+
git -C "${workdir}/repo" show :3:CHANGELOG.md > "${workdir}/CHANGELOG.theirs.md"
143+
(
144+
cd "${workdir}/repo"
145+
php "${DEV_TOOLS_CONFLICT_RESOLVER}" \
146+
--target="${workdir}/CHANGELOG.theirs.md" \
147+
--source="${workdir}/CHANGELOG.ours.md" \
148+
--output="CHANGELOG.md" \
149+
--repository-url="$(repository_url)"
150+
)
151+
git -C "${workdir}/repo" add CHANGELOG.md
152+
fi
153+
154+
if [ -n "$(git -C "${workdir}/repo" diff --name-only --diff-filter=U)" ]; then
155+
git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true
156+
append_summary " - failed: predictable files were handled, but unmerged paths remain"
157+
failed_count=$((failed_count + 1))
158+
159+
return
160+
fi
161+
162+
git -C "${workdir}/repo" commit -m "Resolve predictable conflicts with ${base_ref}" >/dev/null 2>&1
163+
git -C "${workdir}/repo" push origin "HEAD:${head_ref}" >/dev/null 2>&1
164+
append_summary " - resolved: pushed an automatic conflict-resolution commit for \`${title}\`"
165+
resolved_count=$((resolved_count + 1))
166+
}
167+
168+
if [ -z "${GH_TOKEN:-}" ]; then
169+
echo "GH_TOKEN is required." >&2
170+
171+
exit 1
172+
fi
173+
174+
append_summary "## Predictable Conflict Resolution Summary"
175+
append_summary ""
176+
append_summary "- Base branch: \`${base_ref}\`"
177+
178+
pull_requests="$(collect_pull_requests)"
179+
180+
if [ "${pull_requests:0:1}" = "{" ]; then
181+
pull_requests="[${pull_requests}]"
182+
fi
183+
184+
while IFS= read -r pull_request; do
185+
[ -n "${pull_request}" ] || continue
186+
187+
resolve_pull_request \
188+
"$(jq -r '.number' <<< "${pull_request}")" \
189+
"$(jq -r '.title' <<< "${pull_request}")" \
190+
"$(jq -r '.url' <<< "${pull_request}")" \
191+
"$(jq -r '.headRefName' <<< "${pull_request}")" \
192+
"$(jq -r '.headRepositoryOwner.login' <<< "${pull_request}")" \
193+
"$(jq -r '.isCrossRepository' <<< "${pull_request}")" \
194+
"$(jq -r '.baseRefName' <<< "${pull_request}")" \
195+
"$(jq -r '.mergeable // "UNKNOWN"' <<< "${pull_request}")"
196+
done < <(jq -c '.[]' <<< "${pull_requests}")
197+
198+
append_summary ""
199+
append_summary "- Resolved: ${resolved_count}"
200+
append_summary "- Skipped: ${skipped_count}"
201+
append_summary "- Failed: ${failed_count}"
202+
203+
if [ "${failed_count}" -gt 0 ]; then
204+
exit 1
205+
fi
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: Auto-resolve Predictable Conflicts
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
base-ref:
7+
description: Base branch inspected for open pull requests.
8+
required: false
9+
type: string
10+
default: main
11+
pull-request-number:
12+
description: Optional pull request number to inspect.
13+
required: false
14+
type: string
15+
default: ''
16+
workflow_dispatch:
17+
inputs:
18+
base-ref:
19+
description: Base branch inspected for open pull requests.
20+
required: false
21+
type: string
22+
default: main
23+
pull-request-number:
24+
description: Optional pull request number to inspect. Leave empty to scan open pull requests targeting the base branch.
25+
required: false
26+
type: string
27+
default: ''
28+
push:
29+
branches: [ "main" ]
30+
pull_request:
31+
types: [opened, reopened, synchronize, ready_for_review]
32+
33+
permissions:
34+
contents: write
35+
pull-requests: write
36+
37+
concurrency:
38+
group: ${{ github.event_name == 'pull_request' && format('auto-resolve-conflicts-pr-{0}', github.event.pull_request.number) || format('auto-resolve-conflicts-{0}', github.ref) }}
39+
cancel-in-progress: true
40+
41+
env:
42+
FORCE_COLOR: '1'
43+
44+
jobs:
45+
resolve_predictable_conflicts:
46+
name: Resolve Predictable Conflicts
47+
runs-on: ubuntu-latest
48+
env:
49+
BASE_REF: ${{ inputs.base-ref || github.event.pull_request.base.ref || github.event.repository.default_branch || 'main' }}
50+
PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number || github.event.pull_request.number || '' }}
51+
AUTO_RESOLVE_ROOT_VERSION: ${{ github.event_name == 'pull_request' && format('dev-{0}', github.event.pull_request.head.ref) || 'dev-main' }}
52+
GH_TOKEN: ${{ github.token }}
53+
54+
steps:
55+
- uses: actions/checkout@v6
56+
with:
57+
fetch-depth: 0
58+
59+
- name: Checkout dev-tools workflow action source
60+
uses: actions/checkout@v6
61+
with:
62+
repository: php-fast-forward/dev-tools
63+
ref: ${{ github.repository == 'php-fast-forward/dev-tools' && github.sha || 'main' }}
64+
path: .dev-tools-actions
65+
sparse-checkout: |
66+
.github/actions
67+
68+
- name: Setup PHP and install dependencies
69+
uses: ./.dev-tools-actions/.github/actions/php/setup-composer
70+
with:
71+
php-version: '8.3'
72+
root-version: ${{ env.AUTO_RESOLVE_ROOT_VERSION }}
73+
install-options: --prefer-dist --no-progress --no-interaction --no-plugins --no-scripts
74+
75+
- name: Resolve predictable pull request conflicts
76+
uses: ./.dev-tools-actions/.github/actions/github/resolve-predictable-conflicts
77+
with:
78+
base-ref: ${{ env.BASE_REF }}
79+
pull-request-number: ${{ env.PULL_REQUEST_NUMBER }}

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Auto-resolve pull-request conflicts limited to workflow-managed `.github/wiki` pointers and `CHANGELOG.md` `Unreleased` drift (#192)
13+
1014
### Fixed
1115

1216
- 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)

docs/advanced/branch-protection-and-bot-commits.rst

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ test run also mirrors the matrix result into commit statuses named
113113
concurrency cancels older in-progress runs for the same pull request so the
114114
newest commit owns the required check contexts.
115115

116+
The predictable-conflict workflow MAY also refresh pull request branches when
117+
the only conflicts are ``.github/wiki`` pointer drift and/or ``CHANGELOG.md``
118+
``Unreleased`` drift. It keeps pull request wiki preview pointers on the branch
119+
side and replays branch-only changelog entries into the current base
120+
``Unreleased`` section, which avoids placing new entries under a freshly
121+
published release after ``main`` moved.
122+
116123
At a high level, the workflows need permission to read repository contents,
117124
write generated preview commits, update pull request comments, and publish Pages
118125
content. Keep those permissions scoped to the workflow jobs that actually need
@@ -172,8 +179,10 @@ Resolving ``.github/wiki`` Pointer Conflicts
172179
--------------------------------------------
173180

174181
Submodule pointer conflicts happen when ``main`` and the pull request point to
175-
different generated wiki commits. Resolve them by rebasing the pull request and
176-
choosing the preview wiki commit that belongs to the pull request.
182+
different generated wiki commits. The predictable-conflict workflow can resolve
183+
this automatically when the conflict scope is limited to ``.github/wiki`` and
184+
``CHANGELOG.md``. When resolving it manually, rebase the pull request and choose
185+
the preview wiki commit that belongs to the pull request.
177186

178187
For pull request ``123``:
179188

0 commit comments

Comments
 (0)