diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..56584e4
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,21 @@
+name: Tests
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.x'
+ - name: Install dependencies
+ run: pip install -e .[test]
+ - name: Run pytest
+ run: pytest
diff --git a/README.md b/README.md
index 54fe986..b7c7d32 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# GitHub Visualizer
-GitHub Visualizer is a Python utility for exploring and visualizing activity in GitHub repositories.
-
+
[](./LICENSE)
[]()
+GitHub Visualizer is a Python utility for exploring and visualizing activity in GitHub repositories.
## Table of Contents
- [Features](#features)
@@ -17,6 +17,8 @@ GitHub Visualizer is a Python utility for exploring and visualizing activity in
+
+
## Features
- **Repository Overview**: List all repositories for a user with commit previews
- **Contribution Graph**: GitHub-style heatmap showing commit activity over time
@@ -25,6 +27,8 @@ GitHub Visualizer is a Python utility for exploring and visualizing activity in
+
+
## Prerequisites
- Python 3.6 or higher
- pip (Python package manager)
@@ -36,15 +40,10 @@ GitHub Visualizer is a Python utility for exploring and visualizing activity in
pip install git+https://github.com/masonlet/github-visualizer.git
```
-### From Source
-```bash
-git clone https://github.com/masonlet/github-visualizer.git
-cd github-visualizer
-pip install -e .
-```
-
+
+
## Usage
### Interactive Mode
@@ -92,8 +91,10 @@ github-visualizer masonlet --token ghp_xxxxx --refresh --weeks 26
-## Building the Project
-### 1. Clone the Repository
+
+
+## Running Tests
+### 1. Clone github-visualizer
```bash
git clone https://github.com/masonlet/github-visualizer.git
cd github-visualizer
@@ -104,12 +105,21 @@ cd github-visualizer
pip install -e .
```
-### 3. Run the Tool
+### 3. Run Tests
```bash
-github-visualizer
+# Run all tests
+pytest
+
+# Run specific test file
+pytest tests/test_commit_api.py
+
+# Run tests with flags
+pytest -V
```
+
+
## License
-MIT License — see [LICENSE](./LICENSE) for details.
\ No newline at end of file
+MIT License — see [LICENSE](./LICENSE) for details.
diff --git a/pyproject.toml b/pyproject.toml
index 58b76bb..1b95f08 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,6 +16,11 @@ dependencies = [
"requests>=2.25.0",
]
+[project.optional-dependencies]
+test = [
+ "pytest>=7.0",
+]
+
[project.urls]
Homepage = "https://github.com/masonlet/github-visualizer"
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..3a87564
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+minversion = 6.0
+addopts = -v
+testpaths = tests
\ No newline at end of file
diff --git a/src/github_visualizer/visualizer/graph_data.py b/src/github_visualizer/visualizer/graph_data.py
index c490cfe..33e7980 100644
--- a/src/github_visualizer/visualizer/graph_data.py
+++ b/src/github_visualizer/visualizer/graph_data.py
@@ -57,7 +57,7 @@ def create_week_grid(weeks: int = 52) -> list[list[tuple[str, int]]]:
today = datetime.now().date()
days_since_sunday = (today.weekday() + 1) % 7
end_date = today - timedelta(days=days_since_sunday)
- start_date = end_date - timedelta(weeks=weeks - 1)
+ start_date = end_date - timedelta(weeks=weeks) + timedelta(days=1)
grid = [[] for _ in range(7)]
current_date = start_date
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 0000000..156e290
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,60 @@
+"""Tests for cache module."""
+
+from datetime import timedelta, datetime
+from pathlib import Path
+import pytest
+from unittest.mock import patch
+
+from github_visualizer.fetch.cache.formatting import format_time
+from github_visualizer.fetch.cache.paths import get_user_cache_path, get_commit_cache_path
+from github_visualizer.fetch.cache.validation import is_cache_valid
+from github_visualizer.config import CACHE_DIR
+
+class TestFormatTime:
+ def test_just_now(self):
+ assert format_time(timedelta(seconds=30)) == "just now"
+
+
+ def test_minutes(self):
+ assert format_time(timedelta(minutes=1)) == "1 minute ago"
+ assert format_time(timedelta(minutes=5)) == "5 minutes ago"
+
+
+ def test_hours(self):
+ assert format_time(timedelta(hours=1)) == "1 hour ago"
+ assert format_time(timedelta(hours=3)) == "3 hours ago"
+
+
+class TestCachePaths:
+ def test_get_user_cache_path_creates_dir(self, tmp_path):
+ username = "testuser"
+ with patch("github_visualizer.fetch.cache.paths.CACHE_DIR", tmp_path):
+ path = get_user_cache_path(username)
+ assert path == tmp_path / f"{username}.json"
+
+
+ def test_get_commit_cache_path_creates_dir(self, tmp_path):
+ username = "testuser"
+ repo = "testrepo"
+ with patch("github_visualizer.fetch.cache.paths.CACHE_DIR", tmp_path):
+ path = get_commit_cache_path(username, repo)
+ assert path == tmp_path / username / f"{repo}_commits.json"
+
+
+class TestIsCacheValid:
+ def test_returns_false_for_nonexistent_file(self, tmp_path):
+ path = tmp_path / "nonexistent.json"
+ assert not is_cache_valid(path)
+
+
+ def test_returns_true_for_valid_cache(self, tmp_path):
+ path = tmp_path / "cache.json"
+ path.touch()
+ assert is_cache_valid(path)
+
+
+ def test_returns_false_on_stat_error(self):
+ path = Path("dummy.json")
+ with patch("github_visualizer.fetch.cache.validation.Path.exists", return_value=True), \
+ patch("github_visualizer.fetch.cache.validation.Path.stat", side_effect=OSError):
+ assert not is_cache_valid(path)
\ No newline at end of file
diff --git a/tests/test_commit_api.py b/tests/test_commit_api.py
new file mode 100644
index 0000000..08f424b
--- /dev/null
+++ b/tests/test_commit_api.py
@@ -0,0 +1,118 @@
+"""Tests for commit API module."""
+
+import json
+import requests
+import pytest
+from unittest.mock import patch, Mock
+from github_visualizer.fetch.commit_api import get_repo_commits, get_commit_cache_path
+
+def make_fake_commit(message: str, date: str):
+ return {
+ "commit": {
+ "message": message,
+ "author": {"date": date}
+ }
+ }
+
+class TestGetRepoCommits:
+ def test_returns_cached_data_when_valid(self, tmp_path):
+ username = "user"
+ repo = "repo"
+ cache_path = tmp_path / f"{repo}_commits.json"
+ cached_data = [{"repo": repo, "message": "cached commit", "timestamp": "2025-01-01T12:00:00Z"}]
+ cache_path.write_text(json.dumps(cached_data))
+
+ with patch("github_visualizer.fetch.commit_api.get_commit_cache_path", return_value=cache_path), \
+ patch("github_visualizer.fetch.commit_api.is_cache_valid", return_value=True):
+ commits = get_repo_commits(username, repo)
+
+ assert commits == cached_data
+
+ def test_fetches_from_api_when_cache_invalid(self, tmp_path):
+ username = "user"
+ repo = "repo"
+ fake_api_response = [make_fake_commit("new commit", "2025-11-15T12:00:00Z")]
+
+ cache_path = tmp_path / f"{repo}_commits.json"
+
+ with patch("github_visualizer.fetch.commit_api.get_commit_cache_path", return_value=cache_path), \
+ patch("github_visualizer.fetch.commit_api.is_cache_valid", return_value=False), \
+ patch("github_visualizer.fetch.commit_api.requests.get") as mock_get:
+ mock_get.side_effect = [
+ Mock(json=Mock(return_value=fake_api_response), raise_for_status=Mock()),
+ Mock(json=Mock(return_value=[]), raise_for_status=Mock())
+ ]
+
+ commits = get_repo_commits(username, repo)
+
+ assert commits[0]["message"] == "new commit"
+ cached_text = cache_path.read_text()
+ assert "new commit" in cached_text
+
+ def test_handles_pagination(self, tmp_path):
+ username = "user"
+ repo = "repo"
+ page1 = [make_fake_commit("commit1", "2025-11-15T12:00:00Z")]
+ page2 = [make_fake_commit("commit2", "2025-11-15T13:00:00Z")]
+
+ cache_path = tmp_path / f"{repo}_commits.json"
+
+ with patch("github_visualizer.fetch.commit_api.get_commit_cache_path", return_value=cache_path), \
+ patch("github_visualizer.fetch.commit_api.is_cache_valid", return_value=False), \
+ patch("github_visualizer.fetch.commit_api.requests.get") as mock_get:
+ mock_get.side_effect = [
+ Mock(json=Mock(return_value=page1), raise_for_status=Mock()),
+ Mock(json=Mock(return_value=page2), raise_for_status=Mock()),
+ Mock(json=Mock(return_value=[]), raise_for_status=Mock())
+ ]
+
+ commits = get_repo_commits(username, repo)
+
+ assert len(commits) == 2
+ assert commits[0]["message"] == "commit1"
+ assert commits[1]["message"] == "commit2"
+
+ def test_preserves_partial_data_on_error(self, tmp_path):
+ username = "user"
+ repo = "repo"
+ page1 = [make_fake_commit("commit1", "2025-11-15T12:00:00Z")]
+
+ cache_path = tmp_path / f"{repo}_commits.json"
+
+ with patch("github_visualizer.fetch.commit_api.get_commit_cache_path", return_value=cache_path), \
+ patch("github_visualizer.fetch.commit_api.is_cache_valid", return_value=False), \
+ patch("github_visualizer.fetch.commit_api.requests.get") as mock_get, \
+ patch("github_visualizer.fetch.commit_api.handle_api_error") as mock_error:
+ mock_get.side_effect = [
+ Mock(json=Mock(return_value=page1), raise_for_status=Mock()),
+ requests.RequestException("Network error")
+ ]
+
+ commits = get_repo_commits(username, repo)
+
+ assert len(commits) == 1
+ assert commits[0]["message"] == "commit1"
+
+ def test_transforms_commit_format_correctly(self, tmp_path):
+ username = "user"
+ repo = "repo"
+ api_response = [
+ make_fake_commit("my message", "2025-11-15T12:34:56Z")
+ ]
+
+ cache_path = tmp_path / f"{repo}_commits.json"
+
+ with patch("github_visualizer.fetch.commit_api.get_commit_cache_path", return_value=cache_path), \
+ patch("github_visualizer.fetch.commit_api.is_cache_valid", return_value=False), \
+ patch("github_visualizer.fetch.commit_api.requests.get") as mock_get:
+ mock_get.side_effect = [
+ Mock(json=Mock(return_value=api_response), raise_for_status=Mock()),
+ Mock(json=Mock(return_value=[]), raise_for_status=Mock())
+ ]
+
+ commits = get_repo_commits(username, repo)
+
+ c = commits[0]
+ assert c["repo"] == repo
+ assert c["message"] == "my message"
+ assert c["timestamp"] == "2025-11-15T12:34:56Z"
\ No newline at end of file
diff --git a/tests/test_graph_data.py b/tests/test_graph_data.py
new file mode 100644
index 0000000..e7583ff
--- /dev/null
+++ b/tests/test_graph_data.py
@@ -0,0 +1,89 @@
+"""Tests for commit API module."""
+
+import pytest
+from datetime import datetime, timedelta
+from github_visualizer.visualizer.graph_data import (
+ get_commit_dates,
+ get_intensity_char,
+ create_week_grid,
+ populate_grid
+)
+
+
+def make_commit(timestamp: str):
+ return {"repo": "repo", "message": "msg", "timestamp": timestamp}
+
+
+class TestGetCommitDates:
+ def test_counts_commits_by_date(self):
+ commits = [
+ make_commit("2025-11-01T12:00:00Z"),
+ make_commit("2025-11-01T13:00:00Z"),
+ make_commit("2025-11-02T09:00:00Z"),
+ ]
+ date_counts = get_commit_dates(commits)
+ assert date_counts["2025-11-01"] == 2
+ assert date_counts["2025-11-02"] == 1
+
+
+ def test_handles_invalid_timestamps(self):
+ commits = [
+ make_commit("invalid"),
+ make_commit("2025-11-01T12:00:00Z")
+ ]
+ date_counts = get_commit_dates(commits)
+ assert "2025-11-01" in date_counts
+ assert len(date_counts) == 1
+
+
+ def test_ignores_missing_keys(self):
+ commits = [{"repo": "repo"}]
+ date_counts = get_commit_dates(commits)
+ assert date_counts == {}
+
+
+ def test_groups_same_day_commits(self):
+ commits = [
+ make_commit("2025-11-01T01:00:00Z"),
+ make_commit("2025-11-01T23:59:59Z")
+ ]
+ date_counts = get_commit_dates(commits)
+ assert date_counts["2025-11-01"] == 2
+
+
+class TestGetIntensityChar:
+ def test_maps_counts_to_intensity_levels(self):
+ from github_visualizer.config import INTENSITY_CHARS, INTENSITY_LEVELS
+ for i, level in enumerate(INTENSITY_LEVELS):
+ if i > 0:
+ assert get_intensity_char(level - 1) == INTENSITY_CHARS[i - 1]
+ assert get_intensity_char(level) == INTENSITY_CHARS[i]
+
+
+class TestCreateWeekGrid:
+ def test_creates_correct_grid_structure(self):
+ grid = create_week_grid(weeks=2)
+ assert len(grid) == 7
+ for row in grid:
+ assert len(row) >= 2
+ for date, count in row:
+ assert isinstance(date, str)
+ assert count == 0
+
+
+ def test_aligns_to_sunday(self):
+ grid = create_week_grid(weeks=1)
+ first_day_of_first_row = grid[0][0][0]
+ weekday = datetime.fromisoformat(first_day_of_first_row).weekday()
+ assert (weekday + 1) % 7 == 0
+
+class TestPopulateGrid:
+ def test_fills_grid_with_commit_counts(self):
+ grid = create_week_grid(weeks=1)
+ dates = [row[0][0] for row in grid[:2]]
+ commit_counts = {dates[0]: 3, dates[1]: 1}
+ populated = populate_grid(grid, commit_counts)
+ assert populated[0][0][1] == 3
+ assert populated[1][0][1] == 1
+ for row in populated[2:]:
+ assert row[0][1] == 0
\ No newline at end of file