contribs.sh 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +228 -2
- package/bin/contribs-sh.mjs +5 -0
- package/config.json +5 -0
- package/package.json +56 -4
- package/src/cli/index.ts +237 -0
- package/src/cli/workflow.ts +85 -0
- package/src/config.ts +20 -0
- package/src/data/lost.ts +88 -0
- package/src/data/stats.ts +108 -0
- package/src/data/types.ts +49 -0
- package/src/fetch/adapters/forgejo.ts +42 -0
- package/src/fetch/adapters/github.ts +110 -0
- package/src/fetch/adapters/local.ts +96 -0
- package/src/fetch/adapters/types.ts +37 -0
- package/src/fetch/calendar.ts +86 -0
- package/src/fetch/fetch.ts +9 -0
- package/src/fetch/merge.ts +39 -0
- package/src/fetch/run.ts +54 -0
- package/src/fetch/sources.ts +128 -0
- package/src/render/svg.ts +375 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chris Hartman
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,229 @@
|
|
|
1
|
-
#
|
|
1
|
+
# contribs.sh
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A browser dashboard for your contribution history, merged across **multiple sources** —
|
|
4
|
+
github.com accounts, GitHub Enterprise, and Forgejo/Gitea instances (e.g. Codeberg) — rendered
|
|
5
|
+
with [`@json-render/react`](https://json-render.dev). It distinguishes genuine zero-activity days
|
|
6
|
+
from days where no source has data (lost / pre-migration history), which render as a distinct
|
|
7
|
+
hatched "no data" cell, and lets you annotate periods your work lived on another platform.
|
|
8
|
+
|
|
9
|
+
## Make it your own
|
|
10
|
+
|
|
11
|
+
Everything personal (which accounts to fetch, your lost/incomplete periods, tokens, generated data)
|
|
12
|
+
is gitignored — the repo ships `.example` templates, so a fresh clone builds with neutral defaults.
|
|
13
|
+
To run your own version:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
git clone <your-fork-url> && cd contribs.sh
|
|
17
|
+
npm install
|
|
18
|
+
|
|
19
|
+
cp sources.example.json sources.json # edit: your accounts (see "Configuring sources")
|
|
20
|
+
cp lost-ranges.example.json lost-ranges.json # optional: periods on other platforms / lost history
|
|
21
|
+
|
|
22
|
+
npm run fetch # pulls your data → public/data/contributions.json
|
|
23
|
+
npm run dev # open the printed localhost URL
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Prerequisites:** Node 18+ and either the [`gh` CLI](https://cli.github.com/) authenticated with your
|
|
27
|
+
github.com account(s), or Personal Access Tokens for any source not in `gh` (see Tokens below).
|
|
28
|
+
|
|
29
|
+
## Configuring sources
|
|
30
|
+
|
|
31
|
+
`sources.json` (gitignored) lists the accounts to merge. Each entry:
|
|
32
|
+
|
|
33
|
+
| field | meaning |
|
|
34
|
+
| ---------- | ----------------------------------------------------------------------- |
|
|
35
|
+
| `id` | unique slug (used for the per-source file name) |
|
|
36
|
+
| `label` | display name in the UI |
|
|
37
|
+
| `type` | `github` (github.com or Enterprise) or `forgejo` (Codeberg/Gitea) |
|
|
38
|
+
| `host` | `github.com`, your Enterprise host, or `codeberg.org` |
|
|
39
|
+
| `username` | the account's login |
|
|
40
|
+
| `tokenEnv` | name of the env var holding the token — **omit** for a github.com account managed by the `gh` CLI |
|
|
41
|
+
| `enabled` | set `false` to keep an entry without fetching it |
|
|
42
|
+
|
|
43
|
+
**Tokens.** github.com accounts in your `gh` keyring need no token — the fetch script pulls one via
|
|
44
|
+
`gh auth token --user <username>`. Every other source reads its token from an env var named by
|
|
45
|
+
`tokenEnv`, loaded from a gitignored `.env` (see `.env.example`):
|
|
46
|
+
|
|
47
|
+
- **GitHub Enterprise** — a PAT with `repo` + `read:org` on the Enterprise host.
|
|
48
|
+
- **Codeberg / Forgejo** — an access token with `read:user`.
|
|
49
|
+
- **A second github.com account** not in `gh` — a PAT with `repo` + `read:org`.
|
|
50
|
+
|
|
51
|
+
Sources whose token can't be resolved are skipped with a warning, so the dashboard always builds
|
|
52
|
+
with whatever credentials are available.
|
|
53
|
+
|
|
54
|
+
## How it works
|
|
55
|
+
|
|
56
|
+
- **`src/fetch/`** — `npm run fetch` reads `sources.json`, runs one adapter per source
|
|
57
|
+
(`adapters/github.ts` = GraphQL `contributionsCollection`; `adapters/forgejo.ts` = the
|
|
58
|
+
`/api/v1/users/{user}/heatmap` REST endpoint), then `merge.ts` sums per-day counts and unions
|
|
59
|
+
coverage windows into `public/data/contributions.json` (per-source files land in
|
|
60
|
+
`public/data/sources/`).
|
|
61
|
+
- **`src/`** — the React app loads that JSON, exposes it via `contributionsContext`, and renders a
|
|
62
|
+
layout described by `dashboard.json` against the json-render catalog/registry in `catalog.tsx`.
|
|
63
|
+
Components (`YearHeatmap`, `StatsPanel`, `YearSelector`, `SourceLegend`) read the data from context.
|
|
64
|
+
|
|
65
|
+
A day is **known** when at least one source's coverage window includes it; unknown days carry count 0
|
|
66
|
+
and are excluded from streak/average stats so lost history never distorts the numbers.
|
|
67
|
+
Coverage ends no later than the UTC fetch date. The full current-year grid remains visible:
|
|
68
|
+
dates after today use a subdued outline and a "Future date" tooltip, contribute to no stats,
|
|
69
|
+
and cannot receive lost-history annotations. Elapsed dates after the last fetch are "No data"
|
|
70
|
+
until a refresh covers them. Future-date styling advances with the UTC date while the app is open.
|
|
71
|
+
|
|
72
|
+
## Marking history lost to a migration
|
|
73
|
+
|
|
74
|
+
Some real history (e.g. a Bitbucket → GitHub migration) no longer exists on any source. You can mark
|
|
75
|
+
those days so they render in a distinct **gray "lost"** style with a hover note, instead of looking
|
|
76
|
+
like zero-activity days. Edit the committed **`lost-ranges.json`**:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"enabled": true,
|
|
81
|
+
"label": "Contribs lost due to Bitbucket migration",
|
|
82
|
+
"ranges": [
|
|
83
|
+
{
|
|
84
|
+
"id": "bitbucket-era",
|
|
85
|
+
"from": "2016-01-01",
|
|
86
|
+
"to": "2020-12-31",
|
|
87
|
+
"days": "weekdays",
|
|
88
|
+
"weekdays": [1, 2, 3, 4, 5],
|
|
89
|
+
"note": "Contribs lost due to Bitbucket migration",
|
|
90
|
+
"enabled": true
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- `from` / `to` — inclusive ISO date range to mark.
|
|
97
|
+
- `days` — `"weekdays"` (Mon–Fri), `"all"` (include weekends), or `"selective"` (use the `weekdays`
|
|
98
|
+
list; `0`=Sun … `6`=Sat).
|
|
99
|
+
- `note` — optional hover text for this range; falls back to the top-level `label`.
|
|
100
|
+
- `label` — optional short name shown in the legend; falls back to `id`.
|
|
101
|
+
- `color` — optional cell color for this range's lost days (any CSS color); falls back to a neutral
|
|
102
|
+
gray. Use distinct colors to tell ranges apart at a glance.
|
|
103
|
+
- Only **0-contribution** days in the range are colored — days that already have contributions stay
|
|
104
|
+
green. Each active range gets its own swatch in the legend.
|
|
105
|
+
|
|
106
|
+
**Toggle:** the legend has a **"Lost / incomplete"** checkbox that shows/hides the overlay live (your
|
|
107
|
+
choice is remembered in the browser). While it's on, each lost day counts as **1** toward **Total
|
|
108
|
+
contributions**, **Active days**, and **Avg / active day** — but **never** toward **Longest** or
|
|
109
|
+
**Current streak**, which always reflect real GitHub activity only. Turning it off reverts both the
|
|
110
|
+
heatmap and the stats to GitHub-only numbers.
|
|
111
|
+
|
|
112
|
+
**To remove a range entirely:** set its `"enabled": false` (or delete the entry), or flip the
|
|
113
|
+
top-level `"enabled": false`. Config changes apply on reload — no re-fetch.
|
|
114
|
+
|
|
115
|
+
`lost-ranges.example.json` ships a single **disabled** example range; copy it to `lost-ranges.json`,
|
|
116
|
+
set your real window(s), and `"enabled": true`. If `lost-ranges.json` is absent the app falls back to
|
|
117
|
+
the example, so the build never breaks.
|
|
118
|
+
|
|
119
|
+
## Display settings
|
|
120
|
+
|
|
121
|
+
`config.json` (committed — not personal) holds display preferences:
|
|
122
|
+
|
|
123
|
+
| field | meaning |
|
|
124
|
+
| ------------- | ---------------------------------------------------------------------------- |
|
|
125
|
+
| `title` | Site name — shown as the page heading and the browser tab title. |
|
|
126
|
+
| `recentYears` | In the "All years" view, how many recent years to show before the **"Show earlier years"** toggle (default `10`). Older years fade in when you expand them. |
|
|
127
|
+
| `startYear` | Earliest year to display (currently `2007`). Takes effect on the next fetch; years without source coverage show "No data" and do not change contribution totals. Earlier source history is still retained. |
|
|
128
|
+
|
|
129
|
+
The year filter at the top is a dropdown — jump straight to any year (including older, collapsed ones)
|
|
130
|
+
or back to the all-years overview.
|
|
131
|
+
|
|
132
|
+
The sticky legend's service buttons filter the heatmaps, tooltips, year totals, and stats.
|
|
133
|
+
GitHub controls fetched activity from every GitHub source plus any lost range labelled `GitHub`;
|
|
134
|
+
each other lost range (in `lost-ranges.json` order) gets its own button that toggles its
|
|
135
|
+
annotations. The gray Lost / Incomplete checkbox controls all annotations without hiding real
|
|
136
|
+
activity. Selections persist in the browser; No Data and Less–More are informational.
|
|
137
|
+
|
|
138
|
+
## Scripts
|
|
139
|
+
|
|
140
|
+
| script | does |
|
|
141
|
+
| --------------- | ----------------------------------------------- |
|
|
142
|
+
| `npm run fetch` | fetch + merge all sources → `public/data/` |
|
|
143
|
+
| `npm run dev` | Vite dev server |
|
|
144
|
+
| `npm run build` | type-check (app + node) and production build |
|
|
145
|
+
| `npm run preview` | preview the production build |
|
|
146
|
+
| `npm run banner -- ...` | render banner / card / Wrapped / poster images |
|
|
147
|
+
| `npm run cli -- ...` | the `contribs-sh` CLI (`init`, `fetch`, `banner`, `scan`) |
|
|
148
|
+
|
|
149
|
+
## Recovering history from local repos
|
|
150
|
+
|
|
151
|
+
Commits that never reached a hosted forge (an old employer's GitLab, an SVN repo you converted with
|
|
152
|
+
`git svn`, a Bitbucket repo you still have cloned) can be counted straight from disk:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npm run cli -- scan ~/code/old-work --email you@old-employer.example
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`scan` lists commits per year and prints a `"type": "local"` source to paste into `sources.json`.
|
|
159
|
+
Commits are de-duplicated by hash across repos. Anything you also pushed to GitHub is already in your
|
|
160
|
+
GitHub graph, so set `from`/`to` to the period GitHub is missing. Recovered days are real data, so
|
|
161
|
+
they replace the hand-drawn `lost-ranges.json` annotation for that period.
|
|
162
|
+
|
|
163
|
+
## Exporting images
|
|
164
|
+
|
|
165
|
+
The dashboard has an **Export image** panel. It renders the same SVG the CLI uses and downloads it
|
|
166
|
+
as PNG or SVG:
|
|
167
|
+
|
|
168
|
+
| preset | size | layout |
|
|
169
|
+
| ---------- | ------------ | ---------------------------------------------- |
|
|
170
|
+
| `x` | 1500×500 | banner; content kept right of the profile photo |
|
|
171
|
+
| `linkedin` | 1584×396 | banner |
|
|
172
|
+
| `og` | 1200×630 | card |
|
|
173
|
+
| `github` | 1280×640 | card (repo social preview) |
|
|
174
|
+
| `wrapped` | 1080×1350 | year-in-review story with stats |
|
|
175
|
+
| `poster` | 5400×7200 | every year, 18×24 in at 300 dpi |
|
|
176
|
+
|
|
177
|
+
From the command line (renders with `resvg`, no browser):
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
npm run banner -- --preset x # contribs-x.png
|
|
181
|
+
npm run banner -- --preset wrapped --range 2026 --title "2026 in code"
|
|
182
|
+
npm run banner -- --preset poster --theme light --out poster.png
|
|
183
|
+
npm run banner -- --preset all --out banners/ # every preset, PNG + SVG
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Options: `--range rolling|all|YEAR`, `--theme dark|light`, `--title`, `--scale`, `--no-stats`,
|
|
187
|
+
`--no-mark`, `--transparent`. Lost ranges from `lost-ranges.json` are drawn in their colors.
|
|
188
|
+
|
|
189
|
+
## CLI
|
|
190
|
+
|
|
191
|
+
Install with `npx contribs.sh <command>` (or `npm i -g contribs.sh`; the binary is `contribs.sh`, with `contribs-sh` as an alias). Inside a clone, `npm run cli --` runs the same thing:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
contribs.sh init [--ci] # sources.json for your gh login; --ci adds the Pages workflow
|
|
195
|
+
contribs.sh fetch # same as npm run fetch
|
|
196
|
+
contribs.sh banner [...] # see above
|
|
197
|
+
contribs.sh scan DIR... # see "Recovering history from local repos"
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Self-hosting with GitHub Actions
|
|
201
|
+
|
|
202
|
+
`.github/workflows/contribs.yml` refreshes the data daily, builds the dashboard, renders every banner
|
|
203
|
+
preset, and publishes to GitHub Pages. It does nothing until configured, so forks are safe:
|
|
204
|
+
|
|
205
|
+
1. Repo → Settings → Secrets → Actions: add `CONTRIBS_SOURCES` (your `sources.json`, with a
|
|
206
|
+
`tokenEnv` on every source), one secret per `tokenEnv` (e.g. `GH_MAIN_TOKEN`, a classic PAT with
|
|
207
|
+
`read:user`, `repo`, `read:org`), and optionally `CONTRIBS_LOST_RANGES`.
|
|
208
|
+
2. Settings → Pages → Source: **GitHub Actions**.
|
|
209
|
+
3. Run the workflow once from the Actions tab.
|
|
210
|
+
|
|
211
|
+
Your banners are then at `https://<you>.github.io/<repo>/banners/x.png` (and `.svg`), ready to embed in
|
|
212
|
+
a README or site. Add extra token secrets to the `env:` block of the "Fetch, build, render banners" step.
|
|
213
|
+
|
|
214
|
+
## Deploying
|
|
215
|
+
|
|
216
|
+
The app is a static site — the only runtime input is `public/data/contributions.json`, produced by
|
|
217
|
+
`npm run fetch`. To publish:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
npm run fetch # refresh your data (needs gh/tokens; do this wherever you build)
|
|
221
|
+
npm run build # outputs dist/
|
|
222
|
+
# deploy dist/ to GitHub Pages, Vercel, Netlify, Cloudflare Pages, etc.
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Because `sources.json`, `lost-ranges.json`, and `public/data/` are gitignored, the simplest path is to
|
|
226
|
+
**fetch + build + deploy from your own machine**. To deploy from CI instead, provide those files to the
|
|
227
|
+
CI job (e.g. commit a token-free `lost-ranges.json`, recreate `sources.json` from a secret, and run
|
|
228
|
+
`gh auth login` / set token env vars) before `npm run fetch`. Re-run `fetch` on a schedule to keep the
|
|
229
|
+
graph current.
|
package/config.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "contribs.sh",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "tsc -p tsconfig.json && tsc -p tsconfig.node.json && vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"fetch": "tsx src/fetch/fetch.ts",
|
|
10
|
+
"cli": "tsx src/cli/index.ts",
|
|
11
|
+
"banner": "tsx src/cli/index.ts banner"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
15
|
+
"tsx": "^4.19.2"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@json-render/core": "^0.19.0",
|
|
19
|
+
"@json-render/react": "^0.19.0",
|
|
20
|
+
"@types/node": "^22.10.1",
|
|
21
|
+
"@types/react": "^19.0.1",
|
|
22
|
+
"@types/react-dom": "^19.0.1",
|
|
23
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
24
|
+
"react": "^19.2.3",
|
|
25
|
+
"react-dom": "^19.2.3",
|
|
26
|
+
"typescript": "^5.6.3",
|
|
27
|
+
"vite": "^6.0.3",
|
|
28
|
+
"zod": "^4.4.3"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"contribs.sh": "bin/contribs-sh.mjs",
|
|
32
|
+
"contribs-sh": "bin/contribs-sh.mjs"
|
|
33
|
+
},
|
|
34
|
+
"description": "Every contribution, every forge, one heatmap — merge GitHub, GitHub Enterprise, Forgejo and local git history, export banners for X, LinkedIn and more.",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"homepage": "https://contribs.sh",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"bin",
|
|
42
|
+
"src/cli",
|
|
43
|
+
"src/render",
|
|
44
|
+
"src/data",
|
|
45
|
+
"src/fetch",
|
|
46
|
+
"src/config.ts",
|
|
47
|
+
"config.json"
|
|
48
|
+
],
|
|
49
|
+
"keywords": [
|
|
50
|
+
"github",
|
|
51
|
+
"contributions",
|
|
52
|
+
"heatmap",
|
|
53
|
+
"banner",
|
|
54
|
+
"cli",
|
|
55
|
+
"forgejo",
|
|
56
|
+
"gitea"
|
|
57
|
+
]
|
|
58
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import type { MergedData } from '../data/types';
|
|
6
|
+
import { compileLost, EMPTY_LOST_CONFIG, type LostConfig } from '../data/lost';
|
|
7
|
+
import { PRESETS, PRESET_IDS, renderSvg, type PresetId, type Range, type ThemeId } from '../render/svg';
|
|
8
|
+
import { findRepos, localAdapter } from '../fetch/adapters/local';
|
|
9
|
+
import { runFetch } from '../fetch/run';
|
|
10
|
+
import { workflowYaml } from './workflow';
|
|
11
|
+
|
|
12
|
+
const HELP = `contribs.sh — every contribution, every forge, one heatmap.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
contribs.sh init [--ci] create sources.json (and a GitHub Actions workflow with --ci)
|
|
16
|
+
contribs.sh fetch [--out DIR] fetch + merge all sources (default DIR: public/data)
|
|
17
|
+
contribs.sh banner [options] render an image from the merged data
|
|
18
|
+
contribs.sh scan DIR... [options] recover history from local git repos on disk
|
|
19
|
+
|
|
20
|
+
banner options:
|
|
21
|
+
--preset ID ${PRESET_IDS.join(' | ')} | all (default: x)
|
|
22
|
+
--range R rolling | all | YEAR (default depends on preset)
|
|
23
|
+
--theme T dark | light (default: dark)
|
|
24
|
+
--title TEXT heading for card / wrapped / poster layouts
|
|
25
|
+
--out PATH .png or .svg file; a directory when --preset all (default: contribs-<preset>.png)
|
|
26
|
+
--data PATH merged data (default: public/data/contributions.json; fetched if missing)
|
|
27
|
+
--scale N PNG pixel ratio (default: 1)
|
|
28
|
+
--no-stats hide totals and stats text
|
|
29
|
+
--no-mark hide the contribs.sh mark
|
|
30
|
+
--transparent no background fill
|
|
31
|
+
|
|
32
|
+
scan options:
|
|
33
|
+
--email ADDR author email to count (repeatable; default: git config user.email)
|
|
34
|
+
--depth N directory levels to search (default: 4)
|
|
35
|
+
--from DATE ignore commits before YYYY-MM-DD
|
|
36
|
+
--to DATE ignore commits after YYYY-MM-DD
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
const root = process.cwd();
|
|
40
|
+
|
|
41
|
+
function fail(message: string): never {
|
|
42
|
+
console.error(message);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readJson<T>(path: string): T {
|
|
47
|
+
return JSON.parse(readFileSync(path, 'utf8')) as T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function loadLost(): LostConfig {
|
|
51
|
+
const path = resolve(root, 'lost-ranges.json');
|
|
52
|
+
return existsSync(path) ? readJson<LostConfig>(path) : EMPTY_LOST_CONFIG;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function gitEmail(): string | null {
|
|
56
|
+
try {
|
|
57
|
+
return execFileSync('git', ['config', 'user.email'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ghLogin(): string | null {
|
|
64
|
+
try {
|
|
65
|
+
return execFileSync('gh', ['api', 'user', '-q', '.login'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function init(args: string[]) {
|
|
72
|
+
const { values } = parseArgs({ args, options: { ci: { type: 'boolean' } } });
|
|
73
|
+
const sourcesPath = resolve(root, 'sources.json');
|
|
74
|
+
if (existsSync(sourcesPath)) {
|
|
75
|
+
console.log('sources.json already exists — leaving it alone.');
|
|
76
|
+
} else {
|
|
77
|
+
const login = ghLogin();
|
|
78
|
+
const sources = {
|
|
79
|
+
sources: [
|
|
80
|
+
{
|
|
81
|
+
id: 'gh-main',
|
|
82
|
+
label: 'GitHub',
|
|
83
|
+
type: 'github',
|
|
84
|
+
host: 'github.com',
|
|
85
|
+
username: login ?? 'your-github-username',
|
|
86
|
+
...(values.ci ? { tokenEnv: 'GH_MAIN_TOKEN' } : {}),
|
|
87
|
+
enabled: true,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
writeFileSync(sourcesPath, `${JSON.stringify(sources, null, 2)}\n`);
|
|
92
|
+
console.log(`Wrote sources.json${login ? ` for ${login}` : ' — edit "username"'}.`);
|
|
93
|
+
}
|
|
94
|
+
if (values.ci) {
|
|
95
|
+
const wf = resolve(root, '.github/workflows/contribs.yml');
|
|
96
|
+
if (existsSync(wf)) {
|
|
97
|
+
console.log('.github/workflows/contribs.yml already exists — leaving it alone.');
|
|
98
|
+
} else {
|
|
99
|
+
mkdirSync(dirname(wf), { recursive: true });
|
|
100
|
+
writeFileSync(wf, workflowYaml());
|
|
101
|
+
console.log('Wrote .github/workflows/contribs.yml. Add a GH_MAIN_TOKEN repo secret (classic PAT: read:user, repo, read:org)');
|
|
102
|
+
console.log('and a CONTRIBS_SOURCES secret holding your sources.json, then enable GitHub Pages → "GitHub Actions".');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
console.log('Next: contribs.sh fetch && contribs.sh banner');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function fetchCmd(args: string[]) {
|
|
109
|
+
const { values } = parseArgs({ args, options: { out: { type: 'string' } } });
|
|
110
|
+
await runFetch(root, resolve(root, values.out ?? 'public/data'));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function toPng(svg: string, scale: number): Promise<Buffer> {
|
|
114
|
+
const { Resvg } = await import('@resvg/resvg-js');
|
|
115
|
+
const resvg = new Resvg(svg, {
|
|
116
|
+
fitTo: scale === 1 ? { mode: 'original' } : { mode: 'zoom', value: scale },
|
|
117
|
+
font: { loadSystemFonts: true, defaultFontFamily: 'Helvetica' },
|
|
118
|
+
});
|
|
119
|
+
return resvg.render().asPng();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function banner(args: string[]) {
|
|
123
|
+
const { values } = parseArgs({
|
|
124
|
+
args,
|
|
125
|
+
options: {
|
|
126
|
+
preset: { type: 'string', default: 'x' },
|
|
127
|
+
range: { type: 'string' },
|
|
128
|
+
theme: { type: 'string', default: 'dark' },
|
|
129
|
+
title: { type: 'string' },
|
|
130
|
+
out: { type: 'string' },
|
|
131
|
+
data: { type: 'string' },
|
|
132
|
+
scale: { type: 'string', default: '1' },
|
|
133
|
+
'no-stats': { type: 'boolean' },
|
|
134
|
+
'no-mark': { type: 'boolean' },
|
|
135
|
+
transparent: { type: 'boolean' },
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const presets: PresetId[] = values.preset === 'all' ? PRESET_IDS : [values.preset as PresetId];
|
|
140
|
+
for (const p of presets) if (!PRESETS[p]) fail(`Unknown preset "${p}". Use one of: ${PRESET_IDS.join(', ')}, all`);
|
|
141
|
+
if (values.theme !== 'dark' && values.theme !== 'light') fail('--theme must be dark or light');
|
|
142
|
+
const range: Range | undefined = values.range === undefined ? undefined
|
|
143
|
+
: values.range === 'rolling' || values.range === 'all' ? values.range
|
|
144
|
+
: /^\d{4}$/.test(values.range) ? Number(values.range) : fail('--range must be rolling, all, or a year');
|
|
145
|
+
const scale = Number(values.scale);
|
|
146
|
+
if (!(scale > 0 && scale <= 4)) fail('--scale must be between 0 and 4');
|
|
147
|
+
|
|
148
|
+
const dataPath = resolve(root, values.data ?? 'public/data/contributions.json');
|
|
149
|
+
const data: MergedData = existsSync(dataPath)
|
|
150
|
+
? readJson<MergedData>(dataPath)
|
|
151
|
+
: values.data ? fail(`No data at ${dataPath}`) : await runFetch(root, dirname(dataPath));
|
|
152
|
+
|
|
153
|
+
const lostFor = compileLost(loadLost());
|
|
154
|
+
for (const preset of presets) {
|
|
155
|
+
let out = values.out ?? `contribs-${preset}.png`;
|
|
156
|
+
if (presets.length > 1) {
|
|
157
|
+
const dir = resolve(root, values.out ?? 'banners');
|
|
158
|
+
mkdirSync(dir, { recursive: true });
|
|
159
|
+
out = join(dir, `${preset}.png`);
|
|
160
|
+
}
|
|
161
|
+
const svg = renderSvg(data, {
|
|
162
|
+
preset,
|
|
163
|
+
range,
|
|
164
|
+
theme: values.theme as ThemeId,
|
|
165
|
+
title: values.title,
|
|
166
|
+
stats: !values['no-stats'],
|
|
167
|
+
mark: !values['no-mark'],
|
|
168
|
+
transparent: values.transparent,
|
|
169
|
+
lostFor,
|
|
170
|
+
});
|
|
171
|
+
const target = resolve(root, out);
|
|
172
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
173
|
+
if (extname(target).toLowerCase() === '.svg') {
|
|
174
|
+
writeFileSync(target, svg);
|
|
175
|
+
} else {
|
|
176
|
+
writeFileSync(target, await toPng(svg, scale));
|
|
177
|
+
if (presets.length > 1) writeFileSync(target.replace(/\.png$/, '.svg'), svg);
|
|
178
|
+
}
|
|
179
|
+
console.log(`Wrote ${target} (${PRESETS[preset].label})`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function scan(args: string[]) {
|
|
184
|
+
const { values, positionals } = parseArgs({
|
|
185
|
+
args,
|
|
186
|
+
allowPositionals: true,
|
|
187
|
+
options: {
|
|
188
|
+
email: { type: 'string', multiple: true },
|
|
189
|
+
depth: { type: 'string', default: '4' },
|
|
190
|
+
from: { type: 'string' },
|
|
191
|
+
to: { type: 'string' },
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
if (positionals.length === 0) fail('Usage: contribs.sh scan DIR... [--email ADDR]');
|
|
195
|
+
const emails = values.email?.length ? values.email : [gitEmail()].filter((e): e is string => !!e);
|
|
196
|
+
if (emails.length === 0) fail('No author email: pass --email or set git config user.email');
|
|
197
|
+
const depth = Number(values.depth);
|
|
198
|
+
|
|
199
|
+
const repos = [...new Set(positionals.flatMap((p) => findRepos(p, depth)))];
|
|
200
|
+
console.log(`Found ${repos.length} repo(s) under ${positionals.join(', ')}; counting commits by ${emails.join(', ')}…`);
|
|
201
|
+
const result = await localAdapter({
|
|
202
|
+
id: 'local-scan', label: 'Local repos', type: 'local', host: '', username: '', token: '',
|
|
203
|
+
paths: positionals, emails, depth, from: values.from, to: values.to,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const byYear = new Map<string, number>();
|
|
207
|
+
for (const [date, count] of Object.entries(result.days)) byYear.set(date.slice(0, 4), (byYear.get(date.slice(0, 4)) ?? 0) + count);
|
|
208
|
+
const total = [...byYear.values()].reduce((a, b) => a + b, 0);
|
|
209
|
+
if (total === 0) {
|
|
210
|
+
console.log('No matching commits. Check the email(s) your old commits used: git log --format=%ae | sort -u');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
for (const [year, count] of [...byYear].sort()) console.log(` ${year} ${String(count).padStart(6)} commits`);
|
|
214
|
+
console.log(` total ${String(total).padStart(6)} commits (${result.coverage.from} → ${result.coverage.to})\n`);
|
|
215
|
+
console.log('Add this to sources.json to merge it (set from/to to the years GitHub is missing):');
|
|
216
|
+
console.log(JSON.stringify({
|
|
217
|
+
id: 'local-history',
|
|
218
|
+
label: 'Recovered (local git)',
|
|
219
|
+
type: 'local',
|
|
220
|
+
paths: positionals,
|
|
221
|
+
emails,
|
|
222
|
+
from: values.from ?? result.coverage.from,
|
|
223
|
+
to: values.to ?? result.coverage.to,
|
|
224
|
+
enabled: true,
|
|
225
|
+
}, null, 2));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const commands: Record<string, (args: string[]) => Promise<void>> = { init, fetch: fetchCmd, banner, scan };
|
|
229
|
+
|
|
230
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
231
|
+
if (!command || command === '-h' || command === '--help' || command === 'help') {
|
|
232
|
+
process.stdout.write(HELP);
|
|
233
|
+
} else if (!commands[command]) {
|
|
234
|
+
fail(`Unknown command "${command}".\n\n${HELP}`);
|
|
235
|
+
} else {
|
|
236
|
+
commands[command](rest).catch((err: unknown) => fail(err instanceof Error ? err.message : String(err)));
|
|
237
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** GitHub Actions workflow: daily fetch → build → banners → GitHub Pages. */
|
|
2
|
+
export function workflowYaml(): string {
|
|
3
|
+
return `# contribs.sh — refresh your heatmap daily and publish it (plus banner images) to GitHub Pages.
|
|
4
|
+
#
|
|
5
|
+
# Setup (repo → Settings):
|
|
6
|
+
# 1. Secrets and variables → Actions → New repository secret:
|
|
7
|
+
# CONTRIBS_SOURCES contents of your sources.json (every source needs "tokenEnv")
|
|
8
|
+
# GH_MAIN_TOKEN classic PAT with read:user, repo, read:org (one secret per tokenEnv)
|
|
9
|
+
# CONTRIBS_LOST_RANGES optional: contents of your lost-ranges.json
|
|
10
|
+
# 2. Pages → Build and deployment → Source: GitHub Actions
|
|
11
|
+
#
|
|
12
|
+
# Published files: index.html (dashboard), banners/<preset>.png|svg, data/contributions.json
|
|
13
|
+
name: contribs
|
|
14
|
+
|
|
15
|
+
on:
|
|
16
|
+
schedule:
|
|
17
|
+
- cron: '17 6 * * *'
|
|
18
|
+
workflow_dispatch:
|
|
19
|
+
|
|
20
|
+
permissions:
|
|
21
|
+
contents: read
|
|
22
|
+
pages: write
|
|
23
|
+
id-token: write
|
|
24
|
+
|
|
25
|
+
concurrency:
|
|
26
|
+
group: pages
|
|
27
|
+
cancel-in-progress: true
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
build:
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
outputs:
|
|
33
|
+
configured: \${{ steps.check.outputs.configured }}
|
|
34
|
+
steps:
|
|
35
|
+
- id: check
|
|
36
|
+
env:
|
|
37
|
+
CONTRIBS_SOURCES: \${{ secrets.CONTRIBS_SOURCES }}
|
|
38
|
+
run: |
|
|
39
|
+
if [ -n "$CONTRIBS_SOURCES" ]; then echo configured=true >> "$GITHUB_OUTPUT"; else
|
|
40
|
+
echo "::notice::CONTRIBS_SOURCES secret not set — skipping. See the setup notes at the top of this workflow."
|
|
41
|
+
echo configured=false >> "$GITHUB_OUTPUT"; fi
|
|
42
|
+
- if: steps.check.outputs.configured == 'true'
|
|
43
|
+
uses: actions/checkout@v4
|
|
44
|
+
- if: steps.check.outputs.configured == 'true'
|
|
45
|
+
uses: actions/setup-node@v4
|
|
46
|
+
with:
|
|
47
|
+
node-version: 22
|
|
48
|
+
cache: npm
|
|
49
|
+
- if: steps.check.outputs.configured == 'true'
|
|
50
|
+
name: Write config from secrets
|
|
51
|
+
env:
|
|
52
|
+
CONTRIBS_SOURCES: \${{ secrets.CONTRIBS_SOURCES }}
|
|
53
|
+
CONTRIBS_LOST_RANGES: \${{ secrets.CONTRIBS_LOST_RANGES }}
|
|
54
|
+
run: |
|
|
55
|
+
printf '%s' "$CONTRIBS_SOURCES" > sources.json
|
|
56
|
+
if [ -n "$CONTRIBS_LOST_RANGES" ]; then printf '%s' "$CONTRIBS_LOST_RANGES" > lost-ranges.json; fi
|
|
57
|
+
- if: steps.check.outputs.configured == 'true'
|
|
58
|
+
name: Fetch, build, render banners
|
|
59
|
+
env:
|
|
60
|
+
GH_MAIN_TOKEN: \${{ secrets.GH_MAIN_TOKEN }}
|
|
61
|
+
# Add one line per extra "tokenEnv" in your sources.json, e.g.:
|
|
62
|
+
# GHE_TOKEN: \${{ secrets.GHE_TOKEN }}
|
|
63
|
+
# CODEBERG_TOKEN: \${{ secrets.CODEBERG_TOKEN }}
|
|
64
|
+
run: |
|
|
65
|
+
npm ci
|
|
66
|
+
npm run fetch
|
|
67
|
+
npm run build -- --base ./
|
|
68
|
+
npm run banner -- --preset all --out dist/banners
|
|
69
|
+
- if: steps.check.outputs.configured == 'true'
|
|
70
|
+
uses: actions/upload-pages-artifact@v3
|
|
71
|
+
with:
|
|
72
|
+
path: dist
|
|
73
|
+
|
|
74
|
+
deploy:
|
|
75
|
+
needs: build
|
|
76
|
+
if: needs.build.outputs.configured == 'true'
|
|
77
|
+
runs-on: ubuntu-latest
|
|
78
|
+
environment:
|
|
79
|
+
name: github-pages
|
|
80
|
+
url: \${{ steps.deployment.outputs.page_url }}
|
|
81
|
+
steps:
|
|
82
|
+
- id: deployment
|
|
83
|
+
uses: actions/deploy-pages@v4
|
|
84
|
+
`;
|
|
85
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import raw from '../config.json';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Site/display settings. Committed (not personal data) — fork and edit freely.
|
|
5
|
+
* Personal config lives in the gitignored `sources.json` / `lost-ranges.json`.
|
|
6
|
+
*/
|
|
7
|
+
interface AppConfig {
|
|
8
|
+
/** Site name — shown as the page heading and the browser tab title. */
|
|
9
|
+
title: string;
|
|
10
|
+
/** How many of the most recent years to show before the "show earlier" toggle. */
|
|
11
|
+
recentYears: number;
|
|
12
|
+
/** Earliest year to include, even when sources have no coverage. */
|
|
13
|
+
startYear: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const config = raw as Partial<AppConfig>;
|
|
17
|
+
|
|
18
|
+
export const title = config.title ?? 'contribs.sh';
|
|
19
|
+
export const recentYears = config.recentYears ?? 10;
|
|
20
|
+
export const startYear = config.startYear;
|