Skip to content

Instantly share code, notes, and snippets.

@benoit-cty
Created December 1, 2025 09:17
Show Gist options
  • Select an option

  • Save benoit-cty/ce6610f95e069bbcd72692a128d643d5 to your computer and use it in GitHub Desktop.

Select an option

Save benoit-cty/ce6610f95e069bbcd72692a128d643d5 to your computer and use it in GitHub Desktop.
Sha1-Hulud closed PR restore script
"""
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