1010from __future__ import annotations
1111
1212import argparse
13+ import functools
1314import os
15+ import re
1416import shutil
1517import subprocess
1618import sys
1719import tempfile
1820import textwrap
21+ from collections .abc import Mapping
22+
23+ import requests
1924
2025
2126def check_state () -> None :
22- if not os .path .isfile ("README.md" ):
27+ if not os .path .isfile ("README.md" ) and not os . path . isdir ( "mypy" ) :
2328 sys .exit ("error: The current working directory must be the mypy repository root" )
2429 out = subprocess .check_output (["git" , "status" , "-s" , os .path .join ("mypy" , "typeshed" )])
2530 if out :
@@ -37,6 +42,7 @@ def update_typeshed(typeshed_dir: str, commit: str | None) -> str:
3742 if commit :
3843 subprocess .run (["git" , "checkout" , commit ], check = True , cwd = typeshed_dir )
3944 commit = git_head_commit (typeshed_dir )
45+
4046 stdlib_dir = os .path .join ("mypy" , "typeshed" , "stdlib" )
4147 # Remove existing stubs.
4248 shutil .rmtree (stdlib_dir )
@@ -60,6 +66,69 @@ def git_head_commit(repo: str) -> str:
6066 return commit .strip ()
6167
6268
69+ @functools .cache
70+ def get_github_api_headers () -> Mapping [str , str ]:
71+ headers = {"Accept" : "application/vnd.github.v3+json" }
72+ secret = os .environ .get ("GITHUB_TOKEN" )
73+ if secret is not None :
74+ headers ["Authorization" ] = (
75+ f"token { secret } " if secret .startswith ("ghp" ) else f"Bearer { secret } "
76+ )
77+ return headers
78+
79+
80+ @functools .cache
81+ def get_origin_owner () -> str :
82+ output = subprocess .check_output (["git" , "remote" , "get-url" , "origin" ], text = True ).strip ()
83+ match = re .match (
84+ r"([email protected] :|https://github.com/)(?P<owner>[^/]+)/(?P<repo>[^/\s]+)" ,
output 85+ )
86+ assert match is not None , f"Couldn't identify origin's owner: { output !r} "
87+ assert (
88+ match .group ("repo" ).removesuffix (".git" ) == "mypy"
89+ ), f'Unexpected repo: { match .group ("repo" )!r} '
90+ return match .group ("owner" )
91+
92+
93+ def create_or_update_pull_request (* , title : str , body : str , branch_name : str ) -> None :
94+ fork_owner = get_origin_owner ()
95+
96+ with requests .post (
97+ "https://api.github.com/repos/python/mypy/pulls" ,
98+ json = {
99+ "title" : title ,
100+ "body" : body ,
101+ "head" : f"{ fork_owner } :{ branch_name } " ,
102+ "base" : "master" ,
103+ },
104+ headers = get_github_api_headers (),
105+ ) as response :
106+ resp_json = response .json ()
107+ if response .status_code == 422 and any (
108+ "A pull request already exists" in e .get ("message" , "" )
109+ for e in resp_json .get ("errors" , [])
110+ ):
111+ # Find the existing PR
112+ with requests .get (
113+ "https://api.github.com/repos/python/mypy/pulls" ,
114+ params = {"state" : "open" , "head" : f"{ fork_owner } :{ branch_name } " , "base" : "master" },
115+ headers = get_github_api_headers (),
116+ ) as response :
117+ response .raise_for_status ()
118+ resp_json = response .json ()
119+ assert len (resp_json ) >= 1
120+ pr_number = resp_json [0 ]["number" ]
121+ # Update the PR's title and body
122+ with requests .patch (
123+ f"https://api.github.com/repos/python/mypy/pulls/{ pr_number } " ,
124+ json = {"title" : title , "body" : body },
125+ headers = get_github_api_headers (),
126+ ) as response :
127+ response .raise_for_status ()
128+ return
129+ response .raise_for_status ()
130+
131+
63132def main () -> None :
64133 parser = argparse .ArgumentParser ()
65134 parser .add_argument (
@@ -72,12 +141,21 @@ def main() -> None:
72141 default = None ,
73142 help = "Location of typeshed (default to a temporary repository clone)" ,
74143 )
144+ parser .add_argument (
145+ "--make-pr" ,
146+ action = "store_true" ,
147+ help = "Whether to make a PR with the changes (default to no)" ,
148+ )
75149 args = parser .parse_args ()
150+
76151 check_state ()
77- print ("Update contents of mypy/typeshed from typeshed? [yN] " , end = "" )
78- answer = input ()
79- if answer .lower () != "y" :
80- sys .exit ("Aborting" )
152+
153+ if args .make_pr :
154+ if os .environ .get ("GITHUB_TOKEN" ) is None :
155+ raise ValueError ("GITHUB_TOKEN environment variable must be set" )
156+
157+ branch_name = "mypybot/sync-typeshed"
158+ subprocess .run (["git" , "checkout" , "-B" , branch_name , "origin/master" ], check = True )
81159
82160 if not args .typeshed_dir :
83161 # Clone typeshed repo if no directory given.
@@ -95,19 +173,34 @@ def main() -> None:
95173
96174 # Create a commit
97175 message = textwrap .dedent (
98- """\
176+ f """\
99177 Sync typeshed
100178
101179 Source commit:
102180 https://github.com/python/typeshed/commit/{ commit }
103- """ .format (
104- commit = commit
105- )
181+ """
106182 )
107183 subprocess .run (["git" , "add" , "--all" , os .path .join ("mypy" , "typeshed" )], check = True )
108184 subprocess .run (["git" , "commit" , "-m" , message ], check = True )
109185 print ("Created typeshed sync commit." )
110186
187+ # Currently just LiteralString reverts
188+ commits_to_cherry_pick = ["780534b13722b7b0422178c049a1cbbf4ea4255b" ]
189+ for commit in commits_to_cherry_pick :
190+ subprocess .run (["git" , "cherry-pick" , commit ], check = True )
191+ print (f"Cherry-picked { commit } ." )
192+
193+ if args .make_pr :
194+ subprocess .run (["git" , "push" , "--force" , "origin" , branch_name ], check = True )
195+ print ("Pushed commit." )
196+
197+ warning = "Note that you will need to close and re-open the PR in order to trigger CI."
198+
199+ create_or_update_pull_request (
200+ title = "Sync typeshed" , body = message + "\n " + warning , branch_name = branch_name
201+ )
202+ print ("Created PR." )
203+
111204
112205if __name__ == "__main__" :
113206 main ()
0 commit comments