Created
December 1, 2025 09:17
-
-
Save benoit-cty/ce6610f95e069bbcd72692a128d643d5 to your computer and use it in GitHub Desktop.
Sha1-Hulud closed PR restore script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Hello, here is a script to restore a deleted PR even if you don't have it locally : | |
| Run it in the project folder with the project and PR number as argument : | |
| openfisca-core> python3 recover_pr.py openfisca/openfisca-core 1338 | |
| It will give you the command line to restore : | |
| Analyzing PR openfisca/openfisca-core#1338... | |
| Found force-push event by Ndpnt on 2025-11-25T21:32:00Z | |
| Before: 65a22ed928d579284a36c1252be67d8f43d9f0dd | |
| After: d2e7131536b0087227353a220a58577eca98c169 | |
| ---------------------------------------- | |
| To restore this state, you can run: | |
| git fetch git@github.com:openfisca/openfisca-core.git 65a22ed928d579284a36c1252be67d8f43d9f0dd | |
| git checkout -b restore-pr-1338 65a22ed928d579284a36c1252be67d8f43d9f0dd | |
| # To force-push this state back to the original branch: | |
| git push git@github.com:openfisca/openfisca-core.git 65a22ed928d579284a36c1252be67d8f43d9f0dd:refs/heads/update_python_versions --force | |
| # To create a new PR restoring the original content: | |
| gh pr create --repo openfisca/openfisca-core --head openfisca:update_python_versions --base master --title 'Update python and ubuntu versions in CI workflows' --body 'This pull request restores #1338 whose contents were destroyed following a [Shai Hulud 2](https://about.gitlab.com/blog/gitlab-discovers-widespread-npm-supply-chain-attack/) attack. | |
| As the original contents were force-pushed after PR closure, we are not able to restore the original PR. See the original PR for discussion and context. | |
| #### Technical changes | |
| - Update python versions in CI. | |
| ' | |
| """ | |
| import argparse | |
| import json | |
| import subprocess | |
| import sys | |
| import shlex | |
| def get_pr_recovery_info(repo, pr_number): | |
| owner, name = repo.split("/") | |
| query = """ | |
| query($owner: String!, $name: String!, $number: Int!) { | |
| repository(owner: $owner, name: $name) { | |
| pullRequest(number: $number) { | |
| url | |
| title | |
| body | |
| baseRefName | |
| headRefName | |
| headRepository { | |
| owner { login } | |
| url | |
| sshUrl | |
| } | |
| commits(first: 1) { | |
| totalCount | |
| } | |
| timelineItems(last: 100, itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT, BASE_REF_FORCE_PUSHED_EVENT]) { | |
| nodes { | |
| ... on HeadRefForcePushedEvent { | |
| createdAt | |
| actor { login } | |
| beforeCommit { oid } | |
| afterCommit { oid } | |
| } | |
| ... on BaseRefForcePushedEvent { | |
| createdAt | |
| actor { login } | |
| beforeCommit { oid } | |
| afterCommit { oid } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| """ | |
| try: | |
| cmd = [ | |
| "gh", "api", "graphql", | |
| "-f", f"query={query}", | |
| "-F", f"owner={owner}", | |
| "-F", f"name={name}", | |
| "-F", f"number={pr_number}" | |
| ] | |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) | |
| data = json.loads(result.stdout) | |
| return data.get("data", {}).get("repository", {}).get("pullRequest", {}) | |
| except subprocess.CalledProcessError as e: | |
| print(f"Error querying GitHub API: {e.stderr}", file=sys.stderr) | |
| return None | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Recover the commit before a force-push on a PR.") | |
| parser.add_argument("repo", help="Repository in format owner/repo") | |
| parser.add_argument("pr", type=int, help="PR number") | |
| args = parser.parse_args() | |
| print(f"Analyzing PR {args.repo}#{args.pr}...", file=sys.stderr) | |
| pr_data = get_pr_recovery_info(args.repo, args.pr) | |
| if not pr_data: | |
| print("Could not retrieve PR data.", file=sys.stderr) | |
| sys.exit(1) | |
| commit_count = pr_data.get("commits", {}).get("totalCount", 0) | |
| timeline = pr_data.get("timelineItems", {}).get("nodes", []) | |
| if not timeline: | |
| if commit_count == 0: | |
| print(f"This PR has no commits and no force-push events.", file=sys.stderr) | |
| print(f"It may have been affected by a force-push on its base branch or parent PR.", file=sys.stderr) | |
| print(f"Check the PR description for references to parent PRs and try recovering those instead.", file=sys.stderr) | |
| # Try to provide helpful info about the PR | |
| pr_url = pr_data.get("url", "") | |
| if pr_url: | |
| print(f"\nPR URL: {pr_url}", file=sys.stderr) | |
| else: | |
| print("No force-push events found in the recent history of this PR.", file=sys.stderr) | |
| sys.exit(1) | |
| event = timeline[0] | |
| event_type = event.get("__typename", "HeadRefForcePushedEvent") | |
| before_sha = event["beforeCommit"]["oid"] | |
| after_sha = event["afterCommit"]["oid"] | |
| actor = event["actor"]["login"] | |
| date = event["createdAt"] | |
| ref_type = "base branch" if "Base" in event_type else "PR branch" | |
| print(f"Found force-push event on {ref_type} by {actor} on {date}") | |
| print(f"Before: {before_sha}") | |
| print(f"After: {after_sha}") | |
| print("-" * 40) | |
| head_repo_url = pr_data.get("headRepository", {}).get("sshUrl") or pr_data.get("headRepository", {}).get("url") | |
| head_owner = pr_data.get("headRepository", {}).get("owner", {}).get("login") | |
| branch_name = pr_data.get("headRefName") | |
| base_branch = pr_data.get("baseRefName", "master") | |
| title = pr_data.get("title", "") | |
| original_body = pr_data.get("body", "") | |
| print("To restore this state, you can run:") | |
| print(f"git fetch {head_repo_url} {before_sha}") | |
| print(f"git checkout -b restore-pr-{args.pr} {before_sha}") | |
| if branch_name: | |
| print("\n# To force-push this state back to the original branch:") | |
| print(f"git push {head_repo_url} {before_sha}:refs/heads/{branch_name} --force") | |
| print("\n# To create a new PR restoring the original content:") | |
| body_template = f"""This pull request restores #{args.pr} whose contents were destroyed following a [Shai Hulud 2](https://about.gitlab.com/blog/gitlab-discovers-widespread-npm-supply-chain-attack/) attack. | |
| As the original contents were force-pushed after PR closure, we are not able to restore the original PR. See the original PR for discussion and context. | |
| """ | |
| new_body = body_template + "\n\n" + original_body | |
| head_ref = f"{head_owner}:{branch_name}" if head_owner else branch_name | |
| cmd = f"gh pr create --repo {args.repo} --head {head_ref} --base {base_branch} --title {shlex.quote(title)} --body {shlex.quote(new_body)}" | |
| print(cmd) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment