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 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+ > pulls.ndjson
43+ selected_pulls_count=0
44+ manual_mode=0
45+
46+ if [ -n " ${PR_URL:- } " ]; then
47+ manual_mode=1
48+ echo " Manual mode: processing PR from URL: ${PR_URL} "
49+
50+ if [[ ! " $PR_URL " =~ ^https://github.com/([^/]+/[^/]+)/pull/([0-9]+)$ ]]; then
51+ echo " ::error::Invalid PR URL format. Expected: https://github.com/owner/repo/pull/123"
52+ exit 1
53+ fi
54+
55+ pr_repo=" ${BASH_REMATCH[1]} "
56+ manual_pr_num=" ${BASH_REMATCH[2]} "
57+
58+ if [ " $pr_repo " != " $UPSTREAM_REPO " ]; then
59+ echo " ::error::PR repo (${pr_repo} ) does not match UPSTREAM_REPO (${UPSTREAM_REPO} )"
60+ exit 1
61+ fi
62+
63+ pulls=$( gh api " repos/${UPSTREAM_REPO} /pulls/${manual_pr_num} " | jq -s ' .' )
64+ else
65+ lookback_days=" ${UPSTREAM_PR_LOOKBACK_DAYS:- 7} "
66+ cutoff=$( date -u -d " ${lookback_days} days ago" +%Y-%m-%dT%H:%M:%SZ)
67+ max_pulls=" ${MAX_UPSTREAM_PRS:- 10} "
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+ continue
155+ else
156+ echo " PR #${pull_num} : created/updated ${loci_main_branch} . Continuing with PR."
157+ fi
158+ fi
159+
160+ # Check for merge conflicts - against loci/main-* when BASE_SHA provided, otherwise upstream default
161+ if [ -n " ${BASE_SHA:- } " ]; then
162+ conflict_target=" refs/heads/${loci_main_branch} "
163+ conflict_target_name=" $loci_main_branch "
164+ else
165+ conflict_target=" refs/remotes/upstream/${UPSTREAM_DEFAULT} "
166+ conflict_target_name=" upstream ${UPSTREAM_DEFAULT} "
167+ fi
168+
169+ if ! git merge-tree --write-tree --merge-base " ${merge_base} " " ${pull_head_sha} " " ${conflict_target} " & > /dev/null; then
170+ echo " PR #${pull_num} : has conflicts with ${conflict_target_name} . Skipping."
171+ if [ " $manual_mode " -eq 1 ]; then
172+ echo " ::error::PR has merge conflicts with ${conflict_target_name} "
173+ exit 1
174+ fi
175+ continue
176+ fi
177+
178+ origin_sha=$( git ls-remote --heads origin " refs/heads/${loci_pr_branch} " | cut -f1 || true)
179+ if [ -n " ${origin_sha} " ] && [ " ${origin_sha} " = " ${pull_head_sha} " ]; then
180+ echo " PR #${pull_num} : already up-to-date."
181+ # In manual mode, still add it (user explicitly requested); in scheduled mode, skip
182+ if [ " $manual_mode " -eq 0 ]; then
183+ echo " Skipping."
184+ continue
185+ else
186+ echo " Adding anyway (manual mode)."
187+ fi
188+ fi
189+
190+ # Determine if we should target loci/main-* (only when base_sha explicitly provided)
191+ if [ -n " ${BASE_SHA:- } " ]; then
192+ use_loci_base=1
193+ else
194+ use_loci_base=0
195+ fi
196+
197+ # Select pull request
198+ jq -c \
199+ --arg pull_number " $pull_num " \
200+ --arg pull_head_sha " $pull_head_sha " \
201+ --arg loci_pr_branch " $loci_pr_branch " \
202+ --arg short_merge_base " $short_merge_base " \
203+ --arg loci_main_branch " $loci_main_branch " \
204+ --argjson use_loci_base " $use_loci_base " \
205+ ' {
206+ pull_number: $pull_number,
207+ title: .title,
208+ body: (.body // ""),
209+ pull_head_sha: $pull_head_sha,
210+ loci_pr_branch: $loci_pr_branch,
211+ short_merge_base: $short_merge_base,
212+ loci_main_branch: $loci_main_branch,
213+ use_loci_base: $use_loci_base
214+ }' <<< " $pr" >> pulls.ndjson
215+
216+ selected_pulls_count=$(( selected_pulls_count + 1 ))
217+ echo " PR #${pull_num} : added (${selected_pulls_count} )."
218+
219+ if [ " $manual_mode " -eq 0 ] && [ " $selected_pulls_count " -ge " $max_pulls " ]; then
220+ echo " Quota of ${max_pulls} reached, stopping."
221+ break 2
222+ fi
223+ done < <( echo " $pulls " | jq -c ' .[]' 2> /dev/null)
224+
225+ # In manual mode, we only process one PR, so break after first iteration
226+ if [ " $manual_mode " -eq 1 ]; then
227+ break
228+ fi
229+
230+ page=$(( page + 1 ))
231+ done
232+
233+ if [ " $selected_pulls_count " -eq 0 ]; then
234+ latest_upstream_sha=$( git rev-parse " refs/remotes/upstream/${UPSTREAM_DEFAULT} " )
235+ echo " No PRs found. Syncing latest upstream ${UPSTREAM_DEFAULT} (${latest_upstream_sha} )."
236+ if loci_main_branch=$( bash " $SCRIPT_DIR /sync-loci-main.sh" " $latest_upstream_sha " ) ; then
237+ echo " ${loci_main_branch} already up-to-date."
238+ else
239+ echo " Created/updated ${loci_main_branch} ."
240+ fi
241+ echo " prs_to_sync=no" >> " $GITHUB_OUTPUT "
242+ else
243+ echo " prs_to_sync=yes" >> " $GITHUB_OUTPUT "
244+ echo " Selected ${selected_pulls_count} upstream PRs to process"
245+ fi
0 commit comments