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
27 changes: 14 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,24 @@ For local report UI development:
bun run dev:report-web
```

## Options
## Usage

```text
--window <Nd|Nw> rolling time window (default 90d, e.g. 30d, 12w)
--out <basename> output basename; writes <basename>.html and <basename>.zip
(default ./patchwave-report)
--include <repos> comma-separated repo names to include
--exclude <repos> comma-separated repo names to exclude
--help show this help
patchwave-analysis [<org-or-user>]

If <org-or-user> is omitted, you are prompted for it.

--help show this help
```

The CLI takes a single optional argument — the org or user to scan. There are no other flags; the time window (90 days) and output location are fixed.

## Output

Each run writes two siblings next to `--out`:
Each run writes two files into a fresh temporary directory and prints the full paths when the scan finishes:

- **`<basename>.html`** — the self-contained browser report. Open it locally; it embeds the rolled-up report data in the file.
- **`<basename>.zip`** — the same HTML report plus every raw data slice behind it, one JSON file per slice. This is the artifact to send back when you want a deeper look from contextbridge.
- **`patchwave-report.html`** — the self-contained browser report. Open it locally; it embeds the rolled-up report data in the file.
- **`patchwave-report.zip`** — the same HTML report plus every raw data slice behind it, one JSON file per slice. This is the artifact to send back when you want a deeper look from contextbridge.

The zip contains:

Expand All @@ -86,19 +87,19 @@ data/contributors.json — active human committers per repo
data/warnings.json — per-collector warnings suppressed during the crawl
```

Nothing in the report or bundle leaves your machine unless you choose to share it. The archive does not include tokens, secrets, or repository file contents.
Nothing in the report or bundle leaves your machine unless you choose to share it. At the end of a run, you can keep everything local, share only the HTML report, or share the HTML report plus the raw-data zip. The archive does not include tokens, secrets, or repository file contents.

## What it does not do

- It does not upload the report or any GitHub data. It only reads from `api.github.com`. Filesystem writes are limited to the `<basename>.html` / `<basename>.zip` pair under `--out` and a one-time anonymous-id file (see Telemetry).
- It does not upload the report or any GitHub data unless you choose to share the generated artifacts. It reads from `api.github.com`. Filesystem writes are limited to the `patchwave-report.html` / `patchwave-report.zip` pair in a temporary directory and a one-time anonymous-id file (see Telemetry).
- It does not keep a Markdown compatibility report.
- It does not auto-update.

## Telemetry

The CLI sends anonymous product analytics (PostHog) to help us understand how it's used. A random UUID is stored at `$XDG_CONFIG_HOME/contextbridge/anonymous_id` or `~/.config/contextbridge/anonymous_id` and shared across contextbridge tools. **Org names, repo names, tokens, and report contents are never sent** — only event counts and timings.

Events captured: `run_started` (window size, whether include/exclude was used), `run_completed` (repo/PR/warning counts and duration), `run_failed` (error kind and duration).
We capture coarse usage events — when a run starts, finishes, or fails, and the choices you make at the share and open prompts — along with aggregate counts (such as repos, PRs, and warnings), durations, and error kinds.

Opt out by setting any of:

Expand Down
38 changes: 38 additions & 0 deletions bun.lock

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

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,15 @@
"vitest": "^4.0.18"
},
"dependencies": {
"@clack/prompts": "^1.4.0",
"@js-temporal/polyfill": "^0.5.1",
"@octokit/graphql": "^9.0.3",
"@octokit/plugin-retry": "^8.1.0",
"@octokit/plugin-throttling": "^11.0.3",
"@octokit/rest": "^22.0.1",
"fflate": "^0.8.3",
"neverthrow": "^8.2.0",
"open": "^11.0.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"posthog-node": "^5.35.1",
Expand Down
8 changes: 8 additions & 0 deletions src/BaseIo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,24 @@ export interface Io {
readonly stderr: Writer;
writeStdout(chunk: string): void;
writeStderr(chunk: string): void;
isTty(): boolean;
}

export interface BaseIoOptions {
readonly stdout: Writer;
readonly stderr: Writer;
readonly isTty?: boolean;
}

export abstract class BaseIo implements Io {
readonly stdout: Writer;
readonly stderr: Writer;
readonly #isTty: boolean;

protected constructor(options: BaseIoOptions) {
this.stdout = options.stdout;
this.stderr = options.stderr;
this.#isTty = options.isTty ?? Boolean(options.stdout.isTTY);
}

writeStdout(chunk: string): void {
Expand All @@ -39,4 +43,8 @@ export abstract class BaseIo implements Io {
writeStderr(chunk: string): void {
this.stderr.write(chunk);
}

isTty(): boolean {
return this.#isTty;
}
}
28 changes: 28 additions & 0 deletions src/BrowserOpener.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test';
import { BrowserOpenerImpl } from './BrowserOpener.ts';

describe('BrowserOpenerImpl', () => {
test('calls the injected opener with the target and resolves ok', async () => {
const calls: string[] = [];
const opener = new BrowserOpenerImpl({
open: (target) => {
calls.push(target);
return Promise.resolve();
},
});

const result = await opener.open('/tmp/report.html');

expect(result.isOk()).toBe(true);
expect(calls).toEqual(['/tmp/report.html']);
});

test('maps a rejected open into an open-failed error', async () => {
const opener = new BrowserOpenerImpl({ open: () => Promise.reject(new Error('no browser')) });

const result = await opener.open('/tmp/report.html');

expect(result.isErr()).toBe(true);
expect(result._unsafeUnwrapErr()).toMatchObject({ kind: 'open-failed', message: 'no browser' });
});
});
34 changes: 34 additions & 0 deletions src/BrowserOpener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ResultAsync } from 'neverthrow';
import open from 'open';
import { toError } from './errors.ts';

export type BrowserOpenError = { kind: 'open-failed'; message: string };

export interface BrowserOpener {
open(target: string): ResultAsync<void, BrowserOpenError>;
}

export type OpenFn = (target: string) => Promise<unknown>;

export interface BrowserOpenerImplOptions {
readonly open?: OpenFn;
}

export class BrowserOpenerImpl implements BrowserOpener {
readonly #open: OpenFn;

constructor(options: BrowserOpenerImplOptions = {}) {
this.#open = options.open ?? open;
}

open(target: string): ResultAsync<void, BrowserOpenError> {
return ResultAsync.fromPromise(
this.#open(target).then(() => undefined),
(e): BrowserOpenError => ({ kind: 'open-failed', message: toError(e).message }),
);
}
}

export function formatBrowserOpenError(err: BrowserOpenError): string {
return `couldn't open your browser: ${err.message}`;
}
Loading
Loading