Fix server threading, tag URLs, and tag date resolution
- Switch to ThreadingHTTPServer to handle concurrent requests - Catch BrokenPipeError on response writes to suppress noisy tracebacks - Change tag URL from /tags/ to /releases/tag/ - Resolve tag dates via commit SHA from tags API + commits endpoint (works for public repos without auth) - Update tests to match new tag date resolution approach
This commit is contained in:
+2
-2
@@ -307,11 +307,11 @@ def cmd_serve(args: argparse.Namespace, db_path: str) -> None:
|
|||||||
port = args.port or DEFAULT_SERVER_PORT
|
port = args.port or DEFAULT_SERVER_PORT
|
||||||
|
|
||||||
from src.server import create_handler
|
from src.server import create_handler
|
||||||
from http.server import HTTPServer
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
handler = create_handler(db_path)
|
handler = create_handler(db_path)
|
||||||
try:
|
try:
|
||||||
server = HTTPServer((host, port), handler)
|
server = ThreadingHTTPServer((host, port), handler)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
if e.errno == 98: # Address already in use
|
if e.errno == 98: # Address already in use
|
||||||
error(f"Port {port} is already in use")
|
error(f"Port {port} is already in use")
|
||||||
|
|||||||
+9
-19
@@ -117,38 +117,28 @@ class GitHubClient:
|
|||||||
result = []
|
result = []
|
||||||
for tag in data:
|
for tag in data:
|
||||||
tag_name = tag["name"]
|
tag_name = tag["name"]
|
||||||
published_at = self._get_tag_date(owner_repo, tag_name)
|
commit_sha = tag.get("commit", {}).get("sha")
|
||||||
|
published_at = self._get_tag_date(owner_repo, commit_sha)
|
||||||
result.append({
|
result.append({
|
||||||
"tag_name": tag_name,
|
"tag_name": tag_name,
|
||||||
"title": tag_name,
|
"title": tag_name,
|
||||||
"body": "",
|
"body": "",
|
||||||
"published_at": published_at,
|
"published_at": published_at,
|
||||||
"html_url": f"https://github.com/{owner_repo}/tags/{tag_name}",
|
"html_url": f"https://github.com/{owner_repo}/releases/tag/{tag_name}",
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _get_tag_date(self, owner_repo: str, tag_name: str) -> str:
|
def _get_tag_date(self, owner_repo: str, commit_sha: str | None) -> str:
|
||||||
"""Get the commit date for a tag by resolving the git ref."""
|
"""Get the commit date for a tag using the commit SHA from tags API."""
|
||||||
|
if not commit_sha:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
try:
|
try:
|
||||||
resp = self._request(
|
resp = self._request(
|
||||||
"GET",
|
"GET",
|
||||||
f"{self.BASE_URL}/repos/{owner_repo}/git/ref/tags/{tag_name}",
|
f"{self.BASE_URL}/repos/{owner_repo}/commits/{commit_sha}",
|
||||||
)
|
)
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
# The ref may point to a commit directly or to a tag object
|
author_date = data.get("commit", {}).get("author", {}).get("date")
|
||||||
obj = data.get("object", {})
|
|
||||||
obj_type = obj.get("type")
|
|
||||||
if obj_type == "commit":
|
|
||||||
author_date = obj.get("author", {}).get("date")
|
|
||||||
if author_date:
|
|
||||||
return author_date
|
|
||||||
elif obj_type == "tag":
|
|
||||||
# Follow the tag object to get the commit
|
|
||||||
commit_sha = obj.get("sha")
|
|
||||||
if commit_sha:
|
|
||||||
commit_resp = self._request("GET", f"{self.BASE_URL}/repos/{owner_repo}/git/commits/{commit_sha}")
|
|
||||||
commit_data = commit_resp.json()
|
|
||||||
author_date = commit_data.get("author", {}).get("date")
|
|
||||||
if author_date:
|
if author_date:
|
||||||
return author_date
|
return author_date
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -39,20 +39,29 @@ def create_handler(db_path: str):
|
|||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "application/rss+xml; charset=utf-8")
|
self.send_header("Content-Type", "application/rss+xml; charset=utf-8")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
try:
|
||||||
self.wfile.write(feed_xml.encode("utf-8"))
|
self.wfile.write(feed_xml.encode("utf-8"))
|
||||||
|
except BrokenPipeError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _serve_index(self):
|
def _serve_index(self):
|
||||||
html = generate_index_html(self._base_url())
|
html = generate_index_html(self._base_url())
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
try:
|
||||||
self.wfile.write(html.encode("utf-8"))
|
self.wfile.write(html.encode("utf-8"))
|
||||||
|
except BrokenPipeError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _serve_health(self):
|
def _serve_health(self):
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "text/plain")
|
self.send_header("Content-Type", "text/plain")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
try:
|
||||||
self.wfile.write(b"OK")
|
self.wfile.write(b"OK")
|
||||||
|
except BrokenPipeError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _base_url(self) -> str:
|
def _base_url(self) -> str:
|
||||||
host = self.server.server_address[0]
|
host = self.server.server_address[0]
|
||||||
|
|||||||
+14
-28
@@ -61,24 +61,18 @@ class TestFetchTags:
|
|||||||
def test_fetch_tags(self, mock_api):
|
def test_fetch_tags(self, mock_api):
|
||||||
mock_api.get("https://api.github.com/repos/owner/repo/tags").mock(
|
mock_api.get("https://api.github.com/repos/owner/repo/tags").mock(
|
||||||
return_value=httpx.Response(200, json=[
|
return_value=httpx.Response(200, json=[
|
||||||
{"name": "v1.0.0", "zipball_url": "https://api.github.com/repos/owner/repo/zipball/v1.0.0"},
|
{"name": "v1.0.0", "commit": {"sha": "aaa111"}},
|
||||||
{"name": "v0.9.0", "zipball_url": "https://api.github.com/repos/owner/repo/zipball/v0.9.0"},
|
{"name": "v0.9.0", "commit": {"sha": "bbb222"}},
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
mock_api.get("https://api.github.com/repos/owner/repo/git/ref/tags/v1.0.0").mock(
|
mock_api.get("https://api.github.com/repos/owner/repo/commits/aaa111").mock(
|
||||||
return_value=httpx.Response(200, json={
|
return_value=httpx.Response(200, json={
|
||||||
"object": {
|
"commit": {"author": {"date": "2025-01-15T10:30:00Z"}}
|
||||||
"type": "commit",
|
|
||||||
"author": {"date": "2025-01-15T10:30:00Z"},
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
mock_api.get("https://api.github.com/repos/owner/repo/git/ref/tags/v0.9.0").mock(
|
mock_api.get("https://api.github.com/repos/owner/repo/commits/bbb222").mock(
|
||||||
return_value=httpx.Response(200, json={
|
return_value=httpx.Response(200, json={
|
||||||
"object": {
|
"commit": {"author": {"date": "2025-01-10T08:00:00Z"}}
|
||||||
"type": "commit",
|
|
||||||
"author": {"date": "2025-01-10T08:00:00Z"},
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,26 +85,18 @@ class TestFetchTags:
|
|||||||
assert tags[0]["title"] == "v1.0.0"
|
assert tags[0]["title"] == "v1.0.0"
|
||||||
assert tags[0]["body"] == ""
|
assert tags[0]["body"] == ""
|
||||||
assert tags[0]["published_at"] == "2025-01-15T10:30:00Z"
|
assert tags[0]["published_at"] == "2025-01-15T10:30:00Z"
|
||||||
assert tags[0]["html_url"] == "https://github.com/owner/repo/tags/v1.0.0"
|
assert tags[0]["html_url"] == "https://github.com/owner/repo/releases/tag/v1.0.0"
|
||||||
|
|
||||||
def test_fetch_tags_with_tag_object(self, mock_api):
|
def test_fetch_tags_with_tag_object(self, mock_api):
|
||||||
"""When a tag points to a tag object, follow to the commit."""
|
"""Commit date is fetched directly from commit SHA in tags API."""
|
||||||
mock_api.get("https://api.github.com/repos/owner/repo/tags").mock(
|
mock_api.get("https://api.github.com/repos/owner/repo/tags").mock(
|
||||||
return_value=httpx.Response(200, json=[
|
return_value=httpx.Response(200, json=[
|
||||||
{"name": "v2.0.0", "zipball_url": "https://api.github.com/..."},
|
{"name": "v2.0.0", "commit": {"sha": "abc123"}},
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
mock_api.get("https://api.github.com/repos/owner/repo/git/ref/tags/v2.0.0").mock(
|
mock_api.get("https://api.github.com/repos/owner/repo/commits/abc123").mock(
|
||||||
return_value=httpx.Response(200, json={
|
return_value=httpx.Response(200, json={
|
||||||
"object": {
|
"commit": {"author": {"date": "2025-02-01T12:00:00Z"}}
|
||||||
"type": "tag",
|
|
||||||
"sha": "abc123",
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
mock_api.get("https://api.github.com/repos/owner/repo/git/commits/abc123").mock(
|
|
||||||
return_value=httpx.Response(200, json={
|
|
||||||
"author": {"date": "2025-02-01T12:00:00Z"}
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,13 +107,13 @@ class TestFetchTags:
|
|||||||
assert tags[0]["published_at"] == "2025-02-01T12:00:00Z"
|
assert tags[0]["published_at"] == "2025-02-01T12:00:00Z"
|
||||||
|
|
||||||
def test_fetch_tags_fallback_date(self, mock_api):
|
def test_fetch_tags_fallback_date(self, mock_api):
|
||||||
"""When git ref fails, use current time as fallback."""
|
"""When commit API fails, use current time as fallback."""
|
||||||
mock_api.get("https://api.github.com/repos/owner/repo/tags").mock(
|
mock_api.get("https://api.github.com/repos/owner/repo/tags").mock(
|
||||||
return_value=httpx.Response(200, json=[
|
return_value=httpx.Response(200, json=[
|
||||||
{"name": "v1.0.0", "zipball_url": "https://api.github.com/..."},
|
{"name": "v1.0.0", "commit": {"sha": "xyz789"}},
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
mock_api.get("https://api.github.com/repos/owner/repo/git/ref/tags/v1.0.0").mock(
|
mock_api.get("https://api.github.com/repos/owner/repo/commits/xyz789").mock(
|
||||||
return_value=httpx.Response(404)
|
return_value=httpx.Response(404)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user