ghtraffic 0.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ghtraffic-0.0.2/.gitattributes +1 -0
- ghtraffic-0.0.2/.gitignore +120 -0
- ghtraffic-0.0.2/LICENSE +21 -0
- ghtraffic-0.0.2/PKG-INFO +131 -0
- ghtraffic-0.0.2/README.md +110 -0
- ghtraffic-0.0.2/action.yml +72 -0
- ghtraffic-0.0.2/ghtraffic/__init__.py +41 -0
- ghtraffic-0.0.2/ghtraffic/__main__.py +134 -0
- ghtraffic-0.0.2/ghtraffic/collect.py +266 -0
- ghtraffic-0.0.2/ghtraffic/config.py +74 -0
- ghtraffic-0.0.2/ghtraffic/demo.py +185 -0
- ghtraffic-0.0.2/ghtraffic/github.py +199 -0
- ghtraffic-0.0.2/ghtraffic/repos.py +181 -0
- ghtraffic-0.0.2/ghtraffic/scaffold.py +123 -0
- ghtraffic-0.0.2/ghtraffic/tables.py +360 -0
- ghtraffic-0.0.2/ghtraffic/traffic.py +169 -0
- ghtraffic-0.0.2/pyproject.toml +179 -0
- ghtraffic-0.0.2/tests/test_ghtraffic.py +350 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*.ipynb linguist-documentation
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
.claude/handoffs/
|
|
2
|
+
.claude/scratch/
|
|
3
|
+
|
|
4
|
+
# Byte-compiled / optimized / DLL files
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*$py.class
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
.DS_Store
|
|
11
|
+
# C extensions
|
|
12
|
+
*.so
|
|
13
|
+
|
|
14
|
+
# TLS certificates
|
|
15
|
+
## Ignore all PEM files anywhere
|
|
16
|
+
*.pem
|
|
17
|
+
## Also ignore any certs directory
|
|
18
|
+
certs/
|
|
19
|
+
|
|
20
|
+
# Distribution / packaging
|
|
21
|
+
.Python
|
|
22
|
+
build/
|
|
23
|
+
develop-eggs/
|
|
24
|
+
dist/
|
|
25
|
+
downloads/
|
|
26
|
+
eggs/
|
|
27
|
+
.eggs/
|
|
28
|
+
lib/
|
|
29
|
+
lib64/
|
|
30
|
+
parts/
|
|
31
|
+
sdist/
|
|
32
|
+
var/
|
|
33
|
+
wheels/
|
|
34
|
+
node_modules/
|
|
35
|
+
*.egg-info/
|
|
36
|
+
.installed.cfg
|
|
37
|
+
*.egg
|
|
38
|
+
MANIFEST
|
|
39
|
+
_build
|
|
40
|
+
|
|
41
|
+
# PyInstaller
|
|
42
|
+
# Usually these files are written by a python script from a template
|
|
43
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
44
|
+
*.manifest
|
|
45
|
+
*.spec
|
|
46
|
+
|
|
47
|
+
# Installer logs
|
|
48
|
+
pip-log.txt
|
|
49
|
+
pip-delete-this-directory.txt
|
|
50
|
+
|
|
51
|
+
# Unit test / coverage reports
|
|
52
|
+
htmlcov/
|
|
53
|
+
.tox/
|
|
54
|
+
.coverage
|
|
55
|
+
.coverage.*
|
|
56
|
+
.cache
|
|
57
|
+
nosetests.xml
|
|
58
|
+
coverage.xml
|
|
59
|
+
*.cover
|
|
60
|
+
.hypothesis/
|
|
61
|
+
.pytest_cache/
|
|
62
|
+
|
|
63
|
+
# Translations
|
|
64
|
+
*.mo
|
|
65
|
+
*.pot
|
|
66
|
+
|
|
67
|
+
# Django stuff:
|
|
68
|
+
*.log
|
|
69
|
+
local_settings.py
|
|
70
|
+
db.sqlite3
|
|
71
|
+
|
|
72
|
+
# Flask stuff:
|
|
73
|
+
instance/
|
|
74
|
+
.webassets-cache
|
|
75
|
+
|
|
76
|
+
# Scrapy stuff:
|
|
77
|
+
.scrapy
|
|
78
|
+
|
|
79
|
+
# Sphinx documentation
|
|
80
|
+
docs/_build/
|
|
81
|
+
|
|
82
|
+
# PyBuilder
|
|
83
|
+
target/
|
|
84
|
+
|
|
85
|
+
# Jupyter Notebook
|
|
86
|
+
.ipynb_checkpoints
|
|
87
|
+
|
|
88
|
+
# pyenv
|
|
89
|
+
.python-version
|
|
90
|
+
|
|
91
|
+
# celery beat schedule file
|
|
92
|
+
celerybeat-schedule
|
|
93
|
+
|
|
94
|
+
# SageMath parsed files
|
|
95
|
+
*.sage.py
|
|
96
|
+
|
|
97
|
+
# Environments
|
|
98
|
+
.env
|
|
99
|
+
.venv
|
|
100
|
+
env/
|
|
101
|
+
venv/
|
|
102
|
+
ENV/
|
|
103
|
+
env.bak/
|
|
104
|
+
venv.bak/
|
|
105
|
+
|
|
106
|
+
# Spyder project settings
|
|
107
|
+
.spyderproject
|
|
108
|
+
.spyproject
|
|
109
|
+
|
|
110
|
+
# Rope project settings
|
|
111
|
+
.ropeproject
|
|
112
|
+
|
|
113
|
+
# mkdocs documentation
|
|
114
|
+
/site
|
|
115
|
+
|
|
116
|
+
# mypy
|
|
117
|
+
.mypy_cache/
|
|
118
|
+
|
|
119
|
+
# PyCharm
|
|
120
|
+
.idea
|
ghtraffic-0.0.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thor Whalen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ghtraffic-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ghtraffic
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Collect, accumulate and explore GitHub traffic and insights (views, clones, referrers, paths, stars) across all your repos and orgs, with a GitHub Action and a static dashboard.
|
|
5
|
+
Project-URL: Homepage, https://github.com/thorwhalen/ghtraffic
|
|
6
|
+
Project-URL: Repository, https://github.com/thorwhalen/ghtraffic
|
|
7
|
+
Project-URL: Dashboard, https://thorwhalen.github.io/ghtraffic/
|
|
8
|
+
Author: Thor Whalen
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: analytics,dashboard,github,github-actions,insights,traffic
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
17
|
+
Provides-Extra: docs
|
|
18
|
+
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
|
|
19
|
+
Requires-Dist: sphinx>=6.0; extra == 'docs'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# ghtraffic
|
|
23
|
+
|
|
24
|
+
Keep your GitHub traffic for longer than 14 days, across all your repos and orgs, and explore it in a dashboard.
|
|
25
|
+
|
|
26
|
+
GitHub shows a repo's views, clones, referrers and popular pages for the last 14 days only, one repo at a time. `ghtraffic` collects them for every repo you can push to, on a schedule, and merges each run into a growing history of small CSV files in a git repository you own. Star, fork and watcher counts come along, and star history is rebuilt back to each repo's creation. A static dashboard reads that history.
|
|
27
|
+
|
|
28
|
+
**Dashboard (demo data):** https://thorwhalen.github.io/ghtraffic/
|
|
29
|
+
|
|
30
|
+
## Quick start: collect with a GitHub Action
|
|
31
|
+
|
|
32
|
+
1. Create a **private** repository for the data (it will hold the traffic of your private repos too), and add the workflow and config to it:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install ghtraffic
|
|
36
|
+
gh repo create my-traffic-data --private --clone && cd my-traffic-data
|
|
37
|
+
ghtraffic init . --owner @me # or: --owner me --owner my-org ...
|
|
38
|
+
git add -A && git commit -m "ghtraffic" && git push -u origin main
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`ghtraffic init` writes two files: `ghtraffic.toml` (what to collect) and `.github/workflows/ghtraffic.yml`, which is all the workflow needs:
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
- uses: thorwhalen/ghtraffic@0.0.2 # the release you installed; pin it
|
|
46
|
+
with:
|
|
47
|
+
token: ${{ secrets.GHTRAFFIC_TOKEN }}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The Action is pinned to a release tag (or pin a commit SHA): the token it receives can read every repo it covers, so it should run code you chose, not whatever lands on `main` later. It refuses to commit private repos' data into a public repository (input `allow-public: true` overrides).
|
|
51
|
+
|
|
52
|
+
`"@me"` means you **and every organization you belong to**, including client or employer organizations whose repo names and traffic may not be yours to copy. List owners explicitly, or use `exclude = ["client-org/*"]`, when that matters.
|
|
53
|
+
|
|
54
|
+
2. Create a token with push access to the repos you want: a [classic token with the `repo` and `read:org` scopes](https://github.com/settings/tokens/new?scopes=repo,read:org&description=ghtraffic) covers every owner you can push to (a fine-grained token is limited to one owner). Save it as the data repo's `GHTRAFFIC_TOKEN` secret:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
gh secret set GHTRAFFIC_TOKEN
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
3. Run it once by hand (`gh workflow run ghtraffic.yml`), and it will run on its own on Mondays and Thursdays from then on. The first run is the cold start: it takes the 14 days GitHub still has, plus each repo's star history. Every later run merges into what is there; re-running is always safe.
|
|
61
|
+
|
|
62
|
+
## Look at it
|
|
63
|
+
|
|
64
|
+
Open the dashboard at https://thorwhalen.github.io/ghtraffic/ and point it at your data repo, or use `#repo=<owner>/<data-repo>` in the URL. Paste a [fine-grained token](https://github.com/settings/personal-access-tokens/new) that can only **read the contents of that one repo**. Your browser fetches the data straight from the GitHub API with it. The page itself is public, holds no data, talks to no host but GitHub, and refuses to run inside a frame. The token stays in memory unless you tick "remember"; a remembered token is readable by any page on the same `<user>.github.io` origin, which is one more reason to give it read access to the data repo only. `?data=<url>` reads a public data directory from `raw.githubusercontent.com` instead.
|
|
65
|
+
|
|
66
|
+
It shows totals and growth over any time range, by owner and repo, views or clones, totals or uniques, a sortable leaderboard with sparklines, top referrers and pages, and stars and forks over time.
|
|
67
|
+
|
|
68
|
+
## Collect from your own computer
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
ghtraffic collect --owner @me # into ~/.local/share/ghtraffic/data
|
|
72
|
+
ghtraffic collect --data-dir ./my-traffic-data # into a checkout of your data repo
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The token comes from `GHTRAFFIC_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN`, or from the GitHub CLI (`gh auth login`).
|
|
76
|
+
|
|
77
|
+
From Python:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from ghtraffic import collect, load_settings, Files
|
|
81
|
+
|
|
82
|
+
report = collect(load_settings(owners=["@me"], exclude=["my-org/sandbox-*"]), store=Files("~/traffic"))
|
|
83
|
+
print(report.summary())
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`store` is any `MutableMapping[str, str]` (relative path to text): a folder by default, or any [dol](https://github.com/i2mint/dol) store (S3, a database, ...). `rest=` and `graphql=` replace the HTTP transport.
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
`ghtraffic.toml`, at the data directory's root. Every key is optional.
|
|
91
|
+
|
|
92
|
+
| key | default | meaning |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `owners` | `["@me"]` | user and org logins; `"@me"` is you and every org you belong to |
|
|
95
|
+
| `exclude` | `[]` | `owner/repo` glob patterns to skip (`"my-org/*"` skips an owner) |
|
|
96
|
+
| `include_forks` | `true` | collect forks too |
|
|
97
|
+
| `include_archived` | `true` | collect archived repos too |
|
|
98
|
+
| `snapshot_every_days` | `14` | days between snapshots of referrers, paths and 14-day totals |
|
|
99
|
+
| `backfill_stars` | `true` | rebuild a repo's star history the first time it is seen |
|
|
100
|
+
| `max_workers` | `8` | concurrent requests |
|
|
101
|
+
|
|
102
|
+
Unknown keys are refused (a typo never silently falls back to a default). Every key is also a `ghtraffic collect` flag (`--no-include-forks`, `--snapshot-every-days 7`, ...).
|
|
103
|
+
|
|
104
|
+
Repos you cannot push to have no readable traffic: they are counted and listed in `last_run.json`, never an error. An organization policy refusing the token (SAML SSO, a ban on classic tokens) is an error, listed with the repo, so a whole org never silently reads as zero traffic. The Action log carries counts only; names and messages go to `last_run.json`.
|
|
105
|
+
|
|
106
|
+
## Data layout
|
|
107
|
+
|
|
108
|
+
Plain CSV, one table per kind, split by year, sorted by date so each run adds lines at the end of the current year's file. `manifest.json` lists every file and its columns.
|
|
109
|
+
|
|
110
|
+
| file | rows |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `views/YYYY.csv`, `clones/YYYY.csv` | `date,repo,count,uniques`: one per repo and day with any traffic (no row means zero) |
|
|
113
|
+
| `windows/YYYY.csv` | `fetched,repo,kind,count,uniques`: 14-day totals at each snapshot, the only true 14-day unique counts |
|
|
114
|
+
| `referrers/YYYY.csv`, `paths/YYYY.csv` | the top 10 over the 14 days before `fetched`; snapshots are 14 days apart, so they never overlap |
|
|
115
|
+
| `stars.csv` | `date,repo,stars,forks,watchers`: a row only when something changed |
|
|
116
|
+
| `repos.csv` | current metadata of every repo seen |
|
|
117
|
+
| `backfills.csv` | when each repo's star history was rebuilt (a failed backfill is retried next run) |
|
|
118
|
+
| `runs.csv`, `last_run.json` | one row per run; the last run's details (repos without access, errors) |
|
|
119
|
+
|
|
120
|
+
A day seen by several runs keeps the largest value of each field, so overlapping windows, re-runs and partial days all merge to the same result. Why this layout, and the alternatives: [docs/adr/0001-storage.md](docs/adr/0001-storage.md).
|
|
121
|
+
|
|
122
|
+
## Cost
|
|
123
|
+
|
|
124
|
+
One job per run, about 1 minute (a few minutes for the cold start with star history). At two runs a week that is about 10 to 20 Actions minutes a month on a private repository, and two missed runs in a row still lose nothing. Data grows by a few MB a year for a few hundred repos.
|
|
125
|
+
|
|
126
|
+
## Limits
|
|
127
|
+
|
|
128
|
+
- GitHub keeps traffic for 14 days. Nothing earlier can be recovered, except star history.
|
|
129
|
+
- Star history counts current stargazers by the date they starred; unstars are invisible.
|
|
130
|
+
- Referrers and paths are GitHub's top 10 per repo only. Snapshots are 14 days apart so they can be added up; a missed snapshot run loses the referrer and path data of the days it slipped by (daily views and clones are unaffected).
|
|
131
|
+
- One token for every owner means a classic token (fine-grained tokens are per owner).
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# ghtraffic
|
|
2
|
+
|
|
3
|
+
Keep your GitHub traffic for longer than 14 days, across all your repos and orgs, and explore it in a dashboard.
|
|
4
|
+
|
|
5
|
+
GitHub shows a repo's views, clones, referrers and popular pages for the last 14 days only, one repo at a time. `ghtraffic` collects them for every repo you can push to, on a schedule, and merges each run into a growing history of small CSV files in a git repository you own. Star, fork and watcher counts come along, and star history is rebuilt back to each repo's creation. A static dashboard reads that history.
|
|
6
|
+
|
|
7
|
+
**Dashboard (demo data):** https://thorwhalen.github.io/ghtraffic/
|
|
8
|
+
|
|
9
|
+
## Quick start: collect with a GitHub Action
|
|
10
|
+
|
|
11
|
+
1. Create a **private** repository for the data (it will hold the traffic of your private repos too), and add the workflow and config to it:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install ghtraffic
|
|
15
|
+
gh repo create my-traffic-data --private --clone && cd my-traffic-data
|
|
16
|
+
ghtraffic init . --owner @me # or: --owner me --owner my-org ...
|
|
17
|
+
git add -A && git commit -m "ghtraffic" && git push -u origin main
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`ghtraffic init` writes two files: `ghtraffic.toml` (what to collect) and `.github/workflows/ghtraffic.yml`, which is all the workflow needs:
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- uses: thorwhalen/ghtraffic@0.0.2 # the release you installed; pin it
|
|
25
|
+
with:
|
|
26
|
+
token: ${{ secrets.GHTRAFFIC_TOKEN }}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The Action is pinned to a release tag (or pin a commit SHA): the token it receives can read every repo it covers, so it should run code you chose, not whatever lands on `main` later. It refuses to commit private repos' data into a public repository (input `allow-public: true` overrides).
|
|
30
|
+
|
|
31
|
+
`"@me"` means you **and every organization you belong to**, including client or employer organizations whose repo names and traffic may not be yours to copy. List owners explicitly, or use `exclude = ["client-org/*"]`, when that matters.
|
|
32
|
+
|
|
33
|
+
2. Create a token with push access to the repos you want: a [classic token with the `repo` and `read:org` scopes](https://github.com/settings/tokens/new?scopes=repo,read:org&description=ghtraffic) covers every owner you can push to (a fine-grained token is limited to one owner). Save it as the data repo's `GHTRAFFIC_TOKEN` secret:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
gh secret set GHTRAFFIC_TOKEN
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
3. Run it once by hand (`gh workflow run ghtraffic.yml`), and it will run on its own on Mondays and Thursdays from then on. The first run is the cold start: it takes the 14 days GitHub still has, plus each repo's star history. Every later run merges into what is there; re-running is always safe.
|
|
40
|
+
|
|
41
|
+
## Look at it
|
|
42
|
+
|
|
43
|
+
Open the dashboard at https://thorwhalen.github.io/ghtraffic/ and point it at your data repo, or use `#repo=<owner>/<data-repo>` in the URL. Paste a [fine-grained token](https://github.com/settings/personal-access-tokens/new) that can only **read the contents of that one repo**. Your browser fetches the data straight from the GitHub API with it. The page itself is public, holds no data, talks to no host but GitHub, and refuses to run inside a frame. The token stays in memory unless you tick "remember"; a remembered token is readable by any page on the same `<user>.github.io` origin, which is one more reason to give it read access to the data repo only. `?data=<url>` reads a public data directory from `raw.githubusercontent.com` instead.
|
|
44
|
+
|
|
45
|
+
It shows totals and growth over any time range, by owner and repo, views or clones, totals or uniques, a sortable leaderboard with sparklines, top referrers and pages, and stars and forks over time.
|
|
46
|
+
|
|
47
|
+
## Collect from your own computer
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
ghtraffic collect --owner @me # into ~/.local/share/ghtraffic/data
|
|
51
|
+
ghtraffic collect --data-dir ./my-traffic-data # into a checkout of your data repo
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The token comes from `GHTRAFFIC_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN`, or from the GitHub CLI (`gh auth login`).
|
|
55
|
+
|
|
56
|
+
From Python:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from ghtraffic import collect, load_settings, Files
|
|
60
|
+
|
|
61
|
+
report = collect(load_settings(owners=["@me"], exclude=["my-org/sandbox-*"]), store=Files("~/traffic"))
|
|
62
|
+
print(report.summary())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`store` is any `MutableMapping[str, str]` (relative path to text): a folder by default, or any [dol](https://github.com/i2mint/dol) store (S3, a database, ...). `rest=` and `graphql=` replace the HTTP transport.
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
`ghtraffic.toml`, at the data directory's root. Every key is optional.
|
|
70
|
+
|
|
71
|
+
| key | default | meaning |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `owners` | `["@me"]` | user and org logins; `"@me"` is you and every org you belong to |
|
|
74
|
+
| `exclude` | `[]` | `owner/repo` glob patterns to skip (`"my-org/*"` skips an owner) |
|
|
75
|
+
| `include_forks` | `true` | collect forks too |
|
|
76
|
+
| `include_archived` | `true` | collect archived repos too |
|
|
77
|
+
| `snapshot_every_days` | `14` | days between snapshots of referrers, paths and 14-day totals |
|
|
78
|
+
| `backfill_stars` | `true` | rebuild a repo's star history the first time it is seen |
|
|
79
|
+
| `max_workers` | `8` | concurrent requests |
|
|
80
|
+
|
|
81
|
+
Unknown keys are refused (a typo never silently falls back to a default). Every key is also a `ghtraffic collect` flag (`--no-include-forks`, `--snapshot-every-days 7`, ...).
|
|
82
|
+
|
|
83
|
+
Repos you cannot push to have no readable traffic: they are counted and listed in `last_run.json`, never an error. An organization policy refusing the token (SAML SSO, a ban on classic tokens) is an error, listed with the repo, so a whole org never silently reads as zero traffic. The Action log carries counts only; names and messages go to `last_run.json`.
|
|
84
|
+
|
|
85
|
+
## Data layout
|
|
86
|
+
|
|
87
|
+
Plain CSV, one table per kind, split by year, sorted by date so each run adds lines at the end of the current year's file. `manifest.json` lists every file and its columns.
|
|
88
|
+
|
|
89
|
+
| file | rows |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `views/YYYY.csv`, `clones/YYYY.csv` | `date,repo,count,uniques`: one per repo and day with any traffic (no row means zero) |
|
|
92
|
+
| `windows/YYYY.csv` | `fetched,repo,kind,count,uniques`: 14-day totals at each snapshot, the only true 14-day unique counts |
|
|
93
|
+
| `referrers/YYYY.csv`, `paths/YYYY.csv` | the top 10 over the 14 days before `fetched`; snapshots are 14 days apart, so they never overlap |
|
|
94
|
+
| `stars.csv` | `date,repo,stars,forks,watchers`: a row only when something changed |
|
|
95
|
+
| `repos.csv` | current metadata of every repo seen |
|
|
96
|
+
| `backfills.csv` | when each repo's star history was rebuilt (a failed backfill is retried next run) |
|
|
97
|
+
| `runs.csv`, `last_run.json` | one row per run; the last run's details (repos without access, errors) |
|
|
98
|
+
|
|
99
|
+
A day seen by several runs keeps the largest value of each field, so overlapping windows, re-runs and partial days all merge to the same result. Why this layout, and the alternatives: [docs/adr/0001-storage.md](docs/adr/0001-storage.md).
|
|
100
|
+
|
|
101
|
+
## Cost
|
|
102
|
+
|
|
103
|
+
One job per run, about 1 minute (a few minutes for the cold start with star history). At two runs a week that is about 10 to 20 Actions minutes a month on a private repository, and two missed runs in a row still lose nothing. Data grows by a few MB a year for a few hundred repos.
|
|
104
|
+
|
|
105
|
+
## Limits
|
|
106
|
+
|
|
107
|
+
- GitHub keeps traffic for 14 days. Nothing earlier can be recovered, except star history.
|
|
108
|
+
- Star history counts current stargazers by the date they starred; unstars are invisible.
|
|
109
|
+
- Referrers and paths are GitHub's top 10 per repo only. Snapshots are 14 days apart so they can be added up; a missed snapshot run loses the referrer and path data of the days it slipped by (daily views and clones are unaffected).
|
|
110
|
+
- One token for every owner means a classic token (fine-grained tokens are per owner).
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
name: ghtraffic
|
|
2
|
+
description: Collect GitHub traffic (views, clones, referrers, paths, stars) across your repos and orgs, and commit the accumulated history.
|
|
3
|
+
branding:
|
|
4
|
+
icon: bar-chart-2
|
|
5
|
+
color: blue
|
|
6
|
+
inputs:
|
|
7
|
+
token:
|
|
8
|
+
description: A token with push access to the repos to collect (classic, scopes `repo` + `read:org`, for several owners).
|
|
9
|
+
required: true
|
|
10
|
+
data-dir:
|
|
11
|
+
description: Where the history lives in the checked-out repository.
|
|
12
|
+
default: "."
|
|
13
|
+
config:
|
|
14
|
+
description: Path to ghtraffic.toml (default <data-dir>/ghtraffic.toml).
|
|
15
|
+
default: ""
|
|
16
|
+
commit:
|
|
17
|
+
description: Commit and push the updated history.
|
|
18
|
+
default: "true"
|
|
19
|
+
allow-public:
|
|
20
|
+
description: Allow committing private repos' data into a PUBLIC data repository (refused by default).
|
|
21
|
+
default: "false"
|
|
22
|
+
fail-on-errors:
|
|
23
|
+
description: Fail the job when some repo or owner errored (never for missing access).
|
|
24
|
+
default: "false"
|
|
25
|
+
runs:
|
|
26
|
+
using: composite
|
|
27
|
+
steps:
|
|
28
|
+
- name: Collect and commit
|
|
29
|
+
shell: bash
|
|
30
|
+
env:
|
|
31
|
+
GHTRAFFIC_TOKEN: ${{ inputs.token }}
|
|
32
|
+
PYTHONPATH: ${{ github.action_path }}
|
|
33
|
+
DATA_DIR: ${{ inputs.data-dir }}
|
|
34
|
+
CONFIG: ${{ inputs.config }}
|
|
35
|
+
COMMIT: ${{ inputs.commit }}
|
|
36
|
+
ALLOW_PUBLIC: ${{ inputs.allow-public }}
|
|
37
|
+
FAIL_ON_ERRORS: ${{ inputs.fail-on-errors }}
|
|
38
|
+
run: |
|
|
39
|
+
set -euo pipefail
|
|
40
|
+
# ghtraffic needs only the standard library, but Python 3.11+ (tomllib).
|
|
41
|
+
python3 -c 'import sys; sys.exit(sys.version_info < (3, 11))' || {
|
|
42
|
+
echo "::error::ghtraffic needs Python 3.11+; add actions/setup-python before this step."; exit 1; }
|
|
43
|
+
|
|
44
|
+
args=(collect --data-dir "$DATA_DIR")
|
|
45
|
+
[ -n "$CONFIG" ] && args+=(--config "$CONFIG")
|
|
46
|
+
[ "$FAIL_ON_ERRORS" = "true" ] && args+=(--fail-on-errors)
|
|
47
|
+
status=0
|
|
48
|
+
python3 -m ghtraffic "${args[@]}" || status=$?
|
|
49
|
+
[ "$COMMIT" = "true" ] || exit $status
|
|
50
|
+
|
|
51
|
+
# Never put private repos' data into a public repository.
|
|
52
|
+
if [ "$ALLOW_PUBLIC" != "true" ]; then
|
|
53
|
+
python3 -m ghtraffic guard --data-dir "$DATA_DIR" --repo "$GITHUB_REPOSITORY"
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
git config user.name "github-actions[bot]"
|
|
57
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
58
|
+
commit() {
|
|
59
|
+
git add -A "$DATA_DIR"
|
|
60
|
+
git diff --cached --quiet || git commit -q -m "ghtraffic: collect $(date -u +%F)"
|
|
61
|
+
}
|
|
62
|
+
commit
|
|
63
|
+
# On a push race, start from the remote and collect again: merges are
|
|
64
|
+
# idempotent, so re-collecting loses nothing and never conflicts.
|
|
65
|
+
for attempt in 1 2 3; do
|
|
66
|
+
git push -q && exit $status
|
|
67
|
+
git fetch -q origin "$GITHUB_REF_NAME"
|
|
68
|
+
git reset -q --hard "origin/$GITHUB_REF_NAME"
|
|
69
|
+
python3 -m ghtraffic "${args[@]}" || status=$?
|
|
70
|
+
commit
|
|
71
|
+
done
|
|
72
|
+
echo "::error::could not push after 3 attempts"; exit 1
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Accumulate GitHub traffic and insights across all your repos and orgs.
|
|
2
|
+
|
|
3
|
+
GitHub keeps repository traffic (views, clones, referrers, popular paths) for
|
|
4
|
+
14 days only. ``ghtraffic`` collects it on a schedule and merges it into a
|
|
5
|
+
growing, git-friendly history of CSV files, together with star, fork and
|
|
6
|
+
watcher counts, and ships a static dashboard to explore it.
|
|
7
|
+
|
|
8
|
+
>>> from ghtraffic import collect, load_settings, Files # doctest: +SKIP
|
|
9
|
+
>>> collect(load_settings(owners=["@me"]), store=Files("~/traffic")) # doctest: +SKIP
|
|
10
|
+
|
|
11
|
+
Main pieces:
|
|
12
|
+
|
|
13
|
+
- :func:`collect`: one idempotent run (cold start and update are the same call).
|
|
14
|
+
- :func:`load_settings` / :class:`Settings`: which owners and repos.
|
|
15
|
+
- :data:`TABLES`: the on-disk schema; :class:`Files`: the default store.
|
|
16
|
+
- :func:`init_data_repo`: write the workflow + config into a data repository.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from ghtraffic.collect import RunReport, collect, write_manifest
|
|
20
|
+
from ghtraffic.config import Settings, load_settings
|
|
21
|
+
from ghtraffic.github import GitHubError, make_graphql, make_rest, resolve_token
|
|
22
|
+
from ghtraffic.scaffold import init_data_repo
|
|
23
|
+
from ghtraffic.tables import TABLES, Files, TableSpec, merge_rows, read_table
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Files",
|
|
27
|
+
"GitHubError",
|
|
28
|
+
"RunReport",
|
|
29
|
+
"Settings",
|
|
30
|
+
"TABLES",
|
|
31
|
+
"TableSpec",
|
|
32
|
+
"collect",
|
|
33
|
+
"init_data_repo",
|
|
34
|
+
"load_settings",
|
|
35
|
+
"make_graphql",
|
|
36
|
+
"make_rest",
|
|
37
|
+
"merge_rows",
|
|
38
|
+
"read_table",
|
|
39
|
+
"resolve_token",
|
|
40
|
+
"write_manifest",
|
|
41
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Command line: ``ghtraffic collect | init | guard | demo | manifest``.
|
|
2
|
+
|
|
3
|
+
``python -m ghtraffic collect --data-dir .`` is what the GitHub Action runs.
|
|
4
|
+
Without ``--data-dir``, data goes to ``~/.local/share/ghtraffic/data`` (never
|
|
5
|
+
the current directory, so a local run cannot end up committed by accident).
|
|
6
|
+
Every :class:`~ghtraffic.config.Settings` field is also a ``collect`` flag.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
import typing
|
|
14
|
+
from dataclasses import fields
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ghtraffic.collect import collect, has_private_data, write_manifest
|
|
18
|
+
from ghtraffic.config import (
|
|
19
|
+
DFLT_CONFIG_FILENAME,
|
|
20
|
+
DFLT_DATA_DIR,
|
|
21
|
+
Settings,
|
|
22
|
+
load_settings,
|
|
23
|
+
)
|
|
24
|
+
from ghtraffic.scaffold import DFLT_CRON, default_action_ref, init_data_repo
|
|
25
|
+
from ghtraffic.tables import Files
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _add_settings_flags(parser: argparse.ArgumentParser) -> None:
|
|
29
|
+
"""One flag per Settings field, so a new setting needs no CLI edit."""
|
|
30
|
+
hints = typing.get_type_hints(Settings)
|
|
31
|
+
for f in fields(Settings):
|
|
32
|
+
flag = "--" + f.name.replace("_", "-")
|
|
33
|
+
kind = hints[f.name]
|
|
34
|
+
if kind is bool:
|
|
35
|
+
parser.add_argument(flag, action=argparse.BooleanOptionalAction)
|
|
36
|
+
elif typing.get_origin(kind) is tuple:
|
|
37
|
+
parser.add_argument(flag, action="append", help="repeatable")
|
|
38
|
+
else:
|
|
39
|
+
parser.add_argument(flag, type=kind)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parser() -> argparse.ArgumentParser:
|
|
43
|
+
p = argparse.ArgumentParser(prog="ghtraffic", description=__doc__.splitlines()[0])
|
|
44
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
45
|
+
|
|
46
|
+
c = sub.add_parser("collect", help="fetch traffic and merge it into the data dir")
|
|
47
|
+
c.add_argument("--data-dir", default=str(DFLT_DATA_DIR))
|
|
48
|
+
c.add_argument("--config", help=f"default: <data-dir>/{DFLT_CONFIG_FILENAME}")
|
|
49
|
+
c.add_argument(
|
|
50
|
+
"--fail-on-errors",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="exit 1 when some repo or owner failed (never for missing access)",
|
|
53
|
+
)
|
|
54
|
+
_add_settings_flags(c)
|
|
55
|
+
|
|
56
|
+
i = sub.add_parser("init", help="write the workflow and config into a data repo")
|
|
57
|
+
i.add_argument("dir", nargs="?", default=".")
|
|
58
|
+
i.add_argument("--owner", action="append", dest="owners")
|
|
59
|
+
i.add_argument("--cron", default=DFLT_CRON)
|
|
60
|
+
i.add_argument("--action-ref", help="default: this ghtraffic's release tag")
|
|
61
|
+
i.add_argument("--overwrite", action="store_true")
|
|
62
|
+
|
|
63
|
+
g = sub.add_parser(
|
|
64
|
+
"guard", help="exit 1 if private data would be committed to a public repo"
|
|
65
|
+
)
|
|
66
|
+
g.add_argument("--data-dir", default=".")
|
|
67
|
+
g.add_argument("--repo", required=True, help="owner/name of the data repo")
|
|
68
|
+
|
|
69
|
+
d = sub.add_parser(
|
|
70
|
+
"demo", help="write a synthetic data dir (for the demo dashboard)"
|
|
71
|
+
)
|
|
72
|
+
d.add_argument("--data-dir", required=True)
|
|
73
|
+
d.add_argument("--days", type=int, default=420)
|
|
74
|
+
|
|
75
|
+
m = sub.add_parser("manifest", help="rewrite manifest.json from the files present")
|
|
76
|
+
m.add_argument("--data-dir", default=str(DFLT_DATA_DIR))
|
|
77
|
+
return p
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _guard(data_dir: str, repo: str) -> int:
|
|
81
|
+
from ghtraffic.github import make_rest
|
|
82
|
+
|
|
83
|
+
if not make_rest()(f"/repos/{repo}").get("private", False) and has_private_data(
|
|
84
|
+
Files(data_dir)
|
|
85
|
+
):
|
|
86
|
+
print(
|
|
87
|
+
f"ghtraffic: REFUSING to commit: {repo} is public and the history "
|
|
88
|
+
"holds private repos. Make the data repo private, or exclude private "
|
|
89
|
+
"repos, or set the action input allow-public: true.",
|
|
90
|
+
file=sys.stderr,
|
|
91
|
+
)
|
|
92
|
+
return 1
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv=None) -> int:
|
|
97
|
+
"""Entry point of the ``ghtraffic`` command."""
|
|
98
|
+
args = _parser().parse_args(argv)
|
|
99
|
+
if args.cmd == "collect":
|
|
100
|
+
data_dir = Path(args.data_dir)
|
|
101
|
+
overrides = {f.name: getattr(args, f.name) for f in fields(Settings)}
|
|
102
|
+
settings = load_settings(
|
|
103
|
+
args.config or data_dir / DFLT_CONFIG_FILENAME, **overrides
|
|
104
|
+
)
|
|
105
|
+
report = collect(settings, store=Files(data_dir), log=print)
|
|
106
|
+
failed = report.errors or report.owner_errors
|
|
107
|
+
return 1 if (args.fail_on_errors and failed) else 0
|
|
108
|
+
if args.cmd == "init":
|
|
109
|
+
written = init_data_repo(
|
|
110
|
+
args.dir,
|
|
111
|
+
owners=args.owners or ("@me",),
|
|
112
|
+
cron=args.cron,
|
|
113
|
+
action_ref=args.action_ref or default_action_ref(),
|
|
114
|
+
overwrite=args.overwrite,
|
|
115
|
+
)
|
|
116
|
+
print("\n".join(written) or "nothing written (files exist; --overwrite)")
|
|
117
|
+
return 0
|
|
118
|
+
if args.cmd == "guard":
|
|
119
|
+
return _guard(args.data_dir, args.repo)
|
|
120
|
+
if args.cmd == "demo":
|
|
121
|
+
from ghtraffic.demo import make_demo_data
|
|
122
|
+
|
|
123
|
+
make_demo_data(Files(args.data_dir), n_days=args.days)
|
|
124
|
+
return 0
|
|
125
|
+
if args.cmd == "manifest":
|
|
126
|
+
from datetime import date
|
|
127
|
+
|
|
128
|
+
write_manifest(Files(args.data_dir), updated=date.today().isoformat())
|
|
129
|
+
return 0
|
|
130
|
+
return 2 # pragma: no cover
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
sys.exit(main())
|