Skip to content

Commit fb7b51a

Browse files
Pigbibicodex
andcommitted
fix(deps): close superseded strategy pin PRs
Co-Authored-By: Codex <noreply@openai.com>
1 parent c6cc4a2 commit fb7b51a

5 files changed

Lines changed: 146 additions & 7 deletions

File tree

.github/workflows/open-downstream-qpk-pin-prs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ jobs:
7979
run: |
8080
set -euo pipefail
8181
qpk_sha=$(tr -d '[:space:]' < QPK_PIN)
82-
python3 scripts/merge_verified_strategy_qpk_pin_prs.py --qpk-sha "$qpk_sha"
82+
python3 scripts/merge_verified_strategy_qpk_pin_prs.py --qpk-sha "$qpk_sha" --close-superseded
8383
8484
- name: Summarize missing token
8585
if: steps.sync.outputs.missing_token == 'true'

.github/workflows/update-qpk-pin.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ on:
1212
- ".github/workflows/update-qpk-pin.yml"
1313
- "scripts/check_qpk_pin_consistency.py"
1414
- "scripts/open_downstream_qpk_pin_prs.py"
15+
- "scripts/merge_verified_strategy_qpk_pin_prs.py"
1516
- "tests/test_qpk_pin_consistency.py"
17+
- "tests/test_merge_verified_strategy_qpk_pin_prs.py"
1618
- "tests/test_update_qpk_pin_workflow.py"
1719
- "docs/**"
1820
- "**.md"

scripts/merge_verified_strategy_qpk_pin_prs.py

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
re.IGNORECASE,
4141
)
4242
AUTOMATION_AUTHOR = "Pigbibi"
43+
GENERATED_BRANCH_PREFIX = "auto/qpk-pin-sync-"
44+
GENERATED_TITLE_PREFIX = "chore(deps): align QPK pin to "
4345

4446

4547
def 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+
89109
def 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

126173
def 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+
174277
def 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):

tests/test_merge_verified_strategy_qpk_pin_prs.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
from __future__ import annotations
22

3-
from scripts.merge_verified_strategy_qpk_pin_prs import ALLOWED_CHANGED_FILES, candidate_reason, expected_branch
3+
from scripts.merge_verified_strategy_qpk_pin_prs import (
4+
ALLOWED_CHANGED_FILES,
5+
candidate_reason,
6+
expected_branch,
7+
superseded_pr_reason,
8+
)
49
from scripts.open_downstream_qpk_pin_prs import RepoSpec
510

611

@@ -13,6 +18,7 @@ def _pr(*, checks: list[dict[str, str]] | None = None) -> dict[str, object]:
1318
"baseRefName": "main",
1419
"isCrossRepository": False,
1520
"isDraft": False,
21+
"headRefName": "auto/qpk-pin-sync-8378e939d932-usequitystrategies",
1622
"title": f"chore(deps): align QPK pin to {TARGET[:12]}",
1723
"statusCheckRollup": checks
1824
if checks is not None
@@ -61,3 +67,15 @@ def test_strategy_pin_pr_fails_closed_for_unexpected_changes_or_ci() -> None:
6167
pyproject_text=_pyproject("37c81901160c5b31127a27dba1c63944933fb6bf"),
6268
qpk_sha=TARGET,
6369
) == "qpk_pin_mismatch"
70+
71+
72+
def test_only_a_recognized_older_generated_pr_can_be_closed() -> None:
73+
current_branch = "auto/qpk-pin-sync-8378e939d932-usequitystrategies"
74+
older = _pr()
75+
older["headRefName"] = "auto/qpk-pin-sync-37c81901160c-usequitystrategies"
76+
assert superseded_pr_reason(pr=older, current_branch=current_branch) is None
77+
78+
assert superseded_pr_reason(pr=_pr(), current_branch=current_branch) == "current_branch"
79+
manual = _pr()
80+
manual["headRefName"] = "codex/manual-dependency-change"
81+
assert superseded_pr_reason(pr=manual, current_branch=current_branch) == "unexpected_branch"

tests/test_update_qpk_pin_workflow.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,9 @@ def test_dependency_success_reaches_only_guarded_pr_step(tmp_path: Path) -> None
237237
) in workflow
238238
assert ' - ".github/workflows/update-qpk-pin.yml"' in workflow
239239
assert ' - "scripts/open_downstream_qpk_pin_prs.py"' in workflow
240+
assert ' - "scripts/merge_verified_strategy_qpk_pin_prs.py"' in workflow
240241
assert ' - "tests/test_qpk_pin_consistency.py"' in workflow
242+
assert ' - "tests/test_merge_verified_strategy_qpk_pin_prs.py"' in workflow
241243
assert ' - "tests/test_update_qpk_pin_workflow.py"' in workflow
242244
assert "workflow_dispatch:" not in workflow
243245

@@ -255,7 +257,7 @@ def test_downstream_rollout_is_scheduled_and_phase_gated() -> None:
255257
assert "peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676" in workflow
256258
assert "peter-evans/create-pull-request@v7" not in workflow
257259
assert "Queue verified strategy pin PRs" in workflow
258-
assert "merge_verified_strategy_qpk_pin_prs.py --qpk-sha" in workflow
260+
assert "merge_verified_strategy_qpk_pin_prs.py --qpk-sha \"$qpk_sha\" --close-superseded" in workflow
259261

260262

261263
def test_staged_pin_auto_advance_is_limited_to_verified_machine_prs() -> None:

0 commit comments

Comments
 (0)