Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
38 changes: 24 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# GitHub Visualizer
GitHub Visualizer is a Python utility for exploring and visualizing activity in GitHub repositories.

![Tests](https://github.com/masonlet/github-visualizer/actions/workflows/test.yml/badge.svg)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
[![Python 3.6+](https://img.shields.io/badge/python-3.6%2B-blue.svg)]()
GitHub Visualizer is a Python utility for exploring and visualizing activity in GitHub repositories.

## Table of Contents
- [Features](#features)
Expand All @@ -17,6 +17,8 @@ GitHub Visualizer is a Python utility for exploring and visualizing activity in

<br/>



## Features
- **Repository Overview**: List all repositories for a user with commit previews
- **Contribution Graph**: GitHub-style heatmap showing commit activity over time
Expand All @@ -25,6 +27,8 @@ GitHub Visualizer is a Python utility for exploring and visualizing activity in

<br/>



## Prerequisites
- Python 3.6 or higher
- pip (Python package manager)
Expand All @@ -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 .
```

<br/>



## Usage

### Interactive Mode
Expand Down Expand Up @@ -92,8 +91,10 @@ github-visualizer masonlet --token ghp_xxxxx --refresh --weeks 26

<br/>

## 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
Expand All @@ -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
```

<br/>



## License
MIT License — see [LICENSE](./LICENSE) for details.
MIT License — see [LICENSE](./LICENSE) for details.
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
4 changes: 4 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[pytest]
minversion = 6.0
addopts = -v
testpaths = tests
2 changes: 1 addition & 1 deletion src/github_visualizer/visualizer/graph_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Empty file added tests/__init__.py
Empty file.
60 changes: 60 additions & 0 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
@@ -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)
118 changes: 118 additions & 0 deletions tests/test_commit_api.py
Original file line number Diff line number Diff line change
@@ -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"
Loading