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
38 changes: 35 additions & 3 deletions bin/gh-pulse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,28 @@ export const USAGE = `Usage:
gh-pulse json [--range KEY]
gh-pulse --help`;

/**
* A busy line on stderr while a scan runs, when stderr is a terminal. Progress
* lines replace each other on one row; the last one is left standing when the
* scan ends. Without a terminal (cron, a pipe) every line is printed plainly.
*/
export function ttySpinner(label: string, stream: NodeJS.WriteStream = process.stderr): { progress: (line: string) => void; done: () => void } {
if (!stream.isTTY) {
return { progress: (line) => stream.write(`gh-pulse: ${line}\n`), done: () => {} };
}
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let i = 0;
let line = '';
const draw = (): void => { stream.write(`\r\x1b[K${frames[i % frames.length]} ${label}${line ? `: ${line}` : ''}`); i += 1; };
const timer = setInterval(draw, 80);
timer.unref();
draw();
return {
progress: (l) => { line = l; draw(); },
done: () => { clearInterval(timer); stream.write(`\r\x1b[K${line ? `gh-pulse: ${line}\n` : ''}`); },
};
}

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);
Expand Down Expand Up @@ -122,7 +144,10 @@ export async function main(argv: readonly string[]): Promise<number> {
return showTui(dir, { range: startKey, loadRange: (key, progress) => loadRange(rangeSpec(key, deps.now()), progress) });
}
if (verb === 'open' || verb === 'text' || verb === 'json') {
if (spec) await loadRange(spec, deps.log);
if (spec) {
const sp = ttySpinner(`gh-pulse ${spec.label}`);
try { await loadRange(spec, sp.progress); } finally { sp.done(); }
}
const paths = spec ? rangeOutputPaths(dir, rangeSlug(spec, repos)) : outputPaths(dir);
if (verb === 'open') {
if (openInBrowser(paths.html)) return 0;
Expand All @@ -143,22 +168,29 @@ export async function main(argv: readonly string[]): Promise<number> {
// 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');
const sp = ttySpinner(`gh-pulse ${spec.label}`);
try {
await rangeScan({ spec, top, repos, dataDir: dir, send, to, from }, deps);
await rangeScan({ spec, top, repos, dataDir: dir, send, to, from, progress: sp.progress }, deps);
sp.done();
process.stdout.write(readFileSync(rangeOutputPaths(dir, rangeSlug(spec, repos)).text, 'utf8'));
return 0;
} catch (error) {
sp.done();
process.stderr.write(`gh-pulse: ${(error as Error).stack ?? String(error)}\n`);
return 1;
}
}

if (!to) throw new UsageError('no recipient: pass --to, set GH_PULSE_TO, or configure git user.email');
const dryRun = parsed.flags.has('--dry-run');
const sp = ttySpinner(dryRun ? 'gh-pulse dry run' : 'gh-pulse daily run');
try {
await run({ dryRun, to, from, top, hours, repos, dataDir: dir }, deps);
// On a terminal the coarse log lines ride the spinner too; in cron they print as before.
await run({ dryRun, to, from, top, hours, repos, dataDir: dir, progress: sp.progress }, process.stderr.isTTY ? { ...deps, log: sp.progress } : deps);
sp.done();
return 0;
} catch (error) {
sp.done();
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;
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/cli-tools",
"version": "0.34.0",
"version": "0.35.0",
"private": true,
"description": "Local command-line tools, in TypeScript, exposed on PATH.",
"type": "module",
Expand Down Expand Up @@ -32,7 +32,7 @@
"sharp": "^0.35.3"
},
"dependencies": {
"@profullstack/hqtui": "^0.5.1",
"@profullstack/hqtui": "^0.6.0",
"@resvg/resvg-js": "^2.6.2",
"axe-core": "^4.13.0",
"imapflow": "^1.7.8",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

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

2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@
allowBuilds:
esbuild: true
minimumReleaseAgeExclude:
- '@profullstack/hqtui@0.5.1'
- '@profullstack/hqtui@0.5.1 || 0.6.0'
21 changes: 15 additions & 6 deletions src/gh-pulse-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* flips private, r rescans the current range, o opens the HTML, q quits.
*/

import { createApp } from '@profullstack/hqtui';
import { createApp, widgets } from '@profullstack/hqtui';

import {
RANGE_KEYS,
Expand Down Expand Up @@ -213,9 +213,11 @@ export async function showTui(dataDir: string, opts: ShowOptions): Promise<numbe

if (opts.range) pickRange(opts.range);

app.render(({ ui, theme, width, height }) => {
app.render(({ ui, theme, width, height, elapsed }) => {
const rows = visible();
const bodyRows = Math.max(3, height - 13);
const bodyRows = Math.max(3, height - 14);
// The same glyph the spinner line shows, for the range button that is loading.
const glyph = widgets.spinnerFrame(elapsed, widgets.SPINNER_FRAMES.dots);
if (selected >= rows.length) selected = Math.max(0, rows.length - 1);
if (selected < offset) offset = selected;
if (selected >= offset + bodyRows) offset = selected - bodyRows + 1;
Expand All @@ -228,7 +230,7 @@ export async function showTui(dataDir: string, opts: ShowOptions): Promise<numbe
col.buttons([
{ label: 'latest', variant: current === 'latest' ? 'primary' : 'ghost', onPress: () => pickRange('latest') },
...RANGE_KEYS.map((k) => ({
label: `${RANGE_LABEL[k].replace('last ', '')}${scanning === k ? ' …' : ''}`,
label: `${RANGE_LABEL[k].replace('last ', '')}${scanning === k ? ` ${glyph}` : ''}`,
variant: (current === k ? 'primary' : 'ghost') as 'primary' | 'ghost',
onPress: () => pickRange(k),
})),
Expand All @@ -247,8 +249,15 @@ export async function showTui(dataDir: string, opts: ShowOptions): Promise<numbe
}
});

// The busy line: a spinner with the live count while a range is fetching, the last notice otherwise.
if (scanning) col.spinner({ label: `fetching ${RANGE_LABEL[scanning]} from GitHub`, text: scanLine, size: 1 });
else col.text(notice || 'ready · h d w m q y a pick a range, 1-7 flip kinds, click anything', { fg: theme.muted, size: 1 });

if (!report) {
col.panel({ title: scanning ? `Scanning ${RANGE_LABEL[scanning]}` : 'No report' }, (p) => { p.text(scanLine || notice || 'nothing loaded yet', { fg: theme.muted }); });
col.panel({ title: scanning ? `Fetching ${RANGE_LABEL[scanning]}` : 'No report' }, (p) => {
if (scanning) p.spinner({ label: `fetching ${RANGE_LABEL[scanning]} from GitHub`, text: scanLine });
else p.text(notice || 'nothing loaded yet', { fg: theme.muted });
});
return;
}
const rep = report;
Expand All @@ -264,7 +273,7 @@ export async function showTui(dataDir: string, opts: ShowOptions): Promise<numbe
{ label: 'Stars', value: `${rep.totals.stars}${rep.totals.starsDelta === null ? '' : ` (${rep.totals.starsDelta >= 0 ? '+' : ''}${rep.totals.starsDelta})`}`, color: theme.accent },
{ label: 'Views', value: `${rep.totals.views} / ${rep.totals.uniques} unique` },
{ label: 'Clones', value: `${rep.totals.clones} / ${rep.totals.cloners} unique` },
{ label: scanning ? 'Scanning' : 'Status', value: scanning ? `${RANGE_LABEL[scanning]}: ${scanLine}` : (notice || 'ready'), color: scanning ? theme.warning : theme.muted },
{ label: 'Newest traffic day', value: rep.newestTrafficDay },
], { width: '1fr' });
});

Expand Down
13 changes: 13 additions & 0 deletions src/gh-pulse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ export interface RunOptions {
hours: number;
dataDir: string;
from: string;
/** Fine-grained progress (every few repos) for a spinner; the coarse lines still go to `deps.log`. */
progress?: (line: string) => void;
}

export interface RunDeps {
Expand Down Expand Up @@ -1087,13 +1089,17 @@ export async function run(opt: RunOptions, deps: RunDeps = defaultDeps()): Promi
deps.log(`${repos.length} repos`);

// 2. traffic for all of them: the bulk of the calls
const tick = opt.progress ?? (() => {});
const counter = (what: string, total: number) => { let done = 0; return () => { done += 1; if (done % 10 === 0 || done === total) tick(`${what} ${done}/${total}`); }; };
const R = new Map<string, RepoState>();
const trafficDone = counter('traffic', repos.length);
await Promise.all(repos.map((r) => gh.limit(async () => {
const [v, c] = await Promise.all([
gh.get<{ views: Bucket[] }>(`/repos/${r.full_name}/traffic/views`, { allow: [403, 404] }),
gh.get<{ clones: Bucket[] }>(`/repos/${r.full_name}/traffic/clones`, { allow: [403, 404] }),
]);
R.set(r.full_name, { repo: r, views: v.data?.views ?? null, clones: c.data?.clones ?? null, trafficOk: !!(v.data && c.data), m: emptyMovement() });
trafficDone();
})));

// 3. movement per repo
Expand All @@ -1118,10 +1124,12 @@ export async function run(opt: RunOptions, deps: RunDeps = defaultDeps()): Promi

// 4. detail for candidates
const budget = pageBudget('daily');
const detailDone = counter('events', R.size);
await Promise.all([...R.values()].map((x) => gh.limit(async () => {
const n = x.repo.full_name;
const m = x.m;
const hasBaseline = prevRepos.has(n);
detailDone();
if (needStars.has(n)) {
const { logins } = await collectStars(gh, n, cutoffMs, budget.stars);
m.newStargazers = logins;
Expand Down Expand Up @@ -1274,6 +1282,8 @@ export async function rangeScan(opt: RangeOptions, deps: RunDeps = defaultDeps()
// The ledger: every snapshot, plus today's live fourteen days on top.
const ledger = buildLedger(listSnapshots(opt.dataDir).flatMap((f) => { try { return [readSnapshot(f)]; } catch { return []; } }));
const R = new Map<string, RepoState>();
const counter = (what: string, total: number) => { let done = 0; return () => { done += 1; if (done % 10 === 0 || done === total) say(`${what} ${done}/${total}`); }; };
const trafficDone = counter('traffic', repos.length);
await Promise.all(repos.map((r) => gh.limit(async () => {
const [v, c] = await Promise.all([
gh.get<{ views: Bucket[] }>(`/repos/${r.full_name}/traffic/views`, { allow: [403, 404] }),
Expand All @@ -1283,6 +1293,7 @@ export async function rangeScan(opt: RangeOptions, deps: RunDeps = defaultDeps()
const clones = c.data?.clones ?? null;
mergeBuckets(ledger, r.full_name, views, clones);
R.set(r.full_name, { repo: r, views, clones, trafficOk: !!(v.data && c.data), m: emptyMovement() });
trafficDone();
})));
say(`${repos.length} repos, traffic merged into the ledger`);

Expand All @@ -1299,10 +1310,12 @@ export async function rangeScan(opt: RangeOptions, deps: RunDeps = defaultDeps()

const cap = commitCapFor(days);
let partialRepos = 0;
const eventsDone = counter('events', R.size);
await Promise.all([...R.values()].map((x) => gh.limit(async () => {
const r = x.repo;
const n = r.full_name;
const m = x.m;
eventsDone();
const dv = ledgerSum(ledger.views.get(n), fromDay, toDay);
const dc = ledgerSum(ledger.clones.get(n), fromDay, toDay);
m.views = dv.count; m.uniques = dv.uniques; m.clones = dc.count; m.cloners = dc.uniques;
Expand Down
Loading