From 2a5855c3853ecd4c75e65b8260c1b801cc6b0115 Mon Sep 17 00:00:00 2001 From: mchain0 Date: Fri, 20 Feb 2026 13:35:01 +0100 Subject: [PATCH 1/7] cre-1827: detect potential merge conflicts --- .github/actions/prconflicts/action.yaml | 95 +++++++++++++++++++++++++ .github/workflows/pr-conflicts.yaml | 29 ++++++++ 2 files changed, 124 insertions(+) create mode 100644 .github/actions/prconflicts/action.yaml create mode 100644 .github/workflows/pr-conflicts.yaml diff --git a/.github/actions/prconflicts/action.yaml b/.github/actions/prconflicts/action.yaml new file mode 100644 index 00000000000..18bb5af34ec --- /dev/null +++ b/.github/actions/prconflicts/action.yaml @@ -0,0 +1,95 @@ +name: Detect conflicting PRs +description: Detects PRs that would conflict with the current PR and posts a comment + +inputs: + github-token: + description: GitHub token with pull-requests write and contents read permissions + required: true + repository: + description: Repository in owner/repo format + required: true + pr-number: + description: Current PR number + required: true + head-ref: + description: Head branch of the current PR + required: true + base-ref: + description: Base branch of the current PR + required: true + +runs: + using: composite + steps: + - name: Fetch branches + shell: bash + run: | + git fetch origin \ + ${{ inputs.head-ref }} \ + '+refs/pull/*/head:refs/remotes/origin/pr/*' + + - name: Detect conflicts and update comment + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + REPO: ${{ inputs.repository }} + CURRENT_PR: ${{ inputs.pr-number }} + CURRENT_BRANCH: ${{ inputs.head-ref }} + BASE_BRANCH: ${{ inputs.base-ref }} + run: | + set -e + + MARKER="" + + mkdir -p worktrees + + conflicts=() + + prs=$(gh api repos/$REPO/pulls \ + -f state=open \ + -f base=$BASE_BRANCH \ + --jq '.[].number') + + for pr in $prs + do + [ "$pr" = "$CURRENT_PR" ] && continue + + branch="origin/pr/$pr" + dir="worktrees/$pr" + + git worktree add -q "$dir" "$CURRENT_BRANCH" + + if ! git -C "$dir" merge --no-commit --no-ff "$branch" >/dev/null 2>&1 + then + conflicts+=("#$pr") + fi + + git worktree remove -f "$dir" + done + + if [ ${#conflicts[@]} -eq 0 ]; then + BODY="$MARKER + ✅ No conflicts with other open PRs targeting \`$BASE_BRANCH\`" + FAIL=0 + else + BODY="$MARKER + ❌ Conflicts with: + + $(printf '%s\n' "${conflicts[@]}")" + FAIL=1 + fi + + COMMENT_ID=$(gh api repos/$REPO/issues/$CURRENT_PR/comments \ + --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" \ + | head -n1) + + if [ -n "$COMMENT_ID" ]; then + gh api \ + repos/$REPO/issues/comments/$COMMENT_ID \ + -X PATCH \ + -f body="$BODY" + else + gh pr comment $CURRENT_PR --body "$BODY" + fi + + exit 0 diff --git a/.github/workflows/pr-conflicts.yaml b/.github/workflows/pr-conflicts.yaml new file mode 100644 index 00000000000..0ad849b4503 --- /dev/null +++ b/.github/workflows/pr-conflicts.yaml @@ -0,0 +1,29 @@ +name: Detect PR Conflicts + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + detect-conflicts: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ./.github/actions/prconflicts + with: + github-token: ${{ github.token }} + repository: ${{ github.repository }} + pr-number: ${{ github.event.pull_request.number }} + head-ref: ${{ github.head_ref }} + base-ref: ${{ github.base_ref }} From ea13cd73fe2b2735dcea196706413bdde666a038 Mon Sep 17 00:00:00 2001 From: mchain0 Date: Fri, 20 Feb 2026 13:41:36 +0100 Subject: [PATCH 2/7] cre-1827: use gh api for comment --- .github/actions/prconflicts/action.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/prconflicts/action.yaml b/.github/actions/prconflicts/action.yaml index 18bb5af34ec..b7059100611 100644 --- a/.github/actions/prconflicts/action.yaml +++ b/.github/actions/prconflicts/action.yaml @@ -89,7 +89,9 @@ runs: -X PATCH \ -f body="$BODY" else - gh pr comment $CURRENT_PR --body "$BODY" + gh api \ + repos/$REPO/issues/$CURRENT_PR/comments \ + -f body="$BODY" fi exit 0 From 1d97ae5476fde4f431564f653c68fbbf97052b78 Mon Sep 17 00:00:00 2001 From: mchain0 Date: Fri, 20 Feb 2026 13:47:21 +0100 Subject: [PATCH 3/7] cre-1827: another comment attempt --- .github/actions/prconflicts/action.yaml | 26 ++++++++----------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/.github/actions/prconflicts/action.yaml b/.github/actions/prconflicts/action.yaml index b7059100611..b236135edd7 100644 --- a/.github/actions/prconflicts/action.yaml +++ b/.github/actions/prconflicts/action.yaml @@ -45,10 +45,7 @@ runs: conflicts=() - prs=$(gh api repos/$REPO/pulls \ - -f state=open \ - -f base=$BASE_BRANCH \ - --jq '.[].number') + prs=$(gh api --method GET "repos/${REPO}/pulls?state=open&base=${BASE_BRANCH}&per_page=100" --jq '.[].number') for pr in $prs do @@ -68,30 +65,23 @@ runs: done if [ ${#conflicts[@]} -eq 0 ]; then - BODY="$MARKER - ✅ No conflicts with other open PRs targeting \`$BASE_BRANCH\`" - FAIL=0 + BODY="${MARKER} + ✅ No conflicts with other open PRs targeting \`${BASE_BRANCH}\`" else - BODY="$MARKER + BODY="${MARKER} ❌ Conflicts with: $(printf '%s\n' "${conflicts[@]}")" - FAIL=1 fi - COMMENT_ID=$(gh api repos/$REPO/issues/$CURRENT_PR/comments \ - --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" \ + COMMENT_ID=$(gh api --method GET "repos/${REPO}/issues/${CURRENT_PR}/comments" \ + --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ | head -n1) if [ -n "$COMMENT_ID" ]; then - gh api \ - repos/$REPO/issues/comments/$COMMENT_ID \ - -X PATCH \ + gh api --method PATCH "repos/${REPO}/issues/comments/${COMMENT_ID}" \ -f body="$BODY" else - gh api \ - repos/$REPO/issues/$CURRENT_PR/comments \ + gh api --method POST "repos/${REPO}/issues/${CURRENT_PR}/comments" \ -f body="$BODY" fi - - exit 0 From 7dce7fd80de15593f805b3e31b9e36ca828a9716 Mon Sep 17 00:00:00 2001 From: mchain0 Date: Fri, 20 Feb 2026 13:56:06 +0100 Subject: [PATCH 4/7] cre-1827: logic improved --- .github/actions/prconflicts/action.yaml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/actions/prconflicts/action.yaml b/.github/actions/prconflicts/action.yaml index b236135edd7..71912117814 100644 --- a/.github/actions/prconflicts/action.yaml +++ b/.github/actions/prconflicts/action.yaml @@ -25,6 +25,7 @@ runs: shell: bash run: | git fetch origin \ + ${{ inputs.base-ref }} \ ${{ inputs.head-ref }} \ '+refs/pull/*/head:refs/remotes/origin/pr/*' @@ -51,15 +52,25 @@ runs: do [ "$pr" = "$CURRENT_PR" ] && continue - branch="origin/pr/$pr" + other_branch="origin/pr/$pr" dir="worktrees/$pr" - git worktree add -q "$dir" "$CURRENT_BRANCH" + # Start from the base branch (detached HEAD to allow multiple worktrees) + git worktree add -q --detach "$dir" "origin/${BASE_BRANCH}" - if ! git -C "$dir" merge --no-commit --no-ff "$branch" >/dev/null 2>&1 + # Simulate the other PR being merged first + if git -C "$dir" merge --no-commit --no-ff "$other_branch" >/dev/null 2>&1 then - conflicts+=("#$pr") + # Commit so we can attempt the next merge + git -C "$dir" -c user.email="ci@local" -c user.name="CI" commit --no-edit -m "temp" >/dev/null 2>&1 + + # Now try to merge current PR on top - if this fails, they conflict + if ! git -C "$dir" merge --no-commit --no-ff "origin/${CURRENT_BRANCH}" >/dev/null 2>&1 + then + conflicts+=("#$pr") + fi fi + # If other PR can't merge to base, that's their issue, not a conflict with us git worktree remove -f "$dir" done From 0fd815aeedd82503815ff6bac5d4af22ad6c6aee Mon Sep 17 00:00:00 2001 From: mchain0 Date: Fri, 20 Feb 2026 13:58:42 +0100 Subject: [PATCH 5/7] cre-1827: optimized for overlapping files only --- .github/actions/prconflicts/action.yaml | 78 ++++++++++++++++--------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/.github/actions/prconflicts/action.yaml b/.github/actions/prconflicts/action.yaml index 71912117814..8ca65d700df 100644 --- a/.github/actions/prconflicts/action.yaml +++ b/.github/actions/prconflicts/action.yaml @@ -21,13 +21,10 @@ inputs: runs: using: composite steps: - - name: Fetch branches + - name: Fetch base and current branch shell: bash run: | - git fetch origin \ - ${{ inputs.base-ref }} \ - ${{ inputs.head-ref }} \ - '+refs/pull/*/head:refs/remotes/origin/pr/*' + git fetch origin ${{ inputs.base-ref }} ${{ inputs.head-ref }} - name: Detect conflicts and update comment shell: bash @@ -42,38 +39,63 @@ runs: MARKER="" - mkdir -p worktrees + # Get files changed in current PR + current_files=$(gh api --method GET --paginate "repos/${REPO}/pulls/${CURRENT_PR}/files" --jq '.[].filename' | sort -u) + current_files_count=$(echo "$current_files" | wc -l) + echo "Current PR touches $current_files_count files" - conflicts=() + # Get all open PRs targeting same base (paginated) + prs=$(gh api --method GET --paginate "repos/${REPO}/pulls?state=open&base=${BASE_BRANCH}" --jq '.[].number') + total_prs=$(echo "$prs" | wc -w) + echo "Found $total_prs open PRs targeting ${BASE_BRANCH}" - prs=$(gh api --method GET "repos/${REPO}/pulls?state=open&base=${BASE_BRANCH}&per_page=100" --jq '.[].number') - - for pr in $prs - do + # Find PRs with overlapping files (fast API check) + candidates=() + for pr in $prs; do [ "$pr" = "$CURRENT_PR" ] && continue - other_branch="origin/pr/$pr" - dir="worktrees/$pr" + other_files=$(gh api --method GET --paginate "repos/${REPO}/pulls/${pr}/files" --jq '.[].filename' | sort -u) + + # Check for file intersection + if comm -12 <(echo "$current_files") <(echo "$other_files") | grep -q .; then + candidates+=("$pr") + fi + done + + echo "Found ${#candidates[@]} PRs with overlapping files, checking for conflicts..." + + if [ ${#candidates[@]} -eq 0 ]; then + conflicts=() + else + # Fetch only the PR refs we need + fetch_refs="" + for pr in "${candidates[@]}"; do + fetch_refs="$fetch_refs +refs/pull/${pr}/head:refs/remotes/origin/pr/${pr}" + done + git fetch origin $fetch_refs - # Start from the base branch (detached HEAD to allow multiple worktrees) - git worktree add -q --detach "$dir" "origin/${BASE_BRANCH}" + mkdir -p worktrees + conflicts=() - # Simulate the other PR being merged first - if git -C "$dir" merge --no-commit --no-ff "$other_branch" >/dev/null 2>&1 - then - # Commit so we can attempt the next merge - git -C "$dir" -c user.email="ci@local" -c user.name="CI" commit --no-edit -m "temp" >/dev/null 2>&1 + for pr in "${candidates[@]}"; do + other_branch="origin/pr/$pr" + dir="worktrees/$pr" - # Now try to merge current PR on top - if this fails, they conflict - if ! git -C "$dir" merge --no-commit --no-ff "origin/${CURRENT_BRANCH}" >/dev/null 2>&1 - then - conflicts+=("#$pr") + git worktree add -q --detach "$dir" "origin/${BASE_BRANCH}" + + if git -C "$dir" merge --no-commit --no-ff "$other_branch" >/dev/null 2>&1; then + git -C "$dir" -c user.email="ci@local" -c user.name="CI" commit --no-edit -m "temp" >/dev/null 2>&1 + + if ! git -C "$dir" merge --no-commit --no-ff "origin/${CURRENT_BRANCH}" >/dev/null 2>&1; then + conflicts+=("#$pr") + fi fi - fi - # If other PR can't merge to base, that's their issue, not a conflict with us - git worktree remove -f "$dir" - done + git worktree remove -f "$dir" + done + fi + + echo "Found ${#conflicts[@]} conflicting PRs" if [ ${#conflicts[@]} -eq 0 ]; then BODY="${MARKER} From 662691a45c0e175c9cd97108875ce24936177b8c Mon Sep 17 00:00:00 2001 From: mchain0 Date: Fri, 20 Feb 2026 15:46:39 +0100 Subject: [PATCH 6/7] cre-1827: review improvements --- .github/actions/prconflicts/action.yaml | 81 +++++++++++++++++++------ 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/.github/actions/prconflicts/action.yaml b/.github/actions/prconflicts/action.yaml index 8ca65d700df..dbd2a9aa761 100644 --- a/.github/actions/prconflicts/action.yaml +++ b/.github/actions/prconflicts/action.yaml @@ -23,8 +23,11 @@ runs: steps: - name: Fetch base and current branch shell: bash + env: + BASE_REF: ${{ inputs.base-ref }} + HEAD_REF: ${{ inputs.head-ref }} run: | - git fetch origin ${{ inputs.base-ref }} ${{ inputs.head-ref }} + git fetch origin "${BASE_REF}" "${HEAD_REF}" - name: Detect conflicts and update comment shell: bash @@ -39,15 +42,65 @@ runs: MARKER="" + update_comment() { + local body="$1" + local comment_id + comment_id=$(gh api --method GET "repos/${REPO}/issues/${CURRENT_PR}/comments" \ + --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ + | head -n1) + + if [ -n "$comment_id" ]; then + gh api --method PATCH "repos/${REPO}/issues/comments/${comment_id}" \ + -f body="$body" + else + gh api --method POST "repos/${REPO}/issues/${CURRENT_PR}/comments" \ + -f body="$body" + fi + } + # Get files changed in current PR current_files=$(gh api --method GET --paginate "repos/${REPO}/pulls/${CURRENT_PR}/files" --jq '.[].filename' | sort -u) - current_files_count=$(echo "$current_files" | wc -l) + if [ -z "$current_files" ]; then + current_files_count=0 + else + current_files_count=$(echo "$current_files" | wc -l | tr -d ' ') + fi echo "Current PR touches $current_files_count files" - # Get all open PRs targeting same base (paginated) - prs=$(gh api --method GET --paginate "repos/${REPO}/pulls?state=open&base=${BASE_BRANCH}" --jq '.[].number') - total_prs=$(echo "$prs" | wc -w) - echo "Found $total_prs open PRs targeting ${BASE_BRANCH}" + # Early exit if no files changed + if [ "$current_files_count" -eq 0 ]; then + echo "No files changed, skipping conflict check" + update_comment "${MARKER} + ✅ No conflicts with other open PRs targeting \`${BASE_BRANCH}\`" + exit 0 + fi + + SINCE_DATE=$(date -u -d '21 days ago' '+%Y-%m-%d' 2>/dev/null || date -u -v-21d '+%Y-%m-%d') + SEARCH_QUERY="repo:${REPO} is:pr is:open base:${BASE_BRANCH} created:>=${SINCE_DATE}" + echo "Searching PRs with: ${SEARCH_QUERY}" + + prs=$(gh api graphql --paginate -f query=' + query($searchQuery: String!, $endCursor: String) { + search(query: $searchQuery, type: ISSUE, first: 100, after: $endCursor) { + pageInfo { hasNextPage endCursor } + nodes { ... on PullRequest { number } } + } + }' -f searchQuery="${SEARCH_QUERY}" --jq '.data.search.nodes[].number') + + if [ -z "$prs" ]; then + total_prs=0 + else + total_prs=$(echo "$prs" | wc -w | tr -d ' ') + fi + echo "Found $total_prs open PRs targeting ${BASE_BRANCH} (created in last 21 days)" + + # Early exit if no other PRs to check + if [ "$total_prs" -eq 0 ]; then + echo "No recent open PRs, skipping conflict check" + update_comment "${MARKER} + ✅ No conflicts with other open PRs targeting \`${BASE_BRANCH}\`" + exit 0 + fi # Find PRs with overlapping files (fast API check) candidates=() @@ -98,23 +151,11 @@ runs: echo "Found ${#conflicts[@]} conflicting PRs" if [ ${#conflicts[@]} -eq 0 ]; then - BODY="${MARKER} + update_comment "${MARKER} ✅ No conflicts with other open PRs targeting \`${BASE_BRANCH}\`" else - BODY="${MARKER} + update_comment "${MARKER} ❌ Conflicts with: $(printf '%s\n' "${conflicts[@]}")" fi - - COMMENT_ID=$(gh api --method GET "repos/${REPO}/issues/${CURRENT_PR}/comments" \ - --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ - | head -n1) - - if [ -n "$COMMENT_ID" ]; then - gh api --method PATCH "repos/${REPO}/issues/comments/${COMMENT_ID}" \ - -f body="$BODY" - else - gh api --method POST "repos/${REPO}/issues/${CURRENT_PR}/comments" \ - -f body="$BODY" - fi From 36b9e1e5d294efc86ff78f5656238fcfc7bdf4c7 Mon Sep 17 00:00:00 2001 From: mchain0 Date: Fri, 20 Feb 2026 15:54:52 +0100 Subject: [PATCH 7/7] cre-1827: review improvement --- .github/workflows/pr-conflicts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-conflicts.yaml b/.github/workflows/pr-conflicts.yaml index 0ad849b4503..73438a7986c 100644 --- a/.github/workflows/pr-conflicts.yaml +++ b/.github/workflows/pr-conflicts.yaml @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 - uses: ./.github/actions/prconflicts with: