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:
@@ -0,0 +1,86 @@
|
||||
"""Tests for CLI commands."""
|
||||
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from src.db import _create_tables, add_repo
|
||||
|
||||
|
||||
class TestRepoFormatValidation:
|
||||
def test_valid_format(self):
|
||||
from src.cli import REPO_PATTERN
|
||||
assert REPO_PATTERN.match("owner/repo")
|
||||
assert REPO_PATTERN.match("my-org/my_repo")
|
||||
assert REPO_PATTERN.match("a.b/c-d_e")
|
||||
|
||||
def test_invalid_format(self):
|
||||
from src.cli import REPO_PATTERN
|
||||
assert not REPO_PATTERN.match("owner")
|
||||
assert not REPO_PATTERN.match("repo")
|
||||
assert not REPO_PATTERN.match("owner/repo/extra")
|
||||
|
||||
|
||||
class TestParseSince:
|
||||
def test_hours(self):
|
||||
from src.cli import parse_since
|
||||
from datetime import timedelta
|
||||
assert parse_since("24h") == timedelta(hours=24)
|
||||
|
||||
def test_days(self):
|
||||
from src.cli import parse_since
|
||||
from datetime import timedelta
|
||||
assert parse_since("2d") == timedelta(days=2)
|
||||
|
||||
def test_minutes(self):
|
||||
from src.cli import parse_since
|
||||
from datetime import timedelta
|
||||
assert parse_since("30m") == timedelta(minutes=30)
|
||||
|
||||
def test_invalid_defaults(self):
|
||||
from src.cli import parse_since
|
||||
from datetime import timedelta
|
||||
assert parse_since("invalid") == timedelta(hours=24)
|
||||
|
||||
|
||||
class TestCliHelp:
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "src.cli", "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "ghrel" in result.stdout.lower() or "track" in result.stdout.lower()
|
||||
|
||||
def test_version_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "src.cli", "--version"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "0.1.0" in result.stdout
|
||||
|
||||
|
||||
class TestCliList:
|
||||
def test_list_empty(self, tmp_path):
|
||||
db_path = str(tmp_path / "test.db")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "src.cli", "--db-path", db_path, "list"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "No repositories" in result.stdout
|
||||
|
||||
|
||||
class TestCliAddRemove:
|
||||
def test_add_invalid_format(self, tmp_path):
|
||||
db_path = str(tmp_path / "test.db")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "src.cli", "--db-path", db_path, "add", "invalid"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Invalid repo format" in result.stderr
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Tests for the database layer."""
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from src import db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn():
|
||||
"""Provide an in-memory database connection for testing."""
|
||||
c = sqlite3.connect(":memory:")
|
||||
c.execute("PRAGMA foreign_keys=ON")
|
||||
db._create_tables(c)
|
||||
return c
|
||||
|
||||
|
||||
class TestCreateTables:
|
||||
def test_tables_exist(self, conn):
|
||||
tables = set(r[0] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall())
|
||||
assert "repos" in tables
|
||||
assert "entries" in tables
|
||||
|
||||
def test_mode_check_constraint(self, conn):
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO repos (owner_repo, mode) VALUES ('a/b', 'invalid')")
|
||||
conn.commit()
|
||||
|
||||
def test_kind_check_constraint(self, conn):
|
||||
conn.execute("INSERT INTO repos (owner_repo) VALUES ('a/b')")
|
||||
conn.commit()
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO entries (repo_id, kind, tag_name, title, published_at, html_url) "
|
||||
"VALUES (1, 'invalid', 'v1', 'v1', '2025-01-01', 'http://x')"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
class TestAddRepo:
|
||||
def test_add_repo_default_mode(self, conn):
|
||||
repo_id = db.add_repo(conn, "owner/repo")
|
||||
assert repo_id == 1
|
||||
row = conn.execute("SELECT owner_repo, mode FROM repos WHERE id = ?", (repo_id,)).fetchone()
|
||||
assert row == ("owner/repo", "release")
|
||||
|
||||
def test_add_repo_tag_mode(self, conn):
|
||||
repo_id = db.add_repo(conn, "owner/repo", "tag")
|
||||
row = conn.execute("SELECT mode FROM repos WHERE id = ?", (repo_id,)).fetchone()
|
||||
assert row[0] == "tag"
|
||||
|
||||
def test_add_repo_duplicate_fails(self, conn):
|
||||
db.add_repo(conn, "owner/repo")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.add_repo(conn, "owner/repo")
|
||||
|
||||
|
||||
class TestRemoveRepo:
|
||||
def test_remove_existing(self, conn):
|
||||
db.add_repo(conn, "owner/repo")
|
||||
assert db.remove_repo(conn, "owner/repo") is True
|
||||
assert db.list_repos(conn) == []
|
||||
|
||||
def test_remove_nonexistent(self, conn):
|
||||
assert db.remove_repo(conn, "no/one") is False
|
||||
|
||||
def test_remove_cascades_to_entries(self, conn):
|
||||
repo_id = db.add_repo(conn, "owner/repo")
|
||||
db.upsert_entry(conn, repo_id, "release", "v1", "v1", "", "2025-01-01", "http://x")
|
||||
db.remove_repo(conn, "owner/repo")
|
||||
assert db.get_entry_count(conn, repo_id) == 0
|
||||
|
||||
|
||||
class TestUpdateRepoMode:
|
||||
def test_update_mode(self, conn):
|
||||
db.add_repo(conn, "owner/repo")
|
||||
assert db.update_repo_mode(conn, "owner/repo", "tag") is True
|
||||
row = conn.execute("SELECT mode FROM repos WHERE owner_repo = 'owner/repo'").fetchone()
|
||||
assert row[0] == "tag"
|
||||
|
||||
def test_update_nonexistent(self, conn):
|
||||
assert db.update_repo_mode(conn, "no/one", "tag") is False
|
||||
|
||||
|
||||
class TestListRepos:
|
||||
def test_empty_list(self, conn):
|
||||
assert db.list_repos(conn) == []
|
||||
|
||||
def test_list_with_entries(self, conn):
|
||||
repo_id = db.add_repo(conn, "a/b")
|
||||
db.upsert_entry(conn, repo_id, "release", "v1", "v1", "", "2025-01-01", "http://x")
|
||||
result = db.list_repos(conn)
|
||||
assert len(result) == 1
|
||||
assert result[0]["owner_repo"] == "a/b"
|
||||
assert result[0]["entries"] == 1
|
||||
assert result[0]["mode"] == "release"
|
||||
|
||||
|
||||
class TestUpsertEntry:
|
||||
def test_insert_new(self, conn):
|
||||
repo_id = db.add_repo(conn, "owner/repo")
|
||||
assert db.upsert_entry(conn, repo_id, "release", "v1", "Release v1", "body text",
|
||||
"2025-01-01T00:00:00Z", "http://example.com") is True
|
||||
|
||||
def test_skip_duplicate(self, conn):
|
||||
repo_id = db.add_repo(conn, "owner/repo")
|
||||
assert db.upsert_entry(conn, repo_id, "release", "v1", "v1", "", "2025-01-01", "http://x") is True
|
||||
assert db.upsert_entry(conn, repo_id, "release", "v1", "v1", "", "2025-01-01", "http://x") is False
|
||||
|
||||
|
||||
class TestGetAllEntries:
|
||||
def test_returns_entries_sorted_desc(self, conn):
|
||||
repo_id = db.add_repo(conn, "owner/repo")
|
||||
db.upsert_entry(conn, repo_id, "release", "v1", "v1", "", "2025-01-01", "http://x")
|
||||
db.upsert_entry(conn, repo_id, "release", "v2", "v2", "", "2025-01-02", "http://y")
|
||||
entries = db.get_all_entries(conn)
|
||||
assert entries[0]["tag_name"] == "v2"
|
||||
assert entries[1]["tag_name"] == "v1"
|
||||
|
||||
def test_respects_limit(self, conn):
|
||||
repo_id = db.add_repo(conn, "owner/repo")
|
||||
for i in range(5):
|
||||
db.upsert_entry(conn, repo_id, "release", f"v{i}", f"v{i}", "", f"2025-01-0{i}", "http://x")
|
||||
entries = db.get_all_entries(conn, limit=3)
|
||||
assert len(entries) == 3
|
||||
|
||||
|
||||
class TestGetConnection:
|
||||
def test_creates_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "test.db")
|
||||
c = db.get_connection(path)
|
||||
c.close()
|
||||
assert os.path.exists(path)
|
||||
# Check permissions
|
||||
mode = os.stat(path).st_mode
|
||||
assert stat.S_IMODE(mode) == 0o600
|
||||
@@ -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()
|
||||
@@ -0,0 +1,205 @@
|
||||
"""End-to-end integration tests."""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from src.db import _create_tables, add_repo, upsert_entry, get_connection
|
||||
from src.server import create_handler
|
||||
from http.server import HTTPServer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_github():
|
||||
"""Mock only GitHub API calls."""
|
||||
with respx.mock:
|
||||
yield respx
|
||||
|
||||
|
||||
def fetch_feed(port: int) -> str:
|
||||
"""Fetch feed from localhost using urllib (bypasses respx)."""
|
||||
url = f"http://127.0.0.1:{port}/feed.xml"
|
||||
with urllib.request.urlopen(url) as resp:
|
||||
return resp.read().decode("utf-8")
|
||||
|
||||
|
||||
class TestFullFlowWithMockedAPI:
|
||||
"""Test the full flow: add repo, check (with mocked API), serve, fetch feed."""
|
||||
|
||||
def test_release_mode_full_flow(self, mock_github):
|
||||
mock_github.get("https://api.github.com/repos/signoz/signoz/releases").mock(
|
||||
return_value=httpx.Response(200, json=[
|
||||
{
|
||||
"tag_name": "v1.0.0",
|
||||
"name": "Signoz v1.0.0",
|
||||
"body": "# Release v1.0.0\n\n- New dashboard features",
|
||||
"published_at": "2025-01-15T10:30:00Z",
|
||||
"html_url": "https://github.com/signoz/signoz/releases/tag/v1.0.0",
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "test.db"
|
||||
conn = get_connection(db_path)
|
||||
add_repo(conn, "signoz/signoz", "release")
|
||||
conn.close()
|
||||
|
||||
from src.github import GitHubClient
|
||||
from src.db import get_repos_with_mode, upsert_entry as db_upsert
|
||||
|
||||
conn = get_connection(db_path)
|
||||
repos = get_repos_with_mode(conn)
|
||||
assert len(repos) == 1
|
||||
|
||||
with GitHubClient(token="fake_token") as gh:
|
||||
for repo_id, owner_repo, mode in repos:
|
||||
items = gh.fetch_releases(owner_repo)
|
||||
for item in items:
|
||||
db_upsert(conn, repo_id, mode,
|
||||
item["tag_name"], item["title"], item["body"],
|
||||
item["published_at"], item["html_url"])
|
||||
|
||||
conn.close()
|
||||
|
||||
handler = create_handler(str(db_path))
|
||||
server = HTTPServer(("127.0.0.1", 0), handler)
|
||||
port = server.server_address[1]
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
try:
|
||||
text = fetch_feed(port)
|
||||
assert "signoz/signoz" in text
|
||||
assert "v1.0.0" in text
|
||||
assert "Signoz v1.0.0" in text
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
def test_tag_mode_full_flow(self, mock_github):
|
||||
mock_github.get("https://api.github.com/repos/zammad/zammad/tags").mock(
|
||||
return_value=httpx.Response(200, json=[
|
||||
{"name": "6.3.0", "zipball_url": "https://api.github.com/..."},
|
||||
])
|
||||
)
|
||||
mock_github.get("https://api.github.com/repos/zammad/zammad/git/ref/tags/6.3.0").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"object": {
|
||||
"type": "commit",
|
||||
"author": {"date": "2025-02-01T12:00:00Z"},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "test.db"
|
||||
conn = get_connection(db_path)
|
||||
add_repo(conn, "zammad/zammad", "tag")
|
||||
conn.close()
|
||||
|
||||
from src.github import GitHubClient
|
||||
from src.db import get_repos_with_mode, upsert_entry as db_upsert
|
||||
|
||||
conn = get_connection(db_path)
|
||||
repos = get_repos_with_mode(conn)
|
||||
|
||||
with GitHubClient(token="fake_token") as gh:
|
||||
for repo_id, owner_repo, mode in repos:
|
||||
items = gh.fetch_tags(owner_repo)
|
||||
for item in items:
|
||||
db_upsert(conn, repo_id, "tag",
|
||||
item["tag_name"], item["title"], item["body"],
|
||||
item["published_at"], item["html_url"])
|
||||
|
||||
conn.close()
|
||||
|
||||
handler = create_handler(str(db_path))
|
||||
server = HTTPServer(("127.0.0.1", 0), handler)
|
||||
port = server.server_address[1]
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
try:
|
||||
text = fetch_feed(port)
|
||||
assert "zammad/zammad" in text
|
||||
assert "6.3.0" in text
|
||||
assert "Tag 6.3.0" in text
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
|
||||
class TestMultipleRepos:
|
||||
def test_mixed_repos_in_feed(self, mock_github):
|
||||
"""Feed should contain entries from both release and tag repos, sorted by date."""
|
||||
mock_github.get("https://api.github.com/repos/a/b/releases").mock(
|
||||
return_value=httpx.Response(200, json=[
|
||||
{
|
||||
"tag_name": "v1.0.0",
|
||||
"name": "v1.0.0",
|
||||
"body": "Release body",
|
||||
"published_at": "2025-01-10T00:00:00Z",
|
||||
"html_url": "https://github.com/a/b/releases/tag/v1.0.0",
|
||||
}
|
||||
])
|
||||
)
|
||||
mock_github.get("https://api.github.com/repos/c/d/tags").mock(
|
||||
return_value=httpx.Response(200, json=[
|
||||
{"name": "v2.0.0", "zipball_url": "https://api.github.com/..."},
|
||||
])
|
||||
)
|
||||
mock_github.get("https://api.github.com/repos/c/d/git/ref/tags/v2.0.0").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"object": {
|
||||
"type": "commit",
|
||||
"author": {"date": "2025-01-20T00:00:00Z"},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "test.db"
|
||||
conn = get_connection(db_path)
|
||||
add_repo(conn, "a/b", "release")
|
||||
add_repo(conn, "c/d", "tag")
|
||||
conn.close()
|
||||
|
||||
from src.github import GitHubClient
|
||||
from src.db import get_repos_with_mode, upsert_entry as db_upsert
|
||||
|
||||
conn = get_connection(db_path)
|
||||
repos = get_repos_with_mode(conn)
|
||||
|
||||
with GitHubClient(token="fake_token") as gh:
|
||||
for repo_id, owner_repo, mode in repos:
|
||||
if mode == "tag":
|
||||
items = gh.fetch_tags(owner_repo)
|
||||
kind = "tag"
|
||||
else:
|
||||
items = gh.fetch_releases(owner_repo)
|
||||
kind = "release"
|
||||
for item in items:
|
||||
db_upsert(conn, repo_id, kind,
|
||||
item["tag_name"], item["title"], item["body"],
|
||||
item["published_at"], item["html_url"])
|
||||
|
||||
conn.close()
|
||||
|
||||
handler = create_handler(str(db_path))
|
||||
server = HTTPServer(("127.0.0.1", 0), handler)
|
||||
port = server.server_address[1]
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
try:
|
||||
text = fetch_feed(port)
|
||||
c_d_pos = text.find("c/d")
|
||||
a_b_pos = text.find("a/b")
|
||||
assert c_d_pos < a_b_pos
|
||||
finally:
|
||||
server.shutdown()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Tests for RSS feed generation."""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from src.rss import generate_feed, strip_markdown, format_rfc822
|
||||
|
||||
|
||||
class TestStripMarkdown:
|
||||
def test_plain_text(self):
|
||||
assert strip_markdown("Hello world") == "Hello world"
|
||||
|
||||
def test_bold_and_lists(self):
|
||||
result = strip_markdown("**bold** and - item1\n- item2")
|
||||
assert "bold" in result
|
||||
assert "item1" in result
|
||||
|
||||
def test_empty_string(self):
|
||||
assert strip_markdown("") == ""
|
||||
|
||||
def test_none(self):
|
||||
assert strip_markdown("") == ""
|
||||
|
||||
def test_code_blocks(self):
|
||||
result = strip_markdown("```python\nprint('hello')\n```")
|
||||
assert "print" in result or "hello" in result
|
||||
|
||||
|
||||
class TestFormatRfc822:
|
||||
def test_iso_to_rfc822(self):
|
||||
result = format_rfc822("2025-01-15T10:30:00Z")
|
||||
assert "Wed" in result or "15" in result
|
||||
assert "2025" in result
|
||||
|
||||
def test_with_tz_offset(self):
|
||||
result = format_rfc822("2025-01-15T10:30:00+00:00")
|
||||
assert "2025" in result
|
||||
|
||||
|
||||
class TestGenerateFeed:
|
||||
def _entries(self, count=1):
|
||||
return [
|
||||
{
|
||||
"tag_name": f"v1.{i}.0",
|
||||
"title": f"Release v1.{i}.0",
|
||||
"body": f"# Release notes\n\n- Feature {i}",
|
||||
"published_at": "2025-01-15T10:30:00Z",
|
||||
"html_url": f"https://github.com/owner/repo/releases/tag/v1.{i}.0",
|
||||
"owner_repo": "owner/repo",
|
||||
}
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
def test_basic_feed(self):
|
||||
entries = self._entries(2)
|
||||
xml_str = generate_feed(entries)
|
||||
root = ET.fromstring(xml_str)
|
||||
assert root.tag == "rss"
|
||||
assert root.get("version") == "2.0"
|
||||
|
||||
channel = root[0]
|
||||
assert channel.tag == "channel"
|
||||
assert channel.find("title").text == "GitHub Release Monitor"
|
||||
assert channel.find("description").text == "Latest releases from tracked GitHub repositories"
|
||||
|
||||
items = channel.findall("item")
|
||||
assert len(items) == 2
|
||||
|
||||
def test_item_structure(self):
|
||||
entries = self._entries(1)
|
||||
xml_str = generate_feed(entries)
|
||||
root = ET.fromstring(xml_str)
|
||||
channel = root[0]
|
||||
item = channel.find("item")
|
||||
|
||||
assert item.find("title").text == "[owner/repo] Release v1.0.0"
|
||||
assert "Feature 0" in item.find("description").text
|
||||
assert item.find("link").text == "https://github.com/owner/repo/releases/tag/v1.0.0"
|
||||
assert item.find("pubDate").text is not None
|
||||
assert item.find("guid").text == "github.com/owner/repo/releases/tag/v1.0.0"
|
||||
|
||||
def test_description_truncation(self):
|
||||
entry = {
|
||||
"tag_name": "v1.0.0",
|
||||
"title": "Release",
|
||||
"body": "A" * 400,
|
||||
"published_at": "2025-01-15T10:30:00Z",
|
||||
"html_url": "https://example.com",
|
||||
"owner_repo": "owner/repo",
|
||||
}
|
||||
xml_str = generate_feed([entry])
|
||||
root = ET.fromstring(xml_str)
|
||||
desc = root[0].find("item").find("description").text
|
||||
assert len(desc) == 301 # 300 chars + 1 ellipsis char
|
||||
|
||||
def test_tag_entry_no_body(self):
|
||||
entry = {
|
||||
"tag_name": "v1.0.0",
|
||||
"title": "v1.0.0",
|
||||
"body": "",
|
||||
"published_at": "2025-01-15T10:30:00Z",
|
||||
"html_url": "https://github.com/owner/repo/tags/v1.0.0",
|
||||
"owner_repo": "owner/repo",
|
||||
}
|
||||
xml_str = generate_feed([entry])
|
||||
root = ET.fromstring(xml_str)
|
||||
desc = root[0].find("item").find("description").text
|
||||
assert desc == "Tag v1.0.0"
|
||||
|
||||
def test_item_cap(self):
|
||||
entries = self._entries(100)
|
||||
xml_str = generate_feed(entries)
|
||||
root = ET.fromstring(xml_str)
|
||||
items = root[0].findall("item")
|
||||
assert len(items) == 50 # RSS_MAX_ITEMS
|
||||
|
||||
def test_xml_escaping(self):
|
||||
entry = {
|
||||
"tag_name": "v1.0.0",
|
||||
"title": "Release with <special> & 'chars'",
|
||||
"body": "Some **bold** text",
|
||||
"published_at": "2025-01-15T10:30:00Z",
|
||||
"html_url": "https://example.com?a=1&b=2",
|
||||
"owner_repo": "owner/repo",
|
||||
}
|
||||
xml_str = generate_feed([entry])
|
||||
# Should parse without error
|
||||
root = ET.fromstring(xml_str)
|
||||
assert root is not None
|
||||
|
||||
def test_base_url(self):
|
||||
entries = self._entries(1)
|
||||
xml_str = generate_feed(entries, base_url="http://example.com:9000")
|
||||
root = ET.fromstring(xml_str)
|
||||
assert root[0].find("link").text == "http://example.com:9000/"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for the HTTP server."""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
import tempfile
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.db import _create_tables, add_repo, upsert_entry
|
||||
from src.server import create_handler
|
||||
from http.server import HTTPServer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path):
|
||||
"""Create a temporary database with sample data."""
|
||||
path = str(tmp_path / "test.db")
|
||||
conn = __import__("sqlite3").connect(path)
|
||||
_create_tables(conn)
|
||||
repo_id = add_repo(conn, "owner/repo")
|
||||
upsert_entry(conn, repo_id, "release", "v1.0.0", "Release v1.0.0",
|
||||
"# Changelog\n\n- New feature",
|
||||
"2025-01-15T10:30:00Z",
|
||||
"https://github.com/owner/repo/releases/tag/v1.0.0")
|
||||
conn.close()
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(db_path):
|
||||
"""Start a test HTTP server in a background thread."""
|
||||
handler = create_handler(db_path)
|
||||
server = HTTPServer(("127.0.0.1", 0), handler)
|
||||
port = server.server_address[1]
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
server.shutdown()
|
||||
|
||||
|
||||
class TestFeedEndpoint:
|
||||
def test_feed_returns_200(self, server):
|
||||
resp = httpx.get(f"{server}/feed.xml")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_feed_content_type(self, server):
|
||||
resp = httpx.get(f"{server}/feed.xml")
|
||||
assert "application/rss+xml" in resp.headers["content-type"]
|
||||
|
||||
def test_feed_contains_entry(self, server):
|
||||
resp = httpx.get(f"{server}/feed.xml")
|
||||
assert "owner/repo" in resp.text
|
||||
assert "v1.0.0" in resp.text
|
||||
|
||||
def test_feed_is_valid_xml(self, server):
|
||||
import xml.etree.ElementTree as ET
|
||||
resp = httpx.get(f"{server}/feed.xml")
|
||||
root = ET.fromstring(resp.text)
|
||||
assert root.tag == "rss"
|
||||
|
||||
|
||||
class TestIndexEndpoint:
|
||||
def test_index_returns_200(self, server):
|
||||
resp = httpx.get(server + "/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_index_content_type(self, server):
|
||||
resp = httpx.get(server + "/")
|
||||
assert "text/html" in resp.headers["content-type"]
|
||||
|
||||
def test_index_links_to_feed(self, server):
|
||||
resp = httpx.get(server + "/")
|
||||
assert "/feed.xml" in resp.text
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
def test_health_returns_200(self, server):
|
||||
resp = httpx.get(f"{server}/health")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_health_body(self, server):
|
||||
resp = httpx.get(f"{server}/health")
|
||||
assert resp.text == "OK"
|
||||
|
||||
|
||||
class TestNotFound:
|
||||
def test_unknown_path(self, server):
|
||||
resp = httpx.get(f"{server}/unknown")
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user