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
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,56 @@ What it will not do is as much of the design:
- **A check that ran and failed is a result, not an obstacle.** Retrying until it
passes is how a flaky suite becomes a green one that means nothing.

### `gh-pulse`

What moved on GitHub since yesterday, ranked, with traffic. Every repo the
`gh` token can see (yours plus every org you belong to, forks excluded) is
checked for movement since the previous run: stars, forks, commits, pull
requests, issues, releases, and the traffic GitHub shows at `/graphs/traffic`
(views, unique visitors, clones, referrers, popular content). Repos with
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
```

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`.

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
the 14-day buckets since the previous snapshot: exactly "since the last
email", and a skipped day widens the window rather than losing data. Every
run writes `~/.local/share/gh-pulse/snapshots/<date>.json.gz` (`GH_PULSE_DATA`
moves it) with the raw per-repo counts and buckets. GitHub keeps nothing past
14 days, so those files are the long-run history, and what `show` reads.

Scoring: star 5, fork 4, release 5, PR merged 3, PR or issue opened 2, commit
1 (capped at 25), unique visitor 1, unique cloner 1, clone 0.5, view 0.2. A
single clone or visitor in a day is treated as background noise; two unique
visitors or two unique cloners make a repo a mover on traffic alone.

Mail: `RESEND_API_KEY` from the environment, else from
`~/.config/logicsrc/shell.env` (the cron case). The sender must sit on a
verified Resend domain (`GH_PULSE_FROM`, default `pulse@profullstack.com`);
`GH_PULSE_TO` or `--to` names the recipient, falling back to the committer
email. A crash sends a plain FAILED mail so a broken cron is never silent. A
daily entry:

```
5 13 * * * $HOME/.local/bin/gh-pulse >>$HOME/.local/share/gh-pulse/cron.log 2>&1
```

### `gh-prs-fix-all`

Looks at every open threatcrush-scan pull request and fixes the ones broken
Expand Down
136 changes: 136 additions & 0 deletions bin/gh-pulse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* gh-pulse — what moved on GitHub since yesterday, 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)
*
* Options for a scan:
* --to a@b.c recipient (default GH_PULSE_TO, then the git 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.
*
* 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.
*/

import { readFileSync } from 'node:fs';

import { UsageError, parseArgs } from '../src/args.ts';
import {
DEFAULT_FROM,
dataDir,
defaultDeps,
gitEmail,
loadShellEnv,
openInBrowser,
outputPaths,
run,
sendFailure,
} 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 --help`;

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

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;
}
if (verb === 'text' || verb === 'json') {
const file = verb === 'text' ? outputPaths(dir).text : outputPaths(dir).json;
try {
process.stdout.write(readFileSync(file, 'utf8'));
return 0;
} catch {
process.stderr.write(`gh-pulse ${verb}: no report yet in ${dir}. Run \`gh-pulse\` first.\n`);
return 1;
}
}
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');

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;
} catch (error) {
process.stderr.write(`gh-pulse: ${(error as Error).stack ?? String(error)}\n`);
if (!dryRun) await sendFailure(error, to, from, deps.resendKey(), deps.fetch);
return 1;
}
}

if (isMain(import.meta.url)) {
main(process.argv.slice(2)).then(
(code) => { process.exitCode = code; },
(error: unknown) => {
if (error instanceof UsageError) {
process.stderr.write(`gh-pulse: ${error.message}\n${USAGE}\n`);
process.exitCode = 2;
return;
}
process.stderr.write(`gh-pulse: ${(error as Error).stack ?? String(error)}\n`);
process.exitCode = 1;
},
);
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/cli-tools",
"version": "0.32.0",
"version": "0.33.0",
"private": true,
"description": "Local command-line tools, in TypeScript, exposed on PATH.",
"type": "module",
Expand Down Expand Up @@ -32,6 +32,8 @@
"sharp": "^0.35.3"
},
"dependencies": {
"@profullstack/hqtui": "^0.5.1",
"@resvg/resvg-js": "^2.6.2",
"axe-core": "^4.13.0",
"imapflow": "^1.7.8",
"mailparser": "^3.9.20",
Expand Down
144 changes: 144 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@
# makes on purpose.
allowBuilds:
esbuild: true
minimumReleaseAgeExclude:
- '@profullstack/hqtui@0.5.1'
Loading
Loading