1+ #! /usr/bin/env bash
2+ set -euo pipefail
3+
4+ SCRIPT_DIR=" $( dirname " $0 " ) "
5+
6+ # Fetches and processes upstream PRs, outputting selected PRs to /tmp/pulls.ndjson
7+ #
8+ # Required environment variables:
9+ # UPSTREAM_REPO - upstream repo (e.g., "openssl/openssl")
10+ # UPSTREAM_DEFAULT - upstream default branch name
11+ # GH_TOKEN - GitHub token for API calls
12+ # GITHUB_ACTOR - actor for git config
13+ # GITHUB_OUTPUT - path to output file
14+ #
15+ # Optional environment variables (for scheduled mode):
16+ # UPSTREAM_PR_LOOKBACK_DAYS - how far back to look for PRs
17+ # MAX_UPSTREAM_PRS - max PRs to process
18+ #
19+ # Optional environment variables (for manual mode):
20+ # PR_URL - specific PR URL to mirror
21+ # BASE_SHA - upstream main commit SHA to use as base (overrides merge-base)
22+
23+ git remote add upstream " https://github.com/${UPSTREAM_REPO} .git" 2> /dev/null || true
24+ git fetch upstream " ${UPSTREAM_DEFAULT} :refs/remotes/upstream/${UPSTREAM_DEFAULT} "
25+ git fetch origin overlay:refs/remotes/origin/overlay || true
26+
27+ git config user.name " ${GITHUB_ACTOR} "
28+ git config user.email " ${GITHUB_ACTOR} @users.noreply.github.com"
29+
30+ # If BASE_SHA is provided without PR_URL, just create the loci/main branch and exit
31+ if [ -n " ${BASE_SHA:- } " ] && [ -z " ${PR_URL:- } " ]; then
32+ echo " Base SHA mode: creating loci/main branch for ${BASE_SHA} "
33+ if loci_main_branch=$( bash " $SCRIPT_DIR /sync-loci-main.sh" " $BASE_SHA " ) ; then
34+ echo " Branch ${loci_main_branch} already exists and is up-to-date."
35+ else
36+ echo " Created/updated ${loci_main_branch} ."
37+ fi
38+ echo " prs_to_sync=no" >> " $GITHUB_OUTPUT "
39+ exit 0
40+ fi
41+
42+ > /tmp/pulls.ndjson
43+ selected_pulls_count=0
44+ manual_mode=0
45+ max_pulls=" ${MAX_UPSTREAM_PRS:- 2} "
46+
47+ if [ -n " ${PR_URL:- } " ]; then
48+ manual_mode=1
49+ echo " Manual mode: processing PR from URL: ${PR_URL} "
50+
51+ if [[ ! " $PR_URL " =~ ^https://github.com/([^/]+/[^/]+)/pull/([0-9]+)$ ]]; then
52+ echo " ::error::Invalid PR URL format. Expected: https://github.com/owner/repo/pull/123"
53+ exit 1
54+ fi
55+
56+ pr_repo=" ${BASH_REMATCH[1]} "
57+ manual_pr_num=" ${BASH_REMATCH[2]} "
58+
59+ if [ " $pr_repo " != " $UPSTREAM_REPO " ]; then
60+ echo " ::error::PR repo (${pr_repo} ) does not match UPSTREAM_REPO (${UPSTREAM_REPO} )"
61+ exit 1
62+ fi
63+
64+ pulls=$( gh api " repos/${UPSTREAM_REPO} /pulls/${manual_pr_num} " | jq -s ' .' )
65+ else
66+ lookback_days=" ${UPSTREAM_PR_LOOKBACK_DAYS:- 7} "
67+ cutoff=$( date -u -d " ${lookback_days} days ago" +%Y-%m-%dT%H:%M:%SZ)
68+ per_page=20
69+ page=1
70+
71+ echo " Searching for ${max_pulls} valid pull requests targeting ${UPSTREAM_DEFAULT} , updated since ${cutoff} ."
72+ fi
73+
74+ while true ; do
75+ if [ " $manual_mode " -eq 0 ]; then
76+ pulls=$( gh api " repos/${UPSTREAM_REPO} /pulls?state=open&base=${UPSTREAM_DEFAULT} &sort=updated&direction=desc&per_page=${per_page} &page=${page} " 2> /dev/null || echo " []" )
77+ page_pulls_count=$( echo " $pulls " | jq ' length' )
78+
79+ if [ " $page_pulls_count " -eq 0 ]; then
80+ echo " Pull requests exhausted on page ${page} . Stopping."
81+ break
82+ fi
83+ echo " Processing page ${page} (${page_pulls_count} pull requests)"
84+ fi
85+
86+ while read -r pr; do
87+ pull_num=$( jq -r ' .number' <<< " $pr" )
88+ pull_head_sha=$( jq -r ' .head.sha' <<< " $pr" )
89+ pull_head_ref=$( jq -r ' .head.ref' <<< " $pr" )
90+
91+ is_draft=$( jq -r ' .draft' <<< " $pr" )
92+ if [ " $is_draft " = " true" ]; then
93+ if [ " $manual_mode " -eq 1 ]; then
94+ echo " ::notice::PR #${pull_num} is a draft PR. Proceeding anyway (manual mode)."
95+ else
96+ echo " PR #${pull_num} : is a draft. Skipping."
97+ continue
98+ fi
99+ fi
100+
101+ # Skip cutoff check in manual mode
102+ if [ " $manual_mode " -eq 0 ]; then
103+ updated_at=$( jq -r ' .updated_at' <<< " $pr" )
104+ created_at=$( jq -r ' .created_at' <<< " $pr" )
105+ if [[ " $updated_at " < " $cutoff " && " $created_at " < " $cutoff " ]]; then
106+ continue
107+ fi
108+ fi
109+
110+ # Sanitize branch name: replace / with -, truncate to 50 chars
111+ sanitized_branch=$( echo " ${pull_head_ref} " | tr ' /' ' -' | cut -c1-50)
112+ loci_pr_branch=" loci/pr-${pull_num} -${sanitized_branch} "
113+
114+ # Fetch pull request head for merge-base computation
115+ git fetch upstream " refs/pull/${pull_num} /head:refs/remotes/upstream/pr/${pull_num} " 2> /dev/null || \
116+ git fetch upstream " ${pull_head_sha} :refs/remotes/upstream/pr/${pull_num} " 2> /dev/null || true
117+
118+ # Determine merge-base: use BASE_SHA if provided, otherwise compute it
119+ if [ -n " ${BASE_SHA:- } " ]; then
120+ merge_base=" ${BASE_SHA} "
121+ echo " PR #${pull_num} : using provided BASE_SHA as merge-base: ${merge_base} "
122+ else
123+ merge_base=$( git merge-base " ${pull_head_sha} " " refs/remotes/upstream/${UPSTREAM_DEFAULT} " 2> /dev/null || true)
124+ if [ -z " ${merge_base} " ]; then
125+ echo " PR #${pull_num} : could not compute merge-base. Skipping."
126+ if [ " $manual_mode " -eq 1 ]; then
127+ echo " ::error::Could not compute merge-base for manually specified PR"
128+ exit 1
129+ fi
130+ continue
131+ fi
132+ echo " PR #${pull_num} : computed merge-base: ${merge_base} "
133+ fi
134+
135+ short_merge_base=" ${merge_base: 0: 7} "
136+
137+ # Create or update base branch if needed (must happen before conflict check when using loci base)
138+ if loci_main_branch=$( bash " $SCRIPT_DIR /sync-loci-main.sh" " $merge_base " ) ; then
139+ : # Branch already up-to-date
140+ else
141+ # Branch was created/updated — push pending branch and skip PR creation
142+ if [ " $manual_mode " -eq 0 ]; then
143+ pending_branch=" loci/pending-pr-${pull_num} -${sanitized_branch} "
144+ echo " PR #${pull_num} : ${loci_main_branch} just triggered to create/update. Pushing pending branch: ${pending_branch} ."
145+
146+ if git show-ref --verify --quiet " refs/remotes/upstream/pr/${pull_num} " ; then
147+ git branch --no-track -f " ${pending_branch} " " refs/remotes/upstream/pr/${pull_num} "
148+ else
149+ git fetch upstream " refs/pull/${pull_num} /head:refs/heads/${pending_branch} " || \
150+ git fetch upstream " ${pull_head_sha} :refs/heads/${pending_branch} "
151+ fi
152+
153+ git push origin " refs/heads/${pending_branch} :refs/heads/${pending_branch} " --force
154+ selected_pulls_count=$(( selected_pulls_count + 1 ))
155+ echo " PR #${pull_num} : added as pending (${selected_pulls_count} )."
156+ if [ " $selected_pulls_count " -ge " $max_pulls " ]; then
157+ echo " Quota of ${max_pulls} reached, stopping."
158+ break 2
159+ fi
160+ continue
161+ else
162+ echo " PR #${pull_num} : created/updated ${loci_main_branch} . Continuing with PR."
163+ fi
164+ fi
165+
166+ # Check for merge conflicts - against loci/main-* when BASE_SHA provided, otherwise upstream default
167+ if [ -n " ${BASE_SHA:- } " ]; then
168+ conflict_target=" refs/heads/${loci_main_branch} "
169+ conflict_target_name=" $loci_main_branch "
170+ else
171+ conflict_target=" refs/remotes/upstream/${UPSTREAM_DEFAULT} "
172+ conflict_target_name=" upstream ${UPSTREAM_DEFAULT} "
173+ fi
174+
175+ if ! git merge-tree --write-tree --merge-base " ${merge_base} " " ${pull_head_sha} " " ${conflict_target} " & > /dev/null; then
176+ echo " PR #${pull_num} : has conflicts with ${conflict_target_name} . Skipping."
177+ if [ " $manual_mode " -eq 1 ]; then
178+ echo " ::error::PR has merge conflicts with ${conflict_target_name} "
179+ exit 1
180+ fi
181+ continue
182+ fi
183+
184+ origin_sha=$( git ls-remote --heads origin " refs/heads/${loci_pr_branch} " | cut -f1 || true)
185+ if [ -n " ${origin_sha} " ] && [ " ${origin_sha} " = " ${pull_head_sha} " ]; then
186+ echo " PR #${pull_num} : already up-to-date."
187+ # In manual mode, still add it (user explicitly requested); in scheduled mode, skip
188+ if [ " $manual_mode " -eq 0 ]; then
189+ echo " Skipping."
190+ continue
191+ else
192+ echo " Adding anyway (manual mode)."
193+ fi
194+ fi
195+
196+ # Determine if we should target loci/main-* (only when base_sha explicitly provided)
197+ if [ -n " ${BASE_SHA:- } " ]; then
198+ use_loci_base=1
199+ else
200+ use_loci_base=0
201+ fi
202+
203+ # Select pull request
204+ jq -c \
205+ --arg pull_number " $pull_num " \
206+ --arg pull_head_sha " $pull_head_sha " \
207+ --arg loci_pr_branch " $loci_pr_branch " \
208+ --arg short_merge_base " $short_merge_base " \
209+ --arg loci_main_branch " $loci_main_branch " \
210+ --argjson use_loci_base " $use_loci_base " \
211+ ' {
212+ pull_number: $pull_number,
213+ title: .title,
214+ body: (.body // ""),
215+ pull_head_sha: $pull_head_sha,
216+ loci_pr_branch: $loci_pr_branch,
217+ short_merge_base: $short_merge_base,
218+ loci_main_branch: $loci_main_branch,
219+ use_loci_base: $use_loci_base
220+ }' <<< " $pr" >> /tmp/pulls.ndjson
221+
222+ selected_pulls_count=$(( selected_pulls_count + 1 ))
223+ echo " PR #${pull_num} : added (${selected_pulls_count} )."
224+
225+ if [ " $manual_mode " -eq 0 ] && [ " $selected_pulls_count " -ge " $max_pulls " ]; then
226+ echo " Quota of ${max_pulls} reached, stopping."
227+ break 2
228+ fi
229+ done < <( echo " $pulls " | jq -c ' .[]' 2> /dev/null)
230+
231+ # In manual mode, we only process one PR, so break after first iteration
232+ if [ " $manual_mode " -eq 1 ]; then
233+ break
234+ fi
235+
236+ page=$(( page + 1 ))
237+ done
238+
239+ if [ " $selected_pulls_count " -eq 0 ]; then
240+ latest_upstream_sha=$( git rev-parse " refs/remotes/upstream/${UPSTREAM_DEFAULT} " )
241+ echo " No PRs found. Syncing latest upstream ${UPSTREAM_DEFAULT} (${latest_upstream_sha} )."
242+ if loci_main_branch=$( bash " $SCRIPT_DIR /sync-loci-main.sh" " $latest_upstream_sha " ) ; then
243+ echo " ${loci_main_branch} already up-to-date."
244+ else
245+ echo " Created/updated ${loci_main_branch} ."
246+ fi
247+ echo " prs_to_sync=no" >> " $GITHUB_OUTPUT "
248+ else
249+ echo " prs_to_sync=yes" >> " $GITHUB_OUTPUT "
250+ echo " Selected ${selected_pulls_count} upstream PRs to process"
251+ fi
0 commit comments