diff --git a/AGENTS.md b/AGENTS.md index f3e0498..2085a88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org. It runs in the user's environment, crawls `api.github.com`, and writes a self-contained HTML report to a temporary directory. No data leaves the user's network unless they choose to share the generated report. -The single entrypoint is `patchwave-analysis []` — an interactive session that prompts for the target if omitted, then whether to share the report when the scan finishes. There are no other flags; the time window (90 days) is fixed. +The single entrypoint is `patchwave-analysis []` — an interactive session that prompts for the target if omitted, then opens the generated report when the scan finishes. There are no other flags; the time window (90 days) is fixed. ## Stack @@ -24,7 +24,6 @@ patchwave-analysis/ │ ├── interactive/ Clack prompts: token walkthrough, share/open, banner, TTY gate │ ├── prompt/ Prompter abstraction over @clack/prompts │ ├── report/ report aggregation + `report/web/` React UI -│ ├── upload/ artifact sharing │ ├── testHelpers/ shared test utilities │ ├── context.ts CliContext DI root (see Conventions) │ ├── cli.ts arg parsing + main() diff --git a/README.md b/README.md index d1ce4cc..0ea48cd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # [PatchWave](https://patchwave.ai) Analysis -PatchWave Analysis is a free diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org. It reads from the GitHub API and writes a self-contained HTML report you can use on its own, no PatchWave account needed. +PatchWave Analysis is a diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org. It reads from the GitHub API and writes a self-contained HTML report you can use on its own, no PatchWave account needed. ## Run it @@ -77,7 +77,7 @@ The report is one self-contained file with every metric baked in. It carries no ## Telemetry & privacy -We send anonymous usage events and crash reports so we can improve the tool. Org names, repo names, tokens, report contents, and your hostname are never sent. We also do not ask if you want to share the report with us if you opt out of telemetry. +We send anonymous usage events and crash reports so we can improve the tool. Org names, repo names, tokens, report contents, and your hostname are never sent. The generated report stays on your machine. To disable telemetry, set any of these in your environment: diff --git a/src/cli.test.ts b/src/cli.test.ts index 302b402..1d693d1 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -82,7 +82,6 @@ test('writes a report when the GitHub calls succeed', async () => { expect(result.code).toBe(0); // Output lands in a temp dir, not the CWD; locate it via the returned paths. - expect(result.run.target).toBe('acme'); expect(result.run.htmlPath.endsWith('patchwave-report.html')).toBe(true); const written = fs.read(result.run.htmlPath); @@ -93,9 +92,6 @@ test('writes a report when the GitHub calls succeed', async () => { const embedded = JSON.parse(match?.[1] ?? '') as { meta: { org: string } }; expect(embedded.meta.org).toBe('acme'); - // The completed run hands the html back so the caller (index.ts) can drive - // the share prompt without re-reading the filesystem. - expect(result.run.html).toContain(' { - calls.push({ url, init }); - const next = queue.shift(); - if (!next) throw new Error('no more responses'); - return Promise.resolve(next); - }; - return { fetch: fetchFn, calls }; -} - -function presignResponse() { - return new Response(JSON.stringify(presignResponseBody.build()), { status: 200 }); -} - -describe('UploaderImpl', () => { - test('posts owner/email metadata and PUTs raw html bytes with text/html', async () => { - const { fetch, calls } = recordFetch([presignResponse(), new Response('', { status: 200 })]); - const bytes = htmlBytes.build(); - - const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload(uploadInput.build({ bytes })); - - expect(result.isOk()).toBe(true); - expect(result.unwrapOr(null)).toEqual({ uploadId: 'uuid-1' }); - - expect(calls[0]?.url).toBe(ENDPOINT); - expect(calls[0]?.init?.method).toBe('POST'); - const postBody = JSON.parse(calls[0]?.init?.body as string) as Record; - expect(postBody).toMatchObject({ - owner: 'acme', - email: 'ben@example.com', - appVersion: '0.0.1', - timestamp: '2026-05-22T12:00:00Z', - sizeBytes: bytes.byteLength, - }); - - expect(calls[1]?.url).toBe(presignResponseBody.build().presignedUrl); - expect(calls[1]?.init?.method).toBe('PUT'); - expect(calls[1]?.init?.body).toBe(bytes as BodyInit); - expect((calls[1]?.init?.headers as Record)['content-type']).toBe('text/html'); - }); - - test('presign returns non-2xx → presign-bad-status', async () => { - const { fetch } = recordFetch([new Response('rate limited', { status: 429 })]); - const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload(uploadInput.build()); - - expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr()).toEqual({ - kind: 'presign-bad-status', - status: 429, - body: 'rate limited', - }); - }); - - test('presign returns malformed JSON → presign-bad-response', async () => { - const { fetch } = recordFetch([new Response('{not json', { status: 200 })]); - const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload(uploadInput.build()); - - expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr().kind).toBe('presign-bad-response'); - }); - - test('presign returns JSON missing required fields → presign-bad-response', async () => { - const { fetch } = recordFetch([new Response(JSON.stringify({ uploadId: 'x' }), { status: 200 })]); - const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload(uploadInput.build()); - - expect(result.isErr()).toBe(true); - const err = result._unsafeUnwrapErr(); - expect(err.kind).toBe('presign-bad-response'); - if (err.kind === 'presign-bad-response') { - expect(err.message).toContain('presignedUrl'); - } - }); - - test('S3 PUT returns non-2xx → s3-bad-status', async () => { - const { fetch } = recordFetch([presignResponse(), new Response('access denied', { status: 403 })]); - const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload(uploadInput.build()); - - expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr()).toEqual({ - kind: 's3-bad-status', - status: 403, - body: 'access denied', - }); - }); - - test('S3 PUT XML error → parses s3Code and requestId', async () => { - const xml = - 'AccessDenied' + - 'Access DeniedABC123XYZ' + - 'hostid=='; - const { fetch } = recordFetch([presignResponse(), new Response(xml, { status: 403 })]); - const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload(uploadInput.build()); - - expect(result.isErr()).toBe(true); - const err = result._unsafeUnwrapErr(); - expect(err).toMatchObject({ kind: 's3-bad-status', status: 403, s3Code: 'AccessDenied', requestId: 'ABC123XYZ' }); - }); - - test('retries a transient S3 5xx, then succeeds', async () => { - const { fetch, calls } = recordFetch([ - presignResponse(), - new Response('SlowDown', { status: 503 }), - new Response('', { status: 200 }), - ]); - const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch, retryMinTimeoutMs: 0 }).upload( - uploadInput.build(), - ); - - expect(result.isOk()).toBe(true); - expect(calls.length).toBe(3); // presign + 2 PUT attempts - }); - - test('retries a network failure during PUT, then succeeds', async () => { - const responses = [presignResponse(), 'throw' as const, new Response('', { status: 200 })]; - let calls = 0; - const fetchFn: FetchFn = () => { - const next = responses[calls++]; - if (next === 'throw') return Promise.reject(new Error('econnreset')); - return Promise.resolve(next as Response); - }; - const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch: fetchFn, retryMinTimeoutMs: 0 }).upload( - uploadInput.build(), - ); - - expect(result.isOk()).toBe(true); - expect(calls).toBe(3); // presign + failed PUT + retried PUT - }); - - test('does not retry a non-transient 403 from S3', async () => { - const { fetch, calls } = recordFetch([presignResponse(), new Response('access denied', { status: 403 })]); - const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch, retryMinTimeoutMs: 0 }).upload( - uploadInput.build(), - ); - - expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr().kind).toBe('s3-bad-status'); - expect(calls.length).toBe(2); // presign + single PUT, no retry - }); - - test('exhausts attempts on a persistent transient failure', async () => { - const { fetch, calls } = recordFetch([ - presignResponse(), - new Response('InternalError', { status: 500 }), - new Response('InternalError', { status: 500 }), - ]); - const result = await new UploaderImpl({ - endpoint: ENDPOINT, - fetch, - maxAttempts: 2, - retryMinTimeoutMs: 0, - }).upload(uploadInput.build()); - - expect(result.isErr()).toBe(true); - const err = result._unsafeUnwrapErr(); - expect(err).toMatchObject({ kind: 's3-bad-status', status: 500, s3Code: 'InternalError' }); - expect(calls.length).toBe(3); // presign + 2 PUT attempts - }); - - test('network failure during presign → presign-request-failed', async () => { - const fetchFn: FetchFn = () => Promise.reject(new Error('econnreset')); - const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch: fetchFn }).upload(uploadInput.build()); - - expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr()).toEqual({ - kind: 'presign-request-failed', - message: 'econnreset', - }); - }); -}); diff --git a/src/context/Uploader.ts b/src/context/Uploader.ts deleted file mode 100644 index eec2113..0000000 --- a/src/context/Uploader.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { ResultAsync, errAsync, okAsync } from 'neverthrow'; -import { toError } from '../errors.ts'; - -const DEFAULT_UPLOAD_ENDPOINT = 'https://api.patchwave.ai/v1/uploads/analysis-bundle'; - -export type UploadError = - | { kind: 'presign-request-failed'; message: string } - | { kind: 'presign-bad-status'; status: number; body: string } - | { kind: 'presign-bad-response'; message: string } - | { kind: 's3-put-failed'; message: string } - | { kind: 's3-bad-status'; status: number; body: string; s3Code?: string; requestId?: string }; - -export interface UploadInput { - readonly bytes: Uint8Array; - readonly owner: string; - readonly email: string; - readonly appVersion: string; - readonly timestamp: string; -} - -export interface UploadResult { - readonly uploadId: string; -} - -export interface Uploader { - upload(input: UploadInput): ResultAsync; -} - -export type FetchFn = (url: string, init?: RequestInit) => Promise; - -interface UploaderImplOptions { - readonly endpoint?: string; - readonly fetch?: FetchFn; - readonly maxAttempts?: number; - readonly retryMinTimeoutMs?: number; -} - -interface PresignResponse { - readonly uploadId: string; - readonly presignedUrl: string; - readonly expiresAt: string; -} - -export class UploaderImpl implements Uploader { - readonly #endpoint: string; - readonly #fetch: FetchFn; - readonly #maxAttempts: number; - readonly #retryMinTimeoutMs: number; - - constructor(options: UploaderImplOptions = {}) { - this.#endpoint = options.endpoint ?? DEFAULT_UPLOAD_ENDPOINT; - this.#fetch = options.fetch ?? fetch; - this.#maxAttempts = options.maxAttempts ?? 4; - this.#retryMinTimeoutMs = options.retryMinTimeoutMs ?? 500; - } - - upload(input: UploadInput): ResultAsync { - return this.#requestPresign(input).andThen((presign) => - this.#putToS3WithRetry(presign.presignedUrl, input.bytes).map(() => ({ uploadId: presign.uploadId })), - ); - } - - // The bytes are buffered in memory, so the PUT is safely replayable. Retry only - // the transient S3 outcomes (RequestTimeout/SlowDown/5xx, network errors) — - // see isRetryableUploadError; 4xx signature/permission failures fail fast. - // Backoff is exponential from retryMinTimeoutMs; attempts cap at maxAttempts. - #putToS3WithRetry(url: string, bytes: Uint8Array, attempt = 1): ResultAsync { - return this.#putToS3(url, bytes).orElse((err) => { - if (attempt >= this.#maxAttempts || !isRetryableUploadError(err)) { - return errAsync(err); - } - return delay(this.#retryMinTimeoutMs * 2 ** (attempt - 1)).andThen(() => - this.#putToS3WithRetry(url, bytes, attempt + 1), - ); - }); - } - - #requestPresign(input: UploadInput): ResultAsync { - const body = JSON.stringify({ - owner: input.owner, - email: input.email, - appVersion: input.appVersion, - timestamp: input.timestamp, - sizeBytes: input.bytes.byteLength, - }); - return ResultAsync.fromPromise( - this.#fetch(this.#endpoint, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body, - }), - (e): UploadError => ({ kind: 'presign-request-failed', message: toError(e).message }), - ).andThen((res) => { - if (!res.ok) { - return ResultAsync.fromSafePromise(res.text().catch(() => '')).andThen((text) => - errAsync({ kind: 'presign-bad-status', status: res.status, body: text }), - ); - } - return ResultAsync.fromPromise( - res.json(), - (e): UploadError => ({ kind: 'presign-bad-response', message: toError(e).message }), - ).andThen(parsePresign); - }); - } - - #putToS3(url: string, bytes: Uint8Array): ResultAsync { - return ResultAsync.fromPromise( - this.#fetch(url, { - method: 'PUT', - headers: { 'content-type': 'text/html' }, - // The DOM lib's `BodyInit` narrows `BufferSource` to `Uint8Array`, - // but our bytes are `Uint8Array`. fetch accepts them at runtime. - body: bytes as BodyInit, - }), - (e): UploadError => ({ kind: 's3-put-failed', message: toError(e).message }), - ).andThen((res) => { - if (!res.ok) { - return ResultAsync.fromSafePromise(res.text().catch(() => '')).andThen((text) => - errAsync({ kind: 's3-bad-status', status: res.status, body: text, ...parseS3Error(text) }), - ); - } - return okAsync(undefined); - }); - } -} - -function parsePresign(value: unknown): ResultAsync { - if (!isObject(value)) { - return errAsync({ - kind: 'presign-bad-response', - message: 'response was not a JSON object', - }); - } - const uploadId = value['uploadId']; - const presignedUrl = value['presignedUrl']; - const expiresAt = value['expiresAt']; - if (typeof uploadId !== 'string' || typeof presignedUrl !== 'string' || typeof expiresAt !== 'string') { - return errAsync({ - kind: 'presign-bad-response', - message: 'response missing uploadId/presignedUrl/expiresAt', - }); - } - return okAsync({ uploadId, presignedUrl, expiresAt }); -} - -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -const delay = (ms: number): ResultAsync => - ResultAsync.fromSafePromise(new Promise((resolve) => setTimeout(resolve, ms))); - -function isRetryableUploadError(err: UploadError): boolean { - switch (err.kind) { - case 's3-put-failed': - return true; - case 's3-bad-status': - return err.status === 429 || err.status >= 500 || err.s3Code === 'RequestTimeout'; - default: - return false; - } -} - -// S3 errors are XML; we pull the machine-readable and (cause + AWS -// support handle) but drop the body, which echoes the owner/email object key. -function parseS3Error(body: string): { s3Code?: string; requestId?: string } { - const s3Code = body.match(/([^<]+)<\/Code>/)?.[1]; - const requestId = body.match(/([^<]+)<\/RequestId>/)?.[1]; - return { ...(s3Code ? { s3Code } : {}), ...(requestId ? { requestId } : {}) }; -} - -// Privacy-safe telemetry: omits the raw S3 body and presigned URL (both embed the -// owner/email object key). Network error messages are safe to include. -export function uploadErrorTelemetry(err: UploadError): Record { - switch (err.kind) { - case 'presign-request-failed': - case 'presign-bad-response': - case 's3-put-failed': - return { error_kind: err.kind, error_message: err.message.slice(0, 300) }; - case 'presign-bad-status': - return { error_kind: err.kind, status: err.status }; - case 's3-bad-status': - return { - error_kind: err.kind, - status: err.status, - ...(err.s3Code ? { s3_code: err.s3Code } : {}), - ...(err.requestId ? { request_id: err.requestId } : {}), - }; - } -} - -export function formatUploadError(err: UploadError): string { - switch (err.kind) { - case 'presign-request-failed': - return `failed to reach upload service: ${err.message}`; - case 'presign-bad-status': - return `upload service returned ${err.status}: ${err.body || '(empty body)'}`; - case 'presign-bad-response': - return `upload service returned an unexpected response: ${err.message}`; - case 's3-put-failed': - return `failed to upload to S3: ${err.message}`; - case 's3-bad-status': - return `S3 returned ${err.status}${err.s3Code ? ` (${err.s3Code})` : ''}: ${err.body || '(empty body)'}`; - } -} diff --git a/src/context/index.ts b/src/context/index.ts index cc11a11..d928ecd 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -14,8 +14,6 @@ import { IoImpl } from './IoImpl.ts'; import { type Logger, createLogger } from './Logger.ts'; import type { Prompter } from './Prompter.ts'; import { PrompterImpl } from './Prompter.ts'; -import type { Uploader } from './Uploader.ts'; -import { UploaderImpl } from './Uploader.ts'; export interface Context { readonly io: Io; @@ -26,7 +24,6 @@ export interface Context { readonly githubClient: GithubClient; readonly analytics: Analytics; readonly prompter: Prompter; - readonly uploader: Uploader; readonly browserOpener: BrowserOpener; readonly appVersion: string; // Anonymous telemetry id of this machine ('' when telemetry is disabled). Embedded into the @@ -51,7 +48,6 @@ export function createContext(options: CreateContextOptions = {}): Context { githubClient = new GithubClientImpl({ token, logger }), analytics = new NoopAnalytics(), prompter = new PrompterImpl(), - uploader = new UploaderImpl(), browserOpener = new BrowserOpenerImpl(), anonymousId = '', telemetryDisabled = isTelemetryDisabled(env), @@ -66,7 +62,6 @@ export function createContext(options: CreateContextOptions = {}): Context { githubClient, analytics, prompter, - uploader, browserOpener, appVersion, anonymousId, diff --git a/src/context/testFactories.ts b/src/context/testFactories.ts deleted file mode 100644 index 3ce3384..0000000 --- a/src/context/testFactories.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Factory } from 'fishery'; -import type { UploadInput } from './Uploader.ts'; - -export const htmlBytes = Factory.define(() => new TextEncoder().encode('')); - -export const uploadInput = Factory.define(() => ({ - bytes: htmlBytes.build(), - owner: 'acme', - email: 'ben@example.com', - appVersion: '0.0.1', - timestamp: '2026-05-22T12:00:00Z', -})); - -interface PresignResponseBody { - readonly uploadId: string; - readonly presignedUrl: string; - readonly expiresAt: string; -} - -export const presignResponseBody = Factory.define(() => ({ - uploadId: 'uuid-1', - presignedUrl: 'https://s3.test/some-bucket/abc?signed=1', - expiresAt: '2026-05-22T13:00:00Z', -})); diff --git a/src/index.ts b/src/index.ts index 73c4893..732925b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,10 +7,8 @@ import { createContext } from './context/index.ts'; import { IoImpl } from './context/IoImpl.ts'; import { createLogger } from './context/Logger.ts'; import { PrompterImpl } from './context/Prompter.ts'; -import { UploaderImpl } from './context/Uploader.ts'; import { welcomeBannerBody, welcomeBannerTitle } from './interactive/banner.ts'; import { openReport } from './interactive/openReport.ts'; -import { runSharePrompt, shouldRequestReportShare, showLocalReportReadyNotice } from './interactive/sharePrompt.ts'; import { formatInteractiveTokenError, interactiveResolveToken } from './interactive/tokenWalkthrough.ts'; import { enforceTty } from './interactive/ttyGate.ts'; import { type Analytics, NoopAnalytics } from './telemetry/Analytics.ts'; @@ -94,7 +92,6 @@ const ctx = createContext({ logger, analytics, prompter, - uploader: new UploaderImpl(), anonymousId, telemetryDisabled, }); @@ -104,20 +101,6 @@ try { if (result.kind === 'completed') { await openReport({ context: ctx, htmlPath: result.run.htmlPath }); - if (shouldRequestReportShare(ctx)) { - await runSharePrompt({ - context: ctx, - target: result.run.target, - htmlPath: result.run.htmlPath, - htmlContent: result.run.html, - }); - } else { - showLocalReportReadyNotice({ - context: ctx, - target: result.run.target, - htmlPath: result.run.htmlPath, - }); - } } await shutdown(); diff --git a/src/interactive/openReport.ts b/src/interactive/openReport.ts index fc72ff5..6f5a597 100644 --- a/src/interactive/openReport.ts +++ b/src/interactive/openReport.ts @@ -6,12 +6,7 @@ interface OpenReportInputs { readonly htmlPath: string; } -/** - * Open the freshly generated report in the user's browser, before the share - * prompt so they can eyeball it before deciding whether to send it. A failed - * launch degrades to a warning that points at the file on disk; either way the - * share prompt that follows prints the path. Never aborts the run. - */ +/** Opens the report, degrading to its on-disk path when the browser cannot launch. */ export async function openReport(inputs: OpenReportInputs): Promise { const { prompter, analytics, browserOpener } = inputs.context; diff --git a/src/interactive/sharePrompt.test.ts b/src/interactive/sharePrompt.test.ts deleted file mode 100644 index 924fc91..0000000 --- a/src/interactive/sharePrompt.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { fakeContextHandle } from '../testHelpers/testFactories.ts'; -import { runSharePrompt, shouldRequestReportShare, showLocalReportReadyNotice } from './sharePrompt.ts'; -import { sharePromptInputsFor } from './testFactories.ts'; - -describe('report sharing prompt', () => { - test('does not request report sharing when do not track disables telemetry', () => { - const handle = fakeContextHandle.build(); - const context = { ...handle.ctx, env: { ...handle.ctx.env, DO_NOT_TRACK: true }, telemetryDisabled: true }; - - showLocalReportReadyNotice({ context, target: 'acme', htmlPath: '/tmp/report.html' }); - - expect(shouldRequestReportShare(context)).toBe(false); - expect(handle.prompter.selects).toHaveLength(0); - expect(handle.prompter.texts).toHaveLength(0); - expect(handle.uploader.calls).toHaveLength(0); - expect(handle.prompter.notes[0]).toMatchObject({ - title: 'Report ready', - }); - expect(handle.prompter.notes[0]?.message).toContain('Nothing was uploaded because tracking is disabled.'); - }); - test('shows the report path and a clear share question, defaulting to sharing', async () => { - const handle = fakeContextHandle.build(); - handle.prompter.scriptSelect('declined'); - - await runSharePrompt(sharePromptInputsFor(handle)); - - const reportReadyNote = handle.prompter.notes.find((n) => n.title === 'Report ready'); - expect(reportReadyNote?.message).toContain('acme'); - expect(reportReadyNote?.message).toContain('/tmp/report.html'); - expect(reportReadyNote?.message).not.toContain('Open it to see what would be sent'); - expect(handle.prompter.selects[0]?.message).toContain('call with us'); - expect(handle.prompter.selects[0]?.message).toContain("We'll upload exactly what's on disk"); - expect(handle.prompter.selects[0]?.message).toContain("won't share your data"); - expect(handle.prompter.selects[0]?.choices.map((c) => c.value)).toEqual(['html', 'declined']); - expect(handle.prompter.selects[0]?.initialValue).toBe('html'); - }); - - test("declined: doesn't upload, points to founders + patchwave.ai", async () => { - const handle = fakeContextHandle.build(); - handle.prompter.scriptSelect('declined'); - - const outcome = await runSharePrompt(sharePromptInputsFor(handle)); - - expect(outcome).toEqual({ kind: 'declined' }); - expect(handle.uploader.calls).toHaveLength(0); - const allSetNote = handle.prompter.notes.find((n) => n.title === 'All set'); - expect(allSetNote?.message).toContain('founders@contextbridge.ai'); - expect(allSetNote?.message).toContain('patchwave.ai'); - expect(handle.analytics.capturedEvents('share_choice')[0]?.properties).toMatchObject({ choice: 'declined' }); - }); - - test('html-only: uploads the raw html bytes with owner and email', async () => { - const handle = fakeContextHandle.build(); - handle.prompter.scriptSelect('html').scriptText('ben@example.com'); - - const outcome = await runSharePrompt(sharePromptInputsFor(handle)); - - expect(outcome).toMatchObject({ kind: 'shared', email: 'ben@example.com' }); - expect(handle.uploader.calls).toHaveLength(1); - expect(handle.uploader.calls[0]).toMatchObject({ - owner: 'acme', - email: 'ben@example.com', - appVersion: '0.0.1', - timestamp: '2026-05-22T12:00:00Z', - }); - expect(new TextDecoder().decode(handle.uploader.calls[0]?.bytes)).toBe(''); - expect(handle.analytics.capturedEvents('upload_succeeded')).toHaveLength(1); - expect(handle.prompter.outros[0]).toContain("we've got your report"); - expect(handle.prompter.outros[0]).toContain('ben@example.com'); - expect(handle.prompter.outros[0]?.toLowerCase()).not.toContain('upload id'); - expect(handle.prompter.outros[0]).not.toContain('fake-upload-id'); - }); - - test('email prompt requires a valid email address', async () => { - const handle = fakeContextHandle.build(); - handle.prompter.scriptSelect('html').scriptText('ben@example.com'); - - await runSharePrompt(sharePromptInputsFor(handle)); - - const validate = handle.prompter.texts[0]?.validate; - expect(handle.prompter.texts[0]?.message).toBe('Email:'); - expect(validate?.('')).toContain('email'); - expect(validate?.('not an email')).toBeDefined(); - expect(validate?.('ben@example.com')).toBeUndefined(); - }); - - test('upload failure surfaces the error and leaves the report in place', async () => { - const handle = fakeContextHandle.build(); - handle.prompter.scriptSelect('html').scriptText('ben@example.com'); - handle.uploader.fails({ kind: 'presign-bad-status', status: 500, body: 'boom' }); - - const outcome = await runSharePrompt(sharePromptInputsFor(handle)); - - expect(outcome.kind).toBe('upload-failed'); - if (outcome.kind === 'upload-failed') { - expect(outcome.message).toContain('500'); - } - expect(handle.analytics.capturedEvents('upload_failed')[0]?.properties).toMatchObject({ - error_kind: 'presign-bad-status', - }); - const failureNote = handle.prompter.notes.find((n) => n.title === "We couldn't upload"); - expect(failureNote?.message).toContain('/tmp/report.html'); - expect(failureNote?.message).toContain('founders@contextbridge.ai'); - }); - - test('user cancellation at the choice prompt is treated as declined', async () => { - const handle = fakeContextHandle.build(); - handle.prompter.scriptSelect({ kind: 'cancelled' }); - - const outcome = await runSharePrompt(sharePromptInputsFor(handle)); - - expect(outcome).toEqual({ kind: 'cancelled' }); - expect(handle.uploader.calls).toHaveLength(0); - expect(handle.analytics.capturedEvents('share_choice')[0]?.properties).toMatchObject({ choice: 'cancelled' }); - }); -}); diff --git a/src/interactive/sharePrompt.ts b/src/interactive/sharePrompt.ts deleted file mode 100644 index 3a522d9..0000000 --- a/src/interactive/sharePrompt.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Context } from '../context/index.ts'; -import { type Prompter, formatPromptError } from '../context/Prompter.ts'; -import { formatUploadError, uploadErrorTelemetry } from '../context/Uploader.ts'; - -const SUPPORT_LINE = 'Reach us at founders@contextbridge.ai — or learn more at https://patchwave.ai'; - -type ShareChoice = 'html' | 'declined'; - -interface ReportReadyNoticeInputs { - readonly context: Context; - readonly target: string; - readonly htmlPath: string; -} - -export interface SharePromptInputs extends ReportReadyNoticeInputs { - readonly htmlContent: string; -} - -type ShareOutcome = - | { kind: 'shared'; uploadId: string; email: string } - | { kind: 'declined' } - | { kind: 'cancelled' } - | { kind: 'upload-failed'; message: string }; - -export function shouldRequestReportShare(context: Context): boolean { - return !context.telemetryDisabled; -} - -export function showLocalReportReadyNotice(inputs: ReportReadyNoticeInputs): void { - const { prompter } = inputs.context; - prompter.note( - [ - `Scanned: ${inputs.target}`, - `HTML report: ${inputs.htmlPath}`, - '', - 'Nothing was uploaded because tracking is disabled.', - ].join('\n'), - 'Report ready', - ); - prompter.outro('Done.'); -} - -export async function runSharePrompt(inputs: SharePromptInputs): Promise { - const { prompter, analytics, uploader, logger } = inputs.context; - - prompter.note([`Scanned: ${inputs.target}`, `HTML report: ${inputs.htmlPath}`].join('\n'), 'Report ready'); - - analytics.capture('share_prompt_shown', {}); - - const choiceResult = await prompter.select({ - message: - "Share this report with PatchWave? If you've got a call with us coming up, sharing it first lets us dig into your numbers before we talk. We'll upload exactly what's on disk and won't share your data with anyone.", - initialValue: 'html', - choices: [ - { value: 'html', label: 'Share the HTML report', hint: "we'll review it before your call" }, - { value: 'declined', label: 'No thanks — keep it local', hint: 'nothing leaves your machine' }, - ], - }); - - if (choiceResult.isErr()) { - analytics.capture('share_choice', { choice: 'cancelled' }); - if (choiceResult.error.kind !== 'cancelled') prompter.warn(formatPromptError(choiceResult.error)); - declinedOutro(inputs); - return { kind: 'cancelled' }; - } - - const choice = choiceResult.value; - analytics.capture('share_choice', { choice }); - - if (choice === 'declined') { - declinedOutro(inputs); - return { kind: 'declined' }; - } - - const emailResult = await askForEmail(prompter); - if (emailResult.kind === 'cancelled') { - declinedOutro(inputs); - return { kind: 'cancelled' }; - } - const email = emailResult.email; - - const spinner = prompter.spinner(); - spinner.start('Uploading...'); - const uploadResult = await uploader.upload({ - bytes: new TextEncoder().encode(inputs.htmlContent), - owner: inputs.target, - email, - appVersion: inputs.context.appVersion, - timestamp: inputs.context.clock.now().toString(), - }); - - if (uploadResult.isErr()) { - const message = formatUploadError(uploadResult.error); - spinner.stop('Upload failed.'); - analytics.capture('upload_failed', uploadErrorTelemetry(uploadResult.error)); - logger.warn({ err: uploadResult.error }, 'Analysis upload failed'); - prompter.error(message); - prompter.note( - [`Your local report is unchanged:`, ` ${inputs.htmlPath}`, '', SUPPORT_LINE].join('\n'), - "We couldn't upload", - ); - return { kind: 'upload-failed', message }; - } - - const { uploadId } = uploadResult.value; - spinner.stop('Uploaded.'); - analytics.capture('upload_succeeded', {}); - prompter.outro(`Thanks — we've got your report. We'll review it before your call and reach you at ${email}.`); - return { kind: 'shared', uploadId, email }; -} - -function declinedOutro(inputs: SharePromptInputs): void { - const { prompter } = inputs.context; - prompter.note( - [ - `No worries — nothing was uploaded. Your report lives here:`, - ` ${inputs.htmlPath}`, - '', - `Want help cutting your Dependabot burden? ${SUPPORT_LINE}`, - ].join('\n'), - 'All set', - ); - prompter.outro('Done.'); -} - -async function askForEmail(prompter: Prompter): Promise<{ kind: 'ok'; email: string } | { kind: 'cancelled' }> { - const result = await prompter.text({ - message: 'Email:', - placeholder: 'you@example.com', - validate: (value) => { - const trimmed = value.trim(); - if (trimmed.length === 0) return 'Please enter an email address so we can follow up.'; - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed) ? undefined : "that doesn't look like an email address"; - }, - }); - - if (result.isErr()) { - if (result.error.kind === 'cancelled') return { kind: 'cancelled' }; - prompter.warn(formatPromptError(result.error)); - return { kind: 'cancelled' }; - } - - const trimmed = result.value.trim(); - return { kind: 'ok', email: trimmed }; -} diff --git a/src/interactive/testFactories.ts b/src/interactive/testFactories.ts index b9d0815..7bcee53 100644 --- a/src/interactive/testFactories.ts +++ b/src/interactive/testFactories.ts @@ -1,23 +1,5 @@ import { Factory } from 'fishery'; import type { AuthError } from '../github/auth.ts'; -import type { FakeContextHandle } from '../testHelpers/index.ts'; -import { fakeContextHandle } from '../testHelpers/testFactories.ts'; -import type { SharePromptInputs } from './sharePrompt.ts'; - -const sharePromptInputs = Factory.define(() => { - const handle = fakeContextHandle.build(); - return { - context: handle.ctx, - target: 'acme', - htmlPath: '/tmp/report.html', - htmlContent: '', - }; -}); - -export const sharePromptInputsFor = ( - handle: FakeContextHandle, - overrides: Partial = {}, -): SharePromptInputs => sharePromptInputs.build({ context: handle.ctx, ...overrides }); export const githubViewer = Factory.define<{ login: string }>(() => ({ login: 'ben', diff --git a/src/report/web/App.browser.test.tsx b/src/report/web/App.browser.test.tsx index b318aa1..4c8cf49 100644 --- a/src/report/web/App.browser.test.tsx +++ b/src/report/web/App.browser.test.tsx @@ -24,6 +24,7 @@ import { INSTALL_COMMAND } from './lib/installCommand.ts'; import { commandBlockTestIds } from './primitives/CommandBlock.tsx'; import { costReceiptCopy, costReceiptTestIds } from './primitives/CostReceipt.tsx'; import { footnoteReferenceTestId } from './primitives/FootnoteReference.tsx'; +import { reportCtaUrls } from './reportCtaUrls.ts'; import type { EmbeddedReportData } from './types.ts'; afterEach(() => { @@ -498,22 +499,37 @@ describe('sections and calls to action', () => { }); it.each([ - { name: 'verdict', testId: verdictTestIds.primaryCta, label: verdictCopy.primaryCta }, - { name: 'automation waitlist', testId: automatedStoryTestIds.waitlistCta, label: callToActionCopy.ctaLabel }, - { name: 'call to action', testId: callToActionTestIds.cta, label: callToActionCopy.ctaLabel }, - ])('points the $name CTA at patchwave.ai', ({ testId, label }) => { + { + name: 'verdict', + testId: verdictTestIds.primaryCta, + label: verdictCopy.primaryCta, + href: reportCtaUrls.verdict, + }, + { + name: 'automation', + testId: automatedStoryTestIds.primaryCta, + label: callToActionCopy.ctaLabel, + href: reportCtaUrls.automation, + }, + { + name: 'call to action', + testId: callToActionTestIds.cta, + label: callToActionCopy.ctaLabel, + href: reportCtaUrls.final, + }, + ])('points the $name CTA at its destination', ({ testId, label, href }) => { renderReport(); const cta = screen.getByTestId(testId); expect(cta).toHaveTextContent(label); - expect(cta).toHaveAttribute('href', 'https://patchwave.ai'); + expect(cta).toHaveAttribute('href', href); }); }); describe('analytics', () => { it.each([ { name: 'verdict', testId: verdictTestIds.primaryCta, which: 'verdict_primary' }, - { name: 'automation waitlist', testId: automatedStoryTestIds.waitlistCta, which: 'automated_story_waitlist' }, + { name: 'automation', testId: automatedStoryTestIds.primaryCta, which: 'automated_story_primary' }, { name: 'call to action', testId: callToActionTestIds.cta, which: 'call_to_action_primary' }, ])('captures cta_clicked for the $name CTA', ({ testId, which }) => { const analytics = new FakeAnalytics(); diff --git a/src/report/web/acts/AutomatedStory.tsx b/src/report/web/acts/AutomatedStory.tsx index 694b501..fb2d221 100644 --- a/src/report/web/acts/AutomatedStory.tsx +++ b/src/report/web/acts/AutomatedStory.tsx @@ -5,6 +5,7 @@ import { Button } from '../components/ui/button.tsx'; import { type Amount, useFormatAmount } from '../format/amount.ts'; import { useAssumptions } from '../hooks/useAssumptions.tsx'; import { Citation } from '../primitives/Citation.tsx'; +import { reportCtaUrls } from '../reportCtaUrls.ts'; import { callToActionCopy } from './CallToAction.tsx'; export const automatedStoryTestIds = { @@ -14,7 +15,7 @@ export const automatedStoryTestIds = { delta: 'automated-story-delta', savingsBreakdown: 'automated-story-savings-breakdown', shareSlider: 'automated-story-share-slider', - waitlistCta: 'automated-story-waitlist-cta', + primaryCta: 'automated-story-primary-cta', } as const; const SHARE_MIN = 50; @@ -109,9 +110,9 @@ export function AutomatedStory() {