4040 re .IGNORECASE ,
4141)
4242AUTOMATION_AUTHOR = "Pigbibi"
43+ GENERATED_BRANCH_PREFIX = "auto/qpk-pin-sync-"
44+ GENERATED_TITLE_PREFIX = "chore(deps): align QPK pin to "
4345
4446
4547def run (command : list [str ], * , env : dict [str , str ]) -> subprocess .CompletedProcess [str ]:
@@ -63,7 +65,7 @@ def candidate_reason(
6365) -> str | None :
6466 """Return a fail-closed reason when a generated PR is not mergeable."""
6567
66- expected_title = f"chore(deps): align QPK pin to { qpk_sha [:12 ]} "
68+ expected_title = f"{ GENERATED_TITLE_PREFIX } { qpk_sha [:12 ]} "
6769 author = (pr .get ("author" ) or {}).get ("login" )
6870 if author != AUTOMATION_AUTHOR :
6971 return "unexpected_author"
@@ -86,6 +88,24 @@ def candidate_reason(
8688 return None
8789
8890
91+ def superseded_pr_reason (* , pr : dict [str , Any ], current_branch : str ) -> str | None :
92+ """Return a reason unless this is a safely recognizable obsolete PR."""
93+
94+ author = (pr .get ("author" ) or {}).get ("login" )
95+ if author != AUTOMATION_AUTHOR :
96+ return "unexpected_author"
97+ if pr .get ("baseRefName" ) != "main" or pr .get ("isCrossRepository" ) or pr .get ("isDraft" ):
98+ return "unexpected_pr_target"
99+ head_ref = pr .get ("headRefName" ) or ""
100+ if head_ref == current_branch :
101+ return "current_branch"
102+ if not head_ref .startswith (GENERATED_BRANCH_PREFIX ):
103+ return "unexpected_branch"
104+ if not (pr .get ("title" ) or "" ).startswith (GENERATED_TITLE_PREFIX ):
105+ return "unexpected_title"
106+ return None
107+
108+
89109def open_pr_payload (repo : RepoSpec , branch : str , * , env : dict [str , str ]) -> dict [str , Any ] | None :
90110 payload = _json (
91111 [
@@ -109,18 +129,45 @@ def open_pr_payload(repo: RepoSpec, branch: str, *, env: dict[str, str]) -> dict
109129 raise RuntimeError (f"ambiguous_generated_prs:count={ len (payload )} " )
110130 pr_number = str (payload [0 ]["number" ])
111131 return _json (
132+ pr_view_command (repo , pr_number ),
133+ env = env ,
134+ )
135+
136+
137+ def pr_view_command (repo : RepoSpec , pr_number : str ) -> list [str ]:
138+ return [
139+ "gh" ,
140+ "pr" ,
141+ "view" ,
142+ pr_number ,
143+ "--repo" ,
144+ f"QuantStrategyLab/{ repo .name } " ,
145+ "--json" ,
146+ "author,baseRefName,headRefName,headRefOid,isCrossRepository,isDraft,number,statusCheckRollup,title,url" ,
147+ ]
148+
149+
150+ def open_generated_prs (repo : RepoSpec , * , env : dict [str , str ]) -> list [dict [str , Any ]]:
151+ payload = _json (
112152 [
113153 "gh" ,
114154 "pr" ,
115- "view" ,
116- pr_number ,
155+ "list" ,
117156 "--repo" ,
118157 f"QuantStrategyLab/{ repo .name } " ,
158+ "--state" ,
159+ "open" ,
160+ "--limit" ,
161+ "100" ,
119162 "--json" ,
120- "author,baseRefName,headRefName,headRefOid,isCrossRepository,isDraft, number,statusCheckRollup,title,url " ,
163+ "number" ,
121164 ],
122165 env = env ,
123166 )
167+ return [
168+ _json (pr_view_command (repo , str (item ["number" ])), env = env )
169+ for item in payload
170+ ]
124171
125172
126173def changed_files (repo : RepoSpec , pr_number : int , * , env : dict [str , str ]) -> list [str ]:
@@ -171,10 +218,71 @@ def merge_candidate(repo: RepoSpec, pr: dict[str, Any], *, env: dict[str, str])
171218 )
172219
173220
221+ def qpk_ref_from_pyproject (text : str ) -> str | None :
222+ refs = set (QPK_REF_RE .findall (text ))
223+ return next (iter (refs )) if len (refs ) == 1 else None
224+
225+
226+ def is_main_history_ancestor (* , candidate_sha : str , qpk_sha : str , env : dict [str , str ]) -> bool :
227+ status = run (
228+ [
229+ "gh" ,
230+ "api" ,
231+ f"repos/QuantStrategyLab/QuantPlatformKit/compare/{ candidate_sha } ...{ qpk_sha } " ,
232+ "--jq" ,
233+ ".status" ,
234+ ],
235+ env = env ,
236+ ).stdout .strip ()
237+ return status == "ahead"
238+
239+
240+ def close_superseded_candidates (
241+ repo : RepoSpec ,
242+ * ,
243+ current_branch : str ,
244+ qpk_sha : str ,
245+ env : dict [str , str ],
246+ ) -> list [str ]:
247+ """Close only fully recognizable older pins that lead to the current pin."""
248+
249+ results : list [str ] = []
250+ for pr in open_generated_prs (repo , env = env ):
251+ reason = superseded_pr_reason (pr = pr , current_branch = current_branch )
252+ if reason is not None :
253+ continue
254+ old_qpk_sha = qpk_ref_from_pyproject (pyproject_text (repo , pr ["headRefOid" ], env = env ))
255+ if old_qpk_sha is None or not is_main_history_ancestor (
256+ candidate_sha = old_qpk_sha ,
257+ qpk_sha = qpk_sha ,
258+ env = env ,
259+ ):
260+ continue
261+ run (
262+ [
263+ "gh" ,
264+ "pr" ,
265+ "close" ,
266+ str (pr ["number" ]),
267+ "--repo" ,
268+ f"QuantStrategyLab/{ repo .name } " ,
269+ "--delete-branch" ,
270+ ],
271+ env = env ,
272+ )
273+ results .append (str (pr ["number" ]))
274+ return results
275+
276+
174277def parse_args () -> argparse .Namespace :
175278 parser = argparse .ArgumentParser (description = "Merge verified generated strategy QPK pin PRs." )
176279 parser .add_argument ("--qpk-sha" , required = True )
177280 parser .add_argument ("--token-env" , default = "QSL_REPO_SYNC_TOKEN" )
281+ parser .add_argument (
282+ "--close-superseded" ,
283+ action = "store_true" ,
284+ help = "Close only verified older generated strategy pin PRs after a current PR is green." ,
285+ )
178286 return parser .parse_args ()
179287
180288
@@ -207,6 +315,15 @@ def main() -> int:
207315 continue
208316 merge_candidate (repo , pr , env = env )
209317 print (f"{ repo .name } : queued:{ pr ['url' ]} " )
318+ if args .close_superseded :
319+ closed = close_superseded_candidates (
320+ repo ,
321+ current_branch = branch ,
322+ qpk_sha = qpk_sha ,
323+ env = env ,
324+ )
325+ if closed :
326+ print (f"{ repo .name } : closed_superseded:{ ',' .join (closed )} " )
210327 except (RuntimeError , subprocess .CalledProcessError , ValueError , UnicodeDecodeError ) as exc :
211328 failures += 1
212329 if isinstance (exc , subprocess .CalledProcessError ):
0 commit comments