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
6 changes: 6 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,9 @@ tasks:
cmds:
- uv run --group dbt dbt docs generate
- uv run --group dbt dbt docs serve

# ----- Streamlit dashboard -----
dashboard:
desc: Launch the Streamlit analytics dashboard
cmds:
- uv run --group dashboard streamlit run dashboard/app.py
Empty file added dashboard/__init__.py
Empty file.
76 changes: 76 additions & 0 deletions dashboard/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Repolytics analytics dashboard - Streamlit entrypoint.

Reads the dbt marts read-only from the DuckDB warehouse; defines the global
repo/date filters in the sidebar and renders one page per analytics theme.
"""

import streamlit as st

# Imported relative to the dashboard/ app directory, which Streamlit places on
# sys.path when the app is launched via `streamlit run dashboard/app.py`.
from lib import data, queries
from views import (
_components as c,
)
from views import (
contributors,
overview,
popularity,
releases,
velocity,
)

st.set_page_config(page_title="Repolytics", page_icon="📊", layout="wide")

conn = c.get_conn()

if not data.has_marts(conn):
st.title("Repolytics")
st.error("No marts found in the warehouse. Run the pipeline and reload.")
st.stop()

# ----- Global filters (persist across pages) -----
all_repos = queries.all_repo_names(conn)
st.sidebar.header("Filters")
selected = st.sidebar.multiselect("Repositories", all_repos, default=all_repos)
st.session_state["repos"] = selected or all_repos

low, high = data.date_bounds(conn)
if low and high and low < high:
date_from, date_to = st.sidebar.slider(
"Activity date range", min_value=low, max_value=high, value=(low, high)
)
st.session_state["date_from"], st.session_state["date_to"] = date_from, date_to

st.sidebar.caption(
"Reads the dbt marts read-only. The date range filters the activity and velocity "
"charts; snapshot and all-time views (health, stars/downloads, contributors, "
"releases) show their full history. Stars/downloads trends fill in as snapshots "
"accrue."
)

st.navigation(
[
st.Page(
overview.render,
title="Overview",
icon="📊",
url_path="overview",
default=True,
),
st.Page(
popularity.render,
title="Popularity & Health",
icon="⭐",
url_path="popularity",
),
st.Page(velocity.render, title="Velocity", icon="🚀", url_path="velocity"),
st.Page(
contributors.render,
title="Contributors",
icon="👥",
url_path="contributors",
),
st.Page(releases.render, title="Releases", icon="🏷️", url_path="releases"),
]
).run()
Empty file added dashboard/lib/__init__.py
Empty file.
57 changes: 57 additions & 0 deletions dashboard/lib/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Read-only DuckDB access for the dashboard (streamlit-free)."""

from datetime import date
from pathlib import Path

import duckdb
import pandas as pd

from repolytics.config import get_settings


def connect(duckdb_path: str | Path | None = None) -> duckdb.DuckDBPyConnection:
"""Open a read-only DuckDB connection to the warehouse.

Defaults to `Settings.duckdb_path`.
"""
path = Path(duckdb_path) if duckdb_path is not None else get_settings().duckdb_path
return duckdb.connect(str(path), read_only=True)


def run_query(
conn: duckdb.DuckDBPyConnection, sql: str, params: list | None = None
) -> pd.DataFrame:
"""Execute `sql` with positional `params` and return a pandas DataFrame."""
return conn.execute(sql, params or []).df()


def has_marts(conn: duckdb.DuckDBPyConnection) -> bool:
"""True when the `marts` schema is populated (so the dashboard has data)."""
row = conn.execute(
"select count(*) from information_schema.tables "
"where table_schema = 'marts' and table_name = 'dim_repositories'"
).fetchone()
return bool(row and row[0])


def date_bounds(conn: duckdb.DuckDBPyConnection) -> tuple[date, date]:
"""Min/max event date across all activity facts, for the sidebar date filter.

Spans commits, PRs (opened), issues (opened), and releases so the slider covers
every date-filtered chart - a repo whose PRs/releases extend past its last commit
is not clipped.
"""
row = conn.execute(
"""
with event_keys as (
select date_key from marts.fct_commits
union all select opened_date_key from marts.fct_pull_requests
union all select opened_date_key from marts.fct_issues
union all select date_key from marts.fct_releases
)
select min(d.full_date), max(d.full_date)
from event_keys e
join marts.dim_dates d on e.date_key = d.date_key
"""
).fetchone()
return row[0], row[1]
60 changes: 60 additions & 0 deletions dashboard/lib/health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Composite project-health score (streamlit-free, pure DataFrame -> DataFrame).

A blend of five signals, each scored **absolutely** against a fixed reference target.
That keeps a project's score stable regardless of which other projects are selected,
and comparable across sessions and over time. The two rate signals (PR merge rate,
issue close rate) are already 0..1 and are used directly; the unbounded signals
(recent commits, stars) are mapped through a saturating log transform against a
reference, and release recency through an exponential time-decay.

Input columns (one row per repo, from `queries.health_components`):
repository_name, commits_90d, pr_merge_rate, issue_close_rate,
days_since_release (nullable -> treated as never released -> 0), stars

Output adds the five `c_*` component scores (0..1) and `health_score` (0..100).
"""

import numpy as np
import pandas as pd

WEIGHTS: dict[str, float] = {
"activity": 0.30, # commits in the last 90 days
"pr_responsiveness": 0.20, # PR merge rate, last 12 months
"issue_management": 0.20, # issue close rate, last 12 months
"release_recency": 0.15, # how recently the repo last released
"popularity": 0.15, # current stars (log-scaled)
}

# Reference targets for the absolute component scores: the value at which a component
# counts as "fully healthy" (or, for release recency, its decay half-life).
TARGET_COMMITS_90D = 90 # ~1 commit/day over the window saturates the activity score
REF_STARS = 50_000 # log-scale reference for "maximally popular"
RELEASE_HALFLIFE_DAYS = 180.0 # released ~6 months ago -> 0.5; never released -> 0


def _log_ratio(series: pd.Series, reference: float) -> pd.Series:
"""Saturating log map to 0..1: `log1p(value) / log1p(reference)`, capped at 1."""
values = series.astype(float).clip(lower=0)
return np.minimum(np.log1p(values) / np.log1p(reference), 1.0)


def compute_health(components: pd.DataFrame) -> pd.DataFrame:
"""Return `components` with `c_*` component scores and a 0..100 `health_score`."""
df = components.copy()

# Missing release -> infinite days -> recency decays to 0.
days = df["days_since_release"].astype(float).fillna(np.inf)
df["c_activity"] = _log_ratio(df["commits_90d"], TARGET_COMMITS_90D)
df["c_pr_responsiveness"] = df["pr_merge_rate"].astype(float).clip(0, 1)
df["c_issue_management"] = df["issue_close_rate"].astype(float).clip(0, 1)
df["c_release_recency"] = 0.5 ** (days / RELEASE_HALFLIFE_DAYS)
df["c_popularity"] = _log_ratio(df["stars"], REF_STARS)

df["health_score"] = (
df["c_activity"] * WEIGHTS["activity"]
+ df["c_pr_responsiveness"] * WEIGHTS["pr_responsiveness"]
+ df["c_issue_management"] * WEIGHTS["issue_management"]
+ df["c_release_recency"] * WEIGHTS["release_recency"]
+ df["c_popularity"] * WEIGHTS["popularity"]
) * 100.0
return df
Loading
Loading