|
| 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 |
0 commit comments