diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 00000000..acf7c571 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,3 @@ +GITHUB_CLIENT_ID=your_client_id_here +GITHUB_CLIENT_SECRET=your_client_secret_here +ALLOWED_ORIGIN=http://localhost:5173 diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..9fb11745 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# GitHub App client ID — embedded into client-side bundle at build time by Vite. +# This is public information (visible in the OAuth authorize URL). +# Set this as a GitHub Actions variable (not a secret) for CI/CD. +VITE_GITHUB_CLIENT_ID=your_github_app_client_id_here diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..43d5f2b2 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,29 @@ +name: Deploy +on: + push: + branches: [main] +permissions: + contents: read +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run typecheck + - run: pnpm test + - name: Verify CSP hash + run: node scripts/verify-csp-hash.mjs + - run: pnpm run build + env: + VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }} + - uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: deploy diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..44e6b4e6 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,101 @@ +name: Preview +on: + pull_request: +permissions: + contents: read + pull-requests: write +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run typecheck + - run: pnpm test + - name: Verify CSP hash + run: node scripts/verify-csp-hash.mjs + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + - name: Run E2E tests + run: pnpm test:e2e + env: + VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }} + preview: + needs: ci + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Verify CSP hash + run: node scripts/verify-csp-hash.mjs + - run: pnpm run build + env: + VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }} + - name: Slugify branch name + id: slug + env: + BRANCH: ${{ github.head_ref }} + run: echo "alias=$(echo "$BRANCH" | sed 's/[^a-zA-Z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//' | tr 'A-Z' 'a-z' | sed 's/^[^a-z]*//' | cut -c1-48 | sed 's/-$//; s/^$/preview/')" >> "$GITHUB_OUTPUT" + - name: Upload preview version + id: preview + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: versions upload --preview-alias ${{ steps.slug.outputs.alias }} + - name: Comment preview URL + uses: actions/github-script@v7 + env: + WRANGLER_OUTPUT: ${{ steps.preview.outputs.command-output }} + BRANCH_ALIAS: ${{ steps.slug.outputs.alias }} + BRANCH_NAME: ${{ github.head_ref }} + with: + script: | + const output = process.env.WRANGLER_OUTPUT || ''; + const urlMatch = output.match(/https:\/\/[^\s"'`]+\.workers\.dev[^\s"'`]*/); + const alias = process.env.BRANCH_ALIAS; + const branch = process.env.BRANCH_NAME; + const url = urlMatch ? urlMatch[0] : `Preview alias: ${alias} (check workflow logs for URL)`; + const marker = ''; + const body = [ + '### Preview Deployment', + '', + urlMatch ? `[${url}](${url})` : url, + '', + `Branch: \`${branch}\` | Alias: \`${alias}\``, + '', + marker, + ].join('\n'); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/.gitignore b/.gitignore index 282df3b5..090f8486 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist/ *.local .env hack/ +.serena/ +.playwright-mcp/ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 00000000..56782b51 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,146 @@ +# Deployment Guide + +## GitHub Actions Secrets and Variables + +### Secrets (GitHub repo → Settings → Secrets and variables → Actions → Secrets) + +**`CLOUDFLARE_API_TOKEN`** +1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com) → My Profile → API Tokens +2. Click "Create Token" +3. Use the "Edit Cloudflare Workers" template +4. Scope to your account and zone as needed +5. Copy the token and add it as a secret + +**`CLOUDFLARE_ACCOUNT_ID`** +1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com) +2. Select your account (Account Home) +3. Copy the Account ID from the right sidebar +4. Add it as a secret + +### Variables (GitHub repo → Settings → Secrets and variables → Actions → Variables) + +**`VITE_GITHUB_CLIENT_ID`** +- This is the GitHub App Client ID (not a secret — it is embedded in the built JS bundle) +- Add it as an Actions **variable** (not a secret) +- See GitHub App setup below for how to obtain it + +## GitHub App Setup + +1. Go to GitHub → Settings → Developer settings → GitHub Apps → **New GitHub App** +2. Fill in the basic details: + - **App name**: your app name (e.g. `gh-tracker-yourname`) + - **Description**: `Personal dashboard for tracking GitHub issues, PRs, and Actions runs across repos and orgs.` + - **Homepage URL**: `https://gh.gordoncode.dev` +3. Under **Identifying and authorizing users**: + - **Callback URLs** — register all three: + - `https://gh.gordoncode.dev/oauth/callback` (production) + - `https://github-tracker..workers.dev/oauth/callback` (preview — GitHub's subdomain matching should allow per-branch preview aliases like `alias.github-tracker..workers.dev` to work; verify after first preview deploy) + - `http://localhost:5173/oauth/callback` (local dev) + - ✅ **Expire user authorization tokens** — check this. The app uses short-lived access tokens (8hr) with HttpOnly cookie-based refresh token rotation. + - ✅ **Request user authorization (OAuth) during installation** — check this. Streamlines the install + authorize flow into one step. +4. Under **Post installation**: + - Leave **Setup URL** blank + - Leave **Redirect on update** unchecked +5. Under **Webhook**: + - ❌ Uncheck **Active** — the app polls; it does not use webhooks. +6. Under **Permissions**: + + **Repository permissions** (read-only): + + | Permission | Access | Used for | + |------------|--------|----------| + | **Actions** | Read-only | `GET /repos/{owner}/{repo}/actions/runs` — workflow run list | + | **Checks** | Read-only | `GET /repos/{owner}/{repo}/commits/{ref}/check-runs` — PR check status (REST fallback) | + | **Commit statuses** | Read-only | `GET /repos/{owner}/{repo}/commits/{ref}/status` — legacy commit status (REST fallback) | + | **Issues** | Read-only | `GET /search/issues?q=is:issue` — issue search | + | **Metadata** | Read-only | Automatically granted when any repo permission is set. Required for basic repo info. | + | **Pull requests** | Read-only | `GET /search/issues?q=is:pr`, `GET /repos/{owner}/{repo}/pulls/{pull_number}`, `/reviews` — PR search, detail, and reviews | + + **Organization permissions:** + + | Permission | Access | Used for | + |------------|--------|----------| + | **Members** | Read-only | `GET /user/orgs` — list user's organizations for the org selector | + + **Account permissions:** + + | Permission | Access | Used for | + |------------|--------|----------| + | _(none required)_ | | | + +7. Under **Where can this GitHub App be installed?**: + - **Any account** — the app uses OAuth authorization (not installation tokens), so any GitHub user needs to be able to authorize via the login flow +8. Click **Create GitHub App** +9. Note the **Client ID** — this is your `VITE_GITHUB_CLIENT_ID` +10. Click **Generate a new client secret** and save it for the Worker secrets below + +### Notifications API limitation + +The GitHub Notifications API (`GET /notifications`) does not support GitHub App user access tokens — only classic personal access tokens. The app uses notifications as a polling optimization gate (skip full fetch when nothing changed). When the notifications endpoint returns 403, the gate **auto-disables** and the app falls back to time-based polling. No functionality is lost; polling is just slightly less efficient. + +## Cloudflare Worker Secrets + +These are set via wrangler CLI and are stored in the Cloudflare Worker runtime (not in GitHub). + +### Production environment + +```sh +wrangler secret put GITHUB_CLIENT_ID +wrangler secret put GITHUB_CLIENT_SECRET +wrangler secret put ALLOWED_ORIGIN +``` + +- `GITHUB_CLIENT_ID`: same value as `VITE_GITHUB_CLIENT_ID` +- `GITHUB_CLIENT_SECRET`: the Client Secret from your GitHub App +- `ALLOWED_ORIGIN`: `https://gh.gordoncode.dev` + +### Preview versions + +Preview deployments use `wrangler versions upload` (not a separate environment), so they inherit production secrets automatically. No additional secret configuration is needed. + +CORS note: Preview URLs are same-origin (SPA and API share the same `*.workers.dev` host), so the `ALLOWED_ORIGIN` strict-equality check is irrelevant — browsers don't enforce CORS on same-origin requests. + +**Migration note:** If you previously deployed with `wrangler deploy --env preview`, an orphaned `github-tracker-preview` worker may still exist. Delete it via `wrangler delete --name github-tracker-preview` or through the Cloudflare dashboard. + +## Worker API Endpoints + +| Endpoint | Method | Auth | Purpose | +|----------|--------|------|---------| +| `/api/oauth/token` | POST | none | Exchange OAuth code for access token. Refresh token set as HttpOnly cookie. | +| `/api/oauth/refresh` | POST | cookie | Refresh expired access token. Reads `github_tracker_rt` HttpOnly cookie. Sets rotated cookie. | +| `/api/oauth/logout` | POST | none | Clears the `github_tracker_rt` HttpOnly cookie (`Max-Age=0`). | +| `/api/health` | GET | none | Health check. Returns `OK`. | + +### Refresh Token Security + +The refresh token (6-month lifetime) is stored as an **HttpOnly cookie** — never in `localStorage` or the response body. This protects the high-value long-lived credential from XSS: + +- Production cookie: `__Host-github_tracker_rt` with `HttpOnly; Secure; SameSite=Strict; Path=/` +- Local dev: `github_tracker_rt` with `HttpOnly; SameSite=Lax; Path=/` (no `Secure` — localhost is HTTP; no `__Host-` prefix — requires `Secure`) +- The short-lived access token (8hr) is held in-memory only (never persisted to `localStorage`); on page reload, `refreshAccessToken()` obtains a fresh token via the cookie +- On logout, the client calls `POST /api/oauth/logout` to clear the cookie +- GitHub rotates the refresh token on each use; the Worker sets the new value as a cookie + +### CORS + +- `Access-Control-Allow-Origin`: exact match against `ALLOWED_ORIGIN` (no wildcards) +- `Access-Control-Allow-Credentials: true`: enables cookie-based refresh for cross-origin preview deploys +- Same-origin requests (production, local dev) send cookies automatically without CORS + +## Local Development + +Copy `.dev.vars.example` to `.dev.vars` and fill in your values. Wrangler picks up `.dev.vars` automatically for local `wrangler dev` runs. + +## Deploy Manually + +```sh +pnpm run build +wrangler deploy +``` + +For preview (uploads a version without promoting to production): + +```sh +pnpm run build +wrangler versions upload --preview-alias my-feature +``` diff --git a/README.md b/README.md index 1eb30e39..18f56753 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,83 @@ -# github-tracker +# GitHub Tracker -GitHub dashboard for tracking issues, PRs, and Actions runs across repos and orgs. +Dashboard SPA tracking GitHub issues, PRs, and GHA workflow runs across multiple repos/orgs. Built with SolidJS on Cloudflare Workers. + +## Features + +- **Issues Tab** — Open issues where you're the creator, assignee, or mentioned. Sortable, filterable, paginated. +- **Pull Requests Tab** — Open PRs with CI check status indicators (green/yellow/red dots). Draft badges, reviewer names. +- **Actions Tab** — GHA workflow runs grouped by repo and workflow. Accordion collapse, PR run toggle. +- **Onboarding Wizard** — Two-step org/repo selection with search filtering and bulk select. +- **Settings Page** — Refresh interval, notification preferences, theme (light/dark/system), density, GitHub Actions limits. +- **Desktop Notifications** — New item alerts with per-type toggles and batching. +- **Ignore System** — Hide specific items with an "N ignored" badge and unignore popover. +- **Dark Mode** — System-aware with flash prevention via inline script + CSP SHA-256 hash. +- **ETag Caching** — Conditional requests (304s are free against GitHub's rate limit). +- **Auto-refresh** — Visibility-aware polling that pauses when tab is hidden. + +## Tech Stack + +- **Frontend:** SolidJS + Tailwind CSS v4 + TypeScript (strict) +- **Build:** Vite 8 + @cloudflare/vite-plugin +- **Hosting:** Cloudflare Workers (static assets + OAuth endpoint) +- **API:** @octokit/core with throttling, retry, pagination plugins +- **State:** localStorage (config/view) + IndexedDB (API cache with ETags) +- **Testing:** Vitest 4 (happy-dom for browser, @cloudflare/vitest-pool-workers for Worker) +- **Package Manager:** pnpm + +## Development + +```sh +pnpm install +pnpm run dev # Start Vite dev server +pnpm test # Run browser tests (130 tests) +pnpm run typecheck # TypeScript check +pnpm run build # Production build (~241KB JS, ~31KB CSS) +``` + +## Project Structure + +``` +src/ + app/ + components/ + dashboard/ # DashboardPage, IssuesTab, PullRequestsTab, ActionsTab, ItemRow, WorkflowRunRow, IgnoreBadge + layout/ # Header, TabBar, FilterBar + onboarding/ # OnboardingWizard, OrgSelector, RepoSelector + settings/ # SettingsPage (7 config sections + data management) + shared/ # FilterInput, LoadingSpinner, StatusDot + pages/ # LoginPage, OAuthCallback + services/ + api.ts # GitHub API methods (fetchOrgs, fetchRepos, fetchIssues, fetchPRs, fetchWorkflowRuns) + github.ts # Octokit client factory with ETag caching and rate limit tracking + poll.ts # Poll coordinator with visibility-aware auto-refresh + stores/ + auth.ts # OAuth token management with auto-refresh + cache.ts # IndexedDB cache with TTL eviction and ETag support + config.ts # Zod v4-validated config with localStorage persistence + view.ts # View state (tabs, sorting, ignored items, filters) + lib/ + notifications.ts # Desktop notification permission, detection, and dispatch + worker/ + index.ts # OAuth token exchange/refresh endpoint, CORS, security headers +tests/ + fixtures/ # GitHub API response fixtures (orgs, repos, issues, PRs, runs) + services/ # API service, Octokit client, and poll coordinator tests + stores/ # Config and cache store tests + components/ # ItemRow and IssuesTab component tests + lib/ # Notification tests + worker/ # Worker OAuth endpoint tests +``` + +## Security + +- Strict CSP: `script-src 'self'` (SHA-256 exception for dark mode script only) +- OAuth CSRF protection via `crypto.getRandomValues` state parameter +- CORS locked to exact origin (strict equality, no substring matching) +- Access token in-memory only (never persisted); refresh token in `__Host-` HttpOnly cookie +- Auto-refresh on 401 and on page load via HttpOnly cookie +- All GitHub API strings auto-escaped by SolidJS JSX (no innerHTML) + +## Deployment + +See [DEPLOY.md](./DEPLOY.md) for Cloudflare, GitHub App, and CI/CD setup. diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 00000000..1c9ef115 --- /dev/null +++ b/e2e/settings.spec.ts @@ -0,0 +1,138 @@ +import { test, expect, type Page } from "@playwright/test"; + +/** + * Register API route interceptors and inject config BEFORE any navigation. + * The app calls refreshAccessToken() on load, which POSTs to /api/oauth/refresh + * (HttpOnly cookie-based). We intercept that to return a valid access token. + */ +async function setupAuth(page: Page) { + await page.route("**/api/oauth/refresh", (route) => + route.fulfill({ + status: 200, + json: { access_token: "ghu_fake", expires_in: 86400 }, + }) + ); + await page.route("https://api.github.com/user", (route) => + route.fulfill({ + status: 200, + json: { + login: "testuser", + name: "Test User", + avatar_url: "https://github.com/testuser.png", + }, + }) + ); + await page.route("https://api.github.com/search/issues*", (route) => + route.fulfill({ + status: 200, + json: { total_count: 0, incomplete_results: false, items: [] }, + }) + ); + await page.route("https://api.github.com/notifications*", (route) => + route.fulfill({ status: 200, json: [] }) + ); + await page.route("https://api.github.com/graphql", (route) => + route.fulfill({ status: 200, json: { data: {} } }) + ); + + await page.addInitScript(() => { + localStorage.setItem( + "github-tracker:config", + JSON.stringify({ + selectedOrgs: ["testorg"], + selectedRepos: [{ owner: "testorg", name: "testrepo" }], + onboardingComplete: true, + }) + ); + }); +} + +// ── Settings page renders ──────────────────────────────────────────────────── + +test("settings page renders section headings", async ({ page }) => { + await setupAuth(page); + await page.goto("/settings"); + + await expect( + page.getByRole("heading", { name: /organizations & repositories/i }) + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: /appearance/i }) + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: /notifications/i }) + ).toBeVisible(); + await expect(page.getByRole("heading", { name: /data/i })).toBeVisible(); +}); + +test("settings page has main heading", async ({ page }) => { + await setupAuth(page); + await page.goto("/settings"); + + await expect( + page.getByRole("heading", { name: /^settings$/i }) + ).toBeVisible(); +}); + +// ── Back to dashboard ──────────────────────────────────────────────────────── + +test("back link navigates to dashboard", async ({ page }) => { + await setupAuth(page); + await page.goto("/settings"); + + // The back arrow link has aria-label="Back to dashboard" + const backLink = page.getByRole("link", { name: /back to dashboard/i }); + await expect(backLink).toBeVisible(); + await backLink.click(); + + await expect(page).toHaveURL(/\/dashboard/); +}); + +// ── Theme change ───────────────────────────────────────────────────────────── + +test("changing theme to dark adds dark class to html element", async ({ + page, +}) => { + await setupAuth(page); + await page.goto("/settings"); + + // Locate the Theme setting row by its label text, then find its