11from __future__ import annotations
22
33import subprocess
4+ import tempfile
5+ import urllib .request
46from enum import Enum
57from pathlib import Path
68
79import yaml
810from pydantic import BaseModel , HttpUrl , field_validator
911
12+ from .subprocess_utils import run_command
13+
1014
1115class Language (str , Enum ):
1216 C = "C"
@@ -33,8 +37,70 @@ class LibraryType(str, Enum):
3337 CSHARED = "cshared"
3438
3539
40+ def extract_github_repo_info (github_url : str ) -> tuple [str , str ] | None :
41+ """Extract owner and repo name from GitHub URL"""
42+ if not github_url :
43+ return None
44+
45+ # Handle various GitHub URL formats
46+ url = github_url .rstrip ("/" )
47+ if url .startswith ("https://github.com/" ):
48+ path = url [len ("https://github.com/" ) :]
49+ elif url .startswith ("http://github.com/" ):
50+ path = url [len ("http://github.com/" ) :]
51+ elif url .startswith ("github.com/" ):
52+ path = url [len ("github.com/" ) :]
53+ else :
54+ return None
55+
56+ parts = path .split ("/" )
57+ if len (parts ) >= 2 :
58+ return parts [0 ], parts [1 ]
59+ return None
60+
61+
62+ def check_github_release_exists (github_url : str , version : str ) -> bool :
63+ """Check if a GitHub release/tag exists using GitHub API"""
64+ repo_info = extract_github_repo_info (github_url )
65+ if not repo_info :
66+ return False
67+
68+ owner , repo = repo_info
69+
70+ # Try both with and without 'v' prefix
71+ versions_to_check = [version ]
72+ if version .startswith ("v" ):
73+ versions_to_check .append (version [1 :])
74+ else :
75+ versions_to_check .append (f"v{ version } " )
76+
77+ for test_version in versions_to_check :
78+ url = f"https://api.github.com/repos/{ owner } /{ repo } /releases/tags/{ test_version } "
79+ try :
80+ with urllib .request .urlopen (url , timeout = 10 ) as response :
81+ if response .status == 200 :
82+ return True
83+ except (urllib .error .HTTPError , urllib .error .URLError , TimeoutError ):
84+ # Try checking tags endpoint if releases endpoint fails
85+ tag_url = f"https://api.github.com/repos/{ owner } /{ repo } /git/refs/tags/{ test_version } "
86+ try :
87+ with urllib .request .urlopen (tag_url , timeout = 10 ) as tag_response :
88+ if tag_response .status == 200 :
89+ return True
90+ except (urllib .error .HTTPError , urllib .error .URLError , TimeoutError ):
91+ continue
92+
93+ return False
94+
95+
3696def check_git_tag_exists (repo_url : str , tag : str ) -> bool :
3797 """Check if a git tag exists in the remote repository"""
98+ # First try GitHub API if it's a GitHub URL
99+ if "github.com" in repo_url :
100+ if check_github_release_exists (repo_url , tag ):
101+ return True
102+
103+ # Fallback to git ls-remote for non-GitHub repos or if API fails
38104 try :
39105 # Check if tag exists remotely without cloning
40106 result = subprocess .run (
@@ -108,10 +174,6 @@ def check_existing_library_config_remote(repo_url: str, library_id: str) -> dict
108174 Check if a library already exists by temporarily cloning the infra repository.
109175 This is used during interactive detection when we don't have the repo yet.
110176 """
111- import tempfile
112-
113- from .subprocess_utils import run_command
114-
115177 try :
116178 with tempfile .TemporaryDirectory () as tmpdir :
117179 infra_path = Path (tmpdir ) / "infra"
@@ -138,16 +200,16 @@ def check_existing_library_config_remote(repo_url: str, library_id: str) -> dict
138200 return None
139201
140202
141- def determine_version_format (repo_url : str , version : str ) -> tuple [str , str | None ]:
203+ def determine_version_format (repo_url : str , version : str ) -> tuple [str , str | None , bool ]:
142204 """
143205 Determine the actual version format by checking git tags.
144- Returns (normalized_version, target_prefix)
206+ Returns (normalized_version, target_prefix, version_exists )
145207 """
146208 if not repo_url :
147209 # No repo URL, can't check tags - just normalize by removing 'v' prefix
148210 if version .startswith ("v" ):
149- return version [1 :], "v"
150- return version , None
211+ return version [1 :], "v" , False
212+ return version , None , False
151213
152214 # Check if version starts with 'v'
153215 if version .startswith ("v" ):
@@ -157,27 +219,27 @@ def determine_version_format(repo_url: str, version: str) -> tuple[str, str | No
157219
158220 if check_git_tag_exists (repo_url , version_with_v ):
159221 # v1.2.3 exists - use it and set target_prefix
160- return version_without_v , "v"
222+ return version_without_v , "v" , True
161223 elif check_git_tag_exists (repo_url , version_without_v ):
162224 # Only 1.2.3 exists - user made a mistake, use 1.2.3
163- return version_without_v , None
225+ return version_without_v , None , True
164226 else :
165- # Neither exists - assume user intended v1.2.3 format
166- return version_without_v , "v"
227+ # Neither exists - version doesn't exist
228+ return version_without_v , "v" , False
167229 else :
168230 # User entered 1.2.3 - check if both 1.2.3 and v1.2.3 exist
169231 version_without_v = version
170232 version_with_v = f"v{ version } "
171233
172- if check_git_tag_exists (repo_url , version_without_v ):
173- # 1 .2.3 exists - use it without prefix
174- return version_without_v , None
175- elif check_git_tag_exists (repo_url , version_with_v ):
176- # Only v1 .2.3 exists - should use target_prefix
177- return version_without_v , "v"
234+ if check_git_tag_exists (repo_url , version_with_v ):
235+ # v1 .2.3 exists - should use target_prefix
236+ return version_without_v , "v" , True
237+ elif check_git_tag_exists (repo_url , version_without_v ):
238+ # Only 1 .2.3 exists - use it without prefix
239+ return version_without_v , None , True
178240 else :
179- # Neither exists - assume user intended 1.2.3 format
180- return version_without_v , None
241+ # Neither exists - version doesn't exist
242+ return version_without_v , None , False
181243
182244
183245class LibraryConfig (BaseModel ):
@@ -217,22 +279,30 @@ def validate_version(cls, v):
217279 else :
218280 raise ValueError ("Version must be a string or list of strings" )
219281
220- def normalize_versions_with_git_lookup (self ):
282+ def normalize_versions_with_git_lookup (self ) -> list [ str ] :
221283 """
222284 Normalize versions by checking git tags and set target_prefix if needed.
223285 This should be called after the model is fully populated with github_url.
286+ Returns list of any versions that don't exist in the repository.
224287 """
225288 if not self .github_url :
226- return
289+ return []
227290
228291 versions = self .get_versions ()
229292 normalized_versions = []
230293 target_prefix = None
294+ missing_versions = []
231295
232296 for version in versions :
233- normalized_version , prefix = determine_version_format (str (self .github_url ), version )
297+ normalized_version , prefix , exists = determine_version_format (
298+ str (self .github_url ), version
299+ )
234300 normalized_versions .append (normalized_version )
235301
302+ # Track missing versions
303+ if not exists :
304+ missing_versions .append (version )
305+
236306 # Set target_prefix if any version needs it
237307 if prefix :
238308 target_prefix = prefix
@@ -246,6 +316,31 @@ def normalize_versions_with_git_lookup(self):
246316 if target_prefix :
247317 self .target_prefix = target_prefix
248318
319+ return missing_versions
320+
321+ def validate_versions_and_exit_on_missing (self ) -> None :
322+ """
323+ Validate versions for non-Rust libraries and exit with error if any are missing.
324+ This function handles the common pattern of checking versions and failing fast.
325+ """
326+ if self .language == Language .RUST :
327+ return # Rust versions don't need git tag validation
328+
329+ print ("\n Checking git tags for version format..." )
330+ missing_versions = self .normalize_versions_with_git_lookup ()
331+
332+ if missing_versions :
333+ print ("❌ Error: The following versions were not found in the repository:" )
334+ for version in missing_versions :
335+ print (f" - { version } " )
336+ print ("Please check the version numbers and try again." )
337+ exit (1 )
338+ else :
339+ print ("✓ All versions found in repository" )
340+
341+ if self .target_prefix :
342+ print (f"✓ Detected version format requires target_prefix: { self .target_prefix } " )
343+
249344 def get_versions (self ) -> list [str ]:
250345 """Get list of versions, handling both single and multiple version cases"""
251346 if isinstance (self .version , str ):
0 commit comments