Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/cli-retry-exit-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@stats-forge/github-stats-forge-cli': minor
---

feat: exit 75 rather than 1 when a card failed in a way a later run may not repeat, such as a rate limit
7 changes: 7 additions & 0 deletions apps/docs/src/content/docs/docs/usage/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ npx @stats-forge/github-stats-forge-cli --config card.json --generate --out stat
`--check` in its place renders the same card and compares it with `stats.svg`, writing nothing: it
exits 1 when the file is missing or out of date, so a job can tell whether a commit is due.

A card that could not be drawn exits 1, or 75 when a later run may draw it, as after a rate limit
or an outage. A scheduled job can keep the SVG it has and try again rather than fail:

```sh
npx @stats-forge/github-stats-forge-cli --config card.json --generate --out stats.svg || [ $? -eq 75 ]
```

## The query string

Every other way of drawing a card takes a query string: [the action](https://github.com/stats-forge/github-stats-forge-action)'s
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ builds and runs it against the root `.env`.
- **`--check` is `--generate` that compares** with `--out` and writes nothing, exiting 1 on a
difference, a missing file or a failed render; every guard on `generate` covers it.
- **The menu stays open after a render, its cursor in place.**
- **A failed render exits `75` when core says it is `retryable`, else `1`**: `EX_TEMPFAIL`, so a
scheduled job can tell a rate limit from a broken config. `--check` keeps `1` for a stale file.

## Prompts and the menu

Expand Down
47 changes: 34 additions & 13 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ import { DEFAULT_ENV_FILE, loadEnvFile, resolveTokens } from './tokens.ts';
/** Room for a slow connection, well short of undici's five minutes on a stalled one. */
const REQUEST_TIMEOUT_MS = 30_000;

/** `EX_TEMPFAIL` from `sysexits.h`: a failure a later run may not repeat, such as a rate limit. */
const EXIT_RETRYABLE = 75;

/** Why a card was not drawn. */
interface Failed {
failed: string;
retryable: boolean;
}

/** @returns The exit code a failure leaves: `75` when a retry may work, else `1`. */
const exitCodeFor = (failure: Failed): number => (failure.retryable ? EXIT_RETRYABLE : 1);

/** Writes one of core's diagnostics to stderr, where everything but the result goes. */
const toStderr = (...args: Array<unknown>): void => {
process.stderr.write(`${formatWithOptions({}, ...args)}\n`);
Expand All @@ -45,6 +57,7 @@ Options
-v, --version Show the version

The token can also come from PAT_1 in the environment, or be typed when asked.
A card that could not be drawn exits 1, or 75 when a later run may draw it.
`;

/** What the command line can carry. */
Expand Down Expand Up @@ -92,7 +105,7 @@ const renderCard = async (
card: CardKind,
query: Record<string, string>,
config: CardConfig,
): Promise<{ content: string } | { failed: string }> => {
): Promise<{ content: string } | Failed> => {
const result = await withSpinner(`Rendering the ${card.id} card`, async () =>
card.render(query, config),
);
Expand All @@ -112,7 +125,7 @@ const renderCard = async (
.filter(Boolean)
.join('\n')}\n`,
);
return { failed: code };
return { failed: code, retryable: result.retryable };
};

/** @returns Where a card is written, absolute, named after the card unless `out` says. */
Expand All @@ -129,7 +142,7 @@ const renderAndWrite = async (
query: Record<string, string>,
config: CardConfig,
out: string | undefined,
): Promise<{ written: string } | { failed: string }> => {
): Promise<{ written: string } | Failed> => {
const rendered = await renderCard(card, query, config);
if ('failed' in rendered) {
return rendered;
Expand All @@ -145,17 +158,17 @@ const renderAndWrite = async (
/**
* Renders a card and compares it with the file it would be written to, writing nothing.
*
* @returns Whether the file holds exactly this render.
* @returns Whether the file holds exactly this render, or why there was no render to compare.
*/
const renderAndCompare = async (
card: CardKind,
query: Record<string, string>,
config: CardConfig,
out: string | undefined,
): Promise<boolean> => {
): Promise<boolean | Failed> => {
const rendered = await renderCard(card, query, config);
if ('failed' in rendered) {
return false;
return rendered;
}

const file = outFile(card, query, out);
Expand Down Expand Up @@ -280,15 +293,23 @@ const main = async (): Promise<void> => {

// `--generate` renders what the file holds and stops: no menu, nothing to answer.
if (saved && flags.check) {
if (!(await renderAndCompare(card, { ...saved.options, ...pasted }, config, flags.out))) {
const compared = await renderAndCompare(
card,
{ ...saved.options, ...pasted },
config,
flags.out,
);
if (compared === false) {
process.exitCode = 1;
} else if (compared !== true) {
process.exitCode = exitCodeFor(compared);
}
return;
}
if (saved && generate) {
const outcome = await renderAndWrite(card, { ...saved.options, ...pasted }, config, flags.out);
if ('failed' in outcome) {
process.exitCode = 1;
process.exitCode = exitCodeFor(outcome);
}
return;
}
Expand All @@ -304,7 +325,7 @@ const main = async (): Promise<void> => {
* list is to change one thing and look again.
*/
let status: string | undefined;
let lastFailed = false;
let lastFailure: Failed | undefined;

for (;;) {
const action = await navigateOptions(card, menu, status);
Expand Down Expand Up @@ -335,17 +356,17 @@ const main = async (): Promise<void> => {
if ('failed' in outcome) {
// Left on the menu, since a rejected param is one edit away from working.
status = `${outcome.failed} — fix it and generate again`;
lastFailed = true;
lastFailure = outcome;
continue;
}

status = `wrote ${outcome.written} — edit an option and generate again`;
lastFailed = false;
lastFailure = undefined;
}

// Leaving straight after a failure still reports one.
if (lastFailed) {
process.exitCode = 1;
if (lastFailure) {
process.exitCode = exitCodeFor(lastFailure);
}
};

Expand Down
47 changes: 45 additions & 2 deletions packages/cli/tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ vi.mock(import('../src/prompts.ts'), () => prompts);
const DRAWN: ApiResult = { status: 'success', content: '<svg>the card</svg>' };
const REFUSED: ApiResult = {
status: 'error',
retryable: true,
retryable: false,
error: {
code: 'not_found',
reason: 'gist_not_found',
Expand All @@ -57,6 +57,19 @@ const REFUSED: ApiResult = {
},
content: '<svg>the error</svg>',
};
const RATE_LIMITED: ApiResult = {
status: 'error',
retryable: true,
error: {
code: 'rate_limited',
reason: 'rate_limited',
message: 'Every token has hit the rate limit',
secondaryMessage: undefined,
text: 'Every token has hit the rate limit.',
param: undefined,
},
content: '<svg>the error</svg>',
};

let dir: string;
/** A gist card saved with `id=abc&theme=ember_dark`. */
Expand Down Expand Up @@ -223,11 +236,22 @@ describe('the command line', () => {
expect(result.err).toContain('Could not render the gist card.');
expect(result.err).toContain('\n Gist not found. Check the id.\n');
expect(result.err).toContain('code: not_found');
expect(result.err).toContain('This one may work on a retry.');
expect(result.err).not.toContain('retry');
expect(result.code).toBe(1);
expect(existsSync(out)).toBe(false);
});

it('exits 75 when the failure may pass on a retry, as a rate limit may', async () => {
render.mockResolvedValue(RATE_LIMITED);
const out = join(dir, 'card.svg');

const result = await run('--config', savedGist, '--generate', '--pat', 'ghp_x', '--out', out);

expect(result.err).toContain('This one may work on a retry.');
expect(result.code).toBe(75);
expect(existsSync(out)).toBe(false);
});

describe('--check', () => {
it('passes when the file holds exactly this render, and leaves it alone', async () => {
render.mockResolvedValue(DRAWN);
Expand Down Expand Up @@ -268,6 +292,16 @@ describe('the command line', () => {
expect(result.err).toContain('Could not render the gist card.');
expect(result.code).toBe(1);
});

it('exits 75 when the render may pass on a retry', async () => {
render.mockResolvedValue(RATE_LIMITED);
const out = join(dir, 'card.svg');
await writeFile(out, DRAWN.content);

const result = await run('--config', savedGist, '--check', '--pat', 'ghp_x', '--out', out);

expect(result.code).toBe(75);
});
});

it('leaves with 130, and says nothing, when a prompt is cancelled', async () => {
Expand Down Expand Up @@ -366,6 +400,15 @@ describe('the menu', () => {
expect(code).toBe(1);
});

it('leaves with 75 when the last render may pass on a retry', async () => {
render.mockResolvedValue(RATE_LIMITED);
choose('generate');

const { code } = await run('--card', 'gist', '--pat', 'ghp_x', '--out', join(dir, 'card.svg'));

expect(code).toBe(75);
});

it('leaves with 0 when a later render worked', async () => {
render.mockResolvedValueOnce(REFUSED).mockResolvedValueOnce(DRAWN);
choose('generate', 'generate');
Expand Down
Loading