Skip to content

Commit 574949e

Browse files
authored
Merge pull request #53 from OpenRailAssociation/fix-github-tag-detection
Catch error when searching for commit SHA in Github tags
2 parents cefe7b3 + 52f6d41 commit 574949e

2 files changed

Lines changed: 122 additions & 28 deletions

File tree

‎purltools/_git.py‎

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,36 +22,30 @@ def is_sha1(sha: str) -> bool:
2222
return len(sha) == 40 and all(c in "0123456789abcdef" for c in sha.lower())
2323

2424

25-
def github_tag_to_commit(owner: str, repo: str, tag: str) -> str:
26-
"""Convert a GitHub tag to a commit hash.
27-
25+
def _get_github_tag_info(owner: str, repo: str, tag: str, headers: dict) -> dict:
26+
"""
27+
Get information about a GitHub tag using GitHub's REST API.
2828
Args:
29-
tag (str): The GitHub tag.
29+
owner (str): Repository owner
30+
repo (str): Repository name
31+
tag (str): Tag name/reference
32+
headers (dict): Headers to include in the API request
3033
3134
Returns:
32-
str: The commit hash corresponding to the tag.
33-
"""
34-
return get_tag_commit_sha(owner, repo, tag)
35-
36-
37-
def validate_tag_url(url: str) -> None:
38-
"""Validate a GitHub API URL to prevent server-side request forgery"""
39-
if not url.startswith("https://api.github.com"):
40-
raise ValueError(f"Invalid GitHub API URL '{url}")
41-
if not is_sha1(url.split("/")[-1]):
42-
raise ValueError(f"Invalid SHA1 hash in URL '{url}'")
35+
dict: The JSON response from the GitHub API
4336
37+
Raises:
38+
requests.exceptions.RequestException: If the API request fails
39+
"""
40+
logging.debug("Resolving GitHub tag %s in repo %s/%s to commit SHA", tag, owner, repo)
4441

45-
def get_github_token() -> str | None:
46-
"""Get GitHub token from environment variable if available"""
47-
if "GITHUB_TOKEN" in os.environ and os.environ["GITHUB_TOKEN"]:
48-
logging.debug("GitHub token found in environment")
49-
return str(os.environ["GITHUB_TOKEN"])
50-
logging.debug("No GitHub token found, proceeding unauthenticated")
51-
return None
42+
url = f"https://api.github.com/repos/{owner}/{repo}/git/ref/tags/{tag}"
43+
response = requests.get(url, headers=headers, timeout=30)
44+
response.raise_for_status()
45+
return response.json()
5246

5347

54-
def get_tag_commit_sha(owner: str, repo: str, tag: str) -> str:
48+
def github_tag_to_commit(owner: str, repo: str, tag: str) -> str:
5549
"""Convert a GitHub tag to a commit hash using GitHub's REST API.
5650
5751
Args:
@@ -65,16 +59,31 @@ def get_tag_commit_sha(owner: str, repo: str, tag: str) -> str:
6559
Raises:
6660
requests.exceptions.RequestException: If the API request fails
6761
"""
68-
token = get_github_token()
62+
# Set Headers for GitHub API request
6963
headers = {"Accept": "application/vnd.github.v3+json"}
64+
token = get_github_token()
7065
if token:
7166
headers["Authorization"] = f"Bearer {token}"
7267

73-
url = f"https://api.github.com/repos/{owner}/{repo}/git/ref/tags/{tag}"
74-
response = requests.get(url, headers=headers, timeout=30)
75-
response.raise_for_status()
68+
# First try to get the tag info directly
69+
try:
70+
data = _get_github_tag_info(owner, repo, tag, headers)
71+
# If the tag is not found and does not start with 'v', try again with 'v' prefix. Needed for
72+
# GitHub actions and cdxgen
73+
except requests.exceptions.RequestException as e:
74+
status_code = getattr(e.response, "status_code", None)
75+
if status_code == 404 and not tag.startswith("v"):
76+
logging.debug("Tag %s not found, retrying with 'v' prefix", tag)
77+
try:
78+
data = _get_github_tag_info(owner, repo, f"v{tag}", headers)
79+
except requests.exceptions.RequestException:
80+
if getattr(e.response, "status_code", None) == 404:
81+
logging.error("Tag %s (or v%s) not found in repo %s/%s", tag, tag, owner, repo)
82+
return tag
83+
raise e from e
84+
else:
85+
raise
7686

77-
data = response.json()
7887
# Tag refs point to annotated tags first, which then point to commits
7988
if data["object"]["type"] == "tag":
8089
# Get the commit URL from the annotated tag
@@ -86,3 +95,20 @@ def get_tag_commit_sha(owner: str, repo: str, tag: str) -> str:
8695

8796
# Lightweight tags point directly to commits
8897
return data["object"]["sha"]
98+
99+
100+
def validate_tag_url(url: str) -> None:
101+
"""Validate a GitHub API URL to prevent server-side request forgery"""
102+
if not url.startswith("https://api.github.com"):
103+
raise ValueError(f"Invalid GitHub API URL '{url}")
104+
if not is_sha1(url.split("/")[-1]):
105+
raise ValueError(f"Invalid SHA1 hash in URL '{url}'")
106+
107+
108+
def get_github_token() -> str | None:
109+
"""Get GitHub token from environment variable if available"""
110+
if "GITHUB_TOKEN" in os.environ and os.environ["GITHUB_TOKEN"]:
111+
logging.debug("GitHub token found in environment")
112+
return str(os.environ["GITHUB_TOKEN"])
113+
logging.debug("No GitHub token found, proceeding unauthenticated")
114+
return None

‎tests/test_git.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# SPDX-FileCopyrightText: 2025 DB Systel GmbH
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""Test _git.py module functions"""
6+
7+
from unittest.mock import Mock, patch
8+
9+
import requests
10+
11+
from purltools._git import github_tag_to_commit
12+
13+
14+
class TestGitHubTagToCommit:
15+
"""Test cases for github_tag_to_commit function"""
16+
17+
@patch("purltools._git.requests.get")
18+
@patch("purltools._git.get_github_token")
19+
def test_github_purl_tag_not_found_retry_success(self, mock_token, mock_get):
20+
"""Test pkg:github/actions/checkout@5 scenario - tag '5' not found, 'v5' found"""
21+
mock_token.return_value = None
22+
23+
# First call for tag '5' fails with 404
24+
first_response = Mock()
25+
first_error = requests.exceptions.HTTPError()
26+
first_error.response = Mock()
27+
first_error.response.status_code = 404
28+
first_response.raise_for_status.side_effect = first_error
29+
30+
# Second call for tag 'v5' succeeds
31+
second_response = Mock()
32+
second_response.json.return_value = {
33+
"object": {"type": "commit", "sha": "08c6903cd8c0fde910a37f88322edcfb5dd907a8"}
34+
}
35+
36+
mock_get.side_effect = [first_response, second_response]
37+
38+
result = github_tag_to_commit("actions", "checkout", "5")
39+
40+
assert result == "08c6903cd8c0fde910a37f88322edcfb5dd907a8"
41+
assert mock_get.call_count == 2
42+
43+
@patch("purltools._git.requests.get")
44+
@patch("purltools._git.get_github_token")
45+
def test_github_purl_tag_not_found_retry_also_fails(self, mock_token, mock_get):
46+
"""Test pkg:github/actions/checkout@5 scenario - both '5' and 'v5' not found"""
47+
mock_token.return_value = None
48+
49+
# Both calls fail with 404
50+
first_response = Mock()
51+
first_error = requests.exceptions.HTTPError()
52+
first_error.response = Mock()
53+
first_error.response.status_code = 404
54+
first_response.raise_for_status.side_effect = first_error
55+
56+
second_response = Mock()
57+
second_error = requests.exceptions.HTTPError()
58+
second_error.response = Mock()
59+
second_error.response.status_code = 404
60+
second_response.raise_for_status.side_effect = second_error
61+
62+
mock_get.side_effect = [first_response, second_response]
63+
64+
result = github_tag_to_commit("actions", "checkout", "5")
65+
66+
# Should return the original tag name as fallback
67+
assert result == "5"
68+
assert mock_get.call_count == 2

0 commit comments

Comments
 (0)