Skip to content

Commit 6a602b2

Browse files
committed
build: add workflow and script to label PRs with merge conflicts
--- type: pre_commit_static_analysis_report description: Results of running static analysis checks when committing changes. report: - task: lint_filenames status: passed - task: lint_editorconfig status: passed - task: lint_markdown status: na - task: lint_package_json status: na - task: lint_repl_help status: na - task: lint_javascript_src status: na - task: lint_javascript_cli status: na - task: lint_javascript_examples status: na - task: lint_javascript_tests status: na - task: lint_javascript_benchmarks status: na - task: lint_python status: na - task: lint_r status: na - task: lint_c_src status: na - task: lint_c_examples status: na - task: lint_c_benchmarks status: na - task: lint_c_tests_fixtures status: na - task: lint_shell status: passed - task: lint_typescript_declarations status: passed - task: lint_typescript_tests status: na - task: lint_license_headers status: passed ---
1 parent 932f042 commit 6a602b2

2 files changed

Lines changed: 332 additions & 0 deletions

File tree

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) 2025 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"
80+
timeout-minutes: 15
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
#!/usr/bin/env bash
2+
#
3+
# @license Apache-2.0
4+
#
5+
# Copyright (c) 2025 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+
# Usage: check_merge_conflicts_prs
22+
#
23+
# Environment variables:
24+
#
25+
# GITHUB_TOKEN GitHub token for authentication.
26+
27+
# shellcheck disable=SC2153,SC2317
28+
29+
# Ensure that the exit status of pipelines is non-zero in the event that at least one of the commands in a pipeline fails:
30+
set -o pipefail
31+
32+
33+
# VARIABLES #
34+
35+
# GitHub API base URL:
36+
github_api_url="https://api.github.com"
37+
38+
# Repository owner and name:
39+
repo_owner="stdlib-js"
40+
repo_name="stdlib"
41+
42+
# Label for PRs with merge conflicts:
43+
merge_conflicts_label="Merge Conflicts"
44+
45+
# Debug mode controlled by environment variable (defaults to false if not set):
46+
debug="${DEBUG:-false}"
47+
48+
# Get the GitHub authentication token:
49+
github_token="${GITHUB_TOKEN}"
50+
if [ -z "$github_token" ]; then
51+
echo "Error: GITHUB_TOKEN environment variable not set." >&2
52+
exit 1
53+
fi
54+
55+
# Configure retries for API calls:
56+
max_retries=3
57+
retry_delay=2
58+
59+
60+
# FUNCTIONS #
61+
62+
# Debug logging function.
63+
#
64+
# $1 - debug message
65+
debug_log() {
66+
# Only print debug messages if DEBUG environment variable is set to "true":
67+
if [ "$debug" = true ]; then
68+
echo "[DEBUG] $1" >&2
69+
fi
70+
}
71+
72+
# Error handler.
73+
#
74+
# $1 - error status
75+
on_error() {
76+
echo 'ERROR: An error was encountered during execution.' >&2
77+
exit "$1"
78+
}
79+
80+
# Prints a success message.
81+
print_success() {
82+
echo 'Success!' >&2
83+
}
84+
85+
# Fetches pull requests.
86+
fetch_pull_requests() {
87+
local response
88+
response=$(curl -s -X POST 'https://api.github.com/graphql' \
89+
-H "Authorization: bearer ${github_token}" \
90+
-H "Content-Type: application/json" \
91+
--data @- << EOF
92+
{
93+
"query": "query(\$owner: String!, \$repo: String!) { repository(owner: \$owner, name: \$repo) { pullRequests( states: OPEN, last: 100 ) { edges { node { number url mergeable } } } } }",
94+
"variables": {
95+
"owner": "${repo_owner}",
96+
"repo": "${repo_name}"
97+
}
98+
}
99+
EOF
100+
)
101+
echo "$response"
102+
}
103+
104+
105+
# Performs a GitHub API request.
106+
#
107+
# $1 - HTTP method (GET, POST, PATCH, etc.)
108+
# $2 - API endpoint
109+
# $3 - data for POST/PATCH requests
110+
github_api() {
111+
local method="$1"
112+
local endpoint="$2"
113+
local data="$3"
114+
local retry_count=0
115+
local response=""
116+
local status_code
117+
local success=false
118+
119+
# Initialize an array to hold curl headers:
120+
local headers=()
121+
headers+=("-H" "Authorization: token ${github_token}")
122+
123+
debug_log "Making API request: ${method} ${endpoint}"
124+
125+
# For POST/PATCH requests, always set the Content-Type header:
126+
if [ "$method" != "GET" ]; then
127+
headers+=("-H" "Content-Type: application/json")
128+
fi
129+
130+
# Add retry logic...
131+
while [ $retry_count -lt $max_retries ] && [ "$success" = false ]; do
132+
if [ $retry_count -gt 0 ]; then
133+
echo "Retrying request (attempt $((retry_count+1))/${max_retries})..."
134+
sleep $retry_delay
135+
fi
136+
137+
# Make the API request:
138+
if [ -n "${data}" ]; then
139+
response=$(curl -s -w "%{http_code}" -X "${method}" "${headers[@]}" -d "${data}" "${github_api_url}${endpoint}")
140+
else
141+
response=$(curl -s -w "%{http_code}" -X "${method}" "${headers[@]}" "${github_api_url}${endpoint}")
142+
fi
143+
144+
# Extract status code (last 3 digits) and actual response (everything before):
145+
status_code="${response: -3}"
146+
response="${response:0:${#response}-3}"
147+
148+
debug_log "Status code: $status_code"
149+
150+
# Check if we got a successful response:
151+
if [[ $status_code -ge 200 && $status_code -lt 300 ]]; then
152+
success=true
153+
else
154+
echo "API request failed with status $status_code: $response" >&2
155+
retry_count=$((retry_count+1))
156+
fi
157+
done
158+
159+
if [ "$success" = false ]; then
160+
echo "Failed to complete API request after $max_retries attempts" >&2
161+
return 1
162+
fi
163+
164+
# Validate that response is valid JSON if expected:
165+
if ! echo "$response" | jq -e '.' > /dev/null 2>&1; then
166+
echo "Warning: Response is not valid JSON: ${response}" >&2
167+
# Return empty JSON object as fallback:
168+
echo "{}"
169+
return 0
170+
fi
171+
172+
# Return the actual response data (without status code):
173+
echo "$response"
174+
return 0
175+
}
176+
177+
# Removes a label from a PR.
178+
#
179+
# $1 - PR number
180+
# $2 - label name
181+
remove_label() {
182+
local pr_number="$1"
183+
local label="$2"
184+
local encoded_label
185+
encoded_label=$(printf '%s' "$label" | jq -sRr @uri)
186+
187+
debug_log "Removing label '${label}' from PR #${pr_number} (idempotent)"
188+
189+
local headers=(-H "Accept: application/vnd.github+json")
190+
headers+=(-H "Authorization: token ${github_token}")
191+
192+
local status
193+
status=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
194+
"${headers[@]}" \
195+
"${github_api_url}/repos/${repo_owner}/${repo_name}/issues/${pr_number}/labels/${encoded_label}")
196+
197+
case "$status" in
198+
200|204)
199+
debug_log "Label '${label}' removed from PR #${pr_number}"
200+
return 0
201+
;;
202+
404)
203+
debug_log "Label '${label}' not present on PR #${pr_number}; treating as success"
204+
return 0
205+
;;
206+
*)
207+
echo "Failed to remove label '${label}' from PR #${pr_number} (status ${status})" >&2
208+
return 1
209+
;;
210+
esac
211+
}
212+
213+
# Adds a label to a PR.
214+
#
215+
# $1 - PR number
216+
# $2 - label name
217+
add_label() {
218+
local pr_number="$1"
219+
local label="$2"
220+
221+
debug_log "Adding label '${label}' to PR #${pr_number}"
222+
github_api "POST" "/repos/${repo_owner}/${repo_name}/issues/${pr_number}/labels" \
223+
"{\"labels\":[\"${label}\"]}"
224+
}
225+
226+
227+
# Main execution sequence.
228+
main() {
229+
echo "Fetching open pull requests..."
230+
231+
labeled_prs_data=$(fetch_pull_requests)
232+
echo "$labeled_prs_data" \
233+
| jq -r '.data.repository.pullRequests.edges[].node | select( .mergeable == "CONFLICTING" ) | .number' \
234+
| while IFS= read -r pr_number; do
235+
echo "Adding $merge_conflicts_label label to PR #${pr_number}..."
236+
add_label "$pr_number" "$merge_conflicts_label"
237+
done;
238+
239+
echo "$labeled_prs_data" \
240+
| jq -r '.data.repository.pullRequests.edges[].node | select( .mergeable == "MERGEABLE" ) | .number' \
241+
| while IFS= read -r pr_number; do
242+
echo "Ensure $merge_conflicts_label label is removed from PR #${pr_number}..."
243+
remove_label "$pr_number" "$merge_conflicts_label"
244+
done;
245+
246+
debug_log "Script completed successfully"
247+
print_success
248+
exit 0
249+
}
250+
251+
# Run main:
252+
main

0 commit comments

Comments
 (0)