Skip to content

Commit a244590

Browse files
authored
CRE-1827: detect potential merge conflicts (#21255)
* cre-1827: detect potential merge conflicts * cre-1827: use gh api for comment * cre-1827: another comment attempt * cre-1827: logic improved * cre-1827: optimized for overlapping files only * cre-1827: review improvements * cre-1827: review improvement
1 parent c52a20d commit a244590

2 files changed

Lines changed: 190 additions & 0 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
name: Detect conflicting PRs
2+
description: Detects PRs that would conflict with the current PR and posts a comment
3+
4+
inputs:
5+
github-token:
6+
description: GitHub token with pull-requests write and contents read permissions
7+
required: true
8+
repository:
9+
description: Repository in owner/repo format
10+
required: true
11+
pr-number:
12+
description: Current PR number
13+
required: true
14+
head-ref:
15+
description: Head branch of the current PR
16+
required: true
17+
base-ref:
18+
description: Base branch of the current PR
19+
required: true
20+
21+
runs:
22+
using: composite
23+
steps:
24+
- name: Fetch base and current branch
25+
shell: bash
26+
env:
27+
BASE_REF: ${{ inputs.base-ref }}
28+
HEAD_REF: ${{ inputs.head-ref }}
29+
run: |
30+
git fetch origin "${BASE_REF}" "${HEAD_REF}"
31+
32+
- name: Detect conflicts and update comment
33+
shell: bash
34+
env:
35+
GH_TOKEN: ${{ inputs.github-token }}
36+
REPO: ${{ inputs.repository }}
37+
CURRENT_PR: ${{ inputs.pr-number }}
38+
CURRENT_BRANCH: ${{ inputs.head-ref }}
39+
BASE_BRANCH: ${{ inputs.base-ref }}
40+
run: |
41+
set -e
42+
43+
MARKER="<!-- conflict-check -->"
44+
45+
update_comment() {
46+
local body="$1"
47+
local comment_id
48+
comment_id=$(gh api --method GET "repos/${REPO}/issues/${CURRENT_PR}/comments" \
49+
--jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
50+
| head -n1)
51+
52+
if [ -n "$comment_id" ]; then
53+
gh api --method PATCH "repos/${REPO}/issues/comments/${comment_id}" \
54+
-f body="$body"
55+
else
56+
gh api --method POST "repos/${REPO}/issues/${CURRENT_PR}/comments" \
57+
-f body="$body"
58+
fi
59+
}
60+
61+
# Get files changed in current PR
62+
current_files=$(gh api --method GET --paginate "repos/${REPO}/pulls/${CURRENT_PR}/files" --jq '.[].filename' | sort -u)
63+
if [ -z "$current_files" ]; then
64+
current_files_count=0
65+
else
66+
current_files_count=$(echo "$current_files" | wc -l | tr -d ' ')
67+
fi
68+
echo "Current PR touches $current_files_count files"
69+
70+
# Early exit if no files changed
71+
if [ "$current_files_count" -eq 0 ]; then
72+
echo "No files changed, skipping conflict check"
73+
update_comment "${MARKER}
74+
✅ No conflicts with other open PRs targeting \`${BASE_BRANCH}\`"
75+
exit 0
76+
fi
77+
78+
SINCE_DATE=$(date -u -d '21 days ago' '+%Y-%m-%d' 2>/dev/null || date -u -v-21d '+%Y-%m-%d')
79+
SEARCH_QUERY="repo:${REPO} is:pr is:open base:${BASE_BRANCH} created:>=${SINCE_DATE}"
80+
echo "Searching PRs with: ${SEARCH_QUERY}"
81+
82+
prs=$(gh api graphql --paginate -f query='
83+
query($searchQuery: String!, $endCursor: String) {
84+
search(query: $searchQuery, type: ISSUE, first: 100, after: $endCursor) {
85+
pageInfo { hasNextPage endCursor }
86+
nodes { ... on PullRequest { number } }
87+
}
88+
}' -f searchQuery="${SEARCH_QUERY}" --jq '.data.search.nodes[].number')
89+
90+
if [ -z "$prs" ]; then
91+
total_prs=0
92+
else
93+
total_prs=$(echo "$prs" | wc -w | tr -d ' ')
94+
fi
95+
echo "Found $total_prs open PRs targeting ${BASE_BRANCH} (created in last 21 days)"
96+
97+
# Early exit if no other PRs to check
98+
if [ "$total_prs" -eq 0 ]; then
99+
echo "No recent open PRs, skipping conflict check"
100+
update_comment "${MARKER}
101+
✅ No conflicts with other open PRs targeting \`${BASE_BRANCH}\`"
102+
exit 0
103+
fi
104+
105+
# Find PRs with overlapping files (fast API check)
106+
candidates=()
107+
for pr in $prs; do
108+
[ "$pr" = "$CURRENT_PR" ] && continue
109+
110+
other_files=$(gh api --method GET --paginate "repos/${REPO}/pulls/${pr}/files" --jq '.[].filename' | sort -u)
111+
112+
# Check for file intersection
113+
if comm -12 <(echo "$current_files") <(echo "$other_files") | grep -q .; then
114+
candidates+=("$pr")
115+
fi
116+
done
117+
118+
echo "Found ${#candidates[@]} PRs with overlapping files, checking for conflicts..."
119+
120+
if [ ${#candidates[@]} -eq 0 ]; then
121+
conflicts=()
122+
else
123+
# Fetch only the PR refs we need
124+
fetch_refs=""
125+
for pr in "${candidates[@]}"; do
126+
fetch_refs="$fetch_refs +refs/pull/${pr}/head:refs/remotes/origin/pr/${pr}"
127+
done
128+
git fetch origin $fetch_refs
129+
130+
mkdir -p worktrees
131+
conflicts=()
132+
133+
for pr in "${candidates[@]}"; do
134+
other_branch="origin/pr/$pr"
135+
dir="worktrees/$pr"
136+
137+
git worktree add -q --detach "$dir" "origin/${BASE_BRANCH}"
138+
139+
if git -C "$dir" merge --no-commit --no-ff "$other_branch" >/dev/null 2>&1; then
140+
git -C "$dir" -c user.email="ci@local" -c user.name="CI" commit --no-edit -m "temp" >/dev/null 2>&1
141+
142+
if ! git -C "$dir" merge --no-commit --no-ff "origin/${CURRENT_BRANCH}" >/dev/null 2>&1; then
143+
conflicts+=("#$pr")
144+
fi
145+
fi
146+
147+
git worktree remove -f "$dir"
148+
done
149+
fi
150+
151+
echo "Found ${#conflicts[@]} conflicting PRs"
152+
153+
if [ ${#conflicts[@]} -eq 0 ]; then
154+
update_comment "${MARKER}
155+
✅ No conflicts with other open PRs targeting \`${BASE_BRANCH}\`"
156+
else
157+
update_comment "${MARKER}
158+
❌ Conflicts with:
159+
160+
$(printf '%s\n' "${conflicts[@]}")"
161+
fi
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Detect PR Conflicts
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened]
6+
7+
concurrency:
8+
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
9+
cancel-in-progress: true
10+
11+
jobs:
12+
detect-conflicts:
13+
runs-on: ubuntu-latest
14+
permissions:
15+
pull-requests: write
16+
contents: read
17+
18+
steps:
19+
- uses: actions/checkout@v4
20+
with:
21+
fetch-depth: 1
22+
23+
- uses: ./.github/actions/prconflicts
24+
with:
25+
github-token: ${{ github.token }}
26+
repository: ${{ github.repository }}
27+
pr-number: ${{ github.event.pull_request.number }}
28+
head-ref: ${{ github.head_ref }}
29+
base-ref: ${{ github.base_ref }}

0 commit comments

Comments
 (0)