Skip to content

Commit e9f91cd

Browse files
[ci] Auto-resolve predictable PR conflicts (#237)
* Add predictable conflict auto-resolution * Update wiki submodule pointer for PR #237 * Dispatch tests after predictable conflict resolutions --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent d6e865f commit e9f91cd

13 files changed

Lines changed: 783 additions & 8 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: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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+
dispatch_required_tests() {
62+
local head_ref="$1"
63+
64+
if ! gh workflow view tests.yml >/dev/null 2>&1; then
65+
append_summary " - tests dispatch skipped: tests.yml workflow was not found"
66+
67+
return 0
68+
fi
69+
70+
if gh workflow run tests.yml --ref "${head_ref}" -f max-outdated=-1 -f publish-required-statuses=true >/dev/null 2>&1; then
71+
append_summary " - tests dispatch requested with required status mirroring"
72+
73+
return 0
74+
fi
75+
76+
if gh workflow run tests.yml --ref "${head_ref}" >/dev/null 2>&1; then
77+
append_summary " - tests dispatch requested without required status mirroring"
78+
79+
return 0
80+
fi
81+
82+
append_summary " - failed: resolved branch was pushed, but tests.yml could not be dispatched"
83+
84+
return 1
85+
}
86+
87+
resolve_pull_request() {
88+
local number="$1"
89+
local title="$2"
90+
local url="$3"
91+
local head_ref="$4"
92+
local head_owner="$5"
93+
local cross_repository="$6"
94+
local pr_base_ref="$7"
95+
local mergeable="$8"
96+
97+
append_summary "- PR #${number}: inspecting ${url}"
98+
99+
if [ "${pr_base_ref}" != "${base_ref}" ]; then
100+
append_summary " - skipped: base branch is \`${pr_base_ref}\`, expected \`${base_ref}\`"
101+
skipped_count=$((skipped_count + 1))
102+
103+
return
104+
fi
105+
106+
if [ "${cross_repository}" = "true" ] || [ "${head_owner}" != "${GITHUB_REPOSITORY_OWNER}" ]; then
107+
append_summary " - skipped: pull request branch is outside this repository"
108+
skipped_count=$((skipped_count + 1))
109+
110+
return
111+
fi
112+
113+
if [ "${mergeable}" = "MERGEABLE" ]; then
114+
append_summary " - skipped: GitHub currently reports the pull request as mergeable"
115+
skipped_count=$((skipped_count + 1))
116+
117+
return
118+
fi
119+
120+
local workdir
121+
workdir="$(mktemp -d)"
122+
trap 'rm -rf "${workdir}"' RETURN
123+
124+
git clone --no-tags "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${workdir}/repo" >/dev/null 2>&1
125+
git -C "${workdir}/repo" config user.name "github-actions[bot]"
126+
git -C "${workdir}/repo" config user.email "41898282+github-actions[bot]@users.noreply.github.com"
127+
git -C "${workdir}/repo" fetch --no-tags origin \
128+
"+refs/heads/${base_ref}:refs/remotes/origin/${base_ref}" \
129+
"+refs/heads/${head_ref}:refs/remotes/origin/${head_ref}" >/dev/null 2>&1
130+
git -C "${workdir}/repo" switch -C "${head_ref}" "refs/remotes/origin/${head_ref}" >/dev/null 2>&1
131+
132+
if git -C "${workdir}/repo" merge --no-commit --no-ff "refs/remotes/origin/${base_ref}" >/dev/null 2>&1; then
133+
git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true
134+
append_summary " - skipped: merge succeeds cleanly when checked locally"
135+
skipped_count=$((skipped_count + 1))
136+
137+
return
138+
fi
139+
140+
local conflicts
141+
conflicts="$(git -C "${workdir}/repo" diff --name-only --diff-filter=U)"
142+
143+
if [ -z "${conflicts}" ]; then
144+
git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true
145+
append_summary " - skipped: merge failed but no unmerged files were reported"
146+
skipped_count=$((skipped_count + 1))
147+
148+
return
149+
fi
150+
151+
if ! is_allowed_conflict_scope "${conflicts}"; then
152+
git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true
153+
append_summary " - skipped: conflict scope requires manual review"
154+
append_summary "$(printf '%s\n' "${conflicts}" | sed 's/^/ - `/; s/$/`/')"
155+
skipped_count=$((skipped_count + 1))
156+
157+
return
158+
fi
159+
160+
if grep -Fx --quiet -- ".github/wiki" <<< "${conflicts}"; then
161+
git -C "${workdir}/repo" checkout --ours -- .github/wiki
162+
git -C "${workdir}/repo" add .github/wiki
163+
fi
164+
165+
if grep -Fx --quiet -- "CHANGELOG.md" <<< "${conflicts}"; then
166+
# During `git merge base into PR`, stage 2 is the PR side and stage 3 is the base branch side.
167+
git -C "${workdir}/repo" show :2:CHANGELOG.md > "${workdir}/CHANGELOG.ours.md"
168+
git -C "${workdir}/repo" show :3:CHANGELOG.md > "${workdir}/CHANGELOG.theirs.md"
169+
(
170+
cd "${workdir}/repo"
171+
php "${DEV_TOOLS_CONFLICT_RESOLVER}" \
172+
--target="${workdir}/CHANGELOG.theirs.md" \
173+
--source="${workdir}/CHANGELOG.ours.md" \
174+
--output="CHANGELOG.md" \
175+
--repository-url="$(repository_url)"
176+
)
177+
git -C "${workdir}/repo" add CHANGELOG.md
178+
fi
179+
180+
if [ -n "$(git -C "${workdir}/repo" diff --name-only --diff-filter=U)" ]; then
181+
git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true
182+
append_summary " - failed: predictable files were handled, but unmerged paths remain"
183+
failed_count=$((failed_count + 1))
184+
185+
return
186+
fi
187+
188+
git -C "${workdir}/repo" commit -m "Resolve predictable conflicts with ${base_ref}" >/dev/null 2>&1
189+
git -C "${workdir}/repo" push origin "HEAD:${head_ref}" >/dev/null 2>&1
190+
append_summary " - resolved: pushed an automatic conflict-resolution commit for \`${title}\`"
191+
192+
if ! dispatch_required_tests "${head_ref}"; then
193+
failed_count=$((failed_count + 1))
194+
195+
return
196+
fi
197+
198+
resolved_count=$((resolved_count + 1))
199+
}
200+
201+
if [ -z "${GH_TOKEN:-}" ]; then
202+
echo "GH_TOKEN is required." >&2
203+
204+
exit 1
205+
fi
206+
207+
append_summary "## Predictable Conflict Resolution Summary"
208+
append_summary ""
209+
append_summary "- Base branch: \`${base_ref}\`"
210+
211+
pull_requests="$(collect_pull_requests)"
212+
213+
if [ "${pull_requests:0:1}" = "{" ]; then
214+
pull_requests="[${pull_requests}]"
215+
fi
216+
217+
while IFS= read -r pull_request; do
218+
[ -n "${pull_request}" ] || continue
219+
220+
resolve_pull_request \
221+
"$(jq -r '.number' <<< "${pull_request}")" \
222+
"$(jq -r '.title' <<< "${pull_request}")" \
223+
"$(jq -r '.url' <<< "${pull_request}")" \
224+
"$(jq -r '.headRefName' <<< "${pull_request}")" \
225+
"$(jq -r '.headRepositoryOwner.login' <<< "${pull_request}")" \
226+
"$(jq -r '.isCrossRepository' <<< "${pull_request}")" \
227+
"$(jq -r '.baseRefName' <<< "${pull_request}")" \
228+
"$(jq -r '.mergeable // "UNKNOWN"' <<< "${pull_request}")"
229+
done < <(jq -c '.[]' <<< "${pull_requests}")
230+
231+
append_summary ""
232+
append_summary "- Resolved: ${resolved_count}"
233+
append_summary "- Skipped: ${skipped_count}"
234+
append_summary "- Failed: ${failed_count}"
235+
236+
if [ "${failed_count}" -gt 0 ]; then
237+
exit 1
238+
fi

.github/wiki

Submodule wiki updated from d8aa7ba to 6523430
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
actions: write
35+
contents: write
36+
pull-requests: write
37+
38+
concurrency:
39+
group: ${{ github.event_name == 'pull_request' && format('auto-resolve-conflicts-pr-{0}', github.event.pull_request.number) || format('auto-resolve-conflicts-{0}', github.ref) }}
40+
cancel-in-progress: true
41+
42+
env:
43+
FORCE_COLOR: '1'
44+
45+
jobs:
46+
resolve_predictable_conflicts:
47+
name: Resolve Predictable Conflicts
48+
runs-on: ubuntu-latest
49+
env:
50+
BASE_REF: ${{ inputs.base-ref || github.event.pull_request.base.ref || github.event.repository.default_branch || 'main' }}
51+
PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number || github.event.pull_request.number || '' }}
52+
AUTO_RESOLVE_ROOT_VERSION: ${{ github.event_name == 'pull_request' && format('dev-{0}', github.event.pull_request.head.ref) || 'dev-main' }}
53+
GH_TOKEN: ${{ github.token }}
54+
55+
steps:
56+
- uses: actions/checkout@v6
57+
with:
58+
fetch-depth: 0
59+
60+
- name: Checkout dev-tools workflow action source
61+
uses: actions/checkout@v6
62+
with:
63+
repository: php-fast-forward/dev-tools
64+
ref: ${{ github.repository == 'php-fast-forward/dev-tools' && github.sha || 'main' }}
65+
path: .dev-tools-actions
66+
sparse-checkout: |
67+
.github/actions
68+
69+
- name: Setup PHP and install dependencies
70+
uses: ./.dev-tools-actions/.github/actions/php/setup-composer
71+
with:
72+
php-version: '8.3'
73+
root-version: ${{ env.AUTO_RESOLVE_ROOT_VERSION }}
74+
install-options: --prefer-dist --no-progress --no-interaction --no-plugins --no-scripts
75+
76+
- name: Resolve predictable pull request conflicts
77+
uses: ./.dev-tools-actions/.github/actions/github/resolve-predictable-conflicts
78+
with:
79+
base-ref: ${{ env.BASE_REF }}
80+
pull-request-number: ${{ env.PULL_REQUEST_NUMBER }}

0 commit comments

Comments
 (0)