Skip to content

Commit 961dde2

Browse files
Planeshifterkgrytestdlib-bot
authored
build: add workflow and script to label PRs with merge conflicts
PR-URL: #9461 Co-authored-by: Athan Reines <kgryte@gmail.com> Reviewed-by: Athan Reines <kgryte@gmail.com> Co-authored-by: stdlib-bot <noreply@stdlib.io>
1 parent d866858 commit 961dde2

File tree

2 files changed

+330
-0
lines changed

2 files changed

+330
-0
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#/
2+
# @license Apache-2.0
3+
#
4+
# Copyright (c) 2026 The Stdlib Authors.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#/
18+
19+
# Workflow name:
20+
name: check_merge_conflicts_prs
21+
22+
# Workflow triggers:
23+
on:
24+
# Run the workflow daily at 5 AM UTC:
25+
schedule:
26+
- cron: '0 5 * * *'
27+
28+
# Allow the workflow to be manually run:
29+
workflow_dispatch:
30+
inputs:
31+
debug:
32+
description: 'Enable debug output'
33+
required: false
34+
default: 'false'
35+
type: choice
36+
options:
37+
- 'true'
38+
- 'false'
39+
40+
# Global permissions:
41+
permissions:
42+
# Allow read-only access to the repository contents:
43+
contents: read
44+
45+
# Workflow jobs:
46+
jobs:
47+
48+
# Define a job for checking PRs with merge conflicts:
49+
check_merge_conflicts:
50+
51+
# Define a display name:
52+
name: 'Check for PRs with Merge Conflicts'
53+
54+
# Ensure the job does not run on forks:
55+
if: github.repository == 'stdlib-js/stdlib'
56+
57+
# Define the type of virtual host machine:
58+
runs-on: ubuntu-latest
59+
60+
# Define the sequence of job steps...
61+
steps:
62+
# Checkout the repository:
63+
- name: 'Checkout repository'
64+
# Pin action to full length commit SHA
65+
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
66+
with:
67+
# Ensure we have access to the scripts directory:
68+
sparse-checkout: |
69+
.github/workflows/scripts
70+
sparse-checkout-cone-mode: false
71+
timeout-minutes: 10
72+
73+
# Check for merge conflicts in PRs:
74+
- name: 'Check for merge conflicts in PRs'
75+
env:
76+
GITHUB_TOKEN: ${{ secrets.STDLIB_BOT_PAT_REPO_WRITE }}
77+
DEBUG: ${{ inputs.debug || 'false' }}
78+
run: |
79+
. "$GITHUB_WORKSPACE/.github/workflows/scripts/check_merge_conflicts_prs/run"
80+
timeout-minutes: 15
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
#!/usr/bin/env bash
2+
#
3+
# @license Apache-2.0
4+
#
5+
# Copyright (c) 2026 The Stdlib Authors.
6+
#
7+
# Licensed under the Apache License, Version 2.0 (the "License");
8+
# you may not use this file except in compliance with the License.
9+
# You may obtain a copy of the License at
10+
#
11+
# http://www.apache.org/licenses/LICENSE-2.0
12+
#
13+
# Unless required by applicable law or agreed to in writing, software
14+
# distributed under the License is distributed on an "AS IS" BASIS,
15+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
# See the License for the specific language governing permissions and
17+
# limitations under the License.
18+
19+
# Script to identify and label pull requests with merge conflicts.
20+
#
21+
# Environment variables:
22+
#
23+
# GITHUB_TOKEN GitHub token for authentication.
24+
25+
# shellcheck disable=SC2153,SC2317
26+
27+
# Ensure that the exit status of pipelines is non-zero in the event that at least one of the commands in a pipeline fails:
28+
set -o pipefail
29+
30+
31+
# VARIABLES #
32+
33+
# GitHub API base URL:
34+
github_api_url="https://api.github.com"
35+
36+
# Repository owner and name:
37+
repo_owner="stdlib-js"
38+
repo_name="stdlib"
39+
40+
# Label for PRs with merge conflicts:
41+
merge_conflicts_label="Merge Conflicts"
42+
43+
# Debug mode controlled by environment variable (defaults to false if not set):
44+
debug="${DEBUG:-false}"
45+
46+
# Get the GitHub authentication token:
47+
github_token="${GITHUB_TOKEN}"
48+
if [ -z "$github_token" ]; then
49+
echo "Error: GITHUB_TOKEN environment variable not set." >&2
50+
exit 1
51+
fi
52+
53+
# Configure retries for API calls:
54+
max_retries=3
55+
retry_delay=2
56+
57+
58+
# FUNCTIONS #
59+
60+
# Debug logging function.
61+
#
62+
# $1 - debug message
63+
debug_log() {
64+
# Only print debug messages if DEBUG environment variable is set to "true":
65+
if [ "$debug" = true ]; then
66+
echo "[DEBUG] $1" >&2
67+
fi
68+
}
69+
70+
# Error handler.
71+
#
72+
# $1 - error status
73+
on_error() {
74+
echo 'ERROR: An error was encountered during execution.' >&2
75+
exit "$1"
76+
}
77+
78+
# Prints a success message.
79+
print_success() {
80+
echo 'Success!' >&2
81+
}
82+
83+
# Fetches pull requests.
84+
fetch_pull_requests() {
85+
local response
86+
response=$(curl -s -X POST 'https://api.github.com/graphql' \
87+
-H "Authorization: bearer ${github_token}" \
88+
-H "Content-Type: application/json" \
89+
--data @- << EOF
90+
{
91+
"query": "query(\$owner: String!, \$repo: String!) { repository(owner: \$owner, name: \$repo) { pullRequests( states: OPEN, last: 100 ) { edges { node { number url mergeable } } } } }",
92+
"variables": {
93+
"owner": "${repo_owner}",
94+
"repo": "${repo_name}"
95+
}
96+
}
97+
EOF
98+
)
99+
echo "$response"
100+
}
101+
102+
103+
# Performs a GitHub API request.
104+
#
105+
# $1 - HTTP method (GET, POST, PATCH, etc.)
106+
# $2 - API endpoint
107+
# $3 - data for POST/PATCH requests
108+
github_api() {
109+
local method="$1"
110+
local endpoint="$2"
111+
local data="$3"
112+
local retry_count=0
113+
local response=""
114+
local status_code
115+
local success=false
116+
117+
# Initialize an array to hold curl headers:
118+
local headers=()
119+
headers+=("-H" "Authorization: token ${github_token}")
120+
121+
debug_log "Making API request: ${method} ${endpoint}"
122+
123+
# For POST/PATCH requests, always set the Content-Type header:
124+
if [ "$method" != "GET" ]; then
125+
headers+=("-H" "Content-Type: application/json")
126+
fi
127+
128+
# Add retry logic...
129+
while [ $retry_count -lt $max_retries ] && [ "$success" = false ]; do
130+
if [ $retry_count -gt 0 ]; then
131+
echo "Retrying request (attempt $((retry_count+1))/${max_retries})..."
132+
sleep $retry_delay
133+
fi
134+
135+
# Make the API request:
136+
if [ -n "${data}" ]; then
137+
response=$(curl -s -w "%{http_code}" -X "${method}" "${headers[@]}" -d "${data}" "${github_api_url}${endpoint}")
138+
else
139+
response=$(curl -s -w "%{http_code}" -X "${method}" "${headers[@]}" "${github_api_url}${endpoint}")
140+
fi
141+
142+
# Extract status code (last 3 digits) and actual response (everything before):
143+
status_code="${response: -3}"
144+
response="${response:0:${#response}-3}"
145+
146+
debug_log "Status code: $status_code"
147+
148+
# Check if we got a successful response:
149+
if [[ $status_code -ge 200 && $status_code -lt 300 ]]; then
150+
success=true
151+
else
152+
echo "API request failed with status $status_code: $response" >&2
153+
retry_count=$((retry_count+1))
154+
fi
155+
done
156+
157+
if [ "$success" = false ]; then
158+
echo "Failed to complete API request after $max_retries attempts" >&2
159+
return 1
160+
fi
161+
162+
# Validate that response is valid JSON if expected:
163+
if ! echo "$response" | jq -e '.' > /dev/null 2>&1; then
164+
echo "Warning: Response is not valid JSON: ${response}" >&2
165+
# Return empty JSON object as fallback:
166+
echo "{}"
167+
return 0
168+
fi
169+
170+
# Return the actual response data (without status code):
171+
echo "$response"
172+
return 0
173+
}
174+
175+
# Removes a label from a PR.
176+
#
177+
# $1 - PR number
178+
# $2 - label name
179+
remove_label() {
180+
local pr_number="$1"
181+
local label="$2"
182+
local encoded_label
183+
encoded_label=$(printf '%s' "$label" | jq -sRr @uri)
184+
185+
debug_log "Removing label '${label}' from PR #${pr_number} (idempotent)"
186+
187+
local headers=(-H "Accept: application/vnd.github+json")
188+
headers+=(-H "Authorization: token ${github_token}")
189+
190+
local status
191+
status=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
192+
"${headers[@]}" \
193+
"${github_api_url}/repos/${repo_owner}/${repo_name}/issues/${pr_number}/labels/${encoded_label}")
194+
195+
case "$status" in
196+
200|204)
197+
debug_log "Label '${label}' removed from PR #${pr_number}"
198+
return 0
199+
;;
200+
404)
201+
debug_log "Label '${label}' not present on PR #${pr_number}; treating as success"
202+
return 0
203+
;;
204+
*)
205+
echo "Failed to remove label '${label}' from PR #${pr_number} (status ${status})" >&2
206+
return 1
207+
;;
208+
esac
209+
}
210+
211+
# Adds a label to a PR.
212+
#
213+
# $1 - PR number
214+
# $2 - label name
215+
add_label() {
216+
local pr_number="$1"
217+
local label="$2"
218+
219+
debug_log "Adding label '${label}' to PR #${pr_number}"
220+
github_api "POST" "/repos/${repo_owner}/${repo_name}/issues/${pr_number}/labels" \
221+
"{\"labels\":[\"${label}\"]}"
222+
}
223+
224+
225+
# Main execution sequence.
226+
main() {
227+
echo "Fetching open pull requests..."
228+
229+
labeled_prs_data=$(fetch_pull_requests)
230+
echo "$labeled_prs_data" \
231+
| jq -r '.data.repository.pullRequests.edges[].node | select( .mergeable == "CONFLICTING" ) | .number' \
232+
| while IFS= read -r pr_number; do
233+
echo "Adding $merge_conflicts_label label to PR #${pr_number}..."
234+
add_label "$pr_number" "$merge_conflicts_label"
235+
done;
236+
237+
echo "$labeled_prs_data" \
238+
| jq -r '.data.repository.pullRequests.edges[].node | select( .mergeable == "MERGEABLE" ) | .number' \
239+
| while IFS= read -r pr_number; do
240+
echo "Ensure $merge_conflicts_label label is removed from PR #${pr_number}..."
241+
remove_label "$pr_number" "$merge_conflicts_label"
242+
done;
243+
244+
debug_log "Script completed successfully"
245+
print_success
246+
exit 0
247+
}
248+
249+
# Run main:
250+
main

0 commit comments

Comments
 (0)