An end-to-end ELT pipeline that measures the health, activity, and popularity of open-source Python projects over time.
How healthy, active, and popular are open-source Python projects, and how do their contributor communities behave over time?
Specifically, the marts and dashboard answer:
- Popularity & health – stars/forks trends and a composite health score
- Velocity – PR time-to-merge and throughput, is it improving or degrading?
- Contributors – bus factor (how concentrated is activity in the top N contributors?) and retention cohorts
- Releases – cadence and recency
- Adoption – PyPI downloads, and how they correlate with stars
Airflow orchestrates the whole pipeline: it runs the two dlt ingestion sources in parallel, then hands off to a Cosmos-rendered dbt task group. Everything from ingestion to marts lands in DuckDB as separate schemas. The dashboard queries the marts read-only.
flowchart LR
GH[/GitHub REST API/]
PP[/PyPI downloads<br/>BigQuery public dataset/]
subgraph af [Airflow 3 + Cosmos / repolytics_daily DAG]
direction LR
DLT[dlt ingestion]
RAW[(DuckDB<br/>raw schema)]
subgraph dbt [dbt transform]
direction LR
STG[staging] --> INT[intermediate] --> MARTS[(marts)]
end
DLT --> RAW --> STG
end
DASH([Streamlit dashboard])
GH --> DLT
PP --> DLT
MARTS --> DASH
| Category | Tools |
|---|---|
| Language | Python 3.13 |
| Ingestion | dlt, GitHub REST API, BigQuery |
| Warehouse | DuckDB |
| Transformation | dbt |
| Orchestration | Airflow 3, Astronomer Cosmos |
| Serving | Streamlit, Altair |
| Dev tooling | uv, Ruff, Taskfile, Docker |
| Testing & CI | pytest, Codecov, GitHub Actions |
repolytics/
├── src/repolytics/ # dlt ingestion sources + config (installed package)
├── dbt/ # dbt project
│ └── seeds/projects.csv # single source of truth for which projects to analyze
├── dags/ # Airflow DAGs
├── dashboard/ # Streamlit serving layer
├── tests/ # unit / integration / dag tiers + fixtures
├── Taskfile.yml # task runner - defines common commands
├── Dockerfile # custom Airflow image
└── docker-compose.yml # Airflow runtime (LocalExecutor + Postgres)
The marts are a fact constellation (galaxy schema) – multiple fact tables sharing conformed dimensions. dim_repositories, dim_contributors, and dim_dates are the conformed spine: dim_dates joins every fact, dim_repositories all but the contributor-activity fact, and dim_contributors the contributor-grained facts (commits, PRs, issues, monthly activity); dim_labels sits apart, reached only through the bridge_issue_labels many-to-many bridge.
erDiagram
fct_commits }o--|| dim_repositories : ""
fct_commits }o--o| dim_contributors : ""
fct_commits }o--|| dim_dates : ""
fct_pull_requests }o--|| dim_repositories : ""
fct_pull_requests }o--o| dim_contributors : ""
fct_pull_requests }o--|| dim_dates : ""
fct_issues }o--|| dim_repositories : ""
fct_issues }o--o| dim_contributors : ""
fct_issues }o--|| dim_dates : ""
fct_contributor_activity_monthly }o--|| dim_contributors : ""
fct_contributor_activity_monthly }o--|| dim_dates : ""
fct_releases }o--|| dim_repositories : ""
fct_releases }o--|| dim_dates : ""
fct_repository_metrics }o--|| dim_repositories : ""
fct_repository_metrics }o--|| dim_dates : ""
fct_daily_downloads }o--o| dim_repositories : ""
fct_daily_downloads }o--|| dim_dates : ""
bridge_issue_labels }o--|| fct_issues : ""
bridge_issue_labels }o--|| dim_labels : ""
Crow's foot cardinality; a circle on the dimension side marks a nullable (optional) foreign key (e.g. commits with no GitHub-login author, or downloads for an unmapped package).
Fact grains and the dimensions each references:
| Fact | Grain | Dimensions |
|---|---|---|
fct_commits |
one row per commit | repository, contributor, date |
fct_pull_requests |
one row per PR | repository, contributor, date (x2) |
fct_issues |
one row per issue | repository, contributor, date (x2) |
fct_releases |
one row per release | repository, date |
fct_daily_downloads |
one row per package/day | repository, date |
fct_repository_metrics |
one row per repo/snapshot day | repository, date |
fct_contributor_activity_monthly |
one row per contributor/month | contributor, date |
Two modeling choices worth calling out:
dim_repositoriesis SCD Type 2 – repository attributes are versioned via a dbt snapshot, so each fact resolves the repository version that was current when the event occurred (half-open date-range join). Range integrity is enforced withdbt_utils.mutually_exclusive_ranges.- Facts are incremental where it matters – e.g.
fct_commitsandfct_daily_downloadsuse dbt's merge strategy for idempotent, cheap daily runs.
Prerequisites: uv, task, Docker (for the orchestrated run), and gcloud (for PyPI data source).
git clone https://github.com/Neukz/repolytics.git
cd repolytics
task install
task dbt:deps # install dbt packagesCreate a GitHub token with public-repo read scope and note it for the .env file below.
PyPI download stats come from the bigquery-public-data.pypi.file_downloads public dataset. Queries are billed to your project (free under 1 TB/month) and authenticate via Application Default Credentials:
gcloud config set project <project_id> # or: gcloud projects create <project_id>
gcloud services enable bigquery.googleapis.com
gcloud auth application-default login # writes ADC to ~/.config/gcloud/Docker mounts your ADC file into the containers automatically (see docker-compose.yml).
cp .env.example .envGet the UID and generate the Airflow Fernet key:
echo "AIRFLOW_UID=$(id -u)"
uv run python -c "from cryptography.fernet import Fernet; print(f'FERNET_KEY={Fernet.generate_key().decode()}')"Then fill in:
GITHUB_TOKEN=<token from step 2.>
GCP_PROJECT=<project_id from step 3.>
AIRFLOW_UID=<generated above>
FERNET_KEY=<generated above>task dbt:manifest # Cosmos renders the DAG from this manifest - build it first
docker compose up # Airflow (LocalExecutor + Postgres)Open the Airflow UI at http://localhost:8080, log in with the account airflow and password airflow, then enable and trigger the repolytics_daily DAG.
task dlt:ingest # GitHub + PyPI -> DuckDB raw schema
task dbt:build # staging -> intermediate -> marts (+ all dbt tests)task dashboard # Streamlit dashboard at http://localhost:8501The set of projects to analyze lives in dbt/seeds/projects.csv (repo,package rows) – the source of truth for both dlt and dbt. Modify to track your preferred set of projects.
Three pytest tiers, split by path:
tests/unit/– pure logic, no I/O: config, provenance stamping, pipeline skip paths, and the PyPI source's query building and row mapping (via an injectable fake runner).tests/integration/– runs the real dlt sources into a temporary DuckDB from recorded fixtures (GitHub over mocked HTTP, PyPI via a fake BigQuery runner), asserting therawschema contract dbt depends on, plus GitHub's incremental cursor.tests/dag/– Airflow DAG-parse policy checks.
CI runs these as parallel jobs and executes dbt: the dbt-build job seeds the raw schema from the recorded fixtures and runs dbt build against a file-backed DuckDB, exercising every model plus all schema/data tests on fixture data. There is no live warehouse in CI.
task ci # mirrors the GitHub Actions gate locally- DuckDB is single-writer. The DAG sets
max_active_tasks=1andmax_active_runs=1to serialize everything, because a DuckDB file allows only one writer at a time. That rules out concurrent ingestion/transform and horizontal scaling; the intended escape hatch is swapping DuckDB for a cloud warehouse (the dbt SQL is largely portable). - GitHub has no true historical backfill. The dlt incremental cursor is global (not per-interval), so ingestion fetches current-state-forward and skips historical backfill runs. Full history accumulates through
write_disposition="merge"over successive daily runs – it can't replay an arbitrary past window on demand the way PyPI can. - PyPI backfill bills BigQuery. Each day is a query against the public dataset. It's free under 1 TB/month, but a wide multi-year backfill will consume quota, so it's partition-pruned to one day per run.
- Popularity/health facts start as a single snapshot.
fct_repository_metricscaptures one row per repo per run, so stars/forks trends and the health-score trajectory only become meaningful after several daily runs accrue. The dashboard degrades gracefully (current value + note) until then. - The health score is computed in the app, not persisted. It lives in
dashboard/lib/health.py, which means it isn't materialized or covered by dbt tests (see Future work). - Test coverage is limited. Coverage is scoped to
src/repolytics/(config + dlt sources, per[tool.coverage.run]); the Streamlit dashboard and the Airflow DAG at runtime are untested.
- Scale the seed to 10-15 projects.
dbt/seeds/projects.csvcurrently tracks 2 repos; more projects unlock the comparative analytics the marts are built for – cross-project bus factor, retention curves, and downloads-vs-stars correlation. - Graduate the health score to
fct_repository_health. Move the composite metric out ofdashboard/lib/health.pyinto a materialized, contract-and-test-covered mart so it's reusable beyond the dashboard and versioned with the rest of the model. - Migrate to a cloud warehouse to lift the single-writer constraint, enable concurrent serving, and separate storage from compute. The staging/marts SQL is mostly portable; the dlt destination and dbt profile are the main changes.
- Add a weekly aggregation DAG writing rollups (retention cohorts, rolling health) into a
metricsschema – cheaper for the dashboard than recomputing over the atomic facts on every query. - Tighten staging-layer boundaries. A few staging models reassemble dlt parent/child tables via joins that strictly belong in the intermediate layer, and
dim_contributorsre-aggregates staging in parallel withint_contributor_activity; deriving both from one event stream would give contributor activity a single source of truth.