diff --git a/SECURITY.md b/SECURITY.md index 0bef1ed..c852269 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,7 +26,7 @@ devcompass is a browser-only single-page app. There is no backend that we operat | Surface | What is stored | Where | | --- | --- | --- | | Login | Raw PAT, ASCII-sanitized | `localStorage["devcompass.pat"]` | -| Cache panel | Token reference key only | IndexedDB `devcompass.tokens` (id `current`) | +| Cache panel | Token-keyed cache rows (masked in the UI) | IndexedDB `devcompass.prefs` | | Network | `Authorization: bearer ` | Request header to `api.github.com`, never to a server we operate | You can wipe everything from **Settings → Storage → Clear all cache**, or by deleting the `devcompass.pat` key and `devcompass` IndexedDB database from your browser. diff --git a/api/_relay.test.ts b/api/_relay.test.ts new file mode 100644 index 0000000..0fb5768 --- /dev/null +++ b/api/_relay.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isAllowedTarget, relay } from './_relay' + +function jsonResponse(body: unknown, init?: { status?: number; headers?: Record }) { + return new Response(JSON.stringify(body), { + status: init?.status ?? 200, + headers: { 'content-type': 'application/json', ...init?.headers }, + }) +} + +describe('isAllowedTarget', () => { + it('allows sentry.io and regional hosts over https', () => { + expect(isAllowedTarget('https://sentry.io/api/0/organizations/')).not.toBeNull() + expect(isAllowedTarget('https://us.sentry.io/api/0/issues/1/')).not.toBeNull() + expect(isAllowedTarget('https://de.sentry.io/api/0/projects/')).not.toBeNull() + }) + + it('rejects non-allowlisted hosts, lookalikes and http', () => { + expect(isAllowedTarget('https://evil.com/api')).toBeNull() + expect(isAllowedTarget('https://sentry.io.evil.com/api')).toBeNull() + expect(isAllowedTarget('https://notsentry.io/api')).toBeNull() + expect(isAllowedTarget('http://sentry.io/api')).toBeNull() + expect(isAllowedTarget('not a url')).toBeNull() + }) +}) + +describe('relay', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('rejects non-allowlisted targets without calling fetch', async () => { + const fetchSpy = vi.fn() + vi.stubGlobal('fetch', fetchSpy) + const res = await relay({ method: 'GET', targetUrl: 'https://evil.com/x', headers: {} }) + expect(res.status).toBe(400) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('rejects disallowed methods', async () => { + const fetchSpy = vi.fn() + vi.stubGlobal('fetch', fetchSpy) + const res = await relay({ method: 'TRACE', targetUrl: 'https://sentry.io/api/0/', headers: {} }) + expect(res.status).toBe(405) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('forwards only the auth/content/accept headers', async () => { + const fetchSpy = vi.fn(async () => jsonResponse({ ok: true })) + vi.stubGlobal('fetch', fetchSpy) + await relay({ + method: 'GET', + targetUrl: 'https://sentry.io/api/0/organizations/', + headers: { authorization: 'Bearer tok', cookie: 'session=1', 'x-forwarded-for': '1.2.3.4', accept: 'application/json' }, + }) + const sent = fetchSpy.mock.calls[0]![1]!.headers as Headers + expect(sent.get('authorization')).toBe('Bearer tok') + expect(sent.get('accept')).toBe('application/json') + expect(sent.get('cookie')).toBeNull() + expect(sent.get('x-forwarded-for')).toBeNull() + }) + + it('strips set-cookie from the relayed response and exposes link', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse([], { + headers: { 'set-cookie': 'sid=abc', link: '; rel="next"; results="true"; cursor="0:100:0"' }, + }))) + const res = await relay({ method: 'GET', targetUrl: 'https://sentry.io/api/0/issues/', headers: {} }) + expect(res.status).toBe(200) + const keys = Object.keys(res.headers).map((k) => k.toLowerCase()) + expect(keys).not.toContain('set-cookie') + expect(res.headers['link']).toContain('cursor="0:100:0"') + expect(res.headers['access-control-expose-headers']).toContain('link') + }) + + it('refuses oversized responses (declared content-length)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('x', { + status: 200, + headers: { 'content-length': String(100 * 1024 * 1024) }, + }))) + const res = await relay({ method: 'GET', targetUrl: 'https://sentry.io/api/0/issues/', headers: {} }) + expect(res.status).toBe(502) + }) +}) diff --git a/api/_relay.ts b/api/_relay.ts index 36d2a78..3e44381 100644 --- a/api/_relay.ts +++ b/api/_relay.ts @@ -69,9 +69,18 @@ function errorResult(status: number, message: string): RelayResult { } } +/** Methods the connectors actually use — everything else is rejected. */ +const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) + +/** Refuse to relay bodies past this size — protects the browser from a hostile upstream. */ +const MAX_RESPONSE_BYTES = 20 * 1024 * 1024 // 20 MB + export async function relay(input: RelayInput): Promise { const url = isAllowedTarget(input.targetUrl) if (!url) return errorResult(400, 'target not allowed (must be https + allowlisted host)') + if (!ALLOWED_METHODS.has(input.method.toUpperCase())) { + return errorResult(405, `method ${input.method} not allowed`) + } const fwd = new Headers() for (const h of FORWARD_REQUEST_HEADERS) { @@ -101,5 +110,15 @@ export async function relay(input: RelayInput): Promise { // browser fetch can read the cursor. headers['access-control-expose-headers'] = 'link, x-hits, x-max-hits' - return { status: upstream.status, headers, body: await upstream.arrayBuffer() } + // Size cap: trust content-length when present; otherwise verify after reading. + const declared = parseInt(upstream.headers.get('content-length') ?? '', 10) + if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) { + return errorResult(502, `upstream response too large (${declared} bytes)`) + } + const body = await upstream.arrayBuffer() + if (body.byteLength > MAX_RESPONSE_BYTES) { + return errorResult(502, `upstream response too large (${body.byteLength} bytes)`) + } + + return { status: upstream.status, headers, body } } diff --git a/docs/TASK_TODO.md b/docs/TASK_TODO.md index d502c59..3ee5a55 100644 --- a/docs/TASK_TODO.md +++ b/docs/TASK_TODO.md @@ -26,14 +26,14 @@ Wave numbering comes from the 2026-06 product audit (architecture + UX + perf/se - [x] **Skeleton states** — shared ScopeSkeleton replaces raw "Loading…" in Issues/Notifications. - [x] **Text filter for long lists** — title/repo filter input in Issues + Notifications (shown past 8 items). -## Pending — Wave 4 "trust before scale" (robustness) +## In progress — Wave 4 "trust before scale" (robustness) -- [ ] **Tests for high-risk modules** — api/_relay allowlist, sentry client (cursor/retry/env-normalize), useUnifiedIssues merge, utils/time boundaries, agentPrompt builders. -- [ ] **Sanitize Sentry token on store** — sentryConfig should run sanitizeToken like the PAT does. -- [ ] **db.tokens stores the token string** — keep only metadata (scopes/expiry). -- [ ] **Relay hardening round 2** — response size cap, HTTP method restriction. -- [ ] **Timer cleanup** — 4 setTimeout without unmount cleanup (SinceScope, DetailModalHeader, Checks, DetailModal status). -- [ ] **API boundary validation** — gql/rest/sentryFetch all cast `as T` with no runtime shape check. +- [x] **Tests for high-risk modules** — 29 tests across 7 files: relay allowlist/forwarding/size-cap, sentry client (proxy routing, cursor parse, fail-fast 4xx), normEnvironment, relativeTime boundaries (incl. the 0y regression), agentPrompt builders. (useUnifiedIssues merge needs renderHook → future with @testing-library.) +- [x] **Sanitize Sentry token on store** — sentryConfig.update sanitizes at the storage boundary (getAuth already sanitized reads). +- [x] **db.tokens dropped** — the table stored the raw token but had zero readers (saveTokenMeta/getTokenMeta/isTokenExpiringSoon were dead); Dexie v4 removes it; SECURITY.md/Settings copy updated. +- [x] **Relay hardening round 2** — method allowlist (GET/POST/PUT/PATCH/DELETE → 405 otherwise) + 20MB response size cap (declared and actual). +- [x] **Timer cleanup** — shared useFlash hook (clears on re-trigger + unmount) replaces the raw setTimeout in SinceScope, DetailModalHeader, Checks. (DetailModal's status effect already had cleanup.) +- [x] **API boundary guards (cheap)** — gql throws on 200-with-no-data; fetchWorkflowRunJobs null-safe. Full schema validation (zod) stays longer-term. ## Pending — longer term diff --git a/src/api/github/client.ts b/src/api/github/client.ts index e03c551..37fe7e3 100644 --- a/src/api/github/client.ts +++ b/src/api/github/client.ts @@ -28,6 +28,10 @@ export async function gql(token: string, query: string, variables: Record e.message).join('; ')) } + // Cheap boundary check: a 200 with no data is a malformed/proxy response, + // not a usable payload — fail loudly instead of letting `undefined as T` + // propagate into the UI. + if (json.data == null) throw new Error('GitHub API returned no data') return json.data as T } catch (e) { lastError = e instanceof Error ? e : new Error(String(e)) diff --git a/src/api/github/prs.ts b/src/api/github/prs.ts index 7fbe72e..95b9921 100644 --- a/src/api/github/prs.ts +++ b/src/api/github/prs.ts @@ -261,8 +261,8 @@ export async function mergePullRequest( /** Lists jobs in a workflow run. Used to map a CheckRun.name to a job_id so we can fetch its logs. */ export async function fetchWorkflowRunJobs(token: string, owner: string, name: string, runId: number): Promise { if (token === DEMO_TOKEN) return [] - const data = await rest(token, 'GET', `/repos/${owner}/${name}/actions/runs/${runId}/jobs?per_page=100`) as { jobs: WorkflowJob[] } - return data.jobs ?? [] + const data = await rest(token, 'GET', `/repos/${owner}/${name}/actions/runs/${runId}/jobs?per_page=100`) as { jobs?: WorkflowJob[] } | null + return data?.jobs ?? [] } /** diff --git a/src/api/sentry/client.test.ts b/src/api/sentry/client.test.ts new file mode 100644 index 0000000..1c36d03 --- /dev/null +++ b/src/api/sentry/client.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { parseNextCursor, sentryFetch, type SentryAuth } from './client' + +const AUTH: SentryAuth = { token: 'sntryu_test', region: '', proxyBase: '/api/proxy' } + +describe('parseNextCursor', () => { + it('extracts the cursor only when rel="next" has results', () => { + const link = '; rel="previous"; results="false"; cursor="0:0:1", ; rel="next"; results="true"; cursor="0:100:0"' + expect(parseNextCursor(link)).toBe('0:100:0') + }) + + it('returns null when next has no more results, or no header', () => { + expect(parseNextCursor('; rel="next"; results="false"; cursor="0:100:0"')).toBeNull() + expect(parseNextCursor(null)).toBeNull() + expect(parseNextCursor('')).toBeNull() + }) +}) + +describe('sentryFetch', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('routes through the proxy with the upstream url encoded and bearer auth', async () => { + const fetchSpy = vi.fn(async (..._args: unknown[]) => new Response('[]', { status: 200 })) + vi.stubGlobal('fetch', fetchSpy) + await sentryFetch('/organizations/acme/issues/', AUTH, { query: 'is:unresolved', empty: undefined }) + const [url, init] = fetchSpy.mock.calls[0]! as unknown as [string, RequestInit] + expect(url.startsWith('/api/proxy?url=')).toBe(true) + const upstream = decodeURIComponent(url.slice('/api/proxy?url='.length)) + expect(upstream).toContain('https://sentry.io/api/0/organizations/acme/issues/') + expect(upstream).toContain('query=is%3Aunresolved') + expect(upstream).not.toContain('empty=') + expect((init.headers as Record).Authorization).toBe('Bearer sntryu_test') + }) + + it('uses the regional host when region is set', async () => { + const fetchSpy = vi.fn(async (..._args: unknown[]) => new Response('[]', { status: 200 })) + vi.stubGlobal('fetch', fetchSpy) + await sentryFetch('/organizations/', { ...AUTH, region: 'de' }) + expect(decodeURIComponent(fetchSpy.mock.calls[0]![0] as string)).toContain('https://de.sentry.io/api/0/') + }) + + it('returns data + nextCursor from the Link header', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('[{"id":"1"}]', { + status: 200, + headers: { link: '; rel="next"; results="true"; cursor="0:100:0"' }, + }))) + const page = await sentryFetch<{ id: string }[]>('/x/', AUTH) + expect(page.data).toEqual([{ id: '1' }]) + expect(page.nextCursor).toBe('0:100:0') + }) + + it('fails fast on 4xx without retrying', async () => { + const fetchSpy = vi.fn(async () => new Response('{"detail":"nope"}', { status: 404 })) + vi.stubGlobal('fetch', fetchSpy) + await expect(sentryFetch('/missing/', AUTH)).rejects.toThrow(/Sentry 404/) + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/api/sentry/client.ts b/src/api/sentry/client.ts index 353fac5..2b999e8 100644 --- a/src/api/sentry/client.ts +++ b/src/api/sentry/client.ts @@ -61,7 +61,7 @@ export async function sentryFetch( // Sentry cursor pagination (RFC5988): `<…>; rel="next"; results="true"; cursor="0:100:0"`. // Only a next link with results="true" actually has more. -function parseNextCursor(link: string | null): string | null { +export function parseNextCursor(link: string | null): string | null { if (!link) return null for (const part of link.split(',')) { if (part.includes('rel="next"') && part.includes('results="true"')) { diff --git a/src/api/sentry/issues.test.ts b/src/api/sentry/issues.test.ts new file mode 100644 index 0000000..8ecfe1a --- /dev/null +++ b/src/api/sentry/issues.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { normEnvironment } from './issues' + +describe('normEnvironment', () => { + it('treats empty and the "all" sentinel as no filter', () => { + expect(normEnvironment(undefined)).toBeUndefined() + expect(normEnvironment('')).toBeUndefined() + expect(normEnvironment(' ')).toBeUndefined() + expect(normEnvironment('all')).toBeUndefined() + expect(normEnvironment('ALL')).toBeUndefined() + }) + + it('passes real environments through trimmed', () => { + expect(normEnvironment('production')).toBe('production') + expect(normEnvironment(' staging ')).toBe('staging') + }) +}) diff --git a/src/api/sentry/issues.ts b/src/api/sentry/issues.ts index b6cc81c..8fff35c 100644 --- a/src/api/sentry/issues.ts +++ b/src/api/sentry/issues.ts @@ -43,7 +43,7 @@ export type IssueQuery = { // Sentry validates `environment` against real environments and 404s on an // unknown one. Empty or the sentinel "all" means "no filter" → omit the param. -function normEnvironment(env?: string): string | undefined { +export function normEnvironment(env?: string): string | undefined { const e = env?.trim() return !e || e.toLowerCase() === 'all' ? undefined : e } diff --git a/src/components/SettingsTab.tsx b/src/components/SettingsTab.tsx index 27a0ccb..2d9be7a 100644 --- a/src/components/SettingsTab.tsx +++ b/src/components/SettingsTab.tsx @@ -141,7 +141,7 @@ export function SettingsTab({ panel, onForceResync }: Props) {
  • IndexedDB "devcompass" - Repos, orgs, pinned items, snoozes, token meta, and the per-API + Repos, orgs, pinned items, snoozes, and the per-API response cache (viewer / tokenInfo / userOrgs / PR detail / branches / since-last-visit snapshot). All scoped to this origin. diff --git a/src/components/home/DetailModalHeader.tsx b/src/components/home/DetailModalHeader.tsx index 8c008e6..c73022d 100644 --- a/src/components/home/DetailModalHeader.tsx +++ b/src/components/home/DetailModalHeader.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react' import { FaCodeBranch } from 'react-icons/fa' +import { useFlash } from '../../hooks/useFlash' import type { PRDetail } from '../../api/github' import { ConfirmDialog } from '../ConfirmDialog' import { OrgChip } from './OrgChip' @@ -79,7 +80,7 @@ function HeaderMeta({ detail, item }: { detail: PRDetail; item: AttentionItem }) } function LinkActions({ item }: { item: AttentionItem }) { - const [copied, setCopied] = useState(false) + const [copied, flashCopied] = useFlash(1800) // When the Clipboard API fails (insecure context, denied permission, etc.) // we surface the URL in a styled dialog so the user can manually select + // copy. Replaces the old window.prompt fallback. @@ -88,8 +89,7 @@ function LinkActions({ item }: { item: AttentionItem }) { const url = item.url || `${window.location.origin}${window.location.pathname}?pr=${item.org}/${item.repo}/${item.number}` try { await navigator.clipboard.writeText(url) - setCopied(true) - setTimeout(() => setCopied(false), 1800) + flashCopied() } catch { setFallbackUrl(url) } diff --git a/src/components/home/detail/Checks.tsx b/src/components/home/detail/Checks.tsx index bb88ec0..8ae9a4e 100644 --- a/src/components/home/detail/Checks.tsx +++ b/src/components/home/detail/Checks.tsx @@ -6,6 +6,7 @@ import { type PRDetail, type WorkflowJob } from '../../../api/github' +import { useFlash } from '../../../hooks/useFlash' const MAX_LOG_LINES = 500 @@ -144,14 +145,13 @@ function CheckItem({ row, token, owner, repo }: { row: CheckRow; token: string; const [loading, setLoading] = useState(false) const [err, setErr] = useState(null) const [matchedJob, setMatchedJob] = useState(null) - const [copied, setCopied] = useState(false) + const [copied, flashCopied] = useFlash(1500) async function copyLog() { if (!log) return try { await navigator.clipboard.writeText(log) - setCopied(true) - setTimeout(() => setCopied(false), 1500) + flashCopied() } catch { // Fallback: select the
     text so the user can Cmd/Ctrl+C manually.
           const pre = document.querySelector('.hs-log-pre')
    diff --git a/src/components/home/scopes/SinceScope.tsx b/src/components/home/scopes/SinceScope.tsx
    index 40b1807..04c961e 100644
    --- a/src/components/home/scopes/SinceScope.tsx
    +++ b/src/components/home/scopes/SinceScope.tsx
    @@ -1,19 +1,18 @@
    -import { useState } from 'react'
     import { OrgChip } from '../OrgChip'
     import { useSinceLastVisit, type SinceEvent } from '../useSinceLastVisit'
    +import { useFlash } from '../../../hooks/useFlash'
     import type { AttentionItem } from '../types'
     import { relativeTime } from '../../../utils/time'
     import { type ScopeProps } from './common'
     
     export function SinceScope({ repos, onOpenItem, onOpenRepo }: ScopeProps) {
       const { events, isFirstRun, snapshot, markSeen } = useSinceLastVisit(repos)
    -  const [seen, setSeen] = useState(false)
    +  const [seen, flashSeen] = useFlash(1500)
       const showEvents = !seen ? events : []
     
       async function handleMarkSeen() {
         await markSeen()
    -    setSeen(true)
    -    setTimeout(() => setSeen(false), 1500)
    +    flashSeen()
       }
     
       function handleEvent(ev: SinceEvent) {
    diff --git a/src/hooks/useFlash.ts b/src/hooks/useFlash.ts
    new file mode 100644
    index 0000000..8ac77d3
    --- /dev/null
    +++ b/src/hooks/useFlash.ts
    @@ -0,0 +1,20 @@
    +import { useEffect, useRef, useState } from 'react'
    +
    +/**
    + * Transient boolean that auto-resets after `ms` — for "✓ Copied"-style
    + * confirmations. The timer is cleared on re-trigger and on unmount, so rapid
    + * clicks don't stack timers and unmounted components don't set state.
    + */
    +export function useFlash(ms: number): [boolean, () => void] {
    +  const [on, setOn] = useState(false)
    +  const timer = useRef(null)
    +
    +  useEffect(() => () => { if (timer.current) clearTimeout(timer.current) }, [])
    +
    +  function trigger() {
    +    setOn(true)
    +    if (timer.current) clearTimeout(timer.current)
    +    timer.current = window.setTimeout(() => setOn(false), ms)
    +  }
    +  return [on, trigger]
    +}
    diff --git a/src/store/db.ts b/src/store/db.ts
    index cb8387b..3f4775c 100644
    --- a/src/store/db.ts
    +++ b/src/store/db.ts
    @@ -41,15 +41,6 @@ export interface CachedOrg {
       order: number
     }
     
    -export interface TokenMeta {
    -  id: string
    -  token: string
    -  expiresAt: number | null
    -  scopes: string[]
    -  note: string
    -  lastCheckedAt: number
    -}
    -
     export interface PinnedRepo {
       repoId: string
       nameWithOwner: string
    @@ -76,7 +67,6 @@ class GHDatabase extends Dexie {
       repos!: Table
       orgs!: Table
       prefs!: Table
    -  tokens!: Table
       pinnedRepos!: Table
       snoozedPRs!: Table
     
    @@ -110,6 +100,10 @@ class GHDatabase extends Dexie {
           // untilTs lets us cheaply prune expired rows; no upgrade needed since the table is new.
           snoozedPRs: 'prId, untilTs'
         })
    +
    +    // v4 drops the tokens table: it stored the raw token string but had no
    +    // readers — metadata lives in the tokenInfo pref cache instead.
    +    this.version(4).stores({ tokens: null })
       }
     }
     
    @@ -235,28 +229,6 @@ export async function getCachedPref(key: string, ttlMs: number): Promise {
    -  return db.tokens.get('current')
    -}
    -
    -export async function isTokenExpiringSoon(daysThreshold = 7): Promise {
    -  const meta = await getTokenMeta()
    -  if (!meta?.expiresAt) return false
    -  const msFromNow = meta.expiresAt - Date.now()
    -  return msFromNow < daysThreshold * 24 * 60 * 60 * 1000
    -}
    -
     export async function pinRepo(repoId: string, nameWithOwner: string) {
       await db.pinnedRepos.put({
         repoId,
    @@ -282,7 +254,6 @@ export type StorageBreakdown = {
       repos: number
       orgs: number
       prefs: number
    -  tokensMeta: number
       pinned: number
       snoozed: number
       prefKeys: PrefSummary[]
    @@ -297,11 +268,10 @@ export type StorageBreakdown = {
      * responses are currently cached) and the browser-reported quota.
      */
     export async function getStorageBreakdown(): Promise {
    -  const [repos, orgs, prefs, tokensMeta, pinned, snoozed, prefRows] = await Promise.all([
    +  const [repos, orgs, prefs, pinned, snoozed, prefRows] = await Promise.all([
         db.repos.count(),
         db.orgs.count(),
         db.prefs.count(),
    -    db.tokens.count(),
         db.pinnedRepos.count(),
         db.snoozedPRs.count(),
         db.prefs.toArray()
    @@ -319,7 +289,6 @@ export async function getStorageBreakdown(): Promise {
         repos,
         orgs,
         prefs,
    -    tokensMeta,
         pinned,
         snoozed,
         prefKeys: prefRows.map((r) => ({ key: r.key, updatedAt: r.updatedAt })).toSorted((a, b) => b.updatedAt - a.updatedAt),
    diff --git a/src/store/sentryConfig.ts b/src/store/sentryConfig.ts
    index 9a915c9..5a96699 100644
    --- a/src/store/sentryConfig.ts
    +++ b/src/store/sentryConfig.ts
    @@ -44,7 +44,9 @@ export const sentryConfigStore = create()(
       persist(
         (set, get) => ({
           ...DEFAULTS,
    -      update: (patch) => set(patch),
    +      // Sanitize the token at the storage boundary too (same as the PAT) — getAuth
    +      // re-sanitizes on read, but the persisted value shouldn't carry stray unicode.
    +      update: (patch) => set(patch.token !== undefined ? { ...patch, token: sanitizeToken(patch.token) } : patch),
           reset: () => set(DEFAULTS),
           getAuth: () => {
             const s = get()
    diff --git a/src/utils/agentPrompt.test.ts b/src/utils/agentPrompt.test.ts
    new file mode 100644
    index 0000000..4c19b13
    --- /dev/null
    +++ b/src/utils/agentPrompt.test.ts
    @@ -0,0 +1,62 @@
    +import { describe, expect, it } from 'vitest'
    +import { buildGithubIssueAgentText, buildSentryAgentText } from './agentPrompt'
    +import type { GitHubIssueDetail } from '../api/github'
    +import type { SentryIssue } from '../api/sentry'
    +
    +const ISSUE: SentryIssue = {
    +  id: '1', shortId: 'API-3K', title: "TypeError: Cannot read 'orgId'",
    +  culprit: 'auth.ts', level: 'error', status: 'unresolved',
    +  count: '128', userCount: 37, firstSeen: '2026-05-01T0:0:0Z', lastSeen: '2026-06-01T0:0:0Z',
    +  permalink: 'https://acme.sentry.io/issues/1/', project: { id: 'p', slug: 'api', name: 'api' },
    +}
    +
    +describe('buildSentryAgentText', () => {
    +  it('includes repo, meta and a crash-first stacktrace', () => {
    +    const text = buildSentryAgentText(ISSUE, [{
    +      type: 'TypeError',
    +      value: "Cannot read 'orgId'",
    +      stacktrace: { frames: [
    +        { filename: 'outer.ts', function: 'outer', lineNo: 1 },
    +        { filename: 'crash.ts', function: 'resolveSession', lineNo: 42 },
    +      ] },
    +    }], 'acme/api')
    +    expect(text).toContain('Fix this Sentry error in acme/api:')
    +    expect(text).toContain('128 events · 37 users affected')
    +    expect(text).toContain('Exception: TypeError')
    +    // frames are reversed so the crash site comes first
    +    expect(text.indexOf('crash.ts')).toBeLessThan(text.indexOf('outer.ts'))
    +    expect(text).toContain('crash.ts in resolveSession:42')
    +  })
    +
    +  it('survives no repo mapping and no exceptions', () => {
    +    const text = buildSentryAgentText(ISSUE, [], null)
    +    expect(text).toContain('Fix this Sentry error:')
    +    expect(text).not.toContain('Exception:')
    +  })
    +})
    +
    +describe('buildGithubIssueAgentText', () => {
    +  const DETAIL: GitHubIssueDetail = {
    +    number: 92, title: 'Login loops', url: 'https://github.com/acme/api/issues/92',
    +    state: 'OPEN', bodyHTML: '

    hi

    ', body: 'Steps to reproduce…', + createdAt: '2026-05-01T0:0:0Z', updatedAt: '2026-06-01T0:0:0Z', + author: { login: 'ana', avatarUrl: '' }, + repository: { nameWithOwner: 'acme/api' }, + labels: { nodes: [{ name: 'bug', color: 'd73a4a' }] }, + assignees: { nodes: [] }, + comments: { totalCount: 3 }, + } + + it('includes repo#number, labels, url and the raw body', () => { + const text = buildGithubIssueAgentText(DETAIL) + expect(text).toContain('Fix this GitHub issue: acme/api#92') + expect(text).toContain('Labels: bug') + expect(text).toContain('Steps to reproduce…') + }) + + it('falls back when the body is empty', () => { + const text = buildGithubIssueAgentText({ ...DETAIL, body: '', labels: { nodes: [] } }) + expect(text).toContain('(no description provided)') + expect(text).not.toContain('Labels:') + }) +}) diff --git a/src/utils/time.test.ts b/src/utils/time.test.ts new file mode 100644 index 0000000..ad9e335 --- /dev/null +++ b/src/utils/time.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { relativeTime } from './time' + +const NOW = new Date('2026-06-01T12:00:00Z').getTime() + +function ago(ms: number): string { + return new Date(NOW - ms).toISOString() +} + +const MIN = 60_000 +const HOUR = 60 * MIN +const DAY = 24 * HOUR + +describe('relativeTime', () => { + beforeEach(() => vi.spyOn(Date, 'now').mockReturnValue(NOW)) + afterEach(() => vi.restoreAllMocks()) + + it('handles empty input', () => { + expect(relativeTime('')).toBe('') + }) + + it('covers minute/hour/day/month bands', () => { + expect(relativeTime(ago(5 * MIN))).toBe('5m ago') + expect(relativeTime(ago(59 * MIN))).toBe('59m ago') + expect(relativeTime(ago(60 * MIN))).toBe('1h ago') + expect(relativeTime(ago(23 * HOUR))).toBe('23h ago') + expect(relativeTime(ago(24 * HOUR))).toBe('1d ago') + expect(relativeTime(ago(29 * DAY))).toBe('29d ago') + expect(relativeTime(ago(30 * DAY))).toBe('1mo ago') + }) + + it('never says "0y" near the year boundary (360-364d regression)', () => { + expect(relativeTime(ago(362 * DAY))).toBe('12mo ago') + expect(relativeTime(ago(364 * DAY))).toBe('12mo ago') + expect(relativeTime(ago(365 * DAY))).toBe('1y ago') + expect(relativeTime(ago(800 * DAY))).toBe('2y ago') + }) + + it('omits the suffix when asked', () => { + expect(relativeTime(ago(3 * HOUR), false)).toBe('3h') + expect(relativeTime(ago(2 * DAY), false)).toBe('2d') + }) +})