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
45 changes: 34 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,20 +476,43 @@ movement are ranked by a weighted score; each comes with its traffic, the top
ones with a 14-day chart, and new or lost followers are named.

```sh
gh-pulse # scan every repo, email the report, snapshot
gh-pulse --dry-run # scan and write the report, send nothing
gh-pulse show # the last report and the history, as a TUI
gh-pulse open # the last HTML report, in the browser
gh-pulse text # the last report as plain text
gh-pulse json # the last report as JSON
gh-pulse --repo profullstack/nixamp --dry-run # one repo, for a look
gh-pulse # the daily run: scan every repo, email, snapshot
gh-pulse --dry-run # scan and write the daily report, send nothing
gh-pulse --range week # what moved in the last 7 days, to stdout
gh-pulse --range year --send # the same for a year, emailed
gh-pulse --since 2026-09-01 # any start date
gh-pulse show # the TUI: ranges and filters are clickable
gh-pulse show --range quarter # start on a range
gh-pulse open [--range all] # the HTML report, in the browser
gh-pulse text [--range month] # plain text
gh-pulse json [--range day] # JSON (what `show` reads)
gh-pulse --repo profullstack/nixamp --range week # one repo, for a look
```

The same report reaches every surface: the email (HTML with inline charts,
through Resend), the terminal (`show`, built on hqtui: click a row, the
selected repo's 14-day views and clones, a History tab over every snapshot),
the browser (`open`), and pipes (`text`, `json`). The moshcode pit alias is
`/pulse`.
through Resend), the terminal (`show`, built on hqtui), the browser (`open`),
and pipes (`text`, `json`). The moshcode pit alias is `/pulse`.

**Ranges.** `hour`, `day`, `week`, `month`, `quarter`, `year` and `all` (also
`1h`, `24h`, `7d`, `30d`, `90d`, `365d`), or `--since` a date. A range scan
pulls the events live from GitHub for the whole range (commits, PRs, issues,
releases, who starred and forked, walking as many pages as the range
deserves) and the traffic from the ledger of daily snapshots, which is the
only place GitHub's fourteen-day traffic window survives: the longer the
daily run has been going, the further back `year` and `all` can see, and the
report says exactly which days it covers. A range scan never moves the daily
baseline. `show`, `open`, `text` and `json` reuse a range report under an hour
old and scan otherwise. `hour` has hourly events but daily traffic, because
GitHub publishes nothing finer.

**In the TUI** the range row and the filter rows are clickable. Filters are
on/off toggles: which kinds of movement count (stars, forks, commits, PRs,
issues, releases, traffic), which owners, and private repos; a repo stays in
the list while at least one enabled kind moved for it, and keeps its rank.
Keys do the same: `h d w m q y a` pick a range, `l` returns to the latest
daily report, `1`-`7` flip the kinds, `p` flips private, `r` rescans, Tab
switches to History, `o` opens the HTML, `q` quits. Ranges load in the
background while the current view stays up.

GitHub publishes traffic in UTC-day buckets one to two days late, so "the last
24 hours" cannot be read off the clock. Each run instead counts the growth of
Expand Down
151 changes: 98 additions & 53 deletions bin/gh-pulse.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
#!/usr/bin/env node
/**
* gh-pulse — what moved on GitHub since yesterday, ranked, with traffic.
* gh-pulse — what moved on GitHub, ranked, with traffic.
*
* gh-pulse scan every repo, email the report, snapshot
* gh-pulse --dry-run scan and write the report, send nothing
* gh-pulse show the last report and the history, as a TUI
* gh-pulse open the last HTML report, in the browser
* gh-pulse text the last report as plain text
* gh-pulse json the last report as JSON (what `show` reads)
* gh-pulse the daily run: scan every repo, email, snapshot
* gh-pulse --dry-run scan and write the daily report, send nothing
* gh-pulse --range week what moved in the last week, to stdout (no mail)
* gh-pulse --range month --send the same, emailed
* gh-pulse --since 2026-09-01 any start date
* gh-pulse show [--range year] the TUI: ranges and filters are clickable
* gh-pulse open [--range quarter] the HTML report, in the browser
* gh-pulse text [--range all] plain text
* gh-pulse json [--range day] JSON (what `show` reads)
*
* Ranges: hour, day, week, month, quarter, year, all (also 1h, 24h, 7d, 30d,
* 90d, 365d). A range scan pulls the events live from GitHub for the whole
* range and the traffic from the ledger of daily snapshots, which is the only
* place GitHub's fourteen-day traffic window survives. It never moves the
* daily baseline. `show`, `open`, `text` and `json` reuse a range report that
* is under an hour old and scan otherwise.
*
* Options for a scan:
* --to a@b.c recipient (default GH_PULSE_TO, then the git email)
* --to a@b.c recipient (default GH_PULSE_TO, then the committer email)
* --from "N <a@b>" sender on a verified Resend domain (default GH_PULSE_FROM)
* --top N how many ranked repos get a chart and detail (12)
* --repo owner/name only this repo; repeatable; never moves the baseline
* --hours N window when there is no previous snapshot (24)
*
* Every repo the gh token can see (yours plus every org, forks excluded) is
* checked for movement since the previous run: stars, forks, commits, PRs,
* issues, releases, and the traffic GitHub shows at /graphs/traffic. Movers are
* ranked by a weighted score; each comes with its traffic, the top ones with a
* 14-day chart, and new or lost followers are named.
*
* GitHub publishes traffic in UTC-day buckets one to two days late, so the
* window is the growth of the buckets since the previous snapshot rather than
* a clock. Snapshots live under ~/.local/share/gh-pulse (GH_PULSE_DATA) and are
* the only long-run traffic record GitHub leaves you.
* --hours N daily window when there is no previous snapshot (24)
* --send email a range report (the daily run always mails)
*
* Mail goes through Resend: RESEND_API_KEY from the environment, else from
* ~/.config/logicsrc/shell.env (the cron case). A crash sends a FAILED mail.
Expand All @@ -36,29 +36,51 @@ import { readFileSync } from 'node:fs';
import { UsageError, parseArgs } from '../src/args.ts';
import {
DEFAULT_FROM,
RANGE_KEYS,
customRange,
dataDir,
defaultDeps,
gitEmail,
loadShellEnv,
openInBrowser,
outputPaths,
parseRangeKey,
rangeOutputPaths,
rangeScan,
rangeSlug,
rangeSpec,
readRangeReport,
run,
sendFailure,
type RangeKey,
type RangeSpec,
type ReportJson,
} from '../src/gh-pulse.ts';
import { isMain } from '../src/is-main.ts';

export const USAGE = `Usage:
gh-pulse [--dry-run] [--to ADDR] [--from ADDR] [--top N] [--hours N] [--repo OWNER/NAME]...
gh-pulse show
gh-pulse open
gh-pulse text
gh-pulse json
gh-pulse --range ${RANGE_KEYS.join('|')} [--send] [--top N] [--repo OWNER/NAME]...
gh-pulse --since YYYY-MM-DD [--send]
gh-pulse show [--range KEY]
gh-pulse open [--range KEY]
gh-pulse text [--range KEY]
gh-pulse json [--range KEY]
gh-pulse --help`;

function specFrom(rangeText: string | undefined, sinceText: string | undefined, now: Date): RangeSpec | null {
if (rangeText !== undefined && sinceText !== undefined) throw new UsageError('pass --range or --since, not both');
if (sinceText !== undefined) return customRange(sinceText, now);
if (rangeText === undefined) return null;
const key = parseRangeKey(rangeText);
if (!key) throw new UsageError(`--range wants one of ${RANGE_KEYS.join(', ')} (or 1h, 24h, 7d, 30d, 90d, 365d), not ${rangeText}`);
return rangeSpec(key, now);
}

export async function main(argv: readonly string[]): Promise<number> {
const parsed = parseArgs(argv, {
boolean: ['--dry-run', '--help', '-h'],
string: ['--to', '--from', '--top', '--hours', '--repo'],
boolean: ['--dry-run', '--send', '--help', '-h'],
string: ['--to', '--from', '--top', '--hours', '--repo', '--range', '--since'],
});
if (parsed.flags.has('--help') || parsed.flags.has('-h')) {
process.stdout.write(`${USAGE}\n`);
Expand All @@ -68,20 +90,47 @@ export async function main(argv: readonly string[]): Promise<number> {
const [verb, ...rest] = parsed.positional;
if (rest.length > 0) throw new UsageError(`unexpected argument: ${rest[0]}`);

const top = Number(parsed.values.get('--top') ?? 12);
const hours = Number(parsed.values.get('--hours') ?? 24);
if (!Number.isFinite(top) || top < 0) throw new UsageError('--top wants a non-negative number');
if (!Number.isFinite(hours) || hours <= 0) throw new UsageError('--hours wants a positive number');
// parseArgs keeps the last value of a repeated flag; --repo is the one flag
// people repeat, so it is gathered from argv directly.
const repos: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!;
if (a === '--repo' && argv[i + 1]) repos.push(argv[i + 1]!);
else if (a.startsWith('--repo=')) repos.push(a.slice('--repo='.length));
}

loadShellEnv();
const to = parsed.values.get('--to') ?? process.env['GH_PULSE_TO'] ?? gitEmail();
const from = parsed.values.get('--from') ?? process.env['GH_PULSE_FROM'] ?? DEFAULT_FROM;
const deps = defaultDeps();
const spec = specFrom(parsed.values.get('--range'), parsed.values.get('--since'), deps.now());

/** A range report, from the hour-fresh cache or a scan. Progress goes wherever the caller wants it. */
const loadRange = async (s: RangeSpec, progress: (line: string) => void, force = false): Promise<ReportJson> => {
const cached = force ? null : readRangeReport(dir, rangeSlug(s, repos));
if (cached) return cached;
return (await rangeScan({ spec: s, top, repos, dataDir: dir, send: false, to, from, progress }, deps)).report;
};

if (verb === 'show') {
const { showTui } = await import('../src/gh-pulse-tui.ts');
return showTui(dir);
}
if (verb === 'open') {
const file = outputPaths(dir).html;
if (openInBrowser(file)) return 0;
process.stderr.write(`gh-pulse open: no opener found; the report is at ${file}\n`);
return 1;
const startKey: RangeKey | undefined = spec && spec.key !== 'custom' ? spec.key : undefined;
return showTui(dir, { range: startKey, loadRange: (key, progress) => loadRange(rangeSpec(key, deps.now()), progress) });
}
if (verb === 'text' || verb === 'json') {
const file = verb === 'text' ? outputPaths(dir).text : outputPaths(dir).json;
if (verb === 'open' || verb === 'text' || verb === 'json') {
if (spec) await loadRange(spec, deps.log);
const paths = spec ? rangeOutputPaths(dir, rangeSlug(spec, repos)) : outputPaths(dir);
if (verb === 'open') {
if (openInBrowser(paths.html)) return 0;
process.stderr.write(`gh-pulse open: no opener found; the report is at ${paths.html}\n`);
return 1;
}
try {
process.stdout.write(readFileSync(file, 'utf8'));
process.stdout.write(readFileSync(verb === 'text' ? paths.text : paths.json, 'utf8'));
return 0;
} catch {
process.stderr.write(`gh-pulse ${verb}: no report yet in ${dir}. Run \`gh-pulse\` first.\n`);
Expand All @@ -90,26 +139,22 @@ export async function main(argv: readonly string[]): Promise<number> {
}
if (verb !== undefined) throw new UsageError(`unknown command: ${verb}`);

const top = Number(parsed.values.get('--top') ?? 12);
const hours = Number(parsed.values.get('--hours') ?? 24);
if (!Number.isFinite(top) || top < 0) throw new UsageError('--top wants a non-negative number');
if (!Number.isFinite(hours) || hours <= 0) throw new UsageError('--hours wants a positive number');
if (spec) {
// A range scan prints the report; mail is opt-in, and the baseline never moves.
const send = parsed.flags.has('--send');
if (send && !to) throw new UsageError('no recipient: pass --to, set GH_PULSE_TO, or configure git user.email');
try {
await rangeScan({ spec, top, repos, dataDir: dir, send, to, from }, deps);
process.stdout.write(readFileSync(rangeOutputPaths(dir, rangeSlug(spec, repos)).text, 'utf8'));
return 0;
} catch (error) {
process.stderr.write(`gh-pulse: ${(error as Error).stack ?? String(error)}\n`);
return 1;
}
}

loadShellEnv();
const to = parsed.values.get('--to') ?? process.env['GH_PULSE_TO'] ?? gitEmail();
if (!to) throw new UsageError('no recipient: pass --to, set GH_PULSE_TO, or configure git user.email');
const from = parsed.values.get('--from') ?? process.env['GH_PULSE_FROM'] ?? DEFAULT_FROM;
const dryRun = parsed.flags.has('--dry-run');
// parseArgs keeps the last value of a repeated flag; --repo is the one flag
// people repeat, so it is gathered from argv directly.
const repos: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!;
if (a === '--repo' && argv[i + 1]) repos.push(argv[i + 1]!);
else if (a.startsWith('--repo=')) repos.push(a.slice('--repo='.length));
}

const deps = defaultDeps();
try {
await run({ dryRun, to, from, top, hours, repos, dataDir: dir }, deps);
return 0;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/cli-tools",
"version": "0.33.0",
"version": "0.34.0",
"private": true,
"description": "Local command-line tools, in TypeScript, exposed on PATH.",
"type": "module",
Expand Down
Loading
Loading