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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ All notable changes to GitPulse are documented here. The format is based on

## [Unreleased]

### Changed

- The HTML dashboard and terminal summary use the Full-Stack-Spectrum brand:
near-black surfaces with a dot grid, Space Grotesk and JetBrains Mono, and
the five-colour spectrum (blue, blue-soft, yellow, orange, red) for charts.
- The dashboard is dark only; the light theme and its toggle are gone.
- The language breakdown shows the top five languages plus Other, down from
seven, so each gets a spectrum colour.
- The dashboard loads its two fonts from Google Fonts and falls back to system
fonts offline.

## [0.2.1] - 2026-11-03

### Security
Expand Down
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
# GitPulse

[![CI](https://img.shields.io/github/actions/workflow/status/fullstackspectrum/gitpulse/ci.yml?branch=main&style=for-the-badge&logo=githubactions&logoColor=white&label=CI)](https://github.com/fullstackspectrum/gitpulse/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpypi.org%2Fpypi%2Fgitpulse-analytics%2Fjson&query=%24.info.version&prefix=v&label=pypi&style=for-the-badge&logo=pypi&logoColor=white&color=3775A9)](https://pypi.org/project/gitpulse-analytics/)
[![Python](https://img.shields.io/pypi/pyversions/gitpulse-analytics?style=for-the-badge&logo=python&logoColor=white&color=3776AB)](https://pypi.org/project/gitpulse-analytics/)
[![Homebrew](https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffullstackspectrum%2Fhomebrew-gitpulse%2Fmain%2FFormula%2Fgitpulse.rb&search=gitpulse_analytics-%28%5B0-9.%5D%2B%29%5C.tar%5C.gz&replace=v%241&label=homebrew&style=for-the-badge&logo=homebrew&logoColor=white&color=FBB040)](https://github.com/fullstackspectrum/homebrew-gitpulse)
[![Docker](https://img.shields.io/docker/image-size/fullstackspectrum/gitpulse/latest?style=for-the-badge&logo=docker&logoColor=white&label=docker&color=2496ED)](https://hub.docker.com/r/fullstackspectrum/gitpulse)
[![License](https://img.shields.io/github/license/fullstackspectrum/gitpulse?style=for-the-badge&color=6E7781)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/fullstackspectrum/gitpulse/ci.yml?branch=main&style=for-the-badge&logo=githubactions&logoColor=white&label=CI&color=4f8cff&labelColor=10151d)](https://github.com/fullstackspectrum/gitpulse/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpypi.org%2Fpypi%2Fgitpulse-analytics%2Fjson&query=%24.info.version&prefix=v&label=pypi&style=for-the-badge&logo=pypi&logoColor=white&color=8fb4ff&labelColor=10151d)](https://pypi.org/project/gitpulse-analytics/)
[![Python](https://img.shields.io/pypi/pyversions/gitpulse-analytics?style=for-the-badge&logo=python&logoColor=white&color=f5c84c&labelColor=10151d)](https://pypi.org/project/gitpulse-analytics/)
[![Homebrew](https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffullstackspectrum%2Fhomebrew-gitpulse%2Fmain%2FFormula%2Fgitpulse.rb&search=gitpulse_analytics-%28%5B0-9.%5D%2B%29%5C.tar%5C.gz&replace=v%241&label=homebrew&style=for-the-badge&logo=homebrew&logoColor=white&color=ff8a3d&labelColor=10151d)](https://github.com/fullstackspectrum/homebrew-gitpulse)
[![Docker](https://img.shields.io/docker/image-size/fullstackspectrum/gitpulse/latest?style=for-the-badge&logo=docker&logoColor=white&label=docker&color=ff4d5e&labelColor=10151d)](https://hub.docker.com/r/fullstackspectrum/gitpulse)
[![License](https://img.shields.io/github/license/fullstackspectrum/gitpulse?style=for-the-badge&color=646f7f&labelColor=10151d)](LICENSE)
<br>
[![PyPI downloads](https://img.shields.io/pepy/dt/gitpulse-analytics?style=for-the-badge&logo=pypi&logoColor=white&label=pypi%20downloads&color=3775A9)](https://pepy.tech/projects/gitpulse-analytics)
[![Docker pulls](https://img.shields.io/docker/pulls/fullstackspectrum/gitpulse?style=for-the-badge&logo=docker&logoColor=white&label=docker%20pulls&color=2496ED)](https://hub.docker.com/r/fullstackspectrum/gitpulse)
[![PyPI downloads](https://img.shields.io/pepy/dt/gitpulse-analytics?style=for-the-badge&logo=pypi&logoColor=white&label=pypi%20downloads&color=8fb4ff&labelColor=10151d)](https://pepy.tech/projects/gitpulse-analytics)
[![Docker pulls](https://img.shields.io/docker/pulls/fullstackspectrum/gitpulse?style=for-the-badge&logo=docker&logoColor=white&label=docker%20pulls&color=ff4d5e&labelColor=10151d)](https://hub.docker.com/r/fullstackspectrum/gitpulse)

A local Python CLI for GitHub repository traffic and release asset download stats. GitPulse can build a dashboard for a whole organization, and it keeps SQLite snapshots so you have history beyond GitHub's short traffic window.

Part of [Full-Stack-Spectrum](https://fullstackspectrum.com) ↗: small, sharp, open-source utilities that each do one job and install in one line.

> **What GitHub can tell you:** aggregate daily views and clones, unique counts, popular paths, referrers, and release-asset download totals. It does **not** identify individual visitors or people who cloned a repository. GitHub's traffic data is generally available for the most recent 14 days. Release asset downloads are not Homebrew formula install counts.

![GitPulse organization dashboard](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/screenshots/dashboard-light.png)
![GitPulse organization dashboard](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/screenshots/dashboard.png)

## Requirements

Expand Down Expand Up @@ -98,9 +100,7 @@ It also writes a self-contained HTML dashboard to `gitpulse-ORG.html` in the cur
- The most viewed and most starred repositories, a language breakdown, top referrers, and release downloads by repository
- A sortable, filterable table of every repository with a 14-day views sparkline

Styles, charts, and scripts are all inline, so it works offline (only the owner's avatar loads from GitHub). It follows your system's light or dark theme, and has a toggle to switch.

![Dashboard in dark mode](https://raw.githubusercontent.com/fullstackspectrum/gitpulse/main/docs/screenshots/dashboard-dark.png)
Styles, charts, and scripts are all inline. Only the owner's avatar and the Space Grotesk and JetBrains Mono web fonts load from the network; offline, the dashboard falls back to system fonts. It uses the dark [Full-Stack-Spectrum](https://fullstackspectrum.com) style.

By default, forks and archived repositories are skipped; `--all` includes them. When your token can't read traffic for a repository, its stars, forks, and downloads still appear, and the dashboard lists which repositories are missing traffic and why. Unique visitor and cloner counts are summed per repository, so one person who visits two repositories is counted twice.

Expand Down
Binary file removed docs/screenshots/dashboard-dark.png
Binary file not shown.
Binary file removed docs/screenshots/dashboard-light.png
Binary file not shown.
Binary file added docs/screenshots/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/terminal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
70 changes: 46 additions & 24 deletions src/gitpulse/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
TimeElapsedColumn,
)
from rich.table import Table
from rich.theme import Theme

from gitpulse import __version__
from gitpulse.github_api import GitHubAPIError, GitHubClient
Expand All @@ -31,7 +32,26 @@
help="Local GitHub repository traffic and release analytics.",
no_args_is_help=True,
)
console = Console()
# Full-Stack-Spectrum palette (fullstackspectrum.com): red leads, the rest of the spectrum marks data.
THEME = Theme(
{
"brand": "bold #ff4d5e",
"heading": "bold #8fb4ff",
"frame": "#2f3a4b",
"views": "#4f8cff",
"clones": "#ff8a3d",
"stars": "#f5c84c",
"downloads": "#8fb4ff",
"ok": "#8fb4ff",
"warn": "#f5c84c",
"error": "bold #ff4d5e",
"progress.spinner": "#ff4d5e",
"bar.complete": "#ff4d5e",
"bar.finished": "#4f8cff",
"bar.pulse": "#ff4d5e",
}
)
console = Console(theme=THEME)


def _version_callback(value: bool) -> None:
Expand All @@ -53,15 +73,15 @@ def _client() -> GitHubClient:
try:
return GitHubClient()
except GitHubAPIError as exc:
console.print(f"[red]Error:[/red] {exc}")
console.print(f"[error]Error:[/error] {exc}")
raise typer.Exit(code=1) from exc


def _run(action: Any) -> Any:
try:
return action()
except GitHubAPIError as exc:
console.print(f"[red]Error:[/red] {exc}")
console.print(f"[error]Error:[/error] {exc}")
raise typer.Exit(code=1) from exc


Expand All @@ -73,7 +93,7 @@ def _repo_parts(repository: str) -> tuple[str, str]:


def _traffic_table(owner: str, repo: str, metric: str, data: dict[str, Any]) -> Table:
table = Table(title=f"{owner}/{repo} · {metric}", header_style="bold cyan")
table = Table(title=f"{owner}/{repo} · {metric}", header_style="heading")
if metric in {"views", "clones"}:
table.add_column("Date")
table.add_column("Count", justify="right")
Expand Down Expand Up @@ -110,7 +130,7 @@ def list_repos(
repos = _run(_client().list_repositories)
if not all_repos:
repos = [repo for repo in repos if not repo.get("fork") and not repo.get("archived")]
table = Table(title="Your repositories", header_style="bold cyan")
table = Table(title="Your repositories", header_style="heading")
table.add_column("Repository")
table.add_column("Visibility")
table.add_column("Stars", justify="right")
Expand Down Expand Up @@ -143,7 +163,7 @@ def traffic(
console.print(_traffic_table(owner, repo, metric, data))
if save and metric in {"views", "clones"}:
SnapshotStore().save(owner, repo, metric, data)
console.print("[green]Snapshot saved locally.[/green]")
console.print("[ok]Snapshot saved locally.[/ok]")
console.print("[dim]GitHub provides aggregate traffic data, not visitor identities; traffic history is typically limited to 14 days.[/dim]")


Expand All @@ -159,7 +179,7 @@ def snapshot(
data = _run(lambda metric=metric: client.traffic(owner, repo, metric))
store.save(owner, repo, metric, data)
console.print(_traffic_table(owner, repo, metric, data))
console.print("[green]Snapshots saved. Run this regularly to build longer-term history.[/green]")
console.print("[ok]Snapshots saved. Run this regularly to build longer-term history.[/ok]")


@app.command()
Expand All @@ -173,7 +193,7 @@ def history(
if metric not in {"views", "clones"}:
raise typer.BadParameter("History is available for views or clones")
rows = SnapshotStore().history(owner, repo, metric, limit)
table = Table(title=f"Local history · {owner}/{repo} · {metric}", header_style="bold cyan")
table = Table(title=f"Local history · {owner}/{repo} · {metric}", header_style="heading")
table.add_column("Collected at")
table.add_column("14-day total", justify="right")
table.add_column("Sum of daily uniques*", justify="right")
Expand All @@ -199,7 +219,7 @@ def release_downloads(
"""Show GitHub release asset download counts (not Homebrew install counts)."""
owner, repo = _repo_parts(repository)
release_count, assets = _run(lambda: _client().release_downloads(owner, repo))
table = Table(title=f"Release asset downloads · {owner}/{repo}", header_style="bold cyan")
table = Table(title=f"Release asset downloads · {owner}/{repo}", header_style="heading")
table.add_column("Release")
table.add_column("Asset")
table.add_column("Downloads", justify="right")
Expand Down Expand Up @@ -237,22 +257,24 @@ def _org_summary(report: OrgReport) -> None:
name = report.profile.get("name") or report.owner
console.print(
Panel.fit(
f"[dim]//[/dim] [brand]gitpulse[/brand]\n"
f"[bold]{name}[/bold] [dim]@{report.owner}[/dim]\n"
f"{len(report.repos)} repositories · traffic {window}",
border_style="cyan",
border_style="frame",
)
)

def tile(label: str, value: int, style: str = "bold") -> Panel:
return Panel(f"[{style}]{value:,}[/{style}]\n[dim]{label}[/dim]", expand=True, border_style="dim")
def tile(label: str, value: int, style: str = "") -> Panel:
number = f"[{style}]{value:,}[/{style}]" if style else f"{value:,}"
return Panel(f"[bold]{number}[/bold]\n[dim]{label}[/dim]", expand=True, border_style="frame")

tiles = [
tile("views · 14d", report.total("views"), "bold cyan"),
tile("views · 14d", report.total("views"), "views"),
tile("unique visitors*", report.total("view_uniques")),
tile("clones · 14d", report.total("clones"), "bold magenta"),
tile("stars", report.total("stars"), "bold yellow"),
tile("clones · 14d", report.total("clones"), "clones"),
tile("stars", report.total("stars"), "stars"),
tile("forks", report.total("forks")),
tile("release downloads", report.total("downloads"), "bold green"),
tile("release downloads", report.total("downloads"), "downloads"),
]
grid = Table.grid(expand=True, padding=(0, 1))
for _ in range(3):
Expand All @@ -261,11 +283,11 @@ def tile(label: str, value: int, style: str = "bold") -> Panel:
grid.add_row(*tiles[3:])
console.print(grid)
if views:
console.print(f" [dim]daily views[/dim] [cyan]{_spark([c for _, c in views])}[/cyan]")
console.print(f" [dim]daily views[/dim] [views]{_spark([c for _, c in views])}[/views]")
clones = [c for _, c in report.daily("clones_daily")]
console.print(f" [dim]daily clones[/dim] [magenta]{_spark(clones)}[/magenta]\n")
console.print(f" [dim]daily clones[/dim] [clones]{_spark(clones)}[/clones]\n")

table = Table(header_style="bold cyan", box=box.SIMPLE_HEAD, expand=True)
table = Table(header_style="heading", box=box.SIMPLE_HEAD, expand=True)
table.add_column("Repository", overflow="fold")
table.add_column("Lang", style="dim")
for column in ("Stars", "Forks", "Views", "Unique", "Clones", "Downloads"):
Expand All @@ -283,13 +305,13 @@ def tile(label: str, value: int, style: str = "bold") -> Panel:
f"{repo.view_uniques:,}" if ok else na,
f"{repo.clones:,}" if ok else na,
f"{repo.downloads:,}" if repo.releases_error is None else na,
f"[cyan]{_spark([repo.views_daily.get(d, 0) for d in days])}[/cyan]" if ok and days else "",
f"[views]{_spark([repo.views_daily.get(d, 0) for d in days])}[/views]" if ok and days else "",
)
console.print(table)

referrers = report.referrers(limit=5)
if referrers:
ref_table = Table(title="Top referrers", header_style="bold cyan", box=box.SIMPLE_HEAD)
ref_table = Table(title="Top referrers", header_style="heading", box=box.SIMPLE_HEAD)
ref_table.add_column("Source")
ref_table.add_column("Views", justify="right")
ref_table.add_column("Unique*", justify="right")
Expand All @@ -300,7 +322,7 @@ def tile(label: str, value: int, style: str = "bold") -> Panel:
missing = [repo for repo in report.repos if repo.traffic_error]
if missing:
console.print(
f"[yellow]Traffic unavailable for {len(missing)} of {len(report.repos)} repositories[/yellow] "
f"[warn]Traffic unavailable for {len(missing)} of {len(report.repos)} repositories[/warn] "
"[dim](the token needs Administration: Read or push access). See the dashboard for details.[/dim]"
)
console.print("[dim]*Unique counts are summed per repository, so one person can be counted more than once.[/dim]")
Expand Down Expand Up @@ -354,11 +376,11 @@ def org_dashboard(
for metric, payload in repo.raw_traffic.items():
store.save(repo.owner, repo.name, metric, payload)
saved += 1
console.print(f"[green]Saved snapshots for {saved} repositories.[/green]")
console.print(f"[ok]Saved snapshots for {saved} repositories.[/ok]")

path = Path(output or f"gitpulse-{report.owner}.html").expanduser().resolve()
path.write_text(render_html(report), encoding="utf-8")
console.print(f"\n[bold green]✓[/bold green] Dashboard written to [link={path.as_uri()}]{path}[/link]")
console.print(f"\n[ok]✓[/ok] Dashboard written to [link={path.as_uri()}]{path}[/link]")
if open_browser:
webbrowser.open(path.as_uri())

Expand Down
Loading
Loading