Initial commit: GitHub Release Monitor CLI tool

CLI tool that tracks GitHub releases or tags from user-defined repositories
and exposes them as a subscribable RSS 2.0 feed.

Features:
- Track releases or tags per repository
- SQLite storage with automatic deduplication
- RSS 2.0 feed with markdown-stripped release notes
- Built-in HTTP server for local feed access
- Rate-limit-aware GitHub API client
- 64 passing tests
This commit is contained in:
2026-07-28 11:56:10 +02:00
commit 2a5f11bd9b
18 changed files with 2117 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
"""Tests for the GitHub API client."""
import httpx
import pytest
import respx
from src.github import GitHubClient, AuthenticationError, NotFoundError
@pytest.fixture
def mock_api():
"""Enable respx mocking for test scope."""
with respx.mock:
yield respx
class TestFetchReleases:
def test_fetch_releases(self, mock_api):
mock_api.get("https://api.github.com/repos/owner/repo/releases").mock(
return_value=httpx.Response(200, json=[
{
"tag_name": "v1.0.0",
"name": "Release v1.0.0",
"body": "# Changelog\n\n- Fixed bug",
"published_at": "2025-01-15T10:30:00Z",
"html_url": "https://github.com/owner/repo/releases/tag/v1.0.0",
},
{
"tag_name": "v0.9.0",
"name": None,
"body": "",
"published_at": "2025-01-10T08:00:00Z",
"html_url": "https://github.com/owner/repo/releases/tag/v0.9.0",
},
])
)
client = GitHubClient(token="fake_token")
releases = client.fetch_releases("owner/repo")
client.close()
assert len(releases) == 2
assert releases[0]["tag_name"] == "v1.0.0"
assert releases[0]["title"] == "Release v1.0.0"
assert releases[0]["body"] == "# Changelog\n\n- Fixed bug"
assert releases[1]["title"] == "v0.9.0"
assert releases[1]["body"] == ""
def test_fetch_releases_404(self, mock_api):
mock_api.get("https://api.github.com/repos/no/such/releases").mock(
return_value=httpx.Response(404)
)
client = GitHubClient(token="fake_token")
with pytest.raises(NotFoundError):
client.fetch_releases("no/such")
client.close()
class TestFetchTags:
def test_fetch_tags(self, mock_api):
mock_api.get("https://api.github.com/repos/owner/repo/tags").mock(
return_value=httpx.Response(200, json=[
{"name": "v1.0.0", "zipball_url": "https://api.github.com/repos/owner/repo/zipball/v1.0.0"},
{"name": "v0.9.0", "zipball_url": "https://api.github.com/repos/owner/repo/zipball/v0.9.0"},
])
)
mock_api.get("https://api.github.com/repos/owner/repo/git/ref/tags/v1.0.0").mock(
return_value=httpx.Response(200, json={
"object": {
"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(
return_value=httpx.Response(200, json={
"object": {
"type": "commit",
"author": {"date": "2025-01-10T08:00:00Z"},
}
})
)
client = GitHubClient(token="fake_token")
tags = client.fetch_tags("owner/repo")
client.close()
assert len(tags) == 2
assert tags[0]["tag_name"] == "v1.0.0"
assert tags[0]["title"] == "v1.0.0"
assert tags[0]["body"] == ""
assert tags[0]["published_at"] == "2025-01-15T10:30:00Z"
assert tags[0]["html_url"] == "https://github.com/owner/repo/tags/v1.0.0"
def test_fetch_tags_with_tag_object(self, mock_api):
"""When a tag points to a tag object, follow to the commit."""
mock_api.get("https://api.github.com/repos/owner/repo/tags").mock(
return_value=httpx.Response(200, json=[
{"name": "v2.0.0", "zipball_url": "https://api.github.com/..."},
])
)
mock_api.get("https://api.github.com/repos/owner/repo/git/ref/tags/v2.0.0").mock(
return_value=httpx.Response(200, json={
"object": {
"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"}
})
)
client = GitHubClient(token="fake_token")
tags = client.fetch_tags("owner/repo")
client.close()
assert tags[0]["published_at"] == "2025-02-01T12:00:00Z"
def test_fetch_tags_fallback_date(self, mock_api):
"""When git ref fails, use current time as fallback."""
mock_api.get("https://api.github.com/repos/owner/repo/tags").mock(
return_value=httpx.Response(200, json=[
{"name": "v1.0.0", "zipball_url": "https://api.github.com/..."},
])
)
mock_api.get("https://api.github.com/repos/owner/repo/git/ref/tags/v1.0.0").mock(
return_value=httpx.Response(404)
)
client = GitHubClient(token="fake_token")
tags = client.fetch_tags("owner/repo")
client.close()
assert tags[0]["published_at"] is not None
class TestRepoExists:
def test_repo_exists_true(self, mock_api):
mock_api.get("https://api.github.com/repos/owner/repo").mock(
return_value=httpx.Response(200, json={"full_name": "owner/repo"})
)
client = GitHubClient(token="fake_token")
assert client.repo_exists("owner/repo") is True
client.close()
def test_repo_exists_false(self, mock_api):
mock_api.get("https://api.github.com/repos/no/such").mock(
return_value=httpx.Response(404)
)
client = GitHubClient(token="fake_token")
assert client.repo_exists("no/such") is False
client.close()
class TestAuthentication:
def test_invalid_token(self, mock_api):
mock_api.get("https://api.github.com/rate_limit").mock(
return_value=httpx.Response(401)
)
client = GitHubClient(token="bad_token")
with pytest.raises(AuthenticationError, match="401 Unauthorized"):
client.validate_token()
client.close()
def test_no_token_skips_validation(self):
client = GitHubClient(token=None)
client.validate_token()
client.close()